diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d874809fb1..d947e3465a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,11 @@ jobs: fetch-depth: 0 token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }} + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Setup Bun uses: oven-sh/setup-bun@v2 diff --git a/apps/api/src/frameworks/frameworks-upsert.helper.ts b/apps/api/src/frameworks/frameworks-upsert.helper.ts index 7a6d026806..7a62e9e73b 100644 --- a/apps/api/src/frameworks/frameworks-upsert.helper.ts +++ b/apps/api/src/frameworks/frameworks-upsert.helper.ts @@ -144,6 +144,7 @@ export async function upsertOrgFrameworkStructure({ } // Upsert policy instances + const createdPolicyIds: string[] = []; const existingPolicies = await tx.policy.findMany({ where: { organizationId, @@ -182,6 +183,7 @@ export async function upsertOrgFrameworkStructure({ }); if (newPolicies.length > 0) { + createdPolicyIds.push(...newPolicies.map((p) => p.id)); await tx.policyVersion.createMany({ data: newPolicies.map((p) => ({ policyId: p.id, @@ -360,5 +362,6 @@ export async function upsertOrgFrameworkStructure({ controlTemplates, policyTemplates, taskTemplates, + createdPolicyIds, }; } diff --git a/apps/api/src/frameworks/frameworks.controller.ts b/apps/api/src/frameworks/frameworks.controller.ts index fb434bec5a..68a347fa1a 100644 --- a/apps/api/src/frameworks/frameworks.controller.ts +++ b/apps/api/src/frameworks/frameworks.controller.ts @@ -125,10 +125,12 @@ export class FrameworksController { async addFrameworks( @OrganizationId() organizationId: string, @Body() dto: AddFrameworksDto, + @AuthContext() authContext: AuthContextType, ) { return this.frameworksService.addFrameworks( organizationId, dto.frameworkIds, + authContext.memberId, ); } diff --git a/apps/api/src/frameworks/frameworks.service.ts b/apps/api/src/frameworks/frameworks.service.ts index a9443e1b6c..1a3debca66 100644 --- a/apps/api/src/frameworks/frameworks.service.ts +++ b/apps/api/src/frameworks/frameworks.service.ts @@ -5,6 +5,8 @@ import { NotFoundException, } from '@nestjs/common'; import { db, type EvidenceFormType } from '@db'; + +import { tasks } from '@trigger.dev/sdk'; import { getOverviewScores, getCurrentMember, @@ -15,6 +17,7 @@ import { createTimelinesForFrameworks } from './frameworks-timeline.helper'; import { TimelinesService } from '../timelines/timelines.service'; import type { FrameworkManifest } from './framework-versioning/manifest.types'; import { buildUpdatePreview } from './framework-versioning/framework-update-preview'; +import type { updatePolicy } from '../trigger/policies/update-policy'; type RequirementDef = { id: string; @@ -543,7 +546,11 @@ export class FrameworksService { return { ...scores, currentMember }; } - async addFrameworks(organizationId: string, frameworkIds: string[]) { + async addFrameworks( + organizationId: string, + frameworkIds: string[], + memberId?: string, + ) { const result = await db.$transaction(async (tx) => { const frameworks = await tx.frameworkEditorFramework.findMany({ where: { id: { in: frameworkIds }, visible: true }, @@ -558,14 +565,19 @@ export class FrameworksService { const finalIds = frameworks.map((f) => f.id); - await upsertOrgFrameworkStructure({ + const upsertResult = await upsertOrgFrameworkStructure({ organizationId, targetFrameworkEditorIds: finalIds, frameworkEditorFrameworks: frameworks, tx, }); - return { success: true, frameworksAdded: finalIds.length, finalIds }; + return { + success: true, + frameworksAdded: finalIds.length, + finalIds, + createdPolicyIds: upsertResult.createdPolicyIds, + }; }); // Auto-create timeline instances from templates for newly added @@ -584,9 +596,102 @@ export class FrameworksService { ); }); + // Regenerate only newly created policies so placeholder replacement runs + // without touching customer-edited existing policies. + if (result.createdPolicyIds.length > 0) { + this.enqueuePolicyGenerationForNewPolicies({ + organizationId, + policyIds: result.createdPolicyIds, + memberId, + }).catch((err) => { + this.logger.warn( + 'enqueuePolicyGenerationForNewPolicies failed after framework add', + err, + ); + }); + } + return { success: result.success, frameworksAdded: result.frameworksAdded }; } + private async enqueuePolicyGenerationForNewPolicies({ + organizationId, + policyIds, + memberId, + }: { + organizationId: string; + policyIds: string[]; + memberId?: string; + }) { + const [instances, contextEntries] = await Promise.all([ + db.frameworkInstance.findMany({ + where: { organizationId }, + include: { framework: true, customFramework: true }, + }), + db.context.findMany({ + where: { organizationId }, + orderBy: { createdAt: 'asc' }, + }), + ]); + + const normalized = instances.map((fi) => { + if (fi.framework) { + return { + id: fi.framework.id, + name: fi.framework.name, + version: fi.framework.version, + description: fi.framework.description, + visible: fi.framework.visible, + createdAt: fi.framework.createdAt, + updatedAt: fi.framework.updatedAt, + }; + } + if (fi.customFramework) { + return { + id: fi.customFramework.id, + name: fi.customFramework.name, + version: fi.customFramework.version, + description: fi.customFramework.description, + visible: true, + createdAt: fi.customFramework.createdAt, + updatedAt: fi.customFramework.updatedAt, + }; + } + return null; + }); + const uniqueFrameworks = Array.from( + new Map( + normalized + .filter((f): f is NonNullable => f !== null) + .map((f) => [f.id, f]), + ).values(), + ); + + const contextHub = contextEntries + .map((c) => `${c.question}\n${c.answer}`) + .join('\n'); + + const triggerResults = await Promise.allSettled( + policyIds.map((policyId) => + tasks.trigger('update-policy', { + organizationId, + policyId, + contextHub, + frameworks: uniqueFrameworks, + memberId, + }), + ), + ); + + const failedTrigger = triggerResults.find( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + if (failedTrigger) { + this.logger.error('Failed to trigger policy update', failedTrigger.reason); + throw new Error('Failed to trigger policy update'); + } + } + async findRequirement( frameworkInstanceId: string, requirementKey: string, diff --git a/apps/api/src/people/dto/invite-people.dto.ts b/apps/api/src/people/dto/invite-people.dto.ts index d1d4d58086..d1773684fd 100644 --- a/apps/api/src/people/dto/invite-people.dto.ts +++ b/apps/api/src/people/dto/invite-people.dto.ts @@ -6,6 +6,8 @@ import { IsString, ValidateNested, ArrayMinSize, + IsBoolean, + IsOptional, } from 'class-validator'; import { Type } from 'class-transformer'; @@ -20,6 +22,11 @@ export class InviteItemDto { @IsString({ each: true }) @ArrayMinSize(1) roles: string[]; + + @ApiProperty({ example: false, required: false }) + @IsBoolean() + @IsOptional() + sendPortalEmail?: boolean; } export class InvitePeopleDto { diff --git a/apps/api/src/people/people-invite.service.ts b/apps/api/src/people/people-invite.service.ts index 3f6dcc0c9f..22a9df5eac 100644 --- a/apps/api/src/people/people-invite.service.ts +++ b/apps/api/src/people/people-invite.service.ts @@ -7,6 +7,11 @@ import { import { db } from '@db'; import { triggerEmail } from '../email/trigger-email'; import { InviteEmail } from '../email/templates/invite-member'; +import { InvitePortalEmail } from '@trycompai/email'; +import { + BUILT_IN_ROLE_OBLIGATIONS, + RESTRICTED_ROLES, +} from '@trycompai/auth'; import type { InviteItemDto } from './dto/invite-people.dto'; import { checkAutoCompletePhases } from '../frameworks/frameworks-timeline.helper'; import { TimelinesService } from '../timelines/timelines.service'; @@ -61,16 +66,23 @@ export class PeopleInviteService { } const email = invite.email.toLowerCase(); - const hasEmployeeRoleAndNoAdmin = - !invite.roles.includes('admin') && - (invite.roles.includes('employee') || - invite.roles.includes('contractor')); + const restrictedRoles: readonly string[] = RESTRICTED_ROLES; + const isStrictlyEmployee = + invite.roles.every((role) => restrictedRoles.includes(role)); - if (hasEmployeeRoleAndNoAdmin) { + const hasCompliance = await this.rolesHaveComplianceObligation( + invite.roles, + organizationId, + ); + const shouldSendPortalEmail = + !!invite.sendPortalEmail && hasCompliance; + + if (isStrictlyEmployee) { const result = await this.addEmployeeWithoutInvite( email, invite.roles, organizationId, + shouldSendPortalEmail, ); results.push({ email: invite.email, @@ -83,6 +95,7 @@ export class PeopleInviteService { invite.roles, organizationId, callerUserId, + shouldSendPortalEmail, ); results.push({ email: invite.email, success: true }); } @@ -117,6 +130,7 @@ export class PeopleInviteService { email: string, roles: string[], organizationId: string, + sendPortalEmail?: boolean, ): Promise<{ emailSent: boolean }> { const organization = await db.organization.findUnique({ where: { id: organizationId }, @@ -149,11 +163,11 @@ export class PeopleInviteService { let isNewMember = false; if (existingMember) { - if (existingMember.deactivated) { + if (existingMember.deactivated || !existingMember.isActive) { const roleString = [...roles].sort().join(','); member = await db.member.update({ where: { id: existingMember.id }, - data: { deactivated: false, role: roleString }, + data: { deactivated: false, isActive: true, role: roleString }, }); } else { member = existingMember; @@ -177,12 +191,25 @@ export class PeopleInviteService { // Send invite email (non-fatal) let emailSent = true; try { - const inviteLink = this.buildPortalUrl(organizationId); - await triggerEmail({ - to: email, - subject: `You've been invited to join ${organization.name} on Comp AI`, - react: InviteEmail({ organizationName: organization.name, inviteLink }), - }); + if (sendPortalEmail) { + const inviteLink = this.buildPortalUrl(organizationId); + await triggerEmail({ + to: email, + subject: `You've been invited to join ${organization.name} on Comp AI`, + react: InvitePortalEmail({ + organizationName: organization.name, + inviteLink, + email, + }), + }); + } else { + const inviteLink = this.buildPortalUrl(organizationId); + await triggerEmail({ + to: email, + subject: `You've been invited to join ${organization.name} on Comp AI`, + react: InviteEmail({ organizationName: organization.name, inviteLink }), + }); + } } catch (emailErr) { emailSent = false; this.logger.error( @@ -199,6 +226,7 @@ export class PeopleInviteService { roles: string[], organizationId: string, currentUserId: string, + sendPortalEmail?: boolean, ): Promise { const existingUser = await db.user.findFirst({ where: { email: { equals: email, mode: 'insensitive' } }, @@ -225,6 +253,7 @@ export class PeopleInviteService { roles, organizationId, currentUserId, + sendPortalEmail, ); return; } @@ -252,12 +281,25 @@ export class PeopleInviteService { }, }); - const inviteLink = this.buildInviteLink(invitation.id); - await triggerEmail({ - to: email, - subject: `You've been invited to join ${organization.name} on Comp AI`, - react: InviteEmail({ organizationName: organization.name, inviteLink }), - }); + if (sendPortalEmail) { + const inviteLink = this.buildPortalUrl(organizationId); + await triggerEmail({ + to: email, + subject: `You've been invited to join ${organization.name} on Comp AI`, + react: InvitePortalEmail({ + organizationName: organization.name, + inviteLink, + email, + }), + }); + } else { + const inviteLink = this.buildInviteLink(invitation.id); + await triggerEmail({ + to: email, + subject: `You've been invited to join ${organization.name} on Comp AI`, + react: InviteEmail({ organizationName: organization.name, inviteLink }), + }); + } } private async sendInvitationEmailToExistingMember( @@ -265,6 +307,7 @@ export class PeopleInviteService { roles: string[], organizationId: string, inviterId: string, + sendPortalEmail?: boolean, ): Promise { const organization = await db.organization.findUnique({ where: { id: organizationId }, @@ -286,12 +329,67 @@ export class PeopleInviteService { }, }); - const inviteLink = this.buildInviteLink(invitation.id); + if (sendPortalEmail) { + const inviteLink = this.buildPortalUrl(organizationId); + await triggerEmail({ + to: email.toLowerCase(), + subject: `You've been invited to join ${organization.name} on Comp AI`, + react: InvitePortalEmail({ + organizationName: organization.name, + inviteLink, + email: email.toLowerCase(), + }), + }); + } else { + const inviteLink = this.buildInviteLink(invitation.id); + await triggerEmail({ + to: email.toLowerCase(), + subject: `You've been invited to join ${organization.name} on Comp AI`, + react: InviteEmail({ organizationName: organization.name, inviteLink }), + }); + } + } + + async resendPortalInvite(params: { + organizationId: string; + memberId: string; + }): Promise<{ success: boolean }> { + const { organizationId, memberId } = params; + + const member = await db.member.findFirst({ + where: { id: memberId, organizationId }, + include: { user: true, organization: { select: { name: true } } }, + }); + + if (!member) { + throw new BadRequestException('Member not found.'); + } + + const roles = member.role.split(',').map((r) => r.trim()); + const hasCompliance = await this.rolesHaveComplianceObligation( + roles, + organizationId, + ); + if (!hasCompliance) { + throw new BadRequestException( + 'Portal invites can only be sent to members with compliance obligations.', + ); + } + + const email = member.user.email; + const inviteLink = this.buildPortalUrl(organizationId); + await triggerEmail({ - to: email.toLowerCase(), - subject: `You've been invited to join ${organization.name} on Comp AI`, - react: InviteEmail({ organizationName: organization.name, inviteLink }), + to: email, + subject: `Access your ${member.organization.name} Employee Portal on Comp AI`, + react: InvitePortalEmail({ + organizationName: member.organization.name, + inviteLink, + email, + }), }); + + return { success: true }; } private async createTrainingVideoEntries( @@ -319,6 +417,34 @@ export class PeopleInviteService { }); } + private async rolesHaveComplianceObligation( + roles: string[], + organizationId: string, + ): Promise { + for (const role of roles) { + if (BUILT_IN_ROLE_OBLIGATIONS[role]?.compliance) return true; + } + + const customRoleNames = roles.filter((r) => !BUILT_IN_ROLE_OBLIGATIONS[r]); + if (customRoleNames.length === 0) return false; + + const customRoles = await db.organizationRole.findMany({ + where: { + organizationId, + name: { in: customRoleNames }, + }, + select: { obligations: true }, + }); + + return customRoles.some((role) => { + const obligations = + typeof role.obligations === 'string' + ? JSON.parse(role.obligations) + : role.obligations || {}; + return !!obligations.compliance; + }); + } + private buildPortalUrl(organizationId: string): string { const portalUrl = process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai'; diff --git a/apps/api/src/people/people.controller.ts b/apps/api/src/people/people.controller.ts index 464f58d448..e0ee6eee3b 100644 --- a/apps/api/src/people/people.controller.ts +++ b/apps/api/src/people/people.controller.ts @@ -450,6 +450,20 @@ export class PeopleController { }; } + @Post(':id/resend-portal-invite') + @RequirePermission('member', 'update') + @ApiOperation({ summary: 'Resend portal invite email to a member' }) + @ApiParam(PEOPLE_PARAMS.memberId) + async resendPortalInvite( + @Param('id') memberId: string, + @OrganizationId() organizationId: string, + ) { + return this.peopleInviteService.resendPortalInvite({ + organizationId, + memberId, + }); + } + @Delete(':id') @RequirePermission('member', 'delete') @ApiOperation(PEOPLE_OPERATIONS.deleteMember) diff --git a/apps/api/src/people/people.service.ts b/apps/api/src/people/people.service.ts index c0b89448ce..09e712a772 100644 --- a/apps/api/src/people/people.service.ts +++ b/apps/api/src/people/people.service.ts @@ -448,16 +448,16 @@ export class PeopleService { organizationId, ); - if (member) { + if (member && member.isActive) { throw new BadRequestException('Member is already active'); } - // Look for deactivated member - const deactivatedMember = await db.member.findFirst({ + // Look for inactive or deactivated member + const inactiveMember = await db.member.findFirst({ where: { id: memberId, organizationId }, }); - if (!deactivatedMember) { + if (!inactiveMember) { throw new NotFoundException( `Member with ID ${memberId} not found in organization ${organizationId}`, ); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/actions/resendPortalInvite.ts b/apps/app/src/app/(app)/[orgId]/people/all/actions/resendPortalInvite.ts new file mode 100644 index 0000000000..f47bfbd975 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/actions/resendPortalInvite.ts @@ -0,0 +1,16 @@ +'use server'; + +import { resendPortalInviteViaApi } from '@/lib/people-api'; + +export const resendPortalInvite = async (memberId: string) => { + try { + const response = await resendPortalInviteViaApi({ memberId }); + if (response.error) { + throw new Error(response.error); + } + return { success: true }; + } catch (error) { + console.error('Error resending portal invite:', error); + throw error; + } +}; diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx index 7c6e76ef49..1365a51cfd 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx @@ -30,6 +30,7 @@ import { FormMessage, } from '@trycompai/ui/form'; import { Input } from '@trycompai/ui/input'; +import { Checkbox } from '@trycompai/ui/checkbox'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@trycompai/ui/tabs'; import { MultiRoleCombobox } from './MultiRoleCombobox'; @@ -55,12 +56,14 @@ const createFormSchema = (allowedRoles: string[]) => { manualInvites: z .array(manualInviteSchema) .min(1, { message: 'Please add at least one invite.' }), + sendPortalEmail: z.boolean(), csvFile: z.any().optional(), // Optional here, validated by union }); const csvModeSchema = z.object({ mode: z.literal('csv'), manualInvites: z.array(manualInviteSchema).optional(), // Optional here + sendPortalEmail: z.boolean(), csvFile: z.any().refine((val) => val instanceof FileList && val.length === 1, { message: 'Please select a single CSV file.', }), @@ -119,6 +122,7 @@ export function InviteMembersModal({ roles: DEFAULT_ROLES, }, ], + sendPortalEmail: true, csvFile: undefined, }, mode: 'onChange', @@ -161,6 +165,7 @@ export function InviteMembersModal({ const invitePayload = values.manualInvites.map((invite) => ({ email: invite.email.toLowerCase(), roles: invite.roles, + sendPortalEmail: values.sendPortalEmail, })); const { data, error } = await api.post<{ @@ -284,7 +289,7 @@ export function InviteMembersModal({ } // Parse CSV rows into invite items, validating locally first - const csvInvites: Array<{ email: string; roles: string[] }> = []; + const csvInvites: Array<{ email: string; roles: string[]; sendPortalEmail: boolean }> = []; const clientErrors: { email: string; error: string }[] = []; for (const row of dataRows) { @@ -320,7 +325,11 @@ export function InviteMembersModal({ continue; } - csvInvites.push({ email: email.toLowerCase(), roles: validRoles }); + csvInvites.push({ + email: email.toLowerCase(), + roles: validRoles, + sendPortalEmail: values.sendPortalEmail, + }); } if (clientErrors.length > 0) { @@ -566,6 +575,24 @@ export function InviteMembersModal({ + ( + + + + +
+ Send portal invite email + + If enabled, users will receive a direct link to the Employee Portal. + +
+
+ )} + /> +