Skip to content

Commit a8ca59c

Browse files
committed
Implement weekly email schedule job
We want to send an email with relevant data for the organizations using Latitude. We'll do the processing of this data on monday 1:00 A.M and it will be sent to be ready on users' inbox on monday morning
1 parent f33a192 commit a8ca59c

File tree

24 files changed

+1611
-80
lines changed

24 files changed

+1611
-80
lines changed

apps/web/src/app/(admin)/backoffice/search/workspace/[id]/_components/ToggleBigAccountButton/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { useToast } from '@latitude-data/web-ui/atoms/Toast'
44
import useLatitudeAction from '$/hooks/useLatitudeAction'
55
import { toggleBigAccountAction } from '$/actions/admin/workspaces/toggleBigAccount'
66

7-
export function ToggleBigAccountButton({ workspaceId, isBigAccount }: {
7+
export function ToggleBigAccountButton({
8+
workspaceId,
9+
isBigAccount,
10+
}: {
811
workspaceId: number
912
isBigAccount: boolean
1013
}) {

apps/web/src/app/api/spans/limited/route.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,9 @@ export const GET = errorHandler(
5656
: null
5757

5858
let spansResult
59+
const spansRepository = new SpansRepository(workspace.id)
60+
5961
if (filters.traceId) {
60-
// If traceId is present in filters, fetch spans for that specific trace
61-
const spansRepository = new SpansRepository(workspace.id)
6262
const spans = await spansRepository
6363
.list({ traceId: filters.traceId })
6464
.then((r) =>
@@ -70,13 +70,9 @@ export const GET = errorHandler(
7070
next: null,
7171
}
7272
} else {
73-
// Otherwise, fetch spans directly from the repository
74-
const spansRepository = new SpansRepository(workspace.id)
75-
7673
let result: { items: any[]; next: any } = { items: [], next: null }
7774

7875
if (documentUuid) {
79-
// Document queries require commitUuid
8076
if (!commitUuid) {
8177
return NextResponse.json(
8278
{ error: 'commitUuid is required when documentUuid is provided' },

apps/workers/src/workers/schedule.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,11 @@ export async function setupSchedules() {
5858
{ pattern: '0 0 1 * * *' },
5959
{ opts: { attempts: 1 } },
6060
)
61+
62+
// Every Monday at 1:00:00 AM - Schedule weekly email reports
63+
await maintenanceQueue.upsertJobScheduler(
64+
'scheduleWeeklyEmailJobs',
65+
{ pattern: '0 0 1 * * 1' },
66+
{ opts: { attempts: 1 } },
67+
)
6168
}

apps/workers/src/workers/worker-definitions/maintenanceWorker.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ const jobMappings = {
1515
requestDocumentSuggestionsJob: jobs.requestDocumentSuggestionsJob,
1616
scaleDownMcpServerJob: jobs.scaleDownMcpServerJob,
1717
updateMcpServerLastUsedJob: jobs.updateMcpServerLastUsedJob,
18+
scheduleWeeklyEmailJobs: jobs.scheduleWeeklyEmailJobs,
19+
sendWeeklyEmailJob: jobs.sendWeeklyEmailJob,
1820
}
1921

2022
export function startMaintenanceWorker() {
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
import { beforeAll, describe, expect, it } from 'vitest'
2+
import { SpanType } from '../../../constants'
3+
import { createWorkspace } from '../../../tests/factories'
4+
import { createSpan } from '../../../tests/factories/spans'
5+
import { getActiveWorkspacesForWeeklyEmail } from './index'
6+
import { Workspace } from '../../../schema/models/types/Workspace'
7+
8+
let workspace: Workspace
9+
10+
describe('getActiveWorkspacesForWeeklyEmail', () => {
11+
beforeAll(async () => {
12+
const { workspace: ws } = await createWorkspace()
13+
workspace = ws
14+
})
15+
16+
describe('when workspace has no activity', () => {
17+
it('returns empty array when no spans exist', async () => {
18+
// Common workspace has no spans yet
19+
const result = await getActiveWorkspacesForWeeklyEmail()
20+
expect(result).toEqual([])
21+
})
22+
})
23+
24+
describe('when workspace has prompt spans in last 4 weeks', () => {
25+
it('includes workspace with recent prompt spans', async () => {
26+
const threeDaysAgo = new Date()
27+
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3)
28+
29+
await createSpan({
30+
workspaceId: workspace.id,
31+
type: SpanType.Prompt,
32+
startedAt: threeDaysAgo,
33+
})
34+
35+
const result = await getActiveWorkspacesForWeeklyEmail()
36+
37+
expect(result).toEqual(
38+
expect.arrayContaining([
39+
expect.objectContaining({
40+
id: workspace.id,
41+
}),
42+
]),
43+
)
44+
})
45+
})
46+
47+
describe('when workspace has only old or non-prompt spans', () => {
48+
it('excludes workspace with only old prompt spans', async () => {
49+
const { workspace: oldWorkspace } = await createWorkspace()
50+
const fiveWeeksAgo = new Date()
51+
fiveWeeksAgo.setDate(fiveWeeksAgo.getDate() - 35)
52+
53+
await createSpan({
54+
workspaceId: oldWorkspace.id,
55+
type: SpanType.Prompt,
56+
startedAt: fiveWeeksAgo,
57+
})
58+
59+
const result = await getActiveWorkspacesForWeeklyEmail()
60+
61+
expect(result).not.toEqual(
62+
expect.arrayContaining([
63+
expect.objectContaining({
64+
id: oldWorkspace.id,
65+
}),
66+
]),
67+
)
68+
})
69+
70+
it('excludes workspace with only non-prompt spans', async () => {
71+
const { workspace: nonPromptWorkspace } = await createWorkspace()
72+
const threeDaysAgo = new Date()
73+
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3)
74+
75+
// Create completion span (not prompt)
76+
await createSpan({
77+
workspaceId: nonPromptWorkspace.id,
78+
type: SpanType.Completion,
79+
startedAt: threeDaysAgo,
80+
})
81+
82+
// Create step span (not prompt)
83+
await createSpan({
84+
workspaceId: nonPromptWorkspace.id,
85+
type: SpanType.Step,
86+
startedAt: threeDaysAgo,
87+
})
88+
89+
const result = await getActiveWorkspacesForWeeklyEmail()
90+
91+
expect(result).not.toEqual(
92+
expect.arrayContaining([
93+
expect.objectContaining({
94+
id: nonPromptWorkspace.id,
95+
}),
96+
]),
97+
)
98+
})
99+
})
100+
101+
describe('when multiple workspaces exist', () => {
102+
it('includes multiple active workspaces', async () => {
103+
const { workspace: workspace2 } = await createWorkspace()
104+
const { workspace: workspace3 } = await createWorkspace()
105+
106+
const twoDaysAgo = new Date()
107+
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2)
108+
109+
// Create spans for common workspace and workspace2
110+
await createSpan({
111+
workspaceId: workspace.id,
112+
type: SpanType.Prompt,
113+
startedAt: twoDaysAgo,
114+
})
115+
116+
await createSpan({
117+
workspaceId: workspace2.id,
118+
type: SpanType.Prompt,
119+
startedAt: twoDaysAgo,
120+
})
121+
122+
// workspace3 has old activity
123+
const fiveWeeksAgo = new Date()
124+
fiveWeeksAgo.setDate(fiveWeeksAgo.getDate() - 35)
125+
await createSpan({
126+
workspaceId: workspace3.id,
127+
type: SpanType.Prompt,
128+
startedAt: fiveWeeksAgo,
129+
})
130+
131+
const result = await getActiveWorkspacesForWeeklyEmail()
132+
133+
// Should include common workspace and workspace2
134+
expect(result).toEqual(
135+
expect.arrayContaining([
136+
expect.objectContaining({ id: workspace.id }),
137+
expect.objectContaining({ id: workspace2.id }),
138+
]),
139+
)
140+
141+
// Should not include workspace3
142+
expect(result).not.toEqual(
143+
expect.arrayContaining([
144+
expect.objectContaining({ id: workspace3.id }),
145+
]),
146+
)
147+
})
148+
149+
it('handles workspace with multiple prompt spans (counts once)', async () => {
150+
const threeDaysAgo = new Date()
151+
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3)
152+
153+
// Create multiple prompt spans for common workspace
154+
await createSpan({
155+
workspaceId: workspace.id,
156+
type: SpanType.Prompt,
157+
startedAt: threeDaysAgo,
158+
})
159+
160+
await createSpan({
161+
workspaceId: workspace.id,
162+
type: SpanType.Prompt,
163+
startedAt: threeDaysAgo,
164+
})
165+
166+
await createSpan({
167+
workspaceId: workspace.id,
168+
type: SpanType.Prompt,
169+
startedAt: threeDaysAgo,
170+
})
171+
172+
const result = await getActiveWorkspacesForWeeklyEmail()
173+
174+
// Should include workspace only once
175+
const workspaceCount = result.filter((w) => w.id === workspace.id).length
176+
expect(workspaceCount).toEqual(1)
177+
})
178+
})
179+
180+
describe('big account filtering', () => {
181+
it('excludes workspaces marked as big accounts', async () => {
182+
const { workspace: regularWorkspace } = await createWorkspace({
183+
isBigAccount: false,
184+
})
185+
const { workspace: bigWorkspace } = await createWorkspace({
186+
isBigAccount: true,
187+
})
188+
189+
const threeDaysAgo = new Date()
190+
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3)
191+
192+
// Both workspaces have recent activity
193+
await createSpan({
194+
workspaceId: regularWorkspace.id,
195+
type: SpanType.Prompt,
196+
startedAt: threeDaysAgo,
197+
})
198+
199+
await createSpan({
200+
workspaceId: bigWorkspace.id,
201+
type: SpanType.Prompt,
202+
startedAt: threeDaysAgo,
203+
})
204+
205+
const result = await getActiveWorkspacesForWeeklyEmail()
206+
207+
// Should include regular workspace
208+
expect(result).toEqual(
209+
expect.arrayContaining([
210+
expect.objectContaining({ id: regularWorkspace.id }),
211+
]),
212+
)
213+
214+
// Should NOT include big account workspace
215+
expect(result).not.toEqual(
216+
expect.arrayContaining([
217+
expect.objectContaining({ id: bigWorkspace.id }),
218+
]),
219+
)
220+
})
221+
222+
it('includes only non-big accounts when multiple workspaces are active', async () => {
223+
const { workspace: bigWorkspace } = await createWorkspace({
224+
isBigAccount: true,
225+
})
226+
const { workspace: regularWorkspace } = await createWorkspace({
227+
isBigAccount: false,
228+
})
229+
230+
const twoDaysAgo = new Date()
231+
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2)
232+
233+
// All workspaces have activity
234+
await createSpan({
235+
workspaceId: workspace.id,
236+
type: SpanType.Prompt,
237+
startedAt: twoDaysAgo,
238+
})
239+
240+
await createSpan({
241+
workspaceId: bigWorkspace.id,
242+
type: SpanType.Prompt,
243+
startedAt: twoDaysAgo,
244+
})
245+
246+
await createSpan({
247+
workspaceId: regularWorkspace.id,
248+
type: SpanType.Prompt,
249+
startedAt: twoDaysAgo,
250+
})
251+
252+
const result = await getActiveWorkspacesForWeeklyEmail()
253+
254+
// Should include common workspace and regularWorkspace (not big accounts)
255+
expect(result).toEqual(
256+
expect.arrayContaining([
257+
expect.objectContaining({ id: workspace.id }),
258+
expect.objectContaining({ id: regularWorkspace.id }),
259+
]),
260+
)
261+
262+
// Should not include bigWorkspace (big account)
263+
expect(result).not.toEqual(
264+
expect.arrayContaining([
265+
expect.objectContaining({ id: bigWorkspace.id }),
266+
]),
267+
)
268+
})
269+
})
270+
271+
describe('edge cases', () => {
272+
it('includes workspace with span at 27 days ago (within 4 weeks)', async () => {
273+
const { workspace: edgeCaseWorkspace } = await createWorkspace()
274+
const twentySevenDaysAgo = new Date()
275+
twentySevenDaysAgo.setDate(twentySevenDaysAgo.getDate() - 27)
276+
277+
await createSpan({
278+
workspaceId: edgeCaseWorkspace.id,
279+
type: SpanType.Prompt,
280+
startedAt: twentySevenDaysAgo,
281+
})
282+
283+
const result = await getActiveWorkspacesForWeeklyEmail()
284+
285+
expect(result).toEqual(
286+
expect.arrayContaining([
287+
expect.objectContaining({
288+
id: edgeCaseWorkspace.id,
289+
}),
290+
]),
291+
)
292+
})
293+
294+
it('excludes workspace with span exactly 29 days ago', async () => {
295+
const { workspace: tooOldWorkspace } = await createWorkspace()
296+
const twentyNineDaysAgo = new Date()
297+
twentyNineDaysAgo.setDate(twentyNineDaysAgo.getDate() - 29)
298+
299+
await createSpan({
300+
workspaceId: tooOldWorkspace.id,
301+
type: SpanType.Prompt,
302+
startedAt: twentyNineDaysAgo,
303+
})
304+
305+
const result = await getActiveWorkspacesForWeeklyEmail()
306+
307+
expect(result).not.toEqual(
308+
expect.arrayContaining([
309+
expect.objectContaining({
310+
id: tooOldWorkspace.id,
311+
}),
312+
]),
313+
)
314+
})
315+
316+
it('returns full workspace objects with all fields', async () => {
317+
const threeDaysAgo = new Date()
318+
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3)
319+
320+
await createSpan({
321+
workspaceId: workspace.id,
322+
type: SpanType.Prompt,
323+
startedAt: threeDaysAgo,
324+
})
325+
326+
const result = await getActiveWorkspacesForWeeklyEmail()
327+
328+
const foundWorkspace = result.find((w) => w.id === workspace.id)
329+
expect(foundWorkspace).toBeDefined()
330+
expect(foundWorkspace?.name).toBeDefined()
331+
expect(foundWorkspace?.createdAt).toBeDefined()
332+
})
333+
})
334+
})

0 commit comments

Comments
 (0)