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,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",
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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`;
|
||||
});
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user