From f0678038c2c515b0c00499e37a11f0cd2aef3678 Mon Sep 17 00:00:00 2001
From: Innei
Date: Fri, 21 Nov 2025 16:23:13 +0800
Subject: [PATCH] feat: implement managed storage plans and provider settings
- Added new UI schema for managing storage plans, including catalog, pricing, and product configurations.
- Introduced StoragePlanService to handle storage plan operations and integrate with existing billing services.
- Updated SuperAdmin settings to include managed storage provider configurations.
- Enhanced localization files with new keys for storage plan management.
- Implemented API endpoints for fetching and updating storage plans.
Signed-off-by: Innei
---
be/apps/core/src/locales/ui-schema/en.ts | 31 +
be/apps/core/src/locales/ui-schema/zh-CN.ts | 30 +
.../system-setting.constants.ts | 37 +
.../system-setting/system-setting.service.ts | 163 +-
.../system-setting/system-setting.types.ts | 11 +
.../system-setting.ui-schema.ts | 358 ++--
.../billing/billing-plan.constants.ts | 3 +
.../platform/billing/billing-plan.service.ts | 15 +
.../platform/billing/billing-plan.types.ts | 1 +
.../platform/billing/billing.controller.ts | 20 +-
.../platform/billing/billing.module.ts | 3 +-
.../billing/storage-plan.constants.ts | 20 +
.../platform/billing/storage-plan.service.ts | 210 +++
.../platform/billing/storage-plan.types.ts | 34 +
.../platform/super-admin/super-admin.dto.ts | 13 +
.../platform/tenant/tenant.repository.ts | 8 +-
.../modules/platform/tenant/tenant.service.ts | 7 +-
.../src/modules/storage-plans/api.ts | 21 +
.../src/modules/storage-plans/hooks.ts | 24 +
.../src/modules/storage-plans/index.ts | 2 +
.../src/modules/storage-plans/types.ts | 25 +
.../components/ManagedStorageEntryCard.tsx | 76 +
.../components/ManagedStoragePlansModal.tsx | 221 +++
.../components/ProviderCard.tsx | 2 +-
.../components/ProviderEditModal.tsx | 2 +-
.../components/StorageProvidersManager.tsx | 2 +
.../components/ManagedStorageSettings.tsx | 239 +++
.../src/modules/super-admin/index.ts | 1 +
.../src/modules/super-admin/types.ts | 15 +-
.../dashboard/src/pages/superadmin/plans.tsx | 379 +++-
.../src/pages/superadmin/settings.tsx | 21 +-
.../db/migrations/0006_quick_titania.sql | 1 +
.../db/migrations/meta/0006_snapshot.json | 1593 +++++++++++++++++
be/packages/db/migrations/meta/_journal.json | 7 +
be/packages/db/src/schema.ts | 1 +
locales/dashboard/en.json | 56 +
locales/dashboard/zh-CN.json | 56 +
37 files changed, 3537 insertions(+), 171 deletions(-)
create mode 100644 be/apps/core/src/modules/platform/billing/storage-plan.constants.ts
create mode 100644 be/apps/core/src/modules/platform/billing/storage-plan.service.ts
create mode 100644 be/apps/core/src/modules/platform/billing/storage-plan.types.ts
create mode 100644 be/apps/dashboard/src/modules/storage-plans/api.ts
create mode 100644 be/apps/dashboard/src/modules/storage-plans/hooks.ts
create mode 100644 be/apps/dashboard/src/modules/storage-plans/index.ts
create mode 100644 be/apps/dashboard/src/modules/storage-plans/types.ts
create mode 100644 be/apps/dashboard/src/modules/storage-providers/components/ManagedStorageEntryCard.tsx
create mode 100644 be/apps/dashboard/src/modules/storage-providers/components/ManagedStoragePlansModal.tsx
create mode 100644 be/apps/dashboard/src/modules/super-admin/components/ManagedStorageSettings.tsx
create mode 100644 be/packages/db/migrations/0006_quick_titania.sql
create mode 100644 be/packages/db/migrations/meta/0006_snapshot.json
diff --git a/be/apps/core/src/locales/ui-schema/en.ts b/be/apps/core/src/locales/ui-schema/en.ts
index 6e437883..05e412f5 100644
--- a/be/apps/core/src/locales/ui-schema/en.ts
+++ b/be/apps/core/src/locales/ui-schema/en.ts
@@ -298,6 +298,37 @@ const enUiSchema = {
},
},
},
+ 'storage-plans': {
+ title: 'Storage plans',
+ description: 'Managed storage catalog, pricing, and Creem products for storage subscriptions.',
+ fields: {
+ catalog: {
+ title: 'Plan catalog',
+ description:
+ 'Manage storage plans for managed B2 space. Use the dashboard editor; JSON is no longer required.',
+ placeholder: 'Configured via dashboard',
+ helper: 'Plans include name/description/capacity and active flag.',
+ },
+ pricing: {
+ title: 'Storage pricing',
+ description: 'Monthly price and currency per storage plan.',
+ placeholder: 'Configured via dashboard',
+ helper: 'Blank values fall back to defaults or hide pricing.',
+ },
+ products: {
+ title: 'Creem products',
+ description: 'Creem product per storage plan for checkout and portal.',
+ placeholder: 'Configured via dashboard',
+ helper: 'Blank values hide the upgrade entry for that plan.',
+ },
+ 'managed-provider': {
+ title: 'Managed provider key',
+ description: 'Provider ID from storage providers list that backs managed storage plans (e.g., b2-managed).',
+ placeholder: 'b2-managed',
+ helper: 'Used by backend to issue upload/read credentials for managed tenants.',
+ },
+ },
+ },
oauth: {
title: 'OAuth providers',
description: 'Configure shared third-party login providers for all tenants.',
diff --git a/be/apps/core/src/locales/ui-schema/zh-CN.ts b/be/apps/core/src/locales/ui-schema/zh-CN.ts
index 06cd3f3b..691933d5 100644
--- a/be/apps/core/src/locales/ui-schema/zh-CN.ts
+++ b/be/apps/core/src/locales/ui-schema/zh-CN.ts
@@ -296,6 +296,36 @@ const zhCnUiSchema = {
},
},
},
+ 'storage-plans': {
+ title: '存储计划',
+ description: '管理托管存储方案的目录、定价以及 Creem 商品映射。',
+ fields: {
+ catalog: {
+ title: '计划目录',
+ description: '存储计划的名称/描述/容量与启用状态,建议在控制台中编辑,无需手填 JSON。',
+ placeholder: '通过控制台编辑',
+ helper: '包含 plan id、name、description、capacityBytes、isActive 等字段。',
+ },
+ pricing: {
+ title: '存储定价',
+ description: '每个存储计划的月费与币种。',
+ placeholder: '通过控制台编辑',
+ helper: '留空回退到默认值或隐藏价格。',
+ },
+ products: {
+ title: 'Creem 商品',
+ description: '为存储计划绑定 Creem 商品 ID,用于结算与用户门户。',
+ placeholder: '通过控制台编辑',
+ helper: '留空则该计划不会展示升级入口。',
+ },
+ 'managed-provider': {
+ title: '托管存储 Provider Key',
+ description: '与存储提供商列表中的配置项对应(例如 b2-managed)。',
+ placeholder: 'b2-managed',
+ helper: '后端将用该 Provider 为托管存储租户发放上传/读取权限。',
+ },
+ },
+ },
oauth: {
title: 'OAuth 登录渠道',
description: '统一配置所有租户可用的第三方登录渠道。',
diff --git a/be/apps/core/src/modules/configuration/system-setting/system-setting.constants.ts b/be/apps/core/src/modules/configuration/system-setting/system-setting.constants.ts
index 10e65bcb..11f642dc 100644
--- a/be/apps/core/src/modules/configuration/system-setting/system-setting.constants.ts
+++ b/be/apps/core/src/modules/configuration/system-setting/system-setting.constants.ts
@@ -7,6 +7,13 @@ import {
BILLING_PLAN_PRODUCTS_SETTING_KEY,
} from 'core/modules/platform/billing/billing-plan.constants'
import type { BillingPlanId, BillingPlanQuota } from 'core/modules/platform/billing/billing-plan.types'
+import {
+ DEFAULT_STORAGE_PLAN_CATALOG,
+ STORAGE_PLAN_CATALOG_SETTING_KEY,
+ STORAGE_PLAN_PRICING_SETTING_KEY,
+ STORAGE_PLAN_PRODUCTS_SETTING_KEY,
+} from 'core/modules/platform/billing/storage-plan.constants'
+import type { StoragePlanCatalog } from 'core/modules/platform/billing/storage-plan.types'
import { z } from 'zod'
const nonEmptyString = z.string().trim().min(1)
@@ -114,6 +121,36 @@ export const SYSTEM_SETTING_DEFINITIONS = {
defaultValue: {},
isSensitive: false,
},
+ storagePlanCatalog: {
+ key: STORAGE_PLAN_CATALOG_SETTING_KEY,
+ schema: z.record(z.string(), z.any()),
+ defaultValue: DEFAULT_STORAGE_PLAN_CATALOG satisfies StoragePlanCatalog,
+ isSensitive: false,
+ },
+ storagePlanProducts: {
+ key: STORAGE_PLAN_PRODUCTS_SETTING_KEY,
+ schema: z.record(z.string(), z.any()),
+ defaultValue: {},
+ isSensitive: false,
+ },
+ storagePlanPricing: {
+ key: STORAGE_PLAN_PRICING_SETTING_KEY,
+ schema: z.record(z.string(), z.any()),
+ defaultValue: {},
+ isSensitive: false,
+ },
+ managedStorageProvider: {
+ key: 'system.storage.managed.provider',
+ schema: z.string().trim().min(1).nullable(),
+ defaultValue: null as string | null,
+ isSensitive: false,
+ },
+ managedStorageProviders: {
+ key: 'system.storage.managed.providers',
+ schema: z.string(),
+ defaultValue: '[]',
+ isSensitive: true,
+ },
} as const
const BILLING_PLAN_QUOTA_KEYS = [
diff --git a/be/apps/core/src/modules/configuration/system-setting/system-setting.service.ts b/be/apps/core/src/modules/configuration/system-setting/system-setting.service.ts
index 66ab3b1d..d0b900b4 100644
--- a/be/apps/core/src/modules/configuration/system-setting/system-setting.service.ts
+++ b/be/apps/core/src/modules/configuration/system-setting/system-setting.service.ts
@@ -16,12 +16,24 @@ import type {
BillingPlanProductConfigs,
BillingPlanQuota,
} from 'core/modules/platform/billing/billing-plan.types'
+import type {
+ StoragePlanCatalog,
+ StoragePlanPricingConfigs,
+ StoragePlanProductConfigs,
+} from 'core/modules/platform/billing/storage-plan.types'
import { sql } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import type { ZodType } from 'zod'
import { z } from 'zod'
import { getUiSchemaTranslator } from '../../ui/ui-schema/ui-schema.i18n'
+import type { BuilderStorageProvider } from '../setting/storage-provider.utils'
+import {
+ maskStorageProviderSecrets,
+ mergeStorageProviderSecrets,
+ parseStorageProviders,
+ serializeStorageProviders,
+} from '../setting/storage-provider.utils'
import type { SystemSettingDbField } from './system-setting.constants'
import {
BILLING_PLAN_FIELD_DESCRIPTORS,
@@ -135,6 +147,29 @@ export class SystemSettingService {
BILLING_PLAN_PRICING_SCHEMA,
{},
) as BillingPlanPricingConfigs
+ const storagePlanCatalog = this.parseSetting(
+ rawValues[SYSTEM_SETTING_DEFINITIONS.storagePlanCatalog.key],
+ STORAGE_PLAN_CATALOG_SCHEMA,
+ SYSTEM_SETTING_DEFINITIONS.storagePlanCatalog.defaultValue as StoragePlanCatalog,
+ ) as StoragePlanCatalog
+ const storagePlanProducts = this.parseSetting(
+ rawValues[SYSTEM_SETTING_DEFINITIONS.storagePlanProducts.key],
+ STORAGE_PLAN_PRODUCTS_SCHEMA,
+ {},
+ ) as StoragePlanProductConfigs
+ const storagePlanPricing = this.parseSetting(
+ rawValues[SYSTEM_SETTING_DEFINITIONS.storagePlanPricing.key],
+ STORAGE_PLAN_PRICING_SCHEMA,
+ {},
+ ) as StoragePlanPricingConfigs
+ const managedStorageProvider = this.parseSetting(
+ rawValues[SYSTEM_SETTING_DEFINITIONS.managedStorageProvider.key],
+ SYSTEM_SETTING_DEFINITIONS.managedStorageProvider.schema,
+ SYSTEM_SETTING_DEFINITIONS.managedStorageProvider.defaultValue,
+ )
+ const managedStorageProviders = this.parseManagedStorageProviders(
+ rawValues[SYSTEM_SETTING_DEFINITIONS.managedStorageProviders.key],
+ )
return {
allowRegistration,
maxRegistrableUsers,
@@ -151,6 +186,11 @@ export class SystemSettingService {
billingPlanOverrides,
billingPlanProducts,
billingPlanPricing,
+ storagePlanCatalog,
+ storagePlanProducts,
+ storagePlanPricing,
+ managedStorageProvider,
+ managedStorageProviders,
}
}
@@ -169,6 +209,26 @@ export class SystemSettingService {
return settings.billingPlanPricing ?? {}
}
+ async getStoragePlanCatalog(): Promise {
+ const settings = await this.getSettings()
+ return settings.storagePlanCatalog ?? {}
+ }
+
+ async getStoragePlanProducts(): Promise {
+ const settings = await this.getSettings()
+ return settings.storagePlanProducts ?? {}
+ }
+
+ async getStoragePlanPricing(): Promise {
+ const settings = await this.getSettings()
+ return settings.storagePlanPricing ?? {}
+ }
+
+ async getManagedStorageProviderKey(): Promise {
+ const settings = await this.getSettings()
+ return settings.managedStorageProvider ?? null
+ }
+
async getOverview(): Promise {
const settings = await this.getSettings()
const totalUsers = await this.getTotalUserCount()
@@ -194,9 +254,17 @@ export class SystemSettingService {
const updates: Array<{ field: SystemSettingDbField; value: unknown }> = []
- const enqueueUpdate = (field: K, value: unknown) => {
+ const enqueueUpdate = (
+ field: K,
+ value: unknown,
+ currentValue?: SystemSettings[K],
+ ) => {
updates.push({ field, value })
- ;(current as unknown as Record)[field] = value
+ if (currentValue !== undefined) {
+ ;(current as unknown as Record)[field] = currentValue
+ } else {
+ ;(current as unknown as Record)[field] = value
+ }
}
if (patch.allowRegistration !== undefined && patch.allowRegistration !== current.allowRegistration) {
@@ -287,6 +355,37 @@ export class SystemSettingService {
}
}
+ if (patch.storagePlanCatalog !== undefined) {
+ const parsed = STORAGE_PLAN_CATALOG_SCHEMA.parse(patch.storagePlanCatalog)
+ enqueueUpdate('storagePlanCatalog', parsed)
+ }
+
+ if (patch.storagePlanProducts !== undefined) {
+ const parsed = STORAGE_PLAN_PRODUCTS_SCHEMA.parse(patch.storagePlanProducts)
+ enqueueUpdate('storagePlanProducts', parsed)
+ }
+
+ if (patch.storagePlanPricing !== undefined) {
+ const parsed = STORAGE_PLAN_PRICING_SCHEMA.parse(patch.storagePlanPricing)
+ enqueueUpdate('storagePlanPricing', parsed)
+ }
+
+ if (patch.managedStorageProvider !== undefined && patch.managedStorageProvider !== current.managedStorageProvider) {
+ enqueueUpdate('managedStorageProvider', this.normalizeNullableString(patch.managedStorageProvider))
+ }
+
+ if (patch.managedStorageProviders !== undefined) {
+ const normalizedProviders = this.normalizeManagedStorageProvidersPatch(
+ patch.managedStorageProviders,
+ current.managedStorageProviders ?? [],
+ )
+ const nextSerialized = serializeStorageProviders(normalizedProviders)
+ const currentSerialized = serializeStorageProviders(current.managedStorageProviders ?? [])
+ if (nextSerialized !== currentSerialized) {
+ enqueueUpdate('managedStorageProviders', nextSerialized, normalizedProviders)
+ }
+ }
+
if (updates.length === 0) {
return current
}
@@ -309,6 +408,10 @@ export class SystemSettingService {
const map = {} as SystemSettingValueMap
;(Object.keys(SYSTEM_SETTING_DEFINITIONS) as SystemSettingDbField[]).forEach((field) => {
+ if (field === 'managedStorageProviders') {
+ ;(map as Record)[field] = maskStorageProviderSecrets(settings.managedStorageProviders ?? [])
+ return
+ }
;(map as Record)[field] = settings[field]
})
@@ -342,6 +445,51 @@ export class SystemSettingService {
return map
}
+ private parseManagedStorageProviders(raw: unknown): BuilderStorageProvider[] {
+ if (!raw) {
+ return []
+ }
+
+ if (Array.isArray(raw) || typeof raw === 'object') {
+ try {
+ return parseStorageProviders(JSON.stringify(raw))
+ } catch {
+ return []
+ }
+ }
+
+ if (typeof raw === 'string') {
+ const normalized = raw.trim()
+ if (!normalized) {
+ return []
+ }
+ return parseStorageProviders(normalized)
+ }
+
+ return []
+ }
+
+ private normalizeManagedStorageProvidersPatch(
+ patch: unknown,
+ current: BuilderStorageProvider[],
+ ): BuilderStorageProvider[] {
+ let normalized: string
+ if (typeof patch === 'string') {
+ normalized = patch
+ } else if (patch == null) {
+ normalized = '[]'
+ } else {
+ try {
+ normalized = JSON.stringify(patch)
+ } catch {
+ normalized = '[]'
+ }
+ }
+
+ const incoming = parseStorageProviders(normalized)
+ return mergeStorageProviderSecrets(incoming, current ?? [])
+ }
+
private extractPlanFieldUpdates(patch: UpdateSystemSettingsInput): PlanFieldUpdateSummary {
const summary: PlanFieldUpdateSummary = {
hasUpdates: false,
@@ -608,6 +756,17 @@ const PLAN_PRICING_ENTRY_SCHEMA = z.object({
const BILLING_PLAN_PRICING_SCHEMA = z.record(z.string(), PLAN_PRICING_ENTRY_SCHEMA).default({})
+const STORAGE_PLAN_CATALOG_ENTRY_SCHEMA = z.object({
+ name: z.string().trim().min(1),
+ description: z.string().trim().nullable().optional(),
+ capacityBytes: z.number().int().min(0).nullable().optional(),
+ isActive: z.boolean().optional(),
+})
+
+const STORAGE_PLAN_CATALOG_SCHEMA = z.record(z.string(), STORAGE_PLAN_CATALOG_ENTRY_SCHEMA).default({})
+const STORAGE_PLAN_PRODUCTS_SCHEMA = z.record(z.string(), PLAN_PRODUCT_ENTRY_SCHEMA).default({})
+const STORAGE_PLAN_PRICING_SCHEMA = z.record(z.string(), PLAN_PRICING_ENTRY_SCHEMA).default({})
+
type PlanQuotaUpdateMap = Partial>>
type PlanPricingUpdateMap = Partial>>
type PlanProductUpdateMap = Partial>
diff --git a/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts b/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts
index c02895ea..6431ecf8 100644
--- a/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts
+++ b/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts
@@ -3,8 +3,14 @@ import type {
BillingPlanPricingConfigs,
BillingPlanProductConfigs,
} from 'core/modules/platform/billing/billing-plan.types'
+import type {
+ StoragePlanCatalog,
+ StoragePlanPricingConfigs,
+ StoragePlanProductConfigs,
+} from 'core/modules/platform/billing/storage-plan.types'
import type { UiSchema } from 'core/modules/ui/ui-schema/ui-schema.type'
+import type { BuilderStorageProvider } from '../setting/storage-provider.utils'
import type { BillingPlanSettingField, SystemSettingDbField, SystemSettingField } from './system-setting.constants'
export interface SystemSettings {
@@ -23,6 +29,11 @@ export interface SystemSettings {
billingPlanOverrides: BillingPlanOverrides
billingPlanProducts: BillingPlanProductConfigs
billingPlanPricing: BillingPlanPricingConfigs
+ storagePlanCatalog: StoragePlanCatalog
+ storagePlanProducts: StoragePlanProductConfigs
+ storagePlanPricing: StoragePlanPricingConfigs
+ managedStorageProvider: string | null
+ managedStorageProviders: BuilderStorageProvider[]
}
export type SystemSettingValueMap = {
diff --git a/be/apps/core/src/modules/configuration/system-setting/system-setting.ui-schema.ts b/be/apps/core/src/modules/configuration/system-setting/system-setting.ui-schema.ts
index eca3b1a2..25fb85bf 100644
--- a/be/apps/core/src/modules/configuration/system-setting/system-setting.ui-schema.ts
+++ b/be/apps/core/src/modules/configuration/system-setting/system-setting.ui-schema.ts
@@ -1,6 +1,6 @@
import { BILLING_PLAN_IDS } from 'core/modules/platform/billing/billing-plan.constants'
-import type {UiSchemaTFunction} from 'core/modules/ui/ui-schema/ui-schema.i18n';
-import { identityUiSchemaT } from 'core/modules/ui/ui-schema/ui-schema.i18n'
+import type { UiSchemaTFunction } from 'core/modules/ui/ui-schema/ui-schema.i18n'
+import { identityUiSchemaT } from 'core/modules/ui/ui-schema/ui-schema.i18n'
import type { UiNode, UiSchema } from 'core/modules/ui/ui-schema/ui-schema.type'
import type { SystemSettingField } from './system-setting.constants'
@@ -62,7 +62,7 @@ const PLAN_PAYMENT_FIELDS = [
},
] as const
-function buildBillingPlanGroups (t: UiSchemaTFunction): ReadonlyArray> {
+function buildBillingPlanGroups(t: UiSchemaTFunction): ReadonlyArray> {
return BILLING_PLAN_IDS.map((planId) => {
const quotaFields = PLAN_QUOTA_FIELDS.map((field) => ({
type: 'field' as const,
@@ -115,163 +115,211 @@ function buildBillingPlanGroups (t: UiSchemaTFunction): ReadonlyArray {
+export function createSystemSettingUiSchema(t: UiSchemaTFunction): UiSchema {
return {
- version: SYSTEM_SETTING_UI_SCHEMA_VERSION,
- title: t('system.title'),
- description: t('system.description'),
- sections: [
- {
- type: 'section',
- id: 'registration-control',
- title: t('system.sections.registration.title'),
- description: t('system.sections.registration.description'),
- icon: 'user-cog',
- children: [
- {
- type: 'field',
- id: 'registration-allow',
- title: t('system.sections.registration.fields.allow-registration.title'),
- description: t('system.sections.registration.fields.allow-registration.description'),
- key: 'allowRegistration',
- component: {
- type: 'switch',
- },
- },
- {
- type: 'field',
- id: 'local-provider-enabled',
- title: t('system.sections.registration.fields.local-provider.title'),
- description: t('system.sections.registration.fields.local-provider.description'),
- key: 'localProviderEnabled',
- component: {
- type: 'switch',
- },
- },
- {
- type: 'field',
- id: 'platform-base-domain',
- title: t('system.sections.registration.fields.base-domain.title'),
- description: t('system.sections.registration.fields.base-domain.description'),
- helperText: t('system.sections.registration.fields.base-domain.helper'),
- key: 'baseDomain',
- component: {
- type: 'text',
- placeholder: t('system.sections.registration.fields.base-domain.placeholder'),
- },
- },
- {
- type: 'field',
- id: 'registration-max-users',
- title: t('system.sections.registration.fields.max-users.title'),
- description: t('system.sections.registration.fields.max-users.description'),
- helperText: t('system.sections.registration.fields.max-users.helper'),
- key: 'maxRegistrableUsers',
- component: {
- type: 'text' as const,
- inputType: 'number',
- placeholder: t('system.sections.registration.fields.max-users.placeholder'),
- },
- },
- ],
- },
- {
- type: 'section',
- id: 'billing-plan-settings',
- title: t('system.sections.billing.title'),
- description: t('system.sections.billing.description'),
- icon: 'badge-dollar-sign',
- children: buildBillingPlanGroups(t),
- },
- {
- type: 'section',
- id: 'oauth-providers',
- title: t('system.sections.oauth.title'),
- description: t('system.sections.oauth.description'),
- icon: 'shield-check',
- children: [
- {
- type: 'field',
- id: 'oauth-gateway-url',
- title: t('system.sections.oauth.fields.gateway.title'),
- description: t('system.sections.oauth.fields.gateway.description'),
- helperText: t('system.sections.oauth.fields.gateway.helper'),
- key: 'oauthGatewayUrl',
- component: {
- type: 'text',
- placeholder: t('system.sections.oauth.fields.gateway.placeholder'),
- },
- },
- {
- type: 'group',
- id: 'oauth-google',
- title: t('system.sections.oauth.groups.google.title'),
- description: t('system.sections.oauth.groups.google.description'),
- icon: 'badge-check',
- children: [
- {
- type: 'field',
- id: 'oauth-google-client-id',
- title: t('system.sections.oauth.groups.google.fields.client-id.title'),
- description: t('system.sections.oauth.groups.google.fields.client-id.description'),
- key: 'oauthGoogleClientId',
- component: {
- type: 'text',
- placeholder: t('system.sections.oauth.groups.google.fields.client-id.placeholder'),
- },
+ version: SYSTEM_SETTING_UI_SCHEMA_VERSION,
+ title: t('system.title'),
+ description: t('system.description'),
+ sections: [
+ {
+ type: 'section',
+ id: 'registration-control',
+ title: t('system.sections.registration.title'),
+ description: t('system.sections.registration.description'),
+ icon: 'user-cog',
+ children: [
+ {
+ type: 'field',
+ id: 'registration-allow',
+ title: t('system.sections.registration.fields.allow-registration.title'),
+ description: t('system.sections.registration.fields.allow-registration.description'),
+ key: 'allowRegistration',
+ component: {
+ type: 'switch',
},
- {
- type: 'field',
- id: 'oauth-google-client-secret',
- title: t('system.sections.oauth.groups.google.fields.client-secret.title'),
- description: t('system.sections.oauth.groups.google.fields.client-secret.description'),
- key: 'oauthGoogleClientSecret',
- component: {
- type: 'secret',
- placeholder: t('system.sections.oauth.groups.google.fields.client-secret.placeholder'),
- revealable: true,
- autoComplete: 'off',
- },
+ },
+ {
+ type: 'field',
+ id: 'local-provider-enabled',
+ title: t('system.sections.registration.fields.local-provider.title'),
+ description: t('system.sections.registration.fields.local-provider.description'),
+ key: 'localProviderEnabled',
+ component: {
+ type: 'switch',
},
- ],
- },
- {
- type: 'group',
- id: 'oauth-github',
- title: t('system.sections.oauth.groups.github.title'),
- description: t('system.sections.oauth.groups.github.description'),
- icon: 'github',
- children: [
- {
- type: 'field',
- id: 'oauth-github-client-id',
- title: t('system.sections.oauth.groups.github.fields.client-id.title'),
- description: t('system.sections.oauth.groups.github.fields.client-id.description'),
- key: 'oauthGithubClientId',
- component: {
- type: 'text',
- placeholder: t('system.sections.oauth.groups.github.fields.client-id.placeholder'),
- },
+ },
+ {
+ type: 'field',
+ id: 'platform-base-domain',
+ title: t('system.sections.registration.fields.base-domain.title'),
+ description: t('system.sections.registration.fields.base-domain.description'),
+ helperText: t('system.sections.registration.fields.base-domain.helper'),
+ key: 'baseDomain',
+ component: {
+ type: 'text',
+ placeholder: t('system.sections.registration.fields.base-domain.placeholder'),
},
- {
- type: 'field',
- id: 'oauth-github-client-secret',
- title: t('system.sections.oauth.groups.github.fields.client-secret.title'),
- description: t('system.sections.oauth.groups.github.fields.client-secret.description'),
- key: 'oauthGithubClientSecret',
- component: {
- type: 'secret',
- placeholder: t('system.sections.oauth.groups.github.fields.client-secret.placeholder'),
- revealable: true,
- autoComplete: 'off',
- },
+ },
+ {
+ type: 'field',
+ id: 'registration-max-users',
+ title: t('system.sections.registration.fields.max-users.title'),
+ description: t('system.sections.registration.fields.max-users.description'),
+ helperText: t('system.sections.registration.fields.max-users.helper'),
+ key: 'maxRegistrableUsers',
+ component: {
+ type: 'text' as const,
+ inputType: 'number',
+ placeholder: t('system.sections.registration.fields.max-users.placeholder'),
},
- ],
- },
- ],
- },
- ],
-}
+ },
+ ],
+ },
+ {
+ type: 'section',
+ id: 'billing-plan-settings',
+ title: t('system.sections.billing.title'),
+ description: t('system.sections.billing.description'),
+ icon: 'badge-dollar-sign',
+ children: buildBillingPlanGroups(t),
+ },
+ {
+ type: 'section',
+ id: 'storage-plan-settings',
+ title: t('system.sections.storage-plans.title'),
+ description: t('system.sections.storage-plans.description'),
+ icon: 'database',
+ children: [
+ {
+ type: 'field',
+ id: 'storage-plan-catalog',
+ title: t('system.sections.storage-plans.fields.catalog.title'),
+ description: t('system.sections.storage-plans.fields.catalog.description'),
+ helperText: t('system.sections.storage-plans.fields.catalog.helper'),
+ key: 'storagePlanCatalog',
+ component: { type: 'slot', name: 'storage-plan-catalog' } as const,
+ },
+ {
+ type: 'field',
+ id: 'storage-plan-pricing',
+ title: t('system.sections.storage-plans.fields.pricing.title'),
+ description: t('system.sections.storage-plans.fields.pricing.description'),
+ helperText: t('system.sections.storage-plans.fields.pricing.helper'),
+ key: 'storagePlanPricing',
+ component: { type: 'slot', name: 'storage-plan-pricing' } as const,
+ },
+ {
+ type: 'field',
+ id: 'storage-plan-products',
+ title: t('system.sections.storage-plans.fields.products.title'),
+ description: t('system.sections.storage-plans.fields.products.description'),
+ helperText: t('system.sections.storage-plans.fields.products.helper'),
+ key: 'storagePlanProducts',
+ component: { type: 'slot', name: 'storage-plan-products' } as const,
+ },
+ {
+ type: 'field',
+ id: 'storage-provider-managed',
+ title: t('system.sections.storage-plans.fields.managed-provider.title'),
+ description: t('system.sections.storage-plans.fields.managed-provider.description'),
+ helperText: t('system.sections.storage-plans.fields.managed-provider.helper'),
+ key: 'managedStorageProvider',
+ component: {
+ type: 'text',
+ placeholder: t('system.sections.storage-plans.fields.managed-provider.placeholder'),
+ },
+ },
+ ],
+ },
+ {
+ type: 'section',
+ id: 'oauth-providers',
+ title: t('system.sections.oauth.title'),
+ description: t('system.sections.oauth.description'),
+ icon: 'shield-check',
+ children: [
+ {
+ type: 'field',
+ id: 'oauth-gateway-url',
+ title: t('system.sections.oauth.fields.gateway.title'),
+ description: t('system.sections.oauth.fields.gateway.description'),
+ helperText: t('system.sections.oauth.fields.gateway.helper'),
+ key: 'oauthGatewayUrl',
+ component: {
+ type: 'text',
+ placeholder: t('system.sections.oauth.fields.gateway.placeholder'),
+ },
+ },
+ {
+ type: 'group',
+ id: 'oauth-google',
+ title: t('system.sections.oauth.groups.google.title'),
+ description: t('system.sections.oauth.groups.google.description'),
+ icon: 'badge-check',
+ children: [
+ {
+ type: 'field',
+ id: 'oauth-google-client-id',
+ title: t('system.sections.oauth.groups.google.fields.client-id.title'),
+ description: t('system.sections.oauth.groups.google.fields.client-id.description'),
+ key: 'oauthGoogleClientId',
+ component: {
+ type: 'text',
+ placeholder: t('system.sections.oauth.groups.google.fields.client-id.placeholder'),
+ },
+ },
+ {
+ type: 'field',
+ id: 'oauth-google-client-secret',
+ title: t('system.sections.oauth.groups.google.fields.client-secret.title'),
+ description: t('system.sections.oauth.groups.google.fields.client-secret.description'),
+ key: 'oauthGoogleClientSecret',
+ component: {
+ type: 'secret',
+ placeholder: t('system.sections.oauth.groups.google.fields.client-secret.placeholder'),
+ revealable: true,
+ autoComplete: 'off',
+ },
+ },
+ ],
+ },
+ {
+ type: 'group',
+ id: 'oauth-github',
+ title: t('system.sections.oauth.groups.github.title'),
+ description: t('system.sections.oauth.groups.github.description'),
+ icon: 'github',
+ children: [
+ {
+ type: 'field',
+ id: 'oauth-github-client-id',
+ title: t('system.sections.oauth.groups.github.fields.client-id.title'),
+ description: t('system.sections.oauth.groups.github.fields.client-id.description'),
+ key: 'oauthGithubClientId',
+ component: {
+ type: 'text',
+ placeholder: t('system.sections.oauth.groups.github.fields.client-id.placeholder'),
+ },
+ },
+ {
+ type: 'field',
+ id: 'oauth-github-client-secret',
+ title: t('system.sections.oauth.groups.github.fields.client-secret.title'),
+ description: t('system.sections.oauth.groups.github.fields.client-secret.description'),
+ key: 'oauthGithubClientSecret',
+ component: {
+ type: 'secret',
+ placeholder: t('system.sections.oauth.groups.github.fields.client-secret.placeholder'),
+ revealable: true,
+ autoComplete: 'off',
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ }
}
const SYSTEM_SETTING_SCHEMA_FOR_KEYS = createSystemSettingUiSchema(identityUiSchemaT)
diff --git a/be/apps/core/src/modules/platform/billing/billing-plan.constants.ts b/be/apps/core/src/modules/platform/billing/billing-plan.constants.ts
index 64015f6a..0650eba2 100644
--- a/be/apps/core/src/modules/platform/billing/billing-plan.constants.ts
+++ b/be/apps/core/src/modules/platform/billing/billing-plan.constants.ts
@@ -7,6 +7,7 @@ export const BILLING_PLAN_DEFINITIONS: Record {
+ return await this.resolvePlanIdForTenant(tenantId)
+ }
+
+ getIncludedStorageBytes(planId: BillingPlanId): number {
+ const definition = BILLING_PLAN_DEFINITIONS[planId]
+ if (!definition) {
+ return 0
+ }
+ if (definition.includedStorageBytes === null) {
+ return Number.POSITIVE_INFINITY
+ }
+ return definition.includedStorageBytes ?? 0
+ }
+
getPlanDefinitions(): BillingPlanDefinition[] {
return BILLING_PLAN_IDS.map((id) => {
const definition = BILLING_PLAN_DEFINITIONS[id]
diff --git a/be/apps/core/src/modules/platform/billing/billing-plan.types.ts b/be/apps/core/src/modules/platform/billing/billing-plan.types.ts
index eef74a6b..7035887c 100644
--- a/be/apps/core/src/modules/platform/billing/billing-plan.types.ts
+++ b/be/apps/core/src/modules/platform/billing/billing-plan.types.ts
@@ -11,6 +11,7 @@ export interface BillingPlanDefinition {
id: BillingPlanId
name: string
description: string
+ includedStorageBytes?: number | null
quotas: BillingPlanQuota
}
diff --git a/be/apps/core/src/modules/platform/billing/billing.controller.ts b/be/apps/core/src/modules/platform/billing/billing.controller.ts
index 2e3508c1..08117fea 100644
--- a/be/apps/core/src/modules/platform/billing/billing.controller.ts
+++ b/be/apps/core/src/modules/platform/billing/billing.controller.ts
@@ -1,4 +1,4 @@
-import { Controller, createZodSchemaDto, Get, Query } from '@afilmory/framework'
+import { Body, Controller, createZodSchemaDto, Get, Patch, Query } from '@afilmory/framework'
import { Roles } from 'core/guards/roles.decorator'
import { inject } from 'tsyringe'
import z from 'zod'
@@ -7,18 +7,25 @@ import type { BillingPlanSummary } from './billing-plan.service'
import { BillingPlanService } from './billing-plan.service'
import type { BillingUsageOverview } from './billing-usage.service'
import { BillingUsageService } from './billing-usage.service'
+import { StoragePlanService } from './storage-plan.service'
const usageQuerySchema = z.object({
limit: z.coerce.number().positive().int().optional().default(10),
})
class UsageQueryDto extends createZodSchemaDto(usageQuerySchema) {}
+const updateStoragePlanSchema = z.object({
+ planId: z.string().trim().min(1).nullable().optional(),
+})
+class UpdateStoragePlanDto extends createZodSchemaDto(updateStoragePlanSchema) {}
+
@Controller('billing')
@Roles('admin')
export class BillingController {
constructor(
@inject(BillingUsageService) private readonly billingUsageService: BillingUsageService,
@inject(BillingPlanService) private readonly billingPlanService: BillingPlanService,
+ @inject(StoragePlanService) private readonly storagePlanService: StoragePlanService,
) {}
@Get('usage')
@@ -34,4 +41,15 @@ export class BillingController {
])
return { plan, availablePlans }
}
+
+ @Get('storage')
+ async getStoragePlans() {
+ return await this.storagePlanService.getOverviewForCurrentTenant()
+ }
+
+ @Patch('storage')
+ async updateStoragePlan(@Body() payload: UpdateStoragePlanDto) {
+ const planId = payload.planId ?? null
+ return await this.storagePlanService.updateCurrentTenantPlan(planId)
+ }
}
diff --git a/be/apps/core/src/modules/platform/billing/billing.module.ts b/be/apps/core/src/modules/platform/billing/billing.module.ts
index 534effa1..4b2f62b1 100644
--- a/be/apps/core/src/modules/platform/billing/billing.module.ts
+++ b/be/apps/core/src/modules/platform/billing/billing.module.ts
@@ -5,10 +5,11 @@ import { SystemSettingModule } from 'core/modules/configuration/system-setting/s
import { BillingController } from './billing.controller'
import { BillingPlanService } from './billing-plan.service'
import { BillingUsageService } from './billing-usage.service'
+import { StoragePlanService } from './storage-plan.service'
@Module({
imports: [DatabaseModule, SystemSettingModule],
controllers: [BillingController],
- providers: [BillingUsageService, BillingPlanService],
+ providers: [BillingUsageService, BillingPlanService, StoragePlanService],
})
export class BillingModule {}
diff --git a/be/apps/core/src/modules/platform/billing/storage-plan.constants.ts b/be/apps/core/src/modules/platform/billing/storage-plan.constants.ts
new file mode 100644
index 00000000..61e9994b
--- /dev/null
+++ b/be/apps/core/src/modules/platform/billing/storage-plan.constants.ts
@@ -0,0 +1,20 @@
+import type { StoragePlanCatalog } from './storage-plan.types'
+
+export const STORAGE_PLAN_CATALOG_SETTING_KEY = 'system.storage.planCatalog'
+export const STORAGE_PLAN_PRODUCTS_SETTING_KEY = 'system.storage.planProducts'
+export const STORAGE_PLAN_PRICING_SETTING_KEY = 'system.storage.planPricing'
+
+export const DEFAULT_STORAGE_PLAN_CATALOG: StoragePlanCatalog = {
+ 'managed-5gb': {
+ name: 'Managed B2 • 5GB',
+ description: '适用于入门用户的托管存储方案。',
+ capacityBytes: 5 * 1024 * 1024 * 1024,
+ isActive: true,
+ },
+ 'managed-50gb': {
+ name: 'Managed B2 • 50GB',
+ description: '适用于成长阶段的托管存储方案。',
+ capacityBytes: 50 * 1024 * 1024 * 1024,
+ isActive: true,
+ },
+}
diff --git a/be/apps/core/src/modules/platform/billing/storage-plan.service.ts b/be/apps/core/src/modules/platform/billing/storage-plan.service.ts
new file mode 100644
index 00000000..ef5f62f0
--- /dev/null
+++ b/be/apps/core/src/modules/platform/billing/storage-plan.service.ts
@@ -0,0 +1,210 @@
+import { tenants } from '@afilmory/db'
+import { DbAccessor } from 'core/database/database.provider'
+import { BizException, ErrorCode } from 'core/errors'
+import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service'
+import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
+import { eq } from 'drizzle-orm'
+import { injectable } from 'tsyringe'
+
+import { BillingPlanService } from './billing-plan.service'
+import { DEFAULT_STORAGE_PLAN_CATALOG } from './storage-plan.constants'
+import type {
+ StoragePlanCatalog,
+ StoragePlanDefinition,
+ StoragePlanOverview,
+ StoragePlanPaymentInfo,
+ StoragePlanPricing,
+ StoragePlanSummary,
+} from './storage-plan.types'
+
+export interface StorageQuotaSummary {
+ appIncludedBytes: number
+ storagePlanBytes: number | null
+ totalBytes: number | null
+}
+
+@injectable()
+export class StoragePlanService {
+ constructor(
+ private readonly dbAccessor: DbAccessor,
+ private readonly systemSettingService: SystemSettingService,
+ private readonly billingPlanService: BillingPlanService,
+ ) {}
+
+ async getPlanSummaries(): Promise {
+ const [catalog, pricing, products] = await Promise.all([
+ this.getPlanCatalog(),
+ this.systemSettingService.getStoragePlanPricing(),
+ this.systemSettingService.getStoragePlanProducts(),
+ ])
+
+ return Object.entries(catalog)
+ .map(([id, entry]) =>
+ this.buildPlanSummary(
+ {
+ id,
+ ...entry,
+ },
+ pricing[id],
+ products[id],
+ ),
+ )
+ .filter((plan) => plan.isActive !== false)
+ }
+
+ async getPlanById(planId: string): Promise {
+ const [catalog, pricing, products] = await Promise.all([
+ this.getPlanCatalog(),
+ this.systemSettingService.getStoragePlanPricing(),
+ this.systemSettingService.getStoragePlanProducts(),
+ ])
+
+ const definition = catalog[planId]
+ if (!definition) {
+ return null
+ }
+
+ return this.buildPlanSummary({ id: planId, ...definition }, pricing[planId], products[planId])
+ }
+
+ async getQuotaForTenant(tenantId: string): Promise {
+ const [tenantPlanId, resolvedPlanId, catalog] = await Promise.all([
+ this.resolveStoragePlanIdForTenant(tenantId),
+ this.billingPlanService.getPlanIdForTenant(tenantId),
+ this.getPlanCatalog(),
+ ])
+
+ const appIncluded = this.billingPlanService.getIncludedStorageBytes(resolvedPlanId)
+ const storagePlan = tenantPlanId ? catalog[tenantPlanId] : undefined
+ const storagePlanCapacity = storagePlan?.capacityBytes
+ const storagePlanBytes = storagePlanCapacity === undefined ? 0 : storagePlanCapacity
+
+ const totalBytes =
+ appIncluded === Number.POSITIVE_INFINITY || storagePlanCapacity === null
+ ? null
+ : (appIncluded || 0) + (storagePlanBytes || 0)
+
+ return {
+ appIncludedBytes: appIncluded,
+ storagePlanBytes,
+ totalBytes,
+ }
+ }
+
+ async getPlanSummaryForTenant(tenantId: string): Promise {
+ const planId = await this.resolveStoragePlanIdForTenant(tenantId)
+ if (!planId) {
+ return null
+ }
+ const plan = await this.getPlanById(planId)
+ if (!plan) {
+ return null
+ }
+ return plan
+ }
+
+ async getOverviewForCurrentTenant(): Promise {
+ const tenant = requireTenantContext()
+ const [plans, currentPlan, providerKey] = await Promise.all([
+ this.getPlanSummaries(),
+ this.getPlanSummaryForTenant(tenant.tenant.id),
+ this.systemSettingService.getManagedStorageProviderKey(),
+ ])
+
+ return {
+ managedStorageEnabled: Boolean(providerKey),
+ managedProviderKey: providerKey ?? null,
+ currentPlanId: currentPlan?.id ?? null,
+ currentPlan,
+ availablePlans: plans,
+ }
+ }
+
+ async updateCurrentTenantPlan(planId: string | null): Promise {
+ const tenant = requireTenantContext()
+ await this.assignPlanToTenant(tenant.tenant.id, planId)
+ return await this.getOverviewForCurrentTenant()
+ }
+
+ private async resolveStoragePlanIdForTenant(tenantId: string): Promise {
+ const db = this.dbAccessor.get()
+ const [record] = await db
+ .select({ storagePlanId: tenants.storagePlanId })
+ .from(tenants)
+ .where(eq(tenants.id, tenantId))
+ .limit(1)
+
+ if (!record) {
+ throw new BizException(ErrorCode.TENANT_NOT_FOUND)
+ }
+ const planId = record.storagePlanId?.trim()
+ return planId && planId.length > 0 ? planId : null
+ }
+
+ private async getPlanCatalog(): Promise> {
+ const config = await this.systemSettingService.getStoragePlanCatalog()
+ const merged: StoragePlanCatalog = { ...DEFAULT_STORAGE_PLAN_CATALOG, ...config }
+ return Object.entries(merged).reduce>((acc, [id, entry]) => {
+ if (!id) {
+ return acc
+ }
+ acc[id] = {
+ id,
+ name: entry.name,
+ description: entry.description ?? null,
+ capacityBytes: entry.capacityBytes ?? 0,
+ isActive: entry.isActive ?? true,
+ }
+ return acc
+ }, {})
+ }
+
+ private buildPlanSummary(
+ definition: StoragePlanDefinition,
+ pricing?: StoragePlanPricing,
+ payment?: StoragePlanPaymentInfo,
+ ): StoragePlanSummary {
+ return {
+ ...definition,
+ pricing,
+ payment,
+ }
+ }
+
+ private async assignPlanToTenant(tenantId: string, planId: string | null): Promise {
+ const normalizedPlanId = this.normalizePlanId(planId)
+ const managedProviderKey = await this.systemSettingService.getManagedStorageProviderKey()
+ if (!managedProviderKey) {
+ throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
+ message: '托管存储尚未启用,暂时无法订阅托管存储方案。',
+ })
+ }
+
+ if (normalizedPlanId) {
+ const plan = await this.getPlanById(normalizedPlanId)
+ if (!plan || plan.isActive === false) {
+ throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
+ message: `未知或未启用的托管存储方案:${normalizedPlanId}`,
+ })
+ }
+ }
+
+ await this.persistTenantStoragePlan(tenantId, normalizedPlanId)
+ }
+
+ private async persistTenantStoragePlan(tenantId: string, planId: string | null): Promise {
+ const db = this.dbAccessor.get()
+ await db
+ .update(tenants)
+ .set({ storagePlanId: planId, updatedAt: new Date().toISOString() })
+ .where(eq(tenants.id, tenantId))
+ }
+
+ private normalizePlanId(planId?: string | null): string | null {
+ if (typeof planId !== 'string') {
+ return null
+ }
+ const trimmed = planId.trim()
+ return trimmed.length > 0 ? trimmed : null
+ }
+}
diff --git a/be/apps/core/src/modules/platform/billing/storage-plan.types.ts b/be/apps/core/src/modules/platform/billing/storage-plan.types.ts
new file mode 100644
index 00000000..ba766082
--- /dev/null
+++ b/be/apps/core/src/modules/platform/billing/storage-plan.types.ts
@@ -0,0 +1,34 @@
+export interface StoragePlanDefinition {
+ id: string
+ name: string
+ description?: string | null
+ capacityBytes: number | null
+ isActive?: boolean
+}
+
+export type StoragePlanCatalog = Record>
+
+export interface StoragePlanPricing {
+ monthlyPrice: number | null
+ currency: string | null
+}
+
+export interface StoragePlanPaymentInfo {
+ creemProductId?: string | null
+}
+
+export type StoragePlanPricingConfigs = Record
+export type StoragePlanProductConfigs = Record
+
+export interface StoragePlanSummary extends StoragePlanDefinition {
+ pricing?: StoragePlanPricing
+ payment?: StoragePlanPaymentInfo
+}
+
+export interface StoragePlanOverview {
+ managedStorageEnabled: boolean
+ managedProviderKey: string | null
+ currentPlanId: string | null
+ currentPlan: StoragePlanSummary | null
+ availablePlans: StoragePlanSummary[]
+}
diff --git a/be/apps/core/src/modules/platform/super-admin/super-admin.dto.ts b/be/apps/core/src/modules/platform/super-admin/super-admin.dto.ts
index 88e3b9d1..df05f1c9 100644
--- a/be/apps/core/src/modules/platform/super-admin/super-admin.dto.ts
+++ b/be/apps/core/src/modules/platform/super-admin/super-admin.dto.ts
@@ -31,6 +31,14 @@ const planProductFields = (() => {
return fields
})()
+const storageProviderConfigSchema = z.record(z.string(), z.string())
+const storageProviderSchema = z.object({
+ id: z.string().trim().min(1).optional(),
+ name: z.string().trim().optional(),
+ type: z.string().trim().min(1),
+ config: storageProviderConfigSchema.optional(),
+})
+
const updateSuperAdminSettingsSchema = z
.object({
allowRegistration: z.boolean().optional(),
@@ -55,6 +63,11 @@ const updateSuperAdminSettingsSchema = z
oauthGoogleClientSecret: z.string().trim().min(1).nullable().optional(),
oauthGithubClientId: z.string().trim().min(1).nullable().optional(),
oauthGithubClientSecret: z.string().trim().min(1).nullable().optional(),
+ storagePlanCatalog: z.record(z.string(), z.any()).optional(),
+ storagePlanPricing: z.record(z.string(), z.any()).optional(),
+ storagePlanProducts: z.record(z.string(), z.any()).optional(),
+ managedStorageProvider: z.string().trim().min(1).nullable().optional(),
+ managedStorageProviders: z.array(storageProviderSchema).optional(),
...planQuotaFields,
...planPricingFields,
...planProductFields,
diff --git a/be/apps/core/src/modules/platform/tenant/tenant.repository.ts b/be/apps/core/src/modules/platform/tenant/tenant.repository.ts
index 3a081594..30d69463 100644
--- a/be/apps/core/src/modules/platform/tenant/tenant.repository.ts
+++ b/be/apps/core/src/modules/platform/tenant/tenant.repository.ts
@@ -31,7 +31,12 @@ export class TenantRepository {
return { tenant }
}
- async createTenant(payload: { name: string; slug: string; planId?: BillingPlanId }): Promise {
+ async createTenant(payload: {
+ name: string
+ slug: string
+ planId?: BillingPlanId
+ storagePlanId?: string | null
+ }): Promise {
const db = this.dbAccessor.get()
const tenantId = generateId()
const tenantRecord: typeof tenants.$inferInsert = {
@@ -39,6 +44,7 @@ export class TenantRepository {
name: payload.name,
slug: payload.slug,
planId: payload.planId ?? 'free',
+ storagePlanId: payload.storagePlanId ?? null,
status: 'active',
}
diff --git a/be/apps/core/src/modules/platform/tenant/tenant.service.ts b/be/apps/core/src/modules/platform/tenant/tenant.service.ts
index 4d9ac586..204cc41e 100644
--- a/be/apps/core/src/modules/platform/tenant/tenant.service.ts
+++ b/be/apps/core/src/modules/platform/tenant/tenant.service.ts
@@ -16,7 +16,12 @@ import type { TenantAggregate, TenantContext, TenantResolutionInput } from './te
export class TenantService {
constructor(private readonly repository: TenantRepository) {}
- async createTenant(payload: { name: string; slug: string; planId?: BillingPlanId }): Promise {
+ async createTenant(payload: {
+ name: string
+ slug: string
+ planId?: BillingPlanId
+ storagePlanId?: string | null
+ }): Promise {
const normalizedSlug = this.normalizeSlug(payload.slug)
if (!normalizedSlug) {
diff --git a/be/apps/dashboard/src/modules/storage-plans/api.ts b/be/apps/dashboard/src/modules/storage-plans/api.ts
new file mode 100644
index 00000000..48f93a52
--- /dev/null
+++ b/be/apps/dashboard/src/modules/storage-plans/api.ts
@@ -0,0 +1,21 @@
+import { coreApi } from '~/lib/api-client'
+import { camelCaseKeys } from '~/lib/case'
+
+import type { ManagedStorageOverview } from './types'
+
+const STORAGE_BILLING_ENDPOINT = '/billing/storage'
+
+export async function getManagedStorageOverview(): Promise {
+ return camelCaseKeys(
+ await coreApi(STORAGE_BILLING_ENDPOINT, {
+ method: 'GET',
+ }),
+ )
+}
+
+export async function updateManagedStoragePlan(planId: string | null): Promise {
+ return await coreApi(STORAGE_BILLING_ENDPOINT, {
+ method: 'PATCH',
+ body: { planId },
+ })
+}
diff --git a/be/apps/dashboard/src/modules/storage-plans/hooks.ts b/be/apps/dashboard/src/modules/storage-plans/hooks.ts
new file mode 100644
index 00000000..e95065e2
--- /dev/null
+++ b/be/apps/dashboard/src/modules/storage-plans/hooks.ts
@@ -0,0 +1,24 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+
+import { getManagedStorageOverview, updateManagedStoragePlan } from './api'
+import type { ManagedStorageOverview } from './types'
+
+export const MANAGED_STORAGE_PLAN_QUERY_KEY = ['billing', 'storage-plan'] as const
+
+export function useManagedStoragePlansQuery() {
+ return useQuery({
+ queryKey: MANAGED_STORAGE_PLAN_QUERY_KEY,
+ queryFn: getManagedStorageOverview,
+ })
+}
+
+export function useUpdateManagedStoragePlanMutation() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (planId: string | null) => updateManagedStoragePlan(planId),
+ onSuccess: (data) => {
+ queryClient.setQueryData(MANAGED_STORAGE_PLAN_QUERY_KEY, data)
+ },
+ })
+}
diff --git a/be/apps/dashboard/src/modules/storage-plans/index.ts b/be/apps/dashboard/src/modules/storage-plans/index.ts
new file mode 100644
index 00000000..a1c77e91
--- /dev/null
+++ b/be/apps/dashboard/src/modules/storage-plans/index.ts
@@ -0,0 +1,2 @@
+export * from './hooks'
+export * from './types'
diff --git a/be/apps/dashboard/src/modules/storage-plans/types.ts b/be/apps/dashboard/src/modules/storage-plans/types.ts
new file mode 100644
index 00000000..fe2e8d97
--- /dev/null
+++ b/be/apps/dashboard/src/modules/storage-plans/types.ts
@@ -0,0 +1,25 @@
+export interface ManagedStoragePricing {
+ monthlyPrice: number | null
+ currency: string | null
+}
+
+export interface ManagedStoragePaymentInfo {
+ creemProductId?: string | null
+}
+
+export interface ManagedStoragePlanSummary {
+ id: string
+ name: string
+ description?: string | null
+ capacityBytes: number | null
+ pricing?: ManagedStoragePricing
+ payment?: ManagedStoragePaymentInfo
+}
+
+export interface ManagedStorageOverview {
+ managedStorageEnabled: boolean
+ managedProviderKey: string | null
+ currentPlanId: string | null
+ currentPlan: ManagedStoragePlanSummary | null
+ availablePlans: ManagedStoragePlanSummary[]
+}
diff --git a/be/apps/dashboard/src/modules/storage-providers/components/ManagedStorageEntryCard.tsx b/be/apps/dashboard/src/modules/storage-providers/components/ManagedStorageEntryCard.tsx
new file mode 100644
index 00000000..c70591a9
--- /dev/null
+++ b/be/apps/dashboard/src/modules/storage-providers/components/ManagedStorageEntryCard.tsx
@@ -0,0 +1,76 @@
+import { Button, Modal } from '@afilmory/ui'
+import { DynamicIcon } from 'lucide-react/dynamic'
+import { m } from 'motion/react'
+import { useTranslation } from 'react-i18next'
+
+import { useManagedStoragePlansQuery } from '~/modules/storage-plans'
+
+import { ManagedStoragePlansModal } from './ManagedStoragePlansModal'
+
+const managedStorageI18nKeys = {
+ title: 'photos.storage.managed.title',
+ description: 'photos.storage.managed.description',
+ unavailable: 'photos.storage.managed.unavailable',
+ empty: 'photos.storage.managed.empty',
+ action: 'photos.storage.managed.actions.subscribe',
+ seePlans: 'photos.storage.managed.actions.switch',
+ loading: 'photos.storage.managed.actions.loading',
+} as const
+
+export function ManagedStorageEntryCard() {
+ const { t } = useTranslation()
+ const plansQuery = useManagedStoragePlansQuery()
+
+ const openModal = () => {
+ Modal.present(ManagedStoragePlansModal, {}, { dismissOnOutsideClick: true })
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
{t(managedStorageI18nKeys.title)}
+
{t(managedStorageI18nKeys.description)}
+
+ {/*
+
+ {plansQuery.isLoading
+ ? t(managedStorageI18nKeys.loading)
+ : plansQuery.isError
+ ? t(managedStorageI18nKeys.unavailable)
+ : plansQuery.data?.managedStorageEnabled
+ ? t(managedStorageI18nKeys.seePlans)
+ : t(managedStorageI18nKeys.unavailable)}
+
*/}
+
+
+
+
+
+
+ )
+}
diff --git a/be/apps/dashboard/src/modules/storage-providers/components/ManagedStoragePlansModal.tsx b/be/apps/dashboard/src/modules/storage-providers/components/ManagedStoragePlansModal.tsx
new file mode 100644
index 00000000..c1ad07dd
--- /dev/null
+++ b/be/apps/dashboard/src/modules/storage-providers/components/ManagedStoragePlansModal.tsx
@@ -0,0 +1,221 @@
+import { Button, DialogDescription, DialogHeader, DialogTitle } from '@afilmory/ui'
+import { clsxm } from '@afilmory/utils'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+
+import { getI18n } from '~/i18n'
+import type { ManagedStoragePlanSummary } from '~/modules/storage-plans'
+import { useManagedStoragePlansQuery, useUpdateManagedStoragePlanMutation } from '~/modules/storage-plans'
+
+const managedStorageI18nKeys = {
+ title: 'photos.storage.managed.title',
+ description: 'photos.storage.managed.description',
+ unavailable: 'photos.storage.managed.unavailable',
+ empty: 'photos.storage.managed.empty',
+ capacityLabel: 'photos.storage.managed.capacity.label',
+ capacityUnlimited: 'photos.storage.managed.capacity.unlimited',
+ capacityUnknown: 'photos.storage.managed.capacity.unknown',
+ priceLabel: 'photos.storage.managed.price.label',
+ priceFree: 'photos.storage.managed.price.free',
+ actionsSubscribe: 'photos.storage.managed.actions.subscribe',
+ actionsSwitch: 'photos.storage.managed.actions.switch',
+ actionsCurrent: 'photos.storage.managed.actions.current',
+ actionsCancel: 'photos.storage.managed.actions.cancel',
+ actionsLoading: 'photos.storage.managed.actions.loading',
+ errorLoad: 'photos.storage.managed.error.load',
+ toastSuccess: 'photos.storage.managed.toast.success',
+ toastError: 'photos.storage.managed.toast.error',
+} as const
+
+export function ManagedStoragePlansModal() {
+ const { t, i18n } = useTranslation()
+ const locale = i18n.language ?? i18n.resolvedLanguage ?? 'en'
+ const plansQuery = useManagedStoragePlansQuery()
+ const updateMutation = useUpdateManagedStoragePlanMutation()
+
+ const numberFormatter = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 })
+ const priceFormatter = new Intl.NumberFormat(locale, { minimumFractionDigits: 0, maximumFractionDigits: 2 })
+
+ const handleSelect = async (planId: string | null) => {
+ try {
+ await updateMutation.mutateAsync(planId)
+ toast.success(t(managedStorageI18nKeys.toastSuccess))
+ } catch (error) {
+ toast.error(
+ t(managedStorageI18nKeys.toastError, {
+ reason: extractErrorMessage(error, t('common.unknown-error')),
+ }),
+ )
+ }
+ }
+
+ return (
+
+
+
+ {t(managedStorageI18nKeys.title)}
+
+
+ {t(managedStorageI18nKeys.description)}
+
+
+
+
+ {plansQuery.isLoading ? (
+
+ {Array.from({ length: 2 }).map((_, index) => (
+
+ ))}
+
+ ) : plansQuery.isError ? (
+
+ {t(managedStorageI18nKeys.errorLoad)}
+
+ ) : !plansQuery.data?.managedStorageEnabled ? (
+
+ {t(managedStorageI18nKeys.unavailable)}
+
+ ) : plansQuery.data.availablePlans.length === 0 ? (
+
+ {t(managedStorageI18nKeys.empty)}
+
+ ) : (
+
+ {plansQuery.data.availablePlans.map((plan) => (
+
handleSelect(plan.id)}
+ formatCapacity={(bytes) => formatCapacity(bytes, numberFormatter)}
+ formatPrice={(value, currency) => formatPrice(value, currency, priceFormatter)}
+ />
+ ))}
+
+ )}
+
+ {plansQuery.data?.currentPlanId ? (
+
+
+
+ ) : null}
+
+
+ )
+}
+
+function PlanCard({
+ plan,
+ isCurrent,
+ hasCurrentPlan,
+ isProcessing,
+ onSelect,
+ formatCapacity,
+ formatPrice,
+}: {
+ plan: ManagedStoragePlanSummary
+ isCurrent: boolean
+ hasCurrentPlan: boolean
+ isProcessing: boolean
+ onSelect: () => void
+ formatCapacity: (bytes: number | null) => string
+ formatPrice: (value: number, currency: string | null | undefined) => string
+}) {
+ const { t } = useTranslation()
+
+ const hasPrice =
+ plan.pricing &&
+ plan.pricing.monthlyPrice !== null &&
+ plan.pricing.monthlyPrice !== undefined &&
+ Number.isFinite(plan.pricing.monthlyPrice)
+
+ const priceLabel = hasPrice
+ ? t(managedStorageI18nKeys.priceLabel, {
+ price: formatPrice(plan.pricing!.monthlyPrice as number, plan.pricing!.currency ?? null),
+ })
+ : t(managedStorageI18nKeys.priceFree)
+
+ const capacityLabel = formatCapacity(plan.capacityBytes)
+ const actionLabel = isCurrent
+ ? t(managedStorageI18nKeys.actionsCurrent)
+ : hasCurrentPlan
+ ? t(managedStorageI18nKeys.actionsSwitch)
+ : t(managedStorageI18nKeys.actionsSubscribe)
+
+ return (
+
+
+
+
{plan.name}
+ {plan.description ?
{plan.description}
: null}
+
+ {isCurrent ? (
+
+ {t(managedStorageI18nKeys.actionsCurrent)}
+
+ ) : null}
+
+
+
+
{capacityLabel}
+
{priceLabel}
+
+
+
+
+ )
+}
+
+function formatCapacity(bytes: number | null, formatter: Intl.NumberFormat) {
+ const { t } = getI18n()
+ if (bytes === null) {
+ return t(managedStorageI18nKeys.capacityUnlimited)
+ }
+ if (bytes === undefined || bytes <= 0 || Number.isNaN(bytes)) {
+ return t(managedStorageI18nKeys.capacityUnknown)
+ }
+ const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
+ const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
+ const value = bytes / 1024 ** exponent
+ const formattedValue = formatter.format(value >= 10 ? Math.round(value) : value)
+ return t(managedStorageI18nKeys.capacityLabel, { value: `${formattedValue} ${units[exponent]}` })
+}
+
+function formatPrice(value: number, currency: string | null | undefined, formatter: Intl.NumberFormat) {
+ const formatted = formatter.format(value)
+ const normalizedCurrency = currency?.toUpperCase()?.trim()
+ return normalizedCurrency ? `${normalizedCurrency} ${formatted}` : formatted
+}
+
+function extractErrorMessage(error: unknown, fallback: string) {
+ if (error instanceof Error && error.message) return error.message
+ if (typeof error === 'object' && error && 'message' in error && typeof (error as any).message === 'string') {
+ return (error as any).message
+ }
+ return fallback
+}
diff --git a/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx b/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx
index 35090a94..e23d9901 100644
--- a/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx
+++ b/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx
@@ -141,7 +141,7 @@ export const ProviderCard: FC = ({ provider, isActive, onEdit
className="border-accent/30 bg-accent/10 text-accent hover:bg-accent/20 border"
onClick={onToggleActive}
>
-
+
{t(storageProvidersI18nKeys.card.makeActive)}
)}
diff --git a/be/apps/dashboard/src/modules/storage-providers/components/ProviderEditModal.tsx b/be/apps/dashboard/src/modules/storage-providers/components/ProviderEditModal.tsx
index 8290e103..9092a7d5 100644
--- a/be/apps/dashboard/src/modules/storage-providers/components/ProviderEditModal.tsx
+++ b/be/apps/dashboard/src/modules/storage-providers/components/ProviderEditModal.tsx
@@ -88,7 +88,7 @@ export function ProviderEditModal({
const handleSave = () => {
if (!formData) return
- formData.id = formData.id ?? nanoid()
+ formData.id = formData.id && formData.id.trim().length > 0 ? formData.id : nanoid()
onSave(formData)
dismiss()
}
diff --git a/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx b/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx
index 2f0b424d..2d9edd8e 100644
--- a/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx
+++ b/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx
@@ -255,6 +255,8 @@ export function StorageProvidersManager() {
transition={Spring.presets.smooth}
className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
+ {/* */}
+
{orderedProviders.map((provider, index) => (
{
+ const normalized = normalizeStorageProviderConfig(entry as StorageProvider)
+ const id = normalizeProviderId(normalized.id)
+ if (!id) {
+ return null
+ }
+ return { ...normalized, id }
+ })
+ .filter((provider): provider is StorageProvider => provider !== null)
+}
+
+const normalizeProviderId = (value: string | null | undefined): string | null => {
+ if (typeof value !== 'string') {
+ return null
+ }
+ const trimmed = value.trim()
+ return trimmed.length > 0 ? trimmed : null
+}
+
+const ensureLocalProviderId = (value: string | null | undefined): string => {
+ return normalizeProviderId(value) ?? nanoid()
+}
+
+export function ManagedStorageSettings() {
+ const { t } = useTranslation()
+ const schemaQuery = useStorageProviderSchemaQuery()
+ const settingsQuery = useSuperAdminSettingsQuery()
+ const updateSettings = useUpdateSuperAdminSettingsMutation()
+
+ const [providers, setProviders] = useState([])
+ const [baselineProviders, setBaselineProviders] = useState([])
+ const [managedId, setManagedId] = useState(null)
+
+ const settingsSource = useMemo(() => {
+ const payload = settingsQuery.data
+ if (!payload) return null
+ return 'values' in payload ? payload.values : payload.settings
+ }, [settingsQuery.data])
+
+ const baselineManagedId = useMemo(() => {
+ const managed = settingsSource?.managedStorageProvider
+ return typeof managed === 'string' && managed.trim().length > 0 ? managed.trim() : null
+ }, [settingsSource])
+
+ useEffect(() => {
+ if (!settingsSource) {
+ return
+ }
+ const rawProviders = settingsSource.managedStorageProviders
+ const nextProviders = coerceManagedProviders(rawProviders)
+ setProviders(nextProviders)
+ setBaselineProviders(nextProviders)
+ const fallbackId = baselineManagedId ?? normalizeProviderId(nextProviders[0]?.id) ?? null
+ setManagedId(fallbackId)
+ }, [settingsSource, baselineManagedId])
+
+ useEffect(() => {
+ if (!managedId) {
+ return
+ }
+ const exists = providers.some((provider) => normalizeProviderId(provider.id) === managedId)
+ if (!exists) {
+ setManagedId(null)
+ }
+ }, [providers, managedId])
+
+ const orderedProviders = useMemo(() => reorderProvidersByActive(providers, managedId), [providers, managedId])
+ const providersChanged = useMemo(
+ () => JSON.stringify(baselineProviders) !== JSON.stringify(providers),
+ [baselineProviders, providers],
+ )
+ const normalizedManagedId = normalizeProviderId(managedId)
+ const managedChanged = normalizedManagedId !== baselineManagedId
+ const canSave = (providersChanged || managedChanged) && !updateSettings.isPending
+
+ const handleEdit = (provider: StorageProvider | null) => {
+ const providerForm = schemaQuery.data
+ if (!providerForm) return
+
+ const seed = provider ?? createEmptyProvider(providerForm.types[0]?.value ?? 's3')
+ Modal.present(
+ ProviderEditModal,
+ {
+ provider: seed,
+ activeProviderId: null,
+ providerSchema: providerForm,
+ onSave: (next) => {
+ const normalized = normalizeStorageProviderConfig(next)
+ const providerWithId: StorageProvider = { ...normalized, id: ensureLocalProviderId(normalized.id) }
+ setProviders((prev) => {
+ const exists = prev.some((item) => item.id === providerWithId.id)
+ if (exists) {
+ return prev.map((item) => (item.id === providerWithId.id ? providerWithId : item))
+ }
+ return [...prev, providerWithId]
+ })
+ setManagedId((prev) => prev ?? providerWithId.id)
+ },
+ onSetActive: () => {},
+ },
+ { dismissOnOutsideClick: false },
+ )
+ }
+
+ const handleSave = () => {
+ if (!providersChanged && !managedChanged) {
+ return
+ }
+
+ const payload: UpdateSuperAdminSettingsPayload = {}
+ if (providersChanged) {
+ payload.managedStorageProviders = providers
+ }
+ if (managedChanged) {
+ payload.managedStorageProvider = normalizedManagedId
+ }
+ updateSettings.mutate(payload)
+ }
+
+ if (schemaQuery.isLoading || settingsQuery.isLoading) {
+ return (
+
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+ )
+ }
+
+ if (schemaQuery.isError || settingsQuery.isError) {
+ return (
+
+ {t('superadmin.settings.managed-storage.title')}
+
+ {t('superadmin.settings.managed-storage.error', {
+ reason:
+ (settingsQuery.error as Error | undefined)?.message ||
+ (schemaQuery.error as Error | undefined)?.message ||
+ t('common.unknown-error'),
+ })}
+
+
+ )
+ }
+
+ return (
+
+
+
+
{t('superadmin.settings.managed-storage.title')}
+
{t('superadmin.settings.managed-storage.description')}
+
+
+
+
+ {orderedProviders.length === 0 ? (
+ {t('superadmin.settings.managed-storage.empty')}
+ ) : (
+
+ {orderedProviders.map((provider) => (
+
+
+
+
+ {managedId === provider.id ? (
+
+ {t('superadmin.settings.managed-storage.current')}
+
+ ) : null}
+
+
+ {t('superadmin.settings.managed-storage.type', { type: provider.type })}
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/be/apps/dashboard/src/modules/super-admin/index.ts b/be/apps/dashboard/src/modules/super-admin/index.ts
index 61e91f76..84e3f47d 100644
--- a/be/apps/dashboard/src/modules/super-admin/index.ts
+++ b/be/apps/dashboard/src/modules/super-admin/index.ts
@@ -1,4 +1,5 @@
export * from './api'
+export * from './components/ManagedStorageSettings'
export * from './components/SuperAdminSettingsForm'
export * from './components/SuperAdminTenantManager'
export * from './hooks'
diff --git a/be/apps/dashboard/src/modules/super-admin/types.ts b/be/apps/dashboard/src/modules/super-admin/types.ts
index af95d9d3..72cdc2ec 100644
--- a/be/apps/dashboard/src/modules/super-admin/types.ts
+++ b/be/apps/dashboard/src/modules/super-admin/types.ts
@@ -2,10 +2,18 @@ import type { PhotoManifestItem } from '@afilmory/builder'
import type { BillingUsageTotalsEntry, PhotoAssetListItem, PhotoSyncLogLevel } from '../photos/types'
import type { SchemaFormValue, UiSchema } from '../schema-form/types'
+import type { StorageProvider } from '../storage-providers/types'
export type SuperAdminSettingField = string
export type SuperAdminSettings = Record
+export type SuperAdminSettingsWithStorage = SuperAdminSettings & {
+ storagePlanCatalog?: Record
+ storagePlanPricing?: Record
+ storagePlanProducts?: Record
+ managedStorageProvider?: string | null
+ managedStorageProviders?: StorageProvider[]
+}
export interface SuperAdminStats {
totalUsers: number
@@ -27,9 +35,10 @@ export type SuperAdminSettingsResponse =
values?: never
})
-export type UpdateSuperAdminSettingsPayload = Partial<
- Record
->
+export type UpdateSuperAdminSettingsPayload = Partial<{
+ managedStorageProvider: string | null
+ managedStorageProviders: StorageProvider[]
+}>
export type BuilderDebugProgressEvent =
| {
diff --git a/be/apps/dashboard/src/pages/superadmin/plans.tsx b/be/apps/dashboard/src/pages/superadmin/plans.tsx
index 9203162e..4f4ed197 100644
--- a/be/apps/dashboard/src/pages/superadmin/plans.tsx
+++ b/be/apps/dashboard/src/pages/superadmin/plans.tsx
@@ -1,13 +1,30 @@
+import { Button, Input, Switch, Textarea } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
+import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { SuperAdminSettingsForm } from '~/modules/super-admin'
+import { LinearBorderPanel } from '~/components/common/LinearBorderPanel'
+import { PageTabs } from '~/components/navigation/PageTabs'
+import type { UpdateSuperAdminSettingsPayload } from '~/modules/super-admin'
+import {
+ SuperAdminSettingsForm,
+ useSuperAdminSettingsQuery,
+ useUpdateSuperAdminSettingsMutation,
+} from '~/modules/super-admin'
-const PLAN_SECTION_IDS = ['billing-plan-settings'] as const
+const APP_PLAN_SECTION_IDS = ['billing-plan-settings'] as const
+const STORAGE_PLAN_SECTION_IDS = ['storage-plan-settings'] as const
+const TABS = [
+ { id: 'app', labelKey: 'superadmin.plans.tabs.app', sections: APP_PLAN_SECTION_IDS },
+ { id: 'storage', labelKey: 'superadmin.plans.tabs.storage', sections: STORAGE_PLAN_SECTION_IDS },
+] as const
export function Component() {
const { t } = useTranslation()
+ const [activeTab, setActiveTab] = useState<(typeof TABS)[number]['id']>('app')
+ const active = TABS.find((tab) => tab.id === activeTab) ?? TABS[0]
+
return (
{t('superadmin.plans.description')}
-
+ setActiveTab(id as typeof activeTab)}
+ items={TABS.map((tab) => ({
+ id: tab.id,
+ labelKey: tab.labelKey,
+ }))}
+ />
+
+ {active.id === 'app' ? : }
)
}
+
+type StoragePlanRow = {
+ id: string
+ name: string
+ description: string
+ capacityGb: string
+ isActive: boolean
+ monthlyPrice: string
+ currency: string
+ creemProductId: string
+}
+
+function bytesToGb(bytes: unknown): string {
+ if (bytes === null || bytes === undefined) return ''
+ const num = Number(bytes)
+ if (!Number.isFinite(num)) return ''
+ return (num / 1024 / 1024 / 1024).toFixed(2)
+}
+
+function gbToBytes(value: string): number | null {
+ const parsed = Number(value)
+ if (!Number.isFinite(parsed)) return null
+ return Math.max(0, Math.round(parsed * 1024 * 1024 * 1024))
+}
+
+function StoragePlanEditor() {
+ const { t } = useTranslation()
+ const query = useSuperAdminSettingsQuery()
+ const mutation = useUpdateSuperAdminSettingsMutation()
+
+ const rawValues = useMemo(() => {
+ const payload = query.data
+ if (!payload) return null
+ if ('values' in payload && payload.values) return payload.values
+ if ('settings' in payload && payload.settings) return payload.settings
+ return null
+ }, [query.data])
+
+ type StoragePlanCatalogEntry = {
+ name?: string
+ description?: string | null
+ capacityBytes?: number | null
+ isActive?: boolean
+ }
+ type StoragePlanPricingEntry = { monthlyPrice?: number | null; currency?: string | null }
+ type StoragePlanProductEntry = { creemProductId?: string | null }
+
+ const parsed = useMemo(() => {
+ const catalog = (rawValues?.storagePlanCatalog as Record | undefined) ?? {}
+ const pricing = (rawValues?.storagePlanPricing as Record | undefined) ?? {}
+ const products = (rawValues?.storagePlanProducts as Record | undefined) ?? {}
+ const provider = (rawValues?.managedStorageProvider as string | null | undefined) ?? ''
+
+ return {
+ rows: Object.entries(catalog).map(([id, entry]) => ({
+ id,
+ name: entry?.name ?? '',
+ description: entry?.description ?? '',
+ capacityGb: bytesToGb(entry?.capacityBytes),
+ isActive: entry?.isActive !== false,
+ monthlyPrice: pricing[id]?.monthlyPrice?.toString() ?? '',
+ currency: pricing[id]?.currency ?? '',
+ creemProductId: products[id]?.creemProductId ?? '',
+ })),
+ provider,
+ }
+ }, [rawValues])
+
+ const [rows, setRows] = useState(parsed.rows)
+ const [providerKey, setProviderKey] = useState(parsed.provider ?? '')
+
+ useEffect(() => {
+ setRows(parsed.rows)
+ setProviderKey(parsed.provider ?? '')
+ }, [parsed])
+
+ const errors = useMemo(() => {
+ return rows.map((row) => {
+ const rowErrors: string[] = []
+ if (!row.id.trim()) rowErrors.push(t('superadmin.plans.storage.validation.id'))
+ if (!row.name.trim()) rowErrors.push(t('superadmin.plans.storage.validation.name'))
+ if (row.capacityGb && !Number.isFinite(Number(row.capacityGb))) {
+ rowErrors.push(t('superadmin.plans.storage.validation.capacity'))
+ }
+ if (row.monthlyPrice && !Number.isFinite(Number(row.monthlyPrice))) {
+ rowErrors.push(t('superadmin.plans.storage.validation.price'))
+ }
+ return rowErrors
+ })
+ }, [rows, t])
+
+ const hasErrors = errors.some((list) => list.length > 0)
+
+ const payload = useMemo((): UpdateSuperAdminSettingsPayload => {
+ const catalog: Record = {}
+ const pricing: Record = {}
+ const products: Record = {}
+
+ rows.forEach((row) => {
+ const id = row.id.trim()
+ if (!id) return
+ catalog[id] = {
+ name: row.name.trim(),
+ description: row.description.trim() || null,
+ capacityBytes: gbToBytes(row.capacityGb),
+ isActive: row.isActive,
+ }
+
+ if (row.monthlyPrice || row.currency) {
+ const parsedPrice = Number(row.monthlyPrice)
+ pricing[id] = {
+ monthlyPrice: Number.isFinite(parsedPrice) ? parsedPrice : null,
+ currency: row.currency.trim() || null,
+ }
+ }
+
+ if (row.creemProductId.trim()) {
+ products[id] = { creemProductId: row.creemProductId.trim() }
+ }
+ })
+
+ return {
+ managedStorageProvider: providerKey.trim() || null,
+ }
+ }, [providerKey, rows])
+
+ const baselinePayload = useMemo(() => {
+ const catalog = (rawValues?.storagePlanCatalog as Record | undefined) ?? {}
+ const pricing = (rawValues?.storagePlanPricing as Record | undefined) ?? {}
+ const products = (rawValues?.storagePlanProducts as Record | undefined) ?? {}
+ const provider = (rawValues?.managedStorageProvider as string | null | undefined) ?? null
+ return {
+ storagePlanCatalog: catalog,
+ storagePlanPricing: pricing,
+ storagePlanProducts: products,
+ managedStorageProvider: provider,
+ }
+ }, [rawValues])
+
+ const hasChanges = useMemo(
+ () => JSON.stringify(payload) !== JSON.stringify(baselinePayload),
+ [payload, baselinePayload],
+ )
+
+ const handleRowChange = (id: string, patch: Partial) => {
+ setRows((prev) => prev.map((row) => (row.id === id ? { ...row, ...patch } : row)))
+ }
+
+ const handleAdd = () => {
+ setRows((prev) => [
+ ...prev,
+ {
+ id: `managed-${prev.length + 1}`,
+ name: '',
+ description: '',
+ capacityGb: '',
+ isActive: true,
+ monthlyPrice: '',
+ currency: 'USD',
+ creemProductId: '',
+ },
+ ])
+ }
+
+ const handleRemove = (id: string) => {
+ setRows((prev) => prev.filter((row) => row.id !== id))
+ }
+
+ const handleSave = () => {
+ if (hasErrors || rows.length === 0) {
+ return
+ }
+ mutation.mutate(payload)
+ }
+
+ if (query.isLoading) {
+ return (
+
+
+
+ {[1, 2].map((key) => (
+
+ ))}
+
+
+ )
+ }
+
+ if (query.isError || !rawValues) {
+ return (
+
+ {t('superadmin.plans.storage.title')}
+
+ {t('superadmin.plans.storage.error', {
+ reason: query.error instanceof Error ? query.error.message : t('common.unknown-error'),
+ })}
+
+
+ )
+ }
+
+ return (
+
+
+
+
{t('superadmin.plans.storage.title')}
+
{t('superadmin.plans.storage.description')}
+
+
+
+
+
+
+
+ {hasErrors ? {t('superadmin.plans.storage.validation.block')}
: null}
+
+
+ {rows.length === 0 ? (
+
{t('superadmin.plans.storage.empty')}
+ ) : null}
+ {rows.map((row, idx) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ handleRowChange(row.id, { isActive: next })}
+ />
+ {t('superadmin.plans.storage.fields.active')}
+
+
+
+
+
+ {errors[idx] && errors[idx]!.length > 0 ? (
+
+ {errors[idx]!.map((err) => (
+ - {err}
+ ))}
+
+ ) : null}
+
+
+
+ ))}
+
+
+ {mutation.isError ? (
+
+ {t('superadmin.plans.storage.error', {
+ reason: mutation.error instanceof Error ? mutation.error.message : t('common.unknown-error'),
+ })}
+
+ ) : null}
+
+ {mutation.isSuccess && !mutation.isPending ? (
+ {t('superadmin.plans.storage.saved')}
+ ) : null}
+
+ )
+}
diff --git a/be/apps/dashboard/src/pages/superadmin/settings.tsx b/be/apps/dashboard/src/pages/superadmin/settings.tsx
index d0cc479e..1a085cec 100644
--- a/be/apps/dashboard/src/pages/superadmin/settings.tsx
+++ b/be/apps/dashboard/src/pages/superadmin/settings.tsx
@@ -1,11 +1,15 @@
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
+import { useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { SuperAdminSettingsForm } from '~/modules/super-admin'
+import { PageTabs } from '~/components/navigation/PageTabs'
+import { ManagedStorageSettings, SuperAdminSettingsForm } from '~/modules/super-admin'
export function Component() {
const { t } = useTranslation()
+ const [activeTab, setActiveTab] = useState<'general' | 'managed-storage'>('general')
+
return (
{t('superadmin.settings.description')}
-
+ setActiveTab(id as typeof activeTab)}
+ items={[
+ { id: 'general', labelKey: 'superadmin.settings.tabs.general' },
+ { id: 'managed-storage', labelKey: 'superadmin.settings.tabs.managed-storage' },
+ ]}
+ />
+
+ {activeTab === 'general' ? (
+
+ ) : (
+
+ )}
)
}
diff --git a/be/packages/db/migrations/0006_quick_titania.sql b/be/packages/db/migrations/0006_quick_titania.sql
new file mode 100644
index 00000000..2d4c3d8c
--- /dev/null
+++ b/be/packages/db/migrations/0006_quick_titania.sql
@@ -0,0 +1 @@
+ALTER TABLE "tenant" ADD COLUMN "storage_plan_id" text;
\ No newline at end of file
diff --git a/be/packages/db/migrations/meta/0006_snapshot.json b/be/packages/db/migrations/meta/0006_snapshot.json
new file mode 100644
index 00000000..0cb96712
--- /dev/null
+++ b/be/packages/db/migrations/meta/0006_snapshot.json
@@ -0,0 +1,1593 @@
+{
+ "id": "53661480-95e7-4242-9ef7-d39885e82a20",
+ "prevId": "3a791767-7308-42cd-ad3a-80e5ae4d9eaf",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.auth_account": {
+ "name": "auth_account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "auth_account_user_id_auth_user_id_fk": {
+ "name": "auth_account_user_id_auth_user_id_fk",
+ "tableFrom": "auth_account",
+ "tableTo": "auth_user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.auth_session": {
+ "name": "auth_session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "auth_session_tenant_id_tenant_id_fk": {
+ "name": "auth_session_tenant_id_tenant_id_fk",
+ "tableFrom": "auth_session",
+ "tableTo": "tenant",
+ "columnsFrom": ["tenant_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "auth_session_user_id_auth_user_id_fk": {
+ "name": "auth_session_user_id_auth_user_id_fk",
+ "tableFrom": "auth_session",
+ "tableTo": "auth_user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "auth_session_token_unique": {
+ "name": "auth_session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.auth_user": {
+ "name": "auth_user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "creem_customer_id": {
+ "name": "creem_customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "user_role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'user'"
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "two_factor_enabled": {
+ "name": "two_factor_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "display_username": {
+ "name": "display_username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires_at": {
+ "name": "ban_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "auth_user_tenant_id_tenant_id_fk": {
+ "name": "auth_user_tenant_id_tenant_id_fk",
+ "tableFrom": "auth_user",
+ "tableTo": "tenant",
+ "columnsFrom": ["tenant_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "auth_user_email_unique": {
+ "name": "auth_user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": ["email"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.auth_verification": {
+ "name": "auth_verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.billing_usage_event": {
+ "name": "billing_usage_event",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "quantity": {
+ "name": "quantity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "unit": {
+ "name": "unit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'count'"
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'null'::jsonb"
+ },
+ "occurred_at": {
+ "name": "occurred_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_billing_usage_event_tenant": {
+ "name": "idx_billing_usage_event_tenant",
+ "columns": [
+ {
+ "expression": "tenant_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_billing_usage_event_type": {
+ "name": "idx_billing_usage_event_type",
+ "columns": [
+ {
+ "expression": "event_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "billing_usage_event_tenant_id_tenant_id_fk": {
+ "name": "billing_usage_event_tenant_id_tenant_id_fk",
+ "tableFrom": "billing_usage_event",
+ "tableTo": "tenant",
+ "columnsFrom": ["tenant_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.creem_subscription": {
+ "name": "creem_subscription",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "product_id": {
+ "name": "product_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reference_id": {
+ "name": "reference_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "creem_customer_id": {
+ "name": "creem_customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "creem_subscription_id": {
+ "name": "creem_subscription_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "creem_order_id": {
+ "name": "creem_order_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "period_start": {
+ "name": "period_start",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "period_end": {
+ "name": "period_end",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cancel_at_period_end": {
+ "name": "cancel_at_period_end",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.photo_asset": {
+ "name": "photo_asset",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "photo_id": {
+ "name": "photo_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "storage_key": {
+ "name": "storage_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "storage_provider": {
+ "name": "storage_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "size": {
+ "name": "size",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "etag": {
+ "name": "etag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_modified": {
+ "name": "last_modified",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata_hash": {
+ "name": "metadata_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "manifest_version": {
+ "name": "manifest_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'v7'"
+ },
+ "manifest": {
+ "name": "manifest",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sync_status": {
+ "name": "sync_status",
+ "type": "photo_sync_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "conflict_reason": {
+ "name": "conflict_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "conflict_payload": {
+ "name": "conflict_payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'null'::jsonb"
+ },
+ "synced_at": {
+ "name": "synced_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "photo_asset_tenant_id_tenant_id_fk": {
+ "name": "photo_asset_tenant_id_tenant_id_fk",
+ "tableFrom": "photo_asset",
+ "tableTo": "tenant",
+ "columnsFrom": ["tenant_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "uq_photo_asset_tenant_storage_key": {
+ "name": "uq_photo_asset_tenant_storage_key",
+ "nullsNotDistinct": false,
+ "columns": ["tenant_id", "storage_key"]
+ },
+ "uq_photo_asset_tenant_photo_id": {
+ "name": "uq_photo_asset_tenant_photo_id",
+ "nullsNotDistinct": false,
+ "columns": ["tenant_id", "photo_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.photo_sync_run": {
+ "name": "photo_sync_run",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dry_run": {
+ "name": "dry_run",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actions_count": {
+ "name": "actions_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_photo_sync_run_tenant": {
+ "name": "idx_photo_sync_run_tenant",
+ "columns": [
+ {
+ "expression": "tenant_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "photo_sync_run_tenant_id_tenant_id_fk": {
+ "name": "photo_sync_run_tenant_id_tenant_id_fk",
+ "tableFrom": "photo_sync_run",
+ "tableTo": "tenant",
+ "columnsFrom": ["tenant_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reactions": {
+ "name": "reactions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "ref_key": {
+ "name": "ref_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reaction": {
+ "name": "reaction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "idx_reactions_tenant_ref_key": {
+ "name": "idx_reactions_tenant_ref_key",
+ "columns": [
+ {
+ "expression": "tenant_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "ref_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "reactions_tenant_id_tenant_id_fk": {
+ "name": "reactions_tenant_id_tenant_id_fk",
+ "tableFrom": "reactions",
+ "tableTo": "tenant",
+ "columnsFrom": ["tenant_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.settings": {
+ "name": "settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_sensitive": {
+ "name": "is_sensitive",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "settings_tenant_id_tenant_id_fk": {
+ "name": "settings_tenant_id_tenant_id_fk",
+ "tableFrom": "settings",
+ "tableTo": "tenant",
+ "columnsFrom": ["tenant_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "uq_settings_tenant_key": {
+ "name": "uq_settings_tenant_key",
+ "nullsNotDistinct": false,
+ "columns": ["tenant_id", "key"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.system_setting": {
+ "name": "system_setting",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'null'::jsonb"
+ },
+ "is_sensitive": {
+ "name": "is_sensitive",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "uq_system_setting_key": {
+ "name": "uq_system_setting_key",
+ "nullsNotDistinct": false,
+ "columns": ["key"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tenant_auth_account": {
+ "name": "tenant_auth_account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "tenant_auth_account_tenant_id_tenant_id_fk": {
+ "name": "tenant_auth_account_tenant_id_tenant_id_fk",
+ "tableFrom": "tenant_auth_account",
+ "tableTo": "tenant",
+ "columnsFrom": ["tenant_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tenant_auth_account_user_id_tenant_auth_user_id_fk": {
+ "name": "tenant_auth_account_user_id_tenant_auth_user_id_fk",
+ "tableFrom": "tenant_auth_account",
+ "tableTo": "tenant_auth_user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tenant_auth_session": {
+ "name": "tenant_auth_session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "tenant_auth_session_tenant_id_tenant_id_fk": {
+ "name": "tenant_auth_session_tenant_id_tenant_id_fk",
+ "tableFrom": "tenant_auth_session",
+ "tableTo": "tenant",
+ "columnsFrom": ["tenant_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tenant_auth_session_user_id_tenant_auth_user_id_fk": {
+ "name": "tenant_auth_session_user_id_tenant_auth_user_id_fk",
+ "tableFrom": "tenant_auth_session",
+ "tableTo": "tenant_auth_user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "tenant_auth_session_token_unique": {
+ "name": "tenant_auth_session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tenant_auth_user": {
+ "name": "tenant_auth_user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'guest'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "two_factor_enabled": {
+ "name": "two_factor_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "display_username": {
+ "name": "display_username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires_at": {
+ "name": "ban_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "tenant_auth_user_tenant_id_tenant_id_fk": {
+ "name": "tenant_auth_user_tenant_id_tenant_id_fk",
+ "tableFrom": "tenant_auth_user",
+ "tableTo": "tenant",
+ "columnsFrom": ["tenant_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "uq_tenant_auth_user_tenant_email": {
+ "name": "uq_tenant_auth_user_tenant_email",
+ "nullsNotDistinct": false,
+ "columns": ["tenant_id", "email"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tenant_domain": {
+ "name": "tenant_domain",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "domain": {
+ "name": "domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "tenant_domain_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "verification_token": {
+ "name": "verification_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "verified_at": {
+ "name": "verified_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_tenant_domain_tenant": {
+ "name": "idx_tenant_domain_tenant",
+ "columns": [
+ {
+ "expression": "tenant_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tenant_domain_tenant_id_tenant_id_fk": {
+ "name": "tenant_domain_tenant_id_tenant_id_fk",
+ "tableFrom": "tenant_domain",
+ "tableTo": "tenant",
+ "columnsFrom": ["tenant_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "uq_tenant_domain_domain": {
+ "name": "uq_tenant_domain_domain",
+ "nullsNotDistinct": false,
+ "columns": ["domain"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tenant": {
+ "name": "tenant",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "plan_id": {
+ "name": "plan_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'free'"
+ },
+ "storage_plan_id": {
+ "name": "storage_plan_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "status": {
+ "name": "status",
+ "type": "tenant_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'inactive'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "uq_tenant_slug": {
+ "name": "uq_tenant_slug",
+ "nullsNotDistinct": false,
+ "columns": ["slug"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.photo_sync_status": {
+ "name": "photo_sync_status",
+ "schema": "public",
+ "values": ["pending", "synced", "conflict"]
+ },
+ "public.tenant_domain_status": {
+ "name": "tenant_domain_status",
+ "schema": "public",
+ "values": ["pending", "verified", "disabled"]
+ },
+ "public.tenant_status": {
+ "name": "tenant_status",
+ "schema": "public",
+ "values": ["active", "inactive", "suspended"]
+ },
+ "public.user_role": {
+ "name": "user_role",
+ "schema": "public",
+ "values": ["user", "admin", "superadmin"]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/be/packages/db/migrations/meta/_journal.json b/be/packages/db/migrations/meta/_journal.json
index b3bf08ef..b97e03af 100644
--- a/be/packages/db/migrations/meta/_journal.json
+++ b/be/packages/db/migrations/meta/_journal.json
@@ -43,6 +43,13 @@
"when": 1763626275917,
"tag": "0005_flawless_wild_pack",
"breakpoints": true
+ },
+ {
+ "idx": 6,
+ "version": "7",
+ "when": 1763656041992,
+ "tag": "0006_quick_titania",
+ "breakpoints": true
}
]
}
diff --git a/be/packages/db/src/schema.ts b/be/packages/db/src/schema.ts
index d21d3641..6fc8b3ff 100644
--- a/be/packages/db/src/schema.ts
+++ b/be/packages/db/src/schema.ts
@@ -62,6 +62,7 @@ export const tenants = pgTable(
slug: text('slug').notNull(),
name: text('name').notNull(),
planId: text('plan_id').notNull().default('free'),
+ storagePlanId: text('storage_plan_id'),
banned: boolean('banned').notNull().default(false),
status: tenantStatusEnum('status').notNull().default('inactive'),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
diff --git a/locales/dashboard/en.json b/locales/dashboard/en.json
index c196ca72..faf0e695 100644
--- a/locales/dashboard/en.json
+++ b/locales/dashboard/en.json
@@ -275,6 +275,25 @@
"photos.library.tags.toast.success-description": "Storage paths have been updated for the new tag structure.",
"photos.page.description": "Sync and manage photo assets on the server.",
"photos.page.title": "Photo Library",
+ "photos.storage.managed.actions.cancel": "Cancel managed storage",
+ "photos.storage.managed.actions.current": "Current plan",
+ "photos.storage.managed.actions.loading": "Updating…",
+ "photos.storage.managed.actions.retry": "Try again",
+ "photos.storage.managed.actions.subscribe": "Subscribe",
+ "photos.storage.managed.actions.switch": "Switch to this plan",
+ "photos.storage.managed.capacity.label": "Storage capacity: {{value}}",
+ "photos.storage.managed.capacity.unknown": "Capacity information is not available.",
+ "photos.storage.managed.capacity.unlimited": "Unlimited storage capacity",
+ "photos.storage.managed.description": "Subscribe to managed storage plans powered by the platform’s shared provider—no self-hosted bucket required.",
+ "photos.storage.managed.empty": "No managed storage plans are currently available.",
+ "photos.storage.managed.error.load": "Failed to load managed storage plans.",
+ "photos.storage.managed.price.free": "Included in your current plan",
+ "photos.storage.managed.price.label": "{{price}} / month",
+ "photos.storage.managed.provider": "Backed by provider {{provider}}",
+ "photos.storage.managed.title": "Managed storage",
+ "photos.storage.managed.toast.error": "Failed to update managed storage: {{reason}}",
+ "photos.storage.managed.toast.success": "Managed storage plan updated.",
+ "photos.storage.managed.unavailable": "Managed storage hasn’t been enabled for this workspace yet.",
"photos.sync.actions.button.apply": "Sync photos",
"photos.sync.actions.button.preview": "Preview sync",
"photos.sync.actions.toast.apply-success": "Photo sync completed",
@@ -716,11 +735,46 @@
"superadmin.nav.settings": "System Settings",
"superadmin.nav.tenants": "Tenants",
"superadmin.plans.description": "Manage plan quotas, pricing, and Creem product mappings. Only super admins can edit these settings.",
+ "superadmin.plans.storage.actions.add": "Add plan",
+ "superadmin.plans.storage.actions.remove": "Remove",
+ "superadmin.plans.storage.actions.save": "Save storage plans",
+ "superadmin.plans.storage.description": "Create managed storage plans without editing JSON. One plan per tenant; capacity stacks with app plan bundled storage.",
+ "superadmin.plans.storage.empty": "No storage plans yet. Add one to get started.",
+ "superadmin.plans.storage.error": "Failed to load storage plans: {{reason}}",
+ "superadmin.plans.storage.fields.active": "Expose this plan",
+ "superadmin.plans.storage.fields.capacity": "Capacity (GB)",
+ "superadmin.plans.storage.fields.creem": "Creem product ID",
+ "superadmin.plans.storage.fields.currency": "Currency",
+ "superadmin.plans.storage.fields.description": "Description",
+ "superadmin.plans.storage.fields.id": "Plan ID",
+ "superadmin.plans.storage.fields.name": "Display name",
+ "superadmin.plans.storage.fields.placeholder.description": "Who is this plan for? What's included?",
+ "superadmin.plans.storage.fields.placeholder.name": "Managed B2 • 5GB",
+ "superadmin.plans.storage.fields.price": "Monthly price",
+ "superadmin.plans.storage.saved": "Storage plans updated",
+ "superadmin.plans.storage.title": "Storage plans",
+ "superadmin.plans.storage.validation.block": "Fix validation errors before saving.",
+ "superadmin.plans.storage.validation.capacity": "Capacity must be a number in GB.",
+ "superadmin.plans.storage.validation.id": "Plan ID is required.",
+ "superadmin.plans.storage.validation.name": "Display name is required.",
+ "superadmin.plans.storage.validation.price": "Price must be a number.",
+ "superadmin.plans.tabs.app": "App plans",
+ "superadmin.plans.tabs.storage": "Storage plans",
"superadmin.plans.title": "Plan Configuration",
"superadmin.settings.button.loading": "Saving…",
"superadmin.settings.button.save": "Save changes",
"superadmin.settings.description": "Control platform-wide registration policies and local sign-in channels. Managed centrally by super admins.",
"superadmin.settings.error.loading": "Unable to load super admin settings: {{reason}}",
+ "superadmin.settings.managed-storage.actions.edit": "Edit",
+ "superadmin.settings.managed-storage.actions.save": "Save managed provider",
+ "superadmin.settings.managed-storage.actions.select": "Set as managed",
+ "superadmin.settings.managed-storage.actions.selected": "Selected",
+ "superadmin.settings.managed-storage.current": "Managed",
+ "superadmin.settings.managed-storage.description": "Configure the storage provider used for built-in managed storage (e.g., B2).",
+ "superadmin.settings.managed-storage.empty": "No storage providers yet. Add one to use as managed storage.",
+ "superadmin.settings.managed-storage.error": "Failed to load storage providers: {{reason}}",
+ "superadmin.settings.managed-storage.title": "Managed storage provider",
+ "superadmin.settings.managed-storage.type": "Type: {{type}}",
"superadmin.settings.message.dirty": "You have unsaved changes",
"superadmin.settings.message.error": "Failed to save settings: {{reason}}",
"superadmin.settings.message.idle": "All settings are up to date",
@@ -730,6 +784,8 @@
"superadmin.settings.stats.remaining": "Remaining registration slots",
"superadmin.settings.stats.total-users": "Total users",
"superadmin.settings.stats.unlimited": "Unlimited",
+ "superadmin.settings.tabs.general": "General",
+ "superadmin.settings.tabs.managed-storage": "Managed storage",
"superadmin.settings.title": "System Settings",
"superadmin.tenants.button.ban": "Ban",
"superadmin.tenants.button.processing": "Working…",
diff --git a/locales/dashboard/zh-CN.json b/locales/dashboard/zh-CN.json
index 7a28cfdb..bbbdf8b6 100644
--- a/locales/dashboard/zh-CN.json
+++ b/locales/dashboard/zh-CN.json
@@ -275,6 +275,25 @@
"photos.library.tags.toast.success-description": "已根据新标签更新存储路径。",
"photos.page.description": "在此同步和管理服务器中的照片资产。",
"photos.page.title": "照片库",
+ "photos.storage.managed.actions.cancel": "取消托管存储",
+ "photos.storage.managed.actions.current": "当前方案",
+ "photos.storage.managed.actions.loading": "处理中…",
+ "photos.storage.managed.actions.retry": "重试",
+ "photos.storage.managed.actions.subscribe": "立即订阅",
+ "photos.storage.managed.actions.switch": "切换到此方案",
+ "photos.storage.managed.capacity.label": "可用容量:{{value}}",
+ "photos.storage.managed.capacity.unknown": "暂无法获取该方案的容量信息。",
+ "photos.storage.managed.capacity.unlimited": "容量无限制",
+ "photos.storage.managed.description": "订阅由平台托管的存储方案,无需自建对象存储即可扩容。",
+ "photos.storage.managed.empty": "当前没有可订阅的托管存储方案。",
+ "photos.storage.managed.error.load": "无法加载托管存储方案。",
+ "photos.storage.managed.price.free": "包含在当前订阅内",
+ "photos.storage.managed.price.label": "{{price}} / 月",
+ "photos.storage.managed.provider": "托管服务提供方:{{provider}}",
+ "photos.storage.managed.title": "托管存储",
+ "photos.storage.managed.toast.error": "更新托管存储方案失败:{{reason}}",
+ "photos.storage.managed.toast.success": "托管存储方案已更新。",
+ "photos.storage.managed.unavailable": "托管存储尚未启用,请联系管理员。",
"photos.sync.actions.button.apply": "同步照片",
"photos.sync.actions.button.preview": "预览同步",
"photos.sync.actions.toast.apply-success": "照片同步完成",
@@ -715,11 +734,46 @@
"superadmin.nav.settings": "系统设置",
"superadmin.nav.tenants": "租户管理",
"superadmin.plans.description": "管理各个订阅计划的资源配额、定价信息与 Creem Product 连接,仅超级管理员可编辑。",
+ "superadmin.plans.storage.actions.add": "新增计划",
+ "superadmin.plans.storage.actions.remove": "删除",
+ "superadmin.plans.storage.actions.save": "保存存储计划",
+ "superadmin.plans.storage.description": "直接编辑托管存储方案,无需填 JSON。每个租户仅一份存储计划,会与应用计划的赠送存储叠加。",
+ "superadmin.plans.storage.empty": "还没有存储计划,请先新增一条。",
+ "superadmin.plans.storage.error": "加载存储计划失败:{{reason}}",
+ "superadmin.plans.storage.fields.active": "在前台展示该计划",
+ "superadmin.plans.storage.fields.capacity": "容量 (GB)",
+ "superadmin.plans.storage.fields.creem": "Creem 商品 ID",
+ "superadmin.plans.storage.fields.currency": "币种",
+ "superadmin.plans.storage.fields.description": "描述",
+ "superadmin.plans.storage.fields.id": "计划 ID",
+ "superadmin.plans.storage.fields.name": "展示名称",
+ "superadmin.plans.storage.fields.placeholder.description": "适用人群与包含的内容",
+ "superadmin.plans.storage.fields.placeholder.name": "托管 B2 · 5GB",
+ "superadmin.plans.storage.fields.price": "月费",
+ "superadmin.plans.storage.saved": "存储计划已更新",
+ "superadmin.plans.storage.title": "存储计划",
+ "superadmin.plans.storage.validation.block": "请先修复校验错误后再保存。",
+ "superadmin.plans.storage.validation.capacity": "容量必须是数字(GB)。",
+ "superadmin.plans.storage.validation.id": "计划 ID 必填。",
+ "superadmin.plans.storage.validation.name": "展示名称必填。",
+ "superadmin.plans.storage.validation.price": "价格必须是数字。",
+ "superadmin.plans.tabs.app": "应用计划",
+ "superadmin.plans.tabs.storage": "存储计划",
"superadmin.plans.title": "订阅计划配置",
"superadmin.settings.button.loading": "保存中...",
"superadmin.settings.button.save": "保存修改",
"superadmin.settings.description": "管理整个平台的注册策略与本地登录渠道,由超级管理员统一维护。",
"superadmin.settings.error.loading": "无法加载超级管理员设置:{{reason}}",
+ "superadmin.settings.managed-storage.actions.edit": "编辑",
+ "superadmin.settings.managed-storage.actions.save": "保存托管 Provider",
+ "superadmin.settings.managed-storage.actions.select": "设为托管",
+ "superadmin.settings.managed-storage.actions.selected": "已选择",
+ "superadmin.settings.managed-storage.current": "托管使用",
+ "superadmin.settings.managed-storage.description": "配置用于内置托管存储的 Provider(例如 B2)。",
+ "superadmin.settings.managed-storage.empty": "还没有存储提供商,请先新增。",
+ "superadmin.settings.managed-storage.error": "加载存储提供商失败:{{reason}}",
+ "superadmin.settings.managed-storage.title": "托管存储 Provider",
+ "superadmin.settings.managed-storage.type": "类型:{{type}}",
"superadmin.settings.message.dirty": "您有尚未保存的变更",
"superadmin.settings.message.error": "保存失败:{{reason}}",
"superadmin.settings.message.idle": "所有设置已同步",
@@ -729,6 +783,8 @@
"superadmin.settings.stats.remaining": "剩余可注册名额",
"superadmin.settings.stats.total-users": "当前用户总数",
"superadmin.settings.stats.unlimited": "不限",
+ "superadmin.settings.tabs.general": "通用",
+ "superadmin.settings.tabs.managed-storage": "托管存储",
"superadmin.settings.title": "系统设置",
"superadmin.tenants.button.ban": "封禁",
"superadmin.tenants.button.processing": "处理中…",