@easy-cms/core 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -238,6 +238,8 @@ interface EmailMessage {
238
238
  * development. Throw to report a failure: the message is retried later.
239
239
  */
240
240
  interface EmailAdapter {
241
+ /** What sends the email, e.g. `smtp`; shown to admins on the dashboard (System). */
242
+ readonly name?: string;
241
243
  /** The sender when a message has none, e.g. `Easy CMS <no-reply@example.com>`. */
242
244
  readonly from?: string | undefined;
243
245
  send(message: EmailMessage & {
@@ -1063,6 +1065,18 @@ declare class EasyCMS<C extends Config = Config> {
1063
1065
  })[]>;
1064
1066
  private pendingJobs;
1065
1067
  private removeJob;
1068
+ /**
1069
+ * @internal One attempt now for a saved webhook delivery or email (the admin's Retry): removed
1070
+ * when it is sent, otherwise kept as failed with the new error.
1071
+ */
1072
+ retryDelivery(kind: 'webhook' | 'email', id: ID): Promise<{
1073
+ ok: true;
1074
+ } | {
1075
+ ok: false;
1076
+ error: string;
1077
+ }>;
1078
+ /** Removes webhook deliveries and emails that failed more than 30 days ago. */
1079
+ private pruneFailedDeliveries;
1066
1080
  /** @internal */
1067
1081
  collection(slug: string): CollectionConfig;
1068
1082
  private global;
@@ -1726,8 +1740,17 @@ interface Endpoint {
1726
1740
  */
1727
1741
  readonly handler: (request: EndpointRequest) => MaybePromise<unknown>;
1728
1742
  }
1743
+ /** A plugin's package and version, listed for admins on the dashboard (System). */
1744
+ interface PluginInfo {
1745
+ /** Usually the package name, e.g. `@acme/easy-cms-plugin-stats`. */
1746
+ readonly name: string;
1747
+ readonly version?: string;
1748
+ }
1729
1749
  /** Receives the config and returns a modified copy. Runs before validation. */
1730
- type Plugin = (config: Config) => MaybePromise<Config>;
1750
+ type Plugin = ((config: Config) => MaybePromise<Config>) & {
1751
+ /** Set by `definePlugin(plugin, info)`. */
1752
+ readonly info?: PluginInfo;
1753
+ };
1731
1754
  /** Holds a plugin's `PluginTypes` at the type level only: nothing is stored at runtime. */
1732
1755
  declare const PLUGIN_TYPES: unique symbol;
1733
1756
  /**
@@ -1749,17 +1772,18 @@ type TypedPlugin<T extends PluginTypes> = Plugin & {
1749
1772
  readonly [PLUGIN_TYPES]?: T;
1750
1773
  };
1751
1774
  /**
1752
- * Returns the plugin unchanged, with what it adds as types, so `cms.find()` and
1753
- * `CollectionDocument` know the plugin's fields:
1775
+ * Returns the plugin with what it adds as types, so `cms.find()` and `CollectionDocument` know
1776
+ * the plugin's fields; `info` (its name and version) is listed for admins on the dashboard:
1754
1777
  *
1755
1778
  * ```ts
1756
1779
  * export const colorPlugin = (options: { collections: readonly string[] }) =>
1757
1780
  * definePlugin<{ fields: { posts: readonly [{ readonly name: 'color'; readonly type: 'text' }] } }>(
1758
1781
  * (config) => ({ ...config, collections: … }),
1782
+ * { name: '@acme/easy-cms-plugin-color', version: '1.2.0' },
1759
1783
  * )
1760
1784
  * ```
1761
1785
  */
1762
- declare function definePlugin<const T extends PluginTypes = Record<never, never>>(plugin: Plugin): TypedPlugin<T>;
1786
+ declare function definePlugin<const T extends PluginTypes = Record<never, never>>(plugin: Plugin, info?: PluginInfo): TypedPlugin<T>;
1763
1787
  interface Config {
1764
1788
  /** Signs sessions. At least 32 characters; read it from `process.env.EASY_CMS_SECRET`. */
1765
1789
  readonly secret: string;
@@ -1831,6 +1855,8 @@ interface ResolvedConfig extends Omit<Config, 'cors' | 'localization' | 'routes'
1831
1855
  readonly endpoints: readonly Endpoint[];
1832
1856
  readonly commands: readonly CliCommand[];
1833
1857
  readonly fieldTypes: readonly FieldTypeDefinition[];
1858
+ /** The config's plugins in order, with their `info` where they give one. */
1859
+ readonly installedPlugins: readonly Partial<PluginInfo>[];
1834
1860
  }
1835
1861
  /**
1836
1862
  * Declares the Easy CMS config. Returns it unchanged, keeping literal types
@@ -2013,6 +2039,47 @@ declare function applyPlugins(input: Config): Promise<Config>;
2013
2039
  */
2014
2040
  declare function resolveConfig(input: Config | ResolvedConfig): Promise<ResolvedConfig>;
2015
2041
 
2042
+ type DeliveryKind = 'webhook' | 'email';
2043
+ type DeliveryState = 'failed' | 'pending';
2044
+ interface DeliveryBase {
2045
+ id: ID;
2046
+ state: DeliveryState;
2047
+ attempts: number;
2048
+ /** The last attempt's error. */
2049
+ error: string | null;
2050
+ createdAt: string;
2051
+ updatedAt: string;
2052
+ /** When a pending one is tried again. */
2053
+ nextAttemptAt: string;
2054
+ }
2055
+ /** A webhook delivery as the admin shows it (`GET <api>/admin/deliveries?kind=webhook`). */
2056
+ interface AdminWebhookDelivery extends DeliveryBase {
2057
+ kind: 'webhook';
2058
+ url: string;
2059
+ event: string;
2060
+ /** What changed: a document of a collection, or a global. */
2061
+ collection?: string;
2062
+ doc?: ID;
2063
+ global?: string;
2064
+ /** The JSON body as sent. */
2065
+ body: string;
2066
+ }
2067
+ /** An email as the admin shows it: who to and the subject, not the content. */
2068
+ interface AdminEmailDelivery extends DeliveryBase {
2069
+ kind: 'email';
2070
+ to: string[];
2071
+ subject: string;
2072
+ }
2073
+ type AdminDelivery = AdminWebhookDelivery | AdminEmailDelivery;
2074
+ interface AdminDeliveries {
2075
+ docs: AdminDelivery[];
2076
+ totalDocs: number;
2077
+ page: number;
2078
+ totalPages: number;
2079
+ /** How many there are of each state, for the filter. */
2080
+ counts: Record<DeliveryState, number>;
2081
+ }
2082
+
2016
2083
  /**
2017
2084
  * Finds the file of an admin module (`admin.modules`): a package export or a path relative to
2018
2085
  * the project root. Looks from the project root first, then from the server's entry file
@@ -2156,12 +2223,65 @@ interface AdminSchema {
2156
2223
  modules: string[];
2157
2224
  /** Files can be uploaded from links (`upload.fromURL`). */
2158
2225
  uploadFromURL: boolean;
2226
+ /** For admins: which saved deliveries the admin can show (webhooks, emails). */
2227
+ deliveries?: {
2228
+ webhook: boolean;
2229
+ email: boolean;
2230
+ };
2159
2231
  /** Pages this user may open (`admin.pages`). */
2160
2232
  pages: AdminPageRef[];
2161
2233
  /** Dashboard panels this user may see (`admin.dashboard`). */
2162
2234
  dashboard: AdminWidgetRef[];
2163
2235
  }
2164
2236
 
2237
+ /** Something an admin should look at, shown on the dashboard with a link to how to fix it. */
2238
+ type AdminAttention =
2239
+ /** Webhook deliveries that failed for good in the last 7 days; `url`: the one failing most. */
2240
+ {
2241
+ id: 'webhooks';
2242
+ count: number;
2243
+ url: string;
2244
+ }
2245
+ /** Emails waiting over an hour, or that failed for good in the last 7 days. */
2246
+ | {
2247
+ id: 'emails';
2248
+ count: number;
2249
+ }
2250
+ /** Scheduled publishing more than 10 minutes late: nothing runs `jobs/run`. */
2251
+ | {
2252
+ id: 'scheduled';
2253
+ count: number;
2254
+ }
2255
+ /** No `email` in the config: no forgotten-password links, invitations or form emails. */
2256
+ | {
2257
+ id: 'no-email';
2258
+ }
2259
+ /** In production without `serverURL`: links in emails can't be made. */
2260
+ | {
2261
+ id: 'no-server-url';
2262
+ };
2263
+ /** What the dashboard shows admins (`GET <api>/admin/status`). */
2264
+ interface AdminStatus {
2265
+ system: {
2266
+ /** `@easy-cms/core`'s version. */
2267
+ version: string;
2268
+ /** The database adapter, e.g. `sqlite` or `postgres`. */
2269
+ database: string;
2270
+ /** Where uploads are stored, e.g. `local` or `s3`. */
2271
+ storage: string;
2272
+ /** The email adapter's name, `custom` when it has none, or `null` without email. */
2273
+ email: string | null;
2274
+ /** The config's plugins, with their name and version when they give them. */
2275
+ plugins: {
2276
+ name?: string;
2277
+ version?: string;
2278
+ }[];
2279
+ /** Field types from packages (`fieldTypes`). */
2280
+ fieldTypes: string[];
2281
+ };
2282
+ attention: AdminAttention[];
2283
+ }
2284
+
2165
2285
  declare const SESSION_COOKIE = "ecms-session";
2166
2286
  declare const CSRF_COOKIE = "ecms-csrf";
2167
2287
  declare const CSRF_HEADER = "x-csrf-token";
@@ -2197,4 +2317,7 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
2197
2317
  /** Checks a config and returns every problem found. An empty array means it is valid. */
2198
2318
  declare function validateConfig(config: Config): ConfigIssue[];
2199
2319
 
2200
- export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminBrand, type AdminCollection, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminField, type AdminGlobal, type AdminIcon, type AdminLocale, type AdminPage, type AdminPageRef, type AdminSchema, type AdminViewAccess, type AdminWidgetRef, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type Block, type BlocksField, type BooleanField, type BuiltinApiKeysCollection, type BuiltinMediaCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CliCommand, type CollectionAccess, type CollectionAdmin, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionListAdmin, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type ContainerAdmin, type CopyProgress, type CopyResult, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, type CustomField, type CustomFieldTypes, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_PASSWORD_EMAILS, DEFAULT_TOKEN_EXPIRATION, type DashboardWidget, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DatabaseTransfer, type DateField, type DepthOptions, type DocumentOf, EXTENSIONS, EasyCMS, EasyCMSError, type EmailAdapter, type EmailField, type EmailMessage, type Endpoint, type EndpointRequest, FIELD_TYPES, FIELD_TYPE_BASES, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, type FieldError, type FieldType, type FieldTypeBase, type FieldTypeDefinition, type FieldTypeValidateContext, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FilterOptions, type FilterOptionsArgs, type FindArgs, type FindOptions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type InferCollection, type InferGlobal, type JsonField, type Label, type LivePreview, type LoadConfigOptions, type LocalStorageOptions, type LocalizationConfig, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, type PasswordEmail, type PasswordEmailArgs, type PasswordEmailFn, type PasswordLinkOptions, PayloadTooLargeError, type Plugin, type PluginTypes, type PreviewTarget, type PreviewURL, QueryError, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoutesConfig, SESSION_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type SlugOf, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, type TypedPlugin, UnauthorizedError, type UpdateInput, type UpdateOptions, type UploadConfig, type UploadField, type UploadFromURLConfig, ValidationError, type Version, type VersionSummary, type VersionsConfig, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, applyPlugins, configSignature, consoleEmail, consoleLogger, copyDatabase, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, defineFieldType, definePlugin, fillMissing, findConfigFile, generateSlugs, generateTypes, hasRows, hashPassword, imageDimensions, importConfig, isAdmin, isHasMany, isLoggedIn, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, mimeAllowedBy, parseId, populate, readCookie, resolveAdminModule, resolveConfig, rowFields, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
2320
+ /** The version of `@easy-cms/core`, set when the package is built. */
2321
+ declare const VERSION: string;
2322
+
2323
+ export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminAttention, type AdminBrand, type AdminCollection, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminDeliveries, type AdminDelivery, type AdminEmailDelivery, type AdminField, type AdminGlobal, type AdminIcon, type AdminLocale, type AdminPage, type AdminPageRef, type AdminSchema, type AdminStatus, type AdminViewAccess, type AdminWebhookDelivery, type AdminWidgetRef, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type Block, type BlocksField, type BooleanField, type BuiltinApiKeysCollection, type BuiltinMediaCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CliCommand, type CollectionAccess, type CollectionAdmin, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionListAdmin, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type ContainerAdmin, type CopyProgress, type CopyResult, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, type CustomField, type CustomFieldTypes, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_PASSWORD_EMAILS, DEFAULT_TOKEN_EXPIRATION, type DashboardWidget, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DatabaseTransfer, type DateField, type DeliveryKind, type DeliveryState, type DepthOptions, type DocumentOf, EXTENSIONS, EasyCMS, EasyCMSError, type EmailAdapter, type EmailField, type EmailMessage, type Endpoint, type EndpointRequest, FIELD_TYPES, FIELD_TYPE_BASES, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, type FieldError, type FieldType, type FieldTypeBase, type FieldTypeDefinition, type FieldTypeValidateContext, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FilterOptions, type FilterOptionsArgs, type FindArgs, type FindOptions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type InferCollection, type InferGlobal, type JsonField, type Label, type LivePreview, type LoadConfigOptions, type LocalStorageOptions, type LocalizationConfig, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, type PasswordEmail, type PasswordEmailArgs, type PasswordEmailFn, type PasswordLinkOptions, PayloadTooLargeError, type Plugin, type PluginInfo, type PluginTypes, type PreviewTarget, type PreviewURL, QueryError, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoutesConfig, SESSION_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type SlugOf, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, type TypedPlugin, UnauthorizedError, type UpdateInput, type UpdateOptions, type UploadConfig, type UploadField, type UploadFromURLConfig, VERSION, ValidationError, type Version, type VersionSummary, type VersionsConfig, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, applyPlugins, configSignature, consoleEmail, consoleLogger, copyDatabase, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, defineFieldType, definePlugin, fillMissing, findConfigFile, generateSlugs, generateTypes, hasRows, hashPassword, imageDimensions, importConfig, isAdmin, isHasMany, isLoggedIn, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, mimeAllowedBy, parseId, populate, readCookie, resolveAdminModule, resolveConfig, rowFields, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
package/dist/index.js CHANGED
@@ -1142,7 +1142,8 @@ var ADMIN_ICONS = [
1142
1142
  "chart-column",
1143
1143
  "chart-line"
1144
1144
  ];
1145
- function definePlugin(plugin) {
1145
+ function definePlugin(plugin, info) {
1146
+ if (info) Object.defineProperty(plugin, "info", { value: info, configurable: true });
1146
1147
  return plugin;
1147
1148
  }
1148
1149
  function defineConfig(config) {
@@ -1304,13 +1305,13 @@ function generateSlugs(fields, data, localization = null) {
1304
1305
  function mergeForUpdate(fields, existing, patch) {
1305
1306
  const result = {};
1306
1307
  for (const field of fields) {
1307
- const has = Object.hasOwn(patch, field.name);
1308
+ const has2 = Object.hasOwn(patch, field.name);
1308
1309
  const next = patch[field.name];
1309
1310
  const prev = existing[field.name];
1310
- if (field.type === "group" && has && isPlainObject(next)) {
1311
+ if (field.type === "group" && has2 && isPlainObject(next)) {
1311
1312
  result[field.name] = mergeForUpdate(field.fields, isPlainObject(prev) ? prev : {}, next);
1312
1313
  } else {
1313
- result[field.name] = has ? next : prev;
1314
+ result[field.name] = has2 ? next : prev;
1314
1315
  }
1315
1316
  }
1316
1317
  return result;
@@ -1541,6 +1542,7 @@ function normalizeSelect(field, raw, fail) {
1541
1542
  function consoleEmail(options = {}) {
1542
1543
  const sent = [];
1543
1544
  const adapter = {
1545
+ name: "console",
1544
1546
  from: options.from ?? "Easy CMS <no-reply@localhost>",
1545
1547
  sent,
1546
1548
  async send(message) {
@@ -1634,6 +1636,11 @@ var Mailer = class {
1634
1636
  }
1635
1637
  return { sent, failed };
1636
1638
  }
1639
+ /** One attempt now for a saved email (the admin's Retry): `undefined` when sent, else the error. */
1640
+ async sendNow(message) {
1641
+ if (!this.adapter) return "email is not set up (`email` in the config)";
1642
+ return this.attempt(JSON.parse(message));
1643
+ }
1637
1644
  track(delivery) {
1638
1645
  const tracked = delivery.catch((error) => this.logger.error(`Email queue failed: ${error.message}`)).finally(() => this.pending.delete(tracked));
1639
1646
  this.pending.add(tracked);
@@ -1874,6 +1881,15 @@ var Webhooks = class {
1874
1881
  }
1875
1882
  return { sent, failed };
1876
1883
  }
1884
+ /**
1885
+ * One attempt now for a saved delivery (the admin's Retry), with the webhook's current headers
1886
+ * and secret.
1887
+ */
1888
+ async sendNow(delivery) {
1889
+ const hook = this.hooks.find((h) => h.url === delivery.url);
1890
+ if (!hook) return { ok: false, error: "this webhook is no longer in the config" };
1891
+ return this.attempt(hook, delivery.event, delivery.body, delivery.delivery);
1892
+ }
1877
1893
  track(delivery) {
1878
1894
  const tracked = delivery.catch((error) => this.logger.error(`Webhook queue failed: ${error.message}`)).finally(() => this.pending.delete(tracked));
1879
1895
  this.pending.add(tracked);
@@ -2840,7 +2856,7 @@ async function resolveConfig(input) {
2840
2856
  config = applied.config;
2841
2857
  const issues = [...applied.issues, ...validateConfig(config)];
2842
2858
  if (issues.length > 0) throw new ConfigError(issues);
2843
- const { plugins: _plugins, ...rest } = config;
2859
+ const { plugins, ...rest } = config;
2844
2860
  const result = {
2845
2861
  ...rest,
2846
2862
  cors: config.cors ?? [],
@@ -2890,6 +2906,9 @@ async function resolveConfig(input) {
2890
2906
  globals: config.globals ?? [],
2891
2907
  endpoints: config.endpoints ?? [],
2892
2908
  commands: config.commands ?? [],
2909
+ installedPlugins: (plugins ?? []).map(
2910
+ (plugin) => typeof plugin === "function" && plugin.info ? { ...plugin.info } : {}
2911
+ ),
2893
2912
  fieldTypes: config.fieldTypes ?? []
2894
2913
  };
2895
2914
  resolved.add(result);
@@ -4420,6 +4439,7 @@ var EasyCMS = class {
4420
4439
  */
4421
4440
  async runJobs(now = /* @__PURE__ */ new Date()) {
4422
4441
  const scheduled = await this.runScheduled(now);
4442
+ await this.pruneFailedDeliveries(now);
4423
4443
  return {
4424
4444
  ...scheduled,
4425
4445
  webhooks: await this.retryWebhooks(now),
@@ -4648,6 +4668,51 @@ var EasyCMS = class {
4648
4668
  if (!job) throw new NotFoundError("scheduled job", jobId);
4649
4669
  await this.db.delete({ collection: SCHEDULED_JOBS, id: job.id });
4650
4670
  }
4671
+ /**
4672
+ * @internal One attempt now for a saved webhook delivery or email (the admin's Retry): removed
4673
+ * when it is sent, otherwise kept as failed with the new error.
4674
+ */
4675
+ async retryDelivery(kind, id) {
4676
+ const collection2 = kind === "webhook" ? WEBHOOK_DELIVERIES : EMAIL_DELIVERIES;
4677
+ const row = await this.db.findById({ collection: collection2, id });
4678
+ if (!row) throw new NotFoundError(collection2, id);
4679
+ const error = kind === "webhook" ? await this.webhooks.sendNow(row).then((result) => result.ok ? void 0 : result.error) : await this.mailer.sendNow(String(row.message));
4680
+ if (error === void 0) {
4681
+ await this.db.delete({ collection: collection2, id });
4682
+ return { ok: true };
4683
+ }
4684
+ const { id: _id, ...data } = row;
4685
+ await this.db.update({
4686
+ collection: collection2,
4687
+ id,
4688
+ data: {
4689
+ ...data,
4690
+ attempts: Number(row.attempts ?? 0) + 1,
4691
+ state: "failed",
4692
+ error,
4693
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4694
+ }
4695
+ });
4696
+ return { ok: false, error };
4697
+ }
4698
+ /** Removes webhook deliveries and emails that failed more than 30 days ago. */
4699
+ async pruneFailedDeliveries(now) {
4700
+ const before = new Date(now.getTime() - 30 * 864e5).toISOString();
4701
+ for (const collection2 of [WEBHOOK_DELIVERIES, EMAIL_DELIVERIES]) {
4702
+ if (!this.config.collections.some((c) => c.slug === collection2)) continue;
4703
+ for (; ; ) {
4704
+ const { docs } = await this.db.find({
4705
+ collection: collection2,
4706
+ where: { and: [{ state: { equals: "failed" } }, { updatedAt: { lt: before } }] },
4707
+ sort: ["updatedAt"],
4708
+ limit: 100,
4709
+ page: 1
4710
+ });
4711
+ for (const doc of docs) await this.db.delete({ collection: collection2, id: doc.id });
4712
+ if (docs.length < 100) break;
4713
+ }
4714
+ }
4715
+ }
4651
4716
  // -------------------------------------------------------------------------
4652
4717
  /** @internal */
4653
4718
  collection(slug) {
@@ -5199,6 +5264,123 @@ async function readAdminModule(cms, index) {
5199
5264
  return { body, etag: `"${createHash3("sha1").update(body).digest("base64url")}"` };
5200
5265
  }
5201
5266
 
5267
+ // src/rest/admin-deliveries.ts
5268
+ var PER_PAGE = 50;
5269
+ var collectionOf = (kind) => kind === "webhook" ? WEBHOOK_DELIVERIES : EMAIL_DELIVERIES;
5270
+ var exists = (cms, kind) => cms.config.collections.some((c) => c.slug === collectionOf(kind));
5271
+ var isDeliveryKind = (value) => value === "webhook" || value === "email";
5272
+ function parse(text) {
5273
+ try {
5274
+ const value = JSON.parse(String(text ?? ""));
5275
+ return typeof value === "object" && value !== null ? value : {};
5276
+ } catch {
5277
+ return {};
5278
+ }
5279
+ }
5280
+ function toAdmin(kind, row) {
5281
+ const base = {
5282
+ id: row.id,
5283
+ state: row.state === "failed" ? "failed" : "pending",
5284
+ attempts: Number(row.attempts ?? 0),
5285
+ error: typeof row.error === "string" ? row.error : null,
5286
+ createdAt: String(row.createdAt ?? ""),
5287
+ updatedAt: String(row.updatedAt ?? ""),
5288
+ nextAttemptAt: String(row.nextAttemptAt ?? "")
5289
+ };
5290
+ if (kind === "webhook") {
5291
+ const payload = parse(row.body);
5292
+ return {
5293
+ ...base,
5294
+ kind,
5295
+ url: String(row.url ?? ""),
5296
+ event: String(row.event ?? ""),
5297
+ ...typeof payload.collection === "string" ? { collection: payload.collection } : {},
5298
+ ...typeof payload.id === "string" || typeof payload.id === "number" ? { doc: payload.id } : {},
5299
+ ...typeof payload.global === "string" ? { global: payload.global } : {},
5300
+ body: String(row.body ?? "")
5301
+ };
5302
+ }
5303
+ const message = parse(row.message);
5304
+ const to = message.to;
5305
+ return {
5306
+ ...base,
5307
+ kind,
5308
+ to: Array.isArray(to) ? to.map(String) : typeof to === "string" ? [to] : [],
5309
+ subject: String(message.subject ?? "")
5310
+ };
5311
+ }
5312
+ async function listDeliveries(cms, kind, state, page) {
5313
+ if (!exists(cms, kind))
5314
+ return { docs: [], totalDocs: 0, page: 1, totalPages: 0, counts: { failed: 0, pending: 0 } };
5315
+ const collection2 = collectionOf(kind);
5316
+ const [found, failed, pending] = await Promise.all([
5317
+ cms.db.find({
5318
+ collection: collection2,
5319
+ where: { state: { equals: state } },
5320
+ sort: ["-updatedAt"],
5321
+ limit: PER_PAGE,
5322
+ page
5323
+ }),
5324
+ cms.db.count({ collection: collection2, where: { state: { equals: "failed" } } }),
5325
+ cms.db.count({ collection: collection2, where: { state: { equals: "pending" } } })
5326
+ ]);
5327
+ return {
5328
+ docs: found.docs.map((row) => toAdmin(kind, row)),
5329
+ totalDocs: found.totalDocs,
5330
+ page: found.page,
5331
+ totalPages: found.totalPages,
5332
+ counts: { failed, pending }
5333
+ };
5334
+ }
5335
+ async function retryFailedDeliveries(cms, kind) {
5336
+ if (!exists(cms, kind)) return { sent: 0, failed: 0 };
5337
+ const collection2 = collectionOf(kind);
5338
+ const ids = [];
5339
+ for (let page = 1; ; page++) {
5340
+ const { docs, hasNextPage } = await cms.db.find({
5341
+ collection: collection2,
5342
+ where: { state: { equals: "failed" } },
5343
+ sort: ["updatedAt"],
5344
+ limit: 500,
5345
+ page
5346
+ });
5347
+ ids.push(...docs.map((row) => row.id));
5348
+ if (!hasNextPage) break;
5349
+ }
5350
+ let sent = 0;
5351
+ let failed = 0;
5352
+ for (const id of ids) {
5353
+ const result = await cms.retryDelivery(kind, id);
5354
+ if (result.ok) sent++;
5355
+ else failed++;
5356
+ }
5357
+ return { sent, failed };
5358
+ }
5359
+ async function deleteDelivery(cms, kind, id) {
5360
+ const collection2 = collectionOf(kind);
5361
+ if (!exists(cms, kind) || !await cms.db.findById({ collection: collection2, id }))
5362
+ throw new NotFoundError(collection2, id);
5363
+ await cms.db.delete({ collection: collection2, id });
5364
+ }
5365
+ async function deleteFailedDeliveries(cms, kind) {
5366
+ if (!exists(cms, kind)) return { deleted: 0 };
5367
+ const collection2 = collectionOf(kind);
5368
+ let deleted = 0;
5369
+ for (; ; ) {
5370
+ const { docs } = await cms.db.find({
5371
+ collection: collection2,
5372
+ where: { state: { equals: "failed" } },
5373
+ sort: ["updatedAt"],
5374
+ limit: 100,
5375
+ page: 1
5376
+ });
5377
+ for (const row of docs) await cms.db.delete({ collection: collection2, id: row.id });
5378
+ deleted += docs.length;
5379
+ if (docs.length < 100) break;
5380
+ }
5381
+ return { deleted };
5382
+ }
5383
+
5202
5384
  // src/rest/admin-schema.ts
5203
5385
  var SETTINGS = /* @__PURE__ */ new Set([USERS, API_KEYS]);
5204
5386
  function componentRef(component) {
@@ -5350,6 +5532,12 @@ async function adminSchema(cms, user, origin) {
5350
5532
  globals: await Promise.all(cms.config.globals.map((g) => global(g, user, localized))),
5351
5533
  modules: adminModuleUrls(cms),
5352
5534
  uploadFromURL: cms.config.upload.fromURL !== void 0,
5535
+ ...user.role === "admin" && !user.apiKey ? {
5536
+ deliveries: {
5537
+ webhook: cms.config.collections.some((c) => c.slug === WEBHOOK_DELIVERIES),
5538
+ email: cms.config.collections.some((c) => c.slug === EMAIL_DELIVERIES)
5539
+ }
5540
+ } : {},
5353
5541
  pages: await pages(cms, user),
5354
5542
  dashboard: await widgets(cms, user)
5355
5543
  };
@@ -5385,6 +5573,80 @@ async function widgets(cms, user) {
5385
5573
  return out;
5386
5574
  }
5387
5575
 
5576
+ // src/version.ts
5577
+ var VERSION = true ? "0.30.0" : "0.0.0-dev";
5578
+
5579
+ // src/rest/admin-status.ts
5580
+ var DAY = 864e5;
5581
+ var has = (cms, slug) => cms.config.collections.some((c) => c.slug === slug);
5582
+ async function adminStatus(cms) {
5583
+ const config = cms.config;
5584
+ const attention = [];
5585
+ const now = Date.now();
5586
+ const weekAgo = new Date(now - 7 * DAY).toISOString();
5587
+ if (has(cms, WEBHOOK_DELIVERIES)) {
5588
+ const failed = await cms.db.find({
5589
+ collection: WEBHOOK_DELIVERIES,
5590
+ where: { and: [{ state: { equals: "failed" } }, { updatedAt: { gte: weekAgo } }] },
5591
+ sort: ["-updatedAt"],
5592
+ limit: 200,
5593
+ page: 1
5594
+ });
5595
+ if (failed.totalDocs > 0) {
5596
+ const byUrl = /* @__PURE__ */ new Map();
5597
+ for (const row of failed.docs) {
5598
+ const url2 = String(row.url ?? "");
5599
+ byUrl.set(url2, (byUrl.get(url2) ?? 0) + 1);
5600
+ }
5601
+ const [url] = [...byUrl.entries()].sort((a, b) => b[1] - a[1])[0] ?? [""];
5602
+ attention.push({ id: "webhooks", count: failed.totalDocs, url });
5603
+ }
5604
+ }
5605
+ if (has(cms, EMAIL_DELIVERIES)) {
5606
+ const count = await cms.db.count({
5607
+ collection: EMAIL_DELIVERIES,
5608
+ where: {
5609
+ or: [
5610
+ { and: [{ state: { equals: "failed" } }, { updatedAt: { gte: weekAgo } }] },
5611
+ {
5612
+ and: [
5613
+ { state: { equals: "pending" } },
5614
+ { createdAt: { lte: new Date(now - 60 * 6e4).toISOString() } }
5615
+ ]
5616
+ }
5617
+ ]
5618
+ }
5619
+ });
5620
+ if (count > 0) attention.push({ id: "emails", count });
5621
+ }
5622
+ if (has(cms, SCHEDULED_JOBS)) {
5623
+ const count = await cms.db.count({
5624
+ collection: SCHEDULED_JOBS,
5625
+ where: {
5626
+ and: [
5627
+ { state: { equals: "pending" } },
5628
+ { runAt: { lte: new Date(now - 10 * 6e4).toISOString() } }
5629
+ ]
5630
+ }
5631
+ });
5632
+ if (count > 0) attention.push({ id: "scheduled", count });
5633
+ }
5634
+ if (!config.email) attention.push({ id: "no-email" });
5635
+ if (!config.serverURL && process.env.NODE_ENV === "production")
5636
+ attention.push({ id: "no-server-url" });
5637
+ return {
5638
+ system: {
5639
+ version: VERSION,
5640
+ database: config.db.name,
5641
+ storage: config.upload.storage?.name ?? "local",
5642
+ email: config.email ? config.email.name ?? "custom" : null,
5643
+ plugins: config.installedPlugins.map((p) => ({ ...p })),
5644
+ fieldTypes: config.fieldTypes.map((t) => t.name)
5645
+ },
5646
+ attention
5647
+ };
5648
+ }
5649
+
5388
5650
  // src/rest/filter-options.ts
5389
5651
  function fieldAt(fields, path) {
5390
5652
  let current = fields;
@@ -5752,6 +6014,38 @@ async function route(cms, ctx, method, segments, options) {
5752
6014
  }
5753
6015
  return { body: await cms.runJobs() };
5754
6016
  }
6017
+ if (first === "admin" && second === "deliveries") {
6018
+ if (!ctx.user) throw new UnauthorizedError();
6019
+ if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
6020
+ const [, , kind, id2, action, extra] = segments;
6021
+ if (kind === void 0) {
6022
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
6023
+ const k = ctx.url.searchParams.get("kind") ?? "webhook";
6024
+ const state = ctx.url.searchParams.get("state") === "pending" ? "pending" : "failed";
6025
+ if (!isDeliveryKind(k)) throw new HttpError("kind must be webhook or email", 400);
6026
+ const page = Math.max(1, Number.parseInt(ctx.url.searchParams.get("page") ?? "1", 10) || 1);
6027
+ return { body: await listDeliveries(cms, k, state, page) };
6028
+ }
6029
+ if (!isDeliveryKind(kind) || extra !== void 0) throw new HttpError("Not found", 404);
6030
+ if (id2 === "retry" && action === void 0) {
6031
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
6032
+ return { body: await retryFailedDeliveries(cms, kind) };
6033
+ }
6034
+ if (id2 === void 0) {
6035
+ if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
6036
+ return { body: await deleteFailedDeliveries(cms, kind) };
6037
+ }
6038
+ const parsed = parseId(id2);
6039
+ if (parsed === void 0) throw new HttpError("Not found", 404);
6040
+ if (action === "retry") {
6041
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
6042
+ return { body: await cms.retryDelivery(kind, parsed) };
6043
+ }
6044
+ if (action !== void 0) throw new HttpError("Not found", 404);
6045
+ if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
6046
+ await deleteDelivery(cms, kind, parsed);
6047
+ return { body: { deleted: 1 } };
6048
+ }
5755
6049
  if (first === "admin") {
5756
6050
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
5757
6051
  if (!ctx.user) throw new UnauthorizedError();
@@ -5771,6 +6065,10 @@ async function route(cms, ctx, method, segments, options) {
5771
6065
  return { body: new Response(null, { status: 304, headers }) };
5772
6066
  return { body: new Response(file.body, { headers }) };
5773
6067
  }
6068
+ if (second === "status" && third === void 0) {
6069
+ if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
6070
+ return { body: await adminStatus(cms) };
6071
+ }
5774
6072
  if (second === "scheduled" && third === void 0)
5775
6073
  return { body: await cms.upcomingJobs({ user: ctx.user, overrideAccess: false }) };
5776
6074
  const [, , collection3, id2, extra] = segments;
@@ -6382,6 +6680,7 @@ export {
6382
6680
  SchemaError,
6383
6681
  TooManyRequestsError,
6384
6682
  UnauthorizedError,
6683
+ VERSION,
6385
6684
  ValidationError,
6386
6685
  WEBHOOK_EVENTS,
6387
6686
  anyone,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",