Add public AI prompt templates for websites.

Manage reusable templates in Super Admin, attach editable per-site prompts with Meshkee API public links that require no auth.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-26 15:28:41 +03:30
co-authored by Cursor
parent 4db9c7709d
commit 91a20a89d6
12 changed files with 698 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
import {
Body,
Controller,
Delete,
Get,
Header,
HttpCode,
Param,
Patch,
Post,
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';
import { AiPromptsService } from './ai-prompts.service';
import { CreateAiPromptTemplateDto } from './dto/create-ai-prompt-template.dto';
import { CreateDomainAiPromptDto } from './dto/create-domain-ai-prompt.dto';
import { UpdateAiPromptTemplateDto } from './dto/update-ai-prompt-template.dto';
import { UpdateDomainAiPromptDto } from './dto/update-domain-ai-prompt.dto';
@Controller()
export class AiPromptsController {
constructor(private readonly service: AiPromptsService) {}
@Get('ai-prompt-templates')
@UseGuards(JwtAuthGuard)
list(@CurrentUser() user: AuthUser) {
return this.service.list(user);
}
@Post('ai-prompt-templates')
@UseGuards(JwtAuthGuard)
create(
@Body() dto: CreateAiPromptTemplateDto,
@CurrentUser() user: AuthUser,
) {
return this.service.create(dto, user);
}
@Patch('ai-prompt-templates/:templateId')
@UseGuards(JwtAuthGuard)
update(
@Param('templateId') templateId: string,
@Body() dto: UpdateAiPromptTemplateDto,
@CurrentUser() user: AuthUser,
) {
return this.service.update(templateId, dto, user);
}
@Delete('ai-prompt-templates/:templateId')
@HttpCode(200)
@UseGuards(JwtAuthGuard)
remove(
@Param('templateId') templateId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.remove(templateId, user);
}
@Get('domains/:domainId/ai-prompts')
@UseGuards(JwtAuthGuard)
listDomainPrompts(
@Param('domainId') domainId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.listDomainPrompts(domainId, user);
}
@Post('domains/:domainId/ai-prompts')
@UseGuards(JwtAuthGuard)
createDomainPrompt(
@Param('domainId') domainId: string,
@Body() dto: CreateDomainAiPromptDto,
@CurrentUser() user: AuthUser,
) {
return this.service.createDomainPrompt(domainId, dto, user);
}
@Patch('domains/:domainId/ai-prompts/:promptId')
@UseGuards(JwtAuthGuard)
updateDomainPrompt(
@Param('domainId') domainId: string,
@Param('promptId') promptId: string,
@Body() dto: UpdateDomainAiPromptDto,
@CurrentUser() user: AuthUser,
) {
return this.service.updateDomainPrompt(domainId, promptId, dto, user);
}
@Delete('domains/:domainId/ai-prompts/:promptId')
@HttpCode(200)
@UseGuards(JwtAuthGuard)
removeDomainPrompt(
@Param('domainId') domainId: string,
@Param('promptId') promptId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.removeDomainPrompt(domainId, promptId, user);
}
@Get('public/ai-prompts/:publicId')
getPublic(@Param('publicId') publicId: string) {
return this.service.getPublic(publicId);
}
@Get('public/ai-prompts/:publicId/raw')
@Header('Content-Type', 'text/plain; charset=utf-8')
getPublicRaw(@Param('publicId') publicId: string) {
return this.service.getPublicText(publicId);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { AiPromptsController } from './ai-prompts.controller';
import { AiPromptsService } from './ai-prompts.service';
@Module({
imports: [AuthModule],
controllers: [AiPromptsController],
providers: [AiPromptsService],
})
export class AiPromptsModule {}
+315
View File
@@ -0,0 +1,315 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { randomBytes } from 'crypto';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import { CreateAiPromptTemplateDto } from './dto/create-ai-prompt-template.dto';
import { CreateDomainAiPromptDto } from './dto/create-domain-ai-prompt.dto';
import { UpdateAiPromptTemplateDto } from './dto/update-ai-prompt-template.dto';
import { UpdateDomainAiPromptDto } from './dto/update-domain-ai-prompt.dto';
@Injectable()
export class AiPromptsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly config: ConfigService,
) {}
private async assertSuperAdmin(actor: AuthUser) {
if (!(await this.permissions.isSuperAdmin(actor.id))) {
throw new ForbiddenException('Super admin access required');
}
}
private async generateUniquePromptPublicId(): Promise<string> {
for (let attempt = 0; attempt < 12; attempt += 1) {
const candidate = randomBytes(12).toString('hex');
const existing = await this.prisma.domainAiPrompt.findUnique({
where: { publicId: candidate },
select: { id: true },
});
if (!existing) return candidate;
}
throw new BadRequestException('Unable to allocate a public prompt id');
}
private publicUrl(publicId: string): string {
const base =
this.config.get<string>('API_PUBLIC_BASE_URL')?.replace(/\/$/, '') ||
'https://api.meshkee.com';
return `${base}/api/v1/public/ai-prompts/${publicId}`;
}
private serializeTemplate(row: {
id: bigint;
name: string;
body: string;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: row.id.toString(),
name: row.name,
body: row.body,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
private serializeDomainPrompt(row: {
id: bigint;
domainId: bigint;
sourceTemplateId: bigint | null;
name: string;
body: string;
publicId: string;
sortOrder: number;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: row.id.toString(),
domainId: row.domainId.toString(),
sourceTemplateId: row.sourceTemplateId?.toString() ?? null,
name: row.name,
body: row.body,
publicId: row.publicId,
publicUrl: this.publicUrl(row.publicId),
sortOrder: row.sortOrder,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
async list(actor: AuthUser) {
await this.assertSuperAdmin(actor);
const items = await this.prisma.aiPromptTemplate.findMany({
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
});
return { items: items.map((row) => this.serializeTemplate(row)) };
}
async create(dto: CreateAiPromptTemplateDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const created = await this.prisma.aiPromptTemplate.create({
data: {
name: dto.name.trim(),
body: dto.body.trim(),
},
});
return this.serializeTemplate(created);
}
async update(
templateIdRaw: string,
dto: UpdateAiPromptTemplateDto,
actor: AuthUser,
) {
await this.assertSuperAdmin(actor);
const id = BigInt(templateIdRaw);
const existing = await this.prisma.aiPromptTemplate.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('Template not found');
const updated = await this.prisma.aiPromptTemplate.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.body !== undefined ? { body: dto.body.trim() } : {}),
},
});
return this.serializeTemplate(updated);
}
async remove(templateIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const id = BigInt(templateIdRaw);
try {
await this.prisma.aiPromptTemplate.delete({ where: { id } });
} catch {
throw new NotFoundException('Template not found');
}
return { ok: true };
}
async listDomainPrompts(domainIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const domainId = BigInt(domainIdRaw);
const domain = await this.prisma.domain.findUnique({
where: { id: domainId },
select: {
id: true,
host: true,
business: { select: { name: true } },
},
});
if (!domain) throw new NotFoundException('Website not found');
const items = await this.prisma.domainAiPrompt.findMany({
where: { domainId },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
return {
domainId: domain.id.toString(),
host: domain.host,
businessName: domain.business.name,
items: items.map((row) => this.serializeDomainPrompt(row)),
};
}
async createDomainPrompt(
domainIdRaw: string,
dto: CreateDomainAiPromptDto,
actor: AuthUser,
) {
await this.assertSuperAdmin(actor);
const domainId = BigInt(domainIdRaw);
const domain = await this.prisma.domain.findUnique({
where: { id: domainId },
select: { id: true },
});
if (!domain) throw new NotFoundException('Website not found');
let sourceTemplateId: bigint | null = null;
if (dto.sourceTemplateId !== undefined) {
sourceTemplateId = BigInt(dto.sourceTemplateId);
const template = await this.prisma.aiPromptTemplate.findUnique({
where: { id: sourceTemplateId },
select: { id: true },
});
if (!template) throw new NotFoundException('Template not found');
}
const maxSort = await this.prisma.domainAiPrompt.aggregate({
where: { domainId },
_max: { sortOrder: true },
});
const created = await this.prisma.domainAiPrompt.create({
data: {
domainId,
sourceTemplateId,
name: dto.name.trim(),
body: dto.body.trim(),
publicId: await this.generateUniquePromptPublicId(),
sortOrder: (maxSort._max.sortOrder ?? -1) + 1,
},
});
return this.serializeDomainPrompt(created);
}
async updateDomainPrompt(
domainIdRaw: string,
promptIdRaw: string,
dto: UpdateDomainAiPromptDto,
actor: AuthUser,
) {
await this.assertSuperAdmin(actor);
const domainId = BigInt(domainIdRaw);
const id = BigInt(promptIdRaw);
const existing = await this.prisma.domainAiPrompt.findFirst({
where: { id, domainId },
select: { id: true },
});
if (!existing) throw new NotFoundException('Prompt not found');
const updated = await this.prisma.domainAiPrompt.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.body !== undefined ? { body: dto.body.trim() } : {}),
},
});
return this.serializeDomainPrompt(updated);
}
async removeDomainPrompt(
domainIdRaw: string,
promptIdRaw: string,
actor: AuthUser,
) {
await this.assertSuperAdmin(actor);
const domainId = BigInt(domainIdRaw);
const id = BigInt(promptIdRaw);
const existing = await this.prisma.domainAiPrompt.findFirst({
where: { id, domainId },
select: { id: true },
});
if (!existing) throw new NotFoundException('Prompt not found');
await this.prisma.domainAiPrompt.delete({ where: { id } });
return { ok: true };
}
async getPublic(publicIdRaw: string) {
const publicId = publicIdRaw.trim();
if (!/^[a-f0-9]{16,32}$/i.test(publicId)) {
throw new NotFoundException('Prompt not found');
}
const prompt = await this.prisma.domainAiPrompt.findUnique({
where: { publicId },
include: {
domain: {
select: {
host: true,
business: { select: { name: true } },
},
},
},
});
if (prompt) {
return {
name: prompt.name,
body: prompt.body,
website: prompt.domain.host,
businessName: prompt.domain.business.name,
};
}
// Backward compatible: old domain pack public ids.
const domain = await this.prisma.domain.findFirst({
where: { aiPromptsPublicId: publicId },
select: {
host: true,
business: { select: { name: true } },
aiPrompts: {
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
},
},
});
if (!domain) throw new NotFoundException('Prompt not found');
return {
website: domain.host,
businessName: domain.business.name,
templates: domain.aiPrompts.map((item) => ({
name: item.name,
body: item.body,
})),
};
}
async getPublicText(publicIdRaw: string): Promise<string> {
const data = await this.getPublic(publicIdRaw);
if ('body' in data && typeof data.body === 'string') {
return data.body;
}
const pack = data as {
templates: Array<{ name: string; body: string }>;
};
if (!pack.templates?.length) return '';
return pack.templates
.map((t) => `# ${t.name}\n\n${t.body}`)
.join('\n\n---\n\n');
}
}
@@ -0,0 +1,12 @@
import { IsString, MaxLength, MinLength } from 'class-validator';
export class CreateAiPromptTemplateDto {
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsString()
@MinLength(1)
body!: string;
}
@@ -0,0 +1,26 @@
import { Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsPositive,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
export class CreateDomainAiPromptDto {
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsString()
@MinLength(1)
body!: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@IsPositive()
sourceTemplateId?: number;
}
@@ -0,0 +1,14 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class UpdateAiPromptTemplateDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
name?: string;
@IsOptional()
@IsString()
@MinLength(1)
body?: string;
}
@@ -0,0 +1,14 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class UpdateDomainAiPromptDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
name?: string;
@IsOptional()
@IsString()
@MinLength(1)
body?: string;
}
+2
View File
@@ -39,6 +39,7 @@ import { InternalSslModule } from './internal-ssl/internal-ssl.module';
import { WebsiteDocsModule } from './website-docs/website-docs.module';
import { WebsiteAnalyticsModule } from './website-analytics/website-analytics.module';
import { InvoicesModule } from './invoices/invoices.module';
import { AiPromptsModule } from './ai-prompts/ai-prompts.module';
import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module';
import { PublicSmsModule } from './public-sms/public-sms.module';
import { PaymentsModule } from './payments/payments.module';
@@ -88,6 +89,7 @@ import { SitemapModule } from './sitemap/sitemap.module';
WebsiteAnalyticsModule,
WebsiteDocsModule,
InvoicesModule,
AiPromptsModule,
PublicSmsModule,
SitemapModule,
],