Require paid access before redeploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-18 22:37:13 +03:30
parent e4fe8f63cb
commit 97e4c865b6
3 changed files with 64 additions and 10 deletions
@@ -254,7 +254,6 @@ export class BillingController {
metadata: { metadata: {
action: 'activate', action: 'activate',
cycle, cycle,
planExpiresAt: payment.planExpiresAt?.toISOString(),
}, },
}); });
} }
@@ -269,7 +268,6 @@ export class BillingController {
const activated = await this.lifecycleService.activateApp( const activated = await this.lifecycleService.activateApp(
applicationId, applicationId,
cycle, cycle,
payment.planExpiresAt,
); );
return { return {
+61 -7
View File
@@ -6,7 +6,7 @@ import { Deployment } from './entities/deployment.entity';
import { ApplicationsService } from '../applications/applications.service'; import { ApplicationsService } from '../applications/applications.service';
import { KubernetesService } from '../kubernetes/kubernetes.service'; import { KubernetesService } from '../kubernetes/kubernetes.service';
import { BuildService, BuildProgress, BuildCancelledError } from '../build/build.service'; import { BuildService, BuildProgress, BuildCancelledError } from '../build/build.service';
import { DeploymentStatus } from '../common/enums'; import { AppLifecycleStatus, DeploymentStatus } from '../common/enums';
@Injectable() @Injectable()
export class DeploymentsService { export class DeploymentsService {
@@ -100,7 +100,15 @@ export class DeploymentsService {
percent: 96, percent: 96,
message: 'Waiting for all application pods to become ready...', message: 'Waiting for all application pods to become ready...',
}); });
await this.kubernetesService.waitForApplicationReady(app); await this.kubernetesService.waitForApplicationReady(
app,
600_000,
() => this.isDeploymentCancelled(deploymentId),
);
if (await this.isDeploymentCancelled(deploymentId)) {
return;
}
// Step 5: Mark success // Step 5: Mark success
this.buildService.setProgress(deploymentId, { this.buildService.setProgress(deploymentId, {
@@ -114,7 +122,7 @@ export class DeploymentsService {
finishedAt: new Date(), finishedAt: new Date(),
}); });
} catch (error: any) { } catch (error: any) {
if (error instanceof BuildCancelledError || error?.name === 'BuildCancelledError') { if (this.isCancellationError(error) || await this.isDeploymentCancelled(deploymentId)) {
this.logger.log(`Deployment ${deploymentId} cancelled by user`); this.logger.log(`Deployment ${deploymentId} cancelled by user`);
await this.deploymentsRepository.update(deploymentId, { await this.deploymentsRepository.update(deploymentId, {
status: DeploymentStatus.CANCELLED, status: DeploymentStatus.CANCELLED,
@@ -142,9 +150,35 @@ export class DeploymentsService {
} }
async updateStatus(id: string, status: DeploymentStatus): Promise<void> { async updateStatus(id: string, status: DeploymentStatus): Promise<void> {
if (await this.isDeploymentCancelled(id)) {
return;
}
await this.deploymentsRepository.update(id, { status }); await this.deploymentsRepository.update(id, { status });
} }
private async isDeploymentCancelled(id: string): Promise<boolean> {
const deployment = await this.deploymentsRepository.findOne({ where: { id } });
return deployment?.status === DeploymentStatus.CANCELLED || deployment?.errorMessage === 'Cancelled by user';
}
private isCancellationError(error: any): boolean {
return error instanceof BuildCancelledError ||
error?.name === 'BuildCancelledError' ||
String(error?.message || '').toLowerCase().includes('cancelled');
}
private ensureRedeployAllowed(app: any): void {
if (!app.billingCycle) return;
const isActive = app.lifecycleStatus === AppLifecycleStatus.ACTIVE;
const expiresAt = app.planExpiresAt ? new Date(app.planExpiresAt) : null;
const hasPaidTimeRemaining = !!expiresAt && expiresAt > new Date();
if (!isActive || !hasPaidTimeRemaining) {
throw new BadRequestException('Payment must be completed successfully before redeploying this application.');
}
}
async findByApplication(applicationId: string): Promise<Deployment[]> { async findByApplication(applicationId: string): Promise<Deployment[]> {
return this.deploymentsRepository.find({ return this.deploymentsRepository.find({
where: { applicationId }, where: { applicationId },
@@ -242,13 +276,31 @@ export class DeploymentsService {
throw new BadRequestException('No deployment in progress to cancel'); throw new BadRequestException('No deployment in progress to cancel');
} }
await this.buildService.cancelBuild(latest.id);
await this.buildService.cleanupBuildResourcesForApp(app);
latest.status = DeploymentStatus.CANCELLED; latest.status = DeploymentStatus.CANCELLED;
latest.errorMessage = 'Cancelled by user'; latest.errorMessage = 'Cancelled by user';
latest.finishedAt = new Date(); latest.finishedAt = new Date();
return this.deploymentsRepository.save(latest); await this.deploymentsRepository.save(latest);
this.buildService.setProgress(latest.id, { phase: 'cancelled', percent: 0, message: 'Cancelled by user' });
try {
await this.buildService.cancelBuild(latest.id);
} catch (error: any) {
this.logger.warn(`Failed to cancel build resources for ${latest.id}: ${error.message}`);
}
try {
await this.buildService.cleanupBuildResourcesForApp(app);
} catch (error: any) {
this.logger.warn(`Failed to cleanup build resources for ${app.name}: ${error.message}`);
}
try {
await this.kubernetesService.deleteApplication(app);
} catch (error: any) {
this.logger.warn(`Failed to cleanup deployed resources for cancelled app ${app.name}: ${error.message}`);
}
return this.deploymentsRepository.findOneOrFail({ where: { id: latest.id } });
} }
async stopDeployment(applicationId: string, userId: string): Promise<Deployment | null> { async stopDeployment(applicationId: string, userId: string): Promise<Deployment | null> {
@@ -300,6 +352,8 @@ export class DeploymentsService {
throw new NotFoundException('No source code available. Upload code or set a git URL first.'); throw new NotFoundException('No source code available. Upload code or set a git URL first.');
} }
this.ensureRedeployAllowed(app);
// Create new deployment record // Create new deployment record
const deployment = this.deploymentsRepository.create({ const deployment = this.deploymentsRepository.create({
applicationId: app.id, applicationId: app.id,
@@ -894,6 +894,8 @@ export default function AppDetailPage() {
const isStopped = latestStatus === 'stopped'; const isStopped = latestStatus === 'stopped';
const isRunning = latestStatus === 'running'; const isRunning = latestStatus === 'running';
const isInProgress = latestStatus === 'building' || latestStatus === 'deploying' || latestStatus === 'pending'; const isInProgress = latestStatus === 'building' || latestStatus === 'deploying' || latestStatus === 'pending';
const hasPaidAccess = !app.billingCycle ||
(app.lifecycleStatus === 'active' && (!app.planExpiresAt || new Date(app.planExpiresAt) > new Date()));
const handleDelete = async () => { const handleDelete = async () => {
const ok = await confirm({ const ok = await confirm({
@@ -956,7 +958,7 @@ export default function AppDetailPage() {
{restartMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><RotateCw className="w-3 h-3 inline" /> Restart</>} {restartMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><RotateCw className="w-3 h-3 inline" /> Restart</>}
</button> </button>
)} )}
{!isInProgress && ( {!isInProgress && hasPaidAccess && (
<button onClick={() => redeployMutation.mutate()} disabled={redeployMutation.isPending} className="btn-primary text-sm disabled:opacity-50" title="Rebuild from latest source code"> <button onClick={() => redeployMutation.mutate()} disabled={redeployMutation.isPending} className="btn-primary text-sm disabled:opacity-50" title="Rebuild from latest source code">
{redeployMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><RefreshCw className="w-3 h-3 inline" /> Redeploy</>} {redeployMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><RefreshCw className="w-3 h-3 inline" /> Redeploy</>}
</button> </button>