@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/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
- const doc = await db.findOne({ collection: this.collection.slug, id });
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
+ "&": "&amp;",
1582
+ "<": "&lt;",
1583
+ ">": "&gt;",
1584
+ "'": "&#39;",
1585
+ '"': "&quot;"
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">&nbsp;</td></tr></table>`;
1607
+ }
1608
+ function spacer(height = 16) {
1609
+ return table(row("&nbsp;", `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("&nbsp;", `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
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f9fafb;table-layout:fixed">
1343
- <tr>
1344
- <td align="center" style="padding:40px 16px">
1345
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:600px;background-color:#ffffff;border-radius:12px;border:1px solid #e5e7eb;table-layout:fixed">
1346
- <tr>
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
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f9fafb;table-layout:fixed">
1381
- <tr>
1382
- <td align="center" style="padding:40px 16px">
1383
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:600px;background-color:#ffffff;border-radius:12px;border:1px solid #e5e7eb;table-layout:fixed">
1384
- <tr>
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
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f9fafb;table-layout:fixed">
1421
- <tr>
1422
- <td align="center" style="padding:40px 16px">
1423
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:600px;background-color:#ffffff;border-radius:12px;border:1px solid #e5e7eb;table-layout:fixed">
1424
- <tr>
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
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f9fafb;table-layout:fixed">
1471
- <tr>
1472
- <td align="center" style="padding:40px 16px">
1473
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:600px;background-color:#ffffff;border-radius:12px;border:1px solid #e5e7eb;table-layout:fixed">
1474
- <tr>
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, ...needsAudit && !hasAuditCollection ? [AUDIT_COLLECTION] : []],
3115
+ collections: [...normalizedCollections, ...systemCollections],
2790
3116
  globals
2791
3117
  };
2792
3118
  }