mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-30 17:50:07 +08:00
feat: add quota entitlements (#383)
Agent-Profile: https://agent-kanban.dev/agents/1dc839c09b5ee5e5
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE `org_quota_entitlements` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`org_id` text NOT NULL,
|
||||
`resource_type` text NOT NULL,
|
||||
`source` text NOT NULL,
|
||||
`source_id` text NOT NULL,
|
||||
`bytes` integer NOT NULL,
|
||||
`starts_at` integer NOT NULL,
|
||||
`expires_at` integer,
|
||||
`status` text NOT NULL,
|
||||
`metadata` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `org_quota_entitlements_org_resource_idx` ON `org_quota_entitlements` (`org_id`,`resource_type`,`status`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `org_quota_entitlements_source_resource_uniq` ON `org_quota_entitlements` (`source`,`source_id`,`resource_type`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -197,6 +197,13 @@
|
||||
"when": 1778246297143,
|
||||
"tag": "0028_move-cloud-store-settings-to-system-options",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "6",
|
||||
"when": 1778277914288,
|
||||
"tag": "0029_wet_betty_brant",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -47,6 +47,28 @@ export const orgQuotas = sqliteTable('org_quotas', {
|
||||
trafficPeriod: text('traffic_period').notNull().default('1970-01'),
|
||||
})
|
||||
|
||||
export const orgQuotaEntitlements = sqliteTable(
|
||||
'org_quota_entitlements',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
orgId: text('org_id').notNull(),
|
||||
resourceType: text('resource_type').notNull(),
|
||||
source: text('source').notNull(),
|
||||
sourceId: text('source_id').notNull(),
|
||||
bytes: integer('bytes').notNull(),
|
||||
startsAt: integer('starts_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }),
|
||||
status: text('status').notNull(),
|
||||
metadata: text('metadata'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index('org_quota_entitlements_org_resource_idx').on(t.orgId, t.resourceType, t.status),
|
||||
uniqueIndex('org_quota_entitlements_source_resource_uniq').on(t.source, t.sourceId, t.resourceType),
|
||||
],
|
||||
)
|
||||
|
||||
export const webhookEvents = sqliteTable(
|
||||
'webhook_events',
|
||||
{
|
||||
|
||||
@@ -1646,7 +1646,7 @@ describe('Quota Store API', () => {
|
||||
await expect(res.json()).resolves.toMatchObject({ success: true, duplicate: false })
|
||||
})
|
||||
|
||||
it('valid Cloud quota-change webhook updates org quota once and records audit', async () => {
|
||||
it('valid Cloud quota-change webhook records active entitlement once and records audit', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
@@ -1678,8 +1678,16 @@ describe('Quota Store API', () => {
|
||||
expect(events).toEqual([{ status: 'processed', error: null, processedAt: expect.any(Number) }])
|
||||
|
||||
const quotaRes = await app.request('/api/quotas/me', { headers })
|
||||
const quota = (await quotaRes.json()) as { baseQuota: number; quota: number }
|
||||
const quota = (await quotaRes.json()) as { baseQuota: number; entitlementQuota: number; quota: number }
|
||||
expect(quota.baseQuota).toBe(before[0].quota)
|
||||
expect(quota.entitlementQuota).toBe(4096)
|
||||
expect(quota.quota).toBe(before[0].quota + 4096)
|
||||
const entitlement = await db.all<{ bytes: number; status: string; sourceId: string }>(sql`
|
||||
SELECT bytes, status, source_id AS sourceId
|
||||
FROM org_quota_entitlements
|
||||
WHERE org_id = ${orgId} AND resource_type = 'storage'
|
||||
`)
|
||||
expect(entitlement).toEqual([{ bytes: 4096, status: 'active', sourceId: 'order-1' }])
|
||||
const audit = await db.all<{ action: string; metadata: string }>(
|
||||
sql`SELECT action, metadata FROM activity_events WHERE org_id = ${orgId} ORDER BY created_at DESC LIMIT 1`,
|
||||
)
|
||||
@@ -1687,6 +1695,168 @@ describe('Quota Store API', () => {
|
||||
expect(JSON.parse(audit[0].metadata)).toMatchObject({ eventId: 'evt-1', storageBytes: 4096, trafficBytes: 0 })
|
||||
})
|
||||
|
||||
it('accumulates repeated Cloud increases for the same order and resource', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
await seedSettings(app, headers)
|
||||
const orgId = await getFirstOrgId(db)
|
||||
|
||||
const first = await postWebhook(
|
||||
app,
|
||||
JSON.stringify({
|
||||
eventId: 'evt-repeat-increase-1',
|
||||
cloudOrderId: 'order-repeat-increase',
|
||||
targetOrgId: orgId,
|
||||
eventType: 'order.quota_changed',
|
||||
direction: 'increase',
|
||||
storageBytes: 4096,
|
||||
trafficBytes: 0,
|
||||
source: 'stripe',
|
||||
}),
|
||||
)
|
||||
const second = await postWebhook(
|
||||
app,
|
||||
JSON.stringify({
|
||||
eventId: 'evt-repeat-increase-2',
|
||||
cloudOrderId: 'order-repeat-increase',
|
||||
targetOrgId: orgId,
|
||||
eventType: 'order.quota_changed',
|
||||
direction: 'increase',
|
||||
storageBytes: 2048,
|
||||
trafficBytes: 0,
|
||||
source: 'stripe',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(first.status).toBe(200)
|
||||
expect(second.status).toBe(200)
|
||||
const entitlements = await db.all<{ bytes: number; status: string }>(
|
||||
sql`SELECT bytes, status FROM org_quota_entitlements WHERE source_id = 'order-repeat-increase' AND resource_type = 'storage'`,
|
||||
)
|
||||
expect(entitlements).toEqual([{ bytes: 6144, status: 'active' }])
|
||||
|
||||
const quotaRes = await app.request('/api/quotas/me', { headers })
|
||||
const quota = (await quotaRes.json()) as { entitlementQuota: number }
|
||||
expect(quota.entitlementQuota).toBe(6144)
|
||||
})
|
||||
|
||||
it('decreases accumulated Cloud order entitlement bytes without revoking the remainder', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
await seedSettings(app, headers)
|
||||
const orgId = await getFirstOrgId(db)
|
||||
|
||||
await postWebhook(
|
||||
app,
|
||||
JSON.stringify({
|
||||
eventId: 'evt-partial-decrease-inc-1',
|
||||
cloudOrderId: 'order-partial-decrease',
|
||||
targetOrgId: orgId,
|
||||
eventType: 'order.quota_changed',
|
||||
direction: 'increase',
|
||||
storageBytes: 4096,
|
||||
trafficBytes: 0,
|
||||
source: 'stripe',
|
||||
}),
|
||||
)
|
||||
await postWebhook(
|
||||
app,
|
||||
JSON.stringify({
|
||||
eventId: 'evt-partial-decrease-inc-2',
|
||||
cloudOrderId: 'order-partial-decrease',
|
||||
targetOrgId: orgId,
|
||||
eventType: 'order.quota_changed',
|
||||
direction: 'increase',
|
||||
storageBytes: 2048,
|
||||
trafficBytes: 0,
|
||||
source: 'stripe',
|
||||
}),
|
||||
)
|
||||
const decrease = await postWebhook(
|
||||
app,
|
||||
JSON.stringify({
|
||||
eventId: 'evt-partial-decrease-dec',
|
||||
cloudOrderId: 'order-partial-decrease',
|
||||
targetOrgId: orgId,
|
||||
eventType: 'order.quota_changed',
|
||||
direction: 'decrease',
|
||||
storageBytes: 2048,
|
||||
trafficBytes: 0,
|
||||
source: 'stripe',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(decrease.status).toBe(200)
|
||||
const entitlements = await db.all<{ bytes: number; status: string }>(
|
||||
sql`SELECT bytes, status FROM org_quota_entitlements WHERE source_id = 'order-partial-decrease' AND resource_type = 'storage'`,
|
||||
)
|
||||
expect(entitlements).toEqual([{ bytes: 4096, status: 'active' }])
|
||||
|
||||
const quotaRes = await app.request('/api/quotas/me', { headers })
|
||||
const quota = (await quotaRes.json()) as { entitlementQuota: number }
|
||||
expect(quota.entitlementQuota).toBe(4096)
|
||||
})
|
||||
|
||||
it('restarts entitlement bytes when a new increase follows full revocation', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
await seedSettings(app, headers)
|
||||
const orgId = await getFirstOrgId(db)
|
||||
|
||||
await postWebhook(
|
||||
app,
|
||||
JSON.stringify({
|
||||
eventId: 'evt-reactivate-inc-1',
|
||||
cloudOrderId: 'order-reactivate',
|
||||
targetOrgId: orgId,
|
||||
eventType: 'order.quota_changed',
|
||||
direction: 'increase',
|
||||
storageBytes: 4096,
|
||||
trafficBytes: 0,
|
||||
source: 'stripe',
|
||||
}),
|
||||
)
|
||||
await postWebhook(
|
||||
app,
|
||||
JSON.stringify({
|
||||
eventId: 'evt-reactivate-dec',
|
||||
cloudOrderId: 'order-reactivate',
|
||||
targetOrgId: orgId,
|
||||
eventType: 'order.quota_changed',
|
||||
direction: 'decrease',
|
||||
storageBytes: 4096,
|
||||
trafficBytes: 0,
|
||||
source: 'stripe',
|
||||
}),
|
||||
)
|
||||
const increase = await postWebhook(
|
||||
app,
|
||||
JSON.stringify({
|
||||
eventId: 'evt-reactivate-inc-2',
|
||||
cloudOrderId: 'order-reactivate',
|
||||
targetOrgId: orgId,
|
||||
eventType: 'order.quota_changed',
|
||||
direction: 'increase',
|
||||
storageBytes: 2048,
|
||||
trafficBytes: 0,
|
||||
source: 'stripe',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(increase.status).toBe(200)
|
||||
const entitlements = await db.all<{ bytes: number; status: string }>(
|
||||
sql`SELECT bytes, status FROM org_quota_entitlements WHERE source_id = 'order-reactivate' AND resource_type = 'storage'`,
|
||||
)
|
||||
expect(entitlements).toEqual([{ bytes: 2048, status: 'active' }])
|
||||
|
||||
const quotaRes = await app.request('/api/quotas/me', { headers })
|
||||
const quota = (await quotaRes.json()) as { entitlementQuota: number }
|
||||
expect(quota.entitlementQuota).toBe(2048)
|
||||
})
|
||||
|
||||
it('rejects legacy order delivery event types', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
@@ -1708,13 +1878,20 @@ describe('Quota Store API', () => {
|
||||
await expect(res.json()).resolves.toEqual({ error: 'invalid_payload' })
|
||||
})
|
||||
|
||||
it('applies storage decreases without going below zero', async () => {
|
||||
it('storage decreases revoke matching Cloud order entitlements without changing base quota', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
await seedSettings(app, headers)
|
||||
const orgId = await getFirstOrgId(db)
|
||||
await db.run(sql`UPDATE org_quotas SET quota = 2048 WHERE org_id = ${orgId}`)
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO org_quota_entitlements
|
||||
(id, org_id, resource_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
|
||||
VALUES
|
||||
('ent-storage-decrease', ${orgId}, 'storage', 'cloud_order', 'order-storage-decrease', 4096, ${now}, NULL, 'active', NULL, ${now}, ${now})
|
||||
`)
|
||||
|
||||
const res = await postWebhook(
|
||||
app,
|
||||
@@ -1731,10 +1908,14 @@ describe('Quota Store API', () => {
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const rows = await db.all<{ quota: number }>(sql`SELECT quota FROM org_quotas WHERE org_id = ${orgId}`)
|
||||
expect(rows[0].quota).toBe(0)
|
||||
expect(rows[0].quota).toBe(2048)
|
||||
const entitlements = await db.all<{ status: string }>(
|
||||
sql`SELECT status FROM org_quota_entitlements WHERE source_id = 'order-storage-decrease'`,
|
||||
)
|
||||
expect(entitlements).toEqual([{ status: 'revoked' }])
|
||||
})
|
||||
|
||||
it('applies traffic increases and decreases', async () => {
|
||||
it('records traffic increases and revokes them on matching decreases', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
@@ -1757,7 +1938,7 @@ describe('Quota Store API', () => {
|
||||
app,
|
||||
JSON.stringify({
|
||||
eventId: 'evt-traffic-decrease',
|
||||
cloudOrderId: 'order-traffic-decrease',
|
||||
cloudOrderId: 'order-traffic-increase',
|
||||
targetOrgId: orgId,
|
||||
eventType: 'order.quota_changed',
|
||||
direction: 'decrease',
|
||||
@@ -1766,10 +1947,15 @@ describe('Quota Store API', () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const rows = await db.all<{ trafficQuota: number }>(
|
||||
sql`SELECT traffic_quota AS trafficQuota FROM org_quotas WHERE org_id = ${orgId}`,
|
||||
const rows = await db.all<{ trafficQuota: number; status: string }>(
|
||||
sql`SELECT bytes AS trafficQuota, status FROM org_quota_entitlements WHERE source_id = 'order-traffic-increase'`,
|
||||
)
|
||||
expect(rows[0].trafficQuota).toBe(3072)
|
||||
expect(rows[0]).toEqual({ trafficQuota: 3072, status: 'active' })
|
||||
|
||||
const quotaRes = await app.request('/api/quotas/me', { headers })
|
||||
const quota = (await quotaRes.json()) as { entitlementTrafficQuota: number; trafficQuota: number }
|
||||
expect(quota.entitlementTrafficQuota).toBe(3072)
|
||||
expect(quota.trafficQuota).toBe(3072)
|
||||
})
|
||||
|
||||
it('processes same-order increase then decrease as two independent events', async () => {
|
||||
@@ -1814,6 +2000,10 @@ describe('Quota Store API', () => {
|
||||
|
||||
const rows = await db.all<{ quota: number }>(sql`SELECT quota FROM org_quotas WHERE org_id = ${orgId}`)
|
||||
expect(rows[0].quota).toBe(8192)
|
||||
const entitlements = await db.all<{ status: string }>(
|
||||
sql`SELECT status FROM org_quota_entitlements WHERE source_id = 'order-reversal-test'`,
|
||||
)
|
||||
expect(entitlements).toEqual([{ status: 'revoked' }])
|
||||
|
||||
const deliveries = await db.all<{ eventId: string; status: string }>(
|
||||
sql`SELECT event_id AS eventId, status FROM webhook_events WHERE event_id IN ('evt-order-increase', 'evt-order-decrease') ORDER BY created_at`,
|
||||
@@ -1830,6 +2020,60 @@ describe('Quota Store API', () => {
|
||||
expect(auditRows.map((r) => r.action)).toContain('quota_order_increase')
|
||||
})
|
||||
|
||||
it('does not fall back to base quota when a second decrease sees an already revoked entitlement', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
await seedSettings(app, headers)
|
||||
const orgId = await getFirstOrgId(db)
|
||||
await db.run(sql`UPDATE org_quotas SET quota = 8192 WHERE org_id = ${orgId}`)
|
||||
|
||||
await postWebhook(
|
||||
app,
|
||||
JSON.stringify({
|
||||
eventId: 'evt-double-decrease-increase',
|
||||
cloudOrderId: 'order-double-decrease',
|
||||
targetOrgId: orgId,
|
||||
eventType: 'order.quota_changed',
|
||||
direction: 'increase',
|
||||
storageBytes: 4096,
|
||||
trafficBytes: 0,
|
||||
source: 'stripe',
|
||||
}),
|
||||
)
|
||||
const firstDecrease = await postWebhook(
|
||||
app,
|
||||
JSON.stringify({
|
||||
eventId: 'evt-double-decrease-first',
|
||||
cloudOrderId: 'order-double-decrease',
|
||||
targetOrgId: orgId,
|
||||
eventType: 'order.quota_changed',
|
||||
direction: 'decrease',
|
||||
storageBytes: 4096,
|
||||
trafficBytes: 0,
|
||||
source: 'stripe',
|
||||
}),
|
||||
)
|
||||
const secondDecrease = await postWebhook(
|
||||
app,
|
||||
JSON.stringify({
|
||||
eventId: 'evt-double-decrease-second',
|
||||
cloudOrderId: 'order-double-decrease',
|
||||
targetOrgId: orgId,
|
||||
eventType: 'order.quota_changed',
|
||||
direction: 'decrease',
|
||||
storageBytes: 4096,
|
||||
trafficBytes: 0,
|
||||
source: 'stripe',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(firstDecrease.status).toBe(200)
|
||||
expect(secondDecrease.status).toBe(200)
|
||||
const rows = await db.all<{ quota: number }>(sql`SELECT quota FROM org_quotas WHERE org_id = ${orgId}`)
|
||||
expect(rows[0].quota).toBe(8192)
|
||||
})
|
||||
|
||||
it('replaying the same decrease event is idempotent and does not double-deduct', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
@@ -1865,6 +2109,36 @@ describe('Quota Store API', () => {
|
||||
expect(rows[0].quota).toBe(6144)
|
||||
})
|
||||
|
||||
it('reverses pre-migration Cloud order quota stored in base quota', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
await seedSettings(app, headers)
|
||||
const orgId = await getFirstOrgId(db)
|
||||
await db.run(sql`UPDATE org_quotas SET quota = 8192, traffic_quota = 4096 WHERE org_id = ${orgId}`)
|
||||
|
||||
const decrease = await postWebhook(
|
||||
app,
|
||||
JSON.stringify({
|
||||
eventId: 'evt-legacy-base-decrease',
|
||||
cloudOrderId: 'order-legacy-base',
|
||||
targetOrgId: orgId,
|
||||
eventType: 'order.quota_changed',
|
||||
direction: 'decrease',
|
||||
storageBytes: 2048,
|
||||
trafficBytes: 1024,
|
||||
source: 'stripe',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(decrease.status).toBe(200)
|
||||
await expect(decrease.json()).resolves.toMatchObject({ success: true, duplicate: false })
|
||||
const rows = await db.all<{ quota: number; trafficQuota: number }>(
|
||||
sql`SELECT quota, traffic_quota AS trafficQuota FROM org_quotas WHERE org_id = ${orgId}`,
|
||||
)
|
||||
expect(rows[0]).toEqual({ quota: 6144, trafficQuota: 3072 })
|
||||
})
|
||||
|
||||
it('records decrease audit event with correct action and metadata', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
@@ -1937,10 +2211,10 @@ describe('Quota Store API', () => {
|
||||
expect(decrease.status).toBe(200)
|
||||
await expect(decrease.json()).resolves.toMatchObject({ success: true, duplicate: false })
|
||||
|
||||
const rows = await db.all<{ trafficQuota: number }>(
|
||||
sql`SELECT traffic_quota AS trafficQuota FROM org_quotas WHERE org_id = ${orgId}`,
|
||||
const rows = await db.all<{ status: string }>(
|
||||
sql`SELECT status FROM org_quota_entitlements WHERE source_id = 'order-traffic-reversal'`,
|
||||
)
|
||||
expect(rows[0].trafficQuota).toBe(0)
|
||||
expect(rows[0].status).toBe('revoked')
|
||||
})
|
||||
|
||||
it('rejects failed delivery retries when the payload hash changes', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { orgQuotas } from '../db/schema.js'
|
||||
import { orgQuotaEntitlements, orgQuotas } from '../db/schema.js'
|
||||
import { currentTrafficPeriod } from '../services/effective-quota.js'
|
||||
import { S3Service } from '../services/s3.js'
|
||||
import { authedHeaders, createTestApp, seedProLicense } from '../test/setup.js'
|
||||
@@ -80,6 +80,28 @@ async function setOrgQuota(
|
||||
}
|
||||
}
|
||||
|
||||
async function addStorageEntitlement(
|
||||
db: Awaited<ReturnType<typeof createTestApp>>['db'],
|
||||
orgId: string,
|
||||
bytes: number,
|
||||
) {
|
||||
const now = new Date()
|
||||
await db.insert(orgQuotaEntitlements).values({
|
||||
id: nanoid(),
|
||||
orgId,
|
||||
resourceType: 'storage',
|
||||
source: 'test',
|
||||
sourceId: nanoid(),
|
||||
bytes,
|
||||
startsAt: now,
|
||||
expiresAt: null,
|
||||
status: 'active',
|
||||
metadata: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── POST /api/objects/copy — quota enforcement ──────────────────────────
|
||||
|
||||
describe('POST /api/objects/copy — quota enforcement', () => {
|
||||
@@ -332,6 +354,49 @@ describe('PATCH /api/objects/:id (action: confirm) — quota enforcement via con
|
||||
expect(quotaRows[0].used).toBe(350)
|
||||
})
|
||||
|
||||
it('uses active storage entitlements when confirming upload', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
await setOrgQuota(db, orgId, 100, 90)
|
||||
await addStorageEntitlement(db, orgId, 100)
|
||||
await insertFile(db, orgId, { id: 'm-done-entitlement', name: 'entitled.txt', size: 50, status: 'draft' })
|
||||
|
||||
const res = await app.request('/api/objects/m-done-entitlement', {
|
||||
method: 'PATCH',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'confirm' }),
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const quotaRows = await db.all<{ used: number; quota: number }>(
|
||||
sql`SELECT used, quota FROM org_quotas WHERE org_id = ${orgId}`,
|
||||
)
|
||||
expect(quotaRows[0]).toEqual({ used: 140, quota: 100 })
|
||||
})
|
||||
|
||||
it('enforces storage entitlements when base quota is unlimited', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
await setOrgQuota(db, orgId, 0, 90)
|
||||
await addStorageEntitlement(db, orgId, 100)
|
||||
await insertFile(db, orgId, { id: 'm-done-zero-base-entitlement', name: 'limited.txt', size: 11, status: 'draft' })
|
||||
|
||||
const res = await app.request('/api/objects/m-done-zero-base-entitlement', {
|
||||
method: 'PATCH',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'confirm' }),
|
||||
})
|
||||
|
||||
expect(res.status).toBe(422)
|
||||
await expect(res.json()).resolves.toMatchObject({ error: 'Quota exceeded' })
|
||||
})
|
||||
|
||||
it('returns 200 and increments storages.used when quota allows', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
|
||||
@@ -241,6 +241,38 @@ describe('Admin Quotas API', () => {
|
||||
expect(body.items[0].orgName).toBeTruthy()
|
||||
expect(body.items[0].orgType).toBe('personal')
|
||||
})
|
||||
|
||||
it('GET /api/admin/quotas lists effective quota with active entitlements', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const orgs = await db.all<{ id: string }>(
|
||||
sql`SELECT o.id FROM organization o WHERE o.metadata LIKE '%"type":"personal"%' LIMIT 1`,
|
||||
)
|
||||
const orgId = orgs[0].id
|
||||
const now = Date.now()
|
||||
await db.run(sql`UPDATE org_quotas SET quota = 5000, traffic_quota = 1000 WHERE org_id = ${orgId}`)
|
||||
await db.run(sql`
|
||||
INSERT INTO org_quota_entitlements
|
||||
(id, org_id, resource_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
|
||||
VALUES
|
||||
('ent-admin-storage', ${orgId}, 'storage', 'test', 'admin-storage', 3000, ${now}, NULL, 'active', NULL, ${now}, ${now}),
|
||||
('ent-admin-traffic', ${orgId}, 'traffic', 'test', 'admin-traffic', 2000, ${now}, NULL, 'active', NULL, ${now}, ${now}),
|
||||
('ent-admin-revoked', ${orgId}, 'storage', 'test', 'admin-revoked', 9000, ${now}, NULL, 'revoked', NULL, ${now}, ${now})
|
||||
`)
|
||||
|
||||
const res = await app.request('/api/admin/quotas', { headers })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { items: Array<Record<string, unknown>> }
|
||||
expect(body.items[0]).toMatchObject({
|
||||
baseQuota: 5000,
|
||||
entitlementQuota: 3000,
|
||||
quota: 8000,
|
||||
baseTrafficQuota: 1000,
|
||||
entitlementTrafficQuota: 2000,
|
||||
trafficQuota: 3000,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Quotas API — /api/quotas', () => {
|
||||
@@ -308,6 +340,37 @@ describe('User Quotas API — /api/quotas', () => {
|
||||
expect(body.trafficUsed).toBe(0)
|
||||
})
|
||||
|
||||
it('GET /api/quotas/me returns base quota plus active entitlements', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const adminH = await adminHeaders(app)
|
||||
const orgs = await db.all<{ id: string }>(
|
||||
sql`SELECT o.id FROM organization o WHERE o.metadata LIKE '%"type":"personal"%' LIMIT 1`,
|
||||
)
|
||||
const orgId = orgs[0].id
|
||||
const now = Date.now()
|
||||
await db.run(sql`UPDATE org_quotas SET quota = 1000, traffic_quota = 2000 WHERE org_id = ${orgId}`)
|
||||
await db.run(sql`
|
||||
INSERT INTO org_quota_entitlements
|
||||
(id, org_id, resource_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
|
||||
VALUES
|
||||
('ent-user-storage', ${orgId}, 'storage', 'test', 'user-storage', 4000, ${now}, NULL, 'active', NULL, ${now}, ${now}),
|
||||
('ent-user-traffic', ${orgId}, 'traffic', 'test', 'user-traffic', 6000, ${now}, NULL, 'active', NULL, ${now}, ${now})
|
||||
`)
|
||||
|
||||
const res = await app.request('/api/quotas/me', { headers: adminH })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body).toMatchObject({
|
||||
baseQuota: 1000,
|
||||
entitlementQuota: 4000,
|
||||
quota: 5000,
|
||||
baseTrafficQuota: 2000,
|
||||
entitlementTrafficQuota: 6000,
|
||||
trafficQuota: 8000,
|
||||
})
|
||||
})
|
||||
|
||||
it('admin quota updates current org quota without historical grant aggregation', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const adminH = await adminHeaders(app)
|
||||
|
||||
+23
-3
@@ -4,7 +4,7 @@ import { Hono } from 'hono'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { z } from 'zod'
|
||||
import { organization } from '../db/auth-schema'
|
||||
import { orgQuotas } from '../db/schema'
|
||||
import { orgQuotaEntitlements, orgQuotas } from '../db/schema'
|
||||
import { requireAdmin, requireAuth } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { recordActivity } from '../services/activity'
|
||||
@@ -21,6 +21,7 @@ const adminQuotas = new Hono<Env>()
|
||||
.get('/', async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const period = currentTrafficPeriod()
|
||||
const now = new Date()
|
||||
|
||||
await db
|
||||
.update(orgQuotas)
|
||||
@@ -32,7 +33,10 @@ const adminQuotas = new Hono<Env>()
|
||||
id: orgQuotas.id,
|
||||
orgId: orgQuotas.orgId,
|
||||
baseQuota: orgQuotas.quota,
|
||||
entitlementQuota: activeEntitlementBytesSql('storage', now),
|
||||
used: orgQuotas.used,
|
||||
baseTrafficQuota: orgQuotas.trafficQuota,
|
||||
entitlementTrafficQuota: activeEntitlementBytesSql('traffic', now),
|
||||
trafficQuota: orgQuotas.trafficQuota,
|
||||
trafficUsed: orgQuotas.trafficUsed,
|
||||
trafficPeriod: orgQuotas.trafficPeriod,
|
||||
@@ -47,9 +51,12 @@ const adminQuotas = new Hono<Env>()
|
||||
id: r.id,
|
||||
orgId: r.orgId,
|
||||
baseQuota: r.baseQuota,
|
||||
quota: r.baseQuota,
|
||||
entitlementQuota: r.entitlementQuota,
|
||||
quota: r.baseQuota + r.entitlementQuota,
|
||||
used: r.used,
|
||||
trafficQuota: r.trafficQuota,
|
||||
baseTrafficQuota: r.baseTrafficQuota,
|
||||
entitlementTrafficQuota: r.entitlementTrafficQuota,
|
||||
trafficQuota: r.trafficQuota + r.entitlementTrafficQuota,
|
||||
trafficUsed: r.trafficUsed,
|
||||
trafficPeriod: r.trafficPeriod,
|
||||
orgName: r.orgName,
|
||||
@@ -120,3 +127,16 @@ function parseOrgType(metadata: string | null): string {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
function activeEntitlementBytesSql(resourceType: 'storage' | 'traffic', now: Date) {
|
||||
const timestamp = now.getTime()
|
||||
return sql<number>`(
|
||||
SELECT COALESCE(SUM(${orgQuotaEntitlements.bytes}), 0)
|
||||
FROM ${orgQuotaEntitlements}
|
||||
WHERE ${orgQuotaEntitlements.orgId} = ${orgQuotas.orgId}
|
||||
AND ${orgQuotaEntitlements.resourceType} = ${resourceType}
|
||||
AND ${orgQuotaEntitlements.status} = 'active'
|
||||
AND ${orgQuotaEntitlements.startsAt} <= ${timestamp}
|
||||
AND (${orgQuotaEntitlements.expiresAt} IS NULL OR ${orgQuotaEntitlements.expiresAt} > ${timestamp})
|
||||
)`
|
||||
}
|
||||
|
||||
@@ -1,34 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { activityEvents, orgQuotas, webhookEvents } from '../db/schema'
|
||||
import { activityEvents, orgQuotaEntitlements, orgQuotas, webhookEvents } from '../db/schema'
|
||||
import type { Database } from '../platform/interface'
|
||||
import { processCloudOrderQuotaChange } from './cloud-store'
|
||||
|
||||
function createAsyncDb(quotaRows: Array<{ id: string }> = [{ id: 'quota-1' }]) {
|
||||
function createAsyncDb(
|
||||
quotaRows: Array<{ id: string }> = [{ id: 'quota-1' }],
|
||||
entitlementRevokeRows: Array<{ id: string }> = [{ id: 'entitlement-revoked' }],
|
||||
existingEntitlementRows: Array<{ id: string }> = [],
|
||||
) {
|
||||
const state = {
|
||||
audits: 0,
|
||||
webhookStatus: '',
|
||||
quotaUpdates: 0,
|
||||
entitlementInserts: 0,
|
||||
entitlementRevokes: 0,
|
||||
legacyQuotaUpdates: 0,
|
||||
}
|
||||
const db = {
|
||||
constructor: { name: 'AsyncTestDatabase' },
|
||||
transaction: async (fn: (tx: unknown) => Promise<void>) => fn(db),
|
||||
insert: (table: unknown) => ({
|
||||
values: async (values: Record<string, unknown>) => {
|
||||
if (table === webhookEvents) state.webhookStatus = String(values.status)
|
||||
if (table === activityEvents) state.audits += 1
|
||||
values: (values: Record<string, unknown>) => {
|
||||
const apply = () => {
|
||||
if (table === webhookEvents) state.webhookStatus = String(values.status)
|
||||
if (table === activityEvents) state.audits += 1
|
||||
if (table === orgQuotaEntitlements) state.entitlementInserts += Array.isArray(values) ? values.length : 1
|
||||
}
|
||||
if (table === orgQuotaEntitlements) return { onConflictDoUpdate: async () => apply() }
|
||||
return Promise.resolve(apply())
|
||||
},
|
||||
}),
|
||||
select: () => ({
|
||||
from: (table: unknown) => ({
|
||||
where: () => ({
|
||||
limit: async () => {
|
||||
if (table === orgQuotas) return quotaRows
|
||||
if (table === orgQuotaEntitlements) return existingEntitlementRows
|
||||
return []
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
update: (table: unknown) => ({
|
||||
set: (values: Record<string, unknown>) => ({
|
||||
where: () => {
|
||||
if (table === orgQuotas) {
|
||||
if (table === orgQuotaEntitlements) {
|
||||
return {
|
||||
returning: async () => {
|
||||
state.quotaUpdates += 1
|
||||
return quotaRows
|
||||
state.entitlementRevokes += 1
|
||||
return entitlementRevokeRows
|
||||
},
|
||||
}
|
||||
}
|
||||
if (table === orgQuotas) state.legacyQuotaUpdates += 1
|
||||
if (table === webhookEvents) state.webhookStatus = String(values.status)
|
||||
return Promise.resolve()
|
||||
},
|
||||
@@ -53,7 +76,8 @@ function createUniqueConflictDb(existing: { id: string; payloadHash: string; sta
|
||||
const state = {
|
||||
audits: 0,
|
||||
webhookStatus: existing?.status ?? '',
|
||||
quotaUpdates: 0,
|
||||
entitlementInserts: 0,
|
||||
entitlementRevokes: 0,
|
||||
}
|
||||
const db = {
|
||||
constructor: { name: 'AsyncTestDatabase' },
|
||||
@@ -62,24 +86,29 @@ function createUniqueConflictDb(existing: { id: string; payloadHash: string; sta
|
||||
values: async (values: Record<string, unknown>) => {
|
||||
if (table === webhookEvents) throw new Error('UNIQUE constraint failed: webhook_events.source, event_id')
|
||||
if (table === activityEvents) state.audits += 1
|
||||
if (table === orgQuotaEntitlements) state.entitlementInserts += Array.isArray(values) ? values.length : 1
|
||||
return values
|
||||
},
|
||||
}),
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
from: (table: unknown) => ({
|
||||
where: () => ({
|
||||
limit: async () => (existing ? [existing] : []),
|
||||
limit: async () => {
|
||||
if (table === webhookEvents) return existing ? [existing] : []
|
||||
if (table === orgQuotas) return [{ id: 'quota-retry' }]
|
||||
return []
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
update: (table: unknown) => ({
|
||||
set: (values: Record<string, unknown>) => ({
|
||||
where: () => {
|
||||
if (table === orgQuotas) {
|
||||
if (table === orgQuotaEntitlements) {
|
||||
return {
|
||||
returning: async () => {
|
||||
state.quotaUpdates += 1
|
||||
return [{ id: 'quota-retry' }]
|
||||
state.entitlementRevokes += 1
|
||||
return [{ id: 'entitlement-revoked' }]
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -93,11 +122,16 @@ function createUniqueConflictDb(existing: { id: string; payloadHash: string; sta
|
||||
return { db: db as unknown as Database, state }
|
||||
}
|
||||
|
||||
function createSyncDb() {
|
||||
function createSyncDb(
|
||||
entitlementRevokeRows: Array<{ id: string }> = [{ id: 'entitlement-revoked' }],
|
||||
existingEntitlementRows: Array<{ id: string }> = [],
|
||||
) {
|
||||
const state = {
|
||||
audits: 0,
|
||||
webhookStatus: '',
|
||||
quotaUpdates: 0,
|
||||
entitlementInserts: 0,
|
||||
entitlementRevokes: 0,
|
||||
legacyQuotaUpdates: 0,
|
||||
}
|
||||
|
||||
class BetterSQLite3Database {
|
||||
@@ -108,30 +142,50 @@ function createSyncDb() {
|
||||
insert(table: unknown) {
|
||||
return {
|
||||
values: (values: Record<string, unknown>) => {
|
||||
const apply = () => {
|
||||
if (table === webhookEvents) state.webhookStatus = String(values.status)
|
||||
if (table === activityEvents) state.audits += 1
|
||||
if (table === orgQuotaEntitlements) state.entitlementInserts += Array.isArray(values) ? values.length : 1
|
||||
}
|
||||
return {
|
||||
run: () => {
|
||||
if (table === webhookEvents) state.webhookStatus = String(values.status)
|
||||
if (table === activityEvents) state.audits += 1
|
||||
},
|
||||
run: apply,
|
||||
onConflictDoUpdate: () => ({ run: apply }),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
select() {
|
||||
return {
|
||||
from: (table: unknown) => ({
|
||||
where: () => ({
|
||||
limit: () => ({
|
||||
all: () => {
|
||||
if (table === orgQuotas) return [{ id: 'quota-sync' }]
|
||||
if (table === orgQuotaEntitlements) return existingEntitlementRows
|
||||
return []
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
update(table: unknown) {
|
||||
return {
|
||||
set: (values: Record<string, unknown>) => ({
|
||||
where: () => {
|
||||
if (table === orgQuotas) {
|
||||
if (table === orgQuotaEntitlements) {
|
||||
return {
|
||||
returning: () => ({
|
||||
all: () => {
|
||||
state.quotaUpdates += 1
|
||||
return [{ id: 'quota-sync' }]
|
||||
state.entitlementRevokes += 1
|
||||
return entitlementRevokeRows
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (table === orgQuotas) state.legacyQuotaUpdates += 1
|
||||
state.webhookStatus = String(values.status)
|
||||
return { run: () => undefined }
|
||||
},
|
||||
@@ -160,7 +214,7 @@ describe('processCloudOrderQuotaChange', () => {
|
||||
duplicate: false,
|
||||
eventId: 'evt-async',
|
||||
})
|
||||
expect(state).toMatchObject({ audits: 1, webhookStatus: 'processed', quotaUpdates: 1 })
|
||||
expect(state).toMatchObject({ audits: 1, webhookStatus: 'processed', entitlementInserts: 1 })
|
||||
})
|
||||
|
||||
it('processes quota change with a sync transaction database', async () => {
|
||||
@@ -179,7 +233,85 @@ describe('processCloudOrderQuotaChange', () => {
|
||||
duplicate: false,
|
||||
eventId: 'evt-sync',
|
||||
})
|
||||
expect(state).toMatchObject({ audits: 1, webhookStatus: 'processed', quotaUpdates: 1 })
|
||||
expect(state).toMatchObject({ audits: 1, webhookStatus: 'processed', entitlementRevokes: 1 })
|
||||
})
|
||||
|
||||
it('applies legacy base quota decrease with a sync transaction database', async () => {
|
||||
const { db, state } = createSyncDb([], [])
|
||||
const event = {
|
||||
eventId: 'evt-sync-legacy',
|
||||
eventType: 'order.quota_changed' as const,
|
||||
cloudOrderId: 'order-sync-legacy',
|
||||
targetOrgId: 'org-sync-legacy',
|
||||
direction: 'decrease' as const,
|
||||
storageBytes: 4096,
|
||||
trafficBytes: 0,
|
||||
}
|
||||
|
||||
await expect(processCloudOrderQuotaChange(db, event, JSON.stringify(event), 'hash-sync-legacy')).resolves.toEqual({
|
||||
duplicate: false,
|
||||
eventId: 'evt-sync-legacy',
|
||||
})
|
||||
expect(state).toMatchObject({ audits: 1, webhookStatus: 'processed', legacyQuotaUpdates: 1 })
|
||||
})
|
||||
|
||||
it('does not apply sync legacy base decrease when a matching entitlement already exists', async () => {
|
||||
const { db, state } = createSyncDb([], [{ id: 'entitlement-revoked' }])
|
||||
const event = {
|
||||
eventId: 'evt-sync-existing',
|
||||
eventType: 'order.quota_changed' as const,
|
||||
cloudOrderId: 'order-sync-existing',
|
||||
targetOrgId: 'org-sync-existing',
|
||||
direction: 'decrease' as const,
|
||||
storageBytes: 4096,
|
||||
trafficBytes: 0,
|
||||
}
|
||||
|
||||
await expect(processCloudOrderQuotaChange(db, event, JSON.stringify(event), 'hash-sync-existing')).resolves.toEqual(
|
||||
{
|
||||
duplicate: false,
|
||||
eventId: 'evt-sync-existing',
|
||||
},
|
||||
)
|
||||
expect(state).toMatchObject({ audits: 1, webhookStatus: 'processed', legacyQuotaUpdates: 0 })
|
||||
})
|
||||
|
||||
it('applies legacy base quota decrease when no entitlement exists for the Cloud order', async () => {
|
||||
const { db, state } = createAsyncDb([{ id: 'quota-legacy' }], [], [])
|
||||
const event = {
|
||||
eventId: 'evt-legacy-decrease',
|
||||
eventType: 'order.quota_changed' as const,
|
||||
cloudOrderId: 'order-legacy-decrease',
|
||||
targetOrgId: 'org-legacy',
|
||||
direction: 'decrease' as const,
|
||||
storageBytes: 4096,
|
||||
trafficBytes: 2048,
|
||||
}
|
||||
|
||||
await expect(processCloudOrderQuotaChange(db, event, JSON.stringify(event), 'hash-legacy')).resolves.toEqual({
|
||||
duplicate: false,
|
||||
eventId: 'evt-legacy-decrease',
|
||||
})
|
||||
expect(state).toMatchObject({ audits: 1, webhookStatus: 'processed', legacyQuotaUpdates: 1 })
|
||||
})
|
||||
|
||||
it('does not apply legacy base decrease when a matching entitlement already exists', async () => {
|
||||
const { db, state } = createAsyncDb([{ id: 'quota-existing-entitlement' }], [], [{ id: 'entitlement-revoked' }])
|
||||
const event = {
|
||||
eventId: 'evt-existing-entitlement-decrease',
|
||||
eventType: 'order.quota_changed' as const,
|
||||
cloudOrderId: 'order-existing-entitlement',
|
||||
targetOrgId: 'org-existing-entitlement',
|
||||
direction: 'decrease' as const,
|
||||
storageBytes: 4096,
|
||||
trafficBytes: 0,
|
||||
}
|
||||
|
||||
await expect(processCloudOrderQuotaChange(db, event, JSON.stringify(event), 'hash-existing')).resolves.toEqual({
|
||||
duplicate: false,
|
||||
eventId: 'evt-existing-entitlement-decrease',
|
||||
})
|
||||
expect(state).toMatchObject({ audits: 1, webhookStatus: 'processed', legacyQuotaUpdates: 0 })
|
||||
})
|
||||
|
||||
it('marks async quota change failed when the target quota is missing', async () => {
|
||||
@@ -197,7 +329,7 @@ describe('processCloudOrderQuotaChange', () => {
|
||||
await expect(processCloudOrderQuotaChange(db, event, JSON.stringify(event), 'hash-missing')).rejects.toThrow(
|
||||
'target_quota_missing',
|
||||
)
|
||||
expect(state).toMatchObject({ audits: 0, webhookStatus: 'failed', quotaUpdates: 1 })
|
||||
expect(state).toMatchObject({ audits: 0, webhookStatus: 'failed', entitlementRevokes: 0 })
|
||||
})
|
||||
|
||||
it('surfaces quota change webhook insert failures', async () => {
|
||||
@@ -236,7 +368,7 @@ describe('processCloudOrderQuotaChange', () => {
|
||||
duplicate: true,
|
||||
eventId: 'evt-duplicate',
|
||||
})
|
||||
expect(state).toMatchObject({ audits: 0, webhookStatus: 'processed', quotaUpdates: 0 })
|
||||
expect(state).toMatchObject({ audits: 0, webhookStatus: 'processed', entitlementInserts: 0 })
|
||||
})
|
||||
|
||||
it('rejects duplicate webhook events when the payload hash changes', async () => {
|
||||
@@ -280,6 +412,6 @@ describe('processCloudOrderQuotaChange', () => {
|
||||
duplicate: false,
|
||||
eventId: 'evt-retry',
|
||||
})
|
||||
expect(state).toMatchObject({ audits: 1, webhookStatus: 'processed', quotaUpdates: 1 })
|
||||
expect(state).toMatchObject({ audits: 1, webhookStatus: 'processed', entitlementRevokes: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
+227
-25
@@ -3,7 +3,7 @@ import type { CloudStoreSettings, CloudStoreTarget } from '@shared/types'
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { member, organization, user } from '../db/auth-schema'
|
||||
import { activityEvents, orgQuotas, systemOptions, webhookEvents } from '../db/schema'
|
||||
import { activityEvents, orgQuotaEntitlements, orgQuotas, systemOptions, webhookEvents } from '../db/schema'
|
||||
import { loadActiveLicenseBinding } from '../licensing/license-state'
|
||||
import type { Database } from '../platform/interface'
|
||||
|
||||
@@ -154,43 +154,33 @@ async function processQuotaChangeTransaction(
|
||||
}
|
||||
|
||||
async function applyQuotaChange(db: Database, event: CloudOrderQuotaChange): Promise<void> {
|
||||
const rows = await db
|
||||
.update(orgQuotas)
|
||||
.set(quotaUpdateValues(event))
|
||||
.where(eq(orgQuotas.orgId, event.targetOrgId))
|
||||
.returning({ id: orgQuotas.id })
|
||||
await requireTargetQuota(db, event.targetOrgId)
|
||||
|
||||
if (rows.length === 0) throw new Error('target_quota_missing')
|
||||
const now = new Date(event.occurredAt ?? Date.now())
|
||||
if (event.direction === 'increase') {
|
||||
await insertQuotaEntitlements(db, event, now)
|
||||
return
|
||||
}
|
||||
|
||||
await revokeQuotaEntitlements(db, event, now)
|
||||
}
|
||||
|
||||
function applyQuotaChangeSync(db: Database, event: CloudOrderQuotaChange): void {
|
||||
const rows = (
|
||||
db
|
||||
.update(orgQuotas)
|
||||
.set(quotaUpdateValues(event))
|
||||
.where(eq(orgQuotas.orgId, event.targetOrgId))
|
||||
.returning({ id: orgQuotas.id }) as {
|
||||
db.select({ id: orgQuotas.id }).from(orgQuotas).where(eq(orgQuotas.orgId, event.targetOrgId)).limit(1) as {
|
||||
all(): Array<{ id: string }>
|
||||
}
|
||||
).all()
|
||||
|
||||
if (rows.length === 0) throw new Error('target_quota_missing')
|
||||
}
|
||||
|
||||
function quotaUpdateValues(event: CloudOrderQuotaChange) {
|
||||
return {
|
||||
quota: quotaUpdateExpression(orgQuotas.quota, event.storageBytes, event.direction),
|
||||
trafficQuota: quotaUpdateExpression(orgQuotas.trafficQuota, event.trafficBytes, event.direction),
|
||||
const now = new Date(event.occurredAt ?? Date.now())
|
||||
if (event.direction === 'increase') {
|
||||
insertQuotaEntitlementsSync(db, event, now)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function quotaUpdateExpression(
|
||||
column: typeof orgQuotas.quota | typeof orgQuotas.trafficQuota,
|
||||
bytes: number,
|
||||
direction: CloudOrderQuotaChange['direction'],
|
||||
) {
|
||||
if (direction === 'increase') return sql`${column} + ${bytes}`
|
||||
return sql`MAX(0, ${column} - ${bytes})`
|
||||
revokeQuotaEntitlementsSync(db, event, now)
|
||||
}
|
||||
|
||||
async function recordQuotaChangeAudit(db: Database, event: CloudOrderQuotaChange): Promise<void> {
|
||||
@@ -222,6 +212,218 @@ function quotaChangeAuditValues(event: CloudOrderQuotaChange): typeof activityEv
|
||||
}
|
||||
}
|
||||
|
||||
async function requireTargetQuota(db: Database, orgId: string): Promise<void> {
|
||||
const rows = await db.select({ id: orgQuotas.id }).from(orgQuotas).where(eq(orgQuotas.orgId, orgId)).limit(1)
|
||||
if (rows.length === 0) throw new Error('target_quota_missing')
|
||||
}
|
||||
|
||||
async function insertQuotaEntitlements(db: Database, event: CloudOrderQuotaChange, now: Date): Promise<void> {
|
||||
const values = quotaEntitlementValues(event, now)
|
||||
if (values.length === 0) return
|
||||
for (const value of values) {
|
||||
await db
|
||||
.insert(orgQuotaEntitlements)
|
||||
.values(value)
|
||||
.onConflictDoUpdate({
|
||||
target: [orgQuotaEntitlements.source, orgQuotaEntitlements.sourceId, orgQuotaEntitlements.resourceType],
|
||||
set: quotaEntitlementIncreaseValues(value, now),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function insertQuotaEntitlementsSync(db: Database, event: CloudOrderQuotaChange, now: Date): void {
|
||||
const values = quotaEntitlementValues(event, now)
|
||||
if (values.length === 0) return
|
||||
for (const value of values) {
|
||||
;(
|
||||
db
|
||||
.insert(orgQuotaEntitlements)
|
||||
.values(value)
|
||||
.onConflictDoUpdate({
|
||||
target: [orgQuotaEntitlements.source, orgQuotaEntitlements.sourceId, orgQuotaEntitlements.resourceType],
|
||||
set: quotaEntitlementIncreaseValues(value, now),
|
||||
}) as { run(): void }
|
||||
).run()
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeQuotaEntitlements(db: Database, event: CloudOrderQuotaChange, now: Date): Promise<void> {
|
||||
const storageRevoked = await revokeQuotaEntitlement(db, event, 'storage', event.storageBytes, now)
|
||||
const trafficRevoked = await revokeQuotaEntitlement(db, event, 'traffic', event.trafficBytes, now)
|
||||
await applyLegacyQuotaDecrease(db, event, storageRevoked, trafficRevoked)
|
||||
}
|
||||
|
||||
function revokeQuotaEntitlementsSync(db: Database, event: CloudOrderQuotaChange, now: Date): void {
|
||||
const storageRevoked = revokeQuotaEntitlementSync(db, event, 'storage', event.storageBytes, now)
|
||||
const trafficRevoked = revokeQuotaEntitlementSync(db, event, 'traffic', event.trafficBytes, now)
|
||||
applyLegacyQuotaDecreaseSync(db, event, storageRevoked, trafficRevoked)
|
||||
}
|
||||
|
||||
async function revokeQuotaEntitlement(
|
||||
db: Database,
|
||||
event: CloudOrderQuotaChange,
|
||||
resourceType: 'storage' | 'traffic',
|
||||
bytes: number,
|
||||
now: Date,
|
||||
): Promise<boolean> {
|
||||
if (bytes === 0) return true
|
||||
const rows = await db
|
||||
.update(orgQuotaEntitlements)
|
||||
.set(quotaEntitlementDecreaseValues(bytes, now))
|
||||
.where(quotaEntitlementMatch(event, resourceType))
|
||||
.returning({ id: orgQuotaEntitlements.id })
|
||||
if (rows.length > 0) return true
|
||||
|
||||
const existing = await db
|
||||
.select({ id: orgQuotaEntitlements.id })
|
||||
.from(orgQuotaEntitlements)
|
||||
.where(quotaEntitlementSourceMatch(event, resourceType))
|
||||
.limit(1)
|
||||
return existing.length > 0
|
||||
}
|
||||
|
||||
function revokeQuotaEntitlementSync(
|
||||
db: Database,
|
||||
event: CloudOrderQuotaChange,
|
||||
resourceType: 'storage' | 'traffic',
|
||||
bytes: number,
|
||||
now: Date,
|
||||
): boolean {
|
||||
if (bytes === 0) return true
|
||||
const rows = (
|
||||
db
|
||||
.update(orgQuotaEntitlements)
|
||||
.set(quotaEntitlementDecreaseValues(bytes, now))
|
||||
.where(quotaEntitlementMatch(event, resourceType)) as {
|
||||
returning(fields: { id: typeof orgQuotaEntitlements.id }): { all(): Array<{ id: string }> }
|
||||
}
|
||||
)
|
||||
.returning({ id: orgQuotaEntitlements.id })
|
||||
.all()
|
||||
if (rows.length > 0) return true
|
||||
|
||||
const existing = (
|
||||
db
|
||||
.select({ id: orgQuotaEntitlements.id })
|
||||
.from(orgQuotaEntitlements)
|
||||
.where(quotaEntitlementSourceMatch(event, resourceType))
|
||||
.limit(1) as { all(): Array<{ id: string }> }
|
||||
).all()
|
||||
return existing.length > 0
|
||||
}
|
||||
|
||||
async function applyLegacyQuotaDecrease(
|
||||
db: Database,
|
||||
event: CloudOrderQuotaChange,
|
||||
storageRevoked: boolean,
|
||||
trafficRevoked: boolean,
|
||||
): Promise<void> {
|
||||
const values = legacyQuotaDecreaseValues(event, storageRevoked, trafficRevoked)
|
||||
if (!values) return
|
||||
await db.update(orgQuotas).set(values).where(eq(orgQuotas.orgId, event.targetOrgId))
|
||||
}
|
||||
|
||||
function applyLegacyQuotaDecreaseSync(
|
||||
db: Database,
|
||||
event: CloudOrderQuotaChange,
|
||||
storageRevoked: boolean,
|
||||
trafficRevoked: boolean,
|
||||
): void {
|
||||
const values = legacyQuotaDecreaseValues(event, storageRevoked, trafficRevoked)
|
||||
if (!values) return
|
||||
;(db.update(orgQuotas).set(values).where(eq(orgQuotas.orgId, event.targetOrgId)) as { run(): void }).run()
|
||||
}
|
||||
|
||||
function legacyQuotaDecreaseValues(
|
||||
event: CloudOrderQuotaChange,
|
||||
storageRevoked: boolean,
|
||||
trafficRevoked: boolean,
|
||||
): Partial<typeof orgQuotas.$inferInsert> | null {
|
||||
const values: Partial<typeof orgQuotas.$inferInsert> = {}
|
||||
if (!storageRevoked && event.storageBytes > 0)
|
||||
values.quota = sql`MAX(0, ${orgQuotas.quota} - ${event.storageBytes})` as unknown as number
|
||||
if (!trafficRevoked && event.trafficBytes > 0) {
|
||||
values.trafficQuota = sql`MAX(0, ${orgQuotas.trafficQuota} - ${event.trafficBytes})` as unknown as number
|
||||
}
|
||||
return Object.keys(values).length === 0 ? null : values
|
||||
}
|
||||
|
||||
function quotaEntitlementValues(event: CloudOrderQuotaChange, now: Date): (typeof orgQuotaEntitlements.$inferInsert)[] {
|
||||
return [
|
||||
quotaEntitlementValue(event, 'storage', event.storageBytes, now),
|
||||
quotaEntitlementValue(event, 'traffic', event.trafficBytes, now),
|
||||
].filter((value): value is typeof orgQuotaEntitlements.$inferInsert => value !== null)
|
||||
}
|
||||
|
||||
function quotaEntitlementIncreaseValues(value: typeof orgQuotaEntitlements.$inferInsert, now: Date) {
|
||||
return {
|
||||
bytes: sql`CASE
|
||||
WHEN ${orgQuotaEntitlements.status} = 'active' THEN ${orgQuotaEntitlements.bytes} + ${value.bytes}
|
||||
ELSE ${value.bytes}
|
||||
END`,
|
||||
status: 'active',
|
||||
expiresAt: value.expiresAt,
|
||||
metadata: value.metadata,
|
||||
updatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
function quotaEntitlementDecreaseValues(bytes: number, now: Date) {
|
||||
return {
|
||||
bytes: sql`MAX(0, ${orgQuotaEntitlements.bytes} - ${bytes})`,
|
||||
status:
|
||||
sql`CASE WHEN ${orgQuotaEntitlements.bytes} <= ${bytes} THEN 'revoked' ELSE 'active' END` as unknown as string,
|
||||
updatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
function quotaEntitlementValue(
|
||||
event: CloudOrderQuotaChange,
|
||||
resourceType: 'storage' | 'traffic',
|
||||
bytes: number,
|
||||
now: Date,
|
||||
): typeof orgQuotaEntitlements.$inferInsert | null {
|
||||
if (bytes === 0) return null
|
||||
return {
|
||||
id: nanoid(),
|
||||
orgId: event.targetOrgId,
|
||||
resourceType,
|
||||
source: 'cloud_order',
|
||||
sourceId: event.cloudOrderId,
|
||||
bytes,
|
||||
startsAt: now,
|
||||
expiresAt: null,
|
||||
status: 'active',
|
||||
metadata: JSON.stringify(quotaEntitlementMetadata(event)),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
function quotaEntitlementMatch(event: CloudOrderQuotaChange, resourceType: 'storage' | 'traffic') {
|
||||
return and(quotaEntitlementSourceMatch(event, resourceType), eq(orgQuotaEntitlements.status, 'active'))
|
||||
}
|
||||
|
||||
function quotaEntitlementSourceMatch(event: CloudOrderQuotaChange, resourceType: 'storage' | 'traffic') {
|
||||
return and(
|
||||
eq(orgQuotaEntitlements.orgId, event.targetOrgId),
|
||||
eq(orgQuotaEntitlements.resourceType, resourceType),
|
||||
eq(orgQuotaEntitlements.source, 'cloud_order'),
|
||||
eq(orgQuotaEntitlements.sourceId, event.cloudOrderId),
|
||||
)
|
||||
}
|
||||
|
||||
function quotaEntitlementMetadata(event: CloudOrderQuotaChange) {
|
||||
return {
|
||||
eventId: event.eventId,
|
||||
eventType: event.eventType,
|
||||
source: event.source ?? null,
|
||||
packageId: event.packageId ?? null,
|
||||
terminalUserId: event.terminalUserId ?? null,
|
||||
terminalUserEmail: event.terminalUserEmail ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
async function beginWebhookEvent(
|
||||
db: Database,
|
||||
event: CloudOrderQuotaChange,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { orgQuotas } from '../db/schema.js'
|
||||
import { orgQuotaEntitlements, orgQuotas } from '../db/schema.js'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
import {
|
||||
consumeTrafficIfQuotaAllows,
|
||||
getEffectiveQuota,
|
||||
hasQuotaForBytes,
|
||||
hasTrafficQuotaForBytes,
|
||||
refundTraffic,
|
||||
} from './effective-quota.js'
|
||||
@@ -35,6 +36,39 @@ describe('effective quota', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('adds active entitlement bytes to effective storage and traffic quota', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const now = new Date('2026-05-06T00:00:00Z')
|
||||
await db.insert(orgQuotas).values({
|
||||
id: nanoid(),
|
||||
orgId,
|
||||
quota: 1000,
|
||||
used: 250,
|
||||
trafficQuota: 2000,
|
||||
trafficUsed: 500,
|
||||
trafficPeriod: '2026-05',
|
||||
})
|
||||
await db.insert(orgQuotaEntitlements).values([
|
||||
entitlement(orgId, 'storage', 'active-storage', 300, 'active', now),
|
||||
entitlement(orgId, 'traffic', 'active-traffic', 700, 'active', now),
|
||||
entitlement(orgId, 'storage', 'revoked-storage', 900, 'revoked', now),
|
||||
{
|
||||
...entitlement(orgId, 'storage', 'expired-storage', 900, 'active', now),
|
||||
expiresAt: new Date('2026-05-05T00:00:00Z'),
|
||||
},
|
||||
])
|
||||
|
||||
await expect(getEffectiveQuota(db, orgId, now)).resolves.toMatchObject({
|
||||
baseQuota: 1000,
|
||||
entitlementQuota: 300,
|
||||
quota: 1300,
|
||||
baseTrafficQuota: 2000,
|
||||
entitlementTrafficQuota: 700,
|
||||
trafficQuota: 2700,
|
||||
})
|
||||
})
|
||||
|
||||
it('resets monthly traffic usage when the period changes', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
@@ -79,6 +113,48 @@ describe('effective quota', () => {
|
||||
expect(rows[0].trafficUsed).toBe(1000)
|
||||
})
|
||||
|
||||
it('consumes traffic against active traffic entitlements', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const now = new Date('2026-05-06T00:00:00Z')
|
||||
await db.insert(orgQuotas).values({
|
||||
id: nanoid(),
|
||||
orgId,
|
||||
quota: 1000,
|
||||
used: 0,
|
||||
trafficQuota: 1000,
|
||||
trafficUsed: 900,
|
||||
trafficPeriod: '2026-05',
|
||||
})
|
||||
await db.insert(orgQuotaEntitlements).values(entitlement(orgId, 'traffic', 'traffic-overage', 500, 'active', now))
|
||||
|
||||
await expect(consumeTrafficIfQuotaAllows(db, orgId, 400, now)).resolves.toBe(true)
|
||||
await expect(consumeTrafficIfQuotaAllows(db, orgId, 201, now)).resolves.toBe(false)
|
||||
|
||||
const rows = await db.select().from(orgQuotas).where(eq(orgQuotas.orgId, orgId))
|
||||
expect(rows[0].trafficUsed).toBe(1300)
|
||||
})
|
||||
|
||||
it('treats zero base traffic quota as limited when traffic entitlements exist', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const now = new Date('2026-05-06T00:00:00Z')
|
||||
await db.insert(orgQuotas).values({
|
||||
id: nanoid(),
|
||||
orgId,
|
||||
quota: 0,
|
||||
used: 0,
|
||||
trafficQuota: 0,
|
||||
trafficUsed: 400,
|
||||
trafficPeriod: '2026-05',
|
||||
})
|
||||
await db.insert(orgQuotaEntitlements).values(entitlement(orgId, 'traffic', 'traffic-zero-base', 500, 'active', now))
|
||||
|
||||
await expect(hasTrafficQuotaForBytes(db, orgId, 100, now)).resolves.toBe(true)
|
||||
await expect(consumeTrafficIfQuotaAllows(db, orgId, 100, now)).resolves.toBe(true)
|
||||
await expect(consumeTrafficIfQuotaAllows(db, orgId, 1, now)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('refunds current monthly traffic usage', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
@@ -179,4 +255,47 @@ describe('effective quota', () => {
|
||||
await expect(hasTrafficQuotaForBytes(db, orgId, 1024, now)).resolves.toBe(true)
|
||||
await expect(consumeTrafficIfQuotaAllows(db, orgId, 1024, now)).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('treats zero base storage quota as limited when storage entitlements exist', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const now = new Date('2026-05-06T00:00:00Z')
|
||||
await db.insert(orgQuotas).values({
|
||||
id: nanoid(),
|
||||
orgId,
|
||||
quota: 0,
|
||||
used: 400,
|
||||
trafficQuota: 0,
|
||||
trafficUsed: 0,
|
||||
trafficPeriod: '2026-05',
|
||||
})
|
||||
await db.insert(orgQuotaEntitlements).values(entitlement(orgId, 'storage', 'storage-zero-base', 500, 'active', now))
|
||||
|
||||
await expect(hasQuotaForBytes(db, orgId, 100)).resolves.toBe(true)
|
||||
await expect(hasQuotaForBytes(db, orgId, 101)).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
function entitlement(
|
||||
orgId: string,
|
||||
resourceType: 'storage' | 'traffic',
|
||||
sourceId: string,
|
||||
bytes: number,
|
||||
status: string,
|
||||
now: Date,
|
||||
): typeof orgQuotaEntitlements.$inferInsert {
|
||||
return {
|
||||
id: nanoid(),
|
||||
orgId,
|
||||
resourceType,
|
||||
source: 'test',
|
||||
sourceId,
|
||||
bytes,
|
||||
startsAt: now,
|
||||
expiresAt: null,
|
||||
status,
|
||||
metadata: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { orgQuotas, storages } from '../db/schema'
|
||||
import { and, eq, or, sql } from 'drizzle-orm'
|
||||
import { orgQuotaEntitlements, orgQuotas, storages } from '../db/schema'
|
||||
import type { Database } from '../platform/interface'
|
||||
|
||||
export interface EffectiveQuota {
|
||||
orgId: string
|
||||
baseQuota: number
|
||||
entitlementQuota: number
|
||||
quota: number
|
||||
used: number
|
||||
baseTrafficQuota: number
|
||||
entitlementTrafficQuota: number
|
||||
trafficQuota: number
|
||||
trafficUsed: number
|
||||
trafficPeriod: string
|
||||
@@ -39,14 +42,20 @@ export async function getEffectiveQuota(db: Database, orgId: string, now = new D
|
||||
const quotaRow = quotaRows[0]
|
||||
|
||||
const baseQuota = quotaRow?.baseQuota ?? 0
|
||||
const entitlementQuota = await activeEntitlementBytes(db, orgId, 'storage', now)
|
||||
const entitlementTrafficQuota = await activeEntitlementBytes(db, orgId, 'traffic', now)
|
||||
const trafficUsed = quotaRow && quotaRow.trafficPeriod === period ? quotaRow.trafficUsed : 0
|
||||
const trafficPeriod = quotaRow?.trafficPeriod === period ? quotaRow.trafficPeriod : period
|
||||
const baseTrafficQuota = quotaRow?.trafficQuota ?? 0
|
||||
return {
|
||||
orgId,
|
||||
baseQuota,
|
||||
quota: baseQuota,
|
||||
entitlementQuota,
|
||||
quota: baseQuota + entitlementQuota,
|
||||
used: quotaRow?.used ?? 0,
|
||||
trafficQuota: quotaRow?.trafficQuota ?? 0,
|
||||
baseTrafficQuota,
|
||||
entitlementTrafficQuota,
|
||||
trafficQuota: baseTrafficQuota + entitlementTrafficQuota,
|
||||
trafficUsed,
|
||||
trafficPeriod,
|
||||
}
|
||||
@@ -55,7 +64,7 @@ export async function getEffectiveQuota(db: Database, orgId: string, now = new D
|
||||
export async function hasQuotaForBytes(db: Database, orgId: string, bytes: number): Promise<boolean> {
|
||||
if (bytes <= 0) return true
|
||||
const quota = await getEffectiveQuota(db, orgId)
|
||||
if (quota.baseQuota === 0) return true
|
||||
if (quota.baseQuota === 0 && quota.entitlementQuota === 0) return true
|
||||
return quota.used + bytes <= quota.quota
|
||||
}
|
||||
|
||||
@@ -67,7 +76,7 @@ export async function hasTrafficQuotaForBytes(
|
||||
): Promise<boolean> {
|
||||
if (bytes <= 0) return true
|
||||
const quota = await getEffectiveQuota(db, orgId, now)
|
||||
if (quota.trafficQuota === 0) return true
|
||||
if (quota.baseTrafficQuota === 0 && quota.entitlementTrafficQuota === 0) return true
|
||||
return quota.trafficUsed + bytes <= quota.trafficQuota
|
||||
}
|
||||
|
||||
@@ -87,25 +96,33 @@ export async function consumeTrafficIfQuotaAllows(
|
||||
if (quotaRows.length === 0) return true
|
||||
|
||||
if (quotaRows[0].trafficPeriod !== period) {
|
||||
const entitlementBytes = activeEntitlementBytesSql(orgId, 'traffic', now)
|
||||
const updated = await db
|
||||
.update(orgQuotas)
|
||||
.set({ trafficUsed: bytes, trafficPeriod: period })
|
||||
.where(
|
||||
sql`${orgQuotas.orgId} = ${orgId}
|
||||
AND ${orgQuotas.trafficPeriod} != ${period}
|
||||
AND (${orgQuotas.trafficQuota} = 0 OR ${bytes} <= ${orgQuotas.trafficQuota})`,
|
||||
AND (
|
||||
(${orgQuotas.trafficQuota} = 0 AND ${entitlementBytes} = 0)
|
||||
OR ${bytes} <= ${orgQuotas.trafficQuota} + ${entitlementBytes}
|
||||
)`,
|
||||
)
|
||||
.returning({ id: orgQuotas.id })
|
||||
if (updated.length > 0) return true
|
||||
}
|
||||
|
||||
const entitlementBytes = activeEntitlementBytesSql(orgId, 'traffic', now)
|
||||
const updated = await db
|
||||
.update(orgQuotas)
|
||||
.set({ trafficUsed: sql`${orgQuotas.trafficUsed} + ${bytes}` })
|
||||
.where(
|
||||
sql`${orgQuotas.orgId} = ${orgId}
|
||||
AND ${orgQuotas.trafficPeriod} = ${period}
|
||||
AND (${orgQuotas.trafficQuota} = 0 OR ${orgQuotas.trafficUsed} + ${bytes} <= ${orgQuotas.trafficQuota})`,
|
||||
AND (
|
||||
(${orgQuotas.trafficQuota} = 0 AND ${entitlementBytes} = 0)
|
||||
OR ${orgQuotas.trafficUsed} + ${bytes} <= ${orgQuotas.trafficQuota} + ${entitlementBytes}
|
||||
)`,
|
||||
)
|
||||
.returning({ id: orgQuotas.id })
|
||||
|
||||
@@ -129,16 +146,21 @@ export async function incrementUsageIfEffectiveQuotaAllows(
|
||||
storageId: string,
|
||||
bytes: number,
|
||||
teamQuotaEnabled = true,
|
||||
now = new Date(),
|
||||
): Promise<boolean> {
|
||||
if (teamQuotaEnabled) {
|
||||
const rows = await db.select({ id: orgQuotas.id }).from(orgQuotas).where(eq(orgQuotas.orgId, orgId)).limit(1)
|
||||
if (rows.length > 0) {
|
||||
const entitlementBytes = activeEntitlementBytesSql(orgId, 'storage', now)
|
||||
const updated = await db
|
||||
.update(orgQuotas)
|
||||
.set({ used: sql`${orgQuotas.used} + ${bytes}` })
|
||||
.where(
|
||||
sql`${orgQuotas.orgId} = ${orgId}
|
||||
AND (${orgQuotas.quota} = 0 OR ${orgQuotas.used} + ${bytes} <= ${orgQuotas.quota})`,
|
||||
AND (
|
||||
(${orgQuotas.quota} = 0 AND ${entitlementBytes} = 0)
|
||||
OR ${orgQuotas.used} + ${bytes} <= ${orgQuotas.quota} + ${entitlementBytes}
|
||||
)`,
|
||||
)
|
||||
.returning({ id: orgQuotas.id })
|
||||
|
||||
@@ -153,3 +175,41 @@ export async function incrementUsageIfEffectiveQuotaAllows(
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function activeEntitlementBytes(
|
||||
db: Database,
|
||||
orgId: string,
|
||||
resourceType: 'storage' | 'traffic',
|
||||
now: Date,
|
||||
): Promise<number> {
|
||||
const rows = await db
|
||||
.select({ bytes: sql<number>`COALESCE(SUM(${orgQuotaEntitlements.bytes}), 0)` })
|
||||
.from(orgQuotaEntitlements)
|
||||
.where(activeEntitlementWhere(orgId, resourceType, now))
|
||||
|
||||
return rows[0]?.bytes ?? 0
|
||||
}
|
||||
|
||||
function activeEntitlementWhere(orgId: string, resourceType: 'storage' | 'traffic', now: Date) {
|
||||
const timestamp = now.getTime()
|
||||
return and(
|
||||
eq(orgQuotaEntitlements.orgId, orgId),
|
||||
eq(orgQuotaEntitlements.resourceType, resourceType),
|
||||
eq(orgQuotaEntitlements.status, 'active'),
|
||||
sql`${orgQuotaEntitlements.startsAt} <= ${timestamp}`,
|
||||
or(sql`${orgQuotaEntitlements.expiresAt} IS NULL`, sql`${orgQuotaEntitlements.expiresAt} > ${timestamp}`),
|
||||
)
|
||||
}
|
||||
|
||||
function activeEntitlementBytesSql(orgId: string, resourceType: 'storage' | 'traffic', now: Date) {
|
||||
const timestamp = now.getTime()
|
||||
return sql`(
|
||||
SELECT COALESCE(SUM(${orgQuotaEntitlements.bytes}), 0)
|
||||
FROM ${orgQuotaEntitlements}
|
||||
WHERE ${orgQuotaEntitlements.orgId} = ${orgId}
|
||||
AND ${orgQuotaEntitlements.resourceType} = ${resourceType}
|
||||
AND ${orgQuotaEntitlements.status} = 'active'
|
||||
AND ${orgQuotaEntitlements.startsAt} <= ${timestamp}
|
||||
AND (${orgQuotaEntitlements.expiresAt} IS NULL OR ${orgQuotaEntitlements.expiresAt} > ${timestamp})
|
||||
)`
|
||||
}
|
||||
|
||||
+16
-2
@@ -1,7 +1,7 @@
|
||||
import { and, count, desc, eq, inArray, or, sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { member, organization, user } from '../db/auth-schema'
|
||||
import { orgQuotas } from '../db/schema'
|
||||
import { orgQuotaEntitlements, orgQuotas } from '../db/schema'
|
||||
import type { Database } from '../platform/interface'
|
||||
import { currentTrafficPeriod } from './effective-quota'
|
||||
|
||||
@@ -33,6 +33,7 @@ export async function listUsers(
|
||||
): Promise<{ items: UserWithOrg[]; total: number }> {
|
||||
const offset = (page - 1) * pageSize
|
||||
const term = search?.trim().toLowerCase()
|
||||
const now = new Date()
|
||||
const filter = term
|
||||
? or(
|
||||
sql`lower(${user.name}) like ${`%${term}%`}`,
|
||||
@@ -59,7 +60,7 @@ export async function listUsers(
|
||||
orgId: organization.id,
|
||||
orgName: organization.name,
|
||||
quotaUsed: orgQuotas.used,
|
||||
quotaTotal: orgQuotas.quota,
|
||||
quotaTotal: sql<number>`COALESCE(${orgQuotas.quota}, 0) + ${activeStorageEntitlementBytesSql(now)}`,
|
||||
})
|
||||
.from(user)
|
||||
.leftJoin(organization, eq(organization.slug, sql`'personal-' || ${user.id}`))
|
||||
@@ -77,6 +78,19 @@ export async function listUsers(
|
||||
return { items, total }
|
||||
}
|
||||
|
||||
function activeStorageEntitlementBytesSql(now: Date) {
|
||||
const timestamp = now.getTime()
|
||||
return sql`(
|
||||
SELECT COALESCE(SUM(${orgQuotaEntitlements.bytes}), 0)
|
||||
FROM ${orgQuotaEntitlements}
|
||||
WHERE ${orgQuotaEntitlements.orgId} = ${organization.id}
|
||||
AND ${orgQuotaEntitlements.resourceType} = 'storage'
|
||||
AND ${orgQuotaEntitlements.status} = 'active'
|
||||
AND ${orgQuotaEntitlements.startsAt} <= ${timestamp}
|
||||
AND (${orgQuotaEntitlements.expiresAt} IS NULL OR ${orgQuotaEntitlements.expiresAt} > ${timestamp})
|
||||
)`
|
||||
}
|
||||
|
||||
export async function setUserStatus(db: Database, userId: string, status: 'active' | 'disabled'): Promise<boolean> {
|
||||
const existing = await db.select({ id: user.id }).from(user).where(eq(user.id, userId))
|
||||
if (existing.length === 0) return false
|
||||
|
||||
@@ -137,6 +137,24 @@ const APP_SCHEMA_SQL = `
|
||||
traffic_used INTEGER NOT NULL DEFAULT 0,
|
||||
traffic_period TEXT NOT NULL DEFAULT '1970-01'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS org_quota_entitlements (
|
||||
id TEXT PRIMARY KEY,
|
||||
org_id TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL,
|
||||
bytes INTEGER NOT NULL,
|
||||
starts_at INTEGER NOT NULL,
|
||||
expires_at INTEGER,
|
||||
status TEXT NOT NULL,
|
||||
metadata TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS org_quota_entitlements_org_resource_idx
|
||||
ON org_quota_entitlements(org_id, resource_type, status);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS org_quota_entitlements_source_resource_uniq
|
||||
ON org_quota_entitlements(source, source_id, resource_type);
|
||||
CREATE TABLE IF NOT EXISTS webhook_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
|
||||
@@ -37,8 +37,12 @@ export interface Storage {
|
||||
export interface OrgQuota {
|
||||
id: string
|
||||
orgId: string
|
||||
baseQuota: number
|
||||
entitlementQuota: number
|
||||
quota: number
|
||||
used: number
|
||||
baseTrafficQuota: number
|
||||
entitlementTrafficQuota: number
|
||||
trafficQuota: number
|
||||
trafficUsed: number
|
||||
trafficPeriod: string
|
||||
|
||||
@@ -106,8 +106,11 @@ describe('QuotaPanel', () => {
|
||||
vi.mocked(getUserQuota).mockResolvedValue({
|
||||
orgId: 'org-1',
|
||||
baseQuota: 100,
|
||||
entitlementQuota: 0,
|
||||
quota: 100,
|
||||
used: 25,
|
||||
baseTrafficQuota: 0,
|
||||
entitlementTrafficQuota: 0,
|
||||
trafficQuota: 0,
|
||||
trafficUsed: 0,
|
||||
trafficPeriod: '2026-05',
|
||||
@@ -126,8 +129,11 @@ describe('QuotaPanel', () => {
|
||||
vi.mocked(getUserQuota).mockResolvedValue({
|
||||
orgId: 'org-1',
|
||||
baseQuota: 100,
|
||||
entitlementQuota: 0,
|
||||
quota: 100,
|
||||
used: 25,
|
||||
baseTrafficQuota: 0,
|
||||
entitlementTrafficQuota: 0,
|
||||
trafficQuota: 0,
|
||||
trafficUsed: 0,
|
||||
trafficPeriod: '2026-05',
|
||||
@@ -145,8 +151,11 @@ describe('QuotaPanel', () => {
|
||||
vi.mocked(getUserQuota).mockResolvedValue({
|
||||
orgId: 'org-1',
|
||||
baseQuota: 100,
|
||||
entitlementQuota: 100,
|
||||
quota: 200,
|
||||
used: 25,
|
||||
baseTrafficQuota: 0,
|
||||
entitlementTrafficQuota: 0,
|
||||
trafficQuota: 0,
|
||||
trafficUsed: 0,
|
||||
trafficPeriod: '2026-05',
|
||||
|
||||
+19
-1
@@ -1180,7 +1180,18 @@ describe('api', () => {
|
||||
it('fetches quotas list', async () => {
|
||||
const payload = {
|
||||
items: [
|
||||
{ orgId: 'org1', quota: 1024, used: 512, trafficQuota: 2048, trafficUsed: 256, trafficPeriod: '2026-05' },
|
||||
{
|
||||
orgId: 'org1',
|
||||
baseQuota: 1024,
|
||||
entitlementQuota: 0,
|
||||
quota: 1024,
|
||||
used: 512,
|
||||
baseTrafficQuota: 2048,
|
||||
entitlementTrafficQuota: 0,
|
||||
trafficQuota: 2048,
|
||||
trafficUsed: 256,
|
||||
trafficPeriod: '2026-05',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}
|
||||
@@ -1204,8 +1215,12 @@ describe('api', () => {
|
||||
it('puts quota for an org and returns updated quota', async () => {
|
||||
const updated = {
|
||||
orgId: 'org1',
|
||||
baseQuota: 2048,
|
||||
entitlementQuota: 0,
|
||||
quota: 2048,
|
||||
used: 512,
|
||||
baseTrafficQuota: 4096,
|
||||
entitlementTrafficQuota: 0,
|
||||
trafficQuota: 4096,
|
||||
trafficUsed: 256,
|
||||
trafficPeriod: '2026-05',
|
||||
@@ -1234,8 +1249,11 @@ describe('api', () => {
|
||||
const payload = {
|
||||
orgId: 'org1',
|
||||
baseQuota: 1024,
|
||||
entitlementQuota: 0,
|
||||
quota: 1024,
|
||||
used: 256,
|
||||
baseTrafficQuota: 2048,
|
||||
entitlementTrafficQuota: 0,
|
||||
trafficQuota: 2048,
|
||||
trafficUsed: 512,
|
||||
trafficPeriod: '2026-05',
|
||||
|
||||
+15
-2
@@ -275,7 +275,16 @@ export function batchUpdateUserQuota(ids: string[], quota: number) {
|
||||
|
||||
export type QuotaItem = Pick<
|
||||
OrgQuota,
|
||||
'orgId' | 'quota' | 'used' | 'trafficQuota' | 'trafficUsed' | 'trafficPeriod'
|
||||
| 'orgId'
|
||||
| 'baseQuota'
|
||||
| 'entitlementQuota'
|
||||
| 'quota'
|
||||
| 'used'
|
||||
| 'baseTrafficQuota'
|
||||
| 'entitlementTrafficQuota'
|
||||
| 'trafficQuota'
|
||||
| 'trafficUsed'
|
||||
| 'trafficPeriod'
|
||||
> & {
|
||||
orgName?: string
|
||||
orgType?: string
|
||||
@@ -301,9 +310,13 @@ export function getUserQuota() {
|
||||
{
|
||||
orgId: string
|
||||
baseQuota: number
|
||||
entitlementQuota: number
|
||||
quota: number
|
||||
used: number
|
||||
} & Pick<OrgQuota, 'trafficQuota' | 'trafficUsed' | 'trafficPeriod'>
|
||||
} & Pick<
|
||||
OrgQuota,
|
||||
'baseTrafficQuota' | 'entitlementTrafficQuota' | 'trafficQuota' | 'trafficUsed' | 'trafficPeriod'
|
||||
>
|
||||
>(userQuotas.me.$get())
|
||||
}
|
||||
|
||||
|
||||
@@ -138,8 +138,11 @@ describe('StoragePage', () => {
|
||||
vi.mocked(getUserQuota).mockResolvedValue({
|
||||
orgId: 'org-1',
|
||||
baseQuota: 1024,
|
||||
entitlementQuota: 512,
|
||||
quota: 1536,
|
||||
used: 0,
|
||||
baseTrafficQuota: 0,
|
||||
entitlementTrafficQuota: 0,
|
||||
trafficQuota: 0,
|
||||
trafficUsed: 0,
|
||||
trafficPeriod: '2026-05',
|
||||
|
||||
Reference in New Issue
Block a user