@nextsparkjs/theme-default 0.1.0-beta.1

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 (333) hide show
  1. package/about/business.md +49 -0
  2. package/about/features.json +302 -0
  3. package/about/team.md +79 -0
  4. package/api/ai/chat/stream/route.ts +212 -0
  5. package/api/ai/orchestrator/route.ts +226 -0
  6. package/api/ai/single-agent/route.ts +291 -0
  7. package/api/ai/usage/route.ts +122 -0
  8. package/blocks/benefits/component.tsx +100 -0
  9. package/blocks/benefits/config.ts +11 -0
  10. package/blocks/benefits/examples.ts +85 -0
  11. package/blocks/benefits/fields.ts +156 -0
  12. package/blocks/benefits/schema.ts +33 -0
  13. package/blocks/cta-section/component.tsx +100 -0
  14. package/blocks/cta-section/config.ts +11 -0
  15. package/blocks/cta-section/examples.ts +41 -0
  16. package/blocks/cta-section/fields.ts +89 -0
  17. package/blocks/cta-section/index.ts +6 -0
  18. package/blocks/cta-section/schema.ts +32 -0
  19. package/blocks/cta-section/thumbnail.png +1 -0
  20. package/blocks/faq-accordion/component.tsx +156 -0
  21. package/blocks/faq-accordion/config.ts +11 -0
  22. package/blocks/faq-accordion/examples.ts +77 -0
  23. package/blocks/faq-accordion/fields.ts +119 -0
  24. package/blocks/faq-accordion/index.ts +6 -0
  25. package/blocks/faq-accordion/schema.ts +45 -0
  26. package/blocks/features-grid/component.tsx +112 -0
  27. package/blocks/features-grid/config.ts +11 -0
  28. package/blocks/features-grid/examples.ts +63 -0
  29. package/blocks/features-grid/fields.ts +97 -0
  30. package/blocks/features-grid/index.ts +6 -0
  31. package/blocks/features-grid/schema.ts +40 -0
  32. package/blocks/features-grid/thumbnail.png +1 -0
  33. package/blocks/hero/component.tsx +100 -0
  34. package/blocks/hero/config.ts +11 -0
  35. package/blocks/hero/examples.ts +35 -0
  36. package/blocks/hero/fields.ts +60 -0
  37. package/blocks/hero/index.ts +6 -0
  38. package/blocks/hero/schema.ts +32 -0
  39. package/blocks/hero/thumbnail.png +1 -0
  40. package/blocks/hero/thumbnail.png.txt +6 -0
  41. package/blocks/hero-with-form/component.tsx +232 -0
  42. package/blocks/hero-with-form/config.ts +11 -0
  43. package/blocks/hero-with-form/examples.ts +16 -0
  44. package/blocks/hero-with-form/fields.ts +207 -0
  45. package/blocks/hero-with-form/index.ts +6 -0
  46. package/blocks/hero-with-form/schema.ts +54 -0
  47. package/blocks/jumbotron/component.tsx +136 -0
  48. package/blocks/jumbotron/config.ts +11 -0
  49. package/blocks/jumbotron/examples.ts +36 -0
  50. package/blocks/jumbotron/fields.ts +202 -0
  51. package/blocks/jumbotron/index.ts +6 -0
  52. package/blocks/jumbotron/schema.ts +55 -0
  53. package/blocks/logo-cloud/component.tsx +154 -0
  54. package/blocks/logo-cloud/config.ts +11 -0
  55. package/blocks/logo-cloud/examples.ts +34 -0
  56. package/blocks/logo-cloud/fields.ts +133 -0
  57. package/blocks/logo-cloud/index.ts +6 -0
  58. package/blocks/logo-cloud/schema.ts +46 -0
  59. package/blocks/post-content/component.tsx +197 -0
  60. package/blocks/post-content/config.ts +11 -0
  61. package/blocks/post-content/examples.ts +33 -0
  62. package/blocks/post-content/fields.ts +165 -0
  63. package/blocks/post-content/index.ts +4 -0
  64. package/blocks/post-content/schema.ts +46 -0
  65. package/blocks/pricing-table/component.tsx +154 -0
  66. package/blocks/pricing-table/config.ts +11 -0
  67. package/blocks/pricing-table/examples.ts +96 -0
  68. package/blocks/pricing-table/fields.ts +161 -0
  69. package/blocks/pricing-table/index.ts +4 -0
  70. package/blocks/pricing-table/schema.ts +50 -0
  71. package/blocks/split-content/component.tsx +135 -0
  72. package/blocks/split-content/config.ts +11 -0
  73. package/blocks/split-content/examples.ts +38 -0
  74. package/blocks/split-content/fields.ts +198 -0
  75. package/blocks/split-content/index.ts +6 -0
  76. package/blocks/split-content/schema.ts +67 -0
  77. package/blocks/stats-counter/component.tsx +124 -0
  78. package/blocks/stats-counter/config.ts +11 -0
  79. package/blocks/stats-counter/examples.ts +61 -0
  80. package/blocks/stats-counter/fields.ts +134 -0
  81. package/blocks/stats-counter/index.ts +6 -0
  82. package/blocks/stats-counter/schema.ts +47 -0
  83. package/blocks/testimonials/component.tsx +114 -0
  84. package/blocks/testimonials/config.ts +11 -0
  85. package/blocks/testimonials/examples.ts +65 -0
  86. package/blocks/testimonials/fields.ts +105 -0
  87. package/blocks/testimonials/index.ts +6 -0
  88. package/blocks/testimonials/schema.ts +41 -0
  89. package/blocks/testimonials/thumbnail.png +1 -0
  90. package/blocks/text-content/component.tsx +97 -0
  91. package/blocks/text-content/config.ts +11 -0
  92. package/blocks/text-content/examples.ts +30 -0
  93. package/blocks/text-content/fields.ts +88 -0
  94. package/blocks/text-content/index.ts +6 -0
  95. package/blocks/text-content/schema.ts +30 -0
  96. package/blocks/text-content/thumbnail.png +1 -0
  97. package/blocks/timeline/component.tsx +267 -0
  98. package/blocks/timeline/config.ts +11 -0
  99. package/blocks/timeline/examples.ts +68 -0
  100. package/blocks/timeline/fields.ts +147 -0
  101. package/blocks/timeline/index.ts +6 -0
  102. package/blocks/timeline/schema.ts +49 -0
  103. package/blocks/video-hero/component.tsx +270 -0
  104. package/blocks/video-hero/config.ts +11 -0
  105. package/blocks/video-hero/examples.ts +24 -0
  106. package/blocks/video-hero/fields.ts +98 -0
  107. package/blocks/video-hero/index.ts +6 -0
  108. package/blocks/video-hero/schema.ts +39 -0
  109. package/components/ai-chat/ChatPanel.tsx +575 -0
  110. package/components/ai-chat/ConversationItem.tsx +266 -0
  111. package/components/ai-chat/ConversationSidebar.tsx +99 -0
  112. package/components/ai-chat/MarkdownRenderer.tsx +15 -0
  113. package/components/ai-chat/Message.tsx +42 -0
  114. package/components/ai-chat/MessageInput.tsx +49 -0
  115. package/components/ai-chat/MessageList.tsx +46 -0
  116. package/components/ai-chat/TypingIndicator.tsx +11 -0
  117. package/config/app.config.ts +367 -0
  118. package/config/billing.config.ts +349 -0
  119. package/config/dashboard.config.ts +506 -0
  120. package/config/dev.config.ts +104 -0
  121. package/config/features.config.ts +203 -0
  122. package/config/flows.config.ts +129 -0
  123. package/config/permissions.config.ts +245 -0
  124. package/config/theme.config.ts +74 -0
  125. package/docs/01-overview/01-introduction.md +335 -0
  126. package/docs/01-overview/02-customization.md +671 -0
  127. package/docs/02-features/01-components.md +155 -0
  128. package/docs/02-features/02-styling.md +139 -0
  129. package/docs/02-features/03-tasks-entity.md +407 -0
  130. package/docs/03-ai/01-overview.md +211 -0
  131. package/docs/03-ai/02-customization.md +436 -0
  132. package/entities/customers/customers.config.ts +75 -0
  133. package/entities/customers/customers.fields.ts +165 -0
  134. package/entities/customers/customers.service.ts +516 -0
  135. package/entities/customers/customers.types.ts +83 -0
  136. package/entities/customers/messages/en.json +66 -0
  137. package/entities/customers/messages/es.json +66 -0
  138. package/entities/customers/migrations/001_customers_table.sql +102 -0
  139. package/entities/customers/migrations/002_customers_metas.sql +92 -0
  140. package/entities/pages/messages/en.json +41 -0
  141. package/entities/pages/messages/es.json +41 -0
  142. package/entities/pages/migrations/001_pages_table.sql +112 -0
  143. package/entities/pages/migrations/002_pages_metas.sql +56 -0
  144. package/entities/pages/migrations/003_add_status.sql +50 -0
  145. package/entities/pages/pages-management.service.ts +610 -0
  146. package/entities/pages/pages.config.ts +94 -0
  147. package/entities/pages/pages.fields.ts +101 -0
  148. package/entities/pages/pages.service.ts +290 -0
  149. package/entities/pages/pages.types.ts +124 -0
  150. package/entities/posts/components/post-header.tsx +97 -0
  151. package/entities/posts/messages/en.json +55 -0
  152. package/entities/posts/messages/es.json +55 -0
  153. package/entities/posts/migrations/001_posts_table.sql +115 -0
  154. package/entities/posts/migrations/003_add_status.sql +44 -0
  155. package/entities/posts/migrations/004_entity_taxonomy_relations.sql +129 -0
  156. package/entities/posts/migrations/006_posts_metas.sql +56 -0
  157. package/entities/posts/posts.config.ts +101 -0
  158. package/entities/posts/posts.fields.ts +116 -0
  159. package/entities/posts/posts.service.ts +376 -0
  160. package/entities/posts/posts.types.ts +74 -0
  161. package/entities/tasks/messages/en.json +204 -0
  162. package/entities/tasks/messages/es.json +204 -0
  163. package/entities/tasks/migrations/001_tasks_table.sql +105 -0
  164. package/entities/tasks/migrations/002_task_metas.sql +85 -0
  165. package/entities/tasks/migrations/sample_data.json +77 -0
  166. package/entities/tasks/tasks.config.ts +79 -0
  167. package/entities/tasks/tasks.fields.ts +196 -0
  168. package/entities/tasks/tasks.service.ts +541 -0
  169. package/entities/tasks/tasks.types.ts +56 -0
  170. package/lib/hooks/useAiChat.ts +114 -0
  171. package/lib/hooks/useConversations.ts +376 -0
  172. package/lib/hooks/useOrchestratorChat.ts +122 -0
  173. package/lib/hooks/usePersistentChat.ts +315 -0
  174. package/lib/hooks/useStreamingChat.ts +127 -0
  175. package/lib/hooks/useTokenUsage.ts +63 -0
  176. package/lib/langchain/agents/customer-assistant.md +69 -0
  177. package/lib/langchain/agents/index.ts +61 -0
  178. package/lib/langchain/agents/orchestrator.md +59 -0
  179. package/lib/langchain/agents/page-assistant.md +85 -0
  180. package/lib/langchain/agents/single-agent.md +46 -0
  181. package/lib/langchain/agents/task-assistant.md +55 -0
  182. package/lib/langchain/config.ts +45 -0
  183. package/lib/langchain/handlers/customer-handler.ts +338 -0
  184. package/lib/langchain/handlers/page-handler.ts +232 -0
  185. package/lib/langchain/handlers/task-handler.ts +323 -0
  186. package/lib/langchain/langchain.config.ts +223 -0
  187. package/lib/langchain/observability.config.ts +30 -0
  188. package/lib/langchain/orchestrator.ts +562 -0
  189. package/lib/langchain/tools/customers.ts +176 -0
  190. package/lib/langchain/tools/index.ts +10 -0
  191. package/lib/langchain/tools/orchestrator.ts +92 -0
  192. package/lib/langchain/tools/pages.ts +289 -0
  193. package/lib/langchain/tools/tasks.ts +167 -0
  194. package/lib/scheduled-actions/billing.ts +149 -0
  195. package/lib/scheduled-actions/index.ts +170 -0
  196. package/lib/scheduled-actions/webhook.ts +231 -0
  197. package/lib/selectors.ts +197 -0
  198. package/messages/de/admin.json +219 -0
  199. package/messages/de/aiUsage.json +36 -0
  200. package/messages/de/buttons.json +19 -0
  201. package/messages/de/categories.json +35 -0
  202. package/messages/de/common.json +16 -0
  203. package/messages/de/dev.json +101 -0
  204. package/messages/de/docs.json +27 -0
  205. package/messages/de/entities.json +7 -0
  206. package/messages/de/features.json +119 -0
  207. package/messages/de/footer.json +22 -0
  208. package/messages/de/home.json +57 -0
  209. package/messages/de/index.ts +39 -0
  210. package/messages/de/mobileNav.json +13 -0
  211. package/messages/de/navigation.json +8 -0
  212. package/messages/de/observability.json +74 -0
  213. package/messages/de/posts.json +54 -0
  214. package/messages/de/pricing.json +102 -0
  215. package/messages/de/support.json +9 -0
  216. package/messages/de/teams.json +8 -0
  217. package/messages/en/admin.json +219 -0
  218. package/messages/en/aiUsage.json +36 -0
  219. package/messages/en/buttons.json +19 -0
  220. package/messages/en/categories.json +35 -0
  221. package/messages/en/common.json +16 -0
  222. package/messages/en/dev.json +106 -0
  223. package/messages/en/docs.json +27 -0
  224. package/messages/en/entities.json +7 -0
  225. package/messages/en/features.json +119 -0
  226. package/messages/en/footer.json +22 -0
  227. package/messages/en/home.json +57 -0
  228. package/messages/en/index.ts +39 -0
  229. package/messages/en/mobileNav.json +13 -0
  230. package/messages/en/navigation.json +8 -0
  231. package/messages/en/observability.json +74 -0
  232. package/messages/en/posts.json +54 -0
  233. package/messages/en/pricing.json +102 -0
  234. package/messages/en/support.json +9 -0
  235. package/messages/en/teams.json +8 -0
  236. package/messages/es/admin.json +219 -0
  237. package/messages/es/aiUsage.json +36 -0
  238. package/messages/es/buttons.json +19 -0
  239. package/messages/es/categories.json +35 -0
  240. package/messages/es/common.json +16 -0
  241. package/messages/es/dev.json +101 -0
  242. package/messages/es/docs.json +27 -0
  243. package/messages/es/entities.json +7 -0
  244. package/messages/es/features.json +119 -0
  245. package/messages/es/footer.json +22 -0
  246. package/messages/es/home.json +57 -0
  247. package/messages/es/index.ts +39 -0
  248. package/messages/es/mobileNav.json +13 -0
  249. package/messages/es/navigation.json +8 -0
  250. package/messages/es/observability.json +74 -0
  251. package/messages/es/posts.json +54 -0
  252. package/messages/es/pricing.json +102 -0
  253. package/messages/es/support.json +9 -0
  254. package/messages/es/teams.json +8 -0
  255. package/messages/fr/admin.json +219 -0
  256. package/messages/fr/aiUsage.json +36 -0
  257. package/messages/fr/buttons.json +19 -0
  258. package/messages/fr/categories.json +35 -0
  259. package/messages/fr/common.json +16 -0
  260. package/messages/fr/dev.json +101 -0
  261. package/messages/fr/docs.json +27 -0
  262. package/messages/fr/entities.json +7 -0
  263. package/messages/fr/features.json +119 -0
  264. package/messages/fr/footer.json +22 -0
  265. package/messages/fr/home.json +57 -0
  266. package/messages/fr/index.ts +39 -0
  267. package/messages/fr/mobileNav.json +13 -0
  268. package/messages/fr/navigation.json +8 -0
  269. package/messages/fr/observability.json +74 -0
  270. package/messages/fr/posts.json +54 -0
  271. package/messages/fr/pricing.json +102 -0
  272. package/messages/fr/support.json +9 -0
  273. package/messages/fr/teams.json +8 -0
  274. package/messages/it/admin.json +219 -0
  275. package/messages/it/aiUsage.json +36 -0
  276. package/messages/it/buttons.json +19 -0
  277. package/messages/it/categories.json +35 -0
  278. package/messages/it/common.json +16 -0
  279. package/messages/it/dev.json +101 -0
  280. package/messages/it/docs.json +27 -0
  281. package/messages/it/entities.json +7 -0
  282. package/messages/it/features.json +119 -0
  283. package/messages/it/footer.json +22 -0
  284. package/messages/it/home.json +57 -0
  285. package/messages/it/index.ts +39 -0
  286. package/messages/it/mobileNav.json +13 -0
  287. package/messages/it/navigation.json +8 -0
  288. package/messages/it/observability.json +74 -0
  289. package/messages/it/posts.json +54 -0
  290. package/messages/it/pricing.json +102 -0
  291. package/messages/it/support.json +9 -0
  292. package/messages/it/teams.json +8 -0
  293. package/messages/pt/admin.json +219 -0
  294. package/messages/pt/aiUsage.json +36 -0
  295. package/messages/pt/buttons.json +19 -0
  296. package/messages/pt/categories.json +35 -0
  297. package/messages/pt/common.json +16 -0
  298. package/messages/pt/dev.json +101 -0
  299. package/messages/pt/docs.json +27 -0
  300. package/messages/pt/entities.json +7 -0
  301. package/messages/pt/features.json +119 -0
  302. package/messages/pt/footer.json +22 -0
  303. package/messages/pt/home.json +57 -0
  304. package/messages/pt/index.ts +39 -0
  305. package/messages/pt/mobileNav.json +13 -0
  306. package/messages/pt/navigation.json +8 -0
  307. package/messages/pt/observability.json +74 -0
  308. package/messages/pt/posts.json +54 -0
  309. package/messages/pt/pricing.json +102 -0
  310. package/messages/pt/support.json +9 -0
  311. package/messages/pt/teams.json +8 -0
  312. package/migrations/089_add_editor_team_role.sql +39 -0
  313. package/migrations/090_demo_users_teams.sql +540 -0
  314. package/migrations/091_greek_teams_billing.sql +523 -0
  315. package/migrations/092_billing_sample_data.sql +774 -0
  316. package/migrations/093_pages_sample_data.sql +1158 -0
  317. package/migrations/094_posts_sample_data.sql +278 -0
  318. package/migrations/095_tasks_sample_data.sql +440 -0
  319. package/migrations/096_customers_sample_data.sql +358 -0
  320. package/migrations/097_scheduled_actions_sample_data.sql +111 -0
  321. package/package.json +22 -0
  322. package/public/docs/desktop-layout-example.png +0 -0
  323. package/styles/components.css +11 -0
  324. package/styles/globals.css +179 -0
  325. package/templates/(public)/blog/[slug]/page.tsx +65 -0
  326. package/templates/(public)/layout.tsx +25 -0
  327. package/templates/(public)/page.tsx +200 -0
  328. package/templates/(public)/support/page.tsx +321 -0
  329. package/templates/dashboard/(main)/agent-multi/page.tsx +63 -0
  330. package/templates/dashboard/(main)/agent-single/page.tsx +142 -0
  331. package/templates/dashboard/(main)/settings/ai-usage/page.tsx +157 -0
  332. package/templates/superadmin/ai-observability/[traceId]/page.tsx +27 -0
  333. package/templates/superadmin/ai-observability/page.tsx +17 -0
@@ -0,0 +1,50 @@
1
+ -- Migration: 024_add_status_to_pages.sql
2
+ -- Description: Add status column to pages + enable RLS (was missing)
3
+ -- Date: 2025-12-17
4
+ -- Session: builder-entities-unification-v2
5
+
6
+ -- ============================================
7
+ -- STEP 1: Add status column (replaces published boolean)
8
+ -- ============================================
9
+ ALTER TABLE public."pages"
10
+ ADD COLUMN IF NOT EXISTS "status" VARCHAR(50) DEFAULT 'draft';
11
+
12
+ -- Migrate existing data: published = true -> 'published', false -> 'draft'
13
+ UPDATE public."pages"
14
+ SET "status" = CASE
15
+ WHEN "published" = true THEN 'published'
16
+ ELSE 'draft'
17
+ END
18
+ WHERE "status" = 'draft' OR "status" IS NULL;
19
+
20
+ -- Create index for status filtering
21
+ CREATE INDEX IF NOT EXISTS idx_pages_status ON public."pages"("status");
22
+
23
+ -- Add check constraint for valid status values
24
+ -- First drop if exists to avoid errors on re-run
25
+ ALTER TABLE public."pages" DROP CONSTRAINT IF EXISTS pages_status_check;
26
+ ALTER TABLE public."pages"
27
+ ADD CONSTRAINT pages_status_check
28
+ CHECK ("status" IN ('draft', 'published', 'scheduled', 'archived'));
29
+
30
+ -- ============================================
31
+ -- STEP 2: Update RLS public policy to use status field
32
+ -- ============================================
33
+ -- Note: RLS and team isolation policy already created in 001_pages_table.sql
34
+ -- Only update the public select policy to use status instead of published
35
+
36
+ DROP POLICY IF EXISTS "pages public can select" ON public."pages";
37
+
38
+ -- Public can read published pages (using NEW status field)
39
+ CREATE POLICY "pages public can select"
40
+ ON public."pages"
41
+ FOR SELECT TO anon
42
+ USING ("status" = 'published');
43
+
44
+ -- ============================================
45
+ -- COMMENTS
46
+ -- ============================================
47
+ COMMENT ON COLUMN public."pages"."status" IS 'Publication status: draft, published, scheduled, archived';
48
+
49
+ -- Note: We keep 'published' column temporarily for rollback safety
50
+ -- DROP COLUMN published will be in a future migration after validation
@@ -0,0 +1,610 @@
1
+ /**
2
+ * Pages Management Service
3
+ *
4
+ * Provides authenticated CRUD operations for pages including block management.
5
+ * All methods require authentication and use RLS.
6
+ *
7
+ * @module PagesManagementService
8
+ */
9
+
10
+ import { queryOneWithRLS, queryWithRLS, mutateWithRLS } from '@nextsparkjs/core/lib/db'
11
+ import type {
12
+ Block,
13
+ PageFull,
14
+ PageCreateData,
15
+ PageUpdateData,
16
+ PageManagementListOptions,
17
+ PageManagementListResult,
18
+ } from './pages.types'
19
+
20
+ // Database row type for page
21
+ interface DbPage {
22
+ id: string
23
+ slug: string
24
+ title: string
25
+ blocks: Block[]
26
+ locale: string
27
+ status: string
28
+ seoTitle: string | null
29
+ seoDescription: string | null
30
+ ogImage: string | null
31
+ createdAt: string
32
+ updatedAt: string
33
+ }
34
+
35
+ /**
36
+ * Convert database row to PageFull
37
+ */
38
+ function toPageFull(row: DbPage): PageFull {
39
+ return {
40
+ id: row.id,
41
+ slug: row.slug,
42
+ title: row.title,
43
+ blocks: row.blocks || [],
44
+ locale: row.locale,
45
+ status: row.status as 'draft' | 'published',
46
+ seoTitle: row.seoTitle ?? undefined,
47
+ seoDescription: row.seoDescription ?? undefined,
48
+ ogImage: row.ogImage ?? undefined,
49
+ createdAt: row.createdAt,
50
+ updatedAt: row.updatedAt,
51
+ }
52
+ }
53
+
54
+ export class PagesManagementService {
55
+ // ============================================
56
+ // CRUD OPERATIONS
57
+ // ============================================
58
+
59
+ /**
60
+ * List pages with optional filters
61
+ */
62
+ static async list(
63
+ userId: string,
64
+ options: PageManagementListOptions = {}
65
+ ): Promise<PageManagementListResult> {
66
+ try {
67
+ if (!userId || userId.trim() === '') {
68
+ throw new Error('User ID is required for authentication')
69
+ }
70
+
71
+ const {
72
+ limit = 20,
73
+ offset = 0,
74
+ locale,
75
+ status = 'all',
76
+ orderBy = 'createdAt',
77
+ orderDir = 'desc',
78
+ } = options
79
+
80
+ // Build WHERE clause
81
+ const conditions: string[] = []
82
+ const params: unknown[] = []
83
+ let paramIndex = 1
84
+
85
+ if (locale) {
86
+ conditions.push(`locale = $${paramIndex++}`)
87
+ params.push(locale)
88
+ }
89
+
90
+ if (status !== 'all') {
91
+ conditions.push(`status = $${paramIndex++}`)
92
+ params.push(status)
93
+ }
94
+
95
+ const whereClause = conditions.length > 0
96
+ ? `WHERE ${conditions.join(' AND ')}`
97
+ : ''
98
+
99
+ // Validate orderBy
100
+ const validOrderBy = ['createdAt', 'updatedAt', 'title'].includes(orderBy) ? orderBy : 'createdAt'
101
+ const validOrderDir = orderDir === 'asc' ? 'ASC' : 'DESC'
102
+ const orderColumn = validOrderBy === 'title' ? 'title' : `"${validOrderBy}"`
103
+
104
+ // Get total count
105
+ const countResult = await queryWithRLS<{ count: string }>(
106
+ `SELECT COUNT(*)::text as count FROM pages ${whereClause}`,
107
+ params,
108
+ userId
109
+ )
110
+ const total = parseInt(countResult[0]?.count || '0', 10)
111
+
112
+ // Get pages
113
+ params.push(limit, offset)
114
+ const pages = await queryWithRLS<DbPage>(
115
+ `
116
+ SELECT
117
+ id, slug, title, blocks, locale, status,
118
+ "seoTitle", "seoDescription", "ogImage",
119
+ "createdAt", "updatedAt"
120
+ FROM pages
121
+ ${whereClause}
122
+ ORDER BY ${orderColumn} ${validOrderDir}
123
+ LIMIT $${paramIndex++} OFFSET $${paramIndex}
124
+ `,
125
+ params,
126
+ userId
127
+ )
128
+
129
+ return {
130
+ pages: pages.map(toPageFull),
131
+ total,
132
+ }
133
+ } catch (error) {
134
+ console.error('PagesManagementService.list error:', error)
135
+ throw new Error(
136
+ error instanceof Error ? error.message : 'Failed to list pages'
137
+ )
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Get a page by ID
143
+ */
144
+ static async getById(
145
+ userId: string,
146
+ id: string
147
+ ): Promise<PageFull | null> {
148
+ try {
149
+ if (!userId || userId.trim() === '') {
150
+ throw new Error('User ID is required for authentication')
151
+ }
152
+
153
+ if (!id || id.trim() === '') {
154
+ throw new Error('Page ID is required')
155
+ }
156
+
157
+ const page = await queryOneWithRLS<DbPage>(
158
+ `
159
+ SELECT
160
+ id, slug, title, blocks, locale, status,
161
+ "seoTitle", "seoDescription", "ogImage",
162
+ "createdAt", "updatedAt"
163
+ FROM pages
164
+ WHERE id = $1
165
+ `,
166
+ [id],
167
+ userId
168
+ )
169
+
170
+ return page ? toPageFull(page) : null
171
+ } catch (error) {
172
+ console.error('PagesManagementService.getById error:', error)
173
+ throw new Error(
174
+ error instanceof Error ? error.message : 'Failed to get page'
175
+ )
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Create a new page
181
+ */
182
+ static async create(
183
+ userId: string,
184
+ data: PageCreateData
185
+ ): Promise<PageFull> {
186
+ try {
187
+ if (!userId || userId.trim() === '') {
188
+ throw new Error('User ID is required for authentication')
189
+ }
190
+
191
+ if (!data.slug || !data.title) {
192
+ throw new Error('Slug and title are required')
193
+ }
194
+
195
+ const id = crypto.randomUUID()
196
+ const now = new Date().toISOString()
197
+
198
+ const result = await mutateWithRLS<DbPage>(
199
+ `
200
+ INSERT INTO pages (
201
+ id, "userId", "teamId", slug, title, blocks,
202
+ locale, status, "seoTitle", "seoDescription", "ogImage",
203
+ "createdAt", "updatedAt"
204
+ )
205
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
206
+ RETURNING
207
+ id, slug, title, blocks, locale, status,
208
+ "seoTitle", "seoDescription", "ogImage",
209
+ "createdAt", "updatedAt"
210
+ `,
211
+ [
212
+ id,
213
+ userId,
214
+ data.teamId,
215
+ data.slug,
216
+ data.title,
217
+ JSON.stringify(data.blocks || []),
218
+ data.locale || 'en',
219
+ data.status || 'draft',
220
+ data.seoTitle || null,
221
+ data.seoDescription || null,
222
+ data.ogImage || null,
223
+ now,
224
+ now,
225
+ ],
226
+ userId
227
+ )
228
+
229
+ if (!result.rows[0]) {
230
+ throw new Error('Failed to create page')
231
+ }
232
+
233
+ return toPageFull(result.rows[0])
234
+ } catch (error) {
235
+ console.error('PagesManagementService.create error:', error)
236
+ throw new Error(
237
+ error instanceof Error ? error.message : 'Failed to create page'
238
+ )
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Update page metadata (not blocks)
244
+ */
245
+ static async update(
246
+ userId: string,
247
+ id: string,
248
+ data: PageUpdateData
249
+ ): Promise<PageFull> {
250
+ try {
251
+ if (!userId || userId.trim() === '') {
252
+ throw new Error('User ID is required for authentication')
253
+ }
254
+
255
+ if (!id || id.trim() === '') {
256
+ throw new Error('Page ID is required')
257
+ }
258
+
259
+ // Build dynamic update query
260
+ const updates: string[] = []
261
+ const values: unknown[] = []
262
+ let paramIndex = 1
263
+
264
+ if (data.slug !== undefined) {
265
+ updates.push(`slug = $${paramIndex++}`)
266
+ values.push(data.slug)
267
+ }
268
+ if (data.title !== undefined) {
269
+ updates.push(`title = $${paramIndex++}`)
270
+ values.push(data.title)
271
+ }
272
+ if (data.status !== undefined) {
273
+ updates.push(`status = $${paramIndex++}`)
274
+ values.push(data.status)
275
+ }
276
+ if (data.seoTitle !== undefined) {
277
+ updates.push(`"seoTitle" = $${paramIndex++}`)
278
+ values.push(data.seoTitle || null)
279
+ }
280
+ if (data.seoDescription !== undefined) {
281
+ updates.push(`"seoDescription" = $${paramIndex++}`)
282
+ values.push(data.seoDescription || null)
283
+ }
284
+ if (data.ogImage !== undefined) {
285
+ updates.push(`"ogImage" = $${paramIndex++}`)
286
+ values.push(data.ogImage || null)
287
+ }
288
+
289
+ if (updates.length === 0) {
290
+ throw new Error('No fields to update')
291
+ }
292
+
293
+ updates.push(`"updatedAt" = $${paramIndex++}`)
294
+ values.push(new Date().toISOString())
295
+
296
+ values.push(id)
297
+
298
+ const result = await mutateWithRLS<DbPage>(
299
+ `
300
+ UPDATE pages
301
+ SET ${updates.join(', ')}
302
+ WHERE id = $${paramIndex}
303
+ RETURNING
304
+ id, slug, title, blocks, locale, status,
305
+ "seoTitle", "seoDescription", "ogImage",
306
+ "createdAt", "updatedAt"
307
+ `,
308
+ values,
309
+ userId
310
+ )
311
+
312
+ if (!result.rows[0]) {
313
+ throw new Error('Page not found or update failed')
314
+ }
315
+
316
+ return toPageFull(result.rows[0])
317
+ } catch (error) {
318
+ console.error('PagesManagementService.update error:', error)
319
+ throw new Error(
320
+ error instanceof Error ? error.message : 'Failed to update page'
321
+ )
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Delete a page
327
+ */
328
+ static async delete(
329
+ userId: string,
330
+ id: string
331
+ ): Promise<boolean> {
332
+ try {
333
+ if (!userId || userId.trim() === '') {
334
+ throw new Error('User ID is required for authentication')
335
+ }
336
+
337
+ if (!id || id.trim() === '') {
338
+ throw new Error('Page ID is required')
339
+ }
340
+
341
+ const result = await mutateWithRLS(
342
+ `DELETE FROM pages WHERE id = $1`,
343
+ [id],
344
+ userId
345
+ )
346
+
347
+ return result.rowCount > 0
348
+ } catch (error) {
349
+ console.error('PagesManagementService.delete error:', error)
350
+ throw new Error(
351
+ error instanceof Error ? error.message : 'Failed to delete page'
352
+ )
353
+ }
354
+ }
355
+
356
+ // ============================================
357
+ // BLOCK OPERATIONS
358
+ // ============================================
359
+
360
+ /**
361
+ * Add a block to a page
362
+ */
363
+ static async addBlock(
364
+ userId: string,
365
+ pageId: string,
366
+ blockSlug: string,
367
+ props: Record<string, unknown>,
368
+ position?: number
369
+ ): Promise<PageFull> {
370
+ try {
371
+ // Get current page
372
+ const page = await this.getById(userId, pageId)
373
+ if (!page) {
374
+ throw new Error('Page not found')
375
+ }
376
+
377
+ // Create new block
378
+ const newBlock: Block = {
379
+ id: crypto.randomUUID(),
380
+ blockSlug,
381
+ props,
382
+ }
383
+
384
+ // Add block at position or end
385
+ const blocks = [...page.blocks]
386
+ if (position !== undefined && position >= 0 && position <= blocks.length) {
387
+ blocks.splice(position, 0, newBlock)
388
+ } else {
389
+ blocks.push(newBlock)
390
+ }
391
+
392
+ // Update page with new blocks
393
+ const result = await mutateWithRLS<DbPage>(
394
+ `
395
+ UPDATE pages
396
+ SET blocks = $1, "updatedAt" = $2
397
+ WHERE id = $3
398
+ RETURNING
399
+ id, slug, title, blocks, locale, status,
400
+ "seoTitle", "seoDescription", "ogImage",
401
+ "createdAt", "updatedAt"
402
+ `,
403
+ [JSON.stringify(blocks), new Date().toISOString(), pageId],
404
+ userId
405
+ )
406
+
407
+ if (!result.rows[0]) {
408
+ throw new Error('Failed to add block')
409
+ }
410
+
411
+ return toPageFull(result.rows[0])
412
+ } catch (error) {
413
+ console.error('PagesManagementService.addBlock error:', error)
414
+ throw new Error(
415
+ error instanceof Error ? error.message : 'Failed to add block'
416
+ )
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Update a block's properties
422
+ */
423
+ static async updateBlock(
424
+ userId: string,
425
+ pageId: string,
426
+ blockId: string,
427
+ props: Record<string, unknown>
428
+ ): Promise<PageFull> {
429
+ try {
430
+ // Get current page
431
+ const page = await this.getById(userId, pageId)
432
+ if (!page) {
433
+ throw new Error('Page not found')
434
+ }
435
+
436
+ // Find and update block
437
+ const blockIndex = page.blocks.findIndex((b: Block) => b.id === blockId)
438
+ if (blockIndex === -1) {
439
+ throw new Error('Block not found')
440
+ }
441
+
442
+ const blocks = [...page.blocks]
443
+ blocks[blockIndex] = {
444
+ ...blocks[blockIndex],
445
+ props: { ...blocks[blockIndex].props, ...props },
446
+ }
447
+
448
+ // Update page with modified blocks
449
+ const result = await mutateWithRLS<DbPage>(
450
+ `
451
+ UPDATE pages
452
+ SET blocks = $1, "updatedAt" = $2
453
+ WHERE id = $3
454
+ RETURNING
455
+ id, slug, title, blocks, locale, status,
456
+ "seoTitle", "seoDescription", "ogImage",
457
+ "createdAt", "updatedAt"
458
+ `,
459
+ [JSON.stringify(blocks), new Date().toISOString(), pageId],
460
+ userId
461
+ )
462
+
463
+ if (!result.rows[0]) {
464
+ throw new Error('Failed to update block')
465
+ }
466
+
467
+ return toPageFull(result.rows[0])
468
+ } catch (error) {
469
+ console.error('PagesManagementService.updateBlock error:', error)
470
+ throw new Error(
471
+ error instanceof Error ? error.message : 'Failed to update block'
472
+ )
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Remove a block from a page
478
+ */
479
+ static async removeBlock(
480
+ userId: string,
481
+ pageId: string,
482
+ blockId: string
483
+ ): Promise<PageFull> {
484
+ try {
485
+ // Get current page
486
+ const page = await this.getById(userId, pageId)
487
+ if (!page) {
488
+ throw new Error('Page not found')
489
+ }
490
+
491
+ // Filter out the block
492
+ const blocks = page.blocks.filter((b: Block) => b.id !== blockId)
493
+
494
+ if (blocks.length === page.blocks.length) {
495
+ throw new Error('Block not found')
496
+ }
497
+
498
+ // Update page with remaining blocks
499
+ const result = await mutateWithRLS<DbPage>(
500
+ `
501
+ UPDATE pages
502
+ SET blocks = $1, "updatedAt" = $2
503
+ WHERE id = $3
504
+ RETURNING
505
+ id, slug, title, blocks, locale, status,
506
+ "seoTitle", "seoDescription", "ogImage",
507
+ "createdAt", "updatedAt"
508
+ `,
509
+ [JSON.stringify(blocks), new Date().toISOString(), pageId],
510
+ userId
511
+ )
512
+
513
+ if (!result.rows[0]) {
514
+ throw new Error('Failed to remove block')
515
+ }
516
+
517
+ return toPageFull(result.rows[0])
518
+ } catch (error) {
519
+ console.error('PagesManagementService.removeBlock error:', error)
520
+ throw new Error(
521
+ error instanceof Error ? error.message : 'Failed to remove block'
522
+ )
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Reorder blocks in a page
528
+ */
529
+ static async reorderBlocks(
530
+ userId: string,
531
+ pageId: string,
532
+ blockIds: string[]
533
+ ): Promise<PageFull> {
534
+ try {
535
+ // Get current page
536
+ const page = await this.getById(userId, pageId)
537
+ if (!page) {
538
+ throw new Error('Page not found')
539
+ }
540
+
541
+ // Create a map of blocks by ID
542
+ const blockMap = new Map(page.blocks.map(b => [b.id, b]))
543
+
544
+ // Reorder based on provided IDs
545
+ const reorderedBlocks: Block[] = []
546
+ for (const id of blockIds) {
547
+ const block = blockMap.get(id)
548
+ if (block) {
549
+ reorderedBlocks.push(block)
550
+ blockMap.delete(id)
551
+ }
552
+ }
553
+
554
+ // Add any remaining blocks (in case some IDs were missing)
555
+ for (const block of blockMap.values()) {
556
+ reorderedBlocks.push(block)
557
+ }
558
+
559
+ // Update page with reordered blocks
560
+ const result = await mutateWithRLS<DbPage>(
561
+ `
562
+ UPDATE pages
563
+ SET blocks = $1, "updatedAt" = $2
564
+ WHERE id = $3
565
+ RETURNING
566
+ id, slug, title, blocks, locale, status,
567
+ "seoTitle", "seoDescription", "ogImage",
568
+ "createdAt", "updatedAt"
569
+ `,
570
+ [JSON.stringify(reorderedBlocks), new Date().toISOString(), pageId],
571
+ userId
572
+ )
573
+
574
+ if (!result.rows[0]) {
575
+ throw new Error('Failed to reorder blocks')
576
+ }
577
+
578
+ return toPageFull(result.rows[0])
579
+ } catch (error) {
580
+ console.error('PagesManagementService.reorderBlocks error:', error)
581
+ throw new Error(
582
+ error instanceof Error ? error.message : 'Failed to reorder blocks'
583
+ )
584
+ }
585
+ }
586
+
587
+ // ============================================
588
+ // PUBLICATION
589
+ // ============================================
590
+
591
+ /**
592
+ * Publish a page
593
+ */
594
+ static async publish(
595
+ userId: string,
596
+ id: string
597
+ ): Promise<PageFull> {
598
+ return this.update(userId, id, { status: 'published' })
599
+ }
600
+
601
+ /**
602
+ * Unpublish a page (set to draft)
603
+ */
604
+ static async unpublish(
605
+ userId: string,
606
+ id: string
607
+ ): Promise<PageFull> {
608
+ return this.update(userId, id, { status: 'draft' })
609
+ }
610
+ }