@consilioweb/payload-support 6.0.0 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ function dbFind(payload, slug, options = {}) {
2
+ return payload.find({ collection: slug, ...options });
3
+ }
4
+ function dbFindByID(payload, slug, options) {
5
+ return payload.findByID({ collection: slug, ...options });
6
+ }
7
+ function dbCreate(payload, slug, options) {
8
+ return payload.create({ collection: slug, ...options });
9
+ }
10
+ function dbUpdate(payload, slug, options) {
11
+ return payload.update({ collection: slug, ...options });
12
+ }
13
+ function dbCount(payload, slug, options = {}) {
14
+ return payload.count({ collection: slug, ...options });
15
+ }
16
+ function dbDelete(payload, slug, options) {
17
+ return payload.delete({ collection: slug, ...options });
18
+ }
19
+
20
+ export { dbCount, dbCreate, dbDelete, dbFind, dbFindByID, dbUpdate };
@@ -0,0 +1,150 @@
1
+ import { dbFind } from './db.js';
2
+ import { DEFAULT_TICKETING_FEATURES, projectAutoClose, normalizeFeatures } from './features.js';
3
+
4
+ const SUPPORT_SETTINGS_PREF_KEY = "support-settings";
5
+ const PREF_KEY = SUPPORT_SETTINGS_PREF_KEY;
6
+ const USER_PREFS_KEY_PREFIX = "support-user-prefs";
7
+ const LEGACY_ROUND_ROBIN_KEY = "support-round-robin";
8
+ const SUPPORT_STAFF_SLUG_CONFIG_KEY = "supportStaffCollection";
9
+ function resolveStaffPrefSlug(payload, staffSlug) {
10
+ if (staffSlug) return staffSlug;
11
+ const config = payload.config;
12
+ const registered = config?.custom?.[SUPPORT_STAFF_SLUG_CONFIG_KEY];
13
+ if (typeof registered === "string" && registered) return registered;
14
+ return config?.admin?.user || "users";
15
+ }
16
+ const DEFAULT_SETTINGS = {
17
+ email: { fromAddress: "", fromName: "Support", replyToAddress: "" },
18
+ ai: { provider: "anthropic", model: "claude-haiku-4-5-20251001", enableSentiment: true, enableSynthesis: true, enableSuggestion: true, enableRewrite: true },
19
+ sla: { firstResponseMinutes: 120, resolutionMinutes: 1440, businessHoursOnly: true, escalationEmail: "" },
20
+ autoClose: { enabled: true, daysBeforeClose: 7, reminderDaysBefore: 2 },
21
+ features: { ...DEFAULT_TICKETING_FEATURES }
22
+ };
23
+ const DEFAULT_USER_PREFS = {
24
+ locale: "fr",
25
+ signature: ""
26
+ };
27
+ const settingsCache = /* @__PURE__ */ new Map();
28
+ const SETTINGS_TTL_MS = 6e4;
29
+ const SETTINGS_CACHE_MAX = 8;
30
+ const warnedForeignSettingsRow = /* @__PURE__ */ new Set();
31
+ function invalidateSupportSettingsCache() {
32
+ settingsCache.clear();
33
+ warnedForeignSettingsRow.clear();
34
+ }
35
+ function mergeSupportSettings(stored, base = DEFAULT_SETTINGS) {
36
+ const autoClose = { ...base.autoClose, ...stored?.autoClose };
37
+ return {
38
+ email: { ...base.email, ...stored?.email },
39
+ ai: { ...base.ai, ...stored?.ai },
40
+ sla: { ...base.sla, ...stored?.sla },
41
+ autoClose,
42
+ // `autoClose` / `autoCloseDays` are projections of the block above, so they
43
+ // are recomputed here rather than trusted from whatever was persisted.
44
+ features: projectAutoClose(
45
+ normalizeFeatures({ ...base.features, ...stored?.features }),
46
+ autoClose
47
+ )
48
+ };
49
+ }
50
+ async function readSupportSettingsState(payload, staffSlug) {
51
+ const staff = resolveStaffPrefSlug(payload, staffSlug);
52
+ const cached = settingsCache.get(staff);
53
+ if (cached && Date.now() - cached.ts < SETTINGS_TTL_MS) {
54
+ return cached.value;
55
+ }
56
+ let value = {
57
+ settings: mergeSupportSettings(null),
58
+ featuresConfigured: false
59
+ };
60
+ try {
61
+ const prefs = await dbFind(payload, "payload-preferences", {
62
+ // Sibling keys are AND-ed by Payload. The `user.relationTo` clause is the
63
+ // security boundary: without it any authenticated principal can plant a
64
+ // `support-settings` row and own the plugin's server settings.
65
+ where: { key: { equals: PREF_KEY }, "user.relationTo": { equals: staff } },
66
+ // The upsert is scoped per admin user, so several rows can share the key.
67
+ // Sorting makes "last write wins" deterministic instead of arbitrary.
68
+ sort: "-updatedAt",
69
+ limit: 1,
70
+ depth: 0,
71
+ overrideAccess: true
72
+ });
73
+ if (prefs.docs.length > 0) {
74
+ const stored = prefs.docs[0].value;
75
+ const featuresConfigured = !!stored.features && typeof stored.features === "object";
76
+ const settings = mergeSupportSettings(stored);
77
+ if (!featuresConfigured) {
78
+ settings.features.roundRobin = await readLegacyRoundRobin(payload, staff);
79
+ }
80
+ value = { settings, featuresConfigured };
81
+ } else {
82
+ await warnOnForeignSettingsRow(payload, staff);
83
+ }
84
+ } catch {
85
+ }
86
+ if (settingsCache.size >= SETTINGS_CACHE_MAX && !settingsCache.has(staff)) settingsCache.clear();
87
+ settingsCache.set(staff, { value, ts: Date.now() });
88
+ return value;
89
+ }
90
+ async function warnOnForeignSettingsRow(payload, staff) {
91
+ if (warnedForeignSettingsRow.has(staff)) return;
92
+ try {
93
+ const any = await dbFind(payload, "payload-preferences", {
94
+ where: { key: { equals: PREF_KEY } },
95
+ limit: 1,
96
+ depth: 0,
97
+ overrideAccess: true
98
+ });
99
+ if (any.docs.length === 0) return;
100
+ if (warnedForeignSettingsRow.size >= SETTINGS_CACHE_MAX) warnedForeignSettingsRow.clear();
101
+ warnedForeignSettingsRow.add(staff);
102
+ console.warn(
103
+ `[support] A "${PREF_KEY}" preference row exists but none is owned by the "${staff}" collection: the plugin is running on its DEFAULT settings. Either the staff auth collection differs from \`admin.user\`, or the row was written by a principal that is not staff \u2014 in which case it is ignored on purpose.`
104
+ );
105
+ } catch {
106
+ }
107
+ }
108
+ async function readSupportSettings(payload, staffSlug) {
109
+ return (await readSupportSettingsState(payload, staffSlug)).settings;
110
+ }
111
+ async function readLegacyRoundRobin(payload, staff) {
112
+ try {
113
+ const prefs = await dbFind(payload, "payload-preferences", {
114
+ where: { key: { equals: LEGACY_ROUND_ROBIN_KEY }, "user.relationTo": { equals: staff } },
115
+ limit: 1,
116
+ depth: 0,
117
+ overrideAccess: true
118
+ });
119
+ if (prefs.docs.length > 0) {
120
+ return prefs.docs[0].value?.enabled === true;
121
+ }
122
+ } catch {
123
+ }
124
+ return DEFAULT_TICKETING_FEATURES.roundRobin;
125
+ }
126
+ async function readUserPrefs(payload, userId, staffSlug) {
127
+ try {
128
+ const key = `${USER_PREFS_KEY_PREFIX}-${userId}`;
129
+ const prefs = await dbFind(payload, "payload-preferences", {
130
+ where: {
131
+ key: { equals: key },
132
+ "user.relationTo": { equals: resolveStaffPrefSlug(payload, staffSlug) }
133
+ },
134
+ limit: 1,
135
+ depth: 0,
136
+ overrideAccess: true
137
+ });
138
+ if (prefs.docs.length > 0) {
139
+ const stored = prefs.docs[0].value;
140
+ return {
141
+ locale: stored.locale || DEFAULT_USER_PREFS.locale,
142
+ signature: stored.signature ?? DEFAULT_USER_PREFS.signature
143
+ };
144
+ }
145
+ } catch {
146
+ }
147
+ return { ...DEFAULT_USER_PREFS };
148
+ }
149
+
150
+ export { DEFAULT_SETTINGS, DEFAULT_USER_PREFS, SUPPORT_SETTINGS_PREF_KEY, SUPPORT_STAFF_SLUG_CONFIG_KEY, invalidateSupportSettingsCache, mergeSupportSettings, readSupportSettings, readSupportSettingsState, readUserPrefs, resolveStaffPrefSlug };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consilioweb/payload-support",
3
- "version": "6.0.0",
3
+ "version": "6.0.1",
4
4
  "description": "Payload CMS plugin — professional support & ticketing system with AI, SLA, time tracking, live chat, and more",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -52,7 +52,8 @@
52
52
  "scripts/uninstall.mjs",
53
53
  "scripts/uninstall-data.mjs",
54
54
  "README.md",
55
- "LICENSE"
55
+ "LICENSE",
56
+ "scripts"
56
57
  ],
57
58
  "keywords": [
58
59
  "payload",
@@ -137,7 +138,7 @@
137
138
  }
138
139
  },
139
140
  "scripts": {
140
- "build": "tsup && tsc -p tsconfig.types.json && node scripts/copy-subpath-types.mjs",
141
+ "build": "tsup && tsc -p tsconfig.types.json && node scripts/copy-subpath-types.mjs && node scripts/verify-dist-imports.mjs",
141
142
  "typecheck": "tsc --noEmit",
142
143
  "test": "vitest run",
143
144
  "test:watch": "vitest",
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Copies the declarations emitted by `tsc -p tsconfig.types.json` into dist/,
3
+ * then rewrites their relative import specifiers so they carry an explicit
4
+ * `.js` extension.
5
+ *
6
+ * Why a separate pass: the `bundle: false` tsup entry that emits dist/views/**
7
+ * and dist/components/** cannot generate declarations — turning `dts: true` on
8
+ * for ~100 entries makes rollup-plugin-dts run for well over ten minutes without
9
+ * finishing. Without them, `dist/views.d.ts` re-exports 13 views from paths that
10
+ * carry no declaration (TS7016 under noImplicitAny) and the publicly documented
11
+ * `./components/TicketConversation` subpath has no types at all.
12
+ *
13
+ * Why the extension rewrite: the sources are compiled with
14
+ * `moduleResolution: bundler`, so both tsc and tsup emit extensionless relative
15
+ * specifiers (`from './client'`, `from '../../utils/features'`). A consumer on
16
+ * `moduleResolution: node16 | nodenext` — the recommended setting for an ESM
17
+ * package — then gets `TS2835: Relative import paths need explicit file
18
+ * extensions`, and with the default `skipLibCheck: true` that error is swallowed
19
+ * and every export silently degrades to `any`. tsup's `onSuccess` already does
20
+ * this for the emitted `.js`; this does the same for the `.d.ts` (the copied ones
21
+ * *and* the `dist/views.d.ts` barrel produced by tsup's dts pass).
22
+ *
23
+ * `utils` is copied too: the emitted view declarations reference
24
+ * `../../utils/features`, whose JS is already emitted by the same tsup pass.
25
+ */
26
+ import { cpSync, existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
27
+ import { dirname, join, resolve } from 'node:path'
28
+
29
+ const OUT = '.types-out'
30
+ const SUBPATHS = ['views', 'components', 'utils']
31
+
32
+ if (!existsSync(OUT)) {
33
+ console.error(`[build] "${OUT}" is missing — run \`tsc -p tsconfig.types.json\` before this script.`)
34
+ process.exit(1)
35
+ }
36
+
37
+ for (const dir of SUBPATHS) {
38
+ const from = `${OUT}/${dir}`
39
+ if (existsSync(from)) cpSync(from, `dist/${dir}`, { recursive: true })
40
+ }
41
+
42
+ rmSync(OUT, { recursive: true, force: true })
43
+ console.log('✓ Copied subpath declarations into dist/')
44
+
45
+ // ─── Explicit .js extensions on relative specifiers ──────────────────────────
46
+
47
+ const HAS_EXTENSION = /\.(js|jsx|mjs|cjs|css|scss|json)$/
48
+ const RELATIVE_SPECIFIER = /((?:from|import)\s*['"])(\.\.?\/[^'"]+?)(['"])/g
49
+
50
+ const errors = []
51
+
52
+ /** Resolve an extensionless specifier against the emitted declarations. */
53
+ function withExtension(fromFile, specifier) {
54
+ const base = resolve(dirname(fromFile), specifier)
55
+ if (existsSync(`${base}.d.ts`)) return `${specifier}.js`
56
+ if (existsSync(join(base, 'index.d.ts'))) return `${specifier}/index.js`
57
+ return null
58
+ }
59
+
60
+ function rewrite(file) {
61
+ const content = readFileSync(file, 'utf-8')
62
+ const fixed = content.replace(RELATIVE_SPECIFIER, (match, prefix, specifier, suffix) => {
63
+ if (HAS_EXTENSION.test(specifier)) return match
64
+ const resolved = withExtension(file, specifier)
65
+ if (!resolved) {
66
+ errors.push(`${file}: cannot resolve "${specifier}" to an emitted declaration`)
67
+ return match
68
+ }
69
+ return `${prefix}${resolved}${suffix}`
70
+ })
71
+ if (fixed !== content) writeFileSync(file, fixed)
72
+ }
73
+
74
+ function walkDts(dir) {
75
+ if (!existsSync(dir)) return
76
+ for (const entry of readdirSync(dir)) {
77
+ const path = join(dir, entry)
78
+ if (statSync(path).isDirectory()) {
79
+ walkDts(path)
80
+ continue
81
+ }
82
+ if (path.endsWith('.d.ts')) rewrite(path)
83
+ }
84
+ }
85
+
86
+ // The `./views` barrel is emitted by tsup's dts pass, not by the tsc pass above,
87
+ // but it has the exact same extensionless specifiers — and it is the entry point
88
+ // consumers actually import, so it matters most.
89
+ const VIEWS_BARREL = 'dist/views.d.ts'
90
+ if (!existsSync(VIEWS_BARREL)) {
91
+ console.error(`[build] "${VIEWS_BARREL}" is missing — did the tsup views barrel entry run?`)
92
+ process.exit(1)
93
+ }
94
+ rewrite(VIEWS_BARREL)
95
+ for (const dir of SUBPATHS) walkDts(`dist/${dir}`)
96
+
97
+ if (errors.length > 0) {
98
+ console.error('[build] unresolved relative specifiers in emitted declarations:')
99
+ for (const error of errors) console.error(` - ${error}`)
100
+ process.exit(1)
101
+ }
102
+
103
+ console.log('✓ Added explicit .js extensions to relative specifiers in dist/**/*.d.ts')
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Every relative import in `dist/` must resolve to a file that is actually there.
4
+ *
5
+ * This catches one specific, silent failure mode of a multi-pass tsup build.
6
+ * The `bundle: false` pass preserves the source tree and rewrites nothing, so a
7
+ * relative import survives into the emitted file. If the target module is not
8
+ * itself in that pass's `entry` list, it is never emitted as a standalone file —
9
+ * it only exists inlined inside the bundled `dist/index.js`. The import then
10
+ * points at nothing.
11
+ *
12
+ * Nothing in the normal toolchain sees it. `tsc` type-checks the SOURCE tree,
13
+ * where the module exists. Vitest imports from `src/`, same thing. The build
14
+ * itself succeeds — tsup has no reason to object. The failure surfaces only when
15
+ * the HOST application's bundler resolves the published package, which is to say
16
+ * after publication, in someone else's build.
17
+ *
18
+ * `import type` is invisible here on purpose: TypeScript erases it, so it leaves
19
+ * no trace in the emitted file and cannot break anything. Only value imports do.
20
+ * That is why the defect hid for two releases in this package — every other
21
+ * importer of the same module used `import type`.
22
+ */
23
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
24
+ import { dirname, join, resolve } from 'node:path'
25
+
26
+ const DIST = resolve(process.cwd(), 'dist')
27
+
28
+ if (!existsSync(DIST)) {
29
+ console.error('verify-dist-imports: dist/ is missing — run the build first.')
30
+ process.exit(1)
31
+ }
32
+
33
+ /** Every emitted JavaScript file, at any depth. */
34
+ function walk(dir) {
35
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
36
+ const path = join(dir, entry.name)
37
+ if (entry.isDirectory()) return walk(path)
38
+ return /\.(js|cjs|mjs)$/.test(entry.name) ? [path] : []
39
+ })
40
+ }
41
+
42
+ /**
43
+ * Relative specifiers only. Bare specifiers are the consumer's dependency
44
+ * problem, not ours, and `node:` builtins always resolve.
45
+ */
46
+ const SPECIFIER =
47
+ /(?:^|[\s;{(])(?:import|export)\s+(?:[^'"]*?\sfrom\s+)?['"](\.[^'"]+)['"]|require\(\s*['"](\.[^'"]+)['"]\s*\)|import\(\s*['"](\.[^'"]+)['"]\s*\)/g
48
+
49
+ /** Resolve the way Node and a bundler would: as written, then the usual suffixes. */
50
+ function resolves(fromFile, specifier) {
51
+ const base = resolve(dirname(fromFile), specifier)
52
+ const candidates = [
53
+ base,
54
+ `${base}.js`, `${base}.cjs`, `${base}.mjs`,
55
+ join(base, 'index.js'), join(base, 'index.cjs'), join(base, 'index.mjs'),
56
+ ]
57
+ return candidates.some((c) => existsSync(c) && statSync(c).isFile())
58
+ }
59
+
60
+ const broken = []
61
+ for (const file of walk(DIST)) {
62
+ const source = readFileSync(file, 'utf8')
63
+ for (const match of source.matchAll(SPECIFIER)) {
64
+ const specifier = match[1] || match[2] || match[3]
65
+ if (!specifier || resolves(file, specifier)) continue
66
+ broken.push({ file: file.replace(`${process.cwd()}/`, ''), specifier })
67
+ }
68
+ }
69
+
70
+ if (broken.length === 0) {
71
+ console.log('verify-dist-imports: every relative import in dist/ resolves.')
72
+ process.exit(0)
73
+ }
74
+
75
+ console.error(`verify-dist-imports: ${broken.length} unresolved relative import(s) in dist/.\n`)
76
+ for (const { file, specifier } of broken) {
77
+ console.error(` ${file}\n imports '${specifier}' — not emitted`)
78
+ }
79
+ console.error(
80
+ '\nThe target is almost certainly missing from the `entry` list of the\n' +
81
+ '`bundle: false` pass in tsup.config.ts. Add it there, or move the value\n' +
82
+ 'being imported into a module that pass already emits.',
83
+ )
84
+ process.exit(1)