diff --git a/apps/web/src/components/PlaygroundCommon/StatusIndicator/index.tsx b/apps/web/src/components/PlaygroundCommon/StatusIndicator/index.tsx
index 1f78c62844..6f2150da57 100644
--- a/apps/web/src/components/PlaygroundCommon/StatusIndicator/index.tsx
+++ b/apps/web/src/components/PlaygroundCommon/StatusIndicator/index.tsx
@@ -1,6 +1,6 @@
import { formatCostInMillicents, formatDuration } from '$/app/_lib/formatUtils'
import { usePlaygroundChat } from '$/hooks/playgroundChat/usePlaygroundChat'
-import { formatCount } from '$/lib/formatCount'
+import { formatCount } from '@latitude-data/constants/formatCount'
import { LegacyVercelSDKVersion4Usage as LanguageModelUsage } from '@latitude-data/constants'
import { Button } from '@latitude-data/web-ui/atoms/Button'
import { Icon } from '@latitude-data/web-ui/atoms/Icons'
diff --git a/apps/web/src/components/RunPanelStats/index.tsx b/apps/web/src/components/RunPanelStats/index.tsx
index 9b086269d8..0a9a4fec1c 100644
--- a/apps/web/src/components/RunPanelStats/index.tsx
+++ b/apps/web/src/components/RunPanelStats/index.tsx
@@ -1,5 +1,5 @@
import { formatCostInMillicents, formatDuration } from '$/app/_lib/formatUtils'
-import { formatCount } from '$/lib/formatCount'
+import { formatCount } from '@latitude-data/constants/formatCount'
import { Badge } from '@latitude-data/web-ui/atoms/Badge'
import { Button } from '@latitude-data/web-ui/atoms/Button'
import { Text } from '@latitude-data/web-ui/atoms/Text'
diff --git a/apps/web/src/components/evaluations/human/Rating.tsx b/apps/web/src/components/evaluations/human/Rating.tsx
index e944ba6da8..44b63d8552 100644
--- a/apps/web/src/components/evaluations/human/Rating.tsx
+++ b/apps/web/src/components/evaluations/human/Rating.tsx
@@ -1,5 +1,5 @@
import { use, useCallback, useMemo } from 'react'
-import { formatCount } from '$/lib/formatCount'
+import { formatCount } from '@latitude-data/constants/formatCount'
import {
EvaluationType,
EvaluationV2,
diff --git a/apps/web/src/components/evaluations/llm/Custom.tsx b/apps/web/src/components/evaluations/llm/Custom.tsx
index fd9f8fc810..da56cdaeec 100644
--- a/apps/web/src/components/evaluations/llm/Custom.tsx
+++ b/apps/web/src/components/evaluations/llm/Custom.tsx
@@ -1,4 +1,4 @@
-import { formatCount } from '$/lib/formatCount'
+import { formatCount } from '@latitude-data/constants/formatCount'
import {
EvaluationType,
LLM_EVALUATION_CUSTOM_PROMPT_DOCUMENTATION,
diff --git a/apps/web/src/components/evaluations/llm/Rating.tsx b/apps/web/src/components/evaluations/llm/Rating.tsx
index 6096ed0ee1..44cceb2a0b 100644
--- a/apps/web/src/components/evaluations/llm/Rating.tsx
+++ b/apps/web/src/components/evaluations/llm/Rating.tsx
@@ -1,4 +1,4 @@
-import { formatCount } from '$/lib/formatCount'
+import { formatCount } from '@latitude-data/constants/formatCount'
import {
EvaluationType,
LlmEvaluationMetric,
diff --git a/apps/web/src/components/evaluations/llm/index.tsx b/apps/web/src/components/evaluations/llm/index.tsx
index 17f162c0ab..1a099a707b 100644
--- a/apps/web/src/components/evaluations/llm/index.tsx
+++ b/apps/web/src/components/evaluations/llm/index.tsx
@@ -3,7 +3,7 @@ import { MessageList, MessageListSkeleton } from '$/components/ChatWrapper'
import DebugToggle from '$/components/DebugToggle'
import { MetadataItem } from '$/components/MetadataItem'
import useModelOptions from '$/hooks/useModelOptions'
-import { formatCount } from '$/lib/formatCount'
+import { formatCount } from '@latitude-data/constants/formatCount'
import useCurrentWorkspace from '$/stores/currentWorkspace'
import useProviders from '$/stores/providerApiKeys'
import { useProviderLog } from '$/stores/providerLogs'
diff --git a/apps/web/src/components/evaluations/rule/LengthCount.tsx b/apps/web/src/components/evaluations/rule/LengthCount.tsx
index 2e6c12fcf6..0b9f1a7b9a 100644
--- a/apps/web/src/components/evaluations/rule/LengthCount.tsx
+++ b/apps/web/src/components/evaluations/rule/LengthCount.tsx
@@ -1,4 +1,4 @@
-import { formatCount } from '$/lib/formatCount'
+import { formatCount } from '@latitude-data/constants/formatCount'
import {
EvaluationType,
RuleEvaluationLengthCountSpecification,
diff --git a/apps/web/src/components/layouts/AppLayout/Header/Rewards/Content/RewardItem.tsx b/apps/web/src/components/layouts/AppLayout/Header/Rewards/Content/RewardItem.tsx
index d1fca9b390..c77adf39ca 100644
--- a/apps/web/src/components/layouts/AppLayout/Header/Rewards/Content/RewardItem.tsx
+++ b/apps/web/src/components/layouts/AppLayout/Header/Rewards/Content/RewardItem.tsx
@@ -8,7 +8,7 @@ import { Text } from '@latitude-data/web-ui/atoms/Text'
import { cn } from '@latitude-data/web-ui/utils'
import { useMemo } from 'react'
import { REWARD_VALUES, RewardType } from '@latitude-data/core/constants'
-import { formatCount } from '$/lib/formatCount'
+import { formatCount } from '@latitude-data/constants/formatCount'
export function RewardItem({
description,
diff --git a/apps/web/src/services/routes.ts b/apps/web/src/services/routes.ts
index d36fe68a83..58728509fd 100644
--- a/apps/web/src/services/routes.ts
+++ b/apps/web/src/services/routes.ts
@@ -26,6 +26,7 @@ export enum BackofficeRoutes {
grants = 'grants',
promocodes = 'promocodes',
integrations = 'integrations',
+ weekly = 'weekly',
}
const BACKOFFICE_ROOT = '/backoffice'
@@ -80,6 +81,11 @@ export const ROUTES = {
[BackofficeRoutes.integrations]: {
root: `${BACKOFFICE_ROOT}/integrations`,
},
+ [BackofficeRoutes.weekly]: {
+ root: `${BACKOFFICE_ROOT}/weekly`,
+ withWorkspaceId: (workspaceId: number) =>
+ `${BACKOFFICE_ROOT}/weekly?workspaceId=${workspaceId}`,
+ },
},
noWorkspace: {
root: '/no-workspace',
diff --git a/apps/workers/src/workers/schedule.ts b/apps/workers/src/workers/schedule.ts
index 2f0303d1ab..6f02264c21 100644
--- a/apps/workers/src/workers/schedule.ts
+++ b/apps/workers/src/workers/schedule.ts
@@ -58,4 +58,11 @@ export async function setupSchedules() {
{ pattern: '0 0 1 * * *' },
{ opts: { attempts: 1 } },
)
+
+ // Every Monday at 1:00:00 AM - Schedule weekly email reports
+ await maintenanceQueue.upsertJobScheduler(
+ 'scheduleWeeklyEmailJobs',
+ { pattern: '0 0 1 * * 1' },
+ { opts: { attempts: 1 } },
+ )
}
diff --git a/apps/workers/src/workers/worker-definitions/maintenanceWorker.ts b/apps/workers/src/workers/worker-definitions/maintenanceWorker.ts
index 1875d22282..db9a9bc9eb 100644
--- a/apps/workers/src/workers/worker-definitions/maintenanceWorker.ts
+++ b/apps/workers/src/workers/worker-definitions/maintenanceWorker.ts
@@ -15,6 +15,8 @@ const jobMappings = {
requestDocumentSuggestionsJob: jobs.requestDocumentSuggestionsJob,
scaleDownMcpServerJob: jobs.scaleDownMcpServerJob,
updateMcpServerLastUsedJob: jobs.updateMcpServerLastUsedJob,
+ scheduleWeeklyEmailJobs: jobs.scheduleWeeklyEmailJobs,
+ sendWeeklyEmailJob: jobs.sendWeeklyEmailJob,
}
export function startMaintenanceWorker() {
diff --git a/packages/constants/package.json b/packages/constants/package.json
index 0771e11aca..8cd8f24378 100644
--- a/packages/constants/package.json
+++ b/packages/constants/package.json
@@ -23,6 +23,7 @@
"./runs": "./src/runs.ts",
"./latitudePromptSchema": "./src/latitudePromptSchema/index.ts",
"./latte": "./src/latte/index.ts",
+ "./formatCount": "./src/formatCount.ts",
"./promptl": "./src/promptl.ts",
"./trigger": "./src/trigger.ts",
"./issues": "./src/issues/index.ts",
diff --git a/apps/web/src/lib/formatCount.ts b/packages/constants/src/formatCount.ts
similarity index 60%
rename from apps/web/src/lib/formatCount.ts
rename to packages/constants/src/formatCount.ts
index 9314a7e265..03a0b693ab 100644
--- a/apps/web/src/lib/formatCount.ts
+++ b/packages/constants/src/formatCount.ts
@@ -1,9 +1,13 @@
const units = ['', 'K', 'M', 'B', 'T']
const unitSize = 1000
-export function formatCount(count: number): string {
+export function formatCount(
+ count: number,
+ opts: { decimalPlaces?: number } = { decimalPlaces: 1 },
+): string {
if (count < 0) return '-' + formatCount(-count)
- if (count < unitSize) return count.toString()
+ if (count < unitSize)
+ return count.toFixed(opts.decimalPlaces).replace(/\.0+$/, '')
let unitIndex = 0
@@ -18,7 +22,11 @@ export function formatCount(count: number): string {
unitIndex++
}
- const decimalPlaces = count < 10 ? 1 : 0
+ const decimalPlaces = opts.decimalPlaces
+ ? opts.decimalPlaces
+ : count < 10
+ ? 1
+ : 0
return count.toFixed(decimalPlaces).replace(/\.0$/, '') + units[unitIndex]
}
diff --git a/packages/core/package.json b/packages/core/package.json
index a4c1d98ee1..5f523b6368 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -118,7 +118,6 @@
"lint": "eslint src",
"tc": "tsc --noEmit",
"test": "pnpm run db:migrate:test && vitest run --pool=forks",
- "email:dev": "email dev -d ./src/mailers/emails",
"test:watch": "vitest",
"scripts:setup": "tsx scripts/setup.ts",
"backgroundRun:stressTest": "tsx scripts/background-run/stress-test.ts",
@@ -239,4 +238,4 @@
"peerDependencies": {
"dd-trace": "catalog:"
}
-}
\ No newline at end of file
+}
diff --git a/packages/core/src/data-access/weeklyEmail/activeWorkspaces/index.test.ts b/packages/core/src/data-access/weeklyEmail/activeWorkspaces/index.test.ts
new file mode 100644
index 0000000000..289dd6304a
--- /dev/null
+++ b/packages/core/src/data-access/weeklyEmail/activeWorkspaces/index.test.ts
@@ -0,0 +1,334 @@
+import { beforeAll, describe, expect, it } from 'vitest'
+import { SpanType } from '../../../constants'
+import { createWorkspace } from '../../../tests/factories'
+import { createSpan } from '../../../tests/factories/spans'
+import { getActiveWorkspacesForWeeklyEmail } from './index'
+import { Workspace } from '../../../schema/models/types/Workspace'
+
+let workspace: Workspace
+
+describe('getActiveWorkspacesForWeeklyEmail', () => {
+ beforeAll(async () => {
+ const { workspace: ws } = await createWorkspace()
+ workspace = ws
+ })
+
+ describe('when workspace has no activity', () => {
+ it('returns empty array when no spans exist', async () => {
+ // Common workspace has no spans yet
+ const result = await getActiveWorkspacesForWeeklyEmail()
+ expect(result).toEqual([])
+ })
+ })
+
+ describe('when workspace has prompt spans in last 4 weeks', () => {
+ it('includes workspace with recent prompt spans', async () => {
+ const threeDaysAgo = new Date()
+ threeDaysAgo.setDate(threeDaysAgo.getDate() - 3)
+
+ await createSpan({
+ workspaceId: workspace.id,
+ type: SpanType.Prompt,
+ startedAt: threeDaysAgo,
+ })
+
+ const result = await getActiveWorkspacesForWeeklyEmail()
+
+ expect(result).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: workspace.id,
+ }),
+ ]),
+ )
+ })
+ })
+
+ describe('when workspace has only old or non-prompt spans', () => {
+ it('excludes workspace with only old prompt spans', async () => {
+ const { workspace: oldWorkspace } = await createWorkspace()
+ const fiveWeeksAgo = new Date()
+ fiveWeeksAgo.setDate(fiveWeeksAgo.getDate() - 35)
+
+ await createSpan({
+ workspaceId: oldWorkspace.id,
+ type: SpanType.Prompt,
+ startedAt: fiveWeeksAgo,
+ })
+
+ const result = await getActiveWorkspacesForWeeklyEmail()
+
+ expect(result).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: oldWorkspace.id,
+ }),
+ ]),
+ )
+ })
+
+ it('excludes workspace with only non-prompt spans', async () => {
+ const { workspace: nonPromptWorkspace } = await createWorkspace()
+ const threeDaysAgo = new Date()
+ threeDaysAgo.setDate(threeDaysAgo.getDate() - 3)
+
+ // Create completion span (not prompt)
+ await createSpan({
+ workspaceId: nonPromptWorkspace.id,
+ type: SpanType.Completion,
+ startedAt: threeDaysAgo,
+ })
+
+ // Create step span (not prompt)
+ await createSpan({
+ workspaceId: nonPromptWorkspace.id,
+ type: SpanType.Step,
+ startedAt: threeDaysAgo,
+ })
+
+ const result = await getActiveWorkspacesForWeeklyEmail()
+
+ expect(result).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: nonPromptWorkspace.id,
+ }),
+ ]),
+ )
+ })
+ })
+
+ describe('when multiple workspaces exist', () => {
+ it('includes multiple active workspaces', async () => {
+ const { workspace: workspace2 } = await createWorkspace()
+ const { workspace: workspace3 } = await createWorkspace()
+
+ const twoDaysAgo = new Date()
+ twoDaysAgo.setDate(twoDaysAgo.getDate() - 2)
+
+ // Create spans for common workspace and workspace2
+ await createSpan({
+ workspaceId: workspace.id,
+ type: SpanType.Prompt,
+ startedAt: twoDaysAgo,
+ })
+
+ await createSpan({
+ workspaceId: workspace2.id,
+ type: SpanType.Prompt,
+ startedAt: twoDaysAgo,
+ })
+
+ // workspace3 has old activity
+ const fiveWeeksAgo = new Date()
+ fiveWeeksAgo.setDate(fiveWeeksAgo.getDate() - 35)
+ await createSpan({
+ workspaceId: workspace3.id,
+ type: SpanType.Prompt,
+ startedAt: fiveWeeksAgo,
+ })
+
+ const result = await getActiveWorkspacesForWeeklyEmail()
+
+ // Should include common workspace and workspace2
+ expect(result).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: workspace.id }),
+ expect.objectContaining({ id: workspace2.id }),
+ ]),
+ )
+
+ // Should not include workspace3
+ expect(result).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: workspace3.id }),
+ ]),
+ )
+ })
+
+ it('handles workspace with multiple prompt spans (counts once)', async () => {
+ const threeDaysAgo = new Date()
+ threeDaysAgo.setDate(threeDaysAgo.getDate() - 3)
+
+ // Create multiple prompt spans for common workspace
+ await createSpan({
+ workspaceId: workspace.id,
+ type: SpanType.Prompt,
+ startedAt: threeDaysAgo,
+ })
+
+ await createSpan({
+ workspaceId: workspace.id,
+ type: SpanType.Prompt,
+ startedAt: threeDaysAgo,
+ })
+
+ await createSpan({
+ workspaceId: workspace.id,
+ type: SpanType.Prompt,
+ startedAt: threeDaysAgo,
+ })
+
+ const result = await getActiveWorkspacesForWeeklyEmail()
+
+ // Should include workspace only once
+ const workspaceCount = result.filter((w) => w.id === workspace.id).length
+ expect(workspaceCount).toEqual(1)
+ })
+ })
+
+ describe('big account filtering', () => {
+ it('excludes workspaces marked as big accounts', async () => {
+ const { workspace: regularWorkspace } = await createWorkspace({
+ isBigAccount: false,
+ })
+ const { workspace: bigWorkspace } = await createWorkspace({
+ isBigAccount: true,
+ })
+
+ const threeDaysAgo = new Date()
+ threeDaysAgo.setDate(threeDaysAgo.getDate() - 3)
+
+ // Both workspaces have recent activity
+ await createSpan({
+ workspaceId: regularWorkspace.id,
+ type: SpanType.Prompt,
+ startedAt: threeDaysAgo,
+ })
+
+ await createSpan({
+ workspaceId: bigWorkspace.id,
+ type: SpanType.Prompt,
+ startedAt: threeDaysAgo,
+ })
+
+ const result = await getActiveWorkspacesForWeeklyEmail()
+
+ // Should include regular workspace
+ expect(result).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: regularWorkspace.id }),
+ ]),
+ )
+
+ // Should NOT include big account workspace
+ expect(result).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: bigWorkspace.id }),
+ ]),
+ )
+ })
+
+ it('includes only non-big accounts when multiple workspaces are active', async () => {
+ const { workspace: bigWorkspace } = await createWorkspace({
+ isBigAccount: true,
+ })
+ const { workspace: regularWorkspace } = await createWorkspace({
+ isBigAccount: false,
+ })
+
+ const twoDaysAgo = new Date()
+ twoDaysAgo.setDate(twoDaysAgo.getDate() - 2)
+
+ // All workspaces have activity
+ await createSpan({
+ workspaceId: workspace.id,
+ type: SpanType.Prompt,
+ startedAt: twoDaysAgo,
+ })
+
+ await createSpan({
+ workspaceId: bigWorkspace.id,
+ type: SpanType.Prompt,
+ startedAt: twoDaysAgo,
+ })
+
+ await createSpan({
+ workspaceId: regularWorkspace.id,
+ type: SpanType.Prompt,
+ startedAt: twoDaysAgo,
+ })
+
+ const result = await getActiveWorkspacesForWeeklyEmail()
+
+ // Should include common workspace and regularWorkspace (not big accounts)
+ expect(result).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: workspace.id }),
+ expect.objectContaining({ id: regularWorkspace.id }),
+ ]),
+ )
+
+ // Should not include bigWorkspace (big account)
+ expect(result).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: bigWorkspace.id }),
+ ]),
+ )
+ })
+ })
+
+ describe('edge cases', () => {
+ it('includes workspace with span at 27 days ago (within 4 weeks)', async () => {
+ const { workspace: edgeCaseWorkspace } = await createWorkspace()
+ const twentySevenDaysAgo = new Date()
+ twentySevenDaysAgo.setDate(twentySevenDaysAgo.getDate() - 27)
+
+ await createSpan({
+ workspaceId: edgeCaseWorkspace.id,
+ type: SpanType.Prompt,
+ startedAt: twentySevenDaysAgo,
+ })
+
+ const result = await getActiveWorkspacesForWeeklyEmail()
+
+ expect(result).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: edgeCaseWorkspace.id,
+ }),
+ ]),
+ )
+ })
+
+ it('excludes workspace with span exactly 29 days ago', async () => {
+ const { workspace: tooOldWorkspace } = await createWorkspace()
+ const twentyNineDaysAgo = new Date()
+ twentyNineDaysAgo.setDate(twentyNineDaysAgo.getDate() - 29)
+
+ await createSpan({
+ workspaceId: tooOldWorkspace.id,
+ type: SpanType.Prompt,
+ startedAt: twentyNineDaysAgo,
+ })
+
+ const result = await getActiveWorkspacesForWeeklyEmail()
+
+ expect(result).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: tooOldWorkspace.id,
+ }),
+ ]),
+ )
+ })
+
+ it('returns full workspace objects with all fields', async () => {
+ const threeDaysAgo = new Date()
+ threeDaysAgo.setDate(threeDaysAgo.getDate() - 3)
+
+ await createSpan({
+ workspaceId: workspace.id,
+ type: SpanType.Prompt,
+ startedAt: threeDaysAgo,
+ })
+
+ const result = await getActiveWorkspacesForWeeklyEmail()
+
+ const foundWorkspace = result.find((w) => w.id === workspace.id)
+ expect(foundWorkspace).toBeDefined()
+ expect(foundWorkspace?.name).toBeDefined()
+ expect(foundWorkspace?.createdAt).toBeDefined()
+ })
+ })
+})
diff --git a/packages/core/src/data-access/weeklyEmail/activeWorkspaces/index.ts b/packages/core/src/data-access/weeklyEmail/activeWorkspaces/index.ts
new file mode 100644
index 0000000000..b2508664a4
--- /dev/null
+++ b/packages/core/src/data-access/weeklyEmail/activeWorkspaces/index.ts
@@ -0,0 +1,43 @@
+import { and, eq, gte, inArray } from 'drizzle-orm'
+import { database } from '../../../client'
+import { SpanType } from '../../../constants'
+import { spans } from '../../../schema/models/spans'
+import { workspaces } from '../../../schema/models/workspaces'
+
+const NUMBER_OF_WEEKS = 4
+const DAYS_IN_WEEK = 7
+const LAST_PAST_DAYS = NUMBER_OF_WEEKS * DAYS_IN_WEEK
+
+/**
+ * Gets workspaces that have had prompt spans created in the last 4 weeks.
+ * Used to filter which workspaces should receive weekly email reports.
+ * Excludes workspaces marked as big accounts (isBigAccount = true).
+ */
+export async function getActiveWorkspacesForWeeklyEmail(db = database) {
+ const fourWeeksAgo = new Date()
+ fourWeeksAgo.setDate(fourWeeksAgo.getDate() - LAST_PAST_DAYS)
+
+ const activeWorkspaceIds = await db
+ .selectDistinct({ workspaceId: spans.workspaceId })
+ .from(spans)
+ .where(
+ and(eq(spans.type, SpanType.Prompt), gte(spans.startedAt, fourWeeksAgo)),
+ )
+
+ if (activeWorkspaceIds.length === 0) return []
+
+ const activeWorkspaces = await db
+ .select()
+ .from(workspaces)
+ .where(
+ and(
+ inArray(
+ workspaces.id,
+ activeWorkspaceIds.map((w) => w.workspaceId),
+ ),
+ eq(workspaces.isBigAccount, false),
+ ),
+ )
+
+ return activeWorkspaces
+}
diff --git a/packages/core/src/data-access/weeklyEmail/annotations/index.test.ts b/packages/core/src/data-access/weeklyEmail/annotations/index.test.ts
index 5aad87044e..d06792b6bd 100644
--- a/packages/core/src/data-access/weeklyEmail/annotations/index.test.ts
+++ b/packages/core/src/data-access/weeklyEmail/annotations/index.test.ts
@@ -65,6 +65,7 @@ describe('getAnnotationsData', () => {
passedPercentage: 0,
failedPercentage: 0,
topProjects: [],
+ firstProjectId: project.id,
})
})
})
@@ -109,6 +110,7 @@ describe('getAnnotationsData', () => {
passedPercentage: 0,
failedPercentage: 0,
topProjects: [],
+ firstProjectId: null,
})
})
})
@@ -180,6 +182,7 @@ describe('getAnnotationsData', () => {
failedPercentage: 40,
},
],
+ firstProjectId: project.id,
})
})
@@ -232,6 +235,7 @@ describe('getAnnotationsData', () => {
failedPercentage: 0,
},
],
+ firstProjectId: project.id,
})
})
@@ -284,6 +288,7 @@ describe('getAnnotationsData', () => {
failedPercentage: 100,
},
],
+ firstProjectId: project.id,
})
})
@@ -355,6 +360,7 @@ describe('getAnnotationsData', () => {
failedPercentage: 0,
},
],
+ firstProjectId: project.id,
})
})
@@ -428,13 +434,14 @@ describe('getAnnotationsData', () => {
failedPercentage: 0,
},
],
+ firstProjectId: project.id,
})
})
})
describe('when no date range is provided', () => {
- it('fetches annotations from previous calendar week by default and ignores older ones', async () => {
- // Create a fresh workspace for this test to avoid interference
+ it('fetches annotations from last week (Sunday to Sunday) by default', async () => {
+ // Create a fresh workspace to avoid interference from previous tests
const { workspace: freshWorkspace } = await createWorkspace()
const {
project: freshProject,
@@ -454,25 +461,24 @@ describe('getAnnotationsData', () => {
metric: HumanEvaluationMetric.Binary,
})
- // Calculate the previous calendar week (Sunday to Sunday)
+ // Calculate last week's date range (Sunday to Sunday)
const now = new Date()
- const lastSunday = new Date(
+ const lastWeekEnd = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate() - now.getDay(),
)
- const previousSunday = new Date(
- lastSunday.getFullYear(),
- lastSunday.getMonth(),
- lastSunday.getDate() - 7,
+ const lastWeekStart = new Date(
+ lastWeekEnd.getFullYear(),
+ lastWeekEnd.getMonth(),
+ lastWeekEnd.getDate() - 7,
)
- // Create annotation within previous calendar week (Wednesday of that week)
- const dateInRange = new Date(
- previousSunday.getFullYear(),
- previousSunday.getMonth(),
- previousSunday.getDate() + 3, // Wednesday
- 12, // noon
+ // Create annotation within last week range (3 days into the week)
+ const dateInLastWeek = new Date(
+ lastWeekStart.getFullYear(),
+ lastWeekStart.getMonth(),
+ lastWeekStart.getDate() + 3,
)
const span1 = await createSpan({
@@ -486,15 +492,14 @@ describe('getAnnotationsData', () => {
evaluation,
span: span1,
hasPassed: true,
- createdAt: dateInRange,
+ createdAt: dateInLastWeek,
})
- // Create annotation older than previous calendar week (2 weeks ago)
- const dateOutOfRange = new Date(
- previousSunday.getFullYear(),
- previousSunday.getMonth(),
- previousSunday.getDate() - 10, // 10 days before previous Sunday
- 12,
+ // Create annotation outside last week range (2 weeks ago)
+ const twoWeeksAgo = new Date(
+ lastWeekStart.getFullYear(),
+ lastWeekStart.getMonth(),
+ lastWeekStart.getDate() - 10,
)
const span2 = await createSpan({
@@ -508,15 +513,15 @@ describe('getAnnotationsData', () => {
evaluation,
span: span2,
hasPassed: false,
- createdAt: dateOutOfRange,
+ createdAt: twoWeeksAgo,
})
- // Call without dateRange - should use default previous calendar week range
+ // Call without dateRange - should use default last week range
const result = await getAnnotationsData({ workspace: freshWorkspace })
expect(result).toEqual({
hasAnnotations: true,
- annotationsCount: 1, // Only annotation in previous calendar week
+ annotationsCount: 1, // Only the annotation from last week
passedCount: 1,
failedCount: 0,
passedPercentage: 100,
@@ -532,6 +537,7 @@ describe('getAnnotationsData', () => {
failedPercentage: 0,
},
],
+ firstProjectId: freshProject.id,
})
})
})
@@ -612,6 +618,7 @@ describe('getAnnotationsData', () => {
failedPercentage: expect.closeTo(66.67, 2),
},
],
+ firstProjectId: project.id,
})
})
})
diff --git a/packages/core/src/data-access/weeklyEmail/annotations/index.ts b/packages/core/src/data-access/weeklyEmail/annotations/index.ts
index c2fbf43fad..7e97b0e7ac 100644
--- a/packages/core/src/data-access/weeklyEmail/annotations/index.ts
+++ b/packages/core/src/data-access/weeklyEmail/annotations/index.ts
@@ -7,6 +7,8 @@ import { projects } from '../../../schema/models/projects'
import { commits } from '../../../schema/models/commits'
import { Workspace } from '../../../schema/models/types/Workspace'
import { getDateRangeOrLastWeekRange } from '../utils'
+import { AnnotationStats } from '@latitude-data/emails/WeeklyEmailMailTypes'
+import { ProjectsRepository } from '../../../repositories'
async function getAllTimesAnnotationsCount(
{ workspace }: { workspace: Workspace },
@@ -123,7 +125,7 @@ export async function getAnnotationsData(
dateRange?: DateRange
},
db = database,
-) {
+): Promise
{
const allTimesAnnotationsCount = await getAllTimesAnnotationsCount(
{ workspace },
db,
@@ -131,6 +133,9 @@ export async function getAnnotationsData(
const hasAnnotations = allTimesAnnotationsCount > 0
if (!hasAnnotations) {
+ const projects = new ProjectsRepository(workspace.id, db)
+ const firstProject = await projects.findFirst().then((r) => r.unwrap())
+
return {
hasAnnotations: false,
annotationsCount: 0,
@@ -139,6 +144,7 @@ export async function getAnnotationsData(
passedPercentage: 0,
failedPercentage: 0,
topProjects: [],
+ firstProjectId: firstProject?.id ?? null,
}
}
@@ -157,5 +163,6 @@ export async function getAnnotationsData(
passedPercentage: all.passedPercentage,
failedPercentage: all.failedPercentage,
topProjects,
+ firstProjectId: topProjects.length > 0 ? topProjects[0].projectId : null,
}
}
diff --git a/packages/core/src/data-access/weeklyEmail/issues/index.test.ts b/packages/core/src/data-access/weeklyEmail/issues/index.test.ts
index 4b37b7517e..08fd5bd2b3 100644
--- a/packages/core/src/data-access/weeklyEmail/issues/index.test.ts
+++ b/packages/core/src/data-access/weeklyEmail/issues/index.test.ts
@@ -2,7 +2,6 @@ import { eq } from 'drizzle-orm'
import { beforeAll, describe, expect, it } from 'vitest'
import { createWorkspace } from '../../../tests/factories'
import { Workspace } from '../../../schema/models/types/Workspace'
-import { getIssuesData } from './index'
import { createProject } from '../../../tests/factories/projects'
import { Project } from '../../../schema/models/types/Project'
import { createIssue } from '../../../tests/factories/issues'
@@ -10,6 +9,7 @@ import { Commit } from '../../../schema/models/types/Commit'
import { DocumentVersion } from '../../../schema/models/types/DocumentVersion'
import { issues } from '../../../schema/models/issues'
import { database } from '../../../client'
+import { getIssuesData } from './index'
// Static date for testing: Monday, January 8, 2024 at 10:00 AM UTC
const STATIC_TEST_DATE = new Date('2024-01-08T10:00:00Z')
@@ -54,6 +54,7 @@ describe('getIssuesData', () => {
hasIssues: false,
issuesCount: 0,
newIssuesCount: 0,
+ newIssuesList: [],
escalatedIssuesCount: 0,
resolvedIssuesCount: 0,
ignoredIssuesCount: 0,
@@ -96,6 +97,7 @@ describe('getIssuesData', () => {
hasIssues: true,
issuesCount: 0,
newIssuesCount: 0,
+ newIssuesList: [],
escalatedIssuesCount: 0,
resolvedIssuesCount: 0,
ignoredIssuesCount: 0,
diff --git a/packages/core/src/data-access/weeklyEmail/issues/index.ts b/packages/core/src/data-access/weeklyEmail/issues/index.ts
index 87c846c7cf..4d9608c434 100644
--- a/packages/core/src/data-access/weeklyEmail/issues/index.ts
+++ b/packages/core/src/data-access/weeklyEmail/issues/index.ts
@@ -1,12 +1,23 @@
-import { and, between, count, desc, eq, isNotNull, sql } from 'drizzle-orm'
+import {
+ and,
+ between,
+ count,
+ desc,
+ eq,
+ inArray,
+ isNotNull,
+ sql,
+} from 'drizzle-orm'
import { format } from 'date-fns'
import { database } from '../../../client'
import { DateRange, SureDateRange } from '../../../constants'
import { issueHistograms } from '../../../schema/models/issueHistograms'
import { issues } from '../../../schema/models/issues'
import { projects } from '../../../schema/models/projects'
+import { commits } from '../../../schema/models/commits'
import { Workspace } from '../../../schema/models/types/Workspace'
import { getDateRangeOrLastWeekRange } from '../utils'
+import { IssueStats } from '@latitude-data/emails/WeeklyEmailMailTypes'
async function getAllTimesIssuesCount(
{ workspace }: { workspace: Workspace },
@@ -189,6 +200,71 @@ async function getTopProjectsIssuesStats(
}))
}
+async function getNewIssuesList(
+ {
+ workspace,
+ range,
+ }: {
+ workspace: Workspace
+ range: SureDateRange
+ },
+ db = database,
+) {
+ const newIssueIds = await db
+ .select({ id: issues.id })
+ .from(issues)
+ .where(
+ and(
+ eq(issues.workspaceId, workspace.id),
+ between(issues.createdAt, range.from, range.to),
+ ),
+ )
+ .then((rows) => rows.map((r) => r.id))
+
+ if (newIssueIds.length === 0) return []
+
+ // Subquery to get the latest histogram for each new issue using window function
+ // ROW_NUMBER() assigns 1 to the most recent histogram per issue (PARTITION BY issueId)
+ // This is more efficient than a correlated subquery as it scans the table once
+ const latestHistogramSubquery = db
+ .select({
+ issueId: issueHistograms.issueId,
+ commitId: issueHistograms.commitId,
+ rowNum:
+ sql`ROW_NUMBER() OVER (PARTITION BY ${issueHistograms.issueId} ORDER BY ${issueHistograms.occurredAt} DESC)`.as(
+ 'row_num',
+ ),
+ })
+ .from(issueHistograms)
+ .where(
+ and(
+ eq(issueHistograms.workspaceId, workspace.id),
+ inArray(issueHistograms.issueId, newIssueIds),
+ ),
+ )
+ .as('latestHistogram')
+
+ return db
+ .select({
+ id: issues.id,
+ title: issues.title,
+ projectId: issues.projectId,
+ commitUuid: commits.uuid,
+ })
+ .from(issues)
+ .innerJoin(
+ latestHistogramSubquery,
+ and(
+ eq(issues.id, latestHistogramSubquery.issueId),
+ eq(latestHistogramSubquery.rowNum, 1),
+ ),
+ )
+ .innerJoin(commits, eq(latestHistogramSubquery.commitId, commits.id))
+ .where(inArray(issues.id, newIssueIds))
+ .orderBy(desc(issues.createdAt))
+ .limit(10)
+}
+
export async function getIssuesData(
{
workspace,
@@ -198,7 +274,7 @@ export async function getIssuesData(
dateRange?: DateRange
},
db = database,
-) {
+): Promise {
const allTimesIssuesCount = await getAllTimesIssuesCount({ workspace }, db)
const hasIssues = allTimesIssuesCount > 0
@@ -212,16 +288,22 @@ export async function getIssuesData(
ignoredIssuesCount: 0,
regressedIssuesCount: 0,
topProjects: [],
+ newIssuesList: [],
}
}
const range = getDateRangeOrLastWeekRange(dateRange)
const globalStats = await getGlobalIssuesStats({ workspace, range }, db)
const topProjects = await getTopProjectsIssuesStats({ workspace, range }, db)
+ const newIssuesList =
+ globalStats.newIssuesCount > 0
+ ? await getNewIssuesList({ workspace, range }, db)
+ : []
return {
hasIssues: true,
...globalStats,
topProjects,
+ newIssuesList,
}
}
diff --git a/packages/core/src/data-access/weeklyEmail/logs/index.tsx b/packages/core/src/data-access/weeklyEmail/logs/index.tsx
index 58e9686f1e..3437a83534 100644
--- a/packages/core/src/data-access/weeklyEmail/logs/index.tsx
+++ b/packages/core/src/data-access/weeklyEmail/logs/index.tsx
@@ -9,6 +9,7 @@ import {
isNotNull,
sql,
} from 'drizzle-orm'
+import { LogStats } from '@latitude-data/emails/WeeklyEmailMailTypes'
import { database } from '../../../client'
import {
DateRange,
@@ -150,7 +151,7 @@ export async function getLogsData(
dateRange?: DateRange
},
db = database,
-) {
+): Promise {
const allTimesProductionSpansCount = await getAllTimesSpansProductionCount(
{ workspace },
db,
diff --git a/packages/core/src/events/handlers/notifyClientOfExportReady.ts b/packages/core/src/events/handlers/notifyClientOfExportReady.ts
index 1b0cfa3978..5851944da8 100644
--- a/packages/core/src/events/handlers/notifyClientOfExportReady.ts
+++ b/packages/core/src/events/handlers/notifyClientOfExportReady.ts
@@ -1,6 +1,6 @@
import { unsafelyFindWorkspace } from '../../data-access/workspaces'
import { unsafelyGetUser } from '../../data-access/users'
-import { ExportReadyMailer } from '../../mailers/mailers/mailers/exports/ExportReadyMailer'
+import { ExportReadyMailer } from '../../mailer/mailers/exports/ExportReadyMailer'
import { EventHandler, ExportReadyEvent } from '../events'
/**
diff --git a/packages/core/src/events/handlers/sendInvitationToUser.ts b/packages/core/src/events/handlers/sendInvitationToUser.ts
index 89a5729a55..e4c78b27c1 100644
--- a/packages/core/src/events/handlers/sendInvitationToUser.ts
+++ b/packages/core/src/events/handlers/sendInvitationToUser.ts
@@ -1,6 +1,6 @@
import { unsafelyGetUser } from '../../data-access/users'
import { NotFoundError } from '../../lib/errors'
-import { InvitationMailer } from '../../mailers'
+import { InvitationMailer } from '../../mailer/mailers/invitations/InvitationMailer'
import { MembershipCreatedEvent } from '../events'
export const sendInvitationToUserJob = async ({
diff --git a/packages/core/src/events/handlers/sendIssueEscalatingHandler.test.ts b/packages/core/src/events/handlers/sendIssueEscalatingHandler.test.ts
index b89e954e5a..ae5b6d0a1c 100644
--- a/packages/core/src/events/handlers/sendIssueEscalatingHandler.test.ts
+++ b/packages/core/src/events/handlers/sendIssueEscalatingHandler.test.ts
@@ -1,11 +1,12 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { subDays } from 'date-fns'
+import Mail from 'nodemailer/lib/mailer'
import { ESCALATION_EXPIRATION_DAYS } from '@latitude-data/constants/issues'
import { Result } from '../../lib/Result'
import { createIssue, createProject } from '../../tests/factories'
import { IssueIncrementedEvent } from '../events'
import { sendIssueEscalatingHandler } from './sendIssueEscalatingHandler'
-import { IssueEscalatingMailer } from '../../mailers'
+import { IssueEscalatingMailer } from '../../mailer/mailers/issues/IssueEscalatingMailer'
import * as datadogCapture from '../../utils/datadogCapture'
import * as checkEscalationModule from '../../services/issues/histograms/checkEscalation'
import type { Workspace } from '../../schema/models/types/Workspace'
@@ -14,21 +15,43 @@ import type { DocumentVersion } from '../../schema/models/types/DocumentVersion'
import type { User } from '../../schema/models/types/User'
import type { Commit } from '../../schema/models/types/Commit'
-// Mock the mailer
-vi.mock('../../mailers', () => ({
- IssueEscalatingMailer: vi.fn().mockImplementation(() => ({
- send: vi
+// Mock sendMail to prevent actual email sending
+const mockSendMail = vi.fn().mockResolvedValue(
+ Result.ok({
+ messageId: 'test-message-id',
+ accepted: ['test@example.com'],
+ rejected: [],
+ pending: [],
+ envelope: { from: 'test@example.com', to: ['test@example.com'] },
+ response: 'OK',
+ }),
+)
+
+vi.mock('../../mailer/mailers/issues/IssueEscalatingMailer', async () => {
+ const actual = await vi.importActual<
+ typeof import('../../mailer/mailers/issues/IssueEscalatingMailer')
+ >('../../mailer/mailers/issues/IssueEscalatingMailer')
+ const OriginalMailer = actual.IssueEscalatingMailer
+ return {
+ IssueEscalatingMailer: vi
.fn()
- .mockResolvedValue(Result.ok({ messageId: 'test-message-id' })),
- })),
-}))
+ .mockImplementation(
+ (
+ options: Mail.Options,
+ { issueTitle, link }: { issueTitle: string; link: string },
+ ) => {
+ const instance = new OriginalMailer(options, { issueTitle, link })
+ instance['sendMail'] = mockSendMail
+ return instance
+ },
+ ),
+ }
+})
-// Mock datadog capture
vi.mock('../../utils/datadogCapture', () => ({
captureException: vi.fn(),
}))
-// Mock checkEscalation (already tested in isolation)
vi.mock('../../services/issues/histograms/checkEscalation', () => ({
checkEscalation: vi.fn(),
}))
@@ -133,24 +156,14 @@ describe('sendIssueEscalatingHandler', () => {
},
)
- // Verify send was called
- const mailerInstance = vi.mocked(IssueEscalatingMailer).mock.results[0]
- ?.value
- expect(mailerInstance.send).toHaveBeenCalled()
-
- // Verify issue data includes histogram
- const sendCall = mailerInstance.send.mock.calls[0][0]
- expect(sendCall.issue).toBeDefined()
- expect(sendCall.issue.title).toBe(issue.title)
- expect(sendCall.issue.eventsCount).toBeGreaterThanOrEqual(0)
- expect(sendCall.issue.histogram).toBeInstanceOf(Array)
- expect(sendCall.issue.histogram.length).toBeGreaterThan(0)
- expect(sendCall.issue.histogram[0]).toHaveProperty('date')
- expect(sendCall.issue.histogram[0]).toHaveProperty('count')
-
- // Verify currentWorkspace is passed
- expect(sendCall.currentWorkspace).toBeDefined()
- expect(sendCall.currentWorkspace.id).toBe(workspace.id)
+ // Verify email was sent through the adapter
+ expect(mockSendMail).toHaveBeenCalled()
+
+ // Verify email options passed to adapter
+ const emailOptions = mockSendMail.mock.calls[0][0]
+ expect(emailOptions.to).toBeInstanceOf(Array)
+ expect(emailOptions.to.length).toBeGreaterThan(0)
+ expect(emailOptions.to).toContain(user.email)
})
it('should send emails in batches when there are many users', async () => {
@@ -180,17 +193,6 @@ describe('sendIssueEscalatingHandler', () => {
isEscalating: true,
})
- // Mock mailer to track batch calls
- const mockSend = vi
- .fn()
- .mockResolvedValue(Result.ok({ messageId: 'test' }))
- vi.mocked(IssueEscalatingMailer).mockImplementation(
- () =>
- ({
- send: mockSend,
- }) as unknown as IssueEscalatingMailer,
- )
-
const event: IssueIncrementedEvent = {
type: 'issueIncremented',
data: {
@@ -205,23 +207,20 @@ describe('sendIssueEscalatingHandler', () => {
// Pass batchSize: 2 to test batching (3 users = 2 batches: [2, 1])
await sendIssueEscalatingHandler({ data: event, batchSize: 2 })
- // With 3 users and batch size of 2, should send 2 batches
- expect(mockSend).toHaveBeenCalledTimes(2)
+ // With 3 users and batch size of 2, should send 2 batches (real sendInBatches handles this)
+ expect(mockSendMail).toHaveBeenCalledTimes(2)
- // Verify first batch has 2 users
- const firstBatch = mockSend.mock.calls[0][0]
- expect(firstBatch.to).toHaveLength(2)
- expect(firstBatch.recipientVariables).toBeDefined()
- expect(Object.keys(firstBatch.recipientVariables)).toHaveLength(2)
+ // Verify batches were sent with proper email options
+ const firstCall = mockSendMail.mock.calls[0][0]
+ const secondCall = mockSendMail.mock.calls[1][0]
- // Verify second batch has 1 user
- const secondBatch = mockSend.mock.calls[1][0]
- expect(secondBatch.to).toHaveLength(1)
- expect(secondBatch.recipientVariables).toBeDefined()
- expect(Object.keys(secondBatch.recipientVariables)).toHaveLength(1)
+ // First batch should have 2 recipients
+ expect(firstCall.to).toHaveLength(2)
+ // Second batch should have 1 recipient
+ expect(secondCall.to).toHaveLength(1)
// Verify all 3 users were sent emails
- const allRecipients = [...firstBatch.to, ...secondBatch.to]
+ const allRecipients = [...firstCall.to, ...secondCall.to]
expect(allRecipients).toHaveLength(3)
expect(allRecipients).toEqual(
expect.arrayContaining([user.email, user2.email, user3.email]),
@@ -297,7 +296,6 @@ describe('sendIssueEscalatingHandler', () => {
await sendIssueEscalatingHandler({ data: event })
- // Verify mailer was NOT called (still in same escalation period)
expect(IssueEscalatingMailer).not.toHaveBeenCalled()
})
})
@@ -318,14 +316,9 @@ describe('sendIssueEscalatingHandler', () => {
isEscalating: true,
})
- // Mock mailer to return error
+ // Mock sendMail to return error
const sendError = new Error('Email provider failed')
- vi.mocked(IssueEscalatingMailer).mockImplementationOnce(
- () =>
- ({
- send: vi.fn().mockResolvedValue(Result.error(sendError)),
- }) as unknown as IssueEscalatingMailer,
- )
+ mockSendMail.mockResolvedValueOnce(Result.error(sendError))
const event: IssueIncrementedEvent = {
type: 'issueIncremented',
@@ -347,7 +340,7 @@ describe('sendIssueEscalatingHandler', () => {
issueId: issue.id,
issueTitle: issue.title,
workspaceId: workspace.id,
- context: 'issue_escalation_email',
+ mailName: 'issue_escalation_email',
}),
)
})
@@ -368,12 +361,7 @@ describe('sendIssueEscalatingHandler', () => {
})
const sendError = new Error('Batch 0 failed')
- vi.mocked(IssueEscalatingMailer).mockImplementationOnce(
- () =>
- ({
- send: vi.fn().mockResolvedValue(Result.error(sendError)),
- }) as unknown as IssueEscalatingMailer,
- )
+ mockSendMail.mockResolvedValueOnce(Result.error(sendError))
const event: IssueIncrementedEvent = {
type: 'issueIncremented',
@@ -428,34 +416,26 @@ describe('sendIssueEscalatingHandler', () => {
await sendIssueEscalatingHandler({ data: event })
- // Verify mailer was instantiated
- expect(IssueEscalatingMailer).toHaveBeenCalled()
-
- const mailerInstance = vi.mocked(IssueEscalatingMailer).mock.results[0]
- ?.value
+ // Verify email was sent
+ expect(mockSendMail).toHaveBeenCalled()
- // Verify send was called with proper format
- const sendCall = mailerInstance?.send.mock.calls[0][0]
+ // Verify email options passed to adapter
+ const emailOptions = mockSendMail.mock.calls[0][0]
// Verify recipients
- expect(sendCall.to).toBeInstanceOf(Array)
- expect(sendCall.to.length).toBeGreaterThan(0)
-
- // Verify recipient variables
- expect(sendCall.recipientVariables).toBeDefined()
- expect(sendCall.recipientVariables[user.email]).toBeDefined()
- expect(sendCall.recipientVariables[user.email].name).toBeDefined()
- expect(sendCall.recipientVariables[user.email].id).toBeDefined()
-
- // Verify workspace
- expect(sendCall.currentWorkspace).toBeDefined()
- expect(sendCall.currentWorkspace.id).toBe(workspace.id)
-
- // Verify issue data
- expect(sendCall.issue).toBeDefined()
- expect(sendCall.issue.title).toBe(issue.title)
- expect(sendCall.issue.eventsCount).toBeGreaterThanOrEqual(0)
- expect(sendCall.issue.histogram).toBeInstanceOf(Array)
+ expect(emailOptions.to).toBeInstanceOf(Array)
+ expect(emailOptions.to.length).toBeGreaterThan(0)
+ expect(emailOptions.to).toContain(user.email)
+
+ // Verify recipient variables are properly formatted
+ expect(emailOptions['recipient-variables']).toBeDefined()
+ const recipientVars =
+ typeof emailOptions['recipient-variables'] === 'string'
+ ? JSON.parse(emailOptions['recipient-variables'])
+ : emailOptions['recipient-variables']
+ expect(recipientVars[user.email]).toBeDefined()
+ expect(recipientVars[user.email].name).toBeDefined()
+ expect(recipientVars[user.email].userId).toBeDefined()
})
})
@@ -498,17 +478,6 @@ describe('sendIssueEscalatingHandler', () => {
isEscalating: true,
})
- // Mock mailer to track recipients
- const mockSend = vi
- .fn()
- .mockResolvedValue(Result.ok({ messageId: 'test' }))
- vi.mocked(IssueEscalatingMailer).mockImplementation(
- () =>
- ({
- send: mockSend,
- }) as unknown as IssueEscalatingMailer,
- )
-
const event: IssueIncrementedEvent = {
type: 'issueIncremented',
data: {
@@ -522,11 +491,13 @@ describe('sendIssueEscalatingHandler', () => {
await sendIssueEscalatingHandler({ data: event })
- // Verify mailer was called
- expect(mockSend).toHaveBeenCalled()
+ // Verify emails were sent
+ expect(mockSendMail).toHaveBeenCalled()
// Get all recipients from all batches
- const allRecipients = mockSend.mock.calls.flatMap((call) => call[0].to)
+ const allRecipients = mockSendMail.mock.calls.flatMap(
+ (call) => call[0].to,
+ )
// Should only include user (default opted-in) and user2 (explicitly opted-in)
// Should NOT include user3 (opted-out)
diff --git a/packages/core/src/events/handlers/sendIssueEscalatingHandler.ts b/packages/core/src/events/handlers/sendIssueEscalatingHandler.ts
index 905c436f66..6f8e70afd2 100644
--- a/packages/core/src/events/handlers/sendIssueEscalatingHandler.ts
+++ b/packages/core/src/events/handlers/sendIssueEscalatingHandler.ts
@@ -3,13 +3,9 @@ import { ESCALATION_EXPIRATION_DAYS } from '@latitude-data/constants/issues'
import { env } from '@latitude-data/env'
import { unsafelyFindWorkspace } from '../../data-access/workspaces'
import { NotFoundError } from '../../lib/errors'
-import {
- IssueEscalatingMailer,
- SendIssueEscalatingMailOptions,
-} from '../../mailers'
+import { IssueEscalatingMailer } from '../../mailer/mailers/issues/IssueEscalatingMailer'
import { IssuesRepository, IssueHistogramsRepository } from '../../repositories'
import { updateEscalatingIssue } from '../../services/issues/updateEscalating'
-import { captureException } from '../../utils/datadogCapture'
import { IssueIncrementedEvent } from '../events'
import { Workspace } from '../../schema/models/types/Workspace'
import { users } from '../../schema/models/users'
@@ -21,7 +17,7 @@ const BATCH_SIZE = 100 // Batch size can be up to 1000 in mailgun
async function findNotificableMembers(workspace: Workspace) {
return database
.select({
- id: users.id,
+ userId: users.id,
email: users.email,
name: users.name,
membershipId: memberships.id,
@@ -37,7 +33,6 @@ async function findNotificableMembers(workspace: Workspace) {
.orderBy(asc(users.createdAt))
}
-// TODO: Refactor common batching logic with other mailers
async function sendEmail({
workspace,
issue,
@@ -51,15 +46,12 @@ async function sendEmail({
projectId: number
batchSize?: number
}) {
- if (!workspace) {
- return
- }
+ if (!workspace) return
const members = await findNotificableMembers(workspace)
if (members.length === 0) return
- // Fetch histogram data for the issue
const histogramsRepo = new IssueHistogramsRepository(workspace.id)
const histogramResult = await histogramsRepo.findHistogramForIssue({
issueId: issue.id,
@@ -81,52 +73,23 @@ async function sendEmail({
},
)
- const addresses = members.map((member) => ({
- id: member.id,
- address: member.email,
- name: member.name || member.email,
- membershipId: member.membershipId,
- }))
-
- const batches: SendIssueEscalatingMailOptions[] = []
-
- for (let i = 0; i < addresses.length; i += batchSize) {
- const batchAddresses = addresses.slice(i, i + batchSize)
- const recipientVariables: Record> = {}
-
- batchAddresses.forEach((user) => {
- recipientVariables[user.address] = {
- name: user.name,
- id: user.id,
- membershipId: user.membershipId,
- }
- })
-
- batches.push({
- to: batchAddresses.map((u) => u.address),
- recipientVariables,
- currentWorkspace: workspace,
- issue: issueData,
- })
- }
-
- await Promise.allSettled(
- batches.map(async (batch, index) => {
- const result = await mailer.send(batch)
-
- if (result.error) {
- const batchSize = Array.isArray(batch.to) ? batch.to.length : 1
- captureException(result.error, {
- issueId: issue.id,
- issueTitle: issue.title,
- workspaceId: workspace.id,
- batchIndex: index,
- batchSize: batchSize,
- context: 'issue_escalation_email',
- })
- }
- }),
- )
+ await mailer.sendInBatches({
+ addressList: members,
+ sendOptions: async (batch) =>
+ mailer.send({
+ to: batch.to,
+ recipientVariables: batch.recipientVariables,
+ currentWorkspace: workspace,
+ issue: issueData,
+ }),
+ context: {
+ mailName: 'issue_escalation_email',
+ issueId: issue.id,
+ issueTitle: issue.title,
+ workspaceId: workspace.id,
+ },
+ batchSize,
+ })
}
/**
diff --git a/packages/core/src/events/handlers/sendMagicLinkHandler.ts b/packages/core/src/events/handlers/sendMagicLinkHandler.ts
index e668b525db..ec524ab070 100644
--- a/packages/core/src/events/handlers/sendMagicLinkHandler.ts
+++ b/packages/core/src/events/handlers/sendMagicLinkHandler.ts
@@ -1,6 +1,6 @@
import { unsafelyGetUser } from '../../data-access/users'
import { NotFoundError } from '../../lib/errors'
-import { MagicLinkMailer } from '../../mailers'
+import { MagicLinkMailer } from '../../mailer/mailers/magicLinks/MagicLinkMailer'
import { MagicLinkTokenCreated } from '../events'
export async function sendMagicLinkJob({
diff --git a/packages/core/src/events/handlers/sendReferralInvitation.ts b/packages/core/src/events/handlers/sendReferralInvitation.ts
index e7787bdc75..255e0df251 100644
--- a/packages/core/src/events/handlers/sendReferralInvitation.ts
+++ b/packages/core/src/events/handlers/sendReferralInvitation.ts
@@ -2,7 +2,7 @@ import {
unsafelyFindUserByEmail,
unsafelyGetUser,
} from '../../data-access/users'
-import { ReferralMailer } from '../../mailers'
+import { ReferralMailer } from '../../mailer/mailers/invitations/ReferralMailer'
import { SendReferralInvitationEvent } from '../events'
import { NotFoundError } from '@latitude-data/constants/errors'
diff --git a/packages/core/src/events/handlers/sendSuggestionNotification.ts b/packages/core/src/events/handlers/sendSuggestionNotification.ts
index 51d38e68c7..6165f9d5f0 100644
--- a/packages/core/src/events/handlers/sendSuggestionNotification.ts
+++ b/packages/core/src/events/handlers/sendSuggestionNotification.ts
@@ -8,7 +8,7 @@ import { unsafelyFindWorkspace } from '../../data-access/workspaces'
import { NotFoundError } from '../../lib/errors'
import { Result } from '../../lib/Result'
import Transaction from '../../lib/Transaction'
-import { SuggestionMailer } from '../../mailers'
+import { SuggestionMailer } from '../../mailer/mailers/suggestions/SuggestionMailer'
import {
CommitsRepository,
DocumentVersionsRepository,
diff --git a/packages/core/src/jobs/job-definitions/datasets/notifyClientOfDatasetUpdate.ts b/packages/core/src/jobs/job-definitions/datasets/notifyClientOfDatasetUpdate.ts
index 9875cca2d3..c42c89f859 100644
--- a/packages/core/src/jobs/job-definitions/datasets/notifyClientOfDatasetUpdate.ts
+++ b/packages/core/src/jobs/job-definitions/datasets/notifyClientOfDatasetUpdate.ts
@@ -1,7 +1,7 @@
import { Job } from 'bullmq'
import { unsafelyFindWorkspace } from '../../../data-access/workspaces'
import { DatasetsRepository, UsersRepository } from '../../../repositories'
-import { DatasetUpdateMailer } from '../../../mailers/mailers/mailers/datasets/DatasetUpdateMailer'
+import { DatasetUpdateMailer } from '../../../mailer/mailers/datasets/DatasetUpdateMailer'
type NotifyClientOfDatasetUpdateJobProps = {
userId: string
diff --git a/packages/core/src/jobs/job-definitions/index.ts b/packages/core/src/jobs/job-definitions/index.ts
index 3d7a98bf54..facf39ed2b 100644
--- a/packages/core/src/jobs/job-definitions/index.ts
+++ b/packages/core/src/jobs/job-definitions/index.ts
@@ -60,3 +60,4 @@ export * from './webhooks'
export * from './issues/generateIssueDetailsJob'
export * from './issues/discoverResultIssueJob'
export * from './issues/mergeCommonIssuesJob'
+export * from './weeklyEmail'
diff --git a/packages/core/src/jobs/job-definitions/maintenance/scheduleWorkspaceCleanupJobs.ts b/packages/core/src/jobs/job-definitions/maintenance/scheduleWorkspaceCleanupJobs.ts
index f64d4f3414..2a024e5478 100644
--- a/packages/core/src/jobs/job-definitions/maintenance/scheduleWorkspaceCleanupJobs.ts
+++ b/packages/core/src/jobs/job-definitions/maintenance/scheduleWorkspaceCleanupJobs.ts
@@ -19,6 +19,7 @@ export type ScheduleWorkspaceCleanupJobsData = Record
export const scheduleWorkspaceCleanupJobs = async (
_: Job,
) => {
+ const { maintenanceQueue } = await queues()
// Find all workspaces with free plans
const freeWorkspaces = await database
.select({
@@ -36,7 +37,6 @@ export const scheduleWorkspaceCleanupJobs = async (
// Enqueue individual cleanup job for each free workspace
for (const workspace of freeWorkspaces) {
- const { maintenanceQueue } = await queues()
await maintenanceQueue.add(
'cleanupWorkspaceOldLogsJob',
{ workspaceId: workspace.id },
diff --git a/packages/core/src/jobs/job-definitions/weeklyEmail/index.ts b/packages/core/src/jobs/job-definitions/weeklyEmail/index.ts
new file mode 100644
index 0000000000..44049d7e34
--- /dev/null
+++ b/packages/core/src/jobs/job-definitions/weeklyEmail/index.ts
@@ -0,0 +1,2 @@
+export * from './scheduleWeeklyEmailJobs'
+export * from './sendWeeklyEmailJob'
diff --git a/packages/core/src/jobs/job-definitions/weeklyEmail/scheduleWeeklyEmailJobs.test.ts b/packages/core/src/jobs/job-definitions/weeklyEmail/scheduleWeeklyEmailJobs.test.ts
new file mode 100644
index 0000000000..80470539a9
--- /dev/null
+++ b/packages/core/src/jobs/job-definitions/weeklyEmail/scheduleWeeklyEmailJobs.test.ts
@@ -0,0 +1,145 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { Job } from 'bullmq'
+import { createWorkspace } from '../../../tests/factories'
+import { createSpan } from '../../../tests/factories/spans'
+import { SpanType } from '../../../constants'
+import { scheduleWeeklyEmailJobs } from './scheduleWeeklyEmailJobs'
+import * as queuesModule from '../../queues'
+
+describe('scheduleWeeklyEmailJobs', () => {
+ let mockAdd: ReturnType
+
+ beforeEach(() => {
+ mockAdd = vi.fn()
+ vi.spyOn(queuesModule, 'queues').mockResolvedValue({
+ maintenanceQueue: { add: mockAdd },
+ eventsQueue: { add: vi.fn() },
+ webhooksQueue: { add: vi.fn() },
+ } as any)
+ })
+
+ it('enqueues jobs for active workspaces', async () => {
+ const { workspace: workspace1 } = await createWorkspace()
+ const { workspace: workspace2 } = await createWorkspace()
+
+ const threeDaysAgo = new Date()
+ threeDaysAgo.setDate(threeDaysAgo.getDate() - 3)
+ await createSpan({
+ workspaceId: workspace1.id,
+ type: SpanType.Prompt,
+ startedAt: threeDaysAgo,
+ })
+
+ await createSpan({
+ workspaceId: workspace2.id,
+ type: SpanType.Prompt,
+ startedAt: threeDaysAgo,
+ })
+
+ await scheduleWeeklyEmailJobs({} as Job)
+
+ expect(mockAdd).toHaveBeenCalledWith(
+ 'sendWeeklyEmailJob',
+ { workspaceId: workspace1.id },
+ { attempts: 3 },
+ )
+ expect(mockAdd).toHaveBeenCalledWith(
+ 'sendWeeklyEmailJob',
+ { workspaceId: workspace2.id },
+ { attempts: 3 },
+ )
+ })
+
+ it('does not enqueue jobs for workspaces with old activity', async () => {
+ const { workspace } = await createWorkspace()
+
+ const fiveWeeksAgo = new Date()
+ fiveWeeksAgo.setDate(fiveWeeksAgo.getDate() - 35)
+
+ await createSpan({
+ workspaceId: workspace.id,
+ type: SpanType.Prompt,
+ startedAt: fiveWeeksAgo,
+ })
+
+ await scheduleWeeklyEmailJobs({} as Job)
+ expect(mockAdd).not.toHaveBeenCalledWith(
+ 'sendWeeklyEmailJob',
+ { workspaceId: workspace.id },
+ expect.any(Object),
+ )
+ })
+
+ it('does not enqueue jobs for big accounts', async () => {
+ const { workspace: bigWorkspace } = await createWorkspace({
+ isBigAccount: true,
+ })
+
+ const threeDaysAgo = new Date()
+ threeDaysAgo.setDate(threeDaysAgo.getDate() - 3)
+
+ await createSpan({
+ workspaceId: bigWorkspace.id,
+ type: SpanType.Prompt,
+ startedAt: threeDaysAgo,
+ })
+
+ await scheduleWeeklyEmailJobs({} as Job)
+
+ expect(mockAdd).not.toHaveBeenCalledWith(
+ 'sendWeeklyEmailJob',
+ { workspaceId: bigWorkspace.id },
+ expect.any(Object),
+ )
+ })
+
+ it('does not enqueue jobs when no active workspaces', async () => {
+ await createWorkspace()
+
+ await scheduleWeeklyEmailJobs({} as Job)
+
+ expect(mockAdd).not.toHaveBeenCalled()
+ })
+
+ it('handles multiple workspaces with mixed activity', async () => {
+ const { workspace: activeWorkspace } = await createWorkspace()
+ const { workspace: inactiveWorkspace } = await createWorkspace()
+ const { workspace: bigWorkspace } = await createWorkspace({
+ isBigAccount: true,
+ })
+
+ const threeDaysAgo = new Date()
+ threeDaysAgo.setDate(threeDaysAgo.getDate() - 3)
+
+ await createSpan({
+ workspaceId: activeWorkspace.id,
+ type: SpanType.Prompt,
+ startedAt: threeDaysAgo,
+ })
+
+ await createSpan({
+ workspaceId: bigWorkspace.id,
+ type: SpanType.Prompt,
+ startedAt: threeDaysAgo,
+ })
+
+ await scheduleWeeklyEmailJobs({} as Job)
+
+ expect(mockAdd).toHaveBeenCalledWith(
+ 'sendWeeklyEmailJob',
+ { workspaceId: activeWorkspace.id },
+ { attempts: 3 },
+ )
+
+ expect(mockAdd).not.toHaveBeenCalledWith(
+ 'sendWeeklyEmailJob',
+ { workspaceId: bigWorkspace.id },
+ expect.any(Object),
+ )
+ expect(mockAdd).not.toHaveBeenCalledWith(
+ 'sendWeeklyEmailJob',
+ { workspaceId: inactiveWorkspace.id },
+ expect.any(Object),
+ )
+ })
+})
diff --git a/packages/core/src/jobs/job-definitions/weeklyEmail/scheduleWeeklyEmailJobs.ts b/packages/core/src/jobs/job-definitions/weeklyEmail/scheduleWeeklyEmailJobs.ts
new file mode 100644
index 0000000000..2b0423da96
--- /dev/null
+++ b/packages/core/src/jobs/job-definitions/weeklyEmail/scheduleWeeklyEmailJobs.ts
@@ -0,0 +1,28 @@
+import { Job } from 'bullmq'
+import { getActiveWorkspacesForWeeklyEmail } from '../../../data-access/weeklyEmail/activeWorkspaces'
+import { queues } from '../../queues'
+
+export type ScheduleWeeklyEmailJobsData = Record
+
+/**
+ * Job that runs weekly (Monday at 1:00 AM) to schedule weekly email jobs.
+ *
+ * This job:
+ * 1. Finds all workspaces with prompt span activity in the last 4 weeks
+ * 2. Excludes workspaces marked as big accounts (isBigAccount = true)
+ * 3. Enqueues individual sendWeeklyEmailJob for each active workspace
+ */
+export const scheduleWeeklyEmailJobs = async (
+ _: Job,
+) => {
+ const { maintenanceQueue } = await queues()
+ const activeWorkspaces = await getActiveWorkspacesForWeeklyEmail()
+
+ for (const workspace of activeWorkspaces) {
+ await maintenanceQueue.add(
+ 'sendWeeklyEmailJob',
+ { workspaceId: workspace.id },
+ { attempts: 3 },
+ )
+ }
+}
diff --git a/packages/core/src/jobs/job-definitions/weeklyEmail/sendWeeklyEmailJob.test.ts b/packages/core/src/jobs/job-definitions/weeklyEmail/sendWeeklyEmailJob.test.ts
new file mode 100644
index 0000000000..005b3cb598
--- /dev/null
+++ b/packages/core/src/jobs/job-definitions/weeklyEmail/sendWeeklyEmailJob.test.ts
@@ -0,0 +1,314 @@
+import { eq } from 'drizzle-orm'
+import { Job } from 'bullmq'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { createWorkspace, createUser } from '../../../tests/factories'
+import { createMembership } from '../../../tests/factories/memberships'
+import { NotFoundError } from '../../../lib/errors'
+import * as logsModule from '../../../data-access/weeklyEmail/logs'
+import * as issuesModule from '../../../data-access/weeklyEmail/issues'
+import * as annotationsModule from '../../../data-access/weeklyEmail/annotations'
+import * as WeeklyEmailMailerModule from '../../../mailer/mailers/weeklyEmail/WeeklyEmailMailer'
+import { database } from '../../../client'
+import { memberships } from '../../../schema/models/memberships'
+import { sendWeeklyEmailJob } from './sendWeeklyEmailJob'
+import { AddressItem } from '../../../mailer/buildBatchRecipients'
+
+describe('sendWeeklyEmailJob', () => {
+ let mockGetLogsData: ReturnType
+ let mockGetIssuesData: ReturnType
+ let mockGetAnnotationsData: ReturnType
+ let mockSendInBatches: ReturnType
+ let mockSend: ReturnType
+
+ const mockLogsData = {
+ usedInProduction: true,
+ logsCount: 100,
+ tokensSpent: 5000,
+ tokensCost: 10.5,
+ topProjects: [],
+ }
+
+ const mockIssuesData = {
+ hasIssues: true,
+ issuesCount: 5,
+ newIssuesCount: 2,
+ escalatedIssuesCount: 1,
+ resolvedIssuesCount: 3,
+ ignoredIssuesCount: 0,
+ regressedIssuesCount: 0,
+ topProjects: [],
+ newIssuesList: [],
+ }
+
+ const mockAnnotationsData = {
+ hasAnnotations: true,
+ annotationsCount: 50,
+ passedCount: 45,
+ failedCount: 5,
+ passedPercentage: 90,
+ failedPercentage: 10,
+ topProjects: [],
+ }
+
+ beforeEach(() => {
+ mockGetLogsData = vi.fn().mockResolvedValue(mockLogsData)
+ mockGetIssuesData = vi.fn().mockResolvedValue(mockIssuesData)
+ mockGetAnnotationsData = vi.fn().mockResolvedValue(mockAnnotationsData)
+ mockSend = vi.fn().mockResolvedValue({ ok: true, value: {} })
+ mockSendInBatches = vi.fn().mockResolvedValue(undefined)
+
+ vi.spyOn(logsModule, 'getLogsData').mockImplementation(mockGetLogsData)
+ vi.spyOn(issuesModule, 'getIssuesData').mockImplementation(
+ mockGetIssuesData,
+ )
+ vi.spyOn(annotationsModule, 'getAnnotationsData').mockImplementation(
+ mockGetAnnotationsData,
+ )
+
+ vi.spyOn(
+ WeeklyEmailMailerModule.WeeklyEmailMailer.prototype,
+ 'sendInBatches',
+ ).mockImplementation(mockSendInBatches)
+ vi.spyOn(
+ WeeklyEmailMailerModule.WeeklyEmailMailer.prototype,
+ 'send',
+ ).mockImplementation(mockSend)
+ })
+
+ describe('workspace validation', () => {
+ it('throws NotFoundError when workspace does not exist', async () => {
+ const job = {
+ data: { workspaceId: 99999 },
+ } as Job
+
+ await expect(sendWeeklyEmailJob(job)).rejects.toThrow(NotFoundError)
+ await expect(sendWeeklyEmailJob(job)).rejects.toThrow(
+ 'Workspace not found sending weekly email',
+ )
+ })
+
+ it('processes job successfully when workspace exists', async () => {
+ const { workspace } = await createWorkspace()
+ await database
+ .update(memberships)
+ .set({ wantToReceiveWeeklyEmail: true })
+ .where(eq(memberships.workspaceId, workspace.id))
+
+ const job = {
+ data: { workspaceId: workspace.id },
+ } as Job
+
+ await sendWeeklyEmailJob(job)
+
+ expect(mockGetLogsData).toHaveBeenCalledWith({ workspace })
+ expect(mockGetIssuesData).toHaveBeenCalledWith({ workspace })
+ expect(mockGetAnnotationsData).toHaveBeenCalledWith({ workspace })
+ })
+ })
+
+ describe('membership filtering', () => {
+ it('only sends emails to members who want to receive weekly emails', async () => {
+ const { workspace } = await createWorkspace()
+ const user1 = await createUser()
+ const user2 = await createUser()
+ const user3 = await createUser()
+ const creator = await createUser()
+
+ await createMembership({ user: user1, workspace, author: creator })
+ await createMembership({ user: user2, workspace, author: creator })
+ await createMembership({ user: user3, workspace, author: creator })
+
+ // Set wantToReceiveWeeklyEmail for user1 and user2 only
+ await database
+ .update(memberships)
+ .set({ wantToReceiveWeeklyEmail: false })
+ .where(eq(memberships.workspaceId, workspace.id))
+
+ await database
+ .update(memberships)
+ .set({ wantToReceiveWeeklyEmail: true })
+ .where(eq(memberships.userId, user1.id))
+
+ await database
+ .update(memberships)
+ .set({ wantToReceiveWeeklyEmail: true })
+ .where(eq(memberships.userId, user2.id))
+
+ const job = {
+ data: { workspaceId: workspace.id },
+ } as Job
+
+ await sendWeeklyEmailJob(job)
+
+ expect(mockSendInBatches).toHaveBeenCalledTimes(1)
+ const callArgs = mockSendInBatches.mock.calls[0][0]
+ expect(callArgs.addressList).toHaveLength(2)
+ const emails = callArgs.addressList.map((m: AddressItem) => m.email)
+ expect(emails).toContain(user1.email)
+ expect(emails).toContain(user2.email)
+ expect(emails).not.toContain(user3.email)
+ })
+
+ it('does not send emails when no members want to receive them', async () => {
+ const { workspace } = await createWorkspace()
+
+ // Ensure creator doesn't want emails
+ await database
+ .update(memberships)
+ .set({ wantToReceiveWeeklyEmail: false })
+ .where(eq(memberships.workspaceId, workspace.id))
+
+ const job = {
+ data: { workspaceId: workspace.id },
+ } as Job
+
+ await sendWeeklyEmailJob(job)
+
+ expect(mockSendInBatches).not.toHaveBeenCalled()
+ expect(mockGetLogsData).not.toHaveBeenCalled()
+ expect(mockGetIssuesData).not.toHaveBeenCalled()
+ expect(mockGetAnnotationsData).not.toHaveBeenCalled()
+ })
+
+ it('sends emails to all members when all want to receive them', async () => {
+ const { workspace, userData: creator } = await createWorkspace()
+ const user1 = await createUser()
+ const user2 = await createUser()
+
+ await createMembership({ user: user1, workspace, author: creator })
+ await createMembership({ user: user2, workspace, author: creator })
+
+ // Set all to want emails
+ await database
+ .update(memberships)
+ .set({ wantToReceiveWeeklyEmail: true })
+ .where(eq(memberships.workspaceId, workspace.id))
+
+ const job = {
+ data: { workspaceId: workspace.id },
+ } as Job
+
+ await sendWeeklyEmailJob(job)
+
+ expect(mockSendInBatches).toHaveBeenCalledTimes(1)
+ const callArgs = mockSendInBatches.mock.calls[0][0]
+ expect(callArgs.addressList).toHaveLength(3) // creator + 2 users
+ })
+ })
+
+ describe('data fetching', () => {
+ it('fetches logs, issues, and annotations data in parallel', async () => {
+ const { workspace } = await createWorkspace()
+ await database
+ .update(memberships)
+ .set({ wantToReceiveWeeklyEmail: true })
+ .where(eq(memberships.workspaceId, workspace.id))
+
+ const job = {
+ data: { workspaceId: workspace.id },
+ } as Job
+
+ await sendWeeklyEmailJob(job)
+
+ expect(mockGetLogsData).toHaveBeenCalledWith({ workspace })
+ expect(mockGetIssuesData).toHaveBeenCalledWith({ workspace })
+ expect(mockGetAnnotationsData).toHaveBeenCalledWith({ workspace })
+ })
+
+ it('passes fetched data to mailer', async () => {
+ const { workspace } = await createWorkspace()
+ await database
+ .update(memberships)
+ .set({ wantToReceiveWeeklyEmail: true })
+ .where(eq(memberships.workspaceId, workspace.id))
+
+ const job = {
+ data: { workspaceId: workspace.id },
+ } as Job
+
+ await sendWeeklyEmailJob(job)
+
+ expect(mockSendInBatches).toHaveBeenCalledTimes(1)
+ const callArgs = mockSendInBatches.mock.calls[0][0]
+
+ expect(callArgs.context).toEqual({
+ mailName: 'weekly_email',
+ workspaceId: workspace.id,
+ workspaceName: workspace.name,
+ })
+ })
+ })
+
+ describe('mailer integration', () => {
+ it('calls sendInBatches with correct parameters', async () => {
+ const { workspace } = await createWorkspace()
+ await database
+ .update(memberships)
+ .set({ wantToReceiveWeeklyEmail: true })
+ .where(eq(memberships.workspaceId, workspace.id))
+
+ const job = {
+ data: { workspaceId: workspace.id },
+ } as Job
+
+ await sendWeeklyEmailJob(job)
+
+ expect(mockSendInBatches).toHaveBeenCalledTimes(1)
+ const callArgs = mockSendInBatches.mock.calls[0][0]
+
+ expect(callArgs.addressList).toBeDefined()
+ expect(callArgs.sendOptions).toBeInstanceOf(Function)
+ expect(callArgs.context).toEqual({
+ mailName: 'weekly_email',
+ workspaceId: workspace.id,
+ workspaceName: workspace.name,
+ })
+ expect(callArgs.batchSize).toBe(100)
+ })
+
+ it('passes correct batch size', async () => {
+ const { workspace } = await createWorkspace()
+ await database
+ .update(memberships)
+ .set({ wantToReceiveWeeklyEmail: true })
+ .where(eq(memberships.workspaceId, workspace.id))
+
+ const job = {
+ data: { workspaceId: workspace.id },
+ } as Job
+
+ await sendWeeklyEmailJob(job)
+
+ const callArgs = mockSendInBatches.mock.calls[0][0]
+ expect(callArgs.batchSize).toBe(100)
+ })
+ })
+
+ describe('member data structure', () => {
+ it('includes email, name, userId, and membershipId for each member', async () => {
+ const { workspace, userData: creator } = await createWorkspace()
+ const user1 = await createUser()
+
+ await createMembership({ user: user1, workspace, author: creator })
+
+ await database
+ .update(memberships)
+ .set({ wantToReceiveWeeklyEmail: true })
+ .where(eq(memberships.workspaceId, workspace.id))
+
+ const job = {
+ data: { workspaceId: workspace.id },
+ } as Job
+
+ await sendWeeklyEmailJob(job)
+
+ const callArgs = mockSendInBatches.mock.calls[0][0]
+ const member = callArgs.addressList[0]
+
+ expect(member).toHaveProperty('email')
+ expect(member).toHaveProperty('name')
+ expect(member).toHaveProperty('userId')
+ expect(member).toHaveProperty('membershipId')
+ })
+ })
+})
diff --git a/packages/core/src/jobs/job-definitions/weeklyEmail/sendWeeklyEmailJob.ts b/packages/core/src/jobs/job-definitions/weeklyEmail/sendWeeklyEmailJob.ts
new file mode 100644
index 0000000000..849364ce7d
--- /dev/null
+++ b/packages/core/src/jobs/job-definitions/weeklyEmail/sendWeeklyEmailJob.ts
@@ -0,0 +1,102 @@
+import { Job } from 'bullmq'
+import { and, asc, eq } from 'drizzle-orm'
+import { database } from '../../../client'
+import { getLogsData } from '../../../data-access/weeklyEmail/logs'
+import { getIssuesData } from '../../../data-access/weeklyEmail/issues'
+import { getAnnotationsData } from '../../../data-access/weeklyEmail/annotations'
+import { unsafelyFindWorkspace } from '../../../data-access/workspaces'
+import { NotFoundError } from '../../../lib/errors'
+import { WeeklyEmailMailer } from '../../../mailer/mailers/weeklyEmail/WeeklyEmailMailer'
+import { memberships } from '../../../schema/models/memberships'
+import { users } from '../../../schema/models/users'
+import { Workspace } from '../../../schema/models/types/Workspace'
+
+export type SendWeeklyEmailJobData = {
+ workspaceId: number
+ emails?: string[]
+}
+
+const BATCH_SIZE = 100 // Batch size can be up to 1000 in mailgun
+
+async function findNotificableMembers(workspace: Workspace) {
+ return database
+ .select({
+ email: users.email,
+ name: users.name,
+ userId: users.id,
+ membershipId: memberships.id,
+ })
+ .from(users)
+ .innerJoin(memberships, eq(users.id, memberships.userId))
+ .where(
+ and(
+ eq(memberships.workspaceId, workspace.id),
+ eq(memberships.wantToReceiveWeeklyEmail, true),
+ ),
+ )
+ .orderBy(asc(users.createdAt))
+}
+
+async function getAddressListMembers({
+ workspace,
+ emails,
+}: {
+ workspace: Workspace
+ emails?: string[]
+}) {
+ if (emails && emails.length > 0) {
+ return emails.map((email) => ({
+ email,
+ name: email,
+ }))
+ }
+
+ return findNotificableMembers(workspace)
+}
+
+/**
+ * Job that sends weekly email report to workspace members.
+ *
+ * This job:
+ * 1. Fetches workspace data (logs, issues, annotations)
+ * 2. Finds members who want to receive weekly emails
+ * 3. Sends emails in batches using the WeeklyEmailMailer
+ */
+export async function sendWeeklyEmailJob(job: Job) {
+ const { workspaceId, emails } = job.data
+ const workspace = await unsafelyFindWorkspace(workspaceId)
+
+ if (!workspace) {
+ throw new NotFoundError('Workspace not found sending weekly email')
+ }
+
+ const members = await getAddressListMembers({ workspace, emails })
+
+ if (members.length === 0) return
+
+ const [logs, issues, annotations] = await Promise.all([
+ getLogsData({ workspace }),
+ getIssuesData({ workspace }),
+ getAnnotationsData({ workspace }),
+ ])
+
+ const mailer = new WeeklyEmailMailer()
+ await mailer.sendInBatches({
+ addressList: members,
+ sendOptions: async (batch) =>
+ mailer.send({
+ to: batch.to,
+ recipientVariables: batch.recipientVariables,
+ currentWorkspace: workspace,
+ logs,
+ issues,
+ annotations,
+ }),
+ context: {
+ mailName: 'weekly_email',
+ workspaceId: workspace.id,
+ workspaceName: workspace.name,
+ },
+ batchSize: BATCH_SIZE,
+ })
+}
diff --git a/packages/core/src/mailers/mailers/Mailer.ts b/packages/core/src/mailer/Mailer.ts
similarity index 68%
rename from packages/core/src/mailers/mailers/Mailer.ts
rename to packages/core/src/mailer/Mailer.ts
index 02d1457f8b..44116a10a5 100644
--- a/packages/core/src/mailers/mailers/Mailer.ts
+++ b/packages/core/src/mailer/Mailer.ts
@@ -3,8 +3,14 @@ import { SentMessageInfo, Transporter } from 'nodemailer'
import Mail, { Address } from 'nodemailer/lib/mailer'
import SMTPTransport from 'nodemailer/lib/smtp-transport'
-import { Result, TypedResult } from '../../lib/Result'
+import { Result, TypedResult } from '../lib/Result'
+import { captureException } from '../utils/datadogCapture'
import { createAdapter } from './adapters'
+import {
+ AddressItem,
+ buildBatchRecipients,
+ RecipientBatch,
+} from './buildBatchRecipients'
/**
* Batch sending options for Mailgun.
@@ -34,7 +40,7 @@ export default abstract class Mailer {
return `Latitude <${env.FROM_MAILER_EMAIL}>`
}
- constructor(options: Mail.Options, adapter = createAdapter()) {
+ constructor(options: Mail.Options = {}, adapter = createAdapter()) {
this.options = options
this.adapter = adapter
}
@@ -106,4 +112,46 @@ export default abstract class Mailer {
return typeof address === 'string' ? address : address.address
}
+
+ /**
+ * Send emails in batches to multiple recipients.
+ * Mailgun supports up to 1000 recipients per batch.
+ *
+ * @param addresses - Array of recipient objects with userId, email and name
+ * @param sendOptions - Function that returns the mail options for each batch
+ * @param context - Context for error tracking (mailName and additional metadata)
+ * @param batchSize - Number of recipients per batch (default: 100)
+ * @returns Array of results for each batch
+ */
+ async sendInBatches({
+ addressList,
+ sendOptions,
+ context,
+ batchSize = 100,
+ }: {
+ addressList: AddressItem[]
+ sendOptions: (
+ batch: RecipientBatch,
+ ) => Promise>
+ context: {
+ mailName: string
+ [key: string]: unknown
+ }
+ batchSize?: number
+ }) {
+ const batches = buildBatchRecipients({ addressList, batchSize })
+ const results = await Promise.all(
+ batches.map((batch, _i) => sendOptions(batch)),
+ )
+ return results.map((result, index) => {
+ const batchSize = batches[index]!.to.length
+ if (Result.isOk(result)) return
+
+ captureException(result.error, {
+ ...context,
+ batchIndex: index,
+ batchSize,
+ })
+ })
+ }
}
diff --git a/packages/core/src/mailers/mailers/adapters/index.ts b/packages/core/src/mailer/adapters/index.ts
similarity index 100%
rename from packages/core/src/mailers/mailers/adapters/index.ts
rename to packages/core/src/mailer/adapters/index.ts
diff --git a/packages/core/src/mailers/mailers/adapters/mailgun.ts b/packages/core/src/mailer/adapters/mailgun.ts
similarity index 100%
rename from packages/core/src/mailers/mailers/adapters/mailgun.ts
rename to packages/core/src/mailer/adapters/mailgun.ts
diff --git a/packages/core/src/mailers/mailers/adapters/mailpit.ts b/packages/core/src/mailer/adapters/mailpit.ts
similarity index 100%
rename from packages/core/src/mailers/mailers/adapters/mailpit.ts
rename to packages/core/src/mailer/adapters/mailpit.ts
diff --git a/packages/core/src/mailers/mailers/adapters/smtp.ts b/packages/core/src/mailer/adapters/smtp.ts
similarity index 100%
rename from packages/core/src/mailers/mailers/adapters/smtp.ts
rename to packages/core/src/mailer/adapters/smtp.ts
diff --git a/packages/core/src/mailer/buildBatchRecipients.ts b/packages/core/src/mailer/buildBatchRecipients.ts
new file mode 100644
index 0000000000..8c565743fa
--- /dev/null
+++ b/packages/core/src/mailer/buildBatchRecipients.ts
@@ -0,0 +1,39 @@
+export type AddressItem = {
+ email: string
+ name?: string | null
+ userId?: string | number
+ membershipId?: string | number
+}
+
+type BatchRecipient = {
+ to: string[]
+ recipientVariables: Record
+}
+
+export function buildBatchRecipients({
+ addressList,
+ batchSize,
+}: {
+ addressList: AddressItem[]
+ batchSize: number
+}) {
+ const batches: BatchRecipient[] = []
+ for (let i = 0; i < addressList.length; i += batchSize) {
+ const batchAddresses = addressList.slice(i, i + batchSize)
+ const recipientVariables: Record = {}
+
+ batchAddresses.forEach((batch) => {
+ const email = batch.email
+ recipientVariables[email] = { ...batch, name: batch.name ?? email }
+ })
+
+ batches.push({
+ to: batchAddresses.map((b) => b.email),
+ recipientVariables,
+ })
+ }
+
+ return batches
+}
+
+export type RecipientBatch = ReturnType[number]
diff --git a/packages/core/src/mailers/mailers/mailers/datasets/DatasetUpdateMailer.ts b/packages/core/src/mailer/mailers/datasets/DatasetUpdateMailer.ts
similarity index 81%
rename from packages/core/src/mailers/mailers/mailers/datasets/DatasetUpdateMailer.ts
rename to packages/core/src/mailer/mailers/datasets/DatasetUpdateMailer.ts
index fefbff0879..76e03f906b 100644
--- a/packages/core/src/mailers/mailers/mailers/datasets/DatasetUpdateMailer.ts
+++ b/packages/core/src/mailer/mailers/datasets/DatasetUpdateMailer.ts
@@ -3,10 +3,10 @@ import DatasetUpdateMail from '@latitude-data/emails/DatasetUpdateMail'
import Mail from 'nodemailer/lib/mailer'
import SMTPTransport from 'nodemailer/lib/smtp-transport'
-import { TypedResult } from '../../../../lib/Result'
+import { TypedResult } from '../../../lib/Result'
import Mailer from '../../Mailer'
-import { type Dataset } from '../../../../schema/models/types/Dataset'
-import { type User } from '../../../../schema/models/types/User'
+import { type Dataset } from '../../../schema/models/types/Dataset'
+import { type User } from '../../../schema/models/types/User'
export class DatasetUpdateMailer extends Mailer {
dataset: Dataset
diff --git a/packages/core/src/mailers/mailers/mailers/documentEmailTrigger/DocumentTriggerMailer.ts b/packages/core/src/mailer/mailers/documentEmailTrigger/DocumentTriggerMailer.ts
similarity index 94%
rename from packages/core/src/mailers/mailers/mailers/documentEmailTrigger/DocumentTriggerMailer.ts
rename to packages/core/src/mailer/mailers/documentEmailTrigger/DocumentTriggerMailer.ts
index 86dc92cdbf..9a3273bce5 100644
--- a/packages/core/src/mailers/mailers/mailers/documentEmailTrigger/DocumentTriggerMailer.ts
+++ b/packages/core/src/mailer/mailers/documentEmailTrigger/DocumentTriggerMailer.ts
@@ -2,7 +2,7 @@ import { render } from '@latitude-data/emails/render'
import Mail from 'nodemailer/lib/mailer'
import SMTPTransport from 'nodemailer/lib/smtp-transport'
-import { TypedResult } from '../../../../lib/Result'
+import { TypedResult } from '../../../lib/Result'
import Mailer from '../../Mailer'
import type { AssistantMessage } from '@latitude-data/constants/legacyCompiler'
import DocumentTriggerResponseMail from '@latitude-data/emails/DocumentTriggerResponseMail'
diff --git a/packages/core/src/mailers/mailers/mailers/exports/ExportReadyMailer.ts b/packages/core/src/mailer/mailers/exports/ExportReadyMailer.ts
similarity index 86%
rename from packages/core/src/mailers/mailers/mailers/exports/ExportReadyMailer.ts
rename to packages/core/src/mailer/mailers/exports/ExportReadyMailer.ts
index 935d9a5d41..907b3af575 100644
--- a/packages/core/src/mailers/mailers/mailers/exports/ExportReadyMailer.ts
+++ b/packages/core/src/mailer/mailers/exports/ExportReadyMailer.ts
@@ -2,9 +2,9 @@ import { render } from '@latitude-data/emails/render'
import Mail from 'nodemailer/lib/mailer'
import SMTPTransport from 'nodemailer/lib/smtp-transport'
-import { TypedResult } from '../../../../lib/Result'
+import { TypedResult } from '../../../lib/Result'
import Mailer from '../../Mailer'
-import { type User } from '../../../../schema/models/types/User'
+import { type User } from '../../../schema/models/types/User'
import ExportReadyMail from '@latitude-data/emails/ExportReadyMail'
export class ExportReadyMailer extends Mailer {
diff --git a/packages/core/src/mailers/mailers/mailers/invitations/InvitationMailer.ts b/packages/core/src/mailer/mailers/invitations/InvitationMailer.ts
similarity index 90%
rename from packages/core/src/mailers/mailers/mailers/invitations/InvitationMailer.ts
rename to packages/core/src/mailer/mailers/invitations/InvitationMailer.ts
index f30adf364e..860c06ca90 100644
--- a/packages/core/src/mailers/mailers/mailers/invitations/InvitationMailer.ts
+++ b/packages/core/src/mailer/mailers/invitations/InvitationMailer.ts
@@ -3,8 +3,8 @@ import InvitationMail from '@latitude-data/emails/InvitationMail'
import Mail from 'nodemailer/lib/mailer'
import SMTPTransport from 'nodemailer/lib/smtp-transport'
-import { type User } from '../../../../schema/models/types/User'
-import { TypedResult } from '../../../../lib/Result'
+import { type User } from '../../../schema/models/types/User'
+import { TypedResult } from '../../../lib/Result'
import Mailer from '../../Mailer'
export class InvitationMailer extends Mailer {
diff --git a/packages/core/src/mailers/mailers/mailers/invitations/ReferralMailer.ts b/packages/core/src/mailer/mailers/invitations/ReferralMailer.ts
similarity index 87%
rename from packages/core/src/mailers/mailers/mailers/invitations/ReferralMailer.ts
rename to packages/core/src/mailer/mailers/invitations/ReferralMailer.ts
index 9fa76d7cb1..125c029b00 100644
--- a/packages/core/src/mailers/mailers/mailers/invitations/ReferralMailer.ts
+++ b/packages/core/src/mailer/mailers/invitations/ReferralMailer.ts
@@ -2,8 +2,8 @@ import { render } from '@latitude-data/emails/render'
import Mail from 'nodemailer/lib/mailer'
import SMTPTransport from 'nodemailer/lib/smtp-transport'
-import { type User } from '../../../../schema/models/types/User'
-import { TypedResult } from '../../../../lib/Result'
+import { type User } from '../../../schema/models/types/User'
+import { TypedResult } from '../../../lib/Result'
import ReferralMail from '@latitude-data/emails/ReferralMail'
import Mailer from '../../Mailer'
diff --git a/packages/core/src/mailers/mailers/mailers/issues/IssueEscalatingMailer.ts b/packages/core/src/mailer/mailers/issues/IssueEscalatingMailer.ts
similarity index 68%
rename from packages/core/src/mailers/mailers/mailers/issues/IssueEscalatingMailer.ts
rename to packages/core/src/mailer/mailers/issues/IssueEscalatingMailer.ts
index ea25cba9c0..d7dcf4f029 100644
--- a/packages/core/src/mailers/mailers/mailers/issues/IssueEscalatingMailer.ts
+++ b/packages/core/src/mailer/mailers/issues/IssueEscalatingMailer.ts
@@ -3,15 +3,16 @@ import { NotificiationsLayoutProps } from '@latitude-data/emails/types'
import Mail from 'nodemailer/lib/mailer'
import SMTPTransport from 'nodemailer/lib/smtp-transport'
-import { TypedResult } from '../../../../lib/Result'
+import { TypedResult } from '../../../lib/Result'
import IssueEscalatingMail, {
IssueEscalatingMailProps,
} from '@latitude-data/emails/IssueEscalatingMail'
import Mailer from '../../Mailer'
+import { RecipientBatch } from '../../buildBatchRecipients'
export type SendIssueEscalatingMailOptions = {
to: Mail.Options['to']
- recipientVariables?: Record>
+ recipientVariables: RecipientBatch['recipientVariables']
currentWorkspace: NotificiationsLayoutProps['currentWorkspace']
issue: IssueEscalatingMailProps['issue']
}
@@ -38,28 +39,19 @@ export class IssueEscalatingMailer extends Mailer {
}: SendIssueEscalatingMailOptions): Promise<
TypedResult
> {
- try {
- const html = await render(
+ return this.sendMail({
+ to,
+ from: this.options.from,
+ subject: `📈 Latitude issue Escalating: ${this.issueTitle}`,
+ 'recipient-variables': recipientVariables,
+ html: await render(
IssueEscalatingMail({
issueTitle: this.issueTitle,
link: this.link,
currentWorkspace,
issue,
}),
- )
-
- const result = await this.sendMail({
- to,
- from: this.options.from,
- subject: `📈 Latitude issue Escalating: ${this.issueTitle}`,
- 'recipient-variables': recipientVariables,
- html,
- })
-
- return result
- } catch (error) {
- console.error('[IssueEscalatingMailer] Error during send:', error)
- throw error
- }
+ ),
+ })
}
}
diff --git a/packages/core/src/mailers/mailers/mailers/magicLinks/MagicLinkMailer.ts b/packages/core/src/mailer/mailers/magicLinks/MagicLinkMailer.ts
similarity index 95%
rename from packages/core/src/mailers/mailers/mailers/magicLinks/MagicLinkMailer.ts
rename to packages/core/src/mailer/mailers/magicLinks/MagicLinkMailer.ts
index 3c5919af5f..87e3e353c6 100644
--- a/packages/core/src/mailers/mailers/mailers/magicLinks/MagicLinkMailer.ts
+++ b/packages/core/src/mailer/mailers/magicLinks/MagicLinkMailer.ts
@@ -2,7 +2,7 @@ import { render } from '@latitude-data/emails/render'
import Mail from 'nodemailer/lib/mailer'
import SMTPTransport from 'nodemailer/lib/smtp-transport'
-import { TypedResult } from '../../../../lib/Result'
+import { TypedResult } from '../../../lib/Result'
import MagicLinkMail from '@latitude-data/emails/MagicLinkMail'
import Mailer from '../../Mailer'
diff --git a/packages/core/src/mailers/mailers/mailers/suggestions/SuggestionMailer.ts b/packages/core/src/mailer/mailers/suggestions/SuggestionMailer.ts
similarity index 96%
rename from packages/core/src/mailers/mailers/mailers/suggestions/SuggestionMailer.ts
rename to packages/core/src/mailer/mailers/suggestions/SuggestionMailer.ts
index dfd15258fd..80018a61f4 100644
--- a/packages/core/src/mailers/mailers/mailers/suggestions/SuggestionMailer.ts
+++ b/packages/core/src/mailer/mailers/suggestions/SuggestionMailer.ts
@@ -2,7 +2,7 @@ import { render } from '@latitude-data/emails/render'
import Mail from 'nodemailer/lib/mailer'
import SMTPTransport from 'nodemailer/lib/smtp-transport'
-import { TypedResult } from '../../../../lib/Result'
+import { TypedResult } from '../../../lib/Result'
import SuggestionMail from '@latitude-data/emails/SuggestionMail'
import Mailer from '../../Mailer'
diff --git a/packages/core/src/mailer/mailers/weeklyEmail/WeeklyEmailMailer.ts b/packages/core/src/mailer/mailers/weeklyEmail/WeeklyEmailMailer.ts
new file mode 100644
index 0000000000..e60d323ded
--- /dev/null
+++ b/packages/core/src/mailer/mailers/weeklyEmail/WeeklyEmailMailer.ts
@@ -0,0 +1,43 @@
+import { render } from '@latitude-data/emails/render'
+import { NotificiationsLayoutProps } from '@latitude-data/emails/types'
+import Mail from 'nodemailer/lib/mailer'
+import SMTPTransport from 'nodemailer/lib/smtp-transport'
+
+import { TypedResult } from '../../../lib/Result'
+import WeeklyEmailMail from '@latitude-data/emails/WeeklyEmailMail'
+import Mailer from '../../Mailer'
+import { RecipientBatch } from '../../buildBatchRecipients'
+import { WeeklyEmailMailProps } from '@latitude-data/emails/WeeklyEmailMailTypes'
+
+export class WeeklyEmailMailer extends Mailer {
+ async send({
+ to,
+ recipientVariables,
+ currentWorkspace,
+ logs,
+ issues,
+ annotations,
+ }: {
+ to: Mail.Options['to']
+ recipientVariables: RecipientBatch['recipientVariables']
+ currentWorkspace: NotificiationsLayoutProps['currentWorkspace']
+ logs: WeeklyEmailMailProps['logs']
+ issues: WeeklyEmailMailProps['issues']
+ annotations: WeeklyEmailMailProps['annotations']
+ }): Promise> {
+ return this.sendMail({
+ to,
+ from: this.options.from,
+ subject: `📊 Your weekly summary for ${currentWorkspace.name}`,
+ 'recipient-variables': recipientVariables,
+ html: await render(
+ WeeklyEmailMail({
+ currentWorkspace,
+ logs,
+ issues,
+ annotations,
+ }),
+ ),
+ })
+ }
+}
diff --git a/packages/core/src/mailers/index.ts b/packages/core/src/mailers/index.ts
deleted file mode 100644
index fb69c00d86..0000000000
--- a/packages/core/src/mailers/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './mailers/mailers'
diff --git a/packages/core/src/mailers/mailers/mailers/index.ts b/packages/core/src/mailers/mailers/mailers/index.ts
deleted file mode 100644
index 3ad5bb9746..0000000000
--- a/packages/core/src/mailers/mailers/mailers/index.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export * from './invitations/InvitationMailer'
-export * from './invitations/ReferralMailer'
-export * from './magicLinks/MagicLinkMailer'
-export * from './suggestions/SuggestionMailer'
-export * from './documentEmailTrigger/DocumentTriggerMailer'
-export * from './issues/IssueEscalatingMailer'
diff --git a/packages/core/src/services/documentTriggers/handlers/email/sendResponse.ts b/packages/core/src/services/documentTriggers/handlers/email/sendResponse.ts
index 734f767310..136db7e52d 100644
--- a/packages/core/src/services/documentTriggers/handlers/email/sendResponse.ts
+++ b/packages/core/src/services/documentTriggers/handlers/email/sendResponse.ts
@@ -5,7 +5,7 @@ import {
import { type Commit } from '../../../../schema/models/types/Commit'
import { type DocumentTrigger } from '../../../../schema/models/types/DocumentTrigger'
import { type DocumentTriggerEvent } from '../../../../schema/models/types/DocumentTriggerEvent'
-import { DocumentTriggerMailer } from '../../../../mailers'
+import { DocumentTriggerMailer } from '../../../../mailer/mailers/documentEmailTrigger/DocumentTriggerMailer'
import type { AssistantMessage } from '@latitude-data/constants/legacyCompiler'
import { Result, TypedResult } from '../../../../lib/Result'
import { PromisedResult } from '../../../../lib/Transaction'
diff --git a/packages/core/src/services/workspaces/create.ts b/packages/core/src/services/workspaces/create.ts
index 289ef9e347..e1c070582c 100644
--- a/packages/core/src/services/workspaces/create.ts
+++ b/packages/core/src/services/workspaces/create.ts
@@ -16,12 +16,14 @@ export async function createWorkspace(
createdAt,
source = 'default',
subscriptionPlan = SubscriptionPlan.HobbyV3,
+ isBigAccount = false,
}: {
name: string
user: User
source?: string
createdAt?: Date
subscriptionPlan?: SubscriptionPlan
+ isBigAccount?: boolean
},
transaction = new Transaction(),
) {
@@ -29,7 +31,12 @@ export async function createWorkspace(
async (tx) => {
const insertedWorkspaces = await tx
.insert(workspaces)
- .values({ name, creatorId: user.id, createdAt })
+ .values({
+ name,
+ creatorId: user.id,
+ createdAt,
+ isBigAccount,
+ })
.returning()
let workspace = insertedWorkspaces[0]!
diff --git a/packages/core/src/tests/factories/workspaces.ts b/packages/core/src/tests/factories/workspaces.ts
index 214d4fbe20..84e9c8a83d 100644
--- a/packages/core/src/tests/factories/workspaces.ts
+++ b/packages/core/src/tests/factories/workspaces.ts
@@ -17,6 +17,7 @@ export type ICreateWorkspace = {
source?: string
onboarding?: boolean
features?: string[]
+ isBigAccount?: boolean
}
export async function createWorkspace(
workspaceData: Partial = {},
@@ -33,6 +34,7 @@ export async function createWorkspace(
user: userData,
subscriptionPlan: workspaceData.subscriptionPlan,
createdAt: workspaceData.createdAt,
+ isBigAccount: workspaceData.isBigAccount,
})
const workspace = result.unwrap()
await createMembership({ workspace, user: userData }).then((r) => r.unwrap())
diff --git a/packages/emails/package.json b/packages/emails/package.json
index cddbf5470b..09efa77df3 100644
--- a/packages/emails/package.json
+++ b/packages/emails/package.json
@@ -36,6 +36,12 @@
},
"./SuggestionMail": {
"import": "./src/templates/suggestions/SuggestionMail.tsx"
+ },
+ "./WeeklyEmailMail": {
+ "import": "./src/templates/weeklyEmail/WeeklyEmailMail.tsx"
+ },
+ "./WeeklyEmailMailTypes": {
+ "import": "./src/templates/weeklyEmail/types.ts"
}
},
"dependencies": {
diff --git a/packages/emails/src/components/NotificationsFooter/index.tsx b/packages/emails/src/components/NotificationsFooter/index.tsx
index 5810815131..b0db6abfdd 100644
--- a/packages/emails/src/components/NotificationsFooter/index.tsx
+++ b/packages/emails/src/components/NotificationsFooter/index.tsx
@@ -1,21 +1,19 @@
import React from 'react'
-import { env } from '@latitude-data/env'
import { Link, Section } from '@react-email/components'
import { Text } from '../Text'
import { NotificiationsLayoutProps } from '../../types'
+import { EMAIL_ROUTES } from '../../routes'
export default function NotificationsFooter({
currentWorkspace,
}: NotificiationsLayoutProps) {
- const rootUrl = env.APP_URL
- const notificationSettingsUrl = `${rootUrl}/dashboard/notifications/${currentWorkspace.id}`
return (
Don't want to receive these emails?{' '}
-
+
Manage your notifications
diff --git a/packages/emails/src/components/tailwind.email.ts b/packages/emails/src/components/tailwind.email.ts
index a54983ca20..e1869a9ba8 100644
--- a/packages/emails/src/components/tailwind.email.ts
+++ b/packages/emails/src/components/tailwind.email.ts
@@ -23,12 +23,19 @@ export default {
'dark-1': '#0657AE',
'dark-2': '#054387',
},
+ accent: {
+ DEFAULT: '#EFF7FF',
+ },
secondary: {
DEFAULT: '#F9FAFB',
},
destructive: {
DEFAULT: '#DC2626',
},
+ latte: {
+ DEFAULT: '#FEF0D2',
+ foreground: '#fec51b',
+ },
'destructive-muted': {
DEFAULT: '#FEE2E2',
foreground: '#DC2626',
diff --git a/packages/emails/src/routes.ts b/packages/emails/src/routes.ts
index ba4a6b0c27..4e8fe037ca 100644
--- a/packages/emails/src/routes.ts
+++ b/packages/emails/src/routes.ts
@@ -6,3 +6,41 @@ import { env } from '@latitude-data/env'
export function createLink(path: string) {
return `${env.APP_URL}/${path}`
}
+
+export const EMAIL_ROUTES = {
+ notifications: (workspaceId: number) => {
+ const notificationsPath = createLink(
+ `dashboard/notifications/${workspaceId}`,
+ )
+ return {
+ root: notificationsPath,
+ }
+ },
+ projects: {
+ details: (projectId: number) => {
+ const projectPath = createLink(`projects/${projectId}`)
+ return {
+ root: projectPath,
+ commits: {
+ details: (commitUuid: string = 'live') => {
+ const commitPath = `${projectPath}/versions/${commitUuid}`
+ const issuesPath = `${commitPath}/issues`
+ const annotationsPath = `${commitPath}/annotations`
+ return {
+ root: commitPath,
+ issues: {
+ root: issuesPath,
+ details: (issueId: number) => {
+ return `${issuesPath}?issueId=${issueId}`
+ },
+ },
+ annotations: {
+ root: annotationsPath,
+ },
+ }
+ },
+ },
+ }
+ },
+ },
+}
diff --git a/packages/emails/src/templates/issues/IssueEscalatingMail.tsx b/packages/emails/src/templates/issues/IssueEscalatingMail.tsx
index 74170559b2..677d5fd293 100644
--- a/packages/emails/src/templates/issues/IssueEscalatingMail.tsx
+++ b/packages/emails/src/templates/issues/IssueEscalatingMail.tsx
@@ -179,7 +179,7 @@ const EXAMPLE_ISSUE_TITLE = 'Incorrect JSON formatting'
IssueEscalatingMail.PreviewProps = {
issueTitle: EXAMPLE_ISSUE_TITLE,
link: 'https://example.com',
- currentWorkspace: { id: 1 },
+ currentWorkspace: { id: 1, name: 'Acme Corp' },
issue: {
title: EXAMPLE_ISSUE_TITLE,
eventsCount: 60,
diff --git a/packages/emails/src/templates/weeklyEmail/WeeklyEmailMail.tsx b/packages/emails/src/templates/weeklyEmail/WeeklyEmailMail.tsx
new file mode 100644
index 0000000000..a037e14fd7
--- /dev/null
+++ b/packages/emails/src/templates/weeklyEmail/WeeklyEmailMail.tsx
@@ -0,0 +1,467 @@
+import React from 'react'
+
+import { Column, Link, Row, Section } from '@react-email/components'
+import { formatCount } from '@latitude-data/constants/formatCount'
+import ContainerLayout from '../../components/ContainerLayout'
+import NotificationsFooter from '../../components/NotificationsFooter'
+import { Text } from '../../components/Text'
+import { Button } from '../../components/Button'
+import {
+ // fullActivityProfile,
+ // logsOnlyProfile,
+ // someActivityProfile,
+ // issuesWithoutNewProfile,
+ zeroActivityProfile,
+ // highVolumeProfile,
+} from './previewData'
+import {
+ WeeklyEmailMailProps,
+ LogStats,
+ IssueStats,
+ AnnotationStats,
+} from './types'
+import { EMAIL_ROUTES } from '../../routes'
+import { cn } from '@latitude-data/web-ui/utils'
+
+const INTEGRATION_DOCS_URL =
+ 'https://docs.latitude.so/guides/integration/overview'
+
+const ISSUES_VIDEO_URL = 'https://www.youtube.com/watch?v=trOwCWaIAZk'
+
+function BlankSlateCard({
+ title,
+ description,
+ link,
+ type,
+}: {
+ type: 'primary' | 'secondary'
+ title: string
+ description: string
+ link: { label: string; href: string; imgSrc?: string }
+}) {
+ return (
+
+ )
+}
+function StatCard({
+ title,
+ value,
+ subtitle,
+ showSubtitleSpace = false,
+}: {
+ title: string
+ value: string | number
+ subtitle?: string
+ showSubtitleSpace?: boolean
+}) {
+ return (
+
+
+ {value}
+ {showSubtitleSpace && (
+
+
+ {subtitle || '\u00A0'}
+
+
+ )}
+
+ )
+}
+
+function LogsSection({ logs }: { logs: LogStats }) {
+ const noLogs = !logs.usedInProduction || logs.logsCount === 0
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {logs.topProjects.length > 0 && (
+
+
+
+ Top Projects
+
+
+ Traces
+
+
+ Token Cost
+
+
+ {logs.topProjects.slice(0, 5).map((project, index, array) => (
+
+
+
+
+ {project.projectName}
+
+
+
+
+
+ {formatCount(project.logsCount)}
+
+
+
+ {`$${formatCount(project.tokensCost)}`}
+
+
+ ))}
+
+ )}
+ {noLogs ? (
+
+ ) : null}
+
+ )
+}
+
+function IssuesSection({ issues }: { issues: IssueStats }) {
+ const hasNewIssues = issues.newIssuesCount > 0
+ const hasProjects = issues.topProjects.length > 0 && !hasNewIssues
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+ ? `${issues.regressedIssuesCount} regressed`
+ : undefined
+ }
+ />
+
+
+
+
+
+ {!issues.hasIssues ? (
+
+ ) : null}
+ {hasProjects ? (
+
+
+
+ Top Projects
+
+
+ Issues
+
+
+ New
+
+
+ {issues.topProjects.slice(0, 5).map((project, index, array) => (
+
+
+
+
+ {project.projectName}
+
+
+
+
+
+ {project.issuesCount.toLocaleString()}
+
+
+
+
+ {project.newIssuesCount}
+
+
+
+ ))}
+
+ ) : null}
+
+ {hasNewIssues ? (
+
+
+
+ New Issues This Week
+
+
+ {issues.newIssuesList.map((issue, index) => (
+
+
+
+
+ {issue.title}
+
+
+
+
+ ))}
+
+ ) : null}
+
+ )
+}
+
+function AnnotationsSection({ annotations }: { annotations: AnnotationStats }) {
+ const blankSlateUrl = annotations.firstProjectId
+ ? EMAIL_ROUTES.projects
+ .details(annotations.firstProjectId)
+ .commits.details().annotations.root
+ : ISSUES_VIDEO_URL
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!annotations.hasAnnotations ? (
+
+ ) : null}
+ {annotations.topProjects.length > 0 && (
+
+
+
+ Top Projects
+
+
+ Total
+
+
+ Pass Rate
+
+
+ {annotations.topProjects.slice(0, 5).map((project, index, array) => (
+
+
+
+
+ {project.projectName}
+
+
+
+
+
+ {formatCount(project.annotationsCount)}
+
+
+
+
+ {`${project.passedPercentage.toFixed(0)}% passed`}
+
+
+
+ ))}
+
+ )}
+
+ )
+}
+
+export default function WeeklyEmailMail({
+ currentWorkspace,
+ logs,
+ issues,
+ annotations,
+}: WeeklyEmailMailProps) {
+ return (
+ }
+ >
+
+ Weekly Summary
+
+
+ {currentWorkspace.name}
+
+
+
+
+
+
+
+
+ )
+}
+
+// cd packages/emails && pnpm email:dev for checking the UI
+// To swap between different profiles, comment/uncomment the desired profile below:
+
+// WeeklyEmailMail.PreviewProps = fullActivityProfile
+// WeeklyEmailMail.PreviewProps = issuesWithoutNewProfile
+// WeeklyEmailMail.PreviewProps = logsOnlyProfile
+// WeeklyEmailMail.PreviewProps = someActivityProfile
+WeeklyEmailMail.PreviewProps = zeroActivityProfile
+// WeeklyEmailMail.PreviewProps = highVolumeProfile
diff --git a/packages/emails/src/templates/weeklyEmail/previewData.ts b/packages/emails/src/templates/weeklyEmail/previewData.ts
new file mode 100644
index 0000000000..38545da599
--- /dev/null
+++ b/packages/emails/src/templates/weeklyEmail/previewData.ts
@@ -0,0 +1,550 @@
+import { WeeklyEmailMailProps } from './types'
+
+// Profile 1: Workspace with full activity (logs, issues, and annotations)
+export const fullActivityProfile: WeeklyEmailMailProps = {
+ currentWorkspace: { id: 1, name: 'Acme Corp Production' },
+ logs: {
+ usedInProduction: true,
+ logsCount: 15420,
+ tokensSpent: 2847392,
+ tokensCost: 45.67,
+ topProjects: [
+ {
+ projectId: 1,
+ projectName: 'Customer Support Bot',
+ logsCount: 8234,
+ tokensSpent: 1523847,
+ tokensCost: 24.38,
+ },
+ {
+ projectId: 2,
+ projectName: 'Content Generator',
+ logsCount: 4521,
+ tokensSpent: 892341,
+ tokensCost: 14.28,
+ },
+ {
+ projectId: 3,
+ projectName: 'Data Analyzer',
+ logsCount: 2665,
+ tokensSpent: 431204,
+ tokensCost: 7.01,
+ },
+ ],
+ },
+ issues: {
+ hasIssues: true,
+ issuesCount: 12,
+ newIssuesCount: 5,
+ escalatedIssuesCount: 2,
+ resolvedIssuesCount: 8,
+ ignoredIssuesCount: 1,
+ regressedIssuesCount: 1,
+ topProjects: [
+ {
+ projectId: 1,
+ projectName: 'Customer Support Bot',
+ issuesCount: 7,
+ newIssuesCount: 3,
+ escalatedIssuesCount: 2,
+ resolvedIssuesCount: 5,
+ ignoredIssuesCount: 0,
+ regressedIssuesCount: 1,
+ },
+ {
+ projectId: 2,
+ projectName: 'Content Generator',
+ issuesCount: 5,
+ newIssuesCount: 2,
+ escalatedIssuesCount: 0,
+ resolvedIssuesCount: 3,
+ ignoredIssuesCount: 1,
+ regressedIssuesCount: 0,
+ },
+ ],
+ newIssuesList: [
+ {
+ id: 101,
+ title: 'Incorrect JSON formatting in API responses',
+ projectId: 1,
+ commitUuid: 'abc123def456',
+ },
+ {
+ id: 102,
+ title: 'Timeout errors when processing large datasets',
+ projectId: 1,
+ commitUuid: 'abc123def456',
+ },
+ {
+ id: 103,
+ title: 'Memory leak in content generation pipeline',
+ projectId: 2,
+ commitUuid: 'xyz789uvw012',
+ },
+ {
+ id: 104,
+ title: 'Rate limiting not working correctly',
+ projectId: 1,
+ commitUuid: 'abc123def456',
+ },
+ {
+ id: 105,
+ title: 'Database connection pool exhaustion',
+ projectId: 2,
+ commitUuid: 'xyz789uvw012',
+ },
+ ],
+ },
+ annotations: {
+ hasAnnotations: true,
+ annotationsCount: 247,
+ passedCount: 198,
+ failedCount: 49,
+ passedPercentage: 80.16,
+ failedPercentage: 19.84,
+ firstProjectId: 1,
+ topProjects: [
+ {
+ projectId: 1,
+ projectName: 'Customer Support Bot',
+ annotationsCount: 156,
+ passedCount: 132,
+ failedCount: 24,
+ passedPercentage: 84.62,
+ failedPercentage: 15.38,
+ },
+ {
+ projectId: 2,
+ projectName: 'Content Generator',
+ annotationsCount: 91,
+ passedCount: 66,
+ failedCount: 25,
+ passedPercentage: 72.53,
+ failedPercentage: 27.47,
+ },
+ ],
+ },
+}
+
+// Profile 2: Workspace with only logs activity
+export const logsOnlyProfile: WeeklyEmailMailProps = {
+ currentWorkspace: { id: 2, name: 'Acme Corp Production' },
+ logs: {
+ usedInProduction: true,
+ logsCount: 3421,
+ tokensSpent: 542183,
+ tokensCost: 8.67,
+ topProjects: [
+ {
+ projectId: 10,
+ projectName: 'Main Application',
+ logsCount: 2134,
+ tokensSpent: 338492,
+ tokensCost: 5.42,
+ },
+ {
+ projectId: 11,
+ projectName: 'API Gateway',
+ logsCount: 1287,
+ tokensSpent: 203691,
+ tokensCost: 3.25,
+ },
+ ],
+ },
+ issues: {
+ hasIssues: false,
+ issuesCount: 0,
+ newIssuesCount: 0,
+ escalatedIssuesCount: 0,
+ resolvedIssuesCount: 0,
+ ignoredIssuesCount: 0,
+ regressedIssuesCount: 0,
+ topProjects: [],
+ newIssuesList: [],
+ },
+ annotations: {
+ hasAnnotations: false,
+ annotationsCount: 0,
+ passedCount: 0,
+ failedCount: 0,
+ passedPercentage: 0,
+ failedPercentage: 0,
+ topProjects: [],
+ firstProjectId: 1,
+ },
+}
+
+// Profile 3: Workspace with some activity (logs and issues, no annotations)
+export const someActivityProfile: WeeklyEmailMailProps = {
+ currentWorkspace: { id: 3, name: 'Acme Corp Production' },
+ logs: {
+ usedInProduction: true,
+ logsCount: 7823,
+ tokensSpent: 1234567,
+ tokensCost: 19.75,
+ topProjects: [
+ {
+ projectId: 20,
+ projectName: 'E-commerce Assistant',
+ logsCount: 4521,
+ tokensSpent: 723456,
+ tokensCost: 11.57,
+ },
+ {
+ projectId: 21,
+ projectName: 'Recommendation Engine',
+ logsCount: 3302,
+ tokensSpent: 511111,
+ tokensCost: 8.18,
+ },
+ ],
+ },
+ issues: {
+ hasIssues: true,
+ issuesCount: 4,
+ newIssuesCount: 2,
+ escalatedIssuesCount: 0,
+ resolvedIssuesCount: 3,
+ ignoredIssuesCount: 0,
+ regressedIssuesCount: 0,
+ topProjects: [
+ {
+ projectId: 20,
+ projectName: 'E-commerce Assistant',
+ issuesCount: 3,
+ newIssuesCount: 2,
+ escalatedIssuesCount: 0,
+ resolvedIssuesCount: 2,
+ ignoredIssuesCount: 0,
+ regressedIssuesCount: 0,
+ },
+ {
+ projectId: 21,
+ projectName: 'Recommendation Engine',
+ issuesCount: 1,
+ newIssuesCount: 0,
+ escalatedIssuesCount: 0,
+ resolvedIssuesCount: 1,
+ ignoredIssuesCount: 0,
+ regressedIssuesCount: 0,
+ },
+ ],
+ newIssuesList: [
+ {
+ id: 201,
+ title: 'Product recommendation accuracy degraded',
+ projectId: 20,
+ commitUuid: 'ecom456abc789',
+ },
+ {
+ id: 202,
+ title: 'Cart abandonment prediction failing',
+ projectId: 20,
+ commitUuid: 'ecom456abc789',
+ },
+ ],
+ },
+ annotations: {
+ hasAnnotations: false,
+ annotationsCount: 0,
+ passedCount: 0,
+ failedCount: 0,
+ passedPercentage: 0,
+ failedPercentage: 0,
+ topProjects: [],
+ firstProjectId: 1,
+ },
+}
+
+// Profile 4: Workspace with zero activity
+export const zeroActivityProfile: WeeklyEmailMailProps = {
+ currentWorkspace: { id: 4, name: 'Acme Corp Production' },
+ logs: {
+ usedInProduction: false,
+ logsCount: 0,
+ tokensSpent: 0,
+ tokensCost: 0,
+ topProjects: [],
+ },
+ issues: {
+ hasIssues: false,
+ issuesCount: 0,
+ newIssuesCount: 0,
+ escalatedIssuesCount: 0,
+ resolvedIssuesCount: 0,
+ ignoredIssuesCount: 0,
+ regressedIssuesCount: 0,
+ topProjects: [],
+ newIssuesList: [],
+ },
+ annotations: {
+ hasAnnotations: false,
+ annotationsCount: 0,
+ passedCount: 0,
+ failedCount: 0,
+ passedPercentage: 0,
+ failedPercentage: 0,
+ topProjects: [],
+ firstProjectId: 1,
+ },
+}
+
+// Profile 5: Workspace with issues but no new issues (resolved/regressed only)
+export const issuesWithoutNewProfile: WeeklyEmailMailProps = {
+ currentWorkspace: { id: 5, name: 'Acme Corp Production' },
+ logs: {
+ usedInProduction: true,
+ logsCount: 5234,
+ tokensSpent: 892341,
+ tokensCost: 14.28,
+ topProjects: [
+ {
+ projectId: 40,
+ projectName: 'Stable Production App',
+ logsCount: 3421,
+ tokensSpent: 583421,
+ tokensCost: 9.33,
+ },
+ {
+ projectId: 41,
+ projectName: 'Monitoring Service',
+ logsCount: 1813,
+ tokensSpent: 308920,
+ tokensCost: 4.95,
+ },
+ ],
+ },
+ issues: {
+ hasIssues: true,
+ issuesCount: 8,
+ newIssuesCount: 0, // No new issues
+ escalatedIssuesCount: 4, // No escalated issues
+ resolvedIssuesCount: 6,
+ ignoredIssuesCount: 2,
+ regressedIssuesCount: 1, // Has regressed issues
+ topProjects: [
+ {
+ projectId: 40,
+ projectName: 'Stable Production App',
+ issuesCount: 5,
+ newIssuesCount: 0,
+ escalatedIssuesCount: 0,
+ resolvedIssuesCount: 4,
+ ignoredIssuesCount: 1,
+ regressedIssuesCount: 1,
+ },
+ {
+ projectId: 41,
+ projectName: 'Monitoring Service',
+ issuesCount: 3,
+ newIssuesCount: 0,
+ escalatedIssuesCount: 0,
+ resolvedIssuesCount: 2,
+ ignoredIssuesCount: 1,
+ regressedIssuesCount: 0,
+ },
+ ],
+ newIssuesList: [], // Empty list
+ },
+ annotations: {
+ hasAnnotations: true,
+ annotationsCount: 423,
+ passedCount: 389,
+ failedCount: 34,
+ passedPercentage: 91.96,
+ failedPercentage: 8.04,
+ firstProjectId: 1,
+ topProjects: [
+ {
+ projectId: 40,
+ projectName: 'Stable Production App',
+ annotationsCount: 312,
+ passedCount: 289,
+ failedCount: 23,
+ passedPercentage: 92.63,
+ failedPercentage: 7.37,
+ },
+ {
+ projectId: 41,
+ projectName: 'Monitoring Service',
+ annotationsCount: 111,
+ passedCount: 100,
+ failedCount: 11,
+ passedPercentage: 90.09,
+ failedPercentage: 9.91,
+ },
+ ],
+ },
+}
+
+// Profile 6: Workspace with high volume activity
+export const highVolumeProfile: WeeklyEmailMailProps = {
+ currentWorkspace: { id: 5, name: 'Acme Corp Production' },
+ logs: {
+ usedInProduction: true,
+ logsCount: 1245678,
+ tokensSpent: 45678912,
+ tokensCost: 731.26,
+ topProjects: [
+ {
+ projectId: 30,
+ projectName: 'Global Chat Platform',
+ logsCount: 523456,
+ tokensSpent: 19234567,
+ tokensCost: 307.75,
+ },
+ {
+ projectId: 31,
+ projectName: 'AI Assistant API',
+ logsCount: 412345,
+ tokensSpent: 15678901,
+ tokensCost: 250.86,
+ },
+ {
+ projectId: 32,
+ projectName: 'Document Processor',
+ logsCount: 198765,
+ tokensSpent: 7234567,
+ tokensCost: 115.75,
+ },
+ {
+ projectId: 33,
+ projectName: 'Translation Service',
+ logsCount: 111112,
+ tokensSpent: 3530877,
+ tokensCost: 56.49,
+ },
+ ],
+ },
+ issues: {
+ hasIssues: true,
+ issuesCount: 28,
+ newIssuesCount: 12,
+ escalatedIssuesCount: 5,
+ resolvedIssuesCount: 18,
+ ignoredIssuesCount: 3,
+ regressedIssuesCount: 2,
+ topProjects: [
+ {
+ projectId: 30,
+ projectName: 'Global Chat Platform',
+ issuesCount: 15,
+ newIssuesCount: 7,
+ escalatedIssuesCount: 3,
+ resolvedIssuesCount: 10,
+ ignoredIssuesCount: 2,
+ regressedIssuesCount: 1,
+ },
+ {
+ projectId: 31,
+ projectName: 'AI Assistant API',
+ issuesCount: 13,
+ newIssuesCount: 5,
+ escalatedIssuesCount: 2,
+ resolvedIssuesCount: 8,
+ ignoredIssuesCount: 1,
+ regressedIssuesCount: 1,
+ },
+ ],
+ newIssuesList: [
+ {
+ id: 301,
+ title: 'High latency in chat message processing',
+ projectId: 30,
+ commitUuid: 'global789xyz123',
+ },
+ {
+ id: 302,
+ title: 'WebSocket connection drops under load',
+ projectId: 30,
+ commitUuid: 'global789xyz123',
+ },
+ {
+ id: 303,
+ title: 'Context window overflow in long conversations',
+ projectId: 31,
+ commitUuid: 'ai456def789',
+ },
+ {
+ id: 304,
+ title: 'Rate limiting bypass vulnerability',
+ projectId: 31,
+ commitUuid: 'ai456def789',
+ },
+ {
+ id: 305,
+ title: 'Translation accuracy degradation for Asian languages',
+ projectId: 33,
+ commitUuid: 'trans123abc456',
+ },
+ {
+ id: 306,
+ title: 'Document parsing fails for PDFs over 10MB',
+ projectId: 32,
+ commitUuid: 'doc789xyz012',
+ },
+ {
+ id: 307,
+ title: 'Memory leak in streaming response handler',
+ projectId: 30,
+ commitUuid: 'global789xyz123',
+ },
+ {
+ id: 308,
+ title: 'Authentication token refresh race condition',
+ projectId: 31,
+ commitUuid: 'ai456def789',
+ },
+ {
+ id: 309,
+ title: 'Cache invalidation not working correctly',
+ projectId: 30,
+ commitUuid: 'global789xyz123',
+ },
+ {
+ id: 310,
+ title: 'Database query timeout on analytics dashboard',
+ projectId: 32,
+ commitUuid: 'doc789xyz012',
+ },
+ ],
+ },
+ annotations: {
+ hasAnnotations: true,
+ annotationsCount: 1834,
+ passedCount: 1523,
+ failedCount: 311,
+ passedPercentage: 83.04,
+ failedPercentage: 16.96,
+ firstProjectId: 30,
+ topProjects: [
+ {
+ projectId: 30,
+ projectName: 'Global Chat Platform',
+ annotationsCount: 892,
+ passedCount: 756,
+ failedCount: 136,
+ passedPercentage: 84.75,
+ failedPercentage: 15.25,
+ },
+ {
+ projectId: 31,
+ projectName: 'AI Assistant API',
+ annotationsCount: 623,
+ passedCount: 512,
+ failedCount: 111,
+ passedPercentage: 82.18,
+ failedPercentage: 17.82,
+ },
+ {
+ projectId: 32,
+ projectName: 'Document Processor',
+ annotationsCount: 319,
+ passedCount: 255,
+ failedCount: 64,
+ passedPercentage: 79.94,
+ failedPercentage: 20.06,
+ },
+ ],
+ },
+}
diff --git a/packages/emails/src/templates/weeklyEmail/types.ts b/packages/emails/src/templates/weeklyEmail/types.ts
new file mode 100644
index 0000000000..9bb4350689
--- /dev/null
+++ b/packages/emails/src/templates/weeklyEmail/types.ts
@@ -0,0 +1,73 @@
+import { NotificiationsLayoutProps } from '../../types'
+
+export type TopProjectLog = {
+ projectId: number
+ projectName: string
+ logsCount: number
+ tokensSpent: number
+ tokensCost: number
+}
+export type LogStats = {
+ usedInProduction: boolean
+ logsCount: number
+ tokensSpent: number
+ tokensCost: number
+ topProjects: TopProjectLog[]
+}
+
+export type TopProjectAnnotation = {
+ projectId: number
+ projectName: string
+ annotationsCount: number
+ passedCount: number
+ failedCount: number
+ passedPercentage: number
+ failedPercentage: number
+}
+
+export type AnnotationStats = {
+ hasAnnotations: boolean
+ annotationsCount: number
+ passedCount: number
+ failedCount: number
+ passedPercentage: number
+ failedPercentage: number
+ topProjects: TopProjectAnnotation[]
+ firstProjectId: number | null
+}
+
+export type TopProjectIssue = {
+ projectId: number
+ projectName: string
+ issuesCount: number
+ newIssuesCount: number
+ escalatedIssuesCount: number
+ resolvedIssuesCount: number
+ ignoredIssuesCount: number
+ regressedIssuesCount: number
+}
+
+export type NewIssue = {
+ id: number
+ title: string
+ projectId: number
+ commitUuid: string
+}
+export type IssueStats = {
+ hasIssues: boolean
+ issuesCount: number
+ newIssuesCount: number
+ escalatedIssuesCount: number
+ resolvedIssuesCount: number
+ ignoredIssuesCount: number
+ regressedIssuesCount: number
+ topProjects: TopProjectIssue[]
+ newIssuesList: NewIssue[]
+}
+
+export type WeeklyEmailMailProps = {
+ currentWorkspace: NotificiationsLayoutProps['currentWorkspace']
+ logs: LogStats
+ issues: IssueStats
+ annotations: AnnotationStats
+}
diff --git a/packages/emails/src/types.ts b/packages/emails/src/types.ts
index 58eeeb0047..4f1eba8e51 100644
--- a/packages/emails/src/types.ts
+++ b/packages/emails/src/types.ts
@@ -8,5 +8,5 @@ export type ContainerLayoutProps = {
}
export type NotificiationsLayoutProps = {
- currentWorkspace: { id: number }
+ currentWorkspace: { id: number; name: string }
}