@unchainedshop/platform 5.0.0-alpha.1 → 5.0.0-alpha.11

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/README.md CHANGED
@@ -3,147 +3,120 @@
3
3
 
4
4
  # @unchainedshop/platform
5
5
 
6
- Umbrella package for the Unchained Engine. Provides complete platform setup including database initialization, API server, migrations, templates, and runtime configuration.
6
+ Umbrella package for the Unchained Engine. Initializes the database, core modules and services, plugins, GraphQL server, message templates, and work queue.
7
7
 
8
8
  ## Installation
9
9
 
10
10
  ```bash
11
- npm install @unchainedshop/platform
11
+ npm install @unchainedshop/platform express
12
12
  ```
13
13
 
14
- ## Usage
14
+ ## Quick start
15
+
16
+ Register plugins, initialize the platform, and connect it to your HTTP server:
15
17
 
16
18
  ```typescript
17
- import { startPlatform } from '@unchainedshop/platform';
18
19
  import express from 'express';
20
+ import { startPlatform } from '@unchainedshop/platform';
21
+ import { connect } from '@unchainedshop/api/express';
22
+ import { registerBasePlugins } from '@unchainedshop/plugins/presets/base';
19
23
 
20
- const app = express();
24
+ registerBasePlugins();
21
25
 
22
- const { unchainedAPI, graphqlHandler } = await startPlatform({
23
- express: app,
24
- options: {
25
- // Platform options
26
- },
27
- });
26
+ const app = express();
27
+ const platform = await startPlatform({});
28
+ await connect(app, platform);
28
29
 
29
- // Platform is ready
30
+ const products = await platform.unchainedAPI.modules.products.findProducts({});
30
31
  app.listen(4010);
31
32
  ```
32
33
 
33
- ## API Overview
34
-
35
- ### Platform Setup
36
-
37
- | Export | Description |
38
- |--------|-------------|
39
- | `startPlatform` | Initialize complete Unchained platform |
40
- | `runMigrations` | Run database migrations |
41
- | `printRuntimeConfiguration` | Log registered templates, events, and adapters |
42
-
43
- ### Context Helpers
44
-
45
- | Export | Description |
46
- |--------|-------------|
47
- | `setAccessToken` | Set access token for user session |
48
- | `getAccessToken` | Get current access token |
49
- | `invalidateAccessToken` | Invalidate/logout access token |
50
-
51
- ### Templates
52
-
53
- | Export | Description |
54
- |--------|-------------|
55
- | `MessageTypes` | Available message/notification types |
56
- | `setupTemplates` | Register message templates |
57
-
58
- ### Message Types
59
-
60
- | Type | Description |
61
- |------|-------------|
62
- | `ACCOUNT_ACTION` | Account verification, password reset |
63
- | `DELIVERY` | Delivery notifications |
64
- | `ORDER_CONFIRMATION` | Order confirmation emails |
65
- | `ORDER_REJECTION` | Order rejection notifications |
66
- | `QUOTATION_STATUS` | Quotation status updates |
67
- | `ENROLLMENT_STATUS` | Subscription status updates |
68
- | `FORWARD_DELIVERY` | Forward delivery notifications |
69
-
70
- ## Quick Start
71
-
72
- ```typescript
73
- import express from 'express';
74
- import { startPlatform, MessageTypes } from '@unchainedshop/platform';
75
-
76
- const app = express();
77
-
78
- const { unchainedAPI, graphqlHandler } = await startPlatform({
79
- express: app,
80
- options: {
81
- modules: {
82
- // Module-specific configuration
83
- },
84
- plugins: [
85
- // Plugin imports
86
- ],
87
- },
88
- workQueueConfig: {
89
- // Worker queue configuration
90
- },
91
- });
34
+ For Fastify, use `connect` from `@unchainedshop/api/fastify` with a Fastify instance. `connect()` mounts GraphQL, MCP, and registered plugin routes. Pass `{ adminUI: true }` as its third argument to serve an installed `@unchainedshop/admin-ui` package.
92
35
 
93
- // Access unchained API
94
- const products = await unchainedAPI.modules.products.findProducts({});
95
-
96
- // Start server
97
- app.use('/graphql', graphqlHandler);
98
- app.listen(4010);
99
- ```
36
+ Before startup, set `ROOT_URL`, `EMAIL_WEBSITE_NAME`, `EMAIL_WEBSITE_URL`, `EMAIL_FROM`, and `UNCHAINED_TOKEN_SECRET` (at least 32 characters). Set `MONGO_URL` for an external MongoDB; otherwise the database package starts a local instance.
100
37
 
101
38
  ## Configuration
102
39
 
103
40
  ```typescript
104
41
  const platform = await startPlatform({
105
- express: app,
106
42
  options: {
107
- modules: {
108
- orders: {
109
- // Order module options
110
- },
111
- products: {
112
- // Product module options
113
- },
43
+ orders: {
44
+ // Order module settings
114
45
  },
115
- services: {
116
- // Custom services
117
- },
118
- bulkImporter: {
119
- handlers: {
120
- // Custom import handlers
121
- },
46
+ products: {
47
+ // Product module settings
122
48
  },
123
49
  },
124
- workQueueConfig: {
125
- batchSize: 10,
126
- pollInterval: 1000,
50
+ modules: {
51
+ // Custom modules with a configure({ db, migrationRepository, options }) function
127
52
  },
128
- context: (defaultResolver) => async (props, req, res) => {
129
- const context = await defaultResolver(props, req, res);
130
- return {
131
- ...context,
132
- // Custom context properties
133
- };
53
+ services: {
54
+ // Custom services
134
55
  },
56
+ bulkImporter: {
57
+ handlers: {
58
+ // Custom import handlers
59
+ },
60
+ },
61
+ workQueueOptions: {
62
+ skipInvalidationOnStartup: true,
63
+ },
64
+ context: (defaultResolver) => async (props, req, res) => ({
65
+ ...(await defaultResolver(props, req, res)),
66
+ // Additional request context
67
+ }),
135
68
  });
136
69
  ```
137
70
 
138
- ## Returns
71
+ `PlatformOptions` combines core configuration with GraphQL server options, `rolesOptions`, `workQueueOptions`, and `auditLog`. Built-in modules are included automatically. Module settings are direct properties of `options`; module/service overrides and import/export handlers are top-level options.
72
+
73
+ The work queue runs migrations and starts queue managers during startup unless workers are disabled. See [work queue options](src/setup/setupWorkqueue.ts) and [platform initialization](src/startPlatform.ts) for the current options.
139
74
 
140
- The `startPlatform` function returns:
75
+ ## Download documentation for coding assistants
76
+
77
+ ```bash
78
+ npx --package @unchainedshop/platform unchained download-llm-docs
79
+ ```
80
+
81
+ Requires Git. Downloads docs into `.unchained-docs/` for the installed platform
82
+ version's tag, falling back to `master` when the tag is unavailable. Updates a
83
+ managed file index in existing `CLAUDE.md` and `AGENTS.md` files (legacy `agents.md`
84
+ is also supported), or creates `CLAUDE.md` if none exists. Re-running replaces the
85
+ downloaded docs and index while preserving surrounding instructions. Add
86
+ `.unchained-docs/` to your project's `.gitignore`.
87
+
88
+ ## Exports
89
+
90
+ | Export | Description |
91
+ |--------|-------------|
92
+ | `startPlatform` | Initialize the platform |
93
+ | `PlatformOptions` | Platform configuration type |
94
+ | `runMigrations` | Run registered database migrations |
95
+ | `printRuntimeConfiguration` | Log registered templates, events, and adapters |
96
+ | `MessageTypes` | Built-in message type constants |
97
+
98
+ The package also exports the [built-in template resolvers](src/templates/index.ts). Platform startup registers them automatically; custom templates use `MessagingDirector.registerTemplate()` from `@unchainedshop/core`.
99
+
100
+ | Message type | Purpose |
101
+ |--------------|---------|
102
+ | `ACCOUNT_ACTION` | Email verification and password reset |
103
+ | `DELIVERY` | Forward delivery notifications |
104
+ | `ORDER_CONFIRMATION` | Order confirmations |
105
+ | `ORDER_REJECTION` | Order rejections |
106
+ | `QUOTATION_STATUS` | Quotation status updates |
107
+ | `ENROLLMENT_STATUS` | Enrollment status updates |
108
+ | `ERROR_REPORT` | Error reports |
109
+
110
+ ## Return value
141
111
 
142
112
  | Property | Description |
143
113
  |----------|-------------|
144
- | `unchainedAPI` | Complete Unchained core API instance |
145
- | `graphqlHandler` | GraphQL Yoga server instance for Express/Fastify |
114
+ | `unchainedAPI` | Core modules, services, bulk importer/exporter, and options |
115
+ | `graphqlHandler` | GraphQL Yoga server instance |
146
116
  | `db` | MongoDB database instance |
117
+ | `shutdown` | Stops the work queue, plugins, event emitter, GraphQL server, audit log and database without exiting the process; every call returns the same promise |
118
+
119
+ On `SIGTERM` / `SIGINT` the platform calls `shutdown()` and exits. Once `shutdown()` has started, these signals no longer exit the process, so exit it yourself after awaiting `shutdown()`.
147
120
 
148
121
  ## License
149
122
 
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync } from 'node:child_process';
4
+ import {
5
+ cpSync,
6
+ existsSync,
7
+ mkdtempSync,
8
+ readFileSync,
9
+ readdirSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from 'node:fs';
13
+ import { join, relative } from 'node:path';
14
+ import { tmpdir } from 'node:os';
15
+ import { createRequire } from 'node:module';
16
+
17
+ const GITHUB_REPO = 'https://github.com/unchainedshop/unchained.git';
18
+ const DOCS_SUBPATH = 'docs/docs';
19
+ const DOCS_DIR = join(process.cwd(), '.unchained-docs');
20
+ const INDEX_START = '<!-- UNCHAINED_DOCS_INDEX:BEGIN -->';
21
+ const INDEX_END = '<!-- UNCHAINED_DOCS_INDEX:END -->';
22
+
23
+ const command = process.argv[2];
24
+
25
+ if (command !== 'download-llm-docs') {
26
+ console.log(`Usage: unchained <command>
27
+
28
+ Commands:
29
+ download-llm-docs Download Unchained Engine docs for LLM coding assistants
30
+ (Claude Code, Cursor, etc.) and inject a file index into
31
+ CLAUDE.md and/or AGENTS.md`);
32
+ process.exit(command ? 1 : 0);
33
+ }
34
+
35
+ // Resolve version from own package.json
36
+ const require = createRequire(import.meta.url);
37
+ const pkg = require('../package.json');
38
+ const version = pkg.version;
39
+
40
+ // Determine git ref: try tag first, fallback to master
41
+ function tagExists(tag) {
42
+ try {
43
+ execFileSync('git', ['ls-remote', '--tags', '--exit-code', GITHUB_REPO, `refs/tags/${tag}`], {
44
+ stdio: 'pipe',
45
+ });
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ const tag = `v${version}`;
53
+ const ref = tagExists(tag) ? tag : 'master';
54
+ if (ref === 'master') {
55
+ console.log(`Tag ${tag} not found on remote, falling back to master branch.`);
56
+ }
57
+
58
+ // Clone docs using sparse-checkout into a temp directory
59
+ const tempDir = mkdtempSync(join(tmpdir(), 'unchained-docs-'));
60
+ console.log(`Cloning docs from ${ref} using sparse-checkout ...`);
61
+ try {
62
+ execFileSync(
63
+ 'git',
64
+ [
65
+ 'clone',
66
+ '--filter=blob:none',
67
+ '--no-checkout',
68
+ '--depth',
69
+ '1',
70
+ '--branch',
71
+ ref,
72
+ GITHUB_REPO,
73
+ tempDir,
74
+ ],
75
+ { stdio: 'inherit' },
76
+ );
77
+ execFileSync('git', ['-C', tempDir, 'sparse-checkout', 'set', DOCS_SUBPATH], {
78
+ stdio: 'inherit',
79
+ });
80
+ execFileSync('git', ['-C', tempDir, 'checkout'], { stdio: 'inherit' });
81
+ } catch {
82
+ rmSync(tempDir, { recursive: true, force: true });
83
+ console.error('Failed to clone docs repository.');
84
+ process.exit(1);
85
+ }
86
+
87
+ // Copy docs/docs/ contents to .unchained-docs/
88
+ const sourceDir = join(tempDir, DOCS_SUBPATH);
89
+ if (!existsSync(sourceDir)) {
90
+ rmSync(tempDir, { recursive: true, force: true });
91
+ console.error(`Docs directory not found at ${DOCS_SUBPATH} in the repository.`);
92
+ process.exit(1);
93
+ }
94
+
95
+ rmSync(DOCS_DIR, { recursive: true, force: true });
96
+ cpSync(sourceDir, DOCS_DIR, { recursive: true });
97
+ rmSync(tempDir, { recursive: true, force: true });
98
+
99
+ // Scan for markdown files
100
+ const markdownsByDir = Map.groupBy(
101
+ readdirSync(DOCS_DIR, { withFileTypes: true, recursive: true }).filter(
102
+ (e) => e.isFile() && (e.name.endsWith('.md') || e.name.endsWith('.mdx')),
103
+ ),
104
+ (e) => relative(DOCS_DIR, e.parentPath) || '.',
105
+ );
106
+
107
+ const fileCount = [...markdownsByDir.values()].flat().length;
108
+ console.log(`Found ${fileCount} markdown files in .unchained-docs/`);
109
+
110
+ // Build compact pipe-delimited index
111
+ const indexSnippet = [
112
+ INDEX_START,
113
+ [
114
+ `[Unchained Engine v${version} Docs Index]`,
115
+ 'root: ./.unchained-docs',
116
+ 'Search these docs before answering Unchained questions.',
117
+ 'If docs missing, run: npx --package @unchainedshop/platform unchained download-llm-docs',
118
+ ...[...markdownsByDir]
119
+ .sort(([a], [b]) => a.localeCompare(b))
120
+ .map(
121
+ ([dir, entries]) =>
122
+ `${dir}:{${entries
123
+ .map((e) => e.name)
124
+ .sort()
125
+ .join(',')}}`,
126
+ ),
127
+ ].join('|'),
128
+ INDEX_END,
129
+ ].join('');
130
+
131
+ // Upsert index into target files
132
+ function upsertIndex(filePath) {
133
+ const content = readFileSync(filePath, 'utf-8');
134
+ const [before, ...afterParts] = content.split(INDEX_START);
135
+ const after =
136
+ afterParts.length > 0
137
+ ? afterParts.join(INDEX_START).split(INDEX_END).slice(1).join(INDEX_END)
138
+ : undefined;
139
+
140
+ writeFileSync(
141
+ filePath,
142
+ after !== undefined ? before + indexSnippet + after : `${content.trimEnd()}\n${indexSnippet}\n`,
143
+ );
144
+ console.log(`Updated ${relative(process.cwd(), filePath)} with docs index.`);
145
+ }
146
+
147
+ const claudeMdPath = join(process.cwd(), 'CLAUDE.md');
148
+ const agentsMdPath = join(process.cwd(), 'AGENTS.md');
149
+ const legacyAgentsMdPath = join(process.cwd(), 'agents.md');
150
+ const claudeExists = existsSync(claudeMdPath);
151
+ const agentsExists = existsSync(agentsMdPath);
152
+ const legacyAgentsExists = !agentsExists && existsSync(legacyAgentsMdPath);
153
+
154
+ if (claudeExists) upsertIndex(claudeMdPath);
155
+ if (agentsExists) upsertIndex(agentsMdPath);
156
+ if (legacyAgentsExists) upsertIndex(legacyAgentsMdPath);
157
+
158
+ if (!claudeExists && !agentsExists && !legacyAgentsExists) {
159
+ writeFileSync(claudeMdPath, `${indexSnippet}\n`);
160
+ console.log('Created CLAUDE.md with docs index.');
161
+ }
162
+
163
+ // Remind about .gitignore
164
+ console.log('\nDone! Remember to add .unchained-docs to your .gitignore.');
@@ -35,14 +35,8 @@ export const createMigrationRunner = ({ currentId, logger = console, migrationRe
35
35
  .map(this.operationFactory('up'));
36
36
  },
37
37
  async run() {
38
- try {
39
- const migrations = this.migrateToLatest();
40
- const mostRecentId = await migrations.reduce((idPromise, migration) => Promise.resolve(idPromise).then(migration), currentId);
41
- return [mostRecentId, migrations.length];
42
- }
43
- catch (e) {
44
- logger.error(`Migration failed: ${e.name} - ${e.message}`);
45
- return [e.migrationId, null];
46
- }
38
+ const migrations = this.migrateToLatest();
39
+ const mostRecentId = await migrations.reduce((idPromise, migration) => Promise.resolve(idPromise).then(migration), currentId);
40
+ return [mostRecentId, migrations.length];
47
41
  },
48
42
  });
@@ -32,15 +32,10 @@ export const runMigrations = async ({ migrationRepository, logger = createLogger
32
32
  unchainedAPI,
33
33
  });
34
34
  const [lastMigrationId, operationCount] = await runner.run();
35
- if (operationCount !== null) {
36
- if (operationCount > 0) {
37
- logger.info(`All ${operationCount} migrations completed with most recent id: ${lastMigrationId}`);
38
- }
39
- else {
40
- logger.debug(`No migrations run, already at latest id: ${currentId}`);
41
- }
35
+ if (operationCount > 0) {
36
+ logger.info(`All ${operationCount} migrations completed with most recent id: ${lastMigrationId}`);
42
37
  }
43
38
  else {
44
- logger.warn(`Some migrations failed, last successful id: ${lastMigrationId}`);
39
+ logger.debug(`No migrations run, already at latest id: ${currentId}`);
45
40
  }
46
41
  };
@@ -12,17 +12,27 @@ export const queueWorkers = [];
12
12
  export async function setupWorkqueue({ unchainedAPI, migrationRepository, ...workQueueOptions }) {
13
13
  if (workQueueOptions.disableWorker || UNCHAINED_DISABLE_WORKER)
14
14
  return;
15
- await runMigrations({ migrationRepository, unchainedAPI });
15
+ try {
16
+ await runMigrations({ migrationRepository, unchainedAPI });
17
+ }
18
+ catch (error) {
19
+ logger.error('Migration failed; continuing startup', {
20
+ error: error instanceof Error ? error.message : String(error),
21
+ });
22
+ }
16
23
  (workQueueOptions?.enabledQueueManagers || defaultQueueManagers).forEach((f) => {
17
24
  const handler = f.actions(workQueueOptions, unchainedAPI);
18
25
  queueWorkers.push(handler);
19
26
  handler.start();
20
27
  });
21
28
  if (workQueueOptions.invalidateProviders ?? !UNCHAINED_DISABLE_PROVIDER_INVALIDATION) {
22
- const orders = await unchainedAPI.modules.orders.findCartsToInvalidate(workQueueOptions.providerInvalidationMaxAgeDays);
23
- await Promise.allSettled(orders.map(async (order) => {
24
- await unchainedAPI.services.orders.updateCalculation(order._id);
25
- }));
29
+ await unchainedAPI.modules.worker.addWorkIfNotExists({
30
+ type: 'INVALIDATE_CARTS',
31
+ input: workQueueOptions.providerInvalidationMaxAgeDays !== undefined
32
+ ? { maxAgeDays: workQueueOptions.providerInvalidationMaxAgeDays }
33
+ : {},
34
+ retries: 0,
35
+ }, (work) => !work.autoscheduled);
26
36
  }
27
37
  if (workQueueOptions.assignCartForUsers ?? Boolean(UNCHAINED_ASSIGN_CART_FOR_USERS)) {
28
38
  const users = await unchainedAPI.modules.users.findUsers({});
@@ -2,6 +2,7 @@ import { type UnchainedServerOptions } from '@unchainedshop/api';
2
2
  import { type UnchainedCoreOptions } from '@unchainedshop/core';
3
3
  import { mongodb } from '@unchainedshop/mongodb';
4
4
  import type { UnchainedCore } from '@unchainedshop/core';
5
+ import { type AuditLogOptions } from '@unchainedshop/events';
5
6
  import { MessageTypes } from './setup/setupTemplates.ts';
6
7
  import { type SetupWorkqueueOptions } from './setup/setupWorkqueue.ts';
7
8
  import type { IRoleOptionConfig } from '@unchainedshop/roles';
@@ -9,9 +10,11 @@ export { MessageTypes };
9
10
  export type PlatformOptions = {
10
11
  rolesOptions?: IRoleOptionConfig;
11
12
  workQueueOptions?: SetupWorkqueueOptions;
13
+ auditLog?: AuditLogOptions | false;
12
14
  } & Omit<UnchainedCoreOptions, 'migrationRepository' | 'db'> & Omit<UnchainedServerOptions, 'roles' | 'unchainedAPI'>;
13
- export declare const startPlatform: ({ modules, services, options, rolesOptions, bulkImporter, bulkExporter, workQueueOptions, ...arbitraryAPIServerConfiguration }: PlatformOptions) => Promise<{
15
+ export declare const startPlatform: ({ modules, services, options, rolesOptions, bulkImporter, bulkExporter, workQueueOptions, auditLog: auditLogConfig, ...arbitraryAPIServerConfiguration }: PlatformOptions) => Promise<{
14
16
  unchainedAPI: UnchainedCore;
15
17
  graphqlHandler: any;
16
18
  db: mongodb.Db;
19
+ shutdown: () => Promise<void>;
17
20
  }>;
@@ -2,6 +2,8 @@ import { startAPIServer, roles } from '@unchainedshop/api';
2
2
  import { initCore, pluginRegistry } from '@unchainedshop/core';
3
3
  import { initDb, mongodb, stopDb } from '@unchainedshop/mongodb';
4
4
  import { defaultLogger } from '@unchainedshop/logger';
5
+ import { getEmitAdapter } from '@unchainedshop/events';
6
+ import { createAuditLog, configureAuditIntegration, setAuditLogInstance, } from '@unchainedshop/events';
5
7
  import { setupAccounts } from "./setup/setupAccounts.js";
6
8
  import { setupUploadHandlers } from "./setup/setupUploadHandlers.js";
7
9
  import { setupTemplates, MessageTypes } from "./setup/setupTemplates.js";
@@ -30,7 +32,7 @@ const existOnInvalidEnvironmentVariables = () => {
30
32
  process.exit(1);
31
33
  }
32
34
  };
33
- export const startPlatform = async ({ modules, services, options, rolesOptions, bulkImporter, bulkExporter, workQueueOptions, ...arbitraryAPIServerConfiguration }) => {
35
+ export const startPlatform = async ({ modules, services, options, rolesOptions, bulkImporter, bulkExporter, workQueueOptions, auditLog: auditLogConfig, ...arbitraryAPIServerConfiguration }) => {
34
36
  const start = performance.now();
35
37
  exitOnMissingEnvironmentVariables();
36
38
  existOnInvalidEnvironmentVariables();
@@ -58,6 +60,11 @@ export const startPlatform = async ({ modules, services, options, rolesOptions,
58
60
  bulkExporter,
59
61
  });
60
62
  await pluginRegistry.initialize(unchainedAPI);
63
+ let auditLog;
64
+ if (auditLogConfig !== false) {
65
+ auditLog = createAuditLog(auditLogConfig || {});
66
+ setAuditLogInstance(auditLog);
67
+ }
61
68
  setupAccounts(unchainedAPI);
62
69
  setupTemplates(unchainedAPI);
63
70
  setupUploadHandlers(unchainedAPI);
@@ -66,6 +73,9 @@ export const startPlatform = async ({ modules, services, options, rolesOptions,
66
73
  roles: configuredRoles,
67
74
  ...arbitraryAPIServerConfiguration,
68
75
  });
76
+ if (auditLog) {
77
+ configureAuditIntegration(auditLog);
78
+ }
69
79
  await setupWorkqueue({
70
80
  unchainedAPI,
71
81
  migrationRepository,
@@ -73,13 +83,31 @@ export const startPlatform = async ({ modules, services, options, rolesOptions,
73
83
  });
74
84
  const version = UNCHAINED_API_VERSION || npm_package_version || 'n/a';
75
85
  defaultLogger.info(`Unchained Engine running`, { version });
76
- let isCleaningUp = false;
86
+ let shutdownPromise;
87
+ const shutdown = () => {
88
+ shutdownPromise ??= (async () => {
89
+ defaultLogger.debug('Stopping Workqueue');
90
+ stopWorkqueue();
91
+ defaultLogger.debug('Shutting down plugins');
92
+ await pluginRegistry.shutdown(unchainedAPI);
93
+ defaultLogger.debug('Shutting down event emitter');
94
+ await getEmitAdapter()?.shutdown?.();
95
+ defaultLogger.debug('Stopping GraphQL server');
96
+ await graphqlHandler.dispose();
97
+ if (auditLog) {
98
+ defaultLogger.debug('Closing audit log');
99
+ await auditLog.close();
100
+ }
101
+ defaultLogger.debug('Stopping DB Connection');
102
+ await stopDb();
103
+ })();
104
+ return shutdownPromise;
105
+ };
77
106
  const cleanup = (signal) => async () => {
78
- if (isCleaningUp) {
107
+ if (shutdownPromise) {
79
108
  defaultLogger.debug('Cleanup already in progress, ignoring signal', { signal });
80
109
  return;
81
110
  }
82
- isCleaningUp = true;
83
111
  defaultLogger.debug('Starting cleanup', { signal });
84
112
  const forceExitTimeout = setTimeout(() => {
85
113
  defaultLogger.error('Cleanup timeout exceeded, forcing exit');
@@ -87,14 +115,7 @@ export const startPlatform = async ({ modules, services, options, rolesOptions,
87
115
  }, CLEANUP_FORCE_EXIT_TIMEOUT_MS);
88
116
  forceExitTimeout.unref();
89
117
  try {
90
- defaultLogger.debug('Stopping Workqueue', { signal });
91
- stopWorkqueue();
92
- defaultLogger.debug('Shutting down plugins', { signal });
93
- await pluginRegistry.shutdown(unchainedAPI);
94
- defaultLogger.debug('Stopping GraphQL server', { signal });
95
- await graphqlHandler.dispose();
96
- defaultLogger.debug('Stopping DB Connection', { signal });
97
- await stopDb();
118
+ await shutdown();
98
119
  defaultLogger.debug(`Unchained Engine exiting gracefully`, { signal, version });
99
120
  clearTimeout(forceExitTimeout);
100
121
  process.exit(0);
@@ -122,5 +143,5 @@ export const startPlatform = async ({ modules, services, options, rolesOptions,
122
143
  });
123
144
  const end = performance.now();
124
145
  defaultLogger.debug(`Unchained Engine started in ${(end - start).toFixed(0)} ms`);
125
- return { unchainedAPI, graphqlHandler, db };
146
+ return { unchainedAPI, graphqlHandler, db, shutdown };
126
147
  };
@@ -2,7 +2,7 @@ import { ProductPricingSheet } from '@unchainedshop/core';
2
2
  import { ch } from '@unchainedshop/utils';
3
3
  export const getOrderPositionsData = async (order, params, context) => {
4
4
  const { modules } = context;
5
- const { useNetPrice, format = ch.priceToString } = params || {};
5
+ const { useNetPrice, format = (price) => ch.priceToString({ ...price, locale: params?.locale }) } = params || {};
6
6
  const orderPositions = await modules.orders.positions.findOrderPositions({
7
7
  orderId: order._id,
8
8
  });
@@ -2,7 +2,7 @@ import { OrderPricingSheet, OrderPricingRowCategory } from '@unchainedshop/core'
2
2
  import { ch } from '@unchainedshop/utils';
3
3
  export const getOrderSummaryData = async (order, params, context) => {
4
4
  const { modules } = context;
5
- const { useNetPrice, format = ch.priceToString } = params || {};
5
+ const { useNetPrice, format = (price) => ch.priceToString({ ...price, locale: params?.locale }) } = params || {};
6
6
  const orderDelivery = order.deliveryId &&
7
7
  (await modules.orders.deliveries.findDelivery({
8
8
  orderDeliveryId: order.deliveryId,
@@ -21,7 +21,7 @@ export const getOrderSummaryData = async (order, params, context) => {
21
21
  const deliveryProvider = await modules.delivery.findProvider({
22
22
  deliveryProviderId: orderDelivery.deliveryProviderId,
23
23
  });
24
- const deliveryAddress = ch.addressToString(orderDelivery?.context?.deliveryAddress || order.billingAddress);
24
+ const deliveryAddress = ch.addressToString(orderDelivery?.context?.address || orderDelivery?.context?.deliveryAddress || order.billingAddress);
25
25
  const billingAddress = ch.addressToString(order.billingAddress);
26
26
  const orderPricing = OrderPricingSheet({
27
27
  calculation: order.calculation,
@@ -1,14 +1,22 @@
1
1
  const { EMAIL_FROM, EMAIL_WEBSITE_NAME, EMAIL_WEBSITE_URL } = process.env;
2
- export const resolveEnrollmentStatusTemplate = async ({ enrollmentId }, context) => {
2
+ export const resolveEnrollmentStatusTemplate = async ({ enrollmentId, locale }, context) => {
3
3
  const { modules } = context;
4
4
  const enrollment = await modules.enrollments.findEnrollment({ enrollmentId });
5
5
  const user = await modules.users.findUserById(enrollment.userId);
6
- const subject = `${EMAIL_WEBSITE_NAME}: Updated Enrollment / ${enrollment.enrollmentNumber}`;
6
+ const subject = `${EMAIL_WEBSITE_NAME}: Updated Enrollment / ${enrollment.enrollmentNumber || enrollment._id}`;
7
7
  const url = `${EMAIL_WEBSITE_URL}/enrollment?_id=${enrollment._id}`;
8
+ const formatDate = (date) => new Date(date).toLocaleDateString(locale || 'en', { dateStyle: 'long' });
9
+ const scheduledDates = [
10
+ enrollment.expires && `Ends: ${formatDate(enrollment.expires)}`,
11
+ enrollment.resumeAt && `Resumes: ${formatDate(enrollment.resumeAt)}`,
12
+ enrollment.minimumCommitmentEnd &&
13
+ `Minimum commitment until: ${formatDate(enrollment.minimumCommitmentEnd)}`,
14
+ ].filter(Boolean);
8
15
  const text = `
9
16
  ${subject}\n
10
17
  \n
11
18
  Status: ${enrollment.status}
19
+ ${scheduledDates.join('\n ')}
12
20
  \n
13
21
  -----------------\n
14
22
  Show: ${url}\n
@@ -1,9 +1,9 @@
1
- import { stringify } from 'safe-stable-stringify';
1
+ import { safeStringify } from '@unchainedshop/logger';
2
2
  const { EMAIL_FROM = 'noreply@unchained.local', EMAIL_ERROR_REPORT_RECIPIENT = 'support@unchained.local', EMAIL_WEBSITE_NAME = 'Unchained', ROOT_URL, } = process.env;
3
3
  const formatWorkItems = (workItems) => {
4
4
  return workItems
5
5
  .map(({ _id, type, started, error }) => {
6
- const stringifiedErrors = stringify(error, null, 2);
6
+ const stringifiedErrors = safeStringify(error, null, 2);
7
7
  return `${new Date(started).toLocaleString()} ${type} (${_id}): ${stringifiedErrors}`;
8
8
  })
9
9
  .join('\n');
package/package.json CHANGED
@@ -1,10 +1,17 @@
1
1
  {
2
2
  "name": "@unchainedshop/platform",
3
3
  "description": "Complete e-commerce platform bundle for the Unchained Engine",
4
- "version": "5.0.0-alpha.1",
4
+ "version": "5.0.0-alpha.11",
5
+ "files": [
6
+ "lib",
7
+ "bin"
8
+ ],
5
9
  "main": "lib/platform-index.js",
6
10
  "types": "lib/platform-index.d.ts",
7
11
  "type": "module",
12
+ "bin": {
13
+ "unchained": "./bin/unchained.js"
14
+ },
8
15
  "scripts": {
9
16
  "clean": "tsc -b --clean",
10
17
  "build": "tsc -b",
@@ -34,22 +41,21 @@
34
41
  },
35
42
  "homepage": "https://github.com/unchainedshop/unchained#readme",
36
43
  "engines": {
37
- "node": ">=22.0.0",
44
+ "node": ">=26.0.0",
38
45
  "npm": ">=10.0.0"
39
46
  },
40
47
  "dependencies": {
41
- "@unchainedshop/api": "^5.0.0-alpha.1",
42
- "@unchainedshop/core": "^5.0.0-alpha.1",
43
- "@unchainedshop/events": "^5.0.0-alpha.1",
44
- "@unchainedshop/logger": "^5.0.0-alpha.1",
45
- "@unchainedshop/mongodb": "^5.0.0-alpha.1",
46
- "@unchainedshop/plugins": "^5.0.0-alpha.1",
47
- "@unchainedshop/roles": "^5.0.0-alpha.1",
48
- "@unchainedshop/utils": "^5.0.0-alpha.1",
49
- "safe-stable-stringify": "^2.5.0"
48
+ "@unchainedshop/api": "^5.0.0-alpha.11",
49
+ "@unchainedshop/core": "^5.0.0-alpha.11",
50
+ "@unchainedshop/events": "^5.0.0-alpha.11",
51
+ "@unchainedshop/logger": "^5.0.0-alpha.11",
52
+ "@unchainedshop/mongodb": "^5.0.0-alpha.11",
53
+ "@unchainedshop/plugins": "^5.0.0-alpha.11",
54
+ "@unchainedshop/roles": "^5.0.0-alpha.11",
55
+ "@unchainedshop/utils": "^5.0.0-alpha.11"
50
56
  },
51
57
  "devDependencies": {
52
- "@types/node": "^25.0.0",
53
- "typescript": "^5.8.3"
58
+ "@types/node": "^26.2.0",
59
+ "typescript": "^6.0.3"
54
60
  }
55
61
  }