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.
Files changed (180) hide show
  1. package/README.md +41 -0
  2. package/bin/lib.js +97 -0
  3. package/bin/modern-cms.js +197 -0
  4. package/package.json +37 -0
  5. package/template/.env.example +48 -0
  6. package/template/README.md +210 -0
  7. package/template/apps/api/Dockerfile +26 -0
  8. package/template/apps/api/package.json +45 -0
  9. package/template/apps/api/prisma/backfill-search-text.ts +31 -0
  10. package/template/apps/api/prisma/migrations/20260701034530_init/migration.sql +86 -0
  11. package/template/apps/api/prisma/migrations/20260701081942_add_publish_at_and_revisions/migration.sql +19 -0
  12. package/template/apps/api/prisma/migrations/20260701085011_add_form_submissions/migration.sql +13 -0
  13. package/template/apps/api/prisma/migrations/20260702084049_add_global_sections_and_saved_blocks/migration.sql +24 -0
  14. package/template/apps/api/prisma/migrations/20260702085609_add_page_locale_and_translations/migration.sql +6 -0
  15. package/template/apps/api/prisma/migrations/20260702091914_add_collections/migration.sql +38 -0
  16. package/template/apps/api/prisma/migrations/20260702120000_platform_hardening/migration.sql +15 -0
  17. package/template/apps/api/prisma/migrations/20260705031230_add_collections_redirects_media_meta/migration.sql +17 -0
  18. package/template/apps/api/prisma/migrations/20260705033241_add_audit_log/migration.sql +18 -0
  19. package/template/apps/api/prisma/migrations/20260706031301_standard_cms_platform_pass/migration.sql +48 -0
  20. package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
  21. package/template/apps/api/prisma/reset-admin.ts +19 -0
  22. package/template/apps/api/prisma/schema.prisma +251 -0
  23. package/template/apps/api/prisma/seed-homepage.ts +752 -0
  24. package/template/apps/api/prisma/seed-our-charities.ts +231 -0
  25. package/template/apps/api/prisma/seed-templates.ts +154 -0
  26. package/template/apps/api/prisma/seed.ts +191 -0
  27. package/template/apps/api/src/env.ts +61 -0
  28. package/template/apps/api/src/index.ts +66 -0
  29. package/template/apps/api/src/lib/revalidate.ts +28 -0
  30. package/template/apps/api/src/lib/storage/azure.ts +25 -0
  31. package/template/apps/api/src/lib/storage/cloudinary.ts +42 -0
  32. package/template/apps/api/src/lib/storage/index.ts +25 -0
  33. package/template/apps/api/src/lib/storage/s3.ts +39 -0
  34. package/template/apps/api/src/lib/storage/types.ts +8 -0
  35. package/template/apps/api/src/middleware/auditLog.ts +32 -0
  36. package/template/apps/api/src/middleware/auth.ts +53 -0
  37. package/template/apps/api/src/middleware/rateLimit.ts +31 -0
  38. package/template/apps/api/src/middleware/securityHeaders.ts +10 -0
  39. package/template/apps/api/src/prisma.ts +3 -0
  40. package/template/apps/api/src/routes/audit.routes.ts +26 -0
  41. package/template/apps/api/src/routes/auth.routes.ts +46 -0
  42. package/template/apps/api/src/routes/backup.routes.ts +216 -0
  43. package/template/apps/api/src/routes/blocks.routes.ts +38 -0
  44. package/template/apps/api/src/routes/collections.routes.ts +185 -0
  45. package/template/apps/api/src/routes/forms.routes.ts +114 -0
  46. package/template/apps/api/src/routes/globalSections.routes.ts +43 -0
  47. package/template/apps/api/src/routes/media.routes.ts +77 -0
  48. package/template/apps/api/src/routes/pages.routes.ts +319 -0
  49. package/template/apps/api/src/routes/redirects.routes.ts +52 -0
  50. package/template/apps/api/src/routes/search.routes.ts +83 -0
  51. package/template/apps/api/src/routes/templates.routes.ts +41 -0
  52. package/template/apps/api/src/routes/theme.routes.ts +75 -0
  53. package/template/apps/api/src/routes/users.routes.ts +39 -0
  54. package/template/apps/api/tsconfig.json +11 -0
  55. package/template/apps/web/Dockerfile +26 -0
  56. package/template/apps/web/app/[[...slug]]/page.tsx +254 -0
  57. package/template/apps/web/app/admin/(protected)/audit/page.tsx +80 -0
  58. package/template/apps/web/app/admin/(protected)/collections/item/[itemId]/page.tsx +375 -0
  59. package/template/apps/web/app/admin/(protected)/collections/page.tsx +224 -0
  60. package/template/apps/web/app/admin/(protected)/forms/page.tsx +88 -0
  61. package/template/apps/web/app/admin/(protected)/layout.tsx +5 -0
  62. package/template/apps/web/app/admin/(protected)/media/page.tsx +138 -0
  63. package/template/apps/web/app/admin/(protected)/page.tsx +186 -0
  64. package/template/apps/web/app/admin/(protected)/pages/[id]/page.tsx +560 -0
  65. package/template/apps/web/app/admin/(protected)/settings/backup/page.tsx +97 -0
  66. package/template/apps/web/app/admin/(protected)/settings/global-sections/page.tsx +333 -0
  67. package/template/apps/web/app/admin/(protected)/settings/navigation/page.tsx +192 -0
  68. package/template/apps/web/app/admin/(protected)/settings/redirects/page.tsx +108 -0
  69. package/template/apps/web/app/admin/(protected)/settings/theme/page.tsx +239 -0
  70. package/template/apps/web/app/admin/(protected)/users/page.tsx +94 -0
  71. package/template/apps/web/app/admin/login/page.tsx +76 -0
  72. package/template/apps/web/app/api/revalidate/route.ts +34 -0
  73. package/template/apps/web/app/globals.css +120 -0
  74. package/template/apps/web/app/layout.tsx +39 -0
  75. package/template/apps/web/app/preview/[token]/page.tsx +70 -0
  76. package/template/apps/web/app/robots.ts +11 -0
  77. package/template/apps/web/app/sitemap.ts +27 -0
  78. package/template/apps/web/components/admin/AdminShell.tsx +104 -0
  79. package/template/apps/web/components/builder/BuilderCanvasNode.tsx +546 -0
  80. package/template/apps/web/components/builder/BuilderDeviceContext.tsx +13 -0
  81. package/template/apps/web/components/builder/Canvas.tsx +76 -0
  82. package/template/apps/web/components/builder/CanvasOverlayContext.tsx +22 -0
  83. package/template/apps/web/components/builder/ColorPickerField.tsx +47 -0
  84. package/template/apps/web/components/builder/DroppableChildren.tsx +50 -0
  85. package/template/apps/web/components/builder/FieldRenderer.tsx +277 -0
  86. package/template/apps/web/components/builder/IconRenderer.tsx +7 -0
  87. package/template/apps/web/components/builder/Inspector.tsx +956 -0
  88. package/template/apps/web/components/builder/JsonEditor.tsx +71 -0
  89. package/template/apps/web/components/builder/KeyValueListEditor.tsx +76 -0
  90. package/template/apps/web/components/builder/MediaPickerModal.tsx +95 -0
  91. package/template/apps/web/components/builder/PageSettingsModal.tsx +363 -0
  92. package/template/apps/web/components/builder/Palette.tsx +106 -0
  93. package/template/apps/web/components/builder/RichTextEditor.tsx +140 -0
  94. package/template/apps/web/components/renderer/CmsImage.tsx +46 -0
  95. package/template/apps/web/components/renderer/NodeBackgroundLayers.tsx +35 -0
  96. package/template/apps/web/components/renderer/PublicPageView.tsx +56 -0
  97. package/template/apps/web/components/renderer/Renderer.tsx +146 -0
  98. package/template/apps/web/components/renderer/RendererContext.tsx +20 -0
  99. package/template/apps/web/components/renderer/registry.tsx +87 -0
  100. package/template/apps/web/components/renderer/sections/Accordion.tsx +39 -0
  101. package/template/apps/web/components/renderer/sections/BeforeAfter.tsx +75 -0
  102. package/template/apps/web/components/renderer/sections/Button.tsx +42 -0
  103. package/template/apps/web/components/renderer/sections/ButtonGroup.tsx +22 -0
  104. package/template/apps/web/components/renderer/sections/CardGrid.tsx +66 -0
  105. package/template/apps/web/components/renderer/sections/CollectionList.tsx +84 -0
  106. package/template/apps/web/components/renderer/sections/Column.tsx +26 -0
  107. package/template/apps/web/components/renderer/sections/ContactForm.tsx +96 -0
  108. package/template/apps/web/components/renderer/sections/Container.tsx +44 -0
  109. package/template/apps/web/components/renderer/sections/ContentLinks.tsx +38 -0
  110. package/template/apps/web/components/renderer/sections/Countdown.tsx +69 -0
  111. package/template/apps/web/components/renderer/sections/Cta.tsx +15 -0
  112. package/template/apps/web/components/renderer/sections/Divider.tsx +26 -0
  113. package/template/apps/web/components/renderer/sections/EmbedBlock.tsx +43 -0
  114. package/template/apps/web/components/renderer/sections/Footer.tsx +117 -0
  115. package/template/apps/web/components/renderer/sections/Gallery.tsx +27 -0
  116. package/template/apps/web/components/renderer/sections/GoToTop.tsx +54 -0
  117. package/template/apps/web/components/renderer/sections/Grid.tsx +54 -0
  118. package/template/apps/web/components/renderer/sections/GridCell.tsx +33 -0
  119. package/template/apps/web/components/renderer/sections/Header.tsx +132 -0
  120. package/template/apps/web/components/renderer/sections/Hero.tsx +73 -0
  121. package/template/apps/web/components/renderer/sections/ImageBackgroundSection.tsx +20 -0
  122. package/template/apps/web/components/renderer/sections/ImageBlock.tsx +55 -0
  123. package/template/apps/web/components/renderer/sections/LanguageSwitcher.tsx +51 -0
  124. package/template/apps/web/components/renderer/sections/Logo.tsx +24 -0
  125. package/template/apps/web/components/renderer/sections/LogoCloud.tsx +67 -0
  126. package/template/apps/web/components/renderer/sections/MapEmbed.tsx +24 -0
  127. package/template/apps/web/components/renderer/sections/Marquee.tsx +45 -0
  128. package/template/apps/web/components/renderer/sections/NavLinks.tsx +69 -0
  129. package/template/apps/web/components/renderer/sections/NavZone.tsx +19 -0
  130. package/template/apps/web/components/renderer/sections/PricingTable.tsx +65 -0
  131. package/template/apps/web/components/renderer/sections/RichText.tsx +7 -0
  132. package/template/apps/web/components/renderer/sections/SiteSearch.tsx +67 -0
  133. package/template/apps/web/components/renderer/sections/Slider.tsx +81 -0
  134. package/template/apps/web/components/renderer/sections/Spacer.tsx +8 -0
  135. package/template/apps/web/components/renderer/sections/Stats.tsx +69 -0
  136. package/template/apps/web/components/renderer/sections/Tabs.tsx +59 -0
  137. package/template/apps/web/components/renderer/sections/TeamGrid.tsx +49 -0
  138. package/template/apps/web/components/renderer/sections/Testimonial.tsx +79 -0
  139. package/template/apps/web/components/renderer/sections/ThreeBackground.tsx +43 -0
  140. package/template/apps/web/components/renderer/sections/Timeline.tsx +29 -0
  141. package/template/apps/web/components/renderer/sections/VideoBackgroundSection.tsx +20 -0
  142. package/template/apps/web/components/renderer/sections/VideoEmbed.tsx +70 -0
  143. package/template/apps/web/components/renderer/sections/types.ts +7 -0
  144. package/template/apps/web/components/seo/JsonLd.tsx +111 -0
  145. package/template/apps/web/components/theme/AnalyticsScripts.tsx +68 -0
  146. package/template/apps/web/components/theme/DarkModeToggle.tsx +39 -0
  147. package/template/apps/web/components/theme/SiteDataContext.tsx +39 -0
  148. package/template/apps/web/components/theme/ThemeProvider.tsx +58 -0
  149. package/template/apps/web/eslint.config.mjs +16 -0
  150. package/template/apps/web/lib/api.ts +45 -0
  151. package/template/apps/web/lib/responsiveCss.ts +42 -0
  152. package/template/apps/web/lib/sanitizeHtml.ts +16 -0
  153. package/template/apps/web/lib/serverApi.ts +25 -0
  154. package/template/apps/web/lib/style.ts +73 -0
  155. package/template/apps/web/lib/threeBackgroundScene.ts +298 -0
  156. package/template/apps/web/lib/useCurrentUser.ts +35 -0
  157. package/template/apps/web/lib/useEntranceAnimation.ts +245 -0
  158. package/template/apps/web/next-env.d.ts +6 -0
  159. package/template/apps/web/next.config.mjs +32 -0
  160. package/template/apps/web/package.json +46 -0
  161. package/template/apps/web/postcss.config.mjs +6 -0
  162. package/template/apps/web/proxy.ts +23 -0
  163. package/template/apps/web/tailwind.config.ts +24 -0
  164. package/template/apps/web/tsconfig.json +41 -0
  165. package/template/docker-compose.yml +60 -0
  166. package/template/package.json +29 -0
  167. package/template/packages/shared/package.json +21 -0
  168. package/template/packages/shared/src/components.ts +1626 -0
  169. package/template/packages/shared/src/index.ts +9 -0
  170. package/template/packages/shared/src/schema/collections.ts +70 -0
  171. package/template/packages/shared/src/schema/fields.ts +26 -0
  172. package/template/packages/shared/src/schema/media.ts +13 -0
  173. package/template/packages/shared/src/schema/pageNode.ts +255 -0
  174. package/template/packages/shared/src/schema/templates.ts +20 -0
  175. package/template/packages/shared/src/schema/theme.ts +69 -0
  176. package/template/packages/shared/src/schema/user.ts +25 -0
  177. package/template/packages/shared/src/tree.test.ts +65 -0
  178. package/template/packages/shared/src/tree.ts +303 -0
  179. package/template/packages/shared/tsconfig.json +8 -0
  180. 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,11 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "types": ["node"]
9
+ },
10
+ "include": ["src"]
11
+ }
@@ -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"]