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
package/README.md ADDED
@@ -0,0 +1,429 @@
1
+ # kempo-inventory
2
+
3
+ The core of an inventory system for [kempo](https://www.npmjs.com/package/kempo) CMS. It is deliberately small: every item has just a **SKU**, **name**, **quantity** and **description**. Everything else is a *custom field* that a person adds in the admin, or that another extension registers, so the same base can model anything: parts, books, rentals, supplies.
4
+
5
+ - Stock changes go through one audited action, with a reason, a note and a movement history
6
+ - Custom fields of seven types, managed in **Admin > Inventory > Fields**
7
+ - A server SDK and a set of hooks so other extensions can extend it (reorder levels, pricing, suppliers, categories...), and fields, categories and items can be owned by an extension
8
+ - Admin page at **Admin > Inventory**, JSON API under `/inventory/api`
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install kempo-inventory
14
+ ```
15
+
16
+ Then **Admin > Extensions > Install** next to `kempo-inventory`.
17
+
18
+ ## Custom fields
19
+
20
+ Users in the `kempo-inventory:admin` group add fields from **Inventory > Fields**. Each has a label, a key (derived from the label, fixed once created), a type, and whether it is required and shown in the item list.
21
+
22
+ | Type | Holds |
23
+ |---|---|
24
+ | `text` | Short text (up to 1,000 characters) |
25
+ | `longtext` | Longer text (up to 20,000 characters) |
26
+ | `number` | Any number |
27
+ | `boolean` | Yes / no |
28
+ | `date` | A date (`YYYY-MM-DD`) |
29
+ | `rating` | A whole number of stars, 1 to 5, chosen with Kempo UI's star rating (empty = no rating) |
30
+ | `color` | A hex colour (`#ff8800`, or `#ff880080` when see-through), chosen with Kempo UI's colour picker. Optional like any field: clear it to have none |
31
+ | `select` | One of a list of choices you define |
32
+ | `item` | A link to another item, for things that are related in some way |
33
+ | `media` | Photos and other files, up to 20 per field. Only available when [kempo-media](#photos-and-files-optional-kempo-media) is installed |
34
+
35
+ Text is stored **without leading or trailing spaces**: names, SKUs, descriptions and every text, long-text and choice field value are trimmed (by the API, so the forms, imports and scripts all get it), and a value of only spaces counts as empty (a required field with only spaces is missing). That keeps `FolkArt` and `FolkArt ` from becoming two brands. `update.js` 0.14 trimmed what was already stored, and suggestions ignore stray spaces in old data.
36
+
37
+ A `text` field can also **suggest values already used**: switch on "Suggest values already used" in the field editor and, as you type, the form offers matching values from other items (and the most used ones the moment the field is focused). That keeps repeated entries such as a brand consistent (`vallejo` and `Vallejo` count as one suggestion, shown the way it is most often written). It is only a suggestion: anything new can still be typed.
38
+
39
+ The item list, its filters and the item form are all built from the field list. Every `select` field gets a filter, and search covers custom values too. A field's type can only be changed where nothing can be lost: a choice list can become free text (its values are already plain strings) and short text can become long text. Anything else is refused, because existing values could be orphaned; delete the field and create a new one instead. The key never changes. Deleting a field removes its value from every item.
40
+
41
+ ### Fields for one category
42
+
43
+ A field applies either to **every item** (the default) or to **one category**. Make a paint's "Finish" (Matte, Satin, Gloss) for the Paint category and it only appears on items whose category is Paint; filament can have its own "Diameter" and nothing else sees it. Choose what a field **Applies to** when you create it (**Inventory > Categories** has a gear button per category that opens its fields). The item form shows the default fields plus those of the typed category, and swaps them as the category changes.
44
+
45
+ - A key is unique within its category, so two categories can each have a "finish". A category cannot reuse a key a default field has (and the other way round), because both would be stored under the same name on its items.
46
+ - Moving an item to another category keeps the values it held for fields it no longer shows. They are hidden, not deleted, and come back if the item returns.
47
+ - Renaming a category takes its fields with it. Merging into a category that has a field with the same key is refused, and a category that still has fields cannot be deleted. Deleting a field removes its values only from that category's items.
48
+ - A field is identified by its key and category, so the API takes `?category=` to find a category's field.
49
+
50
+ A field is either **custom** (created in the admin; you manage it) or **owned by an extension** (registered through the SDK; only that extension can delete it or change what it holds, though you can rename it and choose whether it shows in the list).
51
+
52
+ ## Categories
53
+
54
+ `category` is a built-in, optional property of every item, like `sku`. It is free text (up to 100 characters) and the item form suggests categories already in use. Categories are not a list you maintain: they are the distinct values in use, matched ignoring case and spacing (`Paint`, `paint` and ` Paint ` are one category). What the extension adds is a small record per category holding an **image** and a **description** (up to 500 characters).
55
+
56
+ Manage them in **Admin > Inventory > Categories**: create one before any item uses it, edit its description and image, rename it (the items change too, and renaming onto an existing name merges the two), or delete it. A category that items still use cannot be deleted.
57
+
58
+ ```js
59
+ import { getCategories, saveCategory, deleteCategory } from 'kempo-inventory/sdk'; // server
60
+ import { getCategories, saveCategory, deleteCategory } from '/inventory/sdk.js'; // browser
61
+
62
+ const [, data] = await getCategories({ includeEmpty: true });
63
+ // { uncategorised: 2, categories: [{ name: 'Paint', key: 'paint', count: 7, description, image: { path, thumbnail, ... } | null }] }
64
+
65
+ await saveCategory('Paint', { description: 'Hobby paints' }); // only what you pass changes
66
+ await saveCategory('Paint', { image: 'files:0123456789abcdef' }); // a stored media id; null clears it
67
+ await saveCategory('Paint', { newName: 'Paints' }); // renames and updates the items
68
+ await deleteCategory('Paint'); // refused (409) while items use it
69
+ ```
70
+
71
+ By default only categories some item has are returned; `includeEmpty` also returns ones that exist only as a record. Images are stored media ids, so they need kempo-media or kempo-files. Fires `kempo-inventory:category:updated` and `kempo-inventory:category:deleted`.
72
+
73
+ ## Forcing a photo's shape and size
74
+
75
+ A photo field (type "Photos / files") can **force how photos are saved**. When you create or edit one, set:
76
+
77
+ - **Force photo shape**: an aspect ratio such as `1:1`, `4:3` or `16:9`. Each photo added to the field opens in Kempo UI's `<k-image-crop>`, locked to that shape, so only that shape can be saved; **Skip** leaves a photo out.
78
+ - **Max size (px)**: photos are kept within this many pixels on a side (scaled down, never up). With only a max size and no shape, photos are simply scaled down, with no cropper.
79
+
80
+ Leave both empty and photos are uploaded as they are. The stored file is the cropped one, so the shape holds everywhere it is shown. A category's picture is always cropped to the `category_image_ratio` setting below. Anything the browser cannot edit (GIF, SVG, HEIC, video) is uploaded untouched. The same two properties (`imageRatio`, `imageMax`) are on the field API, and `<inventory-media-field crop-ratio="1 / 1" crop-max="1600">` applies them in your own forms. Needs kempo-ui 0.5 or newer.
81
+
82
+ ## Image shape
83
+
84
+ Two settings (**Admin > Settings**) set the aspect ratio images are *shown* at (a display crop; a photo field's forced shape above changes what is stored): `category_image_ratio` and `item_image_ratio`, written `width:height` (`4:3` by default, or `1:1`, `16:9`...). A blank or invalid value falls back to `4:3`. Images are **cropped to fit** the shape, never stretched, and the originals are not changed, so changing the setting reshapes every image at once. The admin's category and item pickers use them, and `GET /inventory/api/fields` returns them as `imageRatios` (CSS, e.g. `"4 / 3"`) so a site can do the same; `<inventory-media-field ratio-for="category">` previews at the category shape.
85
+
86
+ ## Import and export
87
+
88
+ **Admin > Inventory** has **Export** and **Import** buttons (admin portal only; the public site has none). Both are limited to the **top tier**: the `kempo-inventory:fields:manage` permission, held by the `kempo-inventory:admin` group. A manager, a viewer or a signed-out visitor gets a 403 (or 401) and does not see the buttons, because an export carries the whole inventory out and an import can rewrite and re-stock all of it.
89
+
90
+ **Export** downloads the whole inventory in one of two forms:
91
+
92
+ - **Data only** (`.json`): every item (SKU, name, category, quantity, tags, description, custom field values) and category (description, tags), plus a list of the field definitions for reference. No photos.
93
+ - **With media** (`.zip`): the same document as `inventory.json` plus every photo and file the items and categories use, under `media/`. Importing it brings the photos back.
94
+
95
+ **Import** takes either one (or any zip with an `inventory.json` in this format, including one re-zipped with compression). It is **done on the server**, in two steps:
96
+
97
+ 1. **Check.** The file is uploaded once and checked. You see how many items are new, how many already exist (matched by **SKU**; categories by name), what would be ignored, and any rows that cannot be imported and why. Nothing in the inventory has changed yet.
98
+ 2. **Import.** For the ones that already exist you choose **Ignore them** (leave what is here exactly as it is; the default) or **Update them** with the file's values (a value the file leaves empty never clears anything, and a changed quantity is recorded as a stock adjustment, "import", so the history stays honest).
99
+
100
+ Things worth knowing: an import never creates or changes **fields**; a value only comes across when a field with the same key exists and applies to the item's category (the rest are ignored and counted). Rows are independent: one that fails (a missing name, a bad number, a required field with no value) does not stop the others. A field linking to another item is exported as that item's SKU and resolved by SKU on import, including to items created in the same import. Photos only come from the zip; each distinct photo is stored once, to the library new uploads normally go to (as the person importing, with its own file-library rules), at its original size.
101
+
102
+ ### How an uploaded file is handled (security)
103
+
104
+ - **Permission first.** Both import routes check `fields:manage` before they read, parse or store anything from the request. Anyone else gets 401/403 and the upload is never looked at. (kempo-server itself has already received the body by the time any route runs, on every route of the site, up to its `maxBodySize`; lower that in the site's kempo-server config to bound it, and see the `import_max_mb` setting below.)
105
+ - **Staging.** The checked upload is kept under a random 128-bit name in the system temp directory, mode 0600, never served, usable only by the person who uploaded it, and deleted when the import runs, when they stage another, or after 30 minutes.
106
+ - **Zips are opened in memory only.** A zip is recognised by its first bytes, not its name. Entries are looked up by the exact names `inventory.json` asks for (`media/<name>`, no folders, no `..`, no slashes), never written to disk by their names, and at most 20,000 files may be in a zip, 200 MB for one file to inflate to (so a small "zip bomb" cannot exhaust the server), 5,000 photos and 1 GB of photos per import.
107
+ - **Only real media.** A file is stored only if its extension is a photo, video, audio or PDF type (png, jpg, gif, webp, avif, bmp, mp4, webm, mov, mp3, wav, ogg, m4a, pdf) **and** its first bytes really are that type: a web page renamed `.png` is refused. Everything else is skipped and listed in the result. Stored files then go through the normal library (kempo-files stores them as untrusted, never executed in the browser).
108
+ - **Text is data.** The text in the JSON is stored as data and escaped wherever it is shown; an import cannot run anything.
109
+
110
+ The `import_max_mb` setting (**Admin > Settings**, 250 by default) is the largest file the import accepts.
111
+
112
+ For scripts: `GET /inventory/api/export?format=json|zip`; and for an import, `POST /inventory/api/import/preview` with the file as the raw request body (`Content-Type: application/octet-stream`) returns the report and an `importId`, then `POST /inventory/api/import/apply` with `{ importId, onMatch: "skip" | "update" }` does it. The server SDK has `buildExport`, `importData`, `previewImport` and `applyImport`, the browser SDK `exportUrl`, `previewImport` and `applyImport`.
113
+
114
+ ## Tags
115
+
116
+ Items and categories can be **tagged**: short labels such as `acrylic`, `red`, `matte`, `water-based`. Tags are built in (like the category), **always lower case**, with spacing tidied (`Water-Based` and ` water-based ` are one tag): the API lower-cases whatever it is given, the item and category forms convert what you type as you type it (and pasted lists), tags show in lower case everywhere, and every search for a tag (`q`, `?tag=`, the site's search and filters) ignores case. `update.js` 0.13 lower-cased the tags that already existed, de-duplicating the ones that collapse together, at most 30 per item or category and 40 characters each. In the item and category forms they use Kempo UI's `<k-tags>`: type a tag and press Enter, Tab or comma to add it (leaving the box does not add it, but saving the form does), click a chip to remove it, and tags already used are completed as you type.
117
+
118
+ - **Search:** the `q` search of items matches tags, and categories can be searched by name, description or tag: `GET /inventory/api/categories?q=acrylic`.
119
+ - **Exact filter:** `?tag=red` returns items with that tag (categories too, on `/categories`); repeat it as a JSON list, `?tag=["red","matte"]`, to require all. Combine with `q`.
120
+ - **Writing:** `tags` (a list, or text such as `"acrylic, red"`) on `POST`/`PATCH /items` and `PUT /categories`; leaving it out leaves the tags alone. Merging categories unites their tags.
121
+ - **All tags:** `GET /inventory/api/tags` (`?q=` narrows it) returns `[{ tag, count }]` for items and categories together, most used first; the SDK has `getTags()`. Anyone who can read items can read it.
122
+ - **Inheritance:** an item does not inherit its category's tags. Searching a category's tag finds the category, and the category is how you reach its items.
123
+ - The admin lists show a Tags column. To reuse the control in your own forms, `ItemFields.js` exports `tagsValue(tags)`, `suggestTags` and `readTags($tags)` for use with `<k-tags .value=${tagsValue(tags)} .getSuggestions=${suggestTags}>`; `readTags` includes text typed but not yet added.
124
+
125
+ ## Who can read, and who can see stock counts
126
+
127
+ Reading the inventory needs the `items:read` permission. Turn on the `public_read` setting (off by default) and **anyone can read it, signed in or not**, so it can be shared as a catalogue. Public reading covers items, categories and fields (and photos, see below); writing always needs the matching permission.
128
+
129
+ **Stock counts are only for people who manage stock.** A caller without `stock:adjust` or `items:update`, whether signed out, signed in with only `items:read`, or reading publicly, gets items **without `quantity`** and no stock history (`GET /inventory/api/movements` is refused). Callers who do manage stock get everything as before.
130
+
131
+ ## Who can see photos
132
+
133
+ kempo-files only serves a file to people with its `files:download` permission, which someone who can merely *read* the inventory does not have, so their photos would be broken images. The `public_photos` setting (on by default) uploads photos to kempo-files as **public**: viewable by anyone who has the link (ids are unguessable), which includes every inventory reader. Turn it off to keep photos behind `files:download`; then give your readers that permission. Changing the setting only affects new uploads. `update.js` also marks the photos items and categories already use (and their thumbnails) public when the setting is on. kempo-media files are ordinary static files and are always viewable.
134
+
135
+ ## Photos and files (optional)
136
+
137
+ Three optional extensions can take part. Inventory never imports any of them up front, each is detected at runtime, and with none installed nothing else changes: the `media` field type is simply not offered, and creating one is refused with a clear message.
138
+
139
+ | Extension | Role |
140
+ |---|---|
141
+ | [kempo-media](https://github.com/dustinpoissant/kempo-media) | A media library with its own image thumbnails. Files are served from `public/media/`, so anyone who knows a URL can fetch them |
142
+ | [kempo-files](https://github.com/dustinpoissant/kempo-files) | A private file library: files live outside `public/` and every download is permission-checked |
143
+ | [kempo-thumbs](https://github.com/dustinpoissant/kempo-thumbs) | Generates thumbnails for kempo-files, including frames from video and audio. It only works on kempo-files |
144
+
145
+ When an item has more than one photo, the first is its **primary** photo (the one shown on cards and lists). In the item form each other photo has a **Make primary** button that moves it to the front.
146
+
147
+ Add a field of type "Photos / files" and every item gets an upload area. New uploads go to one library, chosen by the `media_provider` setting:
148
+
149
+ | Setting | Uploads go to |
150
+ |---|---|
151
+ | `auto` (default) | kempo-files when kempo-thumbs is also installed, otherwise kempo-media, otherwise kempo-files |
152
+ | `kempo-media` | kempo-media (if installed) |
153
+ | `kempo-files` | kempo-files (if installed) |
154
+
155
+ A stored value records where its file lives, so files from both libraries keep working on the same item whichever one takes new uploads. The API returns a `media` map (stored id → `{ kind, name, alt, path, thumbnail }`) next to `linked`, so a client can show them. A kempo-files photo has no `thumbnail` until kempo-thumbs has generated one, so clients should fall back to `path`.
156
+
157
+ - If a library is disabled later, items keep their stored ids and everything else keeps working. Files from that library show as missing, and only *adding* a file from it is refused; an item can still be edited with its existing list unchanged.
158
+ - Deleting a file from its library leaves the id on the item, shown as a missing file.
159
+ - **Permissions:** the inventory groups do not grant media permissions, since they belong to the optional extensions. Uploading needs `media:upload` (kempo-media) or `files:upload` (kempo-files). With kempo-files, *viewing* a photo needs `files:download` too, so give read-only users a group that has it.
160
+
161
+ ## Barcode scanning
162
+
163
+ The item form has a **Scan** button beside the SKU: point the camera at a barcode and the SKU is filled in, with a warning if another item already uses it. The scanner is a reusable module too:
164
+
165
+ ```javascript
166
+ import { scanBarcode } from '/inventory/components/BarcodeScanner.js';
167
+
168
+ const code = await scanBarcode(); // the code's text, or null if cancelled
169
+ ```
170
+
171
+ For a scanner that stays on screen and keeps going (a "scan page"), use `startScanner` with your own `<video>`:
172
+
173
+ ```javascript
174
+ import { startScanner } from '/inventory/components/BarcodeScanner.js';
175
+
176
+ const scanner = await startScanner({ video: $video, onCode: code => lookUp(code) });
177
+ scanner.setPaused(true); // ignore codes while a dialog is open
178
+ scanner.stop(); // release the camera
179
+ ```
180
+
181
+ `onCode` fires once each time a code comes into view. The same code is not reported again until it has left the frame for a moment, so holding a bottle still gives one scan while scanning the next bottle of the same paint gives another. It rejects if the camera can't be started (`cameraProblem()` and `explainCameraError()` turn that into a sentence for the user).
182
+
183
+ - It uses the browser's own `BarcodeDetector` where it exists (Chrome on Android) and otherwise falls back to [ZXing](https://github.com/zxing-js/library), so it also works on iPhone and desktops. The fallback is bundled in `public/vendor/` (see `LICENSES.md` there), so nothing is loaded from a CDN.
184
+ - Reads EAN-13/8, UPC-A/E, Code 128/39/93, ITF, Codabar, QR, Data Matrix, PDF417 and Aztec.
185
+ - **Cameras only work on HTTPS pages** (and `localhost`). On a plain-http address the dialog says so rather than failing silently.
186
+ - The camera is switched off as soon as a code is read or the dialog is closed.
187
+ - A USB or Bluetooth barcode scanner needs no camera: it types the code into the SKU box and presses Enter. In the item form Enter in the SKU box moves to the name instead of submitting the form.
188
+ - `openItemDialog({ defaults: { sku } })` opens the new-item form with a SKU already filled in, which is how a site can offer "no item has this barcode, create it?".
189
+
190
+ ## UI building blocks for sites
191
+
192
+ This extension ships the data and the API, plus an admin screen for managing it. A site that wants its own screens (a dashboard, product pages, a storefront) builds them itself, and can reuse the same browser modules the admin page uses:
193
+
194
+ ```javascript
195
+ import { getItems, getFields, adjustStock } from '/inventory/sdk.js'; // the API client
196
+ import { openItemDialog, openAdjustDialog } from '/inventory/components/ItemDialog.js';
197
+ import { fieldTemplate, readField, displayValue } from '/inventory/components/ItemFields.js';
198
+
199
+ openItemDialog({ item, fields, onSaved: item => reload() }); // the form in a dialog, for a screen that really wants one
200
+ openAdjustDialog({ item, onSaved: item => reload() }); // add or remove stock
201
+ ```
202
+
203
+ **Adding and editing an item are pages, not dialogs.** A dialog closes with a stray tap outside it and takes everything entered with it (photos taken with the camera included), so each gets a page of its own that keeps the work and warns before it is left. In the admin they are **Inventory > New Item** (`/admin/extension/kempo-inventory/new`) and the edit action on a row (`/admin/extension/kempo-inventory/edit?id=…`). A site gets the same from `NewItemPage.js`:
204
+
205
+ ```javascript
206
+ import { mountNewItemPage, safeNext } from '/inventory/components/NewItemPage.js';
207
+ import { showFlash } from '/inventory/components/flash.js';
208
+
209
+ mountNewItemPage({ $container, fields, defaults: { category: 'Paint' }, askQuantity: false, next: '/category?name=Paint' });
210
+ mountEditItemPage({ $container, item, fields, context: { linked, media, ownId: item.id }, next: '/items/' + item.id }); // item, linked, media from getItem()
211
+ showFlash(); // on the page it goes back to: shows "Added <name>" or "Saved <name>" once
212
+ ```
213
+
214
+ The new-item page has **Create item**, **Create and add another** and **Cancel**; the edit page has **Save** and **Cancel**. While anything entered has not been saved, leaving the page (a link, the back button, a refresh, closing the tab) triggers the browser's own "changes may not be saved" prompt, and Cancel asks before discarding. `window.hasUnsavedInventoryWork()` tells anything else that wants to move the page (an offline redirect, say) whether to hold back. `next` must be a path on the same site: `safeNext(value, fallback)` refuses anything else. The form itself is `createItemForm` (`ItemForm.js`: `{ $root, save(), isDirty() }`), which the pages and the dialog all use. (`openItemDialog` is still there for a screen that wants a dialog; it does not close when you click outside it.)
215
+
216
+ `openItemDialog` builds its form from the field list, so custom fields, item links and photo uploads work without any site code. `fieldTemplate` / `readField` render and read a single field's input if you want your own layout, and `displayValue` gives a plain-text rendering safe to put in a table cell.
217
+
218
+ ## Building on this extension
219
+
220
+ An extension that adds to inventory declares the dependency, registers its fields, and cleans up after itself:
221
+
222
+ ```json
223
+ // kempo-config.json
224
+ {
225
+ "dependencies": ["kempo-inventory"],
226
+ "hooks": {
227
+ "kempo-inventory:item:before_create": "./hooks/before-create.js",
228
+ "kempo-inventory:stock:adjusted": "./hooks/stock-adjusted.js"
229
+ }
230
+ }
231
+ ```
232
+
233
+ ```javascript
234
+ // install.js
235
+ import { registerFields } from 'kempo-inventory/sdk';
236
+
237
+ export default async () => {
238
+ const [error] = await registerFields('kempo-inventory-reorder', [
239
+ { key: 'reorderLevel', label: 'Reorder level', type: 'number' },
240
+ { key: 'supplier', label: 'Supplier', type: 'item' },
241
+ ]);
242
+ if(error) throw new Error(error.msg);
243
+ };
244
+ ```
245
+
246
+ ```javascript
247
+ // uninstall.js
248
+ import { unregisterFields } from 'kempo-inventory/sdk';
249
+
250
+ export default async () => { await unregisterFields('kempo-inventory-reorder'); };
251
+ ```
252
+
253
+ `registerFields` is idempotent, so it is safe to call again on update. It refuses to take a key another owner holds, or to change an existing field's type. Kempo stops a dependency being uninstalled while something that depends on it is enabled.
254
+
255
+ ### Ownership
256
+
257
+ Fields, categories and items can each belong to an extension. `owner` is `''` for what people manage in the admin, or the name of the extension that created it. Ownership protects what the owner builds on, and leaves the rest alone so its items stay useful in the admin:
258
+
259
+ | | Only the owner can | Anyone with the permission can still |
260
+ |---|---|---|
261
+ | **Field** | delete it, change its key, type (within the safe conversions), requirement or options | rename it, change its description, list/suggest settings and position |
262
+ | **Category** | rename it (which rewrites its items), merge into or out of it, delete it | change its image, description and tags, and put items in it |
263
+ | **Item** | change its SKU, name and category, delete it | edit description, tags and custom field values, and **adjust stock** |
264
+
265
+ A category cannot be renamed or merged by anyone but its owner while it holds items or fields another extension owns, because that would rewrite them. Sending an owned item's current SKU, name and category back unchanged is not a change, so the admin form keeps working.
266
+
267
+ Ownership only comes from the server SDK. The HTTP routes never read an owner from a request, so the admin and the JSON API always act as people (`''`), and an extension cannot be impersonated from the browser. An extension calling the SDK passes its own name as `owner` to act as the owner; leave it out and it is treated as people, so it cannot rename or delete another extension's records, or even one a person made.
268
+
269
+ ```javascript
270
+ // install.js
271
+ import { registerCategory, registerField } from 'kempo-inventory/sdk';
272
+
273
+ export default async () => {
274
+ await registerCategory('kempo-products', { name: 'Finished goods', description: 'Ready to ship' });
275
+ await registerField('kempo-products', { key: 'price', label: 'Price', type: 'number', category: 'Finished goods' });
276
+ };
277
+
278
+ // at runtime
279
+ import { createItem, updateItem, getItems } from 'kempo-inventory/sdk';
280
+ const [, item] = await createItem({ sku: 'CAR-1', name: 'Model car', category: 'Finished goods', quantity: 2 }, { owner: 'kempo-products' });
281
+ await updateItem(item.id, { name: 'Model car v2' }, { owner: 'kempo-products' });
282
+ const [, mine] = await getItems({ owner: 'kempo-products' }); // also: getItems({ ids: [...] }), getFields({ owner }), getCategories({ owner })
283
+ ```
284
+
285
+ `registerCategory(owner, { name, description, tags, image }, { adopt })` is idempotent. It refuses a category people already manage (409) unless `adopt` is set, which takes the record over; the items in it stay people's. `registerField` takes `category` to scope the field to one category. An owner cannot be changed after creation.
286
+
287
+ For uninstall.js, remove in this order:
288
+
289
+ ```javascript
290
+ import { unregisterItems, unregisterFields, unregisterCategories } from 'kempo-inventory/sdk';
291
+
292
+ export default async () => {
293
+ await unregisterItems('kempo-products', { release: true }); // or omit `release` to delete them and their history
294
+ await unregisterFields('kempo-products');
295
+ await unregisterCategories('kempo-products'); // ones still in use are handed back to people, not deleted
296
+ };
297
+ ```
298
+
299
+ To protect more than this (say, the *values* of a field you own), subscribe to the `before_*` hooks: every payload carries `actor`, the extension making the change or `''` for a person.
300
+
301
+ ### Server SDK
302
+
303
+ ```javascript
304
+ import { getItems, createItem, adjustStock, registerFields } from 'kempo-inventory/sdk';
305
+ ```
306
+
307
+ Every function resolves to `[error, result]`, where `error` is `{ code, msg }` or `null`.
308
+
309
+ | Function | Purpose |
310
+ |---|---|
311
+ | `getItems({ q, filters, ids, owner, limit, offset })` | List and search. `filters` is `{ fieldKey: 'exact value' }`, `ids` fetches exactly those items, `owner` keeps one owner's. Returns `{ items, total, linked }` |
312
+ | `getItem(id)`, `getItemBySku(sku)` | One item |
313
+ | `getLinked(items)` | Resolves `item`-type values to `{ id, sku, name }` |
314
+ | `createItem({ sku, name, description, quantity, fields, userId }, { owner })` | Create. `owner` makes it your extension's |
315
+ | `updateItem(id, { sku, name, description, fields, userId }, { owner })` | Update; `fields` is merged, and an empty value clears a field. SKU, name and category of an owned item need its `owner` |
316
+ | `deleteItem(id, { userId, owner })` | Delete an item and its history (owned items need their `owner`) |
317
+ | `adjustStock(id, { delta, reason, note, userId })` | The only way quantity changes, for anyone. Atomic; refuses to go below zero |
318
+ | `unregisterItems(owner, { release })` | Delete every item you own, or with `release` hand them back to people |
319
+ | `getMovements({ itemId, limit, offset })` | Stock history |
320
+ | `getFields({ category, owner })`, `getField(key, category)` | Read field definitions. `getFields()` is every field (each with its `category` key and `categoryName`); with `category` it is what an item in that category shows |
321
+ | `createField(definition)`, `updateField(key, changes)`, `deleteField(key)` | Manage user-defined fields |
322
+ | `getCategories({ includeEmpty, owner })`, `saveCategory(name, { newName, image, description, tags }, { owner })`, `deleteCategory(name, { owner })` | The categories, and managing them |
323
+ | `registerCategory(owner, { name, description, tags, image }, { adopt })`, `unregisterCategories(owner)` | Add categories owned by your extension, and remove them |
324
+ | `getSuggestions(key, { q, limit, category })` | Values already used for a text field, ranked for what has been typed |
325
+ | `registerField(owner, definition)`, `registerFields(owner, definitions)` | Add fields owned by your extension; `definition.category` scopes one to a category |
326
+ | `unregisterFields(owner)` | Remove every field your extension owns, and their values |
327
+ | `FIELD_TYPES`, `EVENTS` | The field types and the hook event names |
328
+
329
+ An item looks like `{ id, sku, name, description, category, tags, quantity, fields: { ...custom values }, owner, created, updated }`.
330
+
331
+ ### Hooks
332
+
333
+ Declare these in your `kempo-config.json` `hooks`. Every payload also carries `actor`: the extension that made the change through the SDK, or `''` for a person using the admin.
334
+
335
+ **Guards** run before the work and can change it or refuse it. The payload holds a mutable object; edit it in place to change what gets saved, or `throw { code, msg }` to refuse, and the caller receives exactly that error. Everything is validated *after* guards run, so a guard cannot sneak in a value that would not otherwise be accepted. A guard that throws anything other than `{ code, msg }` is logged and reported to the user generically.
336
+
337
+ | Event | Payload | Edit / refuse |
338
+ |---|---|---|
339
+ | `kempo-inventory:item:before_create` | `{ draft: { sku, name, description, quantity, fields }, userId }` | edit `draft` |
340
+ | `kempo-inventory:item:before_update` | `{ item, changes: { sku?, name?, description?, fields? }, userId }` | edit `changes` |
341
+ | `kempo-inventory:item:before_delete` | `{ item, userId }` | refuse only |
342
+ | `kempo-inventory:stock:before_adjust` | `{ item, draft: { delta, reason, note }, userId }` | edit `draft` |
343
+ | `kempo-inventory:category:before_update` | `{ name, newName, changes: { description?, tags?, image? }, userId }` | refuse only |
344
+ | `kempo-inventory:category:before_delete` | `{ category, name, userId }` (`category` is `null` for one with no record) | refuse only |
345
+ | `kempo-inventory:field:before_create` | `{ draft }` | refuse only |
346
+ | `kempo-inventory:field:before_update` | `{ field, changes }` | refuse only |
347
+ | `kempo-inventory:field:before_delete` | `{ field }` | refuse only |
348
+
349
+ Ownership is checked first, so these only run for changes that would otherwise go ahead.
350
+
351
+ **Notifications** run after the change is committed; nothing they do can undo it, and an error in one is logged without affecting the others.
352
+
353
+ | Event | Payload |
354
+ |---|---|
355
+ | `kempo-inventory:item:created` | `{ item, userId }` |
356
+ | `kempo-inventory:item:updated` | `{ item, previous, userId }` |
357
+ | `kempo-inventory:item:deleted` | `{ item, userId }` |
358
+ | `kempo-inventory:stock:adjusted` | `{ item, previousQuantity, delta, reason, note, movement, userId }` |
359
+ | `kempo-inventory:category:updated` | `{ category, previous, moved, userId }` |
360
+ | `kempo-inventory:category:deleted` | `{ category, userId }` |
361
+ | `kempo-inventory:field:created` | `{ field }` |
362
+ | `kempo-inventory:field:updated` | `{ field, previous }` |
363
+ | `kempo-inventory:field:deleted` | `{ field }` |
364
+
365
+ For example, a low-stock check that reacts to every adjustment:
366
+
367
+ ```javascript
368
+ // hooks/stock-adjusted.js
369
+ export default async ({ item }) => {
370
+ const level = item.fields.reorderLevel;
371
+ if(level !== undefined && item.quantity <= level){
372
+ // notify someone, create a purchase order, ...
373
+ }
374
+ };
375
+ ```
376
+
377
+ Hooks run one at a time, in registration order, and are awaited, so a slow handler delays the request that triggered it.
378
+
379
+ ## Permissions and groups
380
+
381
+ | Permission | Allows |
382
+ |---|---|
383
+ | `kempo-inventory:items:read` | View items, fields and movements |
384
+ | `kempo-inventory:items:create` | Add items |
385
+ | `kempo-inventory:items:update` | Edit items |
386
+ | `kempo-inventory:items:delete` | Delete items (and their history) |
387
+ | `kempo-inventory:stock:adjust` | Add or remove stock |
388
+ | `kempo-inventory:fields:manage` | Create, edit and delete custom fields |
389
+
390
+ Groups, each including everything in the one before it:
391
+
392
+ | Group | Can |
393
+ |---|---|
394
+ | `kempo-inventory:viewer` | View items, fields and movements |
395
+ | `kempo-inventory:manager` | Also create, edit and delete items and adjust stock |
396
+ | `kempo-inventory:admin` | Also set up the custom fields (`fields:manage`) |
397
+
398
+ Every group also includes `system:admin:access`, because kempo serves extension pages inside the admin panel and redirects anyone without it. That only opens the panel itself: its other sections stay locked behind their own permissions.
399
+
400
+ ## API
401
+
402
+ All routes require a session holding the permission above.
403
+
404
+ | Route | Purpose |
405
+ |---|---|
406
+ | `GET /inventory/api/items?q=&filters={"key":"value"}&owner=&limit=&offset=` | List and search; `owner` keeps one extension's items. Returns `{ items, total, linked, media }` |
407
+ | `POST /inventory/api/items` | Create `{ sku, name, description?, quantity?, fields? }` |
408
+ | `GET /inventory/api/items/[id]` | Item, recent movements and linked items |
409
+ | `PATCH /inventory/api/items/[id]` | Update `{ sku?, name?, description?, fields? }` (not quantity) |
410
+ | `DELETE /inventory/api/items/[id]` | Delete |
411
+ | `POST /inventory/api/items/[id]/adjust` | `{ delta, reason?, note? }` |
412
+ | `GET /inventory/api/movements?itemId=` | Movement history |
413
+ | `GET /inventory/api/categories` | The categories: `{ categories: [{ name, key, count, fields, image, description }], uncategorised, defaultFields }`; `?includeEmpty=true` includes ones no item uses |
414
+ | `PUT /inventory/api/categories` | Create or update `{ name, newName?, image?, description? }`; only the keys sent change (needs `items:update`) |
415
+ | `DELETE /inventory/api/categories` | Delete `{ name }`; 409 while items use it (needs `items:update`) |
416
+ | `GET /inventory/api/fields` | Field definitions, each with its `category` (`''` = every item); `?category=Paint` returns what an item in that category shows |
417
+ | `POST /inventory/api/fields` | Create `{ label, type, key?, description?, required?, listed?, options?, position?, category? }`; `category` makes it apply to that category only |
418
+ | `PATCH /inventory/api/fields/[key]?category=` | Update a field (`category` identifies a category's field) |
419
+ | `DELETE /inventory/api/fields/[key]?category=` | Delete a user-defined field |
420
+ | `GET /inventory/api/fields/[key]/suggestions?q=&limit=&category=` | Values already used for a text field, most relevant first: `[{ value, uses }]`. Matches are ranked prefix first, then by use. With no `q`, the most used values |
421
+
422
+ ## Notes
423
+
424
+ - Adding a column to an existing table is done in `update.js`, because kempo only creates tables that are new. Version 0.2 adds `suggest` to the field table, 0.3 adds the category table, 0.4 makes `category` a built-in column (moving values from an old custom `category` field) and 0.5 adds category descriptions and 0.11 lets photo fields force a shape and size, 0.10 adds tags to items and categories, 0.14 trims stored text, 0.13 lower-cases every stored tag, 0.12 adds the `import_max_mb` setting, 0.9 adds the `public_read` setting, 0.8 makes photos public by default (the `public_photos` setting), 0.7 adds the image ratio settings and 0.6 lets a field belong to one category (the field key index becomes unique per category).
425
+ - Kempo's installer ignores indexes declared in the schema, so `install.js` creates the unique SKU and field-key indexes itself.
426
+ - Version 0.15 adds `owner` to items and categories (existing ones stay people-managed).
427
+ - Uninstalling drops the item, field and movement tables, including your data.
428
+ - Custom values are stored in a single `jsonb` column, so adding a field never alters the schema.
429
+ - `item` links store the linked item's id. Deleting an item leaves any links to it in place, shown as "(deleted item)".