This commit is contained in:
seb
2026-11-02 00:19:21 +01:00
parent 55f04de8a8
commit d0a33b1f82
35 changed files with 4919 additions and 3739 deletions

View File

@@ -81,7 +81,7 @@ Lists agent printers of the user's selected company, optionally filtered by prin
*Source: `src/store/agentprinter/order.js`* *Source: `src/store/agentprinter/order.js`*
Reorders agent printers for a given printer agent within the user's selected company. Reorders agent printers for a given printer agent or globally across the user's selected company.
**Parameters** **Parameters**
@@ -89,7 +89,7 @@ Reorders agent printers for a given printer agent within the user's selected com
| --- | --- | --- | | --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. | | `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object` | | | `input` | `object` | |
| `input.printeragentId` | `number` | The printer agent id. | | `[input.printeragentId]` | `number` | Optional printer agent id. If omitted, reorders all printers in company. |
| `input.agentprinterIds` | `number[]` | Complete ordered list of agent printer IDs. | | `input.agentprinterIds` | `number[]` | Complete ordered list of agent printer IDs. |
| `[tx]` | `object` | Optional transaction to run within. | | `[tx]` | `object` | Optional transaction to run within. |

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`* *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** **Parameters**
@@ -14,19 +14,52 @@ Adds a company user linking a company to a Google auth account.
| --- | --- | --- | | --- | --- | --- |
| `companyId` | `number` | The id of the company. | | `companyId` | `number` | The id of the company. |
| `googleAuthId` | `number` | The id of the Google auth account. | | `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. | | `[tx]` | `object` | Optional transaction to run within. |
**Returns** **Returns**
`Promise<number>` — The id of the created company user link. `Promise<number>` — The id of the created company user link.
**Throws**
`Error` — 'Invalid role' if the role is not 'admin' or 'user'.
**Example** **Example**
```js ```js
const id = await store.companyuser.add(1, 42); const id = await store.companyuser.add(1, 42, 'admin');
// => 7 // => 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, 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` ## `companyuser.delete`
*Source: `src/store/companyuser/delete.js`* *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'); const ids = await store.companyuser.getByEmail('user@example.com');
// => [1, 2, 3] // => [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, 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, 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`* *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** **Parameters**
@@ -144,7 +144,7 @@ await store.google_auth.setProfile(42, { picture: 'url', pictureBinary: buffer,
*Source: `src/store/google_auth/setSelectedCompany.js`* *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** **Parameters**
@@ -157,6 +157,10 @@ Sets the currently selected company for a Google auth user.
`Promise<void>` `Promise<void>`
**Throws**
`Error` — 'Not authorized' if the user is not a member of the company.
**Example** **Example**
```js ```js

15
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 ## Table of Contents
- [companyuser](API.companyuser.md) - [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, role: 'admin'|'user'}>`
- `delete(id: number, tx?: object): Promise<void>` - `delete(id: number, tx?: object): Promise<void>`
- `getByEmail(email: string, tx?: object): Promise<Array<number>>` - `getByEmail(email: string, tx?: object): Promise<Array<number>>`
- `list(googleAuthId: number, tx?: object): Promise<Array<{id: number, googleAuthId: number, email: string, 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, role: 'admin'|'user'}>`
- `withTransaction(): void`
- [company](API.company.md) - [company](API.company.md)
- `addHistory(googleAuthId: number, input: {text?: string}, tx?: object): Promise<{id: number, companyuserId: number|null, date: string, text: string}>` - `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}>` - `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}>`
@@ -349,7 +356,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `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}}>` - `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>` - `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}>>` - `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[]}>` - `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}>` - `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) - [articlehistory](API.articlehistory.md)
- `record(): void` - `record(): void`
@@ -358,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}>>` - `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) - [articlewarehousesummary](API.articlewarehousesummary.md)
- `syncSummary(): void` - `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) - [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}>` - `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>` - `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`

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

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

@@ -1,6 +1,13 @@
import companyuserGetByEmail from './store/companyuser/getByEmail.js'; import companyuserGetByEmail from './store/companyuser/getByEmail.js';
import companyuserAdd from './store/companyuser/add.js'; import companyuserAdd from './store/companyuser/add.js';
import companyuserDelete from './store/companyuser/delete.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 companyCreate from './store/company/create.js';
import companyUpdate from './store/company/update.js'; import companyUpdate from './store/company/update.js';
import companyDelete from './store/company/delete.js'; import companyDelete from './store/company/delete.js';
@@ -334,6 +341,16 @@ export default class LibDashStore {
getByEmail: companyuserGetByEmail(provider), getByEmail: companyuserGetByEmail(provider),
add: companyuserAdd(provider), add: companyuserAdd(provider),
delete: companyuserDelete(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 = { this.company = {

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

@@ -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

@@ -55,7 +55,8 @@ SELECT
l.iso639, l.iso639,
tr.rate AS taxrate, tr.rate AS taxrate,
rtr.rate AS reducedtaxrate, rtr.rate AS reducedtaxrate,
tfr.rate AS taxfreerate tfr.rate AS taxfreerate,
cu.role AS role
FROM company c FROM company c
INNER JOIN companyuser cu ON cu.company_id = c.id INNER JOIN companyuser cu ON cu.company_id = c.id
LEFT JOIN language l ON l.id = c.default_language_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.root_category_id AS rootCategoryId,
c.default_language_id AS languageId, c.default_language_id AS languageId,
c.poscustomergroup_id AS posCustomerGroupId, c.poscustomergroup_id AS posCustomerGroupId,
cu.id AS companyuserId cu.id AS companyuserId,
cu.role AS role
FROM company c FROM company c
LEFT JOIN companyuser cu LEFT JOIN companyuser cu
ON cu.company_id = c.id AND cu.google_auth_id = @googleAuthId ON cu.company_id = c.id AND cu.google_auth_id = @googleAuthId
@@ -18,12 +19,71 @@ WHERE c.id = @companyId
`; `;
export const ADD = ` export const ADD = `
INSERT INTO companyuser (google_auth_id, company_id) INSERT INTO companyuser (google_auth_id, company_id, role)
OUTPUT INSERTED.id OUTPUT INSERTED.id
VALUES (@googleAuthId, @companyId) VALUES (@googleAuthId, @companyId, @role)
`; `;
export const DELETE = ` export const DELETE = `
DELETE FROM companyuser DELETE FROM companyuser
WHERE id = @id WHERE id = @id
`; `;
export const LIST_BY_COMPANY = `
SELECT
cu.id,
cu.google_auth_id AS googleAuthId,
ga.email,
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
id,
google_auth_id AS googleAuthId,
role
FROM companyuser
WHERE id = @id AND 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

@@ -866,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 = ` export const INIT_COMPANYUSER = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'companyuser') IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'companyuser')
BEGIN BEGIN
@@ -873,6 +889,8 @@ BEGIN
id INT IDENTITY(1,1) PRIMARY KEY, id INT IDENTITY(1,1) PRIMARY KEY,
google_auth_id INT NOT NULL, google_auth_id INT NOT NULL,
company_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 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_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 CONSTRAINT FK_companyuser_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE
@@ -1711,6 +1729,7 @@ export const SCHEMA_SCRIPTS = [
INIT_GET_NEXT_NUMBER_WITH_PREFIX_AND_PADDING, INIT_GET_NEXT_NUMBER_WITH_PREFIX_AND_PADDING,
INIT_PEEK_NEXT_NUMBER_WITH_PREFIX_AND_PADDING, INIT_PEEK_NEXT_NUMBER_WITH_PREFIX_AND_PADDING,
INIT_COMPANYUSER, INIT_COMPANYUSER,
INIT_AUTOINVITE,
INIT_MODELCONVERSATION, INIT_MODELCONVERSATION,
INIT_MODELMESSAGE, INIT_MODELMESSAGE,
INIT_MODELVFS, INIT_MODELVFS,

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,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

@@ -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

@@ -2,4 +2,18 @@ import * as source from '../../mssql/queries/companyuser.js';
import { translateQueries } from './translate.js'; import { translateQueries } from './translate.js';
const queries = translateQueries(source, 'companyuser'); 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

@@ -512,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 = ` export const INIT_COMPANYUSER = `
CREATE TABLE IF NOT EXISTS companyuser ( CREATE TABLE IF NOT EXISTS companyuser (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
google_auth_id INTEGER NOT NULL, google_auth_id INTEGER NOT NULL,
company_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), UNIQUE (google_auth_id, company_id),
FOREIGN KEY (google_auth_id) REFERENCES google_auth(id) ON DELETE CASCADE, FOREIGN KEY (google_auth_id) REFERENCES google_auth(id) ON DELETE CASCADE,
FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE
@@ -1266,6 +1279,7 @@ export const SCHEMA_SCRIPTS = [
INIT_LEDGER, INIT_LEDGER,
INIT_NUMBERS, INIT_NUMBERS,
INIT_COMPANYUSER, INIT_COMPANYUSER,
INIT_AUTOINVITE,
INIT_MODELCONVERSATION, INIT_MODELCONVERSATION,
INIT_MODELMESSAGE, INIT_MODELMESSAGE,
INIT_MODELVFS, INIT_MODELVFS,

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,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

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

View File

@@ -1,26 +1,33 @@
import { ADD as MSSQL_ADD } from '../../providers/mssql/queries/companyuser.js'; import { ADD as MSSQL_ADD } from '../../providers/mssql/queries/companyuser.js';
import { ADD as SQLITE_ADD } from '../../providers/sqlite3/queries/companyuser.js'; import { ADD as SQLITE_ADD } from '../../providers/sqlite3/queries/companyuser.js';
import makeRunner from '../runner.js'; import makeRunner from '../runner.js';
import { assertRole } from './roles.js';
export default function add(provider) { export default function add(provider) {
const sql = provider?.dialect === 'sqlite3' ? SQLITE_ADD : MSSQL_ADD; const sql = provider?.dialect === 'sqlite3' ? SQLITE_ADD : MSSQL_ADD;
/** /**
* 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.
* *
* @param {number} companyId - The id of the company. * @param {number} companyId - The id of the company.
* @param {number} googleAuthId - The id of the Google auth account. * @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. * @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<number>} The id of the created company user link. * @returns {Promise<number>} The id of the created company user link.
* @throws {Error} 'Invalid role' if the role is not 'admin' or 'user'.
* *
* @example * @example
* const id = await store.companyuser.add(1, 42); * const id = await store.companyuser.add(1, 42, 'admin');
* // => 7 * // => 7
*/ */
return async (companyId, googleAuthId, tx) => { return async (companyId, googleAuthId, role = 'user', tx) => {
assertRole(role);
const result = await makeRunner(provider, tx).query(sql, { const result = await makeRunner(provider, tx).query(sql, {
companyId, companyId,
googleAuthId, googleAuthId,
role,
}); });
return result.rows[0]?.id ?? result.insertId; 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, 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, 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, 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, languageId: row.languageId,
posCustomerGroupId: row.posCustomerGroupId ?? null, posCustomerGroupId: row.posCustomerGroupId ?? null,
companyuserId: row.companyuserId, 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, 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, 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 MSSQL_ADD } from '../../providers/mssql/queries/google_auth.js';
import { ADD as SQLITE_ADD } from '../../providers/sqlite3/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) { 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. * @param {string} email - The email address to allow.
* @returns {Promise<number>} The id of the created Google auth row. * @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'); * const id = await store.google_auth.add('user@example.com');
* // => 1 * // => 1
*/ */
return async (email) => { return async (email) =>
const result = await provider.query(sql, { email }); withTransaction(provider, null, async (runner) => {
return result.rows[0]?.id ?? result.insertId; 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 MSSQL_SET } from '../../providers/mssql/queries/google_auth.js';
import { SET_SELECTED_COMPANY as SQLITE_SET } from '../../providers/sqlite3/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) { 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. * 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} googleAuthId - The id of the Google auth user.
* @param {number} companyId - The id of the company to select. * @param {number} companyId - The id of the company to select.
* @returns {Promise<void>} * @returns {Promise<void>}
* @throws {Error} 'Not authorized' if the user is not a member of the company.
* *
* @example * @example
* await store.google_auth.setSelectedCompany(42, 1); * await store.google_auth.setSelectedCompany(42, 1);
*/ */
return async (googleAuthId, companyId) => { 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 }); await provider.query(sql, { googleAuthId, companyId });
}; };
} }