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
+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,
});
}
}