Replace the landing with the artifact design and rebuild the admin in Ant Design.
Adds consultation leads, section editors, and the one-time content reset for this release. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Lead" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"phone" TEXT NOT NULL,
|
||||
"businessType" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL DEFAULT 'consult',
|
||||
"aiQuery" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'new',
|
||||
"note" TEXT,
|
||||
"ip" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Lead_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Lead_status_createdAt_idx" ON "Lead"("status", "createdAt");
|
||||
|
||||
-- Landing redesign: sections from the previous design are no longer rendered.
|
||||
DELETE FROM "SectionContent" WHERE "key" IN ('marquee', 'manifesto', 'services', 'work', 'process', 'stats', 'team');
|
||||
@@ -85,6 +85,22 @@ model GalleryItem {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Lead {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
phone String
|
||||
businessType String
|
||||
source String @default("consult")
|
||||
aiQuery String?
|
||||
status String @default("new")
|
||||
note String?
|
||||
ip String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status, createdAt])
|
||||
}
|
||||
|
||||
model Media {
|
||||
id String @id @default(cuid())
|
||||
filename String
|
||||
|
||||
+62
-185
@@ -1,133 +1,56 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import * as bcrypt from "bcryptjs";
|
||||
import { DEFAULT_SECTIONS, LEGACY_SECTION_KEYS } from "@montech/shared";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const defaultSections: Record<string, unknown> = {
|
||||
hero: {
|
||||
eyebrow: "گروه توسعه نرمافزار",
|
||||
location: "[ تأسیس ۱۴۰۰ — تهران / دورکاری ]",
|
||||
loaderTitle: "هوشافزار منتک",
|
||||
lines: [
|
||||
"در هوشافزار منتک",
|
||||
"بهترین نرمافزارها و خدمات",
|
||||
"متناسب با کسبوکار شما پیشنهاد میشود",
|
||||
],
|
||||
highlightWord: "هوشافزار منتک",
|
||||
highlightWords: ["هوشافزار منتک"],
|
||||
body: "منتک یک تیم مهندسی ارشد است که پلتفرمهای وب، اپلیکیشن موبایل و زیرساخت ابری را برای کسبوکارهایی میسازد که نمیخواهند معمولی باشند.",
|
||||
cta: "مشاهده نمونهکارها",
|
||||
badge: "مهندسی منتک • از ۱۴۰۰ •",
|
||||
coords: "تهران، ایران",
|
||||
projectsLabel: "بیش از ۱۲۰ پروژه تحویلشده",
|
||||
},
|
||||
marquee: {
|
||||
items: [
|
||||
"پلتفرم وب",
|
||||
"اپلیکیشن موبایل",
|
||||
"ابرو DevOps",
|
||||
"هوش مصنوعی",
|
||||
"سیستم طراحی",
|
||||
"معماری API",
|
||||
],
|
||||
},
|
||||
manifesto: {
|
||||
tag: "ما که هستیم",
|
||||
words: [
|
||||
{ text: "ما" },
|
||||
{ text: "نرمافزاری" },
|
||||
{ text: "میسازیم", volt: true },
|
||||
{ text: "که" },
|
||||
{ text: "تفاوت" },
|
||||
{ text: "ایجاد" },
|
||||
{ text: "کند." },
|
||||
{ text: "منتک", volt: true },
|
||||
],
|
||||
body: "استراتژی، طراحی و مهندسی زیر یک سقف — با دقت وسواسگونه نسبت به کیفیت و عملکرد.",
|
||||
},
|
||||
services: {
|
||||
tag: "چه میکنیم",
|
||||
items: [
|
||||
{
|
||||
n: "۰۱",
|
||||
title: "استراتژی محصول",
|
||||
desc: "ایدههای مبهم را به نقشه راه معتبر تبدیل میکنیم. اسپرینت کشف، پژوهش کاربر و نمونهسازی سریع قبل از نوشتن کد.",
|
||||
tags: ["کشف محصول", "پژوهش کاربر", "نقشه راه", "MVP"],
|
||||
},
|
||||
{
|
||||
n: "۰۲",
|
||||
title: "مهندسی وب",
|
||||
desc: "پلتفرمهای وب سریع و مقیاسپذیر — از سایتهای بازاریابی تا داشبوردهای پیچیده SaaS.",
|
||||
tags: ["Nuxt", "NestJS", "Node.js", "React"],
|
||||
},
|
||||
{
|
||||
n: "۰۳",
|
||||
title: "اپلیکیشن موبایل",
|
||||
desc: "تجربهٔ بومیگونه روی iOS و Android از یک کدبیس مشترک — یا کاملاً نیتیو وقتی لازم باشد.",
|
||||
tags: ["React Native", "Flutter", "Swift", "Kotlin"],
|
||||
},
|
||||
{
|
||||
n: "۰۴",
|
||||
title: "ابرو DevOps",
|
||||
desc: "زیرساختی که با رشد شما مقیاس میشود؛ استقرار خودکار، امنیت و مانیتورینگ.",
|
||||
tags: ["AWS / GCP", "کوبرنتیز", "CI/CD", "Terraform"],
|
||||
},
|
||||
{
|
||||
n: "۰۵",
|
||||
title: "هوش مصنوعی و داده",
|
||||
desc: "هوش مصنوعی کاربردی؛ اتصال LLM، اتوماسیون هوشمند و خطوط لوله داده.",
|
||||
tags: ["LLM", "اتوماسیون", "مهندسی داده", "تحلیل"],
|
||||
},
|
||||
],
|
||||
},
|
||||
work: {
|
||||
tag: "نمونه کارها",
|
||||
items: [
|
||||
{ n: "۰۱", title: "پالسبورد", cat: "پلتفرم تحلیلی فینتک", year: "۱۴۰۳" },
|
||||
{ n: "۰۲", title: "ایرولوپ", cat: "نرمافزار لجستیک", year: "۱۴۰۳" },
|
||||
{ n: "۰۳", title: "مدسینک", cat: "پلتفرم هلثتک", year: "۱۴۰۲" },
|
||||
{ n: "۰۴", title: "نوواپی", cat: "زیرساخت پرداخت", year: "۱۴۰۲" },
|
||||
],
|
||||
},
|
||||
process: {
|
||||
tag: "چگونه کار میکنیم",
|
||||
steps: [
|
||||
{ n: "۰۱", title: "کشف", desc: "کارگاه، پژوهش و همراستایی روی نتایجی که واقعاً مهماند." },
|
||||
{ n: "۰۲", title: "طراحی", desc: "سیستمها، جریانها و رابطهایی که ساده و قابلاعتماد به نظر میرسند." },
|
||||
{ n: "۰۳", title: "توسعه", desc: "مهندسان ارشد که نرمافزار آمادهٔ تولید تحویل میدهند." },
|
||||
{ n: "۰۴", title: "استقرار و مقیاس", desc: "CI/CD، مانیتورینگ و زیرساختی که با شما رشد میکند." },
|
||||
],
|
||||
},
|
||||
stats: {
|
||||
items: [
|
||||
{ value: 120, suffix: "+", label: "پروژه تحویلشده" },
|
||||
{ value: 40, suffix: "+", label: "مشتری" },
|
||||
{ value: 15, suffix: "", label: "مهندس و طراح" },
|
||||
{ value: 98, suffix: "%", label: "ماندگاری مشتری" },
|
||||
],
|
||||
},
|
||||
team: {
|
||||
tag: "تیم",
|
||||
headline: "با جمع ما آشنا شوید",
|
||||
body: "تیمی جمعوجور از متخصصان محصول، طراحی و مهندسی.",
|
||||
members: [
|
||||
{ name: "علی رضایی", role: "مدیرعامل و همبنیانگذار" },
|
||||
{ name: "سارا محمدی", role: "مدیر فنی و همبنیانگذار" },
|
||||
{ name: "مریم حسینی", role: "سرپرست طراحی" },
|
||||
{ name: "رضا کریمی", role: "مهندس ارشد" },
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
tag: "تماس",
|
||||
marquee: ["پروژهای در ذهن دارید؟", "سلام کنید", "با هم کار کنیم"],
|
||||
cta: "شروع همکاری",
|
||||
email: "hello@montech.ir",
|
||||
phone: "+98 21 0000 0000",
|
||||
address: ["تهران", "ایران"],
|
||||
socials: ["لینکدین", "گیتهاب", "اینستاگرام"],
|
||||
},
|
||||
const contactDefaults = {
|
||||
brandName: "منتک",
|
||||
tagline: "هوشافزار منظر",
|
||||
email: "info@montech.ir",
|
||||
phone: "۰۹۱۲ ۳۴۵ ۶۷۸۹",
|
||||
address: "تهران، ایران",
|
||||
socials: [
|
||||
{ label: "تلگرام", url: "#" },
|
||||
{ label: "لینکدین", url: "#" },
|
||||
{ label: "اینستاگرام", url: "#" },
|
||||
],
|
||||
};
|
||||
|
||||
const pages = [
|
||||
{
|
||||
key: "home",
|
||||
title: "خانه",
|
||||
seo: {
|
||||
title: "منتک | هوشافزار منظر - پلتفرم راهکارهای نرمافزاری و هوش مصنوعی",
|
||||
description:
|
||||
"با کمک هوش مصنوعی منتک، بهترین نرمافزارها و خدمات متناسب با کسبوکار شما پیشنهاد میشود. نرمافزار اختصاصی، حسابداری، CRM، فروشگاه آنلاین و نرمافزار طلا و جواهر.",
|
||||
canonical: "https://montech.ir/",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "blog",
|
||||
title: "بلاگ",
|
||||
seo: {
|
||||
title: "بلاگ — منتک",
|
||||
description: "یادداشتهایی درباره محصول، مهندسی و ساخت نرمافزار.",
|
||||
canonical: "https://montech.ir/blog",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "gallery",
|
||||
title: "گالری",
|
||||
seo: {
|
||||
title: "گالری — منتک",
|
||||
description: "تصاویر منتخب از پروژهها و فضای استودیو منتک.",
|
||||
canonical: "https://montech.ir/gallery",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Set SEED_RESET_CONTENT=1 to overwrite existing settings/sections/SEO with the defaults. */
|
||||
const resetContent = process.env.SEED_RESET_CONTENT === "1";
|
||||
|
||||
async function main() {
|
||||
const mobile = process.env.ADMIN_MOBILE || "09121234567";
|
||||
const password = process.env.ADMIN_PASSWORD || "change-me-strong";
|
||||
@@ -142,57 +65,10 @@ async function main() {
|
||||
|
||||
await prisma.siteSettings.upsert({
|
||||
where: { id: "default" },
|
||||
create: {
|
||||
id: "default",
|
||||
brandName: "منتک",
|
||||
tagline: "گروه توسعه نرمافزار",
|
||||
email: "hello@montech.ir",
|
||||
phone: "+98 21 0000 0000",
|
||||
address: "تهران، ایران",
|
||||
timezone: "Asia/Tehran",
|
||||
socials: [
|
||||
{ label: "لینکدین", url: "https://linkedin.com" },
|
||||
{ label: "گیتهاب", url: "https://github.com" },
|
||||
],
|
||||
},
|
||||
update: {
|
||||
brandName: "منتک",
|
||||
tagline: "گروه توسعه نرمافزار",
|
||||
address: "تهران، ایران",
|
||||
},
|
||||
create: { id: "default", timezone: "Asia/Tehran", ...contactDefaults },
|
||||
update: resetContent ? contactDefaults : {},
|
||||
});
|
||||
|
||||
const pages = [
|
||||
{
|
||||
key: "home",
|
||||
title: "خانه",
|
||||
seo: {
|
||||
title: "منتک — گروه توسعه نرمافزار",
|
||||
description:
|
||||
"منتک پلتفرمهای وب، اپلیکیشن موبایل و زیرساخت ابری با عملکرد بالا میسازد. مستقر در تهران، فعال در سراسر ایران.",
|
||||
canonical: "https://montech.ir/",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "blog",
|
||||
title: "بلاگ",
|
||||
seo: {
|
||||
title: "بلاگ — منتک",
|
||||
description: "یادداشتهایی درباره محصول، مهندسی و ساخت نرمافزار.",
|
||||
canonical: "https://montech.ir/blog",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "gallery",
|
||||
title: "گالری",
|
||||
seo: {
|
||||
title: "گالری — منتک",
|
||||
description: "تصاویر منتخب از پروژهها و فضای استودیو منتک.",
|
||||
canonical: "https://montech.ir/gallery",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const p of pages) {
|
||||
const page = await prisma.page.upsert({
|
||||
where: { key: p.key },
|
||||
@@ -202,15 +78,23 @@ async function main() {
|
||||
await prisma.pageSeo.upsert({
|
||||
where: { pageId: page.id },
|
||||
create: { pageId: page.id, ...p.seo },
|
||||
update: { ...p.seo },
|
||||
update: resetContent ? { ...p.seo } : {},
|
||||
});
|
||||
}
|
||||
|
||||
for (const [key, data] of Object.entries(defaultSections)) {
|
||||
await prisma.sectionContent.deleteMany({ where: { key: { in: [...LEGACY_SECTION_KEYS] } } });
|
||||
|
||||
for (const [key, data] of Object.entries(DEFAULT_SECTIONS)) {
|
||||
const json = {
|
||||
...data,
|
||||
...(key === "footer"
|
||||
? { email: contactDefaults.email, phone: contactDefaults.phone, address: contactDefaults.address }
|
||||
: {}),
|
||||
} as object;
|
||||
await prisma.sectionContent.upsert({
|
||||
where: { key },
|
||||
create: { key, data: data as object },
|
||||
update: { data: data as object },
|
||||
create: { key, data: json },
|
||||
update: resetContent ? { data: json } : {},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -227,17 +111,10 @@ async function main() {
|
||||
seoTitle: "به منتک خوش آمدید — بلاگ",
|
||||
seoDesc: "معرفی منتک و نحوه ساخت نرمافزار در تیم ما.",
|
||||
},
|
||||
update: {
|
||||
title: "به منتک خوش آمدید",
|
||||
excerpt: "ما نرمافزاری میسازیم که واقعاً کار میکند — و اینجا دربارهاش مینویسیم.",
|
||||
content:
|
||||
"منتک یک گروه توسعه نرمافزار است که پلتفرم وب، اپلیکیشن موبایل و زیرساخت ابری میسازد.\n\nدر این بلاگ درباره ساخت محصول، مهندسی و درسآموختههای استودیو مینویسیم.",
|
||||
seoTitle: "به منتک خوش آمدید — بلاگ",
|
||||
seoDesc: "معرفی منتک و نحوه ساخت نرمافزار در تیم ما.",
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
console.log("Seed complete. Admin:", mobile);
|
||||
console.log(`Seed complete (${resetContent ? "content reset" : "missing rows only"}). Admin:`, mobile);
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -8,6 +8,7 @@ import { BlogModule } from "./blog/blog.module";
|
||||
import { GalleryModule } from "./gallery/gallery.module";
|
||||
import { MediaModule } from "./media/media.module";
|
||||
import { SeoModule } from "./seo/seo.module";
|
||||
import { LeadsModule } from "./leads/leads.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -20,6 +21,7 @@ import { SeoModule } from "./seo/seo.module";
|
||||
GalleryModule,
|
||||
MediaModule,
|
||||
SeoModule,
|
||||
LeadsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { resolveTimezone } from "@montech/shared";
|
||||
import { DEFAULT_SECTIONS, SECTION_KEYS, resolveTimezone, type SectionKey } from "@montech/shared";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
function isSectionKey(key: string): key is SectionKey {
|
||||
return (SECTION_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContentService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
@@ -31,58 +35,67 @@ export class ContentService {
|
||||
},
|
||||
});
|
||||
|
||||
// Keep footer section contact fields in sync with site settings
|
||||
// Keep the footer section's contact fields mirrored from site settings.
|
||||
const footer = await this.prisma.sectionContent.findUnique({ where: { key: "footer" } });
|
||||
if (footer) {
|
||||
const prev = (footer.data || {}) as Record<string, unknown>;
|
||||
const addressLines = settings.address
|
||||
? settings.address.split(/\n|,/).map((s) => s.trim()).filter(Boolean)
|
||||
: Array.isArray(prev.address)
|
||||
? (prev.address as string[])
|
||||
: [];
|
||||
const socials: string[] = Array.isArray(settings.socials)
|
||||
? (settings.socials as Array<{ label: string; url: string }>).map((s) => s.label).filter(Boolean)
|
||||
: Array.isArray(prev.socials)
|
||||
? (prev.socials as string[])
|
||||
: [];
|
||||
const nextData: Prisma.InputJsonValue = {
|
||||
...prev,
|
||||
email: settings.email,
|
||||
phone: settings.phone,
|
||||
address: addressLines,
|
||||
socials,
|
||||
};
|
||||
await this.prisma.sectionContent.update({
|
||||
where: { key: "footer" },
|
||||
data: { data: nextData },
|
||||
});
|
||||
}
|
||||
const prev = (footer?.data || DEFAULT_SECTIONS.footer) as Record<string, unknown>;
|
||||
const nextData: Prisma.InputJsonValue = {
|
||||
...prev,
|
||||
email: settings.email,
|
||||
phone: settings.phone,
|
||||
address: settings.address,
|
||||
} as Prisma.InputJsonObject;
|
||||
await this.prisma.sectionContent.upsert({
|
||||
where: { key: "footer" },
|
||||
create: { key: "footer", data: nextData },
|
||||
update: { data: nextData },
|
||||
});
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
/** Every known section, falling back to built-in defaults for rows that do not exist yet. */
|
||||
async listSections() {
|
||||
return this.prisma.sectionContent.findMany({ orderBy: { key: "asc" } });
|
||||
const rows = await this.prisma.sectionContent.findMany({
|
||||
where: { key: { in: [...SECTION_KEYS] } },
|
||||
});
|
||||
const byKey = new Map(rows.map((r) => [r.key, r]));
|
||||
return SECTION_KEYS.map((key) => {
|
||||
const row = byKey.get(key);
|
||||
return {
|
||||
key,
|
||||
data: row?.data ?? DEFAULT_SECTIONS[key],
|
||||
updatedAt: row?.updatedAt ?? null,
|
||||
isDefault: !row,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getSection(key: string) {
|
||||
if (!isSectionKey(key)) throw new BadRequestException(`Unknown section ${key}`);
|
||||
const row = await this.prisma.sectionContent.findUnique({ where: { key } });
|
||||
if (!row) throw new NotFoundException(`Section ${key} not found`);
|
||||
return row;
|
||||
return {
|
||||
key,
|
||||
data: row?.data ?? DEFAULT_SECTIONS[key],
|
||||
updatedAt: row?.updatedAt ?? null,
|
||||
isDefault: !row,
|
||||
defaults: DEFAULT_SECTIONS[key],
|
||||
};
|
||||
}
|
||||
|
||||
async upsertSection(key: string, data: object) {
|
||||
if (!isSectionKey(key)) throw new BadRequestException(`Unknown section ${key}`);
|
||||
const json = data as Prisma.InputJsonObject;
|
||||
return this.prisma.sectionContent.upsert({
|
||||
where: { key },
|
||||
create: { key, data },
|
||||
update: { data },
|
||||
create: { key, data: json },
|
||||
update: { data: json },
|
||||
});
|
||||
}
|
||||
|
||||
async getPublicSite() {
|
||||
const [settingsRaw, sections, pages] = await Promise.all([
|
||||
this.getSettings(),
|
||||
this.listSections(),
|
||||
this.prisma.sectionContent.findMany({ where: { key: { in: [...SECTION_KEYS] } } }),
|
||||
this.prisma.page.findMany({ include: { seo: true } }),
|
||||
]);
|
||||
const settings = {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Query, Req, UseGuards } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
||||
import { CreateLeadDto, ListLeadsQuery, UpdateLeadDto } from "./leads.dto";
|
||||
import { LeadsService } from "./leads.service";
|
||||
|
||||
function clientIp(req: Request) {
|
||||
const forwarded = req.headers["x-forwarded-for"];
|
||||
const first = Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(",")[0];
|
||||
return (first || req.ip || "unknown").trim();
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class LeadsController {
|
||||
constructor(private leads: LeadsService) {}
|
||||
|
||||
@Post("public/leads")
|
||||
@HttpCode(201)
|
||||
create(@Body() dto: CreateLeadDto, @Req() req: Request) {
|
||||
return this.leads.create(dto, clientIp(req));
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("admin/leads")
|
||||
list(@Query() query: ListLeadsQuery) {
|
||||
return this.leads.list(query);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch("admin/leads/:id")
|
||||
update(@Param("id") id: string, @Body() dto: UpdateLeadDto) {
|
||||
return this.leads.update(id, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete("admin/leads/:id")
|
||||
remove(@Param("id") id: string) {
|
||||
return this.leads.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Transform, Type } from "class-transformer";
|
||||
import { IsIn, IsInt, IsOptional, IsString, Matches, Max, MaxLength, Min, MinLength } from "class-validator";
|
||||
import { LEAD_SOURCES, LEAD_STATUSES } from "@montech/shared";
|
||||
|
||||
const toAsciiDigits = (v: unknown) =>
|
||||
typeof v === "string"
|
||||
? v
|
||||
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
|
||||
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660))
|
||||
.replace(/[\s\-()]/g, "")
|
||||
: v;
|
||||
|
||||
const trim = ({ value }: { value: unknown }) => (typeof value === "string" ? value.trim() : value);
|
||||
|
||||
export class CreateLeadDto {
|
||||
@Transform(trim)
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@Transform(({ value }) => toAsciiDigits(value))
|
||||
@IsString()
|
||||
@Matches(/^\+?\d{7,15}$/, { message: "phone must be a valid phone number" })
|
||||
phone!: string;
|
||||
|
||||
@Transform(trim)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
businessType!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(LEAD_SOURCES)
|
||||
source?: (typeof LEAD_SOURCES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@Transform(trim)
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
aiQuery?: string;
|
||||
}
|
||||
|
||||
export class UpdateLeadDto {
|
||||
@IsOptional()
|
||||
@IsIn(LEAD_STATUSES)
|
||||
status?: (typeof LEAD_STATUSES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class ListLeadsQuery {
|
||||
@IsOptional()
|
||||
@IsIn(LEAD_STATUSES)
|
||||
status?: (typeof LEAD_STATUSES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { LeadsController } from "./leads.controller";
|
||||
import { LeadsService } from "./leads.service";
|
||||
|
||||
@Module({
|
||||
controllers: [LeadsController],
|
||||
providers: [LeadsService],
|
||||
})
|
||||
export class LeadsModule {}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { HttpException, HttpStatus, Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreateLeadDto, ListLeadsQuery, UpdateLeadDto } from "./leads.dto";
|
||||
|
||||
const WINDOW_MS = 10 * 60 * 1000;
|
||||
const MAX_PER_WINDOW = 5;
|
||||
|
||||
@Injectable()
|
||||
export class LeadsService {
|
||||
private readonly hits = new Map<string, number[]>();
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/** Simple in-memory per-IP limiter for the public form (single API instance). */
|
||||
private assertRateLimit(ip: string) {
|
||||
const now = Date.now();
|
||||
const recent = (this.hits.get(ip) || []).filter((t) => now - t < WINDOW_MS);
|
||||
if (recent.length >= MAX_PER_WINDOW) {
|
||||
throw new HttpException("Too many requests", HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
recent.push(now);
|
||||
this.hits.set(ip, recent);
|
||||
if (this.hits.size > 10_000) this.hits.clear();
|
||||
}
|
||||
|
||||
create(dto: CreateLeadDto, ip: string) {
|
||||
this.assertRateLimit(ip);
|
||||
return this.prisma.lead.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
phone: dto.phone,
|
||||
businessType: dto.businessType,
|
||||
source: dto.source ?? "consult",
|
||||
aiQuery: dto.aiQuery || null,
|
||||
ip,
|
||||
},
|
||||
select: { id: true, createdAt: true },
|
||||
});
|
||||
}
|
||||
|
||||
async list(query: ListLeadsQuery) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where = query.status ? { status: query.status } : {};
|
||||
const [items, total, newCount] = await Promise.all([
|
||||
this.prisma.lead.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.lead.count({ where }),
|
||||
this.prisma.lead.count({ where: { status: "new" } }),
|
||||
]);
|
||||
return { items, total, page, pageSize, newCount };
|
||||
}
|
||||
|
||||
update(id: string, dto: UpdateLeadDto) {
|
||||
return this.prisma.lead.update({ where: { id }, data: dto });
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
return this.prisma.lead.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user