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'; import { RespondLinkRequestDto } from './dto/respond-link-request.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] = 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' }, }), ]); const linkItems = [ ...linksA.map((l) => ({ requestedByOrganizationId: this.getRequesterOrganizationId(l.sharedDataTypes), id: l.id, counterpartOrganizationId: l.organizationB.id, organizationName: l.organizationB.name, ownerEmail: l.organizationB.email, phone: l.organizationB.phone, status: l.status, createdAt: l.createdAt.toISOString(), acceptedAt: l.status === LinkStatus.ACTIVE ? l.updatedAt.toISOString() : null, })), ...linksB.map((l) => ({ requestedByOrganizationId: this.getRequesterOrganizationId(l.sharedDataTypes), id: l.id, counterpartOrganizationId: l.organizationA.id, organizationName: l.organizationA.name, ownerEmail: l.organizationA.email, phone: l.organizationA.phone, status: l.status, createdAt: l.createdAt.toISOString(), acceptedAt: l.status === LinkStatus.ACTIVE ? l.updatedAt.toISOString() : null, })), ]; return { success: true, data: { items: linkItems.sort((a, b) => a.createdAt < b.createdAt ? 1 : -1, ), }, }; } 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, })), }, }; } 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: [`requested_by:${organizationId}`], }, }); return { success: true, data: { id: created.id, status: created.status }, message: 'Link request created', }; } async respondToLinkRequest( userId: string, organizationId: string, linkId: string, dto: RespondLinkRequestDto, ) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditOrganizations(actor)) { throw new ForbiddenException('You do not have permission to manage organizations'); } const link = await this.prisma.organizationLink.findFirst({ where: { id: linkId, OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }], }, }); if (!link) { throw new NotFoundException('Link request not found'); } if (link.status !== LinkStatus.PENDING) { throw new BadRequestException('Only pending link requests can be responded to'); } const requesterOrgId = this.getRequesterOrganizationId(link.sharedDataTypes); if (requesterOrgId && requesterOrgId === organizationId) { throw new ForbiddenException('You cannot respond to your own link request'); } const nextStatus = dto.action === 'ACCEPT' ? LinkStatus.ACTIVE : LinkStatus.REJECTED; const updated = await this.prisma.organizationLink.update({ where: { id: link.id }, data: { status: nextStatus }, }); return { success: true, data: { id: updated.id, status: updated.status }, message: nextStatus === LinkStatus.ACTIVE ? 'Link request accepted' : 'Link request rejected', }; } async deleteLink(userId: string, organizationId: string, linkId: 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 link = await this.prisma.organizationLink.findFirst({ where: { id: linkId, status: LinkStatus.ACTIVE, OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }], }, select: { id: true }, }); if (!link) { throw new NotFoundException('Linked organization not found'); } await this.prisma.organizationLink.delete({ where: { id: link.id } }); return { success: true, data: { id: link.id }, message: 'Linked organization removed', }; } 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 || invitation.revokedAt) { throw new BadRequestException('Only pending invitations can provide a link'); } 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), }, }; } 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) { 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'); } 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(), }, }); }); 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 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; } }