diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 06060ae..93749fd 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -12,6 +12,7 @@ import { BuildModule } from './build/build.module'; import { TicketsModule } from './tickets/tickets.module'; import { BillingModule } from './billing/billing.module'; import { SnapshotsModule } from './snapshots/snapshots.module'; +import { LifecycleModule } from './lifecycle/lifecycle.module'; import configuration from './config/configuration'; @Module({ @@ -62,6 +63,7 @@ import configuration from './config/configuration'; TicketsModule, BillingModule, SnapshotsModule, + LifecycleModule, ], }) export class AppModule {} diff --git a/backend/src/applications/entities/application.entity.ts b/backend/src/applications/entities/application.entity.ts index a699f17..5202695 100644 --- a/backend/src/applications/entities/application.entity.ts +++ b/backend/src/applications/entities/application.entity.ts @@ -8,7 +8,7 @@ import { OneToMany, JoinColumn, } from 'typeorm'; -import { AppRuntime, DatabaseType } from '../../common/enums'; +import { AppRuntime, DatabaseType, BillingCycle, AppLifecycleStatus } from '../../common/enums'; import { User } from '../../users/entities/user.entity'; import { Deployment } from '../../deployments/entities/deployment.entity'; @@ -59,6 +59,9 @@ export class Application { @Column({ nullable: true }) codePath: string; // Path to uploaded zip + @Column({ nullable: true }) + dbDumpPath: string; // Path to uploaded SQL dump file + @Column({ type: 'jsonb', nullable: true }) envVars: Record; @@ -105,6 +108,25 @@ export class Application { @Column({ nullable: true }) subdomain: string; // .apps.cloudhost.local + // ── Billing & Lifecycle ───────────────────────────── + @Column({ nullable: true }) + planId: string; // FK to ServicePlan + + @Column({ type: 'enum', enum: BillingCycle, nullable: true }) + billingCycle: BillingCycle; // The cycle this app was purchased under + + @Column({ type: 'enum', enum: AppLifecycleStatus, default: AppLifecycleStatus.ACTIVE }) + lifecycleStatus: AppLifecycleStatus; + + @Column({ type: 'timestamptz', nullable: true }) + planExpiresAt: Date; // When the current billing period ends + + @Column({ type: 'timestamptz', nullable: true }) + suspendedAt: Date; // When the app was suspended (pods scaled to 0) + + @Column({ type: 'timestamptz', nullable: true }) + scheduledDeletionAt: Date; // When the app will be permanently deleted + @CreateDateColumn() createdAt: Date; diff --git a/backend/src/billing/billing.controller.ts b/backend/src/billing/billing.controller.ts index b713308..273ed35 100644 --- a/backend/src/billing/billing.controller.ts +++ b/backend/src/billing/billing.controller.ts @@ -9,10 +9,14 @@ import { Query, UseGuards, Request, + BadRequestException, + Inject, + forwardRef, } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { BillingService } from './billing.service'; +import { AppLifecycleService } from '../lifecycle/app-lifecycle.service'; import { CreateServicePlanDto, UpdateServicePlanDto, @@ -21,14 +25,18 @@ import { } from './dto/billing.dto'; import { RolesGuard } from '../common/guards/roles.guard'; import { Roles } from '../common/decorators/roles.decorator'; -import { UserRole } from '../common/enums'; +import { UserRole, BillingCycle, AppLifecycleStatus } from '../common/enums'; @ApiTags('Billing') @ApiBearerAuth() @Controller('billing') @UseGuards(AuthGuard('jwt'), RolesGuard) export class BillingController { - constructor(private readonly billingService: BillingService) {} + constructor( + private readonly billingService: BillingService, + @Inject(forwardRef(() => AppLifecycleService)) + private readonly lifecycleService: AppLifecycleService, + ) {} // ─── Service Plans (Admin) ──────────────────────────────────────── @@ -98,18 +106,49 @@ export class BillingController { } @Post('wallet/pay/:applicationId') - @ApiOperation({ summary: 'Pay for an application from wallet' }) + @ApiOperation({ summary: 'Pay for an application plan from wallet — activates/renews the app' }) async payForApplication( @Request() req: any, @Param('applicationId') applicationId: string, - @Body() body: { amount: number; cycle: string }, + @Body() body: { planId: string; cycle: string }, ) { - return this.billingService.deductWallet( + const cycle = body.cycle as BillingCycle; + if (!Object.values(BillingCycle).includes(cycle)) { + throw new BadRequestException(`Invalid billing cycle: ${body.cycle}`); + } + + // Calculate cost + const plan = await this.billingService.findPlan(body.planId); + const costs = await this.billingService.calculateCost({ + runtime: plan.runtime, + databaseType: 'none', // Will be refined per-app later + cpuLimit: '500m', + memoryLimit: '512Mi', + replicas: 1, + }); + + const amount = cycle === BillingCycle.HOURLY ? costs.hourly + : cycle === BillingCycle.MONTHLY ? costs.monthly + : costs.yearly; + + // Deduct from wallet + const tx = await this.billingService.deductWallet( req.user.id, - body.amount, - `Payment for application (${body.cycle})`, + amount, + `Payment for app ${applicationId} (${cycle}) — plan: ${plan.name}`, applicationId, ); + + // Activate/reactivate the application + const app = await this.lifecycleService.activateApp(applicationId, cycle, body.planId); + + return { + transaction: tx, + application: { id: app.id, name: app.name, lifecycleStatus: app.lifecycleStatus, planExpiresAt: app.planExpiresAt }, + message: app.lifecycleStatus === AppLifecycleStatus.ACTIVE + ? `Application "${app.name}" activated until ${app.planExpiresAt?.toISOString()}` + : `Payment processed — application status: ${app.lifecycleStatus}`, + }; } // ─── Payment Gateway ───────────────────────────────────────────── diff --git a/backend/src/billing/billing.module.ts b/backend/src/billing/billing.module.ts index fb5ef0d..cad024d 100644 --- a/backend/src/billing/billing.module.ts +++ b/backend/src/billing/billing.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BillingService } from './billing.service'; import { BillingController } from './billing.controller'; @@ -6,10 +6,13 @@ import { ServicePlan } from './entities/service-plan.entity'; import { PricingRule } from './entities/pricing-rule.entity'; import { Wallet } from './entities/wallet.entity'; import { WalletTransaction } from './entities/wallet-transaction.entity'; +import { PlatformSetting } from './entities/platform-setting.entity'; +import { LifecycleModule } from '../lifecycle/lifecycle.module'; @Module({ imports: [ - TypeOrmModule.forFeature([ServicePlan, PricingRule, Wallet, WalletTransaction]), + TypeOrmModule.forFeature([ServicePlan, PricingRule, Wallet, WalletTransaction, PlatformSetting]), + forwardRef(() => LifecycleModule), ], controllers: [BillingController], providers: [BillingService], diff --git a/backend/src/billing/billing.service.ts b/backend/src/billing/billing.service.ts index dcaf5e8..03632e7 100644 --- a/backend/src/billing/billing.service.ts +++ b/backend/src/billing/billing.service.ts @@ -288,4 +288,27 @@ export class BillingService { async getAllWallets(): Promise { return this.walletRepo.find({ relations: ['user'], order: { balance: 'DESC' } }); } + + /** + * Calculate cost for an existing Application entity. + * Used by lifecycle service for auto-renew. + */ + async calculateCostForApp(app: { + runtime: string; + databaseType: string; + cpuLimit: string; + memoryLimit: string; + replicas: number; + dbStorageSize?: string; + }): Promise<{ hourly: number; monthly: number; yearly: number }> { + const result = await this.calculateCost({ + runtime: app.runtime, + databaseType: app.databaseType, + cpuLimit: app.cpuLimit, + memoryLimit: app.memoryLimit, + replicas: app.replicas, + dbStorageSize: app.dbStorageSize, + }); + return { hourly: result.hourly, monthly: result.monthly, yearly: result.yearly }; + } } diff --git a/backend/src/billing/entities/platform-setting.entity.ts b/backend/src/billing/entities/platform-setting.entity.ts new file mode 100644 index 0000000..d39c52c --- /dev/null +++ b/backend/src/billing/entities/platform-setting.entity.ts @@ -0,0 +1,32 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +/** + * Key-value settings table editable by super-admin at runtime. + * Used for lifecycle retention periods, feature flags, etc. + */ +@Entity('platform_settings') +export class PlatformSetting { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + key: string; + + @Column({ type: 'text' }) + value: string; + + @Column({ nullable: true }) + description: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/common/enums.ts b/backend/src/common/enums.ts index 913f937..e59b29e 100644 --- a/backend/src/common/enums.ts +++ b/backend/src/common/enums.ts @@ -80,3 +80,12 @@ export enum TransactionType { DEDUCTION = 'deduction', // Payment for service REFUND = 'refund', // Refund } + +// ── Application Lifecycle ───────────────────────────── + +export enum AppLifecycleStatus { + ACTIVE = 'active', // Paid & running + SUSPENDED = 'suspended', // Plan expired, pods scaled to 0, data retained + PENDING_DELETION = 'pending_deletion', // Grace period — will be deleted if no payment + DELETED = 'deleted', // Fully removed from K8s and DB +} diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index e5362c3..d40f8da 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -23,7 +23,7 @@ export default () => ({ }, registry: { - url: process.env.REGISTRY_URL || 'registry.example.com', + url: process.env.REGISTRY_URL || 'registry.cloudhost-builds.svc.cluster.local:5000', pullUrl: process.env.REGISTRY_PULL_URL || process.env.REGISTRY_URL || 'localhost:30500', username: process.env.REGISTRY_USERNAME || 'admin', password: process.env.REGISTRY_PASSWORD || '', @@ -38,4 +38,23 @@ export default () => ({ domain: process.env.PLATFORM_DOMAIN || 'apps.cloudhost.local', uploadDir: process.env.UPLOAD_DIR || './uploads', }, + + // Lifecycle defaults (can be overridden via PlatformSettings entity by admin) + lifecycle: { + // How long after plan expires to suspend the app (scale to 0) + hourly: { + suspendAfterMs: parseInt(process.env.LIFECYCLE_HOURLY_SUSPEND_MS || String(0), 10), // immediately on expiry + deleteAfterMs: parseInt(process.env.LIFECYCLE_HOURLY_DELETE_MS || String(24 * 3600 * 1000), 10), // 24h after suspend + }, + monthly: { + suspendAfterMs: parseInt(process.env.LIFECYCLE_MONTHLY_SUSPEND_MS || String(0), 10), + deleteAfterMs: parseInt(process.env.LIFECYCLE_MONTHLY_DELETE_MS || String(3 * 24 * 3600 * 1000), 10), // 3 days + }, + yearly: { + suspendAfterMs: parseInt(process.env.LIFECYCLE_YEARLY_SUSPEND_MS || String(0), 10), + deleteAfterMs: parseInt(process.env.LIFECYCLE_YEARLY_DELETE_MS || String(7 * 24 * 3600 * 1000), 10), // 7 days + }, + // Cron interval for the lifecycle scanner + scanIntervalMs: parseInt(process.env.LIFECYCLE_SCAN_INTERVAL_MS || String(60_000), 10), // 1 min + }, }); diff --git a/backend/src/lifecycle/app-lifecycle.service.ts b/backend/src/lifecycle/app-lifecycle.service.ts new file mode 100644 index 0000000..1bd2dc5 --- /dev/null +++ b/backend/src/lifecycle/app-lifecycle.service.ts @@ -0,0 +1,320 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThanOrEqual, In } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { Application } from '../applications/entities/application.entity'; +import { PlatformSetting } from '../billing/entities/platform-setting.entity'; +import { KubernetesService } from '../kubernetes/kubernetes.service'; +import { BillingService } from '../billing/billing.service'; +import { AppLifecycleStatus, BillingCycle, DeploymentStatus } from '../common/enums'; + +/** + * Manages the full lifecycle of applications based on their billing plan: + * + * ACTIVE ──(plan expires)──► SUSPENDED ──(grace period)──► PENDING_DELETION ──► DELETED + * ▲ │ │ + * └──────── payment ─────────────┘ │ + * └──────── payment (if still within grace) ──────────────────────┘ + * + * Runs a periodic scan (configurable interval, default 60s) that: + * 1. Finds ACTIVE apps whose planExpiresAt has passed → suspends them + * 2. Finds SUSPENDED apps past their grace period → marks PENDING_DELETION + * 3. Finds PENDING_DELETION apps past scheduledDeletionAt → fully deletes + */ +@Injectable() +export class AppLifecycleService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(AppLifecycleService.name); + private scanTimer: NodeJS.Timeout | null = null; + + constructor( + @InjectRepository(Application) + private appRepo: Repository, + @InjectRepository(PlatformSetting) + private settingsRepo: Repository, + private configService: ConfigService, + private kubernetesService: KubernetesService, + private billingService: BillingService, + ) {} + + onModuleInit() { + const interval = this.configService.get('lifecycle.scanIntervalMs') || 60_000; + this.logger.log(`Lifecycle scanner starting — interval: ${interval / 1000}s`); + this.scanTimer = setInterval(() => this.runScan(), interval); + // Run once immediately after a short delay to catch up on startup + setTimeout(() => this.runScan(), 5_000); + } + + onModuleDestroy() { + if (this.scanTimer) clearInterval(this.scanTimer); + } + + // ─── Public API (called by billing on payment) ──────────────────── + + /** + * Activate or reactivate an application after successful payment. + * Calculates the new expiry based on billing cycle and scales pods back up. + */ + async activateApp(appId: string, billingCycle: BillingCycle, planId: string): Promise { + const app = await this.appRepo.findOne({ where: { id: appId } }); + if (!app) throw new Error(`Application ${appId} not found`); + + const now = new Date(); + const expiresAt = this.calculateExpiry(now, billingCycle); + + app.planId = planId; + app.billingCycle = billingCycle; + app.lifecycleStatus = AppLifecycleStatus.ACTIVE; + app.planExpiresAt = expiresAt; + app.suspendedAt = null as any; + app.scheduledDeletionAt = null as any; + + const saved = await this.appRepo.save(app); + + // If the app was suspended, scale it back up + if (app.latestImageTag) { + try { + await this.kubernetesService.scaleDeployment(app, app.replicas || 1); + this.logger.log(`Reactivated ${app.name} — scaled to ${app.replicas} replicas, expires ${expiresAt.toISOString()}`); + } catch (e: any) { + this.logger.warn(`Failed to scale up ${app.name} on reactivation: ${e.message}`); + } + } + + return saved; + } + + /** + * Renew an app for another billing period (used for auto-renew or manual renewal). + */ + async renewApp(appId: string): Promise { + const app = await this.appRepo.findOne({ where: { id: appId } }); + if (!app || !app.billingCycle) throw new Error('Application not found or has no billing cycle'); + + const baseDate = app.planExpiresAt && app.planExpiresAt > new Date() + ? app.planExpiresAt // extend from current expiry + : new Date(); // expired — extend from now + + app.planExpiresAt = this.calculateExpiry(baseDate, app.billingCycle); + app.lifecycleStatus = AppLifecycleStatus.ACTIVE; + app.suspendedAt = null as any; + app.scheduledDeletionAt = null as any; + + return this.appRepo.save(app); + } + + // ─── Periodic Scanner ───────────────────────────────────────────── + + private async runScan() { + try { + const now = new Date(); + await this.suspendExpiredApps(now); + await this.markForDeletion(now); + await this.deleteExpiredApps(now); + } catch (e: any) { + this.logger.error(`Lifecycle scan error: ${e.message}`, e.stack); + } + } + + /** + * Step 1: Find ACTIVE apps whose planExpiresAt has passed → SUSPEND + */ + private async suspendExpiredApps(now: Date) { + const expiredApps = await this.appRepo.find({ + where: { + lifecycleStatus: AppLifecycleStatus.ACTIVE, + planExpiresAt: LessThanOrEqual(now), + }, + }); + + for (const app of expiredApps) { + if (!app.billingCycle) continue; // No billing plan — skip (free/unmanaged app) + + try { + // For hourly plans: try auto-deduct from wallet for renewal + if (app.billingCycle === BillingCycle.HOURLY) { + const renewed = await this.tryAutoRenew(app); + if (renewed) continue; + } + + // Suspend: scale to 0 + try { + await this.kubernetesService.scaleDeployment(app, 0); + } catch (e: any) { + this.logger.warn(`Failed to scale down ${app.name}: ${e.message}`); + } + + app.lifecycleStatus = AppLifecycleStatus.SUSPENDED; + app.suspendedAt = now; + + // Calculate when deletion should happen + const deleteAfterMs = await this.getDeleteGracePeriod(app.billingCycle); + app.scheduledDeletionAt = new Date(now.getTime() + deleteAfterMs); + + await this.appRepo.save(app); + this.logger.warn( + `SUSPENDED ${app.name} (${app.billingCycle} plan expired). ` + + `Scheduled deletion: ${app.scheduledDeletionAt.toISOString()}`, + ); + } catch (e: any) { + this.logger.error(`Failed to suspend ${app.name}: ${e.message}`); + } + } + } + + /** + * Step 2: Find SUSPENDED apps past their scheduledDeletionAt → PENDING_DELETION + */ + private async markForDeletion(now: Date) { + const apps = await this.appRepo.find({ + where: { + lifecycleStatus: AppLifecycleStatus.SUSPENDED, + scheduledDeletionAt: LessThanOrEqual(now), + }, + }); + + for (const app of apps) { + app.lifecycleStatus = AppLifecycleStatus.PENDING_DELETION; + await this.appRepo.save(app); + this.logger.warn(`PENDING_DELETION: ${app.name} — grace period ended, will be deleted`); + } + } + + /** + * Step 3: Find PENDING_DELETION apps → fully delete from K8s and DB + */ + private async deleteExpiredApps(now: Date) { + const apps = await this.appRepo.find({ + where: { + lifecycleStatus: AppLifecycleStatus.PENDING_DELETION, + }, + }); + + for (const app of apps) { + try { + // Delete all K8s resources + await this.kubernetesService.deleteApplication(app); + this.logger.log(`Deleted K8s resources for expired app ${app.name}`); + } catch (e: any) { + this.logger.warn(`K8s cleanup failed for expired ${app.name}: ${e.message}`); + } + + try { + // Mark as deleted (soft-delete approach — keep record for audit) + app.lifecycleStatus = AppLifecycleStatus.DELETED; + await this.appRepo.save(app); + this.logger.warn(`DELETED: ${app.name} — all resources removed`); + } catch (e: any) { + this.logger.error(`Failed to mark ${app.name} as deleted: ${e.message}`); + } + } + } + + // ─── Auto-renew for hourly plans ────────────────────────────────── + + private async tryAutoRenew(app: Application): Promise { + if (!app.planId) return false; + + try { + const cost = await this.billingService.calculateCostForApp(app); + const hourlyAmount = cost.hourly; + if (hourlyAmount <= 0) return false; + + // Check wallet balance + const { balance } = await this.billingService.getBalance(app.userId); + if (balance < hourlyAmount) return false; + + // Deduct and renew + await this.billingService.deductWallet( + app.userId, + hourlyAmount, + `Auto-renew hourly: ${app.name}`, + app.id, + ); + + app.planExpiresAt = this.calculateExpiry(new Date(), BillingCycle.HOURLY); + app.lifecycleStatus = AppLifecycleStatus.ACTIVE; + await this.appRepo.save(app); + + this.logger.log(`Auto-renewed hourly plan for ${app.name} — deducted ${hourlyAmount} Toman`); + return true; + } catch (e: any) { + this.logger.warn(`Auto-renew failed for ${app.name}: ${e.message}`); + return false; + } + } + + // ─── Helpers ────────────────────────────────────────────────────── + + private calculateExpiry(from: Date, cycle: BillingCycle): Date { + const expiry = new Date(from); + switch (cycle) { + case BillingCycle.HOURLY: + expiry.setHours(expiry.getHours() + 1); + break; + case BillingCycle.MONTHLY: + expiry.setMonth(expiry.getMonth() + 1); + break; + case BillingCycle.YEARLY: + expiry.setFullYear(expiry.getFullYear() + 1); + break; + } + return expiry; + } + + /** + * Get the grace period (ms) before a suspended app is deleted. + * First checks PlatformSettings (admin-editable), then falls back to config file defaults. + */ + private async getDeleteGracePeriod(cycle: BillingCycle): Promise { + const settingKey = `lifecycle.${cycle}.deleteAfterMs`; + + // Check admin-editable settings first + try { + const setting = await this.settingsRepo.findOne({ where: { key: settingKey } }); + if (setting) return parseInt(setting.value, 10); + } catch {} + + // Fall back to config file + return this.configService.get(`lifecycle.${cycle}.deleteAfterMs`) || 24 * 3600 * 1000; + } + + // ─── Admin API ──────────────────────────────────────────────────── + + async getLifecycleSettings(): Promise> { + const settings = await this.settingsRepo.find({ + where: { key: In([ + 'lifecycle.hourly.deleteAfterMs', + 'lifecycle.monthly.deleteAfterMs', + 'lifecycle.yearly.deleteAfterMs', + ]) }, + }); + + const defaults = { + 'lifecycle.hourly.deleteAfterMs': this.configService.get('lifecycle.hourly.deleteAfterMs'), + 'lifecycle.monthly.deleteAfterMs': this.configService.get('lifecycle.monthly.deleteAfterMs'), + 'lifecycle.yearly.deleteAfterMs': this.configService.get('lifecycle.yearly.deleteAfterMs'), + }; + + const result: Record = { ...defaults }; + for (const s of settings) { + result[s.key] = parseInt(s.value, 10); + } + + return { + hourly: { deleteAfterMs: result['lifecycle.hourly.deleteAfterMs'], deleteAfterHours: result['lifecycle.hourly.deleteAfterMs'] / 3600000 }, + monthly: { deleteAfterMs: result['lifecycle.monthly.deleteAfterMs'], deleteAfterDays: result['lifecycle.monthly.deleteAfterMs'] / 86400000 }, + yearly: { deleteAfterMs: result['lifecycle.yearly.deleteAfterMs'], deleteAfterDays: result['lifecycle.yearly.deleteAfterMs'] / 86400000 }, + }; + } + + async updateLifecycleSetting(key: string, value: number, description?: string): Promise { + let setting = await this.settingsRepo.findOne({ where: { key } }); + if (setting) { + setting.value = String(value); + if (description) setting.description = description; + } else { + setting = this.settingsRepo.create({ key, value: String(value), description }); + } + return this.settingsRepo.save(setting); + } +} diff --git a/backend/src/lifecycle/lifecycle.controller.ts b/backend/src/lifecycle/lifecycle.controller.ts new file mode 100644 index 0000000..2d4e7d2 --- /dev/null +++ b/backend/src/lifecycle/lifecycle.controller.ts @@ -0,0 +1,62 @@ +import { + Controller, + Get, + Patch, + Body, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { UserRole } from '../common/enums'; +import { AppLifecycleService } from './app-lifecycle.service'; + +@ApiTags('Lifecycle') +@ApiBearerAuth() +@Controller('lifecycle') +@UseGuards(AuthGuard('jwt'), RolesGuard) +export class LifecycleController { + constructor(private readonly lifecycleService: AppLifecycleService) {} + + @Get('settings') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Get lifecycle retention settings (Admin)' }) + async getSettings() { + return this.lifecycleService.getLifecycleSettings(); + } + + @Patch('settings') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Update lifecycle retention settings (Admin)' }) + async updateSettings( + @Body() body: { + hourlyDeleteAfterMs?: number; + monthlyDeleteAfterMs?: number; + yearlyDeleteAfterMs?: number; + }, + ) { + const results: Record = {}; + + if (body.hourlyDeleteAfterMs !== undefined) { + results.hourly = await this.lifecycleService.updateLifecycleSetting( + 'lifecycle.hourly.deleteAfterMs', body.hourlyDeleteAfterMs, + 'Grace period before deleting hourly apps (ms)', + ); + } + if (body.monthlyDeleteAfterMs !== undefined) { + results.monthly = await this.lifecycleService.updateLifecycleSetting( + 'lifecycle.monthly.deleteAfterMs', body.monthlyDeleteAfterMs, + 'Grace period before deleting monthly apps (ms)', + ); + } + if (body.yearlyDeleteAfterMs !== undefined) { + results.yearly = await this.lifecycleService.updateLifecycleSetting( + 'lifecycle.yearly.deleteAfterMs', body.yearlyDeleteAfterMs, + 'Grace period before deleting yearly apps (ms)', + ); + } + + return { updated: results, current: await this.lifecycleService.getLifecycleSettings() }; + } +} diff --git a/backend/src/lifecycle/lifecycle.module.ts b/backend/src/lifecycle/lifecycle.module.ts new file mode 100644 index 0000000..bb6d026 --- /dev/null +++ b/backend/src/lifecycle/lifecycle.module.ts @@ -0,0 +1,20 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Application } from '../applications/entities/application.entity'; +import { PlatformSetting } from '../billing/entities/platform-setting.entity'; +import { AppLifecycleService } from './app-lifecycle.service'; +import { LifecycleController } from './lifecycle.controller'; +import { KubernetesModule } from '../kubernetes/kubernetes.module'; +import { BillingModule } from '../billing/billing.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Application, PlatformSetting]), + KubernetesModule, + forwardRef(() => BillingModule), + ], + controllers: [LifecycleController], + providers: [AppLifecycleService], + exports: [AppLifecycleService], +}) +export class LifecycleModule {}