@dyrected/core 2.16.0 → 2.18.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.
package/dist/server.js CHANGED
@@ -6084,6 +6084,72 @@ function createReadonlyDb(db) {
6084
6084
  });
6085
6085
  }
6086
6086
 
6087
+ // src/utils/errors.ts
6088
+ var DyrectedError = class _DyrectedError extends Error {
6089
+ statusCode;
6090
+ constructor(message, statusCode = 500) {
6091
+ super(message);
6092
+ this.name = "DyrectedError";
6093
+ this.statusCode = statusCode;
6094
+ Object.setPrototypeOf(this, _DyrectedError.prototype);
6095
+ }
6096
+ };
6097
+ var ValidationError = class _ValidationError extends DyrectedError {
6098
+ constructor(message) {
6099
+ super(message, 400);
6100
+ this.name = "ValidationError";
6101
+ Object.setPrototypeOf(this, _ValidationError.prototype);
6102
+ }
6103
+ };
6104
+
6105
+ // src/utils/field-integrity.ts
6106
+ function* storedFields(fields) {
6107
+ for (const field of fields ?? []) {
6108
+ if (field.type === "row") {
6109
+ yield* storedFields(field.fields);
6110
+ } else if (field.name) {
6111
+ yield field;
6112
+ }
6113
+ }
6114
+ }
6115
+ function sameValue(a, b) {
6116
+ if (a === b) return true;
6117
+ if (a === null || b === null || a === void 0 || b === void 0) return a == b;
6118
+ if (typeof a === "object" || typeof b === "object") {
6119
+ return JSON.stringify(a) === JSON.stringify(b);
6120
+ }
6121
+ return false;
6122
+ }
6123
+ function assertImmutableFieldsUnchanged(fields, data, original) {
6124
+ for (const field of storedFields(fields)) {
6125
+ if (!field.immutable || data[field.name] === void 0) continue;
6126
+ if (!sameValue(data[field.name], original[field.name])) {
6127
+ throw new ValidationError(
6128
+ `Field "${field.label ?? field.name}" is immutable and cannot be modified.`
6129
+ );
6130
+ }
6131
+ }
6132
+ }
6133
+ function normalizeMoneyFields(fields, data) {
6134
+ let next = data;
6135
+ for (const field of storedFields(fields)) {
6136
+ if (field.type !== "money") continue;
6137
+ const value = data[field.name];
6138
+ if (value === void 0 || value === null || value === "") continue;
6139
+ let minor;
6140
+ if (typeof value === "number") minor = value;
6141
+ else if (typeof value === "string" && /^-?\d+$/.test(value.trim())) minor = Number(value.trim());
6142
+ if (minor === void 0 || !Number.isSafeInteger(minor)) {
6143
+ throw new ValidationError(
6144
+ `Field "${field.label ?? field.name}" must be a whole number of minor units (for example kobo or cents).`
6145
+ );
6146
+ }
6147
+ if (next === data) next = { ...data };
6148
+ next[field.name] = minor;
6149
+ }
6150
+ return next;
6151
+ }
6152
+
6087
6153
  // src/utils/upload-validation.ts
6088
6154
  function isMimeAllowed(mimeType, allowed) {
6089
6155
  const type = (mimeType || "").toLowerCase().trim();
@@ -6679,7 +6745,7 @@ async function dispatchLifecycleEvent(config, event) {
6679
6745
  }
6680
6746
  }
6681
6747
  async function saveWorkflowDraft(args) {
6682
- const { config, collection, id, originalDoc, data, user } = args;
6748
+ const { config, collection, id, originalDoc, data, user, beforeCommit } = args;
6683
6749
  const db = config.db;
6684
6750
  const workflow = collection.workflow;
6685
6751
  if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
@@ -6713,13 +6779,14 @@ async function saveWorkflowDraft(args) {
6713
6779
  }
6714
6780
  const updated = await tx.update({ collection: collection.slug, id, data: updateData });
6715
6781
  await persistEvent(tx, event);
6782
+ await beforeCommit?.(tx, updated);
6716
6783
  return updated;
6717
6784
  });
6718
6785
  void dispatchLifecycleEvent(config, event);
6719
6786
  return { doc, event };
6720
6787
  }
6721
6788
  async function createWorkflowDocument(args) {
6722
- const { config, collection, data, user } = args;
6789
+ const { config, collection, data, user, beforeCommit } = args;
6723
6790
  const db = config.db;
6724
6791
  if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
6725
6792
  let event;
@@ -6733,13 +6800,14 @@ async function createWorkflowDocument(args) {
6733
6800
  payload: { revision: 1, previousRevision: null }
6734
6801
  });
6735
6802
  await persistEvent(tx, event);
6803
+ await beforeCommit?.(tx, created);
6736
6804
  return created;
6737
6805
  });
6738
6806
  void dispatchLifecycleEvent(config, event);
6739
6807
  return { doc, event };
6740
6808
  }
6741
6809
  async function transitionWorkflow(args) {
6742
- const { config, collection, id, transitionName, expectedRevision, comment, user, req } = args;
6810
+ const { config, collection, id, transitionName, expectedRevision, comment, input, user, req } = args;
6743
6811
  const db = config.db;
6744
6812
  const workflow = collection.workflow;
6745
6813
  if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
@@ -6765,7 +6833,7 @@ async function transitionWorkflow(args) {
6765
6833
  if (transition.requireComment && !comment?.trim()) {
6766
6834
  throw Object.assign(new Error(`A comment is required for "${transition.label}".`), { statusCode: 400 });
6767
6835
  }
6768
- const hookContext = { transition, from: meta.state, to: transition.to, doc: original, user, comment, req, db };
6836
+ const hookContext = { transition, from: meta.state, to: transition.to, doc: original, user, comment, input, req, db };
6769
6837
  for (const hook of workflow.hooks?.beforeTransition ?? []) await hook(hookContext);
6770
6838
  const events = [
6771
6839
  createLifecycleEvent({
@@ -6819,6 +6887,19 @@ async function transitionWorkflow(args) {
6819
6887
  if (targetState.published) data.__published = working;
6820
6888
  if (transition.unpublish) data.__published = null;
6821
6889
  const next = await tx.update({ collection: collection.slug, id, data });
6890
+ if (transition.onTransition) {
6891
+ await transition.onTransition({
6892
+ transition,
6893
+ from: lockedMeta.state,
6894
+ to: transition.to,
6895
+ doc: locked,
6896
+ input,
6897
+ comment,
6898
+ user,
6899
+ req,
6900
+ tx
6901
+ });
6902
+ }
6822
6903
  await tx.create({
6823
6904
  collection: WORKFLOW_HISTORY_COLLECTION,
6824
6905
  data: {
@@ -7458,6 +7539,7 @@ var CollectionController = class {
7458
7539
  updatedBy: user?.sub ?? null
7459
7540
  };
7460
7541
  data = DefaultsService.apply(this.collection.fields, data);
7542
+ data = normalizeMoneyFields(this.collection.fields, data);
7461
7543
  if (this.collection.workflow) {
7462
7544
  data = initializeWorkflowDocument(data, this.collection.workflow);
7463
7545
  }
@@ -7505,12 +7587,18 @@ var CollectionController = class {
7505
7587
  if (!data.id) {
7506
7588
  data = { ...data, id: generateDocumentId(this.collection) };
7507
7589
  }
7590
+ const beforeCommit = this.buildBeforeCommit(c, "create", user);
7508
7591
  const doc = this.collection.workflow ? (await createWorkflowDocument({
7509
7592
  config,
7510
7593
  collection: this.collection,
7511
7594
  data,
7512
- user
7513
- })).doc : await db.create({ collection: this.collection.slug, data });
7595
+ user,
7596
+ beforeCommit
7597
+ })).doc : await this.persistInTransaction(
7598
+ db,
7599
+ beforeCommit,
7600
+ (writer) => writer.create({ collection: this.collection.slug, data })
7601
+ );
7514
7602
  if (this.collection.audit && db) {
7515
7603
  AuditService.log(db, {
7516
7604
  operation: "create",
@@ -7744,6 +7832,36 @@ var CollectionController = class {
7744
7832
  const body = await c.req.json();
7745
7833
  return this.performUpdate(c, id, body);
7746
7834
  }
7835
+ /**
7836
+ * Builds the callback that runs the collection's `beforeCommit` hooks inside
7837
+ * the write transaction, or `undefined` when the collection has none.
7838
+ */
7839
+ buildBeforeCommit(c, operation, user, previousDoc) {
7840
+ const hooks = this.collection.hooks?.beforeCommit;
7841
+ if (!hooks?.length) return void 0;
7842
+ return async (tx, doc) => {
7843
+ for (const hook of hooks) {
7844
+ await hook({ doc, previousDoc, req: this.toHookRequestContext(c), user, operation, tx });
7845
+ }
7846
+ };
7847
+ }
7848
+ /**
7849
+ * Runs a single write. When `beforeCommit` hooks exist the write and the
7850
+ * hooks share one transaction, so a throwing hook rolls the write back.
7851
+ */
7852
+ async persistInTransaction(db, beforeCommit, write) {
7853
+ if (!beforeCommit) return write(db);
7854
+ if (!db.transaction) {
7855
+ throw new Error(
7856
+ `Collection "${this.collection.slug}" defines beforeCommit hooks but the configured database adapter does not support transactions.`
7857
+ );
7858
+ }
7859
+ return db.transaction(async (tx) => {
7860
+ const doc = await write(tx);
7861
+ await beforeCommit(tx, doc);
7862
+ return doc;
7863
+ });
7864
+ }
7747
7865
  /**
7748
7866
  * Core update pipeline shared by PATCH requests and operational actions:
7749
7867
  * auth-field stripping, timestamps, access enforcement, field write access,
@@ -7785,6 +7903,7 @@ var CollectionController = class {
7785
7903
  403
7786
7904
  );
7787
7905
  }
7906
+ data = normalizeMoneyFields(this.collection.fields, data);
7788
7907
  let before = null;
7789
7908
  if (this.collection.audit) {
7790
7909
  before = originalDoc;
@@ -7818,14 +7937,21 @@ var CollectionController = class {
7818
7937
  surface: "collection.beforeChange",
7819
7938
  path: `collection:${this.collection.slug}.hooks.beforeChange`
7820
7939
  });
7940
+ assertImmutableFieldsUnchanged(this.collection.fields, data, originalDoc);
7941
+ const beforeCommit = this.buildBeforeCommit(c, "update", user, originalDoc);
7821
7942
  const doc = this.collection.workflow ? (await saveWorkflowDraft({
7822
7943
  config,
7823
7944
  collection: this.collection,
7824
7945
  id,
7825
7946
  originalDoc,
7826
7947
  data,
7827
- user
7828
- })).doc : await db.update({ collection: this.collection.slug, id, data });
7948
+ user,
7949
+ beforeCommit
7950
+ })).doc : await this.persistInTransaction(
7951
+ db,
7952
+ beforeCommit,
7953
+ (writer) => writer.update({ collection: this.collection.slug, id, data })
7954
+ );
7829
7955
  if (this.collection.audit && db) {
7830
7956
  AuditService.log(db, {
7831
7957
  operation: "update",
@@ -8052,6 +8178,7 @@ var CollectionController = class {
8052
8178
  transitionName,
8053
8179
  expectedRevision: body.expectedRevision,
8054
8180
  comment: body.comment,
8181
+ input: body.input,
8055
8182
  user: c.get("user"),
8056
8183
  req: { query: c.req.query(), headers: c.req.header(), raw: c.req.raw }
8057
8184
  });
@@ -9383,6 +9510,20 @@ var MediaFolderController = class {
9383
9510
  }
9384
9511
  };
9385
9512
 
9513
+ // src/utils/admin-url.ts
9514
+ var DEFAULT_ADMIN_PATH = "/admin";
9515
+ function resolveAdminUrl(c, config, clientUrl) {
9516
+ const configured = config.admin?.adminUrl?.trim() || process.env.DYRECTED_ADMIN_URL?.trim() || (typeof clientUrl === "string" ? clientUrl.trim() : "") || DEFAULT_ADMIN_PATH;
9517
+ try {
9518
+ return new URL(configured, c.req.url).toString();
9519
+ } catch {
9520
+ return configured;
9521
+ }
9522
+ }
9523
+ function appendQueryParam(url, key, value) {
9524
+ return `${url}${url.includes("?") ? "&" : "?"}${key}=${encodeURIComponent(value)}`;
9525
+ }
9526
+
9386
9527
  // src/controllers/auth.controller.ts
9387
9528
  import { randomBytes as randomBytes3 } from "crypto";
9388
9529
 
@@ -10058,8 +10199,7 @@ var AuthController = class {
10058
10199
  },
10059
10200
  "1h"
10060
10201
  );
10061
- const resetUrl = body?.resetUrl;
10062
- const url = resetUrl ? `${resetUrl}${resetUrl.includes("?") ? "&" : "?"}token=${encodeURIComponent(resetToken)}` : void 0;
10202
+ const url = appendQueryParam(resolveAdminUrl(c, config, body?.resetUrl), "token", resetToken);
10063
10203
  try {
10064
10204
  const { subject, html } = buildResetPasswordEmail(config, {
10065
10205
  token: resetToken,
@@ -10191,8 +10331,7 @@ var AuthController = class {
10191
10331
  },
10192
10332
  "7d"
10193
10333
  );
10194
- const inviteUrl = body?.inviteUrl;
10195
- const url = inviteUrl ? `${inviteUrl}${inviteUrl.includes("?") ? "&" : "?"}inviteToken=${encodeURIComponent(inviteToken)}` : void 0;
10334
+ const url = appendQueryParam(resolveAdminUrl(c, config, body?.inviteUrl), "inviteToken", inviteToken);
10196
10335
  try {
10197
10336
  const { subject, html } = buildInviteEmail(config, {
10198
10337
  token: inviteToken,
@@ -12471,7 +12610,12 @@ function generateOpenApi(config) {
12471
12610
  type: "object",
12472
12611
  properties: {
12473
12612
  expectedRevision: { type: "integer", minimum: 1 },
12474
- comment: { type: "string" }
12613
+ comment: { type: "string" },
12614
+ input: {
12615
+ type: "object",
12616
+ additionalProperties: true,
12617
+ description: "Extra data passed to the transition's onTransition handler."
12618
+ }
12475
12619
  }
12476
12620
  },
12477
12621
  WorkflowHistoryEntry: {
@@ -13634,6 +13778,9 @@ function fieldToSchema(field) {
13634
13778
  case "number":
13635
13779
  schema = { type: "number" };
13636
13780
  break;
13781
+ case "money":
13782
+ schema = { type: "integer", description: "Amount in minor units (for example kobo or cents)." };
13783
+ break;
13637
13784
  case "boolean":
13638
13785
  schema = { type: "boolean" };
13639
13786
  break;
@@ -13774,6 +13921,537 @@ function getSwaggerHtml(specUrl) {
13774
13921
  `;
13775
13922
  }
13776
13923
 
13924
+ // src/types/navigation.ts
13925
+ var CURRENT_NAV_PREFERENCES_VERSION = 1;
13926
+ var DEFAULT_USER_NAV_PREFERENCES = {
13927
+ _version: CURRENT_NAV_PREFERENCES_VERSION,
13928
+ pinned: [],
13929
+ hidden: [],
13930
+ groupOrder: [],
13931
+ itemOrder: {},
13932
+ groups: [],
13933
+ items: []
13934
+ };
13935
+
13936
+ // src/utils/navigation-reconciler.ts
13937
+ var normalizeKey = (val) => (val || "").toLowerCase().replace(/[^a-z0-9]/g, "-");
13938
+ function reconcileNavigation(baseTree, prefs, schemas, options) {
13939
+ if (!baseTree) {
13940
+ return {
13941
+ groups: [],
13942
+ ungrouped: [],
13943
+ pinnedItems: []
13944
+ };
13945
+ }
13946
+ const includeHidden = options?.includeHidden ?? false;
13947
+ const effectivePrefs = prefs || DEFAULT_USER_NAV_PREFERENCES;
13948
+ const hiddenSet = new Set(effectivePrefs.hidden || []);
13949
+ const knownCollectionSlugs = new Set((schemas?.collections || []).map((c) => c.slug));
13950
+ const knownGlobalSlugs = new Set((schemas?.globals || []).map((g) => g.slug));
13951
+ const groupsList = [];
13952
+ const findGroup = (query) => {
13953
+ const qNorm = normalizeKey(query);
13954
+ return groupsList.find(
13955
+ (g) => g.id === query || g.slug === query || g.name === query || normalizeKey(g.id) === qNorm || normalizeKey(g.slug) === qNorm || normalizeKey(g.name) === qNorm
13956
+ );
13957
+ };
13958
+ for (const g of baseTree.groups || []) {
13959
+ groupsList.push({
13960
+ ...g,
13961
+ items: (g.items || []).map((item) => ({ ...item }))
13962
+ });
13963
+ }
13964
+ for (const ug of effectivePrefs.groups || []) {
13965
+ const existing = findGroup(ug.slug || ug.name);
13966
+ if (!existing) {
13967
+ groupsList.push({
13968
+ id: ug.slug ? `group_${ug.slug}` : `group_${normalizeKey(ug.name)}`,
13969
+ name: ug.name,
13970
+ slug: ug.slug || normalizeKey(ug.name),
13971
+ icon: ug.icon || "Folder",
13972
+ defaultExpanded: ug.defaultExpanded ?? true,
13973
+ order: ug.order ?? 100,
13974
+ items: []
13975
+ });
13976
+ }
13977
+ }
13978
+ for (const itemOpt of effectivePrefs.items || []) {
13979
+ const targetGroup = typeof itemOpt.group === "object" ? itemOpt.group?.name : itemOpt.group;
13980
+ const groupKey = targetGroup || "Custom";
13981
+ let group = findGroup(groupKey);
13982
+ if (!group) {
13983
+ group = {
13984
+ id: `group_${normalizeKey(groupKey)}`,
13985
+ name: groupKey,
13986
+ slug: normalizeKey(groupKey),
13987
+ icon: "Folder",
13988
+ defaultExpanded: true,
13989
+ order: 99,
13990
+ items: []
13991
+ };
13992
+ groupsList.push(group);
13993
+ }
13994
+ const itemId = itemOpt.slug ? `workspace_${itemOpt.slug}` : itemOpt.collection ? `collection_${itemOpt.collection}` : `custom_${Math.random()}`;
13995
+ for (const otherGroup of groupsList) {
13996
+ if (otherGroup !== group) {
13997
+ otherGroup.items = otherGroup.items.filter((i) => i.slug !== itemOpt.slug && i.id !== itemId);
13998
+ }
13999
+ }
14000
+ const existingIndex = group.items.findIndex((i) => i.id === itemId || i.slug === itemOpt.slug);
14001
+ const existingItem = existingIndex >= 0 ? group.items[existingIndex] : void 0;
14002
+ const col = schemas?.collections?.find((c) => c.slug === itemOpt.collection);
14003
+ const colViews = col?.views || [];
14004
+ const baseViews = existingItem?.views || [];
14005
+ const userViews = itemOpt.views || [];
14006
+ const userViewSlugs = new Set(userViews.map((v) => v.slug));
14007
+ let mergedViews = [
14008
+ ...baseViews.filter((v) => !userViewSlugs.has(v.slug)),
14009
+ ...userViews
14010
+ ];
14011
+ if (mergedViews.length === 0 && itemOpt.collection) {
14012
+ if (colViews.length > 0) {
14013
+ mergedViews = [...colViews];
14014
+ } else {
14015
+ mergedViews = [
14016
+ {
14017
+ slug: "default",
14018
+ label: col?.label || col?.labels?.plural || itemOpt.label || "All Records",
14019
+ layout: "table",
14020
+ collection: itemOpt.collection
14021
+ }
14022
+ ];
14023
+ }
14024
+ }
14025
+ mergedViews = mergedViews.map((v) => ({
14026
+ ...v,
14027
+ collection: v.collection || itemOpt.collection || existingItem?.collection
14028
+ }));
14029
+ const compiledItem = {
14030
+ id: itemId,
14031
+ type: itemOpt.slug ? "workspace" : itemOpt.collection ? "collection" : itemOpt.global ? "global" : "workspace",
14032
+ slug: itemOpt.slug || itemOpt.collection || itemOpt.global || "item",
14033
+ label: itemOpt.label || itemOpt.slug || "Item",
14034
+ icon: itemOpt.icon || existingItem?.icon || col?.admin?.icon || "Briefcase",
14035
+ group: group.name,
14036
+ order: itemOpt.order ?? existingItem?.order ?? 100,
14037
+ views: mergedViews,
14038
+ collection: itemOpt.collection || existingItem?.collection,
14039
+ global: itemOpt.global || existingItem?.global,
14040
+ badge: itemOpt.badge || existingItem?.badge,
14041
+ access: itemOpt.access || existingItem?.access
14042
+ };
14043
+ if (existingIndex >= 0) {
14044
+ group.items[existingIndex] = compiledItem;
14045
+ } else {
14046
+ group.items.push(compiledItem);
14047
+ }
14048
+ }
14049
+ const allAvailableItems = [];
14050
+ const reconciledGroups = [];
14051
+ for (const group of groupsList) {
14052
+ const isGroupHidden = hiddenSet.has(group.id) || !!group.slug && hiddenSet.has(group.slug) || hiddenSet.has(group.name);
14053
+ if (!includeHidden && isGroupHidden) {
14054
+ continue;
14055
+ }
14056
+ const visibleItems = [];
14057
+ for (const item of group.items) {
14058
+ const isItemHidden = hiddenSet.has(item.id) || !!item.slug && hiddenSet.has(item.slug);
14059
+ if (!includeHidden && isItemHidden) {
14060
+ continue;
14061
+ }
14062
+ const allViews = item.views || [];
14063
+ const visibleViews = includeHidden ? allViews : allViews.filter((v) => {
14064
+ const compoundKey = `${item.slug}_${v.slug}`;
14065
+ return !hiddenSet.has(compoundKey) && !hiddenSet.has(v.slug);
14066
+ });
14067
+ let isTombstone = false;
14068
+ if (schemas && schemas.collections && schemas.collections.length > 0) {
14069
+ if (item.collection && !knownCollectionSlugs.has(item.collection)) {
14070
+ isTombstone = true;
14071
+ }
14072
+ }
14073
+ if (schemas && schemas.globals && schemas.globals.length > 0) {
14074
+ if (item.global && !knownGlobalSlugs.has(item.global)) {
14075
+ isTombstone = true;
14076
+ }
14077
+ }
14078
+ const processedItem = {
14079
+ ...item,
14080
+ views: visibleViews,
14081
+ ...isTombstone ? { isTombstone: true } : {}
14082
+ };
14083
+ visibleItems.push(processedItem);
14084
+ allAvailableItems.push(processedItem);
14085
+ }
14086
+ const groupKey = group.slug || group.name;
14087
+ const itemOrderList = effectivePrefs.itemOrder?.[group.id] || effectivePrefs.itemOrder?.[groupKey];
14088
+ if (itemOrderList && itemOrderList.length > 0) {
14089
+ visibleItems.sort((a, b) => {
14090
+ const aIdx = itemOrderList.indexOf(a.id) >= 0 ? itemOrderList.indexOf(a.id) : a.slug ? itemOrderList.indexOf(a.slug) : -1;
14091
+ const bIdx = itemOrderList.indexOf(b.id) >= 0 ? itemOrderList.indexOf(b.id) : b.slug ? itemOrderList.indexOf(b.slug) : -1;
14092
+ if (aIdx >= 0 && bIdx >= 0) return aIdx - bIdx;
14093
+ if (aIdx >= 0) return -1;
14094
+ if (bIdx >= 0) return 1;
14095
+ return (a.order ?? 100) - (b.order ?? 100);
14096
+ });
14097
+ } else {
14098
+ visibleItems.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
14099
+ }
14100
+ reconciledGroups.push({
14101
+ ...group,
14102
+ items: visibleItems
14103
+ });
14104
+ }
14105
+ if (effectivePrefs.groupOrder && effectivePrefs.groupOrder.length > 0) {
14106
+ reconciledGroups.sort((a, b) => {
14107
+ const aIdx = effectivePrefs.groupOrder.indexOf(a.id) >= 0 ? effectivePrefs.groupOrder.indexOf(a.id) : a.slug ? effectivePrefs.groupOrder.indexOf(a.slug) : effectivePrefs.groupOrder.indexOf(a.name);
14108
+ const bIdx = effectivePrefs.groupOrder.indexOf(b.id) >= 0 ? effectivePrefs.groupOrder.indexOf(b.id) : b.slug ? effectivePrefs.groupOrder.indexOf(b.slug) : effectivePrefs.groupOrder.indexOf(b.name);
14109
+ if (aIdx >= 0 && bIdx >= 0) return aIdx - bIdx;
14110
+ if (aIdx >= 0) return -1;
14111
+ if (bIdx >= 0) return 1;
14112
+ return (a.order ?? 100) - (b.order ?? 100);
14113
+ });
14114
+ } else {
14115
+ reconciledGroups.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
14116
+ }
14117
+ const reconciledUngrouped = (baseTree.ungrouped || []).filter((item) => {
14118
+ const isItemHidden = hiddenSet.has(item.id) || !!item.slug && hiddenSet.has(item.slug);
14119
+ return includeHidden || !isItemHidden;
14120
+ }).map((item) => {
14121
+ const allViews = item.views || [];
14122
+ const visibleViews = includeHidden ? allViews : allViews.filter((v) => {
14123
+ const compoundKey = `${item.slug}_${v.slug}`;
14124
+ return !hiddenSet.has(compoundKey) && !hiddenSet.has(v.slug);
14125
+ });
14126
+ const processed = { ...item, views: visibleViews };
14127
+ allAvailableItems.push(processed);
14128
+ return processed;
14129
+ });
14130
+ const pinnedItems = [];
14131
+ for (const pin of effectivePrefs.pinned || []) {
14132
+ const match = allAvailableItems.find((i) => i.slug === pin.slug || i.id === pin.slug);
14133
+ if (match) {
14134
+ pinnedItems.push({
14135
+ ...match,
14136
+ id: `pin_${match.id}`,
14137
+ label: pin.label || match.label,
14138
+ icon: pin.icon || match.icon
14139
+ });
14140
+ } else {
14141
+ pinnedItems.push({
14142
+ id: `pin_${pin.slug}`,
14143
+ type: pin.type,
14144
+ slug: pin.slug,
14145
+ label: pin.label || pin.slug,
14146
+ icon: pin.icon || "Star",
14147
+ order: 0,
14148
+ views: []
14149
+ });
14150
+ }
14151
+ }
14152
+ return {
14153
+ groups: reconciledGroups,
14154
+ ungrouped: reconciledUngrouped,
14155
+ pinnedItems
14156
+ };
14157
+ }
14158
+
14159
+ // src/utils/navigation.ts
14160
+ var RESERVED_NAVIGATION_SLUGS = /* @__PURE__ */ new Set([
14161
+ "collections",
14162
+ "globals",
14163
+ "setup",
14164
+ "api",
14165
+ "login",
14166
+ "logout",
14167
+ "settings",
14168
+ "admin",
14169
+ "preferences",
14170
+ "auth"
14171
+ ]);
14172
+ function assertValidNavigationSlugs(navigation) {
14173
+ for (const item of navigation) {
14174
+ if (item.slug) {
14175
+ const normalizedSlug = item.slug.toLowerCase().trim().replace(/^\//, "");
14176
+ if (RESERVED_NAVIGATION_SLUGS.has(normalizedSlug)) {
14177
+ throw new Error(
14178
+ `[Dyrected Config] The slug '${item.slug}' in navigation item '${item.label || item.slug}' is a reserved system path and cannot be used as an operational workspace slug.`
14179
+ );
14180
+ }
14181
+ }
14182
+ }
14183
+ }
14184
+ function resolveGroupMeta(groupProp, defaultGroupName = "Collections") {
14185
+ if (!groupProp) {
14186
+ return { name: defaultGroupName };
14187
+ }
14188
+ if (typeof groupProp === "string") {
14189
+ return { name: groupProp };
14190
+ }
14191
+ return { name: groupProp.name, meta: groupProp };
14192
+ }
14193
+ function compileNavigation(config) {
14194
+ const collections = config.collections || [];
14195
+ const globals = config.globals || [];
14196
+ const explicitNav = config.admin?.navigation || [];
14197
+ assertValidNavigationSlugs(explicitNav);
14198
+ const collectionsBySlug = /* @__PURE__ */ new Map();
14199
+ for (const col of collections) {
14200
+ collectionsBySlug.set(col.slug, col);
14201
+ }
14202
+ const globalsBySlug = /* @__PURE__ */ new Map();
14203
+ for (const gl of globals) {
14204
+ globalsBySlug.set(gl.slug, gl);
14205
+ }
14206
+ const mentionedCollections = /* @__PURE__ */ new Set();
14207
+ const mentionedGlobals = /* @__PURE__ */ new Set();
14208
+ const injectedViewsByCollection = /* @__PURE__ */ new Map();
14209
+ for (const item of explicitNav) {
14210
+ if (item.addToCollection) {
14211
+ const existing = injectedViewsByCollection.get(item.addToCollection) || [];
14212
+ if (item.views && item.views.length > 0) {
14213
+ injectedViewsByCollection.set(item.addToCollection, [...existing, ...item.views]);
14214
+ }
14215
+ }
14216
+ if (item.collection) mentionedCollections.add(item.collection);
14217
+ if (item.global) mentionedGlobals.add(item.global);
14218
+ }
14219
+ const groupMetadataMap = /* @__PURE__ */ new Map();
14220
+ const itemsByGroup = /* @__PURE__ */ new Map();
14221
+ const ungroupedItems = [];
14222
+ const registerItem = (groupName, item) => {
14223
+ if (!groupName) {
14224
+ ungroupedItems.push(item);
14225
+ } else {
14226
+ const list = itemsByGroup.get(groupName) || [];
14227
+ list.push(item);
14228
+ itemsByGroup.set(groupName, list);
14229
+ }
14230
+ };
14231
+ for (const item of explicitNav) {
14232
+ if (item.addToCollection && !item.slug && !item.collection && !item.global && !item.dashboard && !item.href) {
14233
+ continue;
14234
+ }
14235
+ const { name: groupName, meta: groupMeta } = resolveGroupMeta(item.group, void 0);
14236
+ if (groupName && groupMeta) {
14237
+ groupMetadataMap.set(groupName, { ...groupMetadataMap.get(groupName) || {}, ...groupMeta });
14238
+ }
14239
+ if (item.dashboard) {
14240
+ registerItem(groupName, {
14241
+ id: "dashboard",
14242
+ type: "dashboard",
14243
+ slug: "dashboard",
14244
+ label: item.label || "Dashboard",
14245
+ icon: item.icon || "LayoutDashboard",
14246
+ group: groupName,
14247
+ order: item.order ?? 0,
14248
+ views: [],
14249
+ access: item.access
14250
+ });
14251
+ continue;
14252
+ }
14253
+ if (item.collection) {
14254
+ const col = collectionsBySlug.get(item.collection);
14255
+ const combinedViews = [
14256
+ ...col?.views || [],
14257
+ ...injectedViewsByCollection.get(item.collection) || [],
14258
+ ...item.views || []
14259
+ ];
14260
+ registerItem(groupName, {
14261
+ id: item.slug || `collection_${item.collection}`,
14262
+ type: item.slug ? "workspace" : "collection",
14263
+ slug: item.slug || item.collection,
14264
+ collection: item.collection,
14265
+ label: item.label || col?.labels?.plural || item.collection,
14266
+ icon: item.icon || col?.admin?.icon || "Folder",
14267
+ group: groupName,
14268
+ order: item.order ?? col?.admin?.order ?? 100,
14269
+ views: combinedViews,
14270
+ badge: item.badge,
14271
+ access: item.access || col?.access?.read
14272
+ });
14273
+ continue;
14274
+ }
14275
+ if (item.global) {
14276
+ const gl = globalsBySlug.get(item.global);
14277
+ registerItem(groupName, {
14278
+ id: item.slug || `global_${item.global}`,
14279
+ type: "global",
14280
+ slug: item.slug || item.global,
14281
+ global: item.global,
14282
+ label: item.label || gl?.label || item.global,
14283
+ icon: item.icon || "Settings",
14284
+ group: groupName,
14285
+ order: item.order ?? 100,
14286
+ views: [],
14287
+ badge: item.badge,
14288
+ access: item.access
14289
+ });
14290
+ continue;
14291
+ }
14292
+ if (item.href) {
14293
+ registerItem(groupName, {
14294
+ id: `link_${item.slug || item.href}`,
14295
+ type: "link",
14296
+ slug: item.slug || "link",
14297
+ label: item.label || "Link",
14298
+ href: item.href,
14299
+ icon: item.icon || "ExternalLink",
14300
+ group: groupName,
14301
+ order: item.order ?? 100,
14302
+ views: [],
14303
+ badge: item.badge,
14304
+ access: item.access
14305
+ });
14306
+ continue;
14307
+ }
14308
+ const workspaceSlug = item.slug || "workspace";
14309
+ registerItem(groupName, {
14310
+ id: `workspace_${workspaceSlug}`,
14311
+ type: "workspace",
14312
+ slug: workspaceSlug,
14313
+ label: item.label || workspaceSlug,
14314
+ icon: item.icon || "Briefcase",
14315
+ group: groupName,
14316
+ order: item.order ?? 100,
14317
+ views: item.views || [],
14318
+ badge: item.badge,
14319
+ access: item.access
14320
+ });
14321
+ }
14322
+ for (const col of collections) {
14323
+ if (mentionedCollections.has(col.slug)) continue;
14324
+ if (col.admin?.hidden) continue;
14325
+ const groupProp = col.admin?.group;
14326
+ const { name: groupName, meta: groupMeta } = resolveGroupMeta(groupProp, "Collections");
14327
+ if (groupMeta) {
14328
+ groupMetadataMap.set(groupName, { ...groupMetadataMap.get(groupName) || {}, ...groupMeta });
14329
+ }
14330
+ const combinedViews = [
14331
+ ...col.views || [],
14332
+ ...injectedViewsByCollection.get(col.slug) || []
14333
+ ];
14334
+ registerItem(groupName, {
14335
+ id: `collection_${col.slug}`,
14336
+ type: "collection",
14337
+ slug: col.slug,
14338
+ collection: col.slug,
14339
+ label: col.labels?.plural || col.slug,
14340
+ icon: col.admin?.icon || "Folder",
14341
+ group: groupName,
14342
+ order: col?.admin?.order ?? 100,
14343
+ views: combinedViews,
14344
+ access: col.access?.read
14345
+ });
14346
+ }
14347
+ for (const gl of globals) {
14348
+ if (mentionedGlobals.has(gl.slug)) continue;
14349
+ const groupName = "Configuration";
14350
+ registerItem(groupName, {
14351
+ id: `global_${gl.slug}`,
14352
+ type: "global",
14353
+ slug: gl.slug,
14354
+ global: gl.slug,
14355
+ label: gl.label || gl.slug,
14356
+ icon: "Settings",
14357
+ group: groupName,
14358
+ order: 100,
14359
+ views: []
14360
+ });
14361
+ }
14362
+ const sortAndSpliceItems = (items) => {
14363
+ const sorted = [...items].sort((a, b) => a.order - b.order);
14364
+ const explicitMap = /* @__PURE__ */ new Map();
14365
+ for (const opt of explicitNav) {
14366
+ if (opt.slug) explicitMap.set(opt.slug, opt);
14367
+ if (opt.collection) explicitMap.set(opt.collection, opt);
14368
+ if (opt.global) explicitMap.set(opt.global, opt);
14369
+ }
14370
+ for (const opt of explicitNav) {
14371
+ const itemKey = opt.slug || opt.collection || opt.global;
14372
+ if (!itemKey) continue;
14373
+ if (opt.after) {
14374
+ const itemIdx = sorted.findIndex((i) => i.slug === itemKey);
14375
+ const targetIdx = sorted.findIndex((i) => i.slug === opt.after);
14376
+ if (itemIdx !== -1 && targetIdx !== -1 && itemIdx !== targetIdx) {
14377
+ const [moved] = sorted.splice(itemIdx, 1);
14378
+ const newTargetIdx = sorted.findIndex((i) => i.slug === opt.after);
14379
+ sorted.splice(newTargetIdx + 1, 0, moved);
14380
+ }
14381
+ } else if (opt.before) {
14382
+ const itemIdx = sorted.findIndex((i) => i.slug === itemKey);
14383
+ const targetIdx = sorted.findIndex((i) => i.slug === opt.before);
14384
+ if (itemIdx !== -1 && targetIdx !== -1 && itemIdx !== targetIdx) {
14385
+ const [moved] = sorted.splice(itemIdx, 1);
14386
+ const newTargetIdx = sorted.findIndex((i) => i.slug === opt.before);
14387
+ sorted.splice(newTargetIdx, 0, moved);
14388
+ }
14389
+ } else if (opt.position === "first") {
14390
+ const itemIdx = sorted.findIndex((i) => i.slug === itemKey);
14391
+ if (itemIdx > 0) {
14392
+ const [moved] = sorted.splice(itemIdx, 1);
14393
+ sorted.unshift(moved);
14394
+ }
14395
+ } else if (opt.position === "last") {
14396
+ const itemIdx = sorted.findIndex((i) => i.slug === itemKey);
14397
+ if (itemIdx !== -1 && itemIdx < sorted.length - 1) {
14398
+ const [moved] = sorted.splice(itemIdx, 1);
14399
+ sorted.push(moved);
14400
+ }
14401
+ }
14402
+ }
14403
+ return sorted;
14404
+ };
14405
+ const compiledGroups = [];
14406
+ for (const [groupName, rawItems] of itemsByGroup.entries()) {
14407
+ const meta = groupMetadataMap.get(groupName);
14408
+ const sortedItems = sortAndSpliceItems(rawItems);
14409
+ compiledGroups.push({
14410
+ id: `group_${groupName.toLowerCase().replace(/\s+/g, "_")}`,
14411
+ name: groupName,
14412
+ slug: meta?.slug || groupName.toLowerCase().replace(/\s+/g, "-"),
14413
+ icon: meta?.icon,
14414
+ defaultExpanded: meta?.defaultExpanded ?? true,
14415
+ order: meta?.order ?? 100,
14416
+ items: sortedItems
14417
+ });
14418
+ }
14419
+ compiledGroups.sort((a, b) => a.order - b.order);
14420
+ return {
14421
+ groups: compiledGroups,
14422
+ ungrouped: sortAndSpliceItems(ungroupedItems)
14423
+ };
14424
+ }
14425
+ function pruneNavigationForUser(tree, user) {
14426
+ const userRoles = /* @__PURE__ */ new Set();
14427
+ if (user?.role) userRoles.add(user.role);
14428
+ if (Array.isArray(user?.roles)) {
14429
+ for (const r of user.roles) userRoles.add(r);
14430
+ }
14431
+ const isAllowed = (item) => {
14432
+ if (!item.access) return true;
14433
+ if (Array.isArray(item.access)) {
14434
+ if (item.access.length === 0) return true;
14435
+ return item.access.some((role) => userRoles.has(role));
14436
+ }
14437
+ return true;
14438
+ };
14439
+ const prunedGroups = [];
14440
+ for (const group of tree.groups) {
14441
+ const visibleItems = group.items.filter(isAllowed);
14442
+ if (visibleItems.length > 0) {
14443
+ prunedGroups.push({
14444
+ ...group,
14445
+ items: visibleItems
14446
+ });
14447
+ }
14448
+ }
14449
+ return {
14450
+ groups: prunedGroups,
14451
+ ungrouped: tree.ungrouped.filter(isAllowed)
14452
+ };
14453
+ }
14454
+
13777
14455
  // src/router.ts
13778
14456
  init_access_control();
13779
14457
  import { HTTPException as HTTPException5 } from "hono/http-exception";
@@ -13922,6 +14600,30 @@ function serializeDetailForApi(detail) {
13922
14600
  if (!detail || !Array.isArray(detail)) return void 0;
13923
14601
  return detail.map(serializeDetailItemForApi);
13924
14602
  }
14603
+ async function serializeActionsForApi(actions, serializeAccess) {
14604
+ const serializeAccessConfig = async (access) => {
14605
+ if (!access) return void 0;
14606
+ const serialized = {};
14607
+ for (const key of ["read", "create", "update", "delete"]) {
14608
+ if (access[key] !== void 0) serialized[key] = await serializeAccess(access[key]);
14609
+ }
14610
+ return serialized;
14611
+ };
14612
+ return Promise.all(
14613
+ (actions || []).map(async (action) => ({
14614
+ name: action.name,
14615
+ label: action.label,
14616
+ icon: action.icon,
14617
+ type: action.type ?? "row",
14618
+ confirm: action.confirm,
14619
+ submitLabel: action.submitLabel,
14620
+ fields: action.fields?.map(serializeFieldForApi),
14621
+ mutation: action.mutation,
14622
+ // Self-hosted handlers are intentionally omitted: they never leave the server.
14623
+ access: action.access ? await serializeAccessConfig(action.access) : void 0
14624
+ }))
14625
+ );
14626
+ }
13925
14627
  async function serializeViewForApi(view, serializeAccess) {
13926
14628
  if (!view) return view;
13927
14629
  const serializeAccessConfig = async (access) => {
@@ -13948,20 +14650,7 @@ async function serializeViewForApi(view, serializeAccess) {
13948
14650
  features: view.features,
13949
14651
  actionOrder: view.actionOrder,
13950
14652
  components: view.components,
13951
- actions: await Promise.all(
13952
- (view.actions || []).map(async (action) => ({
13953
- name: action.name,
13954
- label: action.label,
13955
- icon: action.icon,
13956
- type: action.type ?? "row",
13957
- confirm: action.confirm,
13958
- submitLabel: action.submitLabel,
13959
- fields: action.fields?.map(serializeFieldForApi),
13960
- mutation: action.mutation,
13961
- // Self-hosted handlers are intentionally omitted: they never leave the server.
13962
- access: action.access ? await serializeAccessConfig(action.access) : void 0
13963
- }))
13964
- ),
14653
+ actions: await serializeActionsForApi(view.actions, serializeAccess),
13965
14654
  access: view.access ? await serializeAccessConfig(view.access) : void 0
13966
14655
  };
13967
14656
  }
@@ -14043,6 +14732,7 @@ function registerRoutes(app, config) {
14043
14732
  drafts: !!col.drafts,
14044
14733
  defaultView: col.defaultView ?? col.admin?.defaultView,
14045
14734
  views: await Promise.all((col.views || []).map((view) => serializeViewForApi(view, serializeAccess))),
14735
+ actions: await serializeActionsForApi(col.actions, serializeAccess),
14046
14736
  admin: col.admin,
14047
14737
  detail: serializeDetailForApi(col.detail),
14048
14738
  workflow: col.workflow ? {
@@ -14273,6 +14963,7 @@ function registerRoutes(app, config) {
14273
14963
  const user = c.get("user");
14274
14964
  const key = c.req.param("key");
14275
14965
  const scope = c.req.query("scope");
14966
+ const roleParam = c.req.query("role");
14276
14967
  if (!db) return c.json({ message: "Database not configured" }, 500);
14277
14968
  if (!key) return c.json({ error: true, message: "Preference key is required." }, 400);
14278
14969
  const getGlobalPreference = async () => {
@@ -14282,27 +14973,61 @@ function registerRoutes(app, config) {
14282
14973
  });
14283
14974
  return globalDoc ? globalDoc.value : null;
14284
14975
  };
14285
- if (scope === "global" || !user?.collection || !user.sub) {
14976
+ const getRolePreference = async (r) => {
14977
+ const roleDoc = await db.findOne({
14978
+ collection: "__role_preferences",
14979
+ id: `${r}_${key}`
14980
+ });
14981
+ return roleDoc ? roleDoc.value : null;
14982
+ };
14983
+ const getUserPreferences = async () => {
14984
+ if (!user?.collection || !user.sub) return null;
14985
+ const doc = await db.findOne({ collection: user.collection, id: user.sub });
14986
+ if (!doc) return null;
14987
+ return typeof doc.__preferences === "object" && doc.__preferences !== null ? doc.__preferences : {};
14988
+ };
14989
+ if (scope === "global") {
14286
14990
  const globalValue2 = await getGlobalPreference();
14287
14991
  return c.json({ key, value: globalValue2 });
14288
14992
  }
14289
- const doc = await db.findOne({ collection: user.collection, id: user.sub });
14290
- if (!doc) {
14291
- const globalValue2 = await getGlobalPreference();
14292
- return c.json({ key, value: globalValue2 });
14993
+ if (scope === "role") {
14994
+ if (roleParam) {
14995
+ const val = await getRolePreference(roleParam);
14996
+ return c.json({ key, value: val, role: roleParam });
14997
+ }
14998
+ const userRoles2 = Array.isArray(user?.roles) ? user.roles : typeof user?.role === "string" ? [user.role] : [];
14999
+ for (const r of userRoles2) {
15000
+ const val = await getRolePreference(r);
15001
+ if (val !== null && val !== void 0) {
15002
+ return c.json({ key, value: val, role: r });
15003
+ }
15004
+ }
15005
+ return c.json({ key, value: null });
14293
15006
  }
14294
- const preferences = typeof doc.__preferences === "object" && doc.__preferences !== null ? doc.__preferences : {};
14295
- if (key in preferences) {
14296
- return c.json({ key, value: preferences[key] ?? null });
15007
+ if (scope === "personal") {
15008
+ const prefs2 = await getUserPreferences();
15009
+ return c.json({ key, value: prefs2 && key in prefs2 ? prefs2[key] ?? null : null });
15010
+ }
15011
+ const prefs = await getUserPreferences();
15012
+ if (prefs && key in prefs && prefs[key] !== null && prefs[key] !== void 0) {
15013
+ return c.json({ key, value: prefs[key] });
15014
+ }
15015
+ const userRoles = Array.isArray(user?.roles) ? user.roles : typeof user?.role === "string" ? [user.role] : [];
15016
+ for (const r of userRoles) {
15017
+ const val = await getRolePreference(r);
15018
+ if (val !== null && val !== void 0) {
15019
+ return c.json({ key, value: val });
15020
+ }
14297
15021
  }
14298
15022
  const globalValue = await getGlobalPreference();
14299
- return c.json({ key, value: globalValue });
15023
+ return c.json({ key, value: globalValue ?? null });
14300
15024
  });
14301
15025
  app.put("/api/preferences/:key", requireAuth(config), async (c) => {
14302
15026
  const db = config.db;
14303
15027
  const user = c.get("user");
14304
15028
  const key = c.req.param("key");
14305
- const scope = c.req.query("scope");
15029
+ const scope = c.req.query("scope") || "personal";
15030
+ const roleParam = c.req.query("role");
14306
15031
  if (!db) return c.json({ message: "Database not configured" }, 500);
14307
15032
  if (!user?.collection || !user.sub) return c.json({ error: true, message: "Authentication required." }, 401);
14308
15033
  if (!key) return c.json({ error: true, message: "Preference key is required." }, 400);
@@ -14335,7 +15060,47 @@ function registerRoutes(app, config) {
14335
15060
  data: { id: key, value: body.value }
14336
15061
  });
14337
15062
  }
14338
- return c.json({ key, value: body.value });
15063
+ return c.json({ key, value: body.value, scope: "global" });
15064
+ }
15065
+ if (scope === "role") {
15066
+ const userCol = config.collections?.find((col) => col.slug === user?.collection);
15067
+ const isAdminUser = isUserAdmin(user, userCol);
15068
+ if (!isAdminUser) {
15069
+ return c.json(
15070
+ {
15071
+ error: true,
15072
+ message: "Only administrators can save role preferences."
15073
+ },
15074
+ 403
15075
+ );
15076
+ }
15077
+ if (!roleParam) {
15078
+ return c.json(
15079
+ {
15080
+ error: true,
15081
+ message: "Role query parameter is required for role-scoped preferences."
15082
+ },
15083
+ 400
15084
+ );
15085
+ }
15086
+ const roleDocId = `${roleParam}_${key}`;
15087
+ const existing = await db.findOne({
15088
+ collection: "__role_preferences",
15089
+ id: roleDocId
15090
+ });
15091
+ if (existing) {
15092
+ await db.update({
15093
+ collection: "__role_preferences",
15094
+ id: roleDocId,
15095
+ data: { value: body.value, role: roleParam, key }
15096
+ });
15097
+ } else {
15098
+ await db.create({
15099
+ collection: "__role_preferences",
15100
+ data: { id: roleDocId, role: roleParam, key, value: body.value }
15101
+ });
15102
+ }
15103
+ return c.json({ key, value: body.value, role: roleParam, scope: "role" });
14339
15104
  }
14340
15105
  const doc = await db.findOne({ collection: user.collection, id: user.sub });
14341
15106
  if (!doc) return c.json({ error: true, message: "User not found." }, 404);
@@ -14346,13 +15111,14 @@ function registerRoutes(app, config) {
14346
15111
  id: user.sub,
14347
15112
  data: { __preferences: nextPreferences }
14348
15113
  });
14349
- return c.json({ key, value: body.value });
15114
+ return c.json({ key, value: body.value, scope: "personal" });
14350
15115
  });
14351
15116
  app.delete("/api/preferences/:key", requireAuth(config), async (c) => {
14352
15117
  const db = config.db;
14353
15118
  const user = c.get("user");
14354
15119
  const key = c.req.param("key");
14355
- const scope = c.req.query("scope");
15120
+ const scope = c.req.query("scope") || "personal";
15121
+ const roleParam = c.req.query("role");
14356
15122
  if (!db) return c.json({ message: "Database not configured" }, 500);
14357
15123
  if (!user?.collection || !user.sub) return c.json({ error: true, message: "Authentication required." }, 401);
14358
15124
  if (!key) return c.json({ error: true, message: "Preference key is required." }, 400);
@@ -14371,6 +15137,30 @@ function registerRoutes(app, config) {
14371
15137
  await db.delete({ collection: "__global_preferences", id: key });
14372
15138
  return c.json({ success: true });
14373
15139
  }
15140
+ if (scope === "role") {
15141
+ const userCol = config.collections?.find((col) => col.slug === user?.collection);
15142
+ const isAdminUser = isUserAdmin(user, userCol);
15143
+ if (!isAdminUser) {
15144
+ return c.json(
15145
+ {
15146
+ error: true,
15147
+ message: "Only administrators can delete role preferences."
15148
+ },
15149
+ 403
15150
+ );
15151
+ }
15152
+ if (!roleParam) {
15153
+ return c.json(
15154
+ {
15155
+ error: true,
15156
+ message: "Role query parameter is required for role-scoped preferences."
15157
+ },
15158
+ 400
15159
+ );
15160
+ }
15161
+ await db.delete({ collection: "__role_preferences", id: `${roleParam}_${key}` });
15162
+ return c.json({ success: true });
15163
+ }
14374
15164
  const doc = await db.findOne({ collection: user.collection, id: user.sub });
14375
15165
  if (!doc) return c.json({ error: true, message: "User not found." }, 404);
14376
15166
  const preferences = typeof doc.__preferences === "object" && doc.__preferences !== null ? { ...doc.__preferences } : {};
@@ -14382,6 +15172,198 @@ function registerRoutes(app, config) {
14382
15172
  });
14383
15173
  return c.json({ success: true });
14384
15174
  });
15175
+ app.get("/api/admin/navigation", optionalAuth(config), async (c) => {
15176
+ const user = c.get("user");
15177
+ const compiled = compileNavigation(config);
15178
+ const pruned = pruneNavigationForUser(compiled, user);
15179
+ return c.json(pruned);
15180
+ });
15181
+ app.on(["GET", "POST"], "/api/admin/navigation/badges", optionalAuth(config), async (c) => {
15182
+ const db = config.db;
15183
+ if (!db) return c.json({ badges: {} });
15184
+ const user = c.get("user");
15185
+ const compiled = compileNavigation(config);
15186
+ let userNavPrefs = null;
15187
+ const prefsQuery = c.req.query("prefs");
15188
+ if (prefsQuery) {
15189
+ try {
15190
+ userNavPrefs = JSON.parse(decodeURIComponent(prefsQuery));
15191
+ } catch {
15192
+ }
15193
+ }
15194
+ if (!userNavPrefs && c.req.method === "POST") {
15195
+ try {
15196
+ const body = await c.req.json();
15197
+ if (body?.preferences) userNavPrefs = body.preferences;
15198
+ } catch {
15199
+ }
15200
+ }
15201
+ if (!userNavPrefs && user?.collection && user?.sub) {
15202
+ try {
15203
+ const userDoc = await db.findOne({ collection: user.collection, id: user.sub });
15204
+ if (userDoc?.__preferences && typeof userDoc.__preferences === "object") {
15205
+ userNavPrefs = userDoc.__preferences["admin:navigation"];
15206
+ }
15207
+ } catch {
15208
+ }
15209
+ }
15210
+ if (!userNavPrefs) {
15211
+ try {
15212
+ const globalDoc = await db.findOne({
15213
+ collection: "__global_preferences",
15214
+ id: "admin:navigation"
15215
+ });
15216
+ if (globalDoc?.value) {
15217
+ userNavPrefs = globalDoc.value;
15218
+ }
15219
+ } catch {
15220
+ }
15221
+ }
15222
+ let allItems;
15223
+ if (userNavPrefs) {
15224
+ const reconciled = reconcileNavigation(compiled, userNavPrefs, {
15225
+ collections: config.collections,
15226
+ globals: config.globals
15227
+ });
15228
+ allItems = [
15229
+ ...reconciled.ungrouped || [],
15230
+ ...reconciled.pinnedItems || [],
15231
+ ...(reconciled.groups || []).flatMap((g) => g.items || [])
15232
+ ];
15233
+ } else {
15234
+ const pruned = pruneNavigationForUser(compiled, user);
15235
+ allItems = [
15236
+ ...pruned.ungrouped,
15237
+ ...pruned.groups.flatMap((g) => g.items)
15238
+ ];
15239
+ }
15240
+ const badges = {};
15241
+ const requestsByCollection = /* @__PURE__ */ new Map();
15242
+ let reqCounter = 0;
15243
+ for (const item of allItems) {
15244
+ if (item.badge) {
15245
+ const itemBadge = item.badge;
15246
+ const badgeKey = item.slug || item.collection || item.global || item.id;
15247
+ if (typeof itemBadge === "string") {
15248
+ badges[badgeKey] = { count: itemBadge, variant: "default" };
15249
+ } else {
15250
+ const badgeConfig = itemBadge;
15251
+ const variant = badgeConfig.variant ?? "default";
15252
+ if (badgeConfig.text) {
15253
+ badges[badgeKey] = { count: badgeConfig.text, variant };
15254
+ } else {
15255
+ let targetCol;
15256
+ let targetWhere;
15257
+ if (badgeConfig.aggregate) {
15258
+ targetCol = badgeConfig.aggregate.collection;
15259
+ targetWhere = badgeConfig.aggregate.where;
15260
+ } else if (badgeConfig.count) {
15261
+ targetCol = item.collection || item.views?.[0]?.collection;
15262
+ const primaryView = item.views?.[0];
15263
+ if (primaryView?.filter) {
15264
+ targetWhere = primaryView.filter;
15265
+ }
15266
+ }
15267
+ if (targetCol) {
15268
+ const reqId = `agg_${reqCounter++}`;
15269
+ const list = requestsByCollection.get(targetCol) || [];
15270
+ list.push({
15271
+ id: reqId,
15272
+ where: targetWhere,
15273
+ onResult: (count2) => {
15274
+ badges[badgeKey] = { count: count2, variant };
15275
+ }
15276
+ });
15277
+ requestsByCollection.set(targetCol, list);
15278
+ }
15279
+ }
15280
+ }
15281
+ }
15282
+ if (item.views && item.views.length > 0) {
15283
+ for (const view of item.views) {
15284
+ if (!view.badge) continue;
15285
+ const viewBadge = view.badge;
15286
+ const viewKey = `${item.slug}:${view.slug}`;
15287
+ const altKey = `${item.slug}_${view.slug}`;
15288
+ const plainKey = view.slug;
15289
+ const setBadgeResult = (res) => {
15290
+ badges[viewKey] = res;
15291
+ badges[altKey] = res;
15292
+ if (!badges[plainKey]) {
15293
+ badges[plainKey] = res;
15294
+ }
15295
+ if (view.collection) {
15296
+ badges[`${view.collection}:${view.slug}`] = res;
15297
+ badges[`${view.collection}_${view.slug}`] = res;
15298
+ }
15299
+ };
15300
+ if (typeof viewBadge === "string") {
15301
+ setBadgeResult({ count: viewBadge, variant: "default" });
15302
+ continue;
15303
+ }
15304
+ const badgeConfig = viewBadge;
15305
+ const variant = badgeConfig.variant ?? "default";
15306
+ if (badgeConfig.text) {
15307
+ setBadgeResult({ count: badgeConfig.text, variant });
15308
+ continue;
15309
+ }
15310
+ const targetCol = badgeConfig.aggregate?.collection || view.collection || item.collection || (item.type === "collection" ? item.slug : void 0);
15311
+ const targetWhere = badgeConfig.aggregate?.where || (typeof view.filter === "object" ? view.filter : void 0);
15312
+ if (!targetCol) continue;
15313
+ const reqId = `agg_${reqCounter++}`;
15314
+ const list = requestsByCollection.get(targetCol) || [];
15315
+ list.push({
15316
+ id: reqId,
15317
+ where: targetWhere,
15318
+ onResult: (count2) => {
15319
+ setBadgeResult({ count: count2, variant });
15320
+ }
15321
+ });
15322
+ requestsByCollection.set(targetCol, list);
15323
+ }
15324
+ }
15325
+ }
15326
+ const collectionTasks = Array.from(requestsByCollection.entries()).map(async ([col, reqs]) => {
15327
+ if (typeof db.aggregate === "function") {
15328
+ try {
15329
+ const aggregates = {};
15330
+ for (const req of reqs) {
15331
+ aggregates[req.id] = { count: "*", where: req.where };
15332
+ }
15333
+ const agg = await db.aggregate({
15334
+ collection: col,
15335
+ aggregates
15336
+ });
15337
+ for (const req of reqs) {
15338
+ const val = agg?.[req.id];
15339
+ req.onResult(typeof val === "number" ? val : 0);
15340
+ }
15341
+ } catch {
15342
+ for (const req of reqs) {
15343
+ req.onResult(0);
15344
+ }
15345
+ }
15346
+ } else {
15347
+ await Promise.allSettled(
15348
+ reqs.map(async (req) => {
15349
+ try {
15350
+ const res = await db.find({
15351
+ collection: col,
15352
+ where: req.where,
15353
+ limit: 1
15354
+ });
15355
+ const total = res?.totalDocs ?? res?.total ?? 0;
15356
+ req.onResult(total);
15357
+ } catch {
15358
+ req.onResult(0);
15359
+ }
15360
+ })
15361
+ );
15362
+ }
15363
+ });
15364
+ await Promise.allSettled(collectionTasks);
15365
+ return c.json({ badges });
15366
+ });
14385
15367
  app.get("/api/media/folders", async (c) => {
14386
15368
  const folderController = new MediaFolderController("media");
14387
15369
  return folderController.list(c);
@@ -14631,6 +15613,31 @@ function registerRoutes(app, config) {
14631
15613
  });
14632
15614
  }
14633
15615
 
15616
+ // src/tasks.ts
15617
+ var TASK_LOCKS_COLLECTION = "__task_locks";
15618
+ var TASK_LOCKS_COLLECTION_CONFIG = {
15619
+ slug: TASK_LOCKS_COLLECTION,
15620
+ labels: { singular: "Task lock", plural: "Task locks" },
15621
+ fields: [
15622
+ { name: "name", type: "text", required: true },
15623
+ // Numeric and promoted so the lock compare-and-set is a real column comparison.
15624
+ { name: "lockedUntil", type: "number", required: true, promoted: true },
15625
+ { name: "lockedBy", type: "text" },
15626
+ { name: "lastScheduledFor", type: "date" },
15627
+ { name: "lastStartedAt", type: "date" },
15628
+ { name: "lastFinishedAt", type: "date" },
15629
+ { name: "lastStatus", type: "text" },
15630
+ { name: "lastError", type: "textarea" }
15631
+ ],
15632
+ access: {
15633
+ read: ({ user }) => !!user?.roles?.includes("admin"),
15634
+ create: () => false,
15635
+ update: () => false,
15636
+ delete: () => false
15637
+ },
15638
+ admin: { hidden: true }
15639
+ };
15640
+
14634
15641
  // src/utils/config.ts
14635
15642
  var AUDIT_COLLECTION_SLUG = "__audit";
14636
15643
  var SYSTEM_FIELDS = [
@@ -14782,6 +15789,7 @@ function normalizeConfig(config) {
14782
15789
  const globals = schemaAwareConfig?.globals || [];
14783
15790
  const needsAudit = collections.some((col) => col.audit);
14784
15791
  const needsWorkflow = collections.some((col) => col.workflow || col.drafts);
15792
+ const needsTasks = (config.tasks?.length ?? 0) > 0;
14785
15793
  const needsAuthSessions = collections.some((col) => !!col.auth);
14786
15794
  const adminAuthCollectionSlug = getAdminAuthCollection({
14787
15795
  collections,
@@ -14861,7 +15869,33 @@ function normalizeConfig(config) {
14861
15869
  }
14862
15870
  ];
14863
15871
  }
15872
+ if (!existingFieldNames.has("__preferences")) {
15873
+ fields = [
15874
+ ...fields,
15875
+ {
15876
+ name: "__preferences",
15877
+ type: "json",
15878
+ label: "Preferences",
15879
+ promoted: true,
15880
+ admin: {
15881
+ hidden: true,
15882
+ readOnly: true
15883
+ }
15884
+ }
15885
+ ];
15886
+ }
14864
15887
  fields = fields.map((field) => {
15888
+ if (field.name === "__preferences") {
15889
+ return {
15890
+ ...field,
15891
+ promoted: true,
15892
+ admin: {
15893
+ ...field.admin || {},
15894
+ hidden: true,
15895
+ readOnly: true
15896
+ }
15897
+ };
15898
+ }
14865
15899
  if (field.name === "email") {
14866
15900
  return {
14867
15901
  ...field,
@@ -14988,6 +16022,9 @@ function normalizeConfig(config) {
14988
16022
  if (needsWorkflow && !normalizedCollections.some((col) => col.slug === LIFECYCLE_EVENTS_COLLECTION)) {
14989
16023
  systemCollections.push(LIFECYCLE_EVENTS_COLLECTION_CONFIG);
14990
16024
  }
16025
+ if (needsTasks && !normalizedCollections.some((col) => col.slug === TASK_LOCKS_COLLECTION)) {
16026
+ systemCollections.push(TASK_LOCKS_COLLECTION_CONFIG);
16027
+ }
14991
16028
  if (needsAuthSessions && !normalizedCollections.some((col) => col.slug === AUTH_SESSIONS_COLLECTION)) {
14992
16029
  systemCollections.push(AUTH_SESSIONS_COLLECTION_CONFIG);
14993
16030
  }
@@ -15357,6 +16394,7 @@ export {
15357
16394
  revokeAllAuthSessions,
15358
16395
  revokeAuthSession,
15359
16396
  sendEmail,
16397
+ serializeActionsForApi,
15360
16398
  serializeDetailForApi,
15361
16399
  serializeViewForApi,
15362
16400
  shouldSampleRequest,