@plank-cms/plank 0.15.3 → 0.17.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.
@@ -45,12 +45,26 @@ var DEFAULT_ROLE_PERMISSIONS = {
45
45
  "settings:webhooks:write",
46
46
  "settings:webhooks:delete"
47
47
  ],
48
- "User": [
48
+ "Contributor": [
49
49
  "content-types:read",
50
50
  "entries:read",
51
51
  "entries:write",
52
+ "entries:delete",
52
53
  "media:read",
53
54
  "media:write"
55
+ ],
56
+ "Editor": [
57
+ "content-types:read",
58
+ "entries:read",
59
+ "entries:write",
60
+ "entries:delete",
61
+ "media:read",
62
+ "media:write"
63
+ ],
64
+ "Viewer": [
65
+ "content-types:read",
66
+ "entries:read",
67
+ "media:read"
54
68
  ]
55
69
  };
56
70
 
@@ -289,7 +303,7 @@ async function createTable(contentType) {
289
303
  await pool_default.query(sql);
290
304
  try {
291
305
  await pool_default.query(`CREATE INDEX IF NOT EXISTS idx_${contentType.tableName}_localized_gin ON ${quotedTableName} USING gin (localized)`);
292
- } catch (err) {
306
+ } catch {
293
307
  }
294
308
  for (const field of contentType.fields) {
295
309
  if (field.type === "relation" && (field.relationType ?? "many-to-one") === "many-to-many" && field.relatedTable) {
@@ -397,10 +411,10 @@ async function syncAllTables() {
397
411
  existingColumns.add("localized");
398
412
  try {
399
413
  await pool_default.query(`CREATE INDEX IF NOT EXISTS idx_${ct.tableName}_localized_gin ON ${quotedTableName} USING gin (localized)`);
400
- } catch (err) {
414
+ } catch {
401
415
  }
402
416
  console.log(`[plank] Added missing column "localized" to table "${ct.tableName}"`);
403
- } catch (err) {
417
+ } catch {
404
418
  }
405
419
  }
406
420
  for (const field of ct.fields) {
@@ -423,6 +437,18 @@ async function syncAllTables() {
423
437
  // ../schema/dist/validator.js
424
438
  var MAX_NAVIGATION_DEPTH = 3;
425
439
  var MAX_NAVIGATION_ITEMS_PER_LEVEL = 20;
440
+ function isMixedArrayValue(value) {
441
+ if (typeof value !== "object" || value === null)
442
+ return false;
443
+ const v3 = value;
444
+ if (v3.kind === "string")
445
+ return typeof v3.value === "string";
446
+ if (v3.kind === "number")
447
+ return typeof v3.value === "number" && !isNaN(v3.value);
448
+ if (v3.kind === "boolean")
449
+ return typeof v3.value === "boolean";
450
+ return false;
451
+ }
426
452
  function validateNavigationItems(value, fieldName, errors, path = fieldName, depth = 1) {
427
453
  if (!Array.isArray(value)) {
428
454
  errors.push(`Field "${path}" must be an array`);
@@ -535,6 +561,36 @@ function validate(contentType, payload) {
535
561
  const subEmpty = subValue === void 0 || subValue === null || subValue === "";
536
562
  if (subField.required && subEmpty) {
537
563
  errors.push(`Field "${field.name}[${i2}].${subField.name}" is required`);
564
+ continue;
565
+ }
566
+ if (subEmpty)
567
+ continue;
568
+ if (subField.type === "string" || subField.type === "text" || subField.type === "richtext") {
569
+ if (typeof subValue !== "string") {
570
+ errors.push(`Field "${field.name}[${i2}].${subField.name}" must be a string`);
571
+ }
572
+ } else if (subField.type === "number") {
573
+ if (typeof subValue !== "number" || isNaN(subValue)) {
574
+ errors.push(`Field "${field.name}[${i2}].${subField.name}" must be a number`);
575
+ } else if (subField.subtype !== "float" && !Number.isInteger(subValue)) {
576
+ errors.push(`Field "${field.name}[${i2}].${subField.name}" must be an integer`);
577
+ }
578
+ } else if (subField.type === "boolean") {
579
+ if (typeof subValue !== "boolean") {
580
+ errors.push(`Field "${field.name}[${i2}].${subField.name}" must be a boolean`);
581
+ }
582
+ } else if (subField.type === "datetime") {
583
+ if (!(subValue instanceof Date) && isNaN(Date.parse(String(subValue)))) {
584
+ errors.push(`Field "${field.name}[${i2}].${subField.name}" must be a valid date`);
585
+ }
586
+ } else if (subField.type === "media") {
587
+ if (typeof subValue !== "string" || !subValue.trim()) {
588
+ errors.push(`Field "${field.name}[${i2}].${subField.name}" must be a non-empty string URL`);
589
+ }
590
+ } else if (subField.type === "mixed") {
591
+ if (!isMixedArrayValue(subValue)) {
592
+ errors.push(`Field "${field.name}[${i2}].${subField.name}" must be a mixed value object with kind/value`);
593
+ }
538
594
  }
539
595
  }
540
596
  }
@@ -2103,7 +2159,7 @@ var localProvider = {
2103
2159
  const base = await publicUrl();
2104
2160
  return { url: `${base}/uploads/${key}`, key };
2105
2161
  },
2106
- async uploadRaw(buffer, exactKey, mimeType) {
2162
+ async uploadRaw(buffer, exactKey, _mimeType) {
2107
2163
  const base_dir = await uploadsDir();
2108
2164
  const dir = join3(base_dir, exactKey.split("/").slice(0, -1).join("/"));
2109
2165
  await mkdir(dir, { recursive: true });
@@ -2427,7 +2483,7 @@ async function login(req, res) {
2427
2483
  res.status(429).json({ error: "Too many login attempts. Try again in 15 minutes." });
2428
2484
  return;
2429
2485
  }
2430
- const { rows } = await pool_default.query(`SELECT id, email, password, role_id, first_name, last_name, avatar_url, job_title, organization, country, two_factor_enabled, two_factor_secret, session_version
2486
+ const { rows } = await pool_default.query(`SELECT id, email, password, role_id, first_name, last_name, avatar_url, job_title, organization, country, two_factor_enabled, two_factor_secret, enabled, session_version
2431
2487
  FROM plank_users
2432
2488
  WHERE email = $1`, [email]);
2433
2489
  const user = rows[0];
@@ -2435,6 +2491,10 @@ async function login(req, res) {
2435
2491
  res.status(401).json({ error: "Invalid credentials" });
2436
2492
  return;
2437
2493
  }
2494
+ if (!user.enabled) {
2495
+ res.status(403).json({ error: "User is disabled" });
2496
+ return;
2497
+ }
2438
2498
  await clearRateLimit("login", rateKey);
2439
2499
  if (user.two_factor_enabled && user.two_factor_secret) {
2440
2500
  const challengeToken = buildChallengeToken({
@@ -2478,9 +2538,13 @@ async function loginWithTwoFactor(req, res) {
2478
2538
  res.status(429).json({ error: "Too many 2FA attempts. Try again in 15 minutes." });
2479
2539
  return;
2480
2540
  }
2481
- const { rows } = await pool_default.query(`SELECT id, email, role_id, first_name, last_name, avatar_url, job_title, organization, country, two_factor_enabled, two_factor_secret, password, session_version
2541
+ const { rows } = await pool_default.query(`SELECT id, email, role_id, first_name, last_name, avatar_url, job_title, organization, country, two_factor_enabled, two_factor_secret, password, enabled, session_version
2482
2542
  FROM plank_users WHERE id = $1`, [payload.sub]);
2483
2543
  const user = rows[0];
2544
+ if (user && !user.enabled) {
2545
+ res.status(403).json({ error: "User is disabled" });
2546
+ return;
2547
+ }
2484
2548
  if (!user || !user.two_factor_enabled || !user.two_factor_secret) {
2485
2549
  res.status(401).json({ error: "2FA is not enabled for this account" });
2486
2550
  return;
@@ -2622,8 +2686,8 @@ async function authenticate(req, res, next) {
2622
2686
  if (typeof payload.sv !== "number") {
2623
2687
  return { ok: false };
2624
2688
  }
2625
- const { rows } = await pool_default.query("SELECT session_version FROM plank_users WHERE id = $1", [payload.sub]);
2626
- if (!rows[0] || rows[0].session_version !== payload.sv) {
2689
+ const { rows } = await pool_default.query("SELECT session_version, enabled FROM plank_users WHERE id = $1", [payload.sub]);
2690
+ if (!rows[0] || !rows[0].enabled || rows[0].session_version !== payload.sv) {
2627
2691
  return { ok: false };
2628
2692
  }
2629
2693
  return { ok: true, sessionVersion: rows[0].session_version };
@@ -2669,6 +2733,25 @@ async function authenticate(req, res, next) {
2669
2733
  }
2670
2734
  }
2671
2735
 
2736
+ // ../core/dist/lib/editorialMode.js
2737
+ async function isEditorialModeEnabled() {
2738
+ const raw = await getSetting("general", "editorial_mode");
2739
+ return String(raw ?? "false").toLowerCase() === "true";
2740
+ }
2741
+
2742
+ // ../core/dist/lib/appModes.js
2743
+ async function resolveAppModes() {
2744
+ return {
2745
+ editorial: await isEditorialModeEnabled()
2746
+ };
2747
+ }
2748
+
2749
+ // ../core/dist/middlewares/appModes.js
2750
+ async function attachAppModes(req, _res, next) {
2751
+ req.appModes = await resolveAppModes();
2752
+ next();
2753
+ }
2754
+
2672
2755
  // ../core/dist/middlewares/authorize.js
2673
2756
  function authorize(permission) {
2674
2757
  return async (req, res, next) => {
@@ -2770,7 +2853,7 @@ var RESERVED_SQL_IDENTIFIERS = /* @__PURE__ */ new Set([
2770
2853
  ]);
2771
2854
  var ArraySubFieldSchema = z2.object({
2772
2855
  name: z2.string().regex(/^[a-z][a-z0-9_]*$/, "Sub-field name must be lowercase with underscores"),
2773
- type: z2.enum(["string", "text", "richtext", "number", "boolean", "datetime", "media"]),
2856
+ type: z2.enum(["string", "text", "richtext", "number", "boolean", "datetime", "media", "mixed"]),
2774
2857
  required: z2.boolean().optional(),
2775
2858
  subtype: z2.enum(["integer", "float"]).optional(),
2776
2859
  allowedTypes: z2.array(z2.enum(["image", "video", "audio", "document"])).optional(),
@@ -3110,11 +3193,17 @@ async function syncManyToMany(entryId, tableName, field, targetIds) {
3110
3193
  const placeholders = targetIds.map((_3, i2) => `($1, $${i2 + 2})`).join(", ");
3111
3194
  await pool_default.query(`INSERT INTO ${quoteIdentifier(jt)} (source_id, target_id) VALUES ${placeholders} ON CONFLICT DO NOTHING`, [entryId, ...targetIds]);
3112
3195
  }
3113
- async function isUserRole(roleId) {
3196
+ async function isContributorRole(roleId) {
3114
3197
  if (!roleId)
3115
3198
  return false;
3116
3199
  const { rows } = await pool_default.query("SELECT name FROM plank_roles WHERE id = $1", [roleId]);
3117
- return rows[0]?.name?.toLowerCase() === "user";
3200
+ return rows[0]?.name?.toLowerCase() === "contributor";
3201
+ }
3202
+ async function roleName(roleId) {
3203
+ if (!roleId)
3204
+ return "";
3205
+ const { rows } = await pool_default.query("SELECT name FROM plank_roles WHERE id = $1", [roleId]);
3206
+ return rows[0]?.name?.toLowerCase() ?? "";
3118
3207
  }
3119
3208
  var listEntries = async (req, res) => {
3120
3209
  const ct = await findContentTypeBySlug(req.params.slug);
@@ -3124,7 +3213,6 @@ var listEntries = async (req, res) => {
3124
3213
  }
3125
3214
  assertSafeIdentifier(ct.tableName);
3126
3215
  const quotedTableName = quoteIdentifier(ct.tableName);
3127
- const isUser = await isUserRole(req.user?.roleId);
3128
3216
  const page = Math.max(1, parseInt(String(req.query.page ?? 1)));
3129
3217
  const limit = Math.min(100, Math.max(1, parseInt(String(req.query.limit ?? 20))));
3130
3218
  const offset = (page - 1) * limit;
@@ -3135,19 +3223,15 @@ var listEntries = async (req, res) => {
3135
3223
  const quotedSortField = quoteIdentifier(sortField);
3136
3224
  const locale = req.query.locale ? String(req.query.locale) : void 0;
3137
3225
  const fallbacks = req.query.fallback ? String(req.query.fallback).split(",") : [];
3138
- const ownCollectionOnly = isUser && ct.kind === "collection";
3139
- const whereClause = ownCollectionOnly ? "WHERE e.created_by = $3" : "";
3140
- const countWhereClause = ownCollectionOnly ? "WHERE created_by = $1" : "";
3141
- const listValues = ownCollectionOnly ? [limit, offset, req.user?.id ?? null] : [limit, offset];
3142
- const countValues = ownCollectionOnly ? [req.user?.id ?? null] : [];
3143
3226
  const [{ rows }, { rows: countRows }] = await Promise.all([
3144
- pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url
3227
+ pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url,
3228
+ ed.first_name AS _editor_first_name, ed.last_name AS _editor_last_name, ed.avatar_url AS _editor_avatar_url
3145
3229
  FROM ${quotedTableName} e
3146
3230
  LEFT JOIN plank_users u ON u.id = e.created_by
3147
- ${whereClause}
3231
+ LEFT JOIN plank_users ed ON ed.id = e.editor_id
3148
3232
  ORDER BY e.${quotedSortField} ${sortDir}
3149
- LIMIT $1 OFFSET $2`, listValues),
3150
- pool_default.query(`SELECT COUNT(*) as count FROM ${quotedTableName} ${countWhereClause}`, countValues)
3233
+ LIMIT $1 OFFSET $2`, [limit, offset]),
3234
+ pool_default.query(`SELECT COUNT(*) as count FROM ${quotedTableName}`)
3151
3235
  ]);
3152
3236
  const provider = await getProvider();
3153
3237
  function entryMatchesLocale(row, locale2) {
@@ -3155,7 +3239,7 @@ var listEntries = async (req, res) => {
3155
3239
  return true;
3156
3240
  const localized = row.localized && typeof row.localized === "object" ? row.localized : {};
3157
3241
  const locales = Object.keys(localized).filter((k3) => !k3.startsWith("_"));
3158
- const meta = localized._meta || {};
3242
+ const meta = localized._meta ?? {};
3159
3243
  const enabled = meta.enabled ?? locales.length > 0;
3160
3244
  const primary = meta.primary;
3161
3245
  if (enabled) {
@@ -3171,6 +3255,10 @@ var listEntries = async (req, res) => {
3171
3255
  if (key && !key.startsWith("http")) {
3172
3256
  resolved._author_avatar_url = await provider.getUrl(key);
3173
3257
  }
3258
+ const editorKey = resolved._editor_avatar_url;
3259
+ if (editorKey && !editorKey.startsWith("http")) {
3260
+ resolved._editor_avatar_url = await provider.getUrl(editorKey);
3261
+ }
3174
3262
  return normalizeNavigationFields({ ...resolved, ...mmIds }, ct.fields);
3175
3263
  }));
3176
3264
  let total = parseInt(countRows[0].count);
@@ -3179,7 +3267,7 @@ var listEntries = async (req, res) => {
3179
3267
  const { rows: allRows } = await pool_default.query(`SELECT localized FROM ${quotedTableName}`);
3180
3268
  const matching = allRows.filter((r2) => entryMatchesLocale(r2, locale));
3181
3269
  total = matching.length;
3182
- } catch (err) {
3270
+ } catch {
3183
3271
  }
3184
3272
  }
3185
3273
  res.json({ data, total, page, limit });
@@ -3204,16 +3292,16 @@ var getEntry = async (req, res) => {
3204
3292
  }
3205
3293
  assertSafeIdentifier(ct.tableName);
3206
3294
  const quotedTableName = quoteIdentifier(ct.tableName);
3207
- const isUser = await isUserRole(req.user?.roleId);
3208
- const { rows } = await pool_default.query(`SELECT * FROM ${quotedTableName} WHERE id = $1`, [req.params.id]);
3295
+ const { rows } = await pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url,
3296
+ ed.first_name AS _editor_first_name, ed.last_name AS _editor_last_name, ed.avatar_url AS _editor_avatar_url
3297
+ FROM ${quotedTableName} e
3298
+ LEFT JOIN plank_users u ON u.id = e.created_by
3299
+ LEFT JOIN plank_users ed ON ed.id = e.editor_id
3300
+ WHERE e.id = $1`, [req.params.id]);
3209
3301
  if (!rows[0]) {
3210
3302
  res.status(404).json({ error: "Entry not found" });
3211
3303
  return;
3212
3304
  }
3213
- if (isUser && ct.kind === "collection" && rows[0].created_by !== req.user?.id) {
3214
- res.status(403).json({ error: "Forbidden" });
3215
- return;
3216
- }
3217
3305
  const locale = req.query.locale ? String(req.query.locale) : void 0;
3218
3306
  const fallbacks = req.query.fallback ? String(req.query.fallback).split(",") : [];
3219
3307
  const mmIds = await loadManyToManyIds(req.params.id, ct.tableName, ct.fields);
@@ -3222,6 +3310,9 @@ var getEntry = async (req, res) => {
3222
3310
  const key = resolved._author_avatar_url;
3223
3311
  if (key && !key.startsWith("http"))
3224
3312
  resolved._author_avatar_url = await provider.getUrl(key);
3313
+ const editorKey = resolved._editor_avatar_url;
3314
+ if (editorKey && !editorKey.startsWith("http"))
3315
+ resolved._editor_avatar_url = await provider.getUrl(editorKey);
3225
3316
  res.json(normalizeNavigationFields({ ...resolved, ...mmIds }, ct.fields));
3226
3317
  };
3227
3318
  var createEntry = async (req, res) => {
@@ -3233,9 +3324,9 @@ var createEntry = async (req, res) => {
3233
3324
  validate(ct, req.body);
3234
3325
  assertSafeIdentifier(ct.tableName);
3235
3326
  const quotedTableName = quoteIdentifier(ct.tableName);
3236
- const isUser = await isUserRole(req.user?.roleId);
3237
- if (isUser && ct.kind === "single") {
3238
- res.status(403).json({ error: "Single types are read-only for User role" });
3327
+ const isContributor = await isContributorRole(req.user?.roleId);
3328
+ if (isContributor && ct.kind === "single") {
3329
+ res.status(403).json({ error: "Single types are read-only for Contributor role" });
3239
3330
  return;
3240
3331
  }
3241
3332
  const mmFields = ct.fields.filter((f2) => f2.type === "relation" && (f2.relationType ?? "many-to-one") === "many-to-many" && req.body[f2.name] !== void 0);
@@ -3343,13 +3434,16 @@ var updateEntry = async (req, res) => {
3343
3434
  validate(ct, req.body);
3344
3435
  assertSafeIdentifier(ct.tableName);
3345
3436
  const quotedTableName = quoteIdentifier(ct.tableName);
3346
- const isUser = await isUserRole(req.user?.roleId);
3347
- if (isUser && ct.kind === "single") {
3348
- res.status(403).json({ error: "Single types are read-only for User role" });
3437
+ const editorialMode = req.appModes?.editorial ?? false;
3438
+ const currentRole = await roleName(req.user?.roleId);
3439
+ const isContributor = currentRole === "contributor";
3440
+ const isEditor = currentRole === "editor";
3441
+ if (isContributor && ct.kind === "single") {
3442
+ res.status(403).json({ error: "Single types are read-only for Contributor role" });
3349
3443
  return;
3350
3444
  }
3351
- if (isUser && ct.kind === "collection") {
3352
- const { rows: authorRows } = await pool_default.query(`SELECT created_by FROM ${quotedTableName} WHERE id = $1`, [req.params.id]);
3445
+ if ((isContributor || isEditor) && ct.kind === "collection") {
3446
+ const { rows: authorRows } = await pool_default.query(`SELECT created_by, status FROM ${quotedTableName} WHERE id = $1`, [req.params.id]);
3353
3447
  if (!authorRows[0]) {
3354
3448
  res.status(404).json({ error: "Entry not found" });
3355
3449
  return;
@@ -3358,6 +3452,10 @@ var updateEntry = async (req, res) => {
3358
3452
  res.status(403).json({ error: "Forbidden" });
3359
3453
  return;
3360
3454
  }
3455
+ if (editorialMode && isContributor && authorRows[0].status === "in_review") {
3456
+ res.status(403).json({ error: "Entry is currently in review and locked for contributor edits" });
3457
+ return;
3458
+ }
3361
3459
  }
3362
3460
  const mmFields = ct.fields.filter((f2) => f2.type === "relation" && (f2.relationType ?? "many-to-one") === "many-to-many" && req.body[f2.name] !== void 0);
3363
3461
  const fields = ct.fields.filter((f2) => req.body[f2.name] !== void 0 && !isVirtualRelation(f2));
@@ -3407,9 +3505,9 @@ function buildSnapshotExpr(tableName) {
3407
3505
  return `(SELECT ${strip} FROM ${quoteIdentifier(tableName)} t WHERE t.id = $1)`;
3408
3506
  }
3409
3507
  var patchEntryStatus = async (req, res) => {
3410
- const { status, scheduled_for } = req.body;
3411
- if (status !== "draft" && status !== "published" && status !== "scheduled") {
3412
- res.status(400).json({ error: "status must be draft, published, or scheduled" });
3508
+ const { status, scheduled_for, editor_id, review_locked_by_editor, review_rejected } = req.body;
3509
+ if (status !== "draft" && status !== "published" && status !== "scheduled" && status !== "pending" && status !== "in_review") {
3510
+ res.status(400).json({ error: "status must be draft, published, scheduled, pending, or in_review" });
3413
3511
  return;
3414
3512
  }
3415
3513
  if (status === "scheduled") {
@@ -3429,13 +3527,17 @@ var patchEntryStatus = async (req, res) => {
3429
3527
  }
3430
3528
  assertSafeIdentifier(ct.tableName);
3431
3529
  const quotedTableName = quoteIdentifier(ct.tableName);
3432
- const isUser = await isUserRole(req.user?.roleId);
3433
- if (isUser && ct.kind === "single") {
3434
- res.status(403).json({ error: "Single types are read-only for User role" });
3530
+ const editorialMode = req.appModes?.editorial ?? false;
3531
+ const currentRole = await roleName(req.user?.roleId);
3532
+ const isContributor = currentRole === "contributor";
3533
+ const isAdminRole = currentRole === "admin" || currentRole === "super admin";
3534
+ const isEditorRole = currentRole === "editor";
3535
+ if (isContributor && ct.kind === "single") {
3536
+ res.status(403).json({ error: "Single types are read-only for Contributor role" });
3435
3537
  return;
3436
3538
  }
3437
- if (isUser && ct.kind === "collection") {
3438
- const { rows: authorRows } = await pool_default.query(`SELECT created_by FROM ${quotedTableName} WHERE id = $1`, [req.params.id]);
3539
+ if (isContributor && ct.kind === "collection") {
3540
+ const { rows: authorRows } = await pool_default.query(`SELECT created_by, review_locked_by_editor FROM ${quotedTableName} WHERE id = $1`, [req.params.id]);
3439
3541
  if (!authorRows[0]) {
3440
3542
  res.status(404).json({ error: "Entry not found" });
3441
3543
  return;
@@ -3444,6 +3546,18 @@ var patchEntryStatus = async (req, res) => {
3444
3546
  res.status(403).json({ error: "Forbidden" });
3445
3547
  return;
3446
3548
  }
3549
+ if (editorialMode && authorRows[0].review_locked_by_editor && status !== "pending") {
3550
+ res.status(403).json({ error: "Entry is currently locked for contributor edits" });
3551
+ return;
3552
+ }
3553
+ }
3554
+ if (editorialMode && isContributor && status === "published") {
3555
+ res.status(403).json({ error: "Contributors cannot publish in editorial mode" });
3556
+ return;
3557
+ }
3558
+ if (editorialMode && isContributor && status === "scheduled") {
3559
+ res.status(403).json({ error: "Contributors cannot schedule in editorial mode" });
3560
+ return;
3447
3561
  }
3448
3562
  let sql;
3449
3563
  let values;
@@ -3454,6 +3568,7 @@ var patchEntryStatus = async (req, res) => {
3454
3568
  published_data = ${buildSnapshotExpr(ct.tableName)},
3455
3569
  published_at = NOW(),
3456
3570
  scheduled_for = NULL,
3571
+ review_rejected = FALSE,
3457
3572
  updated_at = NOW()
3458
3573
  WHERE id = $1
3459
3574
  RETURNING *
@@ -3464,11 +3579,66 @@ var patchEntryStatus = async (req, res) => {
3464
3579
  UPDATE ${quotedTableName} SET
3465
3580
  status = 'scheduled',
3466
3581
  scheduled_for = $2,
3582
+ review_rejected = FALSE,
3467
3583
  updated_at = NOW()
3468
3584
  WHERE id = $1
3469
3585
  RETURNING *
3470
3586
  `;
3471
3587
  values = [req.params.id, scheduled_for];
3588
+ } else if (status === "pending") {
3589
+ sql = `
3590
+ UPDATE ${quotedTableName} SET
3591
+ status = 'pending',
3592
+ review_rejected = COALESCE($2, FALSE),
3593
+ review_locked_by_editor = FALSE,
3594
+ updated_at = NOW()
3595
+ WHERE id = $1
3596
+ RETURNING *
3597
+ `;
3598
+ values = [req.params.id, typeof review_rejected === "boolean" ? review_rejected : false];
3599
+ } else if (status === "in_review") {
3600
+ if (!editorialMode) {
3601
+ res.status(403).json({ error: "In review status requires editorial mode" });
3602
+ return;
3603
+ }
3604
+ if (!isAdminRole && !isEditorRole) {
3605
+ res.status(403).json({ error: "Forbidden" });
3606
+ return;
3607
+ }
3608
+ const requestedEditorId = typeof editor_id === "string" && editor_id.trim().length > 0 ? editor_id : isEditorRole ? req.user?.id ?? null : null;
3609
+ if (isEditorRole && requestedEditorId && requestedEditorId !== req.user?.id) {
3610
+ res.status(403).json({ error: "Editors can only assign themselves" });
3611
+ return;
3612
+ }
3613
+ if (requestedEditorId) {
3614
+ const { rows: editorRows } = await pool_default.query(`SELECT r.name as role_name
3615
+ FROM plank_users u
3616
+ JOIN plank_roles r ON r.id = u.role_id
3617
+ WHERE u.id = $1`, [requestedEditorId]);
3618
+ const targetRole = editorRows[0]?.role_name?.toLowerCase();
3619
+ if (isAdminRole) {
3620
+ if (requestedEditorId !== req.user?.id && targetRole !== "editor") {
3621
+ res.status(403).json({ error: "Admins can assign only themselves or Editors" });
3622
+ return;
3623
+ }
3624
+ } else if (targetRole !== "editor") {
3625
+ res.status(403).json({ error: "Invalid editor assignee" });
3626
+ return;
3627
+ }
3628
+ }
3629
+ const nextEditorId = requestedEditorId ?? (isEditorRole ? req.user?.id ?? null : null);
3630
+ const lock = typeof review_locked_by_editor === "boolean" ? review_locked_by_editor : false;
3631
+ sql = `
3632
+ UPDATE ${quotedTableName} SET
3633
+ status = 'in_review',
3634
+ editor_id = COALESCE($2, editor_id),
3635
+ review_locked_by_editor = $3,
3636
+ review_rejected = FALSE,
3637
+ updated_at = NOW()
3638
+ WHERE id = $1
3639
+ RETURNING *
3640
+ `;
3641
+ values = [req.params.id, nextEditorId, lock];
3472
3642
  } else {
3473
3643
  sql = `
3474
3644
  UPDATE ${quotedTableName} SET
@@ -3476,6 +3646,8 @@ var patchEntryStatus = async (req, res) => {
3476
3646
  published_data = NULL,
3477
3647
  published_at = NULL,
3478
3648
  scheduled_for = NULL,
3649
+ review_rejected = FALSE,
3650
+ review_locked_by_editor = FALSE,
3479
3651
  updated_at = NOW()
3480
3652
  WHERE id = $1
3481
3653
  RETURNING *
@@ -3500,12 +3672,14 @@ var deleteEntry = async (req, res) => {
3500
3672
  }
3501
3673
  assertSafeIdentifier(ct.tableName);
3502
3674
  const quotedTableName = quoteIdentifier(ct.tableName);
3503
- const isUser = await isUserRole(req.user?.roleId);
3504
- if (isUser && ct.kind === "single") {
3505
- res.status(403).json({ error: "Single types are read-only for User role" });
3675
+ const currentRole = await roleName(req.user?.roleId);
3676
+ const isContributor = currentRole === "contributor";
3677
+ const isEditor = currentRole === "editor";
3678
+ if (isContributor && ct.kind === "single") {
3679
+ res.status(403).json({ error: "Single types are read-only for Contributor role" });
3506
3680
  return;
3507
3681
  }
3508
- if (isUser && ct.kind === "collection") {
3682
+ if ((isContributor || isEditor) && ct.kind === "collection") {
3509
3683
  const { rows: authorRows } = await pool_default.query(`SELECT created_by FROM ${quotedTableName} WHERE id = $1`, [req.params.id]);
3510
3684
  if (!authorRows[0]) {
3511
3685
  res.status(404).json({ error: "Entry not found" });
@@ -3534,13 +3708,15 @@ import { z as z4, flattenError as flattenError4 } from "zod";
3534
3708
  var CreateUserSchema = z4.object({
3535
3709
  email: z4.email(),
3536
3710
  password: z4.string().min(8),
3537
- roleId: z4.string().min(1)
3711
+ roleId: z4.string().min(1),
3712
+ enabled: z4.boolean().optional()
3538
3713
  });
3539
3714
  var UpdateUserSchema = z4.object({
3540
3715
  email: z4.email().optional(),
3541
3716
  roleId: z4.string().min(1).optional(),
3542
3717
  firstName: z4.string().max(100).nullable().optional(),
3543
- lastName: z4.string().max(100).nullable().optional()
3718
+ lastName: z4.string().max(100).nullable().optional(),
3719
+ enabled: z4.boolean().optional()
3544
3720
  });
3545
3721
  var ChangePasswordSchema = z4.object({
3546
3722
  currentPassword: z4.string().min(1),
@@ -3594,7 +3770,7 @@ async function roleNameById(roleId) {
3594
3770
  return rows[0]?.name ?? null;
3595
3771
  }
3596
3772
  async function listUsers(_req, res) {
3597
- const { rows } = await pool_default.query(`SELECT u.id, u.email, u.role_id, r.name as role_name, u.first_name, u.last_name, u.created_at
3773
+ const { rows } = await pool_default.query(`SELECT u.id, u.email, u.role_id, r.name as role_name, u.first_name, u.last_name, u.enabled, u.created_at
3598
3774
  FROM plank_users u
3599
3775
  JOIN plank_roles r ON r.id = u.role_id
3600
3776
  ORDER BY u.created_at DESC`);
@@ -3602,7 +3778,7 @@ async function listUsers(_req, res) {
3602
3778
  }
3603
3779
  async function getMe(req, res) {
3604
3780
  const { rows } = await pool_default.query(`SELECT u.id, u.email, u.role_id, u.first_name, u.last_name, u.avatar_url,
3605
- u.job_title, u.organization, u.country, u.two_factor_enabled, u.created_at,
3781
+ u.job_title, u.organization, u.country, u.two_factor_enabled, u.enabled, u.created_at,
3606
3782
  r.name AS role_name, r.permissions
3607
3783
  FROM plank_users u
3608
3784
  JOIN plank_roles r ON r.id = u.role_id
@@ -3612,11 +3788,14 @@ async function getMe(req, res) {
3612
3788
  return;
3613
3789
  }
3614
3790
  const resolved = await resolveAvatarUrl(rows[0]);
3791
+ const modes = req.appModes ?? await resolveAppModes();
3615
3792
  res.json({
3616
3793
  ...resolved,
3617
3794
  role: rows[0].role_name,
3618
3795
  permissions: rows[0].permissions,
3619
- two_factor_enabled: rows[0].two_factor_enabled
3796
+ enabled: rows[0].enabled ?? true,
3797
+ two_factor_enabled: rows[0].two_factor_enabled,
3798
+ modes
3620
3799
  });
3621
3800
  }
3622
3801
  async function getTwoFactorStatus(req, res) {
@@ -3831,17 +4010,20 @@ async function createUser(req, res) {
3831
4010
  res.status(400).json({ errors: flattenError4(parsed.error, (i2) => i2.message) });
3832
4011
  return;
3833
4012
  }
3834
- const { email, password, roleId } = parsed.data;
4013
+ const { email, password, roleId, enabled } = parsed.data;
3835
4014
  const requesterRoleName = await roleNameById(req.user.roleId);
3836
4015
  const targetRoleName = await roleNameById(roleId);
3837
4016
  if (targetRoleName === "Super Admin" && requesterRoleName !== "Super Admin") {
3838
4017
  res.status(403).json({ error: "Only Super Admin can assign Super Admin role" });
3839
4018
  return;
3840
4019
  }
4020
+ const editorialMode = req.appModes?.editorial ?? false;
4021
+ const isEditorialExclusiveRole = ["Editor", "Viewer"].includes(targetRoleName ?? "");
4022
+ const nextEnabled = editorialMode || !isEditorialExclusiveRole ? enabled ?? true : false;
3841
4023
  const hashed = await bcrypt2.hash(password, 12);
3842
4024
  const id = createId();
3843
- await pool_default.query("INSERT INTO plank_users (id, email, password, role_id) VALUES ($1, $2, $3, $4)", [id, email, hashed, roleId]);
3844
- res.status(201).json({ id, email, roleId });
4025
+ await pool_default.query("INSERT INTO plank_users (id, email, password, role_id, enabled) VALUES ($1, $2, $3, $4, $5)", [id, email, hashed, roleId, nextEnabled]);
4026
+ res.status(201).json({ id, email, roleId, enabled: nextEnabled });
3845
4027
  }
3846
4028
  async function updateUser(req, res) {
3847
4029
  const parsed = UpdateUserSchema.safeParse(req.body);
@@ -3849,7 +4031,7 @@ async function updateUser(req, res) {
3849
4031
  res.status(400).json({ errors: flattenError4(parsed.error, (i2) => i2.message) });
3850
4032
  return;
3851
4033
  }
3852
- const { email, roleId, firstName, lastName } = parsed.data;
4034
+ const { email, roleId, firstName, lastName, enabled } = parsed.data;
3853
4035
  const requesterRoleName = await roleNameById(req.user.roleId);
3854
4036
  const { rows: targetRows } = await pool_default.query("SELECT role_id FROM plank_users WHERE id = $1", [req.params.id]);
3855
4037
  if (!targetRows[0]) {
@@ -3861,20 +4043,30 @@ async function updateUser(req, res) {
3861
4043
  res.status(403).json({ error: "Only Super Admin can edit Super Admin users" });
3862
4044
  return;
3863
4045
  }
4046
+ const editorialMode = req.appModes?.editorial ?? false;
4047
+ let resolvedEnabled = enabled;
3864
4048
  if (roleId) {
3865
4049
  const nextRoleName = await roleNameById(roleId);
3866
4050
  if (nextRoleName === "Super Admin" && requesterRoleName !== "Super Admin") {
3867
4051
  res.status(403).json({ error: "Only Super Admin can assign Super Admin role" });
3868
4052
  return;
3869
4053
  }
4054
+ if (!editorialMode && ["Editor", "Viewer"].includes(nextRoleName ?? "")) {
4055
+ resolvedEnabled = false;
4056
+ }
3870
4057
  }
3871
4058
  const { rows } = await pool_default.query(`UPDATE plank_users
3872
4059
  SET email = COALESCE($1, email),
3873
4060
  role_id = COALESCE($2, role_id),
3874
4061
  first_name = COALESCE($3, first_name),
3875
- last_name = COALESCE($4, last_name)
3876
- WHERE id = $5
3877
- RETURNING id, email, role_id, first_name, last_name, created_at`, [email ?? null, roleId ?? null, firstName ?? null, lastName ?? null, req.params.id]);
4062
+ last_name = COALESCE($4, last_name),
4063
+ enabled = COALESCE($5, enabled),
4064
+ session_version = CASE
4065
+ WHEN $5 IS NOT NULL AND $5 = FALSE AND enabled = TRUE THEN session_version + 1
4066
+ ELSE session_version
4067
+ END
4068
+ WHERE id = $6
4069
+ RETURNING id, email, role_id, first_name, last_name, enabled, created_at`, [email ?? null, roleId ?? null, firstName ?? null, lastName ?? null, resolvedEnabled ?? null, req.params.id]);
3878
4070
  if (!rows[0]) {
3879
4071
  res.status(404).json({ error: "User not found" });
3880
4072
  return;
@@ -4222,6 +4414,14 @@ async function getNamespaceSettings(req, res) {
4222
4414
  const settings = await getSettings(namespace);
4223
4415
  res.json(maskSettings(namespace, settings));
4224
4416
  }
4417
+ async function getAppModes(req, res) {
4418
+ const modes = req.appModes ?? await resolveAppModes();
4419
+ res.json(modes);
4420
+ }
4421
+ async function getEditorialMode(_req, res) {
4422
+ const { editorial: enabled } = await resolveAppModes();
4423
+ res.json({ enabled });
4424
+ }
4225
4425
  async function updateNamespaceSettings(req, res) {
4226
4426
  const { namespace } = req.params;
4227
4427
  const incoming = req.body;
@@ -4237,6 +4437,13 @@ async function updateNamespaceSettings(req, res) {
4237
4437
  toSave[key] = value;
4238
4438
  }
4239
4439
  await setSettings(namespace, toSave);
4440
+ if (namespace === "general" && Object.prototype.hasOwnProperty.call(toSave, "editorial_mode") && String(toSave.editorial_mode).toLowerCase() === "false") {
4441
+ await pool_default.query(`UPDATE plank_users
4442
+ SET enabled = FALSE, session_version = session_version + 1
4443
+ WHERE role_id IN (
4444
+ SELECT id FROM plank_roles WHERE LOWER(name) IN ('editor', 'viewer')
4445
+ )`);
4446
+ }
4240
4447
  const updated = await getSettings(namespace);
4241
4448
  res.json(maskSettings(namespace, updated));
4242
4449
  }
@@ -4244,6 +4451,7 @@ async function updateNamespaceSettings(req, res) {
4244
4451
  // ../core/dist/routes/admin.js
4245
4452
  var router2 = Router2();
4246
4453
  router2.use(authenticate);
4454
+ router2.use(attachAppModes);
4247
4455
  router2.get("/content-types", authorize("content-types:read"), listContentTypes);
4248
4456
  router2.post("/content-types", authorize("content-types:write"), createContentType);
4249
4457
  router2.get("/content-types/:slug", authorize("content-types:read"), getContentType);
@@ -4292,6 +4500,8 @@ router2.post("/media", authorize("media:write"), upload.array("files", 500), upl
4292
4500
  router2.get("/media/:id/url", authorize("media:read"), getMediaUrl);
4293
4501
  router2.patch("/media/:id", authorize("media:write"), updateMedia);
4294
4502
  router2.delete("/media/:id", authorize("media:delete"), deleteMedia);
4503
+ router2.get("/modes", getAppModes);
4504
+ router2.get("/editorial-mode", getEditorialMode);
4295
4505
  router2.get("/settings/:namespace", authorize("settings:overview:read"), getNamespaceSettings);
4296
4506
  router2.put("/settings/:namespace", authorize("settings:overview:write"), updateNamespaceSettings);
4297
4507
  router2.get("/webhooks", authorize("settings:webhooks:read"), listWebhooks);
@@ -4353,6 +4563,29 @@ function normalizeNavigationItems2(value) {
4353
4563
  return normalized;
4354
4564
  });
4355
4565
  }
4566
+ function normalizeArrayItemsBySchema(value, field) {
4567
+ if (field.type !== "array" || !Array.isArray(value))
4568
+ return value;
4569
+ const subFields = field.arrayFields ?? [];
4570
+ if (subFields.length === 0)
4571
+ return value;
4572
+ return value.map((item) => {
4573
+ if (typeof item !== "object" || item === null || Array.isArray(item))
4574
+ return item;
4575
+ const raw = item;
4576
+ const normalized = {};
4577
+ for (const subField of subFields) {
4578
+ if (subField.name in raw)
4579
+ normalized[subField.name] = raw[subField.name];
4580
+ }
4581
+ for (const [key, val] of Object.entries(raw)) {
4582
+ if (key in normalized)
4583
+ continue;
4584
+ normalized[key] = val;
4585
+ }
4586
+ return normalized;
4587
+ });
4588
+ }
4356
4589
  async function resolveMediaFields(entries, ct) {
4357
4590
  const singleFields = ct.fields.filter((f2) => f2.type === "media").map((f2) => f2.name);
4358
4591
  const galleryFields = ct.fields.filter((f2) => f2.type === "media-gallery").map((f2) => f2.name);
@@ -4465,14 +4698,19 @@ async function resolveAuthorAvatars(entries) {
4465
4698
  if (author?.avatar_url && !author.avatar_url.startsWith("http")) {
4466
4699
  author.avatar_url = await provider.getUrl(author.avatar_url);
4467
4700
  }
4701
+ const editor = entry.editor;
4702
+ if (editor?.avatar_url && !editor.avatar_url.startsWith("http")) {
4703
+ editor.avatar_url = await provider.getUrl(editor.avatar_url);
4704
+ }
4468
4705
  }));
4469
4706
  }
4470
4707
  function serializeEntry(row, ct, statusParam, locale, fallbacks = []) {
4471
- const { published_data, _author_first_name, _author_last_name, _author_avatar_url, _author_job_title, _author_organization, _author_country, ...rest } = row;
4708
+ const { published_data, _author_first_name, _author_last_name, _author_avatar_url, _author_job_title, _author_organization, _author_country, _editor_first_name, _editor_last_name, _editor_avatar_url, _editor_job_title, _editor_organization, _editor_country, ...rest } = row;
4472
4709
  const source = statusParam === "published" && published_data ? published_data : rest;
4473
4710
  const effective = { ...source };
4474
4711
  if (locale) {
4475
- const localizedContainer = source && typeof source === "object" && source.localized && typeof source.localized === "object" ? source.localized : row.localized && typeof row.localized === "object" ? row.localized : {};
4712
+ const sourceObj = source;
4713
+ const localizedContainer = source && typeof source === "object" && sourceObj.localized && typeof sourceObj.localized === "object" ? sourceObj.localized : row.localized && typeof row.localized === "object" ? row.localized : {};
4476
4714
  const localizableTypes = /* @__PURE__ */ new Set(["string", "text", "richtext", "uid"]);
4477
4715
  for (const f2 of ct.fields) {
4478
4716
  if (!localizableTypes.has(f2.type))
@@ -4496,7 +4734,15 @@ function serializeEntry(row, ct, statusParam, locale, fallbacks = []) {
4496
4734
  for (const field of ct.fields) {
4497
4735
  if (!(field.name in effective))
4498
4736
  continue;
4499
- out[field.name] = field.type === "navigation" ? normalizeNavigationItems2(effective[field.name]) : effective[field.name];
4737
+ if (field.type === "navigation") {
4738
+ out[field.name] = normalizeNavigationItems2(effective[field.name]);
4739
+ continue;
4740
+ }
4741
+ if (field.type === "array") {
4742
+ out[field.name] = normalizeArrayItemsBySchema(effective[field.name], field);
4743
+ continue;
4744
+ }
4745
+ out[field.name] = effective[field.name];
4500
4746
  }
4501
4747
  out.status = row.status;
4502
4748
  out.published_at = row.published_at ?? null;
@@ -4510,6 +4756,14 @@ function serializeEntry(row, ct, statusParam, locale, fallbacks = []) {
4510
4756
  organization: _author_organization ?? null,
4511
4757
  country: _author_country ?? null
4512
4758
  } : null;
4759
+ out.editor = _editor_first_name || _editor_last_name ? {
4760
+ first_name: _editor_first_name ?? null,
4761
+ last_name: _editor_last_name ?? null,
4762
+ avatar_url: _editor_avatar_url ?? null,
4763
+ job_title: _editor_job_title ?? null,
4764
+ organization: _editor_organization ?? null,
4765
+ country: _editor_country ?? null
4766
+ } : null;
4513
4767
  return out;
4514
4768
  }
4515
4769
  var listPublicEntries = async (req, res) => {
@@ -4525,9 +4779,11 @@ var listPublicEntries = async (req, res) => {
4525
4779
  const fallbacks2 = req.query.fallback ? String(req.query.fallback).split(",") : [];
4526
4780
  const statusClause = statusParam2 === "published" || statusParam2 === "draft" ? `WHERE e.status = $1` : "";
4527
4781
  const values = statusClause ? [statusParam2] : [];
4528
- const { rows: rows2 } = await pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url, u.job_title AS _author_job_title, u.organization AS _author_organization, u.country AS _author_country
4782
+ const { rows: rows2 } = await pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url, u.job_title AS _author_job_title, u.organization AS _author_organization, u.country AS _author_country,
4783
+ ed.first_name AS _editor_first_name, ed.last_name AS _editor_last_name, ed.avatar_url AS _editor_avatar_url, ed.job_title AS _editor_job_title, ed.organization AS _editor_organization, ed.country AS _editor_country
4529
4784
  FROM ${ct.tableName} e
4530
4785
  LEFT JOIN plank_users u ON u.id = e.created_by
4786
+ LEFT JOIN plank_users ed ON ed.id = e.editor_id
4531
4787
  ${statusClause} LIMIT 1`, values);
4532
4788
  if (!rows2[0]) {
4533
4789
  res.status(404).json({ error: "Not found" });
@@ -4573,9 +4829,11 @@ var listPublicEntries = async (req, res) => {
4573
4829
  const limitParam = filterValues.length + 1;
4574
4830
  const offsetParam = filterValues.length + 2;
4575
4831
  const [{ rows }, { rows: countRows }] = await Promise.all([
4576
- pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url, u.job_title AS _author_job_title, u.organization AS _author_organization, u.country AS _author_country
4832
+ pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url, u.job_title AS _author_job_title, u.organization AS _author_organization, u.country AS _author_country,
4833
+ ed.first_name AS _editor_first_name, ed.last_name AS _editor_last_name, ed.avatar_url AS _editor_avatar_url, ed.job_title AS _editor_job_title, ed.organization AS _editor_organization, ed.country AS _editor_country
4577
4834
  FROM ${ct.tableName} e
4578
4835
  LEFT JOIN plank_users u ON u.id = e.created_by
4836
+ LEFT JOIN plank_users ed ON ed.id = e.editor_id
4579
4837
  ${where} ORDER BY e.${sortField} ${sortDir} LIMIT $${limitParam} OFFSET $${offsetParam}`, [...filterValues, limit, offset]),
4580
4838
  pool_default.query(`SELECT COUNT(*) as count FROM ${ct.tableName} e ${where}`, filterValues)
4581
4839
  ]);
@@ -4597,9 +4855,11 @@ var getPublicEntry = async (req, res) => {
4597
4855
  const statusParam = String(req.query.status ?? "published");
4598
4856
  const statusClause = statusParam === "published" || statusParam === "draft" ? ` AND e.status = $2` : "";
4599
4857
  const values = statusClause ? [req.params.id, statusParam] : [req.params.id];
4600
- const { rows } = await pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url, u.job_title AS _author_job_title, u.organization AS _author_organization, u.country AS _author_country
4858
+ const { rows } = await pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url, u.job_title AS _author_job_title, u.organization AS _author_organization, u.country AS _author_country,
4859
+ ed.first_name AS _editor_first_name, ed.last_name AS _editor_last_name, ed.avatar_url AS _editor_avatar_url, ed.job_title AS _editor_job_title, ed.organization AS _editor_organization, ed.country AS _editor_country
4601
4860
  FROM ${ct.tableName} e
4602
4861
  LEFT JOIN plank_users u ON u.id = e.created_by
4862
+ LEFT JOIN plank_users ed ON ed.id = e.editor_id
4603
4863
  WHERE e.id = $1${statusClause}`, values);
4604
4864
  if (!rows[0]) {
4605
4865
  res.status(404).json({ error: "Not found" });