feature: clinic & lab invitation flow added. minimal ui implemented for organizations tab.

This commit is contained in:
2026-05-05 19:50:07 +03:30
parent 43e39883a4
commit adfb5b436e
22 changed files with 1265 additions and 23 deletions

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")
sessions Session[] // 👈 ADD THIS - opposite relation for Session
sentStaffInvites StaffInvitation[]
sentOrganizationInvitations OrganizationInvitation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -57,6 +58,7 @@ model Organization {
sharedWithMe OrganizationLink[] @relation("OrganizationB")
sharedWithOthers OrganizationLink[] @relation("OrganizationA")
sentOrganizationInvitations OrganizationInvitation[] @relation("OrganizationInvitationInviter")
patients Patient[]
createdAt DateTime @default(now())
@@ -211,6 +213,32 @@ model OrganizationLink {
@@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 {
id String @id @default(uuid())
userId String

View File

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

View File

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

View File

@@ -4,8 +4,8 @@ export const ALL_TAB_PERMISSIONS = [
'TAB_TODAY_EDIT',
'TAB_STAFF_READ',
'TAB_STAFF_EDIT',
'TAB_LAB_READ',
'TAB_LAB_EDIT',
'TAB_ORGANIZATIONS_READ',
'TAB_ORGANIZATIONS_EDIT',
'TAB_PATIENTS_READ',
'TAB_PATIENTS_EDIT',
'TAB_APPOINTMENTS_READ',
@@ -38,7 +38,7 @@ const EDIT_TO_READ: Record<string, string> = {
TAB_PATIENTS_EDIT: 'TAB_PATIENTS_READ',
TAB_APPOINTMENTS_EDIT: 'TAB_APPOINTMENTS_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_BILLING_EDIT: 'TAB_BILLING_READ',
TAB_REPORTS_EDIT: 'TAB_REPORTS_READ',

View File

@@ -20,8 +20,8 @@ const ALL_PERMISSIONS = [
'TAB_TODAY_EDIT',
'TAB_STAFF_READ',
'TAB_STAFF_EDIT',
'TAB_LAB_READ',
'TAB_LAB_EDIT',
'TAB_ORGANIZATIONS_READ',
'TAB_ORGANIZATIONS_EDIT',
'TAB_PATIENTS_READ',
'TAB_PATIENTS_EDIT',
'TAB_APPOINTMENTS_READ',
@@ -37,7 +37,7 @@ const ALL_PERMISSIONS = [
const READ_ONLY_PERMISSIONS = [
'TAB_TODAY_READ',
'TAB_STAFF_READ',
'TAB_LAB_READ',
'TAB_ORGANIZATIONS_READ',
'TAB_PATIENTS_READ',
'TAB_APPOINTMENTS_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;
}
}