From bcae219747a668403eedc205e81f5b022dc12c07 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Tue, 23 Jun 2026 13:02:59 -0700 Subject: [PATCH 1/5] feat(org): add email preferences section with adoption digest opt-in --- apps/web/src/app/api/organizations/hooks.ts | 15 ++ .../organizations/AdoptionDigestModal.tsx | 198 ++++++++++++++++++ .../organizations/OrganizationDashboard.tsx | 4 + .../OrganizationEmailPreferencesCard.tsx | 123 +++++++++++ .../organization-settings-router.test.ts | 108 ++++++++++ .../organization-settings-router.ts | 68 ++++++ packages/db/src/schema-types.ts | 3 + 7 files changed, 519 insertions(+) create mode 100644 apps/web/src/components/organizations/AdoptionDigestModal.tsx create mode 100644 apps/web/src/components/organizations/OrganizationEmailPreferencesCard.tsx diff --git a/apps/web/src/app/api/organizations/hooks.ts b/apps/web/src/app/api/organizations/hooks.ts index 2093d3affd..2cca74159d 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 useUpdateAdoptionDigest() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + trpc.organizations.settings.updateAdoptionDigest.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/AdoptionDigestModal.tsx b/apps/web/src/components/organizations/AdoptionDigestModal.tsx new file mode 100644 index 0000000000..71ce37caa2 --- /dev/null +++ b/apps/web/src/components/organizations/AdoptionDigestModal.tsx @@ -0,0 +1,198 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import type { OrganizationSettings } from '@/lib/organizations/organization-types'; +import { Loader2, Plus, X } from 'lucide-react'; +import { toast } from 'sonner'; +import { useUpdateAdoptionDigest } from '@/app/api/organizations/hooks'; + +type AdoptionDigestModalProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + organizationId: string; + settings: OrganizationSettings | undefined; +}; + +const isValidEmail = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email.trim()); +}; + +export function AdoptionDigestModal({ + open, + onOpenChange, + organizationId, + settings, +}: AdoptionDigestModalProps) { + const [emails, setEmails] = useState(settings?.adoption_digest_email ?? []); + const [newEmail, setNewEmail] = useState(''); + const [emailError, setEmailError] = useState(null); + + const updateAdoptionDigestMutation = useUpdateAdoptionDigest(); + + // Sync form state with settings when dialog opens. + useEffect(() => { + if (open) { + setEmails(settings?.adoption_digest_email ?? []); + setNewEmail(''); + setEmailError(null); + } + }, [open, settings]); + + const handleAddEmail = () => { + const trimmedEmail = newEmail.trim(); + if (!trimmedEmail) { + return; + } + + if (!isValidEmail(trimmedEmail)) { + setEmailError('Please enter a valid email address'); + return; + } + + if (emails.includes(trimmedEmail)) { + setEmailError('This email is already in the list'); + return; + } + + setEmails([...emails, trimmedEmail]); + setNewEmail(''); + setEmailError(null); + }; + + const handleRemoveEmail = (emailToRemove: string) => { + setEmails(emails.filter(email => email !== emailToRemove)); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddEmail(); + } + }; + + const handleSave = () => { + // An empty recipient list disables the digest; a non-empty list enables it. + const willBeEnabled = emails.length > 0; + + updateAdoptionDigestMutation.mutate( + { + organizationId, + adoption_digest_email: emails, + }, + { + onSuccess: () => { + toast.success( + willBeEnabled + ? `Weekly adoption digest enabled for ${emails.length} recipient${ + emails.length === 1 ? '' : 's' + }` + : 'Weekly adoption digest disabled' + ); + onOpenChange(false); + }, + onError: (error: unknown) => { + toast.error( + error instanceof Error ? error.message : 'Failed to update adoption digest settings' + ); + }, + } + ); + }; + + const handleClose = () => { + onOpenChange(false); + }; + + return ( + + + + Weekly adoption digest + + Email a weekly summary of adopted features and open recommendations to the addresses + below. Remove every address to turn the digest off. + + + +
+
+ +
+ { + setNewEmail(e.target.value); + setEmailError(null); + }} + onKeyDown={handleKeyDown} + className={emailError ? 'border-red-500 focus:border-red-500' : ''} + aria-describedby="adoptionDigestEmailHelp" + /> + +
+ {emailError &&

{emailError}

} + + {emails.length > 0 ? ( +
+ {emails.map(email => ( +
+ {email} + +
+ ))} +
+ ) : ( +

+ Add at least one address to receive the weekly digest. With no addresses, the digest + stays off. +

+ )} +
+
+ + + + + +
+
+ ); +} 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..b14591603f --- /dev/null +++ b/apps/web/src/components/organizations/OrganizationEmailPreferencesCard.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { useState } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Bell, ChartLine, Mail } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import { useOrganizationWithMembers } from '@/app/api/organizations/hooks'; +import { SpendingAlertsModal } from './SpendingAlertsModal'; +import { AdoptionDigestModal } from './AdoptionDigestModal'; + +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, + onConfigure, +}: { + icon: LucideIcon; + title: string; + description: string; + stateLabel: string; + isOn: boolean; + onConfigure: () => void; +}) { + return ( +
+
+ +
+

{title}

+

{description}

+

+ {stateLabel} +

+
+
+ +
+ ); +} + +export function OrganizationEmailPreferencesCard({ organizationId }: Props) { + const { data } = useOrganizationWithMembers(organizationId); + const [isSpendingAlertsOpen, setIsSpendingAlertsOpen] = useState(false); + const [isDigestOpen, setIsDigestOpen] = useState(false); + + 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 digestRecipientCount = settings?.adoption_digest_email?.length ?? 0; + + return ( + + + + + Email preferences + + Choose which emails this organization receives. + + +
+ 0} + onConfigure={() => setIsSpendingAlertsOpen(true)} + /> + {isEnterprise && ( + 0} + onConfigure={() => setIsDigestOpen(true)} + /> + )} +
+
+ + + +
+ ); +} 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..234acc6729 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,112 @@ describe('organizations settings trpc router', () => { expect(result.settings.minimum_balance_alert_email).toBeUndefined(); }); }); + + describe('updateAdoptionDigest procedure', () => { + afterEach(async () => { + // Reset the digest recipients between cases so each starts from a clean slate. + await updateOrganizationSettings(testOrganization.id, {}); + }); + + it('should enable the adoption digest with a recipient list (enterprise org)', async () => { + const caller = await createCallerForUser(owner.id); + + const result = await caller.organizations.settings.updateAdoptionDigest({ + organizationId: testOrganization.id, + adoption_digest_email: ['digest@example.com'], + }); + + expect(result.settings.adoption_digest_email).toEqual(['digest@example.com']); + + const updatedOrg = await getOrganizationById(testOrganization.id); + expect(updatedOrg?.settings?.adoption_digest_email).toEqual(['digest@example.com']); + }); + + it('should deduplicate recipients', async () => { + const caller = await createCallerForUser(owner.id); + + const result = await caller.organizations.settings.updateAdoptionDigest({ + organizationId: testOrganization.id, + adoption_digest_email: ['a@example.com', 'a@example.com', 'b@example.com'], + }); + + expect(result.settings.adoption_digest_email).toEqual(['a@example.com', 'b@example.com']); + }); + + it('should disable the digest and remove the field when given an empty list', async () => { + const caller = await createCallerForUser(owner.id); + + await caller.organizations.settings.updateAdoptionDigest({ + organizationId: testOrganization.id, + adoption_digest_email: ['digest@example.com'], + }); + + const result = await caller.organizations.settings.updateAdoptionDigest({ + organizationId: testOrganization.id, + adoption_digest_email: [], + }); + + expect(result.settings.adoption_digest_email).toBeUndefined(); + + const updatedOrg = await getOrganizationById(testOrganization.id); + expect(updatedOrg?.settings?.adoption_digest_email).toBeUndefined(); + }); + + it('should reject invalid email addresses', async () => { + const caller = await createCallerForUser(owner.id); + + await expect( + caller.organizations.settings.updateAdoptionDigest({ + organizationId: testOrganization.id, + adoption_digest_email: ['not-an-email'], + }) + ).rejects.toThrow(); + }); + + it('should throw UNAUTHORIZED error for non-owner users', async () => { + const caller = await createCallerForUser(member.id); + + await expect( + caller.organizations.settings.updateAdoptionDigest({ + organizationId: testOrganization.id, + adoption_digest_email: ['digest@example.com'], + }) + ).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.updateAdoptionDigest({ + organizationId: teamsOrg.id, + adoption_digest_email: ['digest@example.com'], + }) + ).rejects.toThrow('The adoption digest is not available for this organization.'); + + await db.delete(organizations).where(eq(organizations.id, teamsOrg.id)); + }); + + it('should preserve other settings when enabling 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.updateAdoptionDigest({ + organizationId: testOrganization.id, + adoption_digest_email: ['digest@example.com'], + }); + + 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.adoption_digest_email).toEqual(['digest@example.com']); + }); + }); }); diff --git a/apps/web/src/routers/organizations/organization-settings-router.ts b/apps/web/src/routers/organizations/organization-settings-router.ts index 69a77b60ad..e03c820e48 100644 --- a/apps/web/src/routers/organizations/organization-settings-router.ts +++ b/apps/web/src/routers/organizations/organization-settings-router.ts @@ -8,6 +8,7 @@ import { OrganizationIdInputSchema, organizationBillingMutationProcedure, organizationMemberProcedure, + organizationOwnerMutationProcedure, } from '@/routers/organizations/utils'; import { TRPCError } from '@trpc/server'; import * as z from 'zod'; @@ -141,6 +142,12 @@ const UpdateMinimumBalanceAlertInputSchema = OrganizationIdInputSchema.extend({ } ); +const UpdateAdoptionDigestInputSchema = OrganizationIdInputSchema.extend({ + // Empty array disables the digest (the recipient list is removed); a non-empty + // list of valid emails enables it. No separate boolean — presence is the toggle. + adoption_digest_email: z.array(z.email()), +}); + const SettingsResponseSchema = z.object({ settings: z.custom(), }); @@ -447,6 +454,67 @@ export const organizationsSettingsRouter = createTRPCRouter({ }); } + return { + settings: updatedSettings, + }; + }), + + // Owners-only: configure recipients for the weekly enterprise adoption digest. + // Mirrors updateMinimumBalanceAlert, but is Enterprise-gated and owner-only + // (matching the adoption recommendations dismiss/restore permission model). + updateAdoptionDigest: organizationOwnerMutationProcedure + .input(UpdateAdoptionDigestInputSchema) + .output(SettingsResponseSchema) + .mutation(async ({ input, ctx }) => { + const { organizationId, adoption_digest_email } = 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 adoption digest is not available for this organization.', + }); + } + + const recipients = dedupeStrings(adoption_digest_email); + const enabled = recipients.length > 0; + const wasEnabled = (existingOrg.settings?.adoption_digest_email?.length ?? 0) > 0; + + const currentSettings = existingOrg.settings || {}; + let updatedSettings: OrganizationSettings; + + if (enabled) { + updatedSettings = await updateOrganizationSettings(organizationId, { + ...currentSettings, + adoption_digest_email: recipients, + }); + } else { + // Remove the field when there are no recipients (digest disabled). + const { adoption_digest_email: _omit, ...rest } = currentSettings; + updatedSettings = await updateOrganizationSettings(organizationId, rest); + } + + if (enabled !== wasEnabled || enabled) { + 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 + ? `Adoption digest: enabled (recipients: ${recipients.join(', ')})` + : 'Adoption digest: disabled', + organization_id: organizationId, + }); + } + return { settings: updatedSettings, }; diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts index 28e8bde258..c7e8bfb3f1 100644 --- a/packages/db/src/schema-types.ts +++ b/packages/db/src/schema-types.ts @@ -746,6 +746,9 @@ const OrganizationSettingsSchema = z.object({ projects_ui_enabled: z.boolean().optional(), minimum_balance: z.number().optional(), minimum_balance_alert_email: z.array(z.email()).optional(), + // Recipients for the weekly enterprise adoption digest email. Presence of at + // least one address = enabled; empty/absent = off. Enterprise-only feature. + adoption_digest_email: z.array(z.email()).optional(), suppress_trial_messaging: z.boolean().optional(), // OSS Sponsorship fields // null/undefined = not an OSS org, values: 1, 2, or 3 From 2331274acfba61293519bd4774cc8672db5cc33e Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Tue, 23 Jun 2026 13:36:16 -0700 Subject: [PATCH 2/5] feat(org): send weekly recommendations digest to enterprise owners --- .../route.ts | 35 ++++ apps/web/src/app/api/organizations/hooks.ts | 4 +- .../organizations/AdoptionDigestModal.tsx | 198 ------------------ .../OrganizationEmailPreferencesCard.tsx | 86 +++++--- .../web/src/emails/recommendationsDigest.html | 149 +++++++++++++ apps/web/src/lib/email.ts | 67 ++++++ .../recommendations-digest.test.ts | 105 ++++++++++ .../organizations/recommendations-digest.ts | 149 +++++++++++++ .../organization-settings-router.test.ts | 68 ++---- .../organization-settings-router.ts | 38 ++-- apps/web/vercel.json | 4 + packages/db/src/schema-types.ts | 8 +- 12 files changed, 615 insertions(+), 296 deletions(-) create mode 100644 apps/web/src/app/api/cron/enterprise-recommendations-digest/route.ts delete mode 100644 apps/web/src/components/organizations/AdoptionDigestModal.tsx create mode 100644 apps/web/src/emails/recommendationsDigest.html create mode 100644 apps/web/src/lib/organizations/recommendations-digest.test.ts create mode 100644 apps/web/src/lib/organizations/recommendations-digest.ts 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 2cca74159d..78ed3da1ce 100644 --- a/apps/web/src/app/api/organizations/hooks.ts +++ b/apps/web/src/app/api/organizations/hooks.ts @@ -285,12 +285,12 @@ export function useUpdateMinimumBalanceAlert() { ); } -export function useUpdateAdoptionDigest() { +export function useUpdateRecommendationsDigest() { const trpc = useTRPC(); const queryClient = useQueryClient(); return useMutation( - trpc.organizations.settings.updateAdoptionDigest.mutationOptions({ + trpc.organizations.settings.updateRecommendationsDigest.mutationOptions({ onSuccess: () => { // Invalidate organization data to refresh settings (shared with the // spending-alerts surface, so both stay in sync). diff --git a/apps/web/src/components/organizations/AdoptionDigestModal.tsx b/apps/web/src/components/organizations/AdoptionDigestModal.tsx deleted file mode 100644 index 71ce37caa2..0000000000 --- a/apps/web/src/components/organizations/AdoptionDigestModal.tsx +++ /dev/null @@ -1,198 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import type { OrganizationSettings } from '@/lib/organizations/organization-types'; -import { Loader2, Plus, X } from 'lucide-react'; -import { toast } from 'sonner'; -import { useUpdateAdoptionDigest } from '@/app/api/organizations/hooks'; - -type AdoptionDigestModalProps = { - open: boolean; - onOpenChange: (open: boolean) => void; - organizationId: string; - settings: OrganizationSettings | undefined; -}; - -const isValidEmail = (email: string): boolean => { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(email.trim()); -}; - -export function AdoptionDigestModal({ - open, - onOpenChange, - organizationId, - settings, -}: AdoptionDigestModalProps) { - const [emails, setEmails] = useState(settings?.adoption_digest_email ?? []); - const [newEmail, setNewEmail] = useState(''); - const [emailError, setEmailError] = useState(null); - - const updateAdoptionDigestMutation = useUpdateAdoptionDigest(); - - // Sync form state with settings when dialog opens. - useEffect(() => { - if (open) { - setEmails(settings?.adoption_digest_email ?? []); - setNewEmail(''); - setEmailError(null); - } - }, [open, settings]); - - const handleAddEmail = () => { - const trimmedEmail = newEmail.trim(); - if (!trimmedEmail) { - return; - } - - if (!isValidEmail(trimmedEmail)) { - setEmailError('Please enter a valid email address'); - return; - } - - if (emails.includes(trimmedEmail)) { - setEmailError('This email is already in the list'); - return; - } - - setEmails([...emails, trimmedEmail]); - setNewEmail(''); - setEmailError(null); - }; - - const handleRemoveEmail = (emailToRemove: string) => { - setEmails(emails.filter(email => email !== emailToRemove)); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleAddEmail(); - } - }; - - const handleSave = () => { - // An empty recipient list disables the digest; a non-empty list enables it. - const willBeEnabled = emails.length > 0; - - updateAdoptionDigestMutation.mutate( - { - organizationId, - adoption_digest_email: emails, - }, - { - onSuccess: () => { - toast.success( - willBeEnabled - ? `Weekly adoption digest enabled for ${emails.length} recipient${ - emails.length === 1 ? '' : 's' - }` - : 'Weekly adoption digest disabled' - ); - onOpenChange(false); - }, - onError: (error: unknown) => { - toast.error( - error instanceof Error ? error.message : 'Failed to update adoption digest settings' - ); - }, - } - ); - }; - - const handleClose = () => { - onOpenChange(false); - }; - - return ( - - - - Weekly adoption digest - - Email a weekly summary of adopted features and open recommendations to the addresses - below. Remove every address to turn the digest off. - - - -
-
- -
- { - setNewEmail(e.target.value); - setEmailError(null); - }} - onKeyDown={handleKeyDown} - className={emailError ? 'border-red-500 focus:border-red-500' : ''} - aria-describedby="adoptionDigestEmailHelp" - /> - -
- {emailError &&

{emailError}

} - - {emails.length > 0 ? ( -
- {emails.map(email => ( -
- {email} - -
- ))} -
- ) : ( -

- Add at least one address to receive the weekly digest. With no addresses, the digest - stays off. -

- )} -
-
- - - - - -
-
- ); -} diff --git a/apps/web/src/components/organizations/OrganizationEmailPreferencesCard.tsx b/apps/web/src/components/organizations/OrganizationEmailPreferencesCard.tsx index b14591603f..84b710480c 100644 --- a/apps/web/src/components/organizations/OrganizationEmailPreferencesCard.tsx +++ b/apps/web/src/components/organizations/OrganizationEmailPreferencesCard.tsx @@ -3,11 +3,15 @@ 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 { useOrganizationWithMembers } from '@/app/api/organizations/hooks'; +import { toast } from 'sonner'; +import { + useOrganizationWithMembers, + useUpdateRecommendationsDigest, +} from '@/app/api/organizations/hooks'; import { SpendingAlertsModal } from './SpendingAlertsModal'; -import { AdoptionDigestModal } from './AdoptionDigestModal'; type Props = { organizationId: string; @@ -26,14 +30,14 @@ function PreferenceRow({ description, stateLabel, isOn, - onConfigure, + control, }: { icon: LucideIcon; title: string; description: string; - stateLabel: string; - isOn: boolean; - onConfigure: () => void; + stateLabel?: string; + isOn?: boolean; + control: React.ReactNode; }) { return (
@@ -42,14 +46,14 @@ function PreferenceRow({

{title}

{description}

-

- {stateLabel} -

+ {stateLabel && ( +

+ {stateLabel} +

+ )}
- +
{control}
); } @@ -57,7 +61,7 @@ function PreferenceRow({ export function OrganizationEmailPreferencesCard({ organizationId }: Props) { const { data } = useOrganizationWithMembers(organizationId); const [isSpendingAlertsOpen, setIsSpendingAlertsOpen] = useState(false); - const [isDigestOpen, setIsDigestOpen] = useState(false); + const updateRecommendationsDigest = useUpdateRecommendationsDigest(); if (!data) { return null; @@ -72,7 +76,29 @@ export function OrganizationEmailPreferencesCard({ organizationId }: Props) { settings?.minimum_balance !== undefined ? (settings?.minimum_balance_alert_email?.length ?? 0) : 0; - const digestRecipientCount = settings?.adoption_digest_email?.length ?? 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 ( @@ -91,16 +117,30 @@ export function OrganizationEmailPreferencesCard({ organizationId }: Props) { description="Notify recipients when the organization balance falls below a threshold." stateLabel={recipientStateLabel(spendingRecipientCount)} isOn={spendingRecipientCount > 0} - onConfigure={() => setIsSpendingAlertsOpen(true)} + control={ + + } /> {isEnterprise && ( 0} - onConfigure={() => setIsDigestOpen(true)} + title="Weekly recommendations email" + description="Email the organization's owners a weekly summary of open recommendations and feature setup." + control={ + + } /> )} @@ -112,12 +152,6 @@ export function OrganizationEmailPreferencesCard({ organizationId }: Props) { organizationId={organizationId} settings={settings} /> - ); } 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/recommendations-digest.test.ts b/apps/web/src/lib/organizations/recommendations-digest.test.ts new file mode 100644 index 0000000000..275ea722e7 --- /dev/null +++ b/apps/web/src/lib/organizations/recommendations-digest.test.ts @@ -0,0 +1,105 @@ +import { buildOrganizationRecommendationsDigest } from './recommendations-digest'; + +jest.mock('./recommendations', () => ({ + getOrganizationRecommendations: jest.fn(), +})); + +import { getOrganizationRecommendations } from './recommendations'; + +const mockedGetRecommendations = getOrganizationRecommendations as jest.MockedFunction< + typeof getOrganizationRecommendations +>; + +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']); + }); +}); 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..a912ea5a45 --- /dev/null +++ b/apps/web/src/lib/organizations/recommendations-digest.ts @@ -0,0 +1,149 @@ +import pLimit from 'p-limit'; +import { and, eq, isNull } from 'drizzle-orm'; +import { readDb } from '@/lib/drizzle'; +import { organizations, organization_memberships, kilocode_users } 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; + +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, + })), + }; +} + +// Owner email addresses for an org (excludes bot users). The digest goes to owners +// only, mirroring the owner-only toggle and the recommendations dismiss/restore gate. +export async function getOrganizationOwnerEmails(organizationId: string): Promise { + const rows = await readDb + .select({ 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.map(row => row.email).filter((email): email is string => Boolean(email)); +} + +export type RecommendationsDigestDispatchSummary = { + enabledOrgs: number; + orgsSkippedEmpty: number; + orgsSkippedNoOwners: number; + emailsSent: number; + emailFailures: number; + orgFailures: number; +}; + +// 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 { + const orgs = await readDb + .select({ + id: organizations.id, + name: organizations.name, + settings: organizations.settings, + }) + .from(organizations) + .where(and(eq(organizations.plan, 'enterprise'), isNull(organizations.deleted_at))); + + const enabledOrgs = orgs.filter(org => org.settings?.recommendations_digest_enabled === true); + + const summary: RecommendationsDigestDispatchSummary = { + enabledOrgs: enabledOrgs.length, + orgsSkippedEmpty: 0, + orgsSkippedNoOwners: 0, + emailsSent: 0, + emailFailures: 0, + orgFailures: 0, + }; + + 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 owners = await getOrganizationOwnerEmails(org.id); + if (owners.length === 0) { + summary.orgsSkippedNoOwners++; + return; + } + + for (const recipient of owners) { + const result = await sendRecommendationsDigestEmail(recipient, digest); + if (result.sent) { + summary.emailsSent++; + } else { + summary.emailFailures++; + logExceptInTest( + `[recommendationsDigest] send skipped for org ${org.id}: ${result.reason}` + ); + } + } + } catch (error) { + summary.orgFailures++; + errorExceptInTest('[recommendationsDigest] org dispatch failed', { + organizationId: org.id, + error, + }); + } + }) + ) + ); + + return summary; +} 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 234acc6729..fd451cd1ac 100644 --- a/apps/web/src/routers/organizations/organization-settings-router.test.ts +++ b/apps/web/src/routers/organizations/organization-settings-router.test.ts @@ -664,74 +664,52 @@ describe('organizations settings trpc router', () => { }); }); - describe('updateAdoptionDigest procedure', () => { + describe('updateRecommendationsDigest procedure', () => { afterEach(async () => { - // Reset the digest recipients between cases so each starts from a clean slate. + // Reset settings between cases so each starts from a clean slate. await updateOrganizationSettings(testOrganization.id, {}); }); - it('should enable the adoption digest with a recipient list (enterprise org)', async () => { + it('should enable the recommendations digest (enterprise org)', async () => { const caller = await createCallerForUser(owner.id); - const result = await caller.organizations.settings.updateAdoptionDigest({ + const result = await caller.organizations.settings.updateRecommendationsDigest({ organizationId: testOrganization.id, - adoption_digest_email: ['digest@example.com'], + enabled: true, }); - expect(result.settings.adoption_digest_email).toEqual(['digest@example.com']); + expect(result.settings.recommendations_digest_enabled).toBe(true); const updatedOrg = await getOrganizationById(testOrganization.id); - expect(updatedOrg?.settings?.adoption_digest_email).toEqual(['digest@example.com']); + expect(updatedOrg?.settings?.recommendations_digest_enabled).toBe(true); }); - it('should deduplicate recipients', async () => { + it('should disable the digest and remove the flag', async () => { const caller = await createCallerForUser(owner.id); - const result = await caller.organizations.settings.updateAdoptionDigest({ + await caller.organizations.settings.updateRecommendationsDigest({ organizationId: testOrganization.id, - adoption_digest_email: ['a@example.com', 'a@example.com', 'b@example.com'], - }); - - expect(result.settings.adoption_digest_email).toEqual(['a@example.com', 'b@example.com']); - }); - - it('should disable the digest and remove the field when given an empty list', async () => { - const caller = await createCallerForUser(owner.id); - - await caller.organizations.settings.updateAdoptionDigest({ - organizationId: testOrganization.id, - adoption_digest_email: ['digest@example.com'], + enabled: true, }); - const result = await caller.organizations.settings.updateAdoptionDigest({ + const result = await caller.organizations.settings.updateRecommendationsDigest({ organizationId: testOrganization.id, - adoption_digest_email: [], + enabled: false, }); - expect(result.settings.adoption_digest_email).toBeUndefined(); + expect(result.settings.recommendations_digest_enabled).toBeUndefined(); const updatedOrg = await getOrganizationById(testOrganization.id); - expect(updatedOrg?.settings?.adoption_digest_email).toBeUndefined(); - }); - - it('should reject invalid email addresses', async () => { - const caller = await createCallerForUser(owner.id); - - await expect( - caller.organizations.settings.updateAdoptionDigest({ - organizationId: testOrganization.id, - adoption_digest_email: ['not-an-email'], - }) - ).rejects.toThrow(); + 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.updateAdoptionDigest({ + caller.organizations.settings.updateRecommendationsDigest({ organizationId: testOrganization.id, - adoption_digest_email: ['digest@example.com'], + enabled: true, }) ).rejects.toThrow('You do not have the required organizational role to access this feature'); }); @@ -742,16 +720,16 @@ describe('organizations settings trpc router', () => { const caller = await createCallerForUser(owner.id); await expect( - caller.organizations.settings.updateAdoptionDigest({ + caller.organizations.settings.updateRecommendationsDigest({ organizationId: teamsOrg.id, - adoption_digest_email: ['digest@example.com'], + enabled: true, }) - ).rejects.toThrow('The adoption digest is not available for this organization.'); + ).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 enabling the digest', async () => { + it('should preserve other settings when toggling the digest', async () => { const caller = await createCallerForUser(owner.id); await updateOrganizationSettings(testOrganization.id, { @@ -760,15 +738,15 @@ describe('organizations settings trpc router', () => { minimum_balance_alert_email: ['alert@example.com'], }); - const result = await caller.organizations.settings.updateAdoptionDigest({ + const result = await caller.organizations.settings.updateRecommendationsDigest({ organizationId: testOrganization.id, - adoption_digest_email: ['digest@example.com'], + 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.adoption_digest_email).toEqual(['digest@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 e03c820e48..e893aa2360 100644 --- a/apps/web/src/routers/organizations/organization-settings-router.ts +++ b/apps/web/src/routers/organizations/organization-settings-router.ts @@ -142,10 +142,9 @@ const UpdateMinimumBalanceAlertInputSchema = OrganizationIdInputSchema.extend({ } ); -const UpdateAdoptionDigestInputSchema = OrganizationIdInputSchema.extend({ - // Empty array disables the digest (the recipient list is removed); a non-empty - // list of valid emails enables it. No separate boolean — presence is the toggle. - adoption_digest_email: z.array(z.email()), +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({ @@ -459,14 +458,14 @@ export const organizationsSettingsRouter = createTRPCRouter({ }; }), - // Owners-only: configure recipients for the weekly enterprise adoption digest. - // Mirrors updateMinimumBalanceAlert, but is Enterprise-gated and owner-only - // (matching the adoption recommendations dismiss/restore permission model). - updateAdoptionDigest: organizationOwnerMutationProcedure - .input(UpdateAdoptionDigestInputSchema) + // 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, adoption_digest_email } = input; + const { organizationId, enabled } = input; const existingOrg = await getOrganizationById(organizationId); if (!existingOrg) { @@ -480,37 +479,32 @@ export const organizationsSettingsRouter = createTRPCRouter({ if (existingOrg.plan !== 'enterprise') { throw new TRPCError({ code: 'FORBIDDEN', - message: 'The adoption digest is not available for this organization.', + message: 'The recommendations digest is not available for this organization.', }); } - const recipients = dedupeStrings(adoption_digest_email); - const enabled = recipients.length > 0; - const wasEnabled = (existingOrg.settings?.adoption_digest_email?.length ?? 0) > 0; - + const wasEnabled = existingOrg.settings?.recommendations_digest_enabled === true; const currentSettings = existingOrg.settings || {}; let updatedSettings: OrganizationSettings; if (enabled) { updatedSettings = await updateOrganizationSettings(organizationId, { ...currentSettings, - adoption_digest_email: recipients, + recommendations_digest_enabled: true, }); } else { - // Remove the field when there are no recipients (digest disabled). - const { adoption_digest_email: _omit, ...rest } = currentSettings; + // Remove the flag when disabled rather than storing `false`. + const { recommendations_digest_enabled: _omit, ...rest } = currentSettings; updatedSettings = await updateOrganizationSettings(organizationId, rest); } - if (enabled !== wasEnabled || 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 - ? `Adoption digest: enabled (recipients: ${recipients.join(', ')})` - : 'Adoption digest: disabled', + message: enabled ? 'Recommendations digest: enabled' : 'Recommendations digest: disabled', organization_id: organizationId, }); } 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 c7e8bfb3f1..8b51b14345 100644 --- a/packages/db/src/schema-types.ts +++ b/packages/db/src/schema-types.ts @@ -746,9 +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(), - // Recipients for the weekly enterprise adoption digest email. Presence of at - // least one address = enabled; empty/absent = off. Enterprise-only feature. - adoption_digest_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 From 7101d323b1af25c4e24e2f3f0f116042aca6b4aa Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Tue, 23 Jun 2026 13:55:55 -0700 Subject: [PATCH 3/5] fix local review bugs: atomic digest toggle + SQL opt-in filter + dedup claim --- .../src/lib/organizations/organizations.ts | 21 ++++ .../recommendations-digest.test.ts | 18 ++- .../organizations/recommendations-digest.ts | 107 +++++++++++++++--- .../organization-settings-router.ts | 24 ++-- 4 files changed, 143 insertions(+), 27 deletions(-) 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 index 275ea722e7..973178e807 100644 --- a/apps/web/src/lib/organizations/recommendations-digest.test.ts +++ b/apps/web/src/lib/organizations/recommendations-digest.test.ts @@ -1,4 +1,7 @@ -import { buildOrganizationRecommendationsDigest } from './recommendations-digest'; +import { + buildOrganizationRecommendationsDigest, + currentDigestPeriodKey, +} from './recommendations-digest'; jest.mock('./recommendations', () => ({ getOrganizationRecommendations: jest.fn(), @@ -103,3 +106,16 @@ describe('buildOrganizationRecommendationsDigest', () => { 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'); + }); +}); diff --git a/apps/web/src/lib/organizations/recommendations-digest.ts b/apps/web/src/lib/organizations/recommendations-digest.ts index a912ea5a45..6cafa4b5f3 100644 --- a/apps/web/src/lib/organizations/recommendations-digest.ts +++ b/apps/web/src/lib/organizations/recommendations-digest.ts @@ -1,7 +1,12 @@ import pLimit from 'p-limit'; -import { and, eq, isNull } from 'drizzle-orm'; -import { readDb } from '@/lib/drizzle'; -import { organizations, organization_memberships, kilocode_users } from '@kilocode/db/schema'; +import { and, eq, isNull, sql } from 'drizzle-orm'; +import { db, readDb } 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'; @@ -10,6 +15,26 @@ import { errorExceptInTest, logExceptInTest } from '@/lib/utils.server'; // 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, + recipient: string, + periodKey: string +): string { + return `${organizationId}:${recipient}:${periodKey}`; +} export type RecommendationsDigestData = { organizationId: string; @@ -79,33 +104,90 @@ export type RecommendationsDigestDispatchSummary = { 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: string, + organizationId: string, + periodKey: string, + digest: RecommendationsDigestData +): Promise { + const idempotency_key = digestIdempotencyKey(organizationId, recipient, 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'; + } + + const result = await sendRecommendationsDigestEmail(recipient, digest); + if (result.sent) { + return 'sent'; + } + + // 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) + ) + ); + logExceptInTest( + `[recommendationsDigest] send skipped for org ${organizationId}: ${result.reason}` + ); + 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 { - const orgs = await readDb + // 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. + const enabledOrgs = await readDb .select({ id: organizations.id, name: organizations.name, - settings: organizations.settings, }) .from(organizations) - .where(and(eq(organizations.plan, 'enterprise'), isNull(organizations.deleted_at))); - - const enabledOrgs = orgs.filter(org => org.settings?.recommendations_digest_enabled === true); + .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 => @@ -124,14 +206,13 @@ export async function dispatchEnterpriseRecommendationsDigests(): Promise Date: Tue, 23 Jun 2026 15:07:48 -0700 Subject: [PATCH 4/5] fixes --- apps/web/src/emails/AGENTS.md | 1 + .../recommendations-digest.test.ts | 102 +++++++++++++++++- .../organizations/recommendations-digest.ts | 57 ++++++---- .../src/routers/admin/email-testing-router.ts | 36 +++++++ 4 files changed, 174 insertions(+), 22 deletions(-) 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/lib/organizations/recommendations-digest.test.ts b/apps/web/src/lib/organizations/recommendations-digest.test.ts index 973178e807..e14a9c42ca 100644 --- a/apps/web/src/lib/organizations/recommendations-digest.test.ts +++ b/apps/web/src/lib/organizations/recommendations-digest.test.ts @@ -1,17 +1,33 @@ +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 = getOrganizationRecommendations as jest.MockedFunction< - typeof getOrganizationRecommendations ->; +const mockedGetRecommendations = jest.mocked(getOrganizationRecommendations); +const mockedSendRecommendationsDigestEmail = jest.mocked(sendRecommendationsDigestEmail); type ResolvedRecommendations = Awaited>; @@ -119,3 +135,83 @@ describe('currentDigestPeriodKey', () => { 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 index 6cafa4b5f3..347813298d 100644 --- a/apps/web/src/lib/organizations/recommendations-digest.ts +++ b/apps/web/src/lib/organizations/recommendations-digest.ts @@ -30,10 +30,10 @@ export function currentDigestPeriodKey(now: Date): string { function digestIdempotencyKey( organizationId: string, - recipient: string, + recipientUserId: string, periodKey: string ): string { - return `${organizationId}:${recipient}:${periodKey}`; + return `${organizationId}:${recipientUserId}:${periodKey}`; } export type RecommendationsDigestData = { @@ -82,11 +82,21 @@ export async function buildOrganizationRecommendationsDigest( }; } -// Owner email addresses for an org (excludes bot users). The digest goes to owners -// only, mirroring the owner-only toggle and the recommendations dismiss/restore gate. -export async function getOrganizationOwnerEmails(organizationId: string): Promise { - const rows = await readDb - .select({ email: kilocode_users.google_user_email }) +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( @@ -96,7 +106,7 @@ export async function getOrganizationOwnerEmails(organizationId: string): Promis eq(kilocode_users.is_bot, false) ) ); - return rows.map(row => row.email).filter((email): email is string => Boolean(email)); + return rows.flatMap(row => (row.email ? [{ userId: row.userId, email: row.email }] : [])); } export type RecommendationsDigestDispatchSummary = { @@ -117,12 +127,12 @@ type RecipientSendOutcome = 'sent' | 'duplicate' | 'failed'; // 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: string, + recipient: DigestRecipient, organizationId: string, periodKey: string, digest: RecommendationsDigestData ): Promise { - const idempotency_key = digestIdempotencyKey(organizationId, recipient, periodKey); + const idempotency_key = digestIdempotencyKey(organizationId, recipient.userId, periodKey); const claim = await db .insert(transactional_email_log) @@ -137,9 +147,21 @@ async function sendDigestToRecipientOnce( return 'duplicate'; } - const result = await sendRecommendationsDigestEmail(recipient, digest); - if (result.sent) { - return 'sent'; + 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. @@ -151,9 +173,6 @@ async function sendDigestToRecipientOnce( eq(transactional_email_log.idempotency_key, idempotency_key) ) ); - logExceptInTest( - `[recommendationsDigest] send skipped for org ${organizationId}: ${result.reason}` - ); return 'failed'; } @@ -199,13 +218,13 @@ export async function dispatchEnterpriseRecommendationsDigests(): Promise + `` + + `

${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}`); } From 4e936442c6c948611c6dfae9641904e36a5d9c99 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Tue, 23 Jun 2026 15:23:29 -0700 Subject: [PATCH 5/5] fix Opt-out checks still run against the replica --- apps/web/src/lib/organizations/recommendations-digest.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/lib/organizations/recommendations-digest.ts b/apps/web/src/lib/organizations/recommendations-digest.ts index 347813298d..870e1462b9 100644 --- a/apps/web/src/lib/organizations/recommendations-digest.ts +++ b/apps/web/src/lib/organizations/recommendations-digest.ts @@ -1,6 +1,6 @@ import pLimit from 'p-limit'; import { and, eq, isNull, sql } from 'drizzle-orm'; -import { db, readDb } from '@/lib/drizzle'; +import { db } from '@/lib/drizzle'; import { organizations, organization_memberships, @@ -181,8 +181,10 @@ async function sendDigestToRecipientOnce( 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. - const enabledOrgs = await readDb + // 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,