Skip to content

Commit e3979ff

Browse files
committed
upgrade global work
1 parent c69351b commit e3979ff

9 files changed

Lines changed: 17070 additions & 77 deletions

File tree

apps/sim/app/api/v1/admin/dashboard/global-work/route.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@ export const GET = withRouteHandler(
2222
if (!parsed.success) return parsed.response
2323

2424
try {
25-
const data = await getGlobalWorkSummary(parsed.data.query.month)
25+
const { month, userId, organizationId } = parsed.data.query
26+
const filter = userId
27+
? ({ type: 'user', id: userId } as const)
28+
: organizationId
29+
? ({ type: 'organization', id: organizationId } as const)
30+
: undefined
31+
const data = await getGlobalWorkSummary(month, new Date(), filter)
2632
return NextResponse.json({ data })
2733
} catch (error) {
2834
logger.error('Failed to build Global Work summary', { error })
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/** @vitest-environment node */
2+
3+
import { describe, expect, it } from 'vitest'
4+
import { adminV1GlobalWorkQuerySchema } from '@/lib/api/contracts/v1/admin/global-work'
5+
6+
describe('Global Work admin query contract', () => {
7+
it('accepts either a user or organization filter', () => {
8+
expect(
9+
adminV1GlobalWorkQuerySchema.safeParse({ month: '2026-06', userId: 'user-1' }).success
10+
).toBe(true)
11+
expect(
12+
adminV1GlobalWorkQuerySchema.safeParse({
13+
month: '2026-06',
14+
organizationId: 'org-1',
15+
}).success
16+
).toBe(true)
17+
})
18+
19+
it('rejects ambiguous combined filters', () => {
20+
expect(
21+
adminV1GlobalWorkQuerySchema.safeParse({
22+
month: '2026-06',
23+
userId: 'user-1',
24+
organizationId: 'org-1',
25+
}).success
26+
).toBe(false)
27+
})
28+
})

apps/sim/lib/api/contracts/v1/admin/global-work.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,37 @@ import { z } from 'zod'
22
import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
33
import { lastQueryValue } from '@/lib/api/contracts/v1/admin/shared'
44

5-
export const adminV1GlobalWorkQuerySchema = z.object({
6-
month: z
7-
.preprocess(
8-
lastQueryValue,
9-
z
10-
.string()
11-
.regex(/^\d{4}-(0[1-9]|1[0-2])$/)
12-
.optional()
13-
)
14-
.optional(),
15-
})
5+
const optionalIdQuerySchema = z.preprocess(lastQueryValue, z.string().trim().min(1).optional())
6+
7+
export const adminV1GlobalWorkQuerySchema = z
8+
.object({
9+
month: z
10+
.preprocess(
11+
lastQueryValue,
12+
z
13+
.string()
14+
.regex(/^\d{4}-(0[1-9]|1[0-2])$/)
15+
.optional()
16+
)
17+
.optional(),
18+
userId: optionalIdQuerySchema.optional(),
19+
organizationId: optionalIdQuerySchema.optional(),
20+
})
21+
.refine((query) => !(query.userId && query.organizationId), {
22+
error: 'Filter by either user or organization, not both',
23+
})
1624

1725
export const adminV1GlobalWorkResponseSchema = z.object({
1826
data: z.object({
1927
month: z.string(),
2028
label: z.string(),
2129
isCurrentMonth: z.boolean(),
2230
attribution: z.literal('estimated'),
31+
scope: z.discriminatedUnion('type', [
32+
z.object({ type: z.literal('global'), id: z.null() }),
33+
z.object({ type: z.literal('user'), id: z.string() }),
34+
z.object({ type: z.literal('organization'), id: z.string() }),
35+
]),
2336
formula: z.object({
2437
minutesPerUnit: z.number().positive(),
2538
globalAnnualHours: z.number().positive(),

apps/sim/lib/global-work/summary.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ describe('Global Work Pacific reporting windows', () => {
9292
const summary = await getGlobalWorkSummary('2026-06', new Date('2026-07-09T12:00:00.000Z'))
9393

9494
expect(summary.attribution).toBe('estimated')
95+
expect(summary.scope).toEqual({ type: 'global', id: null })
9596
expect(summary.formula).toEqual(GLOBAL_WORK_FORMULA)
9697
expect(summary.units).toBe(12)
9798
expect(summary.humanEquivalentHours).toBe(1)
@@ -187,4 +188,44 @@ describe('Global Work Pacific reporting windows', () => {
187188
expect(queryText).toContain("AT TIME ZONE 'UTC'")
188189
expect(dialect.sqlToQuery(getCapturedQuery()).params).toContain('America/Los_Angeles')
189190
})
191+
192+
it('filters a user by recorded actor without changing payer eligibility', async () => {
193+
execute.mockResolvedValueOnce([])
194+
195+
const summary = await getGlobalWorkSummary('2026-06', new Date('2026-07-09T12:00:00.000Z'), {
196+
type: 'user',
197+
id: 'user-1',
198+
})
199+
200+
const query = dialect.sqlToQuery(getCapturedQuery())
201+
expect(query.sql).toContain('su.actor_user_id = $')
202+
expect(query.params.filter((parameter) => parameter === 'user-1')).toHaveLength(2)
203+
expect(summary.scope).toEqual({ type: 'user', id: 'user-1' })
204+
})
205+
206+
it('filters an organization by both billing entity type and ID', async () => {
207+
execute.mockResolvedValueOnce([])
208+
209+
const summary = await getGlobalWorkSummary('2026-06', new Date('2026-07-09T12:00:00.000Z'), {
210+
type: 'organization',
211+
id: 'org-1',
212+
})
213+
214+
const query = dialect.sqlToQuery(getCapturedQuery())
215+
expect(query.sql).toContain("su.billing_entity_type = 'organization'")
216+
expect(query.sql).toContain('su.billing_entity_id = $')
217+
expect(query.params.filter((parameter) => parameter === 'org-1')).toHaveLength(2)
218+
expect(summary.scope).toEqual({ type: 'organization', id: 'org-1' })
219+
})
220+
221+
it('preserves billing entity type across snapshots, usage fallback, and current workspace state', async () => {
222+
execute.mockResolvedValueOnce([])
223+
224+
await getGlobalWorkSummary('2026-06', new Date('2026-07-09T12:00:00.000Z'))
225+
226+
const queryText = getCapturedQueryText()
227+
expect(queryText).toContain("'{billingAttribution,billingEntity,type}'")
228+
expect(queryText).toContain('usage_scope.billing_entity_type::text')
229+
expect(queryText).toContain('wb.billing_entity_type')
230+
})
190231
})

apps/sim/lib/global-work/summary.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ export interface GlobalWorkSummary {
2525
label: string
2626
isCurrentMonth: boolean
2727
attribution: 'estimated'
28+
scope:
29+
| { type: 'global'; id: null }
30+
| { type: 'user'; id: string }
31+
| { type: 'organization'; id: string }
2832
formula: {
2933
minutesPerUnit: number
3034
globalAnnualHours: number
@@ -45,6 +49,8 @@ export interface GlobalWorkSummary {
4549
}>
4650
}
4751

52+
export type GlobalWorkFilter = { type: 'user'; id: string } | { type: 'organization'; id: string }
53+
4854
function addMonth(month: string, delta: number): string {
4955
const [year, monthNumber] = month.split('-').map(Number)
5056
const date = new Date(Date.UTC(year, monthNumber - 1 + delta, 1))
@@ -109,14 +115,20 @@ export function buildZeroFilledGlobalWorkDailySeries(params: {
109115
* billing snapshot already persisted for billing; older workflows and all
110116
* Mothership messages fall back to the current/last-known subscription state.
111117
*/
112-
async function queryEstimatedGlobalWorkUnits(periodStart: Date, periodEnd: Date) {
118+
async function queryEstimatedGlobalWorkUnits(
119+
periodStart: Date,
120+
periodEnd: Date,
121+
filter?: GlobalWorkFilter
122+
) {
113123
// Raw `sql` parameters do not infer a timestamp encoder from the surrounding
114124
// comparison. Bind through the matching schema columns so postgres.js
115125
// receives UTC timestamp strings rather than JavaScript Date objects.
116126
const workflowPeriodStart = sql.param(periodStart, workflowExecutionLogs.endedAt)
117127
const workflowPeriodEnd = sql.param(periodEnd, workflowExecutionLogs.endedAt)
118128
const mothershipPeriodStart = sql.param(periodStart, copilotMessages.createdAt)
119129
const mothershipPeriodEnd = sql.param(periodEnd, copilotMessages.createdAt)
130+
const actorUserId = filter?.type === 'user' ? filter.id : null
131+
const billingOrganizationId = filter?.type === 'organization' ? filter.id : null
120132

121133
const result = await dbReplica.execute<AggregatedRow>(sql`
122134
WITH workspace_billing AS (
@@ -127,6 +139,11 @@ async function queryEstimatedGlobalWorkUnits(periodStart: Date, periodEnd: Date)
127139
THEN w.organization_id
128140
ELSE w.billed_account_user_id
129141
END AS billing_entity_id,
142+
CASE
143+
WHEN w.workspace_mode = 'organization' AND w.organization_id IS NOT NULL
144+
THEN 'organization'::text
145+
ELSE 'user'::text
146+
END AS billing_entity_type,
130147
COALESCE(org_owner.user_id, w.billed_account_user_id) AS billing_owner_user_id
131148
FROM workspace w
132149
LEFT JOIN LATERAL (
@@ -146,6 +163,11 @@ async function queryEstimatedGlobalWorkUnits(periodStart: Date, periodEnd: Date)
146163
usage_scope.billing_entity_id,
147164
wb.billing_entity_id
148165
) AS billing_entity_id,
166+
COALESCE(
167+
wel.execution_data #>> '{billingAttribution,billingEntity,type}',
168+
usage_scope.billing_entity_type::text,
169+
wb.billing_entity_type
170+
) AS billing_entity_type,
149171
COALESCE(
150172
wel.execution_data #>> '{billingAttribution,billedAccountUserId}',
151173
wb.billing_owner_user_id
@@ -164,7 +186,7 @@ async function queryEstimatedGlobalWorkUnits(periodStart: Date, periodEnd: Date)
164186
FROM workflow_execution_logs wel
165187
JOIN workspace_billing wb ON wb.workspace_id = wel.workspace_id
166188
LEFT JOIN LATERAL (
167-
SELECT ul.billing_entity_id, ul.user_id
189+
SELECT ul.billing_entity_id, ul.billing_entity_type, ul.user_id
168190
FROM usage_log ul
169191
WHERE NOT (wel.execution_data ? 'billingAttribution')
170192
AND ul.execution_id = wel.execution_id
@@ -184,6 +206,7 @@ async function queryEstimatedGlobalWorkUnits(periodStart: Date, periodEnd: Date)
184206
cm.message_id AS source_event_id,
185207
cm.created_at AS occurred_at,
186208
wb.billing_entity_id,
209+
wb.billing_entity_type,
187210
wb.billing_owner_user_id,
188211
cc.user_id AS actor_user_id,
189212
false AS has_billing_snapshot,
@@ -213,6 +236,7 @@ async function queryEstimatedGlobalWorkUnits(periodStart: Date, periodEnd: Date)
213236
source_event_id,
214237
occurred_at,
215238
billing_entity_id,
239+
billing_entity_type,
216240
billing_owner_user_id,
217241
actor_user_id,
218242
has_billing_snapshot,
@@ -228,6 +252,14 @@ async function queryEstimatedGlobalWorkUnits(periodStart: Date, periodEnd: Date)
228252
LEFT JOIN "user" billing_owner ON billing_owner.id = su.billing_owner_user_id
229253
WHERE lower(split_part(COALESCE(actor.email, ''), '@', 2)) NOT IN ('sim.ai', 'simstudio.ai')
230254
AND lower(split_part(COALESCE(billing_owner.email, ''), '@', 2)) NOT IN ('sim.ai', 'simstudio.ai')
255+
AND (${actorUserId}::text IS NULL OR su.actor_user_id = ${actorUserId})
256+
AND (
257+
${billingOrganizationId}::text IS NULL
258+
OR (
259+
su.billing_entity_type = 'organization'
260+
AND su.billing_entity_id = ${billingOrganizationId}
261+
)
262+
)
231263
AND (
232264
(
233265
su.has_billing_snapshot
@@ -282,13 +314,14 @@ async function queryEstimatedGlobalWorkUnits(periodStart: Date, periodEnd: Date)
282314

283315
export async function getGlobalWorkSummary(
284316
requestedMonth?: string,
285-
now = new Date()
317+
now = new Date(),
318+
filter?: GlobalWorkFilter
286319
): Promise<GlobalWorkSummary> {
287320
const month = requestedMonth ?? getLatestCompletedGlobalWorkMonth(now)
288321
const currentMonth = zonedWallClock(now, REPORTING_TIME_ZONE).slice(0, 7)
289322
const isCurrentMonth = month === currentMonth
290323
const { periodStart, periodEnd } = getGlobalWorkMonthWindow(month)
291-
const rows = await queryEstimatedGlobalWorkUnits(periodStart, periodEnd)
324+
const rows = await queryEstimatedGlobalWorkUnits(periodStart, periodEnd, filter)
292325

293326
const sourceUnits = { workflow: 0, mothership: 0 }
294327
const unitsByDate = new Map<string, { workflow: number; mothership: number }>()
@@ -329,6 +362,7 @@ export async function getGlobalWorkSummary(
329362
label: isCurrentMonth ? 'Month to date · projected annualized' : 'Completed month',
330363
isCurrentMonth,
331364
attribution: 'estimated',
365+
scope: filter ?? { type: 'global', id: null },
332366
formula: GLOBAL_WORK_FORMULA,
333367
units,
334368
humanEquivalentHours: round(humanEquivalentHours, 2),
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
ALTER TABLE "paused_executions" ADD COLUMN IF NOT EXISTS "automatic_resume_retry_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
2+
ALTER TABLE "workspace" ADD COLUMN IF NOT EXISTS "storage_used_bytes" bigint DEFAULT 0 NOT NULL;--> statement-breakpoint
3+
ALTER TABLE "workspace" ADD COLUMN IF NOT EXISTS "organization_assigned_at" timestamp;--> statement-breakpoint
4+
-- migration-safe: replay-only cleanup of the constraint this same migration adds on the next line; no deployed code knows it yet. A failed concurrent index build below leaves this file unjournaled, and the rerun must not fail on ADD CONSTRAINT already-exists.
5+
ALTER TABLE "workspace" DROP CONSTRAINT IF EXISTS "workspace_storage_used_bytes_non_negative";--> statement-breakpoint
6+
ALTER TABLE "workspace" ADD CONSTRAINT "workspace_storage_used_bytes_non_negative" CHECK ("workspace"."storage_used_bytes" >= 0) NOT VALID;--> statement-breakpoint
7+
8+
-- These tables are live and potentially large. End the migration transaction
9+
-- before building their indexes so writes remain available during deployment.
10+
-- Every statement in this file is idempotent, so a failed concurrent build
11+
-- (which leaves this migration unjournaled and an INVALID same-name index
12+
-- behind) replays safely from the top: the drop clears any INVALID leftover
13+
-- before each rebuild.
14+
COMMIT;--> statement-breakpoint
15+
SET lock_timeout = 0;--> statement-breakpoint
16+
DROP INDEX CONCURRENTLY IF EXISTS "copilot_messages_user_created_at_idx";--> statement-breakpoint
17+
CREATE INDEX CONCURRENTLY IF NOT EXISTS "copilot_messages_user_created_at_idx" ON "copilot_messages" USING btree ("created_at","chat_id","message_id") WHERE "copilot_messages"."role" = 'user' AND "copilot_messages"."deleted_at" IS NULL;--> statement-breakpoint
18+
DROP INDEX CONCURRENTLY IF EXISTS "outbox_event_type_created_idx";--> statement-breakpoint
19+
CREATE INDEX CONCURRENTLY IF NOT EXISTS "outbox_event_type_created_idx" ON "outbox_event" USING btree ("event_type","created_at");--> statement-breakpoint
20+
DROP INDEX CONCURRENTLY IF EXISTS "workflow_execution_logs_completed_ended_at_idx";--> statement-breakpoint
21+
CREATE INDEX CONCURRENTLY IF NOT EXISTS "workflow_execution_logs_completed_ended_at_idx" ON "workflow_execution_logs" USING btree ("ended_at","workspace_id","execution_id") WHERE "workflow_execution_logs"."status" = 'completed' AND "workflow_execution_logs"."level" = 'info' AND "workflow_execution_logs"."ended_at" IS NOT NULL;--> statement-breakpoint
22+
SET lock_timeout = '5s';

0 commit comments

Comments
 (0)