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:
co-authored by
Cursor
parent
4db9c7709d
commit
91a20a89d6
@@ -0,0 +1,32 @@
|
|||||||
|
-- Super Admin AI prompt templates (per website / domain), with public share ids.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_prompt_templates (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
domain_id BIGINT NOT NULL REFERENCES domains (id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
public_id VARCHAR(32) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT ai_prompt_templates_public_id_unique UNIQUE (public_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_prompt_templates_domain_id
|
||||||
|
ON ai_prompt_templates (domain_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_prompt_templates_created_at
|
||||||
|
ON ai_prompt_templates (created_at DESC);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION ai_prompt_templates_set_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS ai_prompt_templates_set_updated_at ON ai_prompt_templates;
|
||||||
|
CREATE TRIGGER ai_prompt_templates_set_updated_at
|
||||||
|
BEFORE UPDATE ON ai_prompt_templates
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION ai_prompt_templates_set_updated_at();
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
-- Make AI prompt templates global; assign to websites via join + pack public id.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS domain_ai_prompt_templates (
|
||||||
|
domain_id BIGINT NOT NULL REFERENCES domains (id) ON DELETE CASCADE,
|
||||||
|
template_id BIGINT NOT NULL REFERENCES ai_prompt_templates (id) ON DELETE CASCADE,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (domain_id, template_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domain_ai_prompt_templates_template_id
|
||||||
|
ON domain_ai_prompt_templates (template_id);
|
||||||
|
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD COLUMN IF NOT EXISTS ai_prompts_public_id VARCHAR(32);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'domains_ai_prompts_public_id_unique'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD CONSTRAINT domains_ai_prompts_public_id_unique UNIQUE (ai_prompts_public_id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Preserve any existing per-domain template links (from 075).
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'ai_prompt_templates' AND column_name = 'domain_id'
|
||||||
|
) THEN
|
||||||
|
INSERT INTO domain_ai_prompt_templates (domain_id, template_id, sort_order)
|
||||||
|
SELECT domain_id, id, 0
|
||||||
|
FROM ai_prompt_templates
|
||||||
|
WHERE domain_id IS NOT NULL
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
UPDATE domains d
|
||||||
|
SET ai_prompts_public_id = t.public_id
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (domain_id) domain_id, public_id
|
||||||
|
FROM ai_prompt_templates
|
||||||
|
WHERE public_id IS NOT NULL
|
||||||
|
ORDER BY domain_id, id
|
||||||
|
) t
|
||||||
|
WHERE d.id = t.domain_id
|
||||||
|
AND d.ai_prompts_public_id IS NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE ai_prompt_templates DROP COLUMN IF EXISTS domain_id;
|
||||||
|
ALTER TABLE ai_prompt_templates DROP COLUMN IF EXISTS public_id;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
-- Per-website AI prompt instances (editable copies) with their own public links.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS domain_ai_prompts (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
domain_id BIGINT NOT NULL REFERENCES domains (id) ON DELETE CASCADE,
|
||||||
|
source_template_id BIGINT REFERENCES ai_prompt_templates (id) ON DELETE SET NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
public_id VARCHAR(32) NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT domain_ai_prompts_public_id_unique UNIQUE (public_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domain_ai_prompts_domain_id
|
||||||
|
ON domain_ai_prompts (domain_id, sort_order, id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domain_ai_prompts_source_template_id
|
||||||
|
ON domain_ai_prompts (source_template_id);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION domain_ai_prompts_set_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS domain_ai_prompts_set_updated_at ON domain_ai_prompts;
|
||||||
|
CREATE TRIGGER domain_ai_prompts_set_updated_at
|
||||||
|
BEFORE UPDATE ON domain_ai_prompts
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION domain_ai_prompts_set_updated_at();
|
||||||
|
|
||||||
|
-- Copy existing assignments into editable instances (one public id each).
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
r RECORD;
|
||||||
|
pid TEXT;
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_name = 'domain_ai_prompt_templates'
|
||||||
|
) THEN
|
||||||
|
FOR r IN
|
||||||
|
SELECT
|
||||||
|
l.domain_id,
|
||||||
|
l.template_id,
|
||||||
|
l.sort_order,
|
||||||
|
t.name,
|
||||||
|
t.body
|
||||||
|
FROM domain_ai_prompt_templates l
|
||||||
|
JOIN ai_prompt_templates t ON t.id = l.template_id
|
||||||
|
ORDER BY l.domain_id, l.sort_order, l.template_id
|
||||||
|
LOOP
|
||||||
|
LOOP
|
||||||
|
pid := encode(gen_random_bytes(12), 'hex');
|
||||||
|
EXIT WHEN NOT EXISTS (
|
||||||
|
SELECT 1 FROM domain_ai_prompts WHERE public_id = pid
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
INSERT INTO domain_ai_prompts (
|
||||||
|
domain_id, source_template_id, name, body, public_id, sort_order
|
||||||
|
) VALUES (
|
||||||
|
r.domain_id, r.template_id, r.name, r.body, pid, r.sort_order
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS domain_ai_prompt_templates;
|
||||||
@@ -188,7 +188,9 @@ model Domain {
|
|||||||
lastDeployStatus String? @map("last_deploy_status") @db.VarChar(32)
|
lastDeployStatus String? @map("last_deploy_status") @db.VarChar(32)
|
||||||
deploySlug String? @map("deploy_slug") @db.VarChar(64)
|
deploySlug String? @map("deploy_slug") @db.VarChar(64)
|
||||||
gitRepoUrl String? @map("git_repo_url") @db.VarChar(512)
|
gitRepoUrl String? @map("git_repo_url") @db.VarChar(512)
|
||||||
|
aiPromptsPublicId String? @unique(map: "domains_ai_prompts_public_id_unique") @map("ai_prompts_public_id") @db.VarChar(32)
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
aiPrompts DomainAiPrompt[]
|
||||||
|
|
||||||
@@index([businessId], map: "idx_domains_business_id")
|
@@index([businessId], map: "idx_domains_business_id")
|
||||||
@@index([host], map: "idx_domains_host")
|
@@index([host], map: "idx_domains_host")
|
||||||
@@ -196,6 +198,36 @@ model Domain {
|
|||||||
@@map("domains")
|
@@map("domains")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model AiPromptTemplate {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
name String @db.VarChar(255)
|
||||||
|
body String
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
domainPrompts DomainAiPrompt[]
|
||||||
|
|
||||||
|
@@index([createdAt(sort: Desc)], map: "idx_ai_prompt_templates_created_at")
|
||||||
|
@@map("ai_prompt_templates")
|
||||||
|
}
|
||||||
|
|
||||||
|
model DomainAiPrompt {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
domainId BigInt @map("domain_id")
|
||||||
|
sourceTemplateId BigInt? @map("source_template_id")
|
||||||
|
name String @db.VarChar(255)
|
||||||
|
body String
|
||||||
|
publicId String @unique(map: "domain_ai_prompts_public_id_unique") @map("public_id") @db.VarChar(32)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
sourceTemplate AiPromptTemplate? @relation(fields: [sourceTemplateId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([domainId, sortOrder, id], map: "idx_domain_ai_prompts_domain_id")
|
||||||
|
@@index([sourceTemplateId], map: "idx_domain_ai_prompts_source_template_id")
|
||||||
|
@@map("domain_ai_prompts")
|
||||||
|
}
|
||||||
|
|
||||||
model BusinessUser {
|
model BusinessUser {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
businessId BigInt @map("business_id")
|
businessId BigInt @map("business_id")
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ import { InternalSslModule } from './internal-ssl/internal-ssl.module';
|
|||||||
import { WebsiteDocsModule } from './website-docs/website-docs.module';
|
import { WebsiteDocsModule } from './website-docs/website-docs.module';
|
||||||
import { WebsiteAnalyticsModule } from './website-analytics/website-analytics.module';
|
import { WebsiteAnalyticsModule } from './website-analytics/website-analytics.module';
|
||||||
import { InvoicesModule } from './invoices/invoices.module';
|
import { InvoicesModule } from './invoices/invoices.module';
|
||||||
|
import { AiPromptsModule } from './ai-prompts/ai-prompts.module';
|
||||||
import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module';
|
import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module';
|
||||||
import { PublicSmsModule } from './public-sms/public-sms.module';
|
import { PublicSmsModule } from './public-sms/public-sms.module';
|
||||||
import { PaymentsModule } from './payments/payments.module';
|
import { PaymentsModule } from './payments/payments.module';
|
||||||
@@ -88,6 +89,7 @@ import { SitemapModule } from './sitemap/sitemap.module';
|
|||||||
WebsiteAnalyticsModule,
|
WebsiteAnalyticsModule,
|
||||||
WebsiteDocsModule,
|
WebsiteDocsModule,
|
||||||
InvoicesModule,
|
InvoicesModule,
|
||||||
|
AiPromptsModule,
|
||||||
PublicSmsModule,
|
PublicSmsModule,
|
||||||
SitemapModule,
|
SitemapModule,
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user