feature: Organizations form UX flow now matches the needs.

This commit is contained in:
2026-05-06 18:55:42 +03:30
parent 6258477860
commit bb44270795
5 changed files with 700 additions and 218 deletions

View File

@@ -0,0 +1,6 @@
import { IsIn } from 'class-validator';
export class RespondLinkRequestDto {
@IsIn(['ACCEPT', 'REJECT'])
action: 'ACCEPT' | 'REJECT';
}

View File

@@ -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' })

View File

@@ -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) },