Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
46ea6ff
chore: merge release v3.19.0 back to main [skip ci]
github-actions[bot] Apr 9, 2026
df38287
fix: suppress weekly digest emails for inactive orgs
claudfuen Apr 10, 2026
044fb51
fix: also exclude orgs without access or onboarding from weekly digest
claudfuen Apr 10, 2026
6c6f633
feat: add admin org activity endpoint with session + audit log data
claudfuen Apr 10, 2026
d0fb120
fix: use nullish coalescing for inactiveDays parameter
claudfuen Apr 10, 2026
a454aba
fix: handle NaN from parseInt for inactiveDays parameter
claudfuen Apr 10, 2026
16adc03
fix: add deactivated:false to members.some activity check
claudfuen Apr 10, 2026
65a5e7a
Merge pull request #2494 from trycompai/fix/suppress-inactive-org-emails
tofikwest Apr 10, 2026
e3e3328
Merge branch 'main' into feat/admin-org-activity-endpoint
tofikwest Apr 10, 2026
8b8270b
feat: add task, policy, and audit log counts to activity endpoint
claudfuen Apr 10, 2026
bc05e63
fix(browser-automation): fix Stagehand v3 model format and add delete…
tofikwest Apr 10, 2026
eb012b0
fix(browser-automation): use claude-sonnet-4-6 for Stagehand models
tofikwest Apr 10, 2026
59fc10b
fix: filter deactivated members from activity query and clamp inactiv…
claudfuen Apr 10, 2026
5f700cc
fix(browser-automation): hide next-run timer when all automations are…
tofikwest Apr 10, 2026
7be96c0
Merge pull request #2498 from trycompai/worktree-fix-browser-automation
tofikwest Apr 10, 2026
acb8df8
Merge branch 'main' into feat/admin-org-activity-endpoint
tofikwest Apr 10, 2026
4023cf1
fix: correct Prisma relation name policies -> policy in _count
claudfuen Apr 10, 2026
cfbeb28
Merge pull request #2495 from trycompai/feat/admin-org-activity-endpoint
tofikwest Apr 10, 2026
bd93c1c
fix(app): prevent duplicate org creation during setup onboarding
github-actions[bot] Apr 10, 2026
cfee2f2
chore: remove dead Resend client files from app and portal (#2499)
claudfuen Apr 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions apps/api/src/admin-organizations/admin-organizations.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: 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),
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) {
Expand Down
113 changes: 113 additions & 0 deletions apps/api/src/admin-organizations/admin-organizations.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,119 @@ 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<string, unknown> = {};
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, tasks: true, policy: true, auditLog: true } },
members: {
where: { deactivated: false },
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,
taskCount: org._count.tasks,
policyCount: org._count.policy,
auditLogCount: org._count.auditLog,
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 },
Expand Down
8 changes: 6 additions & 2 deletions apps/api/src/browserbase/browserbase.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-6';
const STAGEHAND_CUA_MODEL = 'anthropic/claude-sonnet-4-6';

const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const PENDING_CONTEXT_ID = '__PENDING__';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
},
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
<BrowserAutomationConfigDialog
isOpen={dialogState.open}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,17 @@

import { cn } from '@/lib/utils';
import { Button } from '@trycompai/ui/button';
import { ChevronDown, Loader2, MonitorPlay, Settings } from 'lucide-react';
import {
ChevronDown,
Loader2,
MonitorPlay,
Pencil,
Power,
PowerOff,
Trash2,
} from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { useState } from 'react';
import type { BrowserAutomation, BrowserAutomationRun } from '../../hooks/types';
import { RunHistory } from './RunHistory';

Expand All @@ -15,6 +24,8 @@ interface AutomationItemProps {
onToggleExpand: () => void;
onRun: () => void;
onEdit: () => void;
onDelete: () => void;
onToggleEnabled: (enabled: boolean) => void;
}

export function AutomationItem({
Expand All @@ -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 (
<div
className={cn(
'rounded-lg border transition-all duration-300',
isDisabled && 'opacity-60',
isExpanded
? 'border-primary/30 shadow-sm bg-primary/2'
: 'border-border/50 hover:border-border hover:shadow-sm',
Expand All @@ -51,9 +69,16 @@ export function AutomationItem({
<div className={cn('h-2.5 w-2.5 rounded-full shrink-0', dotColor)} />

<div className="flex-1 min-w-0">
<p className="font-semibold text-foreground text-sm tracking-tight">
{automation.name}
</p>
<div className="flex items-center gap-2">
<p className="font-semibold text-foreground text-sm tracking-tight">
{automation.name}
</p>
{isDisabled && (
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
Paused
</span>
)}
</div>
{latestRun ? (
<p className="text-xs text-muted-foreground mt-0.5">
Last ran {formatDistanceToNow(new Date(latestRun.createdAt), { addSuffix: true })}
Expand All @@ -63,15 +88,75 @@ export function AutomationItem({
)}
</div>

<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
{!readOnly && (
<Button variant="ghost" size="icon" onClick={onEdit} aria-label="Edit automation">
<Settings className="h-4 w-4" />
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onToggleEnabled(!automation.isEnabled)}
aria-label={automation.isEnabled ? 'Pause automation' : 'Enable automation'}
>
{automation.isEnabled ? (
<Power className="h-3.5 w-3.5 text-primary" />
) : (
<PowerOff className="h-3.5 w-3.5 text-muted-foreground" />
)}
</Button>
)}

{!readOnly && (
<Button variant="outline" size="sm" onClick={onRun} disabled={isRunning}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onEdit}
aria-label="Edit automation"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
)}

{!readOnly && (
confirmDelete ? (
<div className="flex items-center gap-1">
<Button
variant="destructive"
size="sm"
className="h-7 text-xs"
onClick={() => { onDelete(); setConfirmDelete(false); }}
>
Confirm
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setConfirmDelete(false)}
>
Cancel
</Button>
</div>
) : (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setConfirmDelete(true)}
aria-label="Delete automation"
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
</Button>
)
)}

{!readOnly && (
<Button
variant="outline"
size="sm"
onClick={onRun}
disabled={isRunning || isDisabled}
>
{isRunning ? (
<>
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ const defaultProps = {
onRun: vi.fn(),
onCreateClick: vi.fn(),
onEditClick: vi.fn(),
onDelete: vi.fn(),
onToggleEnabled: vi.fn(),
};

describe('BrowserAutomationsList permission gating', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ interface BrowserAutomationsListProps {
/** When undefined, the create button is hidden (e.g., for manual tasks) */
onCreateClick?: () => void;
onEditClick: (automation: BrowserAutomation) => void;
onDelete: (automationId: string) => void;
onToggleEnabled: (automationId: string, enabled: boolean) => void;
}

export function BrowserAutomationsList({
Expand All @@ -42,13 +44,16 @@ export function BrowserAutomationsList({
onRun,
onCreateClick,
onEditClick,
onDelete,
onToggleEnabled,
}: BrowserAutomationsListProps) {
const [expandedId, setExpandedId] = useState<string | null>(null);
const { hasPermission } = usePermissions();
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 (
<div className="rounded-lg border border-border bg-card overflow-hidden">
Expand Down Expand Up @@ -102,6 +107,8 @@ export function BrowserAutomationsList({
}
onRun={() => onRun(automation.id)}
onEdit={() => onEditClick(automation)}
onDelete={() => onDelete(automation.id)}
onToggleEnabled={(enabled) => onToggleEnabled(automation.id, enabled)}
/>
))}
</div>
Expand Down
Loading
Loading