nextjs-cms 0.9.37 → 0.9.38

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 (115) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +279 -279
  3. package/dist/api/actions/files.d.ts.map +1 -1
  4. package/dist/api/actions/pages.d.ts +1 -0
  5. package/dist/api/actions/pages.d.ts.map +1 -1
  6. package/dist/api/actions/pages.js +1 -0
  7. package/dist/api/axios/axiosInstance.d.ts +2 -0
  8. package/dist/api/axios/axiosInstance.d.ts.map +1 -0
  9. package/dist/api/axios/axiosInstance.js +8 -0
  10. package/dist/api/index.d.ts +975 -0
  11. package/dist/api/index.d.ts.map +1 -0
  12. package/dist/api/index.js +13 -0
  13. package/dist/api/lib/serverActions.d.ts +374 -0
  14. package/dist/api/lib/serverActions.d.ts.map +1 -0
  15. package/dist/api/lib/serverActions.js +1445 -0
  16. package/dist/api/root.d.ts +1917 -0
  17. package/dist/api/root.d.ts.map +1 -0
  18. package/dist/api/root.js +115 -0
  19. package/dist/api/routers/accountSettings.d.ts +66 -0
  20. package/dist/api/routers/accountSettings.d.ts.map +1 -0
  21. package/dist/api/routers/accountSettings.js +202 -0
  22. package/dist/api/routers/admins.d.ts +112 -0
  23. package/dist/api/routers/admins.d.ts.map +1 -0
  24. package/dist/api/routers/admins.js +323 -0
  25. package/dist/api/routers/auth.d.ts +54 -0
  26. package/dist/api/routers/auth.d.ts.map +1 -0
  27. package/dist/api/routers/auth.js +50 -0
  28. package/dist/api/routers/categorySection.d.ts +105 -0
  29. package/dist/api/routers/categorySection.d.ts.map +1 -0
  30. package/dist/api/routers/categorySection.js +49 -0
  31. package/dist/api/routers/config.d.ts +48 -0
  32. package/dist/api/routers/config.d.ts.map +1 -0
  33. package/dist/api/routers/config.js +18 -0
  34. package/dist/api/routers/cpanel.d.ts +82 -0
  35. package/dist/api/routers/cpanel.d.ts.map +1 -0
  36. package/dist/api/routers/cpanel.js +216 -0
  37. package/dist/api/routers/fields.d.ts +35 -0
  38. package/dist/api/routers/fields.d.ts.map +1 -0
  39. package/dist/api/routers/fields.js +81 -0
  40. package/dist/api/routers/files.d.ts +34 -0
  41. package/dist/api/routers/files.d.ts.map +1 -0
  42. package/dist/api/routers/files.js +14 -0
  43. package/dist/api/routers/gallery.d.ts +34 -0
  44. package/dist/api/routers/gallery.d.ts.map +1 -0
  45. package/dist/api/routers/gallery.js +64 -0
  46. package/dist/api/routers/hasItemsSection.d.ts +219 -0
  47. package/dist/api/routers/hasItemsSection.d.ts.map +1 -0
  48. package/dist/api/routers/hasItemsSection.js +86 -0
  49. package/dist/api/routers/logs.d.ts +59 -0
  50. package/dist/api/routers/logs.d.ts.map +1 -0
  51. package/dist/api/routers/logs.js +76 -0
  52. package/dist/api/routers/navigation.d.ts +50 -0
  53. package/dist/api/routers/navigation.d.ts.map +1 -0
  54. package/dist/api/routers/navigation.js +11 -0
  55. package/dist/api/routers/simpleSection.d.ts +105 -0
  56. package/dist/api/routers/simpleSection.d.ts.map +1 -0
  57. package/dist/api/routers/simpleSection.js +54 -0
  58. package/dist/api/trpc/root.d.ts +41 -7
  59. package/dist/api/trpc/root.d.ts.map +1 -1
  60. package/dist/api/trpc/routers/config.d.ts.map +1 -1
  61. package/dist/api/trpc/routers/hasItemsSection.d.ts.map +1 -1
  62. package/dist/api/trpc/routers/logs.d.ts +37 -4
  63. package/dist/api/trpc/routers/logs.d.ts.map +1 -1
  64. package/dist/api/trpc/routers/logs.js +92 -43
  65. package/dist/api/trpc/routers/navigation.d.ts +1 -0
  66. package/dist/api/trpc/routers/navigation.d.ts.map +1 -1
  67. package/dist/api/trpc/routers/simpleSection.d.ts.map +1 -1
  68. package/dist/api/trpc/server.d.ts +123 -21
  69. package/dist/api/trpc/server.d.ts.map +1 -1
  70. package/dist/api/trpc.d.ts +111 -0
  71. package/dist/api/trpc.d.ts.map +1 -0
  72. package/dist/api/trpc.js +99 -0
  73. package/dist/auth/axios/axiosInstance.d.ts +2 -0
  74. package/dist/auth/axios/axiosInstance.d.ts.map +1 -0
  75. package/dist/auth/axios/axiosInstance.js +8 -0
  76. package/dist/auth/hooks/useAxiosPrivate.d.ts +5 -0
  77. package/dist/auth/hooks/useAxiosPrivate.d.ts.map +1 -0
  78. package/dist/auth/hooks/useAxiosPrivate.js +74 -0
  79. package/dist/auth/trpc.d.ts +6 -0
  80. package/dist/auth/trpc.d.ts.map +1 -0
  81. package/dist/auth/trpc.js +82 -0
  82. package/dist/cli/lib/db-config.js +10 -10
  83. package/dist/cli/lib/update-sections.js +24 -24
  84. package/dist/core/config/config-loader.d.ts +2 -2
  85. package/dist/core/db/table-checker/MysqlTable.js +10 -10
  86. package/dist/core/factories/section-factory-with-esbuild.js +9 -9
  87. package/dist/core/factories/section-factory-with-jiti.js +9 -9
  88. package/dist/core/fields/dateRange.d.ts +115 -0
  89. package/dist/core/fields/dateRange.d.ts.map +1 -0
  90. package/dist/core/fields/dateRange.js +149 -0
  91. package/dist/logging/event-labels.d.ts +11 -0
  92. package/dist/logging/event-labels.d.ts.map +1 -0
  93. package/dist/logging/event-labels.js +14 -0
  94. package/dist/logging/log.d.ts +1 -1
  95. package/dist/logging/log.d.ts.map +1 -1
  96. package/dist/translations/base/en.d.ts +33 -0
  97. package/dist/translations/base/en.d.ts.map +1 -1
  98. package/dist/translations/base/en.js +33 -0
  99. package/dist/translations/client.d.ts +400 -4
  100. package/dist/translations/client.d.ts.map +1 -1
  101. package/dist/translations/locale-cookie.d.ts +24 -0
  102. package/dist/translations/locale-cookie.d.ts.map +1 -0
  103. package/dist/translations/locale-cookie.js +44 -0
  104. package/dist/translations/locale-utils.d.ts +8 -0
  105. package/dist/translations/locale-utils.d.ts.map +1 -0
  106. package/dist/translations/locale-utils.js +11 -0
  107. package/dist/translations/localization.d.ts +40 -0
  108. package/dist/translations/localization.d.ts.map +1 -0
  109. package/dist/translations/localization.js +48 -0
  110. package/dist/translations/server.d.ts +400 -4
  111. package/dist/translations/server.d.ts.map +1 -1
  112. package/dist/validators/dateRange.d.ts +11 -0
  113. package/dist/validators/dateRange.d.ts.map +1 -0
  114. package/dist/validators/dateRange.js +16 -0
  115. package/package.json +6 -2
@@ -0,0 +1,1445 @@
1
+ // import 'server-only'
2
+ import { db } from '../../db/client.js';
3
+ import { AdminPrivilegesTable, AdminsTable, EditorPhotosTable } from '../../db/schema.js';
4
+ import { and, eq, sql } from 'drizzle-orm';
5
+ import { TRPCError } from '@trpc/server';
6
+ import { FieldFactory, SectionFactory } from '../../core/factories/index.js';
7
+ import { SelectField } from '../../core/fields/index.js';
8
+ import { MysqlTableChecker } from '../../core/db/index.js';
9
+ import fs from 'fs';
10
+ import { Readable } from 'stream';
11
+ import path from 'path';
12
+ import { sanitizeFileName, sanitizeFolderOrFileName } from '../../utils/index.js';
13
+ import sharp from 'sharp';
14
+ import { readChunk } from 'read-chunk';
15
+ import { fileTypeFromBuffer } from 'file-type';
16
+ import { getCMSConfig } from '../../core/config/index.js';
17
+ import { resolveLocale } from '../../core/localization/index.js';
18
+ import { getPluginRoutes } from '../../plugins/loader.js';
19
+ import through2 from 'through2';
20
+ import { recordLog } from '../../logging/index.js';
21
+ import getString from '../../translations/index.js';
22
+ import { resolveMultilingualString } from '../../translations/language-utils.js';
23
+ import { resolveLanguage } from '../../translations/language-utils.js';
24
+ export const isAccessAllowed = async ({ sectionName, role, userId, }) => {
25
+ /**
26
+ * Check admin privileges
27
+ */
28
+ const _res = await db
29
+ .select()
30
+ .from(AdminPrivilegesTable)
31
+ .where(and(eq(AdminPrivilegesTable.adminId, userId), eq(AdminPrivilegesTable.sectionName, sectionName)))
32
+ .limit(1);
33
+ const allowed = _res[0];
34
+ if (!allowed)
35
+ return false;
36
+ if (role) {
37
+ if (!allowed.operations.includes(role))
38
+ return false;
39
+ }
40
+ return true;
41
+ };
42
+ export const getDocument = async (session, input) => {
43
+ const { name, sectionName, fieldName } = input;
44
+ // Sanitize the inputs
45
+ const sanitizedFolder = sanitizeFolderOrFileName(sectionName);
46
+ const sanitizedName = sanitizeFileName(name);
47
+ /**
48
+ * Check the section and the field name, and get the allowed extensions,
49
+ * while also checking if the user has access to the section
50
+ */
51
+ const section = await SectionFactory.getSectionForAdmin({
52
+ name: sanitizedFolder,
53
+ admin: { id: session.user.id },
54
+ });
55
+ /**
56
+ * If the check fails, throw an error
57
+ */
58
+ if (!section?.name) {
59
+ throw new TRPCError({
60
+ code: 'BAD_REQUEST',
61
+ message: getString('invalidFilePath', session.user.language),
62
+ });
63
+ }
64
+ const fieldConfig = section.fields.find((field) => field.name === fieldName);
65
+ if (!fieldConfig || typeof fieldConfig.build !== 'function') {
66
+ throw new TRPCError({
67
+ code: 'BAD_REQUEST',
68
+ message: getString('invalidRequest', session.user.language),
69
+ });
70
+ }
71
+ const field = fieldConfig.build();
72
+ /**
73
+ * If field is not found, throw an error
74
+ */
75
+ if (!field || !field.name || !field.extensions || field.extensions.length === 0) {
76
+ throw new TRPCError({
77
+ code: 'BAD_REQUEST',
78
+ message: getString('invalidRequest', session.user.language),
79
+ });
80
+ }
81
+ /**
82
+ * Split the allowed extensions into an array
83
+ */
84
+ const uploadsFolder = (await getCMSConfig()).media.upload.path;
85
+ const documentAllowedExtensions = field.extensions;
86
+ const dir = '.documents';
87
+ const pathToFile = path.join(uploadsFolder, dir, sanitizedFolder, sanitizedName);
88
+ /**
89
+ * First, check if the file exists
90
+ */
91
+ if (!fs.existsSync(pathToFile)) {
92
+ throw new TRPCError({
93
+ code: 'BAD_REQUEST',
94
+ message: getString('fileNotFound', session.user.language),
95
+ });
96
+ }
97
+ /**
98
+ * Read the first 4100 bytes of the file
99
+ */
100
+ const chunkBuffer = await readChunk(pathToFile, { length: 4100 });
101
+ /**
102
+ * Get the file type from the buffer
103
+ */
104
+ const fileType = await fileTypeFromBuffer(chunkBuffer);
105
+ /**
106
+ * If the file type is invalid, return an error
107
+ */
108
+ if (!fileType) {
109
+ throw new TRPCError({
110
+ code: 'BAD_REQUEST',
111
+ message: getString('invalidFileType', session.user.language),
112
+ });
113
+ }
114
+ /**
115
+ * Check if the file type is allowed
116
+ */
117
+ if (!documentAllowedExtensions.includes(fileType.ext)) {
118
+ throw new TRPCError({
119
+ code: 'BAD_REQUEST',
120
+ message: getString('invalidFileType', session.user.language),
121
+ });
122
+ }
123
+ /**
124
+ * Convert the image to webp format and return a buffer
125
+ */
126
+ const buffer = fs.readFileSync(pathToFile);
127
+ /**
128
+ * Create a base64 string from the buffer
129
+ */
130
+ const base64 = buffer.toString('base64');
131
+ /**
132
+ * Return the base64 string with the mime type
133
+ */
134
+ return {
135
+ base64: `data:${fileType.mime};base64,${base64}`,
136
+ mimeType: fileType.mime,
137
+ };
138
+ };
139
+ /**
140
+ * Helper function with proper cleanup for converting Node.js stream to Web ReadableStream
141
+ * Uses pull()-based reading for proper backpressure support.
142
+ * @param nodeStream
143
+ * @param sharpInstance
144
+ */
145
+ function nodeStreamToWebStream(nodeStream, sharpInstance) {
146
+ return new ReadableStream({
147
+ start() {
148
+ // Start paused — let pull() drive reading
149
+ if ('pause' in nodeStream && typeof nodeStream.pause === 'function') {
150
+ nodeStream.pause();
151
+ }
152
+ },
153
+ pull(controller) {
154
+ return new Promise((resolve, reject) => {
155
+ const onData = (chunk) => {
156
+ controller.enqueue(new Uint8Array(chunk));
157
+ if ('pause' in nodeStream && typeof nodeStream.pause === 'function') {
158
+ nodeStream.pause();
159
+ }
160
+ nodeStream.removeListener('data', onData);
161
+ nodeStream.removeListener('error', onError);
162
+ resolve();
163
+ };
164
+ const onError = (err) => {
165
+ controller.error(err);
166
+ nodeStream.removeListener('data', onData);
167
+ reject(err);
168
+ };
169
+ nodeStream.on('data', onData);
170
+ nodeStream.on('error', onError);
171
+ nodeStream.once('end', () => {
172
+ controller.close();
173
+ resolve();
174
+ });
175
+ if ('resume' in nodeStream && typeof nodeStream.resume === 'function') {
176
+ nodeStream.resume();
177
+ }
178
+ });
179
+ },
180
+ cancel() {
181
+ // Proper cleanup sequence
182
+ sharpInstance.destroy();
183
+ nodeStream.removeAllListeners();
184
+ if ('destroy' in nodeStream && typeof nodeStream.destroy === 'function') {
185
+ nodeStream.destroy();
186
+ }
187
+ },
188
+ });
189
+ }
190
+ export const getPhoto = async (input) => {
191
+ const { name, folder, isThumb } = input;
192
+ // Sanitize the inputs
193
+ const sanitizedFolder = sanitizeFolderOrFileName(folder);
194
+ const sanitizedName = sanitizeFileName(name);
195
+ /**
196
+ * Check the folder name matches a section name in the database
197
+ * Notice: Maybe we don't need this check because making an sql call for each image is maybe expensive
198
+ */
199
+ /*const sectionQuery = await db
200
+ .select()
201
+ .from(SectionsTable)
202
+ .where(eq(SectionsTable.sectionName, sanitizedFolder))
203
+ .limit(1)
204
+
205
+ if (sectionQuery.length === 0) {
206
+ throw new Error('Invalid folder name')
207
+ }*/
208
+ const uploadsFolder = (await getCMSConfig()).media.upload.path;
209
+ const dir = isThumb ? '.thumbs' : '.photos';
210
+ const pathToFile = path.join(uploadsFolder, dir, sanitizedFolder, sanitizedName);
211
+ /**
212
+ * Disable caching for the image to avoid unlink issues when removing the image
213
+ */
214
+ sharp.cache({ files: 0 });
215
+ sharp.cache(false);
216
+ /**
217
+ * Convert the image to webp format and return a buffer
218
+ */
219
+ const sharpInstance = sharp(pathToFile);
220
+ let webpBuffer = await sharpInstance // Load the image
221
+ .toFormat('webp') // Re-encode the image to webp
222
+ .withExif({}) // Strip the exif data
223
+ .toBuffer(); // Return a buffer
224
+ /**
225
+ * Destroy the sharp instance to free it from memory
226
+ */
227
+ const base64String = webpBuffer.toString('base64');
228
+ sharpInstance.destroy();
229
+ /**
230
+ * Return the base64 string with the mime type
231
+ */
232
+ return `data:image/webp;base64,${base64String}`;
233
+ };
234
+ export async function streamPhoto(input) {
235
+ const { name, folder, isThumb } = input;
236
+ // Sanitize the inputs
237
+ const sanitizedFolder = sanitizeFolderOrFileName(folder);
238
+ const sanitizedName = sanitizeFileName(name);
239
+ const dir = isThumb ? '.thumbs' : '.photos';
240
+ const uploadsFolder = (await getCMSConfig()).media.upload.path;
241
+ const pathToFile = path.join(uploadsFolder, dir, sanitizedFolder, sanitizedName);
242
+ /**
243
+ * Disable caching for the image to avoid unlink issues when removing the image
244
+ */
245
+ sharp.cache({ files: 0 });
246
+ sharp.cache(false);
247
+ // Process all images through Sharp
248
+ const processedImage = sharp(pathToFile).toFormat('webp').withExif({});
249
+ /**
250
+ * Convert Node.js stream to Web ReadableStream
251
+ * Also, add through2 for proper streaming
252
+ */
253
+ const nodeStream = processedImage.pipe(through2());
254
+ return nodeStreamToWebStream(nodeStream, processedImage);
255
+ }
256
+ export const getVideo = async (session, input) => {
257
+ throw new Error(getString('useVideoApiRoute', session.user.language));
258
+ };
259
+ export const getAdminPrivileges = async (adminId) => {
260
+ const rows = await db
261
+ .select({ sectionName: AdminPrivilegesTable.sectionName })
262
+ .from(AdminPrivilegesTable)
263
+ .where(eq(AdminPrivilegesTable.adminId, adminId));
264
+ return new Set(rows.map((row) => row.sectionName));
265
+ };
266
+ export const getAllPrivileges = async (session) => {
267
+ const [sections, pluginRoutes] = await Promise.all([SectionFactory.getSections(), getPluginRoutes()]);
268
+ // Get the locale for resolving localized titles
269
+ const cmsConfig = await getCMSConfig();
270
+ const language = resolveLanguage(session.user.language, cmsConfig.i18n.supportedLanguages, cmsConfig.i18n.fallbackLanguage);
271
+ // First, let's assign the static privileges
272
+ const privilegesList = [];
273
+ // Now, let's add the rest of the privileges to the list
274
+ sections.forEach((section, index) => {
275
+ let title;
276
+ if (typeof section.title === 'string') {
277
+ title = resolveMultilingualString(section.title, language, cmsConfig.i18n.fallbackLanguage);
278
+ }
279
+ else {
280
+ title = resolveMultilingualString(section.title.section, language, cmsConfig.i18n.fallbackLanguage);
281
+ }
282
+ privilegesList.push({
283
+ title,
284
+ order: section.order || index,
285
+ sectionName: section.name,
286
+ sectionType: section.type,
287
+ });
288
+ });
289
+ const pluginPrivileges = new Map();
290
+ pluginRoutes.forEach((route) => {
291
+ if (pluginPrivileges.has(route.pluginName))
292
+ return;
293
+ pluginPrivileges.set(route.pluginName, {
294
+ title: resolveMultilingualString(route.title, language, cmsConfig.i18n.fallbackLanguage),
295
+ });
296
+ });
297
+ Array.from(pluginPrivileges.entries()).forEach(([pluginName, meta], index) => {
298
+ privilegesList.push({
299
+ title: meta.title,
300
+ order: sections.length + index,
301
+ sectionName: pluginName,
302
+ sectionType: 'plugin',
303
+ });
304
+ });
305
+ privilegesList.push({ title: getString('admins', language), order: 0, sectionName: 'admins', sectionType: 'static' }, { title: getString('log', language), order: 2, sectionName: 'log', sectionType: 'static' });
306
+ return privilegesList;
307
+ };
308
+ export const getAdminsList = async () => {
309
+ const admins = await db
310
+ .select({
311
+ id: AdminsTable.id,
312
+ username: AdminsTable.user,
313
+ avatar: AdminsTable.coverphoto,
314
+ })
315
+ .from(AdminsTable);
316
+ const roles = await db.select().from(AdminPrivilegesTable);
317
+ /**
318
+ * Assign the roles to the admins
319
+ */
320
+ const adminsWithRoles = [];
321
+ admins.forEach((admin) => {
322
+ const adminRoles = roles.filter((role) => role.adminId === admin.id);
323
+ adminsWithRoles.push({
324
+ ...admin,
325
+ roles: adminRoles,
326
+ });
327
+ });
328
+ return adminsWithRoles;
329
+ };
330
+ export const createSimpleSectionPage = async (session, sectionName, locale) => {
331
+ try {
332
+ const cmsConfig = await getCMSConfig();
333
+ const localeResult = resolveLocale({
334
+ localization: cmsConfig.localization,
335
+ locale,
336
+ });
337
+ if (locale && !localeResult.resolvedLocale) {
338
+ if (localeResult.localizationEnabled === false) {
339
+ return {
340
+ error: {
341
+ message: getString('localizationNotEnabledForSection', session.user.language),
342
+ },
343
+ };
344
+ }
345
+ return {
346
+ error: {
347
+ message: getString('invalidLocale', session.user.language, {
348
+ locale,
349
+ locales: localeResult.availableLocales.map((l) => l.code).join(', '),
350
+ }),
351
+ },
352
+ };
353
+ }
354
+ /**
355
+ * Let's fetch the section information
356
+ */
357
+ const fieldsFactory = new FieldFactory({
358
+ type: 'edit',
359
+ sectionName,
360
+ session: session,
361
+ itemId: 1, // itemId is always 1 for simple sections
362
+ });
363
+ await fieldsFactory.initialize();
364
+ if (fieldsFactory.error) {
365
+ return {
366
+ error: {
367
+ message: fieldsFactory.errorMessage,
368
+ },
369
+ };
370
+ }
371
+ await fieldsFactory.generateFields();
372
+ const sectionInfo = fieldsFactory.sectionInfo;
373
+ const gallery = await sectionInfo.getGallery();
374
+ // Get the locale for resolving localized titles
375
+ const uiLanguage = resolveLanguage(session.user.language, cmsConfig.i18n.supportedLanguages, cmsConfig.i18n.fallbackLanguage);
376
+ const resolvedTitle = resolveMultilingualString(sectionInfo.title, uiLanguage, cmsConfig.i18n.fallbackLanguage);
377
+ /**
378
+ * Fetch localization metadata for sections with localized fields
379
+ */
380
+ let localizationData = null;
381
+ if (localeResult.localizationEnabled) {
382
+ let existingTranslations = [];
383
+ if (sectionInfo.hasLocalizedFields) {
384
+ const localesTableName = sectionInfo.localesTableName;
385
+ const localeColumns = await MysqlTableChecker.getColumns(localesTableName);
386
+ const localeTableExists = localeColumns.length > 0;
387
+ if (localeTableExists) {
388
+ // Simple sections always have itemId = 1
389
+ const sectionItemId = 1;
390
+ const [rows] = await db.execute(sql `SELECT locale FROM \`${sql.raw(localesTableName)}\` WHERE parent_id = ${sectionItemId}`);
391
+ existingTranslations = rows.map((r) => r.locale);
392
+ // Override localized field values with locale-specific data
393
+ if (locale && localeResult.resolvedLocale && localeResult.isDefault === false) {
394
+ const [localeRows] = await db.execute(sql `SELECT * FROM \`${sql.raw(localesTableName)}\` WHERE parent_id = ${sectionItemId} AND locale = ${localeResult.resolvedLocale.code} LIMIT 1`);
395
+ const localeRow = localeRows[0] ?? null;
396
+ for (const field of sectionInfo.fields) {
397
+ if (!field.localized)
398
+ continue;
399
+ // For select/tags with destinationDb, fetch locale-scoped junction values
400
+ const f = field;
401
+ if (f.destinationDb && (field.type === 'select' || field.type === 'select_multiple')) {
402
+ if (f.db) {
403
+ const [_rows] = await db.execute(sql `SELECT * FROM \`${sql.raw(f.destinationDb.table)}\` a JOIN \`${sql.raw(f.db.table)}\` b ON a.\`${sql.raw(f.destinationDb.selectIdentifier)}\` = b.\`${sql.raw(f.db.identifier)}\` WHERE a.\`${sql.raw(f.destinationDb.itemIdentifier)}\` = ${sectionItemId} AND a.\`locale\` = ${localeResult.resolvedLocale.code}`);
404
+ const values = Array.isArray(_rows)
405
+ ? _rows.map((row) => ({
406
+ value: row[f.destinationDb.selectIdentifier],
407
+ label: row[f.db.label],
408
+ }))
409
+ : [];
410
+ field.setValue(values);
411
+ }
412
+ else {
413
+ const [_rows] = await db.execute(sql `SELECT * FROM \`${sql.raw(f.destinationDb.table)}\` WHERE \`${sql.raw(f.destinationDb.itemIdentifier)}\` = ${sectionItemId} AND \`locale\` = ${localeResult.resolvedLocale.code}`);
414
+ const values = Array.isArray(_rows)
415
+ ? _rows.map((row) => ({
416
+ value: row[f.destinationDb.selectIdentifier],
417
+ label: f.options?.find((opt) => opt.value?.toString() ===
418
+ row[f.destinationDb.selectIdentifier]?.toString())?.label ?? '',
419
+ }))
420
+ : [];
421
+ field.setValue(values);
422
+ }
423
+ }
424
+ else if (f.destinationDb && field.type === 'tags') {
425
+ const [_rows] = await db.execute(sql `SELECT \`${sql.raw(f.destinationDb.selectIdentifier)}\` FROM \`${sql.raw(f.destinationDb.table)}\` WHERE \`${sql.raw(f.destinationDb.itemIdentifier)}\` = ${sectionItemId} AND \`locale\` = ${localeResult.resolvedLocale.code}`);
426
+ const tags = Array.isArray(_rows)
427
+ ? _rows.map((row) => row[f.destinationDb.selectIdentifier])
428
+ : [];
429
+ field.setValue(tags.join(','));
430
+ }
431
+ else {
432
+ // Simple field: override from locale row
433
+ field.setValue(localeRow ? (localeRow[field.name] ?? null) : null);
434
+ }
435
+ }
436
+ }
437
+ }
438
+ }
439
+ localizationData = {
440
+ defaultLocale: localeResult.defaultLocale,
441
+ currentLocale: localeResult.resolvedLocale ?? localeResult.defaultLocale,
442
+ existingTranslations,
443
+ locales: localeResult.availableLocales,
444
+ };
445
+ }
446
+ return {
447
+ section: {
448
+ name: sectionInfo.name,
449
+ title: resolvedTitle,
450
+ gallery: gallery,
451
+ variants: sectionInfo.variants,
452
+ configFile: sectionInfo.configFile,
453
+ },
454
+ inputGroups: await fieldsFactory.getGroupedFields(),
455
+ localization: localizationData,
456
+ };
457
+ }
458
+ catch (err) {
459
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
460
+ console.error('Error creating section page', err);
461
+ return {
462
+ error: {
463
+ message: `Failed to create section page: ${errorMessage}`,
464
+ },
465
+ };
466
+ }
467
+ };
468
+ export const deleteSectionItem = async (session, sectionName, sectionItemId, recursive, requestMetadata) => {
469
+ /**
470
+ * Convert the section item id to string
471
+ */
472
+ sectionItemId = sectionItemId.toString();
473
+ try {
474
+ const _s = (await SectionFactory.getSectionForAdmin({
475
+ name: sectionName,
476
+ admin: {
477
+ id: session.user.id,
478
+ requiredRole: 'D',
479
+ },
480
+ }));
481
+ const section = _s?.build();
482
+ if (!section) {
483
+ return {
484
+ error: {
485
+ message: getString('sectionNotFound', session.user.language),
486
+ },
487
+ };
488
+ }
489
+ const tableName = section.db.table;
490
+ const identifierFieldName = section.db.identifier.name;
491
+ const columns = await MysqlTableChecker.getColumns(tableName);
492
+ if (!columns.includes(identifierFieldName)) {
493
+ return {
494
+ error: {
495
+ message: getString('sectionTableIdentifierNotFound', session.user.language),
496
+ },
497
+ };
498
+ }
499
+ /**
500
+ * Get the section item from the table
501
+ */
502
+ const [_v, _f] = await db.execute(sql `select * from ${sql.raw(tableName)} where ${sql.raw(identifierFieldName)} = ${sectionItemId} limit 1`);
503
+ // @ts-ignore
504
+ // Bug: this is a bug in drizzle-orm/mysql2
505
+ const sectionItemRow = _v[0];
506
+ /**
507
+ * Run beforeDelete hook before the actual deletion
508
+ */
509
+ if (section.hooks?.beforeDelete) {
510
+ const hook = section.hooks.beforeDelete;
511
+ const handler = typeof hook === 'function' ? hook : hook.handler;
512
+ await handler({
513
+ itemId: sectionItemId,
514
+ values: sectionItemRow ?? {},
515
+ section: section,
516
+ });
517
+ }
518
+ /**
519
+ * Cascade delete translations from the locales table.
520
+ * Use deleteLocaleTranslation per locale to ensure localized files are cleaned up from disk.
521
+ */
522
+ const cmsConfig = await getCMSConfig();
523
+ if (cmsConfig.localization?.enabled && section.hasLocalizedFields) {
524
+ const localesTableName = section.localesTableName;
525
+ const localeColumns = await MysqlTableChecker.getColumns(localesTableName);
526
+ if (localeColumns.length > 0) {
527
+ const [localeRows] = await db.execute(sql `SELECT locale FROM \`${sql.raw(localesTableName)}\` WHERE parent_id = ${sectionItemId}`);
528
+ const locales = localeRows.map((r) => r.locale);
529
+ for (const locale of locales) {
530
+ const result = await deleteLocaleTranslation(session, sectionName, sectionItemId, locale);
531
+ if (result && typeof result === 'object' && 'error' in result) {
532
+ return result;
533
+ }
534
+ }
535
+ }
536
+ }
537
+ /**
538
+ * Grab the file fields to delete the files
539
+ */
540
+ const fileFieldConfigs = section.fieldConfigs.filter((fieldConfig) => fieldConfig.type === 'document' || fieldConfig.type === 'photo' || fieldConfig.type === 'video');
541
+ const uploadsFolder = cmsConfig.media.upload.path;
542
+ if (sectionItemRow) {
543
+ for (const field of fileFieldConfigs) {
544
+ // @ts-ignore
545
+ const value = sectionItemRow[field.name];
546
+ if (value) {
547
+ if (field.type === 'document') {
548
+ try {
549
+ await fs.promises.unlink(path.join(uploadsFolder, '.documents', section.name, value));
550
+ }
551
+ catch (error) {
552
+ // Log but continue - file may not exist or already deleted
553
+ console.error('Error deleting document', error);
554
+ }
555
+ }
556
+ else if (field.type === 'video') {
557
+ try {
558
+ await fs.promises.unlink(path.join(uploadsFolder, '.videos', section.name, value));
559
+ }
560
+ catch (error) {
561
+ // Log but continue - file may not exist or already deleted
562
+ console.error('Error deleting video', error);
563
+ }
564
+ }
565
+ else {
566
+ try {
567
+ await fs.promises.unlink(path.join(uploadsFolder, '.photos', section.name, value));
568
+ await fs.promises.unlink(path.join(uploadsFolder, '.thumbs', section.name, value));
569
+ }
570
+ catch (error) {
571
+ // Log but continue - file may not exist or already deleted
572
+ console.error('Error deleting photo', error);
573
+ }
574
+ }
575
+ }
576
+ }
577
+ }
578
+ /**
579
+ * Delete the rows from the destination table if they exist
580
+ */
581
+ const fieldsWithDestinationDbTable = section.fieldConfigs.filter((fieldConfig) => ['select_multiple', 'select', 'tags'].includes(fieldConfig.type));
582
+ for (const fieldConfig of fieldsWithDestinationDbTable) {
583
+ const field = fieldConfig.build();
584
+ if (field.destinationDb) {
585
+ const columns = await MysqlTableChecker.getColumns(field.destinationDb.table);
586
+ if (columns.includes(field.destinationDb.selectIdentifier) &&
587
+ columns.includes(field.destinationDb.itemIdentifier)) {
588
+ await db.execute(sql `delete from ${sql.raw(field.destinationDb.table)} where ${sql.raw(field.destinationDb.itemIdentifier)} = ${sectionItemId}`);
589
+ }
590
+ }
591
+ }
592
+ /**
593
+ * Also delete the gallery photos if they exist
594
+ */
595
+ const gallery = await section.getGallery();
596
+ if (gallery) {
597
+ const { tableName, referenceIdentifierField, photoNameField, metaField } = gallery.db;
598
+ const columns = await MysqlTableChecker.getColumns(tableName);
599
+ if (columns.includes(photoNameField) &&
600
+ columns.includes(referenceIdentifierField) &&
601
+ columns.includes(metaField)) {
602
+ /**
603
+ * First, get the gallery photos
604
+ */
605
+ const [_v, _f] = await db.execute(sql `select * from \`${sql.raw(tableName)}\` where \`${sql.raw(referenceIdentifierField)}\` = ${sectionItemId}`);
606
+ // @ts-ignore
607
+ // Bug: this is a bug in drizzle-orm/mysql2
608
+ const galleryPhotos = _v;
609
+ /**
610
+ * Delete the photos from disk
611
+ */
612
+ for (const photo of galleryPhotos) {
613
+ try {
614
+ await fs.promises.unlink(path.join(uploadsFolder, '.photos', section.name, photo[photoNameField]));
615
+ await fs.promises.unlink(path.join(uploadsFolder, '.thumbs', section.name, photo[photoNameField]));
616
+ }
617
+ catch (error) {
618
+ // Log but continue - file may not exist or already deleted
619
+ console.error('Error deleting photo', error);
620
+ }
621
+ }
622
+ /**
623
+ * Delete the photos from the table
624
+ */
625
+ await db.execute(sql `DELETE FROM ${sql.raw(tableName)} WHERE ${sql.raw(referenceIdentifierField)} = ${sectionItemId}`);
626
+ }
627
+ }
628
+ /**
629
+ * Check if there are photos in the editor_photos table
630
+ */
631
+ const editorPhotos = await db
632
+ .select()
633
+ .from(EditorPhotosTable)
634
+ .where(and(eq(EditorPhotosTable.section, sectionName), eq(EditorPhotosTable.itemId, sectionItemId)));
635
+ if (editorPhotos.length > 0) {
636
+ /**
637
+ * Delete the photos from disk
638
+ */
639
+ for (const photo of editorPhotos) {
640
+ try {
641
+ await fs.promises.unlink(path.join(uploadsFolder, '.photos', photo.section, photo.name));
642
+ }
643
+ catch (error) {
644
+ // Log but continue - file may not exist or already deleted
645
+ console.error('Error deleting photo', error);
646
+ }
647
+ }
648
+ /**
649
+ * Delete the photos from the editor_photos table
650
+ */
651
+ await db
652
+ .delete(EditorPhotosTable)
653
+ .where(and(eq(EditorPhotosTable.section, sectionName), eq(EditorPhotosTable.itemId, sectionItemId)));
654
+ }
655
+ /**
656
+ * Delete the row from the table
657
+ */
658
+ await db.execute(sql `delete from \`${sql.raw(tableName)}\` where \`${sql.raw(identifierFieldName)}\` = ${sectionItemId}`);
659
+ /**
660
+ * Run afterDelete hook after the deletion
661
+ */
662
+ if (section.hooks?.afterDelete) {
663
+ const hook = section.hooks.afterDelete;
664
+ const handler = typeof hook === 'function' ? hook : hook.handler;
665
+ try {
666
+ await handler({
667
+ itemId: sectionItemId,
668
+ values: sectionItemRow ?? {},
669
+ section: section,
670
+ });
671
+ }
672
+ catch (error) {
673
+ console.error('afterDelete hook failed:', error);
674
+ }
675
+ }
676
+ const headingFieldName = section.headingField.name;
677
+ const entityLabel = headingFieldName && sectionItemRow
678
+ ? // @ts-ignore
679
+ String(sectionItemRow[headingFieldName] ?? '')
680
+ : null;
681
+ await recordLog({
682
+ eventType: 'section.item.delete',
683
+ actorId: session.user.id,
684
+ actorUsername: session.user.name,
685
+ entityType: 'section_item',
686
+ entityId: sectionItemId,
687
+ entityLabel: entityLabel?.trim() ? entityLabel : null,
688
+ sectionName: section.name,
689
+ metadata: {
690
+ sectionType: section.type,
691
+ recursive: Boolean(recursive),
692
+ },
693
+ requestMetadata,
694
+ });
695
+ return true;
696
+ }
697
+ catch (err) {
698
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
699
+ console.error('Error deleting section item', err);
700
+ return {
701
+ error: {
702
+ message: `Failed to delete section item: ${errorMessage}`,
703
+ },
704
+ };
705
+ }
706
+ };
707
+ export const deleteLocaleTranslation = async (session, sectionName, sectionItemId, locale) => {
708
+ sectionItemId = sectionItemId.toString();
709
+ try {
710
+ const _s = (await SectionFactory.getSectionForAdmin({
711
+ name: sectionName,
712
+ admin: {
713
+ id: session.user.id,
714
+ requiredRole: 'D',
715
+ },
716
+ }));
717
+ const section = _s?.build();
718
+ if (!section) {
719
+ return {
720
+ error: {
721
+ message: getString('sectionNotFound', session.user.language),
722
+ },
723
+ };
724
+ }
725
+ const cmsConfig = await getCMSConfig();
726
+ if (!cmsConfig.localization?.enabled || !section.hasLocalizedFields) {
727
+ return {
728
+ error: {
729
+ message: getString('localizationNotEnabledForSection', session.user.language),
730
+ },
731
+ };
732
+ }
733
+ if (locale === cmsConfig.localization.defaultLocale) {
734
+ return {
735
+ error: {
736
+ message: getString('cannotDeleteBaseLocaleTranslation', session.user.language),
737
+ },
738
+ };
739
+ }
740
+ const localesTableName = section.localesTableName;
741
+ const localeColumns = await MysqlTableChecker.getColumns(localesTableName);
742
+ if (localeColumns.length === 0) {
743
+ return {
744
+ error: {
745
+ message: getString('localesTableDoesNotExist', session.user.language),
746
+ },
747
+ };
748
+ }
749
+ // Fetch the locale row before deleting so we can clean up files
750
+ const [localeRows] = await db.execute(sql `SELECT * FROM \`${sql.raw(localesTableName)}\` WHERE parent_id = ${sectionItemId} AND locale = ${locale} LIMIT 1`);
751
+ const localeRow = localeRows[0] ?? null;
752
+ const beforeDeleteHook = section.hooks?.beforeDelete;
753
+ if (beforeDeleteHook && typeof beforeDeleteHook === 'object' && beforeDeleteHook.runForLocales) {
754
+ await beforeDeleteHook.handler({
755
+ itemId: sectionItemId,
756
+ values: localeRow ?? {},
757
+ section: section,
758
+ locale,
759
+ });
760
+ }
761
+ // Delete files for localized photo/document/video fields
762
+ if (localeRow) {
763
+ const uploadsFolder = cmsConfig.media.upload.path;
764
+ const localizedFileFields = section.fieldConfigs.filter((f) => f.localized === true && (f.type === 'photo' || f.type === 'document' || f.type === 'video'));
765
+ for (const field of localizedFileFields) {
766
+ const value = localeRow[field.name];
767
+ if (!value)
768
+ continue;
769
+ if (field.type === 'document') {
770
+ try {
771
+ await fs.promises.unlink(path.join(uploadsFolder, '.documents', section.name, value));
772
+ }
773
+ catch (error) {
774
+ console.error('Error deleting document', error);
775
+ }
776
+ }
777
+ else if (field.type === 'video') {
778
+ try {
779
+ await fs.promises.unlink(path.join(uploadsFolder, '.videos', section.name, value));
780
+ }
781
+ catch (error) {
782
+ console.error('Error deleting video', error);
783
+ }
784
+ }
785
+ else {
786
+ // photo
787
+ try {
788
+ await fs.promises.unlink(path.join(uploadsFolder, '.photos', section.name, value));
789
+ await fs.promises.unlink(path.join(uploadsFolder, '.thumbs', section.name, value));
790
+ }
791
+ catch (error) {
792
+ console.error('Error deleting photo', error);
793
+ }
794
+ }
795
+ }
796
+ }
797
+ // Delete locale-scoped editor photos for localized rich_text fields
798
+ const editorPhotos = await db
799
+ .select()
800
+ .from(EditorPhotosTable)
801
+ .where(and(eq(EditorPhotosTable.section, sectionName), eq(EditorPhotosTable.itemId, sectionItemId.toString()), eq(EditorPhotosTable.locale, locale)));
802
+ if (editorPhotos.length > 0) {
803
+ const uploadsFolder = cmsConfig.media.upload.path;
804
+ for (const photo of editorPhotos) {
805
+ try {
806
+ await fs.promises.unlink(path.join(uploadsFolder, '.photos', section.name, photo.name));
807
+ }
808
+ catch (error) {
809
+ console.error('Error deleting editor photo', error);
810
+ }
811
+ }
812
+ await db
813
+ .delete(EditorPhotosTable)
814
+ .where(and(eq(EditorPhotosTable.section, sectionName), eq(EditorPhotosTable.itemId, sectionItemId.toString()), eq(EditorPhotosTable.locale, locale)));
815
+ }
816
+ // Delete locale-scoped junction table rows for localized select/tags fields
817
+ const localizedJunctionFields = section.fieldConfigs.filter((f) => f.localized === true &&
818
+ f.destinationDb &&
819
+ (f.type === 'select' || f.type === 'select_multiple' || f.type === 'tags'));
820
+ for (const field of localizedJunctionFields) {
821
+ const destDb = field.destinationDb;
822
+ if (!destDb)
823
+ continue;
824
+ try {
825
+ await db.execute(sql `DELETE FROM \`${sql.raw(destDb.table)}\` WHERE \`${sql.raw(destDb.itemIdentifier)}\` = ${sectionItemId} AND \`locale\` = ${locale}`);
826
+ }
827
+ catch (error) {
828
+ console.error(`Error deleting junction table rows for ${field.name}`, error);
829
+ }
830
+ }
831
+ await db.execute(sql `DELETE FROM \`${sql.raw(localesTableName)}\` WHERE parent_id = ${sectionItemId} AND locale = ${locale}`);
832
+ const afterDeleteHook = section.hooks?.afterDelete;
833
+ if (afterDeleteHook && typeof afterDeleteHook === 'object' && afterDeleteHook.runForLocales) {
834
+ try {
835
+ await afterDeleteHook.handler({
836
+ itemId: sectionItemId,
837
+ values: localeRow ?? {},
838
+ section: section,
839
+ locale,
840
+ });
841
+ }
842
+ catch (e) {
843
+ console.error('afterDelete hook failed:', e);
844
+ }
845
+ }
846
+ await recordLog({
847
+ eventType: 'section.item.locale.delete',
848
+ actorId: session.user.id,
849
+ actorUsername: session.user.name,
850
+ entityType: 'section_item_locale',
851
+ entityId: sectionItemId,
852
+ entityLabel: locale,
853
+ sectionName: section.name,
854
+ metadata: {
855
+ locale,
856
+ },
857
+ });
858
+ return true;
859
+ }
860
+ catch (err) {
861
+ const errorMessage = err instanceof Error ? err.message : getString('unknownErrorOccurred', session.user.language);
862
+ console.error('Error deleting locale translation', err);
863
+ return {
864
+ error: {
865
+ message: getString('deleteLocaleTranslationFailed', session.user.language, { detail: errorMessage }),
866
+ },
867
+ };
868
+ }
869
+ };
870
+ export const createEditPage = async (session, sectionName, sectionItemId, locale) => {
871
+ /**
872
+ * Generate the fields for the edit page
873
+ */
874
+ const fieldsFactory = new FieldFactory({
875
+ type: 'edit',
876
+ sectionName,
877
+ session,
878
+ itemId: sectionItemId,
879
+ });
880
+ try {
881
+ const cmsConfig = await getCMSConfig();
882
+ const localeResult = resolveLocale({
883
+ localization: cmsConfig.localization,
884
+ locale,
885
+ });
886
+ if (locale && !localeResult.resolvedLocale) {
887
+ if (localeResult.localizationEnabled === false) {
888
+ return {
889
+ error: {
890
+ message: getString('localizationNotEnabledForSection', session.user.language),
891
+ },
892
+ };
893
+ }
894
+ return {
895
+ error: {
896
+ message: getString('invalidLocale', session.user.language, {
897
+ locale,
898
+ locales: localeResult.availableLocales.map((l) => l.code).join(', '),
899
+ }),
900
+ },
901
+ };
902
+ }
903
+ await fieldsFactory.initialize();
904
+ await fieldsFactory.generateFields();
905
+ if (fieldsFactory.error) {
906
+ return {
907
+ error: {
908
+ message: fieldsFactory.errorMessage,
909
+ },
910
+ };
911
+ }
912
+ /**
913
+ * Let's check for variants
914
+ */
915
+ /*const variants = section[0].variants
916
+ const sectionVariants: { name: string }[] = []
917
+
918
+ if (variants && variants.trim() !== '') {
919
+ /!**
920
+ * Convert to JSON
921
+ *!/
922
+ const variantsJson = JSON.parse(variants)
923
+
924
+ /!**
925
+ * Loop through the variants
926
+ *!/
927
+
928
+ variantsJson.forEach((variant: any) => {
929
+ const variantName = variant.name
930
+ const variantInfo = variant.info
931
+ })
932
+ }*/
933
+ /**
934
+ * Get the gallery photos
935
+ * TODO: This is a temp implementation, will be removed once converting the gallery into a field
936
+ */
937
+ let galleryItems = [];
938
+ const gallery = await fieldsFactory.sectionInfo?.getGallery();
939
+ if (gallery) {
940
+ const { tableName, referenceIdentifierField, photoNameField, metaField } = gallery.db;
941
+ const columns = await MysqlTableChecker.getColumns(tableName);
942
+ if (columns.includes(photoNameField) &&
943
+ columns.includes(referenceIdentifierField) &&
944
+ columns.includes(metaField)) {
945
+ const [items, fields] = await db.execute(sql `select * from \`${sql.raw(tableName)}\` where \`${sql.raw(referenceIdentifierField)}\` = ${sectionItemId}`);
946
+ const galleryPhotos = items;
947
+ galleryPhotos?.map((item) => {
948
+ galleryItems.push({
949
+ referenceId: item[referenceIdentifierField],
950
+ photo: item[photoNameField],
951
+ meta: item[metaField],
952
+ });
953
+ });
954
+ }
955
+ }
956
+ const sectionInfo = fieldsFactory.sectionInfo;
957
+ // Resolve localized titles using the user's language
958
+ const uiLanguage = resolveLanguage(session.user.language, cmsConfig.i18n.supportedLanguages, cmsConfig.i18n.fallbackLanguage);
959
+ const resolvedTitle = {
960
+ section: resolveMultilingualString(sectionInfo.title.section, uiLanguage, cmsConfig.i18n.fallbackLanguage),
961
+ singular: resolveMultilingualString(sectionInfo.title.singular, uiLanguage, cmsConfig.i18n.fallbackLanguage),
962
+ plural: resolveMultilingualString(sectionInfo.title.plural, uiLanguage, cmsConfig.i18n.fallbackLanguage),
963
+ };
964
+ /**
965
+ * Fetch localization metadata for sections with localized fields
966
+ */
967
+ let localizationData = null;
968
+ if (localeResult.localizationEnabled) {
969
+ let existingTranslations = [];
970
+ if (sectionInfo.hasLocalizedFields) {
971
+ const localesTableName = sectionInfo.localesTableName;
972
+ const localeColumns = await MysqlTableChecker.getColumns(localesTableName);
973
+ const localeTableExists = localeColumns.length > 0;
974
+ if (localeTableExists) {
975
+ const [rows] = await db.execute(sql `SELECT locale FROM \`${sql.raw(localesTableName)}\` WHERE parent_id = ${sectionItemId}`);
976
+ existingTranslations = rows.map((r) => r.locale);
977
+ // Override localized field values with locale-specific data
978
+ if (locale && localeResult.resolvedLocale && localeResult.isDefault === false) {
979
+ const [localeRows] = await db.execute(sql `SELECT * FROM \`${sql.raw(localesTableName)}\` WHERE parent_id = ${sectionItemId} AND locale = ${localeResult.resolvedLocale.code} LIMIT 1`);
980
+ const localeRow = localeRows[0] ?? null;
981
+ for (const field of sectionInfo.fields) {
982
+ if (!field.localized)
983
+ continue;
984
+ // For select/tags with destinationDb, fetch locale-scoped junction values
985
+ const f = field;
986
+ if (f.destinationDb && (field.type === 'select' || field.type === 'select_multiple')) {
987
+ if (f.db) {
988
+ const [_rows] = await db.execute(sql `SELECT * FROM \`${sql.raw(f.destinationDb.table)}\` a JOIN \`${sql.raw(f.db.table)}\` b ON a.\`${sql.raw(f.destinationDb.selectIdentifier)}\` = b.\`${sql.raw(f.db.identifier)}\` WHERE a.\`${sql.raw(f.destinationDb.itemIdentifier)}\` = ${sectionItemId} AND a.\`locale\` = ${localeResult.resolvedLocale.code}`);
989
+ const values = Array.isArray(_rows)
990
+ ? _rows.map((row) => ({
991
+ value: row[f.destinationDb.selectIdentifier],
992
+ label: row[f.db.label],
993
+ }))
994
+ : [];
995
+ field.setValue(values);
996
+ }
997
+ else {
998
+ const [_rows] = await db.execute(sql `SELECT * FROM \`${sql.raw(f.destinationDb.table)}\` WHERE \`${sql.raw(f.destinationDb.itemIdentifier)}\` = ${sectionItemId} AND \`locale\` = ${localeResult.resolvedLocale.code}`);
999
+ const values = Array.isArray(_rows)
1000
+ ? _rows.map((row) => ({
1001
+ value: row[f.destinationDb.selectIdentifier],
1002
+ label: f.options?.find((opt) => opt.value?.toString() ===
1003
+ row[f.destinationDb.selectIdentifier]?.toString())?.label ?? '',
1004
+ }))
1005
+ : [];
1006
+ field.setValue(values);
1007
+ }
1008
+ }
1009
+ else if (f.destinationDb && field.type === 'tags') {
1010
+ const [_rows] = await db.execute(sql `SELECT \`${sql.raw(f.destinationDb.selectIdentifier)}\` FROM \`${sql.raw(f.destinationDb.table)}\` WHERE \`${sql.raw(f.destinationDb.itemIdentifier)}\` = ${sectionItemId} AND \`locale\` = ${localeResult.resolvedLocale.code}`);
1011
+ const tags = Array.isArray(_rows)
1012
+ ? _rows.map((row) => row[f.destinationDb.selectIdentifier])
1013
+ : [];
1014
+ field.setValue(tags.join(','));
1015
+ }
1016
+ else {
1017
+ // Simple field: override from locale row
1018
+ field.setValue(localeRow ? (localeRow[field.name] ?? null) : null);
1019
+ }
1020
+ }
1021
+ }
1022
+ }
1023
+ }
1024
+ localizationData = {
1025
+ defaultLocale: localeResult.defaultLocale,
1026
+ currentLocale: localeResult.resolvedLocale ?? localeResult.defaultLocale,
1027
+ existingTranslations,
1028
+ locales: localeResult.availableLocales,
1029
+ };
1030
+ }
1031
+ return {
1032
+ section: {
1033
+ name: sectionInfo.name,
1034
+ title: resolvedTitle,
1035
+ gallery: gallery,
1036
+ variants: sectionInfo.variants,
1037
+ configFile: sectionInfo.configFile,
1038
+ },
1039
+ inputGroups: await fieldsFactory.getGroupedFields(),
1040
+ gallery: galleryItems,
1041
+ localization: localizationData,
1042
+ };
1043
+ }
1044
+ catch (error) {
1045
+ const errorMessage = error?.errorMessage || (error instanceof Error ? error.message : 'Unknown error occurred');
1046
+ console.error('Error creating edit page', error);
1047
+ return {
1048
+ error: {
1049
+ message: errorMessage,
1050
+ },
1051
+ };
1052
+ }
1053
+ };
1054
+ export const createNewPage = async (session, sectionName) => {
1055
+ try {
1056
+ const fieldsFactory = new FieldFactory({
1057
+ type: 'new',
1058
+ sectionName,
1059
+ session,
1060
+ });
1061
+ await fieldsFactory.initialize();
1062
+ if (fieldsFactory.error) {
1063
+ return {
1064
+ error: {
1065
+ message: fieldsFactory.errorMessage,
1066
+ },
1067
+ };
1068
+ }
1069
+ try {
1070
+ await fieldsFactory.generateFields();
1071
+ }
1072
+ catch (err) {
1073
+ // console.error('Error generating fields', err)
1074
+ return {
1075
+ error: {
1076
+ message: err.message,
1077
+ },
1078
+ };
1079
+ }
1080
+ /**
1081
+ * Let's check for variants
1082
+ */
1083
+ /*const variants = section[0].variants
1084
+ const sectionVariants: { name: string }[] = []
1085
+
1086
+ if (variants && variants.trim() !== '') {
1087
+ /!**
1088
+ * Convert to JSON
1089
+ *!/
1090
+ const variantsJson = JSON.parse(variants)
1091
+
1092
+ /!**
1093
+ * Loop through the variants
1094
+ *!/
1095
+
1096
+ variantsJson.forEach((variant: any) => {
1097
+ const variantName = variant.name
1098
+ const variantInfo = variant.info
1099
+ })
1100
+ }*/
1101
+ const sectionInfo = fieldsFactory.sectionInfo;
1102
+ const gallery = await sectionInfo.getGallery();
1103
+ // Get the locale for resolving localized titles
1104
+ const cmsConfig = await getCMSConfig();
1105
+ const language = resolveLanguage(session.user.language, cmsConfig.i18n.supportedLanguages, cmsConfig.i18n.fallbackLanguage);
1106
+ const resolvedTitle = {
1107
+ section: resolveMultilingualString(sectionInfo.title.section, language, cmsConfig.i18n.fallbackLanguage),
1108
+ singular: resolveMultilingualString(sectionInfo.title.singular, language, cmsConfig.i18n.fallbackLanguage),
1109
+ plural: resolveMultilingualString(sectionInfo.title.plural, language, cmsConfig.i18n.fallbackLanguage),
1110
+ };
1111
+ const defaultLocale = cmsConfig.localization?.enabled
1112
+ ? (cmsConfig.localization.locales.find((l) => l.code === cmsConfig.localization.defaultLocale) ?? null)
1113
+ : null;
1114
+ return {
1115
+ section: {
1116
+ name: sectionInfo.name,
1117
+ title: resolvedTitle,
1118
+ gallery: gallery,
1119
+ variants: sectionInfo.variants,
1120
+ configFile: sectionInfo.configFile,
1121
+ },
1122
+ inputGroups: await fieldsFactory.getGroupedFields(),
1123
+ defaultLocale,
1124
+ };
1125
+ }
1126
+ catch (err) {
1127
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
1128
+ console.error('Error creating new page', err);
1129
+ return {
1130
+ error: {
1131
+ message: errorMessage,
1132
+ },
1133
+ };
1134
+ }
1135
+ };
1136
+ export const getCategorySectionChildren = async ({ session, id, sectionName, level, }) => {
1137
+ /**
1138
+ * First, level up to get the children (next level)
1139
+ */
1140
+ level++;
1141
+ // Let's fetch the section items and admin privileges for the section
1142
+ const _s = (await SectionFactory.getSectionForAdmin({
1143
+ name: sectionName,
1144
+ admin: {
1145
+ id: session.user.id,
1146
+ },
1147
+ }));
1148
+ const section = _s?.build();
1149
+ if (!section) {
1150
+ return {
1151
+ error: {
1152
+ message: getString('sectionNotFound', session.user.language),
1153
+ },
1154
+ };
1155
+ }
1156
+ /**
1157
+ * Check if new level is allowed in the category section depth
1158
+ */
1159
+ if (level > (section.depth ?? 1)) {
1160
+ /**
1161
+ * This is the last level, return an empty array
1162
+ */
1163
+ return {
1164
+ options: null,
1165
+ parentId: id,
1166
+ level: level,
1167
+ };
1168
+ }
1169
+ /**
1170
+ * Let's get the options for the select input
1171
+ */
1172
+ const selectStatement = sql `select * from \`${sql.raw(section.db.table)}\` WHERE parent_id = ${id} AND level = ${level} ORDER BY \`${sql.raw(section.db.orderByField?.name ? section.db.orderByField?.name : section.db.identifier.name)}\` DESC`;
1173
+ /**
1174
+ * Get the options from the table
1175
+ */
1176
+ const selectOptionsRows = await db.execute(selectStatement);
1177
+ const rows = selectOptionsRows[0];
1178
+ return {
1179
+ options: rows.map((row) => {
1180
+ return {
1181
+ value: row[section.db.identifier.name],
1182
+ label: row[section.headingField.name],
1183
+ };
1184
+ }),
1185
+ parentId: id,
1186
+ level: level++,
1187
+ };
1188
+ };
1189
+ export const getCategorySection = async (session, sectionName) => {
1190
+ // Let's fetch the section items and admin privileges for the section
1191
+ const section = (await SectionFactory.getSectionForAdmin({
1192
+ name: sectionName,
1193
+ admin: {
1194
+ id: session.user.id,
1195
+ },
1196
+ }));
1197
+ if (!section) {
1198
+ return {
1199
+ error: {
1200
+ message: getString('sectionNotFound', session.user.language),
1201
+ },
1202
+ };
1203
+ }
1204
+ const tableName = section.db.table;
1205
+ /**
1206
+ * Create a select field config for the category section
1207
+ */
1208
+ const s = new SelectField({
1209
+ name: section.name,
1210
+ label: section.title.section,
1211
+ required: false,
1212
+ order: 1,
1213
+ section: section,
1214
+ destinationDb: undefined,
1215
+ });
1216
+ /**
1217
+ * Build the select field to get the options
1218
+ * from database and do all the necessary checks
1219
+ */
1220
+ await s.build();
1221
+ /**
1222
+ * Set the value to undefined
1223
+ */
1224
+ s.setValue(undefined);
1225
+ // Get the locale for resolving localized titles (label must be a string for React)
1226
+ const cmsConfig = await getCMSConfig();
1227
+ const language = resolveLanguage(session.user.language, cmsConfig.i18n.supportedLanguages, cmsConfig.i18n.fallbackLanguage);
1228
+ const resolvedTitle = {
1229
+ section: resolveMultilingualString(section.title.section, language, cmsConfig.i18n.fallbackLanguage),
1230
+ singular: resolveMultilingualString(section.title.singular, language, cmsConfig.i18n.fallbackLanguage),
1231
+ plural: resolveMultilingualString(section.title.plural, language, cmsConfig.i18n.fallbackLanguage),
1232
+ };
1233
+ const categorySectionSelect = {
1234
+ options: s.options,
1235
+ required: s.required,
1236
+ name: s.name,
1237
+ label: resolvedTitle.section,
1238
+ value: s.value,
1239
+ parentId: undefined,
1240
+ level: 1,
1241
+ depth: section.depth,
1242
+ sectionName: section.name,
1243
+ allowRecursiveDelete: section.allowRecursiveDelete,
1244
+ };
1245
+ return {
1246
+ section: {
1247
+ tableName: tableName,
1248
+ sectionName: section.name,
1249
+ title: resolvedTitle,
1250
+ },
1251
+ data: categorySectionSelect,
1252
+ // publisher: privileges.publisher,
1253
+ };
1254
+ };
1255
+ export const getBrowsePage = async (session, sectionName, page = 1, q) => {
1256
+ // Let's fetch the section items and admin privileges for the section
1257
+ const _s = (await SectionFactory.getSectionForAdmin({
1258
+ name: sectionName,
1259
+ admin: {
1260
+ id: session.user.id,
1261
+ },
1262
+ }));
1263
+ const section = _s?.build();
1264
+ if (!section) {
1265
+ return {
1266
+ error: {
1267
+ message: getString('sectionNotFound', session.user.language),
1268
+ },
1269
+ };
1270
+ }
1271
+ const limit = 12;
1272
+ const offset = (page - 1) * limit;
1273
+ const tableName = section.db.table;
1274
+ const orderByFieldName = section.db.orderByField?.name || 'created_at';
1275
+ const sqlChunks = [];
1276
+ const totalRowsSqlChunks = [];
1277
+ const sectionSearch = section.search?.searchFields ? section.search?.searchFields.length > 0 : false;
1278
+ // Check if we need to JOIN _locales for search
1279
+ const cmsConfig = await getCMSConfig();
1280
+ const hasLocalization = !!cmsConfig.localization?.enabled;
1281
+ const hasLocalizedSearchFields = hasLocalization && q && q.trim().length > 0 && section.search?.searchFields?.some((f) => f.localized);
1282
+ const localesTable = hasLocalizedSearchFields ? section.localesTableName : null;
1283
+ if (localesTable) {
1284
+ // Use DISTINCT to avoid duplicate rows from the LEFT JOIN
1285
+ sqlChunks.push(sql `select distinct ${sql.raw(`\`${tableName}\``)}.* from ${sql.raw(`\`${tableName}\``)} left join ${sql.raw(`\`${localesTable}\``)} on ${sql.raw(`\`${localesTable}\``)}.parent_id = ${sql.raw(`\`${tableName}\``)}.${sql.raw(`\`${section.db.identifier.name}\``)}`);
1286
+ totalRowsSqlChunks.push(sql `select count(distinct ${sql.raw(`\`${tableName}\``)}.${sql.raw(`\`${section.db.identifier.name}\``)} ) as total from ${sql.raw(`\`${tableName}\``)} left join ${sql.raw(`\`${localesTable}\``)} on ${sql.raw(`\`${localesTable}\``)}.parent_id = ${sql.raw(`\`${tableName}\``)}.${sql.raw(`\`${section.db.identifier.name}\``)}`);
1287
+ }
1288
+ else {
1289
+ sqlChunks.push(sql `select * from ${sql.raw(`\`${tableName}\``)}`);
1290
+ totalRowsSqlChunks.push(sql `select count(*) as total from ${sql.raw(`\`${tableName}\``)}`);
1291
+ }
1292
+ if (q && q.trim().length > 0) {
1293
+ if (section.search?.searchFields.length) {
1294
+ const whereParts = [];
1295
+ for (const field of section.search.searchFields) {
1296
+ // Search in main table
1297
+ whereParts.push(sql `${sql.raw(`\`${tableName}\``)}.${sql.raw(`\`${field.name}\``)} LIKE CONCAT('%',${sql `${q.trim()}`},'%')`);
1298
+ // Also search in _locales table for localized fields
1299
+ if (localesTable && field.localized) {
1300
+ whereParts.push(sql `${sql.raw(`\`${localesTable}\``)}.${sql.raw(`\`${field.name}\``)} LIKE CONCAT('%',${sql `${q.trim()}`},'%')`);
1301
+ }
1302
+ }
1303
+ sqlChunks.push(sql `where`);
1304
+ sqlChunks.push(sql.join(whereParts, sql ` or `));
1305
+ totalRowsSqlChunks.push(sql `where`);
1306
+ totalRowsSqlChunks.push(sql.join(whereParts, sql ` or `));
1307
+ }
1308
+ }
1309
+ sqlChunks.push(sql `ORDER BY ${sql.raw(`\`${tableName}\``)}.${sql.raw(`\`${orderByFieldName}\``)} DESC LIMIT ${limit} OFFSET ${offset}`);
1310
+ const finalSql = sql.join(sqlChunks, sql.raw(' '));
1311
+ const totalRowsSql = sql.join(totalRowsSqlChunks, sql.raw(' '));
1312
+ // Now, let's get the section items from the table
1313
+ const sectionItems = await db.execute(finalSql);
1314
+ const totalCountResult = await db.execute(totalRowsSql);
1315
+ const totalCountRows = totalCountResult[0];
1316
+ const rows = sectionItems[0];
1317
+ // Resolve localized section title for the browse page header
1318
+ const language = resolveLanguage(session.user.language, cmsConfig.i18n.supportedLanguages, cmsConfig.i18n.fallbackLanguage);
1319
+ const resolvedTitle = resolveMultilingualString(section.title.section, language, cmsConfig.i18n.fallbackLanguage);
1320
+ return {
1321
+ section: {
1322
+ // tableName: tableName,
1323
+ // headingField: section.headingField.name,
1324
+ // identifierField: section.db.identifier.name,
1325
+ // coverPhotoField: section.coverPhotoField?.name,
1326
+ hasSearch: sectionSearch,
1327
+ name: section.name,
1328
+ title: resolvedTitle,
1329
+ },
1330
+ items: rows.map((row) => {
1331
+ // Custom browse page implementation: if browse.fields is configured,
1332
+ // return the specified fields plus default fields (id, headingTitle, coverPhoto, createdAt, createdBy, permission).
1333
+ // NOTE: This is for custom browse page implementations only.
1334
+ // The default CMS browse page component uses the fallback fields below.
1335
+ if (section.browse?.fields && section.browse.fields.length > 0) {
1336
+ const item = {
1337
+ // Always include default fields
1338
+ id: row[section.db.identifier.name],
1339
+ headingTitle: row[section.headingField.name],
1340
+ coverPhoto: section.coverPhotoField ? row[section.coverPhotoField?.name] : null,
1341
+ createdAt: row['created_at'],
1342
+ createdBy: row['created_by'],
1343
+ permission: row.permission,
1344
+ };
1345
+ // Add configured browse fields
1346
+ for (const field of section.browse.fields) {
1347
+ item[field.name] = row[field.name] ?? null;
1348
+ }
1349
+ return item;
1350
+ }
1351
+ // Default browse page fields (used by the standard CMS browse page)
1352
+ return {
1353
+ id: row[section.db.identifier.name],
1354
+ headingTitle: row[section.headingField.name],
1355
+ coverPhoto: section.coverPhotoField ? row[section.coverPhotoField?.name] : null,
1356
+ createdAt: row['created_at'],
1357
+ createdBy: row['created_by'],
1358
+ permission: row.permission,
1359
+ };
1360
+ }),
1361
+ totalCount: totalCountRows[0].total,
1362
+ };
1363
+ };
1364
+ export const getSidebar = async (session) => {
1365
+ // Let's get simple, has_items and categorized sections from the sections table
1366
+ const { simple, has_items, category, fixed } = await SectionFactory.getSectionsForAdmin({
1367
+ admin: { id: session.user.id },
1368
+ });
1369
+ const pluginRoutes = await getPluginRoutes();
1370
+ const privilegeSet = await getAdminPrivileges(session.user.id);
1371
+ // Get config and check for dashboard override
1372
+ const cmsConfig = await getCMSConfig();
1373
+ const dashboardOverridePath = cmsConfig.dashboard?.override;
1374
+ // Include dashboard override plugin even without explicit privilege
1375
+ const allowedPluginRoutes = pluginRoutes.filter(({ pluginName, path }) => privilegeSet.has(pluginName) || path === dashboardOverridePath);
1376
+ // Get the locale for resolving localized titles
1377
+ const language = resolveLanguage(session.user.language, cmsConfig.i18n.supportedLanguages, cmsConfig.i18n.fallbackLanguage);
1378
+ // Let's loop through the sections and add path, icon and title to each section item
1379
+ const simpleSectionItems = simple.map((section) => {
1380
+ return {
1381
+ title: resolveMultilingualString(section.title, language, cmsConfig.i18n.fallbackLanguage),
1382
+ path: `/section/${section.name}`,
1383
+ icon: section.icon,
1384
+ };
1385
+ });
1386
+ const hasItemsSectionItems = has_items.map((section) => {
1387
+ return {
1388
+ title: resolveMultilingualString(section.title.section, language, cmsConfig.i18n.fallbackLanguage),
1389
+ path: `/${section.name}`,
1390
+ icon: section.icon,
1391
+ };
1392
+ });
1393
+ const categorySectionsItems = category.map((section) => {
1394
+ return {
1395
+ title: resolveMultilingualString(section.title.section, language, cmsConfig.i18n.fallbackLanguage),
1396
+ path: `/categorized/${section.name}`,
1397
+ icon: section.icon,
1398
+ };
1399
+ });
1400
+ // Filter out dashboard override from plugin sections (it becomes the dashboard entry)
1401
+ const pluginSections = allowedPluginRoutes
1402
+ .filter((route) => route.path !== dashboardOverridePath)
1403
+ .map((route) => ({
1404
+ title: resolveMultilingualString(route.title, language, cmsConfig.i18n.fallbackLanguage),
1405
+ path: route.path,
1406
+ icon: route.icon ?? '',
1407
+ }));
1408
+ const fixedSections = [
1409
+ /**
1410
+ * Add the dashboard section (points to override if configured)
1411
+ */
1412
+ {
1413
+ title: getString('dashboard', session.user.language),
1414
+ path: dashboardOverridePath ?? '/dashboard',
1415
+ icon: 'home',
1416
+ },
1417
+ /**
1418
+ * Add the plugin sections (excluding dashboard override)
1419
+ */
1420
+ ...pluginSections,
1421
+ ...fixed.map((section) => ({
1422
+ title: getString(section.name, session.user.language),
1423
+ path: `/${section.name}`,
1424
+ icon: section.icon,
1425
+ })),
1426
+ ];
1427
+ return {
1428
+ fixed_sections: fixedSections,
1429
+ cat_sections: categorySectionsItems,
1430
+ has_items_sections: hasItemsSectionItems,
1431
+ simple_sections: simpleSectionItems,
1432
+ };
1433
+ };
1434
+ /**
1435
+ * Return a stream from the disk with proper backpressure handling.
1436
+ * Uses Node.js built-in Readable.toWeb() which correctly pauses/resumes
1437
+ * the underlying fs stream based on consumer demand.
1438
+ * @param {string} path - The location of the file
1439
+ * @param options
1440
+ * @returns {ReadableStream} A readable stream of the file
1441
+ */
1442
+ export const streamFile = async (path, options) => {
1443
+ const stream = fs.createReadStream(path, options);
1444
+ return Readable.toWeb(stream);
1445
+ };