Compare commits

...

19 Commits

Author SHA1 Message Date
seb
26f21cd7ac u 2026-11-02 00:51:18 +01:00
seb
d0a33b1f82 u 2026-11-02 00:19:21 +01:00
seb
55f04de8a8 u 2026-10-31 21:46:43 +01:00
seb
76ea6642f4 u 2026-10-31 21:02:50 +01:00
seb
f612c87b6e u 2026-10-31 20:28:39 +01:00
seb
c7d1b1a5d2 u 2026-10-30 18:09:33 +01:00
seb
745d0d0077 u 2026-10-30 18:06:03 +01:00
seb
2e00f4adb3 u 2026-10-30 17:28:22 +01:00
seb
60cf5873cd u 2026-10-30 16:56:28 +01:00
seb
211acd651d u 2026-10-30 16:48:36 +01:00
seb
a4718d81c0 u 2026-10-30 15:43:28 +01:00
seb
0fdffb864f u 2026-10-30 06:43:49 +01:00
seb
64050dffaf u 2026-10-30 05:50:41 +01:00
seb
3cb5ec5a7a u 2026-10-29 16:17:38 +01:00
seb
a5cef78d30 u 2026-10-29 03:39:07 +01:00
seb
c7db2262b0 u 2026-10-29 03:29:07 +01:00
seb
0fb5893a70 u 2026-10-29 03:28:39 +01:00
seb
75a83bdc28 u 2026-10-29 03:17:47 +01:00
seb
b0521a4ea6 u 2026-10-29 02:55:51 +01:00
112 changed files with 8509 additions and 4126 deletions

119
API.agentprinter.md Normal file
View File

@@ -0,0 +1,119 @@
# agentprinter
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `agentprinter.delete`
*Source: `src/store/agentprinter/delete.js`*
Deletes an agent printer by id for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The agent printer id. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number}>`
## `agentprinter.findOrCreate`
*Source: `src/store/agentprinter/findOrCreate.js`*
Finds or creates an agentprinter by (printeragentId, type, model, serial) combo.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `printeragentId` | `number` | The parent printer agent id. |
| `input` | `object` | |
| `input.type` | `string` | Printer type ("zpl", "esc/pos", etc.). |
| `[input.model]` | `string|null` | Discovered model name. |
| `[input.serial]` | `string|null` | Discovered serial number. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{created: boolean, agentprinter: {id: number, printeragentId: number, type: string, model: string|null, serial: string|null, enabled: boolean, sort: number}}>`
## `agentprinter.getById`
*Source: `src/store/agentprinter/getById.js`*
Retrieves an agent printer by id, scoped to the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The agent printer id. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, printeragentId: number, type: string, model: string|null, serial: string|null, enabled: boolean, sort: number}|null>`
## `agentprinter.list`
*Source: `src/store/agentprinter/list.js`*
Lists agent printers of the user's selected company, optionally filtered by printer agent.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[printeragentId]` | `number` | Optional printer agent id filter. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<Array<{id: number, printeragentId: number, type: string, model: string|null, serial: string|null, enabled: boolean, sort: number}>>`
## `agentprinter.order`
*Source: `src/store/agentprinter/order.js`*
Reorders agent printers for a given printer agent or globally across the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | |
| `[input.printeragentId]` | `number` | Optional printer agent id. If omitted, reorders all printers in company. |
| `input.agentprinterIds` | `number[]` | Complete ordered list of agent printer IDs. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{agentprinterIds: number[]}>`
## `agentprinter.update`
*Source: `src/store/agentprinter/update.js`*
Updates enabled and/or sort of an agent printer for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | |
| `input.id` | `number` | Agent printer id. |
| `[input.enabled]` | `boolean` | Whether the printer is enabled. |
| `[input.sort]` | `number` | Display order integer. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, printeragentId: number, type: string, model: string|null, serial: string|null, enabled: boolean, sort: number}>`

86
API.autoinvite.md Normal file
View File

@@ -0,0 +1,86 @@
# autoinvite
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `autoinvite.add`
*Source: `src/store/autoinvite/add.js`*
Registers an email for automatic company membership. When that email is registered (`google_auth.add`) the invitation is consumed: it is removed from the list and the new user is added to the company with the given role.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the acting Google auth user (must be admin of the selected company). |
| `input` | `{email: string, role?: 'admin'|'user'}` | The email to invite and the role (default 'user'). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, email: string, role: 'admin'|'user'}>` — The created invitation.
**Throws**
`Error` — 'Not authorized', 'Invalid email', 'Invalid role', 'User already registered', 'Invitation already exists'
**Example**
```js
await store.autoinvite.add(42, { email: 'new@example.com', role: 'user' });
```
## `autoinvite.list`
*Source: `src/store/autoinvite/list.js`*
Lists the pending auto-invitations of the admin's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user (must be admin of the selected company). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<Array<{id: number, email: string, role: 'admin'|'user', createdAt: string}>>` — The invitations ordered by email.
**Throws**
`Error` — 'Not authorized' / 'No company selected'
**Example**
```js
const invites = await store.autoinvite.list(42);
```
## `autoinvite.remove`
*Source: `src/store/autoinvite/remove.js`*
Deletes a pending auto-invitation of the admin's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the acting Google auth user (must be admin of the selected company). |
| `id` | `number` | The invitation id. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<void>`
**Throws**
`Error` — 'Not authorized', 'Invitation not found'
**Example**
```js
await store.autoinvite.remove(42, 3);
```

View File

@@ -6,7 +6,7 @@ This document is auto-generated from the JSDoc comments in `src/store` by runnin
*Source: `src/store/companyuser/add.js`*
Adds a company user linking a company to a Google auth account.
Adds a company user linking a company to a Google auth account. No admin check: this is the low-level link primitive (company creation, invitations). Use `companyuser.addByEmail` for the admin-guarded flow.
**Parameters**
@@ -14,19 +14,52 @@ Adds a company user linking a company to a Google auth account.
| --- | --- | --- |
| `companyId` | `number` | The id of the company. |
| `googleAuthId` | `number` | The id of the Google auth account. |
| `[role='user']` | `'admin'|'user'` | The role within the company. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<number>` — The id of the created company user link.
**Throws**
`Error` — 'Invalid role' if the role is not 'admin' or 'user'.
**Example**
```js
const id = await store.companyuser.add(1, 42);
const id = await store.companyuser.add(1, 42, 'admin');
// => 7
```
## `companyuser.addByEmail`
*Source: `src/store/companyuser/addByEmail.js`*
Adds an existing Google auth user to the admin's selected company. The added user can then select the company as their own selected company. For emails that are not registered yet use `autoinvite.add`.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the acting Google auth user (must be admin of the selected company). |
| `input` | `{email: string, role?: 'admin'|'user'}` | Email of the existing user and role (default 'user'). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, googleAuthId: number, email: string, picture: string|null, role: 'admin'|'user'}>` — The created company user.
**Throws**
`Error` — 'Not authorized', 'Invalid email', 'Invalid role', 'User not found', 'User already in company'
**Example**
```js
const member = await store.companyuser.addByEmail(42, { email: 'new@example.com', role: 'user' });
```
## `companyuser.delete`
*Source: `src/store/companyuser/delete.js`*
@@ -73,3 +106,134 @@ Returns the company user ids associated with the given email address.
const ids = await store.companyuser.getByEmail('user@example.com');
// => [1, 2, 3]
```
## `companyuser.list`
*Source: `src/store/companyuser/list.js`*
Lists the members of the admin's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user (must be admin of the selected company). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<Array<{id: number, googleAuthId: number, email: string, picture: string|null, role: 'admin'|'user'}>>` — The company users ordered by email.
**Throws**
`Error` — 'Not authorized' / 'No company selected'
**Example**
```js
const members = await store.companyuser.list(42);
```
## `companyuser.remove`
*Source: `src/store/companyuser/remove.js`*
Removes a member from the admin's selected company. History entries keep existing but lose their author link; if the removed user had this company selected, their selection is cleared.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the acting Google auth user (must be admin of the selected company). |
| `id` | `number` | The company user id to remove. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, googleAuthId: number}>` — The removed company user.
**Throws**
`Error` — 'Not authorized', 'Company user not found', 'Cannot remove the last admin'
**Example**
```js
await store.companyuser.remove(42, 7);
```
## `companyuser.requireAdmin`
*Source: `src/store/companyuser/requireAdmin.js`*
Resolves the selected company and requires the user to be its admin.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | |
| `[tx]` | `object` | |
**Returns**
`Promise<{companyId: number, companyuserId: number, role: string}>`
**Throws**
`Error` — 'Not authorized' if the user is not an admin of the selected company.
## `companyuser.roles`
*Source: `src/store/companyuser/roles.js`*
Validates a company user role.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `role` | `unknown` | |
**Returns**
`'admin'|'user'`
**Throws**
`Error` — 'Invalid role'
## `companyuser.setRole`
*Source: `src/store/companyuser/setRole.js`*
Changes the role of a member of the admin's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the acting Google auth user (must be admin of the selected company). |
| `id` | `number` | The company user id. |
| `role` | `'admin'|'user'` | The new role. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, googleAuthId: number, email: string, picture: string|null, role: 'admin'|'user'}>` — The updated company user.
**Throws**
`Error` — 'Not authorized', 'Invalid role', 'Company user not found', 'Cannot demote the last admin'
**Example**
```js
await store.companyuser.setRole(42, 7, 'admin');
```
## `companyuser.withTransaction`
*Source: `src/store/companyuser/withTransaction.js`*
Runs `fn(runner)` inside the given transaction, a new one, or (providers without transaction support) directly.

View File

@@ -6,7 +6,7 @@ This document is auto-generated from the JSDoc comments in `src/store` by runnin
*Source: `src/store/google_auth/add.js`*
Adds a new allowed Google auth email.
Adds a new allowed Google auth email (registration). Pending `autoinvite` rows for this email are consumed in the same transaction: each is deleted and turned into a `companyuser` with the invited role.
**Parameters**
@@ -144,7 +144,7 @@ await store.google_auth.setProfile(42, { picture: 'url', pictureBinary: buffer,
*Source: `src/store/google_auth/setSelectedCompany.js`*
Sets the currently selected company for a Google auth user.
Sets the currently selected company for a Google auth user. The user must be a member (`companyuser`) of the company.
**Parameters**
@@ -157,6 +157,10 @@ Sets the currently selected company for a Google auth user.
`Promise<void>`
**Throws**
`Error` — 'Not authorized' if the user is not a member of the company.
**Example**
```js

47
API.md
View File

@@ -82,9 +82,16 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
## Table of Contents
- [companyuser](API.companyuser.md)
- `add(companyId: number, googleAuthId: number, tx?: object): Promise<number>`
- `add(companyId: number, googleAuthId: number, role?: 'admin'|'user', tx?: object): Promise<number>`
- `addByEmail(googleAuthId: number, input: {email: string, role?: 'admin'|'user'}, tx?: object): Promise<{id: number, googleAuthId: number, email: string, picture: string|null, role: 'admin'|'user'}>`
- `delete(id: number, tx?: object): Promise<void>`
- `getByEmail(email: string, tx?: object): Promise<Array<number>>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, googleAuthId: number, email: string, picture: string|null, role: 'admin'|'user'}>>`
- `remove(googleAuthId: number, id: number, tx?: object): Promise<{id: number, googleAuthId: number}>`
- `requireAdmin(googleAuthId: number, tx?: object): Promise<{companyId: number, companyuserId: number, role: string}>`
- `roles(role: unknown): 'admin'|'user'`
- `setRole(googleAuthId: number, id: number, role: 'admin'|'user', tx?: object): Promise<{id: number, googleAuthId: number, email: string, picture: string|null, role: 'admin'|'user'}>`
- `withTransaction(): void`
- [company](API.company.md)
- `addHistory(googleAuthId: number, input: {text?: string}, tx?: object): Promise<{id: number, companyuserId: number|null, date: string, text: string}>`
- `create(input: {iso639: string, name: string, taxrate: number, reducedtaxrate: number, googleAuthId: number}, tx?: object): Promise<{id: number, rootCategoryId: number, languageId: number, taxrateId: number, reducedtaxrateId: number, taxfreerateId: number, posCustomerGroupId: number}>`
@@ -256,8 +263,8 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `ship(provider: object): Function`
- [posterminal](API.posterminal.md)
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `findByPairingpin(pairingpin: string, tx?: object): Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string}|null>`
- `findByToken(token: string, tx?: object): Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string}|null>`
- `findByPairingpin(pairingpin: string, tx?: object): Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string, sort: number, enabled: boolean|number, clientversion: string|null}|null>`
- `findByToken(token: string, tx?: object): Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string, sort: number, enabled: boolean|number, clientversion: string|null}|null>`
- `getCategoryCount(input: {posterminalId: number, cursor?: number}, tx?: object): Promise<number>`
- `getCategoryList(input: {posterminalId: number, cursor?: number, limit?: number}, tx?: object): Promise<Array<object>>`
- `getCompositeProductCount(input: {posterminalId: number, cursor?: number}, tx?: object): Promise<number>`
@@ -270,17 +277,18 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `getMaxOrderIdCount(input: {posterminalId: number}, tx?: object): Promise<number>`
- `getProductCount(input: {posterminalId: number, cursor?: number}, tx?: object): Promise<number>`
- `getProductList(input: {posterminalId: number, cursor?: number, limit?: number}, tx?: object): Promise<Array<object>>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string}>>`
- `pair(id: number, input: {token: string, clientserial?: string, pairedAt?: string}, tx?: object): Promise<{id: number, token: string, clientserial: string|null, pairedAt: string|null}>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string, sort: number, enabled: boolean|number, clientversion: string|null}>>`
- `pair(id: number, input: {token: string, clientserial?: string, pairedAt?: string, clientversion?: string|null}, tx?: object): Promise<{id: number, token: string, clientserial: string|null, pairedAt: string|null, clientversion: string|null}>`
- `recordObituary(input: {companyId: number, entityType: string, entityId: number}, tx?: object): Promise<void>`
- `recordSyncQueue(input: {companyId: number, entityType: string, entityId: number}, tx?: object): Promise<void>`
- `setClientversion(id: number, clientversion: string|null, tx?: object): Promise<{id: number, clientversion: string|null}>`
- `setMaxKnownObituaryId(id: number, maxknownobituaryid: number|null, tx?: object): Promise<{id: number, maxknownobituaryid: number|null}>`
- `setOff(googleAuthId: number, posterminalId: number, categoryId: number, tx?: object): Promise<{id: number, categoryIds: Array<number>}>`
- `setOffAll(googleAuthId: number, posterminalId: number, tx?: object): Promise<{id: number, categoryIds: Array<number>}>`
- `setOn(googleAuthId: number, posterminalId: number, categoryId: number, tx?: object): Promise<{id: number, categoryIds: Array<number>}>`
- `setOnAll(googleAuthId: number, posterminalId: number, tx?: object): Promise<{id: number, categoryIds: Array<number>}>`
- `touchLastseen(id: number, tx?: object): Promise<{id: number}>`
- `upsert(googleAuthId: number, input: {name: string, pairingpin: string, token?: string, id?: number}, tx?: object): Promise<{id: number, name: string, token: string, pairingpin: string}>`
- `upsert(googleAuthId: number, input: {name: string, pairingpin: string, token?: string, id?: number, sort?: number, enabled?: boolean}, tx?: object): Promise<{id: number, name: string, token: string, pairingpin: string, sort: number, enabled: number}>`
- [printer](API.printer.md)
- `create(googleAuthId: number, input: {address: string, type: string, model?: string, serial?: string, sort?: number, desc?: string}, tx?: object): Promise<{id: number, company_id: number, address: string, type: string, model: string|null, serial: string|null, sort: number, desc: string|null}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
@@ -343,6 +351,13 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `create(googleAuthId: number, input: {type: string, name: string}, tx?: object): Promise<{id: number, companyId: number, type: string, name: string}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, companyId: number, type: string, name: string}>>`
- [agentprinter](API.agentprinter.md)
- `delete(googleAuthId: number, id: number, tx?: object): Promise<{id: number}>`
- `findOrCreate(printeragentId: number, input: {type: string, model?: string|null, serial?: string|null}, tx?: object): Promise<{created: boolean, agentprinter: {id: number, printeragentId: number, type: string, model: string|null, serial: string|null, enabled: boolean, sort: number}}>`
- `getById(googleAuthId: number, id: number, tx?: object): Promise<{id: number, printeragentId: number, type: string, model: string|null, serial: string|null, enabled: boolean, sort: number}|null>`
- `list(googleAuthId: number, printeragentId?: number, tx?: object): Promise<Array<{id: number, printeragentId: number, type: string, model: string|null, serial: string|null, enabled: boolean, sort: number}>>`
- `order(googleAuthId: number, input: {printeragentId?: number, agentprinterIds: number[]}, tx?: object): Promise<{agentprinterIds: number[]}>`
- `update(googleAuthId: number, input: {id: number, enabled?: boolean, sort?: number}, tx?: object): Promise<{id: number, printeragentId: number, type: string, model: string|null, serial: string|null, enabled: boolean, sort: number}>`
- [articlehistory](API.articlehistory.md)
- `record(): void`
- [articlelist](API.articlelist.md)
@@ -350,6 +365,10 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `setItemsByArticleSku(googleAuthId: number, sku: string, items?: Array<{articleId?: number, sku?: string, count?: number, sort?: number}>, tx?: object): Promise<Array<{id: number, articleId: number, sku: string, name: string|null, count: number, sort: number}>>`
- [articlewarehousesummary](API.articlewarehousesummary.md)
- `syncSummary(): void`
- [autoinvite](API.autoinvite.md)
- `add(googleAuthId: number, input: {email: string, role?: 'admin'|'user'}, tx?: object): Promise<{id: number, email: string, role: 'admin'|'user'}>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, email: string, role: 'admin'|'user', createdAt: string}>>`
- `remove(googleAuthId: number, id: number, tx?: object): Promise<void>`
- [carrier](API.carrier.md)
- `create(googleAuthId: number, input: {name: string, sort?: number, hasPackages?: boolean, isReverse?: boolean}, tx?: object): Promise<{id: number, name: string, sort: number, hasPackages: boolean, isReverse: boolean}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
@@ -403,6 +422,22 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `create(googleAuthId: number, input: {accountId: number, manifestId: number, type: string, amount: number}, tx?: object): Promise<{id: number, accountId: number, manifestId: number, type: string, amount: number}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `listByManifestId(googleAuthId: number, manifestId: number, tx?: object): Promise<Array<{id: number, accountId: number, manifestId: number, type: string, amount: number}>>`
- [printeragent](API.printeragent.md)
- `create(googleAuthId: number, input: {name: string, pairingpin: string, sort?: number}, tx?: object): Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `findByPairingpin(pairingpin: string, tx?: object): Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}|null>`
- `findByToken(token: string, tx?: object): Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}|null>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>>`
- `pair(id: number, input: {token: string, clientserial?: string, clientmodel?: string, agentversion?: string, pairedAt?: string}, tx?: object): Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>`
- `touchLastseen(id: number, agentversion?: string|null, tx?: object): Promise<{id: number}>`
- `update(googleAuthId: number, input: {id: number, name?: string, sort?: number}, tx?: object): Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>`
- `updateState(id: number, clientstate: string|null, tx?: object): Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>`
- [printerjob](API.printerjob.md)
- `create(googleAuthId: number, input: {agentprinterId: number, data: string, type?: string, state?: string}, tx?: object): Promise<object>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<object>`
- `getById(auth: number|{agentId: number}, id: number, tx?: object): Promise<object|null>`
- `list(auth: number|{agentId: number}, filters?: {agentprinterId?: number, state?: string}, tx?: object): Promise<Array<object>>`
- `updateState(auth: number|{agentId: number}, id: number, state: string, tx?: object): Promise<object>`
- [retoure](API.retoure.md)
- `create(googleAuthId: number, body: {number?: string, date: string, entityId?: number, externalNumber?: string, lines?: Array<object>}, tx?: object): Promise<object>`
- `createShipment(googleAuthId: number, retoureId: number, input: object, tx?: object): Promise<object>`

View File

@@ -45,7 +45,7 @@ Finds a posterminal by its pairingpin.
**Returns**
`Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string}|null>` — The posterminal, or null if not found.
`Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string, sort: number, enabled: boolean|number, clientversion: string|null}|null>` — The posterminal, or null if not found.
## `posterminal.findByToken`
@@ -62,7 +62,7 @@ Finds a posterminal by its auth token.
**Returns**
`Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string}|null>` — The posterminal, or null if not found.
`Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string, sort: number, enabled: boolean|number, clientversion: string|null}|null>` — The posterminal, or null if not found.
## `posterminal.getCategoryCount`
@@ -311,13 +311,13 @@ Lists the posterminals of the user's selected company.
**Returns**
`Promise<Array<{id: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string}>>` — The posterminals.
`Promise<Array<{id: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string, sort: number, enabled: boolean|number, clientversion: string|null}>>` — The posterminals.
**Example**
```js
const terminals = await store.posterminal.list(42);
// => [{ id, name, token, pairingpin, pairedAt, clientserial, maxknownobituaryid, rootCategoryId, syncRootArticles, lastseenAt, updatedAt }]
// => [{ id, name, token, pairingpin, pairedAt, clientserial, maxknownobituaryid, rootCategoryId, syncRootArticles, lastseenAt, updatedAt, sort, enabled, clientversion }]
```
## `posterminal.pair`
@@ -335,11 +335,12 @@ Completes POS device pairing for a terminal.
| `input.token` | `string` | The auth token for the paired device. |
| `[input.clientserial]` | `string` | The client name or serial identifier. |
| `[input.pairedAt]` | `string` | Timestamp when paired. |
| `[input.clientversion]` | `string|null` | Client software version reported by the POS device during pairing. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, token: string, clientserial: string|null, pairedAt: string|null}>`
`Promise<{id: number, token: string, clientserial: string|null, pairedAt: string|null, clientversion: string|null}>`
## `posterminal.recordObituary`
@@ -381,6 +382,31 @@ Enqueues a sync notification for all POS terminals of a company.
`Promise<void>`
## `posterminal.setClientversion`
*Source: `src/store/posterminal/setClientversion.js`*
Stores the software version reported by a POS terminal.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | `number` | The posterminal id. |
| `clientversion` | `string|null` | Version string reported by the client (trimmed; empty → null). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, clientversion: string|null}>`
**Example**
```js
await store.posterminal.setClientversion(7, '1.10.12.0');
// => { id: 7, clientversion: '1.10.12.0' }
```
## `posterminal.setMaxKnownObituaryId`
*Source: `src/store/posterminal/setMaxKnownObituaryId.js`*
@@ -550,11 +576,13 @@ Creates or updates a posterminal for the user's selected company.
| `input.pairingpin` | `string` | The pairing pin. |
| `[input.token]` | `string` | Optional token. |
| `[input.id]` | `number` | Optional id to update an existing posterminal. |
| `[input.sort]` | `number` | Display order (integer, ascending). Default 0 on create. |
| `[input.enabled]` | `boolean` | Whether the terminal may sync. Default true on create. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, name: string, token: string, pairingpin: string}>` — The upserted posterminal.
`Promise<{id: number, name: string, token: string, pairingpin: string, sort: number, enabled: number}>` — The upserted posterminal.
**Throws**
@@ -564,5 +592,5 @@ Creates or updates a posterminal for the user's selected company.
```js
const terminal = await store.posterminal.upsert(42, { name: 'Kasse 1', pairingpin: '1234', token: 'abc' });
// => { id, name, token, pairingpin }
// => { id, name, token, pairingpin, sort, enabled }
```

222
API.printeragent.md Normal file
View File

@@ -0,0 +1,222 @@
# printeragent
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `printeragent.create`
*Source: `src/store/printeragent/create.js`*
Creates an unpaired printer agent for the user's selected company. The agent registers itself later using the pairing pin.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | The printer agent input. |
| `input.name` | `string` | The display name. |
| `input.pairingpin` | `string` | The pairing pin the agent uses to register. |
| `[input.sort]` | `number` | Display order (integer, ascending). Default 0. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>` — The created printer agent.
**Throws**
`Error` — 'Invalid printeragent' if name or pairingpin is missing/blank or sort is not an integer.
**Example**
```js
const agent = await store.printeragent.create(42, { name: 'Bar printer', pairingpin: '123456' });
```
## `printeragent.delete`
*Source: `src/store/printeragent/delete.js`*
Deletes a printer agent of the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | The printer agent id. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<void>`
**Throws**
`Error` — 'Printeragent not found' if the id is unknown in the company.
**Example**
```js
await store.printeragent.delete(42, 5);
```
## `printeragent.findByPairingpin`
*Source: `src/store/printeragent/findByPairingpin.js`*
Finds a printer agent by its pairing pin.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `pairingpin` | `string` | The pairing pin to search for. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}|null>` — The printer agent, or null if not found.
**Example**
```js
const agent = await store.printeragent.findByPairingpin('123456');
```
## `printeragent.findByToken`
*Source: `src/store/printeragent/findByToken.js`*
Finds a printer agent by its authentication token.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `token` | `string` | The auth token to search for. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}|null>` — The printer agent, or null if not found.
**Example**
```js
const agent = await store.printeragent.findByToken('abc...');
```
## `printeragent.list`
*Source: `src/store/printeragent/list.js`*
Lists the printer agents of the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<Array<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>>` — The printer agents.
**Example**
```js
const agents = await store.printeragent.list(42);
// => [{ id, companyId, name, token, pairingpin, pairedAt, clientserial, clientmodel, clientstate, agentversion, lastseenAt, updatedAt, sort }]
```
## `printeragent.pair`
*Source: `src/store/printeragent/pair.js`*
Completes device pairing for a printer agent.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | `number` | The printer agent id. |
| `input` | `object` | |
| `input.token` | `string` | Auth token for the paired device. |
| `[input.clientserial]` | `string` | Device serial identifier. |
| `[input.clientmodel]` | `string` | Device model name. |
| `[input.agentversion]` | `string` | Device agent software version. |
| `[input.pairedAt]` | `string` | Timestamp when paired. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>`
## `printeragent.touchLastseen`
*Source: `src/store/printeragent/touchLastseen.js`*
Updates lastseen_at (and optionally agentversion) for a printer agent.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | `number` | The printer agent id. |
| `[agentversion]` | `string|null` | Optional agent version to update. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number}>`
## `printeragent.update`
*Source: `src/store/printeragent/update.js`*
Updates name and/or sort of a printer agent of the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | The update input. |
| `input.id` | `number` | The printer agent id. |
| `[input.name]` | `string` | The new display name. |
| `[input.sort]` | `number` | The new display order (integer). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>` — The updated printer agent.
**Throws**
`Error` — 'Invalid printeragent' if name is blank, sort is not an integer, or neither is given; 'Printeragent not found' if id is unknown in the company.
**Example**
```js
const agent = await store.printeragent.update(42, { id: 5, name: 'Kitchen printer', sort: 1 });
```
## `printeragent.updateState`
*Source: `src/store/printeragent/updateState.js`*
Updates the client state of a printer agent.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | `number` | The printer agent id. |
| `clientstate` | `string|null` | The state payload/string. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>`

100
API.printerjob.md Normal file
View File

@@ -0,0 +1,100 @@
# printerjob
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `printerjob.create`
*Source: `src/store/printerjob/create.js`*
Creates a new printer job for an agent printer belonging to the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | |
| `input.agentprinterId` | `number` | Target agent printer ID. |
| `input.data` | `string` | Job payload (ZPL code, etc.). |
| `[input.type]` | `string` | Job type ("zpl", "pdf", etc., defaults to agentprinter type or "zpl"). |
| `[input.state]` | `string` | Initial state (defaults to "pending"). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<object>` — Created printer job with printeragentId attached.
## `printerjob.delete`
*Source: `src/store/printerjob/delete.js`*
Deletes a printer job belonging to the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `id` | `number` | Printer job ID. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<object>` — The deleted job's snapshot.
## `printerjob.getById`
*Source: `src/store/printerjob/getById.js`*
Retrieves a printer job by ID. If caller is an agent ({ agentId: number }), checks that the job belongs to that printer agent. If caller is a user (googleAuthId: number), checks that the job belongs to the selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `auth` | `number|{agentId: number}` | User's googleAuthId or { agentId }. |
| `id` | `number` | Printer job ID. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<object|null>`
## `printerjob.list`
*Source: `src/store/printerjob/list.js`*
Lists printer jobs. If caller is an agent ({ agentId: number }), lists jobs for that agent. If caller is a user (googleAuthId: number), lists jobs for the selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `auth` | `number|{agentId: number}` | User's googleAuthId or { agentId }. |
| `[filters]` | `object` | |
| `[filters.agentprinterId]` | `number` | Filter by agentprinterId (company callers only). |
| `[filters.state]` | `string` | Filter by job state (e.g. "pending"). |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<object>>`
## `printerjob.updateState`
*Source: `src/store/printerjob/updateState.js`*
Updates a printer job's state ("processing", "completed", "cancelled", etc.).
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `auth` | `number|{agentId: number}` | User's googleAuthId or { agentId }. |
| `id` | `number` | Printer job ID. |
| `state` | `string` | New state. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<object>` — Updated printer job.

View File

@@ -20,6 +20,7 @@
"init-schema": "node scripts/init-schema.js",
"purge-db": "node scripts/purge-db.js",
"reset-db": "node scripts/reset-db.js",
"downgrade": "node scripts/downgrade.js",
"test": "node --test \"test/*.test.js\"",
"test:api": "node scripts/test-api.js",
"test:benchmark": "node scripts/benchmark.js"

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 441 KiB

After

Width:  |  Height:  |  Size: 459 KiB

54
scripts/downgrade.js Normal file
View File

@@ -0,0 +1,54 @@
/**
* Downgrades the database schema to a target version.
*
* npm run downgrade -- <targetVersion>
*
* Provider from env: DB_PROVIDER=sqlite (+ SQLITE_PATH) or mssql (MSSQL_*, default).
* Runs src/providers/<provider>/downgrade/<from>_to_<to>.js one step at a time,
* each in its own transaction. Not part of the server — the server only upgrades.
* Note: starting a server whose code knows a higher version upgrades again.
*/
import 'dotenv/config';
const [rawTarget] = process.argv.slice(2);
const target = Number(rawTarget);
if (!rawTarget || !Number.isInteger(target) || target < 1) {
console.error('Usage: npm run downgrade -- <targetVersion>');
process.exit(1);
}
const providerName = process.env.DB_PROVIDER || (process.env.SQLITE_PATH ? 'sqlite' : 'mssql');
let provider;
if (providerName === 'sqlite' || providerName === 'better-sqlite3') {
if (!process.env.SQLITE_PATH) {
console.error('SQLITE_PATH is required for DB_PROVIDER=sqlite');
process.exit(1);
}
const { BetterSqlite3Provider } = await import('../src/providers/sqlite3/index.js');
provider = new BetterSqlite3Provider({ filename: process.env.SQLITE_PATH, fileMustExist: true });
} else if (providerName === 'mssql') {
const { MssqlProvider } = await import('../src/providers/mssql/index.js');
provider = new MssqlProvider({
server: process.env.MSSQL_SERVER,
database: process.env.MSSQL_DATABASE,
user: process.env.MSSQL_USER,
password: process.env.MSSQL_PASSWORD,
options: { encrypt: false, trustServerCertificate: true },
});
} else {
console.error(`Unknown DB_PROVIDER: ${providerName}`);
process.exit(1);
}
try {
const before = await provider.getSchemaVersion();
const steps = await provider.downgradeSchema(target);
console.log(`Schema ${providerName}: ${before} -> ${target} (${steps} step(s))`);
} catch (err) {
console.error(`Downgrade failed: ${err.message}`);
process.exitCode = 1;
} finally {
await provider.close();
}

View File

@@ -1,112 +0,0 @@
import 'dotenv/config';
import Database from 'better-sqlite3';
import { MssqlProvider } from '../src/providers/mssql/index.js';
async function migrateSqlite(sqlitePath) {
console.log(`Migrating SQLite database at: ${sqlitePath}`);
const db = new Database(sqlitePath);
const columns = db.pragma('table_info("manifest")');
const hasCreatedAt = columns.some((c) => c.name === 'created_at');
if (hasCreatedAt) {
console.log('SQLite manifest already has created_at column.');
db.close();
return;
}
db.pragma('foreign_keys = OFF');
db.exec(`
BEGIN TRANSACTION;
CREATE TABLE manifest_new (
id INTEGER PRIMARY KEY,
company_id INTEGER NOT NULL,
"number" TEXT NOT NULL,
"type" TEXT NOT NULL,
entity_id INTEGER,
external_number TEXT,
"date" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE NO ACTION,
FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE SET NULL
);
INSERT INTO manifest_new (id, company_id, "number", "type", entity_id, external_number, "date", created_at)
SELECT id, company_id, "number", "type", entity_id, external_number, "date", "date"
FROM manifest;
DROP TABLE manifest;
ALTER TABLE manifest_new RENAME TO manifest;
COMMIT;
`);
db.pragma('foreign_keys = ON');
const fkErrors = db.pragma('foreign_key_check');
if (fkErrors.length > 0) {
console.error('Foreign key check failed after SQLite migration:', fkErrors);
} else {
console.log('SQLite migration completed successfully.');
}
console.log('Updated SQLite manifest columns:', db.pragma('table_info("manifest")'));
console.log('Updated SQLite manifest sample row:', db.prepare('SELECT * FROM manifest LIMIT 1').get());
db.close();
}
async function migrateMssql() {
console.log('Migrating MSSQL database...');
const config = {
server: process.env.MSSQL_SERVER,
database: process.env.MSSQL_DATABASE,
user: process.env.MSSQL_USER,
password: process.env.MSSQL_PASSWORD,
options: {
encrypt: false,
trustServerCertificate: true,
},
};
const provider = new MssqlProvider(config);
try {
const pool = await provider.ensurePool();
const checkRes = await pool.request().query(`
SELECT * FROM sys.columns
WHERE object_id = OBJECT_ID(N'manifest') AND name = 'created_at'
`);
if (checkRes.recordset.length > 0) {
console.log('MSSQL manifest already has created_at column.');
} else {
await pool.request().query(`
ALTER TABLE manifest ADD created_at DATETIME2 NOT NULL CONSTRAINT DF_manifest_created_at DEFAULT SYSUTCDATETIME();
`);
await pool.request().query(`
UPDATE manifest SET created_at = [date];
`);
console.log('MSSQL migration completed successfully.');
}
const colsRes = await pool.request().query(`
SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'manifest'
`);
console.log('Updated MSSQL manifest columns:', colsRes.recordset);
const sampleRes = await pool.request().query(`
SELECT TOP 1 * FROM manifest
`);
console.log('Updated MSSQL manifest sample row:', sampleRes.recordset[0]);
await provider.close();
} catch (err) {
console.error('MSSQL migration failed:', err);
await provider.close();
throw err;
}
}
async function run() {
await migrateSqlite('/home/seb/src/dashback/data/dash.db');
await migrateMssql();
}
run().catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});

View File

@@ -1,105 +0,0 @@
import 'dotenv/config';
import Database from 'better-sqlite3';
import { MssqlProvider } from '../src/providers/mssql/index.js';
async function migrateSqlite(sqlitePath) {
console.log(`Migrating SQLite database at: ${sqlitePath}`);
const db = new Database(sqlitePath);
const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'printer'").get();
if (!row) {
console.log('Printer table does not exist in SQLite.');
db.close();
return;
}
if (row.sql.includes("'ipp'")) {
console.log("SQLite printer table already includes 'ipp' in CHECK constraint.");
db.close();
return;
}
db.pragma('foreign_keys = OFF');
db.exec(`
BEGIN TRANSACTION;
CREATE TABLE printer_new (
id INTEGER PRIMARY KEY,
company_id INTEGER NOT NULL,
address TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('esc/pos', 'zpl', 'ipp')),
model TEXT,
serial TEXT,
sort INTEGER NOT NULL DEFAULT 0,
"desc" TEXT,
FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE
);
INSERT INTO printer_new (id, company_id, address, type, model, serial, sort, "desc")
SELECT id, company_id, address, type, model, serial, sort, "desc"
FROM printer;
DROP TABLE printer;
ALTER TABLE printer_new RENAME TO printer;
COMMIT;
`);
db.pragma('foreign_keys = ON');
const fkErrors = db.pragma('foreign_key_check');
if (fkErrors.length > 0) {
console.error('Foreign key check failed after SQLite migration:', fkErrors);
} else {
console.log('SQLite migration completed successfully.');
}
const updatedRow = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'printer'").get();
console.log('Updated SQLite printer table SQL:', updatedRow?.sql);
db.close();
}
async function migrateMssql() {
console.log('Migrating MSSQL database...');
const config = {
server: process.env.MSSQL_SERVER,
database: process.env.MSSQL_DATABASE,
user: process.env.MSSQL_USER,
password: process.env.MSSQL_PASSWORD,
options: {
encrypt: false,
trustServerCertificate: true,
},
};
try {
const provider = new MssqlProvider(config);
const pool = await provider.ensurePool();
const tableRes = await pool.request().query(`
SELECT * FROM sys.tables WHERE name = 'printer'
`);
if (tableRes.recordset.length === 0) {
console.log('Printer table does not exist in MSSQL.');
await provider.close();
return;
}
// Drop old CK_printer_type constraint if exists and add new one
await pool.request().query(`
IF EXISTS (SELECT * FROM sys.check_constraints WHERE name = 'CK_printer_type')
BEGIN
ALTER TABLE printer DROP CONSTRAINT CK_printer_type;
END
ALTER TABLE printer ADD CONSTRAINT CK_printer_type CHECK (type IN ('esc/pos', 'zpl', 'ipp'));
`);
console.log('MSSQL migration completed successfully.');
await provider.close();
} catch (err) {
console.warn('MSSQL migration skipped/failed (possibly server not running):', err.message);
}
}
async function run() {
await migrateSqlite('/home/seb/src/dashback/data/dash.db');
await migrateMssql();
}
run().catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
async function open(tempDir) {
const provider = new BetterSqlite3Provider({ filename: join(tempDir, 'test.db'), storageDir: join(tempDir, 'storage') });
await provider.initialize();
return { provider, store: await new LibDashStore(provider) };
}
const tempDir = mkdtempSync(join(tmpdir(), 'test-cu-roles-'));
try {
console.log('--- Testing companyuser roles + autoinvite ---');
let { provider, store } = await open(tempDir);
const adminId = await store.google_auth.add('admin@example.com');
const userId = await store.google_auth.add('user@example.com');
const company = await store.company.create({ name: 'Roles Corp', iso639: 'de', taxrate: 19, reducedtaxrate: 7, googleAuthId: adminId });
await store.google_auth.setSelectedCompany(adminId, company.id);
// creator is admin
let members = await store.companyuser.list(adminId);
assert.deepEqual(members.map((m) => [m.email, m.role]), [['admin@example.com', 'admin']]);
assert.equal((await store.company.list(adminId))[0].role, 'admin');
// non-member cannot select the company
await assert.rejects(store.google_auth.setSelectedCompany(userId, company.id), /Not authorized/);
// admin adds existing user
await assert.rejects(store.companyuser.addByEmail(adminId, { email: 'nobody@example.com' }), /User not found/);
await assert.rejects(store.companyuser.addByEmail(adminId, { email: 'user@example.com', role: 'root' }), /Invalid role/);
const added = await store.companyuser.addByEmail(adminId, { email: 'User@Example.com' });
assert.equal(added.role, 'user');
await assert.rejects(store.companyuser.addByEmail(adminId, { email: 'user@example.com' }), /already in company/);
// user can now select it, but is not admin
await store.google_auth.setSelectedCompany(userId, company.id);
await assert.rejects(store.companyuser.list(userId), /Not authorized/);
await assert.rejects(store.companyuser.addByEmail(userId, { email: 'admin@example.com' }), /Not authorized/);
await assert.rejects(store.companyuser.setRole(userId, added.id, 'admin'), /Not authorized/);
await assert.rejects(store.companyuser.remove(userId, added.id), /Not authorized/);
await assert.rejects(store.autoinvite.add(userId, { email: 'x@example.com' }), /Not authorized/);
// last admin guard
const adminCu = members[0].id;
await assert.rejects(store.companyuser.setRole(adminId, adminCu, 'user'), /last admin/);
await assert.rejects(store.companyuser.remove(adminId, adminCu), /last admin/);
await store.companyuser.setRole(adminId, added.id, 'admin');
await store.companyuser.setRole(adminId, adminCu, 'user');
await store.companyuser.setRole(userId, adminCu, 'admin');
await store.companyuser.setRole(userId, added.id, 'user');
await assert.rejects(store.companyuser.setRole(adminId, 99999, 'user'), /Company user not found/);
// autoinvite
await assert.rejects(store.autoinvite.add(adminId, { email: 'user@example.com' }), /already registered/);
await assert.rejects(store.autoinvite.add(adminId, { email: 'bad' }), /Invalid email/);
await store.autoinvite.add(adminId, { email: 'New@Example.com', role: 'admin' });
await assert.rejects(store.autoinvite.add(adminId, { email: 'new@example.com' }), /already exists/);
await store.autoinvite.add(adminId, { email: 'gone@example.com' });
assert.equal((await store.autoinvite.list(adminId)).length, 2);
await store.autoinvite.remove(adminId, (await store.autoinvite.list(adminId)).find((i) => i.email === 'gone@example.com').id);
await assert.rejects(store.autoinvite.remove(adminId, 99999), /Invitation not found/);
const newId = await store.google_auth.add('new@example.com');
assert.deepEqual(await store.autoinvite.list(adminId), []);
await store.google_auth.setSelectedCompany(newId, company.id);
members = await store.companyuser.list(adminId);
assert.equal(members.find((m) => m.email === 'new@example.com').role, 'admin');
// remove clears selection
const userCu = members.find((m) => m.email === 'user@example.com');
await store.companyuser.remove(adminId, userCu.id);
assert.equal((await store.google_auth.getById(userId)).selectedCompanyId, null);
// downgrade → upgrade through the migration engine
await provider.close?.();
const env = { ...process.env, DB_PROVIDER: 'sqlite', SQLITE_PATH: join(tempDir, 'test.db') };
execFileSync('node', ['scripts/downgrade.js', '5'], { env, stdio: 'inherit' });
({ provider, store } = await open(tempDir).catch((e) => { throw e; }));
// opening upgrades to latest again; roles reset to admin for all members
const rows = (await provider.query('SELECT role FROM companyuser')).rows;
assert.ok(rows.length > 0 && rows.every((r) => r.role === 'admin'));
assert.deepEqual((await provider.query('SELECT * FROM autoinvite')).rows, []);
await provider.close?.();
console.log('OK');
} finally {
rmSync(tempDir, { recursive: true, force: true });
}

View File

@@ -0,0 +1,118 @@
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
const tempDir = mkdtempSync(join(tmpdir(), 'dash-printeragent-test-'));
const provider = new BetterSqlite3Provider({ filename: join(tempDir, 'test.db'), storageDir: join(tempDir, 'storage') });
await provider.initialize();
const store = await new LibDashStore(provider);
try {
const googleAuthId = await store.google_auth.add('printeragent-tester@example.com');
const company = await store.company.create({ name: 'PA GmbH', iso639: 'de', taxrate: 19, reducedtaxrate: 7, googleAuthId });
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
assert.deepEqual(await store.printeragent.list(googleAuthId), []);
await assert.rejects(store.printeragent.create(googleAuthId, { name: ' ', pairingpin: '1' }), /Invalid printeragent/);
const a = await store.printeragent.create(googleAuthId, { name: ' Bar ', pairingpin: '123456', sort: 5 });
assert.equal(a.name, 'Bar');
assert.equal(a.pairingpin, '123456');
assert.equal(a.token, null);
assert.equal(a.sort, 5);
assert.equal(a.companyId, company.id);
assert.ok(a.updatedAt);
// findByPairingpin
const foundByPin = await store.printeragent.findByPairingpin('123456');
assert.equal(foundByPin.id, a.id);
assert.equal(await store.printeragent.findByPairingpin('999999'), null);
// pair
const paired = await store.printeragent.pair(a.id, {
token: 'test-token-123',
agentversion: '1.0.0',
});
assert.equal(paired.token, 'test-token-123');
assert.equal(paired.agentversion, '1.0.0');
assert.equal(paired.pairingpin, null);
assert.equal(paired.enabled, true);
assert.ok(paired.pairedAt);
// findByToken
const foundByToken = await store.printeragent.findByToken('test-token-123');
assert.equal(foundByToken.id, a.id);
assert.equal(foundByToken.enabled, true);
assert.equal(await store.printeragent.findByToken('invalid-token'), null);
// touchLastseen
await store.printeragent.touchLastseen(a.id, '1.0.1');
const afterTouch = await store.printeragent.findByToken('test-token-123');
assert.equal(afterTouch.agentversion, '1.0.1');
// update name, sort, and enabled
const b = await store.printeragent.update(googleAuthId, { id: a.id, name: 'Kitchen', sort: 1, enabled: false });
assert.equal(b.name, 'Kitchen');
assert.equal(b.sort, 1);
assert.equal(b.enabled, false);
// list
const list = await store.printeragent.list(googleAuthId);
assert.equal(list.length, 1);
assert.equal(list[0].name, 'Kitchen');
assert.equal(list[0].sort, 1);
assert.equal(list[0].enabled, false);
// test agentprinter findOrCreate
const ap1 = await store.agentprinter.findOrCreate(a.id, { type: 'zpl', model: 'ZD410-200dpi', serial: '50J203002218' });
assert.equal(ap1.created, true);
assert.equal(ap1.agentprinter.type, 'zpl');
assert.equal(ap1.agentprinter.model, 'ZD410-200dpi');
assert.equal(ap1.agentprinter.serial, '50J203002218');
assert.equal(ap1.agentprinter.enabled, true);
// second call with same combo returns existing
const ap1Again = await store.agentprinter.findOrCreate(a.id, { type: 'zpl', model: 'ZD410-200dpi', serial: '50J203002218' });
assert.equal(ap1Again.created, false);
assert.equal(ap1Again.agentprinter.id, ap1.agentprinter.id);
// create second printer
const ap2 = await store.agentprinter.findOrCreate(a.id, { type: 'esc/pos', model: 'TM-T88VI', serial: 'SN999' });
assert.equal(ap2.created, true);
// list agentprinters
const apList = await store.agentprinter.list(googleAuthId, a.id);
assert.equal(apList.length, 2);
// getById
const apGet = await store.agentprinter.getById(googleAuthId, ap1.agentprinter.id);
assert.equal(apGet.serial, '50J203002218');
// update agentprinter
const apUpdated = await store.agentprinter.update(googleAuthId, { id: ap1.agentprinter.id, enabled: false, sort: 10 });
assert.equal(apUpdated.enabled, false);
assert.equal(apUpdated.sort, 10);
// order agentprinters
const ordered = await store.agentprinter.order(googleAuthId, {
printeragentId: a.id,
agentprinterIds: [ap2.agentprinter.id, ap1.agentprinter.id],
});
assert.deepEqual(ordered.agentprinterIds, [ap2.agentprinter.id, ap1.agentprinter.id]);
// delete agentprinter
await store.agentprinter.delete(googleAuthId, ap2.agentprinter.id);
assert.equal((await store.agentprinter.list(googleAuthId, a.id)).length, 1);
// delete
await store.printeragent.delete(googleAuthId, a.id);
assert.equal(await store.printeragent.findByToken('test-token-123'), null);
assert.deepEqual(await store.printeragent.list(googleAuthId), []);
console.log('printeragent full test OK');
} finally {
await provider.close?.();
rmSync(tempDir, { recursive: true, force: true });
}

View File

@@ -1,6 +1,13 @@
import companyuserGetByEmail from './store/companyuser/getByEmail.js';
import companyuserAdd from './store/companyuser/add.js';
import companyuserDelete from './store/companyuser/delete.js';
import companyuserList from './store/companyuser/list.js';
import companyuserAddByEmail from './store/companyuser/addByEmail.js';
import companyuserSetRole from './store/companyuser/setRole.js';
import companyuserRemove from './store/companyuser/remove.js';
import autoinviteAdd from './store/autoinvite/add.js';
import autoinviteList from './store/autoinvite/list.js';
import autoinviteRemove from './store/autoinvite/remove.js';
import companyCreate from './store/company/create.js';
import companyUpdate from './store/company/update.js';
import companyDelete from './store/company/delete.js';
@@ -149,6 +156,7 @@ import posterminalGetCategoryList from './store/posterminal/getCategoryList.js';
import posterminalGetProductList from './store/posterminal/getProductList.js';
import posterminalSetMaxKnownObituaryId from './store/posterminal/setMaxKnownObituaryId.js';
import posterminalTouchLastseen from './store/posterminal/touchLastseen.js';
import posterminalSetClientversion from './store/posterminal/setClientversion.js';
import langCreate from './store/lang/create.js';
import langDelete from './store/lang/delete.js';
import langList from './store/lang/list.js';
@@ -199,6 +207,26 @@ import printerList from './store/printer/list.js';
import printerUpdate from './store/printer/update.js';
import printerDelete from './store/printer/delete.js';
import printerOrder from './store/printer/order.js';
import printeragentList from './store/printeragent/list.js';
import printeragentCreate from './store/printeragent/create.js';
import printeragentUpdate from './store/printeragent/update.js';
import printeragentDelete from './store/printeragent/delete.js';
import printeragentFindByPairingpin from './store/printeragent/findByPairingpin.js';
import printeragentFindByToken from './store/printeragent/findByToken.js';
import printeragentPair from './store/printeragent/pair.js';
import printeragentUpdateState from './store/printeragent/updateState.js';
import printeragentTouchLastseen from './store/printeragent/touchLastseen.js';
import agentprinterList from './store/agentprinter/list.js';
import agentprinterGetById from './store/agentprinter/getById.js';
import agentprinterFindOrCreate from './store/agentprinter/findOrCreate.js';
import agentprinterUpdate from './store/agentprinter/update.js';
import agentprinterOrder from './store/agentprinter/order.js';
import agentprinterDelete from './store/agentprinter/delete.js';
import printerjobCreate from './store/printerjob/create.js';
import printerjobGetById from './store/printerjob/getById.js';
import printerjobList from './store/printerjob/list.js';
import printerjobUpdateState from './store/printerjob/updateState.js';
import printerjobDelete from './store/printerjob/delete.js';
import modelconversationCreate from './store/modelconversation/create.js';
import modelconversationGet from './store/modelconversation/get.js';
import modelconversationList from './store/modelconversation/list.js';
@@ -313,6 +341,16 @@ export default class LibDashStore {
getByEmail: companyuserGetByEmail(provider),
add: companyuserAdd(provider),
delete: companyuserDelete(provider),
list: companyuserList(provider),
addByEmail: companyuserAddByEmail(provider),
setRole: companyuserSetRole(provider),
remove: companyuserRemove(provider),
};
this.autoinvite = {
add: autoinviteAdd(provider),
list: autoinviteList(provider),
remove: autoinviteRemove(provider),
};
this.company = {
@@ -535,6 +573,7 @@ export default class LibDashStore {
getProductList: posterminalGetProductList(provider),
setMaxKnownObituaryId: posterminalSetMaxKnownObituaryId(provider),
touchLastseen: posterminalTouchLastseen(provider),
setClientversion: posterminalSetClientversion(provider),
};
this.account = {
@@ -635,6 +674,35 @@ export default class LibDashStore {
order: printerOrder(provider),
};
this.printeragent = {
list: printeragentList(provider),
create: printeragentCreate(provider),
update: printeragentUpdate(provider),
delete: printeragentDelete(provider),
findByPairingpin: printeragentFindByPairingpin(provider),
findByToken: printeragentFindByToken(provider),
pair: printeragentPair(provider),
updateState: printeragentUpdateState(provider),
touchLastseen: printeragentTouchLastseen(provider),
};
this.agentprinter = {
list: agentprinterList(provider),
getById: agentprinterGetById(provider),
findOrCreate: agentprinterFindOrCreate(provider),
update: agentprinterUpdate(provider),
order: agentprinterOrder(provider),
delete: agentprinterDelete(provider),
};
this.printerjob = {
create: printerjobCreate(provider),
getById: printerjobGetById(provider),
list: printerjobList(provider),
updateState: printerjobUpdateState(provider),
delete: printerjobDelete(provider),
};
this.modelconversation = {
create: modelconversationCreate(provider),
get: modelconversationGet(provider),

View File

@@ -0,0 +1,15 @@
/**
* Schema 2 → 1 (MSSQL): drops posterminal `sort`, `enabled`, `clientversion`.
* @param {{ query: (sql: string, params?: object) => Promise<unknown>, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function downgrade({ query, assertVersion, setVersion }) {
await assertVersion(2);
await query(`
ALTER TABLE posterminal DROP CONSTRAINT DF_posterminal_sort, DF_posterminal_enabled;
ALTER TABLE posterminal DROP COLUMN sort, enabled, clientversion;
`);
await setVersion(1);
}

View File

@@ -0,0 +1,11 @@
/**
* Schema 3 → 2 (MSSQL): drops the `printeragent` table.
* @param {{ query: (sql: string, params?: object) => Promise<unknown>, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function downgrade({ query, assertVersion, setVersion }) {
await assertVersion(3);
await query(`DROP TABLE IF EXISTS printeragent;`);
await setVersion(2);
}

View File

@@ -0,0 +1,38 @@
/**
* Schema 4 → 3 (MSSQL):
* - Drops agentprinter table
* - Removes enabled from printeragent
* - Restores clientserial, clientmodel, clientstate in printeragent
* @param {{ query: (sql: string, params?: object) => Promise<unknown>, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function downgrade({ query, assertVersion, setVersion }) {
await assertVersion(4);
await query(`DROP TABLE IF EXISTS agentprinter;`);
await query(`
IF OBJECT_ID(N'dbo.printeragent', N'U') IS NOT NULL
BEGIN
IF COL_LENGTH('printeragent', 'enabled') IS NOT NULL
BEGIN
DECLARE @defName NVARCHAR(255);
SELECT @defName = dc.name
FROM sys.default_constraints dc
JOIN sys.columns c ON c.default_object_id = dc.object_id
WHERE dc.parent_object_id = OBJECT_ID(N'dbo.printeragent') AND c.name = 'enabled';
IF @defName IS NOT NULL
EXEC('ALTER TABLE printeragent DROP CONSTRAINT ' + @defName);
ALTER TABLE printeragent DROP COLUMN enabled;
END
IF COL_LENGTH('printeragent', 'clientserial') IS NULL
ALTER TABLE printeragent ADD clientserial NVARCHAR(255) NULL;
IF COL_LENGTH('printeragent', 'clientmodel') IS NULL
ALTER TABLE printeragent ADD clientmodel NVARCHAR(255) NULL;
IF COL_LENGTH('printeragent', 'clientstate') IS NULL
ALTER TABLE printeragent ADD clientstate NVARCHAR(255) NULL;
END
`);
await setVersion(3);
}

View File

@@ -0,0 +1,13 @@
/**
* Schema 5 → 4 (MSSQL):
* - Drops printerjob table
* @param {{ query: (sql: string, params?: object) => Promise<unknown>, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function downgrade({ query, assertVersion, setVersion }) {
await assertVersion(5);
await query(`DROP TABLE IF EXISTS printerjob;`);
await setVersion(4);
}

View File

@@ -0,0 +1,18 @@
/**
* Schema 6 → 5 (MSSQL):
* - Drops autoinvite table
* - Drops companyuser.role
* @param {{ query: (sql: string, params?: object) => Promise<unknown>, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function downgrade({ query, assertVersion, setVersion }) {
await assertVersion(6);
await query(`DROP TABLE IF EXISTS autoinvite;`);
await query(`
ALTER TABLE companyuser DROP CONSTRAINT CK_companyuser_role, DF_companyuser_role;
`);
await query(`ALTER TABLE companyuser DROP COLUMN role;`);
await setVersion(5);
}

View File

@@ -9,6 +9,7 @@ import { mkdirSync, promises as fsPromises } from 'node:fs';
import sql from 'mssql';
import { logSlowQuery } from '../provider.js';
import { safeWrite, safeReadByHash, safeDeleteByHash } from '../storage.js';
import { upgradeSchema, downgradeSchema, readSchemaVersion } from './migrate.js';
function normalizeRows(result) {
const recordset = result?.recordset ?? [];
@@ -304,8 +305,17 @@ export class MssqlProvider {
}
}
async initialize() {
/**
* Initializes the MSSQL schema.
* @param {((message: string) => void) | { log?: (message: string) => void }} [options]
* @returns {Promise<void>}
*/
async initialize(options = {}) {
const pool = await this.ensurePool();
const log = typeof options === 'function' ? options : options?.log;
// Versioned upgrades first, so schema.js never meets an outdated table shape.
await upgradeSchema(this, log);
const { SCHEMA_SCRIPTS } = await import('./schema.js');
for (const script of SCHEMA_SCRIPTS) {
if (script && script.trim()) {
@@ -350,6 +360,11 @@ export class MssqlProvider {
BEGIN
ALTER TABLE customergroup ADD updated_at DATETIME2 NOT NULL CONSTRAINT DF_customergroup_updated_at DEFAULT SYSUTCDATETIME();
END
IF OBJECT_ID(N'dbo.printeragent', N'U') IS NOT NULL AND COL_LENGTH('printeragent', 'sort') IS NULL
BEGIN
ALTER TABLE printeragent ADD sort INT NOT NULL CONSTRAINT DF_printeragent_sort DEFAULT 0;
END
`);
} catch {
// ignore
@@ -371,6 +386,24 @@ export class MssqlProvider {
}
}
/**
* Current `meta.version`, or `null` if the database has none.
* @returns {Promise<number|null>}
*/
async getSchemaVersion() {
return readSchemaVersion((sql, params) => this.query(sql, params));
}
/**
* Runs downgrade steps down to `target`. CLI only (`npm run downgrade`).
* @param {number} target
* @param {(message: string) => void} [log]
* @returns {Promise<number>} Steps applied.
*/
async downgradeSchema(target, log) {
return downgradeSchema(this, target, log);
}
close() {
return this.pool ? this.pool.close() : Promise.resolve();
}

View File

@@ -0,0 +1,126 @@
/**
* MSSQL schema versioning (see ../schemaMigrations.js).
*
* Step context: `{ query, getVersion, assertVersion, setVersion }` where
* `query(sql, params)` runs inside the step's transaction (same contract as
* `provider.query`). Note: a column added by `ALTER TABLE` is not visible to
* later statements of the SAME batch — use separate `query()` calls.
*/
import {
BASE_SCHEMA_VERSION,
assertStepResult,
latestSchemaVersion,
makeVersionContext,
migrateSchema,
parseSchemaVersion,
} from '../schemaMigrations.js';
export const UPGRADE_DIR = new URL('./upgrade/', import.meta.url);
export const DOWNGRADE_DIR = new URL('./downgrade/', import.meta.url);
/** Schema version a fresh database is created at. */
export const SCHEMA_VERSION = latestSchemaVersion(UPGRADE_DIR);
/**
* @typedef {(sql: string, params?: object) => Promise<{ rows: any[] }>} QueryFn
*/
/**
* Reads `meta.version`, or `null` when the meta table/row is missing.
* @param {QueryFn} query
* @returns {Promise<number|null>}
*/
export async function readSchemaVersion(query) {
const result = await query(`
IF OBJECT_ID(N'dbo.meta', N'U') IS NOT NULL
SELECT value FROM meta WHERE [key] = N'version'
ELSE
SELECT CAST(NULL AS NVARCHAR(MAX)) AS value WHERE 1 = 0
`);
const row = result.rows[0];
return row ? parseSchemaVersion(row.value) : null;
}
async function writeSchemaVersion(query, version) {
await query(`
UPDATE meta SET value = @version WHERE [key] = N'version';
IF @@ROWCOUNT = 0 INSERT INTO meta ([key], value) VALUES (N'version', @version);
`, { version: String(version) });
}
async function hasAnyTable(query) {
const result = await query(`SELECT TOP 1 1 AS found FROM sys.tables WHERE is_ms_shipped = 0`);
return result.rows.length > 0;
}
/**
* @param {import('./index.js').MssqlProvider} provider
*/
function makeRunStep(provider) {
return async (step, fn) => {
await provider.transaction(async (runner) => {
const query = (sql, params) => runner.query(sql, params);
const ctx = {
query,
...makeVersionContext(step.file, () => readSchemaVersion(query), (v) => writeSchemaVersion(query, v)),
};
await fn(ctx);
assertStepResult(step, await readSchemaVersion(query));
});
};
}
/**
* Upgrades an existing database to `SCHEMA_VERSION`. Must run BEFORE the
* `schema.js` scripts so they never meet an outdated table shape.
* Fresh (empty) databases are skipped — `INIT_META` stamps them.
* A non-empty database without a version row is treated as version 1.
* @param {import('./index.js').MssqlProvider} provider
* @param {(message: string) => void} [log]
*/
export async function upgradeSchema(provider, log) {
const query = (sql, params) => provider.query(sql, params);
if (!(await hasAnyTable(query))) {
log?.(`Fresh database detected; schema will initialize directly at version ${SCHEMA_VERSION}`);
return 0;
}
let current = await readSchemaVersion(query);
if (current === null) {
log?.(`Existing database has no version row; setting baseline version ${BASE_SCHEMA_VERSION}`);
await query(`
IF OBJECT_ID(N'dbo.meta', N'U') IS NULL
CREATE TABLE meta ([key] NVARCHAR(255) NOT NULL, value NVARCHAR(MAX) NOT NULL, CONSTRAINT PK_meta PRIMARY KEY ([key]));
`);
await writeSchemaVersion(query, BASE_SCHEMA_VERSION);
current = BASE_SCHEMA_VERSION;
}
return migrateSchema({
direction: 'upgrade',
dirUrl: UPGRADE_DIR,
current,
target: SCHEMA_VERSION,
runStep: makeRunStep(provider),
log,
});
}
/**
* Downgrades the database to `target` (CLI only, never at server start).
* @param {import('./index.js').MssqlProvider} provider
* @param {number} target
* @param {(message: string) => void} [log]
*/
export async function downgradeSchema(provider, target, log) {
const current = await readSchemaVersion((sql, params) => provider.query(sql, params));
if (current === null) throw new Error('Database has no schema version (meta table missing)');
return migrateSchema({
direction: 'downgrade',
dirUrl: DOWNGRADE_DIR,
current,
target,
runStep: makeRunStep(provider),
log,
});
}

View File

@@ -28,6 +28,9 @@ export const TABLES = [
'posterminalobituary',
'posterminalcategory',
'posterminal',
'printerjob',
'agentprinter',
'printeragent',
'printer',
'numbers',
'ledger',

View File

@@ -0,0 +1,69 @@
export const AGENTPRINTER_COLUMNS = `
ap.id,
ap.printeragent_id AS printeragentId,
ap.type,
ap.model,
ap.serial,
ap.enabled,
ap.sort
`;
export const LIST_BY_COMPANY = `
SELECT
${AGENTPRINTER_COLUMNS}
FROM agentprinter ap
JOIN printeragent pa ON pa.id = ap.printeragent_id
WHERE pa.company_id = @companyId
ORDER BY ap.sort, ap.id
`;
export const LIST_BY_PRINTERAGENT = `
SELECT
${AGENTPRINTER_COLUMNS}
FROM agentprinter ap
JOIN printeragent pa ON pa.id = ap.printeragent_id
WHERE ap.printeragent_id = @printeragentId AND pa.company_id = @companyId
ORDER BY ap.sort, ap.id
`;
export const GET_BY_ID = `
SELECT
${AGENTPRINTER_COLUMNS}
FROM agentprinter ap
JOIN printeragent pa ON pa.id = ap.printeragent_id
WHERE ap.id = @id AND pa.company_id = @companyId
`;
export const FIND_BY_COMBO = `
SELECT
${AGENTPRINTER_COLUMNS}
FROM agentprinter ap
WHERE ap.printeragent_id = @printeragentId
AND ap.type = @type
AND (ap.serial = @serial OR (ap.serial IS NULL AND @serial IS NULL))
AND (ap.model = @model OR (ap.model IS NULL AND @model IS NULL))
`;
export const CREATE = `
INSERT INTO agentprinter (printeragent_id, type, model, serial, enabled, sort)
OUTPUT
INSERTED.id,
INSERTED.printeragent_id AS printeragentId,
INSERTED.type,
INSERTED.model,
INSERTED.serial,
INSERTED.enabled,
INSERTED.sort
VALUES (@printeragentId, @type, @model, @serial, @enabled, @sort)
`;
export const UPDATE = `
UPDATE agentprinter
SET enabled = @enabled, sort = @sort
WHERE id = @id AND printeragent_id IN (SELECT id FROM printeragent WHERE company_id = @companyId)
`;
export const DELETE_BY_COMPANY_AND_ID = `
DELETE FROM agentprinter
WHERE id = @id AND printeragent_id IN (SELECT id FROM printeragent WHERE company_id = @companyId)
`;

View File

@@ -29,6 +29,8 @@ export const ARTICLE_LIST_COLUMNS = `
a.sku,
at.name,
at.slug,
ISNULL(LEN(at.[desc]), 0) AS descLength,
at.seo AS seo,
pr.net,
t.rate AS taxRate,
a.track_stock,

View File

@@ -0,0 +1,43 @@
export const ADD = `
INSERT INTO autoinvite (company_id, email, role)
OUTPUT INSERTED.id
VALUES (@companyId, @email, @role)
`;
export const LIST_BY_COMPANY = `
SELECT
id,
email,
role,
created_at AS createdAt
FROM autoinvite
WHERE company_id = @companyId
ORDER BY email
`;
export const LIST_BY_EMAIL = `
SELECT
id,
company_id AS companyId,
role
FROM autoinvite
WHERE email = @email
ORDER BY id
`;
export const GET_IN_COMPANY = `
SELECT id
FROM autoinvite
WHERE id = @id AND company_id = @companyId
`;
export const GET_BY_COMPANY_EMAIL = `
SELECT id
FROM autoinvite
WHERE company_id = @companyId AND email = @email
`;
export const DELETE = `
DELETE FROM autoinvite
WHERE id = @id
`;

View File

@@ -64,7 +64,6 @@ FROM categoryarticle ca
INNER JOIN article a ON a.id = ca.article_id
${ARTICLE_LIST_JOINS}
WHERE ca.category_id = @categoryId
AND a.parentarticle_id IS NULL
ORDER BY a.id
`;
@@ -80,10 +79,19 @@ INNER JOIN warehouse w ON w.id = aws.warehouse_id
INNER JOIN categoryarticle ca ON ca.article_id = aws.article_id
INNER JOIN article a ON a.id = ca.article_id
WHERE ca.category_id = @categoryId
AND a.parentarticle_id IS NULL
ORDER BY w.sort ASC, w.name ASC, aws.id ASC
`;
export const GET_CATEGORY_ARTICLE_EANS = `
SELECT
ae.article_id AS articleId,
ae.ean
FROM articleean ae
INNER JOIN categoryarticle ca ON ca.article_id = ae.article_id
WHERE ca.category_id = @categoryId
ORDER BY ae.ean ASC
`;
export const CREATE = `
INSERT INTO category (parent_id, sort)
OUTPUT INSERTED.id, INSERTED.parent_id AS parentId, INSERTED.sort

View File

@@ -55,7 +55,8 @@ SELECT
l.iso639,
tr.rate AS taxrate,
rtr.rate AS reducedtaxrate,
tfr.rate AS taxfreerate
tfr.rate AS taxfreerate,
cu.role AS role
FROM company c
INNER JOIN companyuser cu ON cu.company_id = c.id
LEFT JOIN language l ON l.id = c.default_language_id

View File

@@ -10,7 +10,8 @@ SELECT
c.root_category_id AS rootCategoryId,
c.default_language_id AS languageId,
c.poscustomergroup_id AS posCustomerGroupId,
cu.id AS companyuserId
cu.id AS companyuserId,
cu.role AS role
FROM company c
LEFT JOIN companyuser cu
ON cu.company_id = c.id AND cu.google_auth_id = @googleAuthId
@@ -18,12 +19,75 @@ WHERE c.id = @companyId
`;
export const ADD = `
INSERT INTO companyuser (google_auth_id, company_id)
INSERT INTO companyuser (google_auth_id, company_id, role)
OUTPUT INSERTED.id
VALUES (@googleAuthId, @companyId)
VALUES (@googleAuthId, @companyId, @role)
`;
export const DELETE = `
DELETE FROM companyuser
WHERE id = @id
`;
export const LIST_BY_COMPANY = `
SELECT
cu.id,
cu.google_auth_id AS googleAuthId,
ga.email,
ga.picture,
cu.role
FROM companyuser cu
INNER JOIN google_auth ga ON ga.id = cu.google_auth_id
WHERE cu.company_id = @companyId
ORDER BY ga.email
`;
export const GET_IN_COMPANY = `
SELECT
cu.id,
cu.google_auth_id AS googleAuthId,
ga.email,
ga.picture,
cu.role
FROM companyuser cu
INNER JOIN google_auth ga ON ga.id = cu.google_auth_id
WHERE cu.id = @id AND cu.company_id = @companyId
`;
export const GET_MEMBERSHIP = `
SELECT id, role
FROM companyuser
WHERE google_auth_id = @googleAuthId AND company_id = @companyId
`;
export const COUNT_ADMINS = `
SELECT COUNT(*) AS count
FROM companyuser
WHERE company_id = @companyId AND role = 'admin'
`;
export const SET_ROLE = `
UPDATE companyuser
SET role = @role
WHERE id = @id
`;
export const CLEAR_ENTITYHISTORY_USER = `
UPDATE entityhistory SET companyuser_id = NULL WHERE companyuser_id = @id
`;
export const CLEAR_COMPANYHISTORY_USER = `
UPDATE companyhistory SET companyuser_id = NULL WHERE companyuser_id = @id
`;
export const IS_MEMBER = `
SELECT id
FROM companyuser
WHERE google_auth_id = @googleAuthId AND company_id = @companyId
`;
export const CLEAR_SELECTED_COMPANY = `
UPDATE google_auth
SET selected_company = NULL
WHERE id = @googleAuthId AND selected_company = @companyId
`;

View File

@@ -10,10 +10,13 @@ SELECT
root_category_id AS rootCategoryId,
sync_root_articles AS syncRootArticles,
lastseen_at AS lastseenAt,
updated_at AS updatedAt
updated_at AS updatedAt,
sort,
enabled,
clientversion
FROM posterminal
WHERE company_id = @companyId
ORDER BY id
ORDER BY sort, id
`;
export const GET_BY_ID = `
@@ -29,7 +32,10 @@ SELECT
root_category_id AS rootCategoryId,
sync_root_articles AS syncRootArticles,
lastseen_at AS lastseenAt,
updated_at AS updatedAt
updated_at AS updatedAt,
sort,
enabled,
clientversion
FROM posterminal
WHERE id = @id
`;
@@ -47,29 +53,38 @@ SELECT
root_category_id AS rootCategoryId,
sync_root_articles AS syncRootArticles,
lastseen_at AS lastseenAt,
updated_at AS updatedAt
updated_at AS updatedAt,
sort,
enabled,
clientversion
FROM posterminal
WHERE pairingpin = @pairingpin
`;
export const PAIR = `
UPDATE posterminal
SET token = @token, paired_at = @pairedAt, clientserial = @clientserial, pairingpin = NULL, lastseen_at = @pairedAt, updated_at = SYSUTCDATETIME()
SET token = @token, paired_at = @pairedAt, clientserial = @clientserial, clientversion = @clientversion, pairingpin = NULL, lastseen_at = @pairedAt, updated_at = SYSUTCDATETIME()
WHERE id = @id
`;
export const CREATE = `
INSERT INTO posterminal (company_id, name, token, pairingpin, paired_at, clientserial, maxknownobituaryid, root_category_id, sync_root_articles, lastseen_at, updated_at)
OUTPUT INSERTED.id, INSERTED.name, INSERTED.token, INSERTED.pairingpin, INSERTED.paired_at AS pairedAt, INSERTED.clientserial AS clientserial, INSERTED.maxknownobituaryid AS maxknownobituaryid, INSERTED.root_category_id AS rootCategoryId, INSERTED.sync_root_articles AS syncRootArticles, INSERTED.lastseen_at AS lastseenAt, INSERTED.updated_at AS updatedAt
VALUES (@companyId, @name, @token, @pairingpin, @pairedAt, @clientserial, @maxknownobituaryid, @rootCategoryId, @syncRootArticles, @lastseenAt, SYSUTCDATETIME())
INSERT INTO posterminal (company_id, name, token, pairingpin, paired_at, clientserial, maxknownobituaryid, root_category_id, sync_root_articles, lastseen_at, sort, enabled, updated_at)
OUTPUT INSERTED.id, INSERTED.name, INSERTED.token, INSERTED.pairingpin, INSERTED.paired_at AS pairedAt, INSERTED.clientserial AS clientserial, INSERTED.maxknownobituaryid AS maxknownobituaryid, INSERTED.root_category_id AS rootCategoryId, INSERTED.sync_root_articles AS syncRootArticles, INSERTED.lastseen_at AS lastseenAt, INSERTED.updated_at AS updatedAt, INSERTED.sort, INSERTED.enabled, INSERTED.clientversion
VALUES (@companyId, @name, @token, @pairingpin, @pairedAt, @clientserial, @maxknownobituaryid, @rootCategoryId, @syncRootArticles, @lastseenAt, @sort, @enabled, SYSUTCDATETIME())
`;
export const UPDATE = `
UPDATE posterminal
SET name = @name, token = @token, pairingpin = @pairingpin, paired_at = @pairedAt, clientserial = @clientserial, maxknownobituaryid = @maxknownobituaryid, root_category_id = @rootCategoryId, sync_root_articles = @syncRootArticles, lastseen_at = @lastseenAt, updated_at = SYSUTCDATETIME()
SET name = @name, token = @token, pairingpin = @pairingpin, paired_at = @pairedAt, clientserial = @clientserial, maxknownobituaryid = @maxknownobituaryid, root_category_id = @rootCategoryId, sync_root_articles = @syncRootArticles, lastseen_at = @lastseenAt, sort = @sort, enabled = @enabled, clientversion = @clientversion, updated_at = SYSUTCDATETIME()
WHERE id = @id AND company_id = @companyId
`;
export const SET_CLIENTVERSION = `
UPDATE posterminal
SET clientversion = @clientversion, updated_at = SYSUTCDATETIME()
WHERE id = @id
`;
export const TOUCH_LASTSEEN = `
UPDATE posterminal
SET lastseen_at = SYSUTCDATETIME()
@@ -166,7 +181,10 @@ SELECT
root_category_id AS rootCategoryId,
sync_root_articles AS syncRootArticles,
lastseen_at AS lastseenAt,
updated_at AS updatedAt
updated_at AS updatedAt,
sort,
enabled,
clientversion
FROM posterminal
WHERE token = @token
`;

View File

@@ -0,0 +1,109 @@
export const PRINTERAGENT_COLUMNS = `
id,
company_id AS companyId,
name,
token,
pairingpin,
paired_at AS pairedAt,
agentversion,
lastseen_at AS lastseenAt,
updated_at AS updatedAt,
sort,
enabled
`;
export const LIST_BY_COMPANY = `
SELECT
${PRINTERAGENT_COLUMNS}
FROM printeragent
WHERE company_id = @companyId
ORDER BY sort, id
`;
export const GET_BY_COMPANY_AND_ID = `
SELECT
${PRINTERAGENT_COLUMNS}
FROM printeragent
WHERE id = @id AND company_id = @companyId
`;
export const GET_BY_ID = `
SELECT
${PRINTERAGENT_COLUMNS}
FROM printeragent
WHERE id = @id
`;
export const FIND_BY_PAIRINGPIN = `
SELECT
${PRINTERAGENT_COLUMNS}
FROM printeragent
WHERE pairingpin = @pairingpin
`;
export const FIND_BY_TOKEN = `
SELECT
${PRINTERAGENT_COLUMNS}
FROM printeragent
WHERE token = @token
`;
export const PAIR = `
UPDATE printeragent
SET token = @token,
pairingpin = NULL,
paired_at = @pairedAt,
agentversion = @agentversion,
lastseen_at = SYSUTCDATETIME(),
updated_at = SYSUTCDATETIME()
WHERE id = @id
`;
export const UPDATE_STATE = `
UPDATE printeragent
SET clientstate = @clientstate,
lastseen_at = SYSUTCDATETIME(),
updated_at = SYSUTCDATETIME()
WHERE id = @id
`;
export const TOUCH_LASTSEEN = `
UPDATE printeragent
SET lastseen_at = SYSUTCDATETIME()
WHERE id = @id
`;
export const TOUCH_LASTSEEN_AND_VERSION = `
UPDATE printeragent
SET lastseen_at = SYSUTCDATETIME(),
agentversion = @agentversion
WHERE id = @id
`;
export const CREATE = `
INSERT INTO printeragent (company_id, name, pairingpin, sort, enabled)
OUTPUT
INSERTED.id,
INSERTED.company_id AS companyId,
INSERTED.name,
INSERTED.token,
INSERTED.pairingpin,
INSERTED.paired_at AS pairedAt,
INSERTED.agentversion,
INSERTED.lastseen_at AS lastseenAt,
INSERTED.updated_at AS updatedAt,
INSERTED.sort,
INSERTED.enabled
VALUES (@companyId, @name, @pairingpin, @sort, @enabled)
`;
export const UPDATE = `
UPDATE printeragent
SET name = @name, sort = @sort, enabled = @enabled, updated_at = SYSUTCDATETIME()
WHERE id = @id AND company_id = @companyId
`;
export const DELETE_BY_COMPANY_AND_ID = `
DELETE FROM printeragent
WHERE id = @id AND company_id = @companyId
`;

View File

@@ -0,0 +1,115 @@
export const PRINTERJOB_COLUMNS = `
pj.id,
pj.agentprinter_id AS agentprinterId,
pj.type,
pj.state,
pj.data,
pj.created_at AS createdAt,
pj.completed_at AS completedAt
`;
export const CREATE = `
INSERT INTO printerjob (agentprinter_id, type, state, data, created_at)
OUTPUT
INSERTED.id,
INSERTED.agentprinter_id AS agentprinterId,
INSERTED.type,
INSERTED.state,
INSERTED.data,
INSERTED.created_at AS createdAt,
INSERTED.completed_at AS completedAt
VALUES (@agentprinterId, @type, @state, @data, SYSUTCDATETIME())
`;
export const GET_BY_ID_FOR_COMPANY = `
SELECT
${PRINTERJOB_COLUMNS},
ap.printeragent_id AS printeragentId
FROM printerjob pj
JOIN agentprinter ap ON ap.id = pj.agentprinter_id
JOIN printeragent pa ON pa.id = ap.printeragent_id
WHERE pj.id = @id AND pa.company_id = @companyId
`;
export const GET_BY_ID_FOR_AGENT = `
SELECT
${PRINTERJOB_COLUMNS},
ap.printeragent_id AS printeragentId
FROM printerjob pj
JOIN agentprinter ap ON ap.id = pj.agentprinter_id
WHERE pj.id = @id AND ap.printeragent_id = @agentId
`;
export const LIST_BY_COMPANY = `
SELECT
${PRINTERJOB_COLUMNS},
ap.printeragent_id AS printeragentId
FROM printerjob pj
JOIN agentprinter ap ON ap.id = pj.agentprinter_id
JOIN printeragent pa ON pa.id = ap.printeragent_id
WHERE pa.company_id = @companyId
ORDER BY pj.id DESC
`;
export const LIST_BY_COMPANY_AND_AGENTPRINTER = `
SELECT
${PRINTERJOB_COLUMNS},
ap.printeragent_id AS printeragentId
FROM printerjob pj
JOIN agentprinter ap ON ap.id = pj.agentprinter_id
JOIN printeragent pa ON pa.id = ap.printeragent_id
WHERE pa.company_id = @companyId AND pj.agentprinter_id = @agentprinterId
ORDER BY pj.id DESC
`;
export const LIST_BY_AGENT = `
SELECT
${PRINTERJOB_COLUMNS},
ap.printeragent_id AS printeragentId
FROM printerjob pj
JOIN agentprinter ap ON ap.id = pj.agentprinter_id
WHERE ap.printeragent_id = @agentId
ORDER BY pj.id ASC
`;
export const LIST_BY_AGENT_AND_STATE = `
SELECT
${PRINTERJOB_COLUMNS},
ap.printeragent_id AS printeragentId
FROM printerjob pj
JOIN agentprinter ap ON ap.id = pj.agentprinter_id
WHERE ap.printeragent_id = @agentId AND pj.state = @state
ORDER BY pj.id ASC
`;
export const UPDATE_STATE_FOR_AGENT = `
UPDATE printerjob
SET
state = @state,
completed_at = CASE WHEN @state IN ('completed', 'cancelled') THEN SYSUTCDATETIME() ELSE completed_at END
WHERE id = @id AND agentprinter_id IN (
SELECT ap.id FROM agentprinter ap WHERE ap.printeragent_id = @agentId
)
`;
export const UPDATE_STATE_FOR_COMPANY = `
UPDATE printerjob
SET
state = @state,
completed_at = CASE WHEN @state IN ('completed', 'cancelled') THEN SYSUTCDATETIME() ELSE completed_at END
WHERE id = @id AND agentprinter_id IN (
SELECT ap.id FROM agentprinter ap
JOIN printeragent pa ON pa.id = ap.printeragent_id
WHERE pa.company_id = @companyId
)
`;
export const DELETE_BY_COMPANY_AND_ID = `
DELETE FROM printerjob
WHERE id = @id AND agentprinter_id IN (
SELECT ap.id FROM agentprinter ap
JOIN printeragent pa ON pa.id = ap.printeragent_id
WHERE pa.company_id = @companyId
)
`;

View File

@@ -1,3 +1,5 @@
import { SCHEMA_VERSION } from './migrate.js';
export const INIT_META = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'meta')
BEGIN
@@ -11,7 +13,7 @@ END
IF NOT EXISTS (SELECT 1 FROM meta WHERE [key] = N'version')
BEGIN
INSERT INTO meta ([key], value)
VALUES (N'version', N'1');
VALUES (N'version', N'${SCHEMA_VERSION}');
END
`;
@@ -864,6 +866,22 @@ END
');
`;
export const INIT_AUTOINVITE = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'autoinvite')
BEGIN
CREATE TABLE autoinvite (
id INT IDENTITY(1,1) PRIMARY KEY,
company_id INT NOT NULL,
email NVARCHAR(255) NOT NULL,
role NVARCHAR(10) NOT NULL CONSTRAINT DF_autoinvite_role DEFAULT 'user',
created_at DATETIME2 NOT NULL CONSTRAINT DF_autoinvite_created_at DEFAULT SYSUTCDATETIME(),
CONSTRAINT CK_autoinvite_role CHECK (role IN ('admin', 'user')),
CONSTRAINT UQ_autoinvite_company_email UNIQUE (company_id, email),
CONSTRAINT FK_autoinvite_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE
);
END
`;
export const INIT_COMPANYUSER = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'companyuser')
BEGIN
@@ -871,6 +889,8 @@ BEGIN
id INT IDENTITY(1,1) PRIMARY KEY,
google_auth_id INT NOT NULL,
company_id INT NOT NULL,
role NVARCHAR(10) NOT NULL CONSTRAINT DF_companyuser_role DEFAULT 'user',
CONSTRAINT CK_companyuser_role CHECK (role IN ('admin', 'user')),
CONSTRAINT UQ_companyuser_google_auth_company UNIQUE (google_auth_id, company_id),
CONSTRAINT FK_companyuser_google_auth FOREIGN KEY (google_auth_id) REFERENCES google_auth(id) ON DELETE CASCADE,
CONSTRAINT FK_companyuser_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE
@@ -966,6 +986,9 @@ BEGIN
sync_root_articles BIT NOT NULL CONSTRAINT DF_posterminal_sync_root_articles DEFAULT 1,
lastseen_at DATETIME2 NULL,
updated_at DATETIME2 NOT NULL CONSTRAINT DF_posterminal_updated_at DEFAULT SYSUTCDATETIME(),
sort INT NOT NULL CONSTRAINT DF_posterminal_sort DEFAULT 0,
enabled BIT NOT NULL CONSTRAINT DF_posterminal_enabled DEFAULT 1,
clientversion NVARCHAR(255) NULL,
CONSTRAINT FK_posterminal_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE,
CONSTRAINT FK_posterminal_root_category FOREIGN KEY (root_category_id) REFERENCES category(id) ON DELETE SET NULL
);
@@ -1006,6 +1029,63 @@ END
`;
export const INIT_PRINTERAGENT = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'printeragent')
BEGIN
CREATE TABLE printeragent (
id INT IDENTITY(1,1) PRIMARY KEY,
company_id INT NOT NULL,
name NVARCHAR(255) NOT NULL,
token NVARCHAR(255) NULL,
pairingpin NVARCHAR(50) NULL,
paired_at DATETIME2 NULL,
agentversion NVARCHAR(255) NULL,
lastseen_at DATETIME2 NULL,
updated_at DATETIME2 NOT NULL CONSTRAINT DF_printeragent_updated_at DEFAULT SYSUTCDATETIME(),
sort INT NOT NULL CONSTRAINT DF_printeragent_sort DEFAULT 0,
enabled BIT NOT NULL CONSTRAINT DF_printeragent_enabled DEFAULT 1,
CONSTRAINT FK_printeragent_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX UQ_printeragent_token ON printeragent(token) WHERE token IS NOT NULL;
CREATE UNIQUE INDEX UQ_printeragent_pairingpin ON printeragent(pairingpin) WHERE pairingpin IS NOT NULL;
END
`;
export const INIT_AGENTPRINTER = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'agentprinter')
BEGIN
CREATE TABLE agentprinter (
id INT IDENTITY(1,1) PRIMARY KEY,
printeragent_id INT NOT NULL,
type NVARCHAR(50) NOT NULL,
model NVARCHAR(255) NULL,
serial NVARCHAR(255) NULL,
enabled BIT NOT NULL CONSTRAINT DF_agentprinter_enabled DEFAULT 1,
sort INT NOT NULL CONSTRAINT DF_agentprinter_sort DEFAULT 0,
CONSTRAINT FK_agentprinter_printeragent FOREIGN KEY (printeragent_id) REFERENCES printeragent(id) ON DELETE CASCADE
);
CREATE INDEX IX_agentprinter_printeragent_id ON agentprinter(printeragent_id);
END
`;
export const INIT_PRINTERJOB = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'printerjob')
BEGIN
CREATE TABLE printerjob (
id INT IDENTITY(1,1) PRIMARY KEY,
agentprinter_id INT NOT NULL,
type NVARCHAR(50) NOT NULL,
state NVARCHAR(50) NOT NULL CONSTRAINT DF_printerjob_state DEFAULT 'pending',
data NVARCHAR(MAX) NOT NULL,
created_at DATETIME2 NOT NULL CONSTRAINT DF_printerjob_created_at DEFAULT SYSUTCDATETIME(),
completed_at DATETIME2 NULL,
CONSTRAINT FK_printerjob_agentprinter FOREIGN KEY (agentprinter_id) REFERENCES agentprinter(id) ON DELETE CASCADE
);
CREATE INDEX IX_printerjob_agentprinter_id ON printerjob(agentprinter_id);
CREATE INDEX IX_printerjob_state ON printerjob(state);
END
`;
export const INIT_LABELTEMPLATE = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'labeltemplate')
BEGIN
@@ -1649,6 +1729,7 @@ export const SCHEMA_SCRIPTS = [
INIT_GET_NEXT_NUMBER_WITH_PREFIX_AND_PADDING,
INIT_PEEK_NEXT_NUMBER_WITH_PREFIX_AND_PADDING,
INIT_COMPANYUSER,
INIT_AUTOINVITE,
INIT_MODELCONVERSATION,
INIT_MODELMESSAGE,
INIT_MODELVFS,
@@ -1661,6 +1742,9 @@ export const SCHEMA_SCRIPTS = [
INIT_POSTERMINALOBITUARY,
INIT_POSTERMINALSYNCQUEUE,
INIT_PRINTER,
INIT_PRINTERAGENT,
INIT_AGENTPRINTER,
INIT_PRINTERJOB,
INIT_LABELTEMPLATE,
INIT_FILE,
INIT_MANIFESTATTACHMENT,

View File

@@ -0,0 +1,17 @@
/**
* Schema 1 → 2 (MSSQL): posterminal gets `sort`, `enabled`, `clientversion`.
* @param {{ query: (sql: string, params?: object) => Promise<unknown>, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function upgrade({ query, assertVersion, setVersion }) {
await assertVersion(1);
await query(`
ALTER TABLE posterminal ADD
sort INT NOT NULL CONSTRAINT DF_posterminal_sort DEFAULT 0,
enabled BIT NOT NULL CONSTRAINT DF_posterminal_enabled DEFAULT 1,
clientversion NVARCHAR(255) NULL;
`);
await setVersion(2);
}

View File

@@ -0,0 +1,20 @@
import { INIT_PRINTERAGENT } from '../schema.js';
/**
* Schema 2 → 3 (MSSQL): adds the `printeragent` table.
* @param {{ query: (sql: string, params?: object) => Promise<unknown>, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function upgrade({ query, assertVersion, setVersion }) {
await assertVersion(2);
await query(INIT_PRINTERAGENT);
await query(`
IF OBJECT_ID(N'dbo.printeragent', N'U') IS NOT NULL AND COL_LENGTH('printeragent', 'sort') IS NULL
BEGIN
ALTER TABLE printeragent ADD sort INT NOT NULL CONSTRAINT DF_printeragent_sort DEFAULT 0;
END
`);
await setVersion(3);
}

View File

@@ -0,0 +1,31 @@
import { INIT_AGENTPRINTER } from '../schema.js';
/**
* Schema 3 → 4 (MSSQL):
* - Drops clientstate, clientmodel, clientserial from printeragent
* - Adds enabled to printeragent
* - Creates agentprinter table
* @param {{ query: (sql: string, params?: object) => Promise<unknown>, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function upgrade({ query, assertVersion, setVersion }) {
await assertVersion(3);
await query(`
IF OBJECT_ID(N'dbo.printeragent', N'U') IS NOT NULL
BEGIN
IF COL_LENGTH('printeragent', 'clientstate') IS NOT NULL
ALTER TABLE printeragent DROP COLUMN clientstate;
IF COL_LENGTH('printeragent', 'clientmodel') IS NOT NULL
ALTER TABLE printeragent DROP COLUMN clientmodel;
IF COL_LENGTH('printeragent', 'clientserial') IS NOT NULL
ALTER TABLE printeragent DROP COLUMN clientserial;
IF COL_LENGTH('printeragent', 'enabled') IS NULL
ALTER TABLE printeragent ADD enabled BIT NOT NULL CONSTRAINT DF_printeragent_enabled DEFAULT 1;
END
`);
await query(INIT_AGENTPRINTER);
await setVersion(4);
}

View File

@@ -0,0 +1,15 @@
import { INIT_PRINTERJOB } from '../schema.js';
/**
* Schema 4 → 5 (MSSQL):
* - Creates printerjob table
* @param {{ query: (sql: string, params?: object) => Promise<unknown>, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function upgrade({ query, assertVersion, setVersion }) {
await assertVersion(4);
await query(INIT_PRINTERJOB);
await setVersion(5);
}

View File

@@ -0,0 +1,25 @@
import { INIT_AUTOINVITE } from '../schema.js';
/**
* Schema 5 → 6 (MSSQL):
* - Adds companyuser.role ('admin' | 'user'); existing members become 'admin'
* (they had full access before roles existed)
* - Creates autoinvite table
* @param {{ query: (sql: string, params?: object) => Promise<unknown>, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function upgrade({ query, assertVersion, setVersion }) {
await assertVersion(5);
await query(`
ALTER TABLE companyuser ADD
role NVARCHAR(10) NOT NULL CONSTRAINT DF_companyuser_role DEFAULT 'user';
`);
await query(`
ALTER TABLE companyuser ADD CONSTRAINT CK_companyuser_role CHECK (role IN ('admin', 'user'));
`);
await query(`UPDATE companyuser SET role = 'admin';`);
await query(INIT_AUTOINVITE);
await setVersion(6);
}

View File

@@ -0,0 +1,198 @@
/**
* Versioned schema migrations shared by the MSSQL and SQLite providers.
*
* Layout (per provider):
* src/providers/<provider>/upgrade/<from>_to_<to>.js (to = from + 1)
* src/providers/<provider>/downgrade/<from>_to_<to>.js (to = from - 1)
*
* Each step file default-exports `async function (ctx)`. `ctx` always has:
* - `getVersion()` → current `meta.version` (number)
* - `assertVersion(expected)` → throws unless `meta.version === expected`
* - `setVersion(version)` → writes `meta.version`
* plus provider-specific handles (`db`/`exec` for SQLite, `query` for MSSQL).
*
* A step must assert its start version and finish by setting its end version;
* the runner verifies the end version and rolls back on mismatch. Each step
* runs in its own transaction.
*
* Fresh databases are created from `schema.js` (always the latest shape) and
* stamped with `SCHEMA_VERSION` (= highest upgrade target) by `INIT_META`.
* Existing databases are upgraded step by step at provider `initialize()`.
*
* Adding a version N → N+1:
* 1. Add `upgrade/N_to_N+1.js` and `downgrade/N+1_to_N.js` for BOTH providers.
* 2. Update both `schema.js` files so a fresh DB has the new shape.
* `SCHEMA_VERSION` is derived from the upgrade folder — nothing else to bump.
*/
import { readdirSync } from 'node:fs';
/** Version of a database that has never run a versioned upgrade. */
export const BASE_SCHEMA_VERSION = 1;
const STEP_FILE = /^(\d+)_to_(\d+)\.js$/;
/**
* Lists and validates the step files of one direction.
* @param {URL} dirUrl - Directory URL (trailing slash).
* @param {'upgrade'|'downgrade'} direction
* @returns {Array<{ from: number, to: number, file: string, url: URL }>} Sorted by `from` (asc for upgrade, desc for downgrade).
*/
export function listSteps(dirUrl, direction) {
let names;
try {
names = readdirSync(dirUrl);
} catch (err) {
if (err.code === 'ENOENT') return [];
throw err;
}
const delta = direction === 'upgrade' ? 1 : -1;
const steps = [];
const seen = new Set();
for (const file of names) {
if (!file.endsWith('.js')) continue;
const match = STEP_FILE.exec(file);
if (!match) {
throw new Error(`Invalid schema ${direction} file name: ${file} (expected <from>_to_<to>.js)`);
}
const from = Number(match[1]);
const to = Number(match[2]);
if (to !== from + delta) {
throw new Error(`Invalid schema ${direction} step ${file}: must go from N to N${delta > 0 ? '+' : '-'}1`);
}
if (seen.has(from)) {
throw new Error(`Duplicate schema ${direction} step starting at version ${from}`);
}
seen.add(from);
steps.push({ from, to, file, url: new URL(file, dirUrl) });
}
return steps.sort((a, b) => (a.from - b.from) * delta);
}
/**
* Highest schema version reachable through a gap-free upgrade chain starting
* at `BASE_SCHEMA_VERSION`.
* @param {URL} upgradeDirUrl
* @returns {number}
*/
export function latestSchemaVersion(upgradeDirUrl) {
let version = BASE_SCHEMA_VERSION;
for (const step of listSteps(upgradeDirUrl, 'upgrade')) {
if (step.from !== version) {
throw new Error(`Schema upgrade chain broken: expected ${version}_to_${version + 1}.js, found ${step.file}`);
}
version = step.to;
}
return version;
}
/**
* Parses a `meta.version` value.
* @param {unknown} value
* @returns {number}
*/
export function parseSchemaVersion(value) {
const version = Number(value);
if (!Number.isInteger(version) || version < BASE_SCHEMA_VERSION) {
throw new Error(`Invalid schema version in meta table: ${value}`);
}
return version;
}
/**
* Builds the version helpers every step receives.
* @param {string} file - Step file name (for error messages).
* @param {() => Promise<number>|number} getVersion
* @param {(version: number) => Promise<void>|void} setVersion
*/
export function makeVersionContext(file, getVersion, setVersion) {
return {
getVersion: async () => getVersion(),
assertVersion: async (expected) => {
const actual = await getVersion();
if (actual !== expected) {
throw new Error(`Schema step ${file}: expected database version ${expected}, found ${actual}`);
}
},
setVersion: async (version) => {
if (!Number.isInteger(version) || version < BASE_SCHEMA_VERSION) {
throw new Error(`Schema step ${file}: invalid version ${version}`);
}
await setVersion(version);
},
};
}
/**
* Runs the steps that move the database from `current` to `target`.
*
* @param {object} options
* @param {'upgrade'|'downgrade'} options.direction
* @param {URL} options.dirUrl - Step directory for this direction.
* @param {number} options.current - Current database version.
* @param {number} options.target - Target version.
* @param {(step: { from: number, to: number, file: string }, fn: (ctx: object) => Promise<void>) => Promise<void>} options.runStep
* Provider hook: runs `fn(ctx)` in a transaction and verifies the end version.
* @param {(message: string) => void} [options.log]
* @returns {Promise<number>} Number of steps applied.
*/
export async function migrateSchema({ direction, dirUrl, current, target, runStep, log = console.log }) {
if (direction === 'upgrade' && current > target) {
throw new Error(
`Refusing to start: database schema version (${current}) is higher than the highest version in the upgrade folder (${target}). ` +
`The database was updated by a newer version of the application. Please update the application code or downgrade the database schema.`
);
}
if (direction === 'downgrade' && target > current) {
throw new Error(`Cannot downgrade schema from version ${current} to higher version ${target}`);
}
if (target < BASE_SCHEMA_VERSION) {
throw new Error(`Cannot ${direction} schema below version ${BASE_SCHEMA_VERSION}`);
}
if (current === target) {
log?.(`Schema version ${current} is up to date (no migrations needed)`);
return 0;
}
const byFrom = new Map(listSteps(dirUrl, direction).map((step) => [step.from, step]));
const plan = [];
for (let version = current; version !== target; ) {
const step = byFrom.get(version);
if (!step) {
const next = direction === 'upgrade' ? version + 1 : version - 1;
throw new Error(`Missing schema ${direction} step ${version}_to_${next}.js`);
}
plan.push(step);
version = step.to;
}
log?.(`Starting schema ${direction}: version ${current} -> ${target} (${plan.length} step(s) pending)`);
for (const step of plan) {
const start = performance.now();
const mod = await import(step.url.href);
if (typeof mod.default !== 'function') {
throw new Error(`Schema ${direction} step ${step.file} must default-export a function`);
}
await runStep(step, mod.default);
const duration = Math.round(performance.now() - start);
log?.(`Applied schema ${direction} ${step.from} -> ${step.to} (${step.file}) [${duration}ms]`);
}
log?.(`Schema ${direction} completed successfully: database now at version ${target}`);
return plan.length;
}
/**
* Throws unless the version a step left behind matches its target.
* @param {{ file: string, to: number }} step
* @param {number} actual
*/
export function assertStepResult(step, actual) {
if (actual !== step.to) {
throw new Error(`Schema step ${step.file} must end with version ${step.to}, found ${actual}`);
}
}

View File

@@ -0,0 +1,16 @@
/**
* Schema 2 → 1 (SQLite): drops posterminal `sort`, `enabled`, `clientversion`.
* @param {{ exec: (sql: string) => void, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function downgrade({ exec, assertVersion, setVersion }) {
await assertVersion(2);
exec(`
ALTER TABLE posterminal DROP COLUMN clientversion;
ALTER TABLE posterminal DROP COLUMN enabled;
ALTER TABLE posterminal DROP COLUMN sort;
`);
await setVersion(1);
}

View File

@@ -0,0 +1,11 @@
/**
* Schema 3 → 2 (SQLite): drops the `printeragent` table.
* @param {{ exec: (sql: string) => void, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function downgrade({ exec, assertVersion, setVersion }) {
await assertVersion(3);
exec(`DROP TABLE IF EXISTS printeragent;`);
await setVersion(2);
}

View File

@@ -0,0 +1,28 @@
/**
* Schema 4 → 3 (SQLite):
* - Drops agentprinter table
* - Removes enabled from printeragent
* - Restores clientserial, clientmodel, clientstate in printeragent
* @param {{ exec: (sql: string) => void, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function downgrade({ exec, assertVersion, setVersion }) {
await assertVersion(4);
exec(`DROP TABLE IF EXISTS agentprinter;`);
try {
exec(`ALTER TABLE printeragent DROP COLUMN enabled;`);
} catch {}
try {
exec(`ALTER TABLE printeragent ADD COLUMN clientserial TEXT;`);
} catch {}
try {
exec(`ALTER TABLE printeragent ADD COLUMN clientmodel TEXT;`);
} catch {}
try {
exec(`ALTER TABLE printeragent ADD COLUMN clientstate TEXT;`);
} catch {}
await setVersion(3);
}

View File

@@ -0,0 +1,13 @@
/**
* Schema 5 → 4 (SQLite):
* - Drops printerjob table
* @param {{ exec: (sql: string) => void, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function downgrade({ exec, assertVersion, setVersion }) {
await assertVersion(5);
exec(`DROP TABLE IF EXISTS printerjob;`);
await setVersion(4);
}

View File

@@ -0,0 +1,15 @@
/**
* Schema 6 → 5 (SQLite):
* - Drops autoinvite table
* - Drops companyuser.role
* @param {{ exec: (sql: string) => void, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function downgrade({ exec, assertVersion, setVersion }) {
await assertVersion(6);
exec(`DROP TABLE IF EXISTS autoinvite;`);
exec(`ALTER TABLE companyuser DROP COLUMN role;`);
await setVersion(5);
}

View File

@@ -9,6 +9,7 @@ import Database from 'better-sqlite3';
import { AsyncLocalStorage } from 'node:async_hooks';
import { logSlowQuery } from '../provider.js';
import { SCHEMA_SCRIPTS } from './schema.js';
import { upgradeSchema, downgradeSchema, readSchemaVersion } from './migrate.js';
const asyncTxStorage = new AsyncLocalStorage();
@@ -356,9 +357,14 @@ export class BetterSqlite3Provider extends BetterSqlite3Executor {
/**
* Initializes the native SQLite schema.
* @param {((message: string) => void) | { log?: (message: string) => void }} [options]
* @returns {Promise<void>}
*/
async initialize() {
async initialize(options = {}) {
const log = typeof options === 'function' ? options : options?.log;
// Versioned upgrades first, so schema.js never meets an outdated table shape.
await upgradeSchema(this.db, log);
for (const script of SCHEMA_SCRIPTS) {
if (script && script.trim()) {
this.db.exec(script);
@@ -432,6 +438,11 @@ export class BetterSqlite3Provider extends BetterSqlite3Executor {
this.db.exec(`ALTER TABLE customergroup ADD COLUMN updated_at TEXT`);
this.db.exec(`UPDATE customergroup SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE updated_at IS NULL`);
}
const printeragentCols = this.db.pragma('table_info("printeragent")').map((c) => c.name);
if (printeragentCols.length > 0 && !printeragentCols.includes('sort')) {
this.db.exec(`ALTER TABLE printeragent ADD COLUMN sort INTEGER NOT NULL DEFAULT 0`);
}
} catch {
// ignore
}
@@ -475,6 +486,24 @@ export class BetterSqlite3Provider extends BetterSqlite3Executor {
}
/** Explicitly closes the database. */
/**
* Current `meta.version`, or `null` if the database has none.
* @returns {Promise<number|null>}
*/
async getSchemaVersion() {
return readSchemaVersion(this.db);
}
/**
* Runs downgrade steps down to `target`. CLI only (`npm run downgrade`).
* @param {number} target
* @param {(message: string) => void} [log]
* @returns {Promise<number>} Steps applied.
*/
async downgradeSchema(target, log) {
return downgradeSchema(this.db, target, log);
}
close() {
this.db.close();
}

View File

@@ -0,0 +1,125 @@
/**
* SQLite schema versioning (see ../schemaMigrations.js).
*
* Step context: `{ db, exec, getVersion, assertVersion, setVersion }` where
* `db` is the raw better-sqlite3 Database and `exec(sql)` runs DDL/DML.
* Each step runs in `BEGIN IMMEDIATE … COMMIT` with foreign key enforcement
* switched off (SQLite's recommended procedure for table rebuilds);
* `PRAGMA foreign_key_check` must pass before commit.
*/
import {
BASE_SCHEMA_VERSION,
assertStepResult,
latestSchemaVersion,
makeVersionContext,
migrateSchema,
parseSchemaVersion,
} from '../schemaMigrations.js';
export const UPGRADE_DIR = new URL('./upgrade/', import.meta.url);
export const DOWNGRADE_DIR = new URL('./downgrade/', import.meta.url);
/** Schema version a fresh database is created at. */
export const SCHEMA_VERSION = latestSchemaVersion(UPGRADE_DIR);
function hasTable(db, name) {
return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
}
function hasAnyTable(db) {
return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1").get();
}
/**
* Reads `meta.version`, or `null` when the meta table/row is missing.
* @param {import('better-sqlite3').Database} db
* @returns {number|null}
*/
export function readSchemaVersion(db) {
if (!hasTable(db, 'meta')) return null;
const row = db.prepare(`SELECT value FROM meta WHERE "key" = 'version'`).get();
return row ? parseSchemaVersion(row.value) : null;
}
function writeSchemaVersion(db, version) {
db.prepare(`INSERT INTO meta ("key", value) VALUES ('version', ?)
ON CONFLICT("key") DO UPDATE SET value = excluded.value`).run(String(version));
}
function makeRunStep(db) {
return async (step, fn) => {
const fkWasOn = db.pragma('foreign_keys', { simple: true }) === 1;
if (fkWasOn) db.pragma('foreign_keys = OFF');
db.exec('BEGIN IMMEDIATE');
try {
const ctx = {
db,
exec: (sql) => db.exec(sql),
...makeVersionContext(step.file, () => readSchemaVersion(db), (v) => writeSchemaVersion(db, v)),
};
await fn(ctx);
assertStepResult(step, readSchemaVersion(db));
const violations = db.pragma('foreign_key_check');
if (violations.length > 0) {
throw new Error(`Schema step ${step.file} left ${violations.length} foreign key violation(s)`);
}
db.exec('COMMIT');
} catch (err) {
if (db.inTransaction) db.exec('ROLLBACK');
throw err;
} finally {
if (fkWasOn) db.pragma('foreign_keys = ON');
}
};
}
/**
* Upgrades an existing database to `SCHEMA_VERSION`. Must run BEFORE the
* `schema.js` scripts so they never meet an outdated table shape.
* Fresh (empty) databases are skipped — `INIT_META` stamps them.
* A non-empty database without a version row is treated as version 1.
* @param {import('better-sqlite3').Database} db
* @param {(message: string) => void} [log]
*/
export async function upgradeSchema(db, log) {
if (!hasAnyTable(db)) {
log?.(`Fresh database detected; schema will initialize directly at version ${SCHEMA_VERSION}`);
return 0;
}
let current = readSchemaVersion(db);
if (current === null) {
log?.(`Existing database has no version row; setting baseline version ${BASE_SCHEMA_VERSION}`);
db.exec(`CREATE TABLE IF NOT EXISTS meta ("key" TEXT PRIMARY KEY, value TEXT NOT NULL)`);
writeSchemaVersion(db, BASE_SCHEMA_VERSION);
current = BASE_SCHEMA_VERSION;
}
return migrateSchema({
direction: 'upgrade',
dirUrl: UPGRADE_DIR,
current,
target: SCHEMA_VERSION,
runStep: makeRunStep(db),
log,
});
}
/**
* Downgrades the database to `target` (CLI only, never at server start).
* @param {import('better-sqlite3').Database} db
* @param {number} target
* @param {(message: string) => void} [log]
*/
export async function downgradeSchema(db, target, log) {
const current = readSchemaVersion(db);
if (current === null) throw new Error('Database has no schema version (meta table missing)');
return migrateSchema({
direction: 'downgrade',
dirUrl: DOWNGRADE_DIR,
current,
target,
runStep: makeRunStep(db),
log,
});
}

View File

@@ -0,0 +1,15 @@
import * as source from '../../mssql/queries/agentprinter.js';
import { translateQueries } from './translate.js';
const queries = translateQueries(source, 'agentprinter');
export const {
AGENTPRINTER_COLUMNS,
CREATE,
DELETE_BY_COMPANY_AND_ID,
FIND_BY_COMBO,
GET_BY_ID,
LIST_BY_COMPANY,
LIST_BY_PRINTERAGENT,
UPDATE,
} = queries;

View File

@@ -0,0 +1,5 @@
import * as source from '../../mssql/queries/autoinvite.js';
import { translateQueries } from './translate.js';
const queries = translateQueries(source, 'autoinvite');
export const { ADD, LIST_BY_COMPANY, LIST_BY_EMAIL, GET_IN_COMPANY, GET_BY_COMPANY_EMAIL, DELETE } = queries;

View File

@@ -5,6 +5,7 @@ import { ARTICLE_LIST_COLUMNS, ARTICLE_LIST_JOINS } from './article.js';
const queries = translateQueries(source, 'category');
export const {
CREATE, DELETE, GET_BY_PARENT_AND_NAME,
GET_CATEGORY_ARTICLE_EANS,
GET_CATEGORY_ARTICLE_WAREHOUSE_SUMMARIES,
GET_NEXT_SIBLING_SORT, LIST_CHILD_IDS,
LIST_CHILD_CATEGORIES,
@@ -86,7 +87,6 @@ FROM categoryarticle ca
INNER JOIN article a ON a.id = ca.article_id
${ARTICLE_LIST_JOINS}
WHERE ca.category_id = @categoryId
AND a.parentarticle_id IS NULL
ORDER BY a.id
`;

View File

@@ -2,4 +2,18 @@ import * as source from '../../mssql/queries/companyuser.js';
import { translateQueries } from './translate.js';
const queries = translateQueries(source, 'companyuser');
export const { ADD, DELETE, GET_BY_EMAIL, GET_COMPANY_ACCESS } = queries;
export const {
ADD,
DELETE,
GET_BY_EMAIL,
GET_COMPANY_ACCESS,
LIST_BY_COMPANY,
GET_IN_COMPANY,
GET_MEMBERSHIP,
COUNT_ADMINS,
SET_ROLE,
CLEAR_ENTITYHISTORY_USER,
CLEAR_COMPANYHISTORY_USER,
IS_MEMBER,
CLEAR_SELECTED_COMPANY,
} = queries;

View File

@@ -10,7 +10,7 @@ export const {
COUNT_CUSTOMER_GROUPS, COUNT_COMPOSITE_PRODUCTS, MAX_ORDER_ID,
LIST_POS_PRODUCT_CATEGORIES, LIST_POS_CUSTOMER_GROUP_IDS, LIST_POS_PRODUCT_PRICES,
LIST_POS_MANDATED_OPTIONS, LIST_POS_PARENT_OPTIONS,
TOUCH_LASTSEEN, RECORD_OBITUARY, RECORD_SYNC_QUEUE, SEED_SYNC_QUEUE_FOR_TERMINAL,
TOUCH_LASTSEEN, SET_CLIENTVERSION, RECORD_OBITUARY, RECORD_SYNC_QUEUE, SEED_SYNC_QUEUE_FOR_TERMINAL,
COUNT_DELETED_ENTITIES,
} = queries;

View File

@@ -0,0 +1,19 @@
import * as source from '../../mssql/queries/printeragent.js';
import { translateQueries } from './translate.js';
const queries = translateQueries(source, 'printeragent');
export const {
CREATE,
DELETE_BY_COMPANY_AND_ID,
FIND_BY_PAIRINGPIN,
FIND_BY_TOKEN,
GET_BY_COMPANY_AND_ID,
GET_BY_ID,
LIST_BY_COMPANY,
PAIR,
PRINTERAGENT_COLUMNS,
TOUCH_LASTSEEN,
TOUCH_LASTSEEN_AND_VERSION,
UPDATE,
UPDATE_STATE,
} = queries;

View File

@@ -0,0 +1,18 @@
import * as source from '../../mssql/queries/printerjob.js';
import { translateQueries } from './translate.js';
const queries = translateQueries(source, 'printerjob');
export const {
PRINTERJOB_COLUMNS,
CREATE,
GET_BY_ID_FOR_COMPANY,
GET_BY_ID_FOR_AGENT,
LIST_BY_COMPANY,
LIST_BY_COMPANY_AND_AGENTPRINTER,
LIST_BY_AGENT,
LIST_BY_AGENT_AND_STATE,
UPDATE_STATE_FOR_AGENT,
UPDATE_STATE_FOR_COMPANY,
DELETE_BY_COMPANY_AND_ID,
} = queries;

View File

@@ -33,6 +33,7 @@ export function sqliteQuery(sql) {
let result = quoteIdentifiers(sql)
.replace(/\bWITH\s*\(\s*(?:NOLOCK|UPDLOCK|ROWLOCK|HOLDLOCK|READPAST)(?:\s*,\s*(?:NOLOCK|UPDLOCK|ROWLOCK|HOLDLOCK|READPAST))*\s*\)/gi, '')
.replace(/\bISNULL\s*\(/gi, 'IFNULL(')
.replace(/\bLEN\s*\(/gi, 'LENGTH(')
.replace(/\bSYSUTCDATETIME\s*\(\s*\)/gi, "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')")
.replace(/\bDATEDIFF_BIG\s*\(\s*second\s*,\s*'1970-01-01'\s*,\s*([^)]+?)\s*\)/gi, "CAST(strftime('%s', $1) AS INTEGER)")
.replace(/\bCAST\s*\(([^()]+?)\s+AS\s+INT\s*\)/gi, 'CAST($1 AS INTEGER)')

View File

@@ -1,3 +1,5 @@
import { SCHEMA_VERSION } from './migrate.js';
const UTC_NOW = "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
export const INIT_META = `
@@ -5,7 +7,7 @@ CREATE TABLE IF NOT EXISTS meta (
"key" TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT OR IGNORE INTO meta ("key", value) VALUES ('version', '1');
INSERT OR IGNORE INTO meta ("key", value) VALUES ('version', '${SCHEMA_VERSION}');
`;
export const INIT_LANGUAGE = `
@@ -510,11 +512,24 @@ CREATE TABLE IF NOT EXISTS numbers (
);
`;
export const INIT_AUTOINVITE = `
CREATE TABLE IF NOT EXISTS autoinvite (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL,
email TEXT NOT NULL COLLATE NOCASE,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')),
created_at TEXT NOT NULL DEFAULT (${UTC_NOW}),
UNIQUE (company_id, email),
FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE
);
`;
export const INIT_COMPANYUSER = `
CREATE TABLE IF NOT EXISTS companyuser (
id INTEGER PRIMARY KEY AUTOINCREMENT,
google_auth_id INTEGER NOT NULL,
company_id INTEGER NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')),
UNIQUE (google_auth_id, company_id),
FOREIGN KEY (google_auth_id) REFERENCES google_auth(id) ON DELETE CASCADE,
FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE
@@ -629,6 +644,9 @@ CREATE TABLE IF NOT EXISTS posterminal (
sync_root_articles INTEGER NOT NULL DEFAULT 1,
lastseen_at TEXT,
updated_at TEXT NOT NULL DEFAULT (${UTC_NOW}),
sort INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
clientversion TEXT,
FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE,
FOREIGN KEY (root_category_id) REFERENCES category(id) ON DELETE SET NULL
);
@@ -687,6 +705,59 @@ CREATE TABLE IF NOT EXISTS printer (
`;
export const INIT_PRINTERAGENT = `
CREATE TABLE IF NOT EXISTS printeragent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL,
name TEXT NOT NULL,
token TEXT,
pairingpin TEXT,
paired_at TEXT,
agentversion TEXT,
lastseen_at TEXT,
updated_at TEXT NOT NULL DEFAULT (${UTC_NOW}),
sort INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS UQ_printeragent_token
ON printeragent(token) WHERE token IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS UQ_printeragent_pairingpin
ON printeragent(pairingpin) WHERE pairingpin IS NOT NULL;
`;
export const INIT_AGENTPRINTER = `
CREATE TABLE IF NOT EXISTS agentprinter (
id INTEGER PRIMARY KEY AUTOINCREMENT,
printeragent_id INTEGER NOT NULL,
type TEXT NOT NULL,
model TEXT,
serial TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
sort INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (printeragent_id) REFERENCES printeragent(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS IX_agentprinter_printeragent_id
ON agentprinter(printeragent_id);
`;
export const INIT_PRINTERJOB = `
CREATE TABLE IF NOT EXISTS printerjob (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agentprinter_id INTEGER NOT NULL,
type TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'pending',
data TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
completed_at TEXT,
FOREIGN KEY (agentprinter_id) REFERENCES agentprinter(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS IX_printerjob_agentprinter_id
ON printerjob(agentprinter_id);
CREATE INDEX IF NOT EXISTS IX_printerjob_state
ON printerjob(state);
`;
export const INIT_LABELTEMPLATE = `
CREATE TABLE IF NOT EXISTS labeltemplate (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1208,6 +1279,7 @@ export const SCHEMA_SCRIPTS = [
INIT_LEDGER,
INIT_NUMBERS,
INIT_COMPANYUSER,
INIT_AUTOINVITE,
INIT_MODELCONVERSATION,
INIT_MODELMESSAGE,
INIT_MODELVFS,
@@ -1220,6 +1292,9 @@ export const SCHEMA_SCRIPTS = [
INIT_POSTERMINALOBITUARY,
INIT_POSTERMINALSYNCQUEUE,
INIT_PRINTER,
INIT_PRINTERAGENT,
INIT_AGENTPRINTER,
INIT_PRINTERJOB,
INIT_LABELTEMPLATE,
INIT_FILE,
INIT_MANIFESTATTACHMENT,

View File

@@ -0,0 +1,16 @@
/**
* Schema 1 → 2 (SQLite): posterminal gets `sort`, `enabled`, `clientversion`.
* @param {{ exec: (sql: string) => void, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function upgrade({ exec, assertVersion, setVersion }) {
await assertVersion(1);
exec(`
ALTER TABLE posterminal ADD COLUMN sort INTEGER NOT NULL DEFAULT 0;
ALTER TABLE posterminal ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1;
ALTER TABLE posterminal ADD COLUMN clientversion TEXT;
`);
await setVersion(2);
}

View File

@@ -0,0 +1,13 @@
import { INIT_PRINTERAGENT } from '../schema.js';
/**
* Schema 2 → 3 (SQLite): adds the `printeragent` table.
* @param {{ exec: (sql: string) => void, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function upgrade({ exec, assertVersion, setVersion }) {
await assertVersion(2);
exec(INIT_PRINTERAGENT);
await setVersion(3);
}

View File

@@ -0,0 +1,30 @@
import { INIT_AGENTPRINTER } from '../schema.js';
/**
* Schema 3 → 4 (SQLite):
* - Drops clientstate, clientmodel, clientserial from printeragent
* - Adds enabled to printeragent
* - Creates agentprinter table
* @param {{ exec: (sql: string) => void, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function upgrade({ exec, assertVersion, setVersion }) {
await assertVersion(3);
try {
exec(`ALTER TABLE printeragent DROP COLUMN clientstate;`);
} catch {}
try {
exec(`ALTER TABLE printeragent DROP COLUMN clientmodel;`);
} catch {}
try {
exec(`ALTER TABLE printeragent DROP COLUMN clientserial;`);
} catch {}
try {
exec(`ALTER TABLE printeragent ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1;`);
} catch {}
exec(INIT_AGENTPRINTER);
await setVersion(4);
}

View File

@@ -0,0 +1,15 @@
import { INIT_PRINTERJOB } from '../schema.js';
/**
* Schema 4 → 5 (SQLite):
* - Creates printerjob table
* @param {{ exec: (sql: string) => void, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function upgrade({ exec, assertVersion, setVersion }) {
await assertVersion(4);
exec(INIT_PRINTERJOB);
await setVersion(5);
}

View File

@@ -0,0 +1,19 @@
import { INIT_AUTOINVITE } from '../schema.js';
/**
* Schema 5 → 6 (SQLite):
* - Adds companyuser.role ('admin' | 'user'); existing members become 'admin'
* (they had full access before roles existed)
* - Creates autoinvite table
* @param {{ exec: (sql: string) => void, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function upgrade({ exec, assertVersion, setVersion }) {
await assertVersion(5);
exec(`ALTER TABLE companyuser ADD COLUMN role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user'));`);
exec(`UPDATE companyuser SET role = 'admin';`);
exec(INIT_AUTOINVITE);
await setVersion(6);
}

View File

@@ -0,0 +1,16 @@
export function shape(row) {
if (!row) return null;
return {
id: row.id,
printeragentId: row.printeragentId,
type: row.type,
model: row.model ?? null,
serial: row.serial ?? null,
enabled: Boolean(row.enabled),
sort: row.sort,
};
}
export const AGENTPRINTER_TYPEDEF =
'{id: number, printeragentId: number, type: string, model: string|null, serial: string|null, enabled: boolean, sort: number}';

View File

@@ -0,0 +1,35 @@
import {
DELETE_BY_COMPANY_AND_ID as MSSQL_DELETE,
} from '../../providers/mssql/queries/agentprinter.js';
import {
DELETE_BY_COMPANY_AND_ID as SQLITE_DELETE,
} from '../../providers/sqlite3/queries/agentprinter.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function del(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE;
/**
* Deletes an agent printer by id for the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The agent printer id.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number}>}
*/
return async (googleAuthId, id, tx) => {
const numericId = Number(id);
if (!Number.isInteger(numericId) || numericId <= 0) {
throw new Error('Agentprinter not found');
}
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
await runner.query(sql, { id: numericId, companyId });
return { id: numericId };
};
}

View File

@@ -0,0 +1,55 @@
import {
FIND_BY_COMBO as MSSQL_FIND,
CREATE as MSSQL_CREATE,
} from '../../providers/mssql/queries/agentprinter.js';
import {
FIND_BY_COMBO as SQLITE_FIND,
CREATE as SQLITE_CREATE,
} from '../../providers/sqlite3/queries/agentprinter.js';
import makeRunner from '../runner.js';
import { shape } from './_shared.js';
export default function findOrCreate(provider) {
const isSqlite = provider?.dialect === 'sqlite3';
const findSql = isSqlite ? SQLITE_FIND : MSSQL_FIND;
const createSql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE;
/**
* Finds or creates an agentprinter by (printeragentId, type, model, serial) combo.
*
* @param {number} printeragentId - The parent printer agent id.
* @param {object} input
* @param {string} input.type - Printer type ("zpl", "esc/pos", etc.).
* @param {string|null} [input.model] - Discovered model name.
* @param {string|null} [input.serial] - Discovered serial number.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{created: boolean, agentprinter: {id: number, printeragentId: number, type: string, model: string|null, serial: string|null, enabled: boolean, sort: number}}>}
*/
return async (printeragentId, input = {}, tx) => {
const pId = Number(printeragentId);
if (!Number.isInteger(pId) || pId <= 0) {
throw new Error('Printeragent not found');
}
const type = typeof input.type === 'string' && input.type.trim() ? input.type.trim().toLowerCase() : 'zpl';
const model = input.model != null && String(input.model).trim() ? String(input.model).trim() : null;
const serial = input.serial != null && String(input.serial).trim() ? String(input.serial).trim() : null;
const runner = makeRunner(provider, tx);
const existing = await runner.query(findSql, { printeragentId: pId, type, model, serial });
if (existing.rows.length > 0) {
return { created: false, agentprinter: shape(existing.rows[0]) };
}
const created = await runner.query(createSql, {
printeragentId: pId,
type,
model,
serial,
enabled: 1,
sort: 0,
});
return { created: true, agentprinter: shape(created.rows[0]) };
};
}

View File

@@ -0,0 +1,36 @@
import {
GET_BY_ID as MSSQL_GET,
} from '../../providers/mssql/queries/agentprinter.js';
import {
GET_BY_ID as SQLITE_GET,
} from '../../providers/sqlite3/queries/agentprinter.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import { shape } from './_shared.js';
export default function getById(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_GET : MSSQL_GET;
/**
* Retrieves an agent printer by id, scoped to the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The agent printer id.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, printeragentId: number, type: string, model: string|null, serial: string|null, enabled: boolean, sort: number}|null>}
*/
return async (googleAuthId, id, tx) => {
const numericId = Number(id);
if (!Number.isInteger(numericId) || numericId <= 0) {
return null;
}
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const result = await runner.query(sql, { id: numericId, companyId });
return result.rows[0] ? shape(result.rows[0]) : null;
};
}

View File

@@ -0,0 +1,41 @@
import {
LIST_BY_COMPANY as MSSQL_LIST_BY_COMPANY,
LIST_BY_PRINTERAGENT as MSSQL_LIST_BY_PRINTERAGENT,
} from '../../providers/mssql/queries/agentprinter.js';
import {
LIST_BY_COMPANY as SQLITE_LIST_BY_COMPANY,
LIST_BY_PRINTERAGENT as SQLITE_LIST_BY_PRINTERAGENT,
} from '../../providers/sqlite3/queries/agentprinter.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import { shape } from './_shared.js';
export default function list(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const listCompanySql = isSqlite ? SQLITE_LIST_BY_COMPANY : MSSQL_LIST_BY_COMPANY;
const listAgentSql = isSqlite ? SQLITE_LIST_BY_PRINTERAGENT : MSSQL_LIST_BY_PRINTERAGENT;
/**
* Lists agent printers of the user's selected company, optionally filtered by printer agent.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} [printeragentId] - Optional printer agent id filter.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<{id: number, printeragentId: number, type: string, model: string|null, serial: string|null, enabled: boolean, sort: number}>>}
*/
return async (googleAuthId, printeragentId, tx) => {
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const aId = printeragentId != null ? Number(printeragentId) : null;
let result;
if (aId && Number.isInteger(aId) && aId > 0) {
result = await runner.query(listAgentSql, { companyId, printeragentId: aId });
} else {
result = await runner.query(listCompanySql, { companyId });
}
return result.rows.map(shape);
};
}

View File

@@ -0,0 +1,68 @@
import {
LIST_BY_COMPANY as MSSQL_LIST_BY_COMPANY,
LIST_BY_PRINTERAGENT as MSSQL_LIST_BY_AGENT,
UPDATE as MSSQL_UPDATE,
} from '../../providers/mssql/queries/agentprinter.js';
import {
LIST_BY_COMPANY as SQLITE_LIST_BY_COMPANY,
LIST_BY_PRINTERAGENT as SQLITE_LIST_BY_AGENT,
UPDATE as SQLITE_UPDATE,
} from '../../providers/sqlite3/queries/agentprinter.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function order(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const listCompanySql = isSqlite ? SQLITE_LIST_BY_COMPANY : MSSQL_LIST_BY_COMPANY;
const listAgentSql = isSqlite ? SQLITE_LIST_BY_AGENT : MSSQL_LIST_BY_AGENT;
const updateSql = isSqlite ? SQLITE_UPDATE : MSSQL_UPDATE;
/**
* Reorders agent printers for a given printer agent or globally across the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input
* @param {number} [input.printeragentId] - Optional printer agent id. If omitted, reorders all printers in company.
* @param {number[]} input.agentprinterIds - Complete ordered list of agent printer IDs.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{agentprinterIds: number[]}>}
*/
return async (googleAuthId, input = {}, tx) => {
const hasAgent = input.printeragentId != null;
let pId;
if (hasAgent) {
pId = Number(input.printeragentId);
if (!Number.isInteger(pId) || pId <= 0) {
throw new Error('Printeragent not found');
}
}
const ids = input.agentprinterIds;
if (!Array.isArray(ids) || ids.length === 0 || ids.some((id) => !Number.isInteger(Number(id)) || Number(id) <= 0)) {
throw new Error('Invalid agentprinter order');
}
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const existing = hasAgent
? await runner.query(listAgentSql, { companyId, printeragentId: pId })
: await runner.query(listCompanySql, { companyId });
const existingIds = new Set(existing.rows.map((r) => r.id));
if (ids.length !== existingIds.size || ids.some((id) => !existingIds.has(Number(id)))) {
throw new Error('Invalid agentprinter order');
}
for (let i = 0; i < ids.length; i++) {
const id = Number(ids[i]);
const current = existing.rows.find((r) => r.id === id);
const enabled = current ? (current.enabled ? 1 : 0) : 1;
await runner.query(updateSql, { id, companyId, sort: i + 1, enabled });
}
return { agentprinterIds: ids.map(Number) };
};
}

View File

@@ -0,0 +1,63 @@
import {
GET_BY_ID as MSSQL_GET,
UPDATE as MSSQL_UPDATE,
} from '../../providers/mssql/queries/agentprinter.js';
import {
GET_BY_ID as SQLITE_GET,
UPDATE as SQLITE_UPDATE,
} from '../../providers/sqlite3/queries/agentprinter.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import { shape } from './_shared.js';
export default function update(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const getSql = isSqlite ? SQLITE_GET : MSSQL_GET;
const updateSql = isSqlite ? SQLITE_UPDATE : MSSQL_UPDATE;
/**
* Updates enabled and/or sort of an agent printer for the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input
* @param {number} input.id - Agent printer id.
* @param {boolean} [input.enabled] - Whether the printer is enabled.
* @param {number} [input.sort] - Display order integer.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, printeragentId: number, type: string, model: string|null, serial: string|null, enabled: boolean, sort: number}>}
*/
return async (googleAuthId, input = {}, tx) => {
const id = Number(input.id);
if (!Number.isInteger(id) || id <= 0) {
throw new Error('Agentprinter not found');
}
if (input.enabled === undefined && input.sort === undefined) {
throw new Error('Invalid agentprinter');
}
if (input.enabled !== undefined && typeof input.enabled !== 'boolean') {
throw new Error('Invalid agentprinter');
}
if (input.sort !== undefined && !Number.isInteger(input.sort)) {
throw new Error('Invalid agentprinter');
}
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const existing = await runner.query(getSql, { id, companyId });
if (!existing.rows[0]) {
throw new Error('Agentprinter not found');
}
const sort = input.sort !== undefined ? input.sort : existing.rows[0].sort;
const enabled = input.enabled !== undefined ? (input.enabled ? 1 : 0) : (existing.rows[0].enabled ? 1 : 0);
await runner.query(updateSql, { id, companyId, sort, enabled });
const updated = await runner.query(getSql, { id, companyId });
return shape(updated.rows[0]);
};
}

View File

@@ -0,0 +1,46 @@
import { ADD as MSSQL_ADD, GET_BY_COMPANY_EMAIL as MSSQL_EXISTS } from '../../providers/mssql/queries/autoinvite.js';
import { ADD as SQLITE_ADD, GET_BY_COMPANY_EMAIL as SQLITE_EXISTS } from '../../providers/sqlite3/queries/autoinvite.js';
import { GET_BY_EMAIL as MSSQL_GA_BY_EMAIL } from '../../providers/mssql/queries/google_auth.js';
import { GET_BY_EMAIL as SQLITE_GA_BY_EMAIL } from '../../providers/sqlite3/queries/google_auth.js';
import requireAdmin from '../companyuser/requireAdmin.js';
import withTransaction from '../companyuser/withTransaction.js';
import { assertRole, normalizeEmail } from '../companyuser/roles.js';
export default function add(provider) {
const isSqlite = provider?.dialect === 'sqlite3';
const addSql = isSqlite ? SQLITE_ADD : MSSQL_ADD;
const existsSql = isSqlite ? SQLITE_EXISTS : MSSQL_EXISTS;
const gaByEmailSql = isSqlite ? SQLITE_GA_BY_EMAIL : MSSQL_GA_BY_EMAIL;
const admin = requireAdmin(provider);
/**
* Registers an email for automatic company membership. When that email is
* registered (`google_auth.add`) the invitation is consumed: it is removed from
* the list and the new user is added to the company with the given role.
*
* @param {number} googleAuthId - The id of the acting Google auth user (must be admin of the selected company).
* @param {{email: string, role?: 'admin'|'user'}} input - The email to invite and the role (default 'user').
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, email: string, role: 'admin'|'user'}>} The created invitation.
* @throws {Error} 'Not authorized', 'Invalid email', 'Invalid role', 'User already registered', 'Invitation already exists'
*
* @example
* await store.autoinvite.add(42, { email: 'new@example.com', role: 'user' });
*/
return async (googleAuthId, input = {}, tx) =>
withTransaction(provider, tx, async (runner) => {
const { companyId } = await admin(googleAuthId, runner);
const email = normalizeEmail(input.email);
const role = assertRole(input.role ?? 'user');
if ((await runner.query(gaByEmailSql, { email })).rows[0]) {
throw new Error('User already registered');
}
if ((await runner.query(existsSql, { companyId, email })).rows[0]) {
throw new Error('Invitation already exists');
}
const result = await runner.query(addSql, { companyId, email, role });
return { id: result.rows[0]?.id ?? result.insertId, email, role };
});
}

View File

@@ -0,0 +1,26 @@
import { LIST_BY_COMPANY as MSSQL_LIST } from '../../providers/mssql/queries/autoinvite.js';
import { LIST_BY_COMPANY as SQLITE_LIST } from '../../providers/sqlite3/queries/autoinvite.js';
import makeRunner from '../runner.js';
import requireAdmin from '../companyuser/requireAdmin.js';
export default function list(provider) {
const sql = provider?.dialect === 'sqlite3' ? SQLITE_LIST : MSSQL_LIST;
const admin = requireAdmin(provider);
/**
* Lists the pending auto-invitations of the admin's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user (must be admin of the selected company).
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<{id: number, email: string, role: 'admin'|'user', createdAt: string}>>} The invitations ordered by email.
* @throws {Error} 'Not authorized' / 'No company selected'
*
* @example
* const invites = await store.autoinvite.list(42);
*/
return async (googleAuthId, tx) => {
const { companyId } = await admin(googleAuthId, tx);
const result = await makeRunner(provider, tx).query(sql, { companyId });
return result.rows;
};
}

View File

@@ -0,0 +1,32 @@
import { DELETE as MSSQL_DELETE, GET_IN_COMPANY as MSSQL_GET } from '../../providers/mssql/queries/autoinvite.js';
import { DELETE as SQLITE_DELETE, GET_IN_COMPANY as SQLITE_GET } from '../../providers/sqlite3/queries/autoinvite.js';
import requireAdmin from '../companyuser/requireAdmin.js';
import withTransaction from '../companyuser/withTransaction.js';
export default function remove(provider) {
const isSqlite = provider?.dialect === 'sqlite3';
const getSql = isSqlite ? SQLITE_GET : MSSQL_GET;
const deleteSql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE;
const admin = requireAdmin(provider);
/**
* Deletes a pending auto-invitation of the admin's selected company.
*
* @param {number} googleAuthId - The id of the acting Google auth user (must be admin of the selected company).
* @param {number} id - The invitation id.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>}
* @throws {Error} 'Not authorized', 'Invitation not found'
*
* @example
* await store.autoinvite.remove(42, 3);
*/
return async (googleAuthId, id, tx) =>
withTransaction(provider, tx, async (runner) => {
const { companyId } = await admin(googleAuthId, runner);
if (!(await runner.query(getSql, { id, companyId })).rows[0]) {
throw new Error('Invitation not found');
}
await runner.query(deleteSql, { id });
});
}

View File

@@ -1,16 +1,19 @@
import {
GET_CATEGORY_ARTICLES as MSSQL_GET,
GET_CATEGORY_ARTICLE_WAREHOUSE_SUMMARIES as MSSQL_SUMMARIES,
GET_CATEGORY_ARTICLE_EANS as MSSQL_EANS,
} from '../../providers/mssql/queries/category.js';
import {
GET_CATEGORY_ARTICLES as SQLITE_GET,
GET_CATEGORY_ARTICLE_WAREHOUSE_SUMMARIES as SQLITE_SUMMARIES,
GET_CATEGORY_ARTICLE_EANS as SQLITE_EANS,
} from '../../providers/sqlite3/queries/category.js';
import makeRunner from '../runner.js';
export default function queryCategoryArticles(provider) {
const sql = provider?.dialect === 'sqlite3' ? SQLITE_GET : MSSQL_GET;
const sqlSummaries = provider?.dialect === 'sqlite3' ? SQLITE_SUMMARIES : MSSQL_SUMMARIES;
const sqlEans = provider?.dialect === 'sqlite3' ? SQLITE_EANS : MSSQL_EANS;
return async ({ categoryId, languageId, posCustomerGroupId }, tx) => {
const runner = makeRunner(provider, tx);
@@ -43,6 +46,19 @@ export default function queryCategoryArticles(provider) {
});
}
const eanResult = await runner.query(sqlEans, { categoryId });
const eanMap = new Map();
for (const e of eanResult.rows) {
let list = eanMap.get(e.articleId);
if (!list) {
list = [];
eanMap.set(e.articleId, list);
}
if (e.ean) {
list.push(e.ean);
}
}
return result.rows.map((row) => {
const {
brandId,
@@ -54,8 +70,10 @@ export default function queryCategoryArticles(provider) {
...rest
} = row;
const summaries = summaryMap.get(row.id) ?? [];
const eans = eanMap.get(row.id) ?? [];
return {
...rest,
eans,
smallThumbW: rest.smallThumbW != null ? Number(rest.smallThumbW) : null,
smallThumbH: rest.smallThumbH != null ? Number(rest.smallThumbH) : null,
smallThumbBinary: rest.smallThumbBinary

View File

@@ -89,9 +89,11 @@ export default function create(provider) {
companyId,
});
// The creator becomes the company's admin.
await runner.query(addCompanyUserSql, {
companyId,
googleAuthId,
role: 'admin',
});
return {

View File

@@ -1,26 +1,33 @@
import { ADD as MSSQL_ADD } from '../../providers/mssql/queries/companyuser.js';
import { ADD as SQLITE_ADD } from '../../providers/sqlite3/queries/companyuser.js';
import makeRunner from '../runner.js';
import { assertRole } from './roles.js';
export default function add(provider) {
const sql = provider?.dialect === 'sqlite3' ? SQLITE_ADD : MSSQL_ADD;
/**
* Adds a company user linking a company to a Google auth account.
* No admin check: this is the low-level link primitive (company creation, invitations).
* Use `companyuser.addByEmail` for the admin-guarded flow.
*
* @param {number} companyId - The id of the company.
* @param {number} googleAuthId - The id of the Google auth account.
* @param {'admin'|'user'} [role='user'] - The role within the company.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<number>} The id of the created company user link.
* @throws {Error} 'Invalid role' if the role is not 'admin' or 'user'.
*
* @example
* const id = await store.companyuser.add(1, 42);
* const id = await store.companyuser.add(1, 42, 'admin');
* // => 7
*/
return async (companyId, googleAuthId, tx) => {
return async (companyId, googleAuthId, role = 'user', tx) => {
assertRole(role);
const result = await makeRunner(provider, tx).query(sql, {
companyId,
googleAuthId,
role,
});
return result.rows[0]?.id ?? result.insertId;

View File

@@ -0,0 +1,46 @@
import { ADD as MSSQL_ADD, IS_MEMBER as MSSQL_IS_MEMBER } from '../../providers/mssql/queries/companyuser.js';
import { ADD as SQLITE_ADD, IS_MEMBER as SQLITE_IS_MEMBER } from '../../providers/sqlite3/queries/companyuser.js';
import { GET_BY_EMAIL as MSSQL_GA_BY_EMAIL } from '../../providers/mssql/queries/google_auth.js';
import { GET_BY_EMAIL as SQLITE_GA_BY_EMAIL } from '../../providers/sqlite3/queries/google_auth.js';
import requireAdmin from './requireAdmin.js';
import withTransaction from './withTransaction.js';
import { assertRole, normalizeEmail } from './roles.js';
export default function addByEmail(provider) {
const isSqlite = provider?.dialect === 'sqlite3';
const addSql = isSqlite ? SQLITE_ADD : MSSQL_ADD;
const isMemberSql = isSqlite ? SQLITE_IS_MEMBER : MSSQL_IS_MEMBER;
const gaByEmailSql = isSqlite ? SQLITE_GA_BY_EMAIL : MSSQL_GA_BY_EMAIL;
const admin = requireAdmin(provider);
/**
* Adds an existing Google auth user to the admin's selected company.
* The added user can then select the company as their own selected company.
* For emails that are not registered yet use `autoinvite.add`.
*
* @param {number} googleAuthId - The id of the acting Google auth user (must be admin of the selected company).
* @param {{email: string, role?: 'admin'|'user'}} input - Email of the existing user and role (default 'user').
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, googleAuthId: number, email: string, picture: string|null, role: 'admin'|'user'}>} The created company user.
* @throws {Error} 'Not authorized', 'Invalid email', 'Invalid role', 'User not found', 'User already in company'
*
* @example
* const member = await store.companyuser.addByEmail(42, { email: 'new@example.com', role: 'user' });
*/
return async (googleAuthId, input = {}, tx) =>
withTransaction(provider, tx, async (runner) => {
const { companyId } = await admin(googleAuthId, runner);
const email = normalizeEmail(input.email);
const role = assertRole(input.role ?? 'user');
const auth = (await runner.query(gaByEmailSql, { email })).rows[0];
if (!auth) throw new Error('User not found');
const existing = (await runner.query(isMemberSql, { googleAuthId: auth.id, companyId })).rows[0];
if (existing) throw new Error('User already in company');
const result = await runner.query(addSql, { companyId, googleAuthId: auth.id, role });
const id = result.rows[0]?.id ?? result.insertId;
return { id, googleAuthId: auth.id, email: auth.email, picture: auth.picture ?? null, role };
});
}

View File

@@ -0,0 +1,26 @@
import { LIST_BY_COMPANY as MSSQL_LIST } from '../../providers/mssql/queries/companyuser.js';
import { LIST_BY_COMPANY as SQLITE_LIST } from '../../providers/sqlite3/queries/companyuser.js';
import makeRunner from '../runner.js';
import requireAdmin from './requireAdmin.js';
export default function list(provider) {
const sql = provider?.dialect === 'sqlite3' ? SQLITE_LIST : MSSQL_LIST;
const admin = requireAdmin(provider);
/**
* Lists the members of the admin's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user (must be admin of the selected company).
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<{id: number, googleAuthId: number, email: string, picture: string|null, role: 'admin'|'user'}>>} The company users ordered by email.
* @throws {Error} 'Not authorized' / 'No company selected'
*
* @example
* const members = await store.companyuser.list(42);
*/
return async (googleAuthId, tx) => {
const { companyId } = await admin(googleAuthId, tx);
const result = await makeRunner(provider, tx).query(sql, { companyId });
return result.rows;
};
}

View File

@@ -0,0 +1,63 @@
import {
CLEAR_COMPANYHISTORY_USER as MSSQL_CLEAR_COMPANYHISTORY,
CLEAR_ENTITYHISTORY_USER as MSSQL_CLEAR_ENTITYHISTORY,
CLEAR_SELECTED_COMPANY as MSSQL_CLEAR_SELECTED,
COUNT_ADMINS as MSSQL_COUNT_ADMINS,
DELETE as MSSQL_DELETE,
GET_IN_COMPANY as MSSQL_GET,
} from '../../providers/mssql/queries/companyuser.js';
import {
CLEAR_COMPANYHISTORY_USER as SQLITE_CLEAR_COMPANYHISTORY,
CLEAR_ENTITYHISTORY_USER as SQLITE_CLEAR_ENTITYHISTORY,
CLEAR_SELECTED_COMPANY as SQLITE_CLEAR_SELECTED,
COUNT_ADMINS as SQLITE_COUNT_ADMINS,
DELETE as SQLITE_DELETE,
GET_IN_COMPANY as SQLITE_GET,
} from '../../providers/sqlite3/queries/companyuser.js';
import requireAdmin from './requireAdmin.js';
import withTransaction from './withTransaction.js';
export default function remove(provider) {
const isSqlite = provider?.dialect === 'sqlite3';
const pick = (mssql, sqlite) => (isSqlite ? sqlite : mssql);
const getSql = pick(MSSQL_GET, SQLITE_GET);
const countSql = pick(MSSQL_COUNT_ADMINS, SQLITE_COUNT_ADMINS);
const deleteSql = pick(MSSQL_DELETE, SQLITE_DELETE);
const clearEntityHistorySql = pick(MSSQL_CLEAR_ENTITYHISTORY, SQLITE_CLEAR_ENTITYHISTORY);
const clearCompanyHistorySql = pick(MSSQL_CLEAR_COMPANYHISTORY, SQLITE_CLEAR_COMPANYHISTORY);
const clearSelectedSql = pick(MSSQL_CLEAR_SELECTED, SQLITE_CLEAR_SELECTED);
const admin = requireAdmin(provider);
/**
* Removes a member from the admin's selected company. History entries keep
* existing but lose their author link; if the removed user had this company
* selected, their selection is cleared.
*
* @param {number} googleAuthId - The id of the acting Google auth user (must be admin of the selected company).
* @param {number} id - The company user id to remove.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, googleAuthId: number}>} The removed company user.
* @throws {Error} 'Not authorized', 'Company user not found', 'Cannot remove the last admin'
*
* @example
* await store.companyuser.remove(42, 7);
*/
return async (googleAuthId, id, tx) =>
withTransaction(provider, tx, async (runner) => {
const { companyId } = await admin(googleAuthId, runner);
const member = (await runner.query(getSql, { id, companyId })).rows[0];
if (!member) throw new Error('Company user not found');
if (member.role === 'admin') {
const { count } = (await runner.query(countSql, { companyId })).rows[0];
if (Number(count) <= 1) throw new Error('Cannot remove the last admin');
}
await runner.query(clearEntityHistorySql, { id });
await runner.query(clearCompanyHistorySql, { id });
await runner.query(clearSelectedSql, { googleAuthId: member.googleAuthId, companyId });
await runner.query(deleteSql, { id });
return { id: member.id, googleAuthId: member.googleAuthId };
});
}

View File

@@ -0,0 +1,20 @@
import resolveSelectedCompanyAccess from './resolveSelectedCompanyAccess.js';
export default function requireAdmin(provider) {
const access = resolveSelectedCompanyAccess(provider);
/**
* Resolves the selected company and requires the user to be its admin.
* @param {number} googleAuthId
* @param {object} [tx]
* @returns {Promise<{companyId: number, companyuserId: number, role: string}>}
* @throws {Error} 'Not authorized' if the user is not an admin of the selected company.
*/
return async (googleAuthId, tx) => {
const ctx = await access(googleAuthId, tx);
if (ctx.role !== 'admin') {
throw new Error('Not authorized');
}
return ctx;
};
}

View File

@@ -30,6 +30,7 @@ export default function resolveCompanyAccess(provider) {
languageId: row.languageId,
posCustomerGroupId: row.posCustomerGroupId ?? null,
companyuserId: row.companyuserId,
role: row.role,
};
};
}

View File

@@ -0,0 +1,28 @@
export const COMPANY_USER_ROLES = ['admin', 'user'];
/**
* Validates a company user role.
* @param {unknown} role
* @returns {'admin'|'user'}
* @throws {Error} 'Invalid role'
*/
export function assertRole(role) {
if (!COMPANY_USER_ROLES.includes(role)) {
throw new Error('Invalid role');
}
return role;
}
/**
* Normalizes and validates an email address.
* @param {unknown} email
* @returns {string} lower-cased, trimmed email
* @throws {Error} 'Invalid email'
*/
export function normalizeEmail(email) {
const value = typeof email === 'string' ? email.trim().toLowerCase() : '';
if (!value || !value.includes('@')) {
throw new Error('Invalid email');
}
return value;
}

View File

@@ -0,0 +1,51 @@
import {
COUNT_ADMINS as MSSQL_COUNT_ADMINS,
GET_IN_COMPANY as MSSQL_GET,
SET_ROLE as MSSQL_SET_ROLE,
} from '../../providers/mssql/queries/companyuser.js';
import {
COUNT_ADMINS as SQLITE_COUNT_ADMINS,
GET_IN_COMPANY as SQLITE_GET,
SET_ROLE as SQLITE_SET_ROLE,
} from '../../providers/sqlite3/queries/companyuser.js';
import requireAdmin from './requireAdmin.js';
import withTransaction from './withTransaction.js';
import { assertRole } from './roles.js';
export default function setRole(provider) {
const isSqlite = provider?.dialect === 'sqlite3';
const getSql = isSqlite ? SQLITE_GET : MSSQL_GET;
const countSql = isSqlite ? SQLITE_COUNT_ADMINS : MSSQL_COUNT_ADMINS;
const setSql = isSqlite ? SQLITE_SET_ROLE : MSSQL_SET_ROLE;
const admin = requireAdmin(provider);
/**
* Changes the role of a member of the admin's selected company.
*
* @param {number} googleAuthId - The id of the acting Google auth user (must be admin of the selected company).
* @param {number} id - The company user id.
* @param {'admin'|'user'} role - The new role.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, googleAuthId: number, email: string, picture: string|null, role: 'admin'|'user'}>} The updated company user.
* @throws {Error} 'Not authorized', 'Invalid role', 'Company user not found', 'Cannot demote the last admin'
*
* @example
* await store.companyuser.setRole(42, 7, 'admin');
*/
return async (googleAuthId, id, role, tx) =>
withTransaction(provider, tx, async (runner) => {
const { companyId } = await admin(googleAuthId, runner);
assertRole(role);
const member = (await runner.query(getSql, { id, companyId })).rows[0];
if (!member) throw new Error('Company user not found');
if (member.role === 'admin' && role !== 'admin') {
const { count } = (await runner.query(countSql, { companyId })).rows[0];
if (Number(count) <= 1) throw new Error('Cannot demote the last admin');
}
await runner.query(setSql, { id, role });
return { id: member.id, googleAuthId: member.googleAuthId, email: member.email, picture: member.picture ?? null, role };
});
}

View File

@@ -0,0 +1,11 @@
import makeRunner from '../runner.js';
/**
* Runs `fn(runner)` inside the given transaction, a new one, or (providers
* without transaction support) directly.
*/
export default async function withTransaction(provider, tx, fn) {
if (tx) return fn(makeRunner(provider, tx));
if (typeof provider?.transaction === 'function') return provider.transaction(fn);
return fn(makeRunner(provider));
}

View File

@@ -1,10 +1,27 @@
import { ADD as MSSQL_ADD } from '../../providers/mssql/queries/google_auth.js';
import { ADD as SQLITE_ADD } from '../../providers/sqlite3/queries/google_auth.js';
import {
DELETE as MSSQL_DELETE_INVITE,
LIST_BY_EMAIL as MSSQL_INVITES,
} from '../../providers/mssql/queries/autoinvite.js';
import {
DELETE as SQLITE_DELETE_INVITE,
LIST_BY_EMAIL as SQLITE_INVITES,
} from '../../providers/sqlite3/queries/autoinvite.js';
import { ADD as MSSQL_ADD_COMPANYUSER } from '../../providers/mssql/queries/companyuser.js';
import { ADD as SQLITE_ADD_COMPANYUSER } from '../../providers/sqlite3/queries/companyuser.js';
import withTransaction from '../companyuser/withTransaction.js';
export default function add(provider) {
const sql = provider.dialect === 'sqlite3' ? SQLITE_ADD : MSSQL_ADD;
const isSqlite = provider.dialect === 'sqlite3';
const sql = isSqlite ? SQLITE_ADD : MSSQL_ADD;
const invitesSql = isSqlite ? SQLITE_INVITES : MSSQL_INVITES;
const deleteInviteSql = isSqlite ? SQLITE_DELETE_INVITE : MSSQL_DELETE_INVITE;
const addCompanyUserSql = isSqlite ? SQLITE_ADD_COMPANYUSER : MSSQL_ADD_COMPANYUSER;
/**
* Adds a new allowed Google auth email.
* Adds a new allowed Google auth email (registration). Pending
* `autoinvite` rows for this email are consumed in the same transaction:
* each is deleted and turned into a `companyuser` with the invited role.
*
* @param {string} email - The email address to allow.
* @returns {Promise<number>} The id of the created Google auth row.
@@ -13,8 +30,20 @@ export default function add(provider) {
* const id = await store.google_auth.add('user@example.com');
* // => 1
*/
return async (email) => {
const result = await provider.query(sql, { email });
return result.rows[0]?.id ?? result.insertId;
};
return async (email) =>
withTransaction(provider, null, async (runner) => {
const result = await runner.query(sql, { email });
const googleAuthId = result.rows[0]?.id ?? result.insertId;
const invites = (await runner.query(invitesSql, { email: String(email).trim().toLowerCase() })).rows;
for (const invite of invites) {
await runner.query(addCompanyUserSql, {
companyId: invite.companyId,
googleAuthId,
role: invite.role,
});
await runner.query(deleteInviteSql, { id: invite.id });
}
return googleAuthId;
});
}

View File

@@ -1,19 +1,29 @@
import { SET_SELECTED_COMPANY as MSSQL_SET } from '../../providers/mssql/queries/google_auth.js';
import { SET_SELECTED_COMPANY as SQLITE_SET } from '../../providers/sqlite3/queries/google_auth.js';
import { IS_MEMBER as MSSQL_IS_MEMBER } from '../../providers/mssql/queries/companyuser.js';
import { IS_MEMBER as SQLITE_IS_MEMBER } from '../../providers/sqlite3/queries/companyuser.js';
export default function setSelectedCompany(provider) {
const sql = provider.dialect === 'sqlite3' ? SQLITE_SET : MSSQL_SET;
const isSqlite = provider.dialect === 'sqlite3';
const sql = isSqlite ? SQLITE_SET : MSSQL_SET;
const isMemberSql = isSqlite ? SQLITE_IS_MEMBER : MSSQL_IS_MEMBER;
/**
* Sets the currently selected company for a Google auth user.
* The user must be a member (`companyuser`) of the company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} companyId - The id of the company to select.
* @returns {Promise<void>}
* @throws {Error} 'Not authorized' if the user is not a member of the company.
*
* @example
* await store.google_auth.setSelectedCompany(42, 1);
*/
return async (googleAuthId, companyId) => {
if (companyId != null) {
const member = await provider.query(isMemberSql, { googleAuthId, companyId });
if (!member.rows[0]) throw new Error('Not authorized');
}
await provider.query(sql, { googleAuthId, companyId });
};
}

View File

@@ -10,7 +10,7 @@ export default function findByPairingpin(provider) {
*
* @param {string} pairingpin - The pairing pin to search for.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string}|null>} The posterminal, or null if not found.
* @returns {Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string, sort: number, enabled: boolean|number, clientversion: string|null}|null>} The posterminal, or null if not found.
*/
return async (pairingpin, tx) => {
if (!pairingpin) return null;

View File

@@ -10,7 +10,7 @@ export default function findByToken(provider) {
*
* @param {string} token - The auth token to search for.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string}|null>} The posterminal, or null if not found.
* @returns {Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string, sort: number, enabled: boolean|number, clientversion: string|null}|null>} The posterminal, or null if not found.
*/
return async (token, tx) => {
const trimmed = String(token ?? '').trim();

View File

@@ -12,11 +12,11 @@ export default function list(provider) {
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<{id: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string}>>} The posterminals.
* @returns {Promise<Array<{id: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string, sort: number, enabled: boolean|number, clientversion: string|null}>>} The posterminals.
*
* @example
* const terminals = await store.posterminal.list(42);
* // => [{ id, name, token, pairingpin, pairedAt, clientserial, maxknownobituaryid, rootCategoryId, syncRootArticles, lastseenAt, updatedAt }]
* // => [{ id, name, token, pairingpin, pairedAt, clientserial, maxknownobituaryid, rootCategoryId, syncRootArticles, lastseenAt, updatedAt, sort, enabled, clientversion }]
*/
return async (googleAuthId, tx) => {
const { companyId } = await access(googleAuthId, tx);

View File

@@ -13,15 +13,18 @@ export default function pair(provider) {
* @param {string} input.token - The auth token for the paired device.
* @param {string} [input.clientserial] - The client name or serial identifier.
* @param {string} [input.pairedAt] - Timestamp when paired.
* @param {string|null} [input.clientversion] - Client software version reported by the POS device during pairing.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, token: string, clientserial: string|null, pairedAt: string|null}>}
* @returns {Promise<{id: number, token: string, clientserial: string|null, pairedAt: string|null, clientversion: string|null}>}
*/
return async (id, { token, clientserial, pairedAt }, tx) => {
return async (id, { token, clientserial, pairedAt, clientversion }, tx) => {
const versionVal = clientversion ? String(clientversion).trim() : null;
await makeRunner(provider, tx).query(sql, {
id,
token,
clientserial: clientserial ?? null,
pairedAt: pairedAt ?? null,
clientversion: versionVal,
});
return {
@@ -29,6 +32,7 @@ export default function pair(provider) {
token,
clientserial: clientserial ?? null,
pairedAt: pairedAt ?? null,
clientversion: versionVal,
};
};
}

View File

@@ -0,0 +1,26 @@
import { SET_CLIENTVERSION as MSSQL_SET } from '../../providers/mssql/queries/posterminal.js';
import { SET_CLIENTVERSION as SQLITE_SET } from '../../providers/sqlite3/queries/posterminal.js';
import makeRunner from '../runner.js';
export default function setClientversion(provider) {
const sql = provider?.dialect === 'sqlite3' ? SQLITE_SET : MSSQL_SET;
/**
* Stores the software version reported by a POS terminal.
*
* @param {number} id - The posterminal id.
* @param {string|null} clientversion - Version string reported by the client (trimmed; empty → null).
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, clientversion: string|null}>}
*
* @example
* await store.posterminal.setClientversion(7, '1.10.12.0');
* // => { id: 7, clientversion: '1.10.12.0' }
*/
return async (id, clientversion, tx) => {
const value = clientversion == null ? null : String(clientversion).trim() || null;
await makeRunner(provider, tx).query(sql, { id: Number(id), clientversion: value });
return { id: Number(id), clientversion: value };
};
}

View File

@@ -31,13 +31,15 @@ export default function upsert(provider) {
* @param {string} input.pairingpin - The pairing pin.
* @param {string} [input.token] - Optional token.
* @param {number} [input.id] - Optional id to update an existing posterminal.
* @param {number} [input.sort] - Display order (integer, ascending). Default 0 on create.
* @param {boolean} [input.enabled] - Whether the terminal may sync. Default true on create.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, name: string, token: string, pairingpin: string}>} The upserted posterminal.
* @returns {Promise<{id: number, name: string, token: string, pairingpin: string, sort: number, enabled: number}>} The upserted posterminal.
* @throws {Error} If the posterminal is invalid.
*
* @example
* const terminal = await store.posterminal.upsert(42, { name: 'Kasse 1', pairingpin: '1234', token: 'abc' });
* // => { id, name, token, pairingpin }
* // => { id, name, token, pairingpin, sort, enabled }
*/
return async (googleAuthId, input = {}, tx) => {
const id = input.id ?? null;
@@ -45,6 +47,12 @@ export default function upsert(provider) {
if (name === '' || (!name && !id)) {
throw new Error('Invalid posterminal');
}
if (input.sort !== undefined && !Number.isInteger(input.sort)) {
throw new Error('Invalid posterminal');
}
if (input.enabled !== undefined && typeof input.enabled !== 'boolean') {
throw new Error('Invalid posterminal');
}
const token = input.token ?? null;
const pairedAt = input.pairedAt ?? null;
const clientserial = input.clientserial ?? null;
@@ -61,6 +69,15 @@ export default function upsert(provider) {
const nameVal = name !== undefined ? name : existing.rows[0].name;
// Pairing fields are kept unless explicitly provided (PATCH must not unpair).
const tokenVal = input.token !== undefined ? token : (existing.rows[0].token ?? null);
const pairedAtVal = input.pairedAt !== undefined ? pairedAt : (existing.rows[0].pairedAt ?? null);
const clientserialVal = input.clientserial !== undefined ? clientserial : (existing.rows[0].clientserial ?? null);
const sort = input.sort !== undefined ? input.sort : existing.rows[0].sort;
const enabled = input.enabled !== undefined ? (input.enabled ? 1 : 0) : (existing.rows[0].enabled ? 1 : 0);
const clientversion = existing.rows[0].clientversion ?? null;
const pairingpin =
input.pairingpin !== undefined
? (input.pairingpin ? String(input.pairingpin).trim() : null)
@@ -90,27 +107,33 @@ export default function upsert(provider) {
id,
companyId,
name: nameVal,
token,
token: tokenVal,
pairingpin,
pairedAt,
clientserial,
pairedAt: pairedAtVal,
clientserial: clientserialVal,
maxknownobituaryid,
rootCategoryId,
syncRootArticles,
lastseenAt,
sort,
enabled,
clientversion,
});
return {
id,
name: nameVal,
token,
token: tokenVal,
pairingpin,
pairedAt,
clientserial,
pairedAt: pairedAtVal,
clientserial: clientserialVal,
maxknownobituaryid,
rootCategoryId,
syncRootArticles,
lastseenAt,
sort,
enabled,
clientversion,
};
}
@@ -119,6 +142,8 @@ export default function upsert(provider) {
const rootCategoryId = input.rootCategoryId != null ? Number(input.rootCategoryId) : null;
const syncRootArticles = input.syncRootArticles !== undefined ? (input.syncRootArticles ? 1 : 0) : 1;
const lastseenAt = input.lastseenAt ?? null;
const sort = input.sort ?? 0;
const enabled = input.enabled === false ? 0 : 1;
const created = await runner.query(createSql, {
companyId,
@@ -131,6 +156,8 @@ export default function upsert(provider) {
rootCategoryId,
syncRootArticles,
lastseenAt,
sort,
enabled,
});
const row = created.rows[0] ?? {
@@ -144,6 +171,9 @@ export default function upsert(provider) {
rootCategoryId,
syncRootArticles,
lastseenAt,
sort,
enabled,
clientversion: null,
};
await runner.query(seedSyncQueueSql, {
@@ -163,6 +193,9 @@ export default function upsert(provider) {
syncRootArticles: row.syncRootArticles !== undefined ? (row.syncRootArticles ? 1 : 0) : 1,
lastseenAt: row.lastseenAt ?? null,
updatedAt: row.updatedAt ?? null,
sort: row.sort ?? sort,
enabled: row.enabled !== undefined ? (row.enabled ? 1 : 0) : enabled,
clientversion: row.clientversion ?? null,
};
};
}

View File

@@ -0,0 +1,18 @@
export function shape(row) {
return {
id: row.id,
companyId: row.companyId,
name: row.name,
token: row.token ?? null,
pairingpin: row.pairingpin ?? null,
pairedAt: row.pairedAt ?? null,
agentversion: row.agentversion ?? null,
lastseenAt: row.lastseenAt ?? null,
updatedAt: row.updatedAt,
sort: row.sort,
enabled: Boolean(row.enabled),
};
}
export const PRINTERAGENT_TYPEDEF =
'{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number, enabled: boolean}';

View File

@@ -0,0 +1,50 @@
import {
CREATE as MSSQL_CREATE,
} from '../../providers/mssql/queries/printeragent.js';
import {
CREATE as SQLITE_CREATE,
} from '../../providers/sqlite3/queries/printeragent.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import { shape } from './_shared.js';
export default function create(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_CREATE : MSSQL_CREATE;
/**
* Creates an unpaired printer agent for the user's selected company.
* The agent registers itself later using the pairing pin.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - The printer agent input.
* @param {string} input.name - The display name.
* @param {string} input.pairingpin - The pairing pin the agent uses to register.
* @param {number} [input.sort] - Display order (integer, ascending). Default 0.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>} The created printer agent.
* @throws {Error} 'Invalid printeragent' if name or pairingpin is missing/blank or sort is not an integer.
*
* @example
* const agent = await store.printeragent.create(42, { name: 'Bar printer', pairingpin: '123456' });
*/
return async (googleAuthId, input = {}, tx) => {
const name = typeof input.name === 'string' ? input.name.trim() : '';
const pairingpin = input.pairingpin != null ? String(input.pairingpin).trim() : '';
if (!name || !pairingpin) {
throw new Error('Invalid printeragent');
}
if (input.sort !== undefined && !Number.isInteger(input.sort)) {
throw new Error('Invalid printeragent');
}
const sort = input.sort ?? 0;
const enabled = input.enabled !== undefined ? (input.enabled ? 1 : 0) : 1;
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const result = await runner.query(sql, { companyId, name, pairingpin, sort, enabled });
return shape(result.rows[0]);
};
}

View File

@@ -0,0 +1,36 @@
import { DELETE_BY_COMPANY_AND_ID as MSSQL_DELETE } from '../../providers/mssql/queries/printeragent.js';
import { DELETE_BY_COMPANY_AND_ID as SQLITE_DELETE } from '../../providers/sqlite3/queries/printeragent.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function del(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE;
/**
* Deletes a printer agent of the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The printer agent id.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>}
* @throws {Error} 'Printeragent not found' if the id is unknown in the company.
*
* @example
* await store.printeragent.delete(42, 5);
*/
return async (googleAuthId, id, tx) => {
const numericId = Number(id);
if (!Number.isInteger(numericId) || numericId <= 0) {
throw new Error('Printeragent not found');
}
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const result = await runner.query(sql, { id: numericId, companyId });
if (!result.rowsAffected) {
throw new Error('Printeragent not found');
}
};
}

View File

@@ -0,0 +1,27 @@
import { FIND_BY_PAIRINGPIN as MSSQL_FIND } from '../../providers/mssql/queries/printeragent.js';
import { FIND_BY_PAIRINGPIN as SQLITE_FIND } from '../../providers/sqlite3/queries/printeragent.js';
import makeRunner from '../runner.js';
import { shape } from './_shared.js';
export default function findByPairingpin(provider) {
const sql = provider?.dialect === 'sqlite3' ? SQLITE_FIND : MSSQL_FIND;
/**
* Finds a printer agent by its pairing pin.
*
* @param {string} pairingpin - The pairing pin to search for.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}|null>} The printer agent, or null if not found.
*
* @example
* const agent = await store.printeragent.findByPairingpin('123456');
*/
return async (pairingpin, tx) => {
if (!pairingpin) return null;
const result = await makeRunner(provider, tx).query(sql, {
pairingpin: String(pairingpin).trim(),
});
return result.rows[0] ? shape(result.rows[0]) : null;
};
}

View File

@@ -0,0 +1,28 @@
import { FIND_BY_TOKEN as MSSQL_FIND } from '../../providers/mssql/queries/printeragent.js';
import { FIND_BY_TOKEN as SQLITE_FIND } from '../../providers/sqlite3/queries/printeragent.js';
import makeRunner from '../runner.js';
import { shape } from './_shared.js';
export default function findByToken(provider) {
const sql = provider?.dialect === 'sqlite3' ? SQLITE_FIND : MSSQL_FIND;
/**
* Finds a printer agent by its authentication token.
*
* @param {string} token - The auth token to search for.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}|null>} The printer agent, or null if not found.
*
* @example
* const agent = await store.printeragent.findByToken('abc...');
*/
return async (token, tx) => {
const trimmed = String(token ?? '').trim();
if (!trimmed) return null;
const result = await makeRunner(provider, tx).query(sql, {
token: trimmed,
});
return result.rows[0] ? shape(result.rows[0]) : null;
};
}

View File

@@ -0,0 +1,29 @@
import { LIST_BY_COMPANY as MSSQL_LIST } from '../../providers/mssql/queries/printeragent.js';
import { LIST_BY_COMPANY as SQLITE_LIST } from '../../providers/sqlite3/queries/printeragent.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import { shape } from './_shared.js';
export default function list(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_LIST : MSSQL_LIST;
/**
* Lists the printer agents of the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>>} The printer agents.
*
* @example
* const agents = await store.printeragent.list(42);
* // => [{ id, companyId, name, token, pairingpin, pairedAt, clientserial, clientmodel, clientstate, agentversion, lastseenAt, updatedAt, sort }]
*/
return async (googleAuthId, tx) => {
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const result = await runner.query(sql, { companyId });
return result.rows.map(shape);
};
}

View File

@@ -0,0 +1,58 @@
import {
PAIR as MSSQL_PAIR,
GET_BY_ID as MSSQL_GET,
} from '../../providers/mssql/queries/printeragent.js';
import {
PAIR as SQLITE_PAIR,
GET_BY_ID as SQLITE_GET,
} from '../../providers/sqlite3/queries/printeragent.js';
import makeRunner from '../runner.js';
import { shape } from './_shared.js';
export default function pair(provider) {
const isSqlite = provider?.dialect === 'sqlite3';
const pairSql = isSqlite ? SQLITE_PAIR : MSSQL_PAIR;
const getSql = isSqlite ? SQLITE_GET : MSSQL_GET;
/**
* Completes device pairing for a printer agent.
*
* @param {number} id - The printer agent id.
* @param {object} input
* @param {string} input.token - Auth token for the paired device.
* @param {string} [input.clientserial] - Device serial identifier.
* @param {string} [input.clientmodel] - Device model name.
* @param {string} [input.agentversion] - Device agent software version.
* @param {string} [input.pairedAt] - Timestamp when paired.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>}
*/
return async (id, input = {}, tx) => {
const numericId = Number(id);
if (!Number.isInteger(numericId) || numericId <= 0) {
throw new Error('Printeragent not found');
}
const token = typeof input.token === 'string' ? input.token.trim() : '';
if (!token) {
throw new Error('Invalid printeragent');
}
const agentversion = input.agentversion ? String(input.agentversion).trim() : null;
const pairedAt = input.pairedAt ?? new Date().toISOString();
const runner = makeRunner(provider, tx);
await runner.query(pairSql, {
id: numericId,
token,
pairedAt,
agentversion,
});
const updated = await runner.query(getSql, { id: numericId });
if (!updated.rows[0]) {
throw new Error('Printeragent not found');
}
return shape(updated.rows[0]);
};
}

View File

@@ -0,0 +1,40 @@
import {
TOUCH_LASTSEEN as MSSQL_TOUCH,
TOUCH_LASTSEEN_AND_VERSION as MSSQL_TOUCH_VERSION,
} from '../../providers/mssql/queries/printeragent.js';
import {
TOUCH_LASTSEEN as SQLITE_TOUCH,
TOUCH_LASTSEEN_AND_VERSION as SQLITE_TOUCH_VERSION,
} from '../../providers/sqlite3/queries/printeragent.js';
import makeRunner from '../runner.js';
export default function touchLastseen(provider) {
const isSqlite = provider?.dialect === 'sqlite3';
const touchSql = isSqlite ? SQLITE_TOUCH : MSSQL_TOUCH;
const touchVersionSql = isSqlite ? SQLITE_TOUCH_VERSION : MSSQL_TOUCH_VERSION;
/**
* Updates lastseen_at (and optionally agentversion) for a printer agent.
*
* @param {number} id - The printer agent id.
* @param {string|null} [agentversion] - Optional agent version to update.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number}>}
*/
return async (id, agentversion, tx) => {
const numericId = Number(id);
if (!Number.isInteger(numericId) || numericId <= 0) {
throw new Error('Printeragent not found');
}
const runner = makeRunner(provider, tx);
const versionVal = agentversion ? String(agentversion).trim() : null;
if (versionVal) {
await runner.query(touchVersionSql, { id: numericId, agentversion: versionVal });
} else {
await runner.query(touchSql, { id: numericId });
}
return { id: numericId };
};
}

View File

@@ -0,0 +1,71 @@
import {
GET_BY_COMPANY_AND_ID as MSSQL_GET,
UPDATE as MSSQL_UPDATE,
} from '../../providers/mssql/queries/printeragent.js';
import {
GET_BY_COMPANY_AND_ID as SQLITE_GET,
UPDATE as SQLITE_UPDATE,
} from '../../providers/sqlite3/queries/printeragent.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import { shape } from './_shared.js';
export default function update(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const getSql = isSqlite ? SQLITE_GET : MSSQL_GET;
const updateSql = isSqlite ? SQLITE_UPDATE : MSSQL_UPDATE;
/**
* Updates name and/or sort of a printer agent of the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - The update input.
* @param {number} input.id - The printer agent id.
* @param {string} [input.name] - The new display name.
* @param {number} [input.sort] - The new display order (integer).
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, clientmodel: string|null, clientstate: string|null, agentversion: string|null, lastseenAt: string|null, updatedAt: string, sort: number}>} The updated printer agent.
* @throws {Error} 'Invalid printeragent' if name is blank, sort is not an integer, or neither is given; 'Printeragent not found' if id is unknown in the company.
*
* @example
* const agent = await store.printeragent.update(42, { id: 5, name: 'Kitchen printer', sort: 1 });
*/
return async (googleAuthId, input = {}, tx) => {
const id = Number(input.id);
if (!Number.isInteger(id) || id <= 0) {
throw new Error('Printeragent not found');
}
if (input.name === undefined && input.sort === undefined && input.enabled === undefined) {
throw new Error('Invalid printeragent');
}
if (input.name !== undefined && (typeof input.name !== 'string' || !input.name.trim())) {
throw new Error('Invalid printeragent');
}
if (input.sort !== undefined && !Number.isInteger(input.sort)) {
throw new Error('Invalid printeragent');
}
if (input.enabled !== undefined && typeof input.enabled !== 'boolean') {
throw new Error('Invalid printeragent');
}
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const existing = await runner.query(getSql, { id, companyId });
if (!existing.rows[0]) {
throw new Error('Printeragent not found');
}
await runner.query(updateSql, {
id,
companyId,
name: input.name !== undefined ? input.name.trim() : existing.rows[0].name,
sort: input.sort !== undefined ? input.sort : existing.rows[0].sort,
enabled: input.enabled !== undefined ? (input.enabled ? 1 : 0) : existing.rows[0].enabled,
});
const row = await runner.query(getSql, { id, companyId });
return shape(row.rows[0]);
};
}

Some files were not shown because too many files have changed in this diff Show More