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
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import db from 'kempo/server/db/index.js';
|
|
2
|
+
import { eq, and, or, ilike, sql, desc, asc, inArray } from 'drizzle-orm';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import { kempoInventoryItem, kempoInventoryMovement } from '../db/schema.js';
|
|
5
|
+
import { getFields } from './fields.js';
|
|
6
|
+
import { coerceValues, applicableFields, KEY_PATTERN, isEmpty } from './fieldTypes.js';
|
|
7
|
+
import { EVENTS, guard, notify } from './hooks.js';
|
|
8
|
+
import { tidyCategory, categoryKey, MAX_CATEGORY } from './categoryLogic.js';
|
|
9
|
+
import { normalizeTags, tidyTag } from './tags.js';
|
|
10
|
+
import { getAssets, checkAssets } from './media.js';
|
|
11
|
+
import { lockedItemChanges, notYours, ownerLabel } from './ownership.js';
|
|
12
|
+
|
|
13
|
+
const newId = () => crypto.randomBytes(8).toString('hex');
|
|
14
|
+
|
|
15
|
+
const toInt = (value, fallback) => {
|
|
16
|
+
if(isEmpty(value)) return fallback;
|
|
17
|
+
const n = Number(value);
|
|
18
|
+
return Number.isInteger(n) ? n : NaN;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const isUniqueViolation = error => (error?.code || error?.cause?.code) === '23505';
|
|
22
|
+
|
|
23
|
+
/*
|
|
24
|
+
The shape callers see: core properties plus `fields`, the values of the custom fields.
|
|
25
|
+
*/
|
|
26
|
+
const shape = row => row && ({
|
|
27
|
+
id: row.id,
|
|
28
|
+
sku: row.sku,
|
|
29
|
+
name: row.name,
|
|
30
|
+
description: row.description,
|
|
31
|
+
category: row.category,
|
|
32
|
+
quantity: row.quantity,
|
|
33
|
+
tags: row.tags ?? [],
|
|
34
|
+
fields: row.data ?? {},
|
|
35
|
+
owner: row.owner,
|
|
36
|
+
created: row.created,
|
|
37
|
+
updated: row.updated,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/*
|
|
41
|
+
Returns a map of id -> { id, sku, name } for every item the given items link to, so a client can
|
|
42
|
+
show "Widget (W-100)" instead of an opaque id. A missing entry means the linked item was deleted.
|
|
43
|
+
*/
|
|
44
|
+
const resolveLinks = async (items, fields) => {
|
|
45
|
+
const linkKeys = fields.filter(f => f.type === 'item').map(f => f.key);
|
|
46
|
+
const ids = new Set();
|
|
47
|
+
for(const item of items) for(const key of linkKeys) if(item.fields[key]) ids.add(item.fields[key]);
|
|
48
|
+
if(!ids.size) return {};
|
|
49
|
+
const rows = await db.select({ id: kempoInventoryItem.id, sku: kempoInventoryItem.sku, name: kempoInventoryItem.name })
|
|
50
|
+
.from(kempoInventoryItem).where(inArray(kempoInventoryItem.id, [...ids]));
|
|
51
|
+
return Object.fromEntries(rows.map(r => [r.id, r]));
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/*
|
|
55
|
+
Checks every `item` value points at a real item (and not at the item itself).
|
|
56
|
+
*/
|
|
57
|
+
/*
|
|
58
|
+
`existing` is the item's current values when updating. Files it already holds are not checked
|
|
59
|
+
again: an item with a photo from a library that is switched off can still be renamed, because
|
|
60
|
+
only files being *added* need their library to be there.
|
|
61
|
+
*/
|
|
62
|
+
const checkLinks = async (fields, values, ownId = null, existing = {}) => {
|
|
63
|
+
for(const field of fields.filter(f => f.type === 'item')){
|
|
64
|
+
const id = values[field.key];
|
|
65
|
+
if(!id) continue;
|
|
66
|
+
if(id === ownId) return { code: 400, msg: `${field.label}: an item cannot link to itself` };
|
|
67
|
+
const [row] = await db.select({ id: kempoInventoryItem.id }).from(kempoInventoryItem).where(eq(kempoInventoryItem.id, id));
|
|
68
|
+
if(!row) return { code: 400, msg: `${field.label}: the linked item does not exist` };
|
|
69
|
+
}
|
|
70
|
+
for(const field of fields.filter(f => f.type === 'media')){
|
|
71
|
+
const ids = values[field.key];
|
|
72
|
+
if(!ids?.length) continue;
|
|
73
|
+
const added = ids.filter(id => !(existing[field.key] ?? []).includes(id));
|
|
74
|
+
const problem = added.length ? await checkAssets(added, field.label) : null;
|
|
75
|
+
if(problem) return problem;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/*
|
|
81
|
+
Returns a map of id -> a description of each media file the given items hold, for display.
|
|
82
|
+
Empty when kempo-media is not installed, so a client simply has nothing to show.
|
|
83
|
+
*/
|
|
84
|
+
const resolveMedia = async (items, fields) => {
|
|
85
|
+
const mediaKeys = fields.filter(f => f.type === 'media').map(f => f.key);
|
|
86
|
+
const ids = [];
|
|
87
|
+
for(const item of items) for(const key of mediaKeys) ids.push(...(item.fields[key] ?? []));
|
|
88
|
+
return getAssets(ids);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const withoutNulls = values => Object.fromEntries(Object.entries(values).filter(([, v]) => v !== null));
|
|
92
|
+
|
|
93
|
+
/*
|
|
94
|
+
`ids` fetches exactly those items (any that no longer exist are simply absent), and `owner` keeps
|
|
95
|
+
the items one owner manages: an extension name, or '' for the ones people manage.
|
|
96
|
+
*/
|
|
97
|
+
export const getItems = async ({ q, category, tag, uncategorised = false, filters = {}, ids, owner, limit = 50, offset = 0 } = {}) => {
|
|
98
|
+
try {
|
|
99
|
+
const [fieldsError, fields] = await getFields();
|
|
100
|
+
if(fieldsError) return [fieldsError, null];
|
|
101
|
+
const known = new Set(fields.map(f => f.key));
|
|
102
|
+
|
|
103
|
+
const conditions = [];
|
|
104
|
+
if(q){
|
|
105
|
+
const like = `%${q}%`;
|
|
106
|
+
conditions.push(or(
|
|
107
|
+
ilike(kempoInventoryItem.name, like),
|
|
108
|
+
ilike(kempoInventoryItem.sku, like),
|
|
109
|
+
ilike(kempoInventoryItem.description, like),
|
|
110
|
+
ilike(kempoInventoryItem.category, like),
|
|
111
|
+
sql`${kempoInventoryItem.tags}::text ilike ${like}`,
|
|
112
|
+
sql`${kempoInventoryItem.data}::text ilike ${like}`
|
|
113
|
+
));
|
|
114
|
+
}
|
|
115
|
+
/* Categories match ignoring case and spacing, the same way the category list groups them. */
|
|
116
|
+
if(category){
|
|
117
|
+
conditions.push(sql`lower(regexp_replace(btrim(${kempoInventoryItem.category}), '[[:space:]]+', ' ', 'g')) = ${categoryKey(category)}`);
|
|
118
|
+
}
|
|
119
|
+
/* Exact tags, any number of them: an item must have every one. */
|
|
120
|
+
for(const wanted of [tag].flat().map(tidyTag).filter(Boolean)){
|
|
121
|
+
conditions.push(sql`${kempoInventoryItem.tags} @> ${JSON.stringify([wanted])}::jsonb`);
|
|
122
|
+
}
|
|
123
|
+
if(uncategorised) conditions.push(sql`btrim(${kempoInventoryItem.category}) = ''`);
|
|
124
|
+
if(ids !== undefined) conditions.push(inArray(kempoInventoryItem.id, [ids].flat().map(String)));
|
|
125
|
+
if(owner !== undefined) conditions.push(eq(kempoInventoryItem.owner, String(owner ?? '')));
|
|
126
|
+
for(const [key, value] of Object.entries(filters || {})){
|
|
127
|
+
if(!KEY_PATTERN.test(key) || !known.has(key)) return [{ code: 400, msg: `Unknown field "${key}"` }, null];
|
|
128
|
+
conditions.push(sql`${kempoInventoryItem.data}->>${key} = ${String(value)}`);
|
|
129
|
+
}
|
|
130
|
+
const where = conditions.length ? and(...conditions) : undefined;
|
|
131
|
+
|
|
132
|
+
const rows = await db.select().from(kempoInventoryItem).where(where)
|
|
133
|
+
.orderBy(asc(kempoInventoryItem.name), asc(kempoInventoryItem.id)).limit(limit).offset(offset);
|
|
134
|
+
const [{ total }] = await db.select({ total: sql`count(*)::int` }).from(kempoInventoryItem).where(where);
|
|
135
|
+
const items = rows.map(shape);
|
|
136
|
+
return [null, { items, total, linked: await resolveLinks(items, fields), media: await resolveMedia(items, fields) }];
|
|
137
|
+
} catch {
|
|
138
|
+
return [{ code: 500, msg: 'Failed to retrieve items' }, null];
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export const getItem = async id => {
|
|
143
|
+
if(!id) return [{ code: 400, msg: 'Item ID is required' }, null];
|
|
144
|
+
try {
|
|
145
|
+
const [row] = await db.select().from(kempoInventoryItem).where(eq(kempoInventoryItem.id, id));
|
|
146
|
+
if(!row) return [{ code: 404, msg: 'Item not found' }, null];
|
|
147
|
+
return [null, shape(row)];
|
|
148
|
+
} catch {
|
|
149
|
+
return [{ code: 500, msg: 'Failed to retrieve item' }, null];
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export const getItemBySku = async sku => {
|
|
154
|
+
if(!sku) return [{ code: 400, msg: 'SKU is required' }, null];
|
|
155
|
+
try {
|
|
156
|
+
const [row] = await db.select().from(kempoInventoryItem).where(eq(kempoInventoryItem.sku, sku));
|
|
157
|
+
if(!row) return [{ code: 404, msg: 'Item not found' }, null];
|
|
158
|
+
return [null, shape(row)];
|
|
159
|
+
} catch {
|
|
160
|
+
return [{ code: 500, msg: 'Failed to retrieve item' }, null];
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/*
|
|
165
|
+
Resolves link ids for a single item (see resolveLinks).
|
|
166
|
+
*/
|
|
167
|
+
export const getLinked = async items => {
|
|
168
|
+
const [error, fields] = await getFields();
|
|
169
|
+
if(error) return [error, null];
|
|
170
|
+
try {
|
|
171
|
+
return [null, await resolveLinks(Array.isArray(items) ? items : [items], fields)];
|
|
172
|
+
} catch {
|
|
173
|
+
return [{ code: 500, msg: 'Failed to resolve linked items' }, null];
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/*
|
|
178
|
+
Resolves media values to display descriptions (see resolveMedia).
|
|
179
|
+
*/
|
|
180
|
+
export const getMedia = async items => {
|
|
181
|
+
const [error, fields] = await getFields();
|
|
182
|
+
if(error) return [error, null];
|
|
183
|
+
try {
|
|
184
|
+
return [null, await resolveMedia(Array.isArray(items) ? items : [items], fields)];
|
|
185
|
+
} catch {
|
|
186
|
+
return [{ code: 500, msg: 'Failed to resolve media' }, null];
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/*
|
|
191
|
+
`owner` (an extension name, from the server SDK only) marks the item as that extension's: see
|
|
192
|
+
ownership.js for what that protects. It cannot be changed afterwards and is not part of the draft.
|
|
193
|
+
*/
|
|
194
|
+
export const createItem = async ({ sku, name, description = '', category = '', tags = [], quantity, fields: values = {}, userId = '' } = {}, { owner = '' } = {}) => {
|
|
195
|
+
/*
|
|
196
|
+
The draft is what a before_create hook sees and may edit: it can fill in or change any
|
|
197
|
+
property, or throw { code, msg } to refuse. Everything is validated after the hooks have run,
|
|
198
|
+
so a hook cannot smuggle in a value that would not otherwise be accepted.
|
|
199
|
+
*/
|
|
200
|
+
const draft = { sku, name, description, category, tags, quantity, fields: { ...values } };
|
|
201
|
+
const refused = await guard(EVENTS.itemBeforeCreate, { draft, userId, actor: owner });
|
|
202
|
+
if(refused) return [refused, null];
|
|
203
|
+
|
|
204
|
+
const cleanSku = String(draft.sku ?? '').trim();
|
|
205
|
+
const cleanName = String(draft.name ?? '').trim();
|
|
206
|
+
if(!cleanSku) return [{ code: 400, msg: 'SKU is required' }, null];
|
|
207
|
+
if(!cleanName) return [{ code: 400, msg: 'Name is required' }, null];
|
|
208
|
+
const cleanCategory = tidyCategory(draft.category);
|
|
209
|
+
if(cleanCategory.length > MAX_CATEGORY) return [{ code: 400, msg: `Category must be ${MAX_CATEGORY} characters or fewer` }, null];
|
|
210
|
+
const [tagsError, cleanTags] = normalizeTags(draft.tags);
|
|
211
|
+
if(tagsError) return [tagsError, null];
|
|
212
|
+
const qty = toInt(draft.quantity, 0);
|
|
213
|
+
if(Number.isNaN(qty) || qty < 0) return [{ code: 400, msg: 'Quantity must be a non-negative whole number' }, null];
|
|
214
|
+
|
|
215
|
+
const [fieldsError, fields] = await getFields();
|
|
216
|
+
if(fieldsError) return [fieldsError, null];
|
|
217
|
+
const [invalid, coerced] = coerceValues(applicableFields(fields, cleanCategory), draft.fields ?? {});
|
|
218
|
+
if(invalid) return [invalid, null];
|
|
219
|
+
const linkError = await checkLinks(fields, coerced);
|
|
220
|
+
if(linkError) return [linkError, null];
|
|
221
|
+
|
|
222
|
+
const now = new Date();
|
|
223
|
+
try {
|
|
224
|
+
const row = await db.transaction(async tx => {
|
|
225
|
+
const [created] = await tx.insert(kempoInventoryItem).values({
|
|
226
|
+
id: newId(), sku: cleanSku, name: cleanName, description: String(draft.description ?? '').trim(), category: cleanCategory, tags: cleanTags,
|
|
227
|
+
quantity: qty, data: withoutNulls(coerced), owner, created: now, updated: now,
|
|
228
|
+
}).returning();
|
|
229
|
+
if(qty > 0){
|
|
230
|
+
await tx.insert(kempoInventoryMovement).values({
|
|
231
|
+
id: newId(), itemId: created.id, delta: qty, quantityAfter: qty,
|
|
232
|
+
reason: 'initial', note: 'Initial stock', userId, created: now,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
return created;
|
|
236
|
+
});
|
|
237
|
+
const item = shape(row);
|
|
238
|
+
await notify(EVENTS.itemCreated, { item, userId, actor: owner });
|
|
239
|
+
return [null, item];
|
|
240
|
+
} catch(error) {
|
|
241
|
+
if(isUniqueViolation(error)) return [{ code: 409, msg: 'An item with that SKU already exists' }, null];
|
|
242
|
+
return [{ code: 500, msg: 'Failed to create item' }, null];
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/*
|
|
247
|
+
Updates sku, name, description and custom field values. Quantity is not editable here: it only
|
|
248
|
+
changes through adjustStock, which keeps the movement history honest.
|
|
249
|
+
*/
|
|
250
|
+
export const updateItem = async (id, { sku, name, description, category, tags, fields: values, userId = '' } = {}, { owner = '' } = {}) => {
|
|
251
|
+
const [lookupError, existing] = await getItem(id);
|
|
252
|
+
if(lookupError) return [lookupError, null];
|
|
253
|
+
|
|
254
|
+
const changes = {};
|
|
255
|
+
if(sku !== undefined) changes.sku = sku;
|
|
256
|
+
if(name !== undefined) changes.name = name;
|
|
257
|
+
if(description !== undefined) changes.description = description;
|
|
258
|
+
if(category !== undefined) changes.category = category;
|
|
259
|
+
if(tags !== undefined) changes.tags = tags;
|
|
260
|
+
if(values !== undefined) changes.fields = { ...values };
|
|
261
|
+
|
|
262
|
+
const refused = await guard(EVENTS.itemBeforeUpdate, { item: existing, changes, userId, actor: owner });
|
|
263
|
+
if(refused) return [refused, null];
|
|
264
|
+
|
|
265
|
+
/* After the guards, so a hook cannot edit its way past it. */
|
|
266
|
+
const locked = lockedItemChanges(existing, changes, owner);
|
|
267
|
+
if(locked.length) return [{ code: 403, msg: `${locked.join(', ')} can only be changed by ${ownerLabel(existing.owner)}` }, null];
|
|
268
|
+
|
|
269
|
+
const updates = {};
|
|
270
|
+
if(changes.sku !== undefined){
|
|
271
|
+
updates.sku = String(changes.sku).trim();
|
|
272
|
+
if(!updates.sku) return [{ code: 400, msg: 'SKU cannot be empty' }, null];
|
|
273
|
+
}
|
|
274
|
+
if(changes.name !== undefined){
|
|
275
|
+
updates.name = String(changes.name).trim();
|
|
276
|
+
if(!updates.name) return [{ code: 400, msg: 'Name cannot be empty' }, null];
|
|
277
|
+
}
|
|
278
|
+
if(changes.description !== undefined) updates.description = String(changes.description).trim();
|
|
279
|
+
if(changes.category !== undefined){
|
|
280
|
+
updates.category = tidyCategory(changes.category);
|
|
281
|
+
if(updates.category.length > MAX_CATEGORY) return [{ code: 400, msg: `Category must be ${MAX_CATEGORY} characters or fewer` }, null];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if(changes.tags !== undefined){
|
|
285
|
+
const [tagsError, cleanTags] = normalizeTags(changes.tags);
|
|
286
|
+
if(tagsError) return [tagsError, null];
|
|
287
|
+
updates.tags = cleanTags;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if(changes.fields !== undefined){
|
|
291
|
+
const [fieldsError, fields] = await getFields();
|
|
292
|
+
if(fieldsError) return [fieldsError, null];
|
|
293
|
+
/* Only the fields of the category the item will be in. Values it holds for others are kept, just not shown. */
|
|
294
|
+
const [invalid, coerced] = coerceValues(applicableFields(fields, updates.category ?? existing.category), changes.fields, { partial: true });
|
|
295
|
+
if(invalid) return [invalid, null];
|
|
296
|
+
const linkError = await checkLinks(fields, coerced, id, existing.fields);
|
|
297
|
+
if(linkError) return [linkError, null];
|
|
298
|
+
updates.data = withoutNulls({ ...existing.fields, ...coerced });
|
|
299
|
+
for(const [key, value] of Object.entries(coerced)) if(value === null) delete updates.data[key];
|
|
300
|
+
}
|
|
301
|
+
if(!Object.keys(updates).length) return [{ code: 400, msg: 'No changes provided' }, null];
|
|
302
|
+
updates.updated = new Date();
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
const [row] = await db.update(kempoInventoryItem).set(updates).where(eq(kempoInventoryItem.id, id)).returning();
|
|
306
|
+
if(!row) return [{ code: 404, msg: 'Item not found' }, null];
|
|
307
|
+
const item = shape(row);
|
|
308
|
+
await notify(EVENTS.itemUpdated, { item, previous: existing, userId, actor: owner });
|
|
309
|
+
return [null, item];
|
|
310
|
+
} catch(error) {
|
|
311
|
+
if(isUniqueViolation(error)) return [{ code: 409, msg: 'An item with that SKU already exists' }, null];
|
|
312
|
+
return [{ code: 500, msg: 'Failed to update item' }, null];
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
export const deleteItem = async (id, { userId = '', owner = '' } = {}) => {
|
|
317
|
+
const [lookupError, existing] = await getItem(id);
|
|
318
|
+
if(lookupError) return [lookupError, null];
|
|
319
|
+
if(existing.owner !== owner) return [notYours('item', existing, owner), null];
|
|
320
|
+
|
|
321
|
+
const refused = await guard(EVENTS.itemBeforeDelete, { item: existing, userId, actor: owner });
|
|
322
|
+
if(refused) return [refused, null];
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const deleted = await db.transaction(async tx => {
|
|
326
|
+
const rows = await tx.delete(kempoInventoryItem).where(eq(kempoInventoryItem.id, id)).returning();
|
|
327
|
+
if(rows.length) await tx.delete(kempoInventoryMovement).where(eq(kempoInventoryMovement.itemId, id));
|
|
328
|
+
return rows.length;
|
|
329
|
+
});
|
|
330
|
+
if(!deleted) return [{ code: 404, msg: 'Item not found' }, null];
|
|
331
|
+
await notify(EVENTS.itemDeleted, { item: existing, userId, actor: owner });
|
|
332
|
+
return [null, { success: true }];
|
|
333
|
+
} catch {
|
|
334
|
+
return [{ code: 500, msg: 'Failed to delete item' }, null];
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
/*
|
|
339
|
+
Atomic: the quantity changes in a single UPDATE guarded against going negative,
|
|
340
|
+
so concurrent adjustments cannot oversell.
|
|
341
|
+
*/
|
|
342
|
+
/* Anyone may adjust stock, owned item or not; `owner` only tells hooks which extension did it. */
|
|
343
|
+
export const adjustStock = async (id, { delta, reason = 'adjustment', note = '', userId = '', owner = '' } = {}) => {
|
|
344
|
+
const [lookupError, existing] = await getItem(id);
|
|
345
|
+
if(lookupError) return [lookupError, null];
|
|
346
|
+
|
|
347
|
+
const draft = { delta, reason, note };
|
|
348
|
+
const refused = await guard(EVENTS.stockBeforeAdjust, { item: existing, draft, userId, actor: owner });
|
|
349
|
+
if(refused) return [refused, null];
|
|
350
|
+
|
|
351
|
+
const change = toInt(draft.delta, NaN);
|
|
352
|
+
if(Number.isNaN(change) || change === 0) return [{ code: 400, msg: 'delta must be a non-zero whole number' }, null];
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
const result = await db.transaction(async tx => {
|
|
356
|
+
const [row] = await tx.update(kempoInventoryItem)
|
|
357
|
+
.set({ quantity: sql`${kempoInventoryItem.quantity} + ${change}`, updated: new Date() })
|
|
358
|
+
.where(and(eq(kempoInventoryItem.id, id), sql`${kempoInventoryItem.quantity} + ${change} >= 0`))
|
|
359
|
+
.returning();
|
|
360
|
+
if(!row) return null;
|
|
361
|
+
const [movement] = await tx.insert(kempoInventoryMovement).values({
|
|
362
|
+
id: newId(), itemId: id, delta: change, quantityAfter: row.quantity,
|
|
363
|
+
reason: String(draft.reason || 'adjustment'), note: String(draft.note || ''), userId, created: new Date(),
|
|
364
|
+
}).returning();
|
|
365
|
+
return { row, movement };
|
|
366
|
+
});
|
|
367
|
+
if(!result) return [{ code: 409, msg: 'Insufficient stock' }, null];
|
|
368
|
+
|
|
369
|
+
const item = shape(result.row);
|
|
370
|
+
await notify(EVENTS.stockAdjusted, {
|
|
371
|
+
item, previousQuantity: item.quantity - change, delta: change,
|
|
372
|
+
reason: result.movement.reason, note: result.movement.note, movement: result.movement, userId, actor: owner,
|
|
373
|
+
});
|
|
374
|
+
return [null, item];
|
|
375
|
+
} catch {
|
|
376
|
+
return [{ code: 500, msg: 'Failed to adjust stock' }, null];
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
export const getMovements = async ({ itemId, limit = 50, offset = 0 } = {}) => {
|
|
381
|
+
try {
|
|
382
|
+
const where = itemId ? eq(kempoInventoryMovement.itemId, itemId) : undefined;
|
|
383
|
+
const movements = await db.select().from(kempoInventoryMovement).where(where)
|
|
384
|
+
.orderBy(desc(kempoInventoryMovement.created), desc(kempoInventoryMovement.id)).limit(limit).offset(offset);
|
|
385
|
+
return [null, movements];
|
|
386
|
+
} catch {
|
|
387
|
+
return [{ code: 500, msg: 'Failed to retrieve movements' }, null];
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
/*
|
|
392
|
+
For an extension's uninstall.js: deletes every item it owns, with their history. With `release`
|
|
393
|
+
the items are kept and handed back to the people who manage the inventory instead, which is the
|
|
394
|
+
right choice when they hold real stock.
|
|
395
|
+
*/
|
|
396
|
+
export const unregisterItems = async (owner, { release = false } = {}) => {
|
|
397
|
+
if(!owner) return [{ code: 400, msg: 'An owner (your extension name) is required' }, null];
|
|
398
|
+
try {
|
|
399
|
+
if(release){
|
|
400
|
+
const rows = await db.update(kempoInventoryItem).set({ owner: '' }).where(eq(kempoInventoryItem.owner, owner)).returning({ id: kempoInventoryItem.id });
|
|
401
|
+
return [null, { released: rows.length }];
|
|
402
|
+
}
|
|
403
|
+
const rows = await db.select({ id: kempoInventoryItem.id }).from(kempoInventoryItem).where(eq(kempoInventoryItem.owner, owner));
|
|
404
|
+
for(const { id } of rows){
|
|
405
|
+
const [error] = await deleteItem(id, { owner });
|
|
406
|
+
if(error) return [error, null];
|
|
407
|
+
}
|
|
408
|
+
return [null, { removed: rows.length }];
|
|
409
|
+
} catch {
|
|
410
|
+
return [{ code: 500, msg: 'Failed to remove the items' }, null];
|
|
411
|
+
}
|
|
412
|
+
};
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { getExtension, getSetting, triggerHook } from 'kempo/server/sdk.js';
|
|
4
|
+
import { extensionOf, safeFileName } from './mediaSafety.js';
|
|
5
|
+
import { DEFAULT_RATIOS, resolveRatio } from './ratio.js';
|
|
6
|
+
import { parseMediaId, filesId } from './fieldTypes.js';
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
Where item photos and files live. Three optional extensions can take part, and inventory works
|
|
10
|
+
the same with none of them installed:
|
|
11
|
+
|
|
12
|
+
kempo-media a media library with its own image thumbnails
|
|
13
|
+
kempo-files a private file library; uploads need a signed-in user with files:download to view
|
|
14
|
+
kempo-thumbs generates thumbnails for kempo-files (images, and frames from video and audio)
|
|
15
|
+
|
|
16
|
+
Nothing here imports them up front. Each is loaded on demand and only counts as available when it
|
|
17
|
+
is both installed as a package and enabled in kempo. Because kempo-thumbs only works on
|
|
18
|
+
kempo-files, it is only ever used for files stored there.
|
|
19
|
+
|
|
20
|
+
A stored value says where it lives: a bare id is a kempo-media asset, "files:<id>" is a
|
|
21
|
+
kempo-files file. So values from either place keep resolving, whichever is used for new uploads.
|
|
22
|
+
*/
|
|
23
|
+
const loaders = {
|
|
24
|
+
'kempo-media': () => import('kempo-media/sdk'),
|
|
25
|
+
'kempo-files': () => import('kempo-files/sdk'),
|
|
26
|
+
'kempo-thumbs': () => import('kempo-thumbs/sdk'),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const loaded = {};
|
|
30
|
+
|
|
31
|
+
const load = async name => loaded[name] ??= await loaders[name]().catch(() => null);
|
|
32
|
+
|
|
33
|
+
const available = async name => {
|
|
34
|
+
const [error, extension] = await getExtension({ name });
|
|
35
|
+
if(error || !extension?.enabled) return false;
|
|
36
|
+
return Boolean(await load(name));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/*
|
|
40
|
+
What can be used right now. `thumbs` is only true alongside `files`, since that is the only
|
|
41
|
+
place it can generate thumbnails.
|
|
42
|
+
*/
|
|
43
|
+
export const providers = async () => {
|
|
44
|
+
const [media, files, thumbs] = await Promise.all([available('kempo-media'), available('kempo-files'), available('kempo-thumbs')]);
|
|
45
|
+
return { media, files, thumbs: files && thumbs };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const mediaAvailable = async () => {
|
|
49
|
+
const found = await providers();
|
|
50
|
+
return found.media || found.files;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/*
|
|
54
|
+
Which provider new uploads go to: 'media', 'files' or null when neither is installed.
|
|
55
|
+
|
|
56
|
+
The `media_provider` setting can force one ('kempo-media' or 'kempo-files'). Left on 'auto',
|
|
57
|
+
kempo-files wins when kempo-thumbs is there to thumbnail it, because that is a better result than
|
|
58
|
+
kempo-media's; otherwise kempo-media wins, since a bare kempo-files has no thumbnails at all.
|
|
59
|
+
*/
|
|
60
|
+
export const uploadProvider = async () => {
|
|
61
|
+
const found = await providers();
|
|
62
|
+
const [, choice] = await getSetting('kempo-inventory', 'media_provider', 'auto');
|
|
63
|
+
if(choice === 'kempo-media' && found.media) return 'media';
|
|
64
|
+
if(choice === 'kempo-files' && found.files) return 'files';
|
|
65
|
+
if(found.files && found.thumbs) return 'files';
|
|
66
|
+
if(found.media) return 'media';
|
|
67
|
+
if(found.files) return 'files';
|
|
68
|
+
return null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/*
|
|
72
|
+
The aspect ratios images are shown at, as CSS ("4 / 3"): { category, item }. They come from the
|
|
73
|
+
`category_image_ratio` and `item_image_ratio` settings; a blank or invalid one falls back to the default.
|
|
74
|
+
*/
|
|
75
|
+
export const getImageRatios = async () => {
|
|
76
|
+
const [, category] = await getSetting('kempo-inventory', 'category_image_ratio', DEFAULT_RATIOS.category);
|
|
77
|
+
const [, item] = await getSetting('kempo-inventory', 'item_image_ratio', DEFAULT_RATIOS.item);
|
|
78
|
+
return { category: resolveRatio(category, 'category'), item: resolveRatio(item, 'item') };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/* Whether photos uploaded to kempo-files are made public, so people who can only read the inventory can see them. */
|
|
82
|
+
export const publicPhotos = async () => {
|
|
83
|
+
const [, value] = await getSetting('kempo-inventory', 'public_photos', true);
|
|
84
|
+
return value !== false && value !== 'false';
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const capabilities = async () => {
|
|
88
|
+
const found = await providers();
|
|
89
|
+
return { media: found.media || found.files, providers: found, upload: await uploadProvider(), publicFiles: await publicPhotos() };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/*
|
|
93
|
+
The smallest thumbnail that is ready, preferring the one labelled "sm".
|
|
94
|
+
*/
|
|
95
|
+
const pickThumbnail = rows => {
|
|
96
|
+
const ready = (rows ?? []).filter(row => row.status === 'ready' && row.url);
|
|
97
|
+
return (ready.find(row => row.label === 'sm') ?? ready[0])?.url ?? null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const describeMedia = asset => ({
|
|
101
|
+
id: asset.id,
|
|
102
|
+
kind: asset.kind,
|
|
103
|
+
name: asset.originalName,
|
|
104
|
+
alt: asset.altText,
|
|
105
|
+
path: `/${asset.path}`,
|
|
106
|
+
thumbnail: asset.thumbnailPath ? `/${asset.thumbnailPath}` : null,
|
|
107
|
+
width: asset.width,
|
|
108
|
+
height: asset.height,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const describeFile = (file, thumbnails) => ({
|
|
112
|
+
id: filesId(file.id),
|
|
113
|
+
kind: file.kind,
|
|
114
|
+
name: file.name,
|
|
115
|
+
alt: file.altText,
|
|
116
|
+
path: file.alias ? `/${file.alias}` : `/kempo-files/api/files/${file.id}`,
|
|
117
|
+
thumbnail: pickThumbnail(thumbnails),
|
|
118
|
+
width: null,
|
|
119
|
+
height: null,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/*
|
|
123
|
+
Looks up stored values. Returns a map of stored id -> a small description of each file; an id
|
|
124
|
+
with no entry means it was deleted from its library, or its library is not available right now.
|
|
125
|
+
*/
|
|
126
|
+
export const getAssets = async ids => {
|
|
127
|
+
const wanted = [...new Set(ids)];
|
|
128
|
+
if(!wanted.length) return {};
|
|
129
|
+
const found = await providers();
|
|
130
|
+
const parsed = wanted.map(id => ({ id, ...parseMediaId(id) }));
|
|
131
|
+
const result = {};
|
|
132
|
+
|
|
133
|
+
const mediaIds = parsed.filter(p => p.provider === 'media');
|
|
134
|
+
if(found.media && mediaIds.length){
|
|
135
|
+
const { getMediaAsset } = await load('kempo-media');
|
|
136
|
+
const lookups = await Promise.all(mediaIds.map(async p => [p.id, await getMediaAsset(p.rawId)]));
|
|
137
|
+
for(const [id, [error, asset]] of lookups) if(!error) result[id] = describeMedia(asset);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const fileIds = parsed.filter(p => p.provider === 'files');
|
|
141
|
+
if(found.files && fileIds.length){
|
|
142
|
+
const { getFile } = await load('kempo-files');
|
|
143
|
+
const lookups = await Promise.all(fileIds.map(async p => [p, await getFile(p.rawId)]));
|
|
144
|
+
|
|
145
|
+
let thumbnails = {};
|
|
146
|
+
if(found.thumbs){
|
|
147
|
+
const { thumbnailsForMany } = await load('kempo-thumbs');
|
|
148
|
+
const [, data] = await thumbnailsForMany(fileIds.map(p => p.rawId));
|
|
149
|
+
thumbnails = data?.bySource ?? {};
|
|
150
|
+
}
|
|
151
|
+
for(const [p, [error, file]] of lookups) if(!error) result[p.id] = describeFile(file, thumbnails[p.rawId]);
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/*
|
|
157
|
+
The bytes of a stored file, for an export: [null, { name, kind, alt, bytes }], or an error when it is
|
|
158
|
+
gone from its library (or the library is not installed).
|
|
159
|
+
*/
|
|
160
|
+
export const readMedia = async storedId => {
|
|
161
|
+
const { provider, rawId } = parseMediaId(storedId);
|
|
162
|
+
const found = await providers();
|
|
163
|
+
try {
|
|
164
|
+
if(provider === 'files' && found.files){
|
|
165
|
+
const { getFile, filePath } = await load('kempo-files');
|
|
166
|
+
const [error, file] = await getFile(rawId);
|
|
167
|
+
if(error) return [error, null];
|
|
168
|
+
const [pathError, absolute] = await filePath(file);
|
|
169
|
+
if(pathError) return [pathError, null];
|
|
170
|
+
return [null, { name: file.name, kind: file.kind, alt: file.altText ?? '', bytes: await readFile(absolute) }];
|
|
171
|
+
}
|
|
172
|
+
if(provider === 'media' && found.media){
|
|
173
|
+
const { getMediaAsset } = await load('kempo-media');
|
|
174
|
+
const [error, asset] = await getMediaAsset(rawId);
|
|
175
|
+
if(error) return [error, null];
|
|
176
|
+
return [null, { name: asset.originalName, kind: asset.kind, alt: asset.altText ?? '', bytes: await readFile(join(process.cwd(), 'public', asset.path)) }];
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
return [{ code: 404, msg: 'The file could not be read' }, null];
|
|
180
|
+
}
|
|
181
|
+
return [{ code: 409, msg: 'The library holding this file is not installed' }, null];
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif', bmp: 'image/bmp', mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/mp4', pdf: 'application/pdf' };
|
|
185
|
+
|
|
186
|
+
/*
|
|
187
|
+
Stores a file in whichever library new uploads go to (the same choice as an upload from the form), on
|
|
188
|
+
behalf of `userId`, and resolves to [null, storedId]. The caller is responsible for having checked who
|
|
189
|
+
may do this and what the file is (see mediaSafety.js); this is only the storing. A name that is taken is
|
|
190
|
+
retried with a short random suffix, the way the form's uploader does.
|
|
191
|
+
*/
|
|
192
|
+
export const storeMedia = async ({ name, bytes, altText = '', userId }) => {
|
|
193
|
+
const provider = await uploadProvider();
|
|
194
|
+
if(!provider) return [{ code: 409, msg: 'Uploading needs the kempo-media or kempo-files extension' }, null];
|
|
195
|
+
const safe = safeFileName(name);
|
|
196
|
+
const data = Buffer.from(bytes);
|
|
197
|
+
const withSuffix = () => safe.replace(/(.[^.]*)?$/, ext => `-${Math.random().toString(36).slice(2, 7)}${ext ?? ''}`);
|
|
198
|
+
try {
|
|
199
|
+
if(provider === 'files'){
|
|
200
|
+
const { storeUpload } = await load('kempo-files');
|
|
201
|
+
const [, maxMb] = await getSetting('kempo-files', 'max_upload_size_mb', 250);
|
|
202
|
+
const isPublic = await publicPhotos();
|
|
203
|
+
let attempt = safe;
|
|
204
|
+
for(let n = 0; n < 5; n++){
|
|
205
|
+
const [error, file] = await storeUpload({ name: attempt, data, altText, ownerId: userId, public: isPublic, maxBytes: Number(maxMb) * 1024 * 1024 });
|
|
206
|
+
if(!error){
|
|
207
|
+
await triggerHook('file:uploaded', { file });
|
|
208
|
+
return [null, filesId(file.id)];
|
|
209
|
+
}
|
|
210
|
+
if(error.code !== 409) return [error, null];
|
|
211
|
+
attempt = withSuffix();
|
|
212
|
+
}
|
|
213
|
+
return [{ code: 409, msg: 'No free file name could be found' }, null];
|
|
214
|
+
}
|
|
215
|
+
const { storeUpload, createMediaAsset } = await load('kempo-media');
|
|
216
|
+
const [, maxMb] = await getSetting('kempo-media', 'max_upload_size_mb', 250);
|
|
217
|
+
const [, thumbnailMaxDimension] = await getSetting('kempo-media', 'thumbnail_max_dimension', 480);
|
|
218
|
+
const [error, asset] = await storeUpload({
|
|
219
|
+
rootDir: join(process.cwd(), 'public'), filename: safe, data, mimeType: MIME[extensionOf(safe)] ?? 'application/octet-stream',
|
|
220
|
+
altText, uploadedBy: userId, maxBytes: Number(maxMb) * 1024 * 1024, thumbnailMaxDimension: Number(thumbnailMaxDimension),
|
|
221
|
+
});
|
|
222
|
+
if(error) return [error, null];
|
|
223
|
+
const [recordError, row] = await createMediaAsset(asset);
|
|
224
|
+
return recordError ? [recordError, null] : [null, row.id];
|
|
225
|
+
} catch {
|
|
226
|
+
return [{ code: 500, msg: 'The file could not be stored' }, null];
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/*
|
|
231
|
+
Returns an { code, msg } for the first id that cannot be used, or null when all can: its library
|
|
232
|
+
is not installed (409), or it no longer exists (400).
|
|
233
|
+
*/
|
|
234
|
+
export const checkAssets = async (ids, label) => {
|
|
235
|
+
const found = await providers();
|
|
236
|
+
for(const id of ids){
|
|
237
|
+
const { provider } = parseMediaId(id);
|
|
238
|
+
if(!found[provider]){
|
|
239
|
+
return { code: 409, msg: `${label}: ${provider === 'files' ? 'kempo-files' : 'kempo-media'} is not installed` };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const assets = await getAssets(ids);
|
|
243
|
+
const missing = ids.find(id => !assets[id]);
|
|
244
|
+
return missing ? { code: 400, msg: `${label}: a selected file no longer exists in its library` } : null;
|
|
245
|
+
};
|