Files
zpan/server/services/cloud-store.ts
T

451 lines
16 KiB
TypeScript

import type { CloudOrderQuotaChange, CloudStoreSettingsInput } from '@shared/schemas'
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, orgQuotaEntitlements, orgQuotas, systemOptions, webhookEvents } from '../db/schema'
import { loadActiveLicenseBinding } from '../licensing/license-state'
import type { Database } from '../platform/interface'
import { type AtomicQuery, executeRows, executeWriteTransaction } from './db-transaction'
const CLOUD_STORE_ENABLED_KEY = 'cloud_store_enabled'
const CLOUD_STORE_CREATED_AT_KEY = 'cloud_store_created_at'
const CLOUD_STORE_UPDATED_AT_KEY = 'cloud_store_updated_at'
const CLOUD_STORE_SETTING_KEYS = [
CLOUD_STORE_ENABLED_KEY,
CLOUD_STORE_CREATED_AT_KEY,
CLOUD_STORE_UPDATED_AT_KEY,
] as const
export async function getCloudStoreSettings(db: Database): Promise<CloudStoreSettings | null> {
const settings = await getRawSettings(db)
if (!settings) return null
const binding = await loadActiveLicenseBinding(db)
return settingsDto(settings, Boolean(binding?.refreshToken))
}
export async function upsertCloudStoreSettings(
db: Database,
input: CloudStoreSettingsInput,
): Promise<CloudStoreSettings> {
const now = new Date()
const existing = await getRawSettings(db)
const createdAt = existing?.createdAt ?? now
await writeSystemOption(db, CLOUD_STORE_ENABLED_KEY, input.enabled ? 'true' : 'false')
await writeSystemOption(db, CLOUD_STORE_CREATED_AT_KEY, createdAt.toISOString())
await writeSystemOption(db, CLOUD_STORE_UPDATED_AT_KEY, now.toISOString())
return (await getCloudStoreSettings(db))!
}
export async function getAccessibleTargets(db: Database, userId: string): Promise<CloudStoreTarget[]> {
const rows = await db
.select({ orgId: organization.id, name: organization.name, metadata: organization.metadata, role: member.role })
.from(member)
.innerJoin(organization, eq(organization.id, member.organizationId))
.where(eq(member.userId, userId))
.orderBy(organization.name)
return rows.map((r) => ({ orgId: r.orgId, name: r.name, type: parseOrgType(r.metadata), role: r.role }))
}
export async function canAccessTargetOrg(db: Database, userId: string, orgId: string): Promise<boolean> {
const rows = await db
.select({ id: member.id })
.from(member)
.where(and(eq(member.userId, userId), eq(member.organizationId, orgId)))
.limit(1)
return rows.length > 0
}
export async function getCloudStoreBinding(
db: Database,
): Promise<{ boundLicenseId: string; storeId: string; refreshToken: string; instanceId: string }> {
const binding = await loadActiveLicenseBinding(db)
if (!binding?.refreshToken || !binding.cloudStoreId) throw new Error('quota_store_binding_missing')
return {
boundLicenseId: binding.cloudBindingId,
storeId: binding.cloudStoreId,
refreshToken: binding.refreshToken,
instanceId: binding.instanceId,
}
}
export async function getCustomerLabel(db: Database, userId: string): Promise<string | null> {
const rows = await db.select({ email: user.email }).from(user).where(eq(user.id, userId)).limit(1)
return rows[0]?.email ?? null
}
export async function processCloudOrderQuotaChange(
db: Database,
event: CloudOrderQuotaChange,
rawPayload: string,
payloadHash: string,
): Promise<{ duplicate: boolean; eventId: string }> {
const webhook = await beginWebhookEvent(db, event, rawPayload, payloadHash)
if (webhook.duplicate) return { duplicate: true, eventId: event.eventId }
try {
await processQuotaChangeTransaction(db, webhook.id, event)
} catch (error) {
await markWebhookEvent(db, webhook.id, 'failed', (error as Error).message)
throw error
}
return { duplicate: false, eventId: event.eventId }
}
export async function getRequiredSettings(db: Database) {
const settings = await getRawSettings(db)
if (!settings?.enabled) throw new Error('quota_store_disabled')
return settings
}
async function getRawSettings(db: Database) {
const rows = await db
.select({ key: systemOptions.key, value: systemOptions.value })
.from(systemOptions)
.where(inArray(systemOptions.key, [...CLOUD_STORE_SETTING_KEYS]))
if (rows.length === 0) return null
const values = new Map(rows.map((row) => [row.key, row.value]))
const enabled = values.get(CLOUD_STORE_ENABLED_KEY)
if (enabled === undefined) return null
const createdAt = values.get(CLOUD_STORE_CREATED_AT_KEY)
const updatedAt = values.get(CLOUD_STORE_UPDATED_AT_KEY)
if (!createdAt || !updatedAt) throw new Error('cloud_store_settings_incomplete')
return {
id: CLOUD_STORE_ENABLED_KEY,
enabled: enabled === 'true',
createdAt: new Date(createdAt),
updatedAt: new Date(updatedAt),
}
}
async function writeSystemOption(db: Database, key: string, value: string) {
const existing = await db
.select({ key: systemOptions.key })
.from(systemOptions)
.where(eq(systemOptions.key, key))
.limit(1)
if (existing.length > 0) {
await db.update(systemOptions).set({ value, public: false }).where(eq(systemOptions.key, key))
return
}
await db.insert(systemOptions).values({ key, value, public: false })
}
async function processQuotaChangeTransaction(
db: Database,
webhookId: string,
event: CloudOrderQuotaChange,
): Promise<void> {
const now = new Date(event.occurredAt ?? Date.now())
await requireTargetQuota(db, event.targetOrgId)
await executeWriteTransaction(db, [
...quotaChangeQueries(db, event, now),
db.insert(activityEvents).values(quotaChangeAuditValues(event)),
db
.update(webhookEvents)
.set({ status: 'processed', error: null, processedAt: new Date() })
.where(eq(webhookEvents.id, webhookId)),
])
}
function quotaChangeQueries(db: Database, event: CloudOrderQuotaChange, now: Date): AtomicQuery[] {
return event.direction === 'increase'
? insertQuotaEntitlementQueries(db, event, now)
: revokeQuotaEntitlementQueries(db, event, now)
}
function quotaChangeAuditValues(event: CloudOrderQuotaChange): typeof activityEvents.$inferInsert {
return {
id: nanoid(),
orgId: event.targetOrgId,
userId: event.customerId ?? 'cloud-store',
action: `quota_order_${event.direction}`,
targetType: 'quota',
targetId: event.targetOrgId,
targetName: event.targetOrgId,
metadata: JSON.stringify({
eventId: event.eventId,
eventType: event.eventType,
direction: event.direction,
storageBytes: event.storageBytes,
trafficBytes: event.trafficBytes,
cloudOrderId: event.cloudOrderId ?? null,
packageName: event.packageName ?? null,
}),
createdAt: new Date(),
}
}
async function requireTargetQuota(db: Database, orgId: string): Promise<void> {
const rows = await executeRows(
db.select({ id: orgQuotas.id }).from(orgQuotas).where(eq(orgQuotas.orgId, orgId)).limit(1),
)
if (rows.length === 0) throw new Error('target_quota_missing')
}
function insertQuotaEntitlementQueries(db: Database, event: CloudOrderQuotaChange, now: Date): AtomicQuery[] {
return quotaEntitlementValues(event, now).flatMap((value) => [
...revokeExistingPlanQueries(db, value, now),
db
.insert(orgQuotaEntitlements)
.values(value)
.onConflictDoUpdate({
target: [orgQuotaEntitlements.source, orgQuotaEntitlements.sourceId, orgQuotaEntitlements.resourceType],
set: quotaEntitlementIncreaseValues(value, now),
}),
])
}
function revokeExistingPlanQueries(
db: Database,
value: typeof orgQuotaEntitlements.$inferInsert,
now: Date,
): AtomicQuery[] {
if (value.entitlementType !== 'plan') return []
return [
db
.update(orgQuotaEntitlements)
.set({ status: 'revoked', updatedAt: now })
.where(
and(
eq(orgQuotaEntitlements.orgId, value.orgId),
eq(orgQuotaEntitlements.resourceType, value.resourceType),
eq(orgQuotaEntitlements.entitlementType, 'plan'),
eq(orgQuotaEntitlements.status, 'active'),
sql`${orgQuotaEntitlements.sourceId} != ${value.sourceId}`,
),
),
]
}
function revokeQuotaEntitlementQueries(db: Database, event: CloudOrderQuotaChange, now: Date): AtomicQuery[] {
return [
revokeQuotaEntitlementQuery(db, event, 'storage', event.storageBytes, now),
revokeQuotaEntitlementQuery(db, event, 'traffic', event.trafficBytes, now),
legacyQuotaDecreaseQuery(db, event),
].filter((query): query is AtomicQuery => query !== null)
}
function revokeQuotaEntitlementQuery(
db: Database,
event: CloudOrderQuotaChange,
resourceType: 'storage' | 'traffic',
bytes: number,
now: Date,
): AtomicQuery | null {
if (bytes === 0) return null
return db
.update(orgQuotaEntitlements)
.set(quotaEntitlementDecreaseValues(bytes, now))
.where(quotaEntitlementMatch(event, resourceType))
}
function legacyQuotaDecreaseQuery(db: Database, event: CloudOrderQuotaChange): AtomicQuery | null {
const values = legacyQuotaDecreaseBatchValues(event)
if (!values) return null
return db.update(orgQuotas).set(values).where(eq(orgQuotas.orgId, event.targetOrgId))
}
function legacyQuotaDecreaseBatchValues(event: CloudOrderQuotaChange): Partial<typeof orgQuotas.$inferInsert> | null {
const values: Partial<typeof orgQuotas.$inferInsert> = {}
if (event.storageBytes > 0)
values.quota = sql`CASE
WHEN NOT EXISTS (${quotaEntitlementExistsSql(event, 'storage')})
THEN MAX(0, ${orgQuotas.quota} - ${event.storageBytes})
ELSE ${orgQuotas.quota}
END` as unknown as number
if (event.trafficBytes > 0)
values.trafficQuota = sql`CASE
WHEN NOT EXISTS (${quotaEntitlementExistsSql(event, 'traffic')})
THEN MAX(0, ${orgQuotas.trafficQuota} - ${event.trafficBytes})
ELSE ${orgQuotas.trafficQuota}
END` as unknown as number
return Object.keys(values).length === 0 ? null : values
}
function quotaEntitlementExistsSql(event: CloudOrderQuotaChange, resourceType: 'storage' | 'traffic') {
return sql`select 1 from ${orgQuotaEntitlements}
where ${orgQuotaEntitlements.orgId} = ${event.targetOrgId}
and ${orgQuotaEntitlements.resourceType} = ${resourceType}
and ${orgQuotaEntitlements.source} = 'cloud_order'
and ${orgQuotaEntitlements.sourceId} = ${event.cloudOrderId}
limit 1`
}
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) {
const bytes = isSubscriptionSourceId(value.sourceId)
? value.bytes
: (sql`CASE
WHEN ${orgQuotaEntitlements.status} = 'active' THEN ${orgQuotaEntitlements.bytes} + ${value.bytes}
ELSE ${value.bytes}
END` as unknown as number)
return {
bytes,
entitlementType: value.entitlementType,
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,
entitlementType: isSubscriptionSourceId(event.cloudOrderId) ? 'plan' : 'grant',
source: 'cloud_order',
sourceId: event.cloudOrderId,
bytes,
startsAt: now,
expiresAt: event.expiresAt ? new Date(event.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,
packageName: event.packageName ?? null,
trafficOveragePriceCents: event.trafficOveragePriceCents ?? null,
expiresAt: event.expiresAt ?? null,
customerId: event.customerId ?? null,
customerEmail: event.customerEmail ?? null,
}
}
function isSubscriptionSourceId(sourceId: string) {
return sourceId.startsWith('stripe_subscription:')
}
async function beginWebhookEvent(
db: Database,
event: CloudOrderQuotaChange,
rawPayload: string,
payloadHash: string,
): Promise<{ id: string; duplicate: boolean }> {
const id = nanoid()
try {
await db.insert(webhookEvents).values({
id,
source: 'cloud',
eventId: event.eventId,
eventType: event.eventType,
payloadHash,
rawPayload,
status: 'processing',
createdAt: new Date(),
})
return { id, duplicate: false }
} catch (error) {
if (isUniqueConflict(error)) return resumeWebhookEvent(db, event, rawPayload, payloadHash)
throw error
}
}
async function resumeWebhookEvent(
db: Database,
event: CloudOrderQuotaChange,
rawPayload: string,
payloadHash: string,
): Promise<{ id: string; duplicate: boolean }> {
const rows = await db
.select({
id: webhookEvents.id,
payloadHash: webhookEvents.payloadHash,
status: webhookEvents.status,
})
.from(webhookEvents)
.where(and(eq(webhookEvents.source, 'cloud'), eq(webhookEvents.eventId, event.eventId)))
.limit(1)
const existing = rows[0]
if (!existing) throw new Error('webhook_event_conflict')
if (existing.payloadHash !== payloadHash) {
throw new Error('webhook_payload_conflict')
}
if (existing.status === 'processed' || existing.status === 'duplicate' || existing.status === 'processing') {
return { id: existing.id, duplicate: true }
}
await db
.update(webhookEvents)
.set({ rawPayload, status: 'processing', error: null, processedAt: null })
.where(eq(webhookEvents.id, existing.id))
return { id: existing.id, duplicate: false }
}
async function markWebhookEvent(db: Database, id: string, status: string, error: string | null): Promise<void> {
await db.update(webhookEvents).set({ status, error, processedAt: new Date() }).where(eq(webhookEvents.id, id))
}
function settingsDto(
row: { id: string; enabled: boolean; createdAt: Date; updatedAt: Date },
cloudReady = true,
): CloudStoreSettings {
return {
id: row.id,
enabled: row.enabled,
status: cloudReady ? 'ready' : 'cloud_unbound',
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
}
}
function parseOrgType(metadata: string | null): string {
if (!metadata) return 'unknown'
return (JSON.parse(metadata) as { type?: string }).type ?? 'unknown'
}
function isUniqueConflict(error: unknown): boolean {
if (!(error instanceof Error)) return false
const message = error.message.toLowerCase()
return message.includes('unique') || message.includes('constraint failed')
}