Expand invoices with full templates, public viewer API, and account holder.

Add invoice template CRUD, key points/accounts, public GET endpoint, and migration 039 for account_holder_name.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-07-26 11:04:26 +03:30
co-authored by Cursor
parent 426316d53c
commit 3faeb9bc0d
13 changed files with 1094 additions and 52 deletions
+158
View File
@@ -51,6 +51,34 @@ export class InvoiceItemInputDto {
discountedPrice?: number | null;
}
export class InvoiceKeyPointInputDto {
@IsString()
@MinLength(1)
text!: string;
}
export class InvoiceAccountInputDto {
@IsString()
@MinLength(1)
@MaxLength(255)
bankName!: string;
@IsOptional()
@IsString()
@MaxLength(255)
accountHolderName?: string;
@IsOptional()
@IsString()
@MaxLength(64)
cardNumber?: string;
@IsOptional()
@IsString()
@MaxLength(64)
iban?: string;
}
export class CreateInvoiceDto {
@IsArray()
@ArrayMinSize(1)
@@ -63,10 +91,30 @@ export class CreateInvoiceDto {
@MaxLength(255)
name?: string;
@IsOptional()
@IsString()
topText?: string;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
@IsString()
invoiceTemplateId?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => InvoiceKeyPointInputDto)
keyPoints?: InvoiceKeyPointInputDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => InvoiceAccountInputDto)
accounts?: InvoiceAccountInputDto[];
@IsOptional()
@IsEnum(InvoiceStatus)
status?: InvoiceStatus;
@@ -178,3 +226,113 @@ export class UpdateInvoiceItemTemplateDto {
@IsBoolean()
isActive?: boolean;
}
export class InvoiceTemplateItemInputDto {
@IsOptional()
@IsString()
itemTemplateId?: string;
@IsString()
@MinLength(1)
@MaxLength(255)
title!: string;
@IsOptional()
@IsString()
@MaxLength(100)
duration?: string;
@IsOptional()
@IsString()
@MaxLength(100)
worktime?: string;
@IsOptional()
@IsString()
description?: string;
@Type(() => Number)
@IsNumber()
@Min(0)
price!: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
discountedPrice?: number | null;
}
export class CreateInvoiceTemplateDto {
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsOptional()
@IsString()
topText?: string;
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => InvoiceTemplateItemInputDto)
items!: InvoiceTemplateItemInputDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => InvoiceKeyPointInputDto)
keyPoints?: InvoiceKeyPointInputDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => InvoiceAccountInputDto)
accounts?: InvoiceAccountInputDto[];
@IsOptional()
@Type(() => Number)
@IsInt()
sortOrder?: number;
}
export class UpdateInvoiceTemplateDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
name?: string;
@IsOptional()
@IsString()
topText?: string | null;
@IsOptional()
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => InvoiceTemplateItemInputDto)
items?: InvoiceTemplateItemInputDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => InvoiceKeyPointInputDto)
keyPoints?: InvoiceKeyPointInputDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => InvoiceAccountInputDto)
accounts?: InvoiceAccountInputDto[];
@IsOptional()
@Type(() => Number)
@IsInt()
sortOrder?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
+43
View File
@@ -15,9 +15,11 @@ import { AuthUser } from '../auth/auth.types';
import {
CreateInvoiceDto,
CreateInvoiceItemTemplateDto,
CreateInvoiceTemplateDto,
ListInvoicesDto,
UpdateInvoiceItemTemplateDto,
UpdateInvoiceStatusDto,
UpdateInvoiceTemplateDto,
} from './dto/invoice.dto';
import { InvoicesService } from './invoices.service';
@@ -54,6 +56,41 @@ export class InvoicesController {
return this.service.deletePlatformTemplate(templateId, user);
}
// Platform invoice templates (settings)
@Get('invoice-templates')
@UseGuards(JwtAuthGuard)
listInvoiceTemplates(@CurrentUser() user: AuthUser) {
return this.service.listPlatformInvoiceTemplates(user);
}
@Post('invoice-templates')
@UseGuards(JwtAuthGuard)
createInvoiceTemplate(@Body() dto: CreateInvoiceTemplateDto, @CurrentUser() user: AuthUser) {
return this.service.createPlatformInvoiceTemplate(dto, user);
}
@Get('invoice-templates/:templateId')
@UseGuards(JwtAuthGuard)
getInvoiceTemplate(@Param('templateId') templateId: string, @CurrentUser() user: AuthUser) {
return this.service.getPlatformInvoiceTemplate(templateId, user);
}
@Patch('invoice-templates/:templateId')
@UseGuards(JwtAuthGuard)
updateInvoiceTemplate(
@Param('templateId') templateId: string,
@Body() dto: UpdateInvoiceTemplateDto,
@CurrentUser() user: AuthUser,
) {
return this.service.updatePlatformInvoiceTemplate(templateId, dto, user);
}
@Delete('invoice-templates/:templateId')
@UseGuards(JwtAuthGuard)
deleteInvoiceTemplate(@Param('templateId') templateId: string, @CurrentUser() user: AuthUser) {
return this.service.deletePlatformInvoiceTemplate(templateId, user);
}
// Business invoices
@Get('businesses/:businessId/invoices')
@UseGuards(JwtAuthGuard)
@@ -105,4 +142,10 @@ export class InvoicesController {
) {
return this.service.deleteInvoice(businessId, invoiceId, user);
}
/** Public invoice show page (no auth). Issued / paid platform invoices only. */
@Get('public/invoices/:invoiceId')
getPublic(@Param('invoiceId') invoiceId: string) {
return this.service.getPublicInvoice(invoiceId);
}
}
+483 -22
View File
@@ -11,10 +11,15 @@ import { PrismaService } from '../prisma/prisma.service';
import {
CreateInvoiceDto,
CreateInvoiceItemTemplateDto,
CreateInvoiceTemplateDto,
InvoiceAccountInputDto,
InvoiceItemInputDto,
InvoiceKeyPointInputDto,
InvoiceTemplateItemInputDto,
ListInvoicesDto,
UpdateInvoiceItemTemplateDto,
UpdateInvoiceStatusDto,
UpdateInvoiceTemplateDto,
} from './dto/invoice.dto';
@Injectable()
@@ -62,6 +67,140 @@ export class InvoicesService {
};
}
private readonly invoiceInclude = {
business: { select: { id: true, name: true, nameFa: true } },
issuer: { select: { id: true, firstName: true, lastName: true } },
items: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
keyPoints: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
accounts: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
};
private readonly invoiceTemplateInclude = {
items: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
keyPoints: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
accounts: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
};
private serializeKeyPoint(row: {
id: bigint;
text: string;
sortOrder: number;
}) {
return {
id: row.id.toString(),
text: row.text,
sortOrder: row.sortOrder,
};
}
private serializeAccount(row: {
id: bigint;
bankName: string;
accountHolderName: string | null;
cardNumber: string | null;
iban: string | null;
sortOrder: number;
}) {
return {
id: row.id.toString(),
bankName: row.bankName,
accountHolderName: row.accountHolderName,
cardNumber: row.cardNumber,
iban: row.iban,
sortOrder: row.sortOrder,
};
}
private serializeTemplateItem(row: {
id: bigint;
templateId: bigint;
itemTemplateId: bigint | null;
title: string;
duration: string | null;
worktime: string | null;
description: string | null;
price: Prisma.Decimal;
discountedPrice: Prisma.Decimal | null;
sortOrder: number;
}) {
return {
id: row.id.toString(),
templateId: row.templateId.toString(),
itemTemplateId: row.itemTemplateId?.toString() ?? null,
title: row.title,
duration: row.duration,
worktime: row.worktime,
description: row.description,
price: Number(row.price),
discountedPrice: row.discountedPrice === null ? null : Number(row.discountedPrice),
sortOrder: row.sortOrder,
};
}
private serializeInvoiceTemplate(
row: {
id: bigint;
ownerScope: InvoiceOwnerScope;
businessId: bigint | null;
name: string;
topText: string | null;
sortOrder: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
items?: Array<{
id: bigint;
templateId: bigint;
itemTemplateId: bigint | null;
title: string;
duration: string | null;
worktime: string | null;
description: string | null;
price: Prisma.Decimal;
discountedPrice: Prisma.Decimal | null;
sortOrder: number;
}>;
keyPoints?: Array<{
id: bigint;
text: string;
sortOrder: number;
}>;
accounts?: Array<{
id: bigint;
bankName: string;
accountHolderName: string | null;
cardNumber: string | null;
iban: string | null;
sortOrder: number;
}>;
},
includeNested = true,
) {
return {
id: row.id.toString(),
ownerScope: row.ownerScope,
businessId: row.businessId?.toString() ?? null,
name: row.name,
topText: row.topText,
sortOrder: row.sortOrder,
isActive: row.isActive,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
items:
includeNested && row.items
? row.items.map((item) => this.serializeTemplateItem(item))
: undefined,
keyPoints:
includeNested && row.keyPoints
? row.keyPoints.map((kp) => this.serializeKeyPoint(kp))
: undefined,
accounts:
includeNested && row.accounts
? row.accounts.map((acc) => this.serializeAccount(acc))
: undefined,
};
}
private serializeItem(row: {
id: bigint;
invoiceId: bigint;
@@ -89,6 +228,10 @@ export class InvoicesService {
}
private platformInvoicePublicUrl(invoiceId: bigint) {
const base = process.env.INVOICE_PUBLIC_BASE_URL?.trim();
if (base) {
return `${base.replace(/\/$/, '')}/invoices/${invoiceId.toString()}`;
}
const domain = process.env.INVOICE_PUBLIC_DOMAIN?.trim() || 'meshkee.com';
return `https://${domain}/invoices/${invoiceId.toString()}`;
}
@@ -101,7 +244,9 @@ export class InvoicesService {
issuerBusinessId: bigint | null;
status: InvoiceStatus;
name: string | null;
topText: string | null;
notes: string | null;
invoiceTemplateId: bigint | null;
issuedBy: bigint | null;
issuedAt: Date;
createdAt: Date;
@@ -120,10 +265,24 @@ export class InvoicesService {
discountedPrice: Prisma.Decimal | null;
sortOrder: number;
}>;
keyPoints?: Array<{
id: bigint;
text: string;
sortOrder: number;
}>;
accounts?: Array<{
id: bigint;
bankName: string;
accountHolderName: string | null;
cardNumber: string | null;
iban: string | null;
sortOrder: number;
}>;
},
includeItems = true,
includeNested = true,
) {
const items = includeItems && row.items ? row.items.map((item) => this.serializeItem(item)) : undefined;
const items =
includeNested && row.items ? row.items.map((item) => this.serializeItem(item)) : undefined;
const totals = items
? items.reduce(
(acc, item) => {
@@ -147,7 +306,9 @@ export class InvoicesService {
issuerBusinessId: row.issuerBusinessId?.toString() ?? null,
status: row.status,
name: row.name,
topText: row.topText,
notes: row.notes,
invoiceTemplateId: row.invoiceTemplateId?.toString() ?? null,
publicUrl:
row.ownerScope === InvoiceOwnerScope.platform
? this.platformInvoicePublicUrl(row.id)
@@ -171,11 +332,66 @@ export class InvoicesService {
}
: null,
items,
keyPoints:
includeNested && row.keyPoints
? row.keyPoints.map((kp) => this.serializeKeyPoint(kp))
: undefined,
accounts:
includeNested && row.accounts
? row.accounts.map((acc) => this.serializeAccount(acc))
: undefined,
subtotal: totals?.subtotal,
total: totals?.total,
};
}
private normalizeKeyPointInput(keyPoint: InvoiceKeyPointInputDto) {
const text = keyPoint.text.trim();
if (!text) {
throw new BadRequestException('Each key point requires text');
}
return { text };
}
private normalizeAccountInput(account: InvoiceAccountInputDto) {
const bankName = account.bankName.trim();
if (!bankName) {
throw new BadRequestException('Each account requires a bank name');
}
return {
bankName,
accountHolderName: account.accountHolderName?.trim() || null,
cardNumber: account.cardNumber?.trim() || null,
iban: account.iban?.trim() || null,
};
}
private normalizeTemplateItemInput(item: InvoiceTemplateItemInputDto) {
const title = item.title.trim();
if (!title) {
throw new BadRequestException('Each invoice template item requires a title');
}
const discountedPrice =
item.discountedPrice === undefined || item.discountedPrice === null
? null
: item.discountedPrice;
if (discountedPrice !== null && discountedPrice > item.price) {
throw new BadRequestException('Discounted price cannot exceed price');
}
return {
itemTemplateId: item.itemTemplateId?.trim() ? BigInt(item.itemTemplateId) : null,
title,
duration: item.duration?.trim() || null,
worktime: item.worktime?.trim() || null,
description: item.description?.trim() || null,
price: item.price,
discountedPrice,
};
}
private normalizeItemInput(item: InvoiceItemInputDto) {
const title = item.title.trim();
if (!title) {
@@ -309,6 +525,235 @@ export class InvoicesService {
return { ok: true };
}
// --- Invoice templates (platform settings) ---
async listPlatformInvoiceTemplates(actor: AuthUser) {
await this.assertSuperAdmin(actor);
const rows = await this.prisma.invoiceTemplate.findMany({
where: { ownerScope: InvoiceOwnerScope.platform },
include: this.invoiceTemplateInclude,
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
return { items: rows.map((row) => this.serializeInvoiceTemplate(row)) };
}
async getPlatformInvoiceTemplate(templateIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const templateId = BigInt(templateIdRaw);
const row = await this.prisma.invoiceTemplate.findFirst({
where: { id: templateId, ownerScope: InvoiceOwnerScope.platform },
include: this.invoiceTemplateInclude,
});
if (!row) {
throw new NotFoundException('Invoice template not found');
}
return this.serializeInvoiceTemplate(row);
}
async createPlatformInvoiceTemplate(dto: CreateInvoiceTemplateDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const name = dto.name.trim();
if (!name) {
throw new BadRequestException('Name is required');
}
const items = dto.items.map((item) => this.normalizeTemplateItemInput(item));
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc));
const row = await this.prisma.invoiceTemplate.create({
data: {
ownerScope: InvoiceOwnerScope.platform,
name,
topText: dto.topText?.trim() || null,
sortOrder: dto.sortOrder ?? 0,
items: {
create: items.map((item, index) => ({
itemTemplateId: item.itemTemplateId,
title: item.title,
duration: item.duration,
worktime: item.worktime,
description: item.description,
price: item.price,
discountedPrice: item.discountedPrice,
sortOrder: index,
})),
},
keyPoints: {
create: keyPoints.map((kp, index) => ({
text: kp.text,
sortOrder: index,
})),
},
accounts: {
create: accounts.map((acc, index) => ({
bankName: acc.bankName,
accountHolderName: acc.accountHolderName,
cardNumber: acc.cardNumber,
iban: acc.iban,
sortOrder: index,
})),
},
},
include: this.invoiceTemplateInclude,
});
return this.serializeInvoiceTemplate(row);
}
async updatePlatformInvoiceTemplate(
templateIdRaw: string,
dto: UpdateInvoiceTemplateDto,
actor: AuthUser,
) {
await this.assertSuperAdmin(actor);
const templateId = BigInt(templateIdRaw);
const existing = await this.prisma.invoiceTemplate.findFirst({
where: { id: templateId, ownerScope: InvoiceOwnerScope.platform },
});
if (!existing) {
throw new NotFoundException('Invoice template not found');
}
const replaceItems =
dto.items !== undefined ? dto.items.map((item) => this.normalizeTemplateItemInput(item)) : null;
const replaceKeyPoints =
dto.keyPoints !== undefined
? dto.keyPoints.map((kp) => this.normalizeKeyPointInput(kp))
: null;
const replaceAccounts =
dto.accounts !== undefined
? dto.accounts.map((acc) => this.normalizeAccountInput(acc))
: null;
const row = await this.prisma.$transaction(async (tx) => {
if (replaceItems !== null) {
await tx.invoiceTemplateItem.deleteMany({ where: { templateId } });
}
if (replaceKeyPoints !== null) {
await tx.invoiceTemplateKeyPoint.deleteMany({ where: { templateId } });
}
if (replaceAccounts !== null) {
await tx.invoiceTemplateAccount.deleteMany({ where: { templateId } });
}
return tx.invoiceTemplate.update({
where: { id: templateId },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.topText !== undefined ? { topText: dto.topText?.trim() || null } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
...(replaceItems !== null
? {
items: {
create: replaceItems.map((item, index) => ({
itemTemplateId: item.itemTemplateId,
title: item.title,
duration: item.duration,
worktime: item.worktime,
description: item.description,
price: item.price,
discountedPrice: item.discountedPrice,
sortOrder: index,
})),
},
}
: {}),
...(replaceKeyPoints !== null
? {
keyPoints: {
create: replaceKeyPoints.map((kp, index) => ({
text: kp.text,
sortOrder: index,
})),
},
}
: {}),
...(replaceAccounts !== null
? {
accounts: {
create: replaceAccounts.map((acc, index) => ({
bankName: acc.bankName,
accountHolderName: acc.accountHolderName,
cardNumber: acc.cardNumber,
iban: acc.iban,
sortOrder: index,
})),
},
}
: {}),
},
include: this.invoiceTemplateInclude,
});
});
return this.serializeInvoiceTemplate(row);
}
async deletePlatformInvoiceTemplate(templateIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const templateId = BigInt(templateIdRaw);
const existing = await this.prisma.invoiceTemplate.findFirst({
where: { id: templateId, ownerScope: InvoiceOwnerScope.platform },
});
if (!existing) {
throw new NotFoundException('Invoice template not found');
}
await this.prisma.invoiceTemplate.delete({ where: { id: templateId } });
return { ok: true };
}
// --- Public invoice viewer (platform) ---
async getPublicInvoice(invoiceIdRaw: string) {
let invoiceId: bigint;
try {
invoiceId = BigInt(invoiceIdRaw);
} catch {
throw new NotFoundException('Invoice not found');
}
const row = await this.prisma.invoice.findFirst({
where: {
id: invoiceId,
ownerScope: InvoiceOwnerScope.platform,
status: { in: [InvoiceStatus.issued, InvoiceStatus.paid] },
},
include: this.invoiceInclude,
});
if (!row) {
throw new NotFoundException('Invoice not found');
}
const serialized = this.serializeInvoice(row, true);
// Public payload: no internal notes / issuer identity
return {
id: serialized.id,
status: serialized.status,
name: serialized.name,
topText: serialized.topText,
issuedAt: serialized.issuedAt,
business: serialized.business,
items: serialized.items,
keyPoints: serialized.keyPoints,
accounts: serialized.accounts,
subtotal: serialized.subtotal,
total: serialized.total,
publicUrl: serialized.publicUrl,
};
}
// --- Invoices for a business ---
async listForBusiness(businessIdRaw: string, query: ListInvoicesDto, actor: AuthUser) {
@@ -335,11 +780,7 @@ export class InvoicesService {
this.prisma.invoice.count({ where }),
this.prisma.invoice.findMany({
where,
include: {
business: { select: { id: true, name: true, nameFa: true } },
issuer: { select: { id: true, firstName: true, lastName: true } },
items: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
},
include: this.invoiceInclude,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
@@ -367,11 +808,7 @@ export class InvoicesService {
businessId,
ownerScope: InvoiceOwnerScope.platform,
},
include: {
business: { select: { id: true, name: true, nameFa: true } },
issuer: { select: { id: true, firstName: true, lastName: true } },
items: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
},
include: this.invoiceInclude,
});
if (!row) {
@@ -394,6 +831,21 @@ export class InvoicesService {
}
const items = dto.items.map((item) => this.normalizeItemInput(item));
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc));
const invoiceTemplateId = dto.invoiceTemplateId?.trim()
? BigInt(dto.invoiceTemplateId)
: null;
if (invoiceTemplateId !== null) {
const template = await this.prisma.invoiceTemplate.findFirst({
where: { id: invoiceTemplateId, ownerScope: InvoiceOwnerScope.platform },
select: { id: true },
});
if (!template) {
throw new BadRequestException('Invoice template not found');
}
}
const row = await this.prisma.invoice.create({
data: {
@@ -401,7 +853,9 @@ export class InvoicesService {
ownerScope: InvoiceOwnerScope.platform,
status: dto.status ?? InvoiceStatus.issued,
name: dto.name?.trim() || null,
topText: dto.topText?.trim() || null,
notes: dto.notes?.trim() || null,
invoiceTemplateId,
issuedBy: actor.id,
items: {
create: items.map((item, index) => ({
@@ -415,12 +869,23 @@ export class InvoicesService {
sortOrder: index,
})),
},
keyPoints: {
create: keyPoints.map((kp, index) => ({
text: kp.text,
sortOrder: index,
})),
},
accounts: {
create: accounts.map((acc, index) => ({
bankName: acc.bankName,
accountHolderName: acc.accountHolderName,
cardNumber: acc.cardNumber,
iban: acc.iban,
sortOrder: index,
})),
},
},
include: {
business: { select: { id: true, name: true, nameFa: true } },
issuer: { select: { id: true, firstName: true, lastName: true } },
items: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
},
include: this.invoiceInclude,
});
return this.serializeInvoice(row);
@@ -454,11 +919,7 @@ export class InvoicesService {
status: dto.status,
...(dto.notes !== undefined ? { notes: dto.notes?.trim() || null } : {}),
},
include: {
business: { select: { id: true, name: true, nameFa: true } },
issuer: { select: { id: true, firstName: true, lastName: true } },
items: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
},
include: this.invoiceInclude,
});
return this.serializeInvoice(row);
+4 -4
View File
@@ -74,9 +74,9 @@
<div class="panel">
<p style="margin:0 0 0.75rem"><strong>Global links</strong> (share these with designers &amp; AI tools):</p>
<a class="btn" href="./openapi.json">OpenAPI JSON</a>
<a class="btn secondary" href="./Meshkee-Website-API.postman_collection.json">Download Postman</a>
<a class="btn secondary" href="./AI_PROMPT.md">AI prompt</a>
<a class="btn" href="/docs/website/openapi.json">OpenAPI JSON</a>
<a class="btn secondary" href="/docs/website/Meshkee-Website-API.postman_collection.json">Download Postman</a>
<a class="btn secondary" href="/docs/website/AI_PROMPT.md">AI prompt</a>
</div>
<h2>Base URL</h2>
@@ -93,7 +93,7 @@
<h2>For a new website AI / designer</h2>
<ol>
<li>Open <a href="./AI_PROMPT.md">AI_PROMPT.md</a> and paste it into the AI chat.</li>
<li>Open <a href="/docs/website/AI_PROMPT.md">AI_PROMPT.md</a> and paste it into the AI chat.</li>
<li>Replace <code>&lt;WEBSITE_DOMAIN&gt;</code> with that sites apex.</li>
<li>Import the Postman collection (set <code>domain</code>, run Resolve tenant).</li>
<li>Or feed <code>openapi.json</code> to the AI / codegen tool.</li>