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/dto/respond-link-request.dto.ts b/backend/src/modules/organization/dto/respond-link-request.dto.ts new file mode 100644 index 0000000..02086c3 --- /dev/null +++ b/backend/src/modules/organization/dto/respond-link-request.dto.ts @@ -0,0 +1,6 @@ +import { IsIn } from 'class-validator'; + +export class RespondLinkRequestDto { + @IsIn(['ACCEPT', 'REJECT']) + action: 'ACCEPT' | 'REJECT'; +} diff --git a/backend/src/modules/organization/organization.controller.ts b/backend/src/modules/organization/organization.controller.ts new file mode 100644 index 0000000..6a3152f --- /dev/null +++ b/backend/src/modules/organization/organization.controller.ts @@ -0,0 +1,124 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + 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 { RespondLinkRequestDto } from './dto/respond-link-request.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); + } + + @Get('invitations') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'List invitation history for current organization' }) + listInvitations(@Req() req: { user: { id: string; organizationId?: string } }) { + const organizationId = this.organizationService.getOrganizationIdFromUser(req.user); + return this.organizationService.listInvitationHistory(req.user.id, organizationId); + } + + @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); + } + + @Patch('links/:linkId/respond') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Accept or reject a pending link request for current organization' }) + respondToLinkRequest( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('linkId') linkId: string, + @Body() dto: RespondLinkRequestDto, + ) { + const organizationId = this.organizationService.getOrganizationIdFromUser(req.user); + return this.organizationService.respondToLinkRequest(req.user.id, organizationId, linkId, dto); + } + + @Delete('links/:linkId') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Delete linked organization record' }) + deleteLink( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('linkId') linkId: string, + ) { + const organizationId = this.organizationService.getOrganizationIdFromUser(req.user); + return this.organizationService.deleteLink(req.user.id, organizationId, linkId); + } + + @Post('invitations/:invitationId/link') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Get a shareable invite link for a pending invitation' }) + getInvitationLink( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('invitationId') invitationId: string, + ) { + const organizationId = this.organizationService.getOrganizationIdFromUser(req.user); + return this.organizationService.getInvitationLink(req.user.id, organizationId, invitationId); + } + + @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..ce8d967 --- /dev/null +++ b/backend/src/modules/organization/organization.service.ts @@ -0,0 +1,630 @@ +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; + } +} diff --git a/frontend/src/app/(dashboard)/billing/page.tsx b/frontend/src/app/(dashboard)/billing/page.tsx index e92e5fe..6464010 100644 --- a/frontend/src/app/(dashboard)/billing/page.tsx +++ b/frontend/src/app/(dashboard)/billing/page.tsx @@ -1,10 +1,11 @@ // src/app/(dashboard)/billing/page.tsx 'use client'; import { useState } from 'react'; -import { Search, Filter, Plus } from 'lucide-react'; +import { Pencil } from 'lucide-react'; import { Button } from '@/components/ui/common/Button'; -import { Input } from '@/components/ui/common/Input'; import { Badge } from '@/components/ui/common/Badge'; +import { Table } from '@/components/ui/common/Table'; +import { SearchBar } from '@/components/ui/common/SearchBar'; import { useAuth } from '@/lib/hooks/useAuth'; import { hasPermission } from '@/shared/permissions'; // Mock data matching your design @@ -44,11 +45,9 @@ export default function BillingPage() {

Billing

@@ -80,17 +79,12 @@ export default function BillingPage() { /> {/* Filters */} -
-
-
- setSearch(e.target.value)} - icon={} - /> -
-
+ {['all', 'paid', 'unpaid', 'overdue'].map((status) => (
-
-
+ + )} + /> {/* Invoices Table - Matching your design */} -
- - - +
@@ -129,36 +122,37 @@ export default function BillingPage() { - - - + } + body={ + <> {invoices.map((invoice) => ( - - + - - - - - - - - + ))} - -
Invoice ID Paid + Status Action
+
{invoice.id} + {invoice.patient} + {invoice.date} + {invoice.service} + ${invoice.amount} + ${invoice.paid} + +
- {/* Pagination - Matching your design */} -
+ + } + footer={( + <> @@ -190,8 +187,9 @@ export default function BillingPage() { -
-
+ + )} + /> ); } 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..0d8390a --- /dev/null +++ b/frontend/src/app/(dashboard)/organizations/page.tsx @@ -0,0 +1,602 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Check, Copy, Link2, Trash2, X } from 'lucide-react'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { + organizationApi, + type CounterpartItemDto, + type CounterpartSearchResultDto, + type OrganizationInvitationHistoryItemDto, +} from '@/lib/api/organization'; +import { Button } from '@/components/ui/common/Button'; +import { Badge, organizationLinkStatusVariant } from '@/components/ui/common/Badge'; +import { Input } from '@/components/ui/common/Input'; +import { SearchBar } from '@/components/ui/common/SearchBar'; +import { Table } from '@/components/ui/common/Table'; +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 formatOrganizationStatusLabel(status: string): string { + if (!status) return status; + const lower = status.toLowerCase(); + return lower.charAt(0).toUpperCase() + lower.slice(1); +} + +function formatLinkStatusLabel(status: CounterpartItemDto['status']): string { + if (status === 'PENDING') return 'Link request pending'; + if (status === 'ACTIVE') return 'Linked'; + if (status === 'REJECTED') return 'Link request rejected'; + return formatOrganizationStatusLabel(status); +} + +function formatInvitationStatusLabel(status: OrganizationInvitationHistoryItemDto['status']): string { + if (status === 'PENDING') return 'Invitation pending'; + if (status === 'ACTIVE') return 'Invitation Accepted'; + if (status === 'REJECTED') return 'Invitation rejected'; + return formatOrganizationStatusLabel(status); +} + +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'; +} + +function formatTableDate(value: string): string { + const d = new Date(value); + if (Number.isNaN(d.getTime())) return 'β€”'; + return d.toLocaleDateString(); +} + +type TableMode = 'existing' | 'search'; + +export default function OrganizationsPage() { + const { currentOrganization } = useAuth(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const [query, setQuery] = useState(''); + const [mode, setMode] = useState('existing'); + const [searching, setSearching] = useState(false); + const [searchResults, setSearchResults] = useState([]); + const [requestLinkRowId, setRequestLinkRowId] = useState(null); + const [deleteLinkRowId, setDeleteLinkRowId] = useState(null); + + const [items, setItems] = useState([]); + const [manualOrganizationName, setManualOrganizationName] = useState(''); + const [manualOwnerEmail, setManualOwnerEmail] = useState(''); + const [inviteLoading, setInviteLoading] = useState(false); + const [copiedId, setCopiedId] = useState(null); + const [pendingInviteLinks, setPendingInviteLinks] = useState>({}); + const [showInviteForm, setShowInviteForm] = useState(false); + const [historyOpen, setHistoryOpen] = useState(false); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyItems, setHistoryItems] = useState([]); + + const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinic' : 'Lab'; + const tabLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs'; + + const existingRows = items; + + 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() { + const q = query.trim(); + if (!q) { + setMode('existing'); + setSearchResults([]); + setShowInviteForm(false); + return; + } + + setSearching(true); + setError(''); + setMode('search'); + setShowInviteForm(false); + try { + const res = await organizationApi.search(q); + setSearchResults(res.data); + } catch (e) { + setError(formatApiMessage(e)); + setSearchResults([]); + } finally { + setSearching(false); + } + } + + async function submitRequestLink(targetOrganizationId: string) { + setRequestLinkRowId(targetOrganizationId); + setError(''); + try { + await organizationApi.createLink(targetOrganizationId); + setSuccess(`${counterpartLabel} link request sent`); + setSearchResults([]); + setQuery(''); + setMode('existing'); + await loadList(); + } catch (e) { + setError(formatApiMessage(e)); + } finally { + setRequestLinkRowId(null); + } + } + + async function sendInvite() { + setInviteLoading(true); + setError(''); + try { + const res = await organizationApi.invite({ + organizationName: manualOrganizationName.trim(), + ownerEmail: manualOwnerEmail.trim(), + }); + 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(''); + setShowInviteForm(false); + setMode('existing'); + setQuery(''); + setSearchResults([]); + await loadList(); + } catch (e) { + setError(formatApiMessage(e)); + } finally { + setInviteLoading(false); + } + } + + async function openInvitationHistory() { + setHistoryOpen(true); + setHistoryLoading(true); + setError(''); + try { + const res = await organizationApi.listInvitations(); + setHistoryItems(res.data.items); + } catch (e) { + setError(formatApiMessage(e)); + } finally { + setHistoryLoading(false); + } + } + + async function respondToPendingLink(linkId: string, action: 'ACCEPT' | 'REJECT') { + setRequestLinkRowId(linkId); + setError(''); + try { + await organizationApi.respondLink(linkId, action); + setSuccess(action === 'ACCEPT' ? 'Link request accepted' : 'Link request rejected'); + await loadList(); + } catch (e) { + setError(formatApiMessage(e)); + } finally { + setRequestLinkRowId(null); + } + } + + async function deleteLinkedOrganization(linkId: string) { + setDeleteLinkRowId(linkId); + setError(''); + try { + await organizationApi.deleteLink(linkId); + setSuccess('Linked organization removed'); + await loadList(); + } catch (e) { + setError(formatApiMessage(e)); + } finally { + setDeleteLinkRowId(null); + } + } + + async function copyInvitationLink(invitationId: string) { + setError(''); + try { + let invitationUrl = pendingInviteLinks[invitationId]?.invitationUrl; + if (!invitationUrl) { + const res = await organizationApi.getInvitationLink(invitationId); + invitationUrl = res.data.invitationUrl; + if (currentOrganization?.id) { + const nextLinks = { + ...pendingInviteLinks, + [invitationId]: { + invitationId, + ownerEmail: + historyItems.find((item) => item.id === invitationId)?.ownerEmail?.toLowerCase() ?? '', + invitationUrl, + }, + }; + setPendingInviteLinks(nextLinks); + writeStoredInviteLinks(currentOrganization.id, nextLinks); + } + } + await navigator.clipboard.writeText(invitationUrl); + setCopiedId(invitationId); + setTimeout(() => setCopiedId(null), 1500); + } catch { + setError('Could not copy invitation link'); + } + } + + function clearSearchView() { + setMode('existing'); + setQuery(''); + setSearchResults([]); + setShowInviteForm(false); + } + + if (!currentOrganization) { + return

Loading organization...

; + } + + return ( +
+
+
+

{tabLabel}

+

+ Search organizations and send link requests or invitation links in one place. +

+
+ +
+ + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + + void runSearch()} + placeholder={`Search ${counterpartLabel.toLowerCase()} by name, email, or phone...`} + actions={ + <> + + {mode === 'search' && ( + + )} + + } + /> + + + + + + + + + } + body={ + <> + {loading ? ( + + + + ) : mode === 'existing' ? ( + existingRows.length === 0 ? ( + + + + ) : ( + existingRows.map((row) => { + const canRespond = + row.status === 'PENDING' && + row.requestedByOrganizationId !== null && + row.requestedByOrganizationId !== currentOrganization.id; + + return ( + + + + + + + + ); + }) + ) + ) : searchResults.length > 0 ? ( + searchResults.map((r) => ( + + + + + + + + )) + ) : ( + + + + )} + + } + /> + + {historyOpen && ( +
+
+
+

Invitation History

+ +
+ + {historyLoading ? ( +

Loading invitation history...

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

No invitations yet.

+ ) : ( +
+ Organization + + Owner email + + Date + + Status + + Action +
+ Loading... +
+ No organizations linked or pending yet. Use search to find and connect. +
+ {row.organizationName} + {row.ownerEmail} + {formatTableDate(row.createdAt)} + + + {formatLinkStatusLabel(row.status)} + + +
+ {canRespond && ( + <> + + + + )} + {row.status === 'ACTIVE' && ( + + )} +
+
{r.name}{r.owner.email}Today + Found + + +
+
+

+ No organization found in directory search. +

+
+ +
+ {showInviteForm && ( +
+ setManualOrganizationName(e.target.value)} + /> + setManualOwnerEmail(e.target.value)} + /> +
+ +
+
+ )} +
+
+ + + + + + + } + body={ + <> + {historyItems.map((inv) => ( + + + + + + + + ))} + + } + /> + )} + + + )} + + ); +} diff --git a/frontend/src/app/(dashboard)/patients/page.tsx b/frontend/src/app/(dashboard)/patients/page.tsx index 6a3aba9..4095d41 100644 --- a/frontend/src/app/(dashboard)/patients/page.tsx +++ b/frontend/src/app/(dashboard)/patients/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; -import { Plus } from 'lucide-react'; import { Button } from '@/components/ui/common/Button'; import { patientsApi } from '@/lib/api/patients'; import { useAuth } from '@/lib/hooks/useAuth'; @@ -160,7 +159,6 @@ export default function PatientsPage() {

Patients

diff --git a/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx b/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx index edc45aa..e5ab6b1 100644 --- a/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx +++ b/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; import { authApi } from '@/lib/api/auth'; +import { Button } from '@/components/ui/common/Button'; import type { SubscriptionAlertData } from '@/types/subscription'; const PLAN_OPTIONS = [ @@ -176,9 +177,9 @@ export default function SubscriptionsSettingsPage() { ); })} - + {purchaseNotice && (

{purchaseNotice}

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/page.tsx b/frontend/src/app/(dashboard)/staff/page.tsx index e7c7a6d..ea9e9e9 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/(dashboard)/staff/page.tsx @@ -16,12 +16,14 @@ import { formatAccessSummary, type FeaturePermState, } from './staff-permission-form'; -import { UserPlus, Pencil, Trash2, Copy, Check, X, Clock3 } from 'lucide-react'; +import { Pencil, Trash2, Copy, Check, X } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; import { staffApi, type StaffMemberDto } from '@/lib/api/staff'; import { Button } from '@/components/ui/common/Button'; +import { Badge } from '@/components/ui/common/Badge'; import { Input } from '@/components/ui/common/Input'; import { Checkbox } from '@/components/ui/common/Checkbox'; +import { Table } from '@/components/ui/common/Table'; import type { ApiError } from '@/types/api'; type StoredInviteLink = { @@ -335,7 +337,6 @@ export default function StaffPage() { className="shrink-0" title={!canEdit ? 'Read-only access for this organization.' : undefined} > - Invite member
@@ -410,14 +411,7 @@ export default function StaffPage() { } }} > - {copiedInviteMembershipId === lastInviteInfo.membershipId ? ( - - ) : ( - - )} - - {copiedInviteMembershipId === lastInviteInfo.membershipId ? 'Copied' : 'Copy link'} - + {copiedInviteMembershipId === lastInviteInfo.membershipId ? 'Copied' : 'Copy link'}

@@ -431,47 +425,40 @@ export default function StaffPage() { {loading ? (

Loading team…

) : ( -
-
+ Organization + + Owner email + + Date + + Status + + Action +
{inv.organizationName}{inv.ownerEmail} + {formatTableDate(inv.createdAt)} + + + {formatInvitationStatusLabel(inv.status)} + + + {inv.status === 'PENDING' ? ( + + ) : ( + β€” + )} +
- - - - - - - - - - - +
NameEmailRoleStatusAccessActions
+ + + + + + + + } + body={ + <> {members.map((m) => ( - - - - + + + - - - ))} - -
NameEmailRoleStatusAccessActions
{m.name}{m.email} +
{m.name}{m.email} {m.isOwner ? ( Owner ) : ( Staff )} + {m.isOwner || m.invitationStatus === 'ACTIVE' ? ( - - Active - + Active ) : m.invitationStatus === 'PENDING' ? ( - - - Pending - + Pending ) : ( - - Expired - + Expired )} + {m.isOwner ? ( All features ) : ( @@ -480,7 +467,7 @@ export default function StaffPage() { )} + {!m.isOwner && (
{m.invitationStatus === 'PENDING' && pendingInviteLinks[m.id]?.invitationUrl && ( @@ -543,9 +530,9 @@ export default function StaffPage() {
-
+ + } + /> )} {inviteOpen && ( 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-invite/page.tsx b/frontend/src/app/(public)/accept-invite/page.tsx index 7f2553f..bfb3b3c 100644 --- a/frontend/src/app/(public)/accept-invite/page.tsx +++ b/frontend/src/app/(public)/accept-invite/page.tsx @@ -78,7 +78,7 @@ function AcceptInviteContent() { name: name.trim(), password, }); - setSuccess('Invitation accepted. Redirecting to login...'); + setSuccess('Invitation Accepted. Redirecting to login...'); setTimeout(() => { router.replace('/login'); }, 1000); 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..5c4af67 --- /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/Badge.tsx b/frontend/src/components/ui/common/Badge.tsx index 9c5b844..afc8f5f 100644 --- a/frontend/src/components/ui/common/Badge.tsx +++ b/frontend/src/components/ui/common/Badge.tsx @@ -1,31 +1,59 @@ -//src/components/ui/Badge.tsx import React from 'react'; -type BadgeVariant = 'success' | 'warning' | 'danger' | 'default'; +export type BadgeVariant = 'success' | 'warning' | 'danger' | 'default'; interface BadgeProps { - children: React.ReactNode; - variant?: BadgeVariant; - className?: string; + children: React.ReactNode; + variant?: BadgeVariant; + className?: string; + /** + * Same pixel width for every badge (table columns). + * Set false only when the pill should shrink to the label. + */ + fixedWidth?: boolean; } const variantStyles: Record = { - success: 'bg-emerald-900/30 text-emerald-300 border-emerald-700/60', - warning: 'bg-amber-900/30 text-amber-300 border-amber-700/60', - danger: 'bg-red-950/30 text-red-300 border-red-700/60', - default: 'bg-background-secondary text-text-secondary border-border', + success: 'bg-emerald-900/30 text-emerald-300 border-emerald-700/60', + warning: 'bg-amber-900/30 text-amber-300 border-amber-700/60', + danger: 'bg-red-950/30 text-red-300 border-red-700/60', + default: 'bg-background-secondary text-text-secondary border-border', }; +/** Explicit width + height so every row matches; flex centers label optically. */ +const FIXED_LAYOUT_CLASS = + 'w-[8rem] min-w-[8rem] max-w-[8rem] shrink-0 h-7 px-2 py-0'; + export function Badge({ - children, - variant = 'default', - className, + children, + variant = 'default', + className, + fixedWidth = true, }: BadgeProps) { - return ( - - {children} - - ); -} \ No newline at end of file + const layoutClass = fixedWidth + ? `${FIXED_LAYOUT_CLASS} justify-center text-center` + : 'min-h-[1.75rem] px-2.5 py-1 justify-center'; + + return ( + + {children} + + ); +} + +/** Map organization link / invitation row status to badge variant. */ +export function organizationLinkStatusVariant(status: string): BadgeVariant { + switch (status) { + case 'ACTIVE': + return 'success'; + case 'PENDING': + return 'warning'; + case 'REJECTED': + case 'EXPIRED': + return 'danger'; + default: + return 'default'; + } +} diff --git a/frontend/src/components/ui/common/Button.tsx b/frontend/src/components/ui/common/Button.tsx index 1794b75..71e1384 100644 --- a/frontend/src/components/ui/common/Button.tsx +++ b/frontend/src/components/ui/common/Button.tsx @@ -1,6 +1,4 @@ -// src/components/ui/Button.tsx import React from 'react'; -import { Loader2 } from 'lucide-react'; type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost'; type ButtonSize = 'sm' | 'md' | 'lg'; @@ -25,23 +23,22 @@ export const Button: React.FC = ({ }) => { const baseClasses = 'inline-flex items-center justify-center rounded-[var(--radius-md)] font-medium transition-all duration-200 ' + - 'focus:outline-none focus:ring-2 focus:ring-primary/40 disabled:opacity-50 disabled:cursor-not-allowed'; + 'focus:outline-none focus:ring-2 focus:ring-primary/40 disabled:cursor-not-allowed'; const variantClasses: Record = { primary: - 'bg-primary text-primary-contrast hover:brightness-105 shadow-[0_0_0_1px_var(--color-primary-soft)]', + 'bg-primary text-white hover:opacity-90 disabled:opacity-60', secondary: - 'bg-surface-elevated text-text-primary border border-border hover:border-border-strong', + 'bg-surface-elevated text-text-primary border border-border hover:border-border-strong disabled:opacity-50', outline: - 'border border-border text-text-primary hover:bg-background-card/70', + 'border border-border text-text-primary hover:bg-background-card/70 disabled:opacity-50', - danger: - 'bg-red-600 text-white hover:bg-red-700', + danger: 'bg-red-600 text-white hover:bg-red-700 disabled:opacity-50', ghost: - 'text-text-secondary hover:text-text-primary hover:bg-background-card/70', + 'text-text-secondary hover:text-text-primary hover:bg-background-card/70 disabled:opacity-50', }; const sizeClasses: Record = { @@ -51,17 +48,16 @@ export const Button: React.FC = ({ }; const widthClass = fullWidth ? 'w-full' : ''; + const loadingClass = isLoading ? 'opacity-70 animate-pulse pointer-events-none' : ''; return ( ); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/ui/common/SearchBar.tsx b/frontend/src/components/ui/common/SearchBar.tsx new file mode 100644 index 0000000..c93b8eb --- /dev/null +++ b/frontend/src/components/ui/common/SearchBar.tsx @@ -0,0 +1,38 @@ +import { Search } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { Input } from './Input'; + +interface SearchBarProps { + value: string; + onChange: (value: string) => void; + placeholder: string; + onSubmit?: () => void; + actions?: ReactNode; +} + +export function SearchBar({ + value, + onChange, + placeholder, + onSubmit, + actions, +}: SearchBarProps) { + return ( +
+
+
+ onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') onSubmit?.(); + }} + icon={} + /> +
+ {actions &&
{actions}
} +
+
+ ); +} 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/components/ui/common/Table.tsx b/frontend/src/components/ui/common/Table.tsx new file mode 100644 index 0000000..656b1fe --- /dev/null +++ b/frontend/src/components/ui/common/Table.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from 'react'; + +interface TableProps { + headers: ReactNode; + body: ReactNode; + footer?: ReactNode; +} + +export function Table({ headers, body, footer }: TableProps) { + return ( +
+ + + {headers} + + + {body} + +
+ {footer && ( +
+ {footer} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ui/organization/OrganizationSelectorContent.tsx b/frontend/src/components/ui/organization/OrganizationSelectorContent.tsx index 29beead..d997d71 100644 --- a/frontend/src/components/ui/organization/OrganizationSelectorContent.tsx +++ b/frontend/src/components/ui/organization/OrganizationSelectorContent.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useAuth } from '@/lib/hooks/useAuth'; -import { Building2, Beaker, Mail, Plus } from 'lucide-react'; +import { Building2, Beaker, Mail } from 'lucide-react'; import { Input } from '@/components/ui/common/Input'; import { Button } from '@/components/ui/common/Button'; @@ -55,7 +55,6 @@ export function OrganizationSelectorContent() { setIsCreateOpen((prev) => !prev); }} > - {isCreateOpen ? 'Cancel' : 'Create Organization'} diff --git a/frontend/src/lib/api/organization.ts b/frontend/src/lib/api/organization.ts new file mode 100644 index 0000000..bd65b4b --- /dev/null +++ b/frontend/src/lib/api/organization.ts @@ -0,0 +1,115 @@ +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; + requestedByOrganizationId: string | null; + counterpartOrganizationId: string | null; + organizationName: string; + ownerEmail: string; + phone: string | null; + status: 'PENDING' | 'ACTIVE' | 'REJECTED' | 'EXPIRED'; + createdAt: string; + acceptedAt: string | null; +} + +export interface OrganizationInvitationHistoryItemDto { + id: string; + organizationName: string; + ownerEmail: string; + status: 'PENDING' | 'ACTIVE' | 'REJECTED' | 'EXPIRED'; + 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; + }, + + listInvitations: async (): Promise<{ + success: boolean; + data: { items: OrganizationInvitationHistoryItemDto[] }; + }> => { + const response = await apiClient.get('/organizations/invitations'); + 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; + }, + + respondLink: async ( + linkId: string, + action: 'ACCEPT' | 'REJECT', + ): Promise<{ success: boolean; data: { id: string; status: 'PENDING' | 'ACTIVE' | 'REJECTED' } }> => { + const response = await apiClient.patch(`/organizations/links/${linkId}/respond`, { action }); + return response.data; + }, + + deleteLink: async (linkId: string): Promise<{ success: boolean; data: { id: string }; message: string }> => { + const response = await apiClient.delete(`/organizations/links/${linkId}`); + 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; + }, + + getInvitationLink: async ( + invitationId: string, + ): Promise<{ success: boolean; data: { invitationId: string; invitationUrl: string } }> => { + const response = await apiClient.post(`/organizations/invitations/${invitationId}/link`); + 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' },