diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 3d74145..7882769 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -217,6 +217,17 @@ model Feature { @@map("features") } +/// Bidirectional clinic↔lab relationship. One row per unordered pair (A id < B id). +/// +/// Two product flows share this table: +/// 1. **Connection request** — inviter found an existing subscribed org in search; row is PENDING +/// until the counterpart accepts. No OrganizationInvitation row. +/// 2. **Invitation link** — inviter could not find the org; inviteOrganization() creates a +/// placeholder org, an OrganizationInvitation (signup token), and a PENDING link here so the +/// inviter does not need a second request after signup. acceptInvite() sets the link to ACTIVE. +/// +/// `sharedDataTypes` stores metadata (not shared clinical data yet). While PENDING, entries like +/// `requested_by:{orgId}` record who initiated the request (see OrganizationService). model OrganizationLink { id String @id @default(uuid()) @@ -235,6 +246,16 @@ model OrganizationLink { @@map("organization_links") } +/// Signup invite for a counterpart org that is not on DyoLink yet (or has no active subscription). +/// Complements OrganizationLink: invite flow always creates both records in one transaction. +/// +/// Only the token *hash* is stored; the plain token is returned once on create/regenerate and may +/// be cached in the browser (see frontend useOrganizationInviteLinkCopy). Regenerating rotates +/// tokenHash and expiresAt on the same invitation row. +/// +/// `invitedOrganizationId` points at a placeholder Organization (pending-* email) until accept; +/// list() joins open invitations to links so the UI can offer "copy invitation link" on the +/// pending connection row (pendingInvitationId on the API response). model OrganizationInvitation { id String @id @default(uuid()) @@ -278,6 +299,8 @@ model Session { @@map("sessions") } +/// OrganizationLink lifecycle. Invitation rows use overlapping semantics in API mappers +/// (e.g. accepted invitation → ACTIVE in listInvitationHistory). enum LinkStatus { PENDING ACTIVE diff --git a/backend/src/modules/organization/dto/create-connection-request.dto.ts b/backend/src/modules/organization/dto/create-connection-request.dto.ts new file mode 100644 index 0000000..a65b7b9 --- /dev/null +++ b/backend/src/modules/organization/dto/create-connection-request.dto.ts @@ -0,0 +1,7 @@ +import { IsUUID } from 'class-validator'; + +/** Existing subscribed counterpart org (search result). Does not create an OrganizationInvitation. */ +export class CreateConnectionRequestDto { + @IsUUID() + targetOrganizationId: 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 deleted file mode 100644 index 40a0132..0000000 --- a/backend/src/modules/organization/dto/create-link-request.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 index 469a529..706ff96 100644 --- a/backend/src/modules/organization/dto/invite-organization.dto.ts +++ b/backend/src/modules/organization/dto/invite-organization.dto.ts @@ -1,5 +1,6 @@ import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator'; +/** Starts invitation-link flow: creates OrganizationInvitation + PENDING OrganizationLink. */ export class InviteOrganizationDto { @IsString() @MinLength(1) diff --git a/backend/src/modules/organization/dto/respond-connection-request.dto.ts b/backend/src/modules/organization/dto/respond-connection-request.dto.ts new file mode 100644 index 0000000..e9f6253 --- /dev/null +++ b/backend/src/modules/organization/dto/respond-connection-request.dto.ts @@ -0,0 +1,7 @@ +import { IsIn } from 'class-validator'; + +/** Counterpart org accepts or declines an incoming OrganizationLink (connection request). */ +export class RespondConnectionRequestDto { + @IsIn(['ACCEPT', 'REJECT']) + action: 'ACCEPT' | 'REJECT'; +} diff --git a/backend/src/modules/organization/dto/respond-link-request.dto.ts b/backend/src/modules/organization/dto/respond-link-request.dto.ts deleted file mode 100644 index 02086c3..0000000 --- a/backend/src/modules/organization/dto/respond-link-request.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 index 6a3152f..7e56c60 100644 --- a/backend/src/modules/organization/organization.controller.ts +++ b/backend/src/modules/organization/organization.controller.ts @@ -13,12 +13,18 @@ import { 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 { CreateConnectionRequestDto } from './dto/create-connection-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 { RespondConnectionRequestDto } from './dto/respond-connection-request.dto'; import { OrganizationService } from './organization.service'; +/** + * Counterpart orgs (clinic↔lab). + * + * - `/connections` — OrganizationLink rows (connection requests + links created by invites). + * - `/invite`, `/invitations/*` — signup invitation tokens (orgs not yet on DyoLink). + */ @ApiTags('organizations') @ApiBearerAuth('JWT-auth') @Controller('organizations') @@ -58,46 +64,53 @@ export class OrganizationController { return this.organizationService.searchCounterpartOrganizations(req.user.id, organizationId, q); } - @Get('links') + @Get('connections') @UseGuards(JwtAuthGuard) - @ApiOperation({ summary: 'List counterpart links and invitations for current org' }) - list(@Req() req: { user: { id: string; organizationId?: string } }) { + @ApiOperation({ summary: 'List counterpart connections for current organization' }) + listConnections(@Req() req: { user: { id: string; organizationId?: string } }) { const organizationId = this.organizationService.getOrganizationIdFromUser(req.user); return this.organizationService.list(req.user.id, organizationId); } - @Post('links') + @Post('connections') @UseGuards(JwtAuthGuard) - @ApiOperation({ summary: 'Create pending link request to an existing subscribed counterpart org' }) - createLinkRequest( + @ApiOperation({ + summary: 'Create pending connection request to an existing subscribed counterpart org', + }) + createConnectionRequest( @Req() req: { user: { id: string; organizationId?: string } }, - @Body() dto: CreateLinkRequestDto, + @Body() dto: CreateConnectionRequestDto, ) { const organizationId = this.organizationService.getOrganizationIdFromUser(req.user); - return this.organizationService.createLinkRequest(req.user.id, organizationId, dto); + return this.organizationService.createConnectionRequest(req.user.id, organizationId, dto); } - @Patch('links/:linkId/respond') + @Patch('connections/:connectionId/respond') @UseGuards(JwtAuthGuard) - @ApiOperation({ summary: 'Accept or reject a pending link request for current organization' }) - respondToLinkRequest( + @ApiOperation({ summary: 'Accept or reject a pending connection request for current organization' }) + respondToConnectionRequest( @Req() req: { user: { id: string; organizationId?: string } }, - @Param('linkId') linkId: string, - @Body() dto: RespondLinkRequestDto, + @Param('connectionId') connectionId: string, + @Body() dto: RespondConnectionRequestDto, ) { const organizationId = this.organizationService.getOrganizationIdFromUser(req.user); - return this.organizationService.respondToLinkRequest(req.user.id, organizationId, linkId, dto); + return this.organizationService.respondToConnectionRequest( + req.user.id, + organizationId, + connectionId, + dto, + ); } - @Delete('links/:linkId') + @Delete('connections/:connectionId') @UseGuards(JwtAuthGuard) - @ApiOperation({ summary: 'Delete linked organization record' }) - deleteLink( + @ApiOperation({ summary: 'Remove an active connection' }) + deleteConnection( @Req() req: { user: { id: string; organizationId?: string } }, - @Param('linkId') linkId: string, + @Param('connectionId') connectionId: string, ) { const organizationId = this.organizationService.getOrganizationIdFromUser(req.user); - return this.organizationService.deleteLink(req.user.id, organizationId, linkId); + return this.organizationService.deleteConnection(req.user.id, organizationId, connectionId); } @Post('invitations/:invitationId/link') diff --git a/backend/src/modules/organization/organization.service.ts b/backend/src/modules/organization/organization.service.ts index 2b53c15..5198d77 100644 --- a/backend/src/modules/organization/organization.service.ts +++ b/backend/src/modules/organization/organization.service.ts @@ -10,10 +10,22 @@ 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 { CreateConnectionRequestDto } from './dto/create-connection-request.dto'; import { InviteOrganizationDto } from './dto/invite-organization.dto'; -import { RespondLinkRequestDto } from './dto/respond-link-request.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) {} @@ -62,13 +74,16 @@ export class OrganizationService { 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'); } - const [linksA, linksB] = await Promise.all([ + // 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: { @@ -83,31 +98,56 @@ export class OrganizationService { }, 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) => ({ - 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, - })), + ...linksA.map((l) => mapLinkItem(l, l.organizationB)), + ...linksB.map((l) => mapLinkItem(l, l.organizationA)), ]; return { @@ -146,7 +186,12 @@ export class OrganizationService { }; } - async createLinkRequest(userId: string, organizationId: string, dto: CreateLinkRequestDto) { + /** 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'); @@ -188,6 +233,7 @@ export class OrganizationService { organizationAId: aId, organizationBId: bId, status: LinkStatus.PENDING, + // Who initiated; counterpart uses this to block self-accept (see respondToConnectionRequest). sharedDataTypes: [`requested_by:${organizationId}`], }, }); @@ -195,79 +241,83 @@ export class OrganizationService { return { success: true, data: { id: created.id, status: created.status }, - message: 'Link request created', + message: 'Connection request created', }; } - async respondToLinkRequest( + async respondToConnectionRequest( userId: string, organizationId: string, - linkId: string, - dto: RespondLinkRequestDto, + 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 link = await this.prisma.organizationLink.findFirst({ + const connection = await this.prisma.organizationLink.findFirst({ where: { - id: linkId, + id: connectionId, OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }], }, }); - if (!link) { - throw new NotFoundException('Link request not found'); + if (!connection) { + throw new NotFoundException('Connection request not found'); } - if (link.status !== LinkStatus.PENDING) { - throw new BadRequestException('Only pending link requests can be responded to'); + if (connection.status !== LinkStatus.PENDING) { + throw new BadRequestException('Only pending connection requests can be responded to'); } - const requesterOrgId = this.getRequesterOrganizationId(link.sharedDataTypes); + const requesterOrgId = this.getRequesterOrganizationId(connection.sharedDataTypes); if (requesterOrgId && requesterOrgId === organizationId) { - throw new ForbiddenException('You cannot respond to your own link request'); + 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: link.id }, + where: { id: connection.id }, data: { status: nextStatus }, }); return { success: true, data: { id: updated.id, status: updated.status }, - message: nextStatus === LinkStatus.ACTIVE ? 'Link request accepted' : 'Link request rejected', + message: + nextStatus === LinkStatus.ACTIVE + ? 'Connection request accepted' + : 'Connection request declined', }; } - async deleteLink(userId: string, organizationId: string, linkId: string) { + 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 link = await this.prisma.organizationLink.findFirst({ + const connection = await this.prisma.organizationLink.findFirst({ where: { - id: linkId, + id: connectionId, status: LinkStatus.ACTIVE, OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }], }, select: { id: true }, }); - if (!link) { - throw new NotFoundException('Linked organization not found'); + if (!connection) { + throw new NotFoundException('Connected organization not found'); } - await this.prisma.organizationLink.delete({ where: { id: link.id } }); + await this.prisma.organizationLink.delete({ where: { id: connection.id } }); return { success: true, - data: { id: link.id }, - message: 'Linked organization removed', + 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)) { @@ -314,6 +364,10 @@ export class OrganizationService { }; } + /** + * 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)) { @@ -361,6 +415,7 @@ export class OrganizationService { }); if (!invitedOrg) { + // Placeholder org until acceptInvite; real email is set on acceptance. invitedOrg = await tx.organization.create({ data: { name: dto.organizationName.trim(), @@ -384,6 +439,7 @@ export class OrganizationService { 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: { @@ -412,6 +468,7 @@ export class OrganizationService { }); }); + // Plain token is only available here and after getInvitationLink; UI may cache it in localStorage. return { success: true, data: { @@ -448,6 +505,7 @@ export class OrganizationService { }; } + /** 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) { @@ -528,6 +586,7 @@ export class OrganizationService { ? [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 }, @@ -554,7 +613,7 @@ export class OrganizationService { return { success: true, data: { organizationId: organization }, - message: 'Invitation accepted. Organization trial has started and link is active.', + message: 'Invitation accepted. Organization trial has started and connection is active.', }; } @@ -616,11 +675,7 @@ export class OrganizationService { 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; - } - + /** 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) { diff --git a/frontend/src/app/(dashboard)/organizations/page.tsx b/frontend/src/app/(dashboard)/organizations/page.tsx index 0e5b2f0..0ce5451 100644 --- a/frontend/src/app/(dashboard)/organizations/page.tsx +++ b/frontend/src/app/(dashboard)/organizations/page.tsx @@ -1,7 +1,7 @@ -'use client'; +'use client'; import { useEffect, useState } from 'react'; -import { Check, Link2, Trash2, X } from 'lucide-react'; +import { Check, Trash2, UserPlus, X } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; import { useOrganizationInviteLinkCopy } from '@/lib/hooks/useOrganizationInviteLinkCopy'; import { @@ -10,9 +10,11 @@ import { type CounterpartSearchResultDto, type OrganizationInvitationHistoryItemDto, } from '@/lib/api/organization'; +import { invitationTargetFromConnectionRow } from '@/components/invitations/organizationInviteLinks'; +import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton'; import { InvitationHistoryDialog } from '@/components/ui/organizations/InvitationHistoryDialog'; import { Button } from '@/components/ui/common/Button'; -import { Badge, organizationLinkStatusVariant } from '@/components/ui/common/Badge'; +import { Badge, organizationConnectionStatusVariant } 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'; @@ -24,11 +26,22 @@ function formatOrganizationStatusLabel(status: string): string { 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 formatConnectionStatusLabel( + row: CounterpartItemDto, + currentOrganizationId: string, +): string { + if (row.status === 'PENDING') { + if ( + row.pendingInvitationId && + row.requestedByOrganizationId === currentOrganizationId + ) { + return 'Invitation pending'; + } + return 'Connection request pending'; + } + if (row.status === 'ACTIVE') return 'Connected'; + if (row.status === 'REJECTED') return 'Connection request declined'; + return formatOrganizationStatusLabel(row.status); } function formatApiMessage(err: unknown): string { @@ -41,7 +54,7 @@ function formatApiMessage(err: unknown): string { function formatTableDate(value: string): string { const d = new Date(value); - if (Number.isNaN(d.getTime())) return '—'; + if (Number.isNaN(d.getTime())) return '\u2014'; return d.toLocaleDateString(); } @@ -57,8 +70,8 @@ export default function OrganizationsPage() { 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 [pendingConnectionRowId, setPendingConnectionRowId] = useState(null); + const [deleteConnectionRowId, setDeleteConnectionRowId] = useState(null); const [items, setItems] = useState([]); const [manualOrganizationName, setManualOrganizationName] = useState(''); @@ -131,12 +144,12 @@ export default function OrganizationsPage() { } } - async function submitRequestLink(targetOrganizationId: string) { - setRequestLinkRowId(targetOrganizationId); + async function submitConnectionRequest(targetOrganizationId: string) { + setPendingConnectionRowId(targetOrganizationId); setError(''); try { - await organizationApi.createLink(targetOrganizationId); - setSuccess(`${counterpartLabel} link request sent`); + await organizationApi.createConnectionRequest(targetOrganizationId); + setSuccess(`${counterpartLabel} connection request sent.`); setSearchResults([]); setQuery(''); setMode('existing'); @@ -144,7 +157,7 @@ export default function OrganizationsPage() { } catch (e) { setError(formatApiMessage(e)); } finally { - setRequestLinkRowId(null); + setPendingConnectionRowId(null); } } @@ -210,31 +223,60 @@ export default function OrganizationsPage() { } } - async function respondToPendingLink(linkId: string, action: 'ACCEPT' | 'REJECT') { - setRequestLinkRowId(linkId); + async function handleCopyInvitationFromRow(row: CounterpartItemDto) { + const target = invitationTargetFromConnectionRow(row, currentOrganization!.id); + if (!target) return; setError(''); try { - await organizationApi.respondLink(linkId, action); - setSuccess(action === 'ACCEPT' ? 'Link request accepted' : 'Link request rejected'); - await loadList(); + await copyInvitationLink( + { + id: target.id, + organizationName: row.organizationName, + ownerEmail: target.ownerEmail, + status: target.status, + createdAt: row.createdAt, + acceptedAt: target.acceptedAt, + }, + { + onRegenerated: async () => { + await loadList(); + }, + }, + ); + setSuccess('Invitation link copied to clipboard.'); + setTimeout(() => setSuccess(''), 3000); } catch (e) { setError(formatApiMessage(e)); - } finally { - setRequestLinkRowId(null); } } - async function deleteLinkedOrganization(linkId: string) { - setDeleteLinkRowId(linkId); + async function respondToPendingConnection(connectionId: string, action: 'ACCEPT' | 'REJECT') { + setPendingConnectionRowId(connectionId); setError(''); try { - await organizationApi.deleteLink(linkId); - setSuccess('Linked organization removed'); + await organizationApi.respondToConnectionRequest(connectionId, action); + setSuccess( + action === 'ACCEPT' ? 'Connection request accepted.' : 'Connection request declined.', + ); await loadList(); } catch (e) { setError(formatApiMessage(e)); } finally { - setDeleteLinkRowId(null); + setPendingConnectionRowId(null); + } + } + + async function deleteConnection(connectionId: string) { + setDeleteConnectionRowId(connectionId); + setError(''); + try { + await organizationApi.deleteConnection(connectionId); + setSuccess('Connection removed.'); + await loadList(); + } catch (e) { + setError(formatApiMessage(e)); + } finally { + setDeleteConnectionRowId(null); } } @@ -255,7 +297,8 @@ export default function OrganizationsPage() {

{tabLabel}

- Search organizations and send link requests or invitation links in one place. + Search organizations, send connection requests to existing accounts, or invitation + links when they are not on DyoLink yet.

@@ -388,10 +443,10 @@ export default function OrganizationsPage() { @@ -415,12 +470,14 @@ export default function OrganizationsPage() { diff --git a/frontend/src/components/invitations/organizationInviteLinks.ts b/frontend/src/components/invitations/organizationInviteLinks.ts index fb57d21..4cbe038 100644 --- a/frontend/src/components/invitations/organizationInviteLinks.ts +++ b/frontend/src/components/invitations/organizationInviteLinks.ts @@ -1,5 +1,14 @@ -import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization'; +import type { + CounterpartItemDto, + OrganizationInvitationHistoryItemDto, +} from '@/lib/api/organization'; +export type InvitationLinkTarget = Pick< + OrganizationInvitationHistoryItemDto, + 'id' | 'ownerEmail' | 'status' | 'acceptedAt' +>; + +/** Cached after POST /organizations/invite because only tokenHash is persisted server-side. */ export type StoredOrganizationInviteLink = { invitationId: string; ownerEmail: string; @@ -39,3 +48,21 @@ export function canShareOrganizationInviteLink( if (invitation.acceptedAt) return false; return invitation.status === 'PENDING' || invitation.status === 'EXPIRED'; } + +/** + * Maps a connections-list row to copy/regenerate UI when it was created by the invitation flow. + * Plain invite URLs are not stored in the DB; use localStorage (storeInviteLink) or POST …/link. + */ +export function invitationTargetFromConnectionRow( + row: CounterpartItemDto, + currentOrganizationId: string, +): InvitationLinkTarget | null { + if (!row.pendingInvitationId) return null; + if (row.requestedByOrganizationId !== currentOrganizationId) return null; + return { + id: row.pendingInvitationId, + ownerEmail: row.ownerEmail, + status: row.invitationStatus ?? 'PENDING', + acceptedAt: null, + }; +} diff --git a/frontend/src/components/ui/common/Badge.tsx b/frontend/src/components/ui/common/Badge.tsx index 823419d..642840d 100644 --- a/frontend/src/components/ui/common/Badge.tsx +++ b/frontend/src/components/ui/common/Badge.tsx @@ -43,8 +43,8 @@ export function Badge({ ); } -/** Map organization link / invitation row status to badge variant. */ -export function organizationLinkStatusVariant(status: string): BadgeVariant { +/** Map organization connection / invitation row status to badge variant. */ +export function organizationConnectionStatusVariant(status: string): BadgeVariant { switch (status) { case 'ACTIVE': return 'success'; diff --git a/frontend/src/components/ui/organizations/CopyInvitationLinkButton.tsx b/frontend/src/components/ui/organizations/CopyInvitationLinkButton.tsx new file mode 100644 index 0000000..a8df1c8 --- /dev/null +++ b/frontend/src/components/ui/organizations/CopyInvitationLinkButton.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { Check, Copy } from 'lucide-react'; +import { + canShareOrganizationInviteLink, + type InvitationLinkTarget, +} from '@/components/invitations/organizationInviteLinks'; + +type CopyInvitationLinkButtonProps = { + invitation: InvitationLinkTarget; + copied: boolean; + copying: boolean; + onCopy: () => void; +}; + +export function CopyInvitationLinkButton({ + invitation, + copied, + copying, + onCopy, +}: CopyInvitationLinkButtonProps) { + if (!canShareOrganizationInviteLink(invitation)) { + return ; + } + + return ( + + ); +} diff --git a/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx b/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx index bec6cd7..fcd3ee7 100644 --- a/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx +++ b/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx @@ -1,15 +1,14 @@ 'use client'; -import { Check, Copy } from 'lucide-react'; import { DialogCloseButton } from '@/components/ui/common/DialogCloseButton'; import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization'; -import { canShareOrganizationInviteLink } from '@/components/invitations/organizationInviteLinks'; -import { Badge, organizationLinkStatusVariant } from '@/components/ui/common/Badge'; +import { Badge, organizationConnectionStatusVariant } from '@/components/ui/common/Badge'; import { Table } from '@/components/ui/common/Table'; +import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton'; function formatInvitationStatusLabel(status: OrganizationInvitationHistoryItemDto['status']): string { if (status === 'PENDING') return 'Invitation pending'; - if (status === 'ACTIVE') return 'Invitation Accepted'; + if (status === 'ACTIVE') return 'Invitation accepted'; if (status === 'REJECTED') return 'Invitation rejected'; if (status === 'EXPIRED') return 'Invitation expired'; return status; @@ -94,7 +93,7 @@ export function InvitationHistoryDialog({ Status - Action + Invitation link } @@ -108,29 +107,17 @@ export function InvitationHistoryDialog({ {formatTableDate(inv.createdAt)} - + {formatInvitationStatusLabel(inv.status)} - {canShareOrganizationInviteLink(inv) ? ( - - ) : ( - - )} + onCopy(inv)} + /> ))} diff --git a/frontend/src/lib/api/organization.ts b/frontend/src/lib/api/organization.ts index ba2af00..7c940ce 100644 --- a/frontend/src/lib/api/organization.ts +++ b/frontend/src/lib/api/organization.ts @@ -18,6 +18,12 @@ export interface CounterpartItemDto { status: 'PENDING' | 'ACTIVE' | 'REJECTED' | 'EXPIRED'; createdAt: string; acceptedAt: string | null; + /** + * Set by GET /organizations/connections when this PENDING row came from inviteOrganization() + * (joined server-side). Lets the main table show copy-invite without opening history. + */ + pendingInvitationId?: string | null; + invitationStatus?: OrganizationInvitationHistoryItemDto['status'] | null; } export interface OrganizationInvitationHistoryItemDto { @@ -36,7 +42,7 @@ export const organizationApi = { }, list: async (): Promise<{ success: boolean; data: { items: CounterpartItemDto[] } }> => { - const response = await apiClient.get('/organizations/links'); + const response = await apiClient.get('/organizations/connections'); return response.data; }, @@ -48,23 +54,27 @@ export const organizationApi = { return response.data; }, - createLink: async ( + createConnectionRequest: async ( targetOrganizationId: string, ): Promise<{ success: boolean; data: { id: string; status: 'PENDING' | 'ACTIVE' | 'REJECTED' } }> => { - const response = await apiClient.post('/organizations/links', { targetOrganizationId }); + const response = await apiClient.post('/organizations/connections', { targetOrganizationId }); return response.data; }, - respondLink: async ( - linkId: string, + respondToConnectionRequest: async ( + connectionId: string, action: 'ACCEPT' | 'REJECT', ): Promise<{ success: boolean; data: { id: string; status: 'PENDING' | 'ACTIVE' | 'REJECTED' } }> => { - const response = await apiClient.patch(`/organizations/links/${linkId}/respond`, { action }); + const response = await apiClient.patch(`/organizations/connections/${connectionId}/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}`); + deleteConnection: async ( + connectionId: string, + ): Promise<{ success: boolean; data: { id: string }; message: string }> => { + const response = await apiClient.delete(`/organizations/connections/${connectionId}`); return response.data; },