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:
S. Esmaeili
2026-10-21 20:31:05 +03:30
co-authored by Claude Opus 5.5
parent 31076174e2
commit 789d224ea0
395 changed files with 19070 additions and 11592 deletions
@@ -0,0 +1,25 @@
import { timingSafeEqual } from "node:crypto";
/**
* Called by the API after content changes: drops all cached pages so the next request
* renders fresh HTML. Protected by a shared secret (NUXT_REVALIDATE_SECRET).
*/
export default defineEventHandler(async (event) => {
const expected = useRuntimeConfig().revalidateSecret;
const given = getHeader(event, "x-revalidate-secret") ?? "";
if (!expected || !safeEqual(given, expected)) {
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
}
// route-rule caches (swr) are stored as cache:nitro:routes:*
const storage = useStorage("cache");
const keys = await storage.getKeys("nitro:routes");
await Promise.all(keys.map((k) => storage.removeItem(k)));
return { cleared: keys.length };
});
function safeEqual(a: string, b: string) {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
return ab.length === bb.length && timingSafeEqual(ab, bb);
}
@@ -0,0 +1,27 @@
import { siteConfig } from "../../shared/config/site";
export default defineEventHandler(async (event) => {
const name = await fetchSite()
.then((s) => s.sections.settings.name)
.catch(() => "ایمن پیشگامان");
setHeader(event, "Content-Type", "application/manifest+json; charset=utf-8");
return {
name,
short_name: name,
lang: siteConfig.lang,
dir: siteConfig.dir,
start_url: "/",
display: "standalone",
background_color: siteConfig.themeColor,
theme_color: siteConfig.themeColor,
icons: [
{
src: siteConfig.favicon,
sizes: "any",
type: "image/svg+xml",
purpose: "any",
},
],
};
});
+13
View File
@@ -0,0 +1,13 @@
export default defineEventHandler((event) => {
const { siteUrl } = useRuntimeConfig().public;
setHeader(event, "Content-Type", "text/plain; charset=utf-8");
return [
"User-Agent: *",
"Allow: /",
"Disallow: /_internal/",
"",
`Host: ${siteUrl}`,
`Sitemap: ${absUrl("/sitemap.xml")}`,
"",
].join("\n");
});
+22
View File
@@ -0,0 +1,22 @@
interface SitemapEntry {
loc: string;
priority: string;
}
export default defineEventHandler(async (event) => {
const categories = await fetchCategories();
const pages: SitemapEntry[] = [
{ loc: "/", priority: "1" },
...categories.map((c) => ({ loc: `/categories/${c.slug}`, priority: "0.8" })),
];
setHeader(event, "Content-Type", "application/xml; charset=utf-8");
const lastmod = new Date().toISOString();
const urls = pages
.map(
(p) =>
`<url>\n<loc>${absUrl(p.loc)}</loc>\n<lastmod>${lastmod}</lastmod>\n<changefreq>weekly</changefreq>\n<priority>${p.priority}</priority>\n</url>`,
)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
});
+11
View File
@@ -0,0 +1,11 @@
import type { PublicCategorySummary, PublicSite } from "@imen/shared";
/** Server-side call to the backend API (internal network address) */
export const serverApi = <T>(path: string) =>
$fetch<T>(path, { baseURL: useRuntimeConfig().apiInternalUrl, retry: 1 });
export const fetchCategories = () => serverApi<PublicCategorySummary[]>("/public/categories");
export const fetchSite = () => serverApi<PublicSite>("/public/site");
/** Absolute URL on the production domain */
export const absUrl = (p: string) => new URL(p, useRuntimeConfig().public.siteUrl).toString();