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.
- package/README.md +429 -0
- package/admin/categories/index.page.html +229 -0
- package/admin/edit/index.page.html +51 -0
- package/admin/fields/index.page.html +300 -0
- package/admin/index.page.html +203 -0
- package/admin/nav.global.html +3 -0
- package/admin/new/index.page.html +43 -0
- package/install.js +19 -0
- package/kempo-config.json +103 -0
- package/package.json +59 -0
- package/public/api/categories/DELETE.js +12 -0
- package/public/api/categories/GET.js +12 -0
- package/public/api/categories/PUT.js +20 -0
- package/public/api/export/GET.js +31 -0
- package/public/api/fields/GET.js +23 -0
- package/public/api/fields/POST.js +12 -0
- package/public/api/fields/[key]/DELETE.js +11 -0
- package/public/api/fields/[key]/PATCH.js +16 -0
- package/public/api/fields/[key]/suggestions/GET.js +12 -0
- package/public/api/import/apply/POST.js +25 -0
- package/public/api/import/preview/POST.js +30 -0
- package/public/api/items/GET.js +35 -0
- package/public/api/items/POST.js +12 -0
- package/public/api/items/[id]/DELETE.js +11 -0
- package/public/api/items/[id]/GET.js +15 -0
- package/public/api/items/[id]/PATCH.js +12 -0
- package/public/api/items/[id]/adjust/POST.js +12 -0
- package/public/api/movements/GET.js +17 -0
- package/public/api/tags/GET.js +12 -0
- package/public/components/BarcodeScanner.js +198 -0
- package/public/components/ImageCropDialog.js +94 -0
- package/public/components/ImportExport.js +194 -0
- package/public/components/ItemDialog.js +135 -0
- package/public/components/ItemFields.js +441 -0
- package/public/components/ItemForm.js +186 -0
- package/public/components/NewItemPage.js +92 -0
- package/public/components/RowAction.js +34 -0
- package/public/components/flash.js +20 -0
- package/public/sdk.js +96 -0
- package/public/vendor/LICENSES.md +12 -0
- package/public/vendor/ts-custom-error.js +8 -0
- package/public/vendor/zxing-browser.js +8 -0
- package/public/vendor/zxing-library.js +27 -0
- package/sdk.js +65 -0
- package/server/db/schema.js +74 -0
- package/server/utils/access.js +43 -0
- package/server/utils/authorize.js +13 -0
- package/server/utils/categories.js +267 -0
- package/server/utils/categoryLogic.js +54 -0
- package/server/utils/events.js +31 -0
- package/server/utils/fieldTypes.js +227 -0
- package/server/utils/fields.js +238 -0
- package/server/utils/hooks.js +30 -0
- package/server/utils/importExport.js +239 -0
- package/server/utils/importFile.js +148 -0
- package/server/utils/importStage.js +76 -0
- package/server/utils/items.js +412 -0
- package/server/utils/media.js +245 -0
- package/server/utils/mediaSafety.js +51 -0
- package/server/utils/ownership.js +44 -0
- package/server/utils/rank.js +31 -0
- package/server/utils/ratio.js +31 -0
- package/server/utils/suggestions.js +36 -0
- package/server/utils/tagList.js +25 -0
- package/server/utils/tags.js +47 -0
- package/server/utils/zip.js +88 -0
- package/server/utils/zipRead.js +99 -0
- package/update.js +126 -0
package/sdk.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The server SDK other extensions build on. Every function resolves to [error, result].
|
|
3
|
+
|
|
4
|
+
An extension that adds to inventory declares `"dependencies": ["kempo-inventory"]` in its
|
|
5
|
+
kempo-config.json, registers its fields in install.js, and removes them in uninstall.js:
|
|
6
|
+
|
|
7
|
+
import { registerFields } from 'kempo-inventory/sdk';
|
|
8
|
+
export default async () => {
|
|
9
|
+
const [error] = await registerFields('my-extension', [
|
|
10
|
+
{ key: 'manufacturer', label: 'Manufacturer', type: 'text' },
|
|
11
|
+
]);
|
|
12
|
+
if(error) throw new Error(error.msg);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
Fields, categories and items can all be owned. Pass your extension name as `owner` and only you can
|
|
16
|
+
delete them or change the parts that define them; people keep editing everything else (stock
|
|
17
|
+
included) in the admin. Owner is never read from an HTTP request, only from these server calls:
|
|
18
|
+
|
|
19
|
+
registerCategory(owner, { name, description }) registerField(owner, { ..., category })
|
|
20
|
+
createItem(data, { owner }) updateItem(id, data, { owner })
|
|
21
|
+
deleteItem(id, { owner }) saveCategory(name, changes, { owner })
|
|
22
|
+
unregisterItems(owner, { release }) / unregisterCategories(owner) / unregisterFields(owner)
|
|
23
|
+
*/
|
|
24
|
+
export {
|
|
25
|
+
getItems,
|
|
26
|
+
getItem,
|
|
27
|
+
getItemBySku,
|
|
28
|
+
getLinked,
|
|
29
|
+
getMedia,
|
|
30
|
+
createItem,
|
|
31
|
+
updateItem,
|
|
32
|
+
deleteItem,
|
|
33
|
+
unregisterItems,
|
|
34
|
+
adjustStock,
|
|
35
|
+
getMovements,
|
|
36
|
+
} from './server/utils/items.js';
|
|
37
|
+
|
|
38
|
+
export {
|
|
39
|
+
getFields,
|
|
40
|
+
getField,
|
|
41
|
+
createField,
|
|
42
|
+
updateField,
|
|
43
|
+
deleteField,
|
|
44
|
+
registerField,
|
|
45
|
+
registerFields,
|
|
46
|
+
unregisterFields,
|
|
47
|
+
} from './server/utils/fields.js';
|
|
48
|
+
|
|
49
|
+
export { getSuggestions } from './server/utils/suggestions.js';
|
|
50
|
+
export { buildExport, importData } from './server/utils/importExport.js';
|
|
51
|
+
export { previewImport, applyImport } from './server/utils/importFile.js';
|
|
52
|
+
export { buildZip } from './server/utils/zip.js';
|
|
53
|
+
export { getTags } from './server/utils/tagList.js';
|
|
54
|
+
export { normalizeTags, MAX_TAG, MAX_TAGS } from './server/utils/tags.js';
|
|
55
|
+
export {
|
|
56
|
+
getCategories,
|
|
57
|
+
saveCategory,
|
|
58
|
+
setCategoryImage,
|
|
59
|
+
deleteCategory,
|
|
60
|
+
registerCategory,
|
|
61
|
+
unregisterCategories,
|
|
62
|
+
} from './server/utils/categories.js';
|
|
63
|
+
export { mediaAvailable, uploadProvider } from './server/utils/media.js';
|
|
64
|
+
export { FIELD_TYPES } from './server/utils/fieldTypes.js';
|
|
65
|
+
export { EVENTS } from './server/utils/events.js';
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { pgTable, text, timestamp, integer, boolean, jsonb, index, uniqueIndex } from 'drizzle-orm/pg-core';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
An item has only the core columns. Everything else lives in `data`, keyed by the `key` of a
|
|
5
|
+
kempoInventoryField row, so fields can be added and removed without touching the schema.
|
|
6
|
+
*/
|
|
7
|
+
export const kempoInventoryItem = pgTable('kempoInventoryItem', {
|
|
8
|
+
id: text('id').primaryKey(),
|
|
9
|
+
sku: text('sku').notNull(),
|
|
10
|
+
name: text('name').notNull(),
|
|
11
|
+
description: text('description').notNull().default(''),
|
|
12
|
+
category: text('category').notNull().default(''), // built in and optional; '' means uncategorised
|
|
13
|
+
quantity: integer('quantity').notNull().default(0),
|
|
14
|
+
tags: jsonb('tags').notNull().default([]), // built in: short lower-case labels search finds
|
|
15
|
+
data: jsonb('data').notNull().default({}),
|
|
16
|
+
owner: text('owner').notNull().default(''), // '' for items people manage, or the extension that created it; see ownership.js
|
|
17
|
+
created: timestamp('created').notNull(),
|
|
18
|
+
updated: timestamp('updated').notNull(),
|
|
19
|
+
}, table => [uniqueIndex('kempoInventoryItemSkuIdx').on(table.sku), index('kempoInventoryItemOwnerIdx').on(table.owner)]);
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
`owner` is '' for fields a user created in the admin, or the name of the extension that
|
|
23
|
+
registered it. Only the owner can delete a field or change what it holds.
|
|
24
|
+
|
|
25
|
+
`category` scopes a field: '' is the default scope, shown on every item, and a category key makes
|
|
26
|
+
it appear only on items in that category. A key is unique within its scope, and a category cannot
|
|
27
|
+
reuse a key the default scope has, because both would be stored under the same name on its items.
|
|
28
|
+
*/
|
|
29
|
+
export const kempoInventoryField = pgTable('kempoInventoryField', {
|
|
30
|
+
id: text('id').primaryKey(),
|
|
31
|
+
key: text('key').notNull(),
|
|
32
|
+
label: text('label').notNull(),
|
|
33
|
+
type: text('type').notNull(),
|
|
34
|
+
description: text('description').notNull().default(''),
|
|
35
|
+
required: boolean('required').notNull().default(false),
|
|
36
|
+
listed: boolean('listed').notNull().default(true),
|
|
37
|
+
suggest: boolean('suggest').notNull().default(false), // text fields: offer values already used on other items
|
|
38
|
+
options: jsonb('options').notNull().default([]),
|
|
39
|
+
owner: text('owner').notNull().default(''),
|
|
40
|
+
position: integer('position').notNull().default(0),
|
|
41
|
+
imageRatio: text('imageRatio').notNull().default(''), // media fields: the shape photos are cropped to, e.g. '1 / 1'; '' = any shape
|
|
42
|
+
imageMax: integer('imageMax').notNull().default(0), // media fields: photos are kept within this many pixels on a side; 0 = no limit
|
|
43
|
+
category: text('category').notNull().default(''), // '' = every item; otherwise the key of the one category it belongs to
|
|
44
|
+
created: timestamp('created').notNull(),
|
|
45
|
+
}, table => [uniqueIndex('kempoInventoryFieldKeyIdx').on(table.category, table.key)]);
|
|
46
|
+
|
|
47
|
+
export const kempoInventoryMovement = pgTable('kempoInventoryMovement', {
|
|
48
|
+
id: text('id').primaryKey(),
|
|
49
|
+
itemId: text('itemId').notNull(),
|
|
50
|
+
delta: integer('delta').notNull(),
|
|
51
|
+
quantityAfter: integer('quantityAfter').notNull(),
|
|
52
|
+
reason: text('reason').notNull().default('adjustment'),
|
|
53
|
+
note: text('note').notNull().default(''),
|
|
54
|
+
userId: text('userId').notNull().default(''),
|
|
55
|
+
created: timestamp('created').notNull(),
|
|
56
|
+
}, table => [index('kempoInventoryMovementItemIdx').on(table.itemId)]);
|
|
57
|
+
|
|
58
|
+
/*
|
|
59
|
+
Extra information about a category (currently its image), matched to items by `key`: the
|
|
60
|
+
category name lower-cased with its spacing tidied, so "Paint" and "paint" are one category. Items
|
|
61
|
+
do not point at these; they keep their plain text field. A category with no record still exists,
|
|
62
|
+
it just has no image.
|
|
63
|
+
*/
|
|
64
|
+
export const kempoInventoryCategory = pgTable('kempoInventoryCategory', {
|
|
65
|
+
id: text('id').primaryKey(),
|
|
66
|
+
key: text('key').notNull(),
|
|
67
|
+
name: text('name').notNull(),
|
|
68
|
+
image: text('image'), // a stored media id, as held by a media field
|
|
69
|
+
description: text('description').notNull().default(''),
|
|
70
|
+
tags: jsonb('tags').notNull().default([]),
|
|
71
|
+
owner: text('owner').notNull().default(''), // '' for categories people manage, or the extension that registered it
|
|
72
|
+
created: timestamp('created').notNull(),
|
|
73
|
+
updated: timestamp('updated').notNull(),
|
|
74
|
+
}, table => [uniqueIndex('kempoInventoryCategoryKeyIdx').on(table.key)]);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { currentUserHasPermission, getSession, getSetting } from 'kempo/server/sdk.js';
|
|
2
|
+
import authorize from './authorize.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Who may read the inventory, and whether they may see stock counts.
|
|
6
|
+
|
|
7
|
+
Normally reading needs the `items:read` permission. With the `public_read` setting on, anyone can
|
|
8
|
+
read, signed in or not, so the inventory can be shared as a catalogue. Either way, **stock counts
|
|
9
|
+
are only for people who manage stock**: callers without `stock:adjust` or `items:update` get items
|
|
10
|
+
with no `quantity` and no stock history.
|
|
11
|
+
|
|
12
|
+
Resolves to [null, { userId, canSeeStock, anonymous }] or [{ code, msg }, null].
|
|
13
|
+
*/
|
|
14
|
+
export const publicReadEnabled = async () => {
|
|
15
|
+
const [, value] = await getSetting('kempo-inventory', 'public_read', false);
|
|
16
|
+
return value === true || value === 'true';
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const holds = async (token, permission) => {
|
|
20
|
+
const [, allowed] = await currentUserHasPermission(token, `kempo-inventory:${permission}`);
|
|
21
|
+
return Boolean(allowed);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const authorizeRead = async request => {
|
|
25
|
+
const token = request.cookies?.session_token;
|
|
26
|
+
const [error, auth] = await authorize(request, 'items:read');
|
|
27
|
+
if(!error){
|
|
28
|
+
const canSeeStock = (await holds(token, 'stock:adjust')) || (await holds(token, 'items:update'));
|
|
29
|
+
return [null, { userId: auth.userId, canSeeStock, anonymous: false }];
|
|
30
|
+
}
|
|
31
|
+
if(await publicReadEnabled()){
|
|
32
|
+
const [sessionError, session] = token ? await getSession({ token }) : [true, null];
|
|
33
|
+
return [null, { userId: !sessionError && session?.user ? session.user.id : null, canSeeStock: false, anonymous: true }];
|
|
34
|
+
}
|
|
35
|
+
return [error, null];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/* An item as the caller may see it: no stock count unless they manage stock. */
|
|
39
|
+
export const forCaller = (item, access) => {
|
|
40
|
+
if(access.canSeeStock) return item;
|
|
41
|
+
const { quantity, ...rest } = item;
|
|
42
|
+
return rest;
|
|
43
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { currentUserHasPermission, getSession } from 'kempo/server/sdk.js';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Resolves to [null, { userId }] when the request's user holds the permission, or [{ code, msg }, null].
|
|
5
|
+
*/
|
|
6
|
+
export default async (request, permission) => {
|
|
7
|
+
const token = request.cookies.session_token;
|
|
8
|
+
const [sessionError, session] = await getSession({ token });
|
|
9
|
+
if(sessionError || !session?.user) return [{ code: 401, msg: 'Authentication required' }, null];
|
|
10
|
+
const [, allowed] = await currentUserHasPermission(token, `kempo-inventory:${permission}`);
|
|
11
|
+
if(!allowed) return [{ code: 403, msg: 'Insufficient permissions' }, null];
|
|
12
|
+
return [null, { userId: session.user.id }];
|
|
13
|
+
};
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import db from 'kempo/server/db/index.js';
|
|
2
|
+
import { sql, eq } from 'drizzle-orm';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import { kempoInventoryItem, kempoInventoryCategory, kempoInventoryField } from '../db/schema.js';
|
|
5
|
+
import { normalizeTags, tidyTag } from './tags.js';
|
|
6
|
+
import { categoryKey, groupCategories, tidyCategory, MAX_CATEGORY, MAX_DESCRIPTION } from './categoryLogic.js';
|
|
7
|
+
import { coerceValue } from './fieldTypes.js';
|
|
8
|
+
import { getAssets, checkAssets } from './media.js';
|
|
9
|
+
import { EVENTS, guard, notify } from './hooks.js';
|
|
10
|
+
import { blockingOwner, mayManage, notYours, ownerLabel } from './ownership.js';
|
|
11
|
+
|
|
12
|
+
/* Matches an item's category to a key the same way the category list groups them. */
|
|
13
|
+
const sameCategory = key => sql`lower(regexp_replace(btrim(${kempoInventoryItem.category}), '[[:space:]]+', ' ', 'g')) = ${key}`;
|
|
14
|
+
|
|
15
|
+
const countItems = async key => {
|
|
16
|
+
const [{ total }] = await db.select({ total: sql`count(*)::int` }).from(kempoInventoryItem).where(sameCategory(key));
|
|
17
|
+
return total;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/*
|
|
21
|
+
The categories, each with how many items it has, its image (if any) and description:
|
|
22
|
+
{ categories: [{ name, key, count, image, description }], uncategorised }
|
|
23
|
+
|
|
24
|
+
A category is the value of an item's built-in `category` property, so there is no list to
|
|
25
|
+
maintain: they are the distinct values, merged ignoring case and spacing. By default only
|
|
26
|
+
categories some item has are returned; `includeEmpty` also returns ones that exist only as a
|
|
27
|
+
record (created in the admin before any item uses them). `uncategorised` counts items with none.
|
|
28
|
+
*/
|
|
29
|
+
export const getCategories = async ({ includeEmpty = false, q = '', tag = '', owner } = {}) => {
|
|
30
|
+
try {
|
|
31
|
+
const [{ total }] = await db.select({ total: sql`count(*)::int` }).from(kempoInventoryItem);
|
|
32
|
+
|
|
33
|
+
const rows = [...await db.execute(sql`
|
|
34
|
+
SELECT ${kempoInventoryItem.category} AS value, count(*)::int AS uses
|
|
35
|
+
FROM ${kempoInventoryItem}
|
|
36
|
+
WHERE btrim(${kempoInventoryItem.category}) <> ''
|
|
37
|
+
GROUP BY 1`)];
|
|
38
|
+
const records = await db.select().from(kempoInventoryCategory);
|
|
39
|
+
|
|
40
|
+
const grouped = groupCategories(rows, records, { includeEmpty });
|
|
41
|
+
const assets = await getAssets(grouped.map(c => c.image).filter(Boolean));
|
|
42
|
+
const fieldRows = [...await db.execute(sql`SELECT ${kempoInventoryField.category} AS key, count(*)::int AS total FROM ${kempoInventoryField} WHERE ${kempoInventoryField.category} <> '' GROUP BY 1`)];
|
|
43
|
+
const fieldCounts = new Map(fieldRows.map(r => [r.key, r.total]));
|
|
44
|
+
let categories = grouped.map(c => ({ ...c, fields: fieldCounts.get(c.key) ?? 0, image: c.image ? assets[c.image] ?? null : null }));
|
|
45
|
+
|
|
46
|
+
/* `q` finds categories by name, description or tag; `tag` keeps those with every tag named (exact). */
|
|
47
|
+
const term = String(q ?? '').trim().toLowerCase();
|
|
48
|
+
if(term) categories = categories.filter(c => [c.name, c.description, ...c.tags].some(text => String(text).toLowerCase().includes(term)));
|
|
49
|
+
if(owner !== undefined) categories = categories.filter(c => c.owner === String(owner ?? ''));
|
|
50
|
+
const wanted = [tag].flat().map(tidyTag).filter(Boolean);
|
|
51
|
+
if(wanted.length) categories = categories.filter(c => wanted.every(t => c.tags.includes(t)));
|
|
52
|
+
const categorised = rows.reduce((sum, r) => sum + r.uses, 0);
|
|
53
|
+
|
|
54
|
+
const defaultFields = [...await db.execute(sql`SELECT count(*)::int AS total FROM ${kempoInventoryField} WHERE ${kempoInventoryField.category} = ''`)][0].total;
|
|
55
|
+
return [null, { categories, uncategorised: Math.max(0, total - categorised), defaultFields }];
|
|
56
|
+
} catch {
|
|
57
|
+
return [{ code: 500, msg: 'Failed to retrieve categories' }, null];
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/*
|
|
62
|
+
Creates or updates a category, by name (any spelling of it finds the same one). Only what is
|
|
63
|
+
passed changes:
|
|
64
|
+
|
|
65
|
+
saveCategory('Paint', { description: 'Hobby paints' })
|
|
66
|
+
saveCategory('Paint', { image: 'files:0123456789abcdef' }) // a stored media id; null clears it
|
|
67
|
+
saveCategory('Paint', { newName: 'Paints' }) // renames, and updates the items
|
|
68
|
+
|
|
69
|
+
Renaming changes the category on every item that has it. Renaming onto a name that already exists
|
|
70
|
+
merges the two: the items join the existing category, which keeps its own image and description
|
|
71
|
+
unless new ones are given. Resolves to [null, { name, key, image, description, tags, owner, moved }]
|
|
72
|
+
where `moved` is how many items were renamed.
|
|
73
|
+
|
|
74
|
+
A category with an `owner` (see registerCategory) can only be renamed, or merged into or out of,
|
|
75
|
+
by that owner; anyone can still change its image, description and tags. `owner` here is the
|
|
76
|
+
extension making the call ('' for a person), and a category created by this call belongs to it.
|
|
77
|
+
*/
|
|
78
|
+
export const saveCategory = async (name, { newName, image, description, tags } = {}, { userId = '', owner = '' } = {}) => {
|
|
79
|
+
const displayName = tidyCategory(name);
|
|
80
|
+
const key = categoryKey(displayName);
|
|
81
|
+
if(!key) return [{ code: 400, msg: 'A category name is required' }, null];
|
|
82
|
+
|
|
83
|
+
const changes = {};
|
|
84
|
+
if(description !== undefined){
|
|
85
|
+
const text = String(description ?? '').trim();
|
|
86
|
+
if(text.length > MAX_DESCRIPTION) return [{ code: 400, msg: `A description must be ${MAX_DESCRIPTION} characters or fewer` }, null];
|
|
87
|
+
changes.description = text;
|
|
88
|
+
}
|
|
89
|
+
if(tags !== undefined){
|
|
90
|
+
const [tagsError, cleanTags] = normalizeTags(tags);
|
|
91
|
+
if(tagsError) return [tagsError, null];
|
|
92
|
+
changes.tags = cleanTags;
|
|
93
|
+
}
|
|
94
|
+
if(image !== undefined){
|
|
95
|
+
if(image === null || image === ''){
|
|
96
|
+
changes.image = null;
|
|
97
|
+
} else {
|
|
98
|
+
const [invalid, ids] = coerceValue({ type: 'media', label: 'Image' }, [image]);
|
|
99
|
+
if(invalid) return [invalid, null];
|
|
100
|
+
const problem = await checkAssets([ids[0]], 'Image');
|
|
101
|
+
if(problem) return [problem, null];
|
|
102
|
+
changes.image = ids[0];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let targetName = displayName;
|
|
107
|
+
if(newName !== undefined){
|
|
108
|
+
targetName = tidyCategory(newName);
|
|
109
|
+
if(!targetName) return [{ code: 400, msg: 'A category needs a name' }, null];
|
|
110
|
+
if(targetName.length > MAX_CATEGORY) return [{ code: 400, msg: `A category name must be ${MAX_CATEGORY} characters or fewer` }, null];
|
|
111
|
+
}
|
|
112
|
+
const targetKey = categoryKey(targetName);
|
|
113
|
+
const renaming = targetName !== displayName;
|
|
114
|
+
|
|
115
|
+
const refused = await guard(EVENTS.categoryBeforeUpdate, { name: displayName, newName: renaming ? targetName : null, changes, userId, actor: owner });
|
|
116
|
+
if(refused) return [refused, null];
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
/* A category's own fields go with it when it is renamed. Merging two that both define a key would lose one. */
|
|
120
|
+
if(targetKey !== key){
|
|
121
|
+
const clash = [...await db.execute(sql`
|
|
122
|
+
SELECT a."key" FROM ${kempoInventoryField} a JOIN ${kempoInventoryField} b ON a."key" = b."key"
|
|
123
|
+
WHERE a."category" = ${key} AND b."category" = ${targetKey} LIMIT 1`)];
|
|
124
|
+
if(clash.length) return [{ code: 409, msg: `Both categories have a field with the key "${clash[0].key}". Delete or change one first` }, null];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const now = new Date();
|
|
128
|
+
const outcome = await db.transaction(async tx => {
|
|
129
|
+
const [source] = await tx.select().from(kempoInventoryCategory).where(eq(kempoInventoryCategory.key, key));
|
|
130
|
+
const [target] = targetKey === key ? [source] : await tx.select().from(kempoInventoryCategory).where(eq(kempoInventoryCategory.key, targetKey));
|
|
131
|
+
|
|
132
|
+
/*
|
|
133
|
+
Renaming rewrites the category on every item in it and moves its fields, and merging does the
|
|
134
|
+
same to the other category's, so neither may touch what another extension owns.
|
|
135
|
+
*/
|
|
136
|
+
if(renaming || targetKey !== key){
|
|
137
|
+
if(!mayManage(source, owner)) return { refused: notYours('category', source, owner) };
|
|
138
|
+
if(!mayManage(target, owner)) return { refused: notYours('category', target, owner) };
|
|
139
|
+
const keys = [...new Set([key, targetKey])];
|
|
140
|
+
const holders = [...await tx.execute(sql`
|
|
141
|
+
SELECT DISTINCT owner FROM (
|
|
142
|
+
SELECT ${kempoInventoryItem.owner} AS owner FROM ${kempoInventoryItem} WHERE ${sql.join(keys.map(k => sameCategory(k)), sql` OR `)}
|
|
143
|
+
UNION ALL
|
|
144
|
+
SELECT ${kempoInventoryField.owner} AS owner FROM ${kempoInventoryField} WHERE ${kempoInventoryField.category} IN (${sql.join(keys.map(k => sql`${k}`), sql`, `)})
|
|
145
|
+
) held`)].map(r => r.owner);
|
|
146
|
+
const blocker = blockingOwner(holders, owner);
|
|
147
|
+
if(blocker) return { refused: { code: 403, msg: `This category holds items or fields managed by ${ownerLabel(blocker)}, so it cannot be renamed or merged here` } };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* Items first, so a failure leaves the record untouched. Both spellings end up as the new name. */
|
|
151
|
+
let moved = 0;
|
|
152
|
+
if(renaming){
|
|
153
|
+
const rows = await tx.update(kempoInventoryItem)
|
|
154
|
+
.set({ category: targetName, updated: now })
|
|
155
|
+
.where(targetKey === key ? sameCategory(key) : sql`${sameCategory(key)} OR ${sameCategory(targetKey)}`)
|
|
156
|
+
.returning({ id: kempoInventoryItem.id });
|
|
157
|
+
moved = rows.length;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if(targetKey !== key) await tx.update(kempoInventoryField).set({ category: targetKey }).where(eq(kempoInventoryField.category, key));
|
|
161
|
+
|
|
162
|
+
/* What the category ends up holding: this call's changes, then the existing record, then the one merged away. */
|
|
163
|
+
const merged = {
|
|
164
|
+
image: changes.image !== undefined ? changes.image : (target?.image ?? source?.image ?? null),
|
|
165
|
+
description: changes.description !== undefined ? changes.description : (target?.description || source?.description || ''),
|
|
166
|
+
/* Merging two categories keeps the tags of both. */
|
|
167
|
+
tags: changes.tags !== undefined ? changes.tags : [...new Set([...(target?.tags ?? []), ...(source && source !== target ? source.tags ?? [] : [])])],
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
if(targetKey !== key && source) await tx.delete(kempoInventoryCategory).where(eq(kempoInventoryCategory.key, key));
|
|
171
|
+
|
|
172
|
+
let row;
|
|
173
|
+
if(target){
|
|
174
|
+
[row] = await tx.update(kempoInventoryCategory).set({ name: targetName, ...merged, updated: now }).where(eq(kempoInventoryCategory.key, targetKey)).returning();
|
|
175
|
+
} else {
|
|
176
|
+
[row] = await tx.insert(kempoInventoryCategory)
|
|
177
|
+
.values({ id: crypto.randomBytes(8).toString('hex'), key: targetKey, name: targetName, ...merged, owner, created: now, updated: now })
|
|
178
|
+
.returning();
|
|
179
|
+
}
|
|
180
|
+
return { row, moved, previous: source ?? null };
|
|
181
|
+
});
|
|
182
|
+
if(outcome.refused) return [outcome.refused, null];
|
|
183
|
+
|
|
184
|
+
await notify(EVENTS.categoryUpdated, { category: outcome.row, previous: outcome.previous, moved: outcome.moved, userId, actor: owner });
|
|
185
|
+
return [null, { name: outcome.row.name, key: outcome.row.key, image: outcome.row.image, description: outcome.row.description, tags: outcome.row.tags ?? [], owner: outcome.row.owner, moved: outcome.moved }];
|
|
186
|
+
} catch {
|
|
187
|
+
return [{ code: 500, msg: 'Failed to save the category' }, null];
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/* Kept for callers that only set a picture. */
|
|
192
|
+
export const setCategoryImage = (name, image = null, options) => saveCategory(name, { image }, options);
|
|
193
|
+
|
|
194
|
+
/*
|
|
195
|
+
Deletes a category's record (its image and description). Refused while any item is still in the
|
|
196
|
+
category, so deleting can never quietly strip categories off items: move or clear those first.
|
|
197
|
+
*/
|
|
198
|
+
export const deleteCategory = async (name, { userId = '', owner = '' } = {}) => {
|
|
199
|
+
const key = categoryKey(name);
|
|
200
|
+
if(!key) return [{ code: 400, msg: 'A category name is required' }, null];
|
|
201
|
+
try {
|
|
202
|
+
const [record] = await db.select().from(kempoInventoryCategory).where(eq(kempoInventoryCategory.key, key));
|
|
203
|
+
if(record && !mayManage(record, owner)) return [notYours('category', record, owner), null];
|
|
204
|
+
const refused = await guard(EVENTS.categoryBeforeDelete, { category: record ?? null, name: tidyCategory(name), userId, actor: owner });
|
|
205
|
+
if(refused) return [refused, null];
|
|
206
|
+
const [{ fields }] = await db.select({ fields: sql`count(*)::int` }).from(kempoInventoryField).where(eq(kempoInventoryField.category, key));
|
|
207
|
+
if(fields) return [{ code: 409, msg: `This category has ${fields} field${fields === 1 ? '' : 's'}. Delete ${fields === 1 ? 'it' : 'them'} first` }, null];
|
|
208
|
+
const used = await countItems(key);
|
|
209
|
+
if(used) return [{ code: 409, msg: `${used} item${used === 1 ? ' is' : 's are'} still in this category. Move or clear ${used === 1 ? 'it' : 'them'} first` }, null];
|
|
210
|
+
const [row] = await db.delete(kempoInventoryCategory).where(eq(kempoInventoryCategory.key, key)).returning();
|
|
211
|
+
if(!row) return [{ code: 404, msg: 'Category not found' }, null];
|
|
212
|
+
await notify(EVENTS.categoryDeleted, { category: row, userId, actor: owner });
|
|
213
|
+
return [null, { success: true }];
|
|
214
|
+
} catch {
|
|
215
|
+
return [{ code: 500, msg: 'Failed to delete the category' }, null];
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/*
|
|
220
|
+
Idempotent: safe to call from an extension's install.js and again on every update. Makes the
|
|
221
|
+
category a record the extension owns, so people cannot rename or delete it, and refreshes the
|
|
222
|
+
description, tags and image the owner supplies. Refuses a category people already manage unless
|
|
223
|
+
`adopt` is set, which takes it over (its items stay as they are, and stay people's).
|
|
224
|
+
|
|
225
|
+
registerCategory('kempo-products', { name: 'Finished goods', description: 'Ready to ship' })
|
|
226
|
+
*/
|
|
227
|
+
export const registerCategory = async (owner, { name, description, tags, image } = {}, { adopt = false } = {}) => {
|
|
228
|
+
if(!owner) return [{ code: 400, msg: 'An owner (your extension name) is required' }, null];
|
|
229
|
+
const key = categoryKey(name);
|
|
230
|
+
if(!key) return [{ code: 400, msg: 'A category name is required' }, null];
|
|
231
|
+
try {
|
|
232
|
+
const [existing] = await db.select().from(kempoInventoryCategory).where(eq(kempoInventoryCategory.key, key));
|
|
233
|
+
if(existing && existing.owner !== owner && (existing.owner || !adopt)){
|
|
234
|
+
return [{ code: 409, msg: `The category "${existing.name}" is already managed by ${ownerLabel(existing.owner)}` }, null];
|
|
235
|
+
}
|
|
236
|
+
const [error, saved] = await saveCategory(name, { description, tags, image }, { owner });
|
|
237
|
+
if(error) return [error, null];
|
|
238
|
+
if(existing && existing.owner !== owner) await db.update(kempoInventoryCategory).set({ owner }).where(eq(kempoInventoryCategory.key, key));
|
|
239
|
+
return [null, { ...saved, owner }];
|
|
240
|
+
} catch {
|
|
241
|
+
return [{ code: 500, msg: 'Failed to register the category' }, null];
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/*
|
|
246
|
+
For an extension's uninstall.js, after unregisterItems and unregisterFields. Deletes the
|
|
247
|
+
categories it registered, except any that items or fields still use: those are handed back to
|
|
248
|
+
the people who manage the inventory rather than leaving them stranded or stripping their items.
|
|
249
|
+
*/
|
|
250
|
+
export const unregisterCategories = async owner => {
|
|
251
|
+
if(!owner) return [{ code: 400, msg: 'An owner (your extension name) is required' }, null];
|
|
252
|
+
try {
|
|
253
|
+
const rows = await db.select().from(kempoInventoryCategory).where(eq(kempoInventoryCategory.owner, owner));
|
|
254
|
+
let removed = 0;
|
|
255
|
+
let released = 0;
|
|
256
|
+
for(const row of rows){
|
|
257
|
+
const [error] = await deleteCategory(row.name, { owner });
|
|
258
|
+
if(!error){ removed++; continue; }
|
|
259
|
+
if(error.code !== 409) return [error, null];
|
|
260
|
+
await db.update(kempoInventoryCategory).set({ owner: '' }).where(eq(kempoInventoryCategory.key, row.key));
|
|
261
|
+
released++;
|
|
262
|
+
}
|
|
263
|
+
return [null, { removed, released }];
|
|
264
|
+
} catch {
|
|
265
|
+
return [{ code: 500, msg: 'Failed to remove the categories' }, null];
|
|
266
|
+
}
|
|
267
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Pure helpers for categories, with no database access so they can be tested without a server.
|
|
3
|
+
|
|
4
|
+
An item's category is a built-in text property (like its SKU), not something items point at. What
|
|
5
|
+
this extension adds is a small record per category name that can hold an image and a description,
|
|
6
|
+
matched to items by a normalised key, so "Paint", "paint" and " Paint " are one category.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const MAX_CATEGORY = 100;
|
|
10
|
+
export const MAX_DESCRIPTION = 500;
|
|
11
|
+
|
|
12
|
+
/* How a category is stored: trimmed, with runs of whitespace collapsed to one space. */
|
|
13
|
+
export const tidyCategory = name => String(name ?? '').trim().replace(/\s+/g, ' ');
|
|
14
|
+
|
|
15
|
+
export const categoryKey = name => String(name ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
Builds the list a client shows from two sources:
|
|
19
|
+
rows [{ value, uses }] every distinct value items hold, with how many items hold it
|
|
20
|
+
records [{ key, name, image, description, owner }] the stored category records
|
|
21
|
+
|
|
22
|
+
Spellings that differ only in case or spacing are merged and counted together. The name shown is
|
|
23
|
+
the stored record's if there is one, otherwise the most used spelling (on a tie, the one that
|
|
24
|
+
starts with a capital). Categories no item has are left out unless `includeEmpty` is set, which an
|
|
25
|
+
admin screen uses so a category can exist, with its picture and description, before it has items.
|
|
26
|
+
Sorted by name.
|
|
27
|
+
*/
|
|
28
|
+
export const groupCategories = (rows, records = [], { includeEmpty = false } = {}) => {
|
|
29
|
+
const byKey = new Map();
|
|
30
|
+
for(const { value, uses } of rows){
|
|
31
|
+
const key = categoryKey(value);
|
|
32
|
+
if(!key) continue;
|
|
33
|
+
const entry = byKey.get(key) ?? { count: 0, spellings: new Map() };
|
|
34
|
+
entry.count += uses;
|
|
35
|
+
entry.spellings.set(value, (entry.spellings.get(value) ?? 0) + uses);
|
|
36
|
+
byKey.set(key, entry);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const recordByKey = new Map(records.map(record => [record.key, record]));
|
|
40
|
+
const startsUpper = text => /^\p{Lu}/u.test(text);
|
|
41
|
+
|
|
42
|
+
const used = [...byKey.entries()].map(([key, entry]) => {
|
|
43
|
+
const record = recordByKey.get(key);
|
|
44
|
+
const spelling = [...entry.spellings.entries()]
|
|
45
|
+
.sort((a, b) => b[1] - a[1] || Number(startsUpper(b[0])) - Number(startsUpper(a[0])) || a[0].localeCompare(b[0]))[0][0];
|
|
46
|
+
return { key, name: record?.name ?? spelling, count: entry.count, image: record?.image ?? null, description: record?.description ?? '', tags: record?.tags ?? [], owner: record?.owner ?? '' };
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const empty = includeEmpty
|
|
50
|
+
? records.filter(record => !byKey.has(record.key)).map(record => ({ key: record.key, name: record.name, count: 0, image: record.image ?? null, description: record.description ?? '', tags: record.tags ?? [], owner: record.owner ?? '' }))
|
|
51
|
+
: [];
|
|
52
|
+
|
|
53
|
+
return [...used, ...empty].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
|
54
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Events this extension fires. Other extensions subscribe by declaring them in their own
|
|
3
|
+
kempo-config.json `hooks`, e.g. { "kempo-inventory:item:created": "./hooks/item-created.js" }.
|
|
4
|
+
|
|
5
|
+
Guards ("before" events) run first and can change the pending operation or refuse it:
|
|
6
|
+
- the payload carries a mutable `draft` (or `changes`); edit it in place to alter what is saved
|
|
7
|
+
- throw `{ code, msg }` to refuse; the caller receives exactly that error
|
|
8
|
+
Category and field guards can only refuse. Every payload carries `actor`: the extension that made
|
|
9
|
+
the change through the server SDK, or '' for a person using the admin.
|
|
10
|
+
Notifications ("after" events) run once the work is committed; nothing they do can undo it.
|
|
11
|
+
*/
|
|
12
|
+
export const EVENTS = {
|
|
13
|
+
itemBeforeCreate: 'kempo-inventory:item:before_create',
|
|
14
|
+
itemCreated: 'kempo-inventory:item:created',
|
|
15
|
+
itemBeforeUpdate: 'kempo-inventory:item:before_update',
|
|
16
|
+
itemUpdated: 'kempo-inventory:item:updated',
|
|
17
|
+
itemBeforeDelete: 'kempo-inventory:item:before_delete',
|
|
18
|
+
itemDeleted: 'kempo-inventory:item:deleted',
|
|
19
|
+
stockBeforeAdjust: 'kempo-inventory:stock:before_adjust',
|
|
20
|
+
stockAdjusted: 'kempo-inventory:stock:adjusted',
|
|
21
|
+
categoryBeforeUpdate: 'kempo-inventory:category:before_update',
|
|
22
|
+
categoryUpdated: 'kempo-inventory:category:updated',
|
|
23
|
+
categoryBeforeDelete: 'kempo-inventory:category:before_delete',
|
|
24
|
+
categoryDeleted: 'kempo-inventory:category:deleted',
|
|
25
|
+
fieldBeforeCreate: 'kempo-inventory:field:before_create',
|
|
26
|
+
fieldCreated: 'kempo-inventory:field:created',
|
|
27
|
+
fieldBeforeUpdate: 'kempo-inventory:field:before_update',
|
|
28
|
+
fieldUpdated: 'kempo-inventory:field:updated',
|
|
29
|
+
fieldBeforeDelete: 'kempo-inventory:field:before_delete',
|
|
30
|
+
fieldDeleted: 'kempo-inventory:field:deleted',
|
|
31
|
+
};
|