Initial Montech monorepo: Nuxt landing, Vue admin, Nest API.
Scaffold SSR site with CMS/blog/gallery/SEO admin and deploy configs for montech.ir. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
DATABASE_URL="postgresql://montech:montech@127.0.0.1:5432/montech?schema=public"
|
||||
PORT=3050
|
||||
JWT_SECRET="change-me-in-production"
|
||||
CORS_ORIGIN="http://localhost:3000,http://localhost:5174,https://montech.ir"
|
||||
UPLOADS_DIR=
|
||||
ADMIN_MOBILE=09121234567
|
||||
ADMIN_PASSWORD=change-me-strong
|
||||
ADMIN_NAME=Montech Admin
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@montech/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/src/main.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "tsx prisma/seed.ts",
|
||||
"lint": "echo \"api lint placeholder\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@montech/shared": "*",
|
||||
"@nestjs/common": "^11.0.12",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.12",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.12",
|
||||
"@prisma/client": "^6.5.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"multer": "^1.4.5-lts.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.5",
|
||||
"@nestjs/schematics": "^11.0.2",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"prisma": "^6.5.0",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
-- CreateSchema
|
||||
CREATE SCHEMA IF NOT EXISTS "public";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Admin" (
|
||||
"id" TEXT NOT NULL,
|
||||
"mobile" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Admin_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "SiteSettings" (
|
||||
"id" TEXT NOT NULL DEFAULT 'default',
|
||||
"brandName" TEXT NOT NULL DEFAULT 'Montech',
|
||||
"tagline" TEXT NOT NULL DEFAULT 'Software Development Group',
|
||||
"email" TEXT NOT NULL DEFAULT 'hello@montech.ir',
|
||||
"phone" TEXT NOT NULL DEFAULT '+98 21 0000 0000',
|
||||
"address" TEXT NOT NULL DEFAULT 'Tehran, IR',
|
||||
"timezone" TEXT NOT NULL DEFAULT 'Asia/Tehran',
|
||||
"socials" JSONB NOT NULL DEFAULT '[]',
|
||||
"logoUrl" TEXT,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SiteSettings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "Page" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Page_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "PageSeo" (
|
||||
"id" TEXT NOT NULL,
|
||||
"pageId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"ogImage" TEXT,
|
||||
"canonical" TEXT,
|
||||
"noIndex" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "PageSeo_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "SectionContent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"data" JSONB NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SectionContent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "BlogPost" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"excerpt" TEXT NOT NULL DEFAULT '',
|
||||
"content" TEXT NOT NULL DEFAULT '',
|
||||
"coverUrl" TEXT,
|
||||
"published" BOOLEAN NOT NULL DEFAULT false,
|
||||
"publishedAt" TIMESTAMP(3),
|
||||
"seoTitle" TEXT,
|
||||
"seoDesc" TEXT,
|
||||
"ogImage" TEXT,
|
||||
"canonical" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "BlogPost_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "GalleryItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"imageUrl" TEXT NOT NULL,
|
||||
"category" TEXT,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"published" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "GalleryItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "Media" (
|
||||
"id" TEXT NOT NULL,
|
||||
"filename" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Media_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "Admin_mobile_key" ON "Admin"("mobile");
|
||||
CREATE UNIQUE INDEX "Admin_email_key" ON "Admin"("email");
|
||||
CREATE UNIQUE INDEX "Page_key_key" ON "Page"("key");
|
||||
CREATE UNIQUE INDEX "PageSeo_pageId_key" ON "PageSeo"("pageId");
|
||||
CREATE UNIQUE INDEX "SectionContent_key_key" ON "SectionContent"("key");
|
||||
CREATE UNIQUE INDEX "BlogPost_slug_key" ON "BlogPost"("slug");
|
||||
|
||||
ALTER TABLE "PageSeo" ADD CONSTRAINT "PageSeo_pageId_fkey" FOREIGN KEY ("pageId") REFERENCES "Page"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
# This file tells Prisma which database provider to use for migrations.
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,95 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Admin {
|
||||
id String @id @default(cuid())
|
||||
mobile String @unique
|
||||
email String? @unique
|
||||
passwordHash String
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model SiteSettings {
|
||||
id String @id @default("default")
|
||||
brandName String @default("Montech")
|
||||
tagline String @default("Software Development Group")
|
||||
email String @default("hello@montech.ir")
|
||||
phone String @default("+98 21 0000 0000")
|
||||
address String @default("Tehran, IR")
|
||||
timezone String @default("Asia/Tehran")
|
||||
socials Json @default("[]")
|
||||
logoUrl String?
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Page {
|
||||
id String @id @default(cuid())
|
||||
key String @unique
|
||||
title String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
seo PageSeo?
|
||||
}
|
||||
|
||||
model PageSeo {
|
||||
id String @id @default(cuid())
|
||||
pageId String @unique
|
||||
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
description String @default("")
|
||||
ogImage String?
|
||||
canonical String?
|
||||
noIndex Boolean @default(false)
|
||||
}
|
||||
|
||||
model SectionContent {
|
||||
id String @id @default(cuid())
|
||||
key String @unique
|
||||
data Json
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model BlogPost {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
title String
|
||||
excerpt String @default("")
|
||||
content String @default("")
|
||||
coverUrl String?
|
||||
published Boolean @default(false)
|
||||
publishedAt DateTime?
|
||||
seoTitle String?
|
||||
seoDesc String?
|
||||
ogImage String?
|
||||
canonical String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model GalleryItem {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
imageUrl String
|
||||
category String?
|
||||
sortOrder Int @default(0)
|
||||
published Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Media {
|
||||
id String @id @default(cuid())
|
||||
filename String
|
||||
url String
|
||||
mimeType String
|
||||
size Int
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import * as bcrypt from "bcryptjs";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const defaultSections: Record<string, unknown> = {
|
||||
hero: {
|
||||
eyebrow: "SOFTWARE DEVELOPMENT GROUP",
|
||||
location: "[ EST. 2020 — TEHRAN / REMOTE ]",
|
||||
lines: ["We craft", "software that", "performs"],
|
||||
highlightWord: "software",
|
||||
body: "We are a senior engineering group shipping web platforms, mobile apps and cloud infrastructure for companies that refuse to blend in.",
|
||||
cta: "SEE OUR WORK",
|
||||
badge: "MONTECH ENGINEERING • SINCE 2020 •",
|
||||
coords: "35.6892° N / 51.3890° E",
|
||||
projectsLabel: "120+ PROJECTS SHIPPED",
|
||||
},
|
||||
marquee: {
|
||||
items: [
|
||||
"WEB PLATFORMS",
|
||||
"MOBILE APPS",
|
||||
"CLOUD & DEVOPS",
|
||||
"AI SOLUTIONS",
|
||||
"DESIGN SYSTEMS",
|
||||
"API ARCHITECTURE",
|
||||
],
|
||||
},
|
||||
manifesto: {
|
||||
tag: "Who we are",
|
||||
words: [
|
||||
{ text: "We", outline: false },
|
||||
{ text: "build", outline: false },
|
||||
{ text: "software", outline: true },
|
||||
{ text: "that", outline: false },
|
||||
{ text: "moves", outline: false },
|
||||
{ text: "the", outline: false },
|
||||
{ text: "needle.", outline: false },
|
||||
{ text: "MONTECH", outline: false, volt: true },
|
||||
],
|
||||
body: "Strategy, design and engineering under one roof — shipped with obsessive attention to performance and craft.",
|
||||
},
|
||||
services: {
|
||||
tag: "What we do",
|
||||
items: [
|
||||
{
|
||||
n: "01",
|
||||
title: "Product Strategy",
|
||||
desc: "We turn fuzzy ideas into validated roadmaps. Discovery sprints, user research and rapid prototyping de-risk your build before a single line of code is written.",
|
||||
tags: ["Discovery Sprints", "User Research", "Roadmapping", "MVP Scoping"],
|
||||
},
|
||||
{
|
||||
n: "02",
|
||||
title: "Web Engineering",
|
||||
desc: "Blazing-fast, scalable web platforms with modern stacks. From marketing sites to complex SaaS dashboards — engineered for performance, accessibility and conversion.",
|
||||
tags: ["React / Next.js", "Nuxt", "Node.js", "NestJS"],
|
||||
},
|
||||
{
|
||||
n: "03",
|
||||
title: "Mobile Applications",
|
||||
desc: "Native-quality iOS and Android experiences from a single codebase — or fully native when it matters.",
|
||||
tags: ["React Native", "Flutter", "Swift", "Kotlin"],
|
||||
},
|
||||
{
|
||||
n: "04",
|
||||
title: "Cloud & DevOps",
|
||||
desc: "Infrastructure that scales with your ambition. Resilient cloud systems, automated deployments, secure and observable platforms.",
|
||||
tags: ["AWS / GCP", "Kubernetes", "Terraform", "CI/CD"],
|
||||
},
|
||||
{
|
||||
n: "05",
|
||||
title: "AI & Data",
|
||||
desc: "Practical AI, not science projects. LLM integrations, intelligent automation and data pipelines.",
|
||||
tags: ["LLM Integration", "ML Pipelines", "Data Engineering", "Analytics"],
|
||||
},
|
||||
],
|
||||
},
|
||||
work: {
|
||||
tag: "Selected work",
|
||||
items: [
|
||||
{ n: "01", title: "PULSEBOARD", cat: "Fintech Analytics Platform", year: "2024" },
|
||||
{ n: "02", title: "AEROLOOP", cat: "Logistics SaaS", year: "2024" },
|
||||
{ n: "03", title: "MEDSYNC", cat: "HealthTech Platform", year: "2023" },
|
||||
{ n: "04", title: "NOVAPAY", cat: "Payment Infrastructure", year: "2023" },
|
||||
],
|
||||
},
|
||||
process: {
|
||||
tag: "How we ship",
|
||||
steps: [
|
||||
{ n: "01", title: "DISCOVER", desc: "Workshops, research and alignment on outcomes that matter." },
|
||||
{ n: "02", title: "DESIGN", desc: "Systems, flows and interfaces that feel inevitable." },
|
||||
{ n: "03", title: "DEVELOP", desc: "Senior engineers shipping production-ready software." },
|
||||
{ n: "04", title: "DEPLOY & SCALE", desc: "CI/CD, observability and infrastructure that grows with you." },
|
||||
],
|
||||
},
|
||||
stats: {
|
||||
items: [
|
||||
{ value: 120, suffix: "+", label: "Projects shipped" },
|
||||
{ value: 40, suffix: "+", label: "Clients worldwide" },
|
||||
{ value: 15, suffix: "", label: "Engineers & designers" },
|
||||
{ value: 98, suffix: "%", label: "Client retention" },
|
||||
],
|
||||
},
|
||||
team: {
|
||||
tag: "The collective",
|
||||
headline: "Meet the collective",
|
||||
body: "A tight crew of senior builders — product, design and engineering.",
|
||||
members: [
|
||||
{ name: "Alexandra Reyes", role: "CEO & CO-FOUNDER" },
|
||||
{ name: "Marcus Chen", role: "CTO & CO-FOUNDER" },
|
||||
{ name: "Sofia Laurent", role: "HEAD OF DESIGN" },
|
||||
{ name: "David Okafor", role: "PRINCIPAL ENGINEER" },
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
tag: "Contact",
|
||||
marquee: ["GOT A PROJECT IN MIND", "SAY HELLO", "LET'S WORK TOGETHER"],
|
||||
cta: "Start a project",
|
||||
email: "hello@montech.ir",
|
||||
phone: "+98 21 0000 0000",
|
||||
address: ["Tehran", "Iran"],
|
||||
socials: ["LinkedIn", "GitHub", "Dribbble"],
|
||||
},
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const mobile = process.env.ADMIN_MOBILE || "09121234567";
|
||||
const password = process.env.ADMIN_PASSWORD || "change-me-strong";
|
||||
const name = process.env.ADMIN_NAME || "Montech Admin";
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
|
||||
await prisma.admin.upsert({
|
||||
where: { mobile },
|
||||
create: { mobile, passwordHash: hash, name, email: "admin@montech.ir" },
|
||||
update: { passwordHash: hash, name },
|
||||
});
|
||||
|
||||
await prisma.siteSettings.upsert({
|
||||
where: { id: "default" },
|
||||
create: {
|
||||
id: "default",
|
||||
brandName: "Montech",
|
||||
tagline: "Software Development Group",
|
||||
email: "hello@montech.ir",
|
||||
phone: "+98 21 0000 0000",
|
||||
address: "Tehran, IR",
|
||||
timezone: "Asia/Tehran",
|
||||
socials: [
|
||||
{ label: "LinkedIn", url: "https://linkedin.com" },
|
||||
{ label: "GitHub", url: "https://github.com" },
|
||||
],
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const pages = [
|
||||
{
|
||||
key: "home",
|
||||
title: "Home",
|
||||
seo: {
|
||||
title: "Montech — Software Development Group",
|
||||
description:
|
||||
"Montech engineers high-performance web platforms, mobile apps and cloud infrastructure. Based in Tehran, shipping worldwide.",
|
||||
canonical: "https://montech.ir/",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "blog",
|
||||
title: "Blog",
|
||||
seo: {
|
||||
title: "Blog — Montech",
|
||||
description: "Notes on product, engineering and shipping software.",
|
||||
canonical: "https://montech.ir/blog",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "gallery",
|
||||
title: "Gallery",
|
||||
seo: {
|
||||
title: "Gallery — Montech",
|
||||
description: "Selected visuals from Montech projects and studio life.",
|
||||
canonical: "https://montech.ir/gallery",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const p of pages) {
|
||||
const page = await prisma.page.upsert({
|
||||
where: { key: p.key },
|
||||
create: { key: p.key, title: p.title },
|
||||
update: { title: p.title },
|
||||
});
|
||||
await prisma.pageSeo.upsert({
|
||||
where: { pageId: page.id },
|
||||
create: { pageId: page.id, ...p.seo },
|
||||
update: { ...p.seo },
|
||||
});
|
||||
}
|
||||
|
||||
for (const [key, data] of Object.entries(defaultSections)) {
|
||||
await prisma.sectionContent.upsert({
|
||||
where: { key },
|
||||
create: { key, data: data as object },
|
||||
update: { data: data as object },
|
||||
});
|
||||
}
|
||||
|
||||
const existingPost = await prisma.blogPost.findUnique({ where: { slug: "welcome-to-montech" } });
|
||||
if (!existingPost) {
|
||||
await prisma.blogPost.create({
|
||||
data: {
|
||||
slug: "welcome-to-montech",
|
||||
title: "Welcome to Montech",
|
||||
excerpt: "We build software that performs — and this is where we write about it.",
|
||||
content:
|
||||
"<p>Montech is a software development group shipping web platforms, mobile apps and cloud infrastructure.</p><p>This blog covers product craft, engineering practice and lessons from the studio.</p>",
|
||||
published: true,
|
||||
publishedAt: new Date(),
|
||||
seoTitle: "Welcome to Montech — Blog",
|
||||
seoDesc: "Introducing Montech and how we ship software.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Seed complete. Admin:", mobile);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { HealthModule } from "./health/health.module";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { ContentModule } from "./content/content.module";
|
||||
import { BlogModule } from "./blog/blog.module";
|
||||
import { GalleryModule } from "./gallery/gallery.module";
|
||||
import { MediaModule } from "./media/media.module";
|
||||
import { SeoModule } from "./seo/seo.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
HealthModule,
|
||||
AuthModule,
|
||||
ContentModule,
|
||||
BlogModule,
|
||||
GalleryModule,
|
||||
MediaModule,
|
||||
SeoModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Controller, Post, Body, Get, UseGuards, Req } from "@nestjs/common";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { LoginDto } from "./dto";
|
||||
import { JwtAuthGuard } from "./jwt-auth.guard";
|
||||
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(private auth: AuthService) {}
|
||||
|
||||
@Post("login")
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.auth.login(dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("me")
|
||||
me(@Req() req: { user: { id: string } }) {
|
||||
return this.auth.me(req.user.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { JwtStrategy } from "./jwt.strategy";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || "montech-dev-secret-change-me",
|
||||
signOptions: { expiresIn: "7d" },
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import * as bcrypt from "bcryptjs";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { LoginDto } from "./dto";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwt: JwtService,
|
||||
) {}
|
||||
|
||||
async login(dto: LoginDto) {
|
||||
const admin = await this.prisma.admin.findUnique({ where: { mobile: dto.mobile } });
|
||||
if (!admin) throw new UnauthorizedException("Invalid credentials");
|
||||
const ok = await bcrypt.compare(dto.password, admin.passwordHash);
|
||||
if (!ok) throw new UnauthorizedException("Invalid credentials");
|
||||
const token = await this.jwt.signAsync({
|
||||
sub: admin.id,
|
||||
mobile: admin.mobile,
|
||||
name: admin.name,
|
||||
});
|
||||
return {
|
||||
accessToken: token,
|
||||
admin: { id: admin.id, mobile: admin.mobile, name: admin.name, email: admin.email },
|
||||
};
|
||||
}
|
||||
|
||||
async me(adminId: string) {
|
||||
const admin = await this.prisma.admin.findUnique({
|
||||
where: { id: adminId },
|
||||
select: { id: true, mobile: true, name: true, email: true },
|
||||
});
|
||||
if (!admin) throw new UnauthorizedException();
|
||||
return admin;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
mobile!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard("jwt") {}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor() {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_SECRET || "montech-dev-secret-change-me",
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: { sub: string; mobile: string; name: string }) {
|
||||
return { id: payload.sub, mobile: payload.mobile, name: payload.name };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { BlogService } from "./blog.service";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
||||
import { CreateBlogDto, UpdateBlogDto } from "./blog.dto";
|
||||
|
||||
@Controller()
|
||||
export class BlogController {
|
||||
constructor(private blog: BlogService) {}
|
||||
|
||||
@Get("public/blog")
|
||||
listPublic() {
|
||||
return this.blog.listPublic();
|
||||
}
|
||||
|
||||
@Get("public/blog/:slug")
|
||||
getPublic(@Param("slug") slug: string) {
|
||||
return this.blog.getBySlug(slug);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("admin/blog")
|
||||
listAdmin() {
|
||||
return this.blog.listAdmin();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("admin/blog/:id")
|
||||
getAdmin(@Param("id") id: string) {
|
||||
return this.blog.getAdmin(id);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post("admin/blog")
|
||||
create(@Body() dto: CreateBlogDto) {
|
||||
return this.blog.create(dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put("admin/blog/:id")
|
||||
update(@Param("id") id: string, @Body() dto: UpdateBlogDto) {
|
||||
return this.blog.update(id, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete("admin/blog/:id")
|
||||
remove(@Param("id") id: string) {
|
||||
return this.blog.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
|
||||
export class CreateBlogDto {
|
||||
@IsString() @MinLength(1) slug!: string;
|
||||
@IsString() @MinLength(1) title!: string;
|
||||
@IsOptional() @IsString() excerpt?: string;
|
||||
@IsOptional() @IsString() content?: string;
|
||||
@IsOptional() @IsString() coverUrl?: string;
|
||||
@IsOptional() @IsBoolean() published?: boolean;
|
||||
@IsOptional() @IsString() seoTitle?: string;
|
||||
@IsOptional() @IsString() seoDesc?: string;
|
||||
@IsOptional() @IsString() ogImage?: string;
|
||||
@IsOptional() @IsString() canonical?: string;
|
||||
}
|
||||
|
||||
export class UpdateBlogDto {
|
||||
@IsOptional() @IsString() @MinLength(1) slug?: string;
|
||||
@IsOptional() @IsString() @MinLength(1) title?: string;
|
||||
@IsOptional() @IsString() excerpt?: string;
|
||||
@IsOptional() @IsString() content?: string;
|
||||
@IsOptional() @IsString() coverUrl?: string;
|
||||
@IsOptional() @IsBoolean() published?: boolean;
|
||||
@IsOptional() @IsString() seoTitle?: string;
|
||||
@IsOptional() @IsString() seoDesc?: string;
|
||||
@IsOptional() @IsString() ogImage?: string;
|
||||
@IsOptional() @IsString() canonical?: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { BlogService } from "./blog.service";
|
||||
import { BlogController } from "./blog.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [BlogController],
|
||||
providers: [BlogService],
|
||||
})
|
||||
export class BlogModule {}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class BlogService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
listPublic() {
|
||||
return this.prisma.blogPost.findMany({
|
||||
where: { published: true },
|
||||
orderBy: { publishedAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
excerpt: true,
|
||||
coverUrl: true,
|
||||
publishedAt: true,
|
||||
seoTitle: true,
|
||||
seoDesc: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getBySlug(slug: string) {
|
||||
const post = await this.prisma.blogPost.findFirst({
|
||||
where: { slug, published: true },
|
||||
});
|
||||
if (!post) throw new NotFoundException();
|
||||
return post;
|
||||
}
|
||||
|
||||
listAdmin() {
|
||||
return this.prisma.blogPost.findMany({ orderBy: { updatedAt: "desc" } });
|
||||
}
|
||||
|
||||
getAdmin(id: string) {
|
||||
return this.prisma.blogPost.findUniqueOrThrow({ where: { id } });
|
||||
}
|
||||
|
||||
create(data: {
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt?: string;
|
||||
content?: string;
|
||||
coverUrl?: string;
|
||||
published?: boolean;
|
||||
seoTitle?: string;
|
||||
seoDesc?: string;
|
||||
ogImage?: string;
|
||||
canonical?: string;
|
||||
}) {
|
||||
return this.prisma.blogPost.create({
|
||||
data: {
|
||||
...data,
|
||||
publishedAt: data.published ? new Date() : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: {
|
||||
slug?: string;
|
||||
title?: string;
|
||||
excerpt?: string;
|
||||
content?: string;
|
||||
coverUrl?: string | null;
|
||||
published?: boolean;
|
||||
seoTitle?: string | null;
|
||||
seoDesc?: string | null;
|
||||
ogImage?: string | null;
|
||||
canonical?: string | null;
|
||||
},
|
||||
) {
|
||||
const existing = await this.prisma.blogPost.findUniqueOrThrow({ where: { id } });
|
||||
const publishedAt =
|
||||
data.published === true && !existing.publishedAt
|
||||
? new Date()
|
||||
: data.published === false
|
||||
? null
|
||||
: existing.publishedAt;
|
||||
return this.prisma.blogPost.update({
|
||||
where: { id },
|
||||
data: { ...data, publishedAt },
|
||||
});
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
return this.prisma.blogPost.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Body, Controller, Get, Param, Put, UseGuards } from "@nestjs/common";
|
||||
import { ContentService } from "./content.service";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
||||
import { IsObject, IsOptional, IsString } from "class-validator";
|
||||
|
||||
class UpdateSettingsDto {
|
||||
@IsOptional() @IsString() brandName?: string;
|
||||
@IsOptional() @IsString() tagline?: string;
|
||||
@IsOptional() @IsString() email?: string;
|
||||
@IsOptional() @IsString() phone?: string;
|
||||
@IsOptional() @IsString() address?: string;
|
||||
@IsOptional() @IsString() timezone?: string;
|
||||
@IsOptional() socials?: object;
|
||||
@IsOptional() @IsString() logoUrl?: string;
|
||||
}
|
||||
|
||||
class UpsertSectionDto {
|
||||
@IsObject()
|
||||
data!: object;
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class ContentController {
|
||||
constructor(private content: ContentService) {}
|
||||
|
||||
@Get("public/site")
|
||||
publicSite() {
|
||||
return this.content.getPublicSite();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("admin/settings")
|
||||
getSettings() {
|
||||
return this.content.getSettings();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put("admin/settings")
|
||||
updateSettings(@Body() dto: UpdateSettingsDto) {
|
||||
return this.content.updateSettings(dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("admin/sections")
|
||||
listSections() {
|
||||
return this.content.listSections();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("admin/sections/:key")
|
||||
getSection(@Param("key") key: string) {
|
||||
return this.content.getSection(key);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put("admin/sections/:key")
|
||||
upsertSection(@Param("key") key: string, @Body() dto: UpsertSectionDto) {
|
||||
return this.content.upsertSection(key, dto.data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ContentService } from "./content.service";
|
||||
import { ContentController } from "./content.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [ContentController],
|
||||
providers: [ContentService],
|
||||
exports: [ContentService],
|
||||
})
|
||||
export class ContentModule {}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class ContentService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
getSettings() {
|
||||
return this.prisma.siteSettings.findUniqueOrThrow({ where: { id: "default" } });
|
||||
}
|
||||
|
||||
updateSettings(data: {
|
||||
brandName?: string;
|
||||
tagline?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
timezone?: string;
|
||||
socials?: object;
|
||||
logoUrl?: string | null;
|
||||
}) {
|
||||
return this.prisma.siteSettings.update({ where: { id: "default" }, data });
|
||||
}
|
||||
|
||||
async listSections() {
|
||||
return this.prisma.sectionContent.findMany({ orderBy: { key: "asc" } });
|
||||
}
|
||||
|
||||
async getSection(key: string) {
|
||||
const row = await this.prisma.sectionContent.findUnique({ where: { key } });
|
||||
if (!row) throw new NotFoundException(`Section ${key} not found`);
|
||||
return row;
|
||||
}
|
||||
|
||||
async upsertSection(key: string, data: object) {
|
||||
return this.prisma.sectionContent.upsert({
|
||||
where: { key },
|
||||
create: { key, data },
|
||||
update: { data },
|
||||
});
|
||||
}
|
||||
|
||||
async getPublicSite() {
|
||||
const [settings, sections, pages] = await Promise.all([
|
||||
this.getSettings(),
|
||||
this.listSections(),
|
||||
this.prisma.page.findMany({ include: { seo: true } }),
|
||||
]);
|
||||
const sectionMap: Record<string, unknown> = {};
|
||||
for (const s of sections) sectionMap[s.key] = s.data;
|
||||
return { settings, sections: sectionMap, pages };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, MinLength } from "class-validator";
|
||||
import { GalleryService } from "./gallery.service";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
||||
|
||||
class GalleryDto {
|
||||
@IsString() @MinLength(1) title!: string;
|
||||
@IsString() @MinLength(1) imageUrl!: string;
|
||||
@IsOptional() @IsString() category?: string;
|
||||
@IsOptional() @IsInt() sortOrder?: number;
|
||||
@IsOptional() @IsBoolean() published?: boolean;
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class GalleryController {
|
||||
constructor(private gallery: GalleryService) {}
|
||||
|
||||
@Get("public/gallery")
|
||||
listPublic() {
|
||||
return this.gallery.listPublic();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("admin/gallery")
|
||||
listAdmin() {
|
||||
return this.gallery.listAdmin();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post("admin/gallery")
|
||||
create(@Body() dto: GalleryDto) {
|
||||
return this.gallery.create(dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put("admin/gallery/:id")
|
||||
update(@Param("id") id: string, @Body() dto: Partial<GalleryDto>) {
|
||||
return this.gallery.update(id, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete("admin/gallery/:id")
|
||||
remove(@Param("id") id: string) {
|
||||
return this.gallery.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { GalleryService } from "./gallery.service";
|
||||
import { GalleryController } from "./gallery.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [GalleryController],
|
||||
providers: [GalleryService],
|
||||
})
|
||||
export class GalleryModule {}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class GalleryService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
listPublic() {
|
||||
return this.prisma.galleryItem.findMany({
|
||||
where: { published: true },
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
||||
});
|
||||
}
|
||||
|
||||
listAdmin() {
|
||||
return this.prisma.galleryItem.findMany({
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
||||
});
|
||||
}
|
||||
|
||||
create(data: {
|
||||
title: string;
|
||||
imageUrl: string;
|
||||
category?: string;
|
||||
sortOrder?: number;
|
||||
published?: boolean;
|
||||
}) {
|
||||
return this.prisma.galleryItem.create({ data });
|
||||
}
|
||||
|
||||
update(
|
||||
id: string,
|
||||
data: {
|
||||
title?: string;
|
||||
imageUrl?: string;
|
||||
category?: string | null;
|
||||
sortOrder?: number;
|
||||
published?: boolean;
|
||||
},
|
||||
) {
|
||||
return this.prisma.galleryItem.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
return this.prisma.galleryItem.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Controller("health")
|
||||
class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return { ok: true, service: "montech-api" };
|
||||
}
|
||||
}
|
||||
|
||||
@Module({ controllers: [HealthController] })
|
||||
export class HealthModule {}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { join } from "path";
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
app.setGlobalPrefix("api");
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const uploadsRoot = process.env.UPLOADS_DIR || join(process.cwd(), "..", "..", "uploads");
|
||||
app.useStaticAssets(uploadsRoot, { prefix: "/api/uploads/" });
|
||||
|
||||
const origin = process.env.CORS_ORIGIN || "http://localhost:3000,http://localhost:5174";
|
||||
app.enableCors({
|
||||
origin: origin.split(",").map((s) => s.trim()),
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
const port = Number(process.env.PORT || 3050);
|
||||
await app.listen(port, "0.0.0.0");
|
||||
console.log(`Montech API listening on :${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { memoryStorage } from "multer";
|
||||
import { MediaService } from "./media.service";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
||||
|
||||
@Controller("admin/media")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MediaController {
|
||||
constructor(private media: MediaService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.media.list();
|
||||
}
|
||||
|
||||
@Post("upload")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 15 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
upload(@UploadedFile() file: Express.Multer.File) {
|
||||
return this.media.saveFile(file);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
remove(@Param("id") id: string) {
|
||||
return this.media.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { MediaService } from "./media.service";
|
||||
import { MediaController } from "./media.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [MediaController],
|
||||
providers: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { join } from "path";
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
uploadsRoot() {
|
||||
return process.env.UPLOADS_DIR || join(process.cwd(), "..", "..", "uploads");
|
||||
}
|
||||
|
||||
ensureDir() {
|
||||
const dir = this.uploadsRoot();
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
list() {
|
||||
return this.prisma.media.findMany({ orderBy: { createdAt: "desc" } });
|
||||
}
|
||||
|
||||
async saveFile(file: Express.Multer.File) {
|
||||
const dir = this.ensureDir();
|
||||
const ext = file.originalname.includes(".")
|
||||
? file.originalname.slice(file.originalname.lastIndexOf("."))
|
||||
: "";
|
||||
const filename = `${randomUUID()}${ext}`;
|
||||
const { writeFileSync } = await import("fs");
|
||||
writeFileSync(join(dir, filename), file.buffer);
|
||||
const url = `/api/uploads/${filename}`;
|
||||
return this.prisma.media.create({
|
||||
data: {
|
||||
filename,
|
||||
url,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
return this.prisma.media.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { PrismaService } from "./prisma.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Body, Controller, Get, Param, Put, UseGuards } from "@nestjs/common";
|
||||
import { IsBoolean, IsOptional, IsString } from "class-validator";
|
||||
import { SeoService } from "./seo.service";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
||||
|
||||
class SeoDto {
|
||||
@IsOptional() @IsString() title?: string;
|
||||
@IsOptional() @IsString() description?: string;
|
||||
@IsOptional() @IsString() ogImage?: string;
|
||||
@IsOptional() @IsString() canonical?: string;
|
||||
@IsOptional() @IsBoolean() noIndex?: boolean;
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class SeoController {
|
||||
constructor(private seo: SeoService) {}
|
||||
|
||||
@Get("public/pages/:key")
|
||||
getPublic(@Param("key") key: string) {
|
||||
return this.seo.getByKey(key);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("admin/pages")
|
||||
list() {
|
||||
return this.seo.listPages();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put("admin/pages/:key/seo")
|
||||
update(@Param("key") key: string, @Body() dto: SeoDto) {
|
||||
return this.seo.updateSeo(key, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SeoService } from "./seo.service";
|
||||
import { SeoController } from "./seo.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [SeoController],
|
||||
providers: [SeoService],
|
||||
})
|
||||
export class SeoModule {}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class SeoService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
listPages() {
|
||||
return this.prisma.page.findMany({ include: { seo: true }, orderBy: { key: "asc" } });
|
||||
}
|
||||
|
||||
async getByKey(key: string) {
|
||||
const page = await this.prisma.page.findUnique({
|
||||
where: { key },
|
||||
include: { seo: true },
|
||||
});
|
||||
if (!page) throw new NotFoundException();
|
||||
return page;
|
||||
}
|
||||
|
||||
async updateSeo(
|
||||
key: string,
|
||||
data: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
ogImage?: string | null;
|
||||
canonical?: string | null;
|
||||
noIndex?: boolean;
|
||||
},
|
||||
) {
|
||||
const page = await this.prisma.page.findUniqueOrThrow({ where: { key } });
|
||||
return this.prisma.pageSeo.upsert({
|
||||
where: { pageId: page.id },
|
||||
create: {
|
||||
pageId: page.id,
|
||||
title: data.title || page.title,
|
||||
description: data.description || "",
|
||||
ogImage: data.ogImage,
|
||||
canonical: data.canonical,
|
||||
noIndex: data.noIndex ?? false,
|
||||
},
|
||||
update: data,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*", "prisma/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user