@kernhq/module-inventory 0.2.0 → 0.3.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 +104 -25
- package/dist/contract/capabilities.d.ts +4 -4
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +51 -9
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/events.d.ts +43 -0
- package/dist/contract/events.d.ts.map +1 -1
- package/dist/contract/events.js +40 -0
- package/dist/contract/events.js.map +1 -1
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +1 -0
- package/dist/contract/index.js.map +1 -1
- package/dist/contract/models.d.ts +322 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +213 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/notifications.d.ts +24 -0
- package/dist/contract/notifications.d.ts.map +1 -0
- package/dist/contract/notifications.js +72 -0
- package/dist/contract/notifications.js.map +1 -0
- package/dist/contract/permissions.d.ts +52 -2
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +55 -2
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/router.d.ts +1052 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +225 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/contract/settings.d.ts +2 -0
- package/dist/contract/settings.d.ts.map +1 -1
- package/dist/contract/settings.js +24 -4
- package/dist/contract/settings.js.map +1 -1
- package/dist/server/index.d.ts +8 -4
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +289 -5
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts +48 -0
- package/dist/server/jobs.d.ts.map +1 -0
- package/dist/server/jobs.js +358 -0
- package/dist/server/jobs.js.map +1 -0
- package/dist/server/router.d.ts +1337 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +363 -7
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +134 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +158 -4
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +69 -3
- package/dist/server/services/assets.d.ts.map +1 -1
- package/dist/server/services/assets.js +199 -31
- package/dist/server/services/assets.js.map +1 -1
- package/dist/server/services/attachments.d.ts +80 -0
- package/dist/server/services/attachments.d.ts.map +1 -0
- package/dist/server/services/attachments.js +182 -0
- package/dist/server/services/attachments.js.map +1 -0
- package/dist/server/services/audience.d.ts +15 -0
- package/dist/server/services/audience.d.ts.map +1 -0
- package/dist/server/services/audience.js +64 -0
- package/dist/server/services/audience.js.map +1 -0
- package/dist/server/services/categories.d.ts +57 -0
- package/dist/server/services/categories.d.ts.map +1 -0
- package/dist/server/services/categories.js +124 -0
- package/dist/server/services/categories.js.map +1 -0
- package/dist/server/services/cursor.d.ts +68 -0
- package/dist/server/services/cursor.d.ts.map +1 -0
- package/dist/server/services/cursor.js +39 -0
- package/dist/server/services/cursor.js.map +1 -0
- package/dist/server/services/custody.d.ts +175 -0
- package/dist/server/services/custody.d.ts.map +1 -0
- package/dist/server/services/custody.js +367 -0
- package/dist/server/services/custody.js.map +1 -0
- package/dist/server/services/db-errors.d.ts +7 -0
- package/dist/server/services/db-errors.d.ts.map +1 -0
- package/dist/server/services/db-errors.js +32 -0
- package/dist/server/services/db-errors.js.map +1 -0
- package/dist/server/services/index.d.ts +16 -0
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +25 -1
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/members.d.ts +27 -0
- package/dist/server/services/members.d.ts.map +1 -0
- package/dist/server/services/members.js +39 -0
- package/dist/server/services/members.js.map +1 -0
- package/dist/server/services/notify.d.ts +43 -1
- package/dist/server/services/notify.d.ts.map +1 -1
- package/dist/server/services/notify.js +47 -5
- package/dist/server/services/notify.js.map +1 -1
- package/dist/server/services/offboarding.d.ts +70 -0
- package/dist/server/services/offboarding.d.ts.map +1 -0
- package/dist/server/services/offboarding.js +116 -0
- package/dist/server/services/offboarding.js.map +1 -0
- package/dist/server/services/repairs.d.ts +204 -0
- package/dist/server/services/repairs.d.ts.map +1 -0
- package/dist/server/services/repairs.js +476 -0
- package/dist/server/services/repairs.js.map +1 -0
- package/dist/server/services/search.d.ts +85 -0
- package/dist/server/services/search.d.ts.map +1 -0
- package/dist/server/services/search.js +142 -0
- package/dist/server/services/search.js.map +1 -0
- package/dist/server/services/stats.d.ts +42 -0
- package/dist/server/services/stats.d.ts.map +1 -0
- package/dist/server/services/stats.js +80 -0
- package/dist/server/services/stats.js.map +1 -0
- package/dist/server/services/status.d.ts +102 -0
- package/dist/server/services/status.d.ts.map +1 -0
- package/dist/server/services/status.js +71 -0
- package/dist/server/services/status.js.map +1 -0
- package/migrations/0002_custody_and_categories.sql +23 -0
- package/migrations/0003_repairs.sql +23 -0
- package/migrations/0004_platform_surfaces.sql +51 -0
- package/migrations/0005_repair_dates.sql +35 -0
- package/migrations/0006_workspace_registry_read.sql +50 -0
- package/migrations/0007_history_sequence.sql +83 -0
- package/migrations/meta/0002_snapshot.json +1054 -0
- package/migrations/meta/0003_snapshot.json +1070 -0
- package/migrations/meta/0004_snapshot.json +1130 -0
- package/migrations/meta/0005_snapshot.json +1135 -0
- package/migrations/meta/_journal.json +42 -0
- package/package.json +1 -1
- package/src/client/api-instance.ts +27 -2
- package/src/client/bidi.test.ts +148 -0
- package/src/client/bidi.ts +85 -0
- package/src/client/components/AssetDetailPanel.svelte +614 -0
- package/src/client/components/AssetFormDialog.svelte +71 -22
- package/src/client/components/AssetPhoto.svelte +178 -0
- package/src/client/components/AttachmentsSection.svelte +327 -0
- package/src/client/components/CustodyDialog.svelte +201 -0
- package/src/client/components/RepairDialog.svelte +271 -0
- package/src/client/components/RepairsSection.svelte +318 -0
- package/src/client/components/Timeline.svelte +347 -0
- package/src/client/components/TimelineText.svelte +124 -0
- package/src/client/core-api.ts +71 -0
- package/src/client/custody.test.ts +31 -0
- package/src/client/custody.ts +34 -0
- package/src/client/errors.test.ts +365 -0
- package/src/client/errors.ts +201 -0
- package/src/client/links.test.ts +74 -0
- package/src/client/links.ts +44 -0
- package/src/client/members.test.ts +132 -0
- package/src/client/members.ts +116 -0
- package/src/client/messages.test.ts +127 -0
- package/src/client/messages.ts +1043 -18
- package/src/client/mock.test.ts +395 -1
- package/src/client/mock.ts +1066 -79
- package/src/client/module.ts +54 -0
- package/src/client/pages/AssetsPage.svelte +317 -45
- package/src/client/permissions.ts +7 -0
- package/src/client/price.test.ts +151 -3
- package/src/client/price.ts +155 -11
- package/src/client/query.ts +36 -0
- package/src/client/repairs.test.ts +38 -0
- package/src/client/repairs.ts +38 -0
- package/src/client/settings/CategoriesSettings.svelte +421 -0
- package/src/client/settings/GeneralSettings.svelte +0 -0
- package/src/client/status.ts +29 -0
- package/src/client/timeline.test.ts +175 -0
- package/src/client/timeline.ts +206 -0
- package/src/client/widgets/OverviewWidget.svelte +127 -26
- package/src/client/widgets/RepairsWidget.svelte +124 -0
- package/src/contract/capabilities.ts +53 -9
- package/src/contract/events.ts +49 -0
- package/src/contract/index.ts +1 -0
- package/src/contract/models.ts +237 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +55 -2
- package/src/contract/router.ts +255 -1
- package/src/contract/settings.ts +24 -4
- package/src/module.test.ts +194 -4
- package/src/server/index.ts +311 -4
- package/src/server/inventory.int.test.ts +3573 -18
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +113 -0
- package/src/server/router.ts +462 -6
- package/src/server/schema.ts +160 -3
- package/src/server/services/assets.ts +214 -54
- package/src/server/services/attachments.ts +215 -0
- package/src/server/services/audience.ts +77 -0
- package/src/server/services/categories.ts +136 -0
- package/src/server/services/cursor.ts +104 -0
- package/src/server/services/custody.ts +471 -0
- package/src/server/services/db-errors.ts +42 -0
- package/src/server/services/index.ts +34 -1
- package/src/server/services/members.ts +54 -0
- package/src/server/services/notify.ts +52 -7
- package/src/server/services/offboarding.ts +150 -0
- package/src/server/services/repairs.ts +567 -0
- package/src/server/services/search.ts +166 -0
- package/src/server/services/stats.ts +88 -0
- package/src/server/services/status.test.ts +34 -0
- package/src/server/services/status.ts +143 -0
- package/src/client/settings/core-api.ts +0 -32
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audience.d.ts","sourceRoot":"","sources":["../../../src/server/services/audience.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAA;AAuC5C;;;;;;;;;;;GAWG;AACH,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,MAAM,EAAE,CAAC,CAoBnB"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who to tell when the thing that happened belongs to the workspace rather than to a person.
|
|
3
|
+
*
|
|
4
|
+
* Custody notifications have an obvious recipient — the person the item was handed to. A warranty
|
|
5
|
+
* running out on a spare in a cupboard, or a repair nobody has chased, does not: it belongs to
|
|
6
|
+
* whoever looks after the register. There is no "the office manager" field, and inventing one would
|
|
7
|
+
* be a setting somebody has to fill in before the feature works at all, so the question is asked of
|
|
8
|
+
* the permission system instead: **who may actually do the thing this message is asking for.**
|
|
9
|
+
*
|
|
10
|
+
* A warranty notice asks somebody to renew or replace, which is `inventory.asset.manage`. An overdue
|
|
11
|
+
* repair asks somebody to chase the vendor, which is `inventory.repair.manage`. Somebody leaving
|
|
12
|
+
* with a laptop asks somebody to take it back, which is `inventory.custody.manage`. Each message
|
|
13
|
+
* names the key it needs rather than settling for "the admins", because a workspace that gave its
|
|
14
|
+
* office manager a custom role did that on purpose.
|
|
15
|
+
*/
|
|
16
|
+
/** Most senior first, so the cap below keeps the people most likely to act. */
|
|
17
|
+
const ROLE_RANK = { owner: 3, admin: 2, member: 1, guest: 0 };
|
|
18
|
+
/**
|
|
19
|
+
* How many people one workspace-level notification may reach.
|
|
20
|
+
*
|
|
21
|
+
* A message sent to five thousand people is not a notification, it is a mailing list nobody reads —
|
|
22
|
+
* and on a large instance every member holds `inventory.asset.manage` by default, so an uncapped
|
|
23
|
+
* audience would be exactly that. Twenty is well past any real "who looks after the register" group
|
|
24
|
+
* and small enough that the check below stays a handful of calls rather than one per seat.
|
|
25
|
+
*
|
|
26
|
+
* The cap is applied **after** sorting by role, so the people it keeps are the senior ones rather
|
|
27
|
+
* than whichever rows the database happened to return first.
|
|
28
|
+
*/
|
|
29
|
+
const RECIPIENT_CAP = 20;
|
|
30
|
+
/**
|
|
31
|
+
* The members of a workspace who genuinely hold a permission.
|
|
32
|
+
*
|
|
33
|
+
* Genuinely, not "hold the role it defaults to": `kernel.authz.can` reads custom roles and scope
|
|
34
|
+
* bindings as well as the built-in defaults, so a workspace that took `inventory.repair.manage` away
|
|
35
|
+
* from `member` and gave it to one custom role is answered correctly. Guessing from the role would
|
|
36
|
+
* have been one local call instead of a handful, and would have quietly told the wrong people.
|
|
37
|
+
*
|
|
38
|
+
* Every failure here is swallowed: this decides an audience for a best-effort notification, and a
|
|
39
|
+
* member whose principal core cannot produce right now is one fewer recipient, not a reason for a
|
|
40
|
+
* nightly sweep to stop half-way through a workspace.
|
|
41
|
+
*/
|
|
42
|
+
export async function membersWithPermission(kernel, workspaceId, permission) {
|
|
43
|
+
const members = await kernel
|
|
44
|
+
.call('core.workspaces.members', { workspaceId })
|
|
45
|
+
.catch(() => []);
|
|
46
|
+
const ordered = [...members].sort((a, b) => (ROLE_RANK[b.role] ?? 0) - (ROLE_RANK[a.role] ?? 0));
|
|
47
|
+
const allowed = [];
|
|
48
|
+
for (const member of ordered) {
|
|
49
|
+
if (allowed.length >= RECIPIENT_CAP)
|
|
50
|
+
break;
|
|
51
|
+
const principal = await kernel
|
|
52
|
+
.call('core.users.principal', { userId: member.userId })
|
|
53
|
+
.catch(() => null);
|
|
54
|
+
if (!principal)
|
|
55
|
+
continue;
|
|
56
|
+
const can = await kernel.authz
|
|
57
|
+
.can(principal, permission, { kind: 'workspace', workspaceId })
|
|
58
|
+
.catch(() => false);
|
|
59
|
+
if (can)
|
|
60
|
+
allowed.push(member.userId);
|
|
61
|
+
}
|
|
62
|
+
return allowed;
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=audience.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audience.js","sourceRoot":"","sources":["../../../src/server/services/audience.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;GAcG;AAEH,+EAA+E;AAC/E,MAAM,SAAS,GAAgC,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAA;AAE1F;;;;;;;;;;GAUG;AACH,MAAM,aAAa,GAAG,EAAE,CAAA;AAOxB;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB,CACzC,MAAc,EACd,WAAmB,EACnB,UAAkB;IAElB,MAAM,OAAO,GAAG,MAAM,MAAM;SACzB,IAAI,CAAW,yBAAyB,EAAE,EAAE,WAAW,EAAE,CAAC;SAC1D,KAAK,CAAC,GAAG,EAAE,CAAC,EAAc,CAAC,CAAA;IAE9B,MAAM,OAAO,GAAG,CAAC,GAAG,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IAEhG,MAAM,OAAO,GAAa,EAAE,CAAA;IAC5B,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,IAAI,OAAO,CAAC,MAAM,IAAI,aAAa;YAAE,MAAK;QAC1C,MAAM,SAAS,GAAG,MAAM,MAAM;aAC3B,IAAI,CAAmB,sBAAsB,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC;aACzE,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAA;QACpB,IAAI,CAAC,SAAS;YAAE,SAAQ;QACxB,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,KAAK;aAC3B,GAAG,CAAC,SAAS,EAAE,UAAU,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,WAAW,EAAE,CAAC;aAC9D,KAAK,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,CAAA;QACrB,IAAI,GAAG;YAAE,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;IACtC,CAAC;IACD,OAAO,OAAO,CAAA;AAChB,CAAC"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type Tx } from '@kernhq/kernel';
|
|
2
|
+
import type { Category as CategoryModel } from '../../contract/models.js';
|
|
3
|
+
import { categories } from '../schema.js';
|
|
4
|
+
type Row = typeof categories.$inferSelect;
|
|
5
|
+
/** The wire shape: drizzle gives Date objects for timestamps, the contract promises ISO strings. */
|
|
6
|
+
export declare function toCategory(row: Row): CategoryModel;
|
|
7
|
+
/**
|
|
8
|
+
* How a workspace groups what it owns.
|
|
9
|
+
*
|
|
10
|
+
* Small on purpose. A category is a name and a position in a list; everything interesting about an
|
|
11
|
+
* asset belongs to the asset. The one thing here worth reading twice is that nothing deletes — see
|
|
12
|
+
* `archive` below.
|
|
13
|
+
*/
|
|
14
|
+
export declare class CategoryService {
|
|
15
|
+
/**
|
|
16
|
+
* Ordered by `order` and then by name, which is what makes a workspace that never touches the
|
|
17
|
+
* order field still get an alphabetical picker rather than an arbitrary one — every row has
|
|
18
|
+
* `order` 0, so the tiebreak is doing all the work and has to be a name rather than an id.
|
|
19
|
+
*/
|
|
20
|
+
list(tx: Tx, workspaceId: string, includeArchived: boolean): Promise<CategoryModel[]>;
|
|
21
|
+
get(tx: Tx, workspaceId: string, categoryId: string): Promise<Row>;
|
|
22
|
+
/**
|
|
23
|
+
* A duplicate name is a `CONFLICT` with the name in it, never a 500.
|
|
24
|
+
*
|
|
25
|
+
* The unique index is what actually decides — checking first and inserting after is a race that
|
|
26
|
+
* two people adding "Laptops" at once will find — so the check is the insert, and the driver's
|
|
27
|
+
* 23505 is translated into a sentence rather than shown as "Failed query: insert into
|
|
28
|
+
* mod_inventory.categories …".
|
|
29
|
+
*/
|
|
30
|
+
create(tx: Tx, workspaceId: string, name: string, order: number): Promise<Row>;
|
|
31
|
+
update(tx: Tx, workspaceId: string, categoryId: string, patch: {
|
|
32
|
+
name?: string;
|
|
33
|
+
order?: number;
|
|
34
|
+
}): Promise<Row>;
|
|
35
|
+
/**
|
|
36
|
+
* Archive and restore, which are one procedure because they are one column.
|
|
37
|
+
*
|
|
38
|
+
* **Nothing here deletes, and that is the decision this file exists to record.**
|
|
39
|
+
* `assets.category_id` carries no foreign key — a module keeps its ids plain — so a delete would
|
|
40
|
+
* leave every asset filed under this category pointing at a row that is not there: a blank column
|
|
41
|
+
* on the row, a picker that cannot explain what the asset used to be, and an `asset_history`
|
|
42
|
+
* entry saying "category changed to <nothing>". None of it recoverable, all of it caused by a
|
|
43
|
+
* settings screen. An archived category disappears from every picker and every filter and leaves
|
|
44
|
+
* each asset able to say what it is.
|
|
45
|
+
*/
|
|
46
|
+
archive(tx: Tx, workspaceId: string, categoryId: string, archived: boolean): Promise<Row>;
|
|
47
|
+
/**
|
|
48
|
+
* The unique index refused it, or something else did and must not be disguised.
|
|
49
|
+
*
|
|
50
|
+
* Rethrowing the original for anything that is not this constraint matters: turning every failed
|
|
51
|
+
* insert into "that name is taken" would hide a real fault behind a sentence a person would act
|
|
52
|
+
* on by renaming something, for ever.
|
|
53
|
+
*/
|
|
54
|
+
private static nameTaken;
|
|
55
|
+
}
|
|
56
|
+
export {};
|
|
57
|
+
//# sourceMappingURL=categories.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"categories.d.ts","sourceRoot":"","sources":["../../../src/server/services/categories.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,KAAK,EAAE,EAAU,MAAM,gBAAgB,CAAA;AAE3D,OAAO,KAAK,EAAE,QAAQ,IAAI,aAAa,EAAE,MAAM,0BAA0B,CAAA;AACzE,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAGzC,KAAK,GAAG,GAAG,OAAO,UAAU,CAAC,YAAY,CAAA;AAKzC,oGAAoG;AACpG,wBAAgB,UAAU,CAAC,GAAG,EAAE,GAAG,GAAG,aAAa,CAUlD;AAED;;;;;;GAMG;AACH,qBAAa,eAAe;IAC1B;;;;OAIG;IACG,IAAI,CAAC,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,eAAe,EAAE,OAAO,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC;IAWrF,GAAG,CAAC,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC;IASxE;;;;;;;OAOG;IACG,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC;IAS9E,MAAM,CACV,EAAE,EAAE,EAAE,EACN,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GACvC,OAAO,CAAC,GAAG,CAAC;IAqBf;;;;;;;;;;OAUG;IACG,OAAO,CAAC,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC;IAU/F;;;;;;OAMG;IACH,OAAO,CAAC,MAAM,CAAC,SAAS;CAOzB"}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { KernError, uuidv7 } from '@kernhq/kernel';
|
|
2
|
+
import { and, asc, eq, isNull } from 'drizzle-orm';
|
|
3
|
+
import { categories } from '../schema.js';
|
|
4
|
+
import { violated } from './db-errors.js';
|
|
5
|
+
/** The unique index `0000_init.sql` put on (workspace_id, name). */
|
|
6
|
+
const NAME_TAKEN = 'inventory_categories_ws_name_uq';
|
|
7
|
+
/** The wire shape: drizzle gives Date objects for timestamps, the contract promises ISO strings. */
|
|
8
|
+
export function toCategory(row) {
|
|
9
|
+
return {
|
|
10
|
+
id: row.id,
|
|
11
|
+
workspaceId: row.workspaceId,
|
|
12
|
+
name: row.name,
|
|
13
|
+
order: row.order,
|
|
14
|
+
createdAt: row.createdAt.toISOString(),
|
|
15
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
16
|
+
archivedAt: row.archivedAt?.toISOString() ?? null,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* How a workspace groups what it owns.
|
|
21
|
+
*
|
|
22
|
+
* Small on purpose. A category is a name and a position in a list; everything interesting about an
|
|
23
|
+
* asset belongs to the asset. The one thing here worth reading twice is that nothing deletes — see
|
|
24
|
+
* `archive` below.
|
|
25
|
+
*/
|
|
26
|
+
export class CategoryService {
|
|
27
|
+
/**
|
|
28
|
+
* Ordered by `order` and then by name, which is what makes a workspace that never touches the
|
|
29
|
+
* order field still get an alphabetical picker rather than an arbitrary one — every row has
|
|
30
|
+
* `order` 0, so the tiebreak is doing all the work and has to be a name rather than an id.
|
|
31
|
+
*/
|
|
32
|
+
async list(tx, workspaceId, includeArchived) {
|
|
33
|
+
const filters = [eq(categories.workspaceId, workspaceId)];
|
|
34
|
+
if (!includeArchived)
|
|
35
|
+
filters.push(isNull(categories.archivedAt));
|
|
36
|
+
const rows = await tx
|
|
37
|
+
.select()
|
|
38
|
+
.from(categories)
|
|
39
|
+
.where(and(...filters))
|
|
40
|
+
.orderBy(asc(categories.order), asc(categories.name));
|
|
41
|
+
return rows.map(toCategory);
|
|
42
|
+
}
|
|
43
|
+
async get(tx, workspaceId, categoryId) {
|
|
44
|
+
const [row] = await tx
|
|
45
|
+
.select()
|
|
46
|
+
.from(categories)
|
|
47
|
+
.where(and(eq(categories.workspaceId, workspaceId), eq(categories.id, categoryId)));
|
|
48
|
+
if (!row)
|
|
49
|
+
throw KernError.notFound('Category');
|
|
50
|
+
return row;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A duplicate name is a `CONFLICT` with the name in it, never a 500.
|
|
54
|
+
*
|
|
55
|
+
* The unique index is what actually decides — checking first and inserting after is a race that
|
|
56
|
+
* two people adding "Laptops" at once will find — so the check is the insert, and the driver's
|
|
57
|
+
* 23505 is translated into a sentence rather than shown as "Failed query: insert into
|
|
58
|
+
* mod_inventory.categories …".
|
|
59
|
+
*/
|
|
60
|
+
async create(tx, workspaceId, name, order) {
|
|
61
|
+
try {
|
|
62
|
+
const [row] = await tx.insert(categories).values({ id: uuidv7(), workspaceId, name, order }).returning();
|
|
63
|
+
return row;
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
throw CategoryService.nameTaken(err, name);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async update(tx, workspaceId, categoryId, patch) {
|
|
70
|
+
const previous = await this.get(tx, workspaceId, categoryId);
|
|
71
|
+
// `undefined` means "not mentioned". Neither field is nullable, so there is no "clear it" here
|
|
72
|
+
// and no reason for the `null`-versus-`undefined` care `assets.update` needs.
|
|
73
|
+
const values = {
|
|
74
|
+
name: patch.name ?? previous.name,
|
|
75
|
+
order: patch.order ?? previous.order,
|
|
76
|
+
updatedAt: new Date(),
|
|
77
|
+
};
|
|
78
|
+
try {
|
|
79
|
+
const [row] = await tx
|
|
80
|
+
.update(categories)
|
|
81
|
+
.set(values)
|
|
82
|
+
.where(and(eq(categories.workspaceId, workspaceId), eq(categories.id, categoryId)))
|
|
83
|
+
.returning();
|
|
84
|
+
return row;
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
throw CategoryService.nameTaken(err, values.name);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Archive and restore, which are one procedure because they are one column.
|
|
92
|
+
*
|
|
93
|
+
* **Nothing here deletes, and that is the decision this file exists to record.**
|
|
94
|
+
* `assets.category_id` carries no foreign key — a module keeps its ids plain — so a delete would
|
|
95
|
+
* leave every asset filed under this category pointing at a row that is not there: a blank column
|
|
96
|
+
* on the row, a picker that cannot explain what the asset used to be, and an `asset_history`
|
|
97
|
+
* entry saying "category changed to <nothing>". None of it recoverable, all of it caused by a
|
|
98
|
+
* settings screen. An archived category disappears from every picker and every filter and leaves
|
|
99
|
+
* each asset able to say what it is.
|
|
100
|
+
*/
|
|
101
|
+
async archive(tx, workspaceId, categoryId, archived) {
|
|
102
|
+
const [row] = await tx
|
|
103
|
+
.update(categories)
|
|
104
|
+
.set({ archivedAt: archived ? new Date() : null, updatedAt: new Date() })
|
|
105
|
+
.where(and(eq(categories.workspaceId, workspaceId), eq(categories.id, categoryId)))
|
|
106
|
+
.returning();
|
|
107
|
+
if (!row)
|
|
108
|
+
throw KernError.notFound('Category');
|
|
109
|
+
return row;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The unique index refused it, or something else did and must not be disguised.
|
|
113
|
+
*
|
|
114
|
+
* Rethrowing the original for anything that is not this constraint matters: turning every failed
|
|
115
|
+
* insert into "that name is taken" would hide a real fault behind a sentence a person would act
|
|
116
|
+
* on by renaming something, for ever.
|
|
117
|
+
*/
|
|
118
|
+
static nameTaken(err, name) {
|
|
119
|
+
if (!violated(err, NAME_TAKEN))
|
|
120
|
+
return err;
|
|
121
|
+
return KernError.conflict(`This workspace already has a category called “${name}”.`, 'inventory.category.name_taken');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=categories.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"categories.js","sourceRoot":"","sources":["../../../src/server/services/categories.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one page boundary this module issues, for every list it pages.
|
|
3
|
+
*
|
|
4
|
+
* **The bookmarked row's id, and the sort it was issued under.** Nothing else — in particular not
|
|
5
|
+
* the sort key itself, which is what it used to carry. That earlier shape (`{k: <sort key>, i:
|
|
6
|
+
* <id>}`) was wrong three separate ways, each of them reachable by anyone who could type into the
|
|
7
|
+
* address bar:
|
|
8
|
+
*
|
|
9
|
+
* - **It could not be trusted.** `decode` checked only that `i` was a *string*, and `i` is
|
|
10
|
+
* interpolated into `(col, id) < ($1, $2::uuid)`. `{"k":"x","i":"not-a-uuid"}` therefore
|
|
11
|
+
* reached Postgres as a 22P02 nobody caught — an unhandled 500 and an error-level log line per
|
|
12
|
+
* request, at the 600-a-minute the rate limiter allows.
|
|
13
|
+
* - **It was not bound to its sort.** A cursor issued under `sort=recent` replayed under
|
|
14
|
+
* `sort=code` compared a uuid against a code, so page two came back equal to page one and
|
|
15
|
+
* "Load more" never ended.
|
|
16
|
+
* - **It was unbounded.** `sort=name` on a 200-character Persian name encoded to 602 characters,
|
|
17
|
+
* and `Cursor` in `@kernhq/contracts` is `max(512)` — so a long enough name broke "Load more"
|
|
18
|
+
* with a validation error, in exactly the locales least likely to be tested.
|
|
19
|
+
*
|
|
20
|
+
* Carrying the id alone and reading the sort key back from that row in SQL answers all three: the
|
|
21
|
+
* cursor is a fixed ~60 characters whatever the name, a value that is not a uuid is refused before
|
|
22
|
+
* it is anywhere near the database, and a cursor whose sort disagrees with the request is refused
|
|
23
|
+
* rather than quietly misread. Base64 so nothing in the product is tempted to read it — it is a
|
|
24
|
+
* bookmark, not an offset, and its shape is this file's business.
|
|
25
|
+
*
|
|
26
|
+
* It lives here rather than in `assets.ts` because the repair list pages too, and a second bookmark
|
|
27
|
+
* format would be a second set of those three bugs. `src/client/mock.ts` mirrors this byte for
|
|
28
|
+
* byte; read them as one pair and change them as one pair.
|
|
29
|
+
*
|
|
30
|
+
* The asset timeline is the one list that does **not** use this one: it is ordered by a sequence
|
|
31
|
+
* rather than by a row id, so its bookmark is `SeqBookmark` below.
|
|
32
|
+
*/
|
|
33
|
+
export interface Bookmark<S extends string> {
|
|
34
|
+
i: string;
|
|
35
|
+
s: S;
|
|
36
|
+
}
|
|
37
|
+
export declare const encodeMark: <S extends string>(mark: Bookmark<S>) => string;
|
|
38
|
+
export declare function decodeMark<S extends string>(cursor: string, sort: S): Bookmark<S>;
|
|
39
|
+
/**
|
|
40
|
+
* The other page boundary: a row's **sequence number**, and the sort it was issued under.
|
|
41
|
+
*
|
|
42
|
+
* The asset timeline is ordered by `asset_history.seq` rather than by the row id, because a uuidv7
|
|
43
|
+
* is only ordered to the millisecond and two entries written inside one — an attach and a detach, a
|
|
44
|
+
* create and its first entry — sort by ten random bytes. `schema.ts` argues that at the column. A
|
|
45
|
+
* bookmark into that ordering has to name the sequence value, so it is a number rather than a uuid
|
|
46
|
+
* and needs its own codec: handing `decodeMark` a number would have it refused as "not a uuid", and
|
|
47
|
+
* relaxing `decodeMark` to accept either is how one of the two shapes ends up unvalidated.
|
|
48
|
+
*
|
|
49
|
+
* It keeps all three properties the uuid bookmark has, and for the same reasons:
|
|
50
|
+
*
|
|
51
|
+
* - **bounded** — a base64url of `{"n":<integer>,"s":"recent"}` is around forty characters
|
|
52
|
+
* whatever the row, well inside `Cursor`'s `max(512)` in `@kernhq/contracts`;
|
|
53
|
+
* - **checked before it reaches SQL** — anything that is not a safe positive integer is refused
|
|
54
|
+
* here rather than interpolated into a `bigint` comparison for Postgres to reject as a 22P02;
|
|
55
|
+
* - **bound to its sort** — a bookmark issued under one ordering is meaningless under another,
|
|
56
|
+
* and reading it anyway is what made "Load more" loop for ever.
|
|
57
|
+
*
|
|
58
|
+
* `Number.isSafeInteger` is the upper bound as well as the type check: the column is a `bigint` and
|
|
59
|
+
* the sequence would have to hand out nine quadrillion history rows to reach it, but a cursor
|
|
60
|
+
* arrives from outside and `JSON.parse` will happily produce `1e400`.
|
|
61
|
+
*/
|
|
62
|
+
export interface SeqBookmark<S extends string> {
|
|
63
|
+
n: number;
|
|
64
|
+
s: S;
|
|
65
|
+
}
|
|
66
|
+
export declare const encodeSeqMark: <S extends string>(mark: SeqBookmark<S>) => string;
|
|
67
|
+
export declare function decodeSeqMark<S extends string>(cursor: string, sort: S): SeqBookmark<S>;
|
|
68
|
+
//# sourceMappingURL=cursor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cursor.d.ts","sourceRoot":"","sources":["../../../src/server/services/cursor.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAM,WAAW,QAAQ,CAAC,CAAC,SAAS,MAAM;IACxC,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,CAAC,CAAA;CACL;AAED,eAAO,MAAM,UAAU,GAAI,CAAC,SAAS,MAAM,EAAE,MAAM,QAAQ,CAAC,CAAC,CAAC,KAAG,MACA,CAAA;AAKjE,wBAAgB,UAAU,CAAC,CAAC,SAAS,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAcjF;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,WAAW,WAAW,CAAC,CAAC,SAAS,MAAM;IAC3C,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,CAAC,CAAA;CACL;AAED,eAAO,MAAM,aAAa,GAAI,CAAC,SAAS,MAAM,EAAE,MAAM,WAAW,CAAC,CAAC,CAAC,KAAG,MACN,CAAA;AAEjE,wBAAgB,aAAa,CAAC,CAAC,SAAS,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,GAAG,WAAW,CAAC,CAAC,CAAC,CAWvF"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { KernError } from '@kernhq/kernel';
|
|
2
|
+
export const encodeMark = (mark) => Buffer.from(JSON.stringify(mark), 'utf8').toString('base64url');
|
|
3
|
+
/** Cheap and total, and it runs before the value can reach a `::uuid` cast. */
|
|
4
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
5
|
+
export function decodeMark(cursor, sort) {
|
|
6
|
+
const refuse = () => KernError.badRequest('That page marker is not one this list issued');
|
|
7
|
+
let parsed;
|
|
8
|
+
try {
|
|
9
|
+
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
throw refuse();
|
|
13
|
+
}
|
|
14
|
+
if (typeof parsed?.i !== 'string' || !UUID.test(parsed.i))
|
|
15
|
+
throw refuse();
|
|
16
|
+
// One comparison covers both a sort this list never issues and a sort it issued under a
|
|
17
|
+
// *different* request. Either way the bookmark cannot be read against the ordering asked for,
|
|
18
|
+
// and pretending otherwise is what made "Load more" loop for ever.
|
|
19
|
+
if (parsed.s !== sort)
|
|
20
|
+
throw refuse();
|
|
21
|
+
return { i: parsed.i, s: sort };
|
|
22
|
+
}
|
|
23
|
+
export const encodeSeqMark = (mark) => Buffer.from(JSON.stringify(mark), 'utf8').toString('base64url');
|
|
24
|
+
export function decodeSeqMark(cursor, sort) {
|
|
25
|
+
const refuse = () => KernError.badRequest('That page marker is not one this list issued');
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw refuse();
|
|
32
|
+
}
|
|
33
|
+
if (typeof parsed?.n !== 'number' || !Number.isSafeInteger(parsed.n) || parsed.n < 1)
|
|
34
|
+
throw refuse();
|
|
35
|
+
if (parsed.s !== sort)
|
|
36
|
+
throw refuse();
|
|
37
|
+
return { n: parsed.n, s: sort };
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=cursor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cursor.js","sourceRoot":"","sources":["../../../src/server/services/cursor.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAuC1C,MAAM,CAAC,MAAM,UAAU,GAAG,CAAmB,IAAiB,EAAU,EAAE,CACxE,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAA;AAEjE,+EAA+E;AAC/E,MAAM,IAAI,GAAG,iEAAiE,CAAA;AAE9E,MAAM,UAAU,UAAU,CAAmB,MAAc,EAAE,IAAO;IAClE,MAAM,MAAM,GAAG,GAAG,EAAE,CAAC,SAAS,CAAC,UAAU,CAAC,8CAA8C,CAAC,CAAA;IACzF,IAAI,MAAmC,CAAA;IACvC,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAgC,CAAA;IACvG,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,MAAM,EAAE,CAAA;IAChB,CAAC;IACD,IAAI,OAAO,MAAM,EAAE,CAAC,KAAK,QAAQ,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC;QAAE,MAAM,MAAM,EAAE,CAAA;IACzE,wFAAwF;IACxF,8FAA8F;IAC9F,mEAAmE;IACnE,IAAI,MAAM,CAAC,CAAC,KAAK,IAAI;QAAE,MAAM,MAAM,EAAE,CAAA;IACrC,OAAO,EAAE,CAAC,EAAE,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,IAAI,EAAE,CAAA;AACjC,CAAC;AA8BD,MAAM,CAAC,MAAM,aAAa,GAAG,CAAmB,IAAoB,EAAU,EAAE,CAC9E,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAA;AAEjE,MAAM,UAAU,aAAa,CAAmB,MAAc,EAAE,IAAO;IACrE,MAAM,MAAM,GAAG,GAAG,EAAE,CAAC,SAAS,CAAC,UAAU,CAAC,8CAA8C,CAAC,CAAA;IACzF,IAAI,MAAsC,CAAA;IAC1C,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAmC,CAAA;IAC1G,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,MAAM,EAAE,CAAA;IAChB,CAAC;IACD,IAAI,OAAO,MAAM,EAAE,CAAC,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,MAAM,CAAC,CAAC,GAAG,CAAC;QAAE,MAAM,MAAM,EAAE,CAAA;IACpG,IAAI,MAAM,CAAC,CAAC,KAAK,IAAI;QAAE,MAAM,MAAM,EAAE,CAAA;IACrC,OAAO,EAAE,CAAC,EAAE,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,IAAI,EAAE,CAAA;AACjC,CAAC"}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { type Tx } from '@kernhq/kernel';
|
|
2
|
+
import type { CustodyPeriod as CustodyPeriodModel } from '../../contract/models.js';
|
|
3
|
+
import { assets, custodyPeriods } from '../schema.js';
|
|
4
|
+
import type { HistoryInput, NotifyService } from './notify.js';
|
|
5
|
+
type AssetRow = typeof assets.$inferSelect;
|
|
6
|
+
type PeriodRow = typeof custodyPeriods.$inferSelect;
|
|
7
|
+
/** The wire shape: drizzle gives Date objects for timestamps, the contract promises ISO strings. */
|
|
8
|
+
export declare function toCustodyPeriod(row: PeriodRow): CustodyPeriodModel;
|
|
9
|
+
/**
|
|
10
|
+
* What one custody change wrote, and what may only leave the module once it has committed.
|
|
11
|
+
*
|
|
12
|
+
* `previousUserId` and `userId` are what `inventory.custody.changed` carries, and between them they
|
|
13
|
+
* say which of the three verbs happened without a fourth field claiming to: out from stock has no
|
|
14
|
+
* previous, back to stock has no next, a hand-on has both.
|
|
15
|
+
*/
|
|
16
|
+
export interface CustodyWritten {
|
|
17
|
+
asset: AssetRow;
|
|
18
|
+
/** The period this call opened. Null on a return: something closed, nothing opened. */
|
|
19
|
+
period: PeriodRow | null;
|
|
20
|
+
userId: string | null;
|
|
21
|
+
previousUserId: string | null;
|
|
22
|
+
activity: HistoryInput;
|
|
23
|
+
/** Who to tell, which is the recipient and never the person doing the handing. */
|
|
24
|
+
notifyUserId: string | null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Who is holding what, over time.
|
|
28
|
+
*
|
|
29
|
+
* Effective-dated the way HR keeps employments: **nothing is ever updated in place**. A change
|
|
30
|
+
* closes the open row and inserts a new one, so the answer to "who had this laptop in March" is a
|
|
31
|
+
* row that still exists rather than a value that was overwritten.
|
|
32
|
+
*
|
|
33
|
+
* Three things happen in one transaction on every change, and the whole point of the class is that
|
|
34
|
+
* they cannot come apart: the period rows move, `assets.custodian_user_id`/`custody_since`/`status`
|
|
35
|
+
* are brought into step with them, and an `asset_history` entry records it. The denormalised
|
|
36
|
+
* columns on `assets` are what the list filters and the widget read; they are correct because they
|
|
37
|
+
* are written here, in the same transaction, and never by a job afterwards.
|
|
38
|
+
*
|
|
39
|
+
* **A repair never refuses a handover.** An item at the repairer is still somebody's
|
|
40
|
+
* responsibility, so none of the three verbs looks at repair state to decide whether it may run —
|
|
41
|
+
* refusing `assign` would mean refusing `return` as well, and somebody leaving the company while
|
|
42
|
+
* their laptop is in the workshop has to be able to hand it back. What repair state *does* affect
|
|
43
|
+
* is the status these three write, which is `deriveStatus`'s job and is argued in `status.ts`.
|
|
44
|
+
*
|
|
45
|
+
* **The database is the arbiter of who won, not this file.** There is deliberately no `select … for
|
|
46
|
+
* update` before the period is inserted. `inventory_custody_no_overlap` — a GiST exclusion
|
|
47
|
+
* constraint on `(asset_id =, tstzrange(effective_from, effective_to, '[)') &&)` — is what makes two
|
|
48
|
+
* open periods impossible, so two people pressing *Hand over* on the same laptop in the same instant
|
|
49
|
+
* both read "nobody has it", both insert, and Postgres refuses exactly one of them. Locking the
|
|
50
|
+
* asset first would serialise them into two successful handovers, which is a worse answer wearing
|
|
51
|
+
* the clothes of a safer one. What this file owes the loser is a sentence they can act on rather
|
|
52
|
+
* than drizzle's "Failed query: insert into mod_inventory.custody_periods …", and that is `refuse()`
|
|
53
|
+
* below.
|
|
54
|
+
*
|
|
55
|
+
* **The asset row *is* locked afterwards, and that is a different job.** `stamp` takes it before it
|
|
56
|
+
* reads the repair state, because `status` is derived from two facts two services write and the
|
|
57
|
+
* winner of the race still has to write an answer nobody can overwrite from a stale snapshot. The
|
|
58
|
+
* distinction is argued at `lockAsset` in `status.ts`: the constraint decides the contest, the lock
|
|
59
|
+
* orders the bookkeeping that follows it.
|
|
60
|
+
*/
|
|
61
|
+
export declare class CustodyService {
|
|
62
|
+
private readonly notify;
|
|
63
|
+
constructor(notify: NotifyService);
|
|
64
|
+
/**
|
|
65
|
+
* The one error a lost race produces, in every place a race can be lost.
|
|
66
|
+
*
|
|
67
|
+
* Actionable rather than apologetic: the reader's screen is now out of date, and the only thing
|
|
68
|
+
* they can do about it is look again. `reason` is stable so a client could eventually say it in
|
|
69
|
+
* the reader's own language; the message is the honest fallback until one does.
|
|
70
|
+
*/
|
|
71
|
+
private static refuse;
|
|
72
|
+
private asset;
|
|
73
|
+
/** The period that has not been closed, if there is one. At most one exists, by construction. */
|
|
74
|
+
open(tx: Tx, workspaceId: string, assetId: string): Promise<PeriodRow | undefined>;
|
|
75
|
+
/**
|
|
76
|
+
* Every period for one asset, newest first.
|
|
77
|
+
*
|
|
78
|
+
* Capped rather than paged: the rows are bounded by how many times one item changed hands, which
|
|
79
|
+
* is tens over its life. Ordered by `effective_from` and then by id, because a hand-on closes one
|
|
80
|
+
* row and opens another at the same instant and the two would otherwise have no order between
|
|
81
|
+
* them — the panel would show the handover before the return it replaced, at random.
|
|
82
|
+
*/
|
|
83
|
+
history(tx: Tx, workspaceId: string, assetId: string, limit: number): Promise<PeriodRow[]>;
|
|
84
|
+
/**
|
|
85
|
+
* Close the open period at `at`, or refuse.
|
|
86
|
+
*
|
|
87
|
+
* `and effective_to is null` in the predicate is the optimistic guard: under READ COMMITTED a
|
|
88
|
+
* concurrent close blocks this statement, and when it resumes the row no longer matches, so zero
|
|
89
|
+
* rows come back rather than a second close silently overwriting the first one's timestamp.
|
|
90
|
+
*/
|
|
91
|
+
private close;
|
|
92
|
+
private openPeriod;
|
|
93
|
+
/**
|
|
94
|
+
* The three denormalised columns, brought into step inside the same transaction.
|
|
95
|
+
*
|
|
96
|
+
* **`status` is not `userId ? 'assigned' : 'in_stock'`, and that line is what this comment is
|
|
97
|
+
* about.** An item can be at a repairer *and* assigned to somebody — the repair does not release
|
|
98
|
+
* whoever is answerable for it — so a handover that wrote `assigned` unconditionally would
|
|
99
|
+
* announce a laptop as back in the office while it was still in the workshop. Both facts are read
|
|
100
|
+
* and `deriveStatus` decides; the rule is argued in full in `status.ts`.
|
|
101
|
+
*
|
|
102
|
+
* **The asset row is locked before the repair state is read, and the order is the point.** Without
|
|
103
|
+
* it a repair completing in another transaction is invisible here and this handover's status is
|
|
104
|
+
* derived from a snapshot that has already stopped being true — the two writes then interleave
|
|
105
|
+
* into a status matching neither. See `lockAsset`.
|
|
106
|
+
*
|
|
107
|
+
* The lock is also what makes `assets.archive`'s refusal real rather than advisory. Archiving
|
|
108
|
+
* reads "nobody is holding it" under the same lock, so a handover racing an archive is ordered
|
|
109
|
+
* against it: whichever gets the lock second sees what the first committed, and the re-check below
|
|
110
|
+
* is what turns that into a refusal instead of an archived asset with an open custody period.
|
|
111
|
+
*
|
|
112
|
+
* @param repairsOn whether the workspace records repairs, read before the transaction opened.
|
|
113
|
+
* A workspace that has switched the capability off has no `under_repair`, because the procedure
|
|
114
|
+
* that would end one answers 404 — `deriveStatus` argues it in full, and this is one of the two
|
|
115
|
+
* write paths that lets an asset out of a status nothing else could move it out of.
|
|
116
|
+
*/
|
|
117
|
+
private stamp;
|
|
118
|
+
/**
|
|
119
|
+
* The latest instant this asset's custody trail already reaches.
|
|
120
|
+
*
|
|
121
|
+
* The end of the most recent closed period, or the start of the open one. One row answers it
|
|
122
|
+
* because the periods for an asset cannot overlap — that is what `inventory_custody_no_overlap`
|
|
123
|
+
* enforces — so the row that starts last also ends last.
|
|
124
|
+
*/
|
|
125
|
+
private boundary;
|
|
126
|
+
/**
|
|
127
|
+
* The instant this change happens, for both halves of it.
|
|
128
|
+
*
|
|
129
|
+
* One value, so the period that closes and the period that opens abut exactly: `'[)'` ranges
|
|
130
|
+
* `[…, at)` and `[at, …)` do not overlap, where two `now()` calls a microsecond apart would leave
|
|
131
|
+
* a gap during which the asset was held by nobody.
|
|
132
|
+
*
|
|
133
|
+
* **It is a millisecond past the trail's own end, not `now()`, and both halves of that are a
|
|
134
|
+
* defect this replaced.**
|
|
135
|
+
*
|
|
136
|
+
* - `max(now, open.effectiveFrom)` produced a **zero-length period** whenever the two were equal,
|
|
137
|
+
* which two handovers inside one millisecond make them — a JS `Date` resolves no finer. `[t, t)`
|
|
138
|
+
* is empty, an empty range overlaps nothing, so the exclusion constraint waves it through and
|
|
139
|
+
* the trail permanently records somebody holding the item for no time at all. Anybody reading
|
|
140
|
+
* "who had this in March" gets a name that was never true. Strictly after the boundary, there
|
|
141
|
+
* is no such row to write.
|
|
142
|
+
* - `assign` used plain `now()`, which a clock that steps backwards puts *before* the end of the
|
|
143
|
+
* last closed period — so the new period overlapped a finished one, Postgres refused it with
|
|
144
|
+
* `23P01`, and the person was told "somebody changed who is holding this a moment before you
|
|
145
|
+
* did. Reload." Nobody had; reloading changes nothing; the handover is refused again every time.
|
|
146
|
+
* Reading the trail's own end rather than the clock makes that unreachable instead of merely
|
|
147
|
+
* better explained: the next period always starts after the last one ended, whatever the clock
|
|
148
|
+
* says.
|
|
149
|
+
*
|
|
150
|
+
* `now` still wins whenever it is ahead, which is every ordinary case — this only ever moves the
|
|
151
|
+
* instant forward, never back, so it cannot manufacture a period that starts before its asset was
|
|
152
|
+
* bought.
|
|
153
|
+
*/
|
|
154
|
+
private instant;
|
|
155
|
+
/**
|
|
156
|
+
* Hand a free item to a member.
|
|
157
|
+
*
|
|
158
|
+
* Refuses when somebody already has it, rather than quietly taking it off them: `transfer` is the
|
|
159
|
+
* procedure that means "hand it on", and collapsing the two would make a mistyped assignment
|
|
160
|
+
* indistinguishable from a deliberate handover in the timeline everyone reads afterwards.
|
|
161
|
+
*/
|
|
162
|
+
assign(tx: Tx, workspaceId: string, actorId: string | null, assetId: string, userId: string, note: string | null, repairsOn: boolean): Promise<CustodyWritten>;
|
|
163
|
+
/**
|
|
164
|
+
* Hand it straight on. One transaction, not a return followed by an assign.
|
|
165
|
+
*
|
|
166
|
+
* Two calls would leave the asset `in_stock` with no custodian in between — visible to anybody
|
|
167
|
+
* reading the list at that moment, and permanently visible in the timeline as a return nobody
|
|
168
|
+
* performed and a stock period nobody spent.
|
|
169
|
+
*/
|
|
170
|
+
transfer(tx: Tx, workspaceId: string, actorId: string | null, assetId: string, userId: string, note: string | null, repairsOn: boolean): Promise<CustodyWritten>;
|
|
171
|
+
/** Take it back. Closes the open period and puts the item back in stock. */
|
|
172
|
+
return(tx: Tx, workspaceId: string, actorId: string | null, assetId: string, note: string | null, repairsOn: boolean): Promise<CustodyWritten>;
|
|
173
|
+
}
|
|
174
|
+
export {};
|
|
175
|
+
//# sourceMappingURL=custody.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"custody.d.ts","sourceRoot":"","sources":["../../../src/server/services/custody.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,KAAK,EAAE,EAAU,MAAM,gBAAgB,CAAA;AAE3D,OAAO,KAAK,EAAE,aAAa,IAAI,kBAAkB,EAAE,MAAM,0BAA0B,CAAA;AACnF,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAErD,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAG9D,KAAK,QAAQ,GAAG,OAAO,MAAM,CAAC,YAAY,CAAA;AAC1C,KAAK,SAAS,GAAG,OAAO,cAAc,CAAC,YAAY,CAAA;AAYnD,oGAAoG;AACpG,wBAAgB,eAAe,CAAC,GAAG,EAAE,SAAS,GAAG,kBAAkB,CAYlE;AAED;;;;;;GAMG;AACH,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,QAAQ,CAAA;IACf,uFAAuF;IACvF,MAAM,EAAE,SAAS,GAAG,IAAI,CAAA;IACxB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,QAAQ,EAAE,YAAY,CAAA;IACtB,kFAAkF;IAClF,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;CAC5B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,qBAAa,cAAc;IACb,OAAO,CAAC,QAAQ,CAAC,MAAM;gBAAN,MAAM,EAAE,aAAa;IAElD;;;;;;OAMG;IACH,OAAO,CAAC,MAAM,CAAC,MAAM;YAOP,KAAK;IAiBnB,iGAAiG;IAC3F,IAAI,CAAC,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IAexF;;;;;;;OAOG;IACG,OAAO,CAAC,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAShG;;;;;;OAMG;YACW,KAAK;YAgBL,UAAU;IA+BxB;;;;;;;;;;;;;;;;;;;;;;;OAuBG;YACW,KAAK;IA6CnB;;;;;;OAMG;YACW,QAAQ;IAUtB;;;;;;;;;;;;;;;;;;;;;;;;;;;OA2BG;YACW,OAAO;IAKrB;;;;;;OAMG;IACG,MAAM,CACV,EAAE,EAAE,EAAE,EACN,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,GAAG,IAAI,EACtB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,GAAG,IAAI,EACnB,SAAS,EAAE,OAAO,GACjB,OAAO,CAAC,cAAc,CAAC;IAqC1B;;;;;;OAMG;IACG,QAAQ,CACZ,EAAE,EAAE,EAAE,EACN,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,GAAG,IAAI,EACtB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,GAAG,IAAI,EACnB,SAAS,EAAE,OAAO,GACjB,OAAO,CAAC,cAAc,CAAC;IAkC1B,4EAA4E;IACtE,MAAM,CACV,EAAE,EAAE,EAAE,EACN,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,GAAG,IAAI,EACtB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,GAAG,IAAI,EACnB,SAAS,EAAE,OAAO,GACjB,OAAO,CAAC,cAAc,CAAC;CAgC3B"}
|