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:
2026-09-30 18:42:28 +03:30
co-authored by Cursor
commit 7e8e1b1f1b
96 changed files with 19336 additions and 0 deletions
+8
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+48
View File
@@ -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"
+95
View File
@@ -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())
}
+234
View File
@@ -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();
});
+25
View File
@@ -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 {}
+20
View File
@@ -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);
}
}
+20
View File
@@ -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 {}
+38
View File
@@ -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;
}
}
+10
View File
@@ -0,0 +1,10 @@
import { IsString, MinLength } from "class-validator";
export class LoginDto {
@IsString()
mobile!: string;
@IsString()
@MinLength(6)
password!: string;
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {}
+18
View File
@@ -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 };
}
}
+58
View File
@@ -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);
}
}
+32
View File
@@ -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;
}
+9
View File
@@ -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 {}
+92
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+53
View File
@@ -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);
}
}
+9
View File
@@ -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 {}
+47
View File
@@ -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 } });
}
}
+13
View File
@@ -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 {}
+32
View File
@@ -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();
+41
View File
@@ -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);
}
}
+9
View File
@@ -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 {}
+47
View File
@@ -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 } });
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service";
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+13
View File
@@ -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();
}
}
+34
View File
@@ -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);
}
}
+9
View File
@@ -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 {}
+45
View File
@@ -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,
});
}
}
+23
View File
@@ -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/**/*"]
}