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,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
+ };
@@ -0,0 +1,312 @@
1
+ import { FastifyPluginAsync } from 'fastify';
2
+ import { z } from 'zod';
3
+ import { v4 as uuid } from 'uuid';
4
+ import * as Minio from 'minio';
5
+ import sharp from 'sharp';
6
+ import { db } from '../../database/index.js';
7
+ import { config } from '../../config.js';
8
+
9
+ // Initialize MinIO client
10
+ const minioClient = new Minio.Client({
11
+ endPoint: config.minio.endpoint,
12
+ port: config.minio.port,
13
+ useSSL: config.minio.useSSL,
14
+ accessKey: config.minio.accessKey,
15
+ secretKey: config.minio.secretKey,
16
+ });
17
+
18
+ const updateMediaSchema = z.object({
19
+ altText: z.string().optional(),
20
+ caption: z.string().optional(),
21
+ folder: z.string().optional(),
22
+ });
23
+
24
+ const querySchema = z.object({
25
+ folder: z.string().optional(),
26
+ mimeType: z.string().optional(),
27
+ page: z.coerce.number().min(1).default(1),
28
+ limit: z.coerce.number().min(1).max(100).default(20),
29
+ search: z.string().optional(),
30
+ });
31
+
32
+ // Ensure bucket exists with public read policy
33
+ async function ensureBucket() {
34
+ const exists = await minioClient.bucketExists(config.minio.bucket);
35
+ if (!exists) {
36
+ await minioClient.makeBucket(config.minio.bucket);
37
+ }
38
+
39
+ // Always ensure public read policy is set
40
+ const policy = {
41
+ Version: '2012-10-17',
42
+ Statement: [
43
+ {
44
+ Effect: 'Allow',
45
+ Principal: { AWS: ['*'] },
46
+ Action: ['s3:GetObject'],
47
+ Resource: [`arn:aws:s3:::${config.minio.bucket}/*`],
48
+ },
49
+ ],
50
+ };
51
+
52
+ try {
53
+ await minioClient.setBucketPolicy(config.minio.bucket, JSON.stringify(policy));
54
+ } catch (err) {
55
+ console.log('Bucket policy already set or error:', err);
56
+ }
57
+ }
58
+
59
+ export const mediaRoutes: FastifyPluginAsync = async (fastify) => {
60
+ // Ensure bucket on startup
61
+ await ensureBucket();
62
+
63
+ // List media files
64
+ fastify.get('/', {
65
+ schema: {
66
+ tags: ['Media'],
67
+ summary: 'List media files with filtering',
68
+ },
69
+ }, async (request) => {
70
+ const query = querySchema.parse(request.query);
71
+ const offset = (query.page - 1) * query.limit;
72
+
73
+ let whereClause = 'WHERE 1=1';
74
+ const params: any[] = [];
75
+ let paramIndex = 1;
76
+
77
+ if (query.folder) {
78
+ whereClause += ` AND folder = $${paramIndex++}`;
79
+ params.push(query.folder);
80
+ }
81
+ if (query.mimeType) {
82
+ whereClause += ` AND mime_type LIKE $${paramIndex++}`;
83
+ params.push(`${query.mimeType}%`);
84
+ }
85
+ if (query.search) {
86
+ whereClause += ` AND (original_filename ILIKE $${paramIndex} OR alt_text ILIKE $${paramIndex})`;
87
+ params.push(`%${query.search}%`);
88
+ paramIndex++;
89
+ }
90
+
91
+ // Count total
92
+ const countResult = await db.query(
93
+ `SELECT COUNT(*) FROM media ${whereClause}`,
94
+ params
95
+ );
96
+ const total = parseInt(countResult.rows[0].count);
97
+
98
+ // Get files
99
+ params.push(query.limit, offset);
100
+ const result = await db.query(
101
+ `SELECT m.*, u.name as uploaded_by_name
102
+ FROM media m
103
+ LEFT JOIN users u ON m.uploaded_by = u.id
104
+ ${whereClause}
105
+ ORDER BY m.created_at DESC
106
+ LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
107
+ params
108
+ );
109
+
110
+ return {
111
+ files: result.rows,
112
+ pagination: {
113
+ page: query.page,
114
+ limit: query.limit,
115
+ total,
116
+ totalPages: Math.ceil(total / query.limit),
117
+ },
118
+ };
119
+ });
120
+
121
+ // Get single media file
122
+ fastify.get('/:id', {
123
+ schema: {
124
+ tags: ['Media'],
125
+ summary: 'Get media file by ID',
126
+ },
127
+ }, async (request, reply) => {
128
+ const { id } = request.params as { id: string };
129
+
130
+ const result = await db.query(
131
+ `SELECT m.*, u.name as uploaded_by_name
132
+ FROM media m
133
+ LEFT JOIN users u ON m.uploaded_by = u.id
134
+ WHERE m.id = $1`,
135
+ [id]
136
+ );
137
+
138
+ if (result.rows.length === 0) {
139
+ return reply.status(404).send({ error: true, message: 'File not found' });
140
+ }
141
+
142
+ return { file: result.rows[0] };
143
+ });
144
+
145
+ // Upload media file
146
+ fastify.post('/upload', {
147
+ schema: {
148
+ tags: ['Media'],
149
+ summary: 'Upload a media file',
150
+ security: [{ bearerAuth: [] }],
151
+ },
152
+ preHandler: [fastify.authenticate],
153
+ }, async (request, reply) => {
154
+ const user = request.user as { id: string };
155
+ const data = await request.file();
156
+
157
+ if (!data) {
158
+ return reply.status(400).send({ error: true, message: 'No file uploaded' });
159
+ }
160
+
161
+ const buffer = await data.toBuffer();
162
+ const fileId = uuid();
163
+ const ext = data.filename.split('.').pop() || '';
164
+ const filename = `${fileId}.${ext}`;
165
+ const mimeType = data.mimetype;
166
+
167
+ let width: number | undefined;
168
+ let height: number | undefined;
169
+ let thumbnailUrl: string | undefined;
170
+
171
+ // Process images
172
+ if (mimeType.startsWith('image/')) {
173
+ try {
174
+ const metadata = await sharp(buffer).metadata();
175
+ width = metadata.width;
176
+ height = metadata.height;
177
+
178
+ // Create thumbnail
179
+ const thumbnail = await sharp(buffer)
180
+ .resize(300, 300, { fit: 'inside' })
181
+ .jpeg({ quality: 80 })
182
+ .toBuffer();
183
+
184
+ const thumbnailFilename = `thumbnails/${fileId}.jpg`;
185
+ await minioClient.putObject(config.minio.bucket, thumbnailFilename, thumbnail, {
186
+ 'Content-Type': 'image/jpeg',
187
+ });
188
+ thumbnailUrl = `${config.minio.publicUrl}/${config.minio.bucket}/${thumbnailFilename}`;
189
+ } catch (err) {
190
+ fastify.log.warn('Failed to process image:', err);
191
+ }
192
+ }
193
+
194
+ // Upload original file
195
+ await minioClient.putObject(config.minio.bucket, filename, buffer, {
196
+ 'Content-Type': mimeType,
197
+ });
198
+
199
+ const url = `${config.minio.publicUrl}/${config.minio.bucket}/${filename}`;
200
+
201
+ // Save to database
202
+ const result = await db.query(
203
+ `INSERT INTO media (id, filename, original_filename, mime_type, size_bytes, width, height, url, thumbnail_url, uploaded_by)
204
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
205
+ RETURNING *`,
206
+ [fileId, filename, data.filename, mimeType, buffer.length, width, height, url, thumbnailUrl, user.id]
207
+ );
208
+
209
+ return { file: result.rows[0] };
210
+ });
211
+
212
+ // Update media metadata
213
+ fastify.put('/:id', {
214
+ schema: {
215
+ tags: ['Media'],
216
+ summary: 'Update media file metadata',
217
+ security: [{ bearerAuth: [] }],
218
+ },
219
+ preHandler: [fastify.authenticate],
220
+ }, async (request, reply) => {
221
+ const { id } = request.params as { id: string };
222
+ const body = updateMediaSchema.parse(request.body);
223
+
224
+ const existing = await db.query('SELECT id FROM media WHERE id = $1', [id]);
225
+ if (existing.rows.length === 0) {
226
+ return reply.status(404).send({ error: true, message: 'File not found' });
227
+ }
228
+
229
+ const updates: string[] = [];
230
+ const values: any[] = [];
231
+ let paramIndex = 1;
232
+
233
+ if (body.altText !== undefined) {
234
+ updates.push(`alt_text = $${paramIndex++}`);
235
+ values.push(body.altText);
236
+ }
237
+ if (body.caption !== undefined) {
238
+ updates.push(`caption = $${paramIndex++}`);
239
+ values.push(body.caption);
240
+ }
241
+ if (body.folder !== undefined) {
242
+ updates.push(`folder = $${paramIndex++}`);
243
+ values.push(body.folder);
244
+ }
245
+
246
+ if (updates.length === 0) {
247
+ return reply.status(400).send({ error: true, message: 'No updates provided' });
248
+ }
249
+
250
+ values.push(id);
251
+ const result = await db.query(
252
+ `UPDATE media SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
253
+ values
254
+ );
255
+
256
+ return { file: result.rows[0] };
257
+ });
258
+
259
+ // Delete media file
260
+ fastify.delete('/:id', {
261
+ schema: {
262
+ tags: ['Media'],
263
+ summary: 'Delete a media file',
264
+ security: [{ bearerAuth: [] }],
265
+ },
266
+ preHandler: [fastify.authenticate],
267
+ }, async (request, reply) => {
268
+ const { id } = request.params as { id: string };
269
+
270
+ const result = await db.query(
271
+ 'SELECT filename FROM media WHERE id = $1',
272
+ [id]
273
+ );
274
+
275
+ if (result.rows.length === 0) {
276
+ return reply.status(404).send({ error: true, message: 'File not found' });
277
+ }
278
+
279
+ const { filename } = result.rows[0];
280
+
281
+ // Delete from MinIO
282
+ try {
283
+ await minioClient.removeObject(config.minio.bucket, filename);
284
+ // Try to delete thumbnail
285
+ await minioClient.removeObject(config.minio.bucket, `thumbnails/${id}.jpg`).catch(() => {});
286
+ } catch (err) {
287
+ fastify.log.warn('Failed to delete file from storage:', err);
288
+ }
289
+
290
+ // Delete from database
291
+ await db.query('DELETE FROM media WHERE id = $1', [id]);
292
+
293
+ return { success: true, message: 'File deleted' };
294
+ });
295
+
296
+ // Get folders
297
+ fastify.get('/folders', {
298
+ schema: {
299
+ tags: ['Media'],
300
+ summary: 'Get list of media folders',
301
+ },
302
+ }, async () => {
303
+ const result = await db.query(
304
+ `SELECT DISTINCT folder, COUNT(*) as count
305
+ FROM media
306
+ GROUP BY folder
307
+ ORDER BY folder`
308
+ );
309
+
310
+ return { folders: result.rows };
311
+ });
312
+ };