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 { CreateConnectionRequestDto } from './dto/create-connection-request.dto'; import { InviteOrganizationDto } from './dto/invite-organization.dto'; import { RespondConnectionRequestDto } from './dto/respond-connection-request.dto'; /** * Clinic↔lab counterpart relationships. * * **Connection request** (`createConnectionRequest`): target org already exists with a subscription. * Creates OrganizationLink PENDING only; counterpart accepts via `respondToConnectionRequest`. * * **Invitation link** (`inviteOrganization`): target not in directory (no subscription). Creates * placeholder Organization + OrganizationInvitation + PENDING OrganizationLink in one transaction. * Invitee signs up via `acceptInvite`, which activates the link—no second connection request needed. * * API name is "connection"; Prisma model remains `OrganizationLink` (historical table name). */ @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 }; } /** Connections list for the Organizations tab (both sides of each link). */ 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'); } // Open outbound invitations keyed by placeholder/real invited org id — lets UI show copy-invite // on the auto-created PENDING link without opening invitation history. const [linksA, linksB, outboundInvitations] = 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, acceptedAt: null, revokedAt: null, invitedOrganizationId: { not: null }, }, select: { id: true, invitedOrganizationId: true, invitedOwnerEmail: true, expiresAt: true, acceptedAt: true, revokedAt: true, }, }), ]); const invitationByOrgId = new Map( outboundInvitations .filter((inv) => inv.invitedOrganizationId) .map((inv) => [inv.invitedOrganizationId as string, inv]), ); const mapLinkItem = ( l: (typeof linksA)[number] | (typeof linksB)[number], counterpart: { id: string; name: string; email: string; phone: string | null }, ) => { const invitation = invitationByOrgId.get(counterpart.id); return { requestedByOrganizationId: this.getRequesterOrganizationId(l.sharedDataTypes), id: l.id, counterpartOrganizationId: counterpart.id, organizationName: counterpart.name, ownerEmail: invitation?.invitedOwnerEmail ?? counterpart.email, phone: counterpart.phone, status: l.status, createdAt: l.createdAt.toISOString(), acceptedAt: l.status === LinkStatus.ACTIVE ? l.updatedAt.toISOString() : null, // Present only for invite-flow pending links (see inviteOrganization). pendingInvitationId: invitation?.id ?? null, invitationStatus: invitation ? this.mapInvitationStatus(invitation.acceptedAt, invitation.revokedAt, invitation.expiresAt) : null, }; }; const linkItems = [ ...linksA.map((l) => mapLinkItem(l, l.organizationB)), ...linksB.map((l) => mapLinkItem(l, l.organizationA)), ]; return { success: true, data: { items: linkItems.sort((a, b) => a.createdAt < b.createdAt ? 1 : -1, ), }, }; } /** Lightweight count for sidebar badge — incoming PENDING requests only. */ async countIncomingPendingConnections(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 links = await this.prisma.organizationLink.findMany({ where: { status: LinkStatus.PENDING, OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }], }, select: { sharedDataTypes: true }, }); const count = links.filter((link) => { const requesterOrgId = this.getRequesterOrganizationId(link.sharedDataTypes); return requesterOrgId !== null && requesterOrgId !== organizationId; }).length; return { success: true, data: { count } }; } async listInvitationHistory(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 invitations = await this.prisma.organizationInvitation.findMany({ where: { inviterOrganizationId: organizationId }, orderBy: { createdAt: 'desc' }, }); return { success: true, data: { items: invitations.map((i) => ({ id: i.id, organizationName: i.invitedOrganizationName, ownerEmail: i.invitedOwnerEmail, status: this.mapInvitationStatus(i.acceptedAt, i.revokedAt, i.expiresAt), createdAt: i.createdAt.toISOString(), acceptedAt: i.acceptedAt?.toISOString() ?? null, })), }, }; } /** Flow 1: request to connect with an org that already has planId (found via search). */ async createConnectionRequest( userId: string, organizationId: string, dto: CreateConnectionRequestDto, ) { 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, // Who initiated; counterpart uses this to block self-accept (see respondToConnectionRequest). sharedDataTypes: [`requested_by:${organizationId}`], }, }); return { success: true, data: { id: created.id, status: created.status }, message: 'Connection request created', }; } async respondToConnectionRequest( userId: string, organizationId: string, connectionId: string, dto: RespondConnectionRequestDto, ) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditOrganizations(actor)) { throw new ForbiddenException('You do not have permission to manage organizations'); } const connection = await this.prisma.organizationLink.findFirst({ where: { id: connectionId, OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }], }, }); if (!connection) { throw new NotFoundException('Connection request not found'); } if (connection.status !== LinkStatus.PENDING) { throw new BadRequestException('Only pending connection requests can be responded to'); } const requesterOrgId = this.getRequesterOrganizationId(connection.sharedDataTypes); if (requesterOrgId && requesterOrgId === organizationId) { throw new ForbiddenException('You cannot respond to your own connection request'); } const nextStatus = dto.action === 'ACCEPT' ? LinkStatus.ACTIVE : LinkStatus.REJECTED; const updated = await this.prisma.organizationLink.update({ where: { id: connection.id }, data: { status: nextStatus }, }); return { success: true, data: { id: updated.id, status: updated.status }, message: nextStatus === LinkStatus.ACTIVE ? 'Connection request accepted' : 'Connection request declined', }; } async deleteConnection(userId: string, organizationId: string, connectionId: 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 connection = await this.prisma.organizationLink.findFirst({ where: { id: connectionId, status: LinkStatus.ACTIVE, OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }], }, select: { id: true }, }); if (!connection) { throw new NotFoundException('Connected organization not found'); } await this.prisma.organizationLink.delete({ where: { id: connection.id } }); return { success: true, data: { id: connection.id }, message: 'Connection removed', }; } /** Re-issue a shareable URL for a pending invitation (rotates token; previous URL stops working). */ async getInvitationLink(userId: string, organizationId: string, invitationId: 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 invitation = await this.prisma.organizationInvitation.findFirst({ where: { id: invitationId, inviterOrganizationId: organizationId, }, select: { id: true, acceptedAt: true, revokedAt: true, }, }); if (!invitation) { throw new NotFoundException('Invitation not found'); } if (invitation.acceptedAt) { throw new BadRequestException('This invitation has already been accepted'); } if (invitation.revokedAt) { throw new BadRequestException('This invitation is no longer valid'); } const plainToken = this.generateInviteToken(); const tokenHash = this.hashInviteToken(plainToken); await this.prisma.organizationInvitation.update({ where: { id: invitation.id }, data: { tokenHash, expiresAt: this.getInviteExpiryDate(), }, }); return { success: true, data: { invitationId: invitation.id, invitationUrl: this.buildInviteUrl(plainToken), }, }; } /** * Flow 2: invitation link when search finds no subscribed counterpart. * Always creates/updates PENDING OrganizationLink + OrganizationInvitation together. */ 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 invitation = await this.prisma.$transaction(async (tx) => { let owner = await tx.user.findUnique({ where: { email: ownerEmail } }); if (!owner) { owner = await tx.user.create({ data: { email: ownerEmail, name: dto.organizationName.trim(), passwordHash: null, }, }); } let invitedOrg = await tx.organization.findFirst({ where: { ownerId: owner.id, type: { name: invitedType }, }, select: { id: true }, orderBy: { createdAt: 'desc' }, }); if (!invitedOrg) { // Placeholder org until acceptInvite; real email is set on acceptance. invitedOrg = await tx.organization.create({ data: { name: dto.organizationName.trim(), email: `pending-${plainToken.slice(0, 12)}@dyolink.local`, owner: { connect: { id: owner.id } }, type: { connect: { name: invitedType } }, }, select: { id: true }, }); } const [aId, bId] = organizationId < invitedOrg.id ? [organizationId, invitedOrg.id] : [invitedOrg.id, organizationId]; const existingLink = await tx.organizationLink.findUnique({ where: { organizationAId_organizationBId: { organizationAId: aId, organizationBId: bId } }, }); if (existingLink?.status === LinkStatus.ACTIVE) { throw new ConflictException('These organizations are already linked'); } // Pre-create connection so inviter sees one pending row; acceptInvite() flips to ACTIVE. await tx.organizationLink.upsert({ where: { organizationAId_organizationBId: { organizationAId: aId, organizationBId: bId } }, update: { status: LinkStatus.PENDING, sharedDataTypes: [`requested_by:${organizationId}`], }, create: { organizationAId: aId, organizationBId: bId, status: LinkStatus.PENDING, sharedDataTypes: [`requested_by:${organizationId}`], }, }); return tx.organizationInvitation.create({ data: { inviterOrganizationId: organizationId, inviterUserId: userId, invitedOrganizationId: invitedOrg.id, invitedOrganizationName: dto.organizationName.trim(), invitedOwnerEmail: ownerEmail, invitedOrganizationType: invitedType, tokenHash, expiresAt: this.getInviteExpiryDate(), }, }); }); // Plain token is only available here and after getInvitationLink; UI may cache it in localStorage. return { success: true, data: { invitationId: invitation.id, invitationUrl: this.buildInviteUrl(plainToken), status: 'PENDING', }, }; } async previewInvite(token: string) { const invitation = await this.findValidInvitation(token); let organizationEmail = ''; if (invitation.invitedOrganizationId) { const invitedOrg = await this.prisma.organization.findUnique({ where: { id: invitation.invitedOrganizationId }, select: { email: true }, }); if (invitedOrg?.email && !invitedOrg.email.includes('@dyolink.local')) { organizationEmail = invitedOrg.email; } } return { success: true, data: { ownerEmail: invitation.invitedOwnerEmail, organizationName: invitation.invitedOrganizationName, organizationType: invitation.invitedOrganizationType, organizationEmail, inviterOrganizationName: invitation.inviterOrganization.name, expiresAt: invitation.expiresAt.toISOString(), status: invitation.acceptedAt ? 'ACCEPTED' : 'PENDING', }, }; } /** Public signup completion: activates trial org and the pre-created OrganizationLink. */ async acceptInvite(dto: AcceptOrganizationInviteDto) { const invitation = await this.findValidInvitation(dto.token); if (invitation.acceptedAt) { throw new BadRequestException('This invitation has already been accepted'); } if (dto.organizationType !== invitation.invitedOrganizationType) { throw new BadRequestException( `Organization type must be ${invitation.invitedOrganizationType} for this invitation`, ); } const organizationEmail = dto.organizationEmail.trim().toLowerCase(); 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: organizationEmail, owner: { connect: { id: owner.id } }, type: { connect: { name: dto.organizationType } }, plan: { connect: { name: 'trial' } }, }, }); } else { const createdOrg = await tx.organization.create({ data: { name: dto.organizationName.trim(), email: organizationEmail, owner: { connect: { id: owner.id } }, type: { connect: { name: dto.organizationType } }, 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]; // Same link row created at invite time; inviter never needs a separate connection request. 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 connection 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)}`; } /** Parses `requested_by:{orgId}` from OrganizationLink.sharedDataTypes while status is PENDING. */ private getRequesterOrganizationId(sharedDataTypes: unknown): string | null { if (!Array.isArray(sharedDataTypes)) return null; for (const v of sharedDataTypes) { if (typeof v !== 'string') continue; if (!v.startsWith('requested_by:')) continue; const id = v.slice('requested_by:'.length).trim(); if (id) return id; } 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; } }