Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions src/db/repositories/agentConfigsRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { and, eq, isNull } from 'drizzle-orm';
import { getDb } from '../client.js';
import { agentConfigs, projects } from '../schema/index.js';

// ============================================================================
// Agent Configs
// ============================================================================

export async function listAgentConfigs(filter?: { orgId?: string; projectId?: string }) {
const db = getDb();
const conditions = [];

if (filter?.projectId) {
conditions.push(eq(agentConfigs.projectId, filter.projectId));
} else if (filter?.orgId) {
// Return global (no orgId, no projectId) + org-scoped (orgId set, no projectId)
conditions.push(isNull(agentConfigs.projectId));
}

if (conditions.length > 0) {
return db
.select()
.from(agentConfigs)
.where(and(...conditions));
}
return db.select().from(agentConfigs);
}

export async function createAgentConfig(data: {
orgId?: string | null;
projectId?: string | null;
agentType: string;
model?: string | null;
maxIterations?: number | null;
agentBackend?: string | null;
maxConcurrency?: number | null;
}) {
const db = getDb();
const [row] = await db
.insert(agentConfigs)
.values({
orgId: data.orgId ?? null,
projectId: data.projectId ?? null,
agentType: data.agentType,
model: data.model,
maxIterations: data.maxIterations,
agentBackend: data.agentBackend,
maxConcurrency: data.maxConcurrency,
})
.returning({ id: agentConfigs.id });
return row;
}

export async function updateAgentConfig(
id: number,
updates: {
agentType?: string;
model?: string | null;
maxIterations?: number | null;
agentBackend?: string | null;
maxConcurrency?: number | null;
},
) {
const db = getDb();
await db
.update(agentConfigs)
.set({ ...updates, updatedAt: new Date() })
.where(eq(agentConfigs.id, id));
}

export async function deleteAgentConfig(id: number) {
const db = getDb();
await db.delete(agentConfigs).where(eq(agentConfigs.id, id));
}

/**
* Resolve max_concurrency for a (projectId, agentType) pair.
* Checks project-scoped config first, then org-scoped config.
* Returns null if no config with max_concurrency is found (= no limit).
*
* Results are cached for 5 seconds to avoid repeated DB queries on
* sequential webhook batches.
*/
const MAX_CONCURRENCY_TTL_MS = 5_000;
const maxConcurrencyCache = new Map<string, { value: number | null; expiresAt: number }>();

export async function getMaxConcurrency(
projectId: string,
agentType: string,
): Promise<number | null> {
const cacheKey = `${projectId}:${agentType}`;
const cached = maxConcurrencyCache.get(cacheKey);
if (cached && Date.now() < cached.expiresAt) {
return cached.value;
}

const db = getDb();

// 1. Project-scoped config
const [projectConfig] = await db
.select({ maxConcurrency: agentConfigs.maxConcurrency })
.from(agentConfigs)
.where(and(eq(agentConfigs.projectId, projectId), eq(agentConfigs.agentType, agentType)))
.limit(1);
if (projectConfig?.maxConcurrency != null) {
maxConcurrencyCache.set(cacheKey, {
value: projectConfig.maxConcurrency,
expiresAt: Date.now() + MAX_CONCURRENCY_TTL_MS,
});
return projectConfig.maxConcurrency;
}

// 2. Org-scoped config — need orgId from project
const [project] = await db
.select({ orgId: projects.orgId })
.from(projects)
.where(eq(projects.id, projectId))
.limit(1);
if (!project) {
maxConcurrencyCache.set(cacheKey, {
value: null,
expiresAt: Date.now() + MAX_CONCURRENCY_TTL_MS,
});
return null;
}

const [orgConfig] = await db
.select({ maxConcurrency: agentConfigs.maxConcurrency })
.from(agentConfigs)
.where(
and(
eq(agentConfigs.orgId, project.orgId),
isNull(agentConfigs.projectId),
eq(agentConfigs.agentType, agentType),
),
)
.limit(1);

const result = orgConfig?.maxConcurrency ?? null;
maxConcurrencyCache.set(cacheKey, {
value: result,
expiresAt: Date.now() + MAX_CONCURRENCY_TTL_MS,
});
return result;
}
37 changes: 37 additions & 0 deletions src/db/repositories/cascadeDefaultsRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { eq } from 'drizzle-orm';
import { getDb } from '../client.js';
import { cascadeDefaults } from '../schema/index.js';

// ============================================================================
// Cascade Defaults
// ============================================================================

export async function getCascadeDefaults(orgId: string) {
const db = getDb();
const [row] = await db.select().from(cascadeDefaults).where(eq(cascadeDefaults.orgId, orgId));
return row ?? null;
}

export async function upsertCascadeDefaults(
orgId: string,
data: {
model?: string | null;
maxIterations?: number | null;
watchdogTimeoutMs?: number | null;
cardBudgetUsd?: string | null;
agentBackend?: string | null;
progressModel?: string | null;
progressIntervalMinutes?: string | null;
},
) {
const db = getDb();
const existing = await getCascadeDefaults(orgId);
if (existing) {
await db
.update(cascadeDefaults)
.set({ ...data, updatedAt: new Date() })
.where(eq(cascadeDefaults.orgId, orgId));
} else {
await db.insert(cascadeDefaults).values({ orgId, ...data });
}
}
160 changes: 160 additions & 0 deletions src/db/repositories/integrationsRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { and, eq } from 'drizzle-orm';
import { getDb } from '../client.js';
import { credentials, integrationCredentials, projectIntegrations } from '../schema/index.js';

// ============================================================================
// Project Integrations
// ============================================================================

export async function listProjectIntegrations(projectId: string) {
const db = getDb();
return db.select().from(projectIntegrations).where(eq(projectIntegrations.projectId, projectId));
}

export async function getIntegrationByProjectAndCategory(projectId: string, category: string) {
const db = getDb();
const [row] = await db
.select()
.from(projectIntegrations)
.where(
and(eq(projectIntegrations.projectId, projectId), eq(projectIntegrations.category, category)),
);
return row ?? null;
}

export async function upsertProjectIntegration(
projectId: string,
category: string,
provider: string,
config: Record<string, unknown>,
triggers?: Record<string, boolean>,
) {
const db = getDb();
// Preserve existing triggers if not provided (prevents data loss from Integration tab saves)
let triggersToSave = triggers;
if (triggersToSave === undefined) {
const existing = await getIntegrationByProjectAndCategory(projectId, category);
triggersToSave = (existing?.triggers as Record<string, boolean>) ?? {};
}
const [row] = await db
.insert(projectIntegrations)
.values({ projectId, category, provider, config, triggers: triggersToSave })
.onConflictDoUpdate({
target: [projectIntegrations.projectId, projectIntegrations.category],
set: { provider, config, triggers: triggersToSave, updatedAt: new Date() },
})
.returning();
return row;
}

/**
* Update only the triggers column for an existing integration.
* Merges the provided triggers with any existing ones (nested keys are merged).
*/
export async function updateProjectIntegrationTriggers(
projectId: string,
category: string,
triggers: Record<string, unknown>,
) {
const db = getDb();
const existing = await getIntegrationByProjectAndCategory(projectId, category);
if (!existing) {
throw new Error(`No ${category} integration found for project ${projectId}`);
}
// Deep-merge triggers: preserve existing top-level keys, merge nested objects
const existingTriggers = (existing.triggers as Record<string, unknown>) ?? {};
const merged: Record<string, unknown> = { ...existingTriggers };
for (const [key, value] of Object.entries(triggers)) {
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
// Merge nested object
const existingChild =
typeof merged[key] === 'object' && merged[key] !== null
? (merged[key] as Record<string, unknown>)
: {};
merged[key] = { ...existingChild, ...(value as Record<string, unknown>) };
} else {
merged[key] = value;
}
}
await db
.update(projectIntegrations)
.set({ triggers: merged, updatedAt: new Date() })
.where(
and(eq(projectIntegrations.projectId, projectId), eq(projectIntegrations.category, category)),
);
}

export async function deleteProjectIntegration(projectId: string, category: string) {
const db = getDb();
await db
.delete(projectIntegrations)
.where(
and(eq(projectIntegrations.projectId, projectId), eq(projectIntegrations.category, category)),
);
}

export async function getAllProjectIdsWithEmailIntegration(): Promise<string[]> {
const db = getDb();
const rows = await db
.select({ projectId: projectIntegrations.projectId })
.from(projectIntegrations)
.where(eq(projectIntegrations.category, 'email'));
return rows.map((r) => r.projectId);
}

export async function getAllProjectIdsWithSmsIntegration(): Promise<string[]> {
const db = getDb();
const rows = await db
.select({ projectId: projectIntegrations.projectId })
.from(projectIntegrations)
.where(eq(projectIntegrations.category, 'sms'));
return rows.map((r) => r.projectId);
}

// ============================================================================
// Integration Credentials
// ============================================================================

export async function listIntegrationCredentials(integrationId: number) {
const db = getDb();
return db
.select({
id: integrationCredentials.id,
role: integrationCredentials.role,
credentialId: integrationCredentials.credentialId,
credentialName: credentials.name,
})
.from(integrationCredentials)
.innerJoin(credentials, eq(integrationCredentials.credentialId, credentials.id))
.where(eq(integrationCredentials.integrationId, integrationId));
}

export async function setIntegrationCredential(
integrationId: number,
role: string,
credentialId: number,
) {
const db = getDb();
// Upsert: delete + insert to handle unique constraint
await db
.delete(integrationCredentials)
.where(
and(
eq(integrationCredentials.integrationId, integrationId),
eq(integrationCredentials.role, role),
),
);
await db.insert(integrationCredentials).values({ integrationId, role, credentialId });
}

export async function removeIntegrationCredential(integrationId: number, role: string) {
const db = getDb();
await db
.delete(integrationCredentials)
.where(
and(
eq(integrationCredentials.integrationId, integrationId),
eq(integrationCredentials.role, role),
),
);
}
23 changes: 23 additions & 0 deletions src/db/repositories/organizationsRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { eq } from 'drizzle-orm';
import { getDb } from '../client.js';
import { organizations } from '../schema/index.js';

// ============================================================================
// Organizations
// ============================================================================

export async function getOrganization(orgId: string) {
const db = getDb();
const [row] = await db.select().from(organizations).where(eq(organizations.id, orgId));
return row ?? null;
}

export async function updateOrganization(orgId: string, data: { name: string }) {
const db = getDb();
await db.update(organizations).set({ name: data.name }).where(eq(organizations.id, orgId));
}

export async function listAllOrganizations() {
const db = getDb();
return db.select({ id: organizations.id, name: organizations.name }).from(organizations);
}
Loading