From d8e550877e42ef2df91eb8551c27d3d77a3c4547 Mon Sep 17 00:00:00 2001
From: Mariano
Date: Fri, 24 Apr 2026 12:34:22 -0400
Subject: [PATCH 01/24] feat(api): add isDueToday scheduler helper
---
.../src/trigger/shared/is-due-today.test.ts | 85 +++++++++++++++++++
apps/api/src/trigger/shared/is-due-today.ts | 39 +++++++++
2 files changed, 124 insertions(+)
create mode 100644 apps/api/src/trigger/shared/is-due-today.test.ts
create mode 100644 apps/api/src/trigger/shared/is-due-today.ts
diff --git a/apps/api/src/trigger/shared/is-due-today.test.ts b/apps/api/src/trigger/shared/is-due-today.test.ts
new file mode 100644
index 0000000000..274c45e255
--- /dev/null
+++ b/apps/api/src/trigger/shared/is-due-today.test.ts
@@ -0,0 +1,85 @@
+import { TaskFrequency } from '@trycompai/db';
+import { isDueToday } from './is-due-today';
+
+const atUtc = (iso: string) => new Date(`${iso}T00:00:00.000Z`);
+
+describe('isDueToday', () => {
+ const now = atUtc('2026-04-24');
+
+ describe('daily', () => {
+ it('returns true when lastRunAt is null', () => {
+ expect(isDueToday({ scheduleFrequency: TaskFrequency.daily, lastRunAt: null, now })).toBe(true);
+ });
+ it('returns true even when it ran today', () => {
+ expect(
+ isDueToday({ scheduleFrequency: TaskFrequency.daily, lastRunAt: atUtc('2026-04-24'), now }),
+ ).toBe(true);
+ });
+ });
+
+ describe('weekly', () => {
+ it('returns true when lastRunAt is null', () => {
+ expect(isDueToday({ scheduleFrequency: TaskFrequency.weekly, lastRunAt: null, now })).toBe(true);
+ });
+ it('returns false when lastRunAt is 6 days ago', () => {
+ expect(
+ isDueToday({ scheduleFrequency: TaskFrequency.weekly, lastRunAt: atUtc('2026-04-18'), now }),
+ ).toBe(false);
+ });
+ it('returns true when lastRunAt is exactly 7 days ago', () => {
+ expect(
+ isDueToday({ scheduleFrequency: TaskFrequency.weekly, lastRunAt: atUtc('2026-04-17'), now }),
+ ).toBe(true);
+ });
+ it('returns true when lastRunAt is 14 days ago', () => {
+ expect(
+ isDueToday({ scheduleFrequency: TaskFrequency.weekly, lastRunAt: atUtc('2026-04-10'), now }),
+ ).toBe(true);
+ });
+ });
+
+ describe('monthly', () => {
+ it('returns false when lastRunAt is 29 days ago but same calendar month', () => {
+ // 2026-03-26 → 2026-04-24 is 29 days, crosses a month boundary; should be true
+ expect(
+ isDueToday({ scheduleFrequency: TaskFrequency.monthly, lastRunAt: atUtc('2026-03-26'), now }),
+ ).toBe(true);
+ });
+ it('returns false when lastRunAt is same calendar month', () => {
+ expect(
+ isDueToday({ scheduleFrequency: TaskFrequency.monthly, lastRunAt: atUtc('2026-04-01'), now }),
+ ).toBe(false);
+ });
+ it('returns true when lastRunAt is null', () => {
+ expect(
+ isDueToday({ scheduleFrequency: TaskFrequency.monthly, lastRunAt: null, now }),
+ ).toBe(true);
+ });
+ });
+
+ describe('quarterly', () => {
+ it('returns false when lastRunAt is 2 months ago', () => {
+ expect(
+ isDueToday({ scheduleFrequency: TaskFrequency.quarterly, lastRunAt: atUtc('2026-02-24'), now }),
+ ).toBe(false);
+ });
+ it('returns true when lastRunAt is 3 months ago', () => {
+ expect(
+ isDueToday({ scheduleFrequency: TaskFrequency.quarterly, lastRunAt: atUtc('2026-01-24'), now }),
+ ).toBe(true);
+ });
+ });
+
+ describe('yearly', () => {
+ it('returns false when lastRunAt is 11 months ago', () => {
+ expect(
+ isDueToday({ scheduleFrequency: TaskFrequency.yearly, lastRunAt: atUtc('2025-05-24'), now }),
+ ).toBe(false);
+ });
+ it('returns true when lastRunAt is 12 months ago', () => {
+ expect(
+ isDueToday({ scheduleFrequency: TaskFrequency.yearly, lastRunAt: atUtc('2025-04-24'), now }),
+ ).toBe(true);
+ });
+ });
+});
diff --git a/apps/api/src/trigger/shared/is-due-today.ts b/apps/api/src/trigger/shared/is-due-today.ts
new file mode 100644
index 0000000000..07f675b1d9
--- /dev/null
+++ b/apps/api/src/trigger/shared/is-due-today.ts
@@ -0,0 +1,39 @@
+import { TaskFrequency } from '@trycompai/db';
+
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+
+function calendarMonthsBetween(earlier: Date, later: Date): number {
+ const years = later.getUTCFullYear() - earlier.getUTCFullYear();
+ const months = later.getUTCMonth() - earlier.getUTCMonth();
+ return years * 12 + months;
+}
+
+export function isDueToday({
+ scheduleFrequency,
+ lastRunAt,
+ now,
+}: {
+ scheduleFrequency: TaskFrequency;
+ lastRunAt: Date | null;
+ now: Date;
+}): boolean {
+ if (scheduleFrequency === TaskFrequency.daily) return true;
+ if (lastRunAt === null) return true;
+
+ switch (scheduleFrequency) {
+ case TaskFrequency.weekly: {
+ const days = Math.floor((now.getTime() - lastRunAt.getTime()) / MS_PER_DAY);
+ return days >= 7;
+ }
+ case TaskFrequency.monthly:
+ return calendarMonthsBetween(lastRunAt, now) >= 1;
+ case TaskFrequency.quarterly:
+ return calendarMonthsBetween(lastRunAt, now) >= 3;
+ case TaskFrequency.yearly:
+ return calendarMonthsBetween(lastRunAt, now) >= 12;
+ default: {
+ const _exhaustive: never = scheduleFrequency;
+ return _exhaustive;
+ }
+ }
+}
From 647bbfe6dbf2f47739a0db17745ecaf6b8489349 Mon Sep 17 00:00:00 2001
From: Mariano
Date: Fri, 24 Apr 2026 12:37:42 -0400
Subject: [PATCH 02/24] test(api): use .spec.ts suffix and fix misleading test
title
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../shared/{is-due-today.test.ts => is-due-today.spec.ts} | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
rename apps/api/src/trigger/shared/{is-due-today.test.ts => is-due-today.spec.ts} (94%)
diff --git a/apps/api/src/trigger/shared/is-due-today.test.ts b/apps/api/src/trigger/shared/is-due-today.spec.ts
similarity index 94%
rename from apps/api/src/trigger/shared/is-due-today.test.ts
rename to apps/api/src/trigger/shared/is-due-today.spec.ts
index 274c45e255..b919950285 100644
--- a/apps/api/src/trigger/shared/is-due-today.test.ts
+++ b/apps/api/src/trigger/shared/is-due-today.spec.ts
@@ -39,8 +39,8 @@ describe('isDueToday', () => {
});
describe('monthly', () => {
- it('returns false when lastRunAt is 29 days ago but same calendar month', () => {
- // 2026-03-26 → 2026-04-24 is 29 days, crosses a month boundary; should be true
+ it('returns true when lastRunAt crossed a calendar-month boundary', () => {
+ // 2026-03-26 → 2026-04-24: different calendar month → due
expect(
isDueToday({ scheduleFrequency: TaskFrequency.monthly, lastRunAt: atUtc('2026-03-26'), now }),
).toBe(true);
From 7f77a990617eb2d431fbd15eb073ced90854985d Mon Sep 17 00:00:00 2001
From: Mariano
Date: Fri, 24 Apr 2026 12:44:36 -0400
Subject: [PATCH 03/24] refactor(api): harden isDueToday exhaustive check and
document UTC contract
Co-Authored-By: Claude Opus 4.7 (1M context)
---
apps/api/src/trigger/shared/is-due-today.ts | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/apps/api/src/trigger/shared/is-due-today.ts b/apps/api/src/trigger/shared/is-due-today.ts
index 07f675b1d9..3daa85dfd1 100644
--- a/apps/api/src/trigger/shared/is-due-today.ts
+++ b/apps/api/src/trigger/shared/is-due-today.ts
@@ -8,6 +8,16 @@ function calendarMonthsBetween(earlier: Date, later: Date): number {
return years * 12 + months;
}
+/**
+ * Returns whether an automation with the given schedule is due to run at `now`.
+ *
+ * `now` and `lastRunAt` are treated as UTC instants — weekly math uses fixed
+ * 86_400_000-ms days, monthly/quarterly/yearly use UTC calendar buckets. Callers
+ * should pass real `Date` values (any instant works); do NOT pass "midnight in
+ * local time" expecting DST-aware behavior.
+ *
+ * `null` lastRunAt always returns `true` (first run).
+ */
export function isDueToday({
scheduleFrequency,
lastRunAt,
@@ -33,7 +43,7 @@ export function isDueToday({
return calendarMonthsBetween(lastRunAt, now) >= 12;
default: {
const _exhaustive: never = scheduleFrequency;
- return _exhaustive;
+ throw new Error(`Unhandled TaskFrequency: ${String(_exhaustive)}`);
}
}
}
From 576aaa622b22694ab0e9aa8916ad9e75817a4078 Mon Sep 17 00:00:00 2001
From: Mariano
Date: Fri, 24 Apr 2026 12:47:37 -0400
Subject: [PATCH 04/24] feat(db): add per-automation scheduleFrequency and
lastRunAt
---
.../src/browserbase/browserbase.service.ts | 2 -
.../migration.sql | 18 +
packages/db/prisma/schema/auth.prisma | 28 +-
packages/db/prisma/schema/automation.prisma | 12 +-
.../prisma/schema/browserbase-context.prisma | 121 ++--
packages/db/prisma/schema/control.prisma | 14 +-
packages/db/prisma/schema/device.prisma | 14 +-
.../prisma/schema/dynamic-integration.prisma | 150 ++---
.../db/prisma/schema/framework-editor.prisma | 4 +-
.../db/prisma/schema/framework-version.prisma | 20 +-
.../prisma/schema/integration-platform.prisma | 544 +++++++++---------
.../prisma/schema/integration-sync-log.prisma | 6 +-
.../prisma/schema/notification-policy.prisma | 8 +-
.../prisma/schema/organization-billing.prisma | 2 +-
packages/db/prisma/schema/organization.prisma | 38 +-
packages/db/prisma/schema/policy.prisma | 6 +-
.../prisma/schema/remediation-action.prisma | 48 +-
.../db/prisma/schema/remediation-batch.prisma | 32 +-
.../security-penetration-test-run.prisma | 10 +-
packages/db/prisma/schema/soa.prisma | 4 +-
packages/db/prisma/schema/task-item.prisma | 28 +-
packages/db/prisma/schema/task.prisma | 22 +-
packages/db/prisma/schema/timeline.prisma | 110 ++--
23 files changed, 630 insertions(+), 611 deletions(-)
create mode 100644 packages/db/prisma/migrations/20260424164600_custom_task_schedules/migration.sql
diff --git a/apps/api/src/browserbase/browserbase.service.ts b/apps/api/src/browserbase/browserbase.service.ts
index 8e388fbc13..2cffab3473 100644
--- a/apps/api/src/browserbase/browserbase.service.ts
+++ b/apps/api/src/browserbase/browserbase.service.ts
@@ -353,7 +353,6 @@ export class BrowserbaseService {
targetUrl: string;
instruction: string;
evaluationCriteria?: string;
- schedule?: string;
}) {
return db.browserAutomation.create({
data: {
@@ -363,7 +362,6 @@ export class BrowserbaseService {
targetUrl: data.targetUrl,
instruction: data.instruction,
evaluationCriteria: normalizeCriteria(data.evaluationCriteria),
- schedule: data.schedule,
isEnabled: true, // Enable by default so scheduled runs work
},
});
diff --git a/packages/db/prisma/migrations/20260424164600_custom_task_schedules/migration.sql b/packages/db/prisma/migrations/20260424164600_custom_task_schedules/migration.sql
new file mode 100644
index 0000000000..f8cde3c627
--- /dev/null
+++ b/packages/db/prisma/migrations/20260424164600_custom_task_schedules/migration.sql
@@ -0,0 +1,18 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `schedule` on the `BrowserAutomation` table. All the data in the column will be lost.
+
+*/
+-- AlterTable
+ALTER TABLE "BrowserAutomation" DROP COLUMN "schedule",
+ADD COLUMN "lastRunAt" TIMESTAMP(3),
+ADD COLUMN "scheduleFrequency" "TaskFrequency" NOT NULL DEFAULT 'daily';
+
+-- AlterTable
+ALTER TABLE "EvidenceAutomation" ADD COLUMN "lastRunAt" TIMESTAMP(3),
+ADD COLUMN "scheduleFrequency" "TaskFrequency" NOT NULL DEFAULT 'daily';
+
+-- AlterTable
+ALTER TABLE "Task" ADD COLUMN "integrationLastRunAt" TIMESTAMP(3),
+ADD COLUMN "integrationScheduleFrequency" "TaskFrequency" NOT NULL DEFAULT 'daily';
diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma
index 05502d9017..e823f31472 100644
--- a/packages/db/prisma/schema/auth.prisma
+++ b/packages/db/prisma/schema/auth.prisma
@@ -15,20 +15,20 @@ model User {
banExpires DateTime?
isPlatformAdmin Boolean @default(false)
- accounts Account[]
- auditLog AuditLog[]
- integrationResults IntegrationResult[]
- invitations Invitation[]
- members Member[]
- sessions Session[]
- fleetPolicyResults FleetPolicyResult[]
- evidenceSubmissions EvidenceSubmission[] @relation("EvidenceSubmitter")
- evidenceReviews EvidenceSubmission[] @relation("EvidenceReviewer")
- adminFindings Finding[] @relation("AdminFindingCreator")
- timelinePhaseCompletions TimelinePhase[]
- lockedTimelineInstances TimelineInstance[] @relation("TimelineInstanceLockedBy")
- unlockedTimelineInstances TimelineInstance[] @relation("TimelineInstanceUnlockedBy")
- publishedFrameworkVersions FrameworkVersion[] @relation("FrameworkVersionPublisher")
+ accounts Account[]
+ auditLog AuditLog[]
+ integrationResults IntegrationResult[]
+ invitations Invitation[]
+ members Member[]
+ sessions Session[]
+ fleetPolicyResults FleetPolicyResult[]
+ evidenceSubmissions EvidenceSubmission[] @relation("EvidenceSubmitter")
+ evidenceReviews EvidenceSubmission[] @relation("EvidenceReviewer")
+ adminFindings Finding[] @relation("AdminFindingCreator")
+ timelinePhaseCompletions TimelinePhase[]
+ lockedTimelineInstances TimelineInstance[] @relation("TimelineInstanceLockedBy")
+ unlockedTimelineInstances TimelineInstance[] @relation("TimelineInstanceUnlockedBy")
+ publishedFrameworkVersions FrameworkVersion[] @relation("FrameworkVersionPublisher")
@@unique([email])
}
diff --git a/packages/db/prisma/schema/automation.prisma b/packages/db/prisma/schema/automation.prisma
index f9c0501779..8f14424f6d 100644
--- a/packages/db/prisma/schema/automation.prisma
+++ b/packages/db/prisma/schema/automation.prisma
@@ -1,9 +1,11 @@
model EvidenceAutomation {
- id String @id @default(dbgenerated("generate_prefixed_cuid('aut'::text)"))
- name String
- description String?
- createdAt DateTime @default(now())
- isEnabled Boolean @default(false)
+ id String @id @default(dbgenerated("generate_prefixed_cuid('aut'::text)"))
+ name String
+ description String?
+ createdAt DateTime @default(now())
+ isEnabled Boolean @default(false)
+ scheduleFrequency TaskFrequency @default(daily)
+ lastRunAt DateTime?
chatHistory String?
evaluationCriteria String?
diff --git a/packages/db/prisma/schema/browserbase-context.prisma b/packages/db/prisma/schema/browserbase-context.prisma
index 5841a1021b..9c29d19778 100644
--- a/packages/db/prisma/schema/browserbase-context.prisma
+++ b/packages/db/prisma/schema/browserbase-context.prisma
@@ -1,101 +1,100 @@
/// Stores Browserbase context IDs for browser-based automation
/// One context per organization - shared like a normal browser
model BrowserbaseContext {
- id String @id @default(dbgenerated("generate_prefixed_cuid('bbc'::text)"))
+ id String @id @default(dbgenerated("generate_prefixed_cuid('bbc'::text)"))
- /// Organization that owns this browser context
- organizationId String @unique
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ /// Organization that owns this browser context
+ organizationId String @unique
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- /// Browserbase context ID from their API
- contextId String
+ /// Browserbase context ID from their API
+ contextId String
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
- @@index([organizationId])
+ @@index([organizationId])
}
/// Browser automation configuration linked to a task
model BrowserAutomation {
- id String @id @default(dbgenerated("generate_prefixed_cuid('bau'::text)"))
- name String
- description String?
+ id String @id @default(dbgenerated("generate_prefixed_cuid('bau'::text)"))
+ name String
+ description String?
- /// Task this automation belongs to
- taskId String
- task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
+ /// Task this automation belongs to
+ taskId String
+ task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
- /// Starting URL for the automation
- targetUrl String
+ /// Starting URL for the automation
+ targetUrl String
- /// Natural language instruction for the AI agent
- instruction String
+ /// Natural language instruction for the AI agent
+ instruction String
- /// Optional natural-language criteria used to evaluate whether the
- /// automation's outcome satisfies the auditor's requirement.
- /// When null, runs don't produce a pass/fail verdict — only a screenshot.
- evaluationCriteria String?
+ /// Optional natural-language criteria used to evaluate whether the
+ /// automation's outcome satisfies the auditor's requirement.
+ /// When null, runs don't produce a pass/fail verdict — only a screenshot.
+ evaluationCriteria String?
- /// Whether automation is enabled for scheduled runs
- isEnabled Boolean @default(false)
+ /// Whether automation is enabled for scheduled runs
+ isEnabled Boolean @default(false)
+ scheduleFrequency TaskFrequency @default(daily)
+ lastRunAt DateTime?
- /// Cron expression for scheduled runs (null = manual only)
- schedule String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ runs BrowserAutomationRun[]
- runs BrowserAutomationRun[]
-
- @@index([taskId])
+ @@index([taskId])
}
/// Records of browser automation executions
model BrowserAutomationRun {
- id String @id @default(dbgenerated("generate_prefixed_cuid('bar'::text)"))
+ id String @id @default(dbgenerated("generate_prefixed_cuid('bar'::text)"))
- /// Parent automation
- automationId String
- automation BrowserAutomation @relation(fields: [automationId], references: [id], onDelete: Cascade)
+ /// Parent automation
+ automationId String
+ automation BrowserAutomation @relation(fields: [automationId], references: [id], onDelete: Cascade)
- /// Execution status
- status BrowserAutomationRunStatus @default(pending)
+ /// Execution status
+ status BrowserAutomationRunStatus @default(pending)
- /// Timestamps
- startedAt DateTime?
- completedAt DateTime?
+ /// Timestamps
+ startedAt DateTime?
+ completedAt DateTime?
- /// Duration in milliseconds
- durationMs Int?
+ /// Duration in milliseconds
+ durationMs Int?
- /// Screenshot URL in S3 (if successful)
- screenshotUrl String?
+ /// Screenshot URL in S3 (if successful)
+ screenshotUrl String?
- /// Evaluation result - whether the automation fulfilled the task requirements
- evaluationStatus BrowserAutomationEvaluationStatus?
+ /// Evaluation result - whether the automation fulfilled the task requirements
+ evaluationStatus BrowserAutomationEvaluationStatus?
- /// AI explanation of why it passed or failed
- evaluationReason String?
+ /// AI explanation of why it passed or failed
+ evaluationReason String?
- /// Error message (if failed)
- error String?
+ /// Error message (if failed)
+ error String?
- createdAt DateTime @default(now())
+ createdAt DateTime @default(now())
- @@index([automationId])
- @@index([status])
- @@index([createdAt])
+ @@index([automationId])
+ @@index([status])
+ @@index([createdAt])
}
enum BrowserAutomationEvaluationStatus {
- pass
- fail
+ pass
+ fail
}
enum BrowserAutomationRunStatus {
- pending
- running
- completed
- failed
+ pending
+ running
+ completed
+ failed
}
diff --git a/packages/db/prisma/schema/control.prisma b/packages/db/prisma/schema/control.prisma
index 14672cb972..0a70fc5a9e 100644
--- a/packages/db/prisma/schema/control.prisma
+++ b/packages/db/prisma/schema/control.prisma
@@ -15,13 +15,13 @@ model Control {
archivedAt DateTime?
// Relationships
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- organizationId String
- requirementsMapped RequirementMap[]
- tasks Task[]
- policies Policy[]
- controlTemplateId String?
- controlTemplate FrameworkEditorControlTemplate? @relation(fields: [controlTemplateId], references: [id])
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ organizationId String
+ requirementsMapped RequirementMap[]
+ tasks Task[]
+ policies Policy[]
+ controlTemplateId String?
+ controlTemplate FrameworkEditorControlTemplate? @relation(fields: [controlTemplateId], references: [id])
controlDocumentTypes ControlDocumentType[]
@@index([organizationId])
diff --git a/packages/db/prisma/schema/device.prisma b/packages/db/prisma/schema/device.prisma
index 68d5af86f8..3a609c3eb7 100644
--- a/packages/db/prisma/schema/device.prisma
+++ b/packages/db/prisma/schema/device.prisma
@@ -15,17 +15,17 @@ model Device {
agentSessionId String?
agentSession Session? @relation("DeviceAgentSession", fields: [agentSessionId], references: [id], onDelete: SetNull)
- isCompliant Boolean @default(false)
- diskEncryptionEnabled Boolean @default(false)
- antivirusEnabled Boolean @default(false)
- passwordPolicySet Boolean @default(false)
- screenLockEnabled Boolean @default(false)
+ isCompliant Boolean @default(false)
+ diskEncryptionEnabled Boolean @default(false)
+ antivirusEnabled Boolean @default(false)
+ passwordPolicySet Boolean @default(false)
+ screenLockEnabled Boolean @default(false)
checkDetails Json?
lastCheckIn DateTime?
agentVersion String?
- installedAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ installedAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
findings Finding[]
diff --git a/packages/db/prisma/schema/dynamic-integration.prisma b/packages/db/prisma/schema/dynamic-integration.prisma
index 7aac3b288a..58163d17de 100644
--- a/packages/db/prisma/schema/dynamic-integration.prisma
+++ b/packages/db/prisma/schema/dynamic-integration.prisma
@@ -4,95 +4,95 @@
/// Stores a full integration manifest as JSON — replaces hand-written TypeScript manifests
model DynamicIntegration {
- id String @id @default(dbgenerated("generate_prefixed_cuid('din'::text)"))
- /// Unique slug (e.g., "azure-devops", "office-365")
- slug String @unique
- /// Display name
- name String
- /// Short description for catalog
- description String
- /// Category for grouping
- category String
- /// Logo URL
- logoUrl String
- /// URL to documentation
- docsUrl String?
-
- /// API base URL for ctx.fetch
- baseUrl String?
- /// Default headers (JSON object)
- defaultHeaders Json?
-
- /// Auth strategy config (JSON — matches AuthStrategy type: oauth2/api_key/basic/jwt/custom)
- authConfig Json
-
- /// Capabilities JSON array (default ["checks"])
- capabilities Json @default("[\"checks\"]")
-
- /// Whether multiple connections per org are allowed
- supportsMultipleConnections Boolean @default(false)
-
- /// Declarative sync definition (JSON — DSL steps that produce employee list)
- /// When present and capabilities includes 'sync', enables employee sync
- syncDefinition Json?
-
- /// Services metadata (JSON array of { id, name, description, enabledByDefault?, implemented? })
- services Json?
-
- /// Whether this dynamic integration is active
- isActive Boolean @default(true)
-
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
-
- checks DynamicCheck[]
-
- @@index([slug])
- @@index([category])
- @@index([isActive])
+ id String @id @default(dbgenerated("generate_prefixed_cuid('din'::text)"))
+ /// Unique slug (e.g., "azure-devops", "office-365")
+ slug String @unique
+ /// Display name
+ name String
+ /// Short description for catalog
+ description String
+ /// Category for grouping
+ category String
+ /// Logo URL
+ logoUrl String
+ /// URL to documentation
+ docsUrl String?
+
+ /// API base URL for ctx.fetch
+ baseUrl String?
+ /// Default headers (JSON object)
+ defaultHeaders Json?
+
+ /// Auth strategy config (JSON — matches AuthStrategy type: oauth2/api_key/basic/jwt/custom)
+ authConfig Json
+
+ /// Capabilities JSON array (default ["checks"])
+ capabilities Json @default("[\"checks\"]")
+
+ /// Whether multiple connections per org are allowed
+ supportsMultipleConnections Boolean @default(false)
+
+ /// Declarative sync definition (JSON — DSL steps that produce employee list)
+ /// When present and capabilities includes 'sync', enables employee sync
+ syncDefinition Json?
+
+ /// Services metadata (JSON array of { id, name, description, enabledByDefault?, implemented? })
+ services Json?
+
+ /// Whether this dynamic integration is active
+ isActive Boolean @default(true)
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ checks DynamicCheck[]
+
+ @@index([slug])
+ @@index([category])
+ @@index([isActive])
}
/// Stores a declarative check definition — DSL JSON replaces hand-written run() functions
model DynamicCheck {
- id String @id @default(dbgenerated("generate_prefixed_cuid('dck'::text)"))
+ id String @id @default(dbgenerated("generate_prefixed_cuid('dck'::text)"))
- /// Parent integration
- integrationId String
- integration DynamicIntegration @relation(fields: [integrationId], references: [id], onDelete: Cascade)
+ /// Parent integration
+ integrationId String
+ integration DynamicIntegration @relation(fields: [integrationId], references: [id], onDelete: Cascade)
- /// Unique slug within integration (e.g., "mfa_enabled")
- checkSlug String
+ /// Unique slug within integration (e.g., "mfa_enabled")
+ checkSlug String
- /// Human-readable name
- name String
- /// Description of what this check does
- description String
+ /// Human-readable name
+ name String
+ /// Description of what this check does
+ description String
- /// Task template ID for auto-completion (references TASK_TEMPLATES)
- taskMapping String?
+ /// Task template ID for auto-completion (references TASK_TEMPLATES)
+ taskMapping String?
- /// Default severity for findings
- defaultSeverity String @default("medium")
+ /// Default severity for findings
+ defaultSeverity String @default("medium")
- /// Service ID this check belongs to (groups checks under a service)
- service String?
+ /// Service ID this check belongs to (groups checks under a service)
+ service String?
- /// Declarative DSL definition (JSON — the step-by-step instructions)
- definition Json
+ /// Declarative DSL definition (JSON — the step-by-step instructions)
+ definition Json
- /// Check-level variables (JSON array of CheckVariable)
- variables Json @default("[]")
+ /// Check-level variables (JSON array of CheckVariable)
+ variables Json @default("[]")
- /// Whether this check is enabled
- isEnabled Boolean @default(true)
+ /// Whether this check is enabled
+ isEnabled Boolean @default(true)
- /// Display order
- sortOrder Int @default(0)
+ /// Display order
+ sortOrder Int @default(0)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
- @@unique([integrationId, checkSlug])
- @@index([integrationId])
- @@index([isEnabled])
+ @@unique([integrationId, checkSlug])
+ @@index([integrationId])
+ @@index([isEnabled])
}
diff --git a/packages/db/prisma/schema/framework-editor.prisma b/packages/db/prisma/schema/framework-editor.prisma
index ee06645f89..6bb6e302f5 100644
--- a/packages/db/prisma/schema/framework-editor.prisma
+++ b/packages/db/prisma/schema/framework-editor.prisma
@@ -69,8 +69,8 @@ model FrameworkEditorTaskTemplate {
id String @id @default(dbgenerated("generate_prefixed_cuid('frk_tt'::text)"))
name String
description String
- frequency Frequency // Using the enum from shared.prisma
- department Departments // Using the enum from shared.prisma
+ frequency Frequency // Using the enum from shared.prisma
+ department Departments // Using the enum from shared.prisma
automationStatus TaskAutomationStatus @default(AUTOMATED)
controlTemplates FrameworkEditorControlTemplate[]
diff --git a/packages/db/prisma/schema/framework-version.prisma b/packages/db/prisma/schema/framework-version.prisma
index 9708c3f40c..40c5ca7709 100644
--- a/packages/db/prisma/schema/framework-version.prisma
+++ b/packages/db/prisma/schema/framework-version.prisma
@@ -1,20 +1,20 @@
model FrameworkVersion {
- id String @id @default(dbgenerated("generate_prefixed_cuid('fvr'::text)"))
- frameworkId String
- framework FrameworkEditorFramework @relation(fields: [frameworkId], references: [id], onDelete: Cascade)
+ id String @id @default(dbgenerated("generate_prefixed_cuid('fvr'::text)"))
+ frameworkId String
+ framework FrameworkEditorFramework @relation(fields: [frameworkId], references: [id], onDelete: Cascade)
- version String // semver-ish, e.g., "1.0.0", "2.1.0"
- publishedAt DateTime @default(now())
- publishedById String?
- publishedBy User? @relation("FrameworkVersionPublisher", fields: [publishedById], references: [id], onDelete: SetNull)
+ version String // semver-ish, e.g., "1.0.0", "2.1.0"
+ publishedAt DateTime @default(now())
+ publishedById String?
+ publishedBy User? @relation("FrameworkVersionPublisher", fields: [publishedById], references: [id], onDelete: SetNull)
- releaseNotes String? // markdown
+ releaseNotes String? // markdown
// Full snapshot of all templates at publish time (see manifest.types.ts).
// Immutable once published.
- manifest Json
+ manifest Json
- frameworkInstances FrameworkInstance[] @relation("FrameworkInstanceCurrentVersion")
+ frameworkInstances FrameworkInstance[] @relation("FrameworkInstanceCurrentVersion")
syncOperationsFrom FrameworkSyncOperation[] @relation("FrameworkSyncOperationFromVersion")
syncOperationsTo FrameworkSyncOperation[] @relation("FrameworkSyncOperationToVersion")
diff --git a/packages/db/prisma/schema/integration-platform.prisma b/packages/db/prisma/schema/integration-platform.prisma
index 8b28153ac7..98cd949e9e 100644
--- a/packages/db/prisma/schema/integration-platform.prisma
+++ b/packages/db/prisma/schema/integration-platform.prisma
@@ -3,424 +3,424 @@
/// Stores metadata about available integration providers (synced from code manifests)
model IntegrationProvider {
- id String @id @default(dbgenerated("generate_prefixed_cuid('prv'::text)"))
- /// Unique slug matching manifest ID (e.g., "github", "slack")
- slug String @unique
- /// Display name
- name String
- /// Category for grouping
- category String
- /// Hash of manifest for detecting changes
- manifestHash String?
- /// Capabilities JSON array
- capabilities Json @default("[]")
- /// Whether provider is active
- isActive Boolean @default(true)
-
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
-
- connections IntegrationConnection[]
-
- @@index([slug])
- @@index([category])
+ id String @id @default(dbgenerated("generate_prefixed_cuid('prv'::text)"))
+ /// Unique slug matching manifest ID (e.g., "github", "slack")
+ slug String @unique
+ /// Display name
+ name String
+ /// Category for grouping
+ category String
+ /// Hash of manifest for detecting changes
+ manifestHash String?
+ /// Capabilities JSON array
+ capabilities Json @default("[]")
+ /// Whether provider is active
+ isActive Boolean @default(true)
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ connections IntegrationConnection[]
+
+ @@index([slug])
+ @@index([category])
}
/// Represents an organization's connection to an integration provider
model IntegrationConnection {
- id String @id @default(dbgenerated("generate_prefixed_cuid('icn'::text)"))
+ id String @id @default(dbgenerated("generate_prefixed_cuid('icn'::text)"))
- /// Reference to the provider
- providerId String
- provider IntegrationProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
+ /// Reference to the provider
+ providerId String
+ provider IntegrationProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
- /// Organization that owns this connection
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ /// Organization that owns this connection
+ organizationId String
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- /// Connection status
- status IntegrationConnectionStatus @default(pending)
+ /// Connection status
+ status IntegrationConnectionStatus @default(pending)
- /// Auth strategy used (oauth2, api_key, basic, jwt, custom)
- authStrategy String
+ /// Auth strategy used (oauth2, api_key, basic, jwt, custom)
+ authStrategy String
- /// Reference to active credential version
- activeCredentialVersionId String?
+ /// Reference to active credential version
+ activeCredentialVersionId String?
- /// Last successful sync timestamp
- lastSyncAt DateTime?
+ /// Last successful sync timestamp
+ lastSyncAt DateTime?
- /// Next scheduled sync timestamp
- nextSyncAt DateTime?
+ /// Next scheduled sync timestamp
+ nextSyncAt DateTime?
- /// Custom sync cadence (cron expression), null = use default
- syncCadence String?
+ /// Custom sync cadence (cron expression), null = use default
+ syncCadence String?
- /// Additional metadata (e.g., connected account info)
- metadata Json?
+ /// Additional metadata (e.g., connected account info)
+ metadata Json?
- /// User-configured variables for checks (collected after OAuth)
- variables Json?
+ /// User-configured variables for checks (collected after OAuth)
+ variables Json?
- /// Error message if status is error
- errorMessage String?
+ /// Error message if status is error
+ errorMessage String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
- credentialVersions IntegrationCredentialVersion[]
- runs IntegrationRun[]
- findings IntegrationPlatformFinding[]
- checkRuns IntegrationCheckRun[]
- syncLogs IntegrationSyncLog[]
- remediationActions RemediationAction[]
- remediationBatches RemediationBatch[]
+ credentialVersions IntegrationCredentialVersion[]
+ runs IntegrationRun[]
+ findings IntegrationPlatformFinding[]
+ checkRuns IntegrationCheckRun[]
+ syncLogs IntegrationSyncLog[]
+ remediationActions RemediationAction[]
+ remediationBatches RemediationBatch[]
- @@index([organizationId])
- @@index([providerId])
- @@index([providerId, organizationId])
- @@index([status])
+ @@index([organizationId])
+ @@index([providerId])
+ @@index([providerId, organizationId])
+ @@index([status])
}
enum IntegrationConnectionStatus {
- pending // Awaiting credential setup
- active // Connected and operational
- error // Connection has errors
- paused // Manually paused by user
- disconnected // User disconnected
+ pending // Awaiting credential setup
+ active // Connected and operational
+ error // Connection has errors
+ paused // Manually paused by user
+ disconnected // User disconnected
}
/// Stores encrypted credentials with versioning for audit trail
model IntegrationCredentialVersion {
- id String @id @default(dbgenerated("generate_prefixed_cuid('icv'::text)"))
+ id String @id @default(dbgenerated("generate_prefixed_cuid('icv'::text)"))
- /// Parent connection
- connectionId String
- connection IntegrationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
+ /// Parent connection
+ connectionId String
+ connection IntegrationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
- /// Encrypted credential payload (JSON with encrypted fields)
- encryptedPayload Json
+ /// Encrypted credential payload (JSON with encrypted fields)
+ encryptedPayload Json
- /// Version number (auto-increment per connection)
- version Int
+ /// Version number (auto-increment per connection)
+ version Int
- /// Token expiration (for OAuth tokens)
- expiresAt DateTime?
+ /// Token expiration (for OAuth tokens)
+ expiresAt DateTime?
- /// When this version was rotated/replaced
- rotatedAt DateTime?
+ /// When this version was rotated/replaced
+ rotatedAt DateTime?
- createdAt DateTime @default(now())
+ createdAt DateTime @default(now())
- @@unique([connectionId, version])
- @@index([connectionId])
+ @@unique([connectionId, version])
+ @@index([connectionId])
}
/// Records each sync/job execution for audit and debugging
model IntegrationRun {
- id String @id @default(dbgenerated("generate_prefixed_cuid('irn'::text)"))
+ id String @id @default(dbgenerated("generate_prefixed_cuid('irn'::text)"))
- /// Parent connection
- connectionId String
- connection IntegrationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
+ /// Parent connection
+ connectionId String
+ connection IntegrationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
- /// Type of job
- jobType IntegrationRunJobType
+ /// Type of job
+ jobType IntegrationRunJobType
- /// Execution status
- status IntegrationRunStatus @default(pending)
+ /// Execution status
+ status IntegrationRunStatus @default(pending)
- /// Timestamps
- startedAt DateTime?
- completedAt DateTime?
+ /// Timestamps
+ startedAt DateTime?
+ completedAt DateTime?
- /// Duration in milliseconds
- durationMs Int?
+ /// Duration in milliseconds
+ durationMs Int?
- /// Number of findings from this run
- findingsCount Int @default(0)
+ /// Number of findings from this run
+ findingsCount Int @default(0)
- /// Error details if failed
- error Json?
+ /// Error details if failed
+ error Json?
- /// Additional metadata (trigger source, cursor, etc.)
- metadata Json?
+ /// Additional metadata (trigger source, cursor, etc.)
+ metadata Json?
- createdAt DateTime @default(now())
+ createdAt DateTime @default(now())
- findings IntegrationPlatformFinding[]
+ findings IntegrationPlatformFinding[]
- @@index([connectionId])
- @@index([status])
- @@index([createdAt])
+ @@index([connectionId])
+ @@index([status])
+ @@index([createdAt])
}
enum IntegrationRunJobType {
- full_sync
- delta_sync
- webhook
- manual
- test_connection
+ full_sync
+ delta_sync
+ webhook
+ manual
+ test_connection
}
enum IntegrationRunStatus {
- pending
- running
- success
- failed
- cancelled
+ pending
+ running
+ success
+ failed
+ cancelled
}
/// Stores findings/results from integration syncs
model IntegrationPlatformFinding {
- id String @id @default(dbgenerated("generate_prefixed_cuid('ipf'::text)"))
+ id String @id @default(dbgenerated("generate_prefixed_cuid('ipf'::text)"))
- /// Parent run (optional - webhooks may not have runs)
- runId String?
- run IntegrationRun? @relation(fields: [runId], references: [id], onDelete: SetNull)
+ /// Parent run (optional - webhooks may not have runs)
+ runId String?
+ run IntegrationRun? @relation(fields: [runId], references: [id], onDelete: SetNull)
- /// Parent connection
- connectionId String
- connection IntegrationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
+ /// Parent connection
+ connectionId String
+ connection IntegrationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
- /// Resource classification
- resourceType String
- resourceId String
+ /// Resource classification
+ resourceType String
+ resourceId String
- /// Finding details
- title String
- description String?
+ /// Finding details
+ title String
+ description String?
- /// Severity level
- severity IntegrationFindingSeverity @default(info)
+ /// Severity level
+ severity IntegrationFindingSeverity @default(info)
- /// Finding status
- status IntegrationFindingStatus @default(open)
+ /// Finding status
+ status IntegrationFindingStatus @default(open)
- /// Remediation guidance
- remediation String?
+ /// Remediation guidance
+ remediation String?
- /// Raw payload from provider
- rawPayload Json?
+ /// Raw payload from provider
+ rawPayload Json?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
- @@index([connectionId])
- @@index([runId])
- @@index([resourceType, resourceId])
- @@index([severity])
- @@index([status])
+ @@index([connectionId])
+ @@index([runId])
+ @@index([resourceType, resourceId])
+ @@index([severity])
+ @@index([status])
}
enum IntegrationFindingSeverity {
- info
- low
- medium
- high
- critical
+ info
+ low
+ medium
+ high
+ critical
}
enum IntegrationFindingStatus {
- open
- resolved
- ignored
+ open
+ resolved
+ ignored
}
/// Stores OAuth state for CSRF protection during OAuth flow
model IntegrationOAuthState {
- id String @id @default(dbgenerated("generate_prefixed_cuid('ios'::text)"))
+ id String @id @default(dbgenerated("generate_prefixed_cuid('ios'::text)"))
- /// Random state parameter
- state String @unique
+ /// Random state parameter
+ state String @unique
- /// Provider slug
- providerSlug String
+ /// Provider slug
+ providerSlug String
- /// Organization initiating the OAuth
- organizationId String
+ /// Organization initiating the OAuth
+ organizationId String
- /// User initiating the OAuth
- userId String
+ /// User initiating the OAuth
+ userId String
- /// PKCE code verifier (if using PKCE)
- codeVerifier String?
+ /// PKCE code verifier (if using PKCE)
+ codeVerifier String?
- /// Redirect URL after OAuth completes
- redirectUrl String?
+ /// Redirect URL after OAuth completes
+ redirectUrl String?
- /// Expiration timestamp
- expiresAt DateTime
+ /// Expiration timestamp
+ expiresAt DateTime
- createdAt DateTime @default(now())
+ createdAt DateTime @default(now())
- @@index([state])
- @@index([expiresAt])
+ @@index([state])
+ @@index([expiresAt])
}
/// Stores organization-level OAuth app credentials
/// Allows orgs (especially self-hosters) to use their own OAuth apps
model IntegrationOAuthApp {
- id String @id @default(dbgenerated("generate_prefixed_cuid('ioa'::text)"))
+ id String @id @default(dbgenerated("generate_prefixed_cuid('ioa'::text)"))
- /// Provider slug (e.g., "github", "slack")
- providerSlug String
+ /// Provider slug (e.g., "github", "slack")
+ providerSlug String
- /// Organization that owns this OAuth app config
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ /// Organization that owns this OAuth app config
+ organizationId String
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- /// Encrypted client ID
- encryptedClientId Json
+ /// Encrypted client ID
+ encryptedClientId Json
- /// Encrypted client secret
- encryptedClientSecret Json
+ /// Encrypted client secret
+ encryptedClientSecret Json
- /// Optional: custom scopes (overrides manifest defaults)
- customScopes String[]
+ /// Optional: custom scopes (overrides manifest defaults)
+ customScopes String[]
- /// Provider-specific settings (e.g., Rippling app name for authorize URL)
- /// Stored as JSON: { "appName": "compai533c" }
- customSettings Json?
+ /// Provider-specific settings (e.g., Rippling app name for authorize URL)
+ /// Stored as JSON: { "appName": "compai533c" }
+ customSettings Json?
- /// Whether this config is active
- isActive Boolean @default(true)
+ /// Whether this config is active
+ isActive Boolean @default(true)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
- @@unique([providerSlug, organizationId])
- @@index([organizationId])
- @@index([providerSlug])
+ @@unique([providerSlug, organizationId])
+ @@index([organizationId])
+ @@index([providerSlug])
}
/// Records check runs linked to tasks for compliance verification
model IntegrationCheckRun {
- id String @id @default(dbgenerated("generate_prefixed_cuid('icr'::text)"))
+ id String @id @default(dbgenerated("generate_prefixed_cuid('icr'::text)"))
- /// Parent connection
- connectionId String
- connection IntegrationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
+ /// Parent connection
+ connectionId String
+ connection IntegrationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
- /// Task being verified (optional - checks can run without a task)
- taskId String?
- task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull)
+ /// Task being verified (optional - checks can run without a task)
+ taskId String?
+ task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull)
- /// Check ID from the manifest
- checkId String
+ /// Check ID from the manifest
+ checkId String
- /// Check name (denormalized for display)
- checkName String
+ /// Check name (denormalized for display)
+ checkName String
- /// Execution status
- status IntegrationRunStatus @default(pending)
+ /// Execution status
+ status IntegrationRunStatus @default(pending)
- /// Timestamps
- startedAt DateTime?
- completedAt DateTime?
+ /// Timestamps
+ startedAt DateTime?
+ completedAt DateTime?
- /// Duration in milliseconds
- durationMs Int?
+ /// Duration in milliseconds
+ durationMs Int?
- /// Summary counts
- totalChecked Int @default(0)
- passedCount Int @default(0)
- failedCount Int @default(0)
+ /// Summary counts
+ totalChecked Int @default(0)
+ passedCount Int @default(0)
+ failedCount Int @default(0)
- /// Error message if failed
- errorMessage String?
+ /// Error message if failed
+ errorMessage String?
- /// Full execution logs (JSON array)
- logs Json?
+ /// Full execution logs (JSON array)
+ logs Json?
- createdAt DateTime @default(now())
+ createdAt DateTime @default(now())
- /// Results from this check run
- results IntegrationCheckResult[]
+ /// Results from this check run
+ results IntegrationCheckResult[]
- @@index([connectionId])
- @@index([taskId])
- @@index([checkId])
- @@index([status])
- @@index([createdAt])
+ @@index([connectionId])
+ @@index([taskId])
+ @@index([checkId])
+ @@index([status])
+ @@index([createdAt])
}
/// Stores individual results (pass/fail) from check runs
model IntegrationCheckResult {
- id String @id @default(dbgenerated("generate_prefixed_cuid('icx'::text)"))
+ id String @id @default(dbgenerated("generate_prefixed_cuid('icx'::text)"))
- /// Parent check run
- checkRunId String
- checkRun IntegrationCheckRun @relation(fields: [checkRunId], references: [id], onDelete: Cascade)
+ /// Parent check run
+ checkRunId String
+ checkRun IntegrationCheckRun @relation(fields: [checkRunId], references: [id], onDelete: Cascade)
- /// Whether this result is a pass or fail
- passed Boolean
+ /// Whether this result is a pass or fail
+ passed Boolean
- /// Resource classification
- resourceType String
- resourceId String
+ /// Resource classification
+ resourceType String
+ resourceId String
- /// Result details
- title String
- description String?
+ /// Result details
+ title String
+ description String?
- /// Severity (for failures)
- severity IntegrationFindingSeverity?
+ /// Severity (for failures)
+ severity IntegrationFindingSeverity?
- /// Remediation guidance (for failures)
- remediation String?
+ /// Remediation guidance (for failures)
+ remediation String?
- /// Evidence/proof (JSON - API response data)
- evidence Json?
+ /// Evidence/proof (JSON - API response data)
+ evidence Json?
- /// When this evidence was collected
- collectedAt DateTime @default(now())
+ /// When this evidence was collected
+ collectedAt DateTime @default(now())
- remediationActions RemediationAction[]
+ remediationActions RemediationAction[]
- @@index([checkRunId])
- @@index([passed])
- @@index([resourceType, resourceId])
+ @@index([checkRunId])
+ @@index([passed])
+ @@index([resourceType, resourceId])
}
/// Stores platform-wide OAuth app credentials
/// Used by platform operators to provide default OAuth apps for all users
model IntegrationPlatformCredential {
- id String @id @default(dbgenerated("generate_prefixed_cuid('ipc'::text)"))
+ id String @id @default(dbgenerated("generate_prefixed_cuid('ipc'::text)"))
- /// Provider slug (e.g., "github", "slack") - unique per platform
- providerSlug String @unique
+ /// Provider slug (e.g., "github", "slack") - unique per platform
+ providerSlug String @unique
- /// Encrypted client ID
- encryptedClientId Json
+ /// Encrypted client ID
+ encryptedClientId Json
- /// Encrypted client secret
- encryptedClientSecret Json
+ /// Encrypted client secret
+ encryptedClientSecret Json
- /// Masked display hint for client ID (computed at write time)
- clientIdHint String?
+ /// Masked display hint for client ID (computed at write time)
+ clientIdHint String?
- /// Masked display hint for client secret (computed at write time)
- clientSecretHint String?
+ /// Masked display hint for client secret (computed at write time)
+ clientSecretHint String?
- /// Optional: custom scopes (overrides manifest defaults)
- customScopes String[]
+ /// Optional: custom scopes (overrides manifest defaults)
+ customScopes String[]
- /// Provider-specific settings (e.g., Rippling app name for authorize URL)
- /// Stored as JSON: { "appName": "compai533c" }
- customSettings Json?
+ /// Provider-specific settings (e.g., Rippling app name for authorize URL)
+ /// Stored as JSON: { "appName": "compai533c" }
+ customSettings Json?
- /// Whether this credential is active
- isActive Boolean @default(true)
+ /// Whether this credential is active
+ isActive Boolean @default(true)
- /// Who created this credential
- createdById String?
+ /// Who created this credential
+ createdById String?
- /// Who last updated this credential
- updatedById String?
+ /// Who last updated this credential
+ updatedById String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
- @@index([providerSlug])
+ @@index([providerSlug])
}
diff --git a/packages/db/prisma/schema/integration-sync-log.prisma b/packages/db/prisma/schema/integration-sync-log.prisma
index 5cce4660a4..b57767b7a5 100644
--- a/packages/db/prisma/schema/integration-sync-log.prisma
+++ b/packages/db/prisma/schema/integration-sync-log.prisma
@@ -2,11 +2,11 @@
// Generic audit trail for integration sync operations (employee sync, role discovery, etc.)
model IntegrationSyncLog {
- id String @id @default(dbgenerated("generate_prefixed_cuid('isl'::text)"))
+ id String @id @default(dbgenerated("generate_prefixed_cuid('isl'::text)"))
connectionId String
- connection IntegrationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
+ connection IntegrationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
/// Provider slug (e.g., "google-workspace", "rippling", "jumpcloud")
provider String
diff --git a/packages/db/prisma/schema/notification-policy.prisma b/packages/db/prisma/schema/notification-policy.prisma
index 2a789aa955..8674ebc741 100644
--- a/packages/db/prisma/schema/notification-policy.prisma
+++ b/packages/db/prisma/schema/notification-policy.prisma
@@ -1,8 +1,8 @@
model RoleNotificationSetting {
- id String @id @default(dbgenerated("generate_prefixed_cuid('rns'::text)"))
- organizationId String
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
- role String // "owner", "admin", "auditor", "employee", "contractor", or custom role name
+ id String @id @default(dbgenerated("generate_prefixed_cuid('rns'::text)"))
+ organizationId String
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ role String // "owner", "admin", "auditor", "employee", "contractor", or custom role name
policyNotifications Boolean @default(true)
taskReminders Boolean @default(true)
diff --git a/packages/db/prisma/schema/organization-billing.prisma b/packages/db/prisma/schema/organization-billing.prisma
index ba4c522c10..b6c610ae6a 100644
--- a/packages/db/prisma/schema/organization-billing.prisma
+++ b/packages/db/prisma/schema/organization-billing.prisma
@@ -5,7 +5,7 @@ model OrganizationBilling {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
pentestSubscription PentestSubscription?
@@map("organization_billing")
diff --git a/packages/db/prisma/schema/organization.prisma b/packages/db/prisma/schema/organization.prisma
index f6e9111d03..ceadcd5655 100644
--- a/packages/db/prisma/schema/organization.prisma
+++ b/packages/db/prisma/schema/organization.prisma
@@ -1,20 +1,20 @@
model Organization {
- id String @id @default(dbgenerated("generate_prefixed_cuid('org'::text)"))
- name String
- slug String @unique @default(dbgenerated("generate_prefixed_cuid('slug'::text)"))
- logo String?
- createdAt DateTime @default(now())
- metadata String?
- onboarding Onboarding?
- website String?
- onboardingCompleted Boolean @default(false)
- hasAccess Boolean @default(false)
- advancedModeEnabled Boolean @default(false)
- evidenceApprovalEnabled Boolean @default(false)
- deviceAgentStepEnabled Boolean @default(true)
- securityTrainingStepEnabled Boolean @default(true)
- whistleblowerReportEnabled Boolean @default(true)
- accessRequestFormEnabled Boolean @default(true)
+ id String @id @default(dbgenerated("generate_prefixed_cuid('org'::text)"))
+ name String
+ slug String @unique @default(dbgenerated("generate_prefixed_cuid('slug'::text)"))
+ logo String?
+ createdAt DateTime @default(now())
+ metadata String?
+ onboarding Onboarding?
+ website String?
+ onboardingCompleted Boolean @default(false)
+ hasAccess Boolean @default(false)
+ advancedModeEnabled Boolean @default(false)
+ evidenceApprovalEnabled Boolean @default(false)
+ deviceAgentStepEnabled Boolean @default(true)
+ securityTrainingStepEnabled Boolean @default(true)
+ whistleblowerReportEnabled Boolean @default(true)
+ accessRequestFormEnabled Boolean @default(true)
// FleetDM
fleetDmLabelId Int?
@@ -78,11 +78,11 @@ model Organization {
organizationChart OrganizationChart?
// RBAC
- organizationRoles OrganizationRole[]
- roleNotificationSettings RoleNotificationSetting[]
+ organizationRoles OrganizationRole[]
+ roleNotificationSettings RoleNotificationSetting[]
// Timeline
- timelineInstances TimelineInstance[]
+ timelineInstances TimelineInstance[]
// Custom frameworks / requirements authored by this org
customFrameworks CustomFramework[]
diff --git a/packages/db/prisma/schema/policy.prisma b/packages/db/prisma/schema/policy.prisma
index 945e203c52..461ec471b6 100644
--- a/packages/db/prisma/schema/policy.prisma
+++ b/packages/db/prisma/schema/policy.prisma
@@ -4,7 +4,7 @@ enum PolicyDisplayFormat {
}
enum PolicyVisibility {
- ALL // Visible to everyone in organization
+ ALL // Visible to everyone in organization
DEPARTMENT // Only visible to specified departments
}
@@ -66,8 +66,8 @@ model PolicyVersion {
updatedAt DateTime @updatedAt
// Relations
- policyId String
- policy Policy @relation("PolicyVersions", fields: [policyId], references: [id], onDelete: Cascade)
+ policyId String
+ policy Policy @relation("PolicyVersions", fields: [policyId], references: [id], onDelete: Cascade)
currentForPolicy Policy? @relation("PolicyCurrentVersion")
// Version details
diff --git a/packages/db/prisma/schema/remediation-action.prisma b/packages/db/prisma/schema/remediation-action.prisma
index c43e6a6795..e84d34aba0 100644
--- a/packages/db/prisma/schema/remediation-action.prisma
+++ b/packages/db/prisma/schema/remediation-action.prisma
@@ -1,28 +1,28 @@
model RemediationAction {
- id String @id @default(dbgenerated("generate_prefixed_cuid('rma'::text)"))
- checkResultId String
- connectionId String
- organizationId String
- initiatedById String
- remediationKey String
- resourceId String
- resourceType String
- previousState Json
- appliedState Json
- status String @default("pending")
- riskLevel String?
- acknowledgmentText String?
- acknowledgedAt DateTime?
- errorMessage String?
- executedAt DateTime?
- rolledBackAt DateTime?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ id String @id @default(dbgenerated("generate_prefixed_cuid('rma'::text)"))
+ checkResultId String
+ connectionId String
+ organizationId String
+ initiatedById String
+ remediationKey String
+ resourceId String
+ resourceType String
+ previousState Json
+ appliedState Json
+ status String @default("pending")
+ riskLevel String?
+ acknowledgmentText String?
+ acknowledgedAt DateTime?
+ errorMessage String?
+ executedAt DateTime?
+ rolledBackAt DateTime?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
- checkResult IntegrationCheckResult @relation(fields: [checkResultId], references: [id], onDelete: Cascade)
- connection IntegrationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
+ checkResult IntegrationCheckResult @relation(fields: [checkResultId], references: [id], onDelete: Cascade)
+ connection IntegrationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
- @@index([connectionId])
- @@index([organizationId])
- @@index([checkResultId])
+ @@index([connectionId])
+ @@index([organizationId])
+ @@index([checkResultId])
}
diff --git a/packages/db/prisma/schema/remediation-batch.prisma b/packages/db/prisma/schema/remediation-batch.prisma
index 7ae3f446ea..28eccf2d31 100644
--- a/packages/db/prisma/schema/remediation-batch.prisma
+++ b/packages/db/prisma/schema/remediation-batch.prisma
@@ -1,20 +1,20 @@
model RemediationBatch {
- id String @id @default(dbgenerated("generate_prefixed_cuid('rmb'::text)"))
- connectionId String
- organizationId String
- initiatedById String
- triggerRunId String?
- status String @default("pending") // pending, running, done, cancelled
- findings Json @default("[]") // Array of { id, key, title, status, error? }
- fixed Int @default(0)
- skipped Int @default(0)
- failed Int @default(0)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ id String @id @default(dbgenerated("generate_prefixed_cuid('rmb'::text)"))
+ connectionId String
+ organizationId String
+ initiatedById String
+ triggerRunId String?
+ status String @default("pending") // pending, running, done, cancelled
+ findings Json @default("[]") // Array of { id, key, title, status, error? }
+ fixed Int @default(0)
+ skipped Int @default(0)
+ failed Int @default(0)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
- connection IntegrationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
+ connection IntegrationConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
- @@index([connectionId])
- @@index([organizationId])
- @@index([status])
+ @@index([connectionId])
+ @@index([organizationId])
+ @@index([status])
}
diff --git a/packages/db/prisma/schema/security-penetration-test-run.prisma b/packages/db/prisma/schema/security-penetration-test-run.prisma
index 033a5f31f4..8a2ee79548 100644
--- a/packages/db/prisma/schema/security-penetration-test-run.prisma
+++ b/packages/db/prisma/schema/security-penetration-test-run.prisma
@@ -1,9 +1,9 @@
model SecurityPenetrationTestRun {
- id String @id @default(dbgenerated("generate_prefixed_cuid('ptr'::text)"))
- organizationId String @map("organization_id")
- providerRunId String @map("provider_run_id")
- createdAt DateTime @default(now()) @map("created_at")
- updatedAt DateTime @updatedAt @map("updated_at")
+ id String @id @default(dbgenerated("generate_prefixed_cuid('ptr'::text)"))
+ organizationId String @map("organization_id")
+ providerRunId String @map("provider_run_id")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
diff --git a/packages/db/prisma/schema/soa.prisma b/packages/db/prisma/schema/soa.prisma
index d3f28d386f..d8b1de1554 100644
--- a/packages/db/prisma/schema/soa.prisma
+++ b/packages/db/prisma/schema/soa.prisma
@@ -60,9 +60,9 @@ model SOADocument {
answeredQuestions Int @default(0) // Number of questions with answers
// Approval tracking
- preparedBy String @default("Comp AI") // Always "Comp AI"
+ preparedBy String @default("Comp AI") // Always "Comp AI"
approverId String? // Member ID who will approve this document (set when submitted for approval)
- approver Member? @relation("SOADocumentApprover", fields: [approverId], references: [id], onDelete: SetNull, onUpdate: Cascade)
+ approver Member? @relation("SOADocumentApprover", fields: [approverId], references: [id], onDelete: SetNull, onUpdate: Cascade)
approvedAt DateTime? // When document was approved
// Dates
diff --git a/packages/db/prisma/schema/task-item.prisma b/packages/db/prisma/schema/task-item.prisma
index f0da7a8331..8141176e82 100644
--- a/packages/db/prisma/schema/task-item.prisma
+++ b/packages/db/prisma/schema/task-item.prisma
@@ -1,32 +1,32 @@
model TaskItem {
- id String @id @default(dbgenerated("generate_prefixed_cuid('tski'::text)"))
+ id String @id @default(dbgenerated("generate_prefixed_cuid('tski'::text)"))
title String
description String?
- status TaskItemStatus @default(todo)
- priority TaskItemPriority @default(medium)
-
+ status TaskItemStatus @default(todo)
+ priority TaskItemPriority @default(medium)
+
// Polymorphic relation (like Comment and Attachment)
entityId String
entityType TaskItemEntityType
-
+
// Assignment (nullable)
- assigneeId String?
- assignee Member? @relation("TaskItemAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
-
+ assigneeId String?
+ assignee Member? @relation("TaskItemAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
+
// Creator & Updater
createdById String
- createdBy Member @relation("TaskItemCreator", fields: [createdById], references: [id])
+ createdBy Member @relation("TaskItemCreator", fields: [createdById], references: [id])
updatedById String?
- updatedBy Member? @relation("TaskItemUpdater", fields: [updatedById], references: [id])
-
+ updatedBy Member? @relation("TaskItemUpdater", fields: [updatedById], references: [id])
+
// Relationships
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
-
+
// Dates
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
-
+
@@index([entityId, entityType])
@@index([organizationId])
@@index([assigneeId])
@@ -52,4 +52,4 @@ enum TaskItemPriority {
enum TaskItemEntityType {
vendor
risk
-}
\ No newline at end of file
+}
diff --git a/packages/db/prisma/schema/task.prisma b/packages/db/prisma/schema/task.prisma
index 1872ea549e..020fc904fd 100644
--- a/packages/db/prisma/schema/task.prisma
+++ b/packages/db/prisma/schema/task.prisma
@@ -1,13 +1,15 @@
model Task {
// Metadata
- id String @id @default(dbgenerated("generate_prefixed_cuid('tsk'::text)"))
- title String
- description String
- status TaskStatus @default(todo)
- automationStatus TaskAutomationStatus @default(AUTOMATED)
- frequency TaskFrequency?
- department Departments? @default(none)
- order Int @default(0)
+ id String @id @default(dbgenerated("generate_prefixed_cuid('tsk'::text)"))
+ title String
+ description String
+ status TaskStatus @default(todo)
+ automationStatus TaskAutomationStatus @default(AUTOMATED)
+ frequency TaskFrequency?
+ integrationScheduleFrequency TaskFrequency @default(daily)
+ integrationLastRunAt DateTime?
+ department Departments? @default(none)
+ order Int @default(0)
// Dates
createdAt DateTime @default(now())
@@ -29,8 +31,8 @@ model Task {
browserAutomations BrowserAutomation[]
evidenceAutomationRuns EvidenceAutomationRun[]
- integrationCheckRuns IntegrationCheckRun[]
- findings Finding[]
+ integrationCheckRuns IntegrationCheckRun[]
+ findings Finding[]
// Evidence approval
approverId String?
diff --git a/packages/db/prisma/schema/timeline.prisma b/packages/db/prisma/schema/timeline.prisma
index 7f3d63ee30..44f546bbab 100644
--- a/packages/db/prisma/schema/timeline.prisma
+++ b/packages/db/prisma/schema/timeline.prisma
@@ -45,75 +45,75 @@ model TimelineTemplate {
}
model TimelinePhaseTemplate {
- id String @id @default(dbgenerated("generate_prefixed_cuid('tpt'::text)"))
- templateId String
- name String
- description String?
- groupLabel String?
- orderIndex Int
- defaultDurationWeeks Int
- completionType PhaseCompletionType @default(MANUAL)
- locksTimelineOnComplete Boolean @default(false)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ id String @id @default(dbgenerated("generate_prefixed_cuid('tpt'::text)"))
+ templateId String
+ name String
+ description String?
+ groupLabel String?
+ orderIndex Int
+ defaultDurationWeeks Int
+ completionType PhaseCompletionType @default(MANUAL)
+ locksTimelineOnComplete Boolean @default(false)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
template TimelineTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
phases TimelinePhase[]
}
model TimelineInstance {
- id String @id @default(dbgenerated("generate_prefixed_cuid('tli'::text)"))
- organizationId String
+ id String @id @default(dbgenerated("generate_prefixed_cuid('tli'::text)"))
+ organizationId String
frameworkInstanceId String
- templateId String
- trackKey String @default("primary")
- cycleNumber Int
- status TimelineStatus @default(DRAFT)
- startDate DateTime?
- pausedAt DateTime?
- lockedAt DateTime?
- lockedById String?
- unlockedAt DateTime?
- unlockedById String?
- unlockReason String?
- completedAt DateTime?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ templateId String
+ trackKey String @default("primary")
+ cycleNumber Int
+ status TimelineStatus @default(DRAFT)
+ startDate DateTime?
+ pausedAt DateTime?
+ lockedAt DateTime?
+ lockedById String?
+ unlockedAt DateTime?
+ unlockedById String?
+ unlockReason String?
+ completedAt DateTime?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
frameworkInstance FrameworkInstance @relation(fields: [frameworkInstanceId], references: [id], onDelete: Cascade)
- template TimelineTemplate @relation(fields: [templateId], references: [id])
- lockedBy User? @relation("TimelineInstanceLockedBy", fields: [lockedById], references: [id])
- unlockedBy User? @relation("TimelineInstanceUnlockedBy", fields: [unlockedById], references: [id])
- phases TimelinePhase[]
+ template TimelineTemplate @relation(fields: [templateId], references: [id])
+ lockedBy User? @relation("TimelineInstanceLockedBy", fields: [lockedById], references: [id])
+ unlockedBy User? @relation("TimelineInstanceUnlockedBy", fields: [unlockedById], references: [id])
+ phases TimelinePhase[]
@@unique([frameworkInstanceId, trackKey, cycleNumber])
}
model TimelinePhase {
- id String @id @default(dbgenerated("generate_prefixed_cuid('tlp'::text)"))
- instanceId String
- phaseTemplateId String?
- name String
- description String?
- groupLabel String?
- orderIndex Int
- status TimelinePhaseStatus @default(PENDING)
- startDate DateTime?
- endDate DateTime?
- durationWeeks Int
- completionType PhaseCompletionType @default(MANUAL)
- locksTimelineOnComplete Boolean @default(false)
- regressedAt DateTime?
- datesPinned Boolean @default(false)
- completedAt DateTime?
- completedById String?
- readyForReview Boolean @default(false)
- readyForReviewAt DateTime?
- documentUrl String?
- documentName String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ id String @id @default(dbgenerated("generate_prefixed_cuid('tlp'::text)"))
+ instanceId String
+ phaseTemplateId String?
+ name String
+ description String?
+ groupLabel String?
+ orderIndex Int
+ status TimelinePhaseStatus @default(PENDING)
+ startDate DateTime?
+ endDate DateTime?
+ durationWeeks Int
+ completionType PhaseCompletionType @default(MANUAL)
+ locksTimelineOnComplete Boolean @default(false)
+ regressedAt DateTime?
+ datesPinned Boolean @default(false)
+ completedAt DateTime?
+ completedById String?
+ readyForReview Boolean @default(false)
+ readyForReviewAt DateTime?
+ documentUrl String?
+ documentName String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
instance TimelineInstance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
phaseTemplate TimelinePhaseTemplate? @relation(fields: [phaseTemplateId], references: [id])
From 250aa8040a457265e9808a579a9dc69a1900a68d Mon Sep 17 00:00:00 2001
From: Mariano
Date: Fri, 24 Apr 2026 12:50:33 -0400
Subject: [PATCH 05/24] refactor(api): remove stale schedule field from
browserbase DTOs and update service
The BrowserAutomation.schedule column was dropped in the previous commit.
TypeScript did not catch the dangling DTO entries or updateBrowserAutomation
param because object spread does not trigger excess-property checks, but
Prisma would throw "Unknown argument" at runtime if a caller supplied the
field.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
apps/api/src/browserbase/browserbase.service.ts | 1 -
apps/api/src/browserbase/dto/browserbase.dto.ts | 13 -------------
2 files changed, 14 deletions(-)
diff --git a/apps/api/src/browserbase/browserbase.service.ts b/apps/api/src/browserbase/browserbase.service.ts
index 2cffab3473..a6568a92cb 100644
--- a/apps/api/src/browserbase/browserbase.service.ts
+++ b/apps/api/src/browserbase/browserbase.service.ts
@@ -400,7 +400,6 @@ export class BrowserbaseService {
targetUrl?: string;
instruction?: string;
evaluationCriteria?: string;
- schedule?: string;
isEnabled?: boolean;
},
) {
diff --git a/apps/api/src/browserbase/dto/browserbase.dto.ts b/apps/api/src/browserbase/dto/browserbase.dto.ts
index 00b637cf20..103b8b0433 100644
--- a/apps/api/src/browserbase/dto/browserbase.dto.ts
+++ b/apps/api/src/browserbase/dto/browserbase.dto.ts
@@ -89,11 +89,6 @@ export class CreateBrowserAutomationDto {
@IsString()
@IsOptional()
evaluationCriteria?: string;
-
- @ApiPropertyOptional({ description: 'Cron schedule expression' })
- @IsString()
- @IsOptional()
- schedule?: string;
}
export class UpdateBrowserAutomationDto {
@@ -127,11 +122,6 @@ export class UpdateBrowserAutomationDto {
@IsOptional()
evaluationCriteria?: string;
- @ApiPropertyOptional({ description: 'Cron schedule expression' })
- @IsString()
- @IsOptional()
- schedule?: string;
-
@ApiPropertyOptional({ description: 'Whether automation is enabled' })
@IsBoolean()
@IsOptional()
@@ -186,9 +176,6 @@ export class BrowserAutomationResponseDto {
@ApiProperty()
isEnabled: boolean;
- @ApiPropertyOptional()
- schedule?: string;
-
@ApiProperty()
createdAt: Date;
From fcfad8a4cdd586f893a881b36659e8c810f0fcb5 Mon Sep 17 00:00:00 2001
From: Mariano
Date: Fri, 24 Apr 2026 12:54:36 -0400
Subject: [PATCH 06/24] feat(api): respect integrationScheduleFrequency in
integration orchestrator
---
.../run-integration-checks-schedule.spec.ts | 117 ++++++++++++++++++
.../run-integration-checks-schedule.ts | 42 ++++++-
.../run-task-integration-checks.ts | 9 ++
3 files changed, 166 insertions(+), 2 deletions(-)
create mode 100644 apps/api/src/trigger/integration-platform/run-integration-checks-schedule.spec.ts
diff --git a/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.spec.ts b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.spec.ts
new file mode 100644
index 0000000000..9343a609c9
--- /dev/null
+++ b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.spec.ts
@@ -0,0 +1,117 @@
+import { TaskFrequency } from '@trycompai/db';
+import { filterDueTasks } from './run-integration-checks-schedule';
+
+// Mock @db at the module boundary so importing the orchestrator does not try
+// to connect to Postgres. We never call the scheduled `run` function itself
+// (it's wrapped in `schedules.task({...})` and not independently invokable),
+// we only exercise the pure helper it uses.
+jest.mock('@db', () => ({
+ db: {
+ integrationConnection: { findMany: jest.fn() },
+ task: { findMany: jest.fn(), update: jest.fn() },
+ },
+ TaskFrequency: {
+ daily: 'daily',
+ weekly: 'weekly',
+ monthly: 'monthly',
+ quarterly: 'quarterly',
+ yearly: 'yearly',
+ },
+}));
+
+jest.mock('@trycompai/integration-platform', () => ({
+ getManifest: jest.fn(),
+}));
+
+jest.mock('@trigger.dev/sdk', () => ({
+ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
+ schedules: {
+ task: (config: unknown) => config,
+ },
+}));
+
+jest.mock('./run-task-integration-checks', () => ({
+ runTaskIntegrationChecks: { batchTrigger: jest.fn() },
+}));
+
+const atUtc = (iso: string) => new Date(`${iso}T00:00:00.000Z`);
+
+describe('filterDueTasks (integration orchestrator)', () => {
+ const now = atUtc('2026-04-24');
+
+ it('returns only tasks whose schedule says they are due', () => {
+ const candidateTasks = [
+ // Daily → always due
+ {
+ id: 'tsk_daily',
+ title: 'Daily check',
+ taskTemplateId: 'tpl_a',
+ integrationScheduleFrequency: TaskFrequency.daily,
+ integrationLastRunAt: atUtc('2026-04-23'),
+ },
+ // Weekly, ran 2 days ago → NOT due
+ {
+ id: 'tsk_weekly_recent',
+ title: 'Recent weekly',
+ taskTemplateId: 'tpl_b',
+ integrationScheduleFrequency: TaskFrequency.weekly,
+ integrationLastRunAt: atUtc('2026-04-22'),
+ },
+ // Weekly, ran 10 days ago → due
+ {
+ id: 'tsk_weekly_stale',
+ title: 'Stale weekly',
+ taskTemplateId: 'tpl_c',
+ integrationScheduleFrequency: TaskFrequency.weekly,
+ integrationLastRunAt: atUtc('2026-04-14'),
+ },
+ ];
+
+ const dueTasks = filterDueTasks({ tasks: candidateTasks, now });
+
+ expect(dueTasks.map((t) => t.id)).toEqual([
+ 'tsk_daily',
+ 'tsk_weekly_stale',
+ ]);
+ });
+
+ it('treats a null integrationLastRunAt as due (first run)', () => {
+ const candidateTasks = [
+ {
+ id: 'tsk_never_run',
+ title: 'Never run',
+ taskTemplateId: 'tpl_a',
+ integrationScheduleFrequency: TaskFrequency.yearly,
+ integrationLastRunAt: null as Date | null,
+ },
+ ];
+
+ const dueTasks = filterDueTasks({ tasks: candidateTasks, now });
+
+ expect(dueTasks).toHaveLength(1);
+ expect(dueTasks[0]?.id).toBe('tsk_never_run');
+ });
+
+ it('returns an empty array when nothing is due', () => {
+ const candidateTasks = [
+ {
+ id: 'tsk_monthly_recent',
+ title: 'Recent monthly',
+ taskTemplateId: 'tpl_a',
+ integrationScheduleFrequency: TaskFrequency.monthly,
+ integrationLastRunAt: atUtc('2026-04-10'),
+ },
+ {
+ id: 'tsk_quarterly_recent',
+ title: 'Recent quarterly',
+ taskTemplateId: 'tpl_b',
+ integrationScheduleFrequency: TaskFrequency.quarterly,
+ integrationLastRunAt: atUtc('2026-03-01'),
+ },
+ ];
+
+ const dueTasks = filterDueTasks({ tasks: candidateTasks, now });
+
+ expect(dueTasks).toEqual([]);
+ });
+});
diff --git a/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts
index 0a80d22fa6..600e03ef94 100644
--- a/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts
+++ b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts
@@ -1,8 +1,31 @@
import { getManifest } from '@trycompai/integration-platform';
-import { db } from '@db';
+import { db, TaskFrequency } from '@db';
import { logger, schedules } from '@trigger.dev/sdk';
import { runTaskIntegrationChecks } from './run-task-integration-checks';
import { parseDisabledTaskChecks } from '../../integration-platform/utils/disabled-task-checks';
+import { isDueToday } from '../shared/is-due-today';
+
+/**
+ * Pure helper extracted for unit testing. Filters a list of candidate tasks
+ * down to those whose schedule says they are due at `now`.
+ *
+ * Kept in-memory (Shape A from the plan) because the single source of truth
+ * for schedule math is `isDueToday`; duplicating it in SQL would create drift.
+ */
+export function filterDueTasks<
+ T extends {
+ integrationScheduleFrequency: TaskFrequency;
+ integrationLastRunAt: Date | null;
+ },
+>({ tasks, now }: { tasks: T[]; now: Date }): T[] {
+ return tasks.filter((t) =>
+ isDueToday({
+ scheduleFrequency: t.integrationScheduleFrequency,
+ lastRunAt: t.integrationLastRunAt,
+ now,
+ }),
+ );
+}
/**
* Daily scheduled task (orchestrator) that finds all tasks with integration checks
@@ -18,6 +41,8 @@ export const integrationChecksSchedule = schedules.task({
lastRun: payload.lastTimestamp,
});
+ const now = new Date();
+
// Get all active integration connections
const activeConnections = await db.integrationConnection.findMany({
where: { status: 'active' },
@@ -63,7 +88,7 @@ export const integrationChecksSchedule = schedules.task({
}
// Find tasks in this org that match these templates
- const tasks = await db.task.findMany({
+ const candidateTasks = await db.task.findMany({
where: {
organizationId: connection.organizationId,
taskTemplateId: { in: taskTemplateIds as string[] },
@@ -72,9 +97,22 @@ export const integrationChecksSchedule = schedules.task({
id: true,
title: true,
taskTemplateId: true,
+ integrationScheduleFrequency: true,
+ integrationLastRunAt: true,
},
});
+ // Filter by the task's integration schedule. `lastRunAt` is only written
+ // on success inside `runTaskIntegrationChecks`, so failures naturally
+ // retry on the next orchestrator tick (the "crude retry" behavior).
+ const tasks = filterDueTasks({ tasks: candidateTasks, now });
+
+ if (tasks.length < candidateTasks.length) {
+ logger.info(
+ `Skipped ${candidateTasks.length - tasks.length} task(s) not due yet for connection ${connection.id}`,
+ );
+ }
+
// Per-task disabled checks are stored on the connection's metadata so
// users can disconnect individual checks from individual tasks without
// tearing down the whole integration. Resolve once per connection.
diff --git a/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts b/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts
index 57aef35305..462a3e4afe 100644
--- a/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts
+++ b/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts
@@ -415,6 +415,15 @@ export const runTaskIntegrationChecks = task({
data: { lastSyncAt: new Date() },
});
+ // Record successful run on the task so the orchestrator's schedule
+ // filter (`isDueToday`) can skip it on the next tick. Only written on
+ // success — failures leave this untouched so the next orchestrator tick
+ // retries the task.
+ await db.task.update({
+ where: { id: taskId },
+ data: { integrationLastRunAt: new Date() },
+ });
+
// Update task status based on check results
// If any findings or check failures, mark as failed
// If all checks pass with no findings, mark as done (only if not already done)
From b7068d2c67875488e5c749761fcee2600ec0c7d0 Mon Sep 17 00:00:00 2001
From: Mariano
Date: Fri, 24 Apr 2026 12:56:57 -0400
Subject: [PATCH 07/24] fix(api): retry integration task on transient execution
error
Previously, integrationLastRunAt was written whenever the check loop
completed, even if one or more checks returned status='error' (meaning
the check could not execute). On weekly+ schedules this would push the
retry out a full period. Distinguish 'error' (infra issue, retry) from
'failed' (legitimate finding, ran successfully).
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../run-task-integration-checks.ts | 24 ++++++++++++-------
1 file changed, 16 insertions(+), 8 deletions(-)
diff --git a/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts b/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts
index 462a3e4afe..e2c4b404a5 100644
--- a/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts
+++ b/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts
@@ -319,6 +319,7 @@ export const runTaskIntegrationChecks = task({
let totalFindings = 0;
let totalPassing = 0;
let hasFailedChecks = false;
+ let hasExecutionErrors = false;
// Run only the checks that apply to this task
try {
@@ -347,6 +348,9 @@ export const runTaskIntegrationChecks = task({
if (checkResult.status === 'failed' || checkResult.status === 'error') {
hasFailedChecks = true;
}
+ if (checkResult.status === 'error') {
+ hasExecutionErrors = true;
+ }
// Store check run
const checkRun = await db.integrationCheckRun.create({
@@ -415,14 +419,18 @@ export const runTaskIntegrationChecks = task({
data: { lastSyncAt: new Date() },
});
- // Record successful run on the task so the orchestrator's schedule
- // filter (`isDueToday`) can skip it on the next tick. Only written on
- // success — failures leave this untouched so the next orchestrator tick
- // retries the task.
- await db.task.update({
- where: { id: taskId },
- data: { integrationLastRunAt: new Date() },
- });
+ // Record a successful run on the task so the orchestrator's schedule
+ // filter (`isDueToday`) can skip it on the next tick. "Successful" here
+ // means every check executed — including checks that legitimately found
+ // violations (`status: 'failed'`). We skip the write only when a check
+ // couldn't execute (`status: 'error'`, e.g. transient provider error),
+ // so the next orchestrator tick retries instead of waiting a full period.
+ if (!hasExecutionErrors) {
+ await db.task.update({
+ where: { id: taskId },
+ data: { integrationLastRunAt: new Date() },
+ });
+ }
// Update task status based on check results
// If any findings or check failures, mark as failed
From f161627df9e0c864150d446a6fb4984500ee04ee Mon Sep 17 00:00:00 2001
From: Mariano
Date: Fri, 24 Apr 2026 13:04:06 -0400
Subject: [PATCH 08/24] feat(api): respect scheduleFrequency in browser
automation orchestrator
---
.../run-browser-automation.ts | 18 +++
.../run-browser-automations-schedule.spec.ts | 118 ++++++++++++++++++
.../run-browser-automations-schedule.ts | 61 ++++++++-
3 files changed, 192 insertions(+), 5 deletions(-)
create mode 100644 apps/api/src/trigger/browser-automation/run-browser-automations-schedule.spec.ts
diff --git a/apps/api/src/trigger/browser-automation/run-browser-automation.ts b/apps/api/src/trigger/browser-automation/run-browser-automation.ts
index 5b9bb5a8c6..26b54945a2 100644
--- a/apps/api/src/trigger/browser-automation/run-browser-automation.ts
+++ b/apps/api/src/trigger/browser-automation/run-browser-automation.ts
@@ -292,6 +292,7 @@ export const runBrowserAutomation = task({
runId: result.runId,
error: result.error,
needsReauth: result.needsReauth,
+ evaluationStatus: result.evaluationStatus,
});
// Mark task as failed if auth issue
@@ -325,6 +326,23 @@ export const runBrowserAutomation = task({
}
}
+ // Record a successful run on the automation so the orchestrator's
+ // schedule filter (`isDueToday`) can skip it on the next tick. "Executed"
+ // here means the automation actually ran — including runs whose evaluation
+ // legitimately returned `fail`. We skip the write when the automation
+ // genuinely couldn't execute (e.g. `needsReauth` / missing browser context
+ // / other transient infra errors) so the next orchestrator tick retries
+ // instead of waiting a full schedule period.
+ const executed =
+ result.success === true || result.evaluationStatus === 'fail';
+
+ if (executed) {
+ await db.browserAutomation.update({
+ where: { id: automationId },
+ data: { lastRunAt: new Date() },
+ });
+ }
+
return {
success: result.success,
runId: result.runId,
diff --git a/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.spec.ts b/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.spec.ts
new file mode 100644
index 0000000000..2ae7fb5a7d
--- /dev/null
+++ b/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.spec.ts
@@ -0,0 +1,118 @@
+import { TaskFrequency } from '@trycompai/db';
+import { filterDueAutomations } from './run-browser-automations-schedule';
+
+// Mock @db at the module boundary so importing the orchestrator does not try
+// to connect to Postgres. We never call the scheduled `run` function itself
+// (it's wrapped in `schedules.task({...})` and not independently invokable),
+// we only exercise the pure helper it uses.
+jest.mock('@db', () => ({
+ db: {
+ browserAutomation: { findMany: jest.fn(), update: jest.fn() },
+ },
+ TaskFrequency: {
+ daily: 'daily',
+ weekly: 'weekly',
+ monthly: 'monthly',
+ quarterly: 'quarterly',
+ yearly: 'yearly',
+ },
+}));
+
+jest.mock('@trigger.dev/sdk', () => ({
+ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
+ schedules: {
+ task: (config: unknown) => config,
+ },
+}));
+
+jest.mock('./run-browser-automation', () => ({
+ runBrowserAutomation: { batchTrigger: jest.fn() },
+}));
+
+const atUtc = (iso: string) => new Date(`${iso}T00:00:00.000Z`);
+
+describe('filterDueAutomations (browser automation orchestrator)', () => {
+ const now = atUtc('2026-04-24');
+
+ it('returns only automations whose schedule says they are due', () => {
+ const candidateAutomations = [
+ // Daily → always due
+ {
+ id: 'ba_daily',
+ name: 'Daily login check',
+ taskId: 'tsk_a',
+ scheduleFrequency: TaskFrequency.daily,
+ lastRunAt: atUtc('2026-04-23'),
+ },
+ // Weekly, ran 3 days ago → NOT due
+ {
+ id: 'ba_weekly_recent',
+ name: 'Recent weekly',
+ taskId: 'tsk_b',
+ scheduleFrequency: TaskFrequency.weekly,
+ lastRunAt: atUtc('2026-04-21'),
+ },
+ // Weekly, ran 10 days ago → due
+ {
+ id: 'ba_weekly_stale',
+ name: 'Stale weekly',
+ taskId: 'tsk_c',
+ scheduleFrequency: TaskFrequency.weekly,
+ lastRunAt: atUtc('2026-04-14'),
+ },
+ ];
+
+ const due = filterDueAutomations({
+ automations: candidateAutomations,
+ now,
+ });
+
+ expect(due.map((a) => a.id)).toEqual(['ba_daily', 'ba_weekly_stale']);
+ });
+
+ it('treats a null lastRunAt as due (first run)', () => {
+ const candidateAutomations = [
+ {
+ id: 'ba_never_run',
+ name: 'Never run',
+ taskId: 'tsk_a',
+ scheduleFrequency: TaskFrequency.weekly,
+ lastRunAt: null as Date | null,
+ },
+ ];
+
+ const due = filterDueAutomations({
+ automations: candidateAutomations,
+ now,
+ });
+
+ expect(due).toHaveLength(1);
+ expect(due[0]?.id).toBe('ba_never_run');
+ });
+
+ it('returns an empty array when nothing is due', () => {
+ const candidateAutomations = [
+ {
+ id: 'ba_monthly_recent',
+ name: 'Recent monthly',
+ taskId: 'tsk_a',
+ scheduleFrequency: TaskFrequency.monthly,
+ lastRunAt: atUtc('2026-04-10'),
+ },
+ {
+ id: 'ba_quarterly_recent',
+ name: 'Recent quarterly',
+ taskId: 'tsk_b',
+ scheduleFrequency: TaskFrequency.quarterly,
+ lastRunAt: atUtc('2026-03-01'),
+ },
+ ];
+
+ const due = filterDueAutomations({
+ automations: candidateAutomations,
+ now,
+ });
+
+ expect(due).toEqual([]);
+ });
+});
diff --git a/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts b/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts
index 429a6260ab..738bb91451 100644
--- a/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts
+++ b/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts
@@ -1,6 +1,29 @@
-import { db } from '@db';
+import { db, TaskFrequency } from '@db';
import { logger, schedules } from '@trigger.dev/sdk';
import { runBrowserAutomation } from './run-browser-automation';
+import { isDueToday } from '../shared/is-due-today';
+
+/**
+ * Pure helper extracted for unit testing. Filters a list of candidate
+ * automations down to those whose schedule says they are due at `now`.
+ *
+ * Kept in-memory (Shape A from the plan) because the single source of truth
+ * for schedule math is `isDueToday`; duplicating it in SQL would create drift.
+ */
+export function filterDueAutomations<
+ T extends {
+ scheduleFrequency: TaskFrequency;
+ lastRunAt: Date | null;
+ },
+>({ automations, now }: { automations: T[]; now: Date }): T[] {
+ return automations.filter((a) =>
+ isDueToday({
+ scheduleFrequency: a.scheduleFrequency,
+ lastRunAt: a.lastRunAt,
+ now,
+ }),
+ );
+}
/**
* Daily scheduled task (orchestrator) that finds all enabled browser automations
@@ -16,10 +39,17 @@ export const browserAutomationsSchedule = schedules.task({
lastRun: payload.lastTimestamp,
});
+ const now = new Date();
+
// Find all enabled browser automations
- const automations = await db.browserAutomation.findMany({
+ const candidateAutomations = await db.browserAutomation.findMany({
where: { isEnabled: true },
- include: {
+ select: {
+ id: true,
+ name: true,
+ taskId: true,
+ scheduleFrequency: true,
+ lastRunAt: true,
task: {
select: {
id: true,
@@ -30,12 +60,33 @@ export const browserAutomationsSchedule = schedules.task({
},
});
- if (automations.length === 0) {
+ if (candidateAutomations.length === 0) {
logger.info('No enabled browser automations found');
return { success: true, automationsTriggered: 0 };
}
- logger.info(`Found ${automations.length} enabled browser automations`);
+ logger.info(
+ `Found ${candidateAutomations.length} enabled browser automations`,
+ );
+
+ // Filter by the automation's schedule. `lastRunAt` is only written on
+ // success inside `runBrowserAutomation`, so failures naturally retry on
+ // the next orchestrator tick (the "crude retry" behavior).
+ const automations = filterDueAutomations({
+ automations: candidateAutomations,
+ now,
+ });
+
+ if (automations.length < candidateAutomations.length) {
+ logger.info(
+ `Skipped ${candidateAutomations.length - automations.length} automation(s) not due yet`,
+ );
+ }
+
+ if (automations.length === 0) {
+ logger.info('No browser automations due today');
+ return { success: true, automationsTriggered: 0 };
+ }
// Build payloads for batch triggering
const triggerPayloads = automations.map((automation) => ({
From 75231f7308bec4b3d80af6c81292f564dcb00b36 Mon Sep 17 00:00:00 2001
From: Mariano
Date: Fri, 24 Apr 2026 13:08:23 -0400
Subject: [PATCH 09/24] docs(api): clarify browser orchestrator retry comment
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../browser-automation/run-browser-automations-schedule.ts | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts b/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts
index 738bb91451..dedd4b5660 100644
--- a/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts
+++ b/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts
@@ -69,9 +69,10 @@ export const browserAutomationsSchedule = schedules.task({
`Found ${candidateAutomations.length} enabled browser automations`,
);
- // Filter by the automation's schedule. `lastRunAt` is only written on
- // success inside `runBrowserAutomation`, so failures naturally retry on
- // the next orchestrator tick (the "crude retry" behavior).
+ // Filter by the automation's schedule. `lastRunAt` is only written when
+ // the automation actually executed (including legitimate 'fail' verdicts)
+ // inside `runBrowserAutomation`, so infra-level failures naturally retry
+ // on the next orchestrator tick (the "crude retry" behavior).
const automations = filterDueAutomations({
automations: candidateAutomations,
now,
From e9f1d576cc1a405a659de9818ec63b8001a319bd Mon Sep 17 00:00:00 2001
From: Mariano
Date: Fri, 24 Apr 2026 13:52:31 -0400
Subject: [PATCH 10/24] feat(api): accept scheduleFrequency in automation and
task PATCH endpoints
---
.../browserbase/browserbase.service.spec.ts | 89 ++++++++++++++++++-
.../src/browserbase/browserbase.service.ts | 12 ++-
.../src/browserbase/dto/browserbase.dto.ts | 18 ++++
.../tasks/automations/automations.service.ts | 7 +-
.../automations/dto/update-automation.dto.ts | 13 ++-
apps/api/src/tasks/dto/swagger.dto.ts | 16 ++++
apps/api/src/tasks/schemas/task.schemas.ts | 1 +
apps/api/src/tasks/tasks.controller.ts | 11 +++
apps/api/src/tasks/tasks.service.ts | 6 ++
9 files changed, 167 insertions(+), 6 deletions(-)
diff --git a/apps/api/src/browserbase/browserbase.service.spec.ts b/apps/api/src/browserbase/browserbase.service.spec.ts
index 5a06feaf39..7f8cdba32f 100644
--- a/apps/api/src/browserbase/browserbase.service.spec.ts
+++ b/apps/api/src/browserbase/browserbase.service.spec.ts
@@ -8,6 +8,17 @@ jest.mock('@db', () => ({
browserAutomationRun: {
findUnique: jest.fn(),
},
+ browserAutomation: {
+ create: jest.fn(),
+ update: jest.fn(),
+ },
+ },
+ TaskFrequency: {
+ daily: 'daily',
+ weekly: 'weekly',
+ monthly: 'monthly',
+ quarterly: 'quarterly',
+ yearly: 'yearly',
},
}));
@@ -17,7 +28,7 @@ jest.mock('@/app/s3', () => ({
BUCKET_NAME: 'test-bucket',
}));
-import { db } from '@db';
+import { db, TaskFrequency } from '@db';
import { getSignedUrl } from '@/app/s3';
describe('BrowserbaseService.getScreenshotRedirectUrl', () => {
@@ -126,3 +137,79 @@ describe('BrowserbaseService.getScreenshotRedirectUrl', () => {
);
});
});
+
+describe('BrowserbaseService schedule frequency passthrough', () => {
+ let service: BrowserbaseService;
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ const moduleRef = await Test.createTestingModule({
+ providers: [BrowserbaseService],
+ }).compile();
+ service = moduleRef.get(BrowserbaseService);
+ });
+
+ it('forwards scheduleFrequency when creating a browser automation', async () => {
+ (db.browserAutomation.create as jest.Mock).mockResolvedValue({
+ id: 'bau_1',
+ });
+
+ await service.createBrowserAutomation({
+ taskId: 'tsk_1',
+ name: 'name',
+ targetUrl: 'https://example.com',
+ instruction: 'click',
+ scheduleFrequency: TaskFrequency.weekly,
+ });
+
+ expect(db.browserAutomation.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ scheduleFrequency: 'weekly' }),
+ }),
+ );
+ });
+
+ it('omits scheduleFrequency when creating without the field', async () => {
+ (db.browserAutomation.create as jest.Mock).mockResolvedValue({
+ id: 'bau_1',
+ });
+
+ await service.createBrowserAutomation({
+ taskId: 'tsk_1',
+ name: 'name',
+ targetUrl: 'https://example.com',
+ instruction: 'click',
+ });
+
+ const call = (db.browserAutomation.create as jest.Mock).mock.calls[0][0];
+ expect(call.data).not.toHaveProperty('scheduleFrequency');
+ });
+
+ it('forwards scheduleFrequency when updating a browser automation', async () => {
+ (db.browserAutomation.update as jest.Mock).mockResolvedValue({
+ id: 'bau_1',
+ });
+
+ await service.updateBrowserAutomation('bau_1', {
+ scheduleFrequency: TaskFrequency.monthly,
+ });
+
+ expect(db.browserAutomation.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { id: 'bau_1' },
+ data: expect.objectContaining({ scheduleFrequency: 'monthly' }),
+ }),
+ );
+ });
+
+ it('omits scheduleFrequency when updating without the field', async () => {
+ (db.browserAutomation.update as jest.Mock).mockResolvedValue({
+ id: 'bau_1',
+ });
+
+ await service.updateBrowserAutomation('bau_1', { name: 'renamed' });
+
+ const call = (db.browserAutomation.update as jest.Mock).mock.calls[0][0];
+ expect(call.data).not.toHaveProperty('scheduleFrequency');
+ });
+});
diff --git a/apps/api/src/browserbase/browserbase.service.ts b/apps/api/src/browserbase/browserbase.service.ts
index a6568a92cb..fca5edc8f2 100644
--- a/apps/api/src/browserbase/browserbase.service.ts
+++ b/apps/api/src/browserbase/browserbase.service.ts
@@ -3,7 +3,7 @@ import Browserbase from '@browserbasehq/sdk';
// Lazy-imported in createStagehand() to avoid Node v25 crash
// (SlowBuffer.prototype was removed — @browserbasehq/stagehand bundles buffer-equal-constant-time which uses it)
type Stagehand = import('@browserbasehq/stagehand').Stagehand;
-import { db } from '@db';
+import { db, TaskFrequency } from '@db';
import { z } from 'zod';
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { BUCKET_NAME, getSignedUrl, s3Client } from '@/app/s3';
@@ -353,6 +353,7 @@ export class BrowserbaseService {
targetUrl: string;
instruction: string;
evaluationCriteria?: string;
+ scheduleFrequency?: TaskFrequency;
}) {
return db.browserAutomation.create({
data: {
@@ -363,6 +364,9 @@ export class BrowserbaseService {
instruction: data.instruction,
evaluationCriteria: normalizeCriteria(data.evaluationCriteria),
isEnabled: true, // Enable by default so scheduled runs work
+ ...(data.scheduleFrequency !== undefined
+ ? { scheduleFrequency: data.scheduleFrequency }
+ : {}),
},
});
}
@@ -401,9 +405,10 @@ export class BrowserbaseService {
instruction?: string;
evaluationCriteria?: string;
isEnabled?: boolean;
+ scheduleFrequency?: TaskFrequency;
},
) {
- const { evaluationCriteria, ...rest } = data;
+ const { evaluationCriteria, scheduleFrequency, ...rest } = data;
return db.browserAutomation.update({
where: { id: automationId },
data: {
@@ -411,6 +416,9 @@ export class BrowserbaseService {
...(evaluationCriteria !== undefined
? { evaluationCriteria: normalizeCriteria(evaluationCriteria) }
: {}),
+ ...(scheduleFrequency !== undefined
+ ? { scheduleFrequency }
+ : {}),
},
});
}
diff --git a/apps/api/src/browserbase/dto/browserbase.dto.ts b/apps/api/src/browserbase/dto/browserbase.dto.ts
index 103b8b0433..2a7334a782 100644
--- a/apps/api/src/browserbase/dto/browserbase.dto.ts
+++ b/apps/api/src/browserbase/dto/browserbase.dto.ts
@@ -1,11 +1,13 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
+ IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsBoolean,
IsUrl,
} from 'class-validator';
+import { TaskFrequency } from '@db';
import { IsSafeUrl } from '../validators/url-safety.validator';
// ===== Session DTOs =====
@@ -89,6 +91,14 @@ export class CreateBrowserAutomationDto {
@IsString()
@IsOptional()
evaluationCriteria?: string;
+
+ @ApiPropertyOptional({
+ enum: TaskFrequency,
+ description: 'Automation schedule cadence',
+ })
+ @IsEnum(TaskFrequency)
+ @IsOptional()
+ scheduleFrequency?: TaskFrequency;
}
export class UpdateBrowserAutomationDto {
@@ -126,6 +136,14 @@ export class UpdateBrowserAutomationDto {
@IsBoolean()
@IsOptional()
isEnabled?: boolean;
+
+ @ApiPropertyOptional({
+ enum: TaskFrequency,
+ description: 'Automation schedule cadence',
+ })
+ @IsEnum(TaskFrequency)
+ @IsOptional()
+ scheduleFrequency?: TaskFrequency;
}
// ===== Response DTOs =====
diff --git a/apps/api/src/tasks/automations/automations.service.ts b/apps/api/src/tasks/automations/automations.service.ts
index 3afedd380f..86a6fd8ee9 100644
--- a/apps/api/src/tasks/automations/automations.service.ts
+++ b/apps/api/src/tasks/automations/automations.service.ts
@@ -87,12 +87,17 @@ export class AutomationsService {
throw new NotFoundException('Automation not found');
}
+ const { scheduleFrequency, ...rest } = updateAutomationDto;
+
// Update the automation
const automation = await db.evidenceAutomation.update({
where: {
id: automationId,
},
- data: updateAutomationDto,
+ data: {
+ ...rest,
+ ...(scheduleFrequency !== undefined ? { scheduleFrequency } : {}),
+ },
});
return {
diff --git a/apps/api/src/tasks/automations/dto/update-automation.dto.ts b/apps/api/src/tasks/automations/dto/update-automation.dto.ts
index 6c5e87ef47..96ce844067 100644
--- a/apps/api/src/tasks/automations/dto/update-automation.dto.ts
+++ b/apps/api/src/tasks/automations/dto/update-automation.dto.ts
@@ -1,5 +1,6 @@
-import { ApiProperty } from '@nestjs/swagger';
-import { IsString, IsOptional, IsBoolean } from 'class-validator';
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { IsString, IsOptional, IsBoolean, IsEnum } from 'class-validator';
+import { TaskFrequency } from '@db';
export class UpdateAutomationDto {
@ApiProperty({
@@ -35,4 +36,12 @@ export class UpdateAutomationDto {
@IsString()
@IsOptional()
evaluationCriteria?: string;
+
+ @ApiPropertyOptional({
+ enum: TaskFrequency,
+ description: 'Automation schedule cadence',
+ })
+ @IsEnum(TaskFrequency)
+ @IsOptional()
+ scheduleFrequency?: TaskFrequency;
}
diff --git a/apps/api/src/tasks/dto/swagger.dto.ts b/apps/api/src/tasks/dto/swagger.dto.ts
index 19c73df0a5..d2a986ab11 100644
--- a/apps/api/src/tasks/dto/swagger.dto.ts
+++ b/apps/api/src/tasks/dto/swagger.dto.ts
@@ -29,6 +29,14 @@ export class CreateTaskDto {
})
frequency?: string;
+ @ApiProperty({
+ description:
+ 'Cadence for running the integration check attached to this task',
+ enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'],
+ required: false,
+ })
+ integrationScheduleFrequency?: string;
+
@ApiProperty({
description: 'Department assignment',
enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'],
@@ -80,6 +88,14 @@ export class UpdateTaskDto {
})
frequency?: string;
+ @ApiProperty({
+ description:
+ 'Cadence for running the integration check attached to this task',
+ enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'],
+ required: false,
+ })
+ integrationScheduleFrequency?: string;
+
@ApiProperty({
description: 'Department assignment',
enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'],
diff --git a/apps/api/src/tasks/schemas/task.schemas.ts b/apps/api/src/tasks/schemas/task.schemas.ts
index 5380d90987..907a29ef45 100644
--- a/apps/api/src/tasks/schemas/task.schemas.ts
+++ b/apps/api/src/tasks/schemas/task.schemas.ts
@@ -30,6 +30,7 @@ export const CreateTaskSchema = z.object({
description: z.string().min(1, 'Description is required'),
status: TaskStatusSchema.optional().default('todo'),
frequency: TaskFrequencySchema.optional(),
+ integrationScheduleFrequency: TaskFrequencySchema.optional(),
department: DepartmentsSchema.optional().default('none'),
order: z.number().int().min(0).optional().default(0),
assigneeId: z.string().optional(),
diff --git a/apps/api/src/tasks/tasks.controller.ts b/apps/api/src/tasks/tasks.controller.ts
index 47984b0a99..61a4ae8e3b 100644
--- a/apps/api/src/tasks/tasks.controller.ts
+++ b/apps/api/src/tasks/tasks.controller.ts
@@ -712,6 +712,13 @@ export class TasksController {
enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'],
example: 'monthly',
},
+ integrationScheduleFrequency: {
+ type: 'string',
+ enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'],
+ example: 'daily',
+ description:
+ 'Cadence for running the integration check attached to this task',
+ },
department: {
type: 'string',
enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'],
@@ -754,6 +761,7 @@ export class TasksController {
assigneeId?: string | null;
approverId?: string | null;
frequency?: string;
+ integrationScheduleFrequency?: string;
department?: string;
reviewDate?: string;
},
@@ -789,6 +797,9 @@ export class TasksController {
assigneeId: body.assigneeId,
approverId: body.approverId,
frequency: body.frequency as TaskFrequency | undefined,
+ integrationScheduleFrequency: body.integrationScheduleFrequency as
+ | TaskFrequency
+ | undefined,
department: body.department,
reviewDate: parsedReviewDate,
},
diff --git a/apps/api/src/tasks/tasks.service.ts b/apps/api/src/tasks/tasks.service.ts
index cc4fda1350..2d574146c4 100644
--- a/apps/api/src/tasks/tasks.service.ts
+++ b/apps/api/src/tasks/tasks.service.ts
@@ -558,6 +558,7 @@ export class TasksService {
assigneeId?: string | null;
approverId?: string | null;
frequency?: TaskFrequency;
+ integrationScheduleFrequency?: TaskFrequency;
department?: string;
reviewDate?: Date | null;
},
@@ -592,6 +593,7 @@ export class TasksService {
assigneeId?: string | null;
approverId?: string | null;
frequency?: TaskFrequency;
+ integrationScheduleFrequency?: TaskFrequency;
department?: string;
reviewDate?: Date | null;
} = {};
@@ -651,6 +653,10 @@ export class TasksService {
updateData.frequency,
);
}
+ if (updateData.integrationScheduleFrequency !== undefined) {
+ dataToUpdate.integrationScheduleFrequency =
+ updateData.integrationScheduleFrequency;
+ }
if (updateData.department !== undefined) {
dataToUpdate.department = updateData.department;
}
From 535565388504cc3a54c705633d158b1c0dc5dbbf Mon Sep 17 00:00:00 2001
From: Mariano
Date: Fri, 24 Apr 2026 13:58:10 -0400
Subject: [PATCH 11/24] feat(app): add SchedulePicker component
---
.../src/components/schedule-picker.test.tsx | 81 +++++++++++++++++++
apps/app/src/components/schedule-picker.tsx | 51 ++++++++++++
2 files changed, 132 insertions(+)
create mode 100644 apps/app/src/components/schedule-picker.test.tsx
create mode 100644 apps/app/src/components/schedule-picker.tsx
diff --git a/apps/app/src/components/schedule-picker.test.tsx b/apps/app/src/components/schedule-picker.test.tsx
new file mode 100644
index 0000000000..6914e28ec5
--- /dev/null
+++ b/apps/app/src/components/schedule-picker.test.tsx
@@ -0,0 +1,81 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { createContext, useContext, type ReactNode } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+
+interface SelectContextValue {
+ value: string;
+ onValueChange: (next: string) => void;
+ disabled?: boolean;
+}
+
+const MockSelectContext = createContext(null);
+
+function useMockSelect(): SelectContextValue {
+ const ctx = useContext(MockSelectContext);
+ if (!ctx) {
+ throw new Error('Select components must be used within Select');
+ }
+ return ctx;
+}
+
+vi.mock('@trycompai/design-system', () => ({
+ Select: ({
+ value,
+ onValueChange,
+ disabled,
+ children,
+ }: {
+ value: string;
+ onValueChange: (next: string) => void;
+ disabled?: boolean;
+ children: ReactNode;
+ }) => (
+
+ {children}
+
+ ),
+ SelectTrigger: ({ children }: { children: ReactNode }) => {
+ const { disabled } = useMockSelect();
+ return (
+
+ );
+ },
+ SelectContent: ({ children }: { children: ReactNode }) => {children}
,
+ SelectValue: ({ placeholder }: { placeholder?: string }) => {
+ const { value } = useMockSelect();
+ return {value || placeholder};
+ },
+ SelectItem: ({ value, children }: { value: string; children: ReactNode }) => {
+ const { onValueChange } = useMockSelect();
+ return (
+ onValueChange(value)}>
+ {children}
+
+ );
+ },
+}));
+
+// Import AFTER mock is declared so the component uses mocked DS components.
+import { SchedulePicker } from './schedule-picker';
+
+describe('SchedulePicker', () => {
+ it('renders the current value', () => {
+ render( {}} />);
+ expect(screen.getByText('Weekly')).toBeInTheDocument();
+ });
+
+ it('calls onChange when a new option is picked', () => {
+ const onChange = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole('combobox'));
+ fireEvent.click(screen.getByText('Monthly'));
+ expect(onChange).toHaveBeenCalledWith('monthly');
+ });
+
+ it('is disabled when disabled prop is true', () => {
+ render( {}} disabled />);
+ expect(screen.getByRole('combobox')).toBeDisabled();
+ });
+});
diff --git a/apps/app/src/components/schedule-picker.tsx b/apps/app/src/components/schedule-picker.tsx
new file mode 100644
index 0000000000..574441e3b3
--- /dev/null
+++ b/apps/app/src/components/schedule-picker.tsx
@@ -0,0 +1,51 @@
+'use client';
+
+import { TaskFrequency } from '@db';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@trycompai/design-system';
+
+const LABELS: Record = {
+ daily: 'Daily',
+ weekly: 'Weekly',
+ monthly: 'Monthly',
+ quarterly: 'Quarterly',
+ yearly: 'Yearly',
+};
+
+const FREQUENCIES = Object.keys(LABELS) as TaskFrequency[];
+
+export function SchedulePicker({
+ value,
+ onChange,
+ disabled,
+}: {
+ value: TaskFrequency;
+ onChange: (value: TaskFrequency) => void;
+ disabled?: boolean;
+}) {
+ const handleValueChange = (next: TaskFrequency | null) => {
+ if (next !== null) {
+ onChange(next);
+ }
+ };
+
+ return (
+
+ );
+}
From d869d3ea41dac3c24e4b73c7bd67bd0c3a6aa50b Mon Sep 17 00:00:00 2001
From: Mariano
Date: Fri, 24 Apr 2026 14:01:51 -0400
Subject: [PATCH 12/24] feat(app): add schedule picker to browser automation
config
---
.../BrowserAutomationConfigDialog.tsx | 42 +++++++++++++++++--
.../[orgId]/tasks/[taskId]/hooks/types.ts | 3 ++
.../[taskId]/hooks/useBrowserAutomations.ts | 3 ++
3 files changed, 44 insertions(+), 4 deletions(-)
diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/BrowserAutomationConfigDialog.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/BrowserAutomationConfigDialog.tsx
index 07dc6ee42e..29c0bb65fe 100644
--- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/BrowserAutomationConfigDialog.tsx
+++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/browser-automations/BrowserAutomationConfigDialog.tsx
@@ -1,5 +1,6 @@
'use client';
+import { SchedulePicker } from '@/components/schedule-picker';
import { Button } from '@trycompai/ui/button';
import {
Dialog,
@@ -15,7 +16,7 @@ import { Textarea } from '@trycompai/ui/textarea';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2 } from 'lucide-react';
import { useEffect } from 'react';
-import { useForm } from 'react-hook-form';
+import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod';
import type { BrowserAutomation } from '../../hooks/types';
@@ -24,6 +25,7 @@ const automationConfigSchema = z.object({
targetUrl: z.string().trim().url({ message: 'Starting URL must be a valid URL' }),
instruction: z.string().trim().min(1, { message: 'Instruction is required' }),
evaluationCriteria: z.string().trim().optional(),
+ scheduleFrequency: z.enum(['daily', 'weekly', 'monthly', 'quarterly', 'yearly']),
});
type AutomationConfigFormData = z.infer;
@@ -33,7 +35,7 @@ interface BrowserAutomationConfigDialogProps {
mode: 'create' | 'edit';
initialValues?: Pick<
BrowserAutomation,
- 'id' | 'name' | 'targetUrl' | 'instruction' | 'evaluationCriteria'
+ 'id' | 'name' | 'targetUrl' | 'instruction' | 'evaluationCriteria' | 'scheduleFrequency'
>;
isSaving: boolean;
onClose: () => void;
@@ -51,6 +53,7 @@ export function BrowserAutomationConfigDialog({
onUpdate,
}: BrowserAutomationConfigDialogProps) {
const {
+ control,
register,
handleSubmit,
reset,
@@ -62,6 +65,7 @@ export function BrowserAutomationConfigDialog({
targetUrl: '',
instruction: '',
evaluationCriteria: '',
+ scheduleFrequency: 'daily',
},
});
@@ -74,15 +78,28 @@ export function BrowserAutomationConfigDialog({
targetUrl: initialValues.targetUrl ?? '',
instruction: initialValues.instruction ?? '',
evaluationCriteria: initialValues.evaluationCriteria ?? '',
+ scheduleFrequency: initialValues.scheduleFrequency ?? 'daily',
});
return;
}
- reset({ name: '', targetUrl: '', instruction: '', evaluationCriteria: '' });
+ reset({
+ name: '',
+ targetUrl: '',
+ instruction: '',
+ evaluationCriteria: '',
+ scheduleFrequency: 'daily',
+ });
}, [isOpen, mode, initialValues, reset]);
const handleClose = () => {
- reset({ name: '', targetUrl: '', instruction: '', evaluationCriteria: '' });
+ reset({
+ name: '',
+ targetUrl: '',
+ instruction: '',
+ evaluationCriteria: '',
+ scheduleFrequency: 'daily',
+ });
onClose();
};
@@ -180,6 +197,23 @@ export function BrowserAutomationConfigDialog({
+
+
+
(
+
+ )}
+ />
+ {errors.scheduleFrequency?.message && (
+ {errors.scheduleFrequency.message}
+ )}
+
+ How often this automation should run automatically.
+
+
+