diff --git a/backend/prisma/migrations/20260505173000_add_organization_invitations/migration.sql b/backend/prisma/migrations/20260505173000_add_organization_invitations/migration.sql new file mode 100644 index 0000000..0251067 --- /dev/null +++ b/backend/prisma/migrations/20260505173000_add_organization_invitations/migration.sql @@ -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; diff --git a/backend/prisma/migrations/20260505181000_rename_lab_permissions_to_organizations/migration.sql b/backend/prisma/migrations/20260505181000_rename_lab_permissions_to_organizations/migration.sql new file mode 100644 index 0000000..750ff57 --- /dev/null +++ b/backend/prisma/migrations/20260505181000_rename_lab_permissions_to_organizations/migration.sql @@ -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'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 78b6d64..394229c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index a27fa08..6023967 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -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', diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index aaa7938..0f6d322 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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], diff --git a/backend/src/common/permissions.ts b/backend/src/common/permissions.ts index afebf47..5d0b172 100644 --- a/backend/src/common/permissions.ts +++ b/backend/src/common/permissions.ts @@ -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 = { 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', diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 3dccbf0..368624f 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -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', diff --git a/backend/src/modules/organization/dto/accept-organization-invite.dto.ts b/backend/src/modules/organization/dto/accept-organization-invite.dto.ts new file mode 100644 index 0000000..4b4f692 --- /dev/null +++ b/backend/src/modules/organization/dto/accept-organization-invite.dto.ts @@ -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; +} diff --git a/backend/src/modules/organization/dto/create-link-request.dto.ts b/backend/src/modules/organization/dto/create-link-request.dto.ts new file mode 100644 index 0000000..40a0132 --- /dev/null +++ b/backend/src/modules/organization/dto/create-link-request.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class CreateLinkRequestDto { + @IsUUID() + targetOrganizationId: string; +} diff --git a/backend/src/modules/organization/dto/invite-organization.dto.ts b/backend/src/modules/organization/dto/invite-organization.dto.ts new file mode 100644 index 0000000..469a529 --- /dev/null +++ b/backend/src/modules/organization/dto/invite-organization.dto.ts @@ -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; +} diff --git a/backend/src/modules/organization/dto/preview-organization-invite.dto.ts b/backend/src/modules/organization/dto/preview-organization-invite.dto.ts new file mode 100644 index 0000000..8a2f00f --- /dev/null +++ b/backend/src/modules/organization/dto/preview-organization-invite.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MinLength } from 'class-validator'; + +export class PreviewOrganizationInviteDto { + @IsString() + @MinLength(1) + token: string; +} diff --git a/backend/src/modules/organization/organization.controller.ts b/backend/src/modules/organization/organization.controller.ts new file mode 100644 index 0000000..75ab7a2 --- /dev/null +++ b/backend/src/modules/organization/organization.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/organization/organization.module.ts b/backend/src/modules/organization/organization.module.ts new file mode 100644 index 0000000..d36aef2 --- /dev/null +++ b/backend/src/modules/organization/organization.module.ts @@ -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 {} diff --git a/backend/src/modules/organization/organization.service.ts b/backend/src/modules/organization/organization.service.ts new file mode 100644 index 0000000..e495df6 --- /dev/null +++ b/backend/src/modules/organization/organization.service.ts @@ -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; + } +} diff --git a/frontend/src/app/(dashboard)/lab/page.tsx b/frontend/src/app/(dashboard)/lab/page.tsx deleted file mode 100644 index 42c2cab..0000000 --- a/frontend/src/app/(dashboard)/lab/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export default function LabPage() { - return ( -
-

Lab Management

-

- Lab management module is coming soon. -

-
- ); -} diff --git a/frontend/src/app/(dashboard)/organizations/page.tsx b/frontend/src/app/(dashboard)/organizations/page.tsx new file mode 100644 index 0000000..db95130 --- /dev/null +++ b/frontend/src/app/(dashboard)/organizations/page.tsx @@ -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 { + if (typeof window === 'undefined') return {}; + try { + const raw = window.localStorage.getItem(inviteLinksStorageKey(orgId)); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Record; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +function writeStoredInviteLinks(orgId: string, links: Record) { + 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([]); + const [selectedOrgId, setSelectedOrgId] = useState(null); + const [linkLoading, setLinkLoading] = useState(false); + + const [items, setItems] = useState([]); + const [manualOrganizationName, setManualOrganizationName] = useState(''); + const [manualOwnerEmail, setManualOwnerEmail] = useState(''); + const [manualPhone, setManualPhone] = useState(''); + const [inviteLoading, setInviteLoading] = useState(false); + const [copiedId, setCopiedId] = useState(null); + const [pendingInviteLinks, setPendingInviteLinks] = useState>({}); + + 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

Loading organization...

; + } + + return ( +
+
+

{tabLabel}

+

+ Search subscribed {tabLabel.toLowerCase()}, request link access, or invite a new{' '} + {counterpartLabel.toLowerCase()} owner. +

+
+ + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + +
+

Search existing {tabLabel}

+
+ setSearchTerm(e.target.value)} + placeholder={`Search by ${counterpartLabel.toLowerCase()} name, email, or phone`} + /> + +
+ + {results.length > 0 && ( +
+ {results.map((r) => ( + + ))} +
+ )} + +
+ +
+
+ +
+

Invite owner (not yet subscribed)

+ setManualOrganizationName(e.target.value)} + /> + setManualOwnerEmail(e.target.value)} + /> + setManualPhone(e.target.value)} + /> +
+ +
+
+ +
+
+

Requests and invitations

+
+ {loading ? ( +

Loading list...

+ ) : items.length === 0 ? ( +

No records yet.

+ ) : ( +
+ + + + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + +
TypeOrganizationOwner emailStatusAction
+ {item.kind === 'LINK' ? 'Link request' : 'Invitation'} + {item.organizationName}{item.ownerEmail} + + {item.status} + + + {item.kind === 'INVITATION' && + item.status === 'PENDING' && + pendingInviteLinks[item.id]?.invitationUrl && ( + + )} +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/staff/components/staffPermissions.ts b/frontend/src/app/(dashboard)/staff/components/staffPermissions.ts index c4e7799..383c44d 100644 --- a/frontend/src/app/(dashboard)/staff/components/staffPermissions.ts +++ b/frontend/src/app/(dashboard)/staff/components/staffPermissions.ts @@ -2,7 +2,11 @@ export const STAFF_FEATURE_GROUPS = [ { label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_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: 'Appointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' }, { label: 'Treatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' }, diff --git a/frontend/src/app/(dashboard)/staff/staff-permission-form.ts b/frontend/src/app/(dashboard)/staff/staff-permission-form.ts index 8e95e18..54787f2 100644 --- a/frontend/src/app/(dashboard)/staff/staff-permission-form.ts +++ b/frontend/src/app/(dashboard)/staff/staff-permission-form.ts @@ -6,7 +6,7 @@ export const STAFF_FEATURE_GROUPS = [ { label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_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: 'Appointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' }, { label: 'Treatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' }, @@ -21,7 +21,7 @@ export function resolveStaffFeatureLabel( group: (typeof STAFF_FEATURE_GROUPS)[number], organizationType: OrgType, ): string { - if (group.read === 'TAB_LAB_READ') { + if (group.read === 'TAB_ORGANIZATIONS_READ') { return organizationType === 'LAB' ? 'Clinics' : 'Labs'; } return group.label; diff --git a/frontend/src/app/(public)/accept-organization-invite/page.tsx b/frontend/src/app/(public)/accept-organization-invite/page.tsx new file mode 100644 index 0000000..672478e --- /dev/null +++ b/frontend/src/app/(public)/accept-organization-invite/page.tsx @@ -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 ( +
+
+

Accept organization invitation

+ {loading ? ( +

Loading invitation...

+ ) : ( + <> + {inviteInfo && ( +
+

+ Invited by: {inviteInfo.inviterOrganizationName} +

+

+ Owner email: {inviteInfo.ownerEmail} +

+
+ )} + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + {inviteInfo?.status !== 'ACCEPTED' && ( +
+ setOwnerName(e.target.value)} /> + setOrganizationName(e.target.value)} + /> + setPassword(e.target.value)} + /> + setConfirmPassword(e.target.value)} + /> + +
+ )} +

+ Already have access? Go to login +

+ + )} +
+
+ ); +} + +export default function AcceptOrganizationInvitePage() { + return ( + +

Loading invitation...

+ + } + > + +
+ ); +} diff --git a/frontend/src/components/ui/common/Sidebar.tsx b/frontend/src/components/ui/common/Sidebar.tsx index d46b9e6..e8d9b59 100644 --- a/frontend/src/components/ui/common/Sidebar.tsx +++ b/frontend/src/components/ui/common/Sidebar.tsx @@ -35,7 +35,12 @@ function Sidebar() { const withCounterpartTab = [ menu[0], 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[3], menu[4], diff --git a/frontend/src/lib/api/organization.ts b/frontend/src/lib/api/organization.ts new file mode 100644 index 0000000..9287958 --- /dev/null +++ b/frontend/src/lib/api/organization.ts @@ -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; + }, +}; diff --git a/frontend/src/shared/permissions.ts b/frontend/src/shared/permissions.ts index 429288e..123cbe6 100644 --- a/frontend/src/shared/permissions.ts +++ b/frontend/src/shared/permissions.ts @@ -3,7 +3,7 @@ import type { Organization } from '@/types/organization'; const ROUTE_TAB_READ: { prefix: string; permission: string }[] = [ { prefix: '/today', permission: 'TAB_TODAY_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: '/appointments', permission: 'TAB_APPOINTMENTS_READ' }, { prefix: '/treatment', permission: 'TAB_TREATMENT_READ' },