@dyrected/core 2.5.28 → 2.5.30
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/chunk-CQDVPMEU.js +613 -0
- package/dist/index-DTrpTru7.d.cts +1881 -0
- package/dist/index-DTrpTru7.d.ts +1881 -0
- package/dist/index-DzUlE5Hc.d.cts +1751 -0
- package/dist/index-DzUlE5Hc.d.ts +1751 -0
- package/dist/index-qhLus8ZO.d.cts +1881 -0
- package/dist/index-qhLus8ZO.d.ts +1881 -0
- package/dist/index.cjs +330 -4
- package/dist/index.d.cts +56 -3
- package/dist/index.d.ts +56 -3
- package/dist/index.js +40 -4
- package/dist/server.cjs +482 -156
- package/dist/server.d.cts +28 -1
- package/dist/server.d.ts +28 -1
- package/dist/server.js +232 -158
- package/dist/where-sanitizer-WJ2W2QCE.js +54 -0
- package/package.json +2 -1
package/dist/server.cjs
CHANGED
|
@@ -62,6 +62,10 @@ function sanitizeWhereClause(where, fields) {
|
|
|
62
62
|
}
|
|
63
63
|
continue;
|
|
64
64
|
}
|
|
65
|
+
if (key === "id") {
|
|
66
|
+
result[key] = value;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
65
69
|
const fieldDef = fieldMap.get(key);
|
|
66
70
|
if (!fieldDef) continue;
|
|
67
71
|
if (fieldDef.admin?.filterable === false) continue;
|
|
@@ -531,6 +535,215 @@ function createReadonlyDb(db) {
|
|
|
531
535
|
});
|
|
532
536
|
}
|
|
533
537
|
|
|
538
|
+
// src/workflows.ts
|
|
539
|
+
var WORKFLOW_HISTORY_COLLECTION = "__workflow_history";
|
|
540
|
+
var LIFECYCLE_EVENTS_COLLECTION = "__lifecycle_events";
|
|
541
|
+
function publicMetadata(meta) {
|
|
542
|
+
const { availableTransitions: _availableTransitions, ...safe } = meta;
|
|
543
|
+
return safe;
|
|
544
|
+
}
|
|
545
|
+
function workflowCapabilities(workflow, user) {
|
|
546
|
+
const capabilities = new Set(Array.isArray(user?.capabilities) ? user.capabilities : []);
|
|
547
|
+
const roles = Array.isArray(user?.roles) ? user.roles : [];
|
|
548
|
+
for (const mapping of workflow.roles ?? []) {
|
|
549
|
+
if (roles.includes(mapping.role)) mapping.capabilities.forEach((capability) => capabilities.add(capability));
|
|
550
|
+
}
|
|
551
|
+
return capabilities;
|
|
552
|
+
}
|
|
553
|
+
function availableWorkflowTransitions(workflow, state, user) {
|
|
554
|
+
const capabilities = workflowCapabilities(workflow, user);
|
|
555
|
+
return workflow.transitions.filter((transition) => {
|
|
556
|
+
const from = Array.isArray(transition.from) ? transition.from : [transition.from];
|
|
557
|
+
return from.includes(state) && (transition.requiredCapabilities ?? []).every((item) => capabilities.has(item));
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
function initializeWorkflowDocument(data, workflow) {
|
|
561
|
+
return {
|
|
562
|
+
...data,
|
|
563
|
+
__workflow: { state: workflow.initialState, revision: 1 }
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
function materializeWorkflowDocument(doc, workflow, user) {
|
|
567
|
+
const meta = doc.__workflow;
|
|
568
|
+
if (!meta) return doc;
|
|
569
|
+
const { __published, __workflow, ...working } = doc;
|
|
570
|
+
if (!user) {
|
|
571
|
+
if (!__published || typeof __published !== "object") return null;
|
|
572
|
+
return { id: doc.id, ...__published, _workflow: publicMetadata(meta) };
|
|
573
|
+
}
|
|
574
|
+
return {
|
|
575
|
+
...working,
|
|
576
|
+
_workflow: {
|
|
577
|
+
...meta,
|
|
578
|
+
availableTransitions: availableWorkflowTransitions(workflow, meta.state, user).map((item) => item.name)
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
function eventId() {
|
|
583
|
+
return globalThis.crypto?.randomUUID?.() ?? `evt_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
584
|
+
}
|
|
585
|
+
function createLifecycleEvent(args) {
|
|
586
|
+
return {
|
|
587
|
+
id: eventId(),
|
|
588
|
+
name: args.name,
|
|
589
|
+
collection: args.collection,
|
|
590
|
+
documentId: args.documentId,
|
|
591
|
+
occurredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
592
|
+
actorId: args.actorId,
|
|
593
|
+
payload: args.payload,
|
|
594
|
+
attempts: 0,
|
|
595
|
+
status: "pending"
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
async function persistEvent(db, event) {
|
|
599
|
+
await db.create({ collection: LIFECYCLE_EVENTS_COLLECTION, data: event });
|
|
600
|
+
}
|
|
601
|
+
async function dispatchLifecycleEvent(config, event) {
|
|
602
|
+
const db = config.db;
|
|
603
|
+
if (!db || !config.events?.handlers.length) return;
|
|
604
|
+
const maxAttempts = config.events.maxAttempts ?? 8;
|
|
605
|
+
const retryDelayMs = config.events.retryDelayMs ?? 1e3;
|
|
606
|
+
const attempts = event.attempts + 1;
|
|
607
|
+
try {
|
|
608
|
+
await db.update({ collection: LIFECYCLE_EVENTS_COLLECTION, id: event.id, data: { status: "processing", attempts } });
|
|
609
|
+
for (const handler of config.events.handlers) await handler({ ...event, status: "processing", attempts });
|
|
610
|
+
await db.update({
|
|
611
|
+
collection: LIFECYCLE_EVENTS_COLLECTION,
|
|
612
|
+
id: event.id,
|
|
613
|
+
data: { status: "delivered", attempts, deliveredAt: (/* @__PURE__ */ new Date()).toISOString(), lastError: null }
|
|
614
|
+
});
|
|
615
|
+
} catch (error) {
|
|
616
|
+
const exhausted = attempts >= maxAttempts;
|
|
617
|
+
await db.update({
|
|
618
|
+
collection: LIFECYCLE_EVENTS_COLLECTION,
|
|
619
|
+
id: event.id,
|
|
620
|
+
data: {
|
|
621
|
+
status: "failed",
|
|
622
|
+
attempts,
|
|
623
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
624
|
+
nextAttemptAt: exhausted ? null : new Date(Date.now() + retryDelayMs * 2 ** (attempts - 1)).toISOString()
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
async function saveWorkflowDraft(args) {
|
|
630
|
+
const { config, collection, id, originalDoc, data, user } = args;
|
|
631
|
+
const db = config.db;
|
|
632
|
+
const workflow = collection.workflow;
|
|
633
|
+
if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
|
|
634
|
+
const previous = originalDoc.__workflow;
|
|
635
|
+
const event = createLifecycleEvent({
|
|
636
|
+
name: "revision.created",
|
|
637
|
+
collection: collection.slug,
|
|
638
|
+
documentId: id,
|
|
639
|
+
actorId: user?.sub,
|
|
640
|
+
payload: { revision: previous.revision + 1, previousRevision: previous.revision }
|
|
641
|
+
});
|
|
642
|
+
const doc = await db.transaction(async (tx) => {
|
|
643
|
+
const nextMeta = {
|
|
644
|
+
...previous,
|
|
645
|
+
state: originalDoc.__published ? workflow.draftState ?? workflow.initialState : previous.state,
|
|
646
|
+
revision: previous.revision + 1
|
|
647
|
+
};
|
|
648
|
+
const updated = await tx.update({ collection: collection.slug, id, data: { ...data, __workflow: nextMeta } });
|
|
649
|
+
await persistEvent(tx, event);
|
|
650
|
+
return updated;
|
|
651
|
+
});
|
|
652
|
+
void dispatchLifecycleEvent(config, event);
|
|
653
|
+
return { doc, event };
|
|
654
|
+
}
|
|
655
|
+
async function createWorkflowDocument(args) {
|
|
656
|
+
const { config, collection, data, user } = args;
|
|
657
|
+
const db = config.db;
|
|
658
|
+
if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
|
|
659
|
+
let event;
|
|
660
|
+
const doc = await db.transaction(async (tx) => {
|
|
661
|
+
const created = await tx.create({ collection: collection.slug, data });
|
|
662
|
+
event = createLifecycleEvent({
|
|
663
|
+
name: "revision.created",
|
|
664
|
+
collection: collection.slug,
|
|
665
|
+
documentId: created.id,
|
|
666
|
+
actorId: user?.sub,
|
|
667
|
+
payload: { revision: 1, previousRevision: null }
|
|
668
|
+
});
|
|
669
|
+
await persistEvent(tx, event);
|
|
670
|
+
return created;
|
|
671
|
+
});
|
|
672
|
+
void dispatchLifecycleEvent(config, event);
|
|
673
|
+
return { doc, event };
|
|
674
|
+
}
|
|
675
|
+
async function transitionWorkflow(args) {
|
|
676
|
+
const { config, collection, id, transitionName, expectedRevision, comment, user, req } = args;
|
|
677
|
+
const db = config.db;
|
|
678
|
+
const workflow = collection.workflow;
|
|
679
|
+
if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
|
|
680
|
+
const original = await db.findOne({ collection: collection.slug, id });
|
|
681
|
+
if (!original) throw Object.assign(new Error("Not Found"), { statusCode: 404 });
|
|
682
|
+
const meta = original.__workflow;
|
|
683
|
+
if (!meta) throw Object.assign(new Error("Entry has no workflow metadata"), { statusCode: 409 });
|
|
684
|
+
if (expectedRevision !== void 0 && expectedRevision !== meta.revision) {
|
|
685
|
+
throw Object.assign(new Error("This entry changed since it was loaded. Refresh before transitioning it."), { statusCode: 409 });
|
|
686
|
+
}
|
|
687
|
+
const transition = workflow.transitions.find((item) => item.name === transitionName);
|
|
688
|
+
const fromStates = transition ? Array.isArray(transition.from) ? transition.from : [transition.from] : [];
|
|
689
|
+
if (!transition || !fromStates.includes(meta.state)) {
|
|
690
|
+
throw Object.assign(new Error(`Transition "${transitionName}" is not valid from "${meta.state}".`), { statusCode: 409 });
|
|
691
|
+
}
|
|
692
|
+
if (!availableWorkflowTransitions(workflow, meta.state, user).some((item) => item.name === transition.name)) {
|
|
693
|
+
throw Object.assign(new Error(`You do not have permission to perform "${transition.label}".`), { statusCode: 403 });
|
|
694
|
+
}
|
|
695
|
+
if (transition.requireComment && !comment?.trim()) {
|
|
696
|
+
throw Object.assign(new Error(`A comment is required for "${transition.label}".`), { statusCode: 400 });
|
|
697
|
+
}
|
|
698
|
+
const hookContext = { transition, from: meta.state, to: transition.to, doc: original, user, comment, req, db };
|
|
699
|
+
for (const hook of workflow.hooks?.beforeTransition ?? []) await hook(hookContext);
|
|
700
|
+
const events = [createLifecycleEvent({
|
|
701
|
+
name: "workflow.transitioned",
|
|
702
|
+
collection: collection.slug,
|
|
703
|
+
documentId: id,
|
|
704
|
+
actorId: user?.sub,
|
|
705
|
+
payload: { transition: transition.name, from: meta.state, to: transition.to, revision: meta.revision, comment }
|
|
706
|
+
})];
|
|
707
|
+
const targetState = workflow.states.find((state) => state.name === transition.to);
|
|
708
|
+
if (targetState.published) {
|
|
709
|
+
events.push(createLifecycleEvent({ name: "entry.published", collection: collection.slug, documentId: id, actorId: user?.sub, payload: { revision: meta.revision } }));
|
|
710
|
+
} else if (transition.unpublish) {
|
|
711
|
+
events.push(createLifecycleEvent({ name: "entry.unpublished", collection: collection.slug, documentId: id, actorId: user?.sub, payload: { revision: meta.publishedRevision } }));
|
|
712
|
+
}
|
|
713
|
+
const updated = await db.transaction(async (tx) => {
|
|
714
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
715
|
+
const { __published: _published, __workflow: _workflow, id: _id, ...working } = original;
|
|
716
|
+
const nextMeta = {
|
|
717
|
+
...meta,
|
|
718
|
+
state: transition.to,
|
|
719
|
+
...targetState.published ? { publishedRevision: meta.revision, publishedAt: now, publishedBy: user?.sub } : {},
|
|
720
|
+
...transition.unpublish ? { publishedRevision: void 0, publishedAt: void 0, publishedBy: void 0 } : {}
|
|
721
|
+
};
|
|
722
|
+
const data = { __workflow: nextMeta };
|
|
723
|
+
if (targetState.published) data.__published = working;
|
|
724
|
+
if (transition.unpublish) data.__published = null;
|
|
725
|
+
const next = await tx.update({ collection: collection.slug, id, data });
|
|
726
|
+
await tx.create({
|
|
727
|
+
collection: WORKFLOW_HISTORY_COLLECTION,
|
|
728
|
+
data: { collection: collection.slug, documentId: id, transition: transition.name, from: meta.state, to: transition.to, revision: meta.revision, comment: comment ?? null, actorId: user?.sub ?? null, createdAt: now }
|
|
729
|
+
});
|
|
730
|
+
for (const event of events) await persistEvent(tx, event);
|
|
731
|
+
if (collection.audit) {
|
|
732
|
+
await tx.create({ collection: "__audit", data: { collection: collection.slug, documentId: id, operation: "workflow.transition", user: user?.sub ?? null, timestamp: now, changes: JSON.stringify({ transition: transition.name, from: meta.state, to: transition.to, revision: meta.revision }) } });
|
|
733
|
+
}
|
|
734
|
+
return next;
|
|
735
|
+
});
|
|
736
|
+
for (const event of events) void dispatchLifecycleEvent(config, event);
|
|
737
|
+
for (const hook of workflow.hooks?.afterTransition ?? []) {
|
|
738
|
+
try {
|
|
739
|
+
await hook({ ...hookContext, doc: updated, event: events[0] });
|
|
740
|
+
} catch (error) {
|
|
741
|
+
console.error("[dyrected/workflow] afterTransition hook failed:", error);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return updated;
|
|
745
|
+
}
|
|
746
|
+
|
|
534
747
|
// src/controllers/collection.controller.ts
|
|
535
748
|
var CollectionController = class {
|
|
536
749
|
collection;
|
|
@@ -575,6 +788,9 @@ var CollectionController = class {
|
|
|
575
788
|
if (beforeReadResult !== void 0) {
|
|
576
789
|
where = beforeReadResult;
|
|
577
790
|
}
|
|
791
|
+
if (this.collection.workflow && !user) {
|
|
792
|
+
where = where ? { AND: [where, { __published: { exists: true } }] } : { __published: { exists: true } };
|
|
793
|
+
}
|
|
578
794
|
let result = await db.find({
|
|
579
795
|
collection: this.collection.slug,
|
|
580
796
|
limit,
|
|
@@ -595,7 +811,7 @@ var CollectionController = class {
|
|
|
595
811
|
where
|
|
596
812
|
});
|
|
597
813
|
}
|
|
598
|
-
result.docs = result.docs.map((doc) => DefaultsService.apply(this.collection.fields, doc));
|
|
814
|
+
result.docs = result.docs.map((doc) => this.collection.workflow ? materializeWorkflowDocument(doc, this.collection.workflow, user) : doc).filter((doc) => doc !== null).map((doc) => DefaultsService.apply(this.collection.fields, doc));
|
|
599
815
|
const processedDocs = [];
|
|
600
816
|
for (const doc of result.docs) {
|
|
601
817
|
const docWithCollectionHooks = await runCollectionHooks(this.collection.hooks?.afterRead, {
|
|
@@ -623,8 +839,12 @@ var CollectionController = class {
|
|
|
623
839
|
const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 10;
|
|
624
840
|
const user = c.get("user");
|
|
625
841
|
if (!id) return c.json({ message: "Missing ID" }, 400);
|
|
626
|
-
|
|
842
|
+
let doc = await db.findOne({ collection: this.collection.slug, id });
|
|
627
843
|
if (!doc) return c.json({ message: "Not Found" }, 404);
|
|
844
|
+
if (this.collection.workflow) {
|
|
845
|
+
doc = materializeWorkflowDocument(doc, this.collection.workflow, user);
|
|
846
|
+
if (!doc) return c.json({ message: "Not Found" }, 404);
|
|
847
|
+
}
|
|
628
848
|
const docWithDefaults = DefaultsService.apply(this.collection.fields, doc);
|
|
629
849
|
const docWithCollectionHooks = await runCollectionHooks(this.collection.hooks?.afterRead, {
|
|
630
850
|
doc: docWithDefaults,
|
|
@@ -664,6 +884,9 @@ var CollectionController = class {
|
|
|
664
884
|
createdBy: user?.sub ?? null,
|
|
665
885
|
updatedBy: user?.sub ?? null
|
|
666
886
|
};
|
|
887
|
+
if (this.collection.workflow) {
|
|
888
|
+
data = initializeWorkflowDocument(data, this.collection.workflow);
|
|
889
|
+
}
|
|
667
890
|
if (this.collection.auth && data.password) {
|
|
668
891
|
data.password = await hashPassword(data.password);
|
|
669
892
|
}
|
|
@@ -675,7 +898,7 @@ var CollectionController = class {
|
|
|
675
898
|
operation: "create",
|
|
676
899
|
db: readonlyDb
|
|
677
900
|
});
|
|
678
|
-
const doc = await db.create({ collection: this.collection.slug, data });
|
|
901
|
+
const doc = this.collection.workflow ? (await createWorkflowDocument({ config, collection: this.collection, data, user })).doc : await db.create({ collection: this.collection.slug, data });
|
|
679
902
|
if (this.collection.audit && db) {
|
|
680
903
|
AuditService.log(db, {
|
|
681
904
|
operation: "create",
|
|
@@ -693,8 +916,9 @@ var CollectionController = class {
|
|
|
693
916
|
operation: "create",
|
|
694
917
|
db
|
|
695
918
|
}, { isolated: true });
|
|
919
|
+
const responseDoc = this.collection.workflow ? materializeWorkflowDocument(doc, this.collection.workflow, user) : doc;
|
|
696
920
|
const readDoc = await runCollectionHooks(this.collection.hooks?.afterRead, {
|
|
697
|
-
doc,
|
|
921
|
+
doc: responseDoc,
|
|
698
922
|
req: c.req,
|
|
699
923
|
user,
|
|
700
924
|
db: readonlyDb
|
|
@@ -757,8 +981,9 @@ var CollectionController = class {
|
|
|
757
981
|
operation: "create",
|
|
758
982
|
db
|
|
759
983
|
}, { isolated: true });
|
|
984
|
+
const responseDoc = this.collection.workflow ? materializeWorkflowDocument(doc, this.collection.workflow, user) : doc;
|
|
760
985
|
const readDoc = await runCollectionHooks(this.collection.hooks?.afterRead, {
|
|
761
|
-
doc,
|
|
986
|
+
doc: responseDoc,
|
|
762
987
|
req: c.req,
|
|
763
988
|
user,
|
|
764
989
|
db: readonlyDb
|
|
@@ -800,7 +1025,7 @@ var CollectionController = class {
|
|
|
800
1025
|
operation: "update",
|
|
801
1026
|
db: readonlyDb
|
|
802
1027
|
});
|
|
803
|
-
const doc = await db.update({ collection: this.collection.slug, id, data });
|
|
1028
|
+
const doc = this.collection.workflow ? (await saveWorkflowDraft({ config, collection: this.collection, id, originalDoc, data, user })).doc : await db.update({ collection: this.collection.slug, id, data });
|
|
804
1029
|
if (this.collection.audit && db) {
|
|
805
1030
|
AuditService.log(db, {
|
|
806
1031
|
operation: "update",
|
|
@@ -828,6 +1053,42 @@ var CollectionController = class {
|
|
|
828
1053
|
const finalDoc = await executeFieldAfterRead(this.collection.fields, readDoc, user, readonlyDb);
|
|
829
1054
|
return c.json(finalDoc);
|
|
830
1055
|
}
|
|
1056
|
+
async transition(c) {
|
|
1057
|
+
const config = c.get("config");
|
|
1058
|
+
if (!config.db) return c.json({ message: "Database not configured" }, 500);
|
|
1059
|
+
if (!this.collection.workflow) return c.json({ message: "Workflows are not enabled for this collection" }, 404);
|
|
1060
|
+
const id = c.req.param("id");
|
|
1061
|
+
const transitionName = c.req.param("transition");
|
|
1062
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1063
|
+
try {
|
|
1064
|
+
const doc = await transitionWorkflow({
|
|
1065
|
+
config,
|
|
1066
|
+
collection: this.collection,
|
|
1067
|
+
id,
|
|
1068
|
+
transitionName,
|
|
1069
|
+
expectedRevision: body.expectedRevision,
|
|
1070
|
+
comment: body.comment,
|
|
1071
|
+
user: c.get("user"),
|
|
1072
|
+
req: { query: c.req.query(), headers: c.req.header(), raw: c.req.raw }
|
|
1073
|
+
});
|
|
1074
|
+
return c.json(materializeWorkflowDocument(doc, this.collection.workflow, c.get("user")));
|
|
1075
|
+
} catch (error) {
|
|
1076
|
+
const status = typeof error.statusCode === "number" ? error.statusCode : 500;
|
|
1077
|
+
return c.json({ error: true, message: error instanceof Error ? error.message : String(error) }, status);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
async workflowHistory(c) {
|
|
1081
|
+
const config = c.get("config");
|
|
1082
|
+
if (!config.db) return c.json({ message: "Database not configured" }, 500);
|
|
1083
|
+
if (!this.collection.workflow) return c.json({ message: "Workflows are not enabled for this collection" }, 404);
|
|
1084
|
+
const result = await config.db.find({
|
|
1085
|
+
collection: WORKFLOW_HISTORY_COLLECTION,
|
|
1086
|
+
where: { collection: { equals: this.collection.slug }, documentId: { equals: c.req.param("id") } },
|
|
1087
|
+
sort: "-createdAt",
|
|
1088
|
+
limit: Math.min(Number(c.req.query("limit")) || 50, 100)
|
|
1089
|
+
});
|
|
1090
|
+
return c.json(result);
|
|
1091
|
+
}
|
|
831
1092
|
/**
|
|
832
1093
|
* POST /api/collections/:slug/:id/change-password
|
|
833
1094
|
*
|
|
@@ -1295,6 +1556,96 @@ async function verifyCollectionToken(token) {
|
|
|
1295
1556
|
return payload;
|
|
1296
1557
|
}
|
|
1297
1558
|
|
|
1559
|
+
// src/services/email-template.ts
|
|
1560
|
+
var emailTokens = {
|
|
1561
|
+
colors: {
|
|
1562
|
+
canvas: "#f6f7f2",
|
|
1563
|
+
surface: "#ffffff",
|
|
1564
|
+
text: "#171717",
|
|
1565
|
+
muted: "#62665b",
|
|
1566
|
+
subtle: "#8a8f82",
|
|
1567
|
+
border: "#dde0d7",
|
|
1568
|
+
accent: "#b6ff2e",
|
|
1569
|
+
code: "#f1f3ec",
|
|
1570
|
+
dangerSurface: "#fff2f0",
|
|
1571
|
+
dangerBorder: "#ffc9c2",
|
|
1572
|
+
dangerText: "#9f251b"
|
|
1573
|
+
},
|
|
1574
|
+
font: "Arial, 'Helvetica Neue', Helvetica, sans-serif",
|
|
1575
|
+
mono: "'Courier New', Courier, monospace",
|
|
1576
|
+
radius: { card: "12px", control: "6px" },
|
|
1577
|
+
width: "600px"
|
|
1578
|
+
};
|
|
1579
|
+
function escapeHtml(value) {
|
|
1580
|
+
return value.replace(/[&<>'"]/g, (character) => ({
|
|
1581
|
+
"&": "&",
|
|
1582
|
+
"<": "<",
|
|
1583
|
+
">": ">",
|
|
1584
|
+
"'": "'",
|
|
1585
|
+
'"': """
|
|
1586
|
+
})[character]);
|
|
1587
|
+
}
|
|
1588
|
+
function safeHttpUrl(value) {
|
|
1589
|
+
try {
|
|
1590
|
+
const url = new URL(value);
|
|
1591
|
+
return url.protocol === "https:" || url.protocol === "http:" ? escapeHtml(url.toString()) : void 0;
|
|
1592
|
+
} catch {
|
|
1593
|
+
return void 0;
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
function heading(content) {
|
|
1597
|
+
return `<h1 style="margin:0;font-family:${emailTokens.font};font-size:24px;line-height:1.25;font-weight:700;color:${emailTokens.colors.text}">${escapeHtml(content)}</h1>`;
|
|
1598
|
+
}
|
|
1599
|
+
function paragraph(content, margin = "0 0 16px") {
|
|
1600
|
+
return `<p style="margin:${margin};font-family:${emailTokens.font};font-size:15px;line-height:1.6;color:${emailTokens.colors.muted}">${escapeHtml(content)}</p>`;
|
|
1601
|
+
}
|
|
1602
|
+
function sectionLabel(content) {
|
|
1603
|
+
return `<p style="margin:0 0 8px;font-family:${emailTokens.font};font-size:11px;line-height:1.4;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:${emailTokens.colors.subtle}">${escapeHtml(content)}</p>`;
|
|
1604
|
+
}
|
|
1605
|
+
function divider() {
|
|
1606
|
+
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"><tr><td style="height:1px;background:${emailTokens.colors.border};font-size:0;line-height:0"> </td></tr></table>`;
|
|
1607
|
+
}
|
|
1608
|
+
function spacer(height = 16) {
|
|
1609
|
+
return table(row(" ", `height:${height}px;font-size:0;line-height:0`));
|
|
1610
|
+
}
|
|
1611
|
+
function row(content, cellStyle = "") {
|
|
1612
|
+
return `<tr><td style="${cellStyle}">${content}</td></tr>`;
|
|
1613
|
+
}
|
|
1614
|
+
function table(content, style = "width:100%") {
|
|
1615
|
+
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="${style}">${content}</table>`;
|
|
1616
|
+
}
|
|
1617
|
+
function detailBox(content, monospace = false) {
|
|
1618
|
+
const font = monospace ? emailTokens.mono : emailTokens.font;
|
|
1619
|
+
return table(row(escapeHtml(content), `padding:14px 16px;font-family:${font};font-size:13px;line-height:1.5;font-weight:${monospace ? "400" : "700"};color:${emailTokens.colors.text};word-break:break-all`), `width:100%;background:${emailTokens.colors.code};border-radius:${emailTokens.radius.control}`);
|
|
1620
|
+
}
|
|
1621
|
+
function ctaButton(label, href) {
|
|
1622
|
+
const safeHref = safeHttpUrl(href);
|
|
1623
|
+
if (!safeHref) return "";
|
|
1624
|
+
return table(row(
|
|
1625
|
+
`<a href="${safeHref}" style="display:inline-block;padding:13px 22px;border-radius:${emailTokens.radius.control};background:${emailTokens.colors.accent};font-family:${emailTokens.font};font-size:14px;line-height:1.2;font-weight:700;color:${emailTokens.colors.text};text-decoration:none">${escapeHtml(label)}</a>`,
|
|
1626
|
+
"padding:8px 0 24px"
|
|
1627
|
+
), "width:auto");
|
|
1628
|
+
}
|
|
1629
|
+
function alertBox(content) {
|
|
1630
|
+
return table(row(escapeHtml(content), `padding:13px 16px;font-family:${emailTokens.font};font-size:13px;line-height:1.5;color:${emailTokens.colors.dangerText}`), `width:100%;background:${emailTokens.colors.dangerSurface};border:1px solid ${emailTokens.colors.dangerBorder};border-radius:${emailTokens.radius.control}`);
|
|
1631
|
+
}
|
|
1632
|
+
function layout({ preheader, title, content, footer }) {
|
|
1633
|
+
return `<!doctype html>
|
|
1634
|
+
<html lang="en">
|
|
1635
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head>
|
|
1636
|
+
<body style="margin:0;padding:0;background:${emailTokens.colors.canvas}">
|
|
1637
|
+
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent">${escapeHtml(preheader)}</div>
|
|
1638
|
+
${table(row(
|
|
1639
|
+
table(
|
|
1640
|
+
row(" ", `height:5px;background:${emailTokens.colors.accent};font-size:0;line-height:0`) + row(`${sectionLabel("Dyrected")}${heading(title)}`, "padding:30px 32px 24px") + row(content, "padding:0 32px 32px") + row(`${divider()}${paragraph(footer, "20px 0 6px")}${paragraph("Privacy: this message contains account-related information; please avoid forwarding it.", "0")}`, "padding:0 32px 28px"),
|
|
1641
|
+
`width:100%;max-width:${emailTokens.width};background:${emailTokens.colors.surface};border:1px solid ${emailTokens.colors.border};border-radius:${emailTokens.radius.card};overflow:hidden`
|
|
1642
|
+
),
|
|
1643
|
+
"padding:32px 12px"
|
|
1644
|
+
), `width:100%;background:${emailTokens.colors.canvas}`)}
|
|
1645
|
+
</body>
|
|
1646
|
+
</html>`;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1298
1649
|
// src/services/email.service.ts
|
|
1299
1650
|
var _devSend = null;
|
|
1300
1651
|
var _devSendPromise = null;
|
|
@@ -1338,77 +1689,24 @@ function buildWelcomeEmail(config, args) {
|
|
|
1338
1689
|
const custom = config.email?.templates?.welcome?.(args);
|
|
1339
1690
|
return {
|
|
1340
1691
|
subject: custom?.subject ?? "Welcome \u2014 your account is ready",
|
|
1341
|
-
html: custom?.html ??
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
<td style="padding:32px 32px 0">
|
|
1348
|
-
<p style="margin:0 0 4px;font-size:12px;font-weight:600;color:#6b7280;font-family:sans-serif;text-transform:uppercase;letter-spacing:0.05em">Dyrected</p>
|
|
1349
|
-
<h1 style="margin:0 0 24px;font-size:22px;font-weight:700;color:#111827;font-family:sans-serif">Welcome!</h1>
|
|
1350
|
-
</td>
|
|
1351
|
-
</tr>
|
|
1352
|
-
<tr>
|
|
1353
|
-
<td style="padding:0 32px">
|
|
1354
|
-
<p style="margin:0 0 12px;font-size:14px;color:#4b5563;line-height:1.6;font-family:sans-serif">Your account has been created. You can now log in with:</p>
|
|
1355
|
-
<table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f3f4f6;border-radius:6px;table-layout:fixed">
|
|
1356
|
-
<tr>
|
|
1357
|
-
<td style="padding:12px 16px;font-size:14px;font-weight:600;color:#111827;font-family:sans-serif;word-break:break-all">
|
|
1358
|
-
${args.email}
|
|
1359
|
-
</td>
|
|
1360
|
-
</tr>
|
|
1361
|
-
</table>
|
|
1362
|
-
</td>
|
|
1363
|
-
</tr>
|
|
1364
|
-
<tr>
|
|
1365
|
-
<td style="padding:32px">
|
|
1366
|
-
<p style="margin:0;font-size:12px;color:#9ca3af;font-family:sans-serif">If you didn't create this account, you can safely ignore this email.</p>
|
|
1367
|
-
</td>
|
|
1368
|
-
</tr>
|
|
1369
|
-
</table>
|
|
1370
|
-
</td>
|
|
1371
|
-
</tr>
|
|
1372
|
-
</table>`
|
|
1692
|
+
html: custom?.html ?? layout({
|
|
1693
|
+
preheader: "Your Dyrected account is ready.",
|
|
1694
|
+
title: "Welcome \u2014 your account is ready",
|
|
1695
|
+
content: `${paragraph("Your account has been created. You can now log in with:")}${detailBox(args.email)}`,
|
|
1696
|
+
footer: "If you didn't create this account, you can safely ignore this email."
|
|
1697
|
+
})
|
|
1373
1698
|
};
|
|
1374
1699
|
}
|
|
1375
1700
|
function buildInviteEmail(config, args) {
|
|
1376
1701
|
const custom = config.email?.templates?.invite?.(args);
|
|
1377
1702
|
return {
|
|
1378
1703
|
subject: custom?.subject ?? "You've been invited",
|
|
1379
|
-
html: custom?.html ??
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
<td style="padding:32px 32px 0">
|
|
1386
|
-
<p style="margin:0 0 4px;font-size:12px;font-weight:600;color:#6b7280;font-family:sans-serif;text-transform:uppercase;letter-spacing:0.05em">Dyrected</p>
|
|
1387
|
-
<h1 style="margin:0 0 24px;font-size:22px;font-weight:700;color:#111827;font-family:sans-serif">You've been invited</h1>
|
|
1388
|
-
</td>
|
|
1389
|
-
</tr>
|
|
1390
|
-
<tr>
|
|
1391
|
-
<td style="padding:0 32px">
|
|
1392
|
-
${args.invitedByEmail ? `<p style="margin:0 0 12px;font-size:14px;color:#4b5563;line-height:1.6;font-family:sans-serif">You were invited by <strong style="color:#111827">${args.invitedByEmail}</strong>.</p>` : ""}
|
|
1393
|
-
<p style="margin:0 0 16px;font-size:14px;color:#4b5563;line-height:1.6;font-family:sans-serif">Use the token below to accept your invitation. It expires in 7 days.</p>
|
|
1394
|
-
<table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f3f4f6;border-radius:6px;table-layout:fixed">
|
|
1395
|
-
<tr>
|
|
1396
|
-
<td style="padding:12px 16px;font-family:monospace;font-size:12px;color:#374151;word-break:break-all;white-space:normal;line-height:1.4">
|
|
1397
|
-
${args.token}
|
|
1398
|
-
</td>
|
|
1399
|
-
</tr>
|
|
1400
|
-
</table>
|
|
1401
|
-
</td>
|
|
1402
|
-
</tr>
|
|
1403
|
-
<tr>
|
|
1404
|
-
<td style="padding:32px">
|
|
1405
|
-
<p style="margin:0;font-size:12px;color:#9ca3af;font-family:sans-serif">If you weren't expecting this invitation, you can safely ignore this email.</p>
|
|
1406
|
-
</td>
|
|
1407
|
-
</tr>
|
|
1408
|
-
</table>
|
|
1409
|
-
</td>
|
|
1410
|
-
</tr>
|
|
1411
|
-
</table>`
|
|
1704
|
+
html: custom?.html ?? layout({
|
|
1705
|
+
preheader: "You've been invited to join a Dyrected account.",
|
|
1706
|
+
title: "You've been invited",
|
|
1707
|
+
content: `${args.invitedByEmail ? paragraph(`You were invited by ${args.invitedByEmail}.`) : ""}${paragraph("Use the token below to accept your invitation. It expires in 7 days.")}${sectionLabel("Invitation token")}${detailBox(args.token, true)}`,
|
|
1708
|
+
footer: "If you weren't expecting this invitation, you can safely ignore this email."
|
|
1709
|
+
})
|
|
1412
1710
|
};
|
|
1413
1711
|
}
|
|
1414
1712
|
function buildResetPasswordEmail(config, args) {
|
|
@@ -1416,95 +1714,24 @@ function buildResetPasswordEmail(config, args) {
|
|
|
1416
1714
|
const resetLink = args.url;
|
|
1417
1715
|
return {
|
|
1418
1716
|
subject: custom?.subject ?? "Reset your password",
|
|
1419
|
-
html: custom?.html ??
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
<td style="padding:32px 32px 0">
|
|
1426
|
-
<p style="margin:0 0 4px;font-size:12px;font-weight:600;color:#6b7280;font-family:sans-serif;text-transform:uppercase;letter-spacing:0.05em">Dyrected</p>
|
|
1427
|
-
<h1 style="margin:0 0 24px;font-size:22px;font-weight:700;color:#111827;font-family:sans-serif">Reset your password</h1>
|
|
1428
|
-
</td>
|
|
1429
|
-
</tr>
|
|
1430
|
-
<tr>
|
|
1431
|
-
<td style="padding:0 32px">
|
|
1432
|
-
<p style="margin:0 0 24px;font-size:14px;color:#4b5563;line-height:1.6;font-family:sans-serif">We received a request to reset your password. Use the button below to set a new password. It will expire in 1 hour.</p>
|
|
1433
|
-
${resetLink ? `
|
|
1434
|
-
<table cellpadding="0" cellspacing="0" border="0" style="margin-bottom:24px">
|
|
1435
|
-
<tr>
|
|
1436
|
-
<td style="border-radius:6px;background-color:#111827">
|
|
1437
|
-
<a href="${resetLink}" style="display:inline-block;padding:12px 28px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;font-family:sans-serif;border-radius:6px">
|
|
1438
|
-
Reset Password
|
|
1439
|
-
</a>
|
|
1440
|
-
</td>
|
|
1441
|
-
</tr>
|
|
1442
|
-
</table>
|
|
1443
|
-
` : ""}
|
|
1444
|
-
<p style="margin:0 0 8px;font-size:12px;color:#9ca3af;font-family:sans-serif">Or copy and paste this token manually in the admin dashboard:</p>
|
|
1445
|
-
<table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f3f4f6;border-radius:6px;table-layout:fixed">
|
|
1446
|
-
<tr>
|
|
1447
|
-
<td style="padding:12px 16px;font-family:monospace;font-size:12px;color:#374151;word-break:break-all;white-space:normal;line-height:1.4">
|
|
1448
|
-
${args.token}
|
|
1449
|
-
</td>
|
|
1450
|
-
</tr>
|
|
1451
|
-
</table>
|
|
1452
|
-
</td>
|
|
1453
|
-
</tr>
|
|
1454
|
-
<tr>
|
|
1455
|
-
<td style="padding:32px">
|
|
1456
|
-
<p style="margin:0;font-size:12px;color:#9ca3af;font-family:sans-serif">If you didn't request a password reset, you can safely ignore this email.</p>
|
|
1457
|
-
</td>
|
|
1458
|
-
</tr>
|
|
1459
|
-
</table>
|
|
1460
|
-
</td>
|
|
1461
|
-
</tr>
|
|
1462
|
-
</table>`
|
|
1717
|
+
html: custom?.html ?? layout({
|
|
1718
|
+
preheader: "Reset your Dyrected password.",
|
|
1719
|
+
title: "Reset your password",
|
|
1720
|
+
content: `${paragraph("We received a request to reset your password. The reset link and token expire in 1 hour.")}${resetLink ? ctaButton("Reset password", resetLink) : ""}${sectionLabel(resetLink ? "Or use this token" : "Reset token")}${detailBox(args.token, true)}`,
|
|
1721
|
+
footer: "If you didn't request a password reset, you can safely ignore this email."
|
|
1722
|
+
})
|
|
1463
1723
|
};
|
|
1464
1724
|
}
|
|
1465
1725
|
function buildPasswordChangedEmail(config, args) {
|
|
1466
1726
|
const custom = config.email?.templates?.passwordChanged?.(args);
|
|
1467
1727
|
return {
|
|
1468
1728
|
subject: custom?.subject ?? "Your password has been changed",
|
|
1469
|
-
html: custom?.html ??
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
<td style="padding:32px 32px 0">
|
|
1476
|
-
<p style="margin:0 0 4px;font-size:12px;font-weight:600;color:#6b7280;font-family:sans-serif;text-transform:uppercase;letter-spacing:0.05em">Dyrected</p>
|
|
1477
|
-
<h1 style="margin:0 0 24px;font-size:22px;font-weight:700;color:#111827;font-family:sans-serif">Password changed</h1>
|
|
1478
|
-
</td>
|
|
1479
|
-
</tr>
|
|
1480
|
-
<tr>
|
|
1481
|
-
<td style="padding:0 32px">
|
|
1482
|
-
<p style="margin:0 0 12px;font-size:14px;color:#4b5563;line-height:1.6;font-family:sans-serif">The password for your account was just changed:</p>
|
|
1483
|
-
<table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f3f4f6;border-radius:6px;table-layout:fixed">
|
|
1484
|
-
<tr>
|
|
1485
|
-
<td style="padding:12px 16px;font-size:14px;font-weight:600;color:#111827;font-family:sans-serif;word-break:break-all">
|
|
1486
|
-
${args.email}
|
|
1487
|
-
</td>
|
|
1488
|
-
</tr>
|
|
1489
|
-
</table>
|
|
1490
|
-
<table cellpadding="0" cellspacing="0" border="0" style="width:100%;margin-top:16px;background-color:#fef2f2;border-radius:6px;border:1px solid #fecaca;table-layout:fixed">
|
|
1491
|
-
<tr>
|
|
1492
|
-
<td style="padding:12px 16px;font-size:13px;color:#b91c1c;line-height:1.5;font-family:sans-serif">
|
|
1493
|
-
If you did not make this change, please contact support immediately.
|
|
1494
|
-
</td>
|
|
1495
|
-
</tr>
|
|
1496
|
-
</table>
|
|
1497
|
-
</td>
|
|
1498
|
-
</tr>
|
|
1499
|
-
<tr>
|
|
1500
|
-
<td style="padding:32px">
|
|
1501
|
-
<p style="margin:0;font-size:12px;color:#9ca3af;font-family:sans-serif">This is an automated security notification.</p>
|
|
1502
|
-
</td>
|
|
1503
|
-
</tr>
|
|
1504
|
-
</table>
|
|
1505
|
-
</td>
|
|
1506
|
-
</tr>
|
|
1507
|
-
</table>`
|
|
1729
|
+
html: custom?.html ?? layout({
|
|
1730
|
+
preheader: "Your Dyrected password was changed.",
|
|
1731
|
+
title: "Password changed",
|
|
1732
|
+
content: `${paragraph("The password for this account was just changed:")}${detailBox(args.email)}${spacer()}${alertBox("If you did not make this change, please contact support immediately.")}`,
|
|
1733
|
+
footer: "This is an automated security notification."
|
|
1734
|
+
})
|
|
1508
1735
|
};
|
|
1509
1736
|
}
|
|
1510
1737
|
|
|
@@ -2373,7 +2600,22 @@ function registerRoutes(app, config) {
|
|
|
2373
2600
|
}))),
|
|
2374
2601
|
upload: !!col.upload,
|
|
2375
2602
|
auth: !!col.auth,
|
|
2376
|
-
admin: col.admin
|
|
2603
|
+
admin: col.admin,
|
|
2604
|
+
workflow: col.workflow ? {
|
|
2605
|
+
initialState: col.workflow.initialState,
|
|
2606
|
+
draftState: col.workflow.draftState,
|
|
2607
|
+
states: col.workflow.states,
|
|
2608
|
+
transitions: col.workflow.transitions.map((t) => ({
|
|
2609
|
+
name: t.name,
|
|
2610
|
+
label: t.label,
|
|
2611
|
+
from: t.from,
|
|
2612
|
+
to: t.to,
|
|
2613
|
+
requiredCapabilities: t.requiredCapabilities,
|
|
2614
|
+
requireComment: t.requireComment,
|
|
2615
|
+
unpublish: t.unpublish
|
|
2616
|
+
})),
|
|
2617
|
+
roles: col.workflow.roles
|
|
2618
|
+
} : void 0
|
|
2377
2619
|
})));
|
|
2378
2620
|
const filteredGlobals = await Promise.all(globals.filter((glb) => !siteId || glb.shared || !glb.siteId || glb.siteId === siteId).map(async (glb) => ({
|
|
2379
2621
|
slug: glb.slug,
|
|
@@ -2567,6 +2809,10 @@ function registerRoutes(app, config) {
|
|
|
2567
2809
|
if (collection.auth) {
|
|
2568
2810
|
app.post(`${path}/:id/change-password`, requireAuth(), (c) => controller.changePassword(c));
|
|
2569
2811
|
}
|
|
2812
|
+
if (collection.workflow) {
|
|
2813
|
+
app.post(`${path}/:id/transitions/:transition`, requireAuth(), (c) => controller.transition(c));
|
|
2814
|
+
app.get(`${path}/:id/workflow-history`, requireAuth(), (c) => controller.workflowHistory(c));
|
|
2815
|
+
}
|
|
2570
2816
|
}
|
|
2571
2817
|
for (const global of config.globals) {
|
|
2572
2818
|
const path = `/api/globals/${global.slug}`;
|
|
@@ -2578,6 +2824,41 @@ function registerRoutes(app, config) {
|
|
|
2578
2824
|
const previewController = new PreviewController();
|
|
2579
2825
|
app.post("/api/preview-token", requireAuth(), (c) => previewController.createToken(c));
|
|
2580
2826
|
app.get("/api/preview-data", (c) => previewController.getData(c));
|
|
2827
|
+
app.all("/api/collections/:slug/:id/*", requireAuth(), async (c) => {
|
|
2828
|
+
const slug = c.req.param("slug");
|
|
2829
|
+
const id = c.req.param("id");
|
|
2830
|
+
const siteId = c.req.header("X-Site-Id") || c.get("siteId");
|
|
2831
|
+
const config2 = c.get("config");
|
|
2832
|
+
if (config2.collections.some((col) => col.slug === slug)) {
|
|
2833
|
+
return c.json({ message: "Not Found" }, 404);
|
|
2834
|
+
}
|
|
2835
|
+
if (!config2.onSchemaFetch || !siteId) {
|
|
2836
|
+
return c.json({ message: `Collection "${slug}" not found` }, 404);
|
|
2837
|
+
}
|
|
2838
|
+
const dynamic = await config2.onSchemaFetch(siteId);
|
|
2839
|
+
const collection = dynamic.collections?.find((col) => col.slug === slug);
|
|
2840
|
+
if (!collection?.workflow) {
|
|
2841
|
+
return c.json({ message: `Collection "${slug}" not found or has no workflow` }, 404);
|
|
2842
|
+
}
|
|
2843
|
+
const controller = new CollectionController(collection);
|
|
2844
|
+
const rawPath = c.req.path;
|
|
2845
|
+
const method = c.req.method;
|
|
2846
|
+
const transitionMatch = rawPath.match(/\/transitions\/([^/]+)$/);
|
|
2847
|
+
if (transitionMatch && method === "POST") {
|
|
2848
|
+
c.req.raw.__transition = transitionMatch[1];
|
|
2849
|
+
const origParam = c.req.param.bind(c.req);
|
|
2850
|
+
c.req.param = (key) => {
|
|
2851
|
+
if (key === "transition") return transitionMatch[1];
|
|
2852
|
+
if (key === "id") return id;
|
|
2853
|
+
return origParam(key);
|
|
2854
|
+
};
|
|
2855
|
+
return controller.transition(c);
|
|
2856
|
+
}
|
|
2857
|
+
if (rawPath.endsWith("/workflow-history") && method === "GET") {
|
|
2858
|
+
return controller.workflowHistory(c);
|
|
2859
|
+
}
|
|
2860
|
+
return c.json({ message: "Not Found" }, 404);
|
|
2861
|
+
});
|
|
2581
2862
|
app.all("/api/collections/:slug/:id?", async (c) => {
|
|
2582
2863
|
const slug = c.req.param("slug");
|
|
2583
2864
|
const id = c.req.param("id");
|
|
@@ -2686,10 +2967,47 @@ var AUDIT_COLLECTION = {
|
|
|
2686
2967
|
],
|
|
2687
2968
|
admin: { hidden: true }
|
|
2688
2969
|
};
|
|
2970
|
+
var WORKFLOW_HISTORY_COLLECTION_CONFIG = {
|
|
2971
|
+
slug: WORKFLOW_HISTORY_COLLECTION,
|
|
2972
|
+
labels: { singular: "Workflow transition", plural: "Workflow transitions" },
|
|
2973
|
+
fields: [
|
|
2974
|
+
{ name: "collection", type: "text", required: true },
|
|
2975
|
+
{ name: "documentId", type: "text", required: true },
|
|
2976
|
+
{ name: "transition", type: "text", required: true },
|
|
2977
|
+
{ name: "from", type: "text", required: true },
|
|
2978
|
+
{ name: "to", type: "text", required: true },
|
|
2979
|
+
{ name: "revision", type: "number", required: true },
|
|
2980
|
+
{ name: "comment", type: "textarea" },
|
|
2981
|
+
{ name: "actorId", type: "text" },
|
|
2982
|
+
{ name: "createdAt", type: "date", required: true }
|
|
2983
|
+
],
|
|
2984
|
+
access: { read: ({ user }) => !!user, create: () => false, update: () => false, delete: () => false },
|
|
2985
|
+
admin: { hidden: true }
|
|
2986
|
+
};
|
|
2987
|
+
var LIFECYCLE_EVENTS_COLLECTION_CONFIG = {
|
|
2988
|
+
slug: LIFECYCLE_EVENTS_COLLECTION,
|
|
2989
|
+
labels: { singular: "Lifecycle event", plural: "Lifecycle events" },
|
|
2990
|
+
fields: [
|
|
2991
|
+
{ name: "name", type: "text", required: true },
|
|
2992
|
+
{ name: "collection", type: "text", required: true },
|
|
2993
|
+
{ name: "documentId", type: "text", required: true },
|
|
2994
|
+
{ name: "occurredAt", type: "date", required: true },
|
|
2995
|
+
{ name: "actorId", type: "text" },
|
|
2996
|
+
{ name: "payload", type: "json", required: true },
|
|
2997
|
+
{ name: "attempts", type: "number", required: true },
|
|
2998
|
+
{ name: "status", type: "select", options: ["pending", "processing", "delivered", "failed"], required: true },
|
|
2999
|
+
{ name: "nextAttemptAt", type: "date" },
|
|
3000
|
+
{ name: "deliveredAt", type: "date" },
|
|
3001
|
+
{ name: "lastError", type: "textarea" }
|
|
3002
|
+
],
|
|
3003
|
+
access: { read: ({ user }) => !!user?.roles?.includes("admin"), create: () => false, update: () => false, delete: () => false },
|
|
3004
|
+
admin: { hidden: true }
|
|
3005
|
+
};
|
|
2689
3006
|
function normalizeConfig(config) {
|
|
2690
3007
|
const collections = config?.collections || [];
|
|
2691
3008
|
const globals = config?.globals || [];
|
|
2692
3009
|
const needsAudit = collections.some((col) => col.audit);
|
|
3010
|
+
const needsWorkflow = collections.some((col) => col.workflow);
|
|
2693
3011
|
const normalizedCollections = collections.map((col) => {
|
|
2694
3012
|
let fields = col.fields || [];
|
|
2695
3013
|
const existingFieldNames = new Set(fields.map((f) => f.name));
|
|
@@ -2784,9 +3102,17 @@ function normalizeConfig(config) {
|
|
|
2784
3102
|
};
|
|
2785
3103
|
});
|
|
2786
3104
|
const hasAuditCollection = normalizedCollections.some((col) => col.slug === AUDIT_COLLECTION_SLUG);
|
|
3105
|
+
const systemCollections = [];
|
|
3106
|
+
if (needsAudit && !hasAuditCollection) systemCollections.push(AUDIT_COLLECTION);
|
|
3107
|
+
if (needsWorkflow && !normalizedCollections.some((col) => col.slug === WORKFLOW_HISTORY_COLLECTION)) {
|
|
3108
|
+
systemCollections.push(WORKFLOW_HISTORY_COLLECTION_CONFIG);
|
|
3109
|
+
}
|
|
3110
|
+
if (needsWorkflow && !normalizedCollections.some((col) => col.slug === LIFECYCLE_EVENTS_COLLECTION)) {
|
|
3111
|
+
systemCollections.push(LIFECYCLE_EVENTS_COLLECTION_CONFIG);
|
|
3112
|
+
}
|
|
2787
3113
|
return {
|
|
2788
3114
|
...config,
|
|
2789
|
-
collections: [...normalizedCollections, ...
|
|
3115
|
+
collections: [...normalizedCollections, ...systemCollections],
|
|
2790
3116
|
globals
|
|
2791
3117
|
};
|
|
2792
3118
|
}
|