Skip to content

Commit 96262d1

Browse files
feat(trust-access): fix trustportal grand access logic and add endpoint to resend access granted email (#1988)
Co-authored-by: Tofik Hasanov <annexcies@gmail.com>
1 parent 80a70e3 commit 96262d1

13 files changed

Lines changed: 554 additions & 276 deletions

File tree

apps/api/src/email/templates/access-granted.tsx

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ interface Props {
1717
toName: string;
1818
organizationName: string;
1919
expiresAt: Date;
20-
portalUrl?: string | null;
20+
portalUrl: string;
2121
}
2222

2323
export const AccessGrantedEmail = ({
@@ -76,16 +76,14 @@ export const AccessGrantedEmail = ({
7676
</strong>
7777
</Text>
7878

79-
{portalUrl && (
80-
<Section className="mt-[32px] mb-[32px] text-center">
81-
<Button
82-
className="rounded-[3px] bg-[#121212] px-[20px] py-[12px] text-center text-[14px] font-semibold text-white no-underline"
83-
href={portalUrl}
84-
>
85-
View Documents
86-
</Button>
87-
</Section>
88-
)}
79+
<Section className="mt-[32px] mb-[32px] text-center">
80+
<Button
81+
className="rounded-[3px] bg-[#121212] px-[20px] py-[12px] text-center text-[14px] font-semibold text-white no-underline"
82+
href={portalUrl}
83+
>
84+
View Documents
85+
</Button>
86+
</Section>
8987

9088
<Text className="text-[14px] leading-[24px] text-[#121212]">
9189
You can download your signed NDA for your records from the

apps/api/src/trust-portal/email.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export class TrustEmailService {
3636
toName: string;
3737
organizationName: string;
3838
expiresAt: Date;
39-
portalUrl?: string | null;
39+
portalUrl: string;
4040
}): Promise<void> {
4141
const { toEmail, toName, organizationName, expiresAt, portalUrl } = params;
4242

apps/api/src/trust-portal/trust-access.controller.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,33 @@ export class TrustAccessController {
253253
);
254254
}
255255

256+
@Post('admin/grants/:id/resend-access-email')
257+
@UseGuards(HybridAuthGuard)
258+
@ApiSecurity('apikey')
259+
@ApiHeader({
260+
name: 'X-Organization-Id',
261+
description: 'Organization ID',
262+
required: true,
263+
})
264+
@HttpCode(HttpStatus.OK)
265+
@ApiOperation({
266+
summary: 'Resend access granted email',
267+
description: 'Resend the access granted email to user with active grant',
268+
})
269+
@ApiResponse({
270+
status: HttpStatus.OK,
271+
description: 'Access email resent',
272+
})
273+
async resendAccessEmail(
274+
@OrganizationId() organizationId: string,
275+
@Param('id') grantId: string,
276+
) {
277+
return this.trustAccessService.resendAccessGrantEmail(
278+
organizationId,
279+
grantId,
280+
);
281+
}
282+
256283
@Get('nda/:token')
257284
@HttpCode(HttpStatus.OK)
258285
@ApiOperation({

apps/api/src/trust-portal/trust-access.service.ts

Lines changed: 207 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { PolicyPdfRendererService } from './policy-pdf-renderer.service';
2020
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
2121
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
2222
import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '../app/s3';
23-
import { TrustFramework } from '@prisma/client';
23+
import { Prisma, TrustFramework } from '@prisma/client';
2424
import archiver from 'archiver';
2525
import { PassThrough, Readable } from 'stream';
2626

@@ -35,6 +35,130 @@ export class TrustAccessService {
3535
return randomBytes(length).toString('base64url').slice(0, length);
3636
}
3737

38+
/**
39+
* Normalize URL by removing trailing slash
40+
*/
41+
private normalizeUrl(input: string): string {
42+
return input.endsWith('/') ? input.slice(0, -1) : input;
43+
}
44+
45+
/**
46+
* Normalize domain by removing protocol and path
47+
*/
48+
private normalizeDomain(input: string): string {
49+
const trimmed = input.trim();
50+
const withoutProtocol = trimmed.replace(/^https?:\/\//i, '');
51+
const withoutPath = withoutProtocol.split('/')[0] ?? withoutProtocol;
52+
return withoutPath.trim().toLowerCase();
53+
}
54+
55+
/**
56+
* Create a URL-friendly slug from organization name
57+
*/
58+
private slugifyOrganizationName(name: string): string {
59+
const cleaned = name
60+
.trim()
61+
.toLowerCase()
62+
.replace(/&/g, 'and')
63+
.replace(/[^a-z0-9]+/g, '-')
64+
.replace(/-+/g, '-')
65+
.replace(/^-|-$/g, '');
66+
67+
return cleaned.slice(0, 60);
68+
}
69+
70+
/**
71+
* Ensure organization has a friendlyUrl, create one if missing
72+
*/
73+
private async ensureFriendlyUrl(params: {
74+
organizationId: string;
75+
organizationName: string;
76+
}): Promise<string> {
77+
const { organizationId, organizationName } = params;
78+
79+
const current = await db.trust.findUnique({
80+
where: { organizationId },
81+
select: { friendlyUrl: true },
82+
});
83+
84+
if (current?.friendlyUrl) return current.friendlyUrl;
85+
86+
const baseCandidate =
87+
this.slugifyOrganizationName(organizationName) ||
88+
`org-${organizationId.slice(-8)}`;
89+
90+
for (let i = 0; i < 25; i += 1) {
91+
const candidate = i === 0 ? baseCandidate : `${baseCandidate}-${i + 1}`;
92+
93+
const taken = await db.trust.findUnique({
94+
where: { friendlyUrl: candidate },
95+
select: { organizationId: true },
96+
});
97+
98+
if (taken && taken.organizationId !== organizationId) continue;
99+
100+
try {
101+
await db.trust.upsert({
102+
where: { organizationId },
103+
update: { friendlyUrl: candidate },
104+
create: { organizationId, friendlyUrl: candidate },
105+
});
106+
return candidate;
107+
} catch (error: unknown) {
108+
if (
109+
error instanceof Prisma.PrismaClientKnownRequestError &&
110+
error.code === 'P2002'
111+
) {
112+
continue;
113+
}
114+
throw error;
115+
}
116+
}
117+
118+
return organizationId;
119+
}
120+
121+
/**
122+
* Build portal base URL, checking custom domain first
123+
*/
124+
private async buildPortalBaseUrl(params: {
125+
organizationId: string;
126+
organizationName: string;
127+
}): Promise<string> {
128+
const { organizationId, organizationName } = params;
129+
130+
const trust = await db.trust.findUnique({
131+
where: { organizationId },
132+
select: { domain: true, domainVerified: true, friendlyUrl: true },
133+
});
134+
135+
if (trust?.domain && trust.domainVerified) {
136+
return `https://${this.normalizeDomain(trust.domain)}`;
137+
}
138+
139+
const urlId =
140+
trust?.friendlyUrl ||
141+
(await this.ensureFriendlyUrl({ organizationId, organizationName }));
142+
143+
return `${this.normalizeUrl(this.TRUST_APP_URL)}/${urlId}`;
144+
}
145+
146+
/**
147+
* Build portal access URL with access token
148+
*/
149+
private async buildPortalAccessUrl(params: {
150+
organizationId: string;
151+
organizationName: string;
152+
accessToken: string;
153+
}): Promise<string> {
154+
const { organizationId, organizationName, accessToken } = params;
155+
const base = await this.buildPortalBaseUrl({
156+
organizationId,
157+
organizationName,
158+
});
159+
return `${base}/access/${accessToken}`;
160+
}
161+
38162
private async findPublishedTrustByRouteId(id: string) {
39163
// First, try treating `id` as the existing friendlyUrl.
40164
let trust = await db.trust.findUnique({
@@ -560,6 +684,66 @@ export class TrustAccessService {
560684
return updatedGrant;
561685
}
562686

687+
async resendAccessGrantEmail(organizationId: string, grantId: string) {
688+
const grant = await db.trustAccessGrant.findFirst({
689+
where: {
690+
id: grantId,
691+
accessRequest: {
692+
organizationId,
693+
},
694+
},
695+
include: {
696+
accessRequest: {
697+
include: {
698+
organization: {
699+
select: { name: true },
700+
},
701+
},
702+
},
703+
},
704+
});
705+
706+
if (!grant) {
707+
throw new NotFoundException('Grant not found');
708+
}
709+
710+
if (grant.status !== 'active') {
711+
throw new BadRequestException(
712+
`Cannot resend access email for ${grant.status} grant`,
713+
);
714+
}
715+
716+
// Generate a new access token if expired or missing
717+
let accessToken = grant.accessToken;
718+
const now = new Date();
719+
720+
if (!accessToken || (grant.accessTokenExpiresAt && grant.accessTokenExpiresAt < now)) {
721+
accessToken = this.generateToken(32);
722+
const accessTokenExpiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000);
723+
724+
await db.trustAccessGrant.update({
725+
where: { id: grantId },
726+
data: { accessToken, accessTokenExpiresAt },
727+
});
728+
}
729+
730+
const portalUrl = await this.buildPortalAccessUrl({
731+
organizationId,
732+
organizationName: grant.accessRequest.organization.name,
733+
accessToken,
734+
});
735+
736+
await this.emailService.sendAccessGrantedEmail({
737+
toEmail: grant.subjectEmail,
738+
toName: grant.accessRequest.name,
739+
organizationName: grant.accessRequest.organization.name,
740+
expiresAt: grant.expiresAt,
741+
portalUrl,
742+
});
743+
744+
return { message: 'Access email resent successfully' };
745+
}
746+
563747
async getNdaByToken(token: string) {
564748
const nda = await db.trustNDAAgreement.findUnique({
565749
where: { signToken: token },
@@ -577,15 +761,11 @@ export class TrustAccessService {
577761
throw new NotFoundException('NDA agreement not found');
578762
}
579763

580-
const trust = await db.trust.findUnique({
581-
where: { organizationId: nda.organizationId },
582-
select: { friendlyUrl: true },
764+
const portalUrl = await this.buildPortalBaseUrl({
765+
organizationId: nda.organizationId,
766+
organizationName: nda.accessRequest.organization.name,
583767
});
584768

585-
const portalUrl = trust?.friendlyUrl
586-
? `${this.TRUST_APP_URL}/${trust.friendlyUrl}`
587-
: null;
588-
589769
const baseResponse = {
590770
id: nda.id,
591771
organizationName: nda.accessRequest.organization.name,
@@ -612,11 +792,13 @@ export class TrustAccessService {
612792
}
613793

614794
if (nda.status === 'signed') {
615-
let accessUrl = portalUrl;
795+
let accessUrl: string | null = portalUrl;
616796
if (nda.grant?.accessToken && nda.grant.status === 'active') {
617-
if (trust?.friendlyUrl) {
618-
accessUrl = `${this.TRUST_APP_URL}/${trust.friendlyUrl}/access/${nda.grant.accessToken}`;
619-
}
797+
accessUrl = await this.buildPortalAccessUrl({
798+
organizationId: nda.organizationId,
799+
organizationName: nda.accessRequest.organization.name,
800+
accessToken: nda.grant.accessToken,
801+
});
620802
}
621803

622804
return {
@@ -683,15 +865,12 @@ export class TrustAccessService {
683865
});
684866
}
685867

686-
const trust = await db.trust.findUnique({
687-
where: { organizationId: nda.organizationId },
688-
select: { friendlyUrl: true },
868+
const portalUrl = await this.buildPortalAccessUrl({
869+
organizationId: nda.organizationId,
870+
organizationName: nda.accessRequest.organization.name,
871+
accessToken,
689872
});
690873

691-
const portalUrl = trust?.friendlyUrl
692-
? `${this.TRUST_APP_URL}/${trust.friendlyUrl}/access/${accessToken}`
693-
: null;
694-
695874
return {
696875
message: 'NDA already signed',
697876
grant: nda.grant,
@@ -754,15 +933,12 @@ export class TrustAccessService {
754933
return { grant, updatedNda };
755934
});
756935

757-
const trust = await db.trust.findUnique({
758-
where: { organizationId: nda.organizationId },
759-
select: { friendlyUrl: true },
936+
const portalUrl = await this.buildPortalAccessUrl({
937+
organizationId: nda.organizationId,
938+
organizationName: nda.accessRequest.organization.name,
939+
accessToken,
760940
});
761941

762-
const portalUrl = trust?.friendlyUrl
763-
? `${this.TRUST_APP_URL}/${trust.friendlyUrl}/access/${accessToken}`
764-
: null;
765-
766942
await this.emailService.sendAccessGrantedEmail({
767943
toEmail: signerEmail,
768944
toName: signerName,
@@ -975,8 +1151,11 @@ export class TrustAccessService {
9751151
});
9761152
}
9771153

978-
const urlId = trust.friendlyUrl || trust.organizationId;
979-
let accessLink = `${this.TRUST_APP_URL}/${urlId}/access/${accessToken}`;
1154+
let accessLink = await this.buildPortalAccessUrl({
1155+
organizationId: trust.organizationId,
1156+
organizationName: grant.accessRequest.organization.name,
1157+
accessToken,
1158+
});
9801159

9811160
// Append query parameter if provided
9821161
if (query) {

0 commit comments

Comments
 (0)