From df382877142d911044e9df60236e7ec164714e9f Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Fri, 10 Apr 2026 08:57:58 -0400 Subject: [PATCH 01/14] fix: suppress weekly digest emails for inactive orgs Skip organizations where no member has logged in within 90 days. Prevents sending emails to dead/abandoned addresses that may have become spam traps, which contributes to domain reputation damage. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tasks/task/weekly-task-reminder.ts | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts b/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts index 3e35fdc5bb..883f41065b 100644 --- a/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts +++ b/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts @@ -2,6 +2,8 @@ import { db } from '@db/server'; import { logger, schedules } from '@trigger.dev/sdk'; import { sendWeeklyTaskDigestEmailTask } from '../email/weekly-task-digest-email'; +const ORG_INACTIVITY_DAYS = 90; + export const weeklyTaskReminder = schedules.task({ id: 'weekly-task-reminder', cron: '0 9 * * 1', // Every Monday at 9:00 AM UTC @@ -9,8 +11,24 @@ export const weeklyTaskReminder = schedules.task({ run: async () => { logger.info('Starting weekly task reminder job'); - // Get all organizations + const inactivityCutoff = new Date(); + inactivityCutoff.setDate(inactivityCutoff.getDate() - ORG_INACTIVITY_DAYS); + + // Get all organizations that have at least one session updated in the last 90 days const organizations = await db.organization.findMany({ + where: { + members: { + some: { + user: { + sessions: { + some: { + updatedAt: { gte: inactivityCutoff }, + }, + }, + }, + }, + }, + }, select: { id: true, name: true, @@ -36,7 +54,7 @@ export const weeklyTaskReminder = schedules.task({ }, }); - logger.info(`Found ${organizations.length} organizations to process`); + logger.info(`Found ${organizations.length} active organizations to process (skipped orgs with no sessions in ${ORG_INACTIVITY_DAYS} days)`); // Build email payloads for all members with TODO tasks const emailPayloads = []; From 044fb512c6940e2b832bc7b5b230afc7148abb7b Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Fri, 10 Apr 2026 09:17:39 -0400 Subject: [PATCH 02/14] fix: also exclude orgs without access or onboarding from weekly digest Adds hasAccess and onboardingCompleted filters alongside the 90-day session check. This excludes ~4,087 orgs (831 ghost + 3,256 churned) from receiving weekly digest emails. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/app/src/trigger/tasks/task/weekly-task-reminder.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts b/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts index 883f41065b..6b92f05600 100644 --- a/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts +++ b/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts @@ -14,9 +14,12 @@ export const weeklyTaskReminder = schedules.task({ const inactivityCutoff = new Date(); inactivityCutoff.setDate(inactivityCutoff.getDate() - ORG_INACTIVITY_DAYS); - // Get all organizations that have at least one session updated in the last 90 days + // Only email orgs that are active: have access, completed onboarding, + // and at least one member logged in within the last 90 days const organizations = await db.organization.findMany({ where: { + hasAccess: true, + onboardingCompleted: true, members: { some: { user: { From 6c6f63368d168adf0cc8a175c82af62691650655 Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Fri, 10 Apr 2026 09:22:37 -0400 Subject: [PATCH 03/14] feat: add admin org activity endpoint with session + audit log data GET /v1/admin/organizations/activity Returns per-org activity data: last session, last audit log, and combined last activity timestamp. Supports filtering by hasAccess, onboardingCompleted, and inactivity threshold. Used for identifying inactive orgs that should be suppressed from email notifications. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../admin-organizations.controller.ts | 23 ++++ .../admin-organizations.service.ts | 109 ++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/apps/api/src/admin-organizations/admin-organizations.controller.ts b/apps/api/src/admin-organizations/admin-organizations.controller.ts index 6f51e9fcae..ca6cd0249e 100644 --- a/apps/api/src/admin-organizations/admin-organizations.controller.ts +++ b/apps/api/src/admin-organizations/admin-organizations.controller.ts @@ -42,6 +42,29 @@ export class AdminOrganizationsController { }); } + @Get('activity') + @ApiOperation({ summary: 'Organization activity report - shows last session per org (platform admin)' }) + @ApiQuery({ name: 'inactiveDays', required: false, description: 'Filter orgs with no session in N days (default: 90)' }) + @ApiQuery({ name: 'hasAccess', required: false, description: 'Filter by hasAccess (true/false)' }) + @ApiQuery({ name: 'onboarded', required: false, description: 'Filter by onboardingCompleted (true/false)' }) + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'limit', required: false }) + async activity( + @Query('inactiveDays') inactiveDays?: string, + @Query('hasAccess') hasAccess?: string, + @Query('onboarded') onboarded?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + return this.service.getOrgActivity({ + inactiveDays: parseInt(inactiveDays || '90', 10) || 90, + hasAccess: hasAccess === 'true' ? true : hasAccess === 'false' ? false : undefined, + onboarded: onboarded === 'true' ? true : onboarded === 'false' ? false : undefined, + page: Math.max(1, parseInt(page || '1', 10) || 1), + limit: Math.min(100, Math.max(1, parseInt(limit || '50', 10) || 50)), + }); + } + @Get(':id') @ApiOperation({ summary: 'Get organization details (platform admin)' }) async get(@Param('id') id: string) { diff --git a/apps/api/src/admin-organizations/admin-organizations.service.ts b/apps/api/src/admin-organizations/admin-organizations.service.ts index 57dbf78ca5..15a01cc287 100644 --- a/apps/api/src/admin-organizations/admin-organizations.service.ts +++ b/apps/api/src/admin-organizations/admin-organizations.service.ts @@ -100,6 +100,115 @@ export class AdminOrganizationsService { }; } + async getOrgActivity(options: { + inactiveDays: number; + hasAccess?: boolean; + onboarded?: boolean; + page: number; + limit: number; + }) { + const { inactiveDays, hasAccess, onboarded, page, limit } = options; + const skip = (page - 1) * limit; + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - inactiveDays); + + const where: Record = {}; + if (hasAccess !== undefined) where.hasAccess = hasAccess; + if (onboarded !== undefined) where.onboardingCompleted = onboarded; + + const [organizations, total] = await Promise.all([ + db.organization.findMany({ + where, + select: { + id: true, + name: true, + createdAt: true, + hasAccess: true, + onboardingCompleted: true, + _count: { select: { members: true } }, + members: { + select: { + role: true, + user: { + select: { + id: true, + name: true, + email: true, + sessions: { + orderBy: { updatedAt: 'desc' as const }, + take: 1, + select: { updatedAt: true }, + }, + }, + }, + }, + }, + auditLog: { + orderBy: { timestamp: 'desc' as const }, + take: 1, + select: { timestamp: true }, + }, + }, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + db.organization.count({ where }), + ]); + + // Post-process to find last activity per org + const data = organizations.map((org) => { + let lastSession: Date | null = null; + let owner: { id: string; name: string; email: string } | null = null; + + for (const member of org.members) { + const sess = member.user?.sessions?.[0]?.updatedAt; + if (sess && (!lastSession || sess > lastSession)) { + lastSession = sess; + } + if (member.role?.includes('owner') && !owner) { + owner = { id: member.user.id, name: member.user.name, email: member.user.email }; + } + } + + const lastAuditLog = org.auditLog?.[0]?.timestamp ?? null; + const lastActivity = [lastSession, lastAuditLog] + .filter(Boolean) + .sort((a, b) => (b as Date).getTime() - (a as Date).getTime())[0] as Date | undefined; + + const isActive = lastActivity ? lastActivity >= cutoff : false; + + return { + id: org.id, + name: org.name, + createdAt: org.createdAt, + hasAccess: org.hasAccess, + onboardingCompleted: org.onboardingCompleted, + memberCount: org._count.members, + owner, + lastSession: lastSession?.toISOString() ?? null, + lastAuditLog: lastAuditLog ? (lastAuditLog as Date).toISOString() : null, + lastActivity: lastActivity?.toISOString() ?? null, + isActive, + }; + }); + + const activeCount = data.filter((d) => d.isActive).length; + const inactiveCount = data.filter((d) => !d.isActive).length; + + return { + data, + total, + page, + limit, + summary: { + inactiveDays, + activeInPage: activeCount, + inactiveInPage: inactiveCount, + }, + }; + } + async getOrganization(id: string) { const org = await db.organization.findUnique({ where: { id }, From d0fb12043b86183da9258375f3b0fccdb775573d Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Fri, 10 Apr 2026 10:17:25 -0400 Subject: [PATCH 04/14] fix: use nullish coalescing for inactiveDays parameter Prevents inactiveDays=0 from silently defaulting to 90. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/admin-organizations/admin-organizations.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/admin-organizations/admin-organizations.controller.ts b/apps/api/src/admin-organizations/admin-organizations.controller.ts index ca6cd0249e..017bb9b26b 100644 --- a/apps/api/src/admin-organizations/admin-organizations.controller.ts +++ b/apps/api/src/admin-organizations/admin-organizations.controller.ts @@ -57,7 +57,7 @@ export class AdminOrganizationsController { @Query('limit') limit?: string, ) { return this.service.getOrgActivity({ - inactiveDays: parseInt(inactiveDays || '90', 10) || 90, + inactiveDays: parseInt(inactiveDays ?? '90', 10) ?? 90, hasAccess: hasAccess === 'true' ? true : hasAccess === 'false' ? false : undefined, onboarded: onboarded === 'true' ? true : onboarded === 'false' ? false : undefined, page: Math.max(1, parseInt(page || '1', 10) || 1), From a454abae4c653b3fcf3f0da8d7faf8e0427ee071 Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Fri, 10 Apr 2026 10:32:01 -0400 Subject: [PATCH 05/14] fix: handle NaN from parseInt for inactiveDays parameter parseInt returns NaN on invalid input, which is not nullish so ?? won't catch it. Use Number.isFinite to properly fall back to 90. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/admin-organizations/admin-organizations.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/admin-organizations/admin-organizations.controller.ts b/apps/api/src/admin-organizations/admin-organizations.controller.ts index 017bb9b26b..0ef13b57ed 100644 --- a/apps/api/src/admin-organizations/admin-organizations.controller.ts +++ b/apps/api/src/admin-organizations/admin-organizations.controller.ts @@ -57,7 +57,7 @@ export class AdminOrganizationsController { @Query('limit') limit?: string, ) { return this.service.getOrgActivity({ - inactiveDays: parseInt(inactiveDays ?? '90', 10) ?? 90, + inactiveDays: Number.isFinite(parseInt(inactiveDays ?? '90', 10)) ? parseInt(inactiveDays ?? '90', 10) : 90, hasAccess: hasAccess === 'true' ? true : hasAccess === 'false' ? false : undefined, onboarded: onboarded === 'true' ? true : onboarded === 'false' ? false : undefined, page: Math.max(1, parseInt(page || '1', 10) || 1), From 16adc0395aad57f5ad46f1bbdd250e91fa43e7e9 Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Fri, 10 Apr 2026 11:05:02 -0400 Subject: [PATCH 06/14] fix: add deactivated:false to members.some activity check Per Tofik's review - match the same member scope used in the select clause so deactivated members don't count as active. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/app/src/trigger/tasks/task/weekly-task-reminder.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts b/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts index 6b92f05600..f1466eac6a 100644 --- a/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts +++ b/apps/app/src/trigger/tasks/task/weekly-task-reminder.ts @@ -22,6 +22,7 @@ export const weeklyTaskReminder = schedules.task({ onboardingCompleted: true, members: { some: { + deactivated: false, user: { sessions: { some: { From 8b8270b44e69af7366a7643560aba110d80290fc Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Fri, 10 Apr 2026 11:28:09 -0400 Subject: [PATCH 07/14] feat: add task, policy, and audit log counts to activity endpoint Helps diagnose org health - an org with 0 tasks, 0 policies, and 0 audit logs is clearly a ghost regardless of session data. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/admin-organizations/admin-organizations.service.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/api/src/admin-organizations/admin-organizations.service.ts b/apps/api/src/admin-organizations/admin-organizations.service.ts index 15a01cc287..c8e3e65a8e 100644 --- a/apps/api/src/admin-organizations/admin-organizations.service.ts +++ b/apps/api/src/admin-organizations/admin-organizations.service.ts @@ -125,7 +125,7 @@ export class AdminOrganizationsService { createdAt: true, hasAccess: true, onboardingCompleted: true, - _count: { select: { members: true } }, + _count: { select: { members: true, tasks: true, policies: true, auditLog: true } }, members: { select: { role: true, @@ -185,6 +185,9 @@ export class AdminOrganizationsService { hasAccess: org.hasAccess, onboardingCompleted: org.onboardingCompleted, memberCount: org._count.members, + taskCount: org._count.tasks, + policyCount: org._count.policies, + auditLogCount: org._count.auditLog, owner, lastSession: lastSession?.toISOString() ?? null, lastAuditLog: lastAuditLog ? (lastAuditLog as Date).toISOString() : null, From bc05e631624ab2aa311c9f5193db27cc90ce78ae Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Apr 2026 11:45:43 -0400 Subject: [PATCH 08/14] fix(browser-automation): fix Stagehand v3 model format and add delete/toggle controls The Stagehand model config used 'claude-3-7-sonnet-latest' without the required 'provider/model' prefix, causing "Unsupported model" errors. Updated both init and CUA agent models to use 'anthropic/claude-sonnet-4-5-20250929'. Also added delete automation and enable/disable toggle to the frontend for a more complete MVP experience. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/browserbase/browserbase.service.ts | 8 +- .../components/BrowserAutomations.tsx | 2 + .../browser-automations/AutomationItem.tsx | 111 ++++++++++++++++-- .../BrowserAutomationsList.test.tsx | 2 + .../BrowserAutomationsList.tsx | 6 + .../[taskId]/hooks/useBrowserAutomations.ts | 33 ++++++ 6 files changed, 147 insertions(+), 15 deletions(-) diff --git a/apps/api/src/browserbase/browserbase.service.ts b/apps/api/src/browserbase/browserbase.service.ts index dc6a7176b8..79f5b43f69 100644 --- a/apps/api/src/browserbase/browserbase.service.ts +++ b/apps/api/src/browserbase/browserbase.service.ts @@ -15,6 +15,10 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; const BROWSER_WIDTH = 1440; const BROWSER_HEIGHT = 900; +/** Stagehand v3 requires 'provider/model' format. */ +const STAGEHAND_MODEL = 'anthropic/claude-sonnet-4-5-20250929'; +const STAGEHAND_CUA_MODEL = 'anthropic/claude-sonnet-4-5-20250929'; + const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const PENDING_CONTEXT_ID = '__PENDING__'; @@ -205,7 +209,7 @@ export class BrowserbaseService { projectId: this.getProjectId(), browserbaseSessionID: sessionId, model: { - modelName: 'claude-3-7-sonnet-latest', + modelName: STAGEHAND_MODEL, apiKey: process.env.ANTHROPIC_API_KEY, }, verbose: 1, @@ -784,7 +788,7 @@ export class BrowserbaseService { .agent({ cua: true, model: { - modelName: 'anthropic/claude-3-7-sonnet-latest', + modelName: STAGEHAND_CUA_MODEL, apiKey: process.env.ANTHROPIC_API_KEY, }, }) diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx index 821c7d425a..fd529b5cff 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx @@ -130,6 +130,8 @@ export function BrowserAutomations({ taskId, isManualTask = false }: BrowserAuto onRun={execution.runAutomation} onCreateClick={isManualTask ? undefined : () => setDialogState({ open: true, mode: 'create' })} onEditClick={(automation) => setDialogState({ open: true, mode: 'edit', automation })} + onDelete={automations.deleteAutomation} + onToggleEnabled={automations.toggleAutomation} /> void; onRun: () => void; onEdit: () => void; + onDelete: () => void; + onToggleEnabled: (enabled: boolean) => void; } export function AutomationItem({ @@ -25,23 +36,30 @@ export function AutomationItem({ onToggleExpand, onRun, onEdit, + onDelete, + onToggleEnabled, }: AutomationItemProps) { + const [confirmDelete, setConfirmDelete] = useState(false); const runs: BrowserAutomationRun[] = automation.runs || []; const latestRun = runs[0]; // status dot const hasFailed = latestRun?.status === 'failed'; const isCompleted = latestRun?.status === 'completed'; - const dotColor = hasFailed - ? 'bg-destructive shadow-[0_0_8px_rgba(255,0,0,0.3)]' - : isCompleted - ? 'bg-primary shadow-[0_0_8px_rgba(0,77,64,0.4)]' - : 'bg-muted-foreground'; + const isDisabled = !automation.isEnabled; + const dotColor = isDisabled + ? 'bg-muted-foreground/40' + : hasFailed + ? 'bg-destructive shadow-[0_0_8px_rgba(255,0,0,0.3)]' + : isCompleted + ? 'bg-primary shadow-[0_0_8px_rgba(0,77,64,0.4)]' + : 'bg-muted-foreground'; return (
-

- {automation.name} -

+
+

+ {automation.name} +

+ {isDisabled && ( + + Paused + + )} +
{latestRun ? (

Last ran {formatDistanceToNow(new Date(latestRun.createdAt), { addSuffix: true })} @@ -63,15 +88,75 @@ export function AutomationItem({ )}

-
+
{!readOnly && ( - )} {!readOnly && ( - + )} + + {!readOnly && ( + confirmDelete ? ( +
+ + +
+ ) : ( + + ) + )} + + {!readOnly && ( +
diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useBrowserAutomations.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useBrowserAutomations.ts index 95d9126aa6..ab73c86ab1 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useBrowserAutomations.ts +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useBrowserAutomations.ts @@ -87,6 +87,37 @@ export function useBrowserAutomations({ taskId }: UseBrowserAutomationsOptions) [fetchAutomations], ); + const deleteAutomation = useCallback( + async (automationId: string) => { + try { + const res = await apiClient.delete(`/v1/browserbase/automations/${automationId}`); + if (res.error) throw new Error(res.error); + toast.success('Browser automation deleted'); + await fetchAutomations(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to delete automation'); + } + }, + [fetchAutomations], + ); + + const toggleAutomation = useCallback( + async (automationId: string, isEnabled: boolean) => { + try { + const res = await apiClient.patch( + `/v1/browserbase/automations/${automationId}`, + { isEnabled }, + ); + if (res.error) throw new Error(res.error); + toast.success(isEnabled ? 'Automation enabled' : 'Automation disabled'); + await fetchAutomations(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to update automation'); + } + }, + [fetchAutomations], + ); + return { automations, isLoading, @@ -94,5 +125,7 @@ export function useBrowserAutomations({ taskId }: UseBrowserAutomationsOptions) fetchAutomations, createAutomation, updateAutomation, + deleteAutomation, + toggleAutomation, }; } From eb012b060c3d26ee86c829cfc931d3008cb72f7f Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Apr 2026 11:47:28 -0400 Subject: [PATCH 09/14] fix(browser-automation): use claude-sonnet-4-6 for Stagehand models Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/browserbase/browserbase.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/browserbase/browserbase.service.ts b/apps/api/src/browserbase/browserbase.service.ts index 79f5b43f69..c4c63d8dd4 100644 --- a/apps/api/src/browserbase/browserbase.service.ts +++ b/apps/api/src/browserbase/browserbase.service.ts @@ -16,8 +16,8 @@ const BROWSER_WIDTH = 1440; const BROWSER_HEIGHT = 900; /** Stagehand v3 requires 'provider/model' format. */ -const STAGEHAND_MODEL = 'anthropic/claude-sonnet-4-5-20250929'; -const STAGEHAND_CUA_MODEL = 'anthropic/claude-sonnet-4-5-20250929'; +const STAGEHAND_MODEL = 'anthropic/claude-sonnet-4-6'; +const STAGEHAND_CUA_MODEL = 'anthropic/claude-sonnet-4-6'; const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); From 59fc10be5b5d58d75a92533e7274221b4d92b71d Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Fri, 10 Apr 2026 11:52:47 -0400 Subject: [PATCH 10/14] fix: filter deactivated members from activity query and clamp inactiveDays - Add deactivated:false to members query in getOrgActivity so deactivated users don't inflate lastSession or get picked as owner - Clamp inactiveDays to non-negative to prevent future-dated cutoffs Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/admin-organizations/admin-organizations.controller.ts | 2 +- apps/api/src/admin-organizations/admin-organizations.service.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/src/admin-organizations/admin-organizations.controller.ts b/apps/api/src/admin-organizations/admin-organizations.controller.ts index 0ef13b57ed..523a6a4742 100644 --- a/apps/api/src/admin-organizations/admin-organizations.controller.ts +++ b/apps/api/src/admin-organizations/admin-organizations.controller.ts @@ -57,7 +57,7 @@ export class AdminOrganizationsController { @Query('limit') limit?: string, ) { return this.service.getOrgActivity({ - inactiveDays: Number.isFinite(parseInt(inactiveDays ?? '90', 10)) ? parseInt(inactiveDays ?? '90', 10) : 90, + inactiveDays: Math.max(0, Number.isFinite(parseInt(inactiveDays ?? '90', 10)) ? parseInt(inactiveDays ?? '90', 10) : 90), hasAccess: hasAccess === 'true' ? true : hasAccess === 'false' ? false : undefined, onboarded: onboarded === 'true' ? true : onboarded === 'false' ? false : undefined, page: Math.max(1, parseInt(page || '1', 10) || 1), diff --git a/apps/api/src/admin-organizations/admin-organizations.service.ts b/apps/api/src/admin-organizations/admin-organizations.service.ts index c8e3e65a8e..93e1b7fd5f 100644 --- a/apps/api/src/admin-organizations/admin-organizations.service.ts +++ b/apps/api/src/admin-organizations/admin-organizations.service.ts @@ -127,6 +127,7 @@ export class AdminOrganizationsService { onboardingCompleted: true, _count: { select: { members: true, tasks: true, policies: true, auditLog: true } }, members: { + where: { deactivated: false }, select: { role: true, user: { From 5f700cc366b8cec1f1ed3f8940d5dc17563667de Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Apr 2026 11:54:05 -0400 Subject: [PATCH 11/14] fix(browser-automation): hide next-run timer when all automations are paused Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/browser-automations/BrowserAutomationsList.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/BrowserAutomationsList.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/BrowserAutomationsList.tsx index fe4987ae45..d3c5a77cee 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/BrowserAutomationsList.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/BrowserAutomationsList.tsx @@ -52,7 +52,8 @@ export function BrowserAutomationsList({ const canCreateIntegration = hasPermission('integration', 'create'); const canUpdateIntegration = hasPermission('integration', 'update'); - const nextRun = automations.length > 0 ? getNextScheduledRun() : null; + const hasEnabledAutomations = automations.some((a) => a.isEnabled); + const nextRun = hasEnabledAutomations ? getNextScheduledRun() : null; return (
From 4023cf19cabc417e7bcc9ff10380b10fb5a7fd19 Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Fri, 10 Apr 2026 12:14:08 -0400 Subject: [PATCH 12/14] fix: correct Prisma relation name policies -> policy in _count Organization model uses singular `policy` not `policies`. Would have caused runtime Prisma validation error. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/admin-organizations/admin-organizations.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/admin-organizations/admin-organizations.service.ts b/apps/api/src/admin-organizations/admin-organizations.service.ts index 93e1b7fd5f..46a3239de4 100644 --- a/apps/api/src/admin-organizations/admin-organizations.service.ts +++ b/apps/api/src/admin-organizations/admin-organizations.service.ts @@ -125,7 +125,7 @@ export class AdminOrganizationsService { createdAt: true, hasAccess: true, onboardingCompleted: true, - _count: { select: { members: true, tasks: true, policies: true, auditLog: true } }, + _count: { select: { members: true, tasks: true, policy: true, auditLog: true } }, members: { where: { deactivated: false }, select: { @@ -187,7 +187,7 @@ export class AdminOrganizationsService { onboardingCompleted: org.onboardingCompleted, memberCount: org._count.members, taskCount: org._count.tasks, - policyCount: org._count.policies, + policyCount: org._count.policy, auditLogCount: org._count.auditLog, owner, lastSession: lastSession?.toISOString() ?? null, From bd93c1c44a4eeebf133bbba59c23756d00e92926 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:44:14 -0400 Subject: [PATCH 13/14] fix(app): prevent duplicate org creation during setup onboarding * fix(app): prevent duplicate org creation during setup onboarding When users retry or refresh during the redirect after org creation, the action fires again and creates a duplicate org. Add server-side idempotency check that reuses an existing incomplete org with the same name owned by the user, plus a client-side guard against double-submission. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(app): complete partial setup when reusing existing org When reusing an existing incomplete org (idempotency path), ensure onboarding record and framework initialization exist. Handles the case where the original request failed after DB insert but before completing all setup steps. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Mariano Fuentes Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com> --- .../actions/create-organization-minimal.ts | 61 +++++++++++++++++++ .../(app)/setup/hooks/useOnboardingForm.ts | 3 + 2 files changed, 64 insertions(+) diff --git a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts index 5ea1e258bb..6478ec53bd 100644 --- a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts +++ b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts @@ -46,6 +46,67 @@ export const createOrganizationMinimal = authActionClientWithoutOrg // Check if self-hosted const isSelfHosted = env.NEXT_PUBLIC_SELF_HOSTED === 'true'; + // Idempotency: if the user already has a recently created org with the + // same name that hasn't completed onboarding, reuse it instead of + // creating a duplicate (protects against retry/refresh during redirect). + const existingOrg = await db.organization.findFirst({ + where: { + name: parsedInput.organizationName, + onboardingCompleted: false, + members: { + some: { + userId: session.user.id, + role: 'owner', + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + if (existingOrg) { + // Ensure post-creation steps are completed in case the original + // request failed partway through (after DB insert but before + // onboarding record or framework initialization). + const existingOnboarding = await db.onboarding.findUnique({ + where: { organizationId: existingOrg.id }, + }); + + if (!existingOnboarding) { + await db.onboarding.create({ + data: { + organizationId: existingOrg.id, + triggerJobCompleted: false, + }, + }); + } + + if (parsedInput.frameworkIds && parsedInput.frameworkIds.length > 0) { + const existingFrameworks = await db.frameworkInstance.findFirst({ + where: { organizationId: existingOrg.id }, + }); + + if (!existingFrameworks) { + await initializeOrganization({ + frameworkIds: parsedInput.frameworkIds, + organizationId: existingOrg.id, + }); + } + } + + // Ensure this org is set as the active one + await auth.api.setActiveOrganization({ + headers: await headers(), + body: { + organizationId: existingOrg.id, + }, + }); + + return { + success: true, + organizationId: existingOrg.id, + }; + } + // Resolve framework IDs to display names (e.g. "SOC 2", "ISO 27001") const frameworks = await db.frameworkEditorFramework.findMany({ where: { id: { in: parsedInput.frameworkIds } }, diff --git a/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts b/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts index a91d04fa05..0af292fe6c 100644 --- a/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts +++ b/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts @@ -146,6 +146,9 @@ export function useOnboardingForm({ }); const handleCreateOrganizationAction = (currentAnswers: Partial) => { + // Guard against duplicate submissions (retry/double-click) + if (isOnboarding || isFinalizing) return; + // Only pass the first 3 fields to the minimal action createOrganizationAction.execute({ frameworkIds: currentAnswers.frameworkIds || [], From cfee2f2b821c8199a132540fb197b81e15b2ebb4 Mon Sep 17 00:00:00 2001 From: claudio Date: Fri, 10 Apr 2026 12:46:53 -0400 Subject: [PATCH 14/14] chore: remove dead Resend client files from app and portal (#2499) Both apps/app and apps/portal had orphaned Resend client initializations that nothing imported. All email sending goes through Trigger.dev via the API. Co-authored-by: Claude Opus 4.6 (1M context) --- apps/app/src/utils/resend.ts | 3 --- apps/portal/src/app/lib/resend.ts | 4 ---- 2 files changed, 7 deletions(-) delete mode 100644 apps/app/src/utils/resend.ts delete mode 100644 apps/portal/src/app/lib/resend.ts diff --git a/apps/app/src/utils/resend.ts b/apps/app/src/utils/resend.ts deleted file mode 100644 index f91bc49001..0000000000 --- a/apps/app/src/utils/resend.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Resend } from 'resend'; - -export const resend = new Resend(process.env.RESEND_API_KEY!); diff --git a/apps/portal/src/app/lib/resend.ts b/apps/portal/src/app/lib/resend.ts deleted file mode 100644 index c5b5e62d89..0000000000 --- a/apps/portal/src/app/lib/resend.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { env } from '@/env.mjs'; -import { Resend } from 'resend'; - -export const resend = new Resend(env.RESEND_API_KEY);