create-pxlr 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 (153) hide show
  1. package/README.md +160 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +264 -0
  4. package/package.json +51 -0
  5. package/templates/blog/frontend/app/blog/[slug]/page.tsx +175 -0
  6. package/templates/blog/frontend/app/blog/page.tsx +102 -0
  7. package/templates/blog/frontend/app/components/footer.tsx +21 -0
  8. package/templates/blog/frontend/app/components/header.tsx +45 -0
  9. package/templates/blog/frontend/app/globals.css +30 -0
  10. package/templates/blog/frontend/app/layout.tsx +38 -0
  11. package/templates/blog/frontend/app/lib/cms.ts +71 -0
  12. package/templates/blog/frontend/app/page.tsx +155 -0
  13. package/templates/blog/frontend/next.config.ts +16 -0
  14. package/templates/blog/frontend/package.json +24 -0
  15. package/templates/blog/frontend/postcss.config.mjs +7 -0
  16. package/templates/blog/frontend/tsconfig.json +23 -0
  17. package/templates/blog/pxlr-cms/README.md +188 -0
  18. package/templates/blog/pxlr-cms/docker-compose.yml +132 -0
  19. package/templates/blog/pxlr-cms/nginx/nginx.conf +107 -0
  20. package/templates/blog/pxlr-cms/packages/admin/.dockerignore +4 -0
  21. package/templates/blog/pxlr-cms/packages/admin/.env.example +2 -0
  22. package/templates/blog/pxlr-cms/packages/admin/Dockerfile +19 -0
  23. package/templates/blog/pxlr-cms/packages/admin/next-env.d.ts +6 -0
  24. package/templates/blog/pxlr-cms/packages/admin/next.config.ts +22 -0
  25. package/templates/blog/pxlr-cms/packages/admin/package.json +63 -0
  26. package/templates/blog/pxlr-cms/packages/admin/pnpm-lock.yaml +5748 -0
  27. package/templates/blog/pxlr-cms/packages/admin/postcss.config.mjs +9 -0
  28. package/templates/blog/pxlr-cms/packages/admin/src/app/content/[id]/page.tsx +503 -0
  29. package/templates/blog/pxlr-cms/packages/admin/src/app/content/layout.tsx +7 -0
  30. package/templates/blog/pxlr-cms/packages/admin/src/app/content/new/page.tsx +424 -0
  31. package/templates/blog/pxlr-cms/packages/admin/src/app/content/page.tsx +191 -0
  32. package/templates/blog/pxlr-cms/packages/admin/src/app/globals.css +132 -0
  33. package/templates/blog/pxlr-cms/packages/admin/src/app/layout.tsx +25 -0
  34. package/templates/blog/pxlr-cms/packages/admin/src/app/login/page.tsx +119 -0
  35. package/templates/blog/pxlr-cms/packages/admin/src/app/media/layout.tsx +7 -0
  36. package/templates/blog/pxlr-cms/packages/admin/src/app/media/page.tsx +362 -0
  37. package/templates/blog/pxlr-cms/packages/admin/src/app/page.tsx +184 -0
  38. package/templates/blog/pxlr-cms/packages/admin/src/app/profile/layout.tsx +7 -0
  39. package/templates/blog/pxlr-cms/packages/admin/src/app/profile/page.tsx +206 -0
  40. package/templates/blog/pxlr-cms/packages/admin/src/app/schemas/[name]/page.tsx +312 -0
  41. package/templates/blog/pxlr-cms/packages/admin/src/app/schemas/layout.tsx +7 -0
  42. package/templates/blog/pxlr-cms/packages/admin/src/app/schemas/page.tsx +210 -0
  43. package/templates/blog/pxlr-cms/packages/admin/src/app/settings/layout.tsx +7 -0
  44. package/templates/blog/pxlr-cms/packages/admin/src/app/settings/page.tsx +178 -0
  45. package/templates/blog/pxlr-cms/packages/admin/src/components/editor/media-picker.tsx +202 -0
  46. package/templates/blog/pxlr-cms/packages/admin/src/components/editor/rich-text-editor.tsx +387 -0
  47. package/templates/blog/pxlr-cms/packages/admin/src/components/layout/auth-layout.tsx +43 -0
  48. package/templates/blog/pxlr-cms/packages/admin/src/components/layout/header.tsx +79 -0
  49. package/templates/blog/pxlr-cms/packages/admin/src/components/layout/sidebar.tsx +68 -0
  50. package/templates/blog/pxlr-cms/packages/admin/src/components/providers.tsx +29 -0
  51. package/templates/blog/pxlr-cms/packages/admin/src/components/schema-code-generator.tsx +326 -0
  52. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/avatar.tsx +49 -0
  53. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/button.tsx +55 -0
  54. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/dropdown-menu.tsx +194 -0
  55. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/input.tsx +24 -0
  56. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/label.tsx +25 -0
  57. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/toast.tsx +127 -0
  58. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/toaster.tsx +35 -0
  59. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/use-toast.ts +187 -0
  60. package/templates/blog/pxlr-cms/packages/admin/src/lib/api.ts +96 -0
  61. package/templates/blog/pxlr-cms/packages/admin/src/lib/i18n/context.tsx +60 -0
  62. package/templates/blog/pxlr-cms/packages/admin/src/lib/i18n/translations.ts +317 -0
  63. package/templates/blog/pxlr-cms/packages/admin/src/lib/store/auth.ts +51 -0
  64. package/templates/blog/pxlr-cms/packages/admin/src/lib/utils.ts +29 -0
  65. package/templates/blog/pxlr-cms/packages/admin/tailwind.config.ts +57 -0
  66. package/templates/blog/pxlr-cms/packages/admin/tsconfig.json +27 -0
  67. package/templates/blog/pxlr-cms/packages/api/.env.example +23 -0
  68. package/templates/blog/pxlr-cms/packages/api/Dockerfile +26 -0
  69. package/templates/blog/pxlr-cms/packages/api/package.json +42 -0
  70. package/templates/blog/pxlr-cms/packages/api/src/config.ts +39 -0
  71. package/templates/blog/pxlr-cms/packages/api/src/database/index.ts +60 -0
  72. package/templates/blog/pxlr-cms/packages/api/src/database/init.sql +258 -0
  73. package/templates/blog/pxlr-cms/packages/api/src/database/redis.ts +95 -0
  74. package/templates/blog/pxlr-cms/packages/api/src/database/seed.sql +78 -0
  75. package/templates/blog/pxlr-cms/packages/api/src/index.ts +157 -0
  76. package/templates/blog/pxlr-cms/packages/api/src/modules/auth/routes.ts +256 -0
  77. package/templates/blog/pxlr-cms/packages/api/src/modules/content/routes.ts +385 -0
  78. package/templates/blog/pxlr-cms/packages/api/src/modules/media/routes.ts +312 -0
  79. package/templates/blog/pxlr-cms/packages/api/src/modules/realtime/handler.ts +228 -0
  80. package/templates/blog/pxlr-cms/packages/api/src/modules/schema/routes.ts +284 -0
  81. package/templates/blog/pxlr-cms/packages/api/src/modules/versions/routes.ts +70 -0
  82. package/templates/blog/pxlr-cms/packages/api/tsconfig.json +24 -0
  83. package/templates/blog/pxlr-cms/packages/shared/package.json +14 -0
  84. package/templates/blog/pxlr-cms/packages/shared/src/types/index.ts +139 -0
  85. package/templates/blog/pxlr-cms/packages/shared/tsconfig.json +18 -0
  86. package/templates/clean/pxlr-cms/README.md +188 -0
  87. package/templates/clean/pxlr-cms/docker-compose.yml +132 -0
  88. package/templates/clean/pxlr-cms/nginx/nginx.conf +107 -0
  89. package/templates/clean/pxlr-cms/packages/admin/.dockerignore +4 -0
  90. package/templates/clean/pxlr-cms/packages/admin/.env.example +2 -0
  91. package/templates/clean/pxlr-cms/packages/admin/Dockerfile +19 -0
  92. package/templates/clean/pxlr-cms/packages/admin/next-env.d.ts +6 -0
  93. package/templates/clean/pxlr-cms/packages/admin/next.config.ts +22 -0
  94. package/templates/clean/pxlr-cms/packages/admin/package.json +63 -0
  95. package/templates/clean/pxlr-cms/packages/admin/pnpm-lock.yaml +5748 -0
  96. package/templates/clean/pxlr-cms/packages/admin/postcss.config.mjs +9 -0
  97. package/templates/clean/pxlr-cms/packages/admin/src/app/content/[id]/page.tsx +503 -0
  98. package/templates/clean/pxlr-cms/packages/admin/src/app/content/layout.tsx +7 -0
  99. package/templates/clean/pxlr-cms/packages/admin/src/app/content/new/page.tsx +424 -0
  100. package/templates/clean/pxlr-cms/packages/admin/src/app/content/page.tsx +191 -0
  101. package/templates/clean/pxlr-cms/packages/admin/src/app/globals.css +132 -0
  102. package/templates/clean/pxlr-cms/packages/admin/src/app/layout.tsx +25 -0
  103. package/templates/clean/pxlr-cms/packages/admin/src/app/login/page.tsx +119 -0
  104. package/templates/clean/pxlr-cms/packages/admin/src/app/media/layout.tsx +7 -0
  105. package/templates/clean/pxlr-cms/packages/admin/src/app/media/page.tsx +362 -0
  106. package/templates/clean/pxlr-cms/packages/admin/src/app/page.tsx +184 -0
  107. package/templates/clean/pxlr-cms/packages/admin/src/app/profile/layout.tsx +7 -0
  108. package/templates/clean/pxlr-cms/packages/admin/src/app/profile/page.tsx +206 -0
  109. package/templates/clean/pxlr-cms/packages/admin/src/app/schemas/[name]/page.tsx +312 -0
  110. package/templates/clean/pxlr-cms/packages/admin/src/app/schemas/layout.tsx +7 -0
  111. package/templates/clean/pxlr-cms/packages/admin/src/app/schemas/page.tsx +210 -0
  112. package/templates/clean/pxlr-cms/packages/admin/src/app/settings/layout.tsx +7 -0
  113. package/templates/clean/pxlr-cms/packages/admin/src/app/settings/page.tsx +178 -0
  114. package/templates/clean/pxlr-cms/packages/admin/src/components/editor/media-picker.tsx +202 -0
  115. package/templates/clean/pxlr-cms/packages/admin/src/components/editor/rich-text-editor.tsx +387 -0
  116. package/templates/clean/pxlr-cms/packages/admin/src/components/layout/auth-layout.tsx +43 -0
  117. package/templates/clean/pxlr-cms/packages/admin/src/components/layout/header.tsx +79 -0
  118. package/templates/clean/pxlr-cms/packages/admin/src/components/layout/sidebar.tsx +68 -0
  119. package/templates/clean/pxlr-cms/packages/admin/src/components/providers.tsx +29 -0
  120. package/templates/clean/pxlr-cms/packages/admin/src/components/schema-code-generator.tsx +326 -0
  121. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/avatar.tsx +49 -0
  122. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/button.tsx +55 -0
  123. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/dropdown-menu.tsx +194 -0
  124. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/input.tsx +24 -0
  125. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/label.tsx +25 -0
  126. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/toast.tsx +127 -0
  127. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/toaster.tsx +35 -0
  128. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/use-toast.ts +187 -0
  129. package/templates/clean/pxlr-cms/packages/admin/src/lib/api.ts +96 -0
  130. package/templates/clean/pxlr-cms/packages/admin/src/lib/i18n/context.tsx +60 -0
  131. package/templates/clean/pxlr-cms/packages/admin/src/lib/i18n/translations.ts +317 -0
  132. package/templates/clean/pxlr-cms/packages/admin/src/lib/store/auth.ts +51 -0
  133. package/templates/clean/pxlr-cms/packages/admin/src/lib/utils.ts +29 -0
  134. package/templates/clean/pxlr-cms/packages/admin/tailwind.config.ts +57 -0
  135. package/templates/clean/pxlr-cms/packages/admin/tsconfig.json +27 -0
  136. package/templates/clean/pxlr-cms/packages/api/.env.example +23 -0
  137. package/templates/clean/pxlr-cms/packages/api/Dockerfile +26 -0
  138. package/templates/clean/pxlr-cms/packages/api/package.json +42 -0
  139. package/templates/clean/pxlr-cms/packages/api/src/config.ts +39 -0
  140. package/templates/clean/pxlr-cms/packages/api/src/database/index.ts +60 -0
  141. package/templates/clean/pxlr-cms/packages/api/src/database/init.sql +178 -0
  142. package/templates/clean/pxlr-cms/packages/api/src/database/redis.ts +95 -0
  143. package/templates/clean/pxlr-cms/packages/api/src/index.ts +157 -0
  144. package/templates/clean/pxlr-cms/packages/api/src/modules/auth/routes.ts +256 -0
  145. package/templates/clean/pxlr-cms/packages/api/src/modules/content/routes.ts +385 -0
  146. package/templates/clean/pxlr-cms/packages/api/src/modules/media/routes.ts +312 -0
  147. package/templates/clean/pxlr-cms/packages/api/src/modules/realtime/handler.ts +228 -0
  148. package/templates/clean/pxlr-cms/packages/api/src/modules/schema/routes.ts +284 -0
  149. package/templates/clean/pxlr-cms/packages/api/src/modules/versions/routes.ts +70 -0
  150. package/templates/clean/pxlr-cms/packages/api/tsconfig.json +24 -0
  151. package/templates/clean/pxlr-cms/packages/shared/package.json +14 -0
  152. package/templates/clean/pxlr-cms/packages/shared/src/types/index.ts +139 -0
  153. package/templates/clean/pxlr-cms/packages/shared/tsconfig.json +18 -0
@@ -0,0 +1,256 @@
1
+ import { FastifyPluginAsync } from 'fastify';
2
+ import bcrypt from 'bcryptjs';
3
+ import { z } from 'zod';
4
+ import { db } from '../../database/index.js';
5
+
6
+ const loginSchema = z.object({
7
+ email: z.string().email(),
8
+ password: z.string().min(6),
9
+ });
10
+
11
+ const registerSchema = z.object({
12
+ email: z.string().email(),
13
+ password: z.string().min(6),
14
+ name: z.string().min(2),
15
+ });
16
+
17
+ export const authRoutes: FastifyPluginAsync = async (fastify) => {
18
+ // Login
19
+ fastify.post('/login', {
20
+ schema: {
21
+ tags: ['Auth'],
22
+ summary: 'Login to PXLR CMS',
23
+ body: {
24
+ type: 'object',
25
+ required: ['email', 'password'],
26
+ properties: {
27
+ email: { type: 'string', format: 'email' },
28
+ password: { type: 'string', minLength: 6 },
29
+ },
30
+ },
31
+ response: {
32
+ 200: {
33
+ type: 'object',
34
+ properties: {
35
+ token: { type: 'string' },
36
+ user: {
37
+ type: 'object',
38
+ properties: {
39
+ id: { type: 'string' },
40
+ email: { type: 'string' },
41
+ name: { type: 'string' },
42
+ role: { type: 'string' },
43
+ },
44
+ },
45
+ },
46
+ },
47
+ },
48
+ },
49
+ }, async (request, reply) => {
50
+ const body = loginSchema.parse(request.body);
51
+
52
+ const result = await db.query(
53
+ `SELECT id, email, password_hash, name, role, is_active
54
+ FROM users WHERE email = $1`,
55
+ [body.email]
56
+ );
57
+
58
+ const user = result.rows[0];
59
+
60
+ if (!user || !user.is_active) {
61
+ return reply.status(401).send({ error: true, message: 'Invalid credentials' });
62
+ }
63
+
64
+ const validPassword = await bcrypt.compare(body.password, user.password_hash);
65
+ if (!validPassword) {
66
+ return reply.status(401).send({ error: true, message: 'Invalid credentials' });
67
+ }
68
+
69
+ // Update last login
70
+ await db.query(
71
+ 'UPDATE users SET last_login_at = CURRENT_TIMESTAMP WHERE id = $1',
72
+ [user.id]
73
+ );
74
+
75
+ const token = fastify.jwt.sign({
76
+ id: user.id,
77
+ email: user.email,
78
+ role: user.role,
79
+ });
80
+
81
+ return {
82
+ token,
83
+ user: {
84
+ id: user.id,
85
+ email: user.email,
86
+ name: user.name,
87
+ role: user.role,
88
+ },
89
+ };
90
+ });
91
+
92
+ // Register (admin only in production)
93
+ fastify.post('/register', {
94
+ schema: {
95
+ tags: ['Auth'],
96
+ summary: 'Register a new user',
97
+ body: {
98
+ type: 'object',
99
+ required: ['email', 'password', 'name'],
100
+ properties: {
101
+ email: { type: 'string', format: 'email' },
102
+ password: { type: 'string', minLength: 6 },
103
+ name: { type: 'string', minLength: 2 },
104
+ },
105
+ },
106
+ },
107
+ }, async (request, reply) => {
108
+ const body = registerSchema.parse(request.body);
109
+
110
+ // Check if user exists
111
+ const existing = await db.query(
112
+ 'SELECT id FROM users WHERE email = $1',
113
+ [body.email]
114
+ );
115
+
116
+ if (existing.rows.length > 0) {
117
+ return reply.status(400).send({ error: true, message: 'Email already registered' });
118
+ }
119
+
120
+ const passwordHash = await bcrypt.hash(body.password, 12);
121
+
122
+ const result = await db.query(
123
+ `INSERT INTO users (email, password_hash, name, role)
124
+ VALUES ($1, $2, $3, 'editor')
125
+ RETURNING id, email, name, role`,
126
+ [body.email, passwordHash, body.name]
127
+ );
128
+
129
+ const user = result.rows[0];
130
+
131
+ const token = fastify.jwt.sign({
132
+ id: user.id,
133
+ email: user.email,
134
+ role: user.role,
135
+ });
136
+
137
+ return {
138
+ token,
139
+ user: {
140
+ id: user.id,
141
+ email: user.email,
142
+ name: user.name,
143
+ role: user.role,
144
+ },
145
+ };
146
+ });
147
+
148
+ // Get current user
149
+ fastify.get('/me', {
150
+ schema: {
151
+ tags: ['Auth'],
152
+ summary: 'Get current user',
153
+ security: [{ bearerAuth: [] }],
154
+ },
155
+ preHandler: [fastify.authenticate],
156
+ }, async (request) => {
157
+ const user = request.user as { id: string };
158
+
159
+ const result = await db.query(
160
+ `SELECT id, email, name, role, avatar_url, created_at
161
+ FROM users WHERE id = $1`,
162
+ [user.id]
163
+ );
164
+
165
+ return { user: result.rows[0] };
166
+ });
167
+
168
+ // Logout (invalidate token - for future implementation with token blacklist)
169
+ fastify.post('/logout', {
170
+ schema: {
171
+ tags: ['Auth'],
172
+ summary: 'Logout current user',
173
+ security: [{ bearerAuth: [] }],
174
+ },
175
+ preHandler: [fastify.authenticate],
176
+ }, async () => {
177
+ return { success: true, message: 'Logged out successfully' };
178
+ });
179
+
180
+ // Update profile
181
+ fastify.put('/profile', {
182
+ schema: {
183
+ tags: ['Auth'],
184
+ summary: 'Update user profile',
185
+ security: [{ bearerAuth: [] }],
186
+ },
187
+ preHandler: [fastify.authenticate],
188
+ }, async (request, reply) => {
189
+ const user = request.user as { id: string };
190
+ const body = z.object({
191
+ name: z.string().min(2),
192
+ }).parse(request.body);
193
+
194
+ const result = await db.query(
195
+ `UPDATE users SET name = $1, updated_at = CURRENT_TIMESTAMP
196
+ WHERE id = $2
197
+ RETURNING id, email, name, role, avatar_url`,
198
+ [body.name, user.id]
199
+ );
200
+
201
+ if (result.rows.length === 0) {
202
+ return reply.status(404).send({ error: true, message: 'User not found' });
203
+ }
204
+
205
+ return { user: result.rows[0] };
206
+ });
207
+
208
+ // Change password
209
+ fastify.put('/password', {
210
+ schema: {
211
+ tags: ['Auth'],
212
+ summary: 'Change user password',
213
+ security: [{ bearerAuth: [] }],
214
+ },
215
+ preHandler: [fastify.authenticate],
216
+ }, async (request, reply) => {
217
+ const user = request.user as { id: string };
218
+ const body = z.object({
219
+ currentPassword: z.string().min(6),
220
+ newPassword: z.string().min(6),
221
+ }).parse(request.body);
222
+
223
+ // Get current password hash
224
+ const result = await db.query(
225
+ 'SELECT password_hash FROM users WHERE id = $1',
226
+ [user.id]
227
+ );
228
+
229
+ if (result.rows.length === 0) {
230
+ return reply.status(404).send({ error: true, message: 'User not found' });
231
+ }
232
+
233
+ // Verify current password
234
+ const validPassword = await bcrypt.compare(body.currentPassword, result.rows[0].password_hash);
235
+ if (!validPassword) {
236
+ return reply.status(400).send({ error: true, message: 'Current password is incorrect' });
237
+ }
238
+
239
+ // Hash new password and update
240
+ const newPasswordHash = await bcrypt.hash(body.newPassword, 12);
241
+ await db.query(
242
+ 'UPDATE users SET password_hash = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
243
+ [newPasswordHash, user.id]
244
+ );
245
+
246
+ return { success: true, message: 'Password changed successfully' };
247
+ });
248
+
249
+ };
250
+
251
+ // Type augmentation for Fastify
252
+ declare module 'fastify' {
253
+ interface FastifyInstance {
254
+ authenticate: any;
255
+ }
256
+ }
@@ -0,0 +1,385 @@
1
+ import { FastifyPluginAsync } from 'fastify';
2
+ import { z } from 'zod';
3
+ import { v4 as uuid } from 'uuid';
4
+ import { db } from '../../database/index.js';
5
+ import { redis } from '../../database/redis.js';
6
+
7
+ const createDocumentSchema = z.object({
8
+ schemaName: z.string().min(1),
9
+ data: z.record(z.any()),
10
+ locale: z.string().default('en'),
11
+ status: z.enum(['draft', 'published']).default('draft'),
12
+ });
13
+
14
+ const updateDocumentSchema = z.object({
15
+ data: z.record(z.any()).optional(),
16
+ locale: z.string().optional(),
17
+ status: z.enum(['draft', 'published', 'archived']).optional(),
18
+ });
19
+
20
+ const querySchema = z.object({
21
+ schemaName: z.string().optional(),
22
+ status: z.enum(['draft', 'published', 'archived']).optional(),
23
+ locale: z.string().optional(),
24
+ page: z.coerce.number().min(1).default(1),
25
+ limit: z.coerce.number().min(1).max(100).default(20),
26
+ orderBy: z.string().default('created_at'),
27
+ order: z.enum(['asc', 'desc']).default('desc'),
28
+ search: z.string().optional(),
29
+ });
30
+
31
+ export const contentRoutes: FastifyPluginAsync = async (fastify) => {
32
+ // List documents with filtering
33
+ fastify.get('/', {
34
+ schema: {
35
+ tags: ['Content'],
36
+ summary: 'List documents with filtering and pagination',
37
+ querystring: {
38
+ type: 'object',
39
+ properties: {
40
+ schemaName: { type: 'string' },
41
+ status: { type: 'string' },
42
+ locale: { type: 'string' },
43
+ page: { type: 'number' },
44
+ limit: { type: 'number' },
45
+ orderBy: { type: 'string' },
46
+ order: { type: 'string' },
47
+ search: { type: 'string' },
48
+ },
49
+ },
50
+ },
51
+ }, async (request) => {
52
+ const query = querySchema.parse(request.query);
53
+ const offset = (query.page - 1) * query.limit;
54
+
55
+ let whereClause = 'WHERE 1=1';
56
+ const params: any[] = [];
57
+ let paramIndex = 1;
58
+
59
+ if (query.schemaName) {
60
+ whereClause += ` AND schema_name = $${paramIndex++}`;
61
+ params.push(query.schemaName);
62
+ }
63
+ if (query.status) {
64
+ whereClause += ` AND status = $${paramIndex++}`;
65
+ params.push(query.status);
66
+ }
67
+ if (query.locale) {
68
+ whereClause += ` AND locale = $${paramIndex++}`;
69
+ params.push(query.locale);
70
+ }
71
+ if (query.search) {
72
+ whereClause += ` AND data::text ILIKE $${paramIndex++}`;
73
+ params.push(`%${query.search}%`);
74
+ }
75
+
76
+ // Count total
77
+ const countResult = await db.query(
78
+ `SELECT COUNT(*) FROM documents ${whereClause}`,
79
+ params
80
+ );
81
+ const total = parseInt(countResult.rows[0].count);
82
+
83
+ // Get documents
84
+ const allowedOrderBy = ['created_at', 'updated_at', 'published_at'];
85
+ const orderByColumn = allowedOrderBy.includes(query.orderBy) ? query.orderBy : 'created_at';
86
+ const orderDirection = query.order === 'asc' ? 'ASC' : 'DESC';
87
+
88
+ params.push(query.limit, offset);
89
+
90
+ const result = await db.query(
91
+ `SELECT d.*, s.title as schema_title, u.name as created_by_name
92
+ FROM documents d
93
+ LEFT JOIN schemas s ON d.schema_name = s.name
94
+ LEFT JOIN users u ON d.created_by = u.id
95
+ ${whereClause}
96
+ ORDER BY d.${orderByColumn} ${orderDirection}
97
+ LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
98
+ params
99
+ );
100
+
101
+ return {
102
+ documents: result.rows,
103
+ pagination: {
104
+ page: query.page,
105
+ limit: query.limit,
106
+ total,
107
+ totalPages: Math.ceil(total / query.limit),
108
+ },
109
+ };
110
+ });
111
+
112
+ // Get single document
113
+ fastify.get('/:id', {
114
+ schema: {
115
+ tags: ['Content'],
116
+ summary: 'Get document by ID',
117
+ },
118
+ }, async (request, reply) => {
119
+ const { id } = request.params as { id: string };
120
+
121
+ const result = await db.query(
122
+ `SELECT d.*, s.title as schema_title, s.definition as schema_definition,
123
+ u1.name as created_by_name, u2.name as updated_by_name
124
+ FROM documents d
125
+ LEFT JOIN schemas s ON d.schema_name = s.name
126
+ LEFT JOIN users u1 ON d.created_by = u1.id
127
+ LEFT JOIN users u2 ON d.updated_by = u2.id
128
+ WHERE d.id = $1`,
129
+ [id]
130
+ );
131
+
132
+ if (result.rows.length === 0) {
133
+ return reply.status(404).send({ error: true, message: 'Document not found' });
134
+ }
135
+
136
+ return { document: result.rows[0] };
137
+ });
138
+
139
+ // Create document
140
+ fastify.post('/', {
141
+ schema: {
142
+ tags: ['Content'],
143
+ summary: 'Create a new document',
144
+ security: [{ bearerAuth: [] }],
145
+ },
146
+ preHandler: [fastify.authenticate],
147
+ }, async (request, reply) => {
148
+ const body = createDocumentSchema.parse(request.body);
149
+ const user = request.user as { id: string };
150
+
151
+ // Verify schema exists
152
+ const schemaResult = await db.query(
153
+ 'SELECT id, is_singleton FROM schemas WHERE name = $1',
154
+ [body.schemaName]
155
+ );
156
+
157
+ if (schemaResult.rows.length === 0) {
158
+ return reply.status(400).send({ error: true, message: 'Schema not found' });
159
+ }
160
+
161
+ const schema = schemaResult.rows[0];
162
+
163
+ // Check singleton
164
+ if (schema.is_singleton) {
165
+ const existingDoc = await db.query(
166
+ 'SELECT id FROM documents WHERE schema_name = $1 AND locale = $2',
167
+ [body.schemaName, body.locale]
168
+ );
169
+ if (existingDoc.rows.length > 0) {
170
+ return reply.status(400).send({
171
+ error: true,
172
+ message: 'Singleton document already exists for this locale'
173
+ });
174
+ }
175
+ }
176
+
177
+ const documentId = uuid();
178
+ const publishedAt = body.status === 'published' ? new Date() : null;
179
+
180
+ const result = await db.query(
181
+ `INSERT INTO documents (id, schema_name, data, locale, status, published_at, created_by, updated_by)
182
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
183
+ RETURNING *`,
184
+ [documentId, body.schemaName, body.data, body.locale, body.status, publishedAt, user.id]
185
+ );
186
+
187
+ // Create initial version
188
+ await db.query(
189
+ `INSERT INTO document_versions (document_id, version, data, locale, created_by)
190
+ VALUES ($1, 1, $2, $3, $4)`,
191
+ [documentId, body.data, body.locale, user.id]
192
+ );
193
+
194
+ // Publish event for real-time
195
+ await redis.publish('content:created', {
196
+ documentId,
197
+ schemaName: body.schemaName,
198
+ userId: user.id,
199
+ });
200
+
201
+ return { document: result.rows[0] };
202
+ });
203
+
204
+ // Update document
205
+ fastify.put('/:id', {
206
+ schema: {
207
+ tags: ['Content'],
208
+ summary: 'Update a document',
209
+ security: [{ bearerAuth: [] }],
210
+ },
211
+ preHandler: [fastify.authenticate],
212
+ }, async (request, reply) => {
213
+ const { id } = request.params as { id: string };
214
+ const body = updateDocumentSchema.parse(request.body);
215
+ const user = request.user as { id: string };
216
+
217
+ // Get current document
218
+ const currentDoc = await db.query(
219
+ 'SELECT * FROM documents WHERE id = $1',
220
+ [id]
221
+ );
222
+
223
+ if (currentDoc.rows.length === 0) {
224
+ return reply.status(404).send({ error: true, message: 'Document not found' });
225
+ }
226
+
227
+ const doc = currentDoc.rows[0];
228
+
229
+ // Build update query
230
+ const updates: string[] = ['updated_by = $1'];
231
+ const values: any[] = [user.id];
232
+ let paramIndex = 2;
233
+
234
+ if (body.data) {
235
+ updates.push(`data = $${paramIndex++}`);
236
+ values.push(body.data);
237
+ }
238
+ if (body.locale) {
239
+ updates.push(`locale = $${paramIndex++}`);
240
+ values.push(body.locale);
241
+ }
242
+ if (body.status) {
243
+ updates.push(`status = $${paramIndex++}`);
244
+ values.push(body.status);
245
+ if (body.status === 'published' && doc.status !== 'published') {
246
+ updates.push(`published_at = CURRENT_TIMESTAMP`);
247
+ }
248
+ }
249
+
250
+ values.push(id);
251
+
252
+ const result = await db.query(
253
+ `UPDATE documents SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
254
+ values
255
+ );
256
+
257
+ // Create new version if data changed
258
+ if (body.data) {
259
+ const versionResult = await db.query(
260
+ 'SELECT MAX(version) as max_version FROM document_versions WHERE document_id = $1',
261
+ [id]
262
+ );
263
+ const newVersion = (versionResult.rows[0].max_version || 0) + 1;
264
+
265
+ await db.query(
266
+ `INSERT INTO document_versions (document_id, version, data, locale, created_by)
267
+ VALUES ($1, $2, $3, $4, $5)`,
268
+ [id, newVersion, body.data, body.locale || doc.locale, user.id]
269
+ );
270
+ }
271
+
272
+ // Publish event for real-time
273
+ await redis.publish('content:updated', {
274
+ documentId: id,
275
+ schemaName: doc.schema_name,
276
+ userId: user.id,
277
+ });
278
+
279
+ return { document: result.rows[0] };
280
+ });
281
+
282
+ // Delete document
283
+ fastify.delete('/:id', {
284
+ schema: {
285
+ tags: ['Content'],
286
+ summary: 'Delete a document',
287
+ security: [{ bearerAuth: [] }],
288
+ },
289
+ preHandler: [fastify.authenticate],
290
+ }, async (request, reply) => {
291
+ const { id } = request.params as { id: string };
292
+ const user = request.user as { id: string };
293
+
294
+ const doc = await db.query(
295
+ 'SELECT schema_name FROM documents WHERE id = $1',
296
+ [id]
297
+ );
298
+
299
+ if (doc.rows.length === 0) {
300
+ return reply.status(404).send({ error: true, message: 'Document not found' });
301
+ }
302
+
303
+ await db.query('DELETE FROM documents WHERE id = $1', [id]);
304
+
305
+ // Publish event for real-time
306
+ await redis.publish('content:deleted', {
307
+ documentId: id,
308
+ schemaName: doc.rows[0].schema_name,
309
+ userId: user.id,
310
+ });
311
+
312
+ return { success: true, message: 'Document deleted' };
313
+ });
314
+
315
+ // Get document versions
316
+ fastify.get('/:id/versions', {
317
+ schema: {
318
+ tags: ['Content'],
319
+ summary: 'Get document version history',
320
+ },
321
+ }, async (request, reply) => {
322
+ const { id } = request.params as { id: string };
323
+
324
+ const doc = await db.query('SELECT id FROM documents WHERE id = $1', [id]);
325
+ if (doc.rows.length === 0) {
326
+ return reply.status(404).send({ error: true, message: 'Document not found' });
327
+ }
328
+
329
+ const result = await db.query(
330
+ `SELECT v.*, u.name as created_by_name
331
+ FROM document_versions v
332
+ LEFT JOIN users u ON v.created_by = u.id
333
+ WHERE v.document_id = $1
334
+ ORDER BY v.version DESC`,
335
+ [id]
336
+ );
337
+
338
+ return { versions: result.rows };
339
+ });
340
+
341
+ // Restore document version
342
+ fastify.post('/:id/versions/:version/restore', {
343
+ schema: {
344
+ tags: ['Content'],
345
+ summary: 'Restore document to a specific version',
346
+ security: [{ bearerAuth: [] }],
347
+ },
348
+ preHandler: [fastify.authenticate],
349
+ }, async (request, reply) => {
350
+ const { id, version } = request.params as { id: string; version: string };
351
+ const user = request.user as { id: string };
352
+
353
+ const versionResult = await db.query(
354
+ 'SELECT * FROM document_versions WHERE document_id = $1 AND version = $2',
355
+ [id, parseInt(version)]
356
+ );
357
+
358
+ if (versionResult.rows.length === 0) {
359
+ return reply.status(404).send({ error: true, message: 'Version not found' });
360
+ }
361
+
362
+ const versionData = versionResult.rows[0];
363
+
364
+ // Update document with version data
365
+ const result = await db.query(
366
+ `UPDATE documents SET data = $1, updated_by = $2 WHERE id = $3 RETURNING *`,
367
+ [versionData.data, user.id, id]
368
+ );
369
+
370
+ // Create new version for the restore action
371
+ const maxVersionResult = await db.query(
372
+ 'SELECT MAX(version) as max_version FROM document_versions WHERE document_id = $1',
373
+ [id]
374
+ );
375
+ const newVersion = (maxVersionResult.rows[0].max_version || 0) + 1;
376
+
377
+ await db.query(
378
+ `INSERT INTO document_versions (document_id, version, data, locale, change_summary, created_by)
379
+ VALUES ($1, $2, $3, $4, $5, $6)`,
380
+ [id, newVersion, versionData.data, versionData.locale, `Restored to version ${version}`, user.id]
381
+ );
382
+
383
+ return { document: result.rows[0] };
384
+ });
385
+ };