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: {
action: 'activate',
cycle,
planExpiresAt: payment.planExpiresAt?.toISOString(),
},
});
}
@@ -269,7 +268,6 @@ export class BillingController {
const activated = await this.lifecycleService.activateApp(
applicationId,
cycle,
payment.planExpiresAt,
);
return {
+61 -7
View File
@@ -6,7 +6,7 @@ import { Deployment } from './entities/deployment.entity';
import { ApplicationsService } from '../applications/applications.service';
import { KubernetesService } from '../kubernetes/kubernetes.service';
import { BuildService, BuildProgress, BuildCancelledError } from '../build/build.service';
import { DeploymentStatus } from '../common/enums';
import { AppLifecycleStatus, DeploymentStatus } from '../common/enums';
@Injectable()
export class DeploymentsService {
@@ -100,7 +100,15 @@ export class DeploymentsService {
percent: 96,
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
this.buildService.setProgress(deploymentId, {
@@ -114,7 +122,7 @@ export class DeploymentsService {
finishedAt: new Date(),
});
} 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`);
await this.deploymentsRepository.update(deploymentId, {
status: DeploymentStatus.CANCELLED,
@@ -142,9 +150,35 @@ export class DeploymentsService {
}
async updateStatus(id: string, status: DeploymentStatus): Promise<void> {
if (await this.isDeploymentCancelled(id)) {
return;
}
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[]> {
return this.deploymentsRepository.find({
where: { applicationId },
@@ -242,13 +276,31 @@ export class DeploymentsService {
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.errorMessage = 'Cancelled by user';
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> {
@@ -300,6 +352,8 @@ export class DeploymentsService {
throw new NotFoundException('No source code available. Upload code or set a git URL first.');
}
this.ensureRedeployAllowed(app);
// Create new deployment record
const deployment = this.deploymentsRepository.create({
applicationId: app.id,
@@ -894,6 +894,8 @@ export default function AppDetailPage() {
const isStopped = latestStatus === 'stopped';
const isRunning = latestStatus === 'running';
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 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</>}
</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">
{redeployMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><RefreshCw className="w-3 h-3 inline" /> Redeploy</>}
</button>