From f5fc56e696968110bfe66e5c533cbc7f3790f847 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Oct 2025 10:48:48 -0400 Subject: [PATCH 1/6] fix(app): show device list of only employees (#1646) Co-authored-by: chasprowebdev Co-authored-by: Mariano Fuentes --- .../[orgId]/people/devices/data/index.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts b/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts index 058239ea1..3ec1d54f5 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts @@ -19,21 +19,21 @@ export const getEmployeeDevices: () => Promise = async () => { return null; } - const organization = await db.organization.findUnique({ + // Find all members belonging to the organization. + const employees = await db.member.findMany({ where: { - id: organizationId, + organizationId, }, }); - if (!organization) { - return null; - } - - const labelId = organization.fleetDmLabelId; - - // Get all hosts to get their ids. - const employeeDevices = await fleet.get(`/labels/${labelId}/hosts`); - const allIds = employeeDevices.data.hosts.map((host: { id: number }) => host.id); + const labelIdsResponses = await Promise.all( + employees + .filter((employee) => employee.fleetDmLabelId) + .map((employee) => fleet.get(`/labels/${employee.fleetDmLabelId}/hosts`)), + ); + const allIds = labelIdsResponses.flatMap((response) => + response.data.hosts.map((host: { id: number }) => host.id), + ); // Get all devices by id. in parallel const devices = await Promise.all(allIds.map((id: number) => fleet.get(`/hosts/${id}`))); From b737ba3665bb3a80a4a8ea789af103591e56bb06 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Oct 2025 10:50:34 -0400 Subject: [PATCH 2/6] Create trigger.dev task to send policy email due to resend rate limit issue (#1633) * fix(app): trigger 2 new-policy-email events per second at most due to resend rate limit * fix(app): create trigger.dev task to send policy email due to resend rate limit issue * fix(app): remove unused send-policy-email API --------- Co-authored-by: chasprowebdev Co-authored-by: Mariano Fuentes --- .../accept-requested-policy-changes.ts | 50 ++++++++++--------- .../src/app/api/send-policy-email/route.ts | 50 ------------------- .../src/jobs/tasks/email/new-policy-email.ts | 49 ++++++++++++++++++ 3 files changed, 76 insertions(+), 73 deletions(-) delete mode 100644 apps/app/src/app/api/send-policy-email/route.ts create mode 100644 apps/app/src/jobs/tasks/email/new-policy-email.ts diff --git a/apps/app/src/actions/policies/accept-requested-policy-changes.ts b/apps/app/src/actions/policies/accept-requested-policy-changes.ts index bdaa73637..c1ca22993 100644 --- a/apps/app/src/actions/policies/accept-requested-policy-changes.ts +++ b/apps/app/src/actions/policies/accept-requested-policy-changes.ts @@ -1,6 +1,8 @@ 'use server'; +import { sendNewPolicyEmail } from '@/jobs/tasks/email/new-policy-email'; import { db, PolicyStatus } from '@db'; +import { tasks } from '@trigger.dev/sdk'; import { revalidatePath, revalidateTag } from 'next/cache'; import { z } from 'zod'; import { authActionClient } from '../safe-action'; @@ -92,34 +94,36 @@ export const acceptRequestedPolicyChangesAction = authActionClient return roles.includes('employee'); }); - // Call /api/send-policy-email to send emails to employees - // Prepare the events array for the API const events = employeeMembers .filter((employee) => employee.user.email) - .map((employee) => ({ - subscriberId: `${employee.user.id}-${session.activeOrganizationId}`, - email: employee.user.email, - userName: employee.user.name || employee.user.email || 'Employee', - policyName: policy.name, - organizationName: policy.organization.name, - url: `${process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai'}/${session.activeOrganizationId}`, - description: `The "${policy.name}" policy has been ${isNewPolicy ? 'created' : 'updated'}.`, - })); + .map((employee) => { + let notificationType: 'new' | 're-acceptance' | 'updated'; + const wasAlreadySigned = policy.signedBy.includes(employee.id); + if (isNewPolicy) { + notificationType = 'new'; + } else if (wasAlreadySigned) { + notificationType = 're-acceptance'; + } else { + notificationType = 'updated'; + } + + return { + email: employee.user.email, + userName: employee.user.name || employee.user.email || 'Employee', + policyName: policy.name, + organizationId: session.activeOrganizationId || '', + organizationName: policy.organization.name, + notificationType, + }; + }); // Call the API route to send the emails - try { - await fetch(`${process.env.BETTER_AUTH_URL ?? ''}/api/send-policy-email`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(events), - }); - } catch (error) { - console.error('Failed to call /api/send-policy-email:', error); - // Don't throw, just log - } + await Promise.all( + events.map((event) => + tasks.trigger('send-new-policy-email', event), + ), + ); // If a comment was provided, create a comment if (comment && comment.trim() !== '') { diff --git a/apps/app/src/app/api/send-policy-email/route.ts b/apps/app/src/app/api/send-policy-email/route.ts deleted file mode 100644 index ad8457774..000000000 --- a/apps/app/src/app/api/send-policy-email/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { Novu } from '@novu/api'; - -export async function POST(request: NextRequest) { - let events; - try { - events = await request.json(); - } catch (error) { - return NextResponse.json( - { success: false, error: 'Invalid JSON in request body' }, - { status: 400 } - ); - } - - // You may want to validate required fields in the body here - // For now, we just pass the whole body to Novu - - const novuApiKey = process.env.NOVU_API_KEY; - if (!novuApiKey) { - return NextResponse.json( - { success: false, error: 'Novu API key not configured' }, - { status: 500 } - ); - } - - const novu = new Novu({ secretKey: novuApiKey }); - - try { - const result = await novu.triggerBulk({ - events: events.map((event: any) => ({ - workflowId: "new-policy-email", - to: { - subscriberId: event.subscriberId, - email: event.email, - }, - payload: event, - })), - }); - - return NextResponse.json({ success: true, result }); - } catch (error) { - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : 'Failed to trigger notification', - }, - { status: 500 } - ); - } -} diff --git a/apps/app/src/jobs/tasks/email/new-policy-email.ts b/apps/app/src/jobs/tasks/email/new-policy-email.ts new file mode 100644 index 000000000..b9ee7f4da --- /dev/null +++ b/apps/app/src/jobs/tasks/email/new-policy-email.ts @@ -0,0 +1,49 @@ +import { sendPolicyNotificationEmail } from '@comp/email'; +import { logger, queue, task } from '@trigger.dev/sdk'; + +// Queue with concurrency limit of 1 to ensure rate limiting (1 email per second max) +const policyEmailQueue = queue({ + name: 'policy-email-queue', + concurrencyLimit: 2, +}); + +interface PolicyEmailPayload { + email: string; + userName: string; + policyName: string; + organizationId: string; + organizationName: string; + notificationType: 'new' | 'updated' | 're-acceptance'; +} + +export const sendNewPolicyEmail = task({ + id: 'send-new-policy-email', + queue: policyEmailQueue, + run: async (payload: PolicyEmailPayload) => { + logger.info('Sending new policy email', { + email: payload.email, + policyName: payload.policyName, + }); + + try { + await sendPolicyNotificationEmail(payload); + + logger.info('Successfully sent policy email', { + email: payload.email, + policyName: payload.policyName, + }); + + return { + success: true, + email: payload.email, + }; + } catch (error) { + logger.error('Failed to send policy email', { + email: payload.email, + error: error instanceof Error ? error.message : String(error), + }); + + throw error; + } + }, +}); From 7bcb97e0750d118bea4c5e3cc8b47442b212a5af Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Oct 2025 10:55:12 -0400 Subject: [PATCH 3/6] CS-39 [QOL] - Shipping info optional onboarding question (#1630) * feat(app): add shipping info step as onboarding question * fix(app): increase of line height for step title on onboarding * fix(app): fix typescript error * fix(app): update shipping info step on onboarding screen --------- Co-authored-by: chasprowebdev Co-authored-by: Mariano Fuentes --- .../components/table/ContextColumns.tsx | 20 +++++++- .../onboarding/actions/complete-onboarding.ts | 27 ++++++---- .../components/PostPaymentOnboarding.tsx | 15 +++--- .../hooks/usePostPaymentOnboarding.ts | 7 ++- .../setup/components/OnboardingStepInput.tsx | 50 +++++++++++++++++++ .../(app)/setup/hooks/useOnboardingForm.ts | 2 +- apps/app/src/app/(app)/setup/lib/constants.ts | 10 ++++ apps/app/src/app/(app)/setup/lib/types.ts | 5 ++ apps/app/src/lib/utils.ts | 10 ++++ 9 files changed, 125 insertions(+), 21 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/settings/context-hub/components/table/ContextColumns.tsx b/apps/app/src/app/(app)/[orgId]/settings/context-hub/components/table/ContextColumns.tsx index 636d78c23..05e6c2eed 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/context-hub/components/table/ContextColumns.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/context-hub/components/table/ContextColumns.tsx @@ -1,5 +1,6 @@ import { deleteContextEntryAction } from '@/actions/context-hub/delete-context-entry-action'; import { DataTableColumnHeader } from '@/components/data-table/data-table-column-header'; +import { isJSON } from '@/lib/utils'; import { AlertDialog, AlertDialogAction, @@ -78,7 +79,24 @@ export const columns = (): ColumnDef[] => [ id: 'answer', accessorKey: 'answer', header: ({ column }) => , - cell: ({ row }) => {row.original.answer}, + cell: ({ row }) => { + if (isJSON(row.original.answer)) { + return ( + + {Object.entries(JSON.parse(row.original.answer)) + .filter(([key, value]) => !!value) + .map(([key, value]) => ( +
+ {key}: + {value as string} +
+ ))} +
+ ); + } + + return {row.original.answer}; + }, meta: { label: 'Answer' }, enableColumnFilter: true, enableSorting: false, diff --git a/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts b/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts index 1002c945a..bbe85dc1f 100644 --- a/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts +++ b/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts @@ -23,6 +23,11 @@ const onboardingCompletionSchema = z.object({ infrastructure: z.string().min(1), dataTypes: z.string().min(1), geo: z.string().min(1), + shipping: z.object({ + fullName: z.string(), + address: z.string(), + phone: z.string(), + }), }); export const completeOnboarding = authActionClient @@ -63,16 +68,18 @@ export const completeOnboarding = authActionClient // Save the remaining steps to context const postPaymentSteps = steps.slice(3); // Steps 4-12 - await db.context.createMany({ - data: postPaymentSteps - .filter((step) => step.key in parsedInput) - .map((step) => ({ - question: step.question, - answer: parsedInput[step.key as keyof typeof parsedInput] as string, - tags: ['onboarding'], - organizationId: parsedInput.organizationId, - })), - }); + const contextData = postPaymentSteps + .filter((step) => step.key in parsedInput) + .map((step) => ({ + question: step.question, + answer: + typeof parsedInput[step.key as keyof typeof parsedInput] === 'object' + ? JSON.stringify(parsedInput[step.key as keyof typeof parsedInput]) + : (parsedInput[step.key as keyof typeof parsedInput] as string), + tags: ['onboarding'], + organizationId: parsedInput.organizationId, + })); + await db.context.createMany({ data: contextData }); // Update organization to mark onboarding as complete await db.organization.update({ diff --git a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx index 29fa8f756..a5116e889 100644 --- a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx +++ b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx @@ -88,7 +88,7 @@ export function PostPaymentOnboarding({
Step {stepIndex + 1} of {totalSteps}
- + {step?.question || ''} @@ -103,10 +103,7 @@ export function PostPaymentOnboarding({ autoComplete="off" > {steps.map((s, idx) => ( -
+
( @@ -118,9 +115,11 @@ export function PostPaymentOnboarding({ savedAnswers={savedAnswers} /> -
- -
+ {s.key !== 'shipping' && ( +
+ +
+ )} )} /> diff --git a/apps/app/src/app/(app)/onboarding/hooks/usePostPaymentOnboarding.ts b/apps/app/src/app/(app)/onboarding/hooks/usePostPaymentOnboarding.ts index 08943bcd9..16b50af31 100644 --- a/apps/app/src/app/(app)/onboarding/hooks/usePostPaymentOnboarding.ts +++ b/apps/app/src/app/(app)/onboarding/hooks/usePostPaymentOnboarding.ts @@ -134,6 +134,11 @@ export function usePostPaymentOnboarding({ infrastructure: allAnswers.infrastructure || '', dataTypes: allAnswers.dataTypes || '', geo: allAnswers.geo || '', + shipping: allAnswers.shipping || { + fullName: '', + address: '', + phone: '', + }, }); }; @@ -153,7 +158,7 @@ export function usePostPaymentOnboarding({ // Handle multi-select fields with "Other" option for (const key of Object.keys(newAnswers)) { - if (step.options && step.key === key && key !== 'frameworkIds') { + if (step.options && step.key === key && key !== 'frameworkIds' && key !== 'shipping') { const customValue = newAnswers[`${key}Other`] || ''; const values = (newAnswers[key] || '').split(',').filter(Boolean); diff --git a/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx b/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx index ca5d2135a..f0f38a230 100644 --- a/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx +++ b/apps/app/src/app/(app)/setup/components/OnboardingStepInput.tsx @@ -1,3 +1,4 @@ +import { FormLabel } from '@comp/ui/form'; import { Input } from '@comp/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; import { SelectPills } from '@comp/ui/select-pills'; @@ -39,6 +40,55 @@ export function OnboardingStepInput({ ); } + if (currentStep.key === 'shipping') { + return ( +
+
+
+ Full Name + +

+ {form.formState.errors.shipping?.fullName?.message} +

+
+
+ Phone + +

+ {form.formState.errors.shipping?.phone?.message} +

+
+
+
+ Address +