From bb4427079542974f33512a76f52f3cce27b364b4 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 6 May 2026 18:55:42 +0330 Subject: [PATCH] feature: Organizations form UX flow now matches the needs. --- .../dto/respond-link-request.dto.ts | 6 + .../organization/organization.controller.ts | 46 ++ .../organization/organization.service.ts | 273 +++++++-- .../app/(dashboard)/organizations/page.tsx | 553 ++++++++++++------ frontend/src/lib/api/organization.ts | 40 +- 5 files changed, 700 insertions(+), 218 deletions(-) create mode 100644 backend/src/modules/organization/dto/respond-link-request.dto.ts 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 index 75ab7a2..6a3152f 100644 --- a/backend/src/modules/organization/organization.controller.ts +++ b/backend/src/modules/organization/organization.controller.ts @@ -1,7 +1,10 @@ import { Body, Controller, + Delete, Get, + Param, + Patch, Post, Query, Req, @@ -13,6 +16,7 @@ import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dt 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') @@ -27,6 +31,14 @@ export class OrganizationController { 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) { @@ -65,6 +77,40 @@ export class OrganizationController { 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' }) diff --git a/backend/src/modules/organization/organization.service.ts b/backend/src/modules/organization/organization.service.ts index e495df6..ce8d967 100644 --- a/backend/src/modules/organization/organization.service.ts +++ b/backend/src/modules/organization/organization.service.ts @@ -12,6 +12,7 @@ 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 { @@ -67,7 +68,7 @@ export class OrganizationService { throw new ForbiddenException('You do not have permission to manage organizations'); } - const [linksA, linksB, invitations] = await Promise.all([ + const [linksA, linksB] = await Promise.all([ this.prisma.organizationLink.findMany({ where: { organizationAId: organizationId }, include: { @@ -82,61 +83,65 @@ export class OrganizationService { }, orderBy: { createdAt: 'desc' }, }), - this.prisma.organizationInvitation.findMany({ - where: { inviterOrganizationId: organizationId }, - orderBy: { createdAt: 'desc' }, - }), ]); const linkItems = [ ...linksA.map((l) => ({ + requestedByOrganizationId: this.getRequesterOrganizationId(l.sharedDataTypes), id: l.id, - kind: 'LINK' as const, counterpartOrganizationId: l.organizationB.id, organizationName: l.organizationB.name, ownerEmail: l.organizationB.email, phone: l.organizationB.phone, status: l.status, - invitationUrl: null as string | null, createdAt: l.createdAt.toISOString(), acceptedAt: l.status === LinkStatus.ACTIVE ? l.updatedAt.toISOString() : null, })), ...linksB.map((l) => ({ + requestedByOrganizationId: this.getRequesterOrganizationId(l.sharedDataTypes), id: l.id, - kind: 'LINK' as const, counterpartOrganizationId: l.organizationA.id, organizationName: l.organizationA.name, ownerEmail: l.organizationA.email, phone: l.organizationA.phone, status: l.status, - invitationUrl: null as string | null, createdAt: l.createdAt.toISOString(), acceptedAt: l.status === LinkStatus.ACTIVE ? l.updatedAt.toISOString() : null, })), ]; - const inviteItems = invitations.map((i) => ({ - id: i.id, - kind: 'INVITATION' as const, - counterpartOrganizationId: i.invitedOrganizationId, - organizationName: i.invitedOrganizationName, - ownerEmail: i.invitedOwnerEmail, - phone: null as string | null, - status: this.mapInvitationStatus(i.acceptedAt, i.revokedAt, i.expiresAt), - invitationUrl: - !i.acceptedAt && !i.revokedAt && i.expiresAt.getTime() > Date.now() - ? this.buildInviteUrlFromTokenHashPlaceholder() - : null, - createdAt: i.createdAt.toISOString(), - acceptedAt: i.acceptedAt?.toISOString() ?? null, - })); + return { + success: true, + data: { + items: linkItems.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: [...linkItems, ...inviteItems].sort((a, b) => - a.createdAt < b.createdAt ? 1 : -1, - ), + 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, + })), }, }; } @@ -183,7 +188,7 @@ export class OrganizationService { organizationAId: aId, organizationBId: bId, status: LinkStatus.PENDING, - sharedDataTypes: [], + sharedDataTypes: [`requested_by:${organizationId}`], }, }); @@ -194,6 +199,118 @@ export class OrganizationService { }; } + 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)) { @@ -219,26 +336,77 @@ export class OrganizationService { ); } - const invitedOrg = await this.prisma.organization.findFirst({ - where: { - owner: { email: ownerEmail }, - type: { name: invitedType }, - }, - select: { id: true, name: true }, - orderBy: { createdAt: 'desc' }, - }); + const invitation = await this.prisma.$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, + }, + }); + } - const invitation = await this.prisma.organizationInvitation.create({ - data: { - inviterOrganizationId: organizationId, - inviterUserId: userId, - invitedOrganizationId: invitedOrg?.id ?? null, - invitedOrganizationName: dto.organizationName.trim(), - invitedOwnerEmail: ownerEmail, - invitedOrganizationType: invitedType, - tokenHash, - expiresAt: this.getInviteExpiryDate(), - }, + 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 { @@ -430,6 +598,17 @@ export class OrganizationService { 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) }, diff --git a/frontend/src/app/(dashboard)/organizations/page.tsx b/frontend/src/app/(dashboard)/organizations/page.tsx index 1a379b3..c5575da 100644 --- a/frontend/src/app/(dashboard)/organizations/page.tsx +++ b/frontend/src/app/(dashboard)/organizations/page.tsx @@ -1,11 +1,13 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; +import { Search } 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'; @@ -39,13 +41,26 @@ function writeStoredInviteLinks(orgId: string, links: Record([]); - const [selectedOrgId, setSelectedOrgId] = useState(null); - const [linkLoading, setLinkLoading] = useState(false); + 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 [manualPhone, setManualPhone] = 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 selectedResult = useMemo( - () => results.find((r) => r.id === selectedOrgId) ?? null, - [results, selectedOrgId], - ); + const existingRows = items; async function loadList() { setLoading(true); @@ -111,34 +129,43 @@ export default function OrganizationsPage() { }, [success]); async function runSearch() { - setSearchLoading(true); + const q = query.trim(); + if (!q) { + setMode('existing'); + setSearchResults([]); + setShowInviteForm(false); + return; + } + + setSearching(true); setError(''); - setSelectedOrgId(null); + setMode('search'); + setShowInviteForm(false); try { - const res = await organizationApi.search(searchTerm.trim()); - setResults(res.data); + const res = await organizationApi.search(q); + setSearchResults(res.data); } catch (e) { setError(formatApiMessage(e)); + setSearchResults([]); } finally { - setSearchLoading(false); + setSearching(false); } } - async function createLink() { - if (!selectedOrgId) return; - setLinkLoading(true); + async function submitRequestLink(targetOrganizationId: string) { + setRequestLinkRowId(targetOrganizationId); setError(''); try { - await organizationApi.createLink(selectedOrgId); - setSuccess(`${counterpartLabel} link request created`); - setResults([]); - setSelectedOrgId(null); - setSearchTerm(''); + await organizationApi.createLink(targetOrganizationId); + setSuccess(`${counterpartLabel} link request sent`); + setSearchResults([]); + setQuery(''); + setMode('existing'); await loadList(); } catch (e) { setError(formatApiMessage(e)); } finally { - setLinkLoading(false); + setRequestLinkRowId(null); } } @@ -149,7 +176,6 @@ export default function OrganizationsPage() { const res = await organizationApi.invite({ organizationName: manualOrganizationName.trim(), ownerEmail: manualOwnerEmail.trim(), - phone: manualPhone.trim() || undefined, }); if (currentOrganization?.id) { const nextLinks = { @@ -166,7 +192,10 @@ export default function OrganizationsPage() { setSuccess(`Invitation link created for ${manualOwnerEmail.trim()}`); setManualOrganizationName(''); setManualOwnerEmail(''); - setManualPhone(''); + setShowInviteForm(false); + setMode('existing'); + setQuery(''); + setSearchResults([]); await loadList(); } catch (e) { setError(formatApiMessage(e)); @@ -175,18 +204,100 @@ export default function OrganizationsPage() { } } + 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 subscribed {tabLabel.toLowerCase()}, request link access, or invite a new{' '} - {counterpartLabel.toLowerCase()} owner. -

+
+
+

{tabLabel}

+

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

+
+
{error && ( @@ -200,152 +311,256 @@ export default function OrganizationsPage() {
)} -
-

Search existing {tabLabel}

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

Invite owner (not yet subscribed)

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

Requests and invitations

-
- {loading ? ( -

Loading list...

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

No records yet.

- ) : ( -
- - - - - - - - +
+
TypeOrganizationOwner emailStatusAction
+ + + + + + + + + + + {loading ? ( + + + + ) : mode === 'existing' ? ( + existingRows.length === 0 ? ( + + - - - {items.map((item) => ( - - - - - - + + + + + + + ); + }) + ) + ) : searchResults.length > 0 ? ( + searchResults.map((r) => ( + + + + + + + + )) + ) : ( + + - - ))} - -
+ Organization + + Owner email + + Type + + Status + + Action +
+ Loading... +
+ No organizations linked or pending yet. Use search to find and connect. +
- {item.kind === 'LINK' ? 'Link request' : 'Invitation'} - {item.organizationName}{item.ownerEmail} - - {formatOrganizationStatusLabel(item.status)} - - - {item.kind === 'INVITATION' && - item.status === 'PENDING' && - pendingInviteLinks[item.id]?.invitationUrl && ( + ) : ( + existingRows.map((row) => { + const canRespond = + row.status === 'PENDING' && + row.requestedByOrganizationId !== null && + row.requestedByOrganizationId !== currentOrganization.id; + + return ( +
+ {row.organizationName} + {row.ownerEmail}Link + + {formatLinkStatusLabel(row.status)} + + +
+ {canRespond && ( + <> + + + + )} + {row.status === 'ACTIVE' && ( + + )} +
+
{r.name}{r.owner.email}Directory match + Found + + +
+
+

+ No organization found in directory search. +

+
+ +
+ {showInviteForm && ( +
+ setManualOrganizationName(e.target.value)} + /> + setManualOwnerEmail(e.target.value)} + /> +
- )} -
-
- )} +
+
+ )} + + + + )} + + + + {historyOpen && ( +
+
+
+

Invitation history

+ +
+ + {historyLoading ? ( +

Loading invitation history...

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

No invitations yet.

+ ) : ( +
+ + + + + + + + + + + {historyItems.map((inv) => ( + + + + + + + ))} + +
OrganizationOwner emailStatusAction
{inv.organizationName}{inv.ownerEmail} + + {formatInvitationStatusLabel(inv.status)} + + + {inv.status === 'PENDING' ? ( + + ) : ( + + )} +
+
+ )} +
+
+ )} ); } diff --git a/frontend/src/lib/api/organization.ts b/frontend/src/lib/api/organization.ts index 9287958..bd65b4b 100644 --- a/frontend/src/lib/api/organization.ts +++ b/frontend/src/lib/api/organization.ts @@ -10,13 +10,21 @@ export interface CounterpartSearchResultDto { export interface CounterpartItemDto { id: string; - kind: 'LINK' | 'INVITATION'; + requestedByOrganizationId: string | null; counterpartOrganizationId: string | null; organizationName: string; ownerEmail: string; phone: string | null; status: 'PENDING' | 'ACTIVE' | 'REJECTED' | 'EXPIRED'; - invitationUrl: string | null; + createdAt: string; + acceptedAt: string | null; +} + +export interface OrganizationInvitationHistoryItemDto { + id: string; + organizationName: string; + ownerEmail: string; + status: 'PENDING' | 'ACTIVE' | 'REJECTED' | 'EXPIRED'; createdAt: string; acceptedAt: string | null; } @@ -32,6 +40,14 @@ export const organizationApi = { 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' } }> => { @@ -39,6 +55,19 @@ export const organizationApi = { 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; @@ -48,6 +77,13 @@ export const organizationApi = { 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<{