@dyrected/core 2.18.0 → 2.18.1

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.cjs CHANGED
@@ -6491,1229 +6491,1782 @@ async function buildCollectionSearchWhere(args) {
6491
6491
  return clauses.length === 1 ? clauses[0] : { OR: clauses };
6492
6492
  }
6493
6493
 
6494
- // src/controllers/collection.controller.ts
6495
- init_access_control();
6494
+ // src/types/navigation.ts
6495
+ var CURRENT_NAV_PREFERENCES_VERSION = 1;
6496
+ var DEFAULT_USER_NAV_PREFERENCES = {
6497
+ _version: CURRENT_NAV_PREFERENCES_VERSION,
6498
+ pinned: [],
6499
+ hidden: [],
6500
+ groupOrder: [],
6501
+ itemOrder: {},
6502
+ groups: [],
6503
+ items: []
6504
+ };
6496
6505
 
6497
- // src/utils/action-mutation.ts
6498
- function resolveActionMutation(mutation, ctx) {
6499
- if (!mutation) return { ...ctx.input ?? {} };
6500
- return resolveValue(mutation, ctx);
6501
- }
6502
- function resolveValue(value, ctx) {
6503
- if (typeof value === "string") return resolveExpression(value, ctx);
6504
- if (Array.isArray(value)) return value.map((item) => resolveValue(item, ctx));
6505
- if (isPlainObject2(value)) {
6506
- const out = {};
6507
- for (const [key, entry] of Object.entries(value)) {
6508
- out[key] = resolveValue(entry, ctx);
6509
- }
6510
- return out;
6506
+ // src/utils/navigation-reconciler.ts
6507
+ var normalizeKey = (val) => (val || "").toLowerCase().replace(/[^a-z0-9]/g, "-");
6508
+ function reconcileNavigation(baseTree, prefs, schemas, options) {
6509
+ if (!baseTree) {
6510
+ return {
6511
+ groups: [],
6512
+ ungrouped: [],
6513
+ pinnedItems: []
6514
+ };
6511
6515
  }
6512
- return value;
6513
- }
6514
- function resolveExpression(expression, ctx) {
6515
- if (expression === "now()") return (/* @__PURE__ */ new Date()).toISOString();
6516
- const inputPath = expression.match(/^input\.([A-Za-z0-9_.-]+)$/)?.[1];
6517
- if (inputPath !== void 0) return getPath(ctx.input, inputPath);
6518
- const docPath = expression.match(/^doc\.([A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*)$/)?.[1];
6519
- if (docPath !== void 0) return getPath(ctx.doc, docPath);
6520
- const userPath = expression.match(/^user\.([A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*)$/)?.[1];
6521
- if (userPath !== void 0 && ctx.user) return getPath(ctx.user, userPath);
6522
- return expression;
6523
- }
6524
- function getPath(source, path) {
6525
- let cursor = source;
6526
- for (const segment of path.split(".")) {
6527
- if (!cursor || typeof cursor !== "object") return void 0;
6528
- cursor = cursor[segment];
6516
+ const includeHidden = options?.includeHidden ?? false;
6517
+ const effectivePrefs = prefs || DEFAULT_USER_NAV_PREFERENCES;
6518
+ const hiddenSet = new Set(effectivePrefs.hidden || []);
6519
+ const knownCollectionSlugs = new Set((schemas?.collections || []).map((c) => c.slug));
6520
+ const knownGlobalSlugs = new Set((schemas?.globals || []).map((g) => g.slug));
6521
+ const groupsList = [];
6522
+ const findGroup = (query) => {
6523
+ const qNorm = normalizeKey(query);
6524
+ return groupsList.find(
6525
+ (g) => g.id === query || g.slug === query || g.name === query || normalizeKey(g.id) === qNorm || normalizeKey(g.slug) === qNorm || normalizeKey(g.name) === qNorm
6526
+ );
6527
+ };
6528
+ for (const g of baseTree.groups || []) {
6529
+ groupsList.push({
6530
+ ...g,
6531
+ items: (g.items || []).map((item) => ({ ...item }))
6532
+ });
6529
6533
  }
6530
- return cursor;
6531
- }
6532
- function isPlainObject2(value) {
6533
- return typeof value === "object" && value !== null && !Array.isArray(value);
6534
- }
6535
-
6536
- // src/utils/id.ts
6537
- var import_node_crypto2 = require("crypto");
6538
- var NANOID_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-";
6539
- var BASE62_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
6540
- var CROCKFORD_BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
6541
- var COMMON_SLUG_PREFIXES = {
6542
- users: "usr",
6543
- members: "mem",
6544
- accounts: "acc",
6545
- applications: "app",
6546
- customers: "cus",
6547
- products: "prd",
6548
- orders: "ord",
6549
- reports: "rpt",
6550
- sessions: "ses",
6551
- transactions: "txn",
6552
- wallets: "wal",
6553
- media: "med",
6554
- files: "fil",
6555
- posts: "post",
6556
- pages: "page",
6557
- tags: "tag",
6558
- categories: "cat",
6559
- comments: "com"
6560
- };
6561
- function nanoid(size = 21, alphabet = NANOID_ALPHABET) {
6562
- const mask = (2 << 31 - Math.clz32(alphabet.length - 1 | 1)) - 1;
6563
- const step = Math.ceil(1.6 * mask * size / alphabet.length);
6564
- let id = "";
6565
- while (true) {
6566
- const bytes = (0, import_node_crypto2.randomBytes)(step);
6567
- for (let i = 0; i < step; i++) {
6568
- const byte = bytes[i] & mask;
6569
- if (alphabet[byte]) {
6570
- id += alphabet[byte];
6571
- if (id.length === size) return id;
6572
- }
6534
+ for (const ug of effectivePrefs.groups || []) {
6535
+ const existing = findGroup(ug.slug || ug.name);
6536
+ if (!existing) {
6537
+ groupsList.push({
6538
+ id: ug.slug ? `group_${ug.slug}` : `group_${normalizeKey(ug.name)}`,
6539
+ name: ug.name,
6540
+ slug: ug.slug || normalizeKey(ug.name),
6541
+ icon: ug.icon || "Folder",
6542
+ defaultExpanded: ug.defaultExpanded ?? true,
6543
+ order: ug.order ?? 100,
6544
+ items: []
6545
+ });
6573
6546
  }
6574
6547
  }
6575
- }
6576
- function encodeUlidTime(now2, len = 10) {
6577
- let str = "";
6578
- let time = Math.floor(now2);
6579
- for (let i = len - 1; i >= 0; i--) {
6580
- const mod = time % 32;
6581
- str = CROCKFORD_BASE32[mod] + str;
6582
- time = (time - mod) / 32;
6583
- }
6584
- return str;
6585
- }
6586
- function encodeUlidRandom(bytes) {
6587
- let str = "";
6588
- let bitBuffer = 0;
6589
- let bitsInBuffer = 0;
6590
- for (let i = 0; i < bytes.length; i++) {
6591
- bitBuffer = bitBuffer << 8 | bytes[i];
6592
- bitsInBuffer += 8;
6593
- while (bitsInBuffer >= 5) {
6594
- bitsInBuffer -= 5;
6595
- const index = bitBuffer >> bitsInBuffer & 31;
6596
- str += CROCKFORD_BASE32[index];
6548
+ for (const itemOpt of effectivePrefs.items || []) {
6549
+ const targetGroup = typeof itemOpt.group === "object" ? itemOpt.group?.name : itemOpt.group;
6550
+ const groupKey = targetGroup || "Custom";
6551
+ let group = findGroup(groupKey);
6552
+ if (!group) {
6553
+ group = {
6554
+ id: `group_${normalizeKey(groupKey)}`,
6555
+ name: groupKey,
6556
+ slug: normalizeKey(groupKey),
6557
+ icon: "Folder",
6558
+ defaultExpanded: true,
6559
+ order: 99,
6560
+ items: []
6561
+ };
6562
+ groupsList.push(group);
6563
+ }
6564
+ const itemId = itemOpt.slug ? `workspace_${itemOpt.slug}` : itemOpt.collection ? `collection_${itemOpt.collection}` : `custom_${Math.random()}`;
6565
+ for (const otherGroup of groupsList) {
6566
+ if (otherGroup !== group) {
6567
+ otherGroup.items = otherGroup.items.filter((i) => i.slug !== itemOpt.slug && i.id !== itemId);
6568
+ }
6569
+ }
6570
+ const existingIndex = group.items.findIndex((i) => i.id === itemId || i.slug === itemOpt.slug);
6571
+ const existingItem = existingIndex >= 0 ? group.items[existingIndex] : void 0;
6572
+ const col = schemas?.collections?.find((c) => c.slug === itemOpt.collection);
6573
+ const colViews = col?.views || [];
6574
+ const baseViews = existingItem?.views || [];
6575
+ const userViews = itemOpt.views || [];
6576
+ const userViewSlugs = new Set(userViews.map((v) => v.slug));
6577
+ let mergedViews = [
6578
+ ...baseViews.filter((v) => !userViewSlugs.has(v.slug)),
6579
+ ...userViews
6580
+ ];
6581
+ if (mergedViews.length === 0 && itemOpt.collection) {
6582
+ if (colViews.length > 0) {
6583
+ mergedViews = [...colViews];
6584
+ } else {
6585
+ mergedViews = [
6586
+ {
6587
+ slug: "default",
6588
+ label: col?.label || col?.labels?.plural || itemOpt.label || "All Records",
6589
+ layout: "table",
6590
+ collection: itemOpt.collection
6591
+ }
6592
+ ];
6593
+ }
6594
+ }
6595
+ mergedViews = mergedViews.map((v) => ({
6596
+ ...v,
6597
+ collection: v.collection || itemOpt.collection || existingItem?.collection
6598
+ }));
6599
+ const compiledItem = {
6600
+ id: itemId,
6601
+ type: itemOpt.slug ? "workspace" : itemOpt.collection ? "collection" : itemOpt.global ? "global" : "workspace",
6602
+ slug: itemOpt.slug || itemOpt.collection || itemOpt.global || "item",
6603
+ label: itemOpt.label || itemOpt.slug || "Item",
6604
+ icon: itemOpt.icon || existingItem?.icon || col?.admin?.icon || "Briefcase",
6605
+ group: group.name,
6606
+ order: itemOpt.order ?? existingItem?.order ?? 100,
6607
+ views: mergedViews,
6608
+ collection: itemOpt.collection || existingItem?.collection,
6609
+ global: itemOpt.global || existingItem?.global,
6610
+ badge: itemOpt.badge || existingItem?.badge,
6611
+ access: itemOpt.access || existingItem?.access
6612
+ };
6613
+ if (existingIndex >= 0) {
6614
+ group.items[existingIndex] = compiledItem;
6615
+ } else {
6616
+ group.items.push(compiledItem);
6597
6617
  }
6598
6618
  }
6599
- return str;
6600
- }
6601
- function ulid(seedTime = Date.now()) {
6602
- const timePart = encodeUlidTime(seedTime);
6603
- const randPart = encodeUlidRandom((0, import_node_crypto2.randomBytes)(10));
6604
- return timePart + randPart;
6605
- }
6606
- function uuid() {
6607
- return (0, import_node_crypto2.randomUUID)();
6608
- }
6609
- function resolvePrefix(slug, explicitPrefix) {
6610
- if (explicitPrefix && typeof explicitPrefix === "string" && explicitPrefix.trim().length > 0) {
6611
- const cleaned = explicitPrefix.trim().toLowerCase().replace(/[^a-z0-9_]/g, "");
6612
- if (cleaned.length > 0) return cleaned;
6613
- }
6614
- if (!slug || typeof slug !== "string") return "col";
6615
- const s = slug.trim().toLowerCase();
6616
- if (COMMON_SLUG_PREFIXES[s]) return COMMON_SLUG_PREFIXES[s];
6617
- const pieces = s.split(/[-_]/).filter(Boolean);
6618
- if (pieces.length > 1 && pieces[0].length >= 2) {
6619
- const first2 = pieces[0].replace(/[^a-z0-9]/g, "");
6620
- if (first2.length >= 2) return first2.slice(0, 4);
6621
- }
6622
- const cleanWord = s.replace(/[^a-z0-9]/g, "");
6623
- if (cleanWord.length <= 4) return cleanWord || "col";
6624
- return cleanWord.slice(0, 3) || "col";
6625
- }
6626
- function prefixedId(prefix = "col", size = 16) {
6627
- const cleanPrefix = (prefix || "col").toLowerCase().replace(/[^a-z0-9_]/g, "") || "col";
6628
- return `${cleanPrefix}_${nanoid(size, BASE62_ALPHABET)}`;
6629
- }
6630
- function generateId(options) {
6631
- if (typeof options === "string") {
6632
- return prefixedId(resolvePrefix(options));
6633
- }
6634
- const opts = options || {};
6635
- const type = opts.type || "prefixed-nanoid";
6636
- switch (type) {
6637
- case "uuid":
6638
- return uuid();
6639
- case "ulid":
6640
- return ulid();
6641
- case "nanoid":
6642
- return nanoid(opts.size || 21);
6643
- case "prefixed-nanoid":
6644
- default:
6645
- return prefixedId(opts.prefix || "col", opts.size || 16);
6646
- }
6647
- }
6648
- function generateDocumentId(collectionOrSlug) {
6649
- if (!collectionOrSlug) {
6650
- return prefixedId("col");
6619
+ const allAvailableItems = [];
6620
+ const reconciledGroups = [];
6621
+ for (const group of groupsList) {
6622
+ const isGroupHidden = hiddenSet.has(group.id) || !!group.slug && hiddenSet.has(group.slug) || hiddenSet.has(group.name);
6623
+ if (!includeHidden && isGroupHidden) {
6624
+ continue;
6625
+ }
6626
+ const visibleItems = [];
6627
+ for (const item of group.items) {
6628
+ const isItemHidden = hiddenSet.has(item.id) || !!item.slug && hiddenSet.has(item.slug);
6629
+ if (!includeHidden && isItemHidden) {
6630
+ continue;
6631
+ }
6632
+ const allViews = item.views || [];
6633
+ const visibleViews = includeHidden ? allViews : allViews.filter((v) => {
6634
+ const compoundKey = `${item.slug}_${v.slug}`;
6635
+ return !hiddenSet.has(compoundKey) && !hiddenSet.has(v.slug);
6636
+ });
6637
+ let isTombstone = false;
6638
+ if (schemas && schemas.collections && schemas.collections.length > 0) {
6639
+ if (item.collection && !knownCollectionSlugs.has(item.collection)) {
6640
+ isTombstone = true;
6641
+ }
6642
+ }
6643
+ if (schemas && schemas.globals && schemas.globals.length > 0) {
6644
+ if (item.global && !knownGlobalSlugs.has(item.global)) {
6645
+ isTombstone = true;
6646
+ }
6647
+ }
6648
+ const processedItem = {
6649
+ ...item,
6650
+ views: visibleViews,
6651
+ ...isTombstone ? { isTombstone: true } : {}
6652
+ };
6653
+ visibleItems.push(processedItem);
6654
+ allAvailableItems.push(processedItem);
6655
+ }
6656
+ const groupKey = group.slug || group.name;
6657
+ const itemOrderList = effectivePrefs.itemOrder?.[group.id] || effectivePrefs.itemOrder?.[groupKey];
6658
+ if (itemOrderList && itemOrderList.length > 0) {
6659
+ visibleItems.sort((a, b) => {
6660
+ const aIdx = itemOrderList.indexOf(a.id) >= 0 ? itemOrderList.indexOf(a.id) : a.slug ? itemOrderList.indexOf(a.slug) : -1;
6661
+ const bIdx = itemOrderList.indexOf(b.id) >= 0 ? itemOrderList.indexOf(b.id) : b.slug ? itemOrderList.indexOf(b.slug) : -1;
6662
+ if (aIdx >= 0 && bIdx >= 0) return aIdx - bIdx;
6663
+ if (aIdx >= 0) return -1;
6664
+ if (bIdx >= 0) return 1;
6665
+ return (a.order ?? 100) - (b.order ?? 100);
6666
+ });
6667
+ } else {
6668
+ visibleItems.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
6669
+ }
6670
+ reconciledGroups.push({
6671
+ ...group,
6672
+ items: visibleItems
6673
+ });
6651
6674
  }
6652
- if (typeof collectionOrSlug === "string") {
6653
- const prefix2 = resolvePrefix(collectionOrSlug);
6654
- return prefixedId(prefix2);
6675
+ if (effectivePrefs.groupOrder && effectivePrefs.groupOrder.length > 0) {
6676
+ reconciledGroups.sort((a, b) => {
6677
+ 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);
6678
+ 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);
6679
+ if (aIdx >= 0 && bIdx >= 0) return aIdx - bIdx;
6680
+ if (aIdx >= 0) return -1;
6681
+ if (bIdx >= 0) return 1;
6682
+ return (a.order ?? 100) - (b.order ?? 100);
6683
+ });
6684
+ } else {
6685
+ reconciledGroups.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
6655
6686
  }
6656
- if (typeof collectionOrSlug.idGenerator === "function") {
6657
- return collectionOrSlug.idGenerator(collectionOrSlug.slug);
6687
+ const reconciledUngrouped = (baseTree.ungrouped || []).filter((item) => {
6688
+ const isItemHidden = hiddenSet.has(item.id) || !!item.slug && hiddenSet.has(item.slug);
6689
+ return includeHidden || !isItemHidden;
6690
+ }).map((item) => {
6691
+ const allViews = item.views || [];
6692
+ const visibleViews = includeHidden ? allViews : allViews.filter((v) => {
6693
+ const compoundKey = `${item.slug}_${v.slug}`;
6694
+ return !hiddenSet.has(compoundKey) && !hiddenSet.has(v.slug);
6695
+ });
6696
+ const processed = { ...item, views: visibleViews };
6697
+ allAvailableItems.push(processed);
6698
+ return processed;
6699
+ });
6700
+ const pinnedItems = [];
6701
+ for (const pin of effectivePrefs.pinned || []) {
6702
+ const match = allAvailableItems.find((i) => i.slug === pin.slug || i.id === pin.slug);
6703
+ if (match) {
6704
+ pinnedItems.push({
6705
+ ...match,
6706
+ id: `pin_${match.id}`,
6707
+ label: pin.label || match.label,
6708
+ icon: pin.icon || match.icon
6709
+ });
6710
+ } else {
6711
+ pinnedItems.push({
6712
+ id: `pin_${pin.slug}`,
6713
+ type: pin.type,
6714
+ slug: pin.slug,
6715
+ label: pin.label || pin.slug,
6716
+ icon: pin.icon || "Star",
6717
+ order: 0,
6718
+ views: []
6719
+ });
6720
+ }
6658
6721
  }
6659
- const type = collectionOrSlug.idType || "prefixed-nanoid";
6660
- const prefix = resolvePrefix(collectionOrSlug.slug, collectionOrSlug.idPrefix);
6661
- return generateId({ prefix, type });
6662
- }
6663
-
6664
- // src/workflows.ts
6665
- var WORKFLOW_HISTORY_COLLECTION = "__workflow_history";
6666
- var LIFECYCLE_EVENTS_COLLECTION = "__lifecycle_events";
6667
- function simplePublishingWorkflow() {
6668
6722
  return {
6669
- initialState: "draft",
6670
- draftState: "draft",
6671
- states: [
6672
- { name: "draft", label: "Draft", color: "neutral" },
6673
- { name: "published", label: "Published", color: "success", published: true }
6674
- ],
6675
- transitions: [
6676
- { name: "publish", label: "Publish", from: ["draft", "published"], to: "published" },
6677
- { name: "unpublish", label: "Unpublish", from: "published", to: "draft", unpublish: true }
6678
- ]
6679
- // Intentionally no `roles`: this lightweight workflow (synthesized from
6680
- // `drafts: true`) does no capability-based gating. Draft visibility for a
6681
- // no-roles workflow is handled in `canViewWorkflowDraft`.
6723
+ groups: reconciledGroups,
6724
+ ungrouped: reconciledUngrouped,
6725
+ pinnedItems
6682
6726
  };
6683
6727
  }
6684
- function publicMetadata(meta) {
6685
- const { availableTransitions: _availableTransitions, ...safe } = meta;
6686
- return safe;
6687
- }
6688
- function workflowCapabilities(workflow, user) {
6689
- const capabilities = new Set(Array.isArray(user?.capabilities) ? user.capabilities : []);
6690
- const roles = Array.isArray(user?.roles) ? user.roles : typeof user?.role === "string" ? [user.role] : [];
6691
- for (const mapping of workflow.roles ?? []) {
6692
- if (roles.includes(mapping.role)) mapping.capabilities.forEach((capability) => capabilities.add(capability));
6728
+
6729
+ // src/utils/navigation.ts
6730
+ var RESERVED_NAVIGATION_SLUGS = /* @__PURE__ */ new Set([
6731
+ "collections",
6732
+ "globals",
6733
+ "setup",
6734
+ "api",
6735
+ "login",
6736
+ "logout",
6737
+ "settings",
6738
+ "admin",
6739
+ "preferences",
6740
+ "auth"
6741
+ ]);
6742
+ function assertValidNavigationSlugs(navigation) {
6743
+ for (const item of navigation) {
6744
+ if (item.slug) {
6745
+ const normalizedSlug = item.slug.toLowerCase().trim().replace(/^\//, "");
6746
+ if (RESERVED_NAVIGATION_SLUGS.has(normalizedSlug)) {
6747
+ throw new Error(
6748
+ `[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.`
6749
+ );
6750
+ }
6751
+ }
6693
6752
  }
6694
- return capabilities;
6695
6753
  }
6696
- function canViewWorkflowDraft(workflow, user) {
6697
- if (!user) return false;
6698
- const capabilities = workflowCapabilities(workflow, user);
6699
- if (capabilities.has("entry.edit")) return true;
6700
- if (workflow.transitions.some(
6701
- (transition) => (transition.requiredCapabilities ?? []).some((capability) => capabilities.has(capability))
6702
- )) {
6703
- return true;
6754
+ function resolveGroupMeta(groupProp, defaultGroupName = "Collections") {
6755
+ if (!groupProp) {
6756
+ return { name: defaultGroupName };
6704
6757
  }
6705
- if (!workflow.roles || workflow.roles.length === 0) return true;
6706
- return false;
6707
- }
6708
- function availableWorkflowTransitions(workflow, state, user) {
6709
- const capabilities = workflowCapabilities(workflow, user);
6710
- return workflow.transitions.filter((transition) => {
6711
- const from = Array.isArray(transition.from) ? transition.from : [transition.from];
6712
- return from.includes(state) && (transition.requiredCapabilities ?? []).every((item) => capabilities.has(item));
6713
- });
6714
- }
6715
- function initializeWorkflowDocument(data, workflow) {
6716
- return {
6717
- ...data,
6718
- __workflow: { state: workflow.initialState, revision: 1 }
6719
- };
6720
- }
6721
- function publishedStateName(workflow) {
6722
- return workflow.states.find((state) => state.published)?.name ?? workflow.states[workflow.states.length - 1]?.name ?? workflow.initialState;
6758
+ if (typeof groupProp === "string") {
6759
+ return { name: groupProp };
6760
+ }
6761
+ return { name: groupProp.name, meta: groupProp };
6723
6762
  }
6724
- function materializeWorkflowDocument(doc, workflow, user) {
6725
- const meta = doc.__workflow;
6726
- const publishedState = publishedStateName(workflow);
6727
- if (!meta) {
6728
- const { __published: _legacyPublished, __workflow: _legacyWorkflow, ...working2 } = doc;
6729
- const state = publishedStateName(workflow);
6730
- return {
6731
- ...working2,
6732
- _workflow: {
6733
- state,
6734
- revision: 1,
6735
- availableTransitions: availableWorkflowTransitions(workflow, state, user).map((item) => item.name)
6736
- }
6737
- };
6763
+ function compileNavigation(config) {
6764
+ const collections = config.collections || [];
6765
+ const globals = config.globals || [];
6766
+ const explicitNav = config.admin?.navigation || [];
6767
+ assertValidNavigationSlugs(explicitNav);
6768
+ const collectionsBySlug = /* @__PURE__ */ new Map();
6769
+ for (const col of collections) {
6770
+ collectionsBySlug.set(col.slug, col);
6738
6771
  }
6739
- const { __published, __workflow, ...working } = doc;
6740
- if (!canViewWorkflowDraft(workflow, user)) {
6741
- if (!__published || typeof __published !== "object") {
6742
- if (meta.state === publishedState) {
6743
- return { ...working, _workflow: publicMetadata(meta) };
6772
+ const globalsBySlug = /* @__PURE__ */ new Map();
6773
+ for (const gl of globals) {
6774
+ globalsBySlug.set(gl.slug, gl);
6775
+ }
6776
+ const mentionedCollections = /* @__PURE__ */ new Set();
6777
+ const mentionedGlobals = /* @__PURE__ */ new Set();
6778
+ const injectedViewsByCollection = /* @__PURE__ */ new Map();
6779
+ for (const item of explicitNav) {
6780
+ if (item.addToCollection) {
6781
+ const existing = injectedViewsByCollection.get(item.addToCollection) || [];
6782
+ if (item.views && item.views.length > 0) {
6783
+ injectedViewsByCollection.set(item.addToCollection, [...existing, ...item.views]);
6744
6784
  }
6745
- return null;
6746
6785
  }
6747
- return { id: doc.id, ...__published, _workflow: publicMetadata(meta) };
6786
+ if (item.collection) mentionedCollections.add(item.collection);
6787
+ if (item.global) mentionedGlobals.add(item.global);
6748
6788
  }
6749
- return {
6750
- ...working,
6751
- _workflow: {
6752
- ...meta,
6753
- availableTransitions: availableWorkflowTransitions(workflow, meta.state, user).map((item) => item.name)
6789
+ const groupMetadataMap = /* @__PURE__ */ new Map();
6790
+ const itemsByGroup = /* @__PURE__ */ new Map();
6791
+ const ungroupedItems = [];
6792
+ const registerItem = (groupName, item) => {
6793
+ if (!groupName) {
6794
+ ungroupedItems.push(item);
6795
+ } else {
6796
+ const list = itemsByGroup.get(groupName) || [];
6797
+ list.push(item);
6798
+ itemsByGroup.set(groupName, list);
6754
6799
  }
6755
6800
  };
6756
- }
6757
- function eventId() {
6758
- return globalThis.crypto?.randomUUID?.() ?? `evt_${Date.now()}_${Math.random().toString(36).slice(2)}`;
6759
- }
6760
- function createLifecycleEvent(args) {
6761
- return {
6762
- id: eventId(),
6763
- name: args.name,
6764
- collection: args.collection,
6765
- documentId: args.documentId,
6766
- occurredAt: (/* @__PURE__ */ new Date()).toISOString(),
6767
- actorId: args.actorId,
6768
- payload: args.payload,
6769
- attempts: 0,
6770
- status: "pending"
6771
- };
6772
- }
6773
- async function persistEvent(db, event) {
6774
- await db.create({ collection: LIFECYCLE_EVENTS_COLLECTION, data: event });
6775
- }
6776
- async function dispatchLifecycleEvent(config, event) {
6777
- const db = config.db;
6778
- if (!db || !config.events?.handlers.length) return;
6779
- const maxAttempts = config.events.maxAttempts ?? 8;
6780
- const retryDelayMs = config.events.retryDelayMs ?? 1e3;
6781
- const attempts = event.attempts + 1;
6782
- try {
6783
- await db.update({
6784
- collection: LIFECYCLE_EVENTS_COLLECTION,
6785
- id: event.id,
6786
- data: { status: "processing", attempts }
6787
- });
6788
- for (const handler of config.events.handlers) await handler({ ...event, status: "processing", attempts });
6789
- await db.update({
6790
- collection: LIFECYCLE_EVENTS_COLLECTION,
6791
- id: event.id,
6792
- data: { status: "delivered", attempts, deliveredAt: (/* @__PURE__ */ new Date()).toISOString(), lastError: null }
6793
- });
6794
- } catch (error) {
6795
- const exhausted = attempts >= maxAttempts;
6796
- await db.update({
6797
- collection: LIFECYCLE_EVENTS_COLLECTION,
6798
- id: event.id,
6799
- data: {
6800
- status: "failed",
6801
- attempts,
6802
- lastError: error instanceof Error ? error.message : String(error),
6803
- nextAttemptAt: exhausted ? null : new Date(Date.now() + retryDelayMs * 2 ** (attempts - 1)).toISOString()
6804
- }
6805
- });
6806
- }
6807
- }
6808
- async function saveWorkflowDraft(args) {
6809
- const { config, collection, id, originalDoc, data, user, beforeCommit } = args;
6810
- const db = config.db;
6811
- const workflow = collection.workflow;
6812
- if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
6813
- const previous = originalDoc.__workflow;
6814
- const isLegacyPublishedDoc = !previous;
6815
- const baselineRevision = previous?.revision ?? 1;
6816
- const event = createLifecycleEvent({
6817
- name: "revision.created",
6818
- collection: collection.slug,
6819
- documentId: id,
6820
- actorId: user?.sub,
6821
- payload: {
6822
- revision: baselineRevision + 1,
6823
- previousRevision: isLegacyPublishedDoc ? baselineRevision : previous?.revision ?? null
6801
+ for (const item of explicitNav) {
6802
+ if (item.addToCollection && !item.slug && !item.collection && !item.global && !item.dashboard && !item.href) {
6803
+ continue;
6824
6804
  }
6825
- });
6826
- const doc = await db.transaction(async (tx) => {
6827
- const nextMeta = {
6828
- ...previous ?? {},
6829
- state: isLegacyPublishedDoc ? workflow.draftState ?? workflow.initialState : originalDoc.__published ? workflow.draftState ?? workflow.initialState : previous.state,
6830
- revision: baselineRevision + 1,
6831
- ...isLegacyPublishedDoc ? { publishedRevision: baselineRevision } : {}
6832
- };
6833
- const updateData = {
6834
- ...data,
6835
- __workflow: nextMeta
6836
- };
6837
- if (isLegacyPublishedDoc) {
6838
- const { __workflow: _legacyWorkflow, __published: _legacyPublished, ...publishedSnapshot } = originalDoc;
6839
- updateData.__published = publishedSnapshot;
6805
+ const { name: groupName, meta: groupMeta } = resolveGroupMeta(item.group, void 0);
6806
+ if (groupName && groupMeta) {
6807
+ groupMetadataMap.set(groupName, { ...groupMetadataMap.get(groupName) || {}, ...groupMeta });
6840
6808
  }
6841
- const updated = await tx.update({ collection: collection.slug, id, data: updateData });
6842
- await persistEvent(tx, event);
6843
- await beforeCommit?.(tx, updated);
6844
- return updated;
6845
- });
6846
- void dispatchLifecycleEvent(config, event);
6847
- return { doc, event };
6848
- }
6849
- async function createWorkflowDocument(args) {
6850
- const { config, collection, data, user, beforeCommit } = args;
6851
- const db = config.db;
6852
- if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
6853
- let event;
6854
- const doc = await db.transaction(async (tx) => {
6855
- const created = await tx.create({ collection: collection.slug, data });
6856
- event = createLifecycleEvent({
6857
- name: "revision.created",
6858
- collection: collection.slug,
6859
- documentId: created.id,
6860
- actorId: user?.sub,
6861
- payload: { revision: 1, previousRevision: null }
6862
- });
6863
- await persistEvent(tx, event);
6864
- await beforeCommit?.(tx, created);
6865
- return created;
6866
- });
6867
- void dispatchLifecycleEvent(config, event);
6868
- return { doc, event };
6869
- }
6870
- async function transitionWorkflow(args) {
6871
- const { config, collection, id, transitionName, expectedRevision, comment, input, user, req } = args;
6872
- const db = config.db;
6873
- const workflow = collection.workflow;
6874
- if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
6875
- const original = await db.findOne({ collection: collection.slug, id });
6876
- if (!original) throw Object.assign(new Error("Not Found"), { statusCode: 404 });
6877
- const meta = original.__workflow;
6878
- if (!meta) throw Object.assign(new Error("Entry has no workflow metadata"), { statusCode: 409 });
6879
- if (expectedRevision !== void 0 && expectedRevision !== meta.revision) {
6880
- throw Object.assign(new Error("This entry changed since it was loaded. Refresh before transitioning it."), {
6881
- statusCode: 409
6882
- });
6883
- }
6884
- const transition = workflow.transitions.find((item) => item.name === transitionName);
6885
- const fromStates = transition ? Array.isArray(transition.from) ? transition.from : [transition.from] : [];
6886
- if (!transition || !fromStates.includes(meta.state)) {
6887
- throw Object.assign(new Error(`Transition "${transitionName}" is not valid from "${meta.state}".`), {
6888
- statusCode: 409
6889
- });
6890
- }
6891
- if (!availableWorkflowTransitions(workflow, meta.state, user).some((item) => item.name === transition.name)) {
6892
- throw Object.assign(new Error(`You do not have permission to perform "${transition.label}".`), { statusCode: 403 });
6893
- }
6894
- if (transition.requireComment && !comment?.trim()) {
6895
- throw Object.assign(new Error(`A comment is required for "${transition.label}".`), { statusCode: 400 });
6896
- }
6897
- const hookContext = { transition, from: meta.state, to: transition.to, doc: original, user, comment, input, req, db };
6898
- for (const hook of workflow.hooks?.beforeTransition ?? []) await hook(hookContext);
6899
- const events = [
6900
- createLifecycleEvent({
6901
- name: "workflow.transitioned",
6902
- collection: collection.slug,
6903
- documentId: id,
6904
- actorId: user?.sub,
6905
- payload: { transition: transition.name, from: meta.state, to: transition.to, revision: meta.revision, comment }
6906
- })
6907
- ];
6908
- const targetState = workflow.states.find((state) => state.name === transition.to);
6909
- if (targetState.published) {
6910
- events.push(
6911
- createLifecycleEvent({
6912
- name: "entry.published",
6913
- collection: collection.slug,
6914
- documentId: id,
6915
- actorId: user?.sub,
6916
- payload: { revision: meta.revision }
6917
- })
6918
- );
6919
- } else if (transition.unpublish) {
6920
- events.push(
6921
- createLifecycleEvent({
6922
- name: "entry.unpublished",
6923
- collection: collection.slug,
6924
- documentId: id,
6925
- actorId: user?.sub,
6926
- payload: { revision: meta.publishedRevision }
6927
- })
6928
- );
6929
- }
6930
- const updated = await db.transaction(async (tx) => {
6931
- const locked = await tx.findOne({ collection: collection.slug, id });
6932
- if (!locked) throw Object.assign(new Error("Not Found"), { statusCode: 404 });
6933
- const lockedMeta = locked.__workflow;
6934
- if (lockedMeta.revision !== meta.revision || lockedMeta.state !== meta.state || expectedRevision !== void 0 && lockedMeta.revision !== expectedRevision) {
6935
- throw Object.assign(new Error("This entry changed since it was loaded. Refresh before transitioning it."), {
6936
- statusCode: 409
6809
+ if (item.dashboard) {
6810
+ registerItem(groupName, {
6811
+ id: "dashboard",
6812
+ type: "dashboard",
6813
+ slug: "dashboard",
6814
+ label: item.label || "Dashboard",
6815
+ icon: item.icon || "LayoutDashboard",
6816
+ group: groupName,
6817
+ order: item.order ?? 0,
6818
+ views: [],
6819
+ access: item.access
6937
6820
  });
6821
+ continue;
6938
6822
  }
6939
- const now2 = (/* @__PURE__ */ new Date()).toISOString();
6940
- const { __published: _published, __workflow: _workflow, id: _id, ...working } = locked;
6941
- const nextMeta = {
6942
- ...lockedMeta,
6943
- state: transition.to,
6944
- ...targetState.published ? { publishedRevision: lockedMeta.revision, publishedAt: now2, publishedBy: user?.sub } : {},
6945
- ...transition.unpublish ? { publishedRevision: void 0, publishedAt: void 0, publishedBy: void 0 } : {}
6946
- };
6947
- const data = { __workflow: nextMeta };
6948
- if (targetState.published) data.__published = working;
6949
- if (transition.unpublish) data.__published = null;
6950
- const next = await tx.update({ collection: collection.slug, id, data });
6951
- if (transition.onTransition) {
6952
- await transition.onTransition({
6953
- transition,
6954
- from: lockedMeta.state,
6955
- to: transition.to,
6956
- doc: locked,
6957
- input,
6958
- comment,
6959
- user,
6960
- req,
6961
- tx
6823
+ if (item.collection) {
6824
+ const col = collectionsBySlug.get(item.collection);
6825
+ const combinedViews = [
6826
+ ...col?.views || [],
6827
+ ...injectedViewsByCollection.get(item.collection) || [],
6828
+ ...item.views || []
6829
+ ];
6830
+ registerItem(groupName, {
6831
+ id: item.slug || `collection_${item.collection}`,
6832
+ type: item.slug ? "workspace" : "collection",
6833
+ slug: item.slug || item.collection,
6834
+ collection: item.collection,
6835
+ label: item.label || col?.labels?.plural || item.collection,
6836
+ icon: item.icon || col?.admin?.icon || "Folder",
6837
+ group: groupName,
6838
+ order: item.order ?? col?.admin?.order ?? 100,
6839
+ views: combinedViews,
6840
+ badge: item.badge,
6841
+ access: item.access || col?.access?.read
6962
6842
  });
6843
+ continue;
6963
6844
  }
6964
- await tx.create({
6965
- collection: WORKFLOW_HISTORY_COLLECTION,
6966
- data: {
6967
- collection: collection.slug,
6968
- documentId: id,
6969
- transition: transition.name,
6970
- from: lockedMeta.state,
6971
- to: transition.to,
6972
- revision: lockedMeta.revision,
6973
- comment: comment ?? null,
6974
- actorId: user?.sub ?? null,
6975
- createdAt: now2
6976
- }
6977
- });
6978
- for (const event of events) await persistEvent(tx, event);
6979
- if (collection.audit) {
6980
- await tx.create({
6981
- collection: "__audit",
6982
- data: {
6983
- collection: collection.slug,
6984
- documentId: id,
6985
- operation: "workflow.transition",
6986
- user: user?.sub ?? null,
6987
- timestamp: now2,
6988
- changes: JSON.stringify({
6989
- transition: transition.name,
6990
- from: lockedMeta.state,
6991
- to: transition.to,
6992
- revision: lockedMeta.revision
6993
- })
6994
- }
6845
+ if (item.global) {
6846
+ const gl = globalsBySlug.get(item.global);
6847
+ registerItem(groupName, {
6848
+ id: item.slug || `global_${item.global}`,
6849
+ type: "global",
6850
+ slug: item.slug || item.global,
6851
+ global: item.global,
6852
+ label: item.label || gl?.label || item.global,
6853
+ icon: item.icon || "Settings",
6854
+ group: groupName,
6855
+ order: item.order ?? 100,
6856
+ views: [],
6857
+ badge: item.badge,
6858
+ access: item.access
6995
6859
  });
6860
+ continue;
6996
6861
  }
6997
- return next;
6998
- });
6999
- for (const event of events) void dispatchLifecycleEvent(config, event);
7000
- for (const hook of workflow.hooks?.afterTransition ?? []) {
7001
- try {
7002
- await hook({ ...hookContext, doc: updated, event: events[0] });
7003
- } catch (error) {
7004
- try {
7005
- const obs = config?.__observability;
7006
- obs?.recordWorkflowHookFailure?.({
7007
- collection: collection.slug,
7008
- transition: transition.name
7009
- });
7010
- const logger = config?.logger;
7011
- if (logger && typeof logger.child === "function") {
7012
- logger.child({ component: "workflow" }).error({
7013
- err: error,
7014
- msg: "afterTransition hook failed",
7015
- collection: collection.slug,
7016
- transition: transition.name,
7017
- documentId: id
7018
- });
7019
- } else if (logger && typeof logger.error === "function") {
7020
- logger.error({
7021
- err: error,
7022
- msg: "afterTransition hook failed",
7023
- collection: collection.slug,
7024
- transition: transition.name,
7025
- documentId: id
7026
- });
7027
- } else {
7028
- console.error("afterTransition hook failed:", error);
7029
- }
7030
- } catch {
7031
- console.error("afterTransition hook failed:", error);
7032
- }
6862
+ if (item.href) {
6863
+ registerItem(groupName, {
6864
+ id: `link_${item.slug || item.href}`,
6865
+ type: "link",
6866
+ slug: item.slug || "link",
6867
+ label: item.label || "Link",
6868
+ href: item.href,
6869
+ icon: item.icon || "ExternalLink",
6870
+ group: groupName,
6871
+ order: item.order ?? 100,
6872
+ views: [],
6873
+ badge: item.badge,
6874
+ access: item.access
6875
+ });
6876
+ continue;
7033
6877
  }
6878
+ const workspaceSlug = item.slug || "workspace";
6879
+ registerItem(groupName, {
6880
+ id: `workspace_${workspaceSlug}`,
6881
+ type: "workspace",
6882
+ slug: workspaceSlug,
6883
+ label: item.label || workspaceSlug,
6884
+ icon: item.icon || "Briefcase",
6885
+ group: groupName,
6886
+ order: item.order ?? 100,
6887
+ views: item.views || [],
6888
+ badge: item.badge,
6889
+ access: item.access
6890
+ });
7034
6891
  }
7035
- return updated;
7036
- }
7037
-
7038
- // src/detail.ts
7039
- function normalizeDetailItem(item) {
7040
- if (typeof item === "string") {
7041
- return {
7042
- type: "field",
7043
- field: item
7044
- };
6892
+ for (const col of collections) {
6893
+ if (mentionedCollections.has(col.slug)) continue;
6894
+ if (col.admin?.hidden) continue;
6895
+ const groupProp = col.admin?.group;
6896
+ const { name: groupName, meta: groupMeta } = resolveGroupMeta(groupProp, "Collections");
6897
+ if (groupMeta) {
6898
+ groupMetadataMap.set(groupName, { ...groupMetadataMap.get(groupName) || {}, ...groupMeta });
6899
+ }
6900
+ const combinedViews = [
6901
+ ...col.views || [],
6902
+ ...injectedViewsByCollection.get(col.slug) || []
6903
+ ];
6904
+ registerItem(groupName, {
6905
+ id: `collection_${col.slug}`,
6906
+ type: "collection",
6907
+ slug: col.slug,
6908
+ collection: col.slug,
6909
+ label: col.labels?.plural || col.slug,
6910
+ icon: col.admin?.icon || "Folder",
6911
+ group: groupName,
6912
+ order: col?.admin?.order ?? 100,
6913
+ views: combinedViews,
6914
+ access: col.access?.read
6915
+ });
7045
6916
  }
7046
- return item;
7047
- }
7048
- async function evaluateDetailComputed(detail, doc, user, db) {
7049
- if (!doc || !detail || !Array.isArray(detail)) {
7050
- return doc;
6917
+ for (const gl of globals) {
6918
+ if (mentionedGlobals.has(gl.slug)) continue;
6919
+ const groupName = "Configuration";
6920
+ registerItem(groupName, {
6921
+ id: `global_${gl.slug}`,
6922
+ type: "global",
6923
+ slug: gl.slug,
6924
+ global: gl.slug,
6925
+ label: gl.label || gl.slug,
6926
+ icon: "Settings",
6927
+ group: groupName,
6928
+ order: 100,
6929
+ views: []
6930
+ });
7051
6931
  }
7052
- const computedItems = [];
7053
- const extractComputed = (items) => {
7054
- for (const rawItem of items) {
7055
- if (!rawItem) continue;
7056
- const item = normalizeDetailItem(rawItem);
7057
- if (item.type === "computed") {
7058
- const id = item.id || item.label?.toLowerCase().replace(/[^a-z0-9]+/g, "_");
7059
- if (id) {
7060
- computedItems.push({
7061
- id,
7062
- label: item.label,
7063
- expression: item.expression,
7064
- handler: item.handler
7065
- });
6932
+ const sortAndSpliceItems = (items) => {
6933
+ const sorted = [...items].sort((a, b) => a.order - b.order);
6934
+ const explicitMap = /* @__PURE__ */ new Map();
6935
+ for (const opt of explicitNav) {
6936
+ if (opt.slug) explicitMap.set(opt.slug, opt);
6937
+ if (opt.collection) explicitMap.set(opt.collection, opt);
6938
+ if (opt.global) explicitMap.set(opt.global, opt);
6939
+ }
6940
+ for (const opt of explicitNav) {
6941
+ const itemKey = opt.slug || opt.collection || opt.global;
6942
+ if (!itemKey) continue;
6943
+ if (opt.after) {
6944
+ const itemIdx = sorted.findIndex((i) => i.slug === itemKey);
6945
+ const targetIdx = sorted.findIndex((i) => i.slug === opt.after);
6946
+ if (itemIdx !== -1 && targetIdx !== -1 && itemIdx !== targetIdx) {
6947
+ const [moved] = sorted.splice(itemIdx, 1);
6948
+ const newTargetIdx = sorted.findIndex((i) => i.slug === opt.after);
6949
+ sorted.splice(newTargetIdx + 1, 0, moved);
7066
6950
  }
7067
- } else if (item.type === "section" || item.type === "grid" || item.type === "repeat") {
7068
- if (item.items && Array.isArray(item.items)) {
7069
- extractComputed(item.items);
6951
+ } else if (opt.before) {
6952
+ const itemIdx = sorted.findIndex((i) => i.slug === itemKey);
6953
+ const targetIdx = sorted.findIndex((i) => i.slug === opt.before);
6954
+ if (itemIdx !== -1 && targetIdx !== -1 && itemIdx !== targetIdx) {
6955
+ const [moved] = sorted.splice(itemIdx, 1);
6956
+ const newTargetIdx = sorted.findIndex((i) => i.slug === opt.before);
6957
+ sorted.splice(newTargetIdx, 0, moved);
7070
6958
  }
7071
- } else if (item.type === "tabs" && item.tabs && Array.isArray(item.tabs)) {
7072
- for (const tab of item.tabs) {
7073
- if (tab.items) extractComputed(tab.items);
6959
+ } else if (opt.position === "first") {
6960
+ const itemIdx = sorted.findIndex((i) => i.slug === itemKey);
6961
+ if (itemIdx > 0) {
6962
+ const [moved] = sorted.splice(itemIdx, 1);
6963
+ sorted.unshift(moved);
6964
+ }
6965
+ } else if (opt.position === "last") {
6966
+ const itemIdx = sorted.findIndex((i) => i.slug === itemKey);
6967
+ if (itemIdx !== -1 && itemIdx < sorted.length - 1) {
6968
+ const [moved] = sorted.splice(itemIdx, 1);
6969
+ sorted.push(moved);
7074
6970
  }
7075
6971
  }
7076
6972
  }
6973
+ return sorted;
7077
6974
  };
7078
- extractComputed(detail);
7079
- if (computedItems.length === 0) return doc;
7080
- const evaluated = {};
7081
- for (const item of computedItems) {
7082
- try {
7083
- if (typeof item.handler === "function") {
7084
- evaluated[item.id] = await item.handler({ doc, user, db });
7085
- } else if (item.expression) {
7086
- evaluated[item.id] = await evaluateJexl(item.expression, { doc, user });
7087
- }
7088
- } catch (_err) {
7089
- evaluated[item.id] = null;
7090
- }
6975
+ const compiledGroups = [];
6976
+ for (const [groupName, rawItems] of itemsByGroup.entries()) {
6977
+ const meta = groupMetadataMap.get(groupName);
6978
+ const sortedItems = sortAndSpliceItems(rawItems);
6979
+ compiledGroups.push({
6980
+ id: `group_${groupName.toLowerCase().replace(/\s+/g, "_")}`,
6981
+ name: groupName,
6982
+ slug: meta?.slug || groupName.toLowerCase().replace(/\s+/g, "-"),
6983
+ icon: meta?.icon,
6984
+ defaultExpanded: meta?.defaultExpanded ?? true,
6985
+ order: meta?.order ?? 100,
6986
+ items: sortedItems
6987
+ });
7091
6988
  }
6989
+ compiledGroups.sort((a, b) => a.order - b.order);
7092
6990
  return {
7093
- ...doc,
7094
- _meta: {
7095
- ...doc._meta || {},
7096
- computed: evaluated
7097
- }
6991
+ groups: compiledGroups,
6992
+ ungrouped: sortAndSpliceItems(ungroupedItems)
7098
6993
  };
7099
6994
  }
7100
-
7101
- // src/controllers/collection.controller.ts
7102
- init_rag_service();
7103
- var import_http_exception2 = require("hono/http-exception");
7104
-
7105
- // src/utils/tenant.ts
7106
- var import_http_exception = require("hono/http-exception");
7107
- function getAllowedSitesForUser(user, payload) {
7108
- const raw = user?.allowedSites ?? user?.siteIds ?? user?.siteAccess ?? (user?.siteId ? [user.siteId] : void 0) ?? payload?.allowedSites ?? payload?.siteIds ?? payload?.siteAccess ?? (payload?.siteId ? [payload.siteId] : void 0);
7109
- if (Array.isArray(raw)) {
7110
- return raw.filter((s) => typeof s === "string" && s.trim().length > 0);
6995
+ function pruneNavigationForUser(tree, user) {
6996
+ const userRoles = /* @__PURE__ */ new Set();
6997
+ if (user?.role) userRoles.add(user.role);
6998
+ if (Array.isArray(user?.roles)) {
6999
+ for (const r of user.roles) userRoles.add(r);
7111
7000
  }
7112
- return void 0;
7113
- }
7114
- function isSiteAuthorized(user, payload, requestedSiteId) {
7115
- const allowedSites = getAllowedSitesForUser(user, payload);
7116
- if (!allowedSites || allowedSites.length === 0) {
7001
+ const isAllowed = (item) => {
7002
+ if (!item.access) return true;
7003
+ if (Array.isArray(item.access)) {
7004
+ if (item.access.length === 0) return true;
7005
+ return item.access.some((role) => userRoles.has(role));
7006
+ }
7117
7007
  return true;
7008
+ };
7009
+ const prunedGroups = [];
7010
+ for (const group of tree.groups) {
7011
+ const visibleItems = group.items.filter(isAllowed);
7012
+ if (visibleItems.length > 0) {
7013
+ prunedGroups.push({
7014
+ ...group,
7015
+ items: visibleItems
7016
+ });
7017
+ }
7118
7018
  }
7119
- if (!requestedSiteId) {
7120
- return false;
7121
- }
7122
- return allowedSites.includes(requestedSiteId);
7019
+ return {
7020
+ groups: prunedGroups,
7021
+ ungrouped: tree.ungrouped.filter(isAllowed)
7022
+ };
7123
7023
  }
7124
- function resolveAuthorizedSiteId(c) {
7125
- const user = typeof c.get === "function" ? c.get("user") : void 0;
7126
- const tokenPayload = typeof c.get === "function" ? c.get("authTokenPayload") : void 0;
7127
- const requestedSiteId = (typeof c.req?.header === "function" ? c.req.header("X-Site-Id") : void 0) || (typeof c.get === "function" ? c.get("siteId") : void 0);
7128
- const allowedSites = getAllowedSitesForUser(user, tokenPayload);
7129
- if (Array.isArray(allowedSites) && allowedSites.length > 0) {
7130
- if (!requestedSiteId) {
7131
- return allowedSites[0];
7132
- }
7133
- if (!allowedSites.includes(requestedSiteId)) {
7134
- throw new import_http_exception.HTTPException(403, {
7135
- message: `Forbidden: User is not authorized to access site "${requestedSiteId}".`
7136
- });
7024
+ function resolveAllCollectionViews(collection, config) {
7025
+ const views = [...collection.views ?? []];
7026
+ const seenSlugs = new Set(views.map((v) => v.slug));
7027
+ const explicitNav = config?.admin?.navigation;
7028
+ if (Array.isArray(explicitNav)) {
7029
+ for (const item of explicitNav) {
7030
+ if (!item) continue;
7031
+ const itemCol = item.collection || item.addToCollection;
7032
+ if (Array.isArray(item.views)) {
7033
+ for (const view of item.views) {
7034
+ if (!view || !view.slug) continue;
7035
+ const targetCol = view.collection || itemCol;
7036
+ if (targetCol === collection.slug && !seenSlugs.has(view.slug)) {
7037
+ views.push(view);
7038
+ seenSlugs.add(view.slug);
7039
+ }
7040
+ }
7041
+ }
7137
7042
  }
7138
- return requestedSiteId;
7139
7043
  }
7140
- return requestedSiteId || "default";
7044
+ return views;
7141
7045
  }
7142
7046
 
7143
7047
  // src/controllers/collection.controller.ts
7144
- var CollectionController = class {
7145
- collection;
7146
- constructor(collection) {
7147
- this.collection = collection;
7148
- }
7149
- checkTenantAccess(c) {
7150
- let siteId;
7151
- try {
7152
- const hasSiteHeader = Boolean(c.req.header("X-Site-Id") || c.get("siteId"));
7153
- const allowedSites = getAllowedSitesForUser(c.get("user"), c.get("authTokenPayload"));
7154
- if (hasSiteHeader || allowedSites && allowedSites.length > 0) {
7155
- siteId = resolveAuthorizedSiteId(c);
7156
- }
7157
- } catch (err) {
7158
- if (err instanceof import_http_exception2.HTTPException) {
7159
- return {
7160
- ok: false,
7161
- response: c.json({ error: true, message: err.message }, err.status)
7162
- };
7163
- }
7164
- throw err;
7048
+ init_access_control();
7049
+
7050
+ // src/utils/action-mutation.ts
7051
+ function resolveActionMutation(mutation, ctx) {
7052
+ if (!mutation) return { ...ctx.input ?? {} };
7053
+ return resolveValue(mutation, ctx);
7054
+ }
7055
+ function resolveValue(value, ctx) {
7056
+ if (typeof value === "string") return resolveExpression(value, ctx);
7057
+ if (Array.isArray(value)) return value.map((item) => resolveValue(item, ctx));
7058
+ if (isPlainObject2(value)) {
7059
+ const out = {};
7060
+ for (const [key, entry] of Object.entries(value)) {
7061
+ out[key] = resolveValue(entry, ctx);
7165
7062
  }
7166
- if (this.collection.siteId && !this.collection.shared) {
7167
- if (!siteId || this.collection.siteId !== siteId) {
7168
- return {
7169
- ok: false,
7170
- response: c.json(
7171
- { error: true, message: `Collection "${this.collection.slug}" not found in project.` },
7172
- 404
7173
- )
7174
- };
7063
+ return out;
7064
+ }
7065
+ return value;
7066
+ }
7067
+ function resolveExpression(expression, ctx) {
7068
+ if (expression === "now()") return (/* @__PURE__ */ new Date()).toISOString();
7069
+ const inputPath = expression.match(/^input\.([A-Za-z0-9_.-]+)$/)?.[1];
7070
+ if (inputPath !== void 0) return getPath(ctx.input, inputPath);
7071
+ const docPath = expression.match(/^doc\.([A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*)$/)?.[1];
7072
+ if (docPath !== void 0) return getPath(ctx.doc, docPath);
7073
+ const userPath = expression.match(/^user\.([A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*)$/)?.[1];
7074
+ if (userPath !== void 0 && ctx.user) return getPath(ctx.user, userPath);
7075
+ return expression;
7076
+ }
7077
+ function getPath(source, path) {
7078
+ let cursor = source;
7079
+ for (const segment of path.split(".")) {
7080
+ if (!cursor || typeof cursor !== "object") return void 0;
7081
+ cursor = cursor[segment];
7082
+ }
7083
+ return cursor;
7084
+ }
7085
+ function isPlainObject2(value) {
7086
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7087
+ }
7088
+
7089
+ // src/utils/id.ts
7090
+ var import_node_crypto2 = require("crypto");
7091
+ var NANOID_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-";
7092
+ var BASE62_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
7093
+ var CROCKFORD_BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
7094
+ var COMMON_SLUG_PREFIXES = {
7095
+ users: "usr",
7096
+ members: "mem",
7097
+ accounts: "acc",
7098
+ applications: "app",
7099
+ customers: "cus",
7100
+ products: "prd",
7101
+ orders: "ord",
7102
+ reports: "rpt",
7103
+ sessions: "ses",
7104
+ transactions: "txn",
7105
+ wallets: "wal",
7106
+ media: "med",
7107
+ files: "fil",
7108
+ posts: "post",
7109
+ pages: "page",
7110
+ tags: "tag",
7111
+ categories: "cat",
7112
+ comments: "com"
7113
+ };
7114
+ function nanoid(size = 21, alphabet = NANOID_ALPHABET) {
7115
+ const mask = (2 << 31 - Math.clz32(alphabet.length - 1 | 1)) - 1;
7116
+ const step = Math.ceil(1.6 * mask * size / alphabet.length);
7117
+ let id = "";
7118
+ while (true) {
7119
+ const bytes = (0, import_node_crypto2.randomBytes)(step);
7120
+ for (let i = 0; i < step; i++) {
7121
+ const byte = bytes[i] & mask;
7122
+ if (alphabet[byte]) {
7123
+ id += alphabet[byte];
7124
+ if (id.length === size) return id;
7175
7125
  }
7176
7126
  }
7177
- return { ok: true };
7178
7127
  }
7179
- getDelegatedProvider(c) {
7180
- const config = c.get("config");
7181
- const authCollection = getAdminAuthCollection(config);
7182
- if (!authCollection || this.collection.slug !== authCollection.slug) {
7183
- return null;
7128
+ }
7129
+ function encodeUlidTime(now2, len = 10) {
7130
+ let str = "";
7131
+ let time = Math.floor(now2);
7132
+ for (let i = len - 1; i >= 0; i--) {
7133
+ const mod = time % 32;
7134
+ str = CROCKFORD_BASE32[mod] + str;
7135
+ time = (time - mod) / 32;
7136
+ }
7137
+ return str;
7138
+ }
7139
+ function encodeUlidRandom(bytes) {
7140
+ let str = "";
7141
+ let bitBuffer = 0;
7142
+ let bitsInBuffer = 0;
7143
+ for (let i = 0; i < bytes.length; i++) {
7144
+ bitBuffer = bitBuffer << 8 | bytes[i];
7145
+ bitsInBuffer += 8;
7146
+ while (bitsInBuffer >= 5) {
7147
+ bitsInBuffer -= 5;
7148
+ const index = bitBuffer >> bitsInBuffer & 31;
7149
+ str += CROCKFORD_BASE32[index];
7184
7150
  }
7185
- return config.adminAuth?.providers?.find((p) => p.members) || null;
7186
7151
  }
7187
- toHookRequestContext(c) {
7188
- return toHookRequestContext(c.req);
7152
+ return str;
7153
+ }
7154
+ function ulid(seedTime = Date.now()) {
7155
+ const timePart = encodeUlidTime(seedTime);
7156
+ const randPart = encodeUlidRandom((0, import_node_crypto2.randomBytes)(10));
7157
+ return timePart + randPart;
7158
+ }
7159
+ function uuid() {
7160
+ return (0, import_node_crypto2.randomUUID)();
7161
+ }
7162
+ function resolvePrefix(slug, explicitPrefix) {
7163
+ if (explicitPrefix && typeof explicitPrefix === "string" && explicitPrefix.trim().length > 0) {
7164
+ const cleaned = explicitPrefix.trim().toLowerCase().replace(/[^a-z0-9_]/g, "");
7165
+ if (cleaned.length > 0) return cleaned;
7189
7166
  }
7190
- sanitizeDoc(doc) {
7191
- if (!doc || typeof doc !== "object") return doc;
7192
- if (this.collection.auth) {
7193
- const {
7194
- password: _p,
7195
- salt: _s,
7196
- resetPasswordToken: _rpt,
7197
- resetPasswordExpires: _rpe,
7198
- loginAttempts: _la,
7199
- lockUntil: _lu,
7200
- ...safeDoc
7201
- } = doc;
7202
- return safeDoc;
7203
- }
7204
- return doc;
7167
+ if (!slug || typeof slug !== "string") return "col";
7168
+ const s = slug.trim().toLowerCase();
7169
+ if (COMMON_SLUG_PREFIXES[s]) return COMMON_SLUG_PREFIXES[s];
7170
+ const pieces = s.split(/[-_]/).filter(Boolean);
7171
+ if (pieces.length > 1 && pieces[0].length >= 2) {
7172
+ const first2 = pieces[0].replace(/[^a-z0-9]/g, "");
7173
+ if (first2.length >= 2) return first2.slice(0, 4);
7205
7174
  }
7206
- async evaluateAccess(c, action, options = {}) {
7207
- const config = c.get("config");
7208
- return resolveCollectionAccess(
7209
- config,
7210
- this.collection.slug,
7211
- action,
7212
- this.collection.access?.[action],
7213
- {
7214
- id: options.id,
7215
- user: c.get("user"),
7216
- req: this.toHookRequestContext(c),
7217
- doc: options.doc ?? void 0,
7218
- data: options.data
7219
- }
7220
- );
7175
+ const cleanWord = s.replace(/[^a-z0-9]/g, "");
7176
+ if (cleanWord.length <= 4) return cleanWord || "col";
7177
+ return cleanWord.slice(0, 3) || "col";
7178
+ }
7179
+ function prefixedId(prefix = "col", size = 16) {
7180
+ const cleanPrefix = (prefix || "col").toLowerCase().replace(/[^a-z0-9_]/g, "") || "col";
7181
+ return `${cleanPrefix}_${nanoid(size, BASE62_ALPHABET)}`;
7182
+ }
7183
+ function generateId(options) {
7184
+ if (typeof options === "string") {
7185
+ return prefixedId(resolvePrefix(options));
7221
7186
  }
7222
- async find(c) {
7223
- const tenantCheck = this.checkTenantAccess(c);
7224
- if (!tenantCheck.ok) return tenantCheck.response;
7225
- const config = c.get("config");
7226
- const db = config.db;
7227
- if (!db) return c.json({ message: "Database not configured" }, 500);
7228
- const provider = this.getDelegatedProvider(c);
7229
- if (provider && provider.members?.list) {
7230
- const hookReq = this.toHookRequestContext(c);
7231
- const limit2 = Number(c.req.query("limit")) || 10;
7232
- const page2 = Number(c.req.query("page")) || 1;
7233
- const sort2 = c.req.query("sort") || void 0;
7234
- const search2 = c.req.query("search") || void 0;
7235
- let where2 = void 0;
7236
- const whereRaw2 = c.req.query("where");
7237
- if (whereRaw2) {
7238
- try {
7239
- where2 = JSON.parse(decodeURIComponent(whereRaw2));
7240
- } catch {
7241
- }
7242
- }
7243
- const searchWhere2 = await buildCollectionSearchWhere({
7244
- collection: this.collection,
7245
- search: search2,
7246
- db: createReadonlyDb(db),
7247
- collections: config.collections
7248
- });
7249
- if (searchWhere2) {
7250
- where2 = mergeWhereConstraint(where2, searchWhere2);
7187
+ const opts = options || {};
7188
+ const type = opts.type || "prefixed-nanoid";
7189
+ switch (type) {
7190
+ case "uuid":
7191
+ return uuid();
7192
+ case "ulid":
7193
+ return ulid();
7194
+ case "nanoid":
7195
+ return nanoid(opts.size || 21);
7196
+ case "prefixed-nanoid":
7197
+ default:
7198
+ return prefixedId(opts.prefix || "col", opts.size || 16);
7199
+ }
7200
+ }
7201
+ function generateDocumentId(collectionOrSlug) {
7202
+ if (!collectionOrSlug) {
7203
+ return prefixedId("col");
7204
+ }
7205
+ if (typeof collectionOrSlug === "string") {
7206
+ const prefix2 = resolvePrefix(collectionOrSlug);
7207
+ return prefixedId(prefix2);
7208
+ }
7209
+ if (typeof collectionOrSlug.idGenerator === "function") {
7210
+ return collectionOrSlug.idGenerator(collectionOrSlug.slug);
7211
+ }
7212
+ const type = collectionOrSlug.idType || "prefixed-nanoid";
7213
+ const prefix = resolvePrefix(collectionOrSlug.slug, collectionOrSlug.idPrefix);
7214
+ return generateId({ prefix, type });
7215
+ }
7216
+
7217
+ // src/workflows.ts
7218
+ var WORKFLOW_HISTORY_COLLECTION = "__workflow_history";
7219
+ var LIFECYCLE_EVENTS_COLLECTION = "__lifecycle_events";
7220
+ function simplePublishingWorkflow() {
7221
+ return {
7222
+ initialState: "draft",
7223
+ draftState: "draft",
7224
+ states: [
7225
+ { name: "draft", label: "Draft", color: "neutral" },
7226
+ { name: "published", label: "Published", color: "success", published: true }
7227
+ ],
7228
+ transitions: [
7229
+ { name: "publish", label: "Publish", from: ["draft", "published"], to: "published" },
7230
+ { name: "unpublish", label: "Unpublish", from: "published", to: "draft", unpublish: true }
7231
+ ]
7232
+ // Intentionally no `roles`: this lightweight workflow (synthesized from
7233
+ // `drafts: true`) does no capability-based gating. Draft visibility for a
7234
+ // no-roles workflow is handled in `canViewWorkflowDraft`.
7235
+ };
7236
+ }
7237
+ function publicMetadata(meta) {
7238
+ const { availableTransitions: _availableTransitions, ...safe } = meta;
7239
+ return safe;
7240
+ }
7241
+ function workflowCapabilities(workflow, user) {
7242
+ const capabilities = new Set(Array.isArray(user?.capabilities) ? user.capabilities : []);
7243
+ const roles = Array.isArray(user?.roles) ? user.roles : typeof user?.role === "string" ? [user.role] : [];
7244
+ for (const mapping of workflow.roles ?? []) {
7245
+ if (roles.includes(mapping.role)) mapping.capabilities.forEach((capability) => capabilities.add(capability));
7246
+ }
7247
+ return capabilities;
7248
+ }
7249
+ function canViewWorkflowDraft(workflow, user) {
7250
+ if (!user) return false;
7251
+ const capabilities = workflowCapabilities(workflow, user);
7252
+ if (capabilities.has("entry.edit")) return true;
7253
+ if (workflow.transitions.some(
7254
+ (transition) => (transition.requiredCapabilities ?? []).some((capability) => capabilities.has(capability))
7255
+ )) {
7256
+ return true;
7257
+ }
7258
+ if (!workflow.roles || workflow.roles.length === 0) return true;
7259
+ return false;
7260
+ }
7261
+ function availableWorkflowTransitions(workflow, state, user) {
7262
+ const capabilities = workflowCapabilities(workflow, user);
7263
+ return workflow.transitions.filter((transition) => {
7264
+ const from = Array.isArray(transition.from) ? transition.from : [transition.from];
7265
+ return from.includes(state) && (transition.requiredCapabilities ?? []).every((item) => capabilities.has(item));
7266
+ });
7267
+ }
7268
+ function initializeWorkflowDocument(data, workflow) {
7269
+ return {
7270
+ ...data,
7271
+ __workflow: { state: workflow.initialState, revision: 1 }
7272
+ };
7273
+ }
7274
+ function publishedStateName(workflow) {
7275
+ return workflow.states.find((state) => state.published)?.name ?? workflow.states[workflow.states.length - 1]?.name ?? workflow.initialState;
7276
+ }
7277
+ function materializeWorkflowDocument(doc, workflow, user) {
7278
+ const meta = doc.__workflow;
7279
+ const publishedState = publishedStateName(workflow);
7280
+ if (!meta) {
7281
+ const { __published: _legacyPublished, __workflow: _legacyWorkflow, ...working2 } = doc;
7282
+ const state = publishedStateName(workflow);
7283
+ return {
7284
+ ...working2,
7285
+ _workflow: {
7286
+ state,
7287
+ revision: 1,
7288
+ availableTransitions: availableWorkflowTransitions(workflow, state, user).map((item) => item.name)
7251
7289
  }
7252
- const paginatedResult = await provider.members.list({
7253
- limit: limit2,
7254
- page: page2,
7255
- sort: sort2,
7256
- where: where2,
7257
- req: hookReq
7258
- });
7259
- const mappedDocs = [];
7260
- for (const m of paginatedResult.docs) {
7261
- let localId = m.id;
7262
- const localDoc = await db.find({
7263
- collection: this.collection.slug,
7264
- where: m.id ? { externalSubject: { equals: m.id } } : { email: { equals: m.email } },
7265
- limit: 1
7266
- });
7267
- if (localDoc.docs[0]) {
7268
- localId = localDoc.docs[0].id;
7269
- }
7270
- mappedDocs.push({
7271
- ...m,
7272
- id: localId,
7273
- externalSubject: m.id
7274
- });
7290
+ };
7291
+ }
7292
+ const { __published, __workflow, ...working } = doc;
7293
+ if (!canViewWorkflowDraft(workflow, user)) {
7294
+ if (!__published || typeof __published !== "object") {
7295
+ if (meta.state === publishedState) {
7296
+ return { ...working, _workflow: publicMetadata(meta) };
7275
7297
  }
7276
- return c.json({
7277
- ...paginatedResult,
7278
- docs: mappedDocs
7279
- });
7298
+ return null;
7280
7299
  }
7281
- const readonlyDb = createReadonlyDb(db);
7282
- const limit = Math.min(Number(c.req.query("limit")) || 10, 100);
7283
- const page = Number(c.req.query("page")) || 1;
7284
- const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 1;
7285
- const sort = c.req.query("sort") || void 0;
7286
- const search = c.req.query("search") || void 0;
7287
- const user = c.get("user");
7288
- let where = void 0;
7289
- const whereRaw = c.req.query("where");
7290
- if (whereRaw) {
7291
- try {
7292
- where = JSON.parse(decodeURIComponent(whereRaw));
7293
- } catch {
7294
- }
7300
+ return { id: doc.id, ...__published, _workflow: publicMetadata(meta) };
7301
+ }
7302
+ return {
7303
+ ...working,
7304
+ _workflow: {
7305
+ ...meta,
7306
+ availableTransitions: availableWorkflowTransitions(workflow, meta.state, user).map((item) => item.name)
7295
7307
  }
7296
- if (where) {
7297
- if (this.collection.admin?.filterable === false) {
7298
- where = void 0;
7299
- } else {
7300
- const { sanitizeWhereClause: sanitizeWhereClause2 } = await Promise.resolve().then(() => (init_where_sanitizer(), where_sanitizer_exports));
7301
- where = sanitizeWhereClause2(where, this.collection.fields);
7302
- if (Object.keys(where).length === 0) {
7303
- where = void 0;
7304
- }
7308
+ };
7309
+ }
7310
+ function eventId() {
7311
+ return globalThis.crypto?.randomUUID?.() ?? `evt_${Date.now()}_${Math.random().toString(36).slice(2)}`;
7312
+ }
7313
+ function createLifecycleEvent(args) {
7314
+ return {
7315
+ id: eventId(),
7316
+ name: args.name,
7317
+ collection: args.collection,
7318
+ documentId: args.documentId,
7319
+ occurredAt: (/* @__PURE__ */ new Date()).toISOString(),
7320
+ actorId: args.actorId,
7321
+ payload: args.payload,
7322
+ attempts: 0,
7323
+ status: "pending"
7324
+ };
7325
+ }
7326
+ async function persistEvent(db, event) {
7327
+ await db.create({ collection: LIFECYCLE_EVENTS_COLLECTION, data: event });
7328
+ }
7329
+ async function dispatchLifecycleEvent(config, event) {
7330
+ const db = config.db;
7331
+ if (!db || !config.events?.handlers.length) return;
7332
+ const maxAttempts = config.events.maxAttempts ?? 8;
7333
+ const retryDelayMs = config.events.retryDelayMs ?? 1e3;
7334
+ const attempts = event.attempts + 1;
7335
+ try {
7336
+ await db.update({
7337
+ collection: LIFECYCLE_EVENTS_COLLECTION,
7338
+ id: event.id,
7339
+ data: { status: "processing", attempts }
7340
+ });
7341
+ for (const handler of config.events.handlers) await handler({ ...event, status: "processing", attempts });
7342
+ await db.update({
7343
+ collection: LIFECYCLE_EVENTS_COLLECTION,
7344
+ id: event.id,
7345
+ data: { status: "delivered", attempts, deliveredAt: (/* @__PURE__ */ new Date()).toISOString(), lastError: null }
7346
+ });
7347
+ } catch (error) {
7348
+ const exhausted = attempts >= maxAttempts;
7349
+ await db.update({
7350
+ collection: LIFECYCLE_EVENTS_COLLECTION,
7351
+ id: event.id,
7352
+ data: {
7353
+ status: "failed",
7354
+ attempts,
7355
+ lastError: error instanceof Error ? error.message : String(error),
7356
+ nextAttemptAt: exhausted ? null : new Date(Date.now() + retryDelayMs * 2 ** (attempts - 1)).toISOString()
7305
7357
  }
7306
- }
7307
- const searchWhere = await buildCollectionSearchWhere({
7308
- collection: this.collection,
7309
- search,
7310
- db: readonlyDb,
7311
- collections: config.collections
7312
7358
  });
7313
- if (searchWhere) {
7314
- where = mergeWhereConstraint(where, searchWhere);
7315
- }
7316
- const beforeReadResult = await runCollectionHooks(
7317
- this.collection.hooks?.beforeRead,
7318
- {
7319
- req: c.req,
7320
- query: where,
7321
- user,
7322
- db: readonlyDb
7323
- },
7324
- {
7325
- surface: "collection.beforeRead",
7326
- path: `collection:${this.collection.slug}.hooks.beforeRead`
7327
- }
7328
- );
7329
- if (beforeReadResult !== void 0) {
7330
- where = beforeReadResult;
7331
- }
7332
- if (this.collection.workflow && !canViewWorkflowDraft(this.collection.workflow, user)) {
7333
- const publicWorkflowState = publishedStateName(this.collection.workflow);
7334
- where = where ? {
7335
- AND: [
7336
- where,
7337
- {
7338
- OR: [
7339
- { __published: { exists: true } },
7340
- { __workflow: { exists: false } },
7341
- { "__workflow.state": { equals: publicWorkflowState } }
7342
- ]
7343
- }
7344
- ]
7345
- } : {
7346
- OR: [
7347
- { __published: { exists: true } },
7348
- { __workflow: { exists: false } },
7349
- { "__workflow.state": { equals: publicWorkflowState } }
7350
- ]
7351
- };
7352
- }
7353
- const access = await this.evaluateAccess(c, "read");
7354
- if (!access.allowed) {
7355
- return c.json(
7356
- {
7357
- error: true,
7358
- message: `Access denied: read on ${this.collection.slug}`
7359
- },
7360
- 403
7361
- );
7359
+ }
7360
+ }
7361
+ async function saveWorkflowDraft(args) {
7362
+ const { config, collection, id, originalDoc, data, user, beforeCommit } = args;
7363
+ const db = config.db;
7364
+ const workflow = collection.workflow;
7365
+ if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
7366
+ const previous = originalDoc.__workflow;
7367
+ const isLegacyPublishedDoc = !previous;
7368
+ const baselineRevision = previous?.revision ?? 1;
7369
+ const event = createLifecycleEvent({
7370
+ name: "revision.created",
7371
+ collection: collection.slug,
7372
+ documentId: id,
7373
+ actorId: user?.sub,
7374
+ payload: {
7375
+ revision: baselineRevision + 1,
7376
+ previousRevision: isLegacyPublishedDoc ? baselineRevision : previous?.revision ?? null
7362
7377
  }
7363
- if (access.constraint) {
7364
- where = mergeWhereConstraint(where, access.constraint);
7378
+ });
7379
+ const doc = await db.transaction(async (tx) => {
7380
+ const nextMeta = {
7381
+ ...previous ?? {},
7382
+ state: isLegacyPublishedDoc ? workflow.draftState ?? workflow.initialState : originalDoc.__published ? workflow.draftState ?? workflow.initialState : previous.state,
7383
+ revision: baselineRevision + 1,
7384
+ ...isLegacyPublishedDoc ? { publishedRevision: baselineRevision } : {}
7385
+ };
7386
+ const updateData = {
7387
+ ...data,
7388
+ __workflow: nextMeta
7389
+ };
7390
+ if (isLegacyPublishedDoc) {
7391
+ const { __workflow: _legacyWorkflow, __published: _legacyPublished, ...publishedSnapshot } = originalDoc;
7392
+ updateData.__published = publishedSnapshot;
7365
7393
  }
7366
- let result = await db.find({
7367
- collection: this.collection.slug,
7368
- limit,
7369
- page,
7370
- sort,
7371
- where,
7372
- fields: this.collection.fields
7394
+ const updated = await tx.update({ collection: collection.slug, id, data: updateData });
7395
+ await persistEvent(tx, event);
7396
+ await beforeCommit?.(tx, updated);
7397
+ return updated;
7398
+ });
7399
+ void dispatchLifecycleEvent(config, event);
7400
+ return { doc, event };
7401
+ }
7402
+ async function createWorkflowDocument(args) {
7403
+ const { config, collection, data, user, beforeCommit } = args;
7404
+ const db = config.db;
7405
+ if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
7406
+ let event;
7407
+ const doc = await db.transaction(async (tx) => {
7408
+ const created = await tx.create({ collection: collection.slug, data });
7409
+ event = createLifecycleEvent({
7410
+ name: "revision.created",
7411
+ collection: collection.slug,
7412
+ documentId: created.id,
7413
+ actorId: user?.sub,
7414
+ payload: { revision: 1, previousRevision: null }
7373
7415
  });
7374
- if (result.total === 0 && this.collection.initialData && !where && page === 1) {
7375
- getRequestLogger(c, "collection").info({
7376
- msg: "Auto-seeding collection from config.initialData",
7377
- collection: this.collection.slug
7416
+ await persistEvent(tx, event);
7417
+ await beforeCommit?.(tx, created);
7418
+ return created;
7419
+ });
7420
+ void dispatchLifecycleEvent(config, event);
7421
+ return { doc, event };
7422
+ }
7423
+ async function transitionWorkflow(args) {
7424
+ const { config, collection, id, transitionName, expectedRevision, comment, input, user, req } = args;
7425
+ const db = config.db;
7426
+ const workflow = collection.workflow;
7427
+ if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
7428
+ const original = await db.findOne({ collection: collection.slug, id });
7429
+ if (!original) throw Object.assign(new Error("Not Found"), { statusCode: 404 });
7430
+ const meta = original.__workflow;
7431
+ if (!meta) throw Object.assign(new Error("Entry has no workflow metadata"), { statusCode: 409 });
7432
+ if (expectedRevision !== void 0 && expectedRevision !== meta.revision) {
7433
+ throw Object.assign(new Error("This entry changed since it was loaded. Refresh before transitioning it."), {
7434
+ statusCode: 409
7435
+ });
7436
+ }
7437
+ const transition = workflow.transitions.find((item) => item.name === transitionName);
7438
+ const fromStates = transition ? Array.isArray(transition.from) ? transition.from : [transition.from] : [];
7439
+ if (!transition || !fromStates.includes(meta.state)) {
7440
+ throw Object.assign(new Error(`Transition "${transitionName}" is not valid from "${meta.state}".`), {
7441
+ statusCode: 409
7442
+ });
7443
+ }
7444
+ if (!availableWorkflowTransitions(workflow, meta.state, user).some((item) => item.name === transition.name)) {
7445
+ throw Object.assign(new Error(`You do not have permission to perform "${transition.label}".`), { statusCode: 403 });
7446
+ }
7447
+ if (transition.requireComment && !comment?.trim()) {
7448
+ throw Object.assign(new Error(`A comment is required for "${transition.label}".`), { statusCode: 400 });
7449
+ }
7450
+ const hookContext = { transition, from: meta.state, to: transition.to, doc: original, user, comment, input, req, db };
7451
+ for (const hook of workflow.hooks?.beforeTransition ?? []) await hook(hookContext);
7452
+ const events = [
7453
+ createLifecycleEvent({
7454
+ name: "workflow.transitioned",
7455
+ collection: collection.slug,
7456
+ documentId: id,
7457
+ actorId: user?.sub,
7458
+ payload: { transition: transition.name, from: meta.state, to: transition.to, revision: meta.revision, comment }
7459
+ })
7460
+ ];
7461
+ const targetState = workflow.states.find((state) => state.name === transition.to);
7462
+ if (targetState.published) {
7463
+ events.push(
7464
+ createLifecycleEvent({
7465
+ name: "entry.published",
7466
+ collection: collection.slug,
7467
+ documentId: id,
7468
+ actorId: user?.sub,
7469
+ payload: { revision: meta.revision }
7470
+ })
7471
+ );
7472
+ } else if (transition.unpublish) {
7473
+ events.push(
7474
+ createLifecycleEvent({
7475
+ name: "entry.unpublished",
7476
+ collection: collection.slug,
7477
+ documentId: id,
7478
+ actorId: user?.sub,
7479
+ payload: { revision: meta.publishedRevision }
7480
+ })
7481
+ );
7482
+ }
7483
+ const updated = await db.transaction(async (tx) => {
7484
+ const locked = await tx.findOne({ collection: collection.slug, id });
7485
+ if (!locked) throw Object.assign(new Error("Not Found"), { statusCode: 404 });
7486
+ const lockedMeta = locked.__workflow;
7487
+ if (lockedMeta.revision !== meta.revision || lockedMeta.state !== meta.state || expectedRevision !== void 0 && lockedMeta.revision !== expectedRevision) {
7488
+ throw Object.assign(new Error("This entry changed since it was loaded. Refresh before transitioning it."), {
7489
+ statusCode: 409
7378
7490
  });
7379
- for (const data of this.collection.initialData) {
7380
- await db.create({ collection: this.collection.slug, data });
7491
+ }
7492
+ const now2 = (/* @__PURE__ */ new Date()).toISOString();
7493
+ const { __published: _published, __workflow: _workflow, id: _id, ...working } = locked;
7494
+ const nextMeta = {
7495
+ ...lockedMeta,
7496
+ state: transition.to,
7497
+ ...targetState.published ? { publishedRevision: lockedMeta.revision, publishedAt: now2, publishedBy: user?.sub } : {},
7498
+ ...transition.unpublish ? { publishedRevision: void 0, publishedAt: void 0, publishedBy: void 0 } : {}
7499
+ };
7500
+ const data = { __workflow: nextMeta };
7501
+ if (targetState.published) data.__published = working;
7502
+ if (transition.unpublish) data.__published = null;
7503
+ const next = await tx.update({ collection: collection.slug, id, data });
7504
+ if (transition.onTransition) {
7505
+ await transition.onTransition({
7506
+ transition,
7507
+ from: lockedMeta.state,
7508
+ to: transition.to,
7509
+ doc: locked,
7510
+ input,
7511
+ comment,
7512
+ user,
7513
+ req,
7514
+ tx
7515
+ });
7516
+ }
7517
+ await tx.create({
7518
+ collection: WORKFLOW_HISTORY_COLLECTION,
7519
+ data: {
7520
+ collection: collection.slug,
7521
+ documentId: id,
7522
+ transition: transition.name,
7523
+ from: lockedMeta.state,
7524
+ to: transition.to,
7525
+ revision: lockedMeta.revision,
7526
+ comment: comment ?? null,
7527
+ actorId: user?.sub ?? null,
7528
+ createdAt: now2
7381
7529
  }
7382
- result = await db.find({
7383
- collection: this.collection.slug,
7384
- limit,
7385
- page,
7386
- sort,
7387
- where,
7388
- fields: this.collection.fields
7530
+ });
7531
+ for (const event of events) await persistEvent(tx, event);
7532
+ if (collection.audit) {
7533
+ await tx.create({
7534
+ collection: "__audit",
7535
+ data: {
7536
+ collection: collection.slug,
7537
+ documentId: id,
7538
+ operation: "workflow.transition",
7539
+ user: user?.sub ?? null,
7540
+ timestamp: now2,
7541
+ changes: JSON.stringify({
7542
+ transition: transition.name,
7543
+ from: lockedMeta.state,
7544
+ to: transition.to,
7545
+ revision: lockedMeta.revision
7546
+ })
7547
+ }
7389
7548
  });
7390
7549
  }
7391
- result.docs = result.docs.map(
7392
- (doc) => this.collection.workflow ? materializeWorkflowDocument(
7393
- doc,
7394
- this.collection.workflow,
7395
- user
7396
- ) : doc
7397
- ).filter((doc) => doc !== null);
7398
- const processedDocs = [];
7399
- for (const doc of result.docs) {
7400
- const docWithDefaults = DefaultsService.apply(
7401
- this.collection.fields,
7402
- doc
7403
- );
7404
- const docWithCollectionHooks = await runCollectionHooks(
7405
- this.collection.hooks?.afterRead,
7406
- {
7407
- doc: docWithDefaults,
7408
- req: c.req,
7409
- user,
7410
- db: readonlyDb
7550
+ return next;
7551
+ });
7552
+ for (const event of events) void dispatchLifecycleEvent(config, event);
7553
+ for (const hook of workflow.hooks?.afterTransition ?? []) {
7554
+ try {
7555
+ await hook({ ...hookContext, doc: updated, event: events[0] });
7556
+ } catch (error) {
7557
+ try {
7558
+ const obs = config?.__observability;
7559
+ obs?.recordWorkflowHookFailure?.({
7560
+ collection: collection.slug,
7561
+ transition: transition.name
7562
+ });
7563
+ const logger = config?.logger;
7564
+ if (logger && typeof logger.child === "function") {
7565
+ logger.child({ component: "workflow" }).error({
7566
+ err: error,
7567
+ msg: "afterTransition hook failed",
7568
+ collection: collection.slug,
7569
+ transition: transition.name,
7570
+ documentId: id
7571
+ });
7572
+ } else if (logger && typeof logger.error === "function") {
7573
+ logger.error({
7574
+ err: error,
7575
+ msg: "afterTransition hook failed",
7576
+ collection: collection.slug,
7577
+ transition: transition.name,
7578
+ documentId: id
7579
+ });
7580
+ } else {
7581
+ console.error("afterTransition hook failed:", error);
7411
7582
  }
7412
- );
7413
- const docWithFieldHooks = await executeFieldAfterRead(
7414
- this.collection.fields,
7415
- docWithCollectionHooks,
7416
- user,
7417
- readonlyDb
7418
- );
7419
- const docWithFieldAccess = await applyFieldReadAccess(
7420
- {
7421
- config,
7422
- fields: this.collection.fields,
7423
- user,
7424
- req: this.toHookRequestContext(c),
7425
- doc: docWithFieldHooks
7426
- },
7427
- docWithFieldHooks
7428
- );
7429
- processedDocs.push(docWithFieldAccess);
7430
- }
7431
- result.docs = processedDocs;
7432
- if (depth > 0) {
7433
- const populationService = new PopulationService(db, config.collections);
7434
- result = await populationService.populateResult(
7435
- result,
7436
- this.collection.fields,
7437
- depth
7438
- );
7439
- }
7440
- if (this.collection.auth && result.docs && Array.isArray(result.docs)) {
7441
- result.docs = result.docs.map((d) => this.sanitizeDoc(d));
7583
+ } catch {
7584
+ console.error("afterTransition hook failed:", error);
7585
+ }
7442
7586
  }
7443
- return c.json(result);
7444
7587
  }
7445
- async findOne(c) {
7446
- const tenantCheck = this.checkTenantAccess(c);
7447
- if (!tenantCheck.ok) return tenantCheck.response;
7448
- const config = c.get("config");
7449
- const db = config.db;
7450
- if (!db) return c.json({ message: "Database not configured" }, 500);
7451
- const provider = this.getDelegatedProvider(c);
7452
- if (provider && (provider.members?.get || provider.members?.list)) {
7453
- const hookReq = this.toHookRequestContext(c);
7454
- const id2 = c.req.param("id");
7455
- if (!id2) return c.json({ message: "Missing ID" }, 400);
7456
- const localDoc = await db.findOne({
7457
- collection: this.collection.slug,
7458
- id: id2
7459
- });
7460
- const externalSubject = localDoc?.externalSubject || id2;
7461
- if (provider.members.get) {
7462
- const member = await provider.members.get({
7463
- externalSubject,
7464
- req: hookReq
7465
- });
7466
- if (member) {
7467
- return c.json({
7468
- ...member,
7469
- id: localDoc ? localDoc.id : member.id,
7470
- externalSubject: member.id
7588
+ return updated;
7589
+ }
7590
+
7591
+ // src/detail.ts
7592
+ function normalizeDetailItem(item) {
7593
+ if (typeof item === "string") {
7594
+ return {
7595
+ type: "field",
7596
+ field: item
7597
+ };
7598
+ }
7599
+ return item;
7600
+ }
7601
+ async function evaluateDetailComputed(detail, doc, user, db) {
7602
+ if (!doc || !detail || !Array.isArray(detail)) {
7603
+ return doc;
7604
+ }
7605
+ const computedItems = [];
7606
+ const extractComputed = (items) => {
7607
+ for (const rawItem of items) {
7608
+ if (!rawItem) continue;
7609
+ const item = normalizeDetailItem(rawItem);
7610
+ if (item.type === "computed") {
7611
+ const id = item.id || item.label?.toLowerCase().replace(/[^a-z0-9]+/g, "_");
7612
+ if (id) {
7613
+ computedItems.push({
7614
+ id,
7615
+ label: item.label,
7616
+ expression: item.expression,
7617
+ handler: item.handler
7471
7618
  });
7472
7619
  }
7473
- } else if (provider.members.list) {
7474
- const listResult = await provider.members.list({ req: hookReq });
7475
- const matched = listResult.docs.find((m) => m.id === externalSubject || m.id === id2);
7476
- if (matched) {
7477
- return c.json({
7478
- ...matched,
7479
- id: localDoc ? localDoc.id : matched.id,
7480
- externalSubject: matched.id
7481
- });
7620
+ } else if (item.type === "section" || item.type === "grid" || item.type === "repeat") {
7621
+ if (item.items && Array.isArray(item.items)) {
7622
+ extractComputed(item.items);
7623
+ }
7624
+ } else if (item.type === "tabs" && item.tabs && Array.isArray(item.tabs)) {
7625
+ for (const tab of item.tabs) {
7626
+ if (tab.items) extractComputed(tab.items);
7482
7627
  }
7483
7628
  }
7484
7629
  }
7485
- const id = c.req.param("id");
7486
- if (!id) return c.json({ message: "Missing ID" }, 400);
7487
- const depth = Number.parseInt(c.req.query("depth") || "0", 10);
7488
- const user = c.get("user");
7489
- const doc = await db.findOne({
7490
- collection: this.collection.slug,
7491
- id
7492
- });
7493
- if (!doc) {
7494
- return c.json({ message: "Document not found" }, 404);
7495
- }
7496
- let effectiveDoc = doc;
7497
- if (this.collection.workflow) {
7498
- effectiveDoc = materializeWorkflowDocument(
7499
- doc,
7500
- this.collection.workflow,
7501
- user
7502
- );
7503
- if (!effectiveDoc) return c.json({ message: "Document not found" }, 404);
7504
- }
7505
- const access = await this.evaluateAccess(c, "read", { id, doc: effectiveDoc });
7506
- if (!access.allowed) {
7507
- return c.json(
7508
- {
7509
- error: true,
7510
- message: `Access denied: read on ${this.collection.slug}`
7511
- },
7512
- 403
7513
- );
7514
- }
7515
- const docWithDefaults = DefaultsService.apply(
7516
- this.collection.fields,
7517
- effectiveDoc
7518
- );
7519
- const readonlyDb = createReadonlyDb(db);
7520
- const docWithCollectionHooks = await runCollectionHooks(
7521
- this.collection.hooks?.afterRead,
7522
- {
7523
- doc: docWithDefaults,
7524
- req: c.req,
7525
- user,
7526
- db: readonlyDb
7630
+ };
7631
+ extractComputed(detail);
7632
+ if (computedItems.length === 0) return doc;
7633
+ const evaluated = {};
7634
+ for (const item of computedItems) {
7635
+ try {
7636
+ if (typeof item.handler === "function") {
7637
+ evaluated[item.id] = await item.handler({ doc, user, db });
7638
+ } else if (item.expression) {
7639
+ evaluated[item.id] = await evaluateJexl(item.expression, { doc, user });
7527
7640
  }
7528
- );
7529
- const docWithFieldHooks = await executeFieldAfterRead(
7530
- this.collection.fields,
7531
- docWithCollectionHooks,
7532
- user,
7533
- readonlyDb
7534
- );
7535
- const docWithFieldAccess = await applyFieldReadAccess(
7536
- {
7537
- config,
7538
- fields: this.collection.fields,
7539
- user,
7540
- req: this.toHookRequestContext(c),
7541
- doc: docWithFieldHooks
7542
- },
7543
- docWithFieldHooks
7544
- );
7545
- let finalDoc = docWithFieldAccess;
7546
- if (depth > 0 && docWithFieldAccess) {
7547
- const populationService = new PopulationService(db, config.collections);
7548
- finalDoc = await populationService.populate({
7549
- data: docWithFieldAccess,
7550
- fields: this.collection.fields,
7551
- currentDepth: 0,
7552
- maxDepth: depth
7553
- });
7641
+ } catch (_err) {
7642
+ evaluated[item.id] = null;
7554
7643
  }
7555
- if (finalDoc && this.collection.detail) {
7556
- finalDoc = await evaluateDetailComputed(this.collection.detail, finalDoc, user, readonlyDb);
7644
+ }
7645
+ return {
7646
+ ...doc,
7647
+ _meta: {
7648
+ ...doc._meta || {},
7649
+ computed: evaluated
7557
7650
  }
7558
- return c.json(this.sanitizeDoc(finalDoc));
7651
+ };
7652
+ }
7653
+
7654
+ // src/controllers/collection.controller.ts
7655
+ init_rag_service();
7656
+ var import_http_exception2 = require("hono/http-exception");
7657
+
7658
+ // src/utils/tenant.ts
7659
+ var import_http_exception = require("hono/http-exception");
7660
+ function getAllowedSitesForUser(user, payload) {
7661
+ const raw = user?.allowedSites ?? user?.siteIds ?? user?.siteAccess ?? (user?.siteId ? [user.siteId] : void 0) ?? payload?.allowedSites ?? payload?.siteIds ?? payload?.siteAccess ?? (payload?.siteId ? [payload.siteId] : void 0);
7662
+ if (Array.isArray(raw)) {
7663
+ return raw.filter((s) => typeof s === "string" && s.trim().length > 0);
7559
7664
  }
7560
- async create(c) {
7561
- const tenantCheck = this.checkTenantAccess(c);
7665
+ return void 0;
7666
+ }
7667
+ function isSiteAuthorized(user, payload, requestedSiteId) {
7668
+ const allowedSites = getAllowedSitesForUser(user, payload);
7669
+ if (!allowedSites || allowedSites.length === 0) {
7670
+ return true;
7671
+ }
7672
+ if (!requestedSiteId) {
7673
+ return false;
7674
+ }
7675
+ return allowedSites.includes(requestedSiteId);
7676
+ }
7677
+ function resolveAuthorizedSiteId(c) {
7678
+ const user = typeof c.get === "function" ? c.get("user") : void 0;
7679
+ const tokenPayload = typeof c.get === "function" ? c.get("authTokenPayload") : void 0;
7680
+ const requestedSiteId = (typeof c.req?.header === "function" ? c.req.header("X-Site-Id") : void 0) || (typeof c.get === "function" ? c.get("siteId") : void 0);
7681
+ const allowedSites = getAllowedSitesForUser(user, tokenPayload);
7682
+ if (Array.isArray(allowedSites) && allowedSites.length > 0) {
7683
+ if (!requestedSiteId) {
7684
+ return allowedSites[0];
7685
+ }
7686
+ if (!allowedSites.includes(requestedSiteId)) {
7687
+ throw new import_http_exception.HTTPException(403, {
7688
+ message: `Forbidden: User is not authorized to access site "${requestedSiteId}".`
7689
+ });
7690
+ }
7691
+ return requestedSiteId;
7692
+ }
7693
+ return requestedSiteId || "default";
7694
+ }
7695
+
7696
+ // src/controllers/collection.controller.ts
7697
+ var CollectionController = class {
7698
+ collection;
7699
+ constructor(collection) {
7700
+ this.collection = collection;
7701
+ }
7702
+ checkTenantAccess(c) {
7703
+ let siteId;
7704
+ try {
7705
+ const hasSiteHeader = Boolean(c.req.header("X-Site-Id") || c.get("siteId"));
7706
+ const allowedSites = getAllowedSitesForUser(c.get("user"), c.get("authTokenPayload"));
7707
+ if (hasSiteHeader || allowedSites && allowedSites.length > 0) {
7708
+ siteId = resolveAuthorizedSiteId(c);
7709
+ }
7710
+ } catch (err) {
7711
+ if (err instanceof import_http_exception2.HTTPException) {
7712
+ return {
7713
+ ok: false,
7714
+ response: c.json({ error: true, message: err.message }, err.status)
7715
+ };
7716
+ }
7717
+ throw err;
7718
+ }
7719
+ if (this.collection.siteId && !this.collection.shared) {
7720
+ if (!siteId || this.collection.siteId !== siteId) {
7721
+ return {
7722
+ ok: false,
7723
+ response: c.json(
7724
+ { error: true, message: `Collection "${this.collection.slug}" not found in project.` },
7725
+ 404
7726
+ )
7727
+ };
7728
+ }
7729
+ }
7730
+ return { ok: true };
7731
+ }
7732
+ getDelegatedProvider(c) {
7733
+ const config = c.get("config");
7734
+ const authCollection = getAdminAuthCollection(config);
7735
+ if (!authCollection || this.collection.slug !== authCollection.slug) {
7736
+ return null;
7737
+ }
7738
+ return config.adminAuth?.providers?.find((p) => p.members) || null;
7739
+ }
7740
+ toHookRequestContext(c) {
7741
+ return toHookRequestContext(c.req);
7742
+ }
7743
+ sanitizeDoc(doc) {
7744
+ if (!doc || typeof doc !== "object") return doc;
7745
+ if (this.collection.auth) {
7746
+ const {
7747
+ password: _p,
7748
+ salt: _s,
7749
+ resetPasswordToken: _rpt,
7750
+ resetPasswordExpires: _rpe,
7751
+ loginAttempts: _la,
7752
+ lockUntil: _lu,
7753
+ ...safeDoc
7754
+ } = doc;
7755
+ return safeDoc;
7756
+ }
7757
+ return doc;
7758
+ }
7759
+ async evaluateAccess(c, action, options = {}) {
7760
+ const config = c.get("config");
7761
+ return resolveCollectionAccess(
7762
+ config,
7763
+ this.collection.slug,
7764
+ action,
7765
+ this.collection.access?.[action],
7766
+ {
7767
+ id: options.id,
7768
+ user: c.get("user"),
7769
+ req: this.toHookRequestContext(c),
7770
+ doc: options.doc ?? void 0,
7771
+ data: options.data
7772
+ }
7773
+ );
7774
+ }
7775
+ async find(c) {
7776
+ const tenantCheck = this.checkTenantAccess(c);
7562
7777
  if (!tenantCheck.ok) return tenantCheck.response;
7563
7778
  const config = c.get("config");
7564
7779
  const db = config.db;
7565
7780
  if (!db) return c.json({ message: "Database not configured" }, 500);
7566
7781
  const provider = this.getDelegatedProvider(c);
7567
- if (provider && provider.members?.create) {
7782
+ if (provider && provider.members?.list) {
7568
7783
  const hookReq = this.toHookRequestContext(c);
7569
- const contentType2 = c.req.header("Content-Type") || "";
7570
- if (contentType2.toLowerCase().includes("multipart/form-data")) {
7571
- return this.upload(c);
7784
+ const limit2 = Number(c.req.query("limit")) || 10;
7785
+ const page2 = Number(c.req.query("page")) || 1;
7786
+ const sort2 = c.req.query("sort") || void 0;
7787
+ const search2 = c.req.query("search") || void 0;
7788
+ let where2 = void 0;
7789
+ const whereRaw2 = c.req.query("where");
7790
+ if (whereRaw2) {
7791
+ try {
7792
+ where2 = JSON.parse(decodeURIComponent(whereRaw2));
7793
+ } catch {
7794
+ }
7572
7795
  }
7573
- const body2 = await c.req.json();
7574
- const member = await provider.members.create({
7575
- data: body2,
7796
+ const searchWhere2 = await buildCollectionSearchWhere({
7797
+ collection: this.collection,
7798
+ search: search2,
7799
+ db: createReadonlyDb(db),
7800
+ collections: config.collections
7801
+ });
7802
+ if (searchWhere2) {
7803
+ where2 = mergeWhereConstraint(where2, searchWhere2);
7804
+ }
7805
+ const paginatedResult = await provider.members.list({
7806
+ limit: limit2,
7807
+ page: page2,
7808
+ sort: sort2,
7809
+ where: where2,
7576
7810
  req: hookReq
7577
7811
  });
7578
- return c.json(
7579
- {
7580
- ...member,
7581
- id: member.id,
7582
- externalSubject: member.id
7583
- },
7584
- 201
7585
- );
7812
+ const mappedDocs = [];
7813
+ for (const m of paginatedResult.docs) {
7814
+ let localId = m.id;
7815
+ const localDoc = await db.find({
7816
+ collection: this.collection.slug,
7817
+ where: m.id ? { externalSubject: { equals: m.id } } : { email: { equals: m.email } },
7818
+ limit: 1
7819
+ });
7820
+ if (localDoc.docs[0]) {
7821
+ localId = localDoc.docs[0].id;
7822
+ }
7823
+ mappedDocs.push({
7824
+ ...m,
7825
+ id: localId,
7826
+ externalSubject: m.id
7827
+ });
7828
+ }
7829
+ return c.json({
7830
+ ...paginatedResult,
7831
+ docs: mappedDocs
7832
+ });
7586
7833
  }
7587
7834
  const readonlyDb = createReadonlyDb(db);
7588
- const contentType = c.req.header("Content-Type") || "";
7589
- if (contentType.toLowerCase().includes("multipart/form-data")) {
7590
- return this.upload(c);
7591
- }
7592
- const body = await c.req.json();
7835
+ const limit = Math.min(Number(c.req.query("limit")) || 10, 100);
7836
+ const page = Number(c.req.query("page")) || 1;
7837
+ const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 1;
7838
+ const sort = c.req.query("sort") || void 0;
7839
+ const search = c.req.query("search") || void 0;
7593
7840
  const user = c.get("user");
7594
- const now2 = (/* @__PURE__ */ new Date()).toISOString();
7595
- let data = {
7596
- ...body,
7597
- createdAt: now2,
7598
- updatedAt: now2,
7599
- createdBy: user?.sub ?? null,
7600
- updatedBy: user?.sub ?? null
7601
- };
7602
- data = DefaultsService.apply(this.collection.fields, data);
7603
- data = normalizeMoneyFields(this.collection.fields, data);
7604
- if (this.collection.workflow) {
7605
- data = initializeWorkflowDocument(data, this.collection.workflow);
7841
+ let where = void 0;
7842
+ const whereRaw = c.req.query("where");
7843
+ if (whereRaw) {
7844
+ try {
7845
+ where = JSON.parse(decodeURIComponent(whereRaw));
7846
+ } catch {
7847
+ }
7606
7848
  }
7607
- const createAccess = await this.evaluateAccess(c, "create", { data });
7608
- if (!createAccess.allowed) {
7609
- return c.json(
7610
- {
7611
- error: true,
7612
- message: `Access denied: create on ${this.collection.slug}`
7613
- },
7614
- 403
7615
- );
7849
+ if (where) {
7850
+ if (this.collection.admin?.filterable === false) {
7851
+ where = void 0;
7852
+ } else {
7853
+ const { sanitizeWhereClause: sanitizeWhereClause2 } = await Promise.resolve().then(() => (init_where_sanitizer(), where_sanitizer_exports));
7854
+ where = sanitizeWhereClause2(where, this.collection.fields);
7855
+ if (Object.keys(where).length === 0) {
7856
+ where = void 0;
7857
+ }
7858
+ }
7616
7859
  }
7617
- if (this.collection.auth && data.password) {
7618
- data.password = await hashPassword(data.password);
7860
+ const searchWhere = await buildCollectionSearchWhere({
7861
+ collection: this.collection,
7862
+ search,
7863
+ db: readonlyDb,
7864
+ collections: config.collections
7865
+ });
7866
+ if (searchWhere) {
7867
+ where = mergeWhereConstraint(where, searchWhere);
7619
7868
  }
7620
- data = await applyFieldWriteAccess(
7869
+ const beforeReadResult = await runCollectionHooks(
7870
+ this.collection.hooks?.beforeRead,
7621
7871
  {
7622
- config,
7623
- fields: this.collection.fields,
7872
+ req: c.req,
7873
+ query: where,
7624
7874
  user,
7625
- req: this.toHookRequestContext(c),
7626
- data,
7627
- operation: "create"
7875
+ db: readonlyDb
7628
7876
  },
7629
- data
7630
- );
7631
- data = await executeFieldBeforeChange(
7632
- this.collection.fields,
7633
- data,
7634
- null,
7635
- user,
7636
- readonlyDb
7877
+ {
7878
+ surface: "collection.beforeRead",
7879
+ path: `collection:${this.collection.slug}.hooks.beforeRead`
7880
+ }
7637
7881
  );
7638
- data = await runCollectionHooks(this.collection.hooks?.beforeChange, {
7639
- data,
7640
- req: c.req,
7641
- user,
7642
- operation: "create",
7643
- db: readonlyDb
7644
- }, {
7645
- surface: "collection.beforeChange",
7646
- path: `collection:${this.collection.slug}.hooks.beforeChange`
7647
- });
7648
- if (!data.id) {
7649
- data = { ...data, id: generateDocumentId(this.collection) };
7882
+ if (beforeReadResult !== void 0) {
7883
+ where = beforeReadResult;
7650
7884
  }
7651
- const beforeCommit = this.buildBeforeCommit(c, "create", user);
7652
- const doc = this.collection.workflow ? (await createWorkflowDocument({
7653
- config,
7654
- collection: this.collection,
7655
- data,
7656
- user,
7657
- beforeCommit
7658
- })).doc : await this.persistInTransaction(
7659
- db,
7660
- beforeCommit,
7661
- (writer) => writer.create({ collection: this.collection.slug, data })
7662
- );
7663
- if (this.collection.audit && db) {
7664
- AuditService.log(db, {
7665
- operation: "create",
7885
+ if (this.collection.workflow && !canViewWorkflowDraft(this.collection.workflow, user)) {
7886
+ const publicWorkflowState = publishedStateName(this.collection.workflow);
7887
+ where = where ? {
7888
+ AND: [
7889
+ where,
7890
+ {
7891
+ OR: [
7892
+ { __published: { exists: true } },
7893
+ { __workflow: { exists: false } },
7894
+ { "__workflow.state": { equals: publicWorkflowState } }
7895
+ ]
7896
+ }
7897
+ ]
7898
+ } : {
7899
+ OR: [
7900
+ { __published: { exists: true } },
7901
+ { __workflow: { exists: false } },
7902
+ { "__workflow.state": { equals: publicWorkflowState } }
7903
+ ]
7904
+ };
7905
+ }
7906
+ const access = await this.evaluateAccess(c, "read");
7907
+ if (!access.allowed) {
7908
+ return c.json(
7909
+ {
7910
+ error: true,
7911
+ message: `Access denied: read on ${this.collection.slug}`
7912
+ },
7913
+ 403
7914
+ );
7915
+ }
7916
+ if (access.constraint) {
7917
+ where = mergeWhereConstraint(where, access.constraint);
7918
+ }
7919
+ let result = await db.find({
7920
+ collection: this.collection.slug,
7921
+ limit,
7922
+ page,
7923
+ sort,
7924
+ where,
7925
+ fields: this.collection.fields
7926
+ });
7927
+ if (result.total === 0 && this.collection.initialData && !where && page === 1) {
7928
+ getRequestLogger(c, "collection").info({
7929
+ msg: "Auto-seeding collection from config.initialData",
7930
+ collection: this.collection.slug
7931
+ });
7932
+ for (const data of this.collection.initialData) {
7933
+ await db.create({ collection: this.collection.slug, data });
7934
+ }
7935
+ result = await db.find({
7666
7936
  collection: this.collection.slug,
7667
- documentId: doc.id,
7668
- user: user ? { id: user.sub, collection: user.collection, email: user.email } : void 0,
7669
- before: null,
7670
- after: doc
7671
- }, config);
7937
+ limit,
7938
+ page,
7939
+ sort,
7940
+ where,
7941
+ fields: this.collection.fields
7942
+ });
7672
7943
  }
7673
- await runCollectionHooks(
7674
- this.collection.hooks?.afterChange,
7675
- {
7944
+ result.docs = result.docs.map(
7945
+ (doc) => this.collection.workflow ? materializeWorkflowDocument(
7676
7946
  doc,
7947
+ this.collection.workflow,
7948
+ user
7949
+ ) : doc
7950
+ ).filter((doc) => doc !== null);
7951
+ const processedDocs = [];
7952
+ for (const doc of result.docs) {
7953
+ const docWithDefaults = DefaultsService.apply(
7954
+ this.collection.fields,
7955
+ doc
7956
+ );
7957
+ const docWithCollectionHooks = await runCollectionHooks(
7958
+ this.collection.hooks?.afterRead,
7959
+ {
7960
+ doc: docWithDefaults,
7961
+ req: c.req,
7962
+ user,
7963
+ db: readonlyDb
7964
+ }
7965
+ );
7966
+ const docWithFieldHooks = await executeFieldAfterRead(
7967
+ this.collection.fields,
7968
+ docWithCollectionHooks,
7677
7969
  user,
7678
- req: c.req,
7679
- operation: "create",
7680
- db
7681
- },
7682
- { isolated: true }
7683
- );
7684
- if (db && config) {
7685
- const siteId = resolveAuthorizedSiteId(c);
7686
- RAGService.indexDocument({
7687
- db,
7688
- config,
7970
+ readonlyDb
7971
+ );
7972
+ const docWithFieldAccess = await applyFieldReadAccess(
7973
+ {
7974
+ config,
7975
+ fields: this.collection.fields,
7976
+ user,
7977
+ req: this.toHookRequestContext(c),
7978
+ doc: docWithFieldHooks
7979
+ },
7980
+ docWithFieldHooks
7981
+ );
7982
+ processedDocs.push(docWithFieldAccess);
7983
+ }
7984
+ result.docs = processedDocs;
7985
+ if (depth > 0) {
7986
+ const populationService = new PopulationService(db, config.collections);
7987
+ result = await populationService.populateResult(
7988
+ result,
7989
+ this.collection.fields,
7990
+ depth
7991
+ );
7992
+ }
7993
+ if (this.collection.auth && result.docs && Array.isArray(result.docs)) {
7994
+ result.docs = result.docs.map((d) => this.sanitizeDoc(d));
7995
+ }
7996
+ return c.json(result);
7997
+ }
7998
+ async findOne(c) {
7999
+ const tenantCheck = this.checkTenantAccess(c);
8000
+ if (!tenantCheck.ok) return tenantCheck.response;
8001
+ const config = c.get("config");
8002
+ const db = config.db;
8003
+ if (!db) return c.json({ message: "Database not configured" }, 500);
8004
+ const provider = this.getDelegatedProvider(c);
8005
+ if (provider && (provider.members?.get || provider.members?.list)) {
8006
+ const hookReq = this.toHookRequestContext(c);
8007
+ const id2 = c.req.param("id");
8008
+ if (!id2) return c.json({ message: "Missing ID" }, 400);
8009
+ const localDoc = await db.findOne({
7689
8010
  collection: this.collection.slug,
7690
- doc,
7691
- projectId: siteId
7692
- }).catch((err) => console.error(`[dyrected/rag] Auto-index create failed:`, err?.message || err));
8011
+ id: id2
8012
+ });
8013
+ const externalSubject = localDoc?.externalSubject || id2;
8014
+ if (provider.members.get) {
8015
+ const member = await provider.members.get({
8016
+ externalSubject,
8017
+ req: hookReq
8018
+ });
8019
+ if (member) {
8020
+ return c.json({
8021
+ ...member,
8022
+ id: localDoc ? localDoc.id : member.id,
8023
+ externalSubject: member.id
8024
+ });
8025
+ }
8026
+ } else if (provider.members.list) {
8027
+ const listResult = await provider.members.list({ req: hookReq });
8028
+ const matched = listResult.docs.find((m) => m.id === externalSubject || m.id === id2);
8029
+ if (matched) {
8030
+ return c.json({
8031
+ ...matched,
8032
+ id: localDoc ? localDoc.id : matched.id,
8033
+ externalSubject: matched.id
8034
+ });
8035
+ }
8036
+ }
7693
8037
  }
7694
- const responseDoc = this.collection.workflow ? materializeWorkflowDocument(doc, this.collection.workflow, user) : doc;
7695
- const readDoc = await runCollectionHooks(this.collection.hooks?.afterRead, {
7696
- doc: responseDoc,
7697
- req: c.req,
7698
- user,
7699
- db: readonlyDb
7700
- }, {
7701
- surface: "collection.afterRead",
7702
- path: `collection:${this.collection.slug}.hooks.afterRead`
8038
+ const id = c.req.param("id");
8039
+ if (!id) return c.json({ message: "Missing ID" }, 400);
8040
+ const depth = Number.parseInt(c.req.query("depth") || "0", 10);
8041
+ const user = c.get("user");
8042
+ const doc = await db.findOne({
8043
+ collection: this.collection.slug,
8044
+ id
7703
8045
  });
7704
- const finalDoc = await executeFieldAfterRead(
8046
+ if (!doc) {
8047
+ return c.json({ message: "Document not found" }, 404);
8048
+ }
8049
+ let effectiveDoc = doc;
8050
+ if (this.collection.workflow) {
8051
+ effectiveDoc = materializeWorkflowDocument(
8052
+ doc,
8053
+ this.collection.workflow,
8054
+ user
8055
+ );
8056
+ if (!effectiveDoc) return c.json({ message: "Document not found" }, 404);
8057
+ }
8058
+ const access = await this.evaluateAccess(c, "read", { id, doc: effectiveDoc });
8059
+ if (!access.allowed) {
8060
+ return c.json(
8061
+ {
8062
+ error: true,
8063
+ message: `Access denied: read on ${this.collection.slug}`
8064
+ },
8065
+ 403
8066
+ );
8067
+ }
8068
+ const docWithDefaults = DefaultsService.apply(
7705
8069
  this.collection.fields,
7706
- readDoc,
8070
+ effectiveDoc
8071
+ );
8072
+ const readonlyDb = createReadonlyDb(db);
8073
+ const docWithCollectionHooks = await runCollectionHooks(
8074
+ this.collection.hooks?.afterRead,
8075
+ {
8076
+ doc: docWithDefaults,
8077
+ req: c.req,
8078
+ user,
8079
+ db: readonlyDb
8080
+ }
8081
+ );
8082
+ const docWithFieldHooks = await executeFieldAfterRead(
8083
+ this.collection.fields,
8084
+ docWithCollectionHooks,
7707
8085
  user,
7708
8086
  readonlyDb
7709
8087
  );
7710
- const accessibleDoc = await applyFieldReadAccess(
8088
+ const docWithFieldAccess = await applyFieldReadAccess(
7711
8089
  {
7712
8090
  config,
7713
8091
  fields: this.collection.fields,
7714
8092
  user,
7715
8093
  req: this.toHookRequestContext(c),
7716
- doc: finalDoc
8094
+ doc: docWithFieldHooks
8095
+ },
8096
+ docWithFieldHooks
8097
+ );
8098
+ let finalDoc = docWithFieldAccess;
8099
+ if (depth > 0 && docWithFieldAccess) {
8100
+ const populationService = new PopulationService(db, config.collections);
8101
+ finalDoc = await populationService.populate({
8102
+ data: docWithFieldAccess,
8103
+ fields: this.collection.fields,
8104
+ currentDepth: 0,
8105
+ maxDepth: depth
8106
+ });
8107
+ }
8108
+ if (finalDoc && this.collection.detail) {
8109
+ finalDoc = await evaluateDetailComputed(this.collection.detail, finalDoc, user, readonlyDb);
8110
+ }
8111
+ return c.json(this.sanitizeDoc(finalDoc));
8112
+ }
8113
+ async create(c) {
8114
+ const tenantCheck = this.checkTenantAccess(c);
8115
+ if (!tenantCheck.ok) return tenantCheck.response;
8116
+ const config = c.get("config");
8117
+ const db = config.db;
8118
+ if (!db) return c.json({ message: "Database not configured" }, 500);
8119
+ const provider = this.getDelegatedProvider(c);
8120
+ if (provider && provider.members?.create) {
8121
+ const hookReq = this.toHookRequestContext(c);
8122
+ const contentType2 = c.req.header("Content-Type") || "";
8123
+ if (contentType2.toLowerCase().includes("multipart/form-data")) {
8124
+ return this.upload(c);
8125
+ }
8126
+ const body2 = await c.req.json();
8127
+ const member = await provider.members.create({
8128
+ data: body2,
8129
+ req: hookReq
8130
+ });
8131
+ return c.json(
8132
+ {
8133
+ ...member,
8134
+ id: member.id,
8135
+ externalSubject: member.id
8136
+ },
8137
+ 201
8138
+ );
8139
+ }
8140
+ const readonlyDb = createReadonlyDb(db);
8141
+ const contentType = c.req.header("Content-Type") || "";
8142
+ if (contentType.toLowerCase().includes("multipart/form-data")) {
8143
+ return this.upload(c);
8144
+ }
8145
+ const body = await c.req.json();
8146
+ const user = c.get("user");
8147
+ const now2 = (/* @__PURE__ */ new Date()).toISOString();
8148
+ let data = {
8149
+ ...body,
8150
+ createdAt: now2,
8151
+ updatedAt: now2,
8152
+ createdBy: user?.sub ?? null,
8153
+ updatedBy: user?.sub ?? null
8154
+ };
8155
+ data = DefaultsService.apply(this.collection.fields, data);
8156
+ data = normalizeMoneyFields(this.collection.fields, data);
8157
+ if (this.collection.workflow) {
8158
+ data = initializeWorkflowDocument(data, this.collection.workflow);
8159
+ }
8160
+ const createAccess = await this.evaluateAccess(c, "create", { data });
8161
+ if (!createAccess.allowed) {
8162
+ return c.json(
8163
+ {
8164
+ error: true,
8165
+ message: `Access denied: create on ${this.collection.slug}`
8166
+ },
8167
+ 403
8168
+ );
8169
+ }
8170
+ if (this.collection.auth && data.password) {
8171
+ data.password = await hashPassword(data.password);
8172
+ }
8173
+ data = await applyFieldWriteAccess(
8174
+ {
8175
+ config,
8176
+ fields: this.collection.fields,
8177
+ user,
8178
+ req: this.toHookRequestContext(c),
8179
+ data,
8180
+ operation: "create"
8181
+ },
8182
+ data
8183
+ );
8184
+ data = await executeFieldBeforeChange(
8185
+ this.collection.fields,
8186
+ data,
8187
+ null,
8188
+ user,
8189
+ readonlyDb
8190
+ );
8191
+ data = await runCollectionHooks(this.collection.hooks?.beforeChange, {
8192
+ data,
8193
+ req: c.req,
8194
+ user,
8195
+ operation: "create",
8196
+ db: readonlyDb
8197
+ }, {
8198
+ surface: "collection.beforeChange",
8199
+ path: `collection:${this.collection.slug}.hooks.beforeChange`
8200
+ });
8201
+ if (!data.id) {
8202
+ data = { ...data, id: generateDocumentId(this.collection) };
8203
+ }
8204
+ const beforeCommit = this.buildBeforeCommit(c, "create", user);
8205
+ const doc = this.collection.workflow ? (await createWorkflowDocument({
8206
+ config,
8207
+ collection: this.collection,
8208
+ data,
8209
+ user,
8210
+ beforeCommit
8211
+ })).doc : await this.persistInTransaction(
8212
+ db,
8213
+ beforeCommit,
8214
+ (writer) => writer.create({ collection: this.collection.slug, data })
8215
+ );
8216
+ if (this.collection.audit && db) {
8217
+ AuditService.log(db, {
8218
+ operation: "create",
8219
+ collection: this.collection.slug,
8220
+ documentId: doc.id,
8221
+ user: user ? { id: user.sub, collection: user.collection, email: user.email } : void 0,
8222
+ before: null,
8223
+ after: doc
8224
+ }, config);
8225
+ }
8226
+ await runCollectionHooks(
8227
+ this.collection.hooks?.afterChange,
8228
+ {
8229
+ doc,
8230
+ user,
8231
+ req: c.req,
8232
+ operation: "create",
8233
+ db
8234
+ },
8235
+ { isolated: true }
8236
+ );
8237
+ if (db && config) {
8238
+ const siteId = resolveAuthorizedSiteId(c);
8239
+ RAGService.indexDocument({
8240
+ db,
8241
+ config,
8242
+ collection: this.collection.slug,
8243
+ doc,
8244
+ projectId: siteId
8245
+ }).catch((err) => console.error(`[dyrected/rag] Auto-index create failed:`, err?.message || err));
8246
+ }
8247
+ const responseDoc = this.collection.workflow ? materializeWorkflowDocument(doc, this.collection.workflow, user) : doc;
8248
+ const readDoc = await runCollectionHooks(this.collection.hooks?.afterRead, {
8249
+ doc: responseDoc,
8250
+ req: c.req,
8251
+ user,
8252
+ db: readonlyDb
8253
+ }, {
8254
+ surface: "collection.afterRead",
8255
+ path: `collection:${this.collection.slug}.hooks.afterRead`
8256
+ });
8257
+ const finalDoc = await executeFieldAfterRead(
8258
+ this.collection.fields,
8259
+ readDoc,
8260
+ user,
8261
+ readonlyDb
8262
+ );
8263
+ const accessibleDoc = await applyFieldReadAccess(
8264
+ {
8265
+ config,
8266
+ fields: this.collection.fields,
8267
+ user,
8268
+ req: this.toHookRequestContext(c),
8269
+ doc: finalDoc
7717
8270
  },
7718
8271
  finalDoc
7719
8272
  );
@@ -8085,13 +8638,14 @@ var CollectionController = class {
8085
8638
  const config = c.get("config");
8086
8639
  const db = config.db;
8087
8640
  if (!db) return c.json({ message: "Database not configured" }, 500);
8088
- if (!this.collection.views?.length && !this.collection.actions?.length) {
8641
+ const allViews = resolveAllCollectionViews(this.collection, config);
8642
+ if (!allViews.length && !this.collection.actions?.length) {
8089
8643
  return c.json({ error: true, message: `Collection ${this.collection.slug} has no views or actions` }, 404);
8090
8644
  }
8091
8645
  const actionName = c.req.param("action");
8092
8646
  const viewSlugParam = c.req.param("viewSlug");
8093
8647
  let action;
8094
- for (const view2 of this.collection.views ?? []) {
8648
+ for (const view2 of allViews) {
8095
8649
  if (viewSlugParam && view2.slug !== viewSlugParam) continue;
8096
8650
  const match = view2.actions?.find((candidate) => candidate.name === actionName);
8097
8651
  if (match) {
@@ -8120,7 +8674,7 @@ var CollectionController = class {
8120
8674
  const input = body?.input ?? {};
8121
8675
  const user = c.get("user");
8122
8676
  const accessArgs = { user, req: this.toHookRequestContext(c) };
8123
- const view = viewSlugParam ? this.collection.views?.find((candidate) => candidate.slug === viewSlugParam) : void 0;
8677
+ const view = viewSlugParam ? allViews.find((candidate) => candidate.slug === viewSlugParam) : void 0;
8124
8678
  if (view?.access) {
8125
8679
  const allowed = await resolveBooleanAccess(config, view.access.update ?? view.access.read ?? true, {
8126
8680
  ...accessArgs,
@@ -13750,762 +14304,231 @@ function generateOpenApi(config) {
13750
14304
  };
13751
14305
  }
13752
14306
  if (config.storage) {
13753
- spec.paths["/api/media/{filename}"] = {
13754
- get: {
13755
- tags: ["Media"],
13756
- summary: "Serve a stored file",
13757
- security: [],
13758
- parameters: [
13759
- {
13760
- name: "filename",
13761
- in: "path",
13762
- required: true,
13763
- schema: { type: "string" }
13764
- }
13765
- ],
13766
- responses: {
13767
- 200: { description: "Stored file bytes" },
13768
- 404: { description: "File not found" }
13769
- }
13770
- }
13771
- };
13772
- }
13773
- return spec;
13774
- }
13775
- function collectionToSchema(collection) {
13776
- const { properties, required } = fieldsToProperties(collection.fields);
13777
- return {
13778
- type: "object",
13779
- properties: {
13780
- id: { type: "string" },
13781
- createdAt: { type: "string", format: "date-time" },
13782
- updatedAt: { type: "string", format: "date-time" },
13783
- ...collection.workflow ? { _workflow: { $ref: "#/components/schemas/WorkflowMetadata" } } : {},
13784
- ...properties
13785
- },
13786
- required: ["id", ...required]
13787
- };
13788
- }
13789
- function globalToSchema(global) {
13790
- const { properties, required } = fieldsToProperties(global.fields);
13791
- return {
13792
- type: "object",
13793
- properties,
13794
- required
13795
- };
13796
- }
13797
- function fieldsToProperties(fields) {
13798
- const props = {};
13799
- const required = [];
13800
- for (const field of fields) {
13801
- if (field.type === "row") {
13802
- const nested = fieldsToProperties(field.fields || []);
13803
- Object.assign(props, nested.properties);
13804
- required.push(...nested.required);
13805
- continue;
13806
- }
13807
- if (!field.name) continue;
13808
- props[field.name] = fieldToSchema(field);
13809
- if (field.required) {
13810
- required.push(field.name);
13811
- }
13812
- }
13813
- return { properties: props, required };
13814
- }
13815
- function fieldToSchema(field) {
13816
- let schema;
13817
- switch (field.type) {
13818
- case "text":
13819
- case "textarea":
13820
- case "email":
13821
- schema = { type: "string" };
13822
- break;
13823
- case "url":
13824
- schema = {
13825
- oneOf: [
13826
- { type: "string" },
13827
- { type: "object", additionalProperties: true }
13828
- ]
13829
- };
13830
- break;
13831
- case "icon":
13832
- schema = { type: "string" };
13833
- break;
13834
- case "number":
13835
- schema = { type: "number" };
13836
- break;
13837
- case "money":
13838
- schema = { type: "integer", description: "Amount in minor units (for example kobo or cents)." };
13839
- break;
13840
- case "boolean":
13841
- schema = { type: "boolean" };
13842
- break;
13843
- case "date":
13844
- schema = { type: "string", format: "date" };
13845
- break;
13846
- case "datetime":
13847
- schema = { type: "string", format: "date-time" };
13848
- break;
13849
- case "time":
13850
- schema = { type: "string", format: "time" };
13851
- break;
13852
- case "select":
13853
- case "radio":
13854
- schema = {
13855
- type: "string",
13856
- enum: Array.isArray(field.options) ? field.options.map((o) => typeof o === "string" ? o : o.value) : void 0
13857
- };
13858
- break;
13859
- case "multiSelect":
13860
- schema = {
13861
- type: "array",
13862
- items: {
13863
- type: "string",
13864
- enum: Array.isArray(field.options) ? field.options.map((o) => typeof o === "string" ? o : o.value) : void 0
13865
- }
13866
- };
13867
- break;
13868
- case "relationship":
13869
- case "image": {
13870
- const valueSchema = {
13871
- type: "string",
13872
- description: `ID of a ${field.relationTo} record`
13873
- };
13874
- schema = field.hasMany ? { type: "array", items: valueSchema } : valueSchema;
13875
- break;
13876
- }
13877
- case "join":
13878
- schema = {
13879
- type: "array",
13880
- readOnly: true,
13881
- items: { type: "object", additionalProperties: true }
13882
- };
13883
- break;
13884
- case "object": {
13885
- const { properties, required } = fieldsToProperties(field.fields || []);
13886
- schema = { type: "object", properties, required };
13887
- break;
13888
- }
13889
- case "array": {
13890
- const { properties, required } = fieldsToProperties(field.fields || []);
13891
- schema = {
13892
- type: "array",
13893
- items: { type: "object", properties, required }
13894
- };
13895
- break;
13896
- }
13897
- case "json":
13898
- case "richText":
13899
- schema = { type: "object", additionalProperties: true };
13900
- break;
13901
- case "blocks":
13902
- schema = {
13903
- type: "array",
13904
- items: {
13905
- oneOf: field.blocks?.map((block) => {
13906
- const { properties, required } = fieldsToProperties(block.fields);
13907
- return {
13908
- type: "object",
13909
- properties: {
13910
- blockType: { type: "string", enum: [block.slug] },
13911
- ...properties
13912
- },
13913
- required: ["blockType", ...required]
13914
- };
13915
- })
13916
- }
13917
- };
13918
- break;
13919
- default:
13920
- schema = { type: "string" };
13921
- }
13922
- if (field.label) schema.description = field.label;
13923
- return schema;
13924
- }
13925
-
13926
- // src/utils/swagger.ts
13927
- function getSwaggerHtml(specUrl) {
13928
- return `
13929
- <!DOCTYPE html>
13930
- <html lang="en">
13931
- <head>
13932
- <meta charset="utf-8" />
13933
- <meta name="viewport" content="width=device-width, initial-scale=1" />
13934
- <meta name="description" content="SwaggerUI" />
13935
- <title>Dyrected API Documentation</title>
13936
- <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css" />
13937
- </head>
13938
- <body>
13939
- <div id="swagger-ui"></div>
13940
- <script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js" charset="UTF-8"></script>
13941
- <script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-standalone-preset.js" charset="UTF-8"></script>
13942
- <script>
13943
- window.onload = () => {
13944
- // Forward the apikey query param when loading the spec and making API calls
13945
- const params = new URLSearchParams(window.location.search);
13946
- const apiKey = params.get('apikey');
13947
- // Resolve the spec relative to this docs page so any mount prefix
13948
- // (e.g. "/dyrected/api/docs") is preserved. Falls back to an explicit URL
13949
- // when one is supplied by the caller.
13950
- const explicitSpecUrl = ${JSON.stringify(specUrl ?? "")};
13951
- const specUrl = explicitSpecUrl || (window.location.pathname.replace(/\\/docs\\/?$/, '') + '/openapi.json');
13952
- const specUrlWithKey = apiKey ? specUrl + '?apikey=' + encodeURIComponent(apiKey) : specUrl;
13953
-
13954
- window.ui = SwaggerUIBundle({
13955
- url: specUrlWithKey,
13956
- dom_id: '#swagger-ui',
13957
- presets: [
13958
- SwaggerUIBundle.presets.apis,
13959
- SwaggerUIStandalonePreset
13960
- ],
13961
- layout: "BaseLayout",
13962
- deepLinking: true,
13963
- showExtensions: true,
13964
- showCommonExtensions: true,
13965
- // Inject x-api-key header on every request made from the Swagger UI
13966
- requestInterceptor: (request) => {
13967
- if (apiKey) {
13968
- request.headers['x-api-key'] = apiKey;
13969
- }
13970
- return request;
13971
- }
13972
- });
13973
- };
13974
- </script>
13975
- </body>
13976
- </html>
13977
- `;
13978
- }
13979
-
13980
- // src/types/navigation.ts
13981
- var CURRENT_NAV_PREFERENCES_VERSION = 1;
13982
- var DEFAULT_USER_NAV_PREFERENCES = {
13983
- _version: CURRENT_NAV_PREFERENCES_VERSION,
13984
- pinned: [],
13985
- hidden: [],
13986
- groupOrder: [],
13987
- itemOrder: {},
13988
- groups: [],
13989
- items: []
13990
- };
13991
-
13992
- // src/utils/navigation-reconciler.ts
13993
- var normalizeKey = (val) => (val || "").toLowerCase().replace(/[^a-z0-9]/g, "-");
13994
- function reconcileNavigation(baseTree, prefs, schemas, options) {
13995
- if (!baseTree) {
13996
- return {
13997
- groups: [],
13998
- ungrouped: [],
13999
- pinnedItems: []
14000
- };
14001
- }
14002
- const includeHidden = options?.includeHidden ?? false;
14003
- const effectivePrefs = prefs || DEFAULT_USER_NAV_PREFERENCES;
14004
- const hiddenSet = new Set(effectivePrefs.hidden || []);
14005
- const knownCollectionSlugs = new Set((schemas?.collections || []).map((c) => c.slug));
14006
- const knownGlobalSlugs = new Set((schemas?.globals || []).map((g) => g.slug));
14007
- const groupsList = [];
14008
- const findGroup = (query) => {
14009
- const qNorm = normalizeKey(query);
14010
- return groupsList.find(
14011
- (g) => g.id === query || g.slug === query || g.name === query || normalizeKey(g.id) === qNorm || normalizeKey(g.slug) === qNorm || normalizeKey(g.name) === qNorm
14012
- );
14013
- };
14014
- for (const g of baseTree.groups || []) {
14015
- groupsList.push({
14016
- ...g,
14017
- items: (g.items || []).map((item) => ({ ...item }))
14018
- });
14019
- }
14020
- for (const ug of effectivePrefs.groups || []) {
14021
- const existing = findGroup(ug.slug || ug.name);
14022
- if (!existing) {
14023
- groupsList.push({
14024
- id: ug.slug ? `group_${ug.slug}` : `group_${normalizeKey(ug.name)}`,
14025
- name: ug.name,
14026
- slug: ug.slug || normalizeKey(ug.name),
14027
- icon: ug.icon || "Folder",
14028
- defaultExpanded: ug.defaultExpanded ?? true,
14029
- order: ug.order ?? 100,
14030
- items: []
14031
- });
14032
- }
14033
- }
14034
- for (const itemOpt of effectivePrefs.items || []) {
14035
- const targetGroup = typeof itemOpt.group === "object" ? itemOpt.group?.name : itemOpt.group;
14036
- const groupKey = targetGroup || "Custom";
14037
- let group = findGroup(groupKey);
14038
- if (!group) {
14039
- group = {
14040
- id: `group_${normalizeKey(groupKey)}`,
14041
- name: groupKey,
14042
- slug: normalizeKey(groupKey),
14043
- icon: "Folder",
14044
- defaultExpanded: true,
14045
- order: 99,
14046
- items: []
14047
- };
14048
- groupsList.push(group);
14049
- }
14050
- const itemId = itemOpt.slug ? `workspace_${itemOpt.slug}` : itemOpt.collection ? `collection_${itemOpt.collection}` : `custom_${Math.random()}`;
14051
- for (const otherGroup of groupsList) {
14052
- if (otherGroup !== group) {
14053
- otherGroup.items = otherGroup.items.filter((i) => i.slug !== itemOpt.slug && i.id !== itemId);
14054
- }
14055
- }
14056
- const existingIndex = group.items.findIndex((i) => i.id === itemId || i.slug === itemOpt.slug);
14057
- const existingItem = existingIndex >= 0 ? group.items[existingIndex] : void 0;
14058
- const col = schemas?.collections?.find((c) => c.slug === itemOpt.collection);
14059
- const colViews = col?.views || [];
14060
- const baseViews = existingItem?.views || [];
14061
- const userViews = itemOpt.views || [];
14062
- const userViewSlugs = new Set(userViews.map((v) => v.slug));
14063
- let mergedViews = [
14064
- ...baseViews.filter((v) => !userViewSlugs.has(v.slug)),
14065
- ...userViews
14066
- ];
14067
- if (mergedViews.length === 0 && itemOpt.collection) {
14068
- if (colViews.length > 0) {
14069
- mergedViews = [...colViews];
14070
- } else {
14071
- mergedViews = [
14072
- {
14073
- slug: "default",
14074
- label: col?.label || col?.labels?.plural || itemOpt.label || "All Records",
14075
- layout: "table",
14076
- collection: itemOpt.collection
14077
- }
14078
- ];
14079
- }
14080
- }
14081
- mergedViews = mergedViews.map((v) => ({
14082
- ...v,
14083
- collection: v.collection || itemOpt.collection || existingItem?.collection
14084
- }));
14085
- const compiledItem = {
14086
- id: itemId,
14087
- type: itemOpt.slug ? "workspace" : itemOpt.collection ? "collection" : itemOpt.global ? "global" : "workspace",
14088
- slug: itemOpt.slug || itemOpt.collection || itemOpt.global || "item",
14089
- label: itemOpt.label || itemOpt.slug || "Item",
14090
- icon: itemOpt.icon || existingItem?.icon || col?.admin?.icon || "Briefcase",
14091
- group: group.name,
14092
- order: itemOpt.order ?? existingItem?.order ?? 100,
14093
- views: mergedViews,
14094
- collection: itemOpt.collection || existingItem?.collection,
14095
- global: itemOpt.global || existingItem?.global,
14096
- badge: itemOpt.badge || existingItem?.badge,
14097
- access: itemOpt.access || existingItem?.access
14098
- };
14099
- if (existingIndex >= 0) {
14100
- group.items[existingIndex] = compiledItem;
14101
- } else {
14102
- group.items.push(compiledItem);
14103
- }
14104
- }
14105
- const allAvailableItems = [];
14106
- const reconciledGroups = [];
14107
- for (const group of groupsList) {
14108
- const isGroupHidden = hiddenSet.has(group.id) || !!group.slug && hiddenSet.has(group.slug) || hiddenSet.has(group.name);
14109
- if (!includeHidden && isGroupHidden) {
14110
- continue;
14111
- }
14112
- const visibleItems = [];
14113
- for (const item of group.items) {
14114
- const isItemHidden = hiddenSet.has(item.id) || !!item.slug && hiddenSet.has(item.slug);
14115
- if (!includeHidden && isItemHidden) {
14116
- continue;
14117
- }
14118
- const allViews = item.views || [];
14119
- const visibleViews = includeHidden ? allViews : allViews.filter((v) => {
14120
- const compoundKey = `${item.slug}_${v.slug}`;
14121
- return !hiddenSet.has(compoundKey) && !hiddenSet.has(v.slug);
14122
- });
14123
- let isTombstone = false;
14124
- if (schemas && schemas.collections && schemas.collections.length > 0) {
14125
- if (item.collection && !knownCollectionSlugs.has(item.collection)) {
14126
- isTombstone = true;
14127
- }
14128
- }
14129
- if (schemas && schemas.globals && schemas.globals.length > 0) {
14130
- if (item.global && !knownGlobalSlugs.has(item.global)) {
14131
- isTombstone = true;
14132
- }
14133
- }
14134
- const processedItem = {
14135
- ...item,
14136
- views: visibleViews,
14137
- ...isTombstone ? { isTombstone: true } : {}
14138
- };
14139
- visibleItems.push(processedItem);
14140
- allAvailableItems.push(processedItem);
14141
- }
14142
- const groupKey = group.slug || group.name;
14143
- const itemOrderList = effectivePrefs.itemOrder?.[group.id] || effectivePrefs.itemOrder?.[groupKey];
14144
- if (itemOrderList && itemOrderList.length > 0) {
14145
- visibleItems.sort((a, b) => {
14146
- const aIdx = itemOrderList.indexOf(a.id) >= 0 ? itemOrderList.indexOf(a.id) : a.slug ? itemOrderList.indexOf(a.slug) : -1;
14147
- const bIdx = itemOrderList.indexOf(b.id) >= 0 ? itemOrderList.indexOf(b.id) : b.slug ? itemOrderList.indexOf(b.slug) : -1;
14148
- if (aIdx >= 0 && bIdx >= 0) return aIdx - bIdx;
14149
- if (aIdx >= 0) return -1;
14150
- if (bIdx >= 0) return 1;
14151
- return (a.order ?? 100) - (b.order ?? 100);
14152
- });
14153
- } else {
14154
- visibleItems.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
14155
- }
14156
- reconciledGroups.push({
14157
- ...group,
14158
- items: visibleItems
14159
- });
14160
- }
14161
- if (effectivePrefs.groupOrder && effectivePrefs.groupOrder.length > 0) {
14162
- reconciledGroups.sort((a, b) => {
14163
- 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);
14164
- 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);
14165
- if (aIdx >= 0 && bIdx >= 0) return aIdx - bIdx;
14166
- if (aIdx >= 0) return -1;
14167
- if (bIdx >= 0) return 1;
14168
- return (a.order ?? 100) - (b.order ?? 100);
14169
- });
14170
- } else {
14171
- reconciledGroups.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
14172
- }
14173
- const reconciledUngrouped = (baseTree.ungrouped || []).filter((item) => {
14174
- const isItemHidden = hiddenSet.has(item.id) || !!item.slug && hiddenSet.has(item.slug);
14175
- return includeHidden || !isItemHidden;
14176
- }).map((item) => {
14177
- const allViews = item.views || [];
14178
- const visibleViews = includeHidden ? allViews : allViews.filter((v) => {
14179
- const compoundKey = `${item.slug}_${v.slug}`;
14180
- return !hiddenSet.has(compoundKey) && !hiddenSet.has(v.slug);
14181
- });
14182
- const processed = { ...item, views: visibleViews };
14183
- allAvailableItems.push(processed);
14184
- return processed;
14185
- });
14186
- const pinnedItems = [];
14187
- for (const pin of effectivePrefs.pinned || []) {
14188
- const match = allAvailableItems.find((i) => i.slug === pin.slug || i.id === pin.slug);
14189
- if (match) {
14190
- pinnedItems.push({
14191
- ...match,
14192
- id: `pin_${match.id}`,
14193
- label: pin.label || match.label,
14194
- icon: pin.icon || match.icon
14195
- });
14196
- } else {
14197
- pinnedItems.push({
14198
- id: `pin_${pin.slug}`,
14199
- type: pin.type,
14200
- slug: pin.slug,
14201
- label: pin.label || pin.slug,
14202
- icon: pin.icon || "Star",
14203
- order: 0,
14204
- views: []
14205
- });
14206
- }
14207
- }
14208
- return {
14209
- groups: reconciledGroups,
14210
- ungrouped: reconciledUngrouped,
14211
- pinnedItems
14212
- };
14213
- }
14214
-
14215
- // src/utils/navigation.ts
14216
- var RESERVED_NAVIGATION_SLUGS = /* @__PURE__ */ new Set([
14217
- "collections",
14218
- "globals",
14219
- "setup",
14220
- "api",
14221
- "login",
14222
- "logout",
14223
- "settings",
14224
- "admin",
14225
- "preferences",
14226
- "auth"
14227
- ]);
14228
- function assertValidNavigationSlugs(navigation) {
14229
- for (const item of navigation) {
14230
- if (item.slug) {
14231
- const normalizedSlug = item.slug.toLowerCase().trim().replace(/^\//, "");
14232
- if (RESERVED_NAVIGATION_SLUGS.has(normalizedSlug)) {
14233
- throw new Error(
14234
- `[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.`
14235
- );
14236
- }
14237
- }
14238
- }
14239
- }
14240
- function resolveGroupMeta(groupProp, defaultGroupName = "Collections") {
14241
- if (!groupProp) {
14242
- return { name: defaultGroupName };
14243
- }
14244
- if (typeof groupProp === "string") {
14245
- return { name: groupProp };
14246
- }
14247
- return { name: groupProp.name, meta: groupProp };
14248
- }
14249
- function compileNavigation(config) {
14250
- const collections = config.collections || [];
14251
- const globals = config.globals || [];
14252
- const explicitNav = config.admin?.navigation || [];
14253
- assertValidNavigationSlugs(explicitNav);
14254
- const collectionsBySlug = /* @__PURE__ */ new Map();
14255
- for (const col of collections) {
14256
- collectionsBySlug.set(col.slug, col);
14257
- }
14258
- const globalsBySlug = /* @__PURE__ */ new Map();
14259
- for (const gl of globals) {
14260
- globalsBySlug.set(gl.slug, gl);
14261
- }
14262
- const mentionedCollections = /* @__PURE__ */ new Set();
14263
- const mentionedGlobals = /* @__PURE__ */ new Set();
14264
- const injectedViewsByCollection = /* @__PURE__ */ new Map();
14265
- for (const item of explicitNav) {
14266
- if (item.addToCollection) {
14267
- const existing = injectedViewsByCollection.get(item.addToCollection) || [];
14268
- if (item.views && item.views.length > 0) {
14269
- injectedViewsByCollection.set(item.addToCollection, [...existing, ...item.views]);
14270
- }
14271
- }
14272
- if (item.collection) mentionedCollections.add(item.collection);
14273
- if (item.global) mentionedGlobals.add(item.global);
14274
- }
14275
- const groupMetadataMap = /* @__PURE__ */ new Map();
14276
- const itemsByGroup = /* @__PURE__ */ new Map();
14277
- const ungroupedItems = [];
14278
- const registerItem = (groupName, item) => {
14279
- if (!groupName) {
14280
- ungroupedItems.push(item);
14281
- } else {
14282
- const list = itemsByGroup.get(groupName) || [];
14283
- list.push(item);
14284
- itemsByGroup.set(groupName, list);
14285
- }
14286
- };
14287
- for (const item of explicitNav) {
14288
- if (item.addToCollection && !item.slug && !item.collection && !item.global && !item.dashboard && !item.href) {
14289
- continue;
14290
- }
14291
- const { name: groupName, meta: groupMeta } = resolveGroupMeta(item.group, void 0);
14292
- if (groupName && groupMeta) {
14293
- groupMetadataMap.set(groupName, { ...groupMetadataMap.get(groupName) || {}, ...groupMeta });
14294
- }
14295
- if (item.dashboard) {
14296
- registerItem(groupName, {
14297
- id: "dashboard",
14298
- type: "dashboard",
14299
- slug: "dashboard",
14300
- label: item.label || "Dashboard",
14301
- icon: item.icon || "LayoutDashboard",
14302
- group: groupName,
14303
- order: item.order ?? 0,
14304
- views: [],
14305
- access: item.access
14306
- });
14307
- continue;
14308
- }
14309
- if (item.collection) {
14310
- const col = collectionsBySlug.get(item.collection);
14311
- const combinedViews = [
14312
- ...col?.views || [],
14313
- ...injectedViewsByCollection.get(item.collection) || [],
14314
- ...item.views || []
14315
- ];
14316
- registerItem(groupName, {
14317
- id: item.slug || `collection_${item.collection}`,
14318
- type: item.slug ? "workspace" : "collection",
14319
- slug: item.slug || item.collection,
14320
- collection: item.collection,
14321
- label: item.label || col?.labels?.plural || item.collection,
14322
- icon: item.icon || col?.admin?.icon || "Folder",
14323
- group: groupName,
14324
- order: item.order ?? col?.admin?.order ?? 100,
14325
- views: combinedViews,
14326
- badge: item.badge,
14327
- access: item.access || col?.access?.read
14328
- });
14329
- continue;
14330
- }
14331
- if (item.global) {
14332
- const gl = globalsBySlug.get(item.global);
14333
- registerItem(groupName, {
14334
- id: item.slug || `global_${item.global}`,
14335
- type: "global",
14336
- slug: item.slug || item.global,
14337
- global: item.global,
14338
- label: item.label || gl?.label || item.global,
14339
- icon: item.icon || "Settings",
14340
- group: groupName,
14341
- order: item.order ?? 100,
14342
- views: [],
14343
- badge: item.badge,
14344
- access: item.access
14345
- });
14346
- continue;
14347
- }
14348
- if (item.href) {
14349
- registerItem(groupName, {
14350
- id: `link_${item.slug || item.href}`,
14351
- type: "link",
14352
- slug: item.slug || "link",
14353
- label: item.label || "Link",
14354
- href: item.href,
14355
- icon: item.icon || "ExternalLink",
14356
- group: groupName,
14357
- order: item.order ?? 100,
14358
- views: [],
14359
- badge: item.badge,
14360
- access: item.access
14361
- });
14362
- continue;
14363
- }
14364
- const workspaceSlug = item.slug || "workspace";
14365
- registerItem(groupName, {
14366
- id: `workspace_${workspaceSlug}`,
14367
- type: "workspace",
14368
- slug: workspaceSlug,
14369
- label: item.label || workspaceSlug,
14370
- icon: item.icon || "Briefcase",
14371
- group: groupName,
14372
- order: item.order ?? 100,
14373
- views: item.views || [],
14374
- badge: item.badge,
14375
- access: item.access
14376
- });
14377
- }
14378
- for (const col of collections) {
14379
- if (mentionedCollections.has(col.slug)) continue;
14380
- if (col.admin?.hidden) continue;
14381
- const groupProp = col.admin?.group;
14382
- const { name: groupName, meta: groupMeta } = resolveGroupMeta(groupProp, "Collections");
14383
- if (groupMeta) {
14384
- groupMetadataMap.set(groupName, { ...groupMetadataMap.get(groupName) || {}, ...groupMeta });
14385
- }
14386
- const combinedViews = [
14387
- ...col.views || [],
14388
- ...injectedViewsByCollection.get(col.slug) || []
14389
- ];
14390
- registerItem(groupName, {
14391
- id: `collection_${col.slug}`,
14392
- type: "collection",
14393
- slug: col.slug,
14394
- collection: col.slug,
14395
- label: col.labels?.plural || col.slug,
14396
- icon: col.admin?.icon || "Folder",
14397
- group: groupName,
14398
- order: col?.admin?.order ?? 100,
14399
- views: combinedViews,
14400
- access: col.access?.read
14401
- });
14402
- }
14403
- for (const gl of globals) {
14404
- if (mentionedGlobals.has(gl.slug)) continue;
14405
- const groupName = "Configuration";
14406
- registerItem(groupName, {
14407
- id: `global_${gl.slug}`,
14408
- type: "global",
14409
- slug: gl.slug,
14410
- global: gl.slug,
14411
- label: gl.label || gl.slug,
14412
- icon: "Settings",
14413
- group: groupName,
14414
- order: 100,
14415
- views: []
14416
- });
14417
- }
14418
- const sortAndSpliceItems = (items) => {
14419
- const sorted = [...items].sort((a, b) => a.order - b.order);
14420
- const explicitMap = /* @__PURE__ */ new Map();
14421
- for (const opt of explicitNav) {
14422
- if (opt.slug) explicitMap.set(opt.slug, opt);
14423
- if (opt.collection) explicitMap.set(opt.collection, opt);
14424
- if (opt.global) explicitMap.set(opt.global, opt);
14425
- }
14426
- for (const opt of explicitNav) {
14427
- const itemKey = opt.slug || opt.collection || opt.global;
14428
- if (!itemKey) continue;
14429
- if (opt.after) {
14430
- const itemIdx = sorted.findIndex((i) => i.slug === itemKey);
14431
- const targetIdx = sorted.findIndex((i) => i.slug === opt.after);
14432
- if (itemIdx !== -1 && targetIdx !== -1 && itemIdx !== targetIdx) {
14433
- const [moved] = sorted.splice(itemIdx, 1);
14434
- const newTargetIdx = sorted.findIndex((i) => i.slug === opt.after);
14435
- sorted.splice(newTargetIdx + 1, 0, moved);
14436
- }
14437
- } else if (opt.before) {
14438
- const itemIdx = sorted.findIndex((i) => i.slug === itemKey);
14439
- const targetIdx = sorted.findIndex((i) => i.slug === opt.before);
14440
- if (itemIdx !== -1 && targetIdx !== -1 && itemIdx !== targetIdx) {
14441
- const [moved] = sorted.splice(itemIdx, 1);
14442
- const newTargetIdx = sorted.findIndex((i) => i.slug === opt.before);
14443
- sorted.splice(newTargetIdx, 0, moved);
14444
- }
14445
- } else if (opt.position === "first") {
14446
- const itemIdx = sorted.findIndex((i) => i.slug === itemKey);
14447
- if (itemIdx > 0) {
14448
- const [moved] = sorted.splice(itemIdx, 1);
14449
- sorted.unshift(moved);
14450
- }
14451
- } else if (opt.position === "last") {
14452
- const itemIdx = sorted.findIndex((i) => i.slug === itemKey);
14453
- if (itemIdx !== -1 && itemIdx < sorted.length - 1) {
14454
- const [moved] = sorted.splice(itemIdx, 1);
14455
- sorted.push(moved);
14307
+ spec.paths["/api/media/{filename}"] = {
14308
+ get: {
14309
+ tags: ["Media"],
14310
+ summary: "Serve a stored file",
14311
+ security: [],
14312
+ parameters: [
14313
+ {
14314
+ name: "filename",
14315
+ in: "path",
14316
+ required: true,
14317
+ schema: { type: "string" }
14318
+ }
14319
+ ],
14320
+ responses: {
14321
+ 200: { description: "Stored file bytes" },
14322
+ 404: { description: "File not found" }
14456
14323
  }
14457
14324
  }
14458
- }
14459
- return sorted;
14460
- };
14461
- const compiledGroups = [];
14462
- for (const [groupName, rawItems] of itemsByGroup.entries()) {
14463
- const meta = groupMetadataMap.get(groupName);
14464
- const sortedItems = sortAndSpliceItems(rawItems);
14465
- compiledGroups.push({
14466
- id: `group_${groupName.toLowerCase().replace(/\s+/g, "_")}`,
14467
- name: groupName,
14468
- slug: meta?.slug || groupName.toLowerCase().replace(/\s+/g, "-"),
14469
- icon: meta?.icon,
14470
- defaultExpanded: meta?.defaultExpanded ?? true,
14471
- order: meta?.order ?? 100,
14472
- items: sortedItems
14473
- });
14325
+ };
14474
14326
  }
14475
- compiledGroups.sort((a, b) => a.order - b.order);
14327
+ return spec;
14328
+ }
14329
+ function collectionToSchema(collection) {
14330
+ const { properties, required } = fieldsToProperties(collection.fields);
14476
14331
  return {
14477
- groups: compiledGroups,
14478
- ungrouped: sortAndSpliceItems(ungroupedItems)
14332
+ type: "object",
14333
+ properties: {
14334
+ id: { type: "string" },
14335
+ createdAt: { type: "string", format: "date-time" },
14336
+ updatedAt: { type: "string", format: "date-time" },
14337
+ ...collection.workflow ? { _workflow: { $ref: "#/components/schemas/WorkflowMetadata" } } : {},
14338
+ ...properties
14339
+ },
14340
+ required: ["id", ...required]
14479
14341
  };
14480
14342
  }
14481
- function pruneNavigationForUser(tree, user) {
14482
- const userRoles = /* @__PURE__ */ new Set();
14483
- if (user?.role) userRoles.add(user.role);
14484
- if (Array.isArray(user?.roles)) {
14485
- for (const r of user.roles) userRoles.add(r);
14343
+ function globalToSchema(global) {
14344
+ const { properties, required } = fieldsToProperties(global.fields);
14345
+ return {
14346
+ type: "object",
14347
+ properties,
14348
+ required
14349
+ };
14350
+ }
14351
+ function fieldsToProperties(fields) {
14352
+ const props = {};
14353
+ const required = [];
14354
+ for (const field of fields) {
14355
+ if (field.type === "row") {
14356
+ const nested = fieldsToProperties(field.fields || []);
14357
+ Object.assign(props, nested.properties);
14358
+ required.push(...nested.required);
14359
+ continue;
14360
+ }
14361
+ if (!field.name) continue;
14362
+ props[field.name] = fieldToSchema(field);
14363
+ if (field.required) {
14364
+ required.push(field.name);
14365
+ }
14486
14366
  }
14487
- const isAllowed = (item) => {
14488
- if (!item.access) return true;
14489
- if (Array.isArray(item.access)) {
14490
- if (item.access.length === 0) return true;
14491
- return item.access.some((role) => userRoles.has(role));
14367
+ return { properties: props, required };
14368
+ }
14369
+ function fieldToSchema(field) {
14370
+ let schema;
14371
+ switch (field.type) {
14372
+ case "text":
14373
+ case "textarea":
14374
+ case "email":
14375
+ schema = { type: "string" };
14376
+ break;
14377
+ case "url":
14378
+ schema = {
14379
+ oneOf: [
14380
+ { type: "string" },
14381
+ { type: "object", additionalProperties: true }
14382
+ ]
14383
+ };
14384
+ break;
14385
+ case "icon":
14386
+ schema = { type: "string" };
14387
+ break;
14388
+ case "number":
14389
+ schema = { type: "number" };
14390
+ break;
14391
+ case "money":
14392
+ schema = { type: "integer", description: "Amount in minor units (for example kobo or cents)." };
14393
+ break;
14394
+ case "boolean":
14395
+ schema = { type: "boolean" };
14396
+ break;
14397
+ case "date":
14398
+ schema = { type: "string", format: "date" };
14399
+ break;
14400
+ case "datetime":
14401
+ schema = { type: "string", format: "date-time" };
14402
+ break;
14403
+ case "time":
14404
+ schema = { type: "string", format: "time" };
14405
+ break;
14406
+ case "select":
14407
+ case "radio":
14408
+ schema = {
14409
+ type: "string",
14410
+ enum: Array.isArray(field.options) ? field.options.map((o) => typeof o === "string" ? o : o.value) : void 0
14411
+ };
14412
+ break;
14413
+ case "multiSelect":
14414
+ schema = {
14415
+ type: "array",
14416
+ items: {
14417
+ type: "string",
14418
+ enum: Array.isArray(field.options) ? field.options.map((o) => typeof o === "string" ? o : o.value) : void 0
14419
+ }
14420
+ };
14421
+ break;
14422
+ case "relationship":
14423
+ case "image": {
14424
+ const valueSchema = {
14425
+ type: "string",
14426
+ description: `ID of a ${field.relationTo} record`
14427
+ };
14428
+ schema = field.hasMany ? { type: "array", items: valueSchema } : valueSchema;
14429
+ break;
14492
14430
  }
14493
- return true;
14494
- };
14495
- const prunedGroups = [];
14496
- for (const group of tree.groups) {
14497
- const visibleItems = group.items.filter(isAllowed);
14498
- if (visibleItems.length > 0) {
14499
- prunedGroups.push({
14500
- ...group,
14501
- items: visibleItems
14502
- });
14431
+ case "join":
14432
+ schema = {
14433
+ type: "array",
14434
+ readOnly: true,
14435
+ items: { type: "object", additionalProperties: true }
14436
+ };
14437
+ break;
14438
+ case "object": {
14439
+ const { properties, required } = fieldsToProperties(field.fields || []);
14440
+ schema = { type: "object", properties, required };
14441
+ break;
14442
+ }
14443
+ case "array": {
14444
+ const { properties, required } = fieldsToProperties(field.fields || []);
14445
+ schema = {
14446
+ type: "array",
14447
+ items: { type: "object", properties, required }
14448
+ };
14449
+ break;
14503
14450
  }
14451
+ case "json":
14452
+ case "richText":
14453
+ schema = { type: "object", additionalProperties: true };
14454
+ break;
14455
+ case "blocks":
14456
+ schema = {
14457
+ type: "array",
14458
+ items: {
14459
+ oneOf: field.blocks?.map((block) => {
14460
+ const { properties, required } = fieldsToProperties(block.fields);
14461
+ return {
14462
+ type: "object",
14463
+ properties: {
14464
+ blockType: { type: "string", enum: [block.slug] },
14465
+ ...properties
14466
+ },
14467
+ required: ["blockType", ...required]
14468
+ };
14469
+ })
14470
+ }
14471
+ };
14472
+ break;
14473
+ default:
14474
+ schema = { type: "string" };
14504
14475
  }
14505
- return {
14506
- groups: prunedGroups,
14507
- ungrouped: tree.ungrouped.filter(isAllowed)
14476
+ if (field.label) schema.description = field.label;
14477
+ return schema;
14478
+ }
14479
+
14480
+ // src/utils/swagger.ts
14481
+ function getSwaggerHtml(specUrl) {
14482
+ return `
14483
+ <!DOCTYPE html>
14484
+ <html lang="en">
14485
+ <head>
14486
+ <meta charset="utf-8" />
14487
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
14488
+ <meta name="description" content="SwaggerUI" />
14489
+ <title>Dyrected API Documentation</title>
14490
+ <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css" />
14491
+ </head>
14492
+ <body>
14493
+ <div id="swagger-ui"></div>
14494
+ <script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js" charset="UTF-8"></script>
14495
+ <script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-standalone-preset.js" charset="UTF-8"></script>
14496
+ <script>
14497
+ window.onload = () => {
14498
+ // Forward the apikey query param when loading the spec and making API calls
14499
+ const params = new URLSearchParams(window.location.search);
14500
+ const apiKey = params.get('apikey');
14501
+ // Resolve the spec relative to this docs page so any mount prefix
14502
+ // (e.g. "/dyrected/api/docs") is preserved. Falls back to an explicit URL
14503
+ // when one is supplied by the caller.
14504
+ const explicitSpecUrl = ${JSON.stringify(specUrl ?? "")};
14505
+ const specUrl = explicitSpecUrl || (window.location.pathname.replace(/\\/docs\\/?$/, '') + '/openapi.json');
14506
+ const specUrlWithKey = apiKey ? specUrl + '?apikey=' + encodeURIComponent(apiKey) : specUrl;
14507
+
14508
+ window.ui = SwaggerUIBundle({
14509
+ url: specUrlWithKey,
14510
+ dom_id: '#swagger-ui',
14511
+ presets: [
14512
+ SwaggerUIBundle.presets.apis,
14513
+ SwaggerUIStandalonePreset
14514
+ ],
14515
+ layout: "BaseLayout",
14516
+ deepLinking: true,
14517
+ showExtensions: true,
14518
+ showCommonExtensions: true,
14519
+ // Inject x-api-key header on every request made from the Swagger UI
14520
+ requestInterceptor: (request) => {
14521
+ if (apiKey) {
14522
+ request.headers['x-api-key'] = apiKey;
14523
+ }
14524
+ return request;
14525
+ }
14526
+ });
14508
14527
  };
14528
+ </script>
14529
+ </body>
14530
+ </html>
14531
+ `;
14509
14532
  }
14510
14533
 
14511
14534
  // src/router.ts
@@ -15506,10 +15529,11 @@ function registerRoutes(app, config) {
15506
15529
  app.post(`${path}/media`, (c) => controller.create(c));
15507
15530
  app.delete(`${path}/delete-many`, (c) => controller.deleteMany(c));
15508
15531
  app.post(`${path}/aggregate`, (c) => controller.aggregate(c));
15509
- if (collection.views?.length) {
15532
+ const allViews = resolveAllCollectionViews(collection, config);
15533
+ if (allViews.length) {
15510
15534
  app.post(`${path}/views/:viewSlug/actions/:action`, optionalAuth(config), (c) => controller.runViewAction(c));
15511
15535
  }
15512
- if (collection.views?.length || collection.actions?.length) {
15536
+ if (allViews.length || collection.actions?.length) {
15513
15537
  app.post(`${path}/actions/:action`, optionalAuth(config), (c) => controller.runViewAction(c));
15514
15538
  }
15515
15539
  if (collection.audit) {