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