modern-cms 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/bin/lib.js +97 -0
- package/bin/modern-cms.js +197 -0
- package/package.json +37 -0
- package/template/.env.example +48 -0
- package/template/README.md +210 -0
- package/template/apps/api/Dockerfile +26 -0
- package/template/apps/api/package.json +45 -0
- package/template/apps/api/prisma/backfill-search-text.ts +31 -0
- package/template/apps/api/prisma/migrations/20260701034530_init/migration.sql +86 -0
- package/template/apps/api/prisma/migrations/20260701081942_add_publish_at_and_revisions/migration.sql +19 -0
- package/template/apps/api/prisma/migrations/20260701085011_add_form_submissions/migration.sql +13 -0
- package/template/apps/api/prisma/migrations/20260702084049_add_global_sections_and_saved_blocks/migration.sql +24 -0
- package/template/apps/api/prisma/migrations/20260702085609_add_page_locale_and_translations/migration.sql +6 -0
- package/template/apps/api/prisma/migrations/20260702091914_add_collections/migration.sql +38 -0
- package/template/apps/api/prisma/migrations/20260702120000_platform_hardening/migration.sql +15 -0
- package/template/apps/api/prisma/migrations/20260705031230_add_collections_redirects_media_meta/migration.sql +17 -0
- package/template/apps/api/prisma/migrations/20260705033241_add_audit_log/migration.sql +18 -0
- package/template/apps/api/prisma/migrations/20260706031301_standard_cms_platform_pass/migration.sql +48 -0
- package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
- package/template/apps/api/prisma/reset-admin.ts +19 -0
- package/template/apps/api/prisma/schema.prisma +251 -0
- package/template/apps/api/prisma/seed-homepage.ts +752 -0
- package/template/apps/api/prisma/seed-our-charities.ts +231 -0
- package/template/apps/api/prisma/seed-templates.ts +154 -0
- package/template/apps/api/prisma/seed.ts +191 -0
- package/template/apps/api/src/env.ts +61 -0
- package/template/apps/api/src/index.ts +66 -0
- package/template/apps/api/src/lib/revalidate.ts +28 -0
- package/template/apps/api/src/lib/storage/azure.ts +25 -0
- package/template/apps/api/src/lib/storage/cloudinary.ts +42 -0
- package/template/apps/api/src/lib/storage/index.ts +25 -0
- package/template/apps/api/src/lib/storage/s3.ts +39 -0
- package/template/apps/api/src/lib/storage/types.ts +8 -0
- package/template/apps/api/src/middleware/auditLog.ts +32 -0
- package/template/apps/api/src/middleware/auth.ts +53 -0
- package/template/apps/api/src/middleware/rateLimit.ts +31 -0
- package/template/apps/api/src/middleware/securityHeaders.ts +10 -0
- package/template/apps/api/src/prisma.ts +3 -0
- package/template/apps/api/src/routes/audit.routes.ts +26 -0
- package/template/apps/api/src/routes/auth.routes.ts +46 -0
- package/template/apps/api/src/routes/backup.routes.ts +216 -0
- package/template/apps/api/src/routes/blocks.routes.ts +38 -0
- package/template/apps/api/src/routes/collections.routes.ts +185 -0
- package/template/apps/api/src/routes/forms.routes.ts +114 -0
- package/template/apps/api/src/routes/globalSections.routes.ts +43 -0
- package/template/apps/api/src/routes/media.routes.ts +77 -0
- package/template/apps/api/src/routes/pages.routes.ts +319 -0
- package/template/apps/api/src/routes/redirects.routes.ts +52 -0
- package/template/apps/api/src/routes/search.routes.ts +83 -0
- package/template/apps/api/src/routes/templates.routes.ts +41 -0
- package/template/apps/api/src/routes/theme.routes.ts +75 -0
- package/template/apps/api/src/routes/users.routes.ts +39 -0
- package/template/apps/api/tsconfig.json +11 -0
- package/template/apps/web/Dockerfile +26 -0
- package/template/apps/web/app/[[...slug]]/page.tsx +254 -0
- package/template/apps/web/app/admin/(protected)/audit/page.tsx +80 -0
- package/template/apps/web/app/admin/(protected)/collections/item/[itemId]/page.tsx +375 -0
- package/template/apps/web/app/admin/(protected)/collections/page.tsx +224 -0
- package/template/apps/web/app/admin/(protected)/forms/page.tsx +88 -0
- package/template/apps/web/app/admin/(protected)/layout.tsx +5 -0
- package/template/apps/web/app/admin/(protected)/media/page.tsx +138 -0
- package/template/apps/web/app/admin/(protected)/page.tsx +186 -0
- package/template/apps/web/app/admin/(protected)/pages/[id]/page.tsx +560 -0
- package/template/apps/web/app/admin/(protected)/settings/backup/page.tsx +97 -0
- package/template/apps/web/app/admin/(protected)/settings/global-sections/page.tsx +333 -0
- package/template/apps/web/app/admin/(protected)/settings/navigation/page.tsx +192 -0
- package/template/apps/web/app/admin/(protected)/settings/redirects/page.tsx +108 -0
- package/template/apps/web/app/admin/(protected)/settings/theme/page.tsx +239 -0
- package/template/apps/web/app/admin/(protected)/users/page.tsx +94 -0
- package/template/apps/web/app/admin/login/page.tsx +76 -0
- package/template/apps/web/app/api/revalidate/route.ts +34 -0
- package/template/apps/web/app/globals.css +120 -0
- package/template/apps/web/app/layout.tsx +39 -0
- package/template/apps/web/app/preview/[token]/page.tsx +70 -0
- package/template/apps/web/app/robots.ts +11 -0
- package/template/apps/web/app/sitemap.ts +27 -0
- package/template/apps/web/components/admin/AdminShell.tsx +104 -0
- package/template/apps/web/components/builder/BuilderCanvasNode.tsx +546 -0
- package/template/apps/web/components/builder/BuilderDeviceContext.tsx +13 -0
- package/template/apps/web/components/builder/Canvas.tsx +76 -0
- package/template/apps/web/components/builder/CanvasOverlayContext.tsx +22 -0
- package/template/apps/web/components/builder/ColorPickerField.tsx +47 -0
- package/template/apps/web/components/builder/DroppableChildren.tsx +50 -0
- package/template/apps/web/components/builder/FieldRenderer.tsx +277 -0
- package/template/apps/web/components/builder/IconRenderer.tsx +7 -0
- package/template/apps/web/components/builder/Inspector.tsx +956 -0
- package/template/apps/web/components/builder/JsonEditor.tsx +71 -0
- package/template/apps/web/components/builder/KeyValueListEditor.tsx +76 -0
- package/template/apps/web/components/builder/MediaPickerModal.tsx +95 -0
- package/template/apps/web/components/builder/PageSettingsModal.tsx +363 -0
- package/template/apps/web/components/builder/Palette.tsx +106 -0
- package/template/apps/web/components/builder/RichTextEditor.tsx +140 -0
- package/template/apps/web/components/renderer/CmsImage.tsx +46 -0
- package/template/apps/web/components/renderer/NodeBackgroundLayers.tsx +35 -0
- package/template/apps/web/components/renderer/PublicPageView.tsx +56 -0
- package/template/apps/web/components/renderer/Renderer.tsx +146 -0
- package/template/apps/web/components/renderer/RendererContext.tsx +20 -0
- package/template/apps/web/components/renderer/registry.tsx +87 -0
- package/template/apps/web/components/renderer/sections/Accordion.tsx +39 -0
- package/template/apps/web/components/renderer/sections/BeforeAfter.tsx +75 -0
- package/template/apps/web/components/renderer/sections/Button.tsx +42 -0
- package/template/apps/web/components/renderer/sections/ButtonGroup.tsx +22 -0
- package/template/apps/web/components/renderer/sections/CardGrid.tsx +66 -0
- package/template/apps/web/components/renderer/sections/CollectionList.tsx +84 -0
- package/template/apps/web/components/renderer/sections/Column.tsx +26 -0
- package/template/apps/web/components/renderer/sections/ContactForm.tsx +96 -0
- package/template/apps/web/components/renderer/sections/Container.tsx +44 -0
- package/template/apps/web/components/renderer/sections/ContentLinks.tsx +38 -0
- package/template/apps/web/components/renderer/sections/Countdown.tsx +69 -0
- package/template/apps/web/components/renderer/sections/Cta.tsx +15 -0
- package/template/apps/web/components/renderer/sections/Divider.tsx +26 -0
- package/template/apps/web/components/renderer/sections/EmbedBlock.tsx +43 -0
- package/template/apps/web/components/renderer/sections/Footer.tsx +117 -0
- package/template/apps/web/components/renderer/sections/Gallery.tsx +27 -0
- package/template/apps/web/components/renderer/sections/GoToTop.tsx +54 -0
- package/template/apps/web/components/renderer/sections/Grid.tsx +54 -0
- package/template/apps/web/components/renderer/sections/GridCell.tsx +33 -0
- package/template/apps/web/components/renderer/sections/Header.tsx +132 -0
- package/template/apps/web/components/renderer/sections/Hero.tsx +73 -0
- package/template/apps/web/components/renderer/sections/ImageBackgroundSection.tsx +20 -0
- package/template/apps/web/components/renderer/sections/ImageBlock.tsx +55 -0
- package/template/apps/web/components/renderer/sections/LanguageSwitcher.tsx +51 -0
- package/template/apps/web/components/renderer/sections/Logo.tsx +24 -0
- package/template/apps/web/components/renderer/sections/LogoCloud.tsx +67 -0
- package/template/apps/web/components/renderer/sections/MapEmbed.tsx +24 -0
- package/template/apps/web/components/renderer/sections/Marquee.tsx +45 -0
- package/template/apps/web/components/renderer/sections/NavLinks.tsx +69 -0
- package/template/apps/web/components/renderer/sections/NavZone.tsx +19 -0
- package/template/apps/web/components/renderer/sections/PricingTable.tsx +65 -0
- package/template/apps/web/components/renderer/sections/RichText.tsx +7 -0
- package/template/apps/web/components/renderer/sections/SiteSearch.tsx +67 -0
- package/template/apps/web/components/renderer/sections/Slider.tsx +81 -0
- package/template/apps/web/components/renderer/sections/Spacer.tsx +8 -0
- package/template/apps/web/components/renderer/sections/Stats.tsx +69 -0
- package/template/apps/web/components/renderer/sections/Tabs.tsx +59 -0
- package/template/apps/web/components/renderer/sections/TeamGrid.tsx +49 -0
- package/template/apps/web/components/renderer/sections/Testimonial.tsx +79 -0
- package/template/apps/web/components/renderer/sections/ThreeBackground.tsx +43 -0
- package/template/apps/web/components/renderer/sections/Timeline.tsx +29 -0
- package/template/apps/web/components/renderer/sections/VideoBackgroundSection.tsx +20 -0
- package/template/apps/web/components/renderer/sections/VideoEmbed.tsx +70 -0
- package/template/apps/web/components/renderer/sections/types.ts +7 -0
- package/template/apps/web/components/seo/JsonLd.tsx +111 -0
- package/template/apps/web/components/theme/AnalyticsScripts.tsx +68 -0
- package/template/apps/web/components/theme/DarkModeToggle.tsx +39 -0
- package/template/apps/web/components/theme/SiteDataContext.tsx +39 -0
- package/template/apps/web/components/theme/ThemeProvider.tsx +58 -0
- package/template/apps/web/eslint.config.mjs +16 -0
- package/template/apps/web/lib/api.ts +45 -0
- package/template/apps/web/lib/responsiveCss.ts +42 -0
- package/template/apps/web/lib/sanitizeHtml.ts +16 -0
- package/template/apps/web/lib/serverApi.ts +25 -0
- package/template/apps/web/lib/style.ts +73 -0
- package/template/apps/web/lib/threeBackgroundScene.ts +298 -0
- package/template/apps/web/lib/useCurrentUser.ts +35 -0
- package/template/apps/web/lib/useEntranceAnimation.ts +245 -0
- package/template/apps/web/next-env.d.ts +6 -0
- package/template/apps/web/next.config.mjs +32 -0
- package/template/apps/web/package.json +46 -0
- package/template/apps/web/postcss.config.mjs +6 -0
- package/template/apps/web/proxy.ts +23 -0
- package/template/apps/web/tailwind.config.ts +24 -0
- package/template/apps/web/tsconfig.json +41 -0
- package/template/docker-compose.yml +60 -0
- package/template/package.json +29 -0
- package/template/packages/shared/package.json +21 -0
- package/template/packages/shared/src/components.ts +1626 -0
- package/template/packages/shared/src/index.ts +9 -0
- package/template/packages/shared/src/schema/collections.ts +70 -0
- package/template/packages/shared/src/schema/fields.ts +26 -0
- package/template/packages/shared/src/schema/media.ts +13 -0
- package/template/packages/shared/src/schema/pageNode.ts +255 -0
- package/template/packages/shared/src/schema/templates.ts +20 -0
- package/template/packages/shared/src/schema/theme.ts +69 -0
- package/template/packages/shared/src/schema/user.ts +25 -0
- package/template/packages/shared/src/tree.test.ts +65 -0
- package/template/packages/shared/src/tree.ts +303 -0
- package/template/packages/shared/tsconfig.json +8 -0
- package/template/packages/shared/vitest.config.ts +8 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { nanoid } from "nanoid";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import {
|
|
5
|
+
CreatePageInputSchema,
|
|
6
|
+
UpdatePageInputSchema,
|
|
7
|
+
PageNodeSchema,
|
|
8
|
+
createEmptyPage,
|
|
9
|
+
isPagePubliclyVisible,
|
|
10
|
+
extractSearchText,
|
|
11
|
+
cloneWithNewIds,
|
|
12
|
+
type PageNode,
|
|
13
|
+
} from "@pgcms/shared";
|
|
14
|
+
import { prisma } from "../prisma.js";
|
|
15
|
+
import { requireAuth, requireRole, canModify } from "../middleware/auth.js";
|
|
16
|
+
import { pageCacheTags, triggerRevalidation } from "../lib/revalidate.js";
|
|
17
|
+
|
|
18
|
+
export const pagesRouter = Router();
|
|
19
|
+
|
|
20
|
+
const MAX_REVISIONS_PER_PAGE = 30;
|
|
21
|
+
|
|
22
|
+
/** Generates "title (Copy)", "title (Copy 2)", ... and a matching unique slug, used by
|
|
23
|
+
* both /duplicate and template-seeded page creation with a slug clash. */
|
|
24
|
+
async function uniqueCopySlug(baseSlug: string): Promise<string> {
|
|
25
|
+
let candidate = `${baseSlug}-copy`;
|
|
26
|
+
let n = 2;
|
|
27
|
+
while (await prisma.page.findUnique({ where: { slug: candidate } })) {
|
|
28
|
+
candidate = `${baseSlug}-copy-${n}`;
|
|
29
|
+
n++;
|
|
30
|
+
}
|
|
31
|
+
return candidate;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Public: list only pages that are actually visible right now (published, and not
|
|
35
|
+
// still waiting on a future publishAt).
|
|
36
|
+
pagesRouter.get("/public", async (_req, res) => {
|
|
37
|
+
const pages = await prisma.page.findMany({
|
|
38
|
+
where: { status: "PUBLISHED" },
|
|
39
|
+
select: { id: true, slug: true, title: true, status: true, publishAt: true, updatedAt: true, noIndex: true },
|
|
40
|
+
orderBy: { createdAt: "asc" },
|
|
41
|
+
});
|
|
42
|
+
res.json(pages.filter((p) => isPagePubliclyVisible(p) && !p.noIndex));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// All publicly-visible members of a page's translation group — the group root is the
|
|
46
|
+
// page's translationOfId (or the page itself when it IS the root), and members are the
|
|
47
|
+
// root plus everything pointing at it. Query-param based because localized slugs
|
|
48
|
+
// contain "/" and would be ambiguous inside a path segment.
|
|
49
|
+
pagesRouter.get("/public-translations", async (req, res) => {
|
|
50
|
+
const slug = req.query.slug === "home" || req.query.slug === undefined ? "" : String(req.query.slug);
|
|
51
|
+
const page = await prisma.page.findFirst({ where: { slug } });
|
|
52
|
+
if (!page) return res.json([]);
|
|
53
|
+
const rootId = page.translationOfId ?? page.id;
|
|
54
|
+
const group = await prisma.page.findMany({
|
|
55
|
+
where: { OR: [{ id: rootId }, { translationOfId: rootId }], status: "PUBLISHED" },
|
|
56
|
+
select: { locale: true, slug: true, title: true, status: true, publishAt: true },
|
|
57
|
+
});
|
|
58
|
+
res.json(group.filter(isPagePubliclyVisible).map(({ locale, slug: s, title }) => ({ locale, slug: s, title })));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Wildcard rather than :slug so localized slugs with "/" in them ("es/servicios")
|
|
62
|
+
// still resolve as one page path.
|
|
63
|
+
pagesRouter.get("/public/*", async (req, res) => {
|
|
64
|
+
const raw = (req.params as Record<string, string>)[0] ?? "";
|
|
65
|
+
const slug = raw === "home" ? "" : raw;
|
|
66
|
+
const page = await prisma.page.findFirst({ where: { slug, status: "PUBLISHED" } });
|
|
67
|
+
if (!page || !isPagePubliclyVisible(page)) return res.status(404).json({ error: "Page not found" });
|
|
68
|
+
res.json(page);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Shareable draft preview — anyone with the secret token can view unpublished content.
|
|
72
|
+
pagesRouter.get("/preview/:token", async (req, res) => {
|
|
73
|
+
const page = await prisma.page.findUnique({ where: { previewToken: req.params.token } });
|
|
74
|
+
if (!page) return res.status(404).json({ error: "Preview not found" });
|
|
75
|
+
res.json(page);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Admin: full CRUD
|
|
79
|
+
pagesRouter.use(requireAuth);
|
|
80
|
+
|
|
81
|
+
pagesRouter.get("/", async (_req, res) => {
|
|
82
|
+
const pages = await prisma.page.findMany({ orderBy: { createdAt: "asc" } });
|
|
83
|
+
res.json(pages);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
pagesRouter.get("/:id", async (req, res) => {
|
|
87
|
+
const page = await prisma.page.findUnique({ where: { id: req.params.id } });
|
|
88
|
+
if (!page) return res.status(404).json({ error: "Page not found" });
|
|
89
|
+
res.json(page);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
pagesRouter.post("/", requireRole("ADMIN", "EDITOR", "AUTHOR"), async (req, res) => {
|
|
93
|
+
const parsed = CreatePageInputSchema.safeParse(req.body);
|
|
94
|
+
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
|
95
|
+
|
|
96
|
+
const { title, slug, locale, templateId } = parsed.data;
|
|
97
|
+
const existing = await prisma.page.findUnique({ where: { slug } });
|
|
98
|
+
if (existing) return res.status(409).json({ error: "A page with this slug already exists" });
|
|
99
|
+
|
|
100
|
+
let content: PageNode = createEmptyPage();
|
|
101
|
+
if (templateId) {
|
|
102
|
+
const template = await prisma.pageTemplate.findUnique({ where: { id: templateId } });
|
|
103
|
+
if (!template) return res.status(404).json({ error: "Template not found" });
|
|
104
|
+
content = cloneWithNewIds(template.content as unknown as PageNode);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const page = await prisma.page.create({
|
|
108
|
+
data: {
|
|
109
|
+
title,
|
|
110
|
+
slug,
|
|
111
|
+
status: "DRAFT",
|
|
112
|
+
content: content as any,
|
|
113
|
+
searchText: extractSearchText(content),
|
|
114
|
+
previewToken: nanoid(24),
|
|
115
|
+
createdById: req.user!.sub,
|
|
116
|
+
...(locale ? { locale } : {}),
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
res.status(201).json(page);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Clones an existing page's content under a fresh, auto-suffixed slug — the fastest way
|
|
123
|
+
// to start a new page from one that already looks right, or to back up before a risky
|
|
124
|
+
// edit. Always created as DRAFT so a duplicate never accidentally goes live.
|
|
125
|
+
pagesRouter.post("/:id/duplicate", requireRole("ADMIN", "EDITOR", "AUTHOR"), async (req, res) => {
|
|
126
|
+
const source = await prisma.page.findUnique({ where: { id: req.params.id } });
|
|
127
|
+
if (!source) return res.status(404).json({ error: "Page not found" });
|
|
128
|
+
if (!canModify(req.user!, source)) return res.status(403).json({ error: "Forbidden" });
|
|
129
|
+
|
|
130
|
+
const slug = await uniqueCopySlug(source.slug || "home");
|
|
131
|
+
const content = cloneWithNewIds(source.content as unknown as PageNode);
|
|
132
|
+
const page = await prisma.page.create({
|
|
133
|
+
data: {
|
|
134
|
+
title: `${source.title} (Copy)`,
|
|
135
|
+
slug,
|
|
136
|
+
status: "DRAFT",
|
|
137
|
+
content: content as any,
|
|
138
|
+
searchText: extractSearchText(content),
|
|
139
|
+
previewToken: nanoid(24),
|
|
140
|
+
locale: source.locale,
|
|
141
|
+
createdById: req.user!.sub,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
res.status(201).json(page);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const ImportPageInputSchema = z.object({
|
|
148
|
+
title: z.string().min(1),
|
|
149
|
+
slug: z.string().regex(/^[a-z0-9/-]*$/),
|
|
150
|
+
locale: z.string().min(2).max(20).optional(),
|
|
151
|
+
content: PageNodeSchema,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Accepts exactly what /:id/export produces — a slug clash (e.g. re-importing into the
|
|
155
|
+
// same site) auto-suffixes rather than erroring, matching /duplicate's behavior.
|
|
156
|
+
pagesRouter.post("/import", requireRole("ADMIN", "EDITOR", "AUTHOR"), async (req, res) => {
|
|
157
|
+
const parsed = ImportPageInputSchema.safeParse(req.body);
|
|
158
|
+
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
|
159
|
+
|
|
160
|
+
const clash = await prisma.page.findUnique({ where: { slug: parsed.data.slug } });
|
|
161
|
+
const slug = clash ? await uniqueCopySlug(parsed.data.slug || "home") : parsed.data.slug;
|
|
162
|
+
const content = cloneWithNewIds(parsed.data.content);
|
|
163
|
+
|
|
164
|
+
const page = await prisma.page.create({
|
|
165
|
+
data: {
|
|
166
|
+
title: parsed.data.title,
|
|
167
|
+
slug,
|
|
168
|
+
status: "DRAFT",
|
|
169
|
+
content: content as any,
|
|
170
|
+
searchText: extractSearchText(content),
|
|
171
|
+
previewToken: nanoid(24),
|
|
172
|
+
createdById: req.user!.sub,
|
|
173
|
+
...(parsed.data.locale ? { locale: parsed.data.locale } : {}),
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
res.status(201).json(page);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// A JSON download of one page's content — for backing it up locally or moving it into
|
|
180
|
+
// another pg-cms instance via the import flow.
|
|
181
|
+
pagesRouter.get("/:id/export", async (req, res) => {
|
|
182
|
+
const page = await prisma.page.findUnique({ where: { id: req.params.id } });
|
|
183
|
+
if (!page) return res.status(404).json({ error: "Page not found" });
|
|
184
|
+
if (!canModify(req.user!, page)) return res.status(403).json({ error: "Forbidden" });
|
|
185
|
+
res.setHeader("Content-Disposition", `attachment; filename="${page.slug || "home"}.page.json"`);
|
|
186
|
+
res.json({ title: page.title, slug: page.slug, locale: page.locale, content: page.content });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
pagesRouter.put("/:id", requireRole("ADMIN", "EDITOR", "AUTHOR"), async (req, res) => {
|
|
190
|
+
const parsed = UpdatePageInputSchema.safeParse(req.body);
|
|
191
|
+
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
|
192
|
+
|
|
193
|
+
if (parsed.data.slug !== undefined) {
|
|
194
|
+
const clash = await prisma.page.findFirst({
|
|
195
|
+
where: { slug: parsed.data.slug, NOT: { id: req.params.id } },
|
|
196
|
+
});
|
|
197
|
+
if (clash) return res.status(409).json({ error: "A page with this slug already exists" });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const existing = await prisma.page.findUnique({ where: { id: req.params.id } });
|
|
201
|
+
if (!existing) return res.status(404).json({ error: "Page not found" });
|
|
202
|
+
if (!canModify(req.user!, existing)) return res.status(403).json({ error: "Forbidden" });
|
|
203
|
+
|
|
204
|
+
// `publishAt` arrives as whatever a <input type="datetime-local"> produces
|
|
205
|
+
// ("2026-07-04T08:33" — no seconds, no timezone), which Prisma's DateTime field
|
|
206
|
+
// rejects as a raw string. Normalize to a real Date (or null to clear it) before it
|
|
207
|
+
// ever reaches Prisma.
|
|
208
|
+
const data: Record<string, unknown> = { ...parsed.data };
|
|
209
|
+
if ("publishAt" in data) {
|
|
210
|
+
data.publishAt = data.publishAt ? new Date(data.publishAt as string) : null;
|
|
211
|
+
}
|
|
212
|
+
if (parsed.data.content !== undefined) {
|
|
213
|
+
data.searchText = extractSearchText(parsed.data.content);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const [page] = await prisma.$transaction([
|
|
218
|
+
prisma.page.update({ where: { id: req.params.id }, data: data as any }),
|
|
219
|
+
// Snapshot the *previous* content on every content-changing save, so "restore"
|
|
220
|
+
// always has something meaningfully different to offer.
|
|
221
|
+
...(parsed.data.content !== undefined
|
|
222
|
+
? [
|
|
223
|
+
prisma.pageRevision.create({
|
|
224
|
+
data: { pageId: existing.id, title: existing.title, content: existing.content as any },
|
|
225
|
+
}),
|
|
226
|
+
]
|
|
227
|
+
: []),
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
if (parsed.data.content !== undefined) {
|
|
231
|
+
const revisionCount = await prisma.pageRevision.count({ where: { pageId: existing.id } });
|
|
232
|
+
if (revisionCount > MAX_REVISIONS_PER_PAGE) {
|
|
233
|
+
const stale = await prisma.pageRevision.findMany({
|
|
234
|
+
where: { pageId: existing.id },
|
|
235
|
+
orderBy: { createdAt: "asc" },
|
|
236
|
+
take: revisionCount - MAX_REVISIONS_PER_PAGE,
|
|
237
|
+
select: { id: true },
|
|
238
|
+
});
|
|
239
|
+
await prisma.pageRevision.deleteMany({ where: { id: { in: stale.map((r) => r.id) } } });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const tags = new Set<string>(["cms", "cms-pages", "cms-sitemap"]);
|
|
244
|
+
tags.add(`page:${existing.slug || "home"}`);
|
|
245
|
+
if (parsed.data.slug !== undefined && parsed.data.slug !== existing.slug) {
|
|
246
|
+
tags.add(`page:${parsed.data.slug || "home"}`);
|
|
247
|
+
}
|
|
248
|
+
void triggerRevalidation([...tags]);
|
|
249
|
+
|
|
250
|
+
res.json(page);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
// Only Prisma's "record to update not found" (a real 404 — e.g. deleted between
|
|
253
|
+
// our findUnique and the update) should report as 404. Anything else (bad data,
|
|
254
|
+
// a broken query) was previously swallowed into the same misleading 404, which is
|
|
255
|
+
// exactly what hid the publishAt bug above — surface it for real next time.
|
|
256
|
+
if (err && typeof err === "object" && "code" in err && err.code === "P2025") {
|
|
257
|
+
return res.status(404).json({ error: "Page not found" });
|
|
258
|
+
}
|
|
259
|
+
console.error("PUT /api/pages/:id failed:", err);
|
|
260
|
+
res.status(500).json({ error: err instanceof Error ? err.message : "Failed to update page" });
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
pagesRouter.delete("/:id", requireRole("ADMIN"), async (req, res) => {
|
|
265
|
+
try {
|
|
266
|
+
const existing = await prisma.page.findUnique({ where: { id: req.params.id } });
|
|
267
|
+
await prisma.page.delete({ where: { id: req.params.id } });
|
|
268
|
+
if (existing) void triggerRevalidation(pageCacheTags(existing.slug));
|
|
269
|
+
res.status(204).end();
|
|
270
|
+
} catch {
|
|
271
|
+
res.status(404).json({ error: "Page not found" });
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
pagesRouter.get("/:id/revisions", async (req, res) => {
|
|
276
|
+
const revisions = await prisma.pageRevision.findMany({
|
|
277
|
+
where: { pageId: req.params.id },
|
|
278
|
+
orderBy: { createdAt: "desc" },
|
|
279
|
+
select: { id: true, pageId: true, title: true, createdAt: true }, // omit content — list view doesn't need it
|
|
280
|
+
});
|
|
281
|
+
res.json(revisions);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
pagesRouter.post("/:id/revisions/:revisionId/restore", requireRole("ADMIN", "EDITOR", "AUTHOR"), async (req, res) => {
|
|
285
|
+
const revision = await prisma.pageRevision.findUnique({ where: { id: req.params.revisionId } });
|
|
286
|
+
if (!revision || revision.pageId !== req.params.id) return res.status(404).json({ error: "Revision not found" });
|
|
287
|
+
|
|
288
|
+
const current = await prisma.page.findUnique({ where: { id: req.params.id } });
|
|
289
|
+
if (!current) return res.status(404).json({ error: "Page not found" });
|
|
290
|
+
if (!canModify(req.user!, current)) return res.status(403).json({ error: "Forbidden" });
|
|
291
|
+
|
|
292
|
+
// Snapshot the current state too, before overwriting — restoring shouldn't be a
|
|
293
|
+
// one-way trip.
|
|
294
|
+
const [page] = await prisma.$transaction([
|
|
295
|
+
prisma.page.update({
|
|
296
|
+
where: { id: req.params.id },
|
|
297
|
+
data: {
|
|
298
|
+
content: revision.content as any,
|
|
299
|
+
searchText: extractSearchText(revision.content as unknown as PageNode),
|
|
300
|
+
},
|
|
301
|
+
}),
|
|
302
|
+
prisma.pageRevision.create({
|
|
303
|
+
data: { pageId: current.id, title: current.title, content: current.content as any },
|
|
304
|
+
}),
|
|
305
|
+
]);
|
|
306
|
+
void triggerRevalidation(pageCacheTags(current.slug));
|
|
307
|
+
res.json(page);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
pagesRouter.post("/:id/regenerate-preview-token", requireRole("ADMIN", "EDITOR", "AUTHOR"), async (req, res) => {
|
|
311
|
+
const existing = await prisma.page.findUnique({ where: { id: req.params.id } });
|
|
312
|
+
if (!existing) return res.status(404).json({ error: "Page not found" });
|
|
313
|
+
if (!canModify(req.user!, existing)) return res.status(403).json({ error: "Forbidden" });
|
|
314
|
+
const page = await prisma.page.update({
|
|
315
|
+
where: { id: req.params.id },
|
|
316
|
+
data: { previewToken: nanoid(24) },
|
|
317
|
+
});
|
|
318
|
+
res.json(page);
|
|
319
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { prisma } from "../prisma.js";
|
|
4
|
+
import { requireAuth, requireRole } from "../middleware/auth.js";
|
|
5
|
+
|
|
6
|
+
export const redirectsRouter = Router();
|
|
7
|
+
|
|
8
|
+
const normalizePath = (p: string) => "/" + p.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
|
9
|
+
|
|
10
|
+
const CreateRedirectSchema = z.object({
|
|
11
|
+
fromPath: z.string().min(1).max(500),
|
|
12
|
+
toPath: z.string().min(1).max(500),
|
|
13
|
+
permanent: z.boolean().optional(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Public: resolve one path — the web app's 404 fallback asks here before giving up.
|
|
17
|
+
redirectsRouter.get("/resolve", async (req, res) => {
|
|
18
|
+
const path = typeof req.query.path === "string" ? normalizePath(req.query.path) : null;
|
|
19
|
+
if (!path) return res.status(400).json({ error: "path query param required" });
|
|
20
|
+
const redirect = await prisma.redirect.findUnique({ where: { fromPath: path } });
|
|
21
|
+
if (!redirect) return res.status(404).json({ error: "No redirect" });
|
|
22
|
+
res.json({ toPath: redirect.toPath, permanent: redirect.permanent });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
redirectsRouter.use(requireAuth, requireRole("ADMIN", "EDITOR"));
|
|
26
|
+
|
|
27
|
+
redirectsRouter.get("/", async (_req, res) => {
|
|
28
|
+
res.json(await prisma.redirect.findMany({ orderBy: { createdAt: "desc" } }));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
redirectsRouter.post("/", async (req, res) => {
|
|
32
|
+
const parsed = CreateRedirectSchema.safeParse(req.body);
|
|
33
|
+
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
|
34
|
+
const fromPath = normalizePath(parsed.data.fromPath);
|
|
35
|
+
const toPath = parsed.data.toPath.startsWith("http") ? parsed.data.toPath : normalizePath(parsed.data.toPath);
|
|
36
|
+
if (fromPath === toPath) return res.status(400).json({ error: "A redirect cannot point at itself" });
|
|
37
|
+
const existing = await prisma.redirect.findUnique({ where: { fromPath } });
|
|
38
|
+
if (existing) return res.status(409).json({ error: "A redirect from this path already exists" });
|
|
39
|
+
const redirect = await prisma.redirect.create({
|
|
40
|
+
data: { fromPath, toPath, permanent: parsed.data.permanent ?? true },
|
|
41
|
+
});
|
|
42
|
+
res.status(201).json(redirect);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
redirectsRouter.delete("/:id", async (req, res) => {
|
|
46
|
+
try {
|
|
47
|
+
await prisma.redirect.delete({ where: { id: req.params.id } });
|
|
48
|
+
res.json({ ok: true });
|
|
49
|
+
} catch {
|
|
50
|
+
res.status(404).json({ error: "Not found" });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { Prisma } from "@prisma/client";
|
|
3
|
+
import { prisma } from "../prisma.js";
|
|
4
|
+
import { isPagePubliclyVisible } from "@pgcms/shared";
|
|
5
|
+
|
|
6
|
+
export const searchRouter = Router();
|
|
7
|
+
|
|
8
|
+
type PageSearchRow = {
|
|
9
|
+
title: string;
|
|
10
|
+
slug: string;
|
|
11
|
+
seoDescription: string | null;
|
|
12
|
+
status: string;
|
|
13
|
+
publishAt: Date | null;
|
|
14
|
+
noIndex: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type ItemSearchRow = {
|
|
18
|
+
title: string;
|
|
19
|
+
slug: string;
|
|
20
|
+
excerpt: string | null;
|
|
21
|
+
seoDescription: string | null;
|
|
22
|
+
noIndex: boolean;
|
|
23
|
+
collectionSlug: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Public site search — Postgres full-text ranking over title + the denormalized
|
|
27
|
+
* searchText column (see extractSearchText), which already carries every string prop
|
|
28
|
+
* across the page's whole component tree, not just title/excerpt/SEO fields. */
|
|
29
|
+
searchRouter.get("/", async (req, res) => {
|
|
30
|
+
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
|
|
31
|
+
if (q.length < 2) return res.json({ results: [] });
|
|
32
|
+
|
|
33
|
+
const limit = Math.min(20, Math.max(1, Number(req.query.limit) || 10));
|
|
34
|
+
|
|
35
|
+
const [pages, items] = await Promise.all([
|
|
36
|
+
prisma.$queryRaw<PageSearchRow[]>(Prisma.sql`
|
|
37
|
+
SELECT title, slug, "seoDescription", status, "publishAt", "noIndex"
|
|
38
|
+
FROM "Page"
|
|
39
|
+
WHERE status = 'PUBLISHED'
|
|
40
|
+
AND to_tsvector('english', title || ' ' || COALESCE("seoDescription", '') || ' ' || "searchText")
|
|
41
|
+
@@ plainto_tsquery('english', ${q})
|
|
42
|
+
ORDER BY ts_rank(
|
|
43
|
+
to_tsvector('english', title || ' ' || COALESCE("seoDescription", '') || ' ' || "searchText"),
|
|
44
|
+
plainto_tsquery('english', ${q})
|
|
45
|
+
) DESC
|
|
46
|
+
LIMIT ${limit}
|
|
47
|
+
`),
|
|
48
|
+
prisma.$queryRaw<ItemSearchRow[]>(Prisma.sql`
|
|
49
|
+
SELECT ci.title, ci.slug, ci.excerpt, ci."seoDescription", ci."noIndex", c.slug AS "collectionSlug"
|
|
50
|
+
FROM "CollectionItem" ci
|
|
51
|
+
JOIN "Collection" c ON c.id = ci."collectionId"
|
|
52
|
+
WHERE ci.status = 'PUBLISHED'
|
|
53
|
+
AND to_tsvector('english', ci.title || ' ' || COALESCE(ci.excerpt, '') || ' ' || COALESCE(ci."seoDescription", '') || ' ' || ci."searchText")
|
|
54
|
+
@@ plainto_tsquery('english', ${q})
|
|
55
|
+
ORDER BY ts_rank(
|
|
56
|
+
to_tsvector('english', ci.title || ' ' || COALESCE(ci.excerpt, '') || ' ' || COALESCE(ci."seoDescription", '') || ' ' || ci."searchText"),
|
|
57
|
+
plainto_tsquery('english', ${q})
|
|
58
|
+
) DESC
|
|
59
|
+
LIMIT ${limit}
|
|
60
|
+
`),
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const results = [
|
|
64
|
+
...pages
|
|
65
|
+
.filter((p) => isPagePubliclyVisible(p) && !p.noIndex)
|
|
66
|
+
.map((p) => ({
|
|
67
|
+
type: "page" as const,
|
|
68
|
+
title: p.title,
|
|
69
|
+
url: `/${p.slug}`,
|
|
70
|
+
excerpt: p.seoDescription ?? null,
|
|
71
|
+
})),
|
|
72
|
+
...items
|
|
73
|
+
.filter((i) => !i.noIndex)
|
|
74
|
+
.map((i) => ({
|
|
75
|
+
type: "collection" as const,
|
|
76
|
+
title: i.title,
|
|
77
|
+
url: `/${i.collectionSlug}/${i.slug}`,
|
|
78
|
+
excerpt: i.excerpt ?? i.seoDescription ?? null,
|
|
79
|
+
})),
|
|
80
|
+
].slice(0, limit);
|
|
81
|
+
|
|
82
|
+
res.json({ results });
|
|
83
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { CreatePageTemplateInputSchema } from "@pgcms/shared";
|
|
3
|
+
import { prisma } from "../prisma.js";
|
|
4
|
+
import { requireAuth, requireRole } from "../middleware/auth.js";
|
|
5
|
+
|
|
6
|
+
export const templatesRouter = Router();
|
|
7
|
+
|
|
8
|
+
// The whole gallery is builder-only — no public route, since templates are starting
|
|
9
|
+
// points for editors, not content anyone visits directly.
|
|
10
|
+
templatesRouter.use(requireAuth);
|
|
11
|
+
|
|
12
|
+
templatesRouter.get("/", async (_req, res) => {
|
|
13
|
+
const templates = await prisma.pageTemplate.findMany({
|
|
14
|
+
orderBy: { createdAt: "asc" },
|
|
15
|
+
select: { id: true, name: true, category: true, thumbnail: true, createdAt: true },
|
|
16
|
+
});
|
|
17
|
+
res.json(templates);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
templatesRouter.post("/", requireRole("ADMIN", "EDITOR", "AUTHOR"), async (req, res) => {
|
|
21
|
+
const parsed = CreatePageTemplateInputSchema.safeParse(req.body);
|
|
22
|
+
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
|
23
|
+
const template = await prisma.pageTemplate.create({
|
|
24
|
+
data: {
|
|
25
|
+
name: parsed.data.name,
|
|
26
|
+
category: parsed.data.category || "General",
|
|
27
|
+
thumbnail: parsed.data.thumbnail ?? null,
|
|
28
|
+
content: parsed.data.content as object,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
res.status(201).json(template);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
templatesRouter.delete("/:id", requireRole("ADMIN"), async (req, res) => {
|
|
35
|
+
try {
|
|
36
|
+
await prisma.pageTemplate.delete({ where: { id: req.params.id } });
|
|
37
|
+
res.json({ ok: true });
|
|
38
|
+
} catch {
|
|
39
|
+
res.status(404).json({ error: "Template not found" });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { UpdateThemeInputSchema, UpsertNavItemInputSchema } from "@pgcms/shared";
|
|
3
|
+
import { prisma } from "../prisma.js";
|
|
4
|
+
import { requireAuth, requireRole } from "../middleware/auth.js";
|
|
5
|
+
import { triggerRevalidation } from "../lib/revalidate.js";
|
|
6
|
+
|
|
7
|
+
export const themeRouter = Router();
|
|
8
|
+
|
|
9
|
+
async function getOrCreateTheme() {
|
|
10
|
+
const existing = await prisma.siteTheme.findFirst();
|
|
11
|
+
if (existing) return existing;
|
|
12
|
+
return prisma.siteTheme.create({ data: {} });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Public: theme + nav are needed to render the public site
|
|
16
|
+
themeRouter.get("/public", async (_req, res) => {
|
|
17
|
+
const theme = await getOrCreateTheme();
|
|
18
|
+
res.json(theme);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
themeRouter.get("/nav/public", async (_req, res) => {
|
|
22
|
+
const items = await prisma.navItem.findMany({ orderBy: { order: "asc" } });
|
|
23
|
+
res.json(items);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
themeRouter.use(requireAuth);
|
|
27
|
+
|
|
28
|
+
themeRouter.get("/", async (_req, res) => {
|
|
29
|
+
res.json(await getOrCreateTheme());
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
themeRouter.put("/", requireRole("ADMIN"), async (req, res) => {
|
|
33
|
+
const parsed = UpdateThemeInputSchema.safeParse(req.body);
|
|
34
|
+
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
|
35
|
+
|
|
36
|
+
const current = await getOrCreateTheme();
|
|
37
|
+
const updated = await prisma.siteTheme.update({ where: { id: current.id }, data: parsed.data });
|
|
38
|
+
void triggerRevalidation(["cms", "cms-theme"]);
|
|
39
|
+
res.json(updated);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
themeRouter.get("/nav", async (_req, res) => {
|
|
43
|
+
const items = await prisma.navItem.findMany({ orderBy: { order: "asc" } });
|
|
44
|
+
res.json(items);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
themeRouter.post("/nav", requireRole("ADMIN"), async (req, res) => {
|
|
48
|
+
const parsed = UpsertNavItemInputSchema.safeParse(req.body);
|
|
49
|
+
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
|
50
|
+
const item = await prisma.navItem.create({ data: parsed.data });
|
|
51
|
+
void triggerRevalidation(["cms", "cms-nav"]);
|
|
52
|
+
res.status(201).json(item);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
themeRouter.put("/nav/:id", requireRole("ADMIN"), async (req, res) => {
|
|
56
|
+
const parsed = UpsertNavItemInputSchema.partial().safeParse(req.body);
|
|
57
|
+
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
|
58
|
+
try {
|
|
59
|
+
const item = await prisma.navItem.update({ where: { id: req.params.id }, data: parsed.data });
|
|
60
|
+
void triggerRevalidation(["cms", "cms-nav"]);
|
|
61
|
+
res.json(item);
|
|
62
|
+
} catch {
|
|
63
|
+
res.status(404).json({ error: "Not found" });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
themeRouter.delete("/nav/:id", requireRole("ADMIN"), async (req, res) => {
|
|
68
|
+
try {
|
|
69
|
+
await prisma.navItem.delete({ where: { id: req.params.id } });
|
|
70
|
+
void triggerRevalidation(["cms", "cms-nav"]);
|
|
71
|
+
res.status(204).end();
|
|
72
|
+
} catch {
|
|
73
|
+
res.status(404).json({ error: "Not found" });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import bcrypt from "bcryptjs";
|
|
3
|
+
import { CreateUserInputSchema } from "@pgcms/shared";
|
|
4
|
+
import { prisma } from "../prisma.js";
|
|
5
|
+
import { requireAuth, requireRole } from "../middleware/auth.js";
|
|
6
|
+
|
|
7
|
+
export const usersRouter = Router();
|
|
8
|
+
|
|
9
|
+
usersRouter.use(requireAuth, requireRole("ADMIN"));
|
|
10
|
+
|
|
11
|
+
usersRouter.get("/", async (_req, res) => {
|
|
12
|
+
const users = await prisma.user.findMany({
|
|
13
|
+
select: { id: true, email: true, role: true, createdAt: true },
|
|
14
|
+
orderBy: { createdAt: "asc" },
|
|
15
|
+
});
|
|
16
|
+
res.json(users);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
usersRouter.post("/", async (req, res) => {
|
|
20
|
+
const parsed = CreateUserInputSchema.safeParse(req.body);
|
|
21
|
+
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
|
22
|
+
|
|
23
|
+
const { email, password, role } = parsed.data;
|
|
24
|
+
const existing = await prisma.user.findUnique({ where: { email } });
|
|
25
|
+
if (existing) return res.status(409).json({ error: "Email already in use" });
|
|
26
|
+
|
|
27
|
+
const passwordHash = await bcrypt.hash(password, 10);
|
|
28
|
+
const user = await prisma.user.create({ data: { email, passwordHash, role } });
|
|
29
|
+
res.status(201).json({ id: user.id, email: user.email, role: user.role });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
usersRouter.delete("/:id", async (req, res) => {
|
|
33
|
+
try {
|
|
34
|
+
await prisma.user.delete({ where: { id: req.params.id } });
|
|
35
|
+
res.status(204).end();
|
|
36
|
+
} catch {
|
|
37
|
+
res.status(404).json({ error: "Not found" });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
FROM node:24-alpine AS base
|
|
2
|
+
WORKDIR /app
|
|
3
|
+
|
|
4
|
+
FROM base AS deps
|
|
5
|
+
COPY package.json package-lock.json ./
|
|
6
|
+
COPY packages/shared/package.json ./packages/shared/
|
|
7
|
+
COPY apps/web/package.json ./apps/web/
|
|
8
|
+
RUN npm ci --workspace=apps/web --workspace=packages/shared --include-workspace-root
|
|
9
|
+
|
|
10
|
+
FROM base AS build
|
|
11
|
+
ARG API_URL=http://api:4000
|
|
12
|
+
ENV API_URL=$API_URL
|
|
13
|
+
COPY --from=deps /app/node_modules ./node_modules
|
|
14
|
+
COPY . .
|
|
15
|
+
RUN npm run build -w packages/shared && npm run build -w apps/web
|
|
16
|
+
|
|
17
|
+
FROM base AS runner
|
|
18
|
+
ENV NODE_ENV=production
|
|
19
|
+
WORKDIR /app
|
|
20
|
+
COPY --from=build /app/apps/web/.next/standalone ./
|
|
21
|
+
COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
|
|
22
|
+
COPY --from=build /app/apps/web/public ./apps/web/public
|
|
23
|
+
EXPOSE 3000
|
|
24
|
+
ENV PORT=3000
|
|
25
|
+
ENV HOSTNAME=0.0.0.0
|
|
26
|
+
CMD ["node", "apps/web/server.js"]
|