feature/clinic-lab-invitation #14

Merged
admin merged 4 commits from feature/clinic-lab-invitation into master 2026-05-06 19:28:13 +03:30
22 changed files with 1265 additions and 23 deletions
Showing only changes of commit adfb5b436e - Show all commits

View File

@@ -0,0 +1,33 @@
-- CreateTable
CREATE TABLE "organization_invitations" (
"id" TEXT NOT NULL,
"inviterOrganizationId" TEXT NOT NULL,
"inviterUserId" TEXT NOT NULL,
"invitedOrganizationId" TEXT,
"invitedOrganizationName" TEXT NOT NULL,
"invitedOwnerEmail" TEXT NOT NULL,
"invitedOrganizationType" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"acceptedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "organization_invitations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "organization_invitations_tokenHash_key" ON "organization_invitations"("tokenHash");
-- CreateIndex
CREATE INDEX "organization_invitations_inviterOrganizationId_createdAt_idx" ON "organization_invitations"("inviterOrganizationId", "createdAt");
-- CreateIndex
CREATE INDEX "organization_invitations_invitedOwnerEmail_createdAt_idx" ON "organization_invitations"("invitedOwnerEmail", "createdAt");
-- AddForeignKey
ALTER TABLE "organization_invitations" ADD CONSTRAINT "organization_invitations_inviterOrganizationId_fkey" FOREIGN KEY ("inviterOrganizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "organization_invitations" ADD CONSTRAINT "organization_invitations_inviterUserId_fkey" FOREIGN KEY ("inviterUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,7 @@
UPDATE "permissions"
SET "name" = 'TAB_ORGANIZATIONS_READ'
WHERE "name" = 'TAB_LAB_READ';
UPDATE "permissions"
SET "name" = 'TAB_ORGANIZATIONS_EDIT'
WHERE "name" = 'TAB_LAB_EDIT';

View File

@@ -21,6 +21,7 @@ model User {
ownedOrganizations Organization[] @relation("OrganizationOwner") ownedOrganizations Organization[] @relation("OrganizationOwner")
sessions Session[] // 👈 ADD THIS - opposite relation for Session sessions Session[] // 👈 ADD THIS - opposite relation for Session
sentStaffInvites StaffInvitation[] sentStaffInvites StaffInvitation[]
sentOrganizationInvitations OrganizationInvitation[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -57,6 +58,7 @@ model Organization {
sharedWithMe OrganizationLink[] @relation("OrganizationB") sharedWithMe OrganizationLink[] @relation("OrganizationB")
sharedWithOthers OrganizationLink[] @relation("OrganizationA") sharedWithOthers OrganizationLink[] @relation("OrganizationA")
sentOrganizationInvitations OrganizationInvitation[] @relation("OrganizationInvitationInviter")
patients Patient[] patients Patient[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -211,6 +213,32 @@ model OrganizationLink {
@@map("organization_links") @@map("organization_links")
} }
model OrganizationInvitation {
id String @id @default(uuid())
inviterOrganizationId String
inviterUserId String
invitedOrganizationId String?
invitedOrganizationName String
invitedOwnerEmail String
invitedOrganizationType String
tokenHash String @unique
expiresAt DateTime
acceptedAt DateTime?
revokedAt DateTime?
inviterOrganization Organization @relation("OrganizationInvitationInviter", fields: [inviterOrganizationId], references: [id], onDelete: Cascade)
inviterUser User @relation(fields: [inviterUserId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([inviterOrganizationId, createdAt])
@@index([invitedOwnerEmail, createdAt])
@@map("organization_invitations")
}
model Session { model Session {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String

View File

@@ -74,8 +74,8 @@ async function main() {
permissions: ['TAB_STAFF_READ', 'TAB_STAFF_EDIT'], permissions: ['TAB_STAFF_READ', 'TAB_STAFF_EDIT'],
}, },
{ {
name: 'Labs / Clinics', name: 'Organizations',
permissions: ['TAB_LAB_READ', 'TAB_LAB_EDIT'], permissions: ['TAB_ORGANIZATIONS_READ', 'TAB_ORGANIZATIONS_EDIT'],
}, },
{ {
name: 'Patients', name: 'Patients',

View File

@@ -8,6 +8,7 @@ import { AdminModule } from './admin/admin.module';
import { PrismaModule } from '../prisma/prisma.module'; // ✅ import { PrismaModule } from '../prisma/prisma.module'; // ✅
import { PatientsModule } from './modules/patients/patients.module'; import { PatientsModule } from './modules/patients/patients.module';
import { StaffModule } from './modules/staff/staff.module'; import { StaffModule } from './modules/staff/staff.module';
import { OrganizationModule } from './modules/organization/organization.module';
@Module({ @Module({
imports: [ imports: [
@@ -19,6 +20,7 @@ import { StaffModule } from './modules/staff/staff.module';
AuthModule, AuthModule,
PatientsModule, PatientsModule,
StaffModule, StaffModule,
OrganizationModule,
AdminModule.forRoot(), AdminModule.forRoot(),
], ],
controllers: [AppController], controllers: [AppController],

View File

@@ -4,8 +4,8 @@ export const ALL_TAB_PERMISSIONS = [
'TAB_TODAY_EDIT', 'TAB_TODAY_EDIT',
'TAB_STAFF_READ', 'TAB_STAFF_READ',
'TAB_STAFF_EDIT', 'TAB_STAFF_EDIT',
'TAB_LAB_READ', 'TAB_ORGANIZATIONS_READ',
'TAB_LAB_EDIT', 'TAB_ORGANIZATIONS_EDIT',
'TAB_PATIENTS_READ', 'TAB_PATIENTS_READ',
'TAB_PATIENTS_EDIT', 'TAB_PATIENTS_EDIT',
'TAB_APPOINTMENTS_READ', 'TAB_APPOINTMENTS_READ',
@@ -38,7 +38,7 @@ const EDIT_TO_READ: Record<string, string> = {
TAB_PATIENTS_EDIT: 'TAB_PATIENTS_READ', TAB_PATIENTS_EDIT: 'TAB_PATIENTS_READ',
TAB_APPOINTMENTS_EDIT: 'TAB_APPOINTMENTS_READ', TAB_APPOINTMENTS_EDIT: 'TAB_APPOINTMENTS_READ',
TAB_STAFF_EDIT: 'TAB_STAFF_READ', TAB_STAFF_EDIT: 'TAB_STAFF_READ',
TAB_LAB_EDIT: 'TAB_LAB_READ', TAB_ORGANIZATIONS_EDIT: 'TAB_ORGANIZATIONS_READ',
TAB_TREATMENT_EDIT: 'TAB_TREATMENT_READ', TAB_TREATMENT_EDIT: 'TAB_TREATMENT_READ',
TAB_BILLING_EDIT: 'TAB_BILLING_READ', TAB_BILLING_EDIT: 'TAB_BILLING_READ',
TAB_REPORTS_EDIT: 'TAB_REPORTS_READ', TAB_REPORTS_EDIT: 'TAB_REPORTS_READ',

View File

@@ -20,8 +20,8 @@ const ALL_PERMISSIONS = [
'TAB_TODAY_EDIT', 'TAB_TODAY_EDIT',
'TAB_STAFF_READ', 'TAB_STAFF_READ',
'TAB_STAFF_EDIT', 'TAB_STAFF_EDIT',
'TAB_LAB_READ', 'TAB_ORGANIZATIONS_READ',
'TAB_LAB_EDIT', 'TAB_ORGANIZATIONS_EDIT',
'TAB_PATIENTS_READ', 'TAB_PATIENTS_READ',
'TAB_PATIENTS_EDIT', 'TAB_PATIENTS_EDIT',
'TAB_APPOINTMENTS_READ', 'TAB_APPOINTMENTS_READ',
@@ -37,7 +37,7 @@ const ALL_PERMISSIONS = [
const READ_ONLY_PERMISSIONS = [ const READ_ONLY_PERMISSIONS = [
'TAB_TODAY_READ', 'TAB_TODAY_READ',
'TAB_STAFF_READ', 'TAB_STAFF_READ',
'TAB_LAB_READ', 'TAB_ORGANIZATIONS_READ',
'TAB_PATIENTS_READ', 'TAB_PATIENTS_READ',
'TAB_APPOINTMENTS_READ', 'TAB_APPOINTMENTS_READ',
'TAB_TREATMENT_READ', 'TAB_TREATMENT_READ',

View File

@@ -0,0 +1,19 @@
import { IsString, MinLength } from 'class-validator';
export class AcceptOrganizationInviteDto {
@IsString()
@MinLength(1)
token: string;
@IsString()
@MinLength(1)
organizationName: string;
@IsString()
@MinLength(1)
ownerName: string;
@IsString()
@MinLength(8)
password: string;
}

View File

@@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class CreateLinkRequestDto {
@IsUUID()
targetOrganizationId: string;
}

View File

@@ -0,0 +1,14 @@
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
export class InviteOrganizationDto {
@IsString()
@MinLength(1)
organizationName: string;
@IsEmail()
ownerEmail: string;
@IsOptional()
@IsString()
phone?: string;
}

View File

@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class PreviewOrganizationInviteDto {
@IsString()
@MinLength(1)
token: string;
}

View File

@@ -0,0 +1,78 @@
import {
Body,
Controller,
Get,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto';
import { CreateLinkRequestDto } from './dto/create-link-request.dto';
import { InviteOrganizationDto } from './dto/invite-organization.dto';
import { PreviewOrganizationInviteDto } from './dto/preview-organization-invite.dto';
import { OrganizationService } from './organization.service';
@ApiTags('organizations')
@ApiBearerAuth('JWT-auth')
@Controller('organizations')
export class OrganizationController {
constructor(private readonly organizationService: OrganizationService) {}
@Get('invitations/preview')
@ApiOperation({ summary: 'Preview organization invite by token (public)' })
previewInvite(@Query() query: PreviewOrganizationInviteDto) {
return this.organizationService.previewInvite(query.token);
}
@Post('invitations/accept')
@ApiOperation({ summary: 'Accept organization invite and create/link counterpart org (public)' })
acceptInvite(@Body() dto: AcceptOrganizationInviteDto) {
return this.organizationService.acceptInvite(dto);
}
@Get('search')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Search counterpart organizations (active subscription only)',
})
search(
@Req() req: { user: { id: string; organizationId?: string } },
@Query('q') q = '',
) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.searchCounterpartOrganizations(req.user.id, organizationId, q);
}
@Get('links')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'List counterpart links and invitations for current org' })
list(@Req() req: { user: { id: string; organizationId?: string } }) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.list(req.user.id, organizationId);
}
@Post('links')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Create pending link request to an existing subscribed counterpart org' })
createLinkRequest(
@Req() req: { user: { id: string; organizationId?: string } },
@Body() dto: CreateLinkRequestDto,
) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.createLinkRequest(req.user.id, organizationId, dto);
}
@Post('invite')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Create invite link for owner of not-yet-subscribed counterpart org' })
inviteOrganization(
@Req() req: { user: { id: string; organizationId?: string } },
@Body() dto: InviteOrganizationDto,
) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.inviteOrganization(req.user.id, organizationId, dto);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { OrganizationController } from './organization.controller';
import { OrganizationService } from './organization.service';
@Module({
controllers: [OrganizationController],
providers: [OrganizationService, PrismaService],
})
export class OrganizationModule {}

View File

@@ -0,0 +1,451 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { LinkStatus } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { createHash, randomBytes } from 'crypto';
import { PrismaService } from '../../../prisma/prisma.service';
import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto';
import { CreateLinkRequestDto } from './dto/create-link-request.dto';
import { InviteOrganizationDto } from './dto/invite-organization.dto';
@Injectable()
export class OrganizationService {
constructor(private readonly prisma: PrismaService) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
throw new BadRequestException('Organization is not selected');
}
return user.organizationId;
}
async searchCounterpartOrganizations(userId: string, organizationId: string, query: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
const targetType = this.getCounterpartType(actor.organization.type.name);
const q = query.trim();
const organizations = await this.prisma.organization.findMany({
where: {
type: { name: targetType },
planId: { not: null },
...(q
? {
OR: [
{ name: { contains: q, mode: 'insensitive' } },
{ email: { contains: q, mode: 'insensitive' } },
{ phone: { contains: q, mode: 'insensitive' } },
],
}
: {}),
},
select: {
id: true,
name: true,
email: true,
phone: true,
owner: { select: { email: true, name: true } },
},
orderBy: { name: 'asc' },
take: 25,
});
return { success: true, data: organizations };
}
async list(userId: string, organizationId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
const [linksA, linksB, invitations] = await Promise.all([
this.prisma.organizationLink.findMany({
where: { organizationAId: organizationId },
include: {
organizationB: { select: { id: true, name: true, email: true, phone: true, type: true } },
},
orderBy: { createdAt: 'desc' },
}),
this.prisma.organizationLink.findMany({
where: { organizationBId: organizationId },
include: {
organizationA: { select: { id: true, name: true, email: true, phone: true, type: true } },
},
orderBy: { createdAt: 'desc' },
}),
this.prisma.organizationInvitation.findMany({
where: { inviterOrganizationId: organizationId },
orderBy: { createdAt: 'desc' },
}),
]);
const linkItems = [
...linksA.map((l) => ({
id: l.id,
kind: 'LINK' as const,
counterpartOrganizationId: l.organizationB.id,
organizationName: l.organizationB.name,
ownerEmail: l.organizationB.email,
phone: l.organizationB.phone,
status: l.status,
invitationUrl: null as string | null,
createdAt: l.createdAt.toISOString(),
acceptedAt: l.status === LinkStatus.ACTIVE ? l.updatedAt.toISOString() : null,
})),
...linksB.map((l) => ({
id: l.id,
kind: 'LINK' as const,
counterpartOrganizationId: l.organizationA.id,
organizationName: l.organizationA.name,
ownerEmail: l.organizationA.email,
phone: l.organizationA.phone,
status: l.status,
invitationUrl: null as string | null,
createdAt: l.createdAt.toISOString(),
acceptedAt: l.status === LinkStatus.ACTIVE ? l.updatedAt.toISOString() : null,
})),
];
const inviteItems = invitations.map((i) => ({
id: i.id,
kind: 'INVITATION' as const,
counterpartOrganizationId: i.invitedOrganizationId,
organizationName: i.invitedOrganizationName,
ownerEmail: i.invitedOwnerEmail,
phone: null as string | null,
status: this.mapInvitationStatus(i.acceptedAt, i.revokedAt, i.expiresAt),
invitationUrl:
!i.acceptedAt && !i.revokedAt && i.expiresAt.getTime() > Date.now()
? this.buildInviteUrlFromTokenHashPlaceholder()
: null,
createdAt: i.createdAt.toISOString(),
acceptedAt: i.acceptedAt?.toISOString() ?? null,
}));
return {
success: true,
data: {
items: [...linkItems, ...inviteItems].sort((a, b) =>
a.createdAt < b.createdAt ? 1 : -1,
),
},
};
}
async createLinkRequest(userId: string, organizationId: string, dto: CreateLinkRequestDto) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
if (dto.targetOrganizationId === organizationId) {
throw new BadRequestException('You cannot link organization to itself');
}
const sourceType = actor.organization.type.name;
const targetType = this.getCounterpartType(sourceType);
const target = await this.prisma.organization.findUnique({
where: { id: dto.targetOrganizationId },
select: { id: true, type: true, planId: true },
});
if (!target) {
throw new NotFoundException('Organization not found');
}
if (target.type.name !== targetType) {
throw new BadRequestException(`You can only link to ${targetType} organizations`);
}
if (!target.planId) {
throw new BadRequestException('Target organization does not have an active subscription');
}
const [aId, bId] =
organizationId < dto.targetOrganizationId
? [organizationId, dto.targetOrganizationId]
: [dto.targetOrganizationId, organizationId];
const existing = await this.prisma.organizationLink.findUnique({
where: { organizationAId_organizationBId: { organizationAId: aId, organizationBId: bId } },
});
if (existing) {
throw new ConflictException('Link already exists for these organizations');
}
const created = await this.prisma.organizationLink.create({
data: {
organizationAId: aId,
organizationBId: bId,
status: LinkStatus.PENDING,
sharedDataTypes: [],
},
});
return {
success: true,
data: { id: created.id, status: created.status },
message: 'Link request created',
};
}
async inviteOrganization(userId: string, organizationId: string, dto: InviteOrganizationDto) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
const ownerEmail = dto.ownerEmail.trim().toLowerCase();
const inviterType = actor.organization.type.name;
const invitedType = this.getCounterpartType(inviterType);
const plainToken = this.generateInviteToken();
const tokenHash = this.hashInviteToken(plainToken);
const existingOwnerWithPlan = await this.prisma.organization.findFirst({
where: {
owner: { email: ownerEmail },
planId: { not: null },
},
select: { id: true },
});
if (existingOwnerWithPlan) {
throw new BadRequestException(
'This owner already has an organization with active subscription. Select that organization from search instead of sending invitation.',
);
}
const invitedOrg = await this.prisma.organization.findFirst({
where: {
owner: { email: ownerEmail },
type: { name: invitedType },
},
select: { id: true, name: true },
orderBy: { createdAt: 'desc' },
});
const invitation = await this.prisma.organizationInvitation.create({
data: {
inviterOrganizationId: organizationId,
inviterUserId: userId,
invitedOrganizationId: invitedOrg?.id ?? null,
invitedOrganizationName: dto.organizationName.trim(),
invitedOwnerEmail: ownerEmail,
invitedOrganizationType: invitedType,
tokenHash,
expiresAt: this.getInviteExpiryDate(),
},
});
return {
success: true,
data: {
invitationId: invitation.id,
invitationUrl: this.buildInviteUrl(plainToken),
status: 'PENDING',
},
};
}
async previewInvite(token: string) {
const invitation = await this.findValidInvitation(token);
return {
success: true,
data: {
ownerEmail: invitation.invitedOwnerEmail,
organizationName: invitation.invitedOrganizationName,
organizationType: invitation.invitedOrganizationType,
inviterOrganizationName: invitation.inviterOrganization.name,
expiresAt: invitation.expiresAt.toISOString(),
status: invitation.acceptedAt ? 'ACCEPTED' : 'PENDING',
},
};
}
async acceptInvite(dto: AcceptOrganizationInviteDto) {
const invitation = await this.findValidInvitation(dto.token);
if (invitation.acceptedAt) {
throw new BadRequestException('This invitation has already been accepted');
}
const organization = await this.prisma.$transaction(async (tx) => {
const passwordHash = await bcrypt.hash(dto.password, 10);
const ownerEmail = invitation.invitedOwnerEmail;
let owner = await tx.user.findUnique({ where: { email: ownerEmail } });
if (!owner) {
owner = await tx.user.create({
data: {
email: ownerEmail,
name: dto.ownerName.trim(),
passwordHash,
trialUsedAt: new Date(),
},
});
} else if (!owner.passwordHash) {
owner = await tx.user.update({
where: { id: owner.id },
data: { passwordHash, name: dto.ownerName.trim(), trialUsedAt: owner.trialUsedAt ?? new Date() },
});
}
let targetOrganizationId = invitation.invitedOrganizationId;
if (targetOrganizationId) {
await tx.organization.update({
where: { id: targetOrganizationId },
data: {
name: dto.organizationName.trim(),
email: ownerEmail,
owner: { connect: { id: owner.id } },
plan: { connect: { name: 'trial' } },
},
});
} else {
const createdOrg = await tx.organization.create({
data: {
name: dto.organizationName.trim(),
email: ownerEmail,
owner: { connect: { id: owner.id } },
type: { connect: { name: invitation.invitedOrganizationType } },
plan: { connect: { name: 'trial' } },
},
});
targetOrganizationId = createdOrg.id;
}
const ownerMembership = await tx.membership.findFirst({
where: { userId: owner.id, organizationId: targetOrganizationId },
select: { id: true },
});
if (!ownerMembership) {
await tx.membership.create({
data: {
userId: owner.id,
organizationId: targetOrganizationId,
isOwner: true,
isActive: true,
},
});
}
const [aId, bId] =
invitation.inviterOrganizationId < targetOrganizationId
? [invitation.inviterOrganizationId, targetOrganizationId]
: [targetOrganizationId, invitation.inviterOrganizationId];
await tx.organizationLink.upsert({
where: { organizationAId_organizationBId: { organizationAId: aId, organizationBId: bId } },
update: { status: LinkStatus.ACTIVE },
create: {
organizationAId: aId,
organizationBId: bId,
status: LinkStatus.ACTIVE,
sharedDataTypes: [],
},
});
await tx.organizationInvitation.update({
where: { id: invitation.id },
data: {
acceptedAt: new Date(),
invitedOrganizationId: targetOrganizationId,
invitedOrganizationName: dto.organizationName.trim(),
},
});
return targetOrganizationId;
});
return {
success: true,
data: { organizationId: organization },
message: 'Invitation accepted. Organization trial has started and link is active.',
};
}
private async getActorMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: { userId, organizationId },
include: {
organization: {
select: {
id: true,
type: true,
},
},
permissions: { include: { permission: true } },
},
});
}
private canEditOrganizations(m: {
isOwner: boolean;
permissions: { permission: { name: string } }[];
}): boolean {
if (m.isOwner) return true;
return m.permissions.some((p) => p.permission.name === 'TAB_ORGANIZATIONS_EDIT');
}
private getCounterpartType(orgType: string): 'CLINIC' | 'LAB' {
if (orgType === 'CLINIC') return 'LAB';
if (orgType === 'LAB') return 'CLINIC';
throw new BadRequestException('Unknown organization type');
}
private mapInvitationStatus(
acceptedAt: Date | null,
revokedAt: Date | null,
expiresAt: Date,
): LinkStatus | 'EXPIRED' {
if (acceptedAt) return LinkStatus.ACTIVE;
if (revokedAt) return LinkStatus.REJECTED;
return expiresAt.getTime() > Date.now() ? LinkStatus.PENDING : 'EXPIRED';
}
private generateInviteToken(): string {
return randomBytes(32).toString('hex');
}
private hashInviteToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
private getInviteExpiryDate(): Date {
const d = new Date();
d.setDate(d.getDate() + 7);
return d;
}
private buildInviteUrl(token: string): string {
const appUrl = process.env.FRONTEND_URL || 'http://localhost:3001';
return `${appUrl}/accept-organization-invite?token=${encodeURIComponent(token)}`;
}
private buildInviteUrlFromTokenHashPlaceholder(): null {
// Raw token cannot be reconstructed from hash, so pending links are preserved client-side after creation.
return null;
}
private async findValidInvitation(token: string) {
const invitation = await this.prisma.organizationInvitation.findUnique({
where: { tokenHash: this.hashInviteToken(token) },
include: {
inviterOrganization: { select: { id: true, name: true } },
},
});
if (!invitation) {
throw new NotFoundException('Invitation not found');
}
if (invitation.revokedAt) {
throw new BadRequestException('Invitation has been revoked');
}
if (invitation.expiresAt.getTime() <= Date.now()) {
throw new BadRequestException('Invitation has expired');
}
return invitation;
}
}

View File

@@ -1,10 +0,0 @@
export default function LabPage() {
return (
<div className="space-y-3">
<h1 className="text-2xl font-semibold text-text-primary">Lab Management</h1>
<p className="text-sm text-text-secondary">
Lab management module is coming soon.
</p>
</div>
);
}

View File

@@ -0,0 +1,350 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Check, Copy, Search, Send } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import {
organizationApi,
type CounterpartItemDto,
type CounterpartSearchResultDto,
} from '@/lib/api/organization';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import type { ApiError } from '@/types/api';
type StoredInviteLink = {
invitationId: string;
ownerEmail: string;
invitationUrl: string;
};
function inviteLinksStorageKey(orgId: string): string {
return `counterpartInviteLinks:${orgId}`;
}
function readStoredInviteLinks(orgId: string): Record<string, StoredInviteLink> {
if (typeof window === 'undefined') return {};
try {
const raw = window.localStorage.getItem(inviteLinksStorageKey(orgId));
if (!raw) return {};
const parsed = JSON.parse(raw) as Record<string, StoredInviteLink>;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function writeStoredInviteLinks(orgId: string, links: Record<string, StoredInviteLink>) {
if (typeof window === 'undefined') return;
window.localStorage.setItem(inviteLinksStorageKey(orgId), JSON.stringify(links));
}
function formatApiMessage(err: unknown): string {
if (!err || typeof err !== 'object') return 'Something went wrong';
const m = (err as ApiError).message;
if (Array.isArray(m)) return m.join(', ');
if (typeof m === 'string') return m;
return 'Something went wrong';
}
export default function OrganizationsPage() {
const { currentOrganization } = useAuth();
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [searchLoading, setSearchLoading] = useState(false);
const [results, setResults] = useState<CounterpartSearchResultDto[]>([]);
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
const [linkLoading, setLinkLoading] = useState(false);
const [items, setItems] = useState<CounterpartItemDto[]>([]);
const [manualOrganizationName, setManualOrganizationName] = useState('');
const [manualOwnerEmail, setManualOwnerEmail] = useState('');
const [manualPhone, setManualPhone] = useState('');
const [inviteLoading, setInviteLoading] = useState(false);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [pendingInviteLinks, setPendingInviteLinks] = useState<Record<string, StoredInviteLink>>({});
const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinic' : 'Lab';
const tabLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs';
const selectedResult = useMemo(
() => results.find((r) => r.id === selectedOrgId) ?? null,
[results, selectedOrgId],
);
async function loadList() {
setLoading(true);
setError('');
try {
const res = await organizationApi.list();
setItems(res.data.items);
} catch (e) {
setError(formatApiMessage(e));
} finally {
setLoading(false);
}
}
useEffect(() => {
if (!currentOrganization?.id) return;
setPendingInviteLinks(readStoredInviteLinks(currentOrganization.id));
}, [currentOrganization?.id]);
useEffect(() => {
void loadList();
}, []);
useEffect(() => {
if (!success) return;
const t = setTimeout(() => setSuccess(''), 4000);
return () => clearTimeout(t);
}, [success]);
async function runSearch() {
setSearchLoading(true);
setError('');
setSelectedOrgId(null);
try {
const res = await organizationApi.search(searchTerm.trim());
setResults(res.data);
} catch (e) {
setError(formatApiMessage(e));
} finally {
setSearchLoading(false);
}
}
async function createLink() {
if (!selectedOrgId) return;
setLinkLoading(true);
setError('');
try {
await organizationApi.createLink(selectedOrgId);
setSuccess(`${counterpartLabel} link request created`);
setResults([]);
setSelectedOrgId(null);
setSearchTerm('');
await loadList();
} catch (e) {
setError(formatApiMessage(e));
} finally {
setLinkLoading(false);
}
}
async function sendInvite() {
setInviteLoading(true);
setError('');
try {
const res = await organizationApi.invite({
organizationName: manualOrganizationName.trim(),
ownerEmail: manualOwnerEmail.trim(),
phone: manualPhone.trim() || undefined,
});
if (currentOrganization?.id) {
const nextLinks = {
...pendingInviteLinks,
[res.data.invitationId]: {
invitationId: res.data.invitationId,
ownerEmail: manualOwnerEmail.trim().toLowerCase(),
invitationUrl: res.data.invitationUrl,
},
};
setPendingInviteLinks(nextLinks);
writeStoredInviteLinks(currentOrganization.id, nextLinks);
}
setSuccess(`Invitation link created for ${manualOwnerEmail.trim()}`);
setManualOrganizationName('');
setManualOwnerEmail('');
setManualPhone('');
await loadList();
} catch (e) {
setError(formatApiMessage(e));
} finally {
setInviteLoading(false);
}
}
if (!currentOrganization) {
return <p className="text-sm text-text-secondary">Loading organization...</p>;
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-text-primary">{tabLabel}</h1>
<p className="text-sm text-text-secondary mt-1">
Search subscribed {tabLabel.toLowerCase()}, request link access, or invite a new{' '}
{counterpartLabel.toLowerCase()} owner.
</p>
</div>
{error && (
<div className="rounded-[var(--radius-md)] border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{success && (
<div className="rounded-[var(--radius-md)] border border-primary/30 bg-primary-soft/40 px-4 py-3 text-sm text-text-primary">
{success}
</div>
)}
<div className="rounded-[var(--radius-md)] border border-border/70 p-4 space-y-3">
<h2 className="text-base font-semibold text-text-primary">Search existing {tabLabel}</h2>
<div className="flex gap-2">
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={`Search by ${counterpartLabel.toLowerCase()} name, email, or phone`}
/>
<Button type="button" isLoading={searchLoading} onClick={() => void runSearch()}>
<Search className="w-4 h-4 mr-2" />
Search
</Button>
</div>
{results.length > 0 && (
<div className="space-y-2 pt-1">
{results.map((r) => (
<button
key={r.id}
type="button"
onClick={() => setSelectedOrgId(r.id)}
className={`w-full text-left rounded-[var(--radius-md)] border px-3 py-2 ${
selectedOrgId === r.id
? 'border-primary/70 bg-primary-soft/30'
: 'border-border/60 hover:border-border-strong'
}`}
>
<p className="text-sm font-medium text-text-primary">{r.name}</p>
<p className="text-xs text-text-secondary mt-0.5">
{r.email}
{r.phone ? ` - ${r.phone}` : ''}
</p>
<p className="text-xs text-text-muted mt-0.5">Owner: {r.owner.email}</p>
</button>
))}
</div>
)}
<div className="flex justify-end">
<Button
type="button"
isLoading={linkLoading}
disabled={!selectedResult}
onClick={() => void createLink()}
>
<Send className="w-4 h-4 mr-2" />
Request link
</Button>
</div>
</div>
<div className="rounded-[var(--radius-md)] border border-border/70 p-4 space-y-3">
<h2 className="text-base font-semibold text-text-primary">Invite owner (not yet subscribed)</h2>
<Input
label={`${counterpartLabel} name`}
value={manualOrganizationName}
onChange={(e) => setManualOrganizationName(e.target.value)}
/>
<Input
label="Owner email"
type="email"
value={manualOwnerEmail}
onChange={(e) => setManualOwnerEmail(e.target.value)}
/>
<Input
label="Mobile number (optional)"
value={manualPhone}
onChange={(e) => setManualPhone(e.target.value)}
/>
<div className="flex justify-end">
<Button
type="button"
isLoading={inviteLoading}
disabled={!manualOrganizationName.trim() || !manualOwnerEmail.trim()}
onClick={() => void sendInvite()}
>
Send invite link
</Button>
</div>
</div>
<div className="rounded-[var(--radius-md)] border border-border/70">
<div className="border-b border-border/70 px-4 py-3">
<h2 className="text-base font-semibold text-text-primary">Requests and invitations</h2>
</div>
{loading ? (
<p className="px-4 py-4 text-sm text-text-secondary">Loading list...</p>
) : items.length === 0 ? (
<p className="px-4 py-4 text-sm text-text-secondary">No records yet.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/70 text-left text-text-secondary">
<th className="p-3 font-medium">Type</th>
<th className="p-3 font-medium">Organization</th>
<th className="p-3 font-medium">Owner email</th>
<th className="p-3 font-medium">Status</th>
<th className="p-3 font-medium">Action</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-b border-border/40 last:border-0">
<td className="p-3 text-text-secondary">
{item.kind === 'LINK' ? 'Link request' : 'Invitation'}
</td>
<td className="p-3 text-text-primary">{item.organizationName}</td>
<td className="p-3 text-text-secondary">{item.ownerEmail}</td>
<td className="p-3">
<span className="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs text-text-primary">
{item.status}
</span>
</td>
<td className="p-3">
{item.kind === 'INVITATION' &&
item.status === 'PENDING' &&
pendingInviteLinks[item.id]?.invitationUrl && (
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(
pendingInviteLinks[item.id].invitationUrl,
);
setCopiedId(item.id);
setTimeout(() => setCopiedId(null), 1500);
} catch {
setError('Could not copy invitation link');
}
}}
>
{copiedId === item.id ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
<span className="ml-1">
{copiedId === item.id ? 'Copied' : 'Copy link'}
</span>
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@@ -2,7 +2,11 @@
export const STAFF_FEATURE_GROUPS = [ export const STAFF_FEATURE_GROUPS = [
{ label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' }, { label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' },
{ label: 'Staff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' }, { label: 'Staff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' },
{ label: 'Labs / Clinics', read: 'TAB_LAB_READ', edit: 'TAB_LAB_EDIT' }, {
label: 'Organizations',
read: 'TAB_ORGANIZATIONS_READ',
edit: 'TAB_ORGANIZATIONS_EDIT',
},
{ label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' }, { label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' },
{ label: 'Appointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' }, { label: 'Appointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' },
{ label: 'Treatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' }, { label: 'Treatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' },

View File

@@ -6,7 +6,7 @@
export const STAFF_FEATURE_GROUPS = [ export const STAFF_FEATURE_GROUPS = [
{ label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' }, { label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' },
{ label: 'Staff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' }, { label: 'Staff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' },
{ label: 'Labs', read: 'TAB_LAB_READ', edit: 'TAB_LAB_EDIT' }, { label: 'Organizations', read: 'TAB_ORGANIZATIONS_READ', edit: 'TAB_ORGANIZATIONS_EDIT' },
{ label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' }, { label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' },
{ label: 'Appointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' }, { label: 'Appointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' },
{ label: 'Treatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' }, { label: 'Treatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' },
@@ -21,7 +21,7 @@ export function resolveStaffFeatureLabel(
group: (typeof STAFF_FEATURE_GROUPS)[number], group: (typeof STAFF_FEATURE_GROUPS)[number],
organizationType: OrgType, organizationType: OrgType,
): string { ): string {
if (group.read === 'TAB_LAB_READ') { if (group.read === 'TAB_ORGANIZATIONS_READ') {
return organizationType === 'LAB' ? 'Clinics' : 'Labs'; return organizationType === 'LAB' ? 'Clinics' : 'Labs';
} }
return group.label; return group.label;

View File

@@ -0,0 +1,159 @@
'use client';
import { Suspense, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import { organizationApi } from '@/lib/api/organization';
function AcceptOrganizationInviteContent() {
const params = useSearchParams();
const router = useRouter();
const token = useMemo(() => params.get('token') || '', [params]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [inviteInfo, setInviteInfo] = useState<{
ownerEmail: string;
organizationName: string;
organizationType: 'CLINIC' | 'LAB';
inviterOrganizationName: string;
expiresAt: string;
status: 'PENDING' | 'ACCEPTED';
} | null>(null);
const [ownerName, setOwnerName] = useState('');
const [organizationName, setOrganizationName] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
useEffect(() => {
if (!token) {
setLoading(false);
setError('Invalid invitation link');
return;
}
void (async () => {
setLoading(true);
setError('');
try {
const res = await organizationApi.previewInvite(token);
setInviteInfo(res.data);
setOrganizationName(res.data.organizationName || '');
if (res.data.status === 'ACCEPTED') {
setSuccess('This invitation is already accepted. You can log in now.');
}
} catch (e: any) {
setError(e?.message || 'Could not load invitation');
} finally {
setLoading(false);
}
})();
}, [token]);
async function onAccept() {
if (!token) return;
setError('');
setSuccess('');
if (!ownerName.trim()) return setError('Owner name is required');
if (!organizationName.trim()) return setError('Organization name is required');
if (password.length < 8) return setError('Password must be at least 8 characters');
if (password !== confirmPassword) return setError('Passwords do not match');
setSubmitting(true);
try {
await organizationApi.acceptInvite({
token,
ownerName: ownerName.trim(),
organizationName: organizationName.trim(),
password,
});
setSuccess('Invitation accepted. Redirecting to login...');
setTimeout(() => router.replace('/login'), 1000);
} catch (e: any) {
setError(e?.message || 'Could not accept invitation');
} finally {
setSubmitting(false);
}
}
return (
<div className="min-h-screen app-web-bg flex items-center justify-center p-4">
<div className="w-full max-w-md surface-card p-6 space-y-5">
<h1 className="text-xl font-semibold text-text-primary">Accept organization invitation</h1>
{loading ? (
<p className="text-sm text-text-secondary">Loading invitation...</p>
) : (
<>
{inviteInfo && (
<div className="rounded-[var(--radius-md)] border border-border/70 bg-background-secondary/70 px-3 py-2 text-sm text-text-secondary space-y-1">
<p>
Invited by: <span className="text-text-primary">{inviteInfo.inviterOrganizationName}</span>
</p>
<p>
Owner email: <span className="text-text-primary">{inviteInfo.ownerEmail}</span>
</p>
</div>
)}
{error && (
<div className="rounded-[var(--radius-md)] border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-300">
{error}
</div>
)}
{success && (
<div className="rounded-[var(--radius-md)] border border-primary/30 bg-primary-soft/40 px-3 py-2 text-sm text-text-primary">
{success}
</div>
)}
{inviteInfo?.status !== 'ACCEPTED' && (
<div className="space-y-3">
<Input label="Owner name" value={ownerName} onChange={(e) => setOwnerName(e.target.value)} />
<Input
label="Organization name"
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
/>
<Input
label="Create password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Input
label="Confirm password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
<Button type="button" fullWidth isLoading={submitting} onClick={() => void onAccept()}>
Activate organization
</Button>
</div>
)}
<p className="text-xs text-text-muted">
Already have access? <Link href="/login" className="text-primary">Go to login</Link>
</p>
</>
)}
</div>
</div>
);
}
export default function AcceptOrganizationInvitePage() {
return (
<Suspense
fallback={
<div className="min-h-screen app-web-bg flex items-center justify-center">
<p className="text-sm text-text-secondary">Loading invitation...</p>
</div>
}
>
<AcceptOrganizationInviteContent />
</Suspense>
);
}

View File

@@ -35,7 +35,12 @@ function Sidebar() {
const withCounterpartTab = [ const withCounterpartTab = [
menu[0], menu[0],
menu[1], menu[1],
{ name: counterpartLabel, path: '/lab', icon: FlaskConical, read: 'TAB_LAB_READ' as const }, {
name: counterpartLabel,
path: '/organizations',
icon: FlaskConical,
read: 'TAB_ORGANIZATIONS_READ' as const,
},
menu[2], menu[2],
menu[3], menu[3],
menu[4], menu[4],

View File

@@ -0,0 +1,79 @@
import { apiClient } from './client';
export interface CounterpartSearchResultDto {
id: string;
name: string;
email: string;
phone: string | null;
owner: { email: string; name: string };
}
export interface CounterpartItemDto {
id: string;
kind: 'LINK' | 'INVITATION';
counterpartOrganizationId: string | null;
organizationName: string;
ownerEmail: string;
phone: string | null;
status: 'PENDING' | 'ACTIVE' | 'REJECTED' | 'EXPIRED';
invitationUrl: string | null;
createdAt: string;
acceptedAt: string | null;
}
export const organizationApi = {
search: async (q: string): Promise<{ success: boolean; data: CounterpartSearchResultDto[] }> => {
const response = await apiClient.get(`/organizations/search?q=${encodeURIComponent(q)}`);
return response.data;
},
list: async (): Promise<{ success: boolean; data: { items: CounterpartItemDto[] } }> => {
const response = await apiClient.get('/organizations/links');
return response.data;
},
createLink: async (
targetOrganizationId: string,
): Promise<{ success: boolean; data: { id: string; status: 'PENDING' | 'ACTIVE' | 'REJECTED' } }> => {
const response = await apiClient.post('/organizations/links', { targetOrganizationId });
return response.data;
},
invite: async (body: {
organizationName: string;
ownerEmail: string;
phone?: string;
}): Promise<{ success: boolean; data: { invitationId: string; invitationUrl: string; status: 'PENDING' } }> => {
const response = await apiClient.post('/organizations/invite', body);
return response.data;
},
previewInvite: async (
token: string,
): Promise<{
success: boolean;
data: {
ownerEmail: string;
organizationName: string;
organizationType: 'CLINIC' | 'LAB';
inviterOrganizationName: string;
expiresAt: string;
status: 'PENDING' | 'ACCEPTED';
};
}> => {
const response = await apiClient.get(
`/organizations/invitations/preview?token=${encodeURIComponent(token)}`,
);
return response.data;
},
acceptInvite: async (body: {
token: string;
organizationName: string;
ownerName: string;
password: string;
}): Promise<{ success: boolean; message: string; data: { organizationId: string } }> => {
const response = await apiClient.post('/organizations/invitations/accept', body);
return response.data;
},
};

View File

@@ -3,7 +3,7 @@ import type { Organization } from '@/types/organization';
const ROUTE_TAB_READ: { prefix: string; permission: string }[] = [ const ROUTE_TAB_READ: { prefix: string; permission: string }[] = [
{ prefix: '/today', permission: 'TAB_TODAY_READ' }, { prefix: '/today', permission: 'TAB_TODAY_READ' },
{ prefix: '/staff', permission: 'TAB_STAFF_READ' }, { prefix: '/staff', permission: 'TAB_STAFF_READ' },
{ prefix: '/lab', permission: 'TAB_LAB_READ' }, { prefix: '/organizations', permission: 'TAB_ORGANIZATIONS_READ' },
{ prefix: '/patients', permission: 'TAB_PATIENTS_READ' }, { prefix: '/patients', permission: 'TAB_PATIENTS_READ' },
{ prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ' }, { prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ' },
{ prefix: '/treatment', permission: 'TAB_TREATMENT_READ' }, { prefix: '/treatment', permission: 'TAB_TREATMENT_READ' },