feat: monorepo with NestJS API, admin panel and DB-driven website
Restructure the repo into a pnpm + Turborepo monorepo: - apps/web: Nuxt 4 site, now server-rendered and reading all content from the API; pages cached with SWR and purged on content changes - apps/api: NestJS 12 + Prisma 7 + PostgreSQL. Auth with short-lived access tokens and rotating httpOnly refresh cookies, roles (ADMIN, EDITOR), catalog, editable site sections, media upload to S3-compatible storage (converted to WebP), contact messages, audit log - apps/admin: Vue 3 admin panel (Ant Design Vue, Pinia, Tailwind) for products, categories, site sections, messages, media and users - packages/shared: Zod schemas and types shared by API and admin Also: - seed script that loads the original 8 categories / 89 products and section content - Docker images for api (with a one-off migrate service), web, admin, and an nginx gateway for both domains - dev and production compose files, daily database backup service - API e2e tests Removes the committed static out/ build and the old root Dockerfile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
31076174e2
commit
789d224ea0
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Injectable,
|
||||
Module,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Put,
|
||||
ServiceUnavailableException,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
isSectionKey,
|
||||
SECTION_KEYS,
|
||||
sectionSchemas,
|
||||
type SiteSections,
|
||||
toIssues,
|
||||
} from "@imen/shared";
|
||||
import { AdminAuth, type AuthUser, CurrentUser } from "../common/auth.js";
|
||||
import { AuditService, RevalidateService } from "../common/common.module.js";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
|
||||
@Injectable()
|
||||
export class SectionsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly revalidate: RevalidateService,
|
||||
) {}
|
||||
|
||||
/** All sections, parsed through their schemas so missing optional fields get defaults */
|
||||
async getAll(): Promise<SiteSections> {
|
||||
const rows = await this.prisma.siteSection.findMany();
|
||||
const byKey = new Map(rows.map((r) => [r.key, r.data]));
|
||||
const missing = SECTION_KEYS.filter((k) => !byKey.has(k));
|
||||
if (missing.length) {
|
||||
throw new ServiceUnavailableException(
|
||||
`محتوای سایت مقداردهی نشده است (${missing.join(", ")}) — pnpm db:seed را اجرا کنید`,
|
||||
);
|
||||
}
|
||||
return Object.fromEntries(
|
||||
SECTION_KEYS.map((k) => [k, sectionSchemas[k].parse(byKey.get(k))]),
|
||||
) as SiteSections;
|
||||
}
|
||||
|
||||
async get(key: string) {
|
||||
if (!isSectionKey(key)) throw new NotFoundException("بخش پیدا نشد");
|
||||
const row = await this.prisma.siteSection.findUnique({ where: { key } });
|
||||
if (!row) throw new NotFoundException("بخش پیدا نشد");
|
||||
return { key, data: sectionSchemas[key].parse(row.data), updatedAt: row.updatedAt };
|
||||
}
|
||||
|
||||
async update(key: string, body: unknown, user: AuthUser) {
|
||||
if (!isSectionKey(key)) throw new NotFoundException("بخش پیدا نشد");
|
||||
// site-wide settings (contact info, SEO defaults) are limited to admins
|
||||
if (key === "settings" && user.role !== "ADMIN") {
|
||||
throw new ForbiddenException("فقط مدیر کل میتواند تنظیمات سایت را تغییر دهد");
|
||||
}
|
||||
const parsed = sectionSchemas[key].safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestException({
|
||||
statusCode: 400,
|
||||
message: "اطلاعات واردشده معتبر نیست",
|
||||
issues: toIssues(parsed.error),
|
||||
});
|
||||
}
|
||||
const row = await this.prisma.siteSection.upsert({
|
||||
where: { key },
|
||||
create: { key, data: parsed.data, updatedById: user.id },
|
||||
update: { data: parsed.data, updatedById: user.id },
|
||||
});
|
||||
this.audit.log(user.id, "update", "section", key, key);
|
||||
this.revalidate.trigger();
|
||||
return { key, data: parsed.data, updatedAt: row.updatedAt };
|
||||
}
|
||||
}
|
||||
|
||||
@AdminAuth()
|
||||
@Controller("admin/sections")
|
||||
export class SectionsController {
|
||||
constructor(private readonly sections: SectionsService) {}
|
||||
|
||||
@Get(":key")
|
||||
get(@Param("key") key: string) {
|
||||
return this.sections.get(key);
|
||||
}
|
||||
|
||||
@Put(":key")
|
||||
update(@Param("key") key: string, @Body() body: unknown, @CurrentUser() user: AuthUser) {
|
||||
return this.sections.update(key, body, user);
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
controllers: [SectionsController],
|
||||
providers: [SectionsService],
|
||||
exports: [SectionsService],
|
||||
})
|
||||
export class SectionsModule {}
|
||||
Reference in New Issue
Block a user