Add website API docs, SSL api-hosts, and git-only deploy workflow.

Serve public storefront docs at /docs/website, expose api.{domain} hosts for API SSL sync, and require push-then-pull deploys instead of rsync.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-07-22 21:39:51 +03:30
co-authored by Cursor
parent bb59d5e9ba
commit 016cc15bf0
32 changed files with 6159 additions and 37 deletions
+19 -1
View File
@@ -1,4 +1,15 @@
import { Body, Controller, Delete, Get, Param, Patch, Query, UseGuards } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@@ -18,6 +29,13 @@ export class DomainAdminController {
return this.service.list(query, user);
}
@Post(':domainId/deploy')
@HttpCode(202)
@UseGuards(JwtAuthGuard)
deploy(@Param('domainId') domainId: string, @CurrentUser() user: AuthUser) {
return this.service.deploy(domainId, user);
}
@Patch(':domainId')
@UseGuards(JwtAuthGuard)
update(
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthModule } from '../auth/auth.module';
import { DomainAdminController } from './domain-admin.controller';
import { DomainAdminService } from './domain-admin.service';
@Module({
imports: [AuthModule],
imports: [AuthModule, ConfigModule],
controllers: [DomainAdminController],
providers: [DomainAdminService],
})
+89 -2
View File
@@ -4,7 +4,9 @@ import {
ForbiddenException,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
@@ -14,6 +16,11 @@ import { ListDomainsDto } from './dto/list-domains.dto';
import { ToggleSslDto } from './dto/toggle-ssl.dto';
import { UpdateDomainAdminDto } from './dto/update-domain-admin.dto';
/** Apex hosts that have a storefront deploy on the websites VM. */
const WEBSITE_DEPLOY_SLUGS: Record<string, string> = {
'ali-mohammadi.ir': 'ali-mohammadi',
};
type DomainRow = {
id: bigint;
host: string;
@@ -23,6 +30,8 @@ type DomainRow = {
isActive: boolean;
expiresAt: Date | null;
createdAt: Date;
lastDeployedAt: Date | null;
lastDeployStatus: string | null;
};
@Injectable()
@@ -30,6 +39,7 @@ export class DomainAdminService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly config: ConfigService,
) {}
private async assertSuperAdmin(actor: AuthUser) {
@@ -38,6 +48,10 @@ export class DomainAdminService {
}
}
private deploySlugForHost(host: string): string | null {
return WEBSITE_DEPLOY_SLUGS[host.trim().toLowerCase()] ?? null;
}
async list(query: ListDomainsDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
@@ -51,7 +65,7 @@ export class DomainAdminService {
${nameLike ? Prisma.sql`AND d.host ILIKE ${nameLike}` : Prisma.empty}
`;
const [items, totalRow] = await Promise.all([
const [rows, totalRow] = await Promise.all([
this.prisma.$queryRaw<DomainRow[]>(Prisma.sql`
SELECT
d.id AS "id",
@@ -61,7 +75,9 @@ export class DomainAdminService {
d.ssl_enabled AS "sslEnabled",
d.is_active AS "isActive",
d.expires_at AS "expiresAt",
d.created_at AS "createdAt"
d.created_at AS "createdAt",
d.last_deployed_at AS "lastDeployedAt",
d.last_deploy_status AS "lastDeployStatus"
FROM domains d
JOIN businesses b ON b.id = d.business_id
${where}
@@ -75,9 +91,80 @@ export class DomainAdminService {
`),
]);
const items = rows.map((row) => ({
...row,
deploySlug: this.deploySlugForHost(row.host),
}));
return { items, total: totalRow[0]?.total ?? 0, page, pageSize };
}
async deploy(domainIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const domainId = BigInt(domainIdRaw);
const domain = await this.prisma.domain.findUnique({ where: { id: domainId } });
if (!domain) {
throw new NotFoundException('Domain not found');
}
const slug = this.deploySlugForHost(domain.host);
if (!slug) {
throw new BadRequestException('This domain has no storefront deploy configured');
}
const agentUrl = this.config.get<string>('WEBSITE_DEPLOY_AGENT_URL')?.trim();
const token = this.config.get<string>('WEBSITE_DEPLOY_TOKEN')?.trim();
if (!agentUrl || !token) {
throw new ServiceUnavailableException('Website deploy agent is not configured');
}
const markDeploy = async (status: 'started' | 'failed') => {
const updated = await this.prisma.domain.update({
where: { id: domainId },
data: {
lastDeployedAt: new Date(),
lastDeployStatus: status,
},
});
return updated;
};
let response: Response;
try {
response = await fetch(agentUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Deploy-Token': token,
},
body: JSON.stringify({ slug }),
});
} catch {
await markDeploy('failed');
throw new ServiceUnavailableException('Could not reach website deploy agent');
}
if (!response.ok) {
await markDeploy('failed');
const text = await response.text().catch(() => '');
throw new ServiceUnavailableException(
`Deploy agent rejected request (${response.status})${text ? `: ${text}` : ''}`,
);
}
const updated = await markDeploy('started');
return {
status: 'accepted' as const,
slug,
host: domain.host,
message: 'Deploy started on websites server',
lastDeployedAt: updated.lastDeployedAt?.toISOString() ?? null,
lastDeployStatus: updated.lastDeployStatus,
};
}
async update(domainIdRaw: string, dto: UpdateDomainAdminDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);