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:
Innei
2025-11-22 01:21:26 +08:00
parent f0678038c2
commit 9095bb08c8
36 changed files with 5273 additions and 340 deletions

View 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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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 }
}

View File

@@ -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],
})

View File

@@ -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 = {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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 {}

View File

@@ -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',
})
}

View File

@@ -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(/^\/+/, '')
}
}

View File

@@ -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
}
}

View 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()
}

View File

@@ -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 },
}),
)
}

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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,
})
}
}

View File

@@ -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',

View File

@@ -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
}

View File

@@ -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={[

View File

@@ -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 =

View File

@@ -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

View File

@@ -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])

View 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");

View File

@@ -0,0 +1 @@
ALTER TABLE "managed_storage_usage" ADD COLUMN "operation" text;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}

View File

@@ -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,