diff --git a/src/db/repositories/agentConfigsRepository.ts b/src/db/repositories/agentConfigsRepository.ts new file mode 100644 index 00000000..f89d79d7 --- /dev/null +++ b/src/db/repositories/agentConfigsRepository.ts @@ -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(); + +export async function getMaxConcurrency( + projectId: string, + agentType: string, +): Promise { + 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; +} diff --git a/src/db/repositories/cascadeDefaultsRepository.ts b/src/db/repositories/cascadeDefaultsRepository.ts new file mode 100644 index 00000000..a532bc2b --- /dev/null +++ b/src/db/repositories/cascadeDefaultsRepository.ts @@ -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 }); + } +} diff --git a/src/db/repositories/integrationsRepository.ts b/src/db/repositories/integrationsRepository.ts new file mode 100644 index 00000000..b3502359 --- /dev/null +++ b/src/db/repositories/integrationsRepository.ts @@ -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, + triggers?: Record, +) { + 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) ?? {}; + } + 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, +) { + 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) ?? {}; + const merged: Record = { ...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) + : {}; + merged[key] = { ...existingChild, ...(value as Record) }; + } 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 { + 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 { + 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), + ), + ); +} diff --git a/src/db/repositories/organizationsRepository.ts b/src/db/repositories/organizationsRepository.ts new file mode 100644 index 00000000..70204109 --- /dev/null +++ b/src/db/repositories/organizationsRepository.ts @@ -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); +} diff --git a/src/db/repositories/projectsRepository.ts b/src/db/repositories/projectsRepository.ts new file mode 100644 index 00000000..ee2b2e57 --- /dev/null +++ b/src/db/repositories/projectsRepository.ts @@ -0,0 +1,80 @@ +import { and, eq } from 'drizzle-orm'; +import { getDb } from '../client.js'; +import { projects } from '../schema/index.js'; + +// ============================================================================ +// Projects (full CRUD) +// ============================================================================ + +export async function listProjectsFull(orgId: string) { + const db = getDb(); + return db.select().from(projects).where(eq(projects.orgId, orgId)); +} + +export async function getProjectFull(projectId: string, orgId: string) { + const db = getDb(); + const [row] = await db + .select() + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.orgId, orgId))); + return row ?? null; +} + +export async function createProject( + orgId: string, + data: { + id: string; + name: string; + repo?: string; + baseBranch?: string; + branchPrefix?: string; + model?: string | null; + cardBudgetUsd?: string | null; + agentBackend?: string | null; + subscriptionCostZero?: boolean; + }, +) { + const db = getDb(); + const [row] = await db + .insert(projects) + .values({ + id: data.id, + orgId, + name: data.name, + repo: data.repo ?? null, + baseBranch: data.baseBranch ?? 'main', + branchPrefix: data.branchPrefix ?? 'feature/', + model: data.model, + cardBudgetUsd: data.cardBudgetUsd, + agentBackend: data.agentBackend, + subscriptionCostZero: data.subscriptionCostZero ?? false, + }) + .returning(); + return row; +} + +export async function updateProject( + projectId: string, + orgId: string, + updates: { + name?: string; + repo?: string; + baseBranch?: string; + branchPrefix?: string; + model?: string | null; + cardBudgetUsd?: string | null; + agentBackend?: string | null; + subscriptionCostZero?: boolean; + }, +) { + const db = getDb(); + await db + .update(projects) + .set({ ...updates, updatedAt: new Date() }) + .where(and(eq(projects.id, projectId), eq(projects.orgId, orgId))); +} + +export async function deleteProject(projectId: string, orgId: string) { + const db = getDb(); + await db.delete(projects).where(and(eq(projects.id, projectId), eq(projects.orgId, orgId))); +} diff --git a/src/db/repositories/settingsRepository.ts b/src/db/repositories/settingsRepository.ts index c79261e5..a39aab2f 100644 --- a/src/db/repositories/settingsRepository.ts +++ b/src/db/repositories/settingsRepository.ts @@ -1,441 +1,19 @@ -import { and, eq, isNull } from 'drizzle-orm'; -import { getDb } from '../client.js'; -import { - agentConfigs, - cascadeDefaults, - credentials, - integrationCredentials, - organizations, - projectIntegrations, - projects, -} 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); -} - -// ============================================================================ -// 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 }); - } -} - -// ============================================================================ -// Projects (full CRUD) -// ============================================================================ - -export async function listProjectsFull(orgId: string) { - const db = getDb(); - return db.select().from(projects).where(eq(projects.orgId, orgId)); -} - -export async function getProjectFull(projectId: string, orgId: string) { - const db = getDb(); - const [row] = await db - .select() - .from(projects) - .where(and(eq(projects.id, projectId), eq(projects.orgId, orgId))); - return row ?? null; -} - -export async function createProject( - orgId: string, - data: { - id: string; - name: string; - repo?: string; - baseBranch?: string; - branchPrefix?: string; - model?: string | null; - cardBudgetUsd?: string | null; - agentBackend?: string | null; - subscriptionCostZero?: boolean; - }, -) { - const db = getDb(); - const [row] = await db - .insert(projects) - .values({ - id: data.id, - orgId, - name: data.name, - repo: data.repo ?? null, - baseBranch: data.baseBranch ?? 'main', - branchPrefix: data.branchPrefix ?? 'feature/', - model: data.model, - cardBudgetUsd: data.cardBudgetUsd, - agentBackend: data.agentBackend, - subscriptionCostZero: data.subscriptionCostZero ?? false, - }) - .returning(); - return row; -} - -export async function updateProject( - projectId: string, - orgId: string, - updates: { - name?: string; - repo?: string; - baseBranch?: string; - branchPrefix?: string; - model?: string | null; - cardBudgetUsd?: string | null; - agentBackend?: string | null; - subscriptionCostZero?: boolean; - }, -) { - const db = getDb(); - await db - .update(projects) - .set({ ...updates, updatedAt: new Date() }) - .where(and(eq(projects.id, projectId), eq(projects.orgId, orgId))); -} - -export async function deleteProject(projectId: string, orgId: string) { - const db = getDb(); - await db.delete(projects).where(and(eq(projects.id, projectId), eq(projects.orgId, orgId))); -} - -// ============================================================================ -// 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, - triggers?: Record, -) { - 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) ?? {}; - } - 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, -) { - 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) ?? {}; - const merged: Record = { ...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) - : {}; - merged[key] = { ...existingChild, ...(value as Record) }; - } 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 { - 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 { - 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), - ), - ); -} - -// ============================================================================ -// 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). + * Barrel re-export for backward compatibility. + * + * The original god-module has been split into focused single-responsibility modules: + * - organizationsRepository.ts (getOrganization, updateOrganization, listAllOrganizations) + * - cascadeDefaultsRepository.ts (getCascadeDefaults, upsertCascadeDefaults) + * - projectsRepository.ts (listProjectsFull, getProjectFull, createProject, updateProject, deleteProject) + * - integrationsRepository.ts (all projectIntegration* + integrationCredential* functions) + * - agentConfigsRepository.ts (listAgentConfigs, createAgentConfig, updateAgentConfig, deleteAgentConfig, getMaxConcurrency) * - * Results are cached for 5 seconds to avoid repeated DB queries on - * sequential webhook batches. + * All existing import sites continue to work without modification. + * Consumers can migrate to the focused modules at their own pace. */ -const MAX_CONCURRENCY_TTL_MS = 5_000; -const maxConcurrencyCache = new Map(); - -export async function getMaxConcurrency( - projectId: string, - agentType: string, -): Promise { - 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; -} +export * from './organizationsRepository.js'; +export * from './cascadeDefaultsRepository.js'; +export * from './projectsRepository.js'; +export * from './integrationsRepository.js'; +export * from './agentConfigsRepository.js'; diff --git a/tests/unit/db/repositories/agentConfigsRepository.test.ts b/tests/unit/db/repositories/agentConfigsRepository.test.ts new file mode 100644 index 00000000..bd2b370e --- /dev/null +++ b/tests/unit/db/repositories/agentConfigsRepository.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockDb } from '../../../helpers/mockDb.js'; + +vi.mock('../../../../src/db/client.js', () => ({ + getDb: vi.fn(), +})); + +import { getDb } from '../../../../src/db/client.js'; +import { + createAgentConfig, + deleteAgentConfig, + listAgentConfigs, + updateAgentConfig, +} from '../../../../src/db/repositories/agentConfigsRepository.js'; + +describe('agentConfigsRepository', () => { + let mockDb: ReturnType; + + beforeEach(() => { + mockDb = createMockDb({ withUpsert: true, withThenable: true }); + vi.mocked(getDb).mockReturnValue(mockDb.db as never); + }); + + describe('listAgentConfigs', () => { + it('returns all configs when no filter', async () => { + const configs = [{ id: 1, agentType: 'impl' }]; + // No where clause → thenable chain resolves + const fromMock = vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(configs), + // biome-ignore lint/suspicious/noThenProperty: intentional thenable mock for Drizzle query chains + then: (resolve: (v: unknown) => unknown) => Promise.resolve(configs).then(resolve), + }); + mockDb.db.select.mockReturnValue({ from: fromMock }); + + const result = await listAgentConfigs(); + expect(result).toEqual(configs); + }); + + it('filters by projectId when provided', async () => { + const configs = [{ id: 2, agentType: 'review', projectId: 'p1' }]; + mockDb.chain.where.mockResolvedValueOnce(configs); + + const result = await listAgentConfigs({ projectId: 'p1' }); + expect(result).toEqual(configs); + }); + + it('filters to non-project configs when orgId provided', async () => { + const configs = [{ id: 3, agentType: 'impl', orgId: 'org-1' }]; + mockDb.chain.where.mockResolvedValueOnce(configs); + + const result = await listAgentConfigs({ orgId: 'org-1' }); + expect(result).toEqual(configs); + }); + }); + + describe('createAgentConfig', () => { + it('inserts config and returns id', async () => { + mockDb.chain.returning.mockResolvedValueOnce([{ id: 42 }]); + + const result = await createAgentConfig({ + orgId: 'org-1', + agentType: 'implementation', + model: 'test-model', + maxIterations: 20, + }); + + expect(result).toEqual({ id: 42 }); + expect(mockDb.chain.values).toHaveBeenCalledWith( + expect.objectContaining({ + orgId: 'org-1', + projectId: null, + agentType: 'implementation', + model: 'test-model', + maxIterations: 20, + }), + ); + }); + }); + + describe('updateAgentConfig', () => { + it('updates config fields', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await updateAgentConfig(42, { model: 'new-model', maxIterations: 30 }); + + expect(mockDb.db.update).toHaveBeenCalledTimes(1); + const setArg = mockDb.chain.set.mock.calls[0][0]; + expect(setArg.model).toBe('new-model'); + expect(setArg.maxIterations).toBe(30); + expect(setArg.updatedAt).toBeInstanceOf(Date); + }); + }); + + describe('deleteAgentConfig', () => { + it('deletes config by id', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await deleteAgentConfig(42); + + expect(mockDb.db.delete).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/tests/unit/db/repositories/cascadeDefaultsRepository.test.ts b/tests/unit/db/repositories/cascadeDefaultsRepository.test.ts new file mode 100644 index 00000000..9dbe477c --- /dev/null +++ b/tests/unit/db/repositories/cascadeDefaultsRepository.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockDb } from '../../../helpers/mockDb.js'; + +vi.mock('../../../../src/db/client.js', () => ({ + getDb: vi.fn(), +})); + +import { getDb } from '../../../../src/db/client.js'; +import { + getCascadeDefaults, + upsertCascadeDefaults, +} from '../../../../src/db/repositories/cascadeDefaultsRepository.js'; + +describe('cascadeDefaultsRepository', () => { + let mockDb: ReturnType; + + beforeEach(() => { + mockDb = createMockDb({ withUpsert: true, withThenable: true }); + vi.mocked(getDb).mockReturnValue(mockDb.db as never); + }); + + describe('getCascadeDefaults', () => { + it('returns defaults when found', async () => { + const defaults = { orgId: 'org-1', model: 'claude-sonnet-4-5-20250929', maxIterations: 20 }; + mockDb.chain.where.mockResolvedValueOnce([defaults]); + + const result = await getCascadeDefaults('org-1'); + expect(result).toEqual(defaults); + }); + + it('returns null when not found', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await getCascadeDefaults('missing'); + expect(result).toBeNull(); + }); + }); + + describe('upsertCascadeDefaults', () => { + it('inserts when no existing defaults', async () => { + // getCascadeDefaults returns null + mockDb.chain.where.mockResolvedValueOnce([]); + + await upsertCascadeDefaults('org-1', { model: 'test-model' }); + + expect(mockDb.db.insert).toHaveBeenCalledTimes(1); + expect(mockDb.chain.values).toHaveBeenCalledWith( + expect.objectContaining({ orgId: 'org-1', model: 'test-model' }), + ); + }); + + it('updates when existing defaults found', async () => { + // getCascadeDefaults returns existing row + mockDb.chain.where.mockResolvedValueOnce([{ orgId: 'org-1', model: 'old-model' }]); + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await upsertCascadeDefaults('org-1', { model: 'new-model' }); + + expect(mockDb.db.update).toHaveBeenCalledTimes(1); + expect(mockDb.chain.set).toHaveBeenCalledWith( + expect.objectContaining({ model: 'new-model' }), + ); + }); + }); +}); diff --git a/tests/unit/db/repositories/integrationsRepository.test.ts b/tests/unit/db/repositories/integrationsRepository.test.ts new file mode 100644 index 00000000..59e124e8 --- /dev/null +++ b/tests/unit/db/repositories/integrationsRepository.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockDb } from '../../../helpers/mockDb.js'; + +vi.mock('../../../../src/db/client.js', () => ({ + getDb: vi.fn(), +})); + +import { getDb } from '../../../../src/db/client.js'; +import { + deleteProjectIntegration, + getAllProjectIdsWithEmailIntegration, + getAllProjectIdsWithSmsIntegration, + listProjectIntegrations, + upsertProjectIntegration, +} from '../../../../src/db/repositories/integrationsRepository.js'; + +describe('integrationsRepository', () => { + let mockDb: ReturnType; + + beforeEach(() => { + mockDb = createMockDb({ withUpsert: true, withThenable: true }); + vi.mocked(getDb).mockReturnValue(mockDb.db as never); + }); + + describe('listProjectIntegrations', () => { + it('returns integrations for project', async () => { + const integrations = [ + { id: 1, projectId: 'p1', category: 'pm', provider: 'trello', config: {}, triggers: {} }, + ]; + mockDb.chain.where.mockResolvedValueOnce(integrations); + + const result = await listProjectIntegrations('p1'); + expect(result).toEqual(integrations); + }); + }); + + describe('upsertProjectIntegration', () => { + it('upserts integration with onConflictDoUpdate', async () => { + await upsertProjectIntegration('p1', 'pm', 'trello', { boardId: 'abc' }, {}); + + expect(mockDb.db.delete).not.toHaveBeenCalled(); + expect(mockDb.db.insert).toHaveBeenCalledTimes(1); + expect(mockDb.chain.values).toHaveBeenCalledWith({ + projectId: 'p1', + category: 'pm', + provider: 'trello', + config: { boardId: 'abc' }, + triggers: {}, + }); + expect(mockDb.chain.onConflictDoUpdate).toHaveBeenCalledTimes(1); + }); + + it('preserves existing triggers when triggers not provided', async () => { + // Mock getIntegrationByProjectAndCategory to return existing integration with triggers + mockDb.chain.where.mockResolvedValueOnce([ + { + id: 1, + projectId: 'p1', + category: 'pm', + provider: 'trello', + config: {}, + triggers: { cardMovedToBriefing: true, cardMovedToPlanning: false }, + }, + ]); // getIntegrationByProjectAndCategory + + await upsertProjectIntegration('p1', 'pm', 'trello', { boardId: 'xyz' }); + + expect(mockDb.db.select).toHaveBeenCalledTimes(1); // getIntegrationByProjectAndCategory + expect(mockDb.db.delete).not.toHaveBeenCalled(); + expect(mockDb.db.insert).toHaveBeenCalledTimes(1); + expect(mockDb.chain.values).toHaveBeenCalledWith({ + projectId: 'p1', + category: 'pm', + provider: 'trello', + config: { boardId: 'xyz' }, + triggers: { cardMovedToBriefing: true, cardMovedToPlanning: false }, + }); + expect(mockDb.chain.onConflictDoUpdate).toHaveBeenCalledTimes(1); + }); + + it('preserves integration id on update (no delete)', async () => { + await upsertProjectIntegration('p1', 'scm', 'github', { repo: 'owner/repo' }, {}); + + expect(mockDb.db.delete).not.toHaveBeenCalled(); + expect(mockDb.chain.onConflictDoUpdate).toHaveBeenCalledTimes(1); + }); + }); + + describe('deleteProjectIntegration', () => { + it('deletes integration by projectId and type', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await deleteProjectIntegration('p1', 'trello'); + + expect(mockDb.db.delete).toHaveBeenCalledTimes(1); + }); + }); + + describe('getAllProjectIdsWithEmailIntegration', () => { + it('returns projectIds for all email integrations', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ projectId: 'proj-1' }, { projectId: 'proj-2' }]); + + const result = await getAllProjectIdsWithEmailIntegration(); + + expect(result).toEqual(['proj-1', 'proj-2']); + expect(mockDb.db.select).toHaveBeenCalledTimes(1); + }); + + it('returns empty array when no email integrations exist', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await getAllProjectIdsWithEmailIntegration(); + + expect(result).toEqual([]); + }); + }); + + describe('getAllProjectIdsWithSmsIntegration', () => { + it('returns projectIds for all SMS integrations', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ projectId: 'proj-3' }, { projectId: 'proj-4' }]); + + const result = await getAllProjectIdsWithSmsIntegration(); + + expect(result).toEqual(['proj-3', 'proj-4']); + expect(mockDb.db.select).toHaveBeenCalledTimes(1); + }); + + it('returns empty array when no SMS integrations exist', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await getAllProjectIdsWithSmsIntegration(); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/tests/unit/db/repositories/organizationsRepository.test.ts b/tests/unit/db/repositories/organizationsRepository.test.ts new file mode 100644 index 00000000..176341e5 --- /dev/null +++ b/tests/unit/db/repositories/organizationsRepository.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockDb } from '../../../helpers/mockDb.js'; + +vi.mock('../../../../src/db/client.js', () => ({ + getDb: vi.fn(), +})); + +import { getDb } from '../../../../src/db/client.js'; +import { + getOrganization, + listAllOrganizations, + updateOrganization, +} from '../../../../src/db/repositories/organizationsRepository.js'; + +describe('organizationsRepository', () => { + let mockDb: ReturnType; + + beforeEach(() => { + mockDb = createMockDb({ withUpsert: true, withThenable: true }); + vi.mocked(getDb).mockReturnValue(mockDb.db as never); + }); + + describe('getOrganization', () => { + it('returns organization when found', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ id: 'org-1', name: 'My Org' }]); + + const result = await getOrganization('org-1'); + expect(result).toEqual({ id: 'org-1', name: 'My Org' }); + }); + + it('returns null when not found', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await getOrganization('missing'); + expect(result).toBeNull(); + }); + }); + + describe('updateOrganization', () => { + it('updates organization name', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await updateOrganization('org-1', { name: 'New Name' }); + + expect(mockDb.db.update).toHaveBeenCalledTimes(1); + expect(mockDb.chain.set).toHaveBeenCalledWith({ name: 'New Name' }); + }); + }); + + describe('listAllOrganizations', () => { + it('returns all organizations', async () => { + const orgs = [ + { id: 'org-1', name: 'Org One' }, + { id: 'org-2', name: 'Org Two' }, + ]; + const fromMock = vi.fn().mockResolvedValue(orgs); + mockDb.db.select.mockReturnValue({ from: fromMock }); + + const result = await listAllOrganizations(); + expect(result).toEqual(orgs); + }); + }); +}); diff --git a/tests/unit/db/repositories/projectsRepository.test.ts b/tests/unit/db/repositories/projectsRepository.test.ts new file mode 100644 index 00000000..47e6fb7b --- /dev/null +++ b/tests/unit/db/repositories/projectsRepository.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockDb } from '../../../helpers/mockDb.js'; + +vi.mock('../../../../src/db/client.js', () => ({ + getDb: vi.fn(), +})); + +import { getDb } from '../../../../src/db/client.js'; +import { + createProject, + deleteProject, + getProjectFull, + listProjectsFull, + updateProject, +} from '../../../../src/db/repositories/projectsRepository.js'; + +describe('projectsRepository', () => { + let mockDb: ReturnType; + + beforeEach(() => { + mockDb = createMockDb({ withUpsert: true, withThenable: true }); + vi.mocked(getDb).mockReturnValue(mockDb.db as never); + }); + + describe('listProjectsFull', () => { + it('queries projects by orgId', async () => { + const projects = [{ id: 'p1', name: 'Project 1' }]; + mockDb.chain.where.mockResolvedValueOnce(projects); + + const result = await listProjectsFull('org-1'); + expect(result).toEqual(projects); + expect(mockDb.db.select).toHaveBeenCalledTimes(1); + }); + }); + + describe('getProjectFull', () => { + it('returns project when found with matching org', async () => { + const project = { id: 'p1', orgId: 'org-1', name: 'Project 1' }; + mockDb.chain.where.mockResolvedValueOnce([project]); + + const result = await getProjectFull('p1', 'org-1'); + expect(result).toEqual(project); + }); + + it('returns null when not found', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await getProjectFull('missing', 'org-1'); + expect(result).toBeNull(); + }); + }); + + describe('createProject', () => { + it('inserts project and returns row', async () => { + const newProject = { id: 'p1', orgId: 'org-1', name: 'New Project', repo: 'owner/repo' }; + mockDb.chain.returning.mockResolvedValueOnce([newProject]); + + const result = await createProject('org-1', { + id: 'p1', + name: 'New Project', + repo: 'owner/repo', + }); + + expect(result).toEqual(newProject); + expect(mockDb.db.insert).toHaveBeenCalledTimes(1); + expect(mockDb.chain.values).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'p1', + orgId: 'org-1', + name: 'New Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + subscriptionCostZero: false, + }), + ); + }); + }); + + describe('updateProject', () => { + it('updates project with new values', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await updateProject('p1', 'org-1', { name: 'Updated', model: 'new-model' }); + + expect(mockDb.db.update).toHaveBeenCalledTimes(1); + const setArg = mockDb.chain.set.mock.calls[0][0]; + expect(setArg.name).toBe('Updated'); + expect(setArg.model).toBe('new-model'); + expect(setArg.updatedAt).toBeInstanceOf(Date); + }); + }); + + describe('deleteProject', () => { + it('deletes project by id and orgId', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await deleteProject('p1', 'org-1'); + + expect(mockDb.db.delete).toHaveBeenCalledTimes(1); + }); + }); +});