kempo-inventory 0.2.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.
Files changed (68) hide show
  1. package/README.md +429 -0
  2. package/admin/categories/index.page.html +229 -0
  3. package/admin/edit/index.page.html +51 -0
  4. package/admin/fields/index.page.html +300 -0
  5. package/admin/index.page.html +203 -0
  6. package/admin/nav.global.html +3 -0
  7. package/admin/new/index.page.html +43 -0
  8. package/install.js +19 -0
  9. package/kempo-config.json +103 -0
  10. package/package.json +59 -0
  11. package/public/api/categories/DELETE.js +12 -0
  12. package/public/api/categories/GET.js +12 -0
  13. package/public/api/categories/PUT.js +20 -0
  14. package/public/api/export/GET.js +31 -0
  15. package/public/api/fields/GET.js +23 -0
  16. package/public/api/fields/POST.js +12 -0
  17. package/public/api/fields/[key]/DELETE.js +11 -0
  18. package/public/api/fields/[key]/PATCH.js +16 -0
  19. package/public/api/fields/[key]/suggestions/GET.js +12 -0
  20. package/public/api/import/apply/POST.js +25 -0
  21. package/public/api/import/preview/POST.js +30 -0
  22. package/public/api/items/GET.js +35 -0
  23. package/public/api/items/POST.js +12 -0
  24. package/public/api/items/[id]/DELETE.js +11 -0
  25. package/public/api/items/[id]/GET.js +15 -0
  26. package/public/api/items/[id]/PATCH.js +12 -0
  27. package/public/api/items/[id]/adjust/POST.js +12 -0
  28. package/public/api/movements/GET.js +17 -0
  29. package/public/api/tags/GET.js +12 -0
  30. package/public/components/BarcodeScanner.js +198 -0
  31. package/public/components/ImageCropDialog.js +94 -0
  32. package/public/components/ImportExport.js +194 -0
  33. package/public/components/ItemDialog.js +135 -0
  34. package/public/components/ItemFields.js +441 -0
  35. package/public/components/ItemForm.js +186 -0
  36. package/public/components/NewItemPage.js +92 -0
  37. package/public/components/RowAction.js +34 -0
  38. package/public/components/flash.js +20 -0
  39. package/public/sdk.js +96 -0
  40. package/public/vendor/LICENSES.md +12 -0
  41. package/public/vendor/ts-custom-error.js +8 -0
  42. package/public/vendor/zxing-browser.js +8 -0
  43. package/public/vendor/zxing-library.js +27 -0
  44. package/sdk.js +65 -0
  45. package/server/db/schema.js +74 -0
  46. package/server/utils/access.js +43 -0
  47. package/server/utils/authorize.js +13 -0
  48. package/server/utils/categories.js +267 -0
  49. package/server/utils/categoryLogic.js +54 -0
  50. package/server/utils/events.js +31 -0
  51. package/server/utils/fieldTypes.js +227 -0
  52. package/server/utils/fields.js +238 -0
  53. package/server/utils/hooks.js +30 -0
  54. package/server/utils/importExport.js +239 -0
  55. package/server/utils/importFile.js +148 -0
  56. package/server/utils/importStage.js +76 -0
  57. package/server/utils/items.js +412 -0
  58. package/server/utils/media.js +245 -0
  59. package/server/utils/mediaSafety.js +51 -0
  60. package/server/utils/ownership.js +44 -0
  61. package/server/utils/rank.js +31 -0
  62. package/server/utils/ratio.js +31 -0
  63. package/server/utils/suggestions.js +36 -0
  64. package/server/utils/tagList.js +25 -0
  65. package/server/utils/tags.js +47 -0
  66. package/server/utils/zip.js +88 -0
  67. package/server/utils/zipRead.js +99 -0
  68. package/update.js +126 -0
@@ -0,0 +1,227 @@
1
+ /*
2
+ Pure helpers with no database access, so field definitions and values can be validated
3
+ and unit tested without a server.
4
+ */
5
+
6
+ import { categoryKey } from './categoryLogic.js';
7
+ import { parseRatio } from './ratio.js';
8
+
9
+ export const FIELD_TYPES = ['text', 'longtext', 'number', 'boolean', 'date', 'color', 'rating', 'select', 'item', 'media'];
10
+
11
+ /*
12
+ Types that depend on another extension being installed. They are always valid in a definition,
13
+ but creating one is refused when the extension that backs it is missing (see fields.js).
14
+ */
15
+ export const OPTIONAL_TYPES = { media: 'kempo-media or kempo-files' };
16
+
17
+ export const MAX_MEDIA = 20;
18
+ export const MAX_RATING = 5;
19
+ export const MAX_IMAGE_EDGE = 8000;
20
+
21
+ /*
22
+ The only type changes that cannot lose or reject what items already hold: a choice list becomes
23
+ free text (its values are already plain strings), and short text becomes long text. Anything
24
+ else, such as text to a number, could orphan existing values, so it is refused: delete the field
25
+ and make a new one instead.
26
+ */
27
+ export const CONVERSIONS = { select: ['text'], text: ['longtext'] };
28
+
29
+ export const canConvert = (from, to) => from === to || (CONVERSIONS[from] ?? []).includes(to);
30
+
31
+ /*
32
+ A `media` value is a list of ids, each saying where the file lives. A bare id is a kempo-media
33
+ asset; "files:<id>" is a kempo-files file.
34
+ */
35
+ const FILES_PREFIX = 'files:';
36
+ const MEDIA_ID = /^(files:)?[a-f0-9]{16}$/;
37
+
38
+ export const filesId = id => `${FILES_PREFIX}${id}`;
39
+
40
+ export const parseMediaId = id => id.startsWith(FILES_PREFIX)
41
+ ? { provider: 'files', rawId: id.slice(FILES_PREFIX.length) }
42
+ : { provider: 'media', rawId: id };
43
+
44
+ export const CORE_KEYS = ['id', 'sku', 'name', 'description', 'category', 'quantity', 'tags', 'created', 'updated', 'fields', 'data', 'linked'];
45
+
46
+ export const KEY_PATTERN = /^[a-z][a-zA-Z0-9]{0,39}$/;
47
+
48
+ const MAX_TEXT = 1000;
49
+ const MAX_LONGTEXT = 20000;
50
+
51
+ export const isEmpty = value => value === undefined || value === null || value === '';
52
+
53
+ /*
54
+ Turns a human label into a valid key: "Reorder level" -> "reorderLevel".
55
+ */
56
+ export const keyFromLabel = label => {
57
+ const words = String(label ?? '').replace(/[^a-zA-Z0-9]+/g, ' ').trim().split(' ').filter(Boolean);
58
+ if(!words.length) return '';
59
+ const key = words.map((w, i) => i === 0 ? w.charAt(0).toLowerCase() + w.slice(1) : w.charAt(0).toUpperCase() + w.slice(1)).join('');
60
+ return /^[a-z]/.test(key) ? key.slice(0, 40) : '';
61
+ };
62
+
63
+ /*
64
+ Validates and normalises a field definition. Returns [error, definition].
65
+ */
66
+ export const normalizeFieldDefinition = (input = {}) => {
67
+ const label = String(input.label ?? '').trim();
68
+ if(!label) return [{ code: 400, msg: 'Field label is required' }, null];
69
+
70
+ const key = String(input.key ?? keyFromLabel(label)).trim();
71
+ if(!KEY_PATTERN.test(key)){
72
+ return [{ code: 400, msg: 'Field key must start with a lowercase letter and contain only letters and numbers (max 40)' }, null];
73
+ }
74
+ if(CORE_KEYS.includes(key)) return [{ code: 400, msg: `"${key}" is reserved` }, null];
75
+
76
+ if(!FIELD_TYPES.includes(input.type)) return [{ code: 400, msg: `Field type must be one of: ${FIELD_TYPES.join(', ')}` }, null];
77
+
78
+ let options = [];
79
+ if(input.type === 'select'){
80
+ options = [...new Set((Array.isArray(input.options) ? input.options : []).map(o => String(o).trim()).filter(Boolean))];
81
+ if(!options.length) return [{ code: 400, msg: 'A select field needs at least one option' }, null];
82
+ }
83
+
84
+ /*
85
+ A photo field can force how photos are saved: a shape (an aspect ratio such as 1:1 or 16:9) they
86
+ are cropped to as they are added, and a maximum size in pixels on either side. Other types ignore both.
87
+ */
88
+ let imageRatio = '';
89
+ let imageMax = 0;
90
+ if(input.type === 'media'){
91
+ if(!isEmpty(input.imageRatio)){
92
+ imageRatio = parseRatio(input.imageRatio);
93
+ if(!imageRatio) return [{ code: 400, msg: 'The photo shape must be a ratio such as 1:1, 4:3 or 16:9' }, null];
94
+ }
95
+ if(!isEmpty(input.imageMax)){
96
+ imageMax = Number(input.imageMax);
97
+ if(!Number.isInteger(imageMax) || imageMax < 0 || (imageMax > 0 && imageMax < 16) || imageMax > MAX_IMAGE_EDGE){
98
+ return [{ code: 400, msg: `The maximum photo size must be a whole number of pixels from 16 to ${MAX_IMAGE_EDGE} (or 0 for no limit)` }, null];
99
+ }
100
+ }
101
+ }
102
+
103
+ return [null, {
104
+ key,
105
+ label,
106
+ type: input.type,
107
+ description: String(input.description ?? ''),
108
+ required: Boolean(input.required),
109
+ listed: input.listed === undefined ? true : Boolean(input.listed),
110
+ suggest: input.type === 'text' && Boolean(input.suggest),
111
+ options,
112
+ position: Number.isInteger(input.position) ? input.position : 0,
113
+ imageRatio,
114
+ imageMax,
115
+ }];
116
+ };
117
+
118
+ /*
119
+ Coerces one raw value to the field's type. Returns [error, value], where an empty input
120
+ yields [null, null] (meaning "no value"). `item` fields are only shape-checked here; whether the
121
+ linked item exists is checked by the caller, which has the database.
122
+ */
123
+ export const coerceValue = (field, raw) => {
124
+ if(field.type !== 'boolean' && (isEmpty(raw) || (Array.isArray(raw) && !raw.length))) return [null, null];
125
+ const bad = msg => [{ code: 400, msg: `${field.label}: ${msg}` }, null];
126
+
127
+ switch(field.type){
128
+ case 'text': {
129
+ /* Spaces before or after what was typed are never part of the value ("Vallejo " and "Vallejo" are one brand). */
130
+ const value = String(raw).trim();
131
+ if(!value) return [null, null];
132
+ return value.length > MAX_TEXT ? bad(`must be ${MAX_TEXT} characters or fewer`) : [null, value];
133
+ }
134
+ case 'longtext': {
135
+ const value = String(raw).trim();
136
+ if(!value) return [null, null];
137
+ return value.length > MAX_LONGTEXT ? bad(`must be ${MAX_LONGTEXT} characters or fewer`) : [null, value];
138
+ }
139
+ case 'number': {
140
+ const value = typeof raw === 'number' ? raw : Number(String(raw).trim());
141
+ return Number.isFinite(value) ? [null, value] : bad('must be a number');
142
+ }
143
+ case 'rating': {
144
+ /* A whole number of stars, 1 to 5. No stars (0) means no rating, the same as leaving it empty. */
145
+ const value = typeof raw === 'number' ? raw : Number(String(raw).trim());
146
+ if(!Number.isInteger(value) || value < 0 || value > MAX_RATING) return bad(`must be a whole number of stars from 1 to ${MAX_RATING}`);
147
+ return [null, value === 0 ? null : value];
148
+ }
149
+ case 'boolean': {
150
+ if(isEmpty(raw)) return [null, null];
151
+ if(typeof raw === 'boolean') return [null, raw];
152
+ if(raw === 'true') return [null, true];
153
+ if(raw === 'false') return [null, false];
154
+ return bad('must be true or false');
155
+ }
156
+ case 'date': {
157
+ const value = String(raw);
158
+ const valid = /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(`${value}T00:00:00Z`))
159
+ && new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value;
160
+ return valid ? [null, value] : bad('must be a date (YYYY-MM-DD)');
161
+ }
162
+ case 'color': {
163
+ /* A hex colour. Stored as "#rrggbb", or "#rrggbbaa" when it is see-through; "#abc" is expanded. */
164
+ const text = String(raw).trim().toLowerCase();
165
+ const short = text.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])([0-9a-f])?$/);
166
+ const value = short ? `#${short.slice(1).filter(Boolean).map(c => c + c).join('')}` : text;
167
+ return /^#(?:[0-9a-f]{6}|[0-9a-f]{8})$/.test(value) ? [null, value.endsWith('ff') && value.length === 9 ? value.slice(0, 7) : value] : bad('must be a hex color such as #ff8800');
168
+ }
169
+ case 'select': {
170
+ const value = String(raw).trim();
171
+ if(!value) return [null, null];
172
+ return field.options.includes(value) ? [null, value] : bad(`must be one of: ${field.options.join(', ')}`);
173
+ }
174
+ case 'item': {
175
+ const value = String(raw);
176
+ return /^[a-f0-9]{16}$/.test(value) ? [null, value] : bad('must be a valid item');
177
+ }
178
+ case 'media': {
179
+ const list = Array.isArray(raw) ? raw : String(raw).split(',');
180
+ const ids = [...new Set(list.map(v => String(v).trim()).filter(Boolean))];
181
+ if(!ids.length) return [null, null];
182
+ if(ids.length > MAX_MEDIA) return bad(`can hold at most ${MAX_MEDIA} files`);
183
+ return ids.every(id => MEDIA_ID.test(id)) ? [null, ids] : bad('must be a list of media files');
184
+ }
185
+ default:
186
+ return bad('has an unsupported type');
187
+ }
188
+ };
189
+
190
+ /*
191
+ Validates `input` (an object keyed by field key) against `fields`.
192
+ partial=false: every required field must be present (creating an item)
193
+ partial=true: only the keys supplied are checked (updating an item)
194
+ Returns [error, values] where `values[key]` is the coerced value or null to clear it.
195
+ */
196
+ /*
197
+ The fields an item shows: the default ones (category '') plus those scoped to its category.
198
+ `category` is the item's category in any spelling; an uncategorised item gets only the defaults.
199
+ */
200
+ export const applicableFields = (fields, category) => {
201
+ const key = categoryKey(category);
202
+ return fields.filter(f => !f.category || f.category === key);
203
+ };
204
+
205
+ export const coerceValues = (fields, input = {}, { partial = false } = {}) => {
206
+ if(input === null || typeof input !== 'object' || Array.isArray(input)){
207
+ return [{ code: 400, msg: 'fields must be an object' }, null];
208
+ }
209
+ const byKey = new Map(fields.map(f => [f.key, f]));
210
+ const values = {};
211
+
212
+ for(const key of Object.keys(input)){
213
+ if(!byKey.has(key)) return [{ code: 400, msg: `Unknown field "${key}"` }, null];
214
+ }
215
+
216
+ for(const field of fields){
217
+ const supplied = Object.prototype.hasOwnProperty.call(input, field.key);
218
+ if(partial && !supplied) continue;
219
+ const [error, value] = coerceValue(field, supplied ? input[field.key] : undefined);
220
+ if(error) return [error, null];
221
+ if(value === null && field.required){
222
+ return [{ code: 400, msg: `${field.label} is required` }, null];
223
+ }
224
+ if(supplied || value !== null) values[field.key] = value;
225
+ }
226
+ return [null, values];
227
+ };
@@ -0,0 +1,238 @@
1
+ import db from 'kempo/server/db/index.js';
2
+ import { eq, and, asc, sql } from 'drizzle-orm';
3
+ import crypto from 'crypto';
4
+ import { kempoInventoryField, kempoInventoryItem, kempoInventoryCategory } from '../db/schema.js';
5
+ import { categoryKey, tidyCategory, MAX_CATEGORY } from './categoryLogic.js';
6
+ import { normalizeFieldDefinition, OPTIONAL_TYPES, canConvert } from './fieldTypes.js';
7
+ import { mediaAvailable } from './media.js';
8
+ import { EVENTS, guard, notify } from './hooks.js';
9
+
10
+ const newId = () => crypto.randomBytes(8).toString('hex');
11
+
12
+ const isUniqueViolation = error => (error?.code || error?.cause?.code) === '23505';
13
+
14
+ /*
15
+ Properties an extension's own field accepts on update, and what anyone else (the admin UI) may
16
+ adjust on a field an extension owns. Key and ownership never change after creation, and the type
17
+ only changes where nothing can be lost (see CONVERSIONS in fieldTypes.js).
18
+ */
19
+ const OWNER_EDITABLE = ['label', 'description', 'required', 'listed', 'suggest', 'options', 'position', 'type', 'imageRatio', 'imageMax'];
20
+ const ANYONE_EDITABLE = ['label', 'description', 'listed', 'suggest', 'position', 'imageRatio', 'imageMax'];
21
+
22
+ /*
23
+ Every field, each with the `category` key it is scoped to ('' = every item) and `categoryName`
24
+ for display. Pass `category` to get only the fields an item in that category shows: the default
25
+ ones plus that category's ('' gives just the defaults).
26
+ */
27
+ export const getFields = async ({ category, owner } = {}) => {
28
+ try {
29
+ const rows = await db.select().from(kempoInventoryField)
30
+ .orderBy(asc(kempoInventoryField.position), asc(kempoInventoryField.created), asc(kempoInventoryField.id));
31
+ const records = await db.select({ key: kempoInventoryCategory.key, name: kempoInventoryCategory.name }).from(kempoInventoryCategory);
32
+ const names = new Map(records.map(r => [r.key, r.name]));
33
+ let fields = rows.map(f => ({ ...f, categoryName: f.category ? names.get(f.category) ?? f.category : '' }));
34
+ if(category !== undefined){
35
+ const key = categoryKey(category);
36
+ fields = fields.filter(f => !f.category || f.category === key);
37
+ }
38
+ if(owner !== undefined) fields = fields.filter(f => f.owner === String(owner ?? ''));
39
+ return [null, fields];
40
+ } catch {
41
+ return [{ code: 500, msg: 'Failed to retrieve fields' }, null];
42
+ }
43
+ };
44
+
45
+ /* A field is identified by its key and the category it belongs to ('' for the default scope). */
46
+ export const getField = async (key, category = '') => {
47
+ if(!key) return [{ code: 400, msg: 'Field key is required' }, null];
48
+ try {
49
+ const [field] = await db.select().from(kempoInventoryField)
50
+ .where(and(eq(kempoInventoryField.key, key), eq(kempoInventoryField.category, categoryKey(category))));
51
+ if(!field) return [{ code: 404, msg: 'Field not found' }, null];
52
+ return [null, field];
53
+ } catch {
54
+ return [{ code: 500, msg: 'Failed to retrieve field' }, null];
55
+ }
56
+ };
57
+
58
+ /* The items a field's values live on: everything for a default field, otherwise that category's items. */
59
+ export const itemsInScope = category => category
60
+ ? sql`lower(regexp_replace(btrim(${kempoInventoryItem.category}), '[[:space:]]+', ' ', 'g')) = ${category}`
61
+ : sql`true`;
62
+
63
+ /*
64
+ Creates a field. `owner` is '' for a field made by a person in the admin, or the extension's
65
+ name for one it registers itself.
66
+ */
67
+ export const createField = async (definition, { owner = '' } = {}) => {
68
+ const [invalid, normalized] = normalizeFieldDefinition(definition);
69
+ if(invalid) return [invalid, null];
70
+
71
+ const refused = await guard(EVENTS.fieldBeforeCreate, { draft: { ...normalized, category: tidyCategory(definition?.category) }, actor: owner });
72
+ if(refused) return [refused, null];
73
+
74
+ /* `definition.category` names the category the field belongs to; empty means every item. */
75
+ const categoryName = tidyCategory(definition?.category);
76
+ if(categoryName.length > MAX_CATEGORY) return [{ code: 400, msg: `A category name must be ${MAX_CATEGORY} characters or fewer` }, null];
77
+ const scope = categoryKey(categoryName);
78
+ if(normalized.type === 'media' && !(await mediaAvailable())){
79
+ return [{ code: 409, msg: `Media fields need the ${OPTIONAL_TYPES.media} extension, which is not installed` }, null];
80
+ }
81
+
82
+ try {
83
+ /*
84
+ Items in a category hold the default fields and that category's under one set of names, so a
85
+ key cannot be reused where it would collide: a default field's key is taken in every category,
86
+ and a category's key is taken in the default scope.
87
+ */
88
+ const clash = await db.select({ category: kempoInventoryField.category }).from(kempoInventoryField).where(and(
89
+ eq(kempoInventoryField.key, normalized.key),
90
+ scope ? sql`${kempoInventoryField.category} in ('', ${scope})` : sql`true`,
91
+ ));
92
+ if(clash.length){
93
+ const where = clash[0].category === scope ? (scope ? 'this category' : 'the default fields') : (scope ? 'the default fields' : 'a category');
94
+ return [{ code: 409, msg: `A field with the key "${normalized.key}" already exists in ${where}` }, null];
95
+ }
96
+
97
+ const field = await db.transaction(async tx => {
98
+ if(scope){
99
+ /* A category with a field has to exist as a record, even before any item uses it. */
100
+ const now = new Date();
101
+ await tx.insert(kempoInventoryCategory)
102
+ .values({ id: newId(), key: scope, name: categoryName, image: null, created: now, updated: now })
103
+ .onConflictDoNothing();
104
+ }
105
+ const [created] = await tx.insert(kempoInventoryField)
106
+ .values({ id: newId(), ...normalized, category: scope, owner, created: new Date() })
107
+ .returning();
108
+ return created;
109
+ });
110
+ await notify(EVENTS.fieldCreated, { field, actor: owner });
111
+ return [null, field];
112
+ } catch(error) {
113
+ if(isUniqueViolation(error)) return [{ code: 409, msg: `A field with the key "${normalized.key}" already exists` }, null];
114
+ return [{ code: 500, msg: 'Failed to create field' }, null];
115
+ }
116
+ };
117
+
118
+ export const updateField = async (key, changes = {}, { owner = null, category = '' } = {}) => {
119
+ const [lookupError, existing] = await getField(key, category);
120
+ if(lookupError) return [lookupError, null];
121
+
122
+ const targetType = changes.type ?? existing.type;
123
+ if(!canConvert(existing.type, targetType)){
124
+ return [{ code: 409, msg: `A ${existing.type} field cannot be changed to ${targetType}. Delete it and create a new one instead` }, null];
125
+ }
126
+ const isOwner = existing.owner === (owner ?? '');
127
+ const allowed = isOwner ? OWNER_EDITABLE : ANYONE_EDITABLE;
128
+ const rejected = Object.keys(changes).filter(k => k !== 'key' && k !== 'category' && !(k === 'type' && targetType === existing.type) && !allowed.includes(k));
129
+ if(rejected.length){
130
+ return [{ code: 403, msg: `${rejected.join(', ')} can only be changed by ${existing.owner ? `the "${existing.owner}" extension` : 'the field\'s owner'}` }, null];
131
+ }
132
+
133
+ const [invalid, normalized] = normalizeFieldDefinition({ ...existing, ...changes, key: existing.key, type: targetType });
134
+ if(invalid) return [invalid, null];
135
+
136
+ const updates = {};
137
+ for(const property of allowed){
138
+ if(changes[property] !== undefined) updates[property] = normalized[property];
139
+ }
140
+ if(targetType !== existing.type){
141
+ /* Converting: the type changes, and what only made sense for the old type goes with it. */
142
+ updates.type = targetType;
143
+ updates.options = normalized.options;
144
+ updates.suggest = normalized.suggest;
145
+ }
146
+ if(!Object.keys(updates).length) return [{ code: 400, msg: 'No changes provided' }, null];
147
+
148
+ const refused = await guard(EVENTS.fieldBeforeUpdate, { field: existing, changes: updates, actor: owner ?? '' });
149
+ if(refused) return [refused, null];
150
+
151
+ try {
152
+ const [field] = await db.update(kempoInventoryField).set(updates)
153
+ .where(and(eq(kempoInventoryField.key, key), eq(kempoInventoryField.category, existing.category))).returning();
154
+ await notify(EVENTS.fieldUpdated, { field, previous: existing, actor: owner ?? '' });
155
+ return [null, field];
156
+ } catch {
157
+ return [{ code: 500, msg: 'Failed to update field' }, null];
158
+ }
159
+ };
160
+
161
+ /*
162
+ Deletes a field and the values items hold for it. A person using the admin deletes their own
163
+ fields (owner ''); an extension deletes the ones it registered.
164
+ */
165
+ export const deleteField = async (key, { owner = '', category = '' } = {}) => {
166
+ const [lookupError, existing] = await getField(key, category);
167
+ if(lookupError) return [lookupError, null];
168
+ if(existing.owner !== owner){
169
+ return [{ code: 403, msg: `This field is managed by the "${existing.owner}" extension and cannot be deleted here` }, null];
170
+ }
171
+ const refused = await guard(EVENTS.fieldBeforeDelete, { field: existing, actor: owner });
172
+ if(refused) return [refused, null];
173
+
174
+ try {
175
+ await db.transaction(async tx => {
176
+ await tx.update(kempoInventoryItem).set({ data: sql`${kempoInventoryItem.data} - ${key}::text` }).where(itemsInScope(existing.category));
177
+ await tx.delete(kempoInventoryField).where(and(eq(kempoInventoryField.key, key), eq(kempoInventoryField.category, existing.category)));
178
+ });
179
+ await notify(EVENTS.fieldDeleted, { field: existing, actor: owner });
180
+ return [null, { success: true }];
181
+ } catch {
182
+ return [{ code: 500, msg: 'Failed to delete field' }, null];
183
+ }
184
+ };
185
+
186
+ /*
187
+ Idempotent: safe to call from an extension's install.js and again on every update. Creates the
188
+ field if it is new, otherwise refreshes what the owner is allowed to change. Refuses to take
189
+ over a key another owner holds, or to change an existing field's type.
190
+
191
+ `definition.category` scopes the field to one category (see registerCategory); without it the
192
+ field is on every item.
193
+ */
194
+ export const registerField = async (owner, definition) => {
195
+ if(!owner) return [{ code: 400, msg: 'An owner (your extension name) is required' }, null];
196
+ const [invalid, normalized] = normalizeFieldDefinition(definition);
197
+ if(invalid) return [invalid, null];
198
+
199
+ const category = tidyCategory(definition?.category);
200
+ const [lookupError, existing] = await getField(normalized.key, category);
201
+ if(lookupError && lookupError.code !== 404) return [lookupError, null];
202
+ if(!existing) return createField({ ...normalized, category }, { owner });
203
+
204
+ if(existing.owner !== owner){
205
+ return [{ code: 409, msg: `The key "${normalized.key}" is already used by ${existing.owner ? `the "${existing.owner}" extension` : 'a user-defined field'}` }, null];
206
+ }
207
+ if(existing.type !== normalized.type){
208
+ return [{ code: 409, msg: `"${normalized.key}" already exists as a ${existing.type} field` }, null];
209
+ }
210
+ const { key, type, ...changes } = normalized;
211
+ return updateField(normalized.key, changes, { owner, category });
212
+ };
213
+
214
+ export const registerFields = async (owner, definitions = []) => {
215
+ const registered = [];
216
+ for(const definition of definitions){
217
+ const [error, field] = await registerField(owner, definition);
218
+ if(error) return [error, registered];
219
+ registered.push(field);
220
+ }
221
+ return [null, registered];
222
+ };
223
+
224
+ /*
225
+ For an extension's uninstall.js: removes every field it registered, and their values.
226
+ */
227
+ export const unregisterFields = async owner => {
228
+ if(!owner) return [{ code: 400, msg: 'An owner (your extension name) is required' }, null];
229
+ const [error, fields] = await getFields();
230
+ if(error) return [error, null];
231
+ let removed = 0;
232
+ for(const field of fields.filter(f => f.owner === owner)){
233
+ const [deleteError] = await deleteField(field.key, { owner, category: field.category });
234
+ if(deleteError) return [deleteError, null];
235
+ removed++;
236
+ }
237
+ return [null, { removed }];
238
+ };
@@ -0,0 +1,30 @@
1
+ import { triggerHook } from 'kempo/server/sdk.js';
2
+
3
+ import { EVENTS } from './events.js';
4
+
5
+ export { EVENTS };
6
+
7
+ /*
8
+ Resolves to null to proceed, or an `{ code, msg }` to stop. A handler that throws anything other
9
+ than `{ code, msg }` is logged and reported generically, so internals never reach the caller.
10
+ */
11
+ export const guard = async (event, data) => {
12
+ try {
13
+ await triggerHook(event, data, { bail: true });
14
+ return null;
15
+ } catch(error) {
16
+ if(error && Number.isInteger(error.code) && typeof error.msg === 'string'){
17
+ return { code: error.code, msg: error.msg };
18
+ }
19
+ console.error(`[kempo-inventory] "${event}" hook failed:`, error);
20
+ return { code: 500, msg: 'The change was rejected by an extension' };
21
+ }
22
+ };
23
+
24
+ export const notify = async (event, data) => {
25
+ try {
26
+ await triggerHook(event, data);
27
+ } catch(error) {
28
+ console.error(`[kempo-inventory] "${event}" notification failed:`, error);
29
+ }
30
+ };