mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
feat: add normalization helper functions and integrate into services
- Introduced new helper functions for string and date normalization, enhancing input validation across various services. - Updated SiteSettingService and SystemSettingService to utilize the new normalization functions for improved data handling. - Refactored existing code to replace custom normalization logic with the new helper methods, ensuring consistency and reducing redundancy. - Enhanced localization files to support new error messages related to normalization. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
106
be/apps/core/src/helpers/normalize.helper.ts
Normal file
106
be/apps/core/src/helpers/normalize.helper.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
|
||||
/**
|
||||
* Normalizes a string value by trimming whitespace.
|
||||
* Returns null if the value is not a string or is empty after trimming.
|
||||
*/
|
||||
export function normalizeString(value?: string | null): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a string value by trimming whitespace.
|
||||
* Returns undefined if the value is not a string or is empty after trimming.
|
||||
* This variant is useful when you need undefined instead of null for optional properties.
|
||||
*/
|
||||
export function normalizeStringToUndefined(value?: string | null): string | undefined {
|
||||
const normalized = normalizeString(value)
|
||||
return normalized ?? undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a nullable string value by trimming whitespace.
|
||||
* Returns null if the value is null, undefined, or is empty after trimming.
|
||||
* This is an alias for normalizeString that provides clearer semantics for nullable values.
|
||||
*/
|
||||
export function normalizeNullableString(value: string | null | undefined): string | null {
|
||||
return normalizeString(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a number value.
|
||||
* Returns 0 if the value is not a valid finite number.
|
||||
* Negative values are clamped to 0.
|
||||
*/
|
||||
export function normalizeNumber(value?: number | null): number {
|
||||
if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) {
|
||||
return 0
|
||||
}
|
||||
return Math.max(0, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a number value to an integer.
|
||||
* Returns 0 if the value is not a valid finite number.
|
||||
* Negative values are clamped to 0.
|
||||
* Decimal values are truncated (not rounded).
|
||||
*/
|
||||
export function normalizeInteger(value?: number | null): number {
|
||||
if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) {
|
||||
return 0
|
||||
}
|
||||
return Math.max(0, Math.trunc(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a date value to an ISO string.
|
||||
* Accepts Date objects or date strings.
|
||||
* Returns null if the value cannot be parsed as a valid date.
|
||||
*/
|
||||
export function normalizeDate(value?: Date | string | null): string | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString()
|
||||
}
|
||||
const timestamp = Date.parse(value)
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return null
|
||||
}
|
||||
return new Date(timestamp).toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires a string value to be present and non-empty.
|
||||
* Throws a BizException if the value is missing or empty after normalization.
|
||||
* @param value - The value to validate
|
||||
* @param label - The label/name of the field for error messages
|
||||
* @returns The normalized string value
|
||||
*/
|
||||
export function requireString(value: string | undefined | null, label: string): string {
|
||||
const normalized = normalizeString(value)
|
||||
if (!normalized) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: `缺少必填字段 ${label}` })
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires a string value to be present and non-empty.
|
||||
* Throws a BizException if the value is missing or empty after normalization.
|
||||
* @param value - The value to validate
|
||||
* @param message - Custom error message
|
||||
* @returns The normalized string value
|
||||
*/
|
||||
export function requireStringWithMessage(value: string | undefined | null, message: string): string {
|
||||
const normalized = normalizeString(value)
|
||||
if (!normalized) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message })
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { authUsers } from '@afilmory/db'
|
||||
import { DbAccessor } from 'core/database/database.provider'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { normalizeStringToUndefined } from 'core/helpers/normalize.helper'
|
||||
import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
|
||||
import { asc, eq, sql } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
@@ -129,7 +130,7 @@ export class SiteSettingService {
|
||||
})
|
||||
}
|
||||
|
||||
const name = normalizeString(input.name)
|
||||
const name = normalizeStringToUndefined(input.name)
|
||||
if (!name) {
|
||||
throw new BizException(ErrorCode.COMMON_VALIDATION, { message: '作者名称不能为空' })
|
||||
}
|
||||
@@ -168,18 +169,18 @@ export class SiteSettingService {
|
||||
return null
|
||||
}
|
||||
|
||||
const fallbackName = normalizeString(siteName) ?? siteName
|
||||
const fallbackName = normalizeStringToUndefined(siteName) ?? siteName
|
||||
const normalizedName =
|
||||
normalizeString(user.displayUsername) ??
|
||||
normalizeString(user.username) ??
|
||||
normalizeString(user.name) ??
|
||||
normalizeStringToUndefined(user.displayUsername) ??
|
||||
normalizeStringToUndefined(user.username) ??
|
||||
normalizeStringToUndefined(user.name) ??
|
||||
fallbackName
|
||||
|
||||
const author: SiteConfigAuthor = {
|
||||
name: normalizedName,
|
||||
}
|
||||
|
||||
const avatar = normalizeString(user.image)
|
||||
const avatar = normalizeStringToUndefined(user.image)
|
||||
if (avatar) {
|
||||
author.avatar = avatar
|
||||
}
|
||||
@@ -214,7 +215,7 @@ export class SiteSettingService {
|
||||
}
|
||||
|
||||
private normalizeAvatarInput(value: string | null | undefined): string | null {
|
||||
const normalized = normalizeString(value)
|
||||
const normalized = normalizeStringToUndefined(value)
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
@@ -352,23 +353,15 @@ const DEFAULT_SITE_CONFIG: SiteConfig = {
|
||||
type SiteSettingValueMap = Partial<Record<SiteSettingKey, string | null>>
|
||||
|
||||
function assignString(value: string | null | undefined, updater: (value: string) => void) {
|
||||
const normalized = normalizeString(value)
|
||||
const normalized = normalizeStringToUndefined(value)
|
||||
if (normalized === undefined) {
|
||||
return
|
||||
}
|
||||
updater(normalized)
|
||||
}
|
||||
|
||||
function normalizeString(value: string | null | undefined): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : undefined
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: string | null | undefined): string | null {
|
||||
const normalized = normalizeString(value)
|
||||
const normalized = normalizeStringToUndefined(value)
|
||||
return normalized ?? null
|
||||
}
|
||||
|
||||
@@ -386,7 +379,7 @@ function toSiteAuthorProfile(user: AuthorUserRecord): SiteAuthorProfile {
|
||||
}
|
||||
|
||||
function parseJsonStringArray(value: string | null | undefined): string[] | undefined {
|
||||
const normalized = normalizeString(value)
|
||||
const normalized = normalizeStringToUndefined(value)
|
||||
if (!normalized) {
|
||||
return undefined
|
||||
}
|
||||
@@ -405,7 +398,7 @@ function parseJsonStringArray(value: string | null | undefined): string[] | unde
|
||||
}
|
||||
|
||||
function parseBooleanString(value: string | null | undefined): boolean | undefined {
|
||||
const normalized = normalizeString(value)
|
||||
const normalized = normalizeStringToUndefined(value)
|
||||
if (!normalized) {
|
||||
return undefined
|
||||
}
|
||||
@@ -439,8 +432,8 @@ function buildSocialConfig(values: SiteSettingValueMap): SiteConfig['social'] |
|
||||
}
|
||||
|
||||
function buildFeedConfig(values: SiteSettingValueMap): SiteConfig['feed'] | undefined {
|
||||
const feedId = normalizeString(values['site.feed.folo.challenge.feedId'])
|
||||
const userId = normalizeString(values['site.feed.folo.challenge.userId'])
|
||||
const feedId = normalizeStringToUndefined(values['site.feed.folo.challenge.feedId'])
|
||||
const userId = normalizeStringToUndefined(values['site.feed.folo.challenge.userId'])
|
||||
|
||||
if (!feedId && !userId) {
|
||||
return undefined
|
||||
@@ -457,7 +450,7 @@ function buildFeedConfig(values: SiteSettingValueMap): SiteConfig['feed'] | unde
|
||||
}
|
||||
|
||||
function normalizeMapProjection(value: string | null | undefined): SiteConfig['mapProjection'] | undefined {
|
||||
const normalized = normalizeString(value)
|
||||
const normalized = normalizeStringToUndefined(value)
|
||||
if (!normalized) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { authUsers } from '@afilmory/db'
|
||||
import { DbAccessor } from 'core/database/database.provider'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { normalizeNullableString } from 'core/helpers/normalize.helper'
|
||||
import type { SocialProvidersConfig } from 'core/modules/platform/auth/auth.config'
|
||||
import {
|
||||
BILLING_PLAN_OVERRIDES_SETTING_KEY,
|
||||
@@ -229,6 +230,15 @@ export class SystemSettingService {
|
||||
return settings.managedStorageProvider ?? null
|
||||
}
|
||||
|
||||
async getManagedStorageProvider(): Promise<BuilderStorageProvider | null> {
|
||||
const settings = await this.getSettings()
|
||||
const providerKey = settings.managedStorageProvider?.trim()
|
||||
if (!providerKey) {
|
||||
return null
|
||||
}
|
||||
return (settings.managedStorageProviders ?? []).find((provider) => provider.id === providerKey) ?? null
|
||||
}
|
||||
|
||||
async getOverview(): Promise<SystemSettingOverview> {
|
||||
const settings = await this.getSettings()
|
||||
const totalUsers = await this.getTotalUserCount()
|
||||
@@ -328,28 +338,28 @@ export class SystemSettingService {
|
||||
}
|
||||
|
||||
if (patch.oauthGoogleClientId !== undefined) {
|
||||
const sanitized = this.normalizeNullableString(patch.oauthGoogleClientId)
|
||||
const sanitized = normalizeNullableString(patch.oauthGoogleClientId)
|
||||
if (sanitized !== current.oauthGoogleClientId) {
|
||||
enqueueUpdate('oauthGoogleClientId', sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.oauthGoogleClientSecret !== undefined) {
|
||||
const sanitized = this.normalizeNullableString(patch.oauthGoogleClientSecret)
|
||||
const sanitized = normalizeNullableString(patch.oauthGoogleClientSecret)
|
||||
if (sanitized !== current.oauthGoogleClientSecret) {
|
||||
enqueueUpdate('oauthGoogleClientSecret', sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.oauthGithubClientId !== undefined) {
|
||||
const sanitized = this.normalizeNullableString(patch.oauthGithubClientId)
|
||||
const sanitized = normalizeNullableString(patch.oauthGithubClientId)
|
||||
if (sanitized !== current.oauthGithubClientId) {
|
||||
enqueueUpdate('oauthGithubClientId', sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.oauthGithubClientSecret !== undefined) {
|
||||
const sanitized = this.normalizeNullableString(patch.oauthGithubClientSecret)
|
||||
const sanitized = normalizeNullableString(patch.oauthGithubClientSecret)
|
||||
if (sanitized !== current.oauthGithubClientSecret) {
|
||||
enqueueUpdate('oauthGithubClientSecret', sanitized)
|
||||
}
|
||||
@@ -371,7 +381,7 @@ export class SystemSettingService {
|
||||
}
|
||||
|
||||
if (patch.managedStorageProvider !== undefined && patch.managedStorageProvider !== current.managedStorageProvider) {
|
||||
enqueueUpdate('managedStorageProvider', this.normalizeNullableString(patch.managedStorageProvider))
|
||||
enqueueUpdate('managedStorageProvider', normalizeNullableString(patch.managedStorageProvider))
|
||||
}
|
||||
|
||||
if (patch.managedStorageProviders !== undefined) {
|
||||
@@ -527,8 +537,8 @@ export class SystemSettingService {
|
||||
raw === null || raw === undefined
|
||||
? null
|
||||
: typeof raw === 'string'
|
||||
? this.normalizeNullableString(raw)
|
||||
: this.normalizeNullableString(String(raw))
|
||||
? normalizeNullableString(raw)
|
||||
: normalizeNullableString(String(raw))
|
||||
planPatch.currency = normalized
|
||||
} else if (descriptor.key === 'monthlyPrice') {
|
||||
const numericValue = raw === null || raw === undefined ? null : typeof raw === 'number' ? raw : Number(raw)
|
||||
@@ -549,8 +559,8 @@ export class SystemSettingService {
|
||||
raw === null || raw === undefined
|
||||
? null
|
||||
: typeof raw === 'string'
|
||||
? this.normalizeNullableString(raw)
|
||||
: this.normalizeNullableString(String(raw))
|
||||
? normalizeNullableString(raw)
|
||||
: normalizeNullableString(String(raw))
|
||||
summary.products[descriptor.planId] = { creemProductId: normalized }
|
||||
}
|
||||
|
||||
@@ -609,7 +619,7 @@ export class SystemSettingService {
|
||||
for (const [planId, product] of Object.entries(updates.products) as Array<
|
||||
[BillingPlanId, BillingPlanPaymentInfo]
|
||||
>) {
|
||||
const normalized = this.normalizeNullableString(product.creemProductId)
|
||||
const normalized = normalizeNullableString(product.creemProductId)
|
||||
if (!normalized) {
|
||||
delete nextProducts[planId]
|
||||
} else {
|
||||
@@ -684,14 +694,6 @@ export class SystemSettingService {
|
||||
return providers
|
||||
}
|
||||
|
||||
private normalizeNullableString(value: string | null | undefined): string | null {
|
||||
if (value === undefined || value === null) {
|
||||
return null
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
private normalizeGatewayUrl(value: string | null | undefined): string | null {
|
||||
if (value === undefined || value === null) {
|
||||
return null
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
DEFAULT_DIRECTORY as DEFAULT_THUMBNAIL_DIRECTORY,
|
||||
} from '@afilmory/builder/plugins/thumbnail-storage/shared.js'
|
||||
import { StorageManager } from '@afilmory/builder/storage/index.js'
|
||||
import type { GitHubConfig, S3Config } from '@afilmory/builder/storage/interfaces.js'
|
||||
import type { GitHubConfig, ManagedStorageConfig, S3Config } from '@afilmory/builder/storage/interfaces.js'
|
||||
import { CURRENT_PHOTO_MANIFEST_VERSION, DATABASE_ONLY_PROVIDER, photoAssets } from '@afilmory/db'
|
||||
import { EventEmitterService } from '@afilmory/framework'
|
||||
import { DbAccessor } from 'core/database/database.provider'
|
||||
@@ -24,6 +24,8 @@ import type {
|
||||
import { BILLING_USAGE_EVENT } from 'core/modules/platform/billing/billing.constants'
|
||||
import { BillingPlanService } from 'core/modules/platform/billing/billing-plan.service'
|
||||
import { BillingUsageService } from 'core/modules/platform/billing/billing-usage.service'
|
||||
import { StoragePlanService } from 'core/modules/platform/billing/storage-plan.service'
|
||||
import { ManagedStorageService } from 'core/modules/platform/managed-storage/managed-storage.service'
|
||||
import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
@@ -75,6 +77,8 @@ export class PhotoAssetService {
|
||||
private readonly photoStorageService: PhotoStorageService,
|
||||
private readonly billingPlanService: BillingPlanService,
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
private readonly storagePlanService: StoragePlanService,
|
||||
private readonly managedStorageService: ManagedStorageService,
|
||||
) {}
|
||||
|
||||
private async emitManifestChanged(tenantId: string): Promise<void> {
|
||||
@@ -157,6 +161,12 @@ export class PhotoAssetService {
|
||||
return this.convertMbToBytes(planQuota.maxUploadSizeMb)
|
||||
}
|
||||
|
||||
async isManagedStorage(): Promise<boolean> {
|
||||
const tenant = requireTenantContext()
|
||||
const { storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id)
|
||||
return storageConfig.provider === 'managed'
|
||||
}
|
||||
|
||||
async findPhotosByIds(photoIds: string[]): Promise<PhotoManifestItem[]> {
|
||||
if (photoIds.length === 0) {
|
||||
return []
|
||||
@@ -194,9 +204,14 @@ export class PhotoAssetService {
|
||||
}
|
||||
|
||||
const shouldDeleteFromStorage = options?.deleteFromStorage === true
|
||||
let storageConfigForDeletion: StorageConfig | null = null
|
||||
let managedProviderKey: string | null = null
|
||||
const managedKeysToDelete = new Set<string>()
|
||||
|
||||
if (shouldDeleteFromStorage) {
|
||||
const { builderConfig, storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id)
|
||||
storageConfigForDeletion = storageConfig
|
||||
managedProviderKey = this.resolveManagedProviderKey(storageConfig)
|
||||
const storageManager = await this.createStorageManager(builderConfig, storageConfig)
|
||||
const thumbnailRemotePrefix = this.resolveThumbnailRemotePrefix(storageConfig)
|
||||
const deletedThumbnailKeys = new Set<string>()
|
||||
@@ -209,6 +224,9 @@ export class PhotoAssetService {
|
||||
|
||||
try {
|
||||
await storageManager.deleteFile(record.storageKey)
|
||||
if (managedProviderKey) {
|
||||
managedKeysToDelete.add(this.normalizeKeyPath(record.storageKey))
|
||||
}
|
||||
} catch (error) {
|
||||
throw new BizException(ErrorCode.IMAGE_PROCESSING_FAILED, {
|
||||
message: `无法删除存储中的文件 ${record.storageKey}: ${String(error)}`,
|
||||
@@ -222,6 +240,9 @@ export class PhotoAssetService {
|
||||
try {
|
||||
await storageManager.deleteFile(videoKey)
|
||||
deletedVideoKeys.add(videoKey)
|
||||
if (managedProviderKey) {
|
||||
managedKeysToDelete.add(this.normalizeKeyPath(videoKey))
|
||||
}
|
||||
} catch {
|
||||
// 忽略缺失的 Live Photo 视频文件
|
||||
deletedVideoKeys.add(videoKey)
|
||||
@@ -244,6 +265,14 @@ export class PhotoAssetService {
|
||||
|
||||
await db.delete(photoAssets).where(and(eq(photoAssets.tenantId, tenant.tenant.id), inArray(photoAssets.id, ids)))
|
||||
|
||||
if (managedProviderKey && storageConfigForDeletion) {
|
||||
const keys = [...managedKeysToDelete].filter((key) => key.length > 0)
|
||||
if (keys.length > 0) {
|
||||
await this.managedStorageService.deleteFileReferences(managedProviderKey, keys, tenant.tenant.id)
|
||||
}
|
||||
await this.recordManagedStorageSnapshot(storageConfigForDeletion, tenant.tenant.id, 'delete')
|
||||
}
|
||||
|
||||
if (records.length > 0) {
|
||||
await this.billingUsageService.recordEvent({
|
||||
eventType: BILLING_USAGE_EVENT.PHOTO_ASSET_DELETED,
|
||||
@@ -367,6 +396,17 @@ export class PhotoAssetService {
|
||||
activeVideoPlans,
|
||||
storageManager,
|
||||
)
|
||||
const { incomingBytes } = this.estimateManagedStorageDelta(
|
||||
storageConfig,
|
||||
allPendingPhotoPlans,
|
||||
activeVideoPlans,
|
||||
existingStorageMap,
|
||||
)
|
||||
await this.ensureManagedStorageCapacity({
|
||||
storageConfig,
|
||||
tenantId: tenant.tenant.id,
|
||||
incomingBytes,
|
||||
})
|
||||
const videoBufferMap = new Map<string, Buffer>()
|
||||
const videoObjectsByBaseName = await this.prepareVideoObjects(
|
||||
activeVideoPlans,
|
||||
@@ -443,6 +483,7 @@ export class PhotoAssetService {
|
||||
})
|
||||
}
|
||||
shouldRollbackUploads = false
|
||||
await this.recordManagedStorageSnapshot(storageConfig, tenant.tenant.id)
|
||||
return existingItemsRaw
|
||||
}
|
||||
|
||||
@@ -537,6 +578,7 @@ export class PhotoAssetService {
|
||||
}
|
||||
|
||||
shouldRollbackUploads = false
|
||||
await this.recordManagedStorageSnapshot(storageConfig, tenant.tenant.id)
|
||||
return result
|
||||
} catch (error) {
|
||||
if (shouldRollbackUploads) {
|
||||
@@ -1028,6 +1070,28 @@ export class PhotoAssetService {
|
||||
|
||||
const publicUrl = await Promise.resolve(storageManager.generatePublicUrl(resolvedPhotoKey))
|
||||
|
||||
await this.recordManagedStorageReferences(storageConfig, tenantId, [
|
||||
{
|
||||
storageKey: resolvedPhotoKey,
|
||||
size: snapshot.size ?? storageObject.size ?? plan.original.buffer?.byteLength ?? null,
|
||||
contentType: plan.original.contentType ?? null,
|
||||
etag: snapshot.etag ?? storageObject.etag ?? null,
|
||||
referenceType: 'photo.asset',
|
||||
referenceId: item.id,
|
||||
},
|
||||
...(videoObject?.key
|
||||
? [
|
||||
{
|
||||
storageKey: videoObject.key,
|
||||
size: videoObject.size ?? videoBufferMap.get(videoObject.key)?.byteLength ?? null,
|
||||
etag: videoObject.etag ?? null,
|
||||
referenceType: 'photo.asset.video',
|
||||
referenceId: item.id,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
])
|
||||
|
||||
if (onProcessed) {
|
||||
await onProcessed({ plan, storageObject, manifestItem: item })
|
||||
}
|
||||
@@ -1610,6 +1674,187 @@ export class PhotoAssetService {
|
||||
return prefix.length > 0 ? prefix : null
|
||||
}
|
||||
|
||||
private resolveManagedProviderKey(storageConfig: StorageConfig): string | null {
|
||||
if (storageConfig.provider !== 'managed') {
|
||||
return null
|
||||
}
|
||||
const managedConfig = storageConfig as ManagedStorageConfig
|
||||
const providerKey = managedConfig.providerKey ?? managedConfig.upstream.provider
|
||||
if (typeof providerKey !== 'string') {
|
||||
return null
|
||||
}
|
||||
const normalized = providerKey.trim()
|
||||
return normalized.length > 0 ? normalized : null
|
||||
}
|
||||
|
||||
private estimateManagedStorageDelta(
|
||||
storageConfig: StorageConfig,
|
||||
pendingPhotoPlans: PreparedUploadPlan[],
|
||||
activeVideoPlans: PreparedUploadPlan[],
|
||||
existingStorageMap: Map<string, StorageObject>,
|
||||
): { providerKey: string | null; incomingBytes: number; incomingFiles: number } {
|
||||
const providerKey = this.resolveManagedProviderKey(storageConfig)
|
||||
if (!providerKey) {
|
||||
return { providerKey: null, incomingBytes: 0, incomingFiles: 0 }
|
||||
}
|
||||
|
||||
let incomingBytes = 0
|
||||
let incomingFiles = 0
|
||||
const seenKeys = new Set<string>()
|
||||
|
||||
const appendPlan = (plan: PreparedUploadPlan) => {
|
||||
const normalizedKey = this.normalizeKeyPath(plan.storageKey)
|
||||
if (!normalizedKey || plan.isExisting || existingStorageMap.has(normalizedKey) || seenKeys.has(normalizedKey)) {
|
||||
return
|
||||
}
|
||||
seenKeys.add(normalizedKey)
|
||||
incomingFiles += 1
|
||||
incomingBytes += plan.original.buffer?.byteLength ?? 0
|
||||
}
|
||||
|
||||
pendingPhotoPlans.forEach(appendPlan)
|
||||
activeVideoPlans.forEach(appendPlan)
|
||||
|
||||
return { providerKey, incomingBytes, incomingFiles }
|
||||
}
|
||||
|
||||
private async ensureManagedStorageCapacity(params: {
|
||||
storageConfig: StorageConfig
|
||||
tenantId: string
|
||||
incomingBytes: number
|
||||
}): Promise<void> {
|
||||
const providerKey = this.resolveManagedProviderKey(params.storageConfig)
|
||||
if (!providerKey) {
|
||||
return
|
||||
}
|
||||
|
||||
const quota = await this.storagePlanService.getQuotaForTenant(params.tenantId)
|
||||
const capacity = quota.totalBytes
|
||||
if (capacity === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const usage = await this.managedStorageService.getUsageTotals(providerKey, params.tenantId)
|
||||
const projectedBytes = usage.totalBytes + Math.max(0, params.incomingBytes)
|
||||
|
||||
if (usage.totalBytes > capacity) {
|
||||
await this.managedStorageService.recordUsageSnapshot({
|
||||
tenantId: params.tenantId,
|
||||
providerKey,
|
||||
operation: 'over-limit',
|
||||
totalBytes: usage.totalBytes,
|
||||
fileCount: usage.fileCount,
|
||||
})
|
||||
throw new BizException(ErrorCode.BILLING_QUOTA_EXCEEDED, {
|
||||
message: `托管存储空间已超出套餐上限:当前已用 ${this.formatBytesForDisplay(
|
||||
usage.totalBytes,
|
||||
)},套餐上限 ${this.formatBytesForDisplay(capacity)}。请清理空间或升级存储方案后再试。`,
|
||||
})
|
||||
}
|
||||
|
||||
if (projectedBytes > capacity) {
|
||||
await this.managedStorageService.recordUsageSnapshot({
|
||||
tenantId: params.tenantId,
|
||||
providerKey,
|
||||
operation: 'over-limit',
|
||||
totalBytes: usage.totalBytes,
|
||||
fileCount: usage.fileCount,
|
||||
})
|
||||
throw new BizException(ErrorCode.BILLING_QUOTA_EXCEEDED, {
|
||||
message: `托管存储空间不足:当前已用 ${this.formatBytesForDisplay(
|
||||
usage.totalBytes,
|
||||
)},上传后预计 ${this.formatBytesForDisplay(projectedBytes)},已超过套餐上限 ${this.formatBytesForDisplay(
|
||||
capacity,
|
||||
)}。请清理空间或升级存储方案后再试。`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private formatBytesForDisplay(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) {
|
||||
return '0 B'
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let value = bytes
|
||||
let unitIndex = 0
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
|
||||
const fixed = value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)
|
||||
return `${fixed} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
private async recordManagedStorageReferences(
|
||||
storageConfig: StorageConfig,
|
||||
tenantId: string,
|
||||
references: Array<{
|
||||
storageKey: string
|
||||
size?: number | null
|
||||
contentType?: string | null
|
||||
etag?: string | null
|
||||
referenceType?: string | null
|
||||
referenceId?: string | null
|
||||
}>,
|
||||
): Promise<void> {
|
||||
if (storageConfig.provider !== 'managed') {
|
||||
return
|
||||
}
|
||||
|
||||
const managedConfig = storageConfig as ManagedStorageConfig
|
||||
const providerKey = managedConfig.providerKey ?? managedConfig.upstream.provider
|
||||
if (!providerKey || references.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const tasks = references
|
||||
.map((reference) => ({
|
||||
...reference,
|
||||
storageKey: this.normalizeKeyPath(reference.storageKey),
|
||||
}))
|
||||
.filter((reference) => reference.storageKey.length > 0)
|
||||
.map((reference) =>
|
||||
this.managedStorageService.upsertFileReference({
|
||||
tenantId,
|
||||
providerKey,
|
||||
storageProvider: managedConfig.upstream.provider,
|
||||
storageKey: reference.storageKey,
|
||||
size: reference.size ?? null,
|
||||
contentType: reference.contentType ?? null,
|
||||
etag: reference.etag ?? null,
|
||||
referenceType: reference.referenceType ?? null,
|
||||
referenceId: reference.referenceId ?? null,
|
||||
}),
|
||||
)
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all(tasks)
|
||||
}
|
||||
|
||||
private async recordManagedStorageSnapshot(
|
||||
storageConfig: StorageConfig,
|
||||
tenantId: string,
|
||||
operation?: string | null,
|
||||
): Promise<void> {
|
||||
const providerKey = this.resolveManagedProviderKey(storageConfig)
|
||||
if (!providerKey) {
|
||||
return
|
||||
}
|
||||
const usage = await this.managedStorageService.getUsageTotals(providerKey, tenantId)
|
||||
await this.managedStorageService.recordUsageSnapshot({
|
||||
tenantId,
|
||||
providerKey,
|
||||
operation: operation ?? 'snapshot',
|
||||
totalBytes: usage.totalBytes,
|
||||
fileCount: usage.fileCount,
|
||||
})
|
||||
}
|
||||
|
||||
private async relocateLivePhotoVideo(
|
||||
manifest: PhotoManifestItem,
|
||||
storageManager: StorageManager,
|
||||
|
||||
@@ -71,7 +71,11 @@ export class PhotoController {
|
||||
@Delete('assets')
|
||||
async deleteAssets(@Body() body: DeleteAssetsDto) {
|
||||
const ids = Array.isArray(body?.ids) ? body.ids : []
|
||||
const deleteFromStorage = body?.deleteFromStorage === true
|
||||
const deleteFromStorageRequested = body?.deleteFromStorage === true
|
||||
const isManagedStorage = await this.photoAssetService.isManagedStorage()
|
||||
// managed storage always delete from storage
|
||||
const deleteFromStorage = isManagedStorage ? true : deleteFromStorageRequested
|
||||
|
||||
await this.photoAssetService.deleteAssets(ids, { deleteFromStorage })
|
||||
return { ids, deleted: true, deleteFromStorage }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@afilmory/framework'
|
||||
import { BuilderConfigService } from 'core/modules/configuration/builder-config/builder-config.service'
|
||||
import { SystemSettingModule } from 'core/modules/configuration/system-setting/system-setting.module'
|
||||
import { BillingModule } from 'core/modules/platform/billing/billing.module'
|
||||
import { ManagedStorageModule } from 'core/modules/platform/managed-storage/managed-storage.module'
|
||||
|
||||
import { PhotoController } from './assets/photo.controller'
|
||||
import { PhotoAssetService } from './assets/photo-asset.service'
|
||||
@@ -9,7 +10,7 @@ import { PhotoBuilderService } from './builder/photo-builder.service'
|
||||
import { PhotoStorageService } from './storage/photo-storage.service'
|
||||
|
||||
@Module({
|
||||
imports: [SystemSettingModule, BillingModule],
|
||||
imports: [SystemSettingModule, BillingModule, ManagedStorageModule],
|
||||
controllers: [PhotoController],
|
||||
providers: [PhotoBuilderService, PhotoStorageService, PhotoAssetService, BuilderConfigService],
|
||||
})
|
||||
|
||||
@@ -4,31 +4,45 @@ import type {
|
||||
B2Config,
|
||||
GitHubConfig,
|
||||
LocalStorageProviderName,
|
||||
ManagedStorageConfig,
|
||||
RemoteStorageConfig,
|
||||
S3Config,
|
||||
} from '@afilmory/builder/storage/interfaces.js'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { normalizeStringToUndefined, requireStringWithMessage } from 'core/helpers/normalize.helper'
|
||||
import { BuilderConfigService } from 'core/modules/configuration/builder-config/builder-config.service'
|
||||
import { SettingService } from 'core/modules/configuration/setting/setting.service'
|
||||
import type { BuilderStorageProvider } from 'core/modules/configuration/setting/storage-provider.utils'
|
||||
import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service'
|
||||
import { StoragePlanService } from 'core/modules/platform/billing/storage-plan.service'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
|
||||
type ResolveOverrides = {
|
||||
builderConfig?: BuilderConfig
|
||||
storageConfig?: StorageConfig
|
||||
}
|
||||
|
||||
const MANAGED_ACTIVE_PROVIDER_ID = 'managed'
|
||||
|
||||
@injectable()
|
||||
export class PhotoStorageService {
|
||||
constructor(
|
||||
private readonly settingService: SettingService,
|
||||
private readonly builderConfigService: BuilderConfigService,
|
||||
private readonly systemSettingService: SystemSettingService,
|
||||
private readonly storagePlanService: StoragePlanService,
|
||||
) {}
|
||||
|
||||
async resolveConfigForTenant(
|
||||
tenantId: string,
|
||||
overrides: ResolveOverrides = {},
|
||||
): Promise<{ builderConfig: BuilderConfig; storageConfig: StorageConfig }> {
|
||||
const activeProviderIdRaw = await this.settingService.get('builder.storage.activeProvider', { tenantId })
|
||||
const activeProviderId =
|
||||
typeof activeProviderIdRaw === 'string' && activeProviderIdRaw.trim().length > 0
|
||||
? activeProviderIdRaw.trim()
|
||||
: null
|
||||
|
||||
if (overrides.builderConfig) {
|
||||
const storageConfig = overrides.storageConfig ?? overrides.builderConfig.user?.storage
|
||||
if (!storageConfig) {
|
||||
@@ -40,6 +54,16 @@ export class PhotoStorageService {
|
||||
}
|
||||
|
||||
const activeProvider = await this.settingService.getActiveStorageProvider({ tenantId })
|
||||
if (activeProviderId === MANAGED_ACTIVE_PROVIDER_ID) {
|
||||
const managedConfig = await this.tryResolveManagedStorageConfig(tenantId)
|
||||
if (managedConfig) {
|
||||
const builderConfig = await this.builderConfigService.getConfigForTenant(tenantId)
|
||||
const userSettings = this.ensureUserSettings(builderConfig)
|
||||
userSettings.storage = managedConfig
|
||||
return { builderConfig, storageConfig: managedConfig }
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeProvider) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
|
||||
message: 'Active storage provider is not configured. Configure storage settings before running sync.',
|
||||
@@ -54,31 +78,58 @@ export class PhotoStorageService {
|
||||
return { builderConfig, storageConfig }
|
||||
}
|
||||
|
||||
private async tryResolveManagedStorageConfig(tenantId: string): Promise<ManagedStorageConfig | null> {
|
||||
const [plan, provider] = await Promise.all([
|
||||
this.storagePlanService.getPlanSummaryForTenant(tenantId),
|
||||
this.systemSettingService.getManagedStorageProvider(),
|
||||
])
|
||||
|
||||
if (!plan) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!provider) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
|
||||
message: '托管存储尚未启用或未配置 Provider。',
|
||||
})
|
||||
}
|
||||
|
||||
const upstream = this.mapProviderToStorageConfig(provider) as RemoteStorageConfig
|
||||
|
||||
return {
|
||||
provider: 'managed',
|
||||
providerKey: provider.id,
|
||||
tenantId,
|
||||
upstream,
|
||||
basePrefix: null,
|
||||
}
|
||||
}
|
||||
|
||||
private mapProviderToStorageConfig(provider: BuilderStorageProvider): StorageConfig {
|
||||
this.assertProviderSupported(provider.type)
|
||||
|
||||
const config = provider.config ?? {}
|
||||
switch (provider.type) {
|
||||
case 's3': {
|
||||
const bucket = this.requireString(config.bucket, 'Active S3 storage provider is missing `bucket`.')
|
||||
const bucket = requireStringWithMessage(config.bucket, 'Active S3 storage provider is missing `bucket`.')
|
||||
const result: S3Config = {
|
||||
provider: 's3',
|
||||
bucket,
|
||||
}
|
||||
|
||||
const region = this.normalizeString(config.region)
|
||||
const region = normalizeStringToUndefined(config.region)
|
||||
if (region) result.region = region
|
||||
const endpoint = this.normalizeString(config.endpoint)
|
||||
const endpoint = normalizeStringToUndefined(config.endpoint)
|
||||
if (endpoint) result.endpoint = endpoint
|
||||
const accessKeyId = this.normalizeString(config.accessKeyId)
|
||||
const accessKeyId = normalizeStringToUndefined(config.accessKeyId)
|
||||
if (accessKeyId) result.accessKeyId = accessKeyId
|
||||
const secretAccessKey = this.normalizeString(config.secretAccessKey)
|
||||
const secretAccessKey = normalizeStringToUndefined(config.secretAccessKey)
|
||||
if (secretAccessKey) result.secretAccessKey = secretAccessKey
|
||||
const prefix = this.normalizeString(config.prefix)
|
||||
const prefix = normalizeStringToUndefined(config.prefix)
|
||||
if (prefix) result.prefix = prefix
|
||||
const customDomain = this.normalizeString(config.customDomain)
|
||||
const customDomain = normalizeStringToUndefined(config.customDomain)
|
||||
if (customDomain) result.customDomain = customDomain
|
||||
const excludeRegex = this.normalizeString(config.excludeRegex)
|
||||
const excludeRegex = normalizeStringToUndefined(config.excludeRegex)
|
||||
if (excludeRegex) result.excludeRegex = excludeRegex
|
||||
|
||||
const maxFileLimit = this.parseNumber(config.maxFileLimit)
|
||||
@@ -107,8 +158,8 @@ export class PhotoStorageService {
|
||||
return result
|
||||
}
|
||||
case 'github': {
|
||||
const owner = this.requireString(config.owner, 'Active GitHub storage provider is missing `owner`.')
|
||||
const repo = this.requireString(config.repo, 'Active GitHub storage provider is missing `repo`.')
|
||||
const owner = requireStringWithMessage(config.owner, 'Active GitHub storage provider is missing `owner`.')
|
||||
const repo = requireStringWithMessage(config.repo, 'Active GitHub storage provider is missing `repo`.')
|
||||
|
||||
const result: GitHubConfig = {
|
||||
provider: 'github',
|
||||
@@ -116,11 +167,11 @@ export class PhotoStorageService {
|
||||
repo,
|
||||
}
|
||||
|
||||
const branch = this.normalizeString(config.branch)
|
||||
const branch = normalizeStringToUndefined(config.branch)
|
||||
if (branch) result.branch = branch
|
||||
const token = this.normalizeString(config.token)
|
||||
const token = normalizeStringToUndefined(config.token)
|
||||
if (token) result.token = token
|
||||
const pathValue = this.normalizeString(config.path)
|
||||
const pathValue = normalizeStringToUndefined(config.path)
|
||||
if (pathValue) result.path = pathValue
|
||||
const useRawUrl = this.parseBoolean(config.useRawUrl)
|
||||
if (typeof useRawUrl === 'boolean') result.useRawUrl = useRawUrl
|
||||
@@ -128,17 +179,20 @@ export class PhotoStorageService {
|
||||
return result
|
||||
}
|
||||
case 'b2': {
|
||||
const applicationKeyId = this.requireString(
|
||||
const applicationKeyId = requireStringWithMessage(
|
||||
config.applicationKeyId,
|
||||
'Active B2 storage provider is missing `applicationKeyId`.',
|
||||
)
|
||||
const applicationKey = this.requireString(
|
||||
const applicationKey = requireStringWithMessage(
|
||||
config.applicationKey,
|
||||
'Active B2 storage provider is missing `applicationKey`.',
|
||||
)
|
||||
const bucketId = this.requireString(config.bucketId, 'Active B2 storage provider is missing `bucketId`.')
|
||||
const bucketId = requireStringWithMessage(config.bucketId, 'Active B2 storage provider is missing `bucketId`.')
|
||||
|
||||
const bucketName = this.requireString(config.bucketName, 'Active B2 storage provider is missing `bucketName`.')
|
||||
const bucketName = requireStringWithMessage(
|
||||
config.bucketName,
|
||||
'Active B2 storage provider is missing `bucketName`.',
|
||||
)
|
||||
|
||||
const result: B2Config = {
|
||||
provider: 'b2',
|
||||
@@ -147,11 +201,11 @@ export class PhotoStorageService {
|
||||
bucketId,
|
||||
bucketName,
|
||||
}
|
||||
const prefix = this.normalizeString(config.prefix)
|
||||
const prefix = normalizeStringToUndefined(config.prefix)
|
||||
if (prefix) result.prefix = prefix
|
||||
const customDomain = this.normalizeString(config.customDomain)
|
||||
const customDomain = normalizeStringToUndefined(config.customDomain)
|
||||
if (customDomain) result.customDomain = customDomain
|
||||
const excludeRegex = this.normalizeString(config.excludeRegex)
|
||||
const excludeRegex = normalizeStringToUndefined(config.excludeRegex)
|
||||
if (excludeRegex) result.excludeRegex = excludeRegex
|
||||
|
||||
const maxFileLimit = this.parseNumber(config.maxFileLimit)
|
||||
@@ -180,17 +234,8 @@ export class PhotoStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeString(value?: string | null): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const normalized = value.trim()
|
||||
return normalized.length > 0 ? normalized : undefined
|
||||
}
|
||||
|
||||
private parseNumber(value?: string | null): number | undefined {
|
||||
const normalized = this.normalizeString(value)
|
||||
const normalized = normalizeStringToUndefined(value)
|
||||
if (!normalized) {
|
||||
return undefined
|
||||
}
|
||||
@@ -200,7 +245,7 @@ export class PhotoStorageService {
|
||||
}
|
||||
|
||||
private parseBoolean(value?: string | null): boolean | undefined {
|
||||
const normalized = this.normalizeString(value)
|
||||
const normalized = normalizeStringToUndefined(value)
|
||||
if (!normalized) {
|
||||
return undefined
|
||||
}
|
||||
@@ -216,7 +261,7 @@ export class PhotoStorageService {
|
||||
}
|
||||
|
||||
private parseRetryMode(value?: string | null): S3Config['retryMode'] | undefined {
|
||||
const normalized = this.normalizeString(value)
|
||||
const normalized = normalizeStringToUndefined(value)
|
||||
if (!normalized) {
|
||||
return undefined
|
||||
}
|
||||
@@ -228,14 +273,6 @@ export class PhotoStorageService {
|
||||
return undefined
|
||||
}
|
||||
|
||||
private requireString(value: string | undefined | null, message: string): string {
|
||||
const normalized = this.normalizeString(value)
|
||||
if (!normalized) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message })
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
private ensureUserSettings(config: BuilderConfig): NonNullable<BuilderConfig['user']> {
|
||||
if (!config.user) {
|
||||
config.user = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { authAccounts, authSessions, authUsers, authVerifications, creemSubscrip
|
||||
import { env } from '@afilmory/env'
|
||||
import type { OnModuleInit } from '@afilmory/framework'
|
||||
import { createLogger, HttpContext } from '@afilmory/framework'
|
||||
import type { FlatSubscriptionEvent } from '@creem_io/better-auth'
|
||||
import { creem } from '@creem_io/better-auth'
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||
@@ -15,6 +16,7 @@ import { SystemSettingService } from 'core/modules/configuration/system-setting/
|
||||
import { BILLING_PLAN_IDS } from 'core/modules/platform/billing/billing-plan.constants'
|
||||
import { BillingPlanService } from 'core/modules/platform/billing/billing-plan.service'
|
||||
import type { BillingPlanId } from 'core/modules/platform/billing/billing-plan.types'
|
||||
import { StoragePlanService } from 'core/modules/platform/billing/storage-plan.service'
|
||||
import type { Context } from 'hono'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
@@ -39,6 +41,7 @@ export class AuthProvider implements OnModuleInit {
|
||||
private readonly systemSettings: SystemSettingService,
|
||||
private readonly tenantService: TenantService,
|
||||
private readonly billingPlanService: BillingPlanService,
|
||||
private readonly storagePlanService: StoragePlanService,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
@@ -322,11 +325,25 @@ export class AuthProvider implements OnModuleInit {
|
||||
webhookSecret: env.CREEM_WEBHOOK_SECRET,
|
||||
persistSubscriptions: true,
|
||||
testMode: env.NODE_ENV !== 'production',
|
||||
onGrantAccess: async ({ metadata }) => {
|
||||
await this.handleCreemGrant(metadata)
|
||||
onCheckoutCompleted: async (data) => {
|
||||
await this.handleCreemWebhook({
|
||||
event: data.webhookEventType,
|
||||
metadata: this.mergeMetadata(data.metadata, data.subscription?.metadata),
|
||||
status: data.subscription?.status ?? null,
|
||||
defaultGrant: true,
|
||||
})
|
||||
},
|
||||
onRevokeAccess: async ({ metadata }) => {
|
||||
await this.handleCreemRevoke(metadata)
|
||||
// onRefundCreated: async (data: FlatRefundCreated) => {
|
||||
// await this.handleCreemRefundCreated(data)
|
||||
// },
|
||||
onSubscriptionCanceled: async (data) => {
|
||||
await this.handleCreemSubscriptionEvent(data, true)
|
||||
},
|
||||
onSubscriptionExpired: async (data) => {
|
||||
await this.handleCreemSubscriptionEvent(data, true)
|
||||
},
|
||||
onSubscriptionUpdate: async (data) => {
|
||||
await this.handleCreemSubscriptionEvent(data, false)
|
||||
},
|
||||
}),
|
||||
],
|
||||
@@ -402,35 +419,152 @@ export class AuthProvider implements OnModuleInit {
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
private async handleCreemGrant(metadata?: Record<string, unknown>): Promise<void> {
|
||||
const tenantId = this.extractMetadataValue(metadata, 'tenantId')
|
||||
const planId = this.extractPlanIdFromMetadata(metadata)
|
||||
private async handleCreemSubscriptionEvent(data: FlatSubscriptionEvent<string>, forceRevoke: boolean): Promise<void> {
|
||||
await this.handleCreemWebhook({
|
||||
event: data.webhookEventType,
|
||||
metadata: this.mergeMetadata(data.metadata),
|
||||
status: data.status,
|
||||
forceRevoke,
|
||||
})
|
||||
}
|
||||
|
||||
if (!tenantId || !planId) {
|
||||
logger.warn('[AuthProvider] Creem grant event missing tenantId or planId metadata')
|
||||
private async handleCreemWebhook(params: {
|
||||
event: string
|
||||
metadata?: Record<string, unknown> | null
|
||||
status?: string | null
|
||||
defaultGrant?: boolean
|
||||
forceRevoke?: boolean
|
||||
}): Promise<void> {
|
||||
const { event, metadata, status, defaultGrant = false, forceRevoke = false } = params
|
||||
const tenantId = this.extractMetadataValue(metadata ?? undefined, 'tenantId')
|
||||
const planId = this.extractPlanIdFromMetadata(metadata ?? undefined)
|
||||
const storagePlanId = this.extractStoragePlanIdFromMetadata(metadata ?? undefined)
|
||||
|
||||
if (!tenantId) {
|
||||
logger.warn(`[AuthProvider] Creem ${event} event missing tenantId metadata`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await this.billingPlanService.updateTenantPlan(tenantId, planId)
|
||||
logger.info(`[AuthProvider] Tenant ${tenantId} upgraded to ${planId} via Creem`)
|
||||
} catch (error) {
|
||||
logger.error(`[AuthProvider] Failed to update tenant ${tenantId} plan from Creem grant`, error)
|
||||
const shouldGrant = this.shouldGrantStatus(status, event, defaultGrant, forceRevoke)
|
||||
if (shouldGrant === null) {
|
||||
logger.warn(`[AuthProvider] Creem ${event} event for tenant ${tenantId} missing actionable status, skipping`)
|
||||
return
|
||||
}
|
||||
if (shouldGrant) {
|
||||
await this.applyPlanUpdates({ tenantId, planId, storagePlanId, event })
|
||||
return
|
||||
}
|
||||
|
||||
await this.applyRevocation({ tenantId, planId, storagePlanId, event })
|
||||
}
|
||||
|
||||
private mergeMetadata(...sources: Array<Record<string, unknown> | null | undefined>): Record<string, unknown> | null {
|
||||
const merged = sources.filter(Boolean).reduce<Record<string, unknown>>((acc, curr) => {
|
||||
Object.assign(acc, curr as Record<string, unknown>)
|
||||
return acc
|
||||
}, {})
|
||||
return Object.keys(merged).length > 0 ? merged : null
|
||||
}
|
||||
|
||||
private shouldGrantStatus(
|
||||
status: string | null | undefined,
|
||||
event: string,
|
||||
defaultGrant: boolean,
|
||||
forceRevoke: boolean,
|
||||
): boolean | null {
|
||||
if (forceRevoke) {
|
||||
return false
|
||||
}
|
||||
const normalized = status?.toLowerCase() ?? null
|
||||
const grantStatuses = new Set(['active', 'trialing', 'paid'])
|
||||
|
||||
if (event === 'checkout.completed') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (normalized && grantStatuses.has(normalized)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (event === 'subscription.update') {
|
||||
if (!normalized) {
|
||||
return defaultGrant ? true : null
|
||||
}
|
||||
return grantStatuses.has(normalized)
|
||||
}
|
||||
|
||||
if (!normalized && !defaultGrant) {
|
||||
return null
|
||||
}
|
||||
|
||||
return defaultGrant
|
||||
}
|
||||
|
||||
private async applyPlanUpdates(params: {
|
||||
tenantId: string
|
||||
planId: BillingPlanId | null
|
||||
storagePlanId: string | null
|
||||
event: string
|
||||
}): Promise<void> {
|
||||
const { tenantId, planId, storagePlanId, event } = params
|
||||
let handled = false
|
||||
|
||||
if (planId) {
|
||||
handled = true
|
||||
try {
|
||||
await this.billingPlanService.updateTenantPlan(tenantId, planId)
|
||||
logger.info(`[AuthProvider] Tenant ${tenantId} set to billing plan ${planId} via Creem (${event})`)
|
||||
} catch (error) {
|
||||
logger.error(`[AuthProvider] Failed to update tenant ${tenantId} billing plan from Creem (${event})`, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (storagePlanId) {
|
||||
handled = true
|
||||
try {
|
||||
await this.storagePlanService.updateTenantPlan(tenantId, storagePlanId)
|
||||
logger.info(`[AuthProvider] Tenant ${tenantId} storage plan set to ${storagePlanId} via Creem (${event})`)
|
||||
} catch (error) {
|
||||
logger.error(`[AuthProvider] Failed to update tenant ${tenantId} storage plan from Creem (${event})`, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!handled) {
|
||||
logger.warn(`[AuthProvider] Creem ${event} event for tenant ${tenantId} missing plan metadata`)
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCreemRevoke(metadata?: Record<string, unknown>): Promise<void> {
|
||||
const tenantId = this.extractMetadataValue(metadata, 'tenantId')
|
||||
if (!tenantId) {
|
||||
logger.warn('[AuthProvider] Creem revoke event missing tenantId metadata')
|
||||
return
|
||||
private async applyRevocation(params: {
|
||||
tenantId: string
|
||||
planId: BillingPlanId | null
|
||||
storagePlanId: string | null
|
||||
event: string
|
||||
}): Promise<void> {
|
||||
const { tenantId, planId, storagePlanId, event } = params
|
||||
let handled = false
|
||||
|
||||
if (planId) {
|
||||
handled = true
|
||||
try {
|
||||
await this.billingPlanService.updateTenantPlan(tenantId, 'free')
|
||||
logger.info(`[AuthProvider] Tenant ${tenantId} downgraded to free via Creem (${event})`)
|
||||
} catch (error) {
|
||||
logger.error(`[AuthProvider] Failed to downgrade tenant ${tenantId} after Creem ${event}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.billingPlanService.updateTenantPlan(tenantId, 'free')
|
||||
logger.info(`[AuthProvider] Tenant ${tenantId} downgraded to free via Creem revoke`)
|
||||
} catch (error) {
|
||||
logger.error(`[AuthProvider] Failed to downgrade tenant ${tenantId} after Creem revoke`, error)
|
||||
if (storagePlanId) {
|
||||
handled = true
|
||||
try {
|
||||
await this.storagePlanService.updateTenantPlan(tenantId, null)
|
||||
logger.info(`[AuthProvider] Tenant ${tenantId} storage plan cleared via Creem (${event})`)
|
||||
} catch (error) {
|
||||
logger.error(`[AuthProvider] Failed to clear tenant ${tenantId} storage plan after Creem ${event}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!handled) {
|
||||
logger.warn(`[AuthProvider] Creem ${event} event for tenant ${tenantId} missing plan metadata`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,6 +579,10 @@ export class AuthProvider implements OnModuleInit {
|
||||
return null
|
||||
}
|
||||
|
||||
private extractStoragePlanIdFromMetadata(metadata?: Record<string, unknown>): string | null {
|
||||
return this.extractMetadataValue(metadata, 'storagePlanId')
|
||||
}
|
||||
|
||||
private extractMetadataValue(metadata: Record<string, unknown> | undefined, key: string): string | null {
|
||||
if (!metadata) {
|
||||
return null
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { tenants } from '@afilmory/db'
|
||||
import { DbAccessor } from 'core/database/database.provider'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { normalizeString } from 'core/helpers/normalize.helper'
|
||||
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'
|
||||
@@ -189,7 +190,7 @@ export class BillingPlanService {
|
||||
if (!entry) {
|
||||
return undefined
|
||||
}
|
||||
const creemProductId = this.normalizeString(entry.creemProductId)
|
||||
const creemProductId = normalizeString(entry.creemProductId)
|
||||
if (!creemProductId) {
|
||||
return undefined
|
||||
}
|
||||
@@ -218,14 +219,6 @@ export class BillingPlanService {
|
||||
currency: entry.currency ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeString(value?: string | null): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
}
|
||||
|
||||
export interface BillingPlanSummary {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { billingUsageEvents } from '@afilmory/db'
|
||||
import { DbAccessor } from 'core/database/database.provider'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { normalizeDate } from 'core/helpers/normalize.helper'
|
||||
import { getTenantContext, requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
|
||||
import { and, desc, eq, gte, inArray, lte, sql } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
@@ -99,7 +100,7 @@ export class BillingUsageService {
|
||||
const rows = inputs.map((input) => {
|
||||
const tenantId = this.resolveTenantId(input.tenantId)
|
||||
const quantity = this.normalizeQuantity(input.quantity)
|
||||
const occurredAt = this.normalizeDate(input.occurredAt) ?? now.toISOString()
|
||||
const occurredAt = normalizeDate(input.occurredAt) ?? now.toISOString()
|
||||
return {
|
||||
tenantId,
|
||||
eventType: input.eventType,
|
||||
@@ -175,7 +176,7 @@ export class BillingUsageService {
|
||||
const totalsByTenant: Record<string, BillingUsageTotalsEntry[]> = {}
|
||||
|
||||
for (const row of rows) {
|
||||
const {tenantId} = row
|
||||
const { tenantId } = row
|
||||
if (!tenantId) {
|
||||
continue
|
||||
}
|
||||
@@ -234,16 +235,4 @@ export class BillingUsageService {
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
private normalizeDate(value?: Date | string | null): string | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null
|
||||
}
|
||||
return date.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, createZodSchemaDto, Get, Patch, Query } from '@afilmory/framework'
|
||||
import { Controller, createZodSchemaDto, Get, Query } from '@afilmory/framework'
|
||||
import { Roles } from 'core/guards/roles.decorator'
|
||||
import { inject } from 'tsyringe'
|
||||
import z from 'zod'
|
||||
@@ -14,11 +14,6 @@ const usageQuerySchema = z.object({
|
||||
})
|
||||
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 {
|
||||
@@ -46,10 +41,4 @@ export class BillingController {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +42,8 @@ export class StoragePlanService {
|
||||
.map(([id, entry]) =>
|
||||
this.buildPlanSummary(
|
||||
{
|
||||
id,
|
||||
...entry,
|
||||
id,
|
||||
},
|
||||
pricing[id],
|
||||
products[id],
|
||||
@@ -64,7 +64,7 @@ export class StoragePlanService {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.buildPlanSummary({ id: planId, ...definition }, pricing[planId], products[planId])
|
||||
return this.buildPlanSummary({ ...definition, id: planId }, pricing[planId], products[planId])
|
||||
}
|
||||
|
||||
async getQuotaForTenant(tenantId: string): Promise<StorageQuotaSummary> {
|
||||
@@ -126,6 +126,13 @@ export class StoragePlanService {
|
||||
return await this.getOverviewForCurrentTenant()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the managed storage plan for the given tenant directly (e.g. from billing webhooks).
|
||||
*/
|
||||
async updateTenantPlan(tenantId: string, planId: string | null): Promise<void> {
|
||||
await this.assignPlanToTenant(tenantId, planId)
|
||||
}
|
||||
|
||||
private async resolveStoragePlanIdForTenant(tenantId: string): Promise<string | null> {
|
||||
const db = this.dbAccessor.get()
|
||||
const [record] = await db
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@afilmory/framework'
|
||||
import { DatabaseModule } from 'core/database/database.module'
|
||||
|
||||
import { registerManagedStorageProvider } from './managed-storage.provider'
|
||||
import { ManagedStorageService } from './managed-storage.service'
|
||||
|
||||
// Register the managed storage provider at module load so StorageFactory is ready before use.
|
||||
registerManagedStorageProvider()
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
providers: [ManagedStorageService],
|
||||
})
|
||||
export class ManagedStorageModule {}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { StorageFactory } from '@afilmory/builder/storage/index.js'
|
||||
import type {
|
||||
ManagedStorageConfig,
|
||||
ProgressCallback,
|
||||
RemoteStorageConfig,
|
||||
StorageObject,
|
||||
StorageProvider,
|
||||
StorageUploadOptions,
|
||||
} from '@afilmory/builder/storage/interfaces.js'
|
||||
|
||||
type PrefixedStorageObject = StorageObject & { key: string }
|
||||
|
||||
export class ManagedStorageProvider implements StorageProvider {
|
||||
private readonly upstream: StorageProvider
|
||||
private readonly upstreamConfig: RemoteStorageConfig
|
||||
private readonly effectivePrefix: string
|
||||
private readonly needsManualPrefix: boolean
|
||||
|
||||
constructor(private readonly config: ManagedStorageConfig) {
|
||||
const tenantSegment = this.normalizePath(config.tenantId)
|
||||
if (!tenantSegment) {
|
||||
throw new Error('Managed storage provider requires a valid tenantId.')
|
||||
}
|
||||
|
||||
const upstreamBase = this.normalizePath(this.extractUpstreamBasePath(config.upstream))
|
||||
const customBase = this.normalizePath(config.basePrefix)
|
||||
const combinedBase = this.joinSegments(upstreamBase, customBase)
|
||||
this.effectivePrefix = this.joinSegments(combinedBase, tenantSegment)
|
||||
|
||||
const scopedConfig = this.applyTenantPrefix(config.upstream, this.effectivePrefix)
|
||||
this.upstreamConfig = scopedConfig
|
||||
this.needsManualPrefix = scopedConfig.provider === 's3'
|
||||
this.upstream = StorageFactory.createProvider(scopedConfig)
|
||||
}
|
||||
|
||||
async getFile(key: string): Promise<Buffer | null> {
|
||||
const targetKey = this.prepareKeyForUpstream(key)
|
||||
return await this.upstream.getFile(targetKey)
|
||||
}
|
||||
|
||||
async listImages(): Promise<StorageObject[]> {
|
||||
const objects = await this.upstream.listImages()
|
||||
return this.normalizeResults(objects)
|
||||
}
|
||||
|
||||
async listAllFiles(progressCallback?: ProgressCallback): Promise<StorageObject[]> {
|
||||
const objects = await this.upstream.listAllFiles(progressCallback)
|
||||
return this.normalizeResults(objects)
|
||||
}
|
||||
|
||||
async generatePublicUrl(key: string): Promise<string> {
|
||||
const targetKey = this.prepareKeyForUpstream(key)
|
||||
return await this.upstream.generatePublicUrl(targetKey)
|
||||
}
|
||||
|
||||
async detectLivePhotos(allObjects?: StorageObject[]): Promise<Map<string, StorageObject>> {
|
||||
const upstreamObjects = allObjects ? this.toUpstreamObjects(allObjects) : undefined
|
||||
const sourceObjects = upstreamObjects ?? (await this.upstream.listAllFiles())
|
||||
const liveMap = await Promise.resolve(this.upstream.detectLivePhotos(sourceObjects))
|
||||
const result = new Map<string, StorageObject>()
|
||||
|
||||
for (const [key, value] of liveMap.entries()) {
|
||||
const normalizedKey = this.stripEffectivePrefix(key)
|
||||
if (!normalizedKey) {
|
||||
continue
|
||||
}
|
||||
const normalizedValue: StorageObject = value?.key
|
||||
? { ...value, key: this.stripEffectivePrefix(value.key) }
|
||||
: value
|
||||
result.set(normalizedKey, normalizedValue)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async deleteFile(key: string): Promise<void> {
|
||||
const targetKey = this.prepareKeyForUpstream(key)
|
||||
await this.upstream.deleteFile(targetKey)
|
||||
}
|
||||
|
||||
async uploadFile(key: string, data: Buffer, options?: StorageUploadOptions): Promise<StorageObject> {
|
||||
const targetKey = this.prepareKeyForUpstream(key)
|
||||
const uploaded = await this.upstream.uploadFile(targetKey, data, options)
|
||||
return this.normalizeResult(uploaded)
|
||||
}
|
||||
|
||||
async moveFile(sourceKey: string, targetKey: string, options?: StorageUploadOptions): Promise<StorageObject> {
|
||||
const source = this.prepareKeyForUpstream(sourceKey)
|
||||
const target = this.prepareKeyForUpstream(targetKey)
|
||||
const moved = await this.upstream.moveFile(source, target, options)
|
||||
return this.normalizeResult(moved)
|
||||
}
|
||||
|
||||
private prepareKeyForUpstream(key: string): string {
|
||||
const normalizedKey = this.normalizePath(key) ?? ''
|
||||
if (!this.needsManualPrefix) {
|
||||
return normalizedKey
|
||||
}
|
||||
|
||||
return this.joinSegments(this.effectivePrefix, normalizedKey)
|
||||
}
|
||||
|
||||
private toUpstreamObjects(objects: StorageObject[]): PrefixedStorageObject[] {
|
||||
return objects
|
||||
.map((obj) => {
|
||||
if (!obj.key) {
|
||||
return null
|
||||
}
|
||||
const upstreamKey = this.prepareKeyForUpstream(obj.key)
|
||||
return { ...obj, key: upstreamKey }
|
||||
})
|
||||
.filter((obj): obj is PrefixedStorageObject => obj !== null)
|
||||
}
|
||||
|
||||
private normalizeResult<T extends StorageObject>(object: T): T {
|
||||
if (!object.key) {
|
||||
return object
|
||||
}
|
||||
const normalizedKey = this.stripEffectivePrefix(object.key)
|
||||
return { ...object, key: normalizedKey } as T
|
||||
}
|
||||
|
||||
private normalizeResults(objects: StorageObject[]): StorageObject[] {
|
||||
return objects
|
||||
.map((obj) => {
|
||||
if (!obj.key) {
|
||||
return null
|
||||
}
|
||||
return this.normalizeResult(obj)
|
||||
})
|
||||
.filter((obj): obj is StorageObject => obj !== null)
|
||||
}
|
||||
|
||||
private stripEffectivePrefix(rawKey: string): string {
|
||||
const normalizedKey = this.normalizePath(rawKey) ?? ''
|
||||
if (!this.effectivePrefix) {
|
||||
return normalizedKey
|
||||
}
|
||||
|
||||
if (normalizedKey === this.effectivePrefix) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const prefixWithSlash = `${this.effectivePrefix}/`
|
||||
if (normalizedKey.startsWith(prefixWithSlash)) {
|
||||
return normalizedKey.slice(prefixWithSlash.length)
|
||||
}
|
||||
|
||||
return normalizedKey
|
||||
}
|
||||
|
||||
private normalizePath(value?: string | null): string | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
const normalized = value
|
||||
.replaceAll('\\', '/')
|
||||
.replaceAll(/\/+/g, '/')
|
||||
.replaceAll(/^\/+|\/+$/g, '')
|
||||
return normalized.length > 0 ? normalized : null
|
||||
}
|
||||
|
||||
private joinSegments(...segments: Array<string | null>): string {
|
||||
const filtered = segments.filter(Boolean)
|
||||
if (filtered.length === 0) {
|
||||
return ''
|
||||
}
|
||||
return filtered
|
||||
.map((segment) => segment.replaceAll(/^\/+|\/+$/g, ''))
|
||||
.filter(Boolean)
|
||||
.join('/')
|
||||
}
|
||||
|
||||
private extractUpstreamBasePath(config: RemoteStorageConfig): string | null {
|
||||
switch (config.provider) {
|
||||
case 's3':
|
||||
case 'b2': {
|
||||
return this.normalizePath(config.prefix)
|
||||
}
|
||||
case 'github': {
|
||||
return this.normalizePath(config.path)
|
||||
}
|
||||
default: {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyTenantPrefix(config: RemoteStorageConfig, prefix: string): RemoteStorageConfig {
|
||||
const normalizedPrefix = this.normalizePath(prefix)
|
||||
if (!normalizedPrefix) {
|
||||
return config
|
||||
}
|
||||
|
||||
switch (config.provider) {
|
||||
case 's3': {
|
||||
return { ...config, prefix: normalizedPrefix }
|
||||
}
|
||||
case 'b2': {
|
||||
return { ...config, prefix: normalizedPrefix }
|
||||
}
|
||||
case 'github': {
|
||||
return { ...config, path: normalizedPrefix }
|
||||
}
|
||||
default: {
|
||||
return config
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerManagedStorageProvider(): void {
|
||||
StorageFactory.registerProvider('managed', (config) => new ManagedStorageProvider(config as ManagedStorageConfig), {
|
||||
category: 'remote',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { ManagedStorageMetadata } from '@afilmory/db'
|
||||
import { managedStorageFileReferences, managedStorageUsages } from '@afilmory/db'
|
||||
import { DbAccessor } from 'core/database/database.provider'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import {
|
||||
normalizeDate,
|
||||
normalizeInteger,
|
||||
normalizeNumber,
|
||||
normalizeString,
|
||||
requireString,
|
||||
} from 'core/helpers/normalize.helper'
|
||||
import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
export interface ManagedStorageUsageSnapshotInput {
|
||||
tenantId?: string | null
|
||||
providerKey: string
|
||||
totalBytes: number
|
||||
operation?: string | null
|
||||
fileCount?: number
|
||||
recordedAt?: Date | string
|
||||
periodStart?: Date | string | null
|
||||
periodEnd?: Date | string | null
|
||||
}
|
||||
|
||||
export interface ManagedStorageFileReferenceInput {
|
||||
tenantId?: string | null
|
||||
providerKey: string
|
||||
storageProvider?: string | null
|
||||
storageKey: string
|
||||
size?: number | null
|
||||
contentType?: string | null
|
||||
etag?: string | null
|
||||
referenceType?: string | null
|
||||
referenceId?: string | null
|
||||
metadata?: ManagedStorageMetadata | null
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class ManagedStorageService {
|
||||
constructor(private readonly dbAccessor: DbAccessor) {}
|
||||
|
||||
async recordUsageSnapshot(input: ManagedStorageUsageSnapshotInput): Promise<void> {
|
||||
const tenantId = this.resolveTenantId(input.tenantId)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const payload: typeof managedStorageUsages.$inferInsert = {
|
||||
tenantId,
|
||||
providerKey: requireString(input.providerKey, 'providerKey'),
|
||||
operation: normalizeString(input.operation),
|
||||
totalBytes: normalizeNumber(input.totalBytes),
|
||||
fileCount: normalizeInteger(input.fileCount),
|
||||
periodStart: normalizeDate(input.periodStart),
|
||||
periodEnd: normalizeDate(input.periodEnd),
|
||||
recordedAt: normalizeDate(input.recordedAt) ?? now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
const db = this.dbAccessor.get()
|
||||
await db.insert(managedStorageUsages).values(payload)
|
||||
}
|
||||
|
||||
async getUsageTotals(
|
||||
providerKey: string,
|
||||
tenantId?: string | null,
|
||||
): Promise<{ totalBytes: number; fileCount: number }> {
|
||||
const resolvedTenantId = this.resolveTenantId(tenantId)
|
||||
const normalizedProviderKey = requireString(providerKey, 'providerKey')
|
||||
const db = this.dbAccessor.get()
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
totalBytes: sql<number>`coalesce(sum(${managedStorageFileReferences.size}), 0)`,
|
||||
fileCount: sql<number>`count(*)`,
|
||||
})
|
||||
.from(managedStorageFileReferences)
|
||||
.where(
|
||||
and(
|
||||
eq(managedStorageFileReferences.tenantId, resolvedTenantId),
|
||||
eq(managedStorageFileReferences.providerKey, normalizedProviderKey),
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
totalBytes: Number(row?.totalBytes ?? 0),
|
||||
fileCount: Number(row?.fileCount ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFileReferences(providerKey: string, storageKeys: string[], tenantId?: string | null): Promise<void> {
|
||||
const resolvedTenantId = this.resolveTenantId(tenantId)
|
||||
const normalizedProviderKey = requireString(providerKey, 'providerKey')
|
||||
const normalizedKeys = storageKeys
|
||||
.map((key) => this.normalizeStorageKey(key))
|
||||
.filter((key): key is string => typeof key === 'string' && key.length > 0)
|
||||
|
||||
if (normalizedKeys.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const db = this.dbAccessor.get()
|
||||
await db
|
||||
.delete(managedStorageFileReferences)
|
||||
.where(
|
||||
and(
|
||||
eq(managedStorageFileReferences.tenantId, resolvedTenantId),
|
||||
eq(managedStorageFileReferences.providerKey, normalizedProviderKey),
|
||||
inArray(managedStorageFileReferences.storageKey, normalizedKeys),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async upsertFileReference(input: ManagedStorageFileReferenceInput): Promise<void> {
|
||||
const tenantId = this.resolveTenantId(input.tenantId)
|
||||
const now = new Date().toISOString()
|
||||
const storageKey = this.normalizeStorageKey(input.storageKey)
|
||||
|
||||
const insertPayload: typeof managedStorageFileReferences.$inferInsert = {
|
||||
tenantId,
|
||||
providerKey: requireString(input.providerKey, 'providerKey'),
|
||||
storageProvider: normalizeString(input.storageProvider),
|
||||
storageKey,
|
||||
size: normalizeNumber(input.size),
|
||||
contentType: normalizeString(input.contentType),
|
||||
etag: normalizeString(input.etag),
|
||||
referenceType: normalizeString(input.referenceType),
|
||||
referenceId: normalizeString(input.referenceId),
|
||||
metadata: input.metadata ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
const updatePayload: Partial<typeof managedStorageFileReferences.$inferInsert> = {
|
||||
providerKey: insertPayload.providerKey,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
if (input.storageProvider !== undefined) {
|
||||
updatePayload.storageProvider = insertPayload.storageProvider
|
||||
}
|
||||
if (input.size !== undefined) {
|
||||
updatePayload.size = insertPayload.size
|
||||
}
|
||||
if (input.contentType !== undefined) {
|
||||
updatePayload.contentType = insertPayload.contentType
|
||||
}
|
||||
if (input.etag !== undefined) {
|
||||
updatePayload.etag = insertPayload.etag
|
||||
}
|
||||
if (input.referenceType !== undefined) {
|
||||
updatePayload.referenceType = insertPayload.referenceType
|
||||
}
|
||||
if (input.referenceId !== undefined) {
|
||||
updatePayload.referenceId = insertPayload.referenceId
|
||||
}
|
||||
if (input.metadata !== undefined) {
|
||||
updatePayload.metadata = insertPayload.metadata
|
||||
}
|
||||
|
||||
const db = this.dbAccessor.get()
|
||||
await db
|
||||
.insert(managedStorageFileReferences)
|
||||
.values(insertPayload)
|
||||
.onConflictDoUpdate({
|
||||
target: [managedStorageFileReferences.tenantId, managedStorageFileReferences.storageKey],
|
||||
set: updatePayload,
|
||||
})
|
||||
}
|
||||
|
||||
private resolveTenantId(explicitTenantId?: string | null): string {
|
||||
if (explicitTenantId) {
|
||||
return explicitTenantId
|
||||
}
|
||||
const context = requireTenantContext()
|
||||
return context.tenant.id
|
||||
}
|
||||
|
||||
private normalizeStorageKey(value: string | undefined): string {
|
||||
const normalized = normalizeString(value)
|
||||
if (!normalized) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少有效的 storageKey' })
|
||||
}
|
||||
return normalized.replaceAll('\\', '/').replace(/^\/+/, '')
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isTenantSlugReserved } from '@afilmory/utils'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { normalizeString } from 'core/helpers/normalize.helper'
|
||||
import type { BillingPlanId } from 'core/modules/platform/billing/billing-plan.types'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
@@ -79,7 +80,7 @@ export class TenantService {
|
||||
async resolve(input: TenantResolutionInput, noThrow: boolean): Promise<TenantContext | null>
|
||||
async resolve(input: TenantResolutionInput): Promise<TenantContext>
|
||||
async resolve(input: TenantResolutionInput, noThrow = false): Promise<TenantContext | null> {
|
||||
const tenantId = this.normalizeString(input.tenantId)
|
||||
const tenantId = normalizeString(input.tenantId)
|
||||
const slug = this.normalizeSlug(input.slug)
|
||||
|
||||
let aggregate: TenantAggregate | null = null
|
||||
@@ -172,16 +173,8 @@ export class TenantService {
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeString(value?: string | null): string | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
private normalizeSlug(value?: string | null): string | null {
|
||||
const normalized = this.normalizeString(value)
|
||||
const normalized = normalizeString(value)
|
||||
return normalized ? normalized.toLowerCase() : null
|
||||
}
|
||||
}
|
||||
|
||||
17
be/apps/dashboard/src/modules/billing/creem-utils.ts
Normal file
17
be/apps/dashboard/src/modules/billing/creem-utils.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function buildCheckoutSuccessUrl(tenantSlug: string | null): string {
|
||||
const { origin, pathname, search, hash, protocol, hostname, port } = window.location
|
||||
const defaultUrl = `${origin}${pathname}${search}${hash}`
|
||||
const isLocalSubdomain = hostname !== 'localhost' && hostname.endsWith('.localhost')
|
||||
|
||||
if (!isLocalSubdomain) {
|
||||
return defaultUrl
|
||||
}
|
||||
|
||||
const redirectOrigin = `${protocol}//localhost${port ? `:${port}` : ''}`
|
||||
const redirectUrl = new URL('/creem-redirect.html', redirectOrigin)
|
||||
redirectUrl.searchParams.set('redirect', defaultUrl)
|
||||
if (tenantSlug) {
|
||||
redirectUrl.searchParams.set('tenant', tenantSlug)
|
||||
}
|
||||
return redirectUrl.toString()
|
||||
}
|
||||
@@ -14,8 +14,10 @@ export async function getManagedStorageOverview(): Promise<ManagedStorageOvervie
|
||||
}
|
||||
|
||||
export async function updateManagedStoragePlan(planId: string | null): Promise<ManagedStorageOverview> {
|
||||
return await coreApi<ManagedStorageOverview>(STORAGE_BILLING_ENDPOINT, {
|
||||
method: 'PATCH',
|
||||
body: { planId },
|
||||
})
|
||||
return camelCaseKeys<ManagedStorageOverview>(
|
||||
await coreApi(STORAGE_BILLING_ENDPOINT, {
|
||||
method: 'PATCH',
|
||||
body: { planId },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,9 +15,25 @@ const managedStorageI18nKeys = {
|
||||
action: 'photos.storage.managed.actions.subscribe',
|
||||
seePlans: 'photos.storage.managed.actions.switch',
|
||||
loading: 'photos.storage.managed.actions.loading',
|
||||
subscribed: 'photos.storage.managed.actions.subscribed',
|
||||
upgrade: 'photos.storage.managed.actions.upgrade',
|
||||
makeActive: 'photos.storage.managed.actions.make-active',
|
||||
makeInactive: 'photos.storage.managed.actions.make-inactive',
|
||||
} as const
|
||||
|
||||
export function ManagedStorageEntryCard() {
|
||||
type ManagedStorageEntryCardProps = {
|
||||
isActive?: boolean
|
||||
canToggle?: boolean
|
||||
onMakeActive?: () => void
|
||||
onMakeInactive?: () => void
|
||||
}
|
||||
|
||||
export function ManagedStorageEntryCard({
|
||||
isActive,
|
||||
canToggle,
|
||||
onMakeActive,
|
||||
onMakeInactive,
|
||||
}: ManagedStorageEntryCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const plansQuery = useManagedStoragePlansQuery()
|
||||
|
||||
@@ -25,6 +41,16 @@ export function ManagedStorageEntryCard() {
|
||||
Modal.present(ManagedStoragePlansModal, {}, { dismissOnOutsideClick: true })
|
||||
}
|
||||
|
||||
const currentPlan = plansQuery.data?.currentPlan ?? null
|
||||
|
||||
const capacityLabel = (() => {
|
||||
const val = currentPlan?.capacityBytes ?? null
|
||||
if (val === null) return t('photos.storage.managed.capacity.unlimited')
|
||||
if (val === undefined || Number.isNaN(val)) return t('photos.storage.managed.capacity.unknown')
|
||||
const gb = val / 1024 ** 3
|
||||
return `${t(managedStorageI18nKeys.subscribed)}: ${gb.toFixed(0)} GB`
|
||||
})()
|
||||
|
||||
return (
|
||||
<m.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }}>
|
||||
<div className="group relative flex h-full flex-col gap-3 overflow-hidden bg-background-tertiary p-5 text-left transition-all duration-200 hover:shadow-lg">
|
||||
@@ -41,7 +67,9 @@ export function ManagedStorageEntryCard() {
|
||||
|
||||
<div className="flex-1 space-y-1">
|
||||
<h3 className="text-text text-sm font-semibold">{t(managedStorageI18nKeys.title)}</h3>
|
||||
<p className="text-text-tertiary text-xs">{t(managedStorageI18nKeys.description)}</p>
|
||||
<p className="text-text-tertiary text-xs">
|
||||
{currentPlan ? capacityLabel : t(managedStorageI18nKeys.description)}
|
||||
</p>
|
||||
</div>
|
||||
{/*
|
||||
<div className="text-text-tertiary/80 text-xs">
|
||||
@@ -54,21 +82,75 @@ export function ManagedStorageEntryCard() {
|
||||
: t(managedStorageI18nKeys.unavailable)}
|
||||
</div> */}
|
||||
|
||||
<div className="flex justify-end -mb-3 -mt-2 -mr-3.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={
|
||||
plansQuery.isLoading ||
|
||||
plansQuery.isError ||
|
||||
!plansQuery.data ||
|
||||
(!plansQuery.data.managedStorageEnabled && plansQuery.data.availablePlans.length === 0)
|
||||
}
|
||||
onClick={openModal}
|
||||
>
|
||||
{t(managedStorageI18nKeys.action)}
|
||||
</Button>
|
||||
<div className="flex justify-end gap-2 -mb-3 -mt-2 -mr-3.5">
|
||||
{currentPlan ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={plansQuery.isLoading || plansQuery.isError}
|
||||
onClick={openModal}
|
||||
>
|
||||
{t(managedStorageI18nKeys.upgrade)}
|
||||
</Button>
|
||||
{canToggle && isActive && onMakeInactive ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={plansQuery.isLoading || plansQuery.isError}
|
||||
onClick={onMakeInactive}
|
||||
>
|
||||
{t(managedStorageI18nKeys.makeInactive)}
|
||||
</Button>
|
||||
) : null}
|
||||
{canToggle && !isActive && onMakeActive ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={plansQuery.isLoading || plansQuery.isError}
|
||||
onClick={onMakeActive}
|
||||
>
|
||||
{t(managedStorageI18nKeys.makeActive)}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={
|
||||
plansQuery.isLoading ||
|
||||
plansQuery.isError ||
|
||||
!plansQuery.data ||
|
||||
(!plansQuery.data.managedStorageEnabled && plansQuery.data.availablePlans.length === 0)
|
||||
}
|
||||
onClick={openModal}
|
||||
>
|
||||
{t(managedStorageI18nKeys.action)}
|
||||
</Button>
|
||||
{canToggle && !isActive && onMakeActive ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={
|
||||
plansQuery.isLoading ||
|
||||
plansQuery.isError ||
|
||||
!plansQuery.data ||
|
||||
(!plansQuery.data.managedStorageEnabled && plansQuery.data.availablePlans.length === 0)
|
||||
}
|
||||
onClick={onMakeActive}
|
||||
>
|
||||
{t(managedStorageI18nKeys.makeActive)}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</m.div>
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { ModalComponent } from '@afilmory/ui'
|
||||
import { Button, DialogDescription, DialogHeader, DialogTitle } from '@afilmory/ui'
|
||||
import { clsxm } from '@afilmory/utils'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { getI18n } from '~/i18n'
|
||||
import type { SessionResponse } from '~/modules/auth/api/session'
|
||||
import { AUTH_SESSION_QUERY_KEY } from '~/modules/auth/api/session'
|
||||
import { authClient } from '~/modules/auth/auth-client'
|
||||
import { buildCheckoutSuccessUrl } from '~/modules/billing/creem-utils'
|
||||
import type { ManagedStoragePlanSummary } from '~/modules/storage-plans'
|
||||
import { useManagedStoragePlansQuery, useUpdateManagedStoragePlanMutation } from '~/modules/storage-plans'
|
||||
import { useManagedStoragePlansQuery } from '~/modules/storage-plans'
|
||||
|
||||
const managedStorageI18nKeys = {
|
||||
title: 'photos.storage.managed.title',
|
||||
@@ -22,30 +29,82 @@ const managedStorageI18nKeys = {
|
||||
actionsCurrent: 'photos.storage.managed.actions.current',
|
||||
actionsCancel: 'photos.storage.managed.actions.cancel',
|
||||
actionsLoading: 'photos.storage.managed.actions.loading',
|
||||
actionsManage: 'photos.storage.managed.actions.manage',
|
||||
errorLoad: 'photos.storage.managed.error.load',
|
||||
toastSuccess: 'photos.storage.managed.toast.success',
|
||||
toastError: 'photos.storage.managed.toast.error',
|
||||
toastCheckoutFailure: 'photos.storage.managed.toast.checkout-failure',
|
||||
toastMissingCheckoutUrl: 'photos.storage.managed.toast.missing-checkout-url',
|
||||
toastPortalFailure: 'photos.storage.managed.toast.portal-failure',
|
||||
toastMissingPortalUrl: 'photos.storage.managed.toast.missing-portal-url',
|
||||
toastCheckoutUnavailable: 'photos.storage.managed.toast.checkout-unavailable',
|
||||
} as const
|
||||
|
||||
export function ManagedStoragePlansModal() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const locale = i18n.language ?? i18n.resolvedLanguage ?? 'en'
|
||||
export const ManagedStoragePlansModal: ModalComponent = () => {
|
||||
const { t } = useTranslation()
|
||||
const plansQuery = useManagedStoragePlansQuery()
|
||||
const updateMutation = useUpdateManagedStoragePlanMutation()
|
||||
const queryClient = useQueryClient()
|
||||
const session = (queryClient.getQueryData<SessionResponse | null>(AUTH_SESSION_QUERY_KEY) ??
|
||||
null) as SessionResponse | null
|
||||
const tenantId = session?.tenant?.id ?? null
|
||||
const tenantSlug = session?.tenant?.slug ?? null
|
||||
const creemCustomerId = session?.user?.creemCustomerId ?? null
|
||||
const [activeAction, setActiveAction] = useState<string | null>(null)
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 })
|
||||
const priceFormatter = new Intl.NumberFormat(locale, { minimumFractionDigits: 0, maximumFractionDigits: 2 })
|
||||
const handleCheckout = async (plan: ManagedStoragePlanSummary) => {
|
||||
const productId = plan.payment?.creemProductId ?? null
|
||||
if (!tenantId || !productId) {
|
||||
toast.error(t(managedStorageI18nKeys.toastCheckoutUnavailable))
|
||||
return
|
||||
}
|
||||
const successUrl = buildCheckoutSuccessUrl(tenantSlug)
|
||||
const metadata: Record<string, string> = { tenantId, storagePlanId: plan.id }
|
||||
if (tenantSlug) {
|
||||
metadata.tenantSlug = tenantSlug
|
||||
}
|
||||
|
||||
const handleSelect = async (planId: string | null) => {
|
||||
setActiveAction(plan.id)
|
||||
try {
|
||||
await updateMutation.mutateAsync(planId)
|
||||
toast.success(t(managedStorageI18nKeys.toastSuccess))
|
||||
const { data, error } = await authClient.creem.createCheckout({
|
||||
productId,
|
||||
successUrl,
|
||||
metadata,
|
||||
})
|
||||
if (error) {
|
||||
throw new Error(error.message ?? t(managedStorageI18nKeys.toastCheckoutFailure))
|
||||
}
|
||||
if (data?.url) {
|
||||
window.location.href = data.url
|
||||
return
|
||||
}
|
||||
toast.error(t(managedStorageI18nKeys.toastMissingCheckoutUrl))
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t(managedStorageI18nKeys.toastError, {
|
||||
reason: extractErrorMessage(error, t('common.unknown-error')),
|
||||
}),
|
||||
)
|
||||
toast.error(error instanceof Error ? error.message : t(managedStorageI18nKeys.toastCheckoutFailure))
|
||||
} finally {
|
||||
setActiveAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePortal = async () => {
|
||||
if (!creemCustomerId) {
|
||||
toast.error(t(managedStorageI18nKeys.toastCheckoutUnavailable))
|
||||
return
|
||||
}
|
||||
setActiveAction('portal')
|
||||
try {
|
||||
const { data, error } = await authClient.creem.createPortal({ customerId: creemCustomerId })
|
||||
if (error) {
|
||||
throw new Error(error.message ?? t(managedStorageI18nKeys.toastPortalFailure))
|
||||
}
|
||||
if (data?.url) {
|
||||
window.location.href = data.url
|
||||
return
|
||||
}
|
||||
toast.error(t(managedStorageI18nKeys.toastMissingPortalUrl))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t(managedStorageI18nKeys.toastPortalFailure))
|
||||
} finally {
|
||||
setActiveAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,80 +119,81 @@ export function ManagedStoragePlansModal() {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="mt-6 space-y-4">
|
||||
{plansQuery.isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<div key={index} className="bg-background-tertiary h-40 animate-pulse rounded-xl" />
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{Array.from({ length: 2 }, (_, index) => `skeleton-${index}`).map((key) => (
|
||||
<div key={key} className="bg-background-tertiary h-64 animate-pulse rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : plansQuery.isError ? (
|
||||
<div className="rounded-xl border border-red/30 bg-red/10 p-4 text-sm text-red">
|
||||
<div className="rounded-lg border border-red/30 bg-red/10 p-4 text-sm text-red">
|
||||
{t(managedStorageI18nKeys.errorLoad)}
|
||||
</div>
|
||||
) : !plansQuery.data?.managedStorageEnabled ? (
|
||||
<div className="rounded-xl border border-border/30 bg-background-secondary/30 p-4 text-sm text-text-secondary">
|
||||
<div className="rounded-lg border border-fill-tertiary bg-background-tertiary p-4 text-sm text-text-secondary">
|
||||
{t(managedStorageI18nKeys.unavailable)}
|
||||
</div>
|
||||
) : plansQuery.data.availablePlans.length === 0 ? (
|
||||
<div className="rounded-xl border border-border/30 bg-background-secondary/30 p-4 text-sm text-text-secondary">
|
||||
<div className="rounded-lg border border-fill-tertiary bg-background-tertiary p-4 text-sm text-text-secondary">
|
||||
{t(managedStorageI18nKeys.empty)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{plansQuery.data.availablePlans.map((plan) => (
|
||||
<PlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
isCurrent={plansQuery.data?.currentPlanId === plan.id}
|
||||
hasCurrentPlan={Boolean(plansQuery.data?.currentPlanId)}
|
||||
isProcessing={updateMutation.isPending}
|
||||
onSelect={() => handleSelect(plan.id)}
|
||||
formatCapacity={(bytes) => formatCapacity(bytes, numberFormatter)}
|
||||
formatPrice={(value, currency) => formatPrice(value, currency, priceFormatter)}
|
||||
isProcessing={activeAction !== null}
|
||||
onCheckout={() => handleCheckout(plan)}
|
||||
onPortal={handlePortal}
|
||||
canCheckout={Boolean(plan.payment?.creemProductId && tenantId)}
|
||||
canManage={Boolean(
|
||||
plansQuery.data?.currentPlanId === plan.id && plan.payment?.creemProductId && creemCustomerId,
|
||||
)}
|
||||
isActiveAction={activeAction === plan.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plansQuery.data?.currentPlanId ? (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={updateMutation.isPending}
|
||||
onClick={() => handleSelect(null)}
|
||||
>
|
||||
{updateMutation.isPending
|
||||
? t(managedStorageI18nKeys.actionsLoading)
|
||||
: t(managedStorageI18nKeys.actionsCancel)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
ManagedStoragePlansModal.contentClassName = 'w-[700px] max-w-[85vw]'
|
||||
function PlanCard({
|
||||
plan,
|
||||
isCurrent,
|
||||
hasCurrentPlan,
|
||||
isProcessing,
|
||||
onSelect,
|
||||
formatCapacity,
|
||||
formatPrice,
|
||||
onCheckout,
|
||||
onPortal,
|
||||
canCheckout,
|
||||
canManage,
|
||||
isActiveAction,
|
||||
}: {
|
||||
plan: ManagedStoragePlanSummary
|
||||
isCurrent: boolean
|
||||
hasCurrentPlan: boolean
|
||||
isProcessing: boolean
|
||||
onSelect: () => void
|
||||
formatCapacity: (bytes: number | null) => string
|
||||
formatPrice: (value: number, currency: string | null | undefined) => string
|
||||
onCheckout: () => void
|
||||
onPortal: () => void
|
||||
canCheckout: boolean
|
||||
canManage: boolean
|
||||
isActiveAction: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const locale = i18n.language ?? i18n.resolvedLanguage ?? 'en'
|
||||
|
||||
const { priceFormatter, numberFormatter } = useMemo(() => {
|
||||
return {
|
||||
priceFormatter: new Intl.NumberFormat(locale, { minimumFractionDigits: 0, maximumFractionDigits: 2 }),
|
||||
numberFormatter: new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }),
|
||||
}
|
||||
}, [locale])
|
||||
|
||||
const hasPrice =
|
||||
plan.pricing &&
|
||||
@@ -143,50 +203,70 @@ function PlanCard({
|
||||
|
||||
const priceLabel = hasPrice
|
||||
? t(managedStorageI18nKeys.priceLabel, {
|
||||
price: formatPrice(plan.pricing!.monthlyPrice as number, plan.pricing!.currency ?? null),
|
||||
price: formatPrice(plan.pricing!.monthlyPrice as number, plan.pricing!.currency ?? null, priceFormatter),
|
||||
})
|
||||
: t(managedStorageI18nKeys.priceFree)
|
||||
|
||||
const capacityLabel = formatCapacity(plan.capacityBytes)
|
||||
const capacityLabel = formatCapacity(plan.capacityBytes, numberFormatter)
|
||||
const actionLabel = isCurrent
|
||||
? t(managedStorageI18nKeys.actionsCurrent)
|
||||
: hasCurrentPlan
|
||||
? t(managedStorageI18nKeys.actionsSwitch)
|
||||
: t(managedStorageI18nKeys.actionsSubscribe)
|
||||
|
||||
const isPaidPlan = Boolean(plan.payment?.creemProductId)
|
||||
const showManage = isCurrent && canManage
|
||||
const primaryAction = showManage ? onPortal : onCheckout
|
||||
const primaryLabel = showManage ? t(managedStorageI18nKeys.actionsManage) : actionLabel
|
||||
const shouldDisable = isProcessing || (isPaidPlan && !canCheckout) || (isCurrent && !showManage && isPaidPlan)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsxm(
|
||||
'border-border/40 bg-background-secondary/40 flex h-full flex-col rounded-2xl border p-5',
|
||||
isCurrent && 'border-accent/50 shadow-[0_0_0_1px_rgba(var(--color-accent-rgb),0.3)]',
|
||||
'border-fill-tertiary bg-background-tertiary flex h-full flex-col rounded-lg border transition-all duration-200',
|
||||
isCurrent && 'border-accent/60 bg-background',
|
||||
!isCurrent && 'hover:border-fill-secondary',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-text text-base font-semibold">{plan.name}</h3>
|
||||
{plan.description ? <p className="text-text-tertiary mt-1 text-sm leading-snug">{plan.description}</p> : null}
|
||||
{/* Header Section */}
|
||||
<div className="border-fill-tertiary flex items-start justify-between gap-3 border-b p-5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-text text-lg font-semibold leading-tight">{plan.name}</h3>
|
||||
{plan.description ? (
|
||||
<p className="text-text-tertiary mt-2 text-sm leading-relaxed whitespace-pre-line">{plan.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{isCurrent ? (
|
||||
<span className="text-accent border-accent/40 bg-accent/10 rounded-full px-2 py-0.5 text-xs font-semibold">
|
||||
<span className="text-accent border-accent/40 bg-accent/10 shrink-0 rounded-full px-3 py-1 text-xs font-semibold whitespace-nowrap">
|
||||
{t(managedStorageI18nKeys.actionsCurrent)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-1 text-sm">
|
||||
<p className="text-text font-medium">{capacityLabel}</p>
|
||||
<p className="text-text-secondary">{priceLabel}</p>
|
||||
{/* Features Section */}
|
||||
<div className="flex-1 p-5 space-y-4">
|
||||
<div>
|
||||
<p className="text-text-secondary mb-1.5 text-xs font-medium uppercase tracking-wider">Capacity</p>
|
||||
<p className="text-text text-base font-semibold">{capacityLabel}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text-secondary mb-1.5 text-xs font-medium uppercase tracking-wider">Price</p>
|
||||
<p className="text-text text-base font-semibold">{priceLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-6 w-full"
|
||||
variant={isCurrent ? 'secondary' : 'primary'}
|
||||
disabled={isCurrent || isProcessing}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{isProcessing ? t(managedStorageI18nKeys.actionsLoading) : actionLabel}
|
||||
</Button>
|
||||
{/* Action Button */}
|
||||
<div className="border-fill-tertiary border-t p-5">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
variant={isCurrent ? 'secondary' : 'primary'}
|
||||
disabled={shouldDisable}
|
||||
onClick={primaryAction}
|
||||
>
|
||||
{isProcessing && isActiveAction ? t(managedStorageI18nKeys.actionsLoading) : primaryLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -211,11 +291,3 @@ function formatPrice(value: number, currency: string | null | undefined, formatt
|
||||
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
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ import { useSetPhotoSyncAutoRun } from '~/atoms/photo-sync'
|
||||
import { LinearBorderPanel } from '~/components/common/LinearBorderPanel'
|
||||
import { MainPageLayout, useMainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
import { useBlock } from '~/hooks/useBlock'
|
||||
import { useManagedStoragePlansQuery } from '~/modules/storage-plans'
|
||||
|
||||
import { storageProvidersI18nKeys } from '../constants'
|
||||
import { MANAGED_STORAGE_ACTIVE_ID, storageProvidersI18nKeys } from '../constants'
|
||||
import { useStorageProviderSchemaQuery, useStorageProvidersQuery, useUpdateStorageProvidersMutation } from '../hooks'
|
||||
import type { StorageProvider } from '../types'
|
||||
import { createEmptyProvider, reorderProvidersByActive } from '../utils'
|
||||
import { createEmptyProvider } from '../utils'
|
||||
import { ManagedStorageEntryCard } from './ManagedStorageEntryCard'
|
||||
import { ProviderCard } from './ProviderCard'
|
||||
import { ProviderEditModal } from './ProviderEditModal'
|
||||
|
||||
@@ -33,6 +35,7 @@ export function StorageProvidersManager() {
|
||||
error: schemaError,
|
||||
} = useStorageProviderSchemaQuery()
|
||||
const updateMutation = useUpdateStorageProvidersMutation()
|
||||
const managedPlansQuery = useManagedStoragePlansQuery()
|
||||
const { setHeaderActionState } = useMainPageLayout()
|
||||
const navigate = useNavigate()
|
||||
const setPhotoSyncAutoRun = useSetPhotoSyncAutoRun()
|
||||
@@ -90,7 +93,9 @@ export function StorageProvidersManager() {
|
||||
return new Map(providerForm.types.map((type) => [type.value, type.label]))
|
||||
}, [providerForm])
|
||||
|
||||
const orderedProviders = reorderProvidersByActive(providers, activeProviderId)
|
||||
const managedPlanAvailable = Boolean(managedPlansQuery.data?.currentPlan)
|
||||
const managedActive = activeProviderId === MANAGED_STORAGE_ACTIVE_ID
|
||||
const effectiveActiveId = managedActive ? null : activeProviderId
|
||||
|
||||
const markDirty = () => setIsDirty(true)
|
||||
|
||||
@@ -139,14 +144,14 @@ export function StorageProvidersManager() {
|
||||
markDirty()
|
||||
}
|
||||
|
||||
const handleSetActive = (providerId: string) => {
|
||||
const handleSetActive = (providerId: string | null) => {
|
||||
setActiveProviderId(providerId)
|
||||
markDirty()
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
const resolvedActiveId =
|
||||
activeProviderId && providers.some((provider) => provider.id === activeProviderId) ? activeProviderId : null
|
||||
activeProviderId === MANAGED_STORAGE_ACTIVE_ID ? MANAGED_STORAGE_ACTIVE_ID : activeProviderId
|
||||
|
||||
updateMutation.mutate(
|
||||
{
|
||||
@@ -178,7 +183,12 @@ export function StorageProvidersManager() {
|
||||
}
|
||||
|
||||
const disableSave =
|
||||
isLoading || isError || !schemaReady || !isDirty || updateMutation.isPending || providers.length === 0
|
||||
isLoading ||
|
||||
isError ||
|
||||
!schemaReady ||
|
||||
!isDirty ||
|
||||
updateMutation.isPending ||
|
||||
(providers.length === 0 && activeProviderId !== MANAGED_STORAGE_ACTIVE_ID)
|
||||
useEffect(() => {
|
||||
setHeaderActionState((prev) => {
|
||||
const nextState = {
|
||||
@@ -255,9 +265,14 @@ export function StorageProvidersManager() {
|
||||
transition={Spring.presets.smooth}
|
||||
className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
>
|
||||
{/* <ManagedStorageEntryCard /> */}
|
||||
<ManagedStorageEntryCard
|
||||
isActive={managedActive}
|
||||
canToggle={managedPlanAvailable}
|
||||
onMakeActive={() => handleSetActive(MANAGED_STORAGE_ACTIVE_ID)}
|
||||
onMakeInactive={() => handleSetActive(null)}
|
||||
/>
|
||||
|
||||
{orderedProviders.map((provider, index) => (
|
||||
{providers.map((provider, index) => (
|
||||
<m.div
|
||||
key={provider.id}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
@@ -266,11 +281,15 @@ export function StorageProvidersManager() {
|
||||
>
|
||||
<ProviderCard
|
||||
provider={provider}
|
||||
isActive={provider.id === activeProviderId}
|
||||
isActive={provider.id === effectiveActiveId}
|
||||
onEdit={() => handleEditProvider(provider)}
|
||||
onToggleActive={() => {
|
||||
setActiveProviderId((prev) => (prev === provider.id ? null : provider.id))
|
||||
markDirty()
|
||||
if (provider.id === effectiveActiveId) {
|
||||
setActiveProviderId(null)
|
||||
markDirty()
|
||||
return
|
||||
}
|
||||
handleSetActive(provider.id)
|
||||
}}
|
||||
typeLabel={providerTypeLabels.get(provider.type)}
|
||||
/>
|
||||
@@ -297,20 +316,6 @@ export function StorageProvidersManager() {
|
||||
)}
|
||||
</m.div>
|
||||
|
||||
{/* Status Message */}
|
||||
{hasProviders && (
|
||||
<m.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ ...Spring.presets.smooth, delay: 0.2 }}
|
||||
className="mt-4 text-center"
|
||||
>
|
||||
<p className="text-text-tertiary text-xs">
|
||||
<span>{getStatusMessage()}</span>
|
||||
</p>
|
||||
</m.div>
|
||||
)}
|
||||
|
||||
{/* Security Notice */}
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
@@ -343,23 +348,4 @@ export function StorageProvidersManager() {
|
||||
</m.div>
|
||||
</>
|
||||
)
|
||||
|
||||
function getStatusMessage() {
|
||||
if (updateMutation.isError && updateMutation.error) {
|
||||
const reason = updateMutation.error instanceof Error ? updateMutation.error.message : t('common.unknown-error')
|
||||
return t(storageProvidersI18nKeys.status.error, { reason })
|
||||
}
|
||||
if (updateMutation.isSuccess && !isDirty) {
|
||||
return t(storageProvidersI18nKeys.status.saved)
|
||||
}
|
||||
if (isDirty) {
|
||||
return t(storageProvidersI18nKeys.status.dirty, { total: providers.length })
|
||||
}
|
||||
const activeName =
|
||||
orderedProviders.find((p) => p.id === activeProviderId)?.name || t(storageProvidersI18nKeys.card.untitled)
|
||||
return t(storageProvidersI18nKeys.status.summary, {
|
||||
total: providers.length,
|
||||
active: activeName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ export const STORAGE_SETTING_KEYS = {
|
||||
activeProvider: string
|
||||
}
|
||||
|
||||
export const MANAGED_STORAGE_ACTIVE_ID = 'managed'
|
||||
|
||||
export const storageProvidersI18nKeys = {
|
||||
blocker: {
|
||||
title: 'storage.providers.blocker.title',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getI18n } from '~/i18n'
|
||||
|
||||
import { storageProvidersI18nKeys } from './constants'
|
||||
import { MANAGED_STORAGE_ACTIVE_ID, storageProvidersI18nKeys } from './constants'
|
||||
import type { StorageProvider, StorageProviderType } from './types'
|
||||
|
||||
function generateId() {
|
||||
@@ -103,6 +103,10 @@ export function ensureActiveProviderId(providers: readonly StorageProvider[], ac
|
||||
return null
|
||||
}
|
||||
|
||||
if (activeId === MANAGED_STORAGE_ACTIVE_ID) {
|
||||
return activeId
|
||||
}
|
||||
|
||||
return providers.some((provider) => provider.id === activeId) ? activeId : null
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { storageProvidersI18nKeys } from '~/modules/storage-providers/constants'
|
||||
import {
|
||||
createEmptyProvider,
|
||||
normalizeStorageProviderConfig,
|
||||
reorderProvidersByActive,
|
||||
} from '~/modules/storage-providers/utils'
|
||||
|
||||
import { useSuperAdminSettingsQuery, useUpdateSuperAdminSettingsMutation } from '../hooks'
|
||||
@@ -89,7 +88,6 @@ export function ManagedStorageSettings() {
|
||||
}
|
||||
}, [providers, managedId])
|
||||
|
||||
const orderedProviders = useMemo(() => reorderProvidersByActive(providers, managedId), [providers, managedId])
|
||||
const providersChanged = useMemo(
|
||||
() => JSON.stringify(baselineProviders) !== JSON.stringify(providers),
|
||||
[baselineProviders, providers],
|
||||
@@ -183,11 +181,11 @@ export function ManagedStorageSettings() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{orderedProviders.length === 0 ? (
|
||||
{providers.length === 0 ? (
|
||||
<p className="text-text-secondary text-sm">{t('superadmin.settings.managed-storage.empty')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{orderedProviders.map((provider) => (
|
||||
{providers.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className={[
|
||||
|
||||
@@ -38,6 +38,9 @@ export type SuperAdminSettingsResponse =
|
||||
export type UpdateSuperAdminSettingsPayload = Partial<{
|
||||
managedStorageProvider: string | null
|
||||
managedStorageProviders: StorageProvider[]
|
||||
storagePlanCatalog: Record<string, unknown>
|
||||
storagePlanPricing: Record<string, unknown>
|
||||
storagePlanProducts: Record<string, unknown>
|
||||
}>
|
||||
|
||||
export type BuilderDebugProgressEvent =
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AUTH_SESSION_QUERY_KEY } from '~/modules/auth/api/session'
|
||||
import { authClient } from '~/modules/auth/auth-client'
|
||||
import type { BillingPlanSummary } from '~/modules/billing'
|
||||
import { useTenantPlanQuery } from '~/modules/billing'
|
||||
import { buildCheckoutSuccessUrl } from '~/modules/billing/creem-utils'
|
||||
|
||||
const planI18nKeys = {
|
||||
pageTitle: 'plan.page.title',
|
||||
@@ -305,24 +306,6 @@ function PlanCard({
|
||||
)
|
||||
}
|
||||
|
||||
function buildCheckoutSuccessUrl(tenantSlug: string | null): string {
|
||||
const { origin, pathname, search, hash, protocol, hostname, port } = window.location
|
||||
const defaultUrl = `${origin}${pathname}${search}${hash}`
|
||||
const isLocalSubdomain = hostname !== 'localhost' && hostname.endsWith('.localhost')
|
||||
|
||||
if (!isLocalSubdomain) {
|
||||
return defaultUrl
|
||||
}
|
||||
|
||||
const redirectOrigin = `${protocol}//localhost${port ? `:${port}` : ''}`
|
||||
const redirectUrl = new URL('/creem-redirect.html', redirectOrigin)
|
||||
redirectUrl.searchParams.set('redirect', defaultUrl)
|
||||
if (tenantSlug) {
|
||||
redirectUrl.searchParams.set('tenant', tenantSlug)
|
||||
}
|
||||
return redirectUrl.toString()
|
||||
}
|
||||
|
||||
function CurrentBadge({ planId }: { planId: string }) {
|
||||
const { t } = useTranslation()
|
||||
const labelKey = planId === 'friend' ? planI18nKeys.badgeInternal : planI18nKeys.badgeCurrent
|
||||
|
||||
@@ -6,12 +6,12 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
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'
|
||||
import type { UpdateSuperAdminSettingsPayload } from '~/modules/super-admin/types'
|
||||
|
||||
const APP_PLAN_SECTION_IDS = ['billing-plan-settings'] as const
|
||||
const STORAGE_PLAN_SECTION_IDS = ['storage-plan-settings'] as const
|
||||
@@ -101,7 +101,6 @@ function StoragePlanEditor() {
|
||||
const catalog = (rawValues?.storagePlanCatalog as Record<string, StoragePlanCatalogEntry> | undefined) ?? {}
|
||||
const pricing = (rawValues?.storagePlanPricing as Record<string, StoragePlanPricingEntry> | undefined) ?? {}
|
||||
const products = (rawValues?.storagePlanProducts as Record<string, StoragePlanProductEntry> | undefined) ?? {}
|
||||
const provider = (rawValues?.managedStorageProvider as string | null | undefined) ?? ''
|
||||
|
||||
return {
|
||||
rows: Object.entries(catalog).map(([id, entry]) => ({
|
||||
@@ -114,16 +113,13 @@ function StoragePlanEditor() {
|
||||
currency: pricing[id]?.currency ?? '',
|
||||
creemProductId: products[id]?.creemProductId ?? '',
|
||||
})),
|
||||
provider,
|
||||
}
|
||||
}, [rawValues])
|
||||
|
||||
const [rows, setRows] = useState<StoragePlanRow[]>(parsed.rows)
|
||||
const [providerKey, setProviderKey] = useState<string>(parsed.provider ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
setRows(parsed.rows)
|
||||
setProviderKey(parsed.provider ?? '')
|
||||
}, [parsed])
|
||||
|
||||
const errors = useMemo(() => {
|
||||
@@ -172,20 +168,21 @@ function StoragePlanEditor() {
|
||||
})
|
||||
|
||||
return {
|
||||
managedStorageProvider: providerKey.trim() || null,
|
||||
}
|
||||
}, [providerKey, rows])
|
||||
storagePlanCatalog: catalog as Record<string, unknown>,
|
||||
storagePlanPricing: pricing as Record<string, unknown>,
|
||||
storagePlanProducts: products as Record<string, unknown>,
|
||||
} as UpdateSuperAdminSettingsPayload
|
||||
}, [rows])
|
||||
|
||||
const baselinePayload = useMemo(() => {
|
||||
const catalog = (rawValues?.storagePlanCatalog as Record<string, StoragePlanCatalogEntry> | undefined) ?? {}
|
||||
const pricing = (rawValues?.storagePlanPricing as Record<string, StoragePlanPricingEntry> | undefined) ?? {}
|
||||
const products = (rawValues?.storagePlanProducts as Record<string, StoragePlanProductEntry> | undefined) ?? {}
|
||||
const provider = (rawValues?.managedStorageProvider as string | null | undefined) ?? null
|
||||
|
||||
return {
|
||||
storagePlanCatalog: catalog,
|
||||
storagePlanPricing: pricing,
|
||||
storagePlanProducts: products,
|
||||
managedStorageProvider: provider,
|
||||
}
|
||||
}, [rawValues])
|
||||
|
||||
|
||||
41
be/packages/db/migrations/0007_managed_storage.sql
Normal file
41
be/packages/db/migrations/0007_managed_storage.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
CREATE TABLE "managed_storage_usage" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"tenant_id" text NOT NULL,
|
||||
"provider_key" text NOT NULL,
|
||||
"total_bytes" bigint DEFAULT 0 NOT NULL,
|
||||
"file_count" integer DEFAULT 0 NOT NULL,
|
||||
"period_start" timestamp,
|
||||
"period_end" timestamp,
|
||||
"recorded_at" timestamp DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "managed_storage_usage" ADD CONSTRAINT "managed_storage_usage_tenant_id_tenant_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "idx_managed_storage_usage_tenant_recorded" ON "managed_storage_usage" USING btree ("tenant_id","recorded_at");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "idx_managed_storage_usage_provider" ON "managed_storage_usage" USING btree ("provider_key");
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "managed_storage_file_reference" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"tenant_id" text NOT NULL,
|
||||
"provider_key" text NOT NULL,
|
||||
"storage_key" text NOT NULL,
|
||||
"storage_provider" text,
|
||||
"size" bigint,
|
||||
"content_type" text,
|
||||
"etag" text,
|
||||
"reference_type" text,
|
||||
"reference_id" text,
|
||||
"metadata" jsonb DEFAULT null,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "uq_managed_storage_file_ref_tenant_key" UNIQUE("tenant_id","storage_key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "managed_storage_file_reference" ADD CONSTRAINT "managed_storage_file_reference_tenant_id_tenant_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "idx_managed_storage_file_ref_provider" ON "managed_storage_file_reference" USING btree ("provider_key");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "idx_managed_storage_file_ref_reference" ON "managed_storage_file_reference" USING btree ("reference_type","reference_id");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "managed_storage_usage" ADD COLUMN "operation" text;
|
||||
1865
be/packages/db/migrations/meta/0007_snapshot.json
Normal file
1865
be/packages/db/migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1871
be/packages/db/migrations/meta/0008_snapshot.json
Normal file
1871
be/packages/db/migrations/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,20 @@
|
||||
"when": 1763656041992,
|
||||
"tag": "0006_quick_titania",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1763656042992,
|
||||
"tag": "0007_managed_storage",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1763656043992,
|
||||
"tag": "0008_managed_storage_usage_operation",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface PhotoSyncRunSummary {
|
||||
errors: number
|
||||
}
|
||||
|
||||
export type ManagedStorageMetadata = Record<string, unknown>
|
||||
|
||||
export const tenants = pgTable(
|
||||
'tenant',
|
||||
{
|
||||
@@ -273,6 +275,55 @@ export const reactions = pgTable(
|
||||
(t) => [index('idx_reactions_tenant_ref_key').on(t.tenantId, t.refKey)],
|
||||
)
|
||||
|
||||
export const managedStorageUsages = pgTable(
|
||||
'managed_storage_usage',
|
||||
{
|
||||
id: snowflakeId,
|
||||
tenantId: text('tenant_id')
|
||||
.notNull()
|
||||
.references(() => tenants.id, { onDelete: 'cascade' }),
|
||||
providerKey: text('provider_key').notNull(),
|
||||
operation: text('operation'),
|
||||
totalBytes: bigint('total_bytes', { mode: 'number' }).notNull().default(0),
|
||||
fileCount: integer('file_count').notNull().default(0),
|
||||
periodStart: timestamp('period_start', { mode: 'string' }),
|
||||
periodEnd: timestamp('period_end', { mode: 'string' }),
|
||||
recordedAt: timestamp('recorded_at', { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index('idx_managed_storage_usage_tenant_recorded').on(t.tenantId, t.recordedAt),
|
||||
index('idx_managed_storage_usage_provider').on(t.providerKey),
|
||||
],
|
||||
)
|
||||
|
||||
export const managedStorageFileReferences = pgTable(
|
||||
'managed_storage_file_reference',
|
||||
{
|
||||
id: snowflakeId,
|
||||
tenantId: text('tenant_id')
|
||||
.notNull()
|
||||
.references(() => tenants.id, { onDelete: 'cascade' }),
|
||||
providerKey: text('provider_key').notNull(),
|
||||
storageKey: text('storage_key').notNull(),
|
||||
storageProvider: text('storage_provider'),
|
||||
size: bigint('size', { mode: 'number' }),
|
||||
contentType: text('content_type'),
|
||||
etag: text('etag'),
|
||||
referenceType: text('reference_type'),
|
||||
referenceId: text('reference_id'),
|
||||
metadata: jsonb('metadata').$type<ManagedStorageMetadata | null>().default(null),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => [
|
||||
unique('uq_managed_storage_file_ref_tenant_key').on(t.tenantId, t.storageKey),
|
||||
index('idx_managed_storage_file_ref_provider').on(t.providerKey),
|
||||
index('idx_managed_storage_file_ref_reference').on(t.referenceType, t.referenceId),
|
||||
],
|
||||
)
|
||||
|
||||
export const photoAssets = pgTable(
|
||||
'photo_asset',
|
||||
{
|
||||
@@ -357,6 +408,8 @@ export const dbSchema = {
|
||||
settings,
|
||||
systemSettings,
|
||||
reactions,
|
||||
managedStorageUsages,
|
||||
managedStorageFileReferences,
|
||||
photoAssets,
|
||||
photoSyncRuns,
|
||||
billingUsageEvents,
|
||||
|
||||
Reference in New Issue
Block a user