diff --git a/apps/web/src/app/api/cron/enterprise-recommendations-digest/route.ts b/apps/web/src/app/api/cron/enterprise-recommendations-digest/route.ts new file mode 100644 index 0000000000..f17de34199 --- /dev/null +++ b/apps/web/src/app/api/cron/enterprise-recommendations-digest/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server'; + +import { CRON_SECRET } from '@/lib/config.server'; +import { dispatchEnterpriseRecommendationsDigests } from '@/lib/organizations/recommendations-digest'; +import { sentryLogger } from '@/lib/utils.server'; + +if (!CRON_SECRET) { + throw new Error('CRON_SECRET is not configured in environment variables'); +} + +export async function GET(request: Request) { + const authHeader = request.headers.get('authorization'); + const expectedAuth = `Bearer ${CRON_SECRET}`; + if (authHeader !== expectedAuth) { + sentryLogger( + 'cron', + 'warning' + )( + 'SECURITY: Invalid CRON job authorization attempt: ' + + (authHeader ? 'Invalid authorization header' : 'Missing authorization header') + ); + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const summary = await dispatchEnterpriseRecommendationsDigests(); + + return NextResponse.json( + { + success: true, + summary, + timestamp: new Date().toISOString(), + }, + { status: 200 } + ); +} diff --git a/apps/web/src/app/api/organizations/hooks.ts b/apps/web/src/app/api/organizations/hooks.ts index 2093d3affd..78ed3da1ce 100644 --- a/apps/web/src/app/api/organizations/hooks.ts +++ b/apps/web/src/app/api/organizations/hooks.ts @@ -285,6 +285,21 @@ export function useUpdateMinimumBalanceAlert() { ); } +export function useUpdateRecommendationsDigest() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + trpc.organizations.settings.updateRecommendationsDigest.mutationOptions({ + onSuccess: () => { + // Invalidate organization data to refresh settings (shared with the + // spending-alerts surface, so both stay in sync). + void queryClient.invalidateQueries({ queryKey: trpc.organizations.pathKey() }); + }, + }) + ); +} + export function useEnableOssSponsorship() { const trpc = useTRPC(); const invalidate = useInvalidateAllOrganizationData(); diff --git a/apps/web/src/components/organizations/OrganizationDashboard.tsx b/apps/web/src/components/organizations/OrganizationDashboard.tsx index 5b69d93365..4da11df6fd 100644 --- a/apps/web/src/components/organizations/OrganizationDashboard.tsx +++ b/apps/web/src/components/organizations/OrganizationDashboard.tsx @@ -18,6 +18,7 @@ import { useOrganizationWithMembers } from '@/app/api/organizations/hooks'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { MessageCircleQuestion, Terminal } from 'lucide-react'; import { OrganizationProvidersAndModelsConfigurationCard } from '@/components/organizations/OrganizationProvidersAndModelsConfigurationCard'; +import { OrganizationEmailPreferencesCard } from '@/components/organizations/OrganizationEmailPreferencesCard'; import { OrgActiveKiloclawsCard } from '@/components/organizations/OrgActiveKiloclawsCard'; import { OpenInExtensionButton } from '@/components/auth/OpenInExtensionButton'; import Image from 'next/image'; @@ -168,6 +169,9 @@ export function OrganizationDashboard({ )} + {(currentRole === 'owner' || isKiloAdmin) && ( + + )} diff --git a/apps/web/src/components/organizations/OrganizationEmailPreferencesCard.tsx b/apps/web/src/components/organizations/OrganizationEmailPreferencesCard.tsx new file mode 100644 index 0000000000..84b710480c --- /dev/null +++ b/apps/web/src/components/organizations/OrganizationEmailPreferencesCard.tsx @@ -0,0 +1,157 @@ +'use client'; + +import { useState } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Bell, ChartLine, Mail } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import { toast } from 'sonner'; +import { + useOrganizationWithMembers, + useUpdateRecommendationsDigest, +} from '@/app/api/organizations/hooks'; +import { SpendingAlertsModal } from './SpendingAlertsModal'; + +type Props = { + organizationId: string; +}; + +function recipientStateLabel(recipientCount: number): string { + if (recipientCount === 0) { + return 'Off'; + } + return `On · ${recipientCount} recipient${recipientCount === 1 ? '' : 's'}`; +} + +function PreferenceRow({ + icon: Icon, + title, + description, + stateLabel, + isOn, + control, +}: { + icon: LucideIcon; + title: string; + description: string; + stateLabel?: string; + isOn?: boolean; + control: React.ReactNode; +}) { + return ( +
+
+ +
+

{title}

+

{description}

+ {stateLabel && ( +

+ {stateLabel} +

+ )} +
+
+
{control}
+
+ ); +} + +export function OrganizationEmailPreferencesCard({ organizationId }: Props) { + const { data } = useOrganizationWithMembers(organizationId); + const [isSpendingAlertsOpen, setIsSpendingAlertsOpen] = useState(false); + const updateRecommendationsDigest = useUpdateRecommendationsDigest(); + + if (!data) { + return null; + } + + const settings = data.settings; + const isEnterprise = data.plan === 'enterprise'; + + // Low-balance alerts are "on" only when both a threshold and at least one + // recipient are configured (matches SpendingAlertsModal's enabled check). + const spendingRecipientCount = + settings?.minimum_balance !== undefined + ? (settings?.minimum_balance_alert_email?.length ?? 0) + : 0; + const digestEnabled = settings?.recommendations_digest_enabled === true; + + const handleDigestToggle = (next: boolean) => { + updateRecommendationsDigest.mutate( + { organizationId, enabled: next }, + { + onSuccess: () => { + toast.success( + next + ? 'Weekly recommendations email enabled. Organization owners will receive it.' + : 'Weekly recommendations email disabled.' + ); + }, + onError: (error: unknown) => { + toast.error( + error instanceof Error + ? error.message + : 'Failed to update the recommendations digest setting' + ); + }, + } + ); + }; + + return ( + + + + + Email preferences + + Choose which emails this organization receives. + + +
+ 0} + control={ + + } + /> + {isEnterprise && ( + + } + /> + )} +
+
+ + +
+ ); +} diff --git a/apps/web/src/emails/AGENTS.md b/apps/web/src/emails/AGENTS.md index b1ac9fa720..336689f01d 100644 --- a/apps/web/src/emails/AGENTS.md +++ b/apps/web/src/emails/AGENTS.md @@ -99,3 +99,4 @@ Every template must include this branding footer below the content table: | `securityFindingNew.html` | `severity`, `repository_name`, `finding_title`, `finding_description`, `finding_details`, `action_url`, `manage_notifications_url`, `year` | — | | `securityFindingSlaWarning.html` | `severity`, `repository_name`, `finding_title`, `finding_description`, `finding_details`, `sla_deadline`, `action_url`, `manage_notifications_url`, `year` | — | | `securityFindingSlaBreach.html` | `severity`, `repository_name`, `finding_title`, `finding_description`, `finding_details`, `sla_deadline`, `action_url`, `manage_notifications_url`, `year` | — | +| `recommendationsDigest.html` | `organization_name`, `adopted_summary`, `open_count`, `recommendations_section`, `dashboard_url`, `year` | — | diff --git a/apps/web/src/emails/recommendationsDigest.html b/apps/web/src/emails/recommendationsDigest.html new file mode 100644 index 0000000000..1f02261539 --- /dev/null +++ b/apps/web/src/emails/recommendationsDigest.html @@ -0,0 +1,149 @@ + + + + + + Your weekly Kilo recommendations + + + + + + +
+ + + + + + + + + + + + + +
+

+ Your weekly Kilo recommendations +

+
+

+ Here's how to get more from Kilo for + {{ organization_name }} this week. You + have {{ open_count }} open + recommendations, and your organization has set up + {{ adopted_summary }} features. +

+ + + {{ recommendations_section }} + + + + + + +
+ + View recommendations + +
+ +

+ You're receiving this because you're an owner of this organization. You can turn + the weekly recommendations email off from the organization's Email preferences. +

+
+

+ — The Kilo Team +

+
+ + + + + +
+

+ © {{ year }} Kilo Code, Inc
455 Market St, Ste 1940 PMB 993504
San + Francisco, CA 94105, USA +

+
+
+ + diff --git a/apps/web/src/lib/email.ts b/apps/web/src/lib/email.ts index e88a7fdf3c..505020486d 100644 --- a/apps/web/src/lib/email.ts +++ b/apps/web/src/lib/email.ts @@ -58,6 +58,7 @@ export const subjects = { securityFindingNew: 'Kilo Security Agent: New finding', securityFindingSlaWarning: 'Kilo Security Agent: SLA warning', securityFindingSlaBreach: 'Kilo Security Agent: SLA breached', + recommendationsDigest: 'Kilo: Your weekly recommendations', } as const; export type TemplateName = keyof typeof subjects; @@ -584,3 +585,69 @@ export async function sendKiloPassDuplicateCardCanceledEmail( templateVars: { support_url }, }); } + +type RecommendationsDigestRecommendation = { + title: string; + description: string; + actionLabel: string; + // Relative path (e.g. /organizations//integrations); prefixed with the app URL. + actionUrl: string; +}; + +type SendRecommendationsDigestEmailProps = { + organizationId: string; + organizationName: string; + adoptedCount: number; + totalCount: number; + openCount: number; + recommendations: RecommendationsDigestRecommendation[]; +}; + +function buildRecommendationsDigestSection( + recommendations: RecommendationsDigestRecommendation[], + baseUrl: string +): RawHtml { + const rows = recommendations + .map(rec => { + const href = `${baseUrl}${escapeHtml(rec.actionUrl)}`; + return ` + + +

${escapeHtml( + rec.title + )}

+

${escapeHtml( + rec.description + )}

+ ${escapeHtml( + rec.actionLabel + )} + + `; + }) + .join(''); + return new RawHtml( + `${rows}
` + ); +} + +export async function sendRecommendationsDigestEmail( + to: string, + props: SendRecommendationsDigestEmailProps +): Promise { + const dashboard_url = `${NEXTAUTH_URL}/organizations/${props.organizationId}/usage-details?view=feature-adoption`; + return send({ + to, + templateName: 'recommendationsDigest', + templateVars: { + organization_name: renderNonAutolinkedText(props.organizationName), + adopted_summary: `${props.adoptedCount} of ${props.totalCount}`, + open_count: String(props.openCount), + recommendations_section: buildRecommendationsDigestSection( + props.recommendations, + NEXTAUTH_URL + ), + dashboard_url, + }, + }); +} diff --git a/apps/web/src/lib/organizations/organizations.ts b/apps/web/src/lib/organizations/organizations.ts index 22f1bc8ac2..1f2c5b6ff0 100644 --- a/apps/web/src/lib/organizations/organizations.ts +++ b/apps/web/src/lib/organizations/organizations.ts @@ -826,6 +826,27 @@ export async function updateOrganizationSettings( return settings; } +// Atomically toggle the recommendations-digest flag inside the settings JSONB +// without a read-modify-write of the whole object, so a concurrent settings +// mutation can't clobber other fields. Enabling sets the single key; disabling +// removes it (we never persist `false`). Returns the fresh settings. +export async function setOrganizationRecommendationsDigestEnabled( + organizationId: Organization['id'], + enabled: boolean +): Promise { + const [row] = await db + .update(organizations) + .set({ + settings: enabled + ? sql`jsonb_set(COALESCE(${organizations.settings}, '{}'::jsonb), '{recommendations_digest_enabled}', 'true'::jsonb)` + : sql`COALESCE(${organizations.settings}, '{}'::jsonb) - 'recommendations_digest_enabled'`, + }) + .where(eq(organizations.id, organizationId)) + .returning({ settings: organizations.settings }); + + return row?.settings ?? {}; +} + export async function markOrganizationAsDeleted(organizationId: Organization['id']): Promise { await db .update(organizations) diff --git a/apps/web/src/lib/organizations/recommendations-digest.test.ts b/apps/web/src/lib/organizations/recommendations-digest.test.ts new file mode 100644 index 0000000000..e14a9c42ca --- /dev/null +++ b/apps/web/src/lib/organizations/recommendations-digest.test.ts @@ -0,0 +1,217 @@ +import { and, eq, inArray } from 'drizzle-orm'; +import { + buildOrganizationRecommendationsDigest, + currentDigestPeriodKey, + dispatchEnterpriseRecommendationsDigests, + getOrganizationOwnerRecipients, +} from './recommendations-digest'; +import { db } from '@/lib/drizzle'; +import { + kilocode_users, + organization_memberships, + organizations, + transactional_email_log, +} from '@kilocode/db/schema'; +import { createTestOrganization } from '@/tests/helpers/organization.helper'; +import { insertTestUser } from '@/tests/helpers/user.helper'; + +jest.mock('./recommendations', () => ({ + getOrganizationRecommendations: jest.fn(), +})); + +jest.mock('@/lib/email', () => ({ + sendRecommendationsDigestEmail: jest.fn(), +})); + +import { sendRecommendationsDigestEmail } from '@/lib/email'; +import { getOrganizationRecommendations } from './recommendations'; + +const mockedGetRecommendations = jest.mocked(getOrganizationRecommendations); +const mockedSendRecommendationsDigestEmail = jest.mocked(sendRecommendationsDigestEmail); + +type ResolvedRecommendations = Awaited>; + +function check(adopted: boolean) { + return { + key: 'source-control-integration', + title: 'Source control', + description: 'desc', + adopted, + adoptedLabel: 'Connected', + notAdoptedLabel: 'Not connected', + actionLabel: 'Connect', + actionUrl: '/x', + }; +} + +function recommendation(status: 'open' | 'completed' | 'dismissed', index: number) { + return { + key: `rec-${index}`, + feature: 'code-reviewer', + status, + title: `Title ${index}`, + description: `Description ${index}`, + actionLabel: `Action ${index}`, + actionUrl: `/organizations/org/setting-${index}`, + severity: 'suggestion', + }; +} + +function mockResolved( + plan: 'teams' | 'enterprise', + checks: ReturnType[], + recommendations: ReturnType[] +) { + mockedGetRecommendations.mockResolvedValue({ + plan, + checks, + recommendations, + } as unknown as ResolvedRecommendations); +} + +describe('buildOrganizationRecommendationsDigest', () => { + beforeEach(() => { + mockedGetRecommendations.mockReset(); + }); + + it('returns null for non-enterprise organizations', async () => { + mockResolved('teams', [], []); + + const result = await buildOrganizationRecommendationsDigest('org-1', 'Acme'); + + expect(result).toBeNull(); + }); + + it('returns null when there are no open recommendations (skip-empty)', async () => { + mockResolved( + 'enterprise', + [check(true), check(true)], + [recommendation('completed', 0), recommendation('dismissed', 1)] + ); + + const result = await buildOrganizationRecommendationsDigest('org-1', 'Acme'); + + expect(result).toBeNull(); + }); + + it('builds the payload with adoption counts and only open recommendations', async () => { + mockResolved( + 'enterprise', + [check(true), check(true), check(true), check(false), check(false), check(false)], + [recommendation('open', 0), recommendation('completed', 1), recommendation('open', 2)] + ); + + const result = await buildOrganizationRecommendationsDigest('org-1', 'Acme'); + + expect(result).not.toBeNull(); + expect(result?.organizationName).toBe('Acme'); + expect(result?.adoptedCount).toBe(3); + expect(result?.totalCount).toBe(6); + expect(result?.openCount).toBe(2); + expect(result?.recommendations.map(r => r.title)).toEqual(['Title 0', 'Title 2']); + }); + + it('caps the listed recommendations at three but keeps the full open count', async () => { + const openRecs = Array.from({ length: 5 }, (_, i) => recommendation('open', i)); + mockResolved('enterprise', [check(true)], openRecs); + + const result = await buildOrganizationRecommendationsDigest('org-1', 'Acme'); + + expect(result?.openCount).toBe(5); + expect(result?.recommendations).toHaveLength(3); + expect(result?.recommendations.map(r => r.title)).toEqual(['Title 0', 'Title 1', 'Title 2']); + }); +}); + +describe('currentDigestPeriodKey', () => { + it("returns the week's Monday (UTC) for any day in that week", () => { + // 2026-06-22 is a Monday; every day Mon..Sun maps to it. + expect(currentDigestPeriodKey(new Date('2026-06-22T09:00:00Z'))).toBe('2026-06-22'); + expect(currentDigestPeriodKey(new Date('2026-06-24T23:30:00Z'))).toBe('2026-06-22'); + expect(currentDigestPeriodKey(new Date('2026-06-28T00:00:00Z'))).toBe('2026-06-22'); + }); + + it('rolls over to the next Monday for the following week', () => { + expect(currentDigestPeriodKey(new Date('2026-06-29T00:00:00Z'))).toBe('2026-06-29'); + }); +}); + +describe('recommendations digest dispatch', () => { + const userIds: string[] = []; + const organizationIds: string[] = []; + + beforeEach(() => { + mockedGetRecommendations.mockReset(); + mockedSendRecommendationsDigestEmail.mockReset(); + }); + + afterEach(async () => { + if (organizationIds.length > 0) { + await db.delete(organizations).where(inArray(organizations.id, organizationIds.splice(0))); + } + if (userIds.length > 0) { + await db.delete(kilocode_users).where(inArray(kilocode_users.id, userIds.splice(0))); + } + }); + + async function createEnabledOrganizationWithTwoOwners() { + const firstOwner = await insertTestUser(); + const secondOwner = await insertTestUser(); + userIds.push(firstOwner.id, secondOwner.id); + + const organization = await createTestOrganization('Digest dispatch org', firstOwner.id, 0, { + recommendations_digest_enabled: true, + }); + organizationIds.push(organization.id); + + await db.insert(organization_memberships).values({ + organization_id: organization.id, + kilo_user_id: secondOwner.id, + role: 'owner', + }); + + return { organization, firstOwner, secondOwner }; + } + + it('returns owner user IDs with email addresses for non-PII idempotency', async () => { + const { organization, firstOwner, secondOwner } = + await createEnabledOrganizationWithTwoOwners(); + + const recipients = await getOrganizationOwnerRecipients(organization.id); + + expect(recipients).toEqual( + expect.arrayContaining([ + { userId: firstOwner.id, email: firstOwner.google_user_email }, + { userId: secondOwner.id, email: secondOwner.google_user_email }, + ]) + ); + }); + + it('releases a failed claim and continues sending to remaining owners', async () => { + const { organization, firstOwner, secondOwner } = + await createEnabledOrganizationWithTwoOwners(); + mockResolved('enterprise', [check(false)], [recommendation('open', 0)]); + mockedSendRecommendationsDigestEmail + .mockRejectedValueOnce(new Error('Mailgun unavailable')) + .mockResolvedValueOnce({ sent: true }); + + const summary = await dispatchEnterpriseRecommendationsDigests(); + + expect(summary.emailFailures).toBe(1); + expect(summary.emailsSent).toBe(1); + expect(mockedSendRecommendationsDigestEmail).toHaveBeenCalledTimes(2); + + const markers = await db + .select({ idempotencyKey: transactional_email_log.idempotency_key }) + .from(transactional_email_log) + .where( + and( + eq(transactional_email_log.organization_id, organization.id), + eq(transactional_email_log.email_type, 'recommendations_digest') + ) + ); + expect(markers).toHaveLength(1); + expect(markers[0].idempotencyKey).not.toContain(firstOwner.google_user_email); + expect(markers[0].idempotencyKey).not.toContain(secondOwner.google_user_email); + }); +}); diff --git a/apps/web/src/lib/organizations/recommendations-digest.ts b/apps/web/src/lib/organizations/recommendations-digest.ts new file mode 100644 index 0000000000..870e1462b9 --- /dev/null +++ b/apps/web/src/lib/organizations/recommendations-digest.ts @@ -0,0 +1,251 @@ +import pLimit from 'p-limit'; +import { and, eq, isNull, sql } from 'drizzle-orm'; +import { db } from '@/lib/drizzle'; +import { + organizations, + organization_memberships, + kilocode_users, + transactional_email_log, +} from '@kilocode/db/schema'; +import { getOrganizationRecommendations } from './recommendations'; +import { sendRecommendationsDigestEmail } from '@/lib/email'; +import { errorExceptInTest, logExceptInTest } from '@/lib/utils.server'; + +// Cap the number of recommendations listed in the email; the rest live on the +// dashboard the email links to. +const MAX_RECOMMENDATIONS_IN_EMAIL = 3; +const ORG_DISPATCH_CONCURRENCY = 4; +// email_type for the transactional_email_log idempotency markers. +const DIGEST_EMAIL_TYPE = 'recommendations_digest'; + +// Period key for one weekly send: the UTC date (YYYY-MM-DD) of the week's Monday. +// Two invocations in the same week (cron overlap, a Vercel retry, a replay) compute +// the same key, so the per-recipient idempotency claim below dedupes them. +export function currentDigestPeriodKey(now: Date): string { + const midnightUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const daysSinceMonday = (midnightUtc.getUTCDay() + 6) % 7; + midnightUtc.setUTCDate(midnightUtc.getUTCDate() - daysSinceMonday); + return midnightUtc.toISOString().slice(0, 10); +} + +function digestIdempotencyKey( + organizationId: string, + recipientUserId: string, + periodKey: string +): string { + return `${organizationId}:${recipientUserId}:${periodKey}`; +} + +export type RecommendationsDigestData = { + organizationId: string; + organizationName: string; + adoptedCount: number; + totalCount: number; + openCount: number; + recommendations: Array<{ + title: string; + description: string; + actionLabel: string; + actionUrl: string; + }>; +}; + +// Build the digest payload for one org, or null when there's nothing actionable. +// Skip-empty rule: no open recommendations means no email this week (a digest +// that says "all good" every week trains owners to ignore it). +export async function buildOrganizationRecommendationsDigest( + organizationId: string, + organizationName: string +): Promise { + const { plan, checks, recommendations } = await getOrganizationRecommendations(organizationId); + if (plan !== 'enterprise') { + return null; + } + + const openRecommendations = recommendations.filter(rec => rec.status === 'open'); + if (openRecommendations.length === 0) { + return null; + } + + return { + organizationId, + organizationName, + adoptedCount: checks.filter(check => check.adopted).length, + totalCount: checks.length, + openCount: openRecommendations.length, + recommendations: openRecommendations.slice(0, MAX_RECOMMENDATIONS_IN_EMAIL).map(rec => ({ + title: rec.title, + description: rec.description, + actionLabel: rec.actionLabel, + actionUrl: rec.actionUrl, + })), + }; +} + +type DigestRecipient = { + userId: string; + email: string; +}; + +// Resolve recipients from the primary immediately before delivery so a recent +// membership removal cannot leak organization details through replica lag. +export async function getOrganizationOwnerRecipients( + organizationId: string +): Promise { + const rows = await db + .select({ + userId: kilocode_users.id, + email: kilocode_users.google_user_email, + }) + .from(organization_memberships) + .innerJoin(kilocode_users, eq(kilocode_users.id, organization_memberships.kilo_user_id)) + .where( + and( + eq(organization_memberships.organization_id, organizationId), + eq(organization_memberships.role, 'owner'), + eq(kilocode_users.is_bot, false) + ) + ); + return rows.flatMap(row => (row.email ? [{ userId: row.userId, email: row.email }] : [])); +} + +export type RecommendationsDigestDispatchSummary = { + enabledOrgs: number; + orgsSkippedEmpty: number; + orgsSkippedNoOwners: number; + emailsSent: number; + duplicatesSkipped: number; + emailFailures: number; + orgFailures: number; +}; + +type RecipientSendOutcome = 'sent' | 'duplicate' | 'failed'; + +// Claim (org, recipient, week) before sending so the same owner can't get two +// digests for one week. The unique (email_type, idempotency_key) index makes the +// insert the atomic claim; a lost insert (rowCount 0) means another invocation +// already owns this send. If the send then fails, the claim is released so a later +// run can retry. Mirrors the kilo-pass duplicate-card email path. +async function sendDigestToRecipientOnce( + recipient: DigestRecipient, + organizationId: string, + periodKey: string, + digest: RecommendationsDigestData +): Promise { + const idempotency_key = digestIdempotencyKey(organizationId, recipient.userId, periodKey); + + const claim = await db + .insert(transactional_email_log) + .values({ + organization_id: organizationId, + email_type: DIGEST_EMAIL_TYPE, + idempotency_key, + }) + .onConflictDoNothing(); + + if ((claim.rowCount ?? 0) === 0) { + return 'duplicate'; + } + + try { + const result = await sendRecommendationsDigestEmail(recipient.email, digest); + if (result.sent) { + return 'sent'; + } + + logExceptInTest( + `[recommendationsDigest] send skipped for org ${organizationId}: ${result.reason}` + ); + } catch (error) { + errorExceptInTest('[recommendationsDigest] recipient send failed', { + organizationId, + recipientUserId: recipient.userId, + error, + }); + } + + // Release the claim so a future run can retry this recipient/week. + await db + .delete(transactional_email_log) + .where( + and( + eq(transactional_email_log.email_type, DIGEST_EMAIL_TYPE), + eq(transactional_email_log.idempotency_key, idempotency_key) + ) + ); + return 'failed'; +} + +// Cron entrypoint: send the weekly recommendations digest to the owners of every +// Enterprise org that has the digest enabled and has something actionable. +export async function dispatchEnterpriseRecommendationsDigests(): Promise { + // Filter to opted-in orgs in SQL so the read scales with actual recipients, not + // the entire Enterprise population. The flag is only ever stored as `true` (it is + // removed when disabled), so a `->> = 'true'` predicate is exact. Read from the + // primary, not the replica: this query is the opt-out gate, and replica lag could + // otherwise send one more digest to an org that just disabled it. + const enabledOrgs = await db + .select({ + id: organizations.id, + name: organizations.name, + }) + .from(organizations) + .where( + and( + eq(organizations.plan, 'enterprise'), + isNull(organizations.deleted_at), + sql`${organizations.settings}->>'recommendations_digest_enabled' = 'true'` + ) + ); + + const summary: RecommendationsDigestDispatchSummary = { + enabledOrgs: enabledOrgs.length, + orgsSkippedEmpty: 0, + orgsSkippedNoOwners: 0, + emailsSent: 0, + duplicatesSkipped: 0, + emailFailures: 0, + orgFailures: 0, + }; + + const periodKey = currentDigestPeriodKey(new Date()); + const limit = pLimit(ORG_DISPATCH_CONCURRENCY); + await Promise.all( + enabledOrgs.map(org => + limit(async () => { + try { + const digest = await buildOrganizationRecommendationsDigest(org.id, org.name); + if (!digest) { + summary.orgsSkippedEmpty++; + return; + } + + const recipients = await getOrganizationOwnerRecipients(org.id); + if (recipients.length === 0) { + summary.orgsSkippedNoOwners++; + return; + } + + for (const recipient of recipients) { + const outcome = await sendDigestToRecipientOnce(recipient, org.id, periodKey, digest); + if (outcome === 'sent') { + summary.emailsSent++; + } else if (outcome === 'duplicate') { + summary.duplicatesSkipped++; + } else { + summary.emailFailures++; + } + } + } catch (error) { + summary.orgFailures++; + errorExceptInTest('[recommendationsDigest] org dispatch failed', { + organizationId: org.id, + error, + }); + } + }) + ) + ); + + return summary; +} diff --git a/apps/web/src/routers/admin/email-testing-router.ts b/apps/web/src/routers/admin/email-testing-router.ts index 43b03aafe5..df3913bf8c 100644 --- a/apps/web/src/routers/admin/email-testing-router.ts +++ b/apps/web/src/routers/admin/email-testing-router.ts @@ -227,6 +227,42 @@ function fixtureTemplateVars(template: TemplateName): Record + `` + + `

${title}

` + + `

${description}

` + + `${label}` + + ``; + return { + organization_name: 'Acme Corp', + adopted_summary: '4 of 6', + open_count: '3', + recommendations_section: new RawHtml( + `${[ + recRow( + 'Add a security focus to Code Reviewer', + 'Code Reviewer is on but not reviewing for security issues.', + 'Open Code Reviewer settings', + code_reviews_url + ), + recRow( + 'Reconnect GitHub', + 'Your GitHub integration needs to be reconnected; automation is paused.', + 'Open integrations', + integrations_url + ), + recRow( + 'Set up SSO for your organization', + 'Require single sign-on for your team.', + 'Open organization settings', + organization_url + ), + ].join('')}
` + ), + dashboard_url: `${NEXTAUTH_URL}/organizations/${orgId}/usage-details?view=feature-adoption`, + }; + } } throw new Error(`Unknown template: ${template}`); } diff --git a/apps/web/src/routers/organizations/organization-settings-router.test.ts b/apps/web/src/routers/organizations/organization-settings-router.test.ts index e9fac0f99b..fd451cd1ac 100644 --- a/apps/web/src/routers/organizations/organization-settings-router.test.ts +++ b/apps/web/src/routers/organizations/organization-settings-router.test.ts @@ -663,4 +663,90 @@ describe('organizations settings trpc router', () => { expect(result.settings.minimum_balance_alert_email).toBeUndefined(); }); }); + + describe('updateRecommendationsDigest procedure', () => { + afterEach(async () => { + // Reset settings between cases so each starts from a clean slate. + await updateOrganizationSettings(testOrganization.id, {}); + }); + + it('should enable the recommendations digest (enterprise org)', async () => { + const caller = await createCallerForUser(owner.id); + + const result = await caller.organizations.settings.updateRecommendationsDigest({ + organizationId: testOrganization.id, + enabled: true, + }); + + expect(result.settings.recommendations_digest_enabled).toBe(true); + + const updatedOrg = await getOrganizationById(testOrganization.id); + expect(updatedOrg?.settings?.recommendations_digest_enabled).toBe(true); + }); + + it('should disable the digest and remove the flag', async () => { + const caller = await createCallerForUser(owner.id); + + await caller.organizations.settings.updateRecommendationsDigest({ + organizationId: testOrganization.id, + enabled: true, + }); + + const result = await caller.organizations.settings.updateRecommendationsDigest({ + organizationId: testOrganization.id, + enabled: false, + }); + + expect(result.settings.recommendations_digest_enabled).toBeUndefined(); + + const updatedOrg = await getOrganizationById(testOrganization.id); + expect(updatedOrg?.settings?.recommendations_digest_enabled).toBeUndefined(); + }); + + it('should throw UNAUTHORIZED error for non-owner users', async () => { + const caller = await createCallerForUser(member.id); + + await expect( + caller.organizations.settings.updateRecommendationsDigest({ + organizationId: testOrganization.id, + enabled: true, + }) + ).rejects.toThrow('You do not have the required organizational role to access this feature'); + }); + + it('should throw FORBIDDEN for non-enterprise organizations', async () => { + const teamsOrg = await createTestOrganization('Teams Org Digest', owner.id, 0, {}, true); + + const caller = await createCallerForUser(owner.id); + + await expect( + caller.organizations.settings.updateRecommendationsDigest({ + organizationId: teamsOrg.id, + enabled: true, + }) + ).rejects.toThrow('The recommendations digest is not available for this organization.'); + + await db.delete(organizations).where(eq(organizations.id, teamsOrg.id)); + }); + + it('should preserve other settings when toggling the digest', async () => { + const caller = await createCallerForUser(owner.id); + + await updateOrganizationSettings(testOrganization.id, { + model_deny_list: ['gpt-4'], + minimum_balance: 100, + minimum_balance_alert_email: ['alert@example.com'], + }); + + const result = await caller.organizations.settings.updateRecommendationsDigest({ + organizationId: testOrganization.id, + enabled: true, + }); + + expect(result.settings.model_deny_list).toEqual(['gpt-4']); + expect(result.settings.minimum_balance).toBe(100); + expect(result.settings.minimum_balance_alert_email).toEqual(['alert@example.com']); + expect(result.settings.recommendations_digest_enabled).toBe(true); + }); + }); }); diff --git a/apps/web/src/routers/organizations/organization-settings-router.ts b/apps/web/src/routers/organizations/organization-settings-router.ts index 69a77b60ad..7822d99b13 100644 --- a/apps/web/src/routers/organizations/organization-settings-router.ts +++ b/apps/web/src/routers/organizations/organization-settings-router.ts @@ -1,4 +1,8 @@ -import { getOrganizationById, updateOrganizationSettings } from '@/lib/organizations/organizations'; +import { + getOrganizationById, + setOrganizationRecommendationsDigestEnabled, + updateOrganizationSettings, +} from '@/lib/organizations/organizations'; import type { OpenRouterModelsResponse, OrganizationSettings, @@ -8,6 +12,7 @@ import { OrganizationIdInputSchema, organizationBillingMutationProcedure, organizationMemberProcedure, + organizationOwnerMutationProcedure, } from '@/routers/organizations/utils'; import { TRPCError } from '@trpc/server'; import * as z from 'zod'; @@ -141,6 +146,11 @@ const UpdateMinimumBalanceAlertInputSchema = OrganizationIdInputSchema.extend({ } ); +const UpdateRecommendationsDigestInputSchema = OrganizationIdInputSchema.extend({ + // The digest is a simple on/off toggle; when on it emails the org's owners. + enabled: z.boolean(), +}); + const SettingsResponseSchema = z.object({ settings: z.custom(), }); @@ -447,6 +457,56 @@ export const organizationsSettingsRouter = createTRPCRouter({ }); } + return { + settings: updatedSettings, + }; + }), + + // Owners-only: toggle the weekly enterprise recommendations digest email. + // Enterprise-gated and owner-only (matching the recommendations dismiss/restore + // permission model). When on, the digest is emailed to the org's owners. + updateRecommendationsDigest: organizationOwnerMutationProcedure + .input(UpdateRecommendationsDigestInputSchema) + .output(SettingsResponseSchema) + .mutation(async ({ input, ctx }) => { + const { organizationId, enabled } = input; + + const existingOrg = await getOrganizationById(organizationId); + if (!existingOrg) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Organization not found', + }); + } + + // Enterprise-only feature. + if (existingOrg.plan !== 'enterprise') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'The recommendations digest is not available for this organization.', + }); + } + + const wasEnabled = existingOrg.settings?.recommendations_digest_enabled === true; + + // Atomic single-key JSONB update so a concurrent settings mutation can't be + // clobbered by a stale read-modify-write of the whole settings object. + const updatedSettings = await setOrganizationRecommendationsDigestEnabled( + organizationId, + enabled + ); + + if (enabled !== wasEnabled) { + await createAuditLog({ + action: 'organization.settings.change', + actor_email: ctx.user.google_user_email, + actor_id: ctx.user.id, + actor_name: ctx.user.google_user_name, + message: enabled ? 'Recommendations digest: enabled' : 'Recommendations digest: disabled', + organization_id: organizationId, + }); + } + return { settings: updatedSettings, }; diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 2479e3ce11..23cbc48a5e 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -75,6 +75,10 @@ { "path": "/api/cron/cleanup-review-memory", "schedule": "0 */12 * * *" + }, + { + "path": "/api/cron/enterprise-recommendations-digest", + "schedule": "0 9 * * 1" } ], "build": { diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts index 28e8bde258..8b51b14345 100644 --- a/packages/db/src/schema-types.ts +++ b/packages/db/src/schema-types.ts @@ -746,6 +746,11 @@ const OrganizationSettingsSchema = z.object({ projects_ui_enabled: z.boolean().optional(), minimum_balance: z.number().optional(), minimum_balance_alert_email: z.array(z.email()).optional(), + // Whether the weekly enterprise recommendations digest email is enabled. When on, + // the digest is emailed to the organization's owners. Enterprise-only feature. + // Named "recommendations" (not "adoption") to avoid confusion with AI adoption + // usage data and the Feature adoption tab. + recommendations_digest_enabled: z.boolean().optional(), suppress_trial_messaging: z.boolean().optional(), // OSS Sponsorship fields // null/undefined = not an OSS org, values: 1, 2, or 3