From d0a33b1f828defdb3ae37e163c64580edd0ec4a9 Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 2 Nov 2026 00:19:21 +0100 Subject: [PATCH] u --- API.agentprinter.md | 4 +- API.autoinvite.md | 86 + API.companyuser.md | 168 +- API.google_auth.md | 8 +- API.md | 15 +- schema-graph.dot | 328 +- schema-graph.svg | 7281 +++++++++-------- scripts/test-companyuser-roles.js | 92 + src/index.js | 17 + src/providers/mssql/downgrade/6_to_5.js | 18 + src/providers/mssql/queries/autoinvite.js | 43 + src/providers/mssql/queries/company.js | 3 +- src/providers/mssql/queries/companyuser.js | 66 +- src/providers/mssql/schema.js | 19 + src/providers/mssql/upgrade/5_to_6.js | 25 + src/providers/sqlite3/downgrade/6_to_5.js | 15 + src/providers/sqlite3/queries/autoinvite.js | 5 + src/providers/sqlite3/queries/companyuser.js | 16 +- src/providers/sqlite3/schema.js | 14 + src/providers/sqlite3/upgrade/5_to_6.js | 19 + src/store/autoinvite/add.js | 46 + src/store/autoinvite/list.js | 26 + src/store/autoinvite/remove.js | 32 + src/store/company/create.js | 2 + src/store/companyuser/add.js | 11 +- src/store/companyuser/addByEmail.js | 46 + src/store/companyuser/list.js | 26 + src/store/companyuser/remove.js | 63 + src/store/companyuser/requireAdmin.js | 20 + src/store/companyuser/resolveCompanyAccess.js | 1 + src/store/companyuser/roles.js | 28 + src/store/companyuser/setRole.js | 51 + src/store/companyuser/withTransaction.js | 11 + src/store/google_auth/add.js | 41 +- src/store/google_auth/setSelectedCompany.js | 12 +- 35 files changed, 4919 insertions(+), 3739 deletions(-) create mode 100644 API.autoinvite.md create mode 100644 scripts/test-companyuser-roles.js create mode 100644 src/providers/mssql/downgrade/6_to_5.js create mode 100644 src/providers/mssql/queries/autoinvite.js create mode 100644 src/providers/mssql/upgrade/5_to_6.js create mode 100644 src/providers/sqlite3/downgrade/6_to_5.js create mode 100644 src/providers/sqlite3/queries/autoinvite.js create mode 100644 src/providers/sqlite3/upgrade/5_to_6.js create mode 100644 src/store/autoinvite/add.js create mode 100644 src/store/autoinvite/list.js create mode 100644 src/store/autoinvite/remove.js create mode 100644 src/store/companyuser/addByEmail.js create mode 100644 src/store/companyuser/list.js create mode 100644 src/store/companyuser/remove.js create mode 100644 src/store/companyuser/requireAdmin.js create mode 100644 src/store/companyuser/roles.js create mode 100644 src/store/companyuser/setRole.js create mode 100644 src/store/companyuser/withTransaction.js diff --git a/API.agentprinter.md b/API.agentprinter.md index dea9f8b..424c356 100644 --- a/API.agentprinter.md +++ b/API.agentprinter.md @@ -81,7 +81,7 @@ Lists agent printers of the user's selected company, optionally filtered by prin *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** @@ -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. | | `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. | | `[tx]` | `object` | Optional transaction to run within. | diff --git a/API.autoinvite.md b/API.autoinvite.md new file mode 100644 index 0000000..5d3f8a7 --- /dev/null +++ b/API.autoinvite.md @@ -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>` — 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` + +**Throws** + +`Error` — 'Not authorized', 'Invitation not found' + +**Example** + +```js +await store.autoinvite.remove(42, 3); +``` diff --git a/API.companyuser.md b/API.companyuser.md index c1f1788..fcc5274 100644 --- a/API.companyuser.md +++ b/API.companyuser.md @@ -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` — 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, 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>` — 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. diff --git a/API.google_auth.md b/API.google_auth.md index c140fa3..4a09422 100644 --- a/API.google_auth.md +++ b/API.google_auth.md @@ -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` +**Throws** + +`Error` — 'Not authorized' if the user is not a member of the company. + **Example** ```js diff --git a/API.md b/API.md index bfa637b..1d5de4e 100644 --- a/API.md +++ b/API.md @@ -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` + - `add(companyId: number, googleAuthId: number, role?: 'admin'|'user', tx?: object): Promise` + - `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` - `getByEmail(email: string, tx?: object): Promise>` + - `list(googleAuthId: number, tx?: object): Promise>` + - `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) - `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}>` @@ -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}}>` - `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>` - - `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}>` - [articlehistory](API.articlehistory.md) - `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>` - [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>` + - `remove(googleAuthId: number, id: number, tx?: object): Promise` - [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` diff --git a/schema-graph.dot b/schema-graph.dot index b47b0bb..43d93fe 100644 --- a/schema-graph.dot +++ b/schema-graph.dot @@ -9,18 +9,18 @@ digraph schema { label="Identity & Company"; labeljust="l"; fontsize=15; style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; fontcolor="#e2e8f0"; margin=14; - companyhistory [label=<
companyhistory
idINT PK
company_idINT FK
companyuser_id?INT FK
dateDATETIME2
textNVARCHAR(MAX)
>]; - company [label=<
company
idINT PK
default_language_id?INT FK
taxrate?INT FK
reducedtaxrate?INT FK
taxfreerate?INT FK
root_category_id?INT FK
poscustomergroup_idINT FK
nameNVARCHAR(255)
>]; - numbers [label=<
numbers
idINT PK
company_idINT FK
typeNVARCHAR(100)
prefixNVARCHAR(50)
paddingINT
nextINT
>]; - meta [label=<
meta
keyNVARCHAR(255)
valueNVARCHAR(MAX)
>]; - companysetting [label=<
companysetting
idINT PK
company_idINT FK
keyNVARCHAR(255)
valueNVARCHAR(MAX)
frontendBIT
writeonlyBIT
>]; - entityaddress [label=<
entityaddress
idINT PK
entity_idINT FK
first_name?NVARCHAR(255)
name?NVARCHAR(255)
street?NVARCHAR(255)
streetnumber?NVARCHAR(50)
zip?NVARCHAR(50)
city?NVARCHAR(255)
county?NVARCHAR(255)
addresstypeNVARCHAR(50)
sortINT
>]; customergroup [label=<
customergroup
idINT PK
company_id?INT FK
nameNVARCHAR(255)
sortINT
updated_atDATETIME2
>]; + companyhistory [label=<
companyhistory
idINT PK
companyuser_id?INT FK
company_idINT FK
dateDATETIME2
textNVARCHAR(MAX)
>]; + meta [label=<
meta
keyNVARCHAR(255)
valueNVARCHAR(MAX)
>]; + numbers [label=<
numbers
idINT PK
company_idINT FK
typeNVARCHAR(100)
prefixNVARCHAR(50)
paddingINT
nextINT
>]; + entityaddress [label=<
entityaddress
idINT PK
entity_idINT FK
first_name?NVARCHAR(255)
name?NVARCHAR(255)
street?NVARCHAR(255)
streetnumber?NVARCHAR(50)
zip?NVARCHAR(50)
city?NVARCHAR(255)
county?NVARCHAR(255)
addresstypeNVARCHAR(50)
sortINT
>]; + companyuser [label=<
companyuser
idINT PK
company_idINT FK
google_auth_idINT FK
>]; entity [label=<
entity
idINT PK
customergroup_idINT FK
first_name?NVARCHAR(255)
nameNVARCHAR(255)
number?NVARCHAR(100)
created_atDATETIME2
updated_atDATETIME2
>]; + company [label=<
company
idINT PK
poscustomergroup_idINT FK
default_language_id?INT FK
root_category_id?INT FK
taxrate?INT FK
reducedtaxrate?INT FK
taxfreerate?INT FK
nameNVARCHAR(255)
>]; + companysetting [label=<
companysetting
idINT PK
company_idINT FK
keyNVARCHAR(255)
valueNVARCHAR(MAX)
frontendBIT
writeonlyBIT
>]; entitycontact [label=<
entitycontact
idINT PK
entity_idINT FK
contacttypeNVARCHAR(50)
contactNVARCHAR(255)
sortINT
>]; google_auth [label=<
google_auth
idINT PK
selected_company?INT FK
emailNVARCHAR(320)
picture?NVARCHAR(2048)
picturebinary?VARBINARY(MAX)
sub?NVARCHAR(255)
created_atDATETIME2
>]; - entityhistory [label=<
entityhistory
idINT PK
entity_idINT FK
companyuser_id?INT FK
dateDATETIME2
textNVARCHAR(MAX)
>]; - companyuser [label=<
companyuser
idINT PK
company_idINT FK
google_auth_idINT FK
>]; + entityhistory [label=<
entityhistory
idINT PK
companyuser_id?INT FK
entity_idINT FK
dateDATETIME2
textNVARCHAR(MAX)
>]; } subgraph cluster_i18n { label="Localization"; labeljust="l"; fontsize=15; @@ -28,61 +28,61 @@ digraph schema { fontcolor="#e2e8f0"; margin=14; language [label=<
language
idINT PK
iso639NVARCHAR(10)
>]; unittext [label=<
unittext
idINT PK
language_idINT FK
unit_idINT FK
symbolNVARCHAR(50)
nameNVARCHAR(255)
>]; - articletext [label=<
articletext
idINT PK
language_idINT FK
article_idINT FK
nameNVARCHAR(255)
slugNVARCHAR(255)
seo?NVARCHAR(MAX)
desc?NVARCHAR(MAX)
>]; categorytext [label=<
categorytext
idINT PK
language_idINT FK
category_idINT FK
nameNVARCHAR(255)
slugNVARCHAR(255)
seo?NVARCHAR(MAX)
desc?NVARCHAR(MAX)
>]; + articletext [label=<
articletext
idINT PK
language_idINT FK
article_idINT FK
nameNVARCHAR(255)
slugNVARCHAR(255)
seo?NVARCHAR(MAX)
desc?NVARCHAR(MAX)
>]; } subgraph cluster_catalog { label="Catalog"; labeljust="l"; fontsize=15; style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; fontcolor="#e2e8f0"; margin=14; - brand [label=<
brand
idINT PK
company_idINT FK
nameNVARCHAR(255)
gsprpinfo?NVARCHAR(MAX)
>]; - articlelist [label=<
articlelist
idINT PK
>]; - articlelistarticle [label=<
articlelistarticle
idINT PK
articlelist_idINT FK
article_idINT FK
countINT
sortINT
>]; - optionslist [label=<
optionslist
idINT PK
>]; - optionslistarticle [label=<
optionslistarticle
idINT PK
optionslist_idINT FK
article_idINT FK
countINT
sortINT
mandatedBIT
>]; - articlehistory [label=<
articlehistory
idINT PK
article_idINT FK
companyuser_id?INT FK
dateDATETIME2
textNVARCHAR(MAX)
meta?NVARCHAR(MAX)
>]; - articleean [label=<
articleean
idINT PK
article_idINT FK
eanNVARCHAR(50)
>]; - article [label=<
article
idINT PK
unit_idINT FK
brand_id?INT FK
articlelist_id?INT FK
optionslist_id?INT FK
parentarticle_id?INT FK
parentattributename_id?INT FK
company_idINT FK
sku?NVARCHAR(100)
unitamountFLOAT
weightFLOAT
track_stockBIT
is_parentBIT
parentsortINT
created_atDATETIME2
updated_atDATETIME2
>]; - articlesupplier [label=<
articlesupplier
idINT PK
tax_id?INT FK
article_idINT FK
entity_idINT FK
net?FLOAT
sku?NVARCHAR(100)
stock?FLOAT
puFLOAT
sortINT
>]; + categoryarticle [label=<
categoryarticle
idINT PK
category_idINT FK
article_idINT FK
>]; category [label=<
category
idINT PK
parent_id?INT FK
sortINT
updated_atDATETIME2
>]; - categoryarticle [label=<
categoryarticle
idINT PK
article_idINT FK
category_idINT FK
>]; + articlehistory [label=<
articlehistory
idINT PK
companyuser_id?INT FK
article_idINT FK
dateDATETIME2
textNVARCHAR(MAX)
meta?NVARCHAR(MAX)
>]; + article [label=<
article
idINT PK
company_idINT FK
parentattributename_id?INT FK
unit_idINT FK
parentarticle_id?INT FK
articlelist_id?INT FK
optionslist_id?INT FK
brand_id?INT FK
sku?NVARCHAR(100)
unitamountFLOAT
weightFLOAT
track_stockBIT
is_parentBIT
parentsortINT
created_atDATETIME2
updated_atDATETIME2
>]; + articleean [label=<
articleean
idINT PK
article_idINT FK
eanNVARCHAR(50)
>]; + articlesupplier [label=<
articlesupplier
idINT PK
entity_idINT FK
article_idINT FK
tax_id?INT FK
net?FLOAT
sku?NVARCHAR(100)
stock?FLOAT
puFLOAT
sortINT
>]; + articlelist [label=<
articlelist
idINT PK
>]; + articlelistarticle [label=<
articlelistarticle
idINT PK
article_idINT FK
articlelist_idINT FK
countINT
sortINT
>]; + optionslist [label=<
optionslist
idINT PK
>]; + optionslistarticle [label=<
optionslistarticle
idINT PK
article_idINT FK
optionslist_idINT FK
countINT
sortINT
mandatedBIT
>]; + brand [label=<
brand
idINT PK
company_idINT FK
nameNVARCHAR(255)
gsprpinfo?NVARCHAR(MAX)
>]; } subgraph cluster_media { label="Media"; labeljust="l"; fontsize=15; style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; fontcolor="#e2e8f0"; margin=14; pic [label=<
pic
idINT PK
wINT
hINT
sizeBIGINT
hashNVARCHAR(64)
thumbwINT
thumbhINT
thumbhashNVARCHAR(64)
smallthumbwINT
smallthumbhINT
smallthumbbinaryVARBINARY(MAX)
sourceNVARCHAR(MAX)
>]; - brandpic [label=<
brandpic
idINT PK
brand_idINT FK
pic_idINT FK
sortINT
>]; - articlepic [label=<
articlepic
idINT PK
pic_idINT FK
article_idINT FK
sortINT
>]; + attributevaluepic [label=<
attributevaluepic
idINT PK
attributevalue_idINT FK
pic_idINT FK
sortINT
>]; categorypic [label=<
categorypic
idINT PK
pic_idINT FK
category_idINT FK
sortINT
>]; - attributevaluepic [label=<
attributevaluepic
idINT PK
pic_idINT FK
attributevalue_idINT FK
sortINT
>]; + articlepic [label=<
articlepic
idINT PK
pic_idINT FK
article_idINT FK
sortINT
>]; + brandpic [label=<
brandpic
idINT PK
pic_idINT FK
brand_idINT FK
sortINT
>]; file [label=<
file
idINT PK
hashNVARCHAR(64)
mimetypeNVARCHAR(255)
nameNVARCHAR(255)
sizeBIGINT
createdDATETIME2
modifiedDATETIME2
contentVARBINARY(MAX)
thumbavif?VARBINARY(MAX)
thumbx?INT
thumby?INT
meta?NVARCHAR(MAX)
>]; } subgraph cluster_manifests { label="Documents & Shipping"; labeljust="l"; fontsize=15; style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; fontcolor="#e2e8f0"; margin=14; - articlewarehousesummary [label=<
articlewarehousesummary
idINT PK
article_idINT FK
warehouse_idINT FK
stockFLOAT
>]; + deliverysummary [label=<
deliverysummary
idINT PK
supplier_id?INT FK
company_idINT FK
manifest_idINT FK
delivery_numberNVARCHAR(100)
external_number?NVARCHAR(100)
delivery_dateDATETIME2
created_atDATETIME2
supplier_name?NVARCHAR(255)
positionsINT
gross_totalFLOAT
net_totalFLOAT
total_quantityFLOAT
stock_positionsINT
booked_positionsINT
unbooked_positionsINT
stock_quantityFLOAT
booked_quantityFLOAT
unbooked_quantityFLOAT
is_bookedTINYINT
>]; + ordersummary [label=<
ordersummary
idINT PK
entity_id?INT FK
company_idINT FK
manifest_idINT FK
order_numberNVARCHAR(100)
order_dateDATETIME2
created_atDATETIME2
customer_name?NVARCHAR(255)
positionsINT
gross_totalFLOAT
paid_totalFLOAT
ordered_quantityFLOAT
shipped_quantityFLOAT
is_paidTINYINT
is_shippedTINYINT
>]; + quotesummary [label=<
quotesummary
idINT PK
entity_id?INT FK
company_idINT FK
manifest_idINT FK
quote_numberNVARCHAR(100)
quote_dateDATETIME2
created_atDATETIME2
customer_name?NVARCHAR(255)
positionsINT
gross_totalFLOAT
total_quantityFLOAT
>]; + shipmentsummary [label=<
shipmentsummary
idINT PK
entity_id?INT FK
company_idINT FK
manifest_idINT FK
shipment_numberNVARCHAR(100)
shipment_dateDATETIME2
created_atDATETIME2
order_id?INT
order_number?NVARCHAR(100)
customer_name?NVARCHAR(255)
positionsINT
total_quantityFLOAT
package_countINT
carrier?NVARCHAR(100)
tracking_code?NVARCHAR(255)
stateNVARCHAR(50)
>]; + package [label=<
package
idINT PK
entityaddress_idINT FK
shipping_idINT FK
numberNVARCHAR(100)
typeNVARCHAR(100)
stateNVARCHAR(100)
details?NVARCHAR(MAX)
>]; manifestarticle [label=<
manifestarticle
idINT PK
manifest_idINT FK
sku?NVARCHAR(100)
name?NVARCHAR(255)
puFLOAT
amountFLOAT
netFLOAT
comment?NVARCHAR(MAX)
rebateFLOAT
taxrateFLOAT
shippableTINYINT
>]; - manifest [label=<
manifest
idINT PK
company_idINT FK
entity_id?INT FK
numberNVARCHAR(100)
typeNVARCHAR(100)
external_number?NVARCHAR(100)
dateDATETIME2
created_atDATETIME2
>]; - manifestattachment [label=<
manifestattachment
idINT PK
file_idINT FK
manifest_idINT FK
sortINT
>]; - shipping [label=<
shipping
idINT PK
manifest_idINT FK
carrier_idINT FK
numberNVARCHAR(100)
tracking_code?NVARCHAR(255)
typeNVARCHAR(100)
stateNVARCHAR(100)
>]; - ledger [label=<
ledger
idINT PK
manifestarticle_idINT FK
warehouse_id?INT FK
transaction_typeNVARCHAR(100)
amountFLOAT
transaction_textNVARCHAR(MAX)
hash_versionINT
prev_hash?NVARCHAR(128)
created_atDATETIME2
>]; + manifestattachment [label=<
manifestattachment
idINT PK
manifest_idINT FK
file_idINT FK
sortINT
>]; + manifest [label=<
manifest
idINT PK
entity_id?INT FK
company_idINT FK
numberNVARCHAR(100)
typeNVARCHAR(100)
external_number?NVARCHAR(100)
dateDATETIME2
created_atDATETIME2
>]; + shipping [label=<
shipping
idINT PK
carrier_idINT FK
manifest_idINT FK
numberNVARCHAR(100)
tracking_code?NVARCHAR(255)
typeNVARCHAR(100)
stateNVARCHAR(100)
>]; warehouse [label=<
warehouse
idINT PK
company_idINT FK
nameNVARCHAR(255)
sortINT
>]; - deliverysummary [label=<
deliverysummary
idINT PK
manifest_idINT FK
company_idINT FK
supplier_id?INT FK
delivery_numberNVARCHAR(100)
external_number?NVARCHAR(100)
delivery_dateDATETIME2
created_atDATETIME2
supplier_name?NVARCHAR(255)
positionsINT
gross_totalFLOAT
net_totalFLOAT
total_quantityFLOAT
stock_positionsINT
booked_positionsINT
unbooked_positionsINT
stock_quantityFLOAT
booked_quantityFLOAT
unbooked_quantityFLOAT
is_bookedTINYINT
>]; - package [label=<
package
idINT PK
shipping_idINT FK
entityaddress_idINT FK
numberNVARCHAR(100)
typeNVARCHAR(100)
stateNVARCHAR(100)
details?NVARCHAR(MAX)
>]; - ordersummary [label=<
ordersummary
idINT PK
manifest_idINT FK
company_idINT FK
entity_id?INT FK
order_numberNVARCHAR(100)
order_dateDATETIME2
created_atDATETIME2
customer_name?NVARCHAR(255)
positionsINT
gross_totalFLOAT
paid_totalFLOAT
ordered_quantityFLOAT
shipped_quantityFLOAT
is_paidTINYINT
is_shippedTINYINT
>]; - quotesummary [label=<
quotesummary
idINT PK
manifest_idINT FK
company_idINT FK
entity_id?INT FK
quote_numberNVARCHAR(100)
quote_dateDATETIME2
created_atDATETIME2
customer_name?NVARCHAR(255)
positionsINT
gross_totalFLOAT
total_quantityFLOAT
>]; - shipmentsummary [label=<
shipmentsummary
idINT PK
manifest_idINT FK
company_idINT FK
entity_id?INT FK
shipment_numberNVARCHAR(100)
shipment_dateDATETIME2
created_atDATETIME2
order_id?INT
order_number?NVARCHAR(100)
customer_name?NVARCHAR(255)
positionsINT
total_quantityFLOAT
package_countINT
carrier?NVARCHAR(100)
tracking_code?NVARCHAR(255)
stateNVARCHAR(50)
>]; + ledger [label=<
ledger
idINT PK
manifestarticle_idINT FK
warehouse_id?INT FK
transaction_typeNVARCHAR(100)
amountFLOAT
transaction_textNVARCHAR(MAX)
hash_versionINT
prev_hash?NVARCHAR(128)
created_atDATETIME2
>]; + articlewarehousesummary [label=<
articlewarehousesummary
idINT PK
warehouse_idINT FK
article_idINT FK
stockFLOAT
>]; } subgraph cluster_commerce { label="Pricing & Payments"; labeljust="l"; fontsize=15; style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; fontcolor="#e2e8f0"; margin=14; tax [label=<
tax
idINT PK
rateDECIMAL(18,4)
>]; + payment [label=<
payment
idINT PK
manifest_idINT FK
account_idINT FK
typeNVARCHAR(100)
amountFLOAT
created_atDATETIME2
>]; + price [label=<
price
idINT PK
customergroup_idINT FK
article_idINT FK
tax_idINT FK
minmultiplierDECIMAL(18,4)
netFLOAT
>]; account [label=<
account
idINT PK
company_idINT FK
typeNVARCHAR(100)
nameNVARCHAR(255)
>]; - price [label=<
price
idINT PK
tax_idINT FK
article_idINT FK
customergroup_idINT FK
minmultiplierDECIMAL(18,4)
netFLOAT
>]; - payment [label=<
payment
idINT PK
account_idINT FK
manifest_idINT FK
typeNVARCHAR(100)
amountFLOAT
created_atDATETIME2
>]; } subgraph cluster_units { label="Units"; labeljust="l"; fontsize=15; @@ -94,162 +94,168 @@ digraph schema { label="POS"; labeljust="l"; fontsize=15; style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; fontcolor="#e2e8f0"; margin=14; - posterminalobituary [label=<
posterminalobituary
idINT PK
posterminal_idINT FK
entity_typeNVARCHAR(50)
entity_idINT
created_atDATETIME2
>]; - posterminal [label=<
posterminal
idINT PK
root_category_id?INT FK
company_idINT FK
nameNVARCHAR(255)
token?NVARCHAR(255)
pairingpin?NVARCHAR(50)
paired_at?DATETIME2
clientserial?NVARCHAR(255)
maxknownobituaryid?INT
sync_root_articlesBIT
lastseen_at?DATETIME2
updated_atDATETIME2
>]; - posterminalsyncqueue [label=<
posterminalsyncqueue
idINT PK
posterminal_idINT FK
entity_typeNVARCHAR(50)
entity_idINT
>]; - posterminalcategory [label=<
posterminalcategory
idINT PK
category_idINT FK
posterminal_idINT FK
>]; labeltemplate [label=<
labeltemplate
idINT PK
company_idINT FK
nameNVARCHAR(255)
widthINT
heightINT
dpiINT
typeNVARCHAR(50)
contentNVARCHAR(MAX)
thumb?VARBINARY(MAX)
>]; + posterminalobituary [label=<
posterminalobituary
idINT PK
posterminal_idINT FK
entity_typeNVARCHAR(50)
entity_idINT
created_atDATETIME2
>]; + posterminal [label=<
posterminal
idINT PK
company_idINT FK
root_category_id?INT FK
nameNVARCHAR(255)
token?NVARCHAR(255)
pairingpin?NVARCHAR(50)
paired_at?DATETIME2
clientserial?NVARCHAR(255)
maxknownobituaryid?INT
sync_root_articlesBIT
lastseen_at?DATETIME2
updated_atDATETIME2
sortINT
enabledBIT
clientversion?NVARCHAR(255)
>]; + posterminalsyncqueue [label=<
posterminalsyncqueue
idINT PK
posterminal_idINT FK
entity_typeNVARCHAR(50)
entity_idINT
>]; + posterminalcategory [label=<
posterminalcategory
idINT PK
posterminal_idINT FK
category_idINT FK
>]; printer [label=<
printer
idINT PK
company_idINT FK
addressNVARCHAR(255)
typeNVARCHAR(50)
model?NVARCHAR(255)
serial?NVARCHAR(255)
sortINT
desc?NVARCHAR(MAX)
>]; } subgraph cluster_ai { label="AI & Models"; labeljust="l"; fontsize=15; style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; fontcolor="#e2e8f0"; margin=14; + modelvfs [label=<
modelvfs
idINT PK
company_idINT FK
nameNVARCHAR(255)
mimeNVARCHAR(100)
contentVARBINARY(MAX)
metaNVARCHAR(MAX)
created_atDATETIME2
updated_atDATETIME2
>]; subagentschedule [label=<
subagentschedule
idINT PK
company_idINT FK
desc?NVARCHAR(MAX)
promptNVARCHAR(MAX)
skills?NVARCHAR(MAX)
lastagentnote?NVARCHAR(MAX)
max_itrINT
cronNVARCHAR(100)
created_atDATETIME2
lastrun_at?DATETIME2
>]; modelconversation [label=<
modelconversation
idINT PK
companyuser_idINT FK
providerNVARCHAR(100)
modelNVARCHAR(255)
created_atDATETIME2
updated_atDATETIME2
stateNVARCHAR(20)
started_by?INT
>]; modelmessage [label=<
modelmessage
idINT PK
conversation_idINT FK
created_atDATETIME2
roleNVARCHAR(20)
messageNVARCHAR(MAX)
>]; - modelvfs [label=<
modelvfs
idINT PK
company_idINT FK
nameNVARCHAR(255)
mimeNVARCHAR(100)
contentVARBINARY(MAX)
metaNVARCHAR(MAX)
created_atDATETIME2
updated_atDATETIME2
>]; } subgraph cluster_attributes { label="Properties & Attributes"; labeljust="l"; fontsize=15; style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; fontcolor="#e2e8f0"; margin=14; - articleproperty [label=<
articleproperty
idINT PK
article_idINT FK
property_idINT FK
valueNVARCHAR(MAX)
sortINT
>]; - articleattributevalue [label=<
articleattributevalue
idINT PK
article_idINT FK
attributevalue_idINT FK
sortINT
>]; - categoryproperty [label=<
categoryproperty
idINT PK
category_idINT FK
property_idINT FK
valueNVARCHAR(MAX)
sortINT
>]; - categoryattributevalue [label=<
categoryattributevalue
idINT PK
category_idINT FK
attributevalue_idINT FK
sortINT
>]; - propertyname [label=<
propertyname
idINT PK
company_idINT FK
nameNVARCHAR(255)
sortINT
exclusiveBIT
>]; - attributename [label=<
attributename
idINT PK
company_idINT FK
nameNVARCHAR(255)
sortINT
exclusiveBIT
>]; attributevalue [label=<
attributevalue
idINT PK
attribute_idINT FK
valueNVARCHAR(255)
sortINT
>]; + attributename [label=<
attributename
idINT PK
company_idINT FK
nameNVARCHAR(255)
sortINT
exclusiveBIT
>]; + propertyname [label=<
propertyname
idINT PK
company_idINT FK
nameNVARCHAR(255)
sortINT
exclusiveBIT
>]; + categoryproperty [label=<
categoryproperty
idINT PK
property_idINT FK
category_idINT FK
valueNVARCHAR(MAX)
sortINT
>]; + categoryattributevalue [label=<
categoryattributevalue
idINT PK
attributevalue_idINT FK
category_idINT FK
sortINT
>]; + articleproperty [label=<
articleproperty
idINT PK
property_idINT FK
article_idINT FK
valueNVARCHAR(MAX)
sortINT
>]; + articleattributevalue [label=<
articleattributevalue
idINT PK
attributevalue_idINT FK
article_idINT FK
sortINT
>]; } - returnsummary [label=<
returnsummary
idINT PK
manifest_idINT FK
company_idINT FK
supplier_id?INT FK
return_numberNVARCHAR(100)
external_number?NVARCHAR(100)
return_dateDATETIME2
created_atDATETIME2
supplier_name?NVARCHAR(255)
positionsINT
gross_totalFLOAT
net_totalFLOAT
total_quantityFLOAT
stock_positionsINT
booked_positionsINT
unbooked_positionsINT
stock_quantityFLOAT
booked_quantityFLOAT
unbooked_quantityFLOAT
is_bookedTINYINT
>]; - retouresummary [label=<
retouresummary
idINT PK
manifest_idINT FK
company_idINT FK
entity_id?INT FK
retoure_numberNVARCHAR(100)
retoure_dateDATETIME2
created_atDATETIME2
customer_name?NVARCHAR(255)
positionsINT
gross_totalFLOAT
paid_totalFLOAT
ordered_quantityFLOAT
shipped_quantityFLOAT
is_paidTINYINT
is_shippedTINYINT
>]; + printeragent [label=<
printeragent
idINT PK
company_idINT FK
nameNVARCHAR(255)
token?NVARCHAR(255)
pairingpin?NVARCHAR(50)
paired_at?DATETIME2
agentversion?NVARCHAR(255)
lastseen_at?DATETIME2
updated_atDATETIME2
sortINT
enabledBIT
>]; carrier [label=<
carrier
idINT PK
company_idINT FK
nameNVARCHAR(255)
sortINT
has_packagesBIT
is_reverseBIT
>]; + agentprinter [label=<
agentprinter
idINT PK
printeragent_idINT FK
typeNVARCHAR(50)
model?NVARCHAR(255)
serial?NVARCHAR(255)
enabledBIT
sortINT
>]; + printerjob [label=<
printerjob
idINT PK
agentprinter_idINT FK
typeNVARCHAR(50)
stateNVARCHAR(50)
dataNVARCHAR(MAX)
created_atDATETIME2
completed_at?DATETIME2
>]; + returnsummary [label=<
returnsummary
idINT PK
supplier_id?INT FK
company_idINT FK
manifest_idINT FK
return_numberNVARCHAR(100)
external_number?NVARCHAR(100)
return_dateDATETIME2
created_atDATETIME2
supplier_name?NVARCHAR(255)
positionsINT
gross_totalFLOAT
net_totalFLOAT
total_quantityFLOAT
stock_positionsINT
booked_positionsINT
unbooked_positionsINT
stock_quantityFLOAT
booked_quantityFLOAT
unbooked_quantityFLOAT
is_bookedTINYINT
>]; + retouresummary [label=<
retouresummary
idINT PK
entity_id?INT FK
company_idINT FK
manifest_idINT FK
retoure_numberNVARCHAR(100)
retoure_dateDATETIME2
created_atDATETIME2
customer_name?NVARCHAR(255)
positionsINT
gross_totalFLOAT
paid_totalFLOAT
ordered_quantityFLOAT
shipped_quantityFLOAT
is_paidTINYINT
is_shippedTINYINT
>]; + mcp_token [label=<
mcp_token
idINT PK
google_auth_idINT FK
refresh_tokenNVARCHAR(128)
client_idNVARCHAR(100)
scope?NVARCHAR(255)
expires_atDATETIME2
revokedTINYINT
created_atDATETIME2
>]; + mcp_auth_code [label=<
mcp_auth_code
codeNVARCHAR(128) PK
google_auth_idINT FK
client_idNVARCHAR(100)
redirect_uriNVARCHAR(2048)
code_challengeNVARCHAR(255)
code_challenge_methodNVARCHAR(32)
scope?NVARCHAR(255)
expires_atDATETIME2
usedTINYINT
created_atDATETIME2
>]; mcp_client [label=<
mcp_client
idINT PK
client_idNVARCHAR(100)
client_secret?NVARCHAR(255)
client_nameNVARCHAR(255)
redirect_urisNVARCHAR(MAX)
grant_types?NVARCHAR(255)
response_types?NVARCHAR(255)
scope?NVARCHAR(255)
created_atDATETIME2
>]; healthcheck [label=<
healthcheck
idINT PK
sequenceBIGINT
updated_atDATETIME2
>]; - mcp_auth_code [label=<
mcp_auth_code
codeNVARCHAR(128) PK
google_auth_idINT FK
client_idNVARCHAR(100)
redirect_uriNVARCHAR(2048)
code_challengeNVARCHAR(255)
code_challenge_methodNVARCHAR(32)
scope?NVARCHAR(255)
expires_atDATETIME2
usedTINYINT
created_atDATETIME2
>]; - mcp_token [label=<
mcp_token
idINT PK
google_auth_idINT FK
refresh_tokenNVARCHAR(128)
client_idNVARCHAR(100)
scope?NVARCHAR(255)
expires_atDATETIME2
revokedTINYINT
created_atDATETIME2
>]; __legend [label=<
Legend
PKprimary key
FKforeign key
?nullable column
—FOREIGN KEY constraint
- -naming convention only, no constraint
>]; - google_auth:selected_company:w -> company:in19:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="FK_google_auth_selected_company", weight=3]; + google_auth:selected_company:nw -> company:in10:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="FK_google_auth_selected_company", weight=3]; category:parent_id:e -> category:id:e [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="FK_category_parent", constraint=false]; - company:root_category_id:w -> category:in6:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="FK_company_root_category", weight=3]; - company:default_language_id:w -> language:in3:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="FK_company_default_language", constraint=false, weight=1]; - company:taxrate:w -> tax:in4:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_company_taxrate", constraint=false, weight=1]; - company:reducedtaxrate:w -> tax:in2:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="FK_company_reducedtaxrate", constraint=false, weight=1]; - company:taxfreerate:w -> tax:in3:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="FK_company_taxfreerate", constraint=false, weight=1]; - customergroup:company_id:w -> company:in18:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="FK_customergroup_company", weight=3]; - company:poscustomergroup_id:e -> customergroup:in1:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="FK_company_poscustomergroup", constraint=false, weight=1]; - entity:customergroup_id:se -> customergroup:in2:n [color="#58d18a", fontcolor="#58d18a", tooltip="FK_entity_customergroup", weight=3]; - entityaddress:entity_id:ne -> entity:in9:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="FK_entityaddress_entity", weight=3]; - entitycontact:entity_id:sw -> entity:in8:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="FK_entitycontact_entity", weight=3]; - entityhistory:entity_id:sw -> entity:in10:n [color="#a98cff", fontcolor="#a98cff", tooltip="FK_entityhistory_entity", weight=3]; + company:root_category_id:e -> category:in0:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="FK_company_root_category", weight=3]; + company:default_language_id:w -> language:in0:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="FK_company_default_language", constraint=false, weight=1]; + company:taxrate:w -> tax:in2:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_company_taxrate", constraint=false, weight=1]; + company:reducedtaxrate:w -> tax:in0:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="FK_company_reducedtaxrate", constraint=false, weight=1]; + company:taxfreerate:w -> tax:in1:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="FK_company_taxfreerate", constraint=false, weight=1]; + customergroup:company_id:w -> company:in7:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="FK_customergroup_company", weight=3]; + company:poscustomergroup_id:w -> customergroup:in1:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="FK_company_poscustomergroup", constraint=false, weight=1]; + entity:customergroup_id:e -> customergroup:in0:n [color="#58d18a", fontcolor="#58d18a", tooltip="FK_entity_customergroup", weight=3]; + entityaddress:entity_id:sw -> entity:in2:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="FK_entityaddress_entity", weight=3]; + entitycontact:entity_id:se -> entity:in3:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="FK_entitycontact_entity", weight=3]; + entityhistory:entity_id:se -> entity:in5:n [color="#a98cff", fontcolor="#a98cff", tooltip="FK_entityhistory_entity", weight=3]; entityhistory:companyuser_id:sw -> companyuser:in2:n [color="#ff6f6f", fontcolor="#ff6f6f", tooltip="FK_entityhistory_companyuser", constraint=false, weight=1]; - unit:company_id:e -> company:in0:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="FK_unit_company", weight=3]; + unit:company_id:w -> company:in24:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="FK_unit_company", weight=3]; unittext:unit_id:sw -> unit:in0:n [color="#5aa7e6", fontcolor="#5aa7e6", tooltip="FK_unittext_unit", weight=3]; - unittext:language_id:w -> language:in0:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="FK_unittext_language", constraint=false, weight=1]; - brand:company_id:w -> company:in1:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="FK_brand_company", weight=3]; - article:company_id:w -> company:in3:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="FK_article_company", weight=3]; - article:unit_id:e -> unit:in1:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="FK_article_unit", constraint=false, weight=1]; - article:brand_id:w -> brand:in1:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="FK_article_brand", constraint=false, weight=1]; - article:articlelist_id:w -> articlelist:in1:n [color="#56d68a", fontcolor="#56d68a", tooltip="FK_article_articlelist", constraint=false, weight=1]; - article:optionslist_id:w -> optionslist:in1:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="FK_article_optionslist", constraint=false, weight=1]; + unittext:language_id:w -> language:in1:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="FK_unittext_language", constraint=false, weight=1]; + brand:company_id:e -> company:in25:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="FK_brand_company", weight=3]; + article:company_id:w -> company:in23:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="FK_article_company", weight=3]; + article:unit_id:w -> unit:in1:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="FK_article_unit", constraint=false, weight=1]; + article:brand_id:e -> brand:in0:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="FK_article_brand", constraint=false, weight=1]; + article:articlelist_id:e -> articlelist:in0:n [color="#56d68a", fontcolor="#56d68a", tooltip="FK_article_articlelist", constraint=false, weight=1]; + article:optionslist_id:e -> optionslist:in0:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="FK_article_optionslist", constraint=false, weight=1]; article:parentarticle_id:e -> article:id:e [color="#c79bff", fontcolor="#c79bff", tooltip="FK_article_parentarticle", constraint=false]; - article:parentattributename_id:se -> attributename:in1:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="FK_article_parentattributename", constraint=false, weight=1]; - article:parentattributename_id:se -> attributename:in1:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="FK_article_parentattributename", constraint=false, weight=1]; - articlehistory:article_id:se -> article:in4:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="FK_articlehistory_article", weight=3]; - articlehistory:companyuser_id:e -> companyuser:in0:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="FK_articlehistory_companyuser", constraint=false, weight=1]; - companyhistory:company_id:ne -> company:in15:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="FK_companyhistory_company", weight=3]; - companyhistory:companyuser_id:w -> companyuser:in1:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_companyhistory_companyuser", constraint=false, weight=1]; - posterminalobituary:posterminal_id:ne -> posterminal:in0:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="FK_posterminalobituary_posterminal", weight=3]; + article:parentattributename_id:sw -> attributename:in2:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="FK_article_parentattributename", constraint=false, weight=1]; + article:parentattributename_id:sw -> attributename:in2:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="FK_article_parentattributename", constraint=false, weight=1]; + articlehistory:article_id:se -> article:in5:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="FK_articlehistory_article", weight=3]; + articlehistory:companyuser_id:e -> companyuser:in3:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="FK_articlehistory_companyuser", constraint=false, weight=1]; + companyhistory:company_id:e -> company:in8:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="FK_companyhistory_company", weight=3]; + companyhistory:companyuser_id:e -> companyuser:in1:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_companyhistory_companyuser", constraint=false, weight=1]; + posterminalobituary:posterminal_id:sw -> posterminal:in0:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="FK_posterminalobituary_posterminal", weight=3]; posterminalsyncqueue:posterminal_id:sw -> posterminal:in1:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="FK_posterminalsyncqueue_posterminal", weight=3]; - articleean:article_id:se -> article:in6:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="FK_articleean_article", weight=3]; - articlepic:article_id:se -> article:in5:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="FK_articlepic_article", weight=3]; - articlepic:pic_id:w -> pic:in0:n [color="#58d18a", fontcolor="#58d18a", tooltip="FK_articlepic_pic", constraint=false, weight=1]; - brandpic:brand_id:e -> brand:in0:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="FK_brandpic_brand", weight=3]; - brandpic:pic_id:w -> pic:in1:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="FK_brandpic_pic", constraint=false, weight=1]; - attributevaluepic:attributevalue_id:w -> attributevalue:in0:n [color="#a98cff", fontcolor="#a98cff", tooltip="FK_attributevaluepic_attributevalue", weight=3]; - attributevaluepic:pic_id:w -> pic:in3:n [color="#ff6f6f", fontcolor="#ff6f6f", tooltip="FK_attributevaluepic_pic", constraint=false, weight=1]; - articlelistarticle:articlelist_id:sw -> articlelist:in0:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="FK_articlelistarticle_articlelist", weight=3]; - articlelistarticle:article_id:e -> article:in1:n [color="#5aa7e6", fontcolor="#5aa7e6", tooltip="FK_articlelistarticle_article", constraint=false, weight=1]; - optionslistarticle:optionslist_id:sw -> optionslist:in0:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="FK_optionslistarticle_optionslist", weight=3]; - optionslistarticle:article_id:e -> article:in3:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="FK_optionslistarticle_article", constraint=false, weight=1]; - categoryarticle:category_id:nw -> category:in1:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="FK_categoryarticle_category", weight=3]; - categoryarticle:article_id:sw -> article:in10:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="FK_categoryarticle_article", constraint=false, weight=1]; - categorypic:category_id:sw -> category:in3:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="FK_categorypic_category", weight=3]; - categorypic:pic_id:w -> pic:in2:n [color="#56d68a", fontcolor="#56d68a", tooltip="FK_categorypic_pic", constraint=false, weight=1]; - categorytext:category_id:e -> category:in0:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="FK_categorytext_category", weight=3]; + articleean:article_id:sw -> article:in8:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="FK_articleean_article", weight=3]; + articlepic:article_id:sw -> article:in7:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="FK_articlepic_article", weight=3]; + articlepic:pic_id:w -> pic:in2:n [color="#58d18a", fontcolor="#58d18a", tooltip="FK_articlepic_pic", constraint=false, weight=1]; + brandpic:brand_id:e -> brand:in1:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="FK_brandpic_brand", weight=3]; + brandpic:pic_id:w -> pic:in3:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="FK_brandpic_pic", constraint=false, weight=1]; + attributevaluepic:attributevalue_id:e -> attributevalue:in2:n [color="#a98cff", fontcolor="#a98cff", tooltip="FK_attributevaluepic_attributevalue", weight=3]; + attributevaluepic:pic_id:w -> pic:in0:n [color="#ff6f6f", fontcolor="#ff6f6f", tooltip="FK_attributevaluepic_pic", constraint=false, weight=1]; + articlelistarticle:articlelist_id:se -> articlelist:in1:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="FK_articlelistarticle_articlelist", weight=3]; + articlelistarticle:article_id:sw -> article:in9:n [color="#5aa7e6", fontcolor="#5aa7e6", tooltip="FK_articlelistarticle_article", constraint=false, weight=1]; + optionslistarticle:optionslist_id:se -> optionslist:in1:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="FK_optionslistarticle_optionslist", weight=3]; + optionslistarticle:article_id:e -> article:in11:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="FK_optionslistarticle_article", constraint=false, weight=1]; + categoryarticle:category_id:se -> category:in8:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="FK_categoryarticle_category", weight=3]; + categoryarticle:article_id:se -> article:in4:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="FK_categoryarticle_article", constraint=false, weight=1]; + categorypic:category_id:sw -> category:in6:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="FK_categorypic_category", weight=3]; + categorypic:pic_id:w -> pic:in1:n [color="#56d68a", fontcolor="#56d68a", tooltip="FK_categorypic_pic", constraint=false, weight=1]; + categorytext:category_id:e -> category:in5:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="FK_categorytext_category", weight=3]; categorytext:language_id:e -> language:in2:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_categorytext_language", constraint=false, weight=1]; - articletext:article_id:e -> article:in0:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="FK_articletext_article", weight=3]; - articletext:language_id:w -> language:in1:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="FK_articletext_language", constraint=false, weight=1]; - price:tax_id:w -> tax:in0:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="FK_price_tax", constraint=false, weight=1]; - price:article_id:sw -> article:in2:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="FK_price_article", weight=3]; - price:customergroup_id:e -> customergroup:in0:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_price_customergroup", constraint=false, weight=1]; - articlesupplier:article_id:sw -> article:in8:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="FK_articlesupplier_article", weight=3]; - articlesupplier:entity_id:e -> entity:in0:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="FK_articlesupplier_entity", constraint=false, weight=1]; - articlesupplier:tax_id:w -> tax:in1:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="FK_articlesupplier_tax", constraint=false, weight=1]; - manifest:company_id:e -> company:in4:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="FK_manifest_company", weight=3]; - manifest:entity_id:e -> entity:in1:n [color="#58d18a", fontcolor="#58d18a", tooltip="FK_manifest_entity", constraint=false, weight=1]; - manifestarticle:manifest_id:se -> manifest:in2:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="FK_manifestarticle_manifest", weight=3]; - warehouse:company_id:se -> company:in7:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="FK_warehouse_company", weight=3]; + articletext:article_id:e -> article:in3:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="FK_articletext_article", weight=3]; + articletext:language_id:w -> language:in3:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="FK_articletext_language", constraint=false, weight=1]; + price:tax_id:e -> tax:in3:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="FK_price_tax", constraint=false, weight=1]; + price:article_id:se -> article:in12:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="FK_price_article", weight=3]; + price:customergroup_id:w -> customergroup:in2:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_price_customergroup", constraint=false, weight=1]; + articlesupplier:article_id:sw -> article:in10:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="FK_articlesupplier_article", weight=3]; + articlesupplier:entity_id:e -> entity:in10:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="FK_articlesupplier_entity", constraint=false, weight=1]; + articlesupplier:tax_id:ne -> tax:in4:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="FK_articlesupplier_tax", constraint=false, weight=1]; + manifest:company_id:w -> company:in17:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="FK_manifest_company", weight=3]; + manifest:entity_id:w -> entity:in9:n [color="#58d18a", fontcolor="#58d18a", tooltip="FK_manifest_entity", constraint=false, weight=1]; + manifestarticle:manifest_id:ne -> manifest:in6:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="FK_manifestarticle_manifest", weight=3]; + warehouse:company_id:w -> company:in19:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="FK_warehouse_company", weight=3]; ledger:manifestarticle_id:w -> manifestarticle:in0:n [color="#a98cff", fontcolor="#a98cff", tooltip="FK_ledger_manifestarticle", constraint=false, weight=1]; - ledger:warehouse_id:sw -> warehouse:in1:n [color="#ff6f6f", fontcolor="#ff6f6f", tooltip="FK_ledger_warehouse", weight=3]; - numbers:company_id:sw -> company:in16:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="FK_numbers_company", weight=3]; - companyuser:google_auth_id:e -> google_auth:in1:n [color="#5aa7e6", fontcolor="#5aa7e6", tooltip="FK_companyuser_google_auth", constraint=false, weight=1]; - companyuser:company_id:w -> company:in20:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="FK_companyuser_company", weight=3]; - modelconversation:companyuser_id:w -> companyuser:in3:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="FK_modelconversation_companyuser", weight=3]; - modelvfs:company_id:w -> company:in22:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="FK_modelvfs_company", weight=3]; - modelmessage:conversation_id:sw -> modelconversation:in0:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="FK_modelmessage_conversation", weight=3]; - companysetting:company_id:sw -> company:in17:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="FK_companysetting_company", weight=3]; - posterminal:company_id:w -> company:in23:n [color="#56d68a", fontcolor="#56d68a", tooltip="FK_posterminal_company", weight=3]; - posterminal:root_category_id:w -> category:in7:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="FK_posterminal_root_category", constraint=false, weight=1]; + ledger:warehouse_id:sw -> warehouse:in0:n [color="#ff6f6f", fontcolor="#ff6f6f", tooltip="FK_ledger_warehouse", weight=3]; + numbers:company_id:se -> company:in6:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="FK_numbers_company", weight=3]; + companyuser:google_auth_id:e -> google_auth:in0:n [color="#5aa7e6", fontcolor="#5aa7e6", tooltip="FK_companyuser_google_auth", constraint=false, weight=1]; + companyuser:company_id:ne -> company:in9:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="FK_companyuser_company", weight=3]; + modelconversation:companyuser_id:e -> companyuser:in0:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="FK_modelconversation_companyuser", weight=3]; + modelvfs:company_id:e -> company:in3:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="FK_modelvfs_company", weight=3]; + modelmessage:conversation_id:se -> modelconversation:in0:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="FK_modelmessage_conversation", weight=3]; + companysetting:company_id:nw -> company:in11:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="FK_companysetting_company", weight=3]; + posterminal:company_id:w -> company:in18:n [color="#56d68a", fontcolor="#56d68a", tooltip="FK_posterminal_company", weight=3]; + posterminal:root_category_id:e -> category:in1:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="FK_posterminal_root_category", constraint=false, weight=1]; posterminalcategory:posterminal_id:sw -> posterminal:in2:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_posterminalcategory_posterminal", weight=3]; - posterminalcategory:category_id:e -> category:in8:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="FK_posterminalcategory_category", constraint=false, weight=1]; - printer:company_id:w -> company:in24:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="FK_printer_company", weight=3]; - labeltemplate:company_id:w -> company:in25:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="FK_labeltemplate_company", weight=3]; - manifestattachment:manifest_id:nw -> manifest:in3:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="FK_manifestattachment_manifest", weight=3]; - manifestattachment:file_id:e -> file:in0:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_manifestattachment_file", constraint=false, weight=1]; - propertyname:company_id:e -> company:in5:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="FK_propertyname_company", weight=3]; - articleproperty:article_id:w -> article:in9:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="FK_articleproperty_article", weight=3]; - articleproperty:property_id:e -> propertyname:in1:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="FK_articleproperty_property", constraint=false, weight=1]; - attributename:company_id:w -> company:in6:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="FK_attributename_company", weight=3]; - attributevalue:attribute_id:se -> attributename:in2:n [color="#58d18a", fontcolor="#58d18a", tooltip="FK_attributevalue_attribute", weight=3]; - articleattributevalue:article_id:e -> article:in12:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="FK_articleattributevalue_article", weight=3]; - articleattributevalue:attributevalue_id:w -> attributevalue:in1:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="FK_articleattributevalue_attributevalue", constraint=false, weight=1]; - categoryproperty:category_id:w -> category:in5:n [color="#a98cff", fontcolor="#a98cff", tooltip="FK_categoryproperty_category", weight=3]; - categoryproperty:property_id:e -> propertyname:in0:n [color="#ff6f6f", fontcolor="#ff6f6f", tooltip="FK_categoryproperty_property", constraint=false, weight=1]; - categoryattributevalue:category_id:w -> category:in4:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="FK_categoryattributevalue_category", weight=3]; - categoryattributevalue:attributevalue_id:e -> attributevalue:in2:n [color="#5aa7e6", fontcolor="#5aa7e6", tooltip="FK_categoryattributevalue_attributevalue", constraint=false, weight=1]; - account:company_id:w -> company:in2:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="FK_account_company", weight=3]; - payment:account_id:w -> account:in0:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="FK_payment_account", constraint=false, weight=1]; - payment:manifest_id:e -> manifest:in0:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="FK_payment_manifest", weight=3]; - carrier:company_id:e -> company:in11:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="FK_carrier_company", weight=3]; - shipping:manifest_id:nw -> manifest:in1:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="FK_shipping_manifest", weight=3]; - shipping:carrier_id:se -> carrier:in0:n [color="#56d68a", fontcolor="#56d68a", tooltip="FK_shipping_carrier", constraint=false, weight=1]; - package:shipping_id:w -> shipping:in0:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="FK_package_shipping", constraint=false, weight=1]; - package:entityaddress_id:e -> entityaddress:in0:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_package_entityaddress", weight=3]; - ordersummary:company_id:sw -> company:in13:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="FK_ordersummary_company", weight=3]; - ordersummary:manifest_id:e -> manifest:in8:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="FK_ordersummary_manifest", constraint=false, weight=1]; - ordersummary:entity_id:w -> entity:in5:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="FK_ordersummary_entity", constraint=false, weight=1]; - quotesummary:company_id:se -> company:in12:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="FK_quotesummary_company", weight=3]; - quotesummary:manifest_id:e -> manifest:in6:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_quotesummary_manifest", constraint=false, weight=1]; - quotesummary:entity_id:e -> entity:in6:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="FK_quotesummary_entity", constraint=false, weight=1]; - shipmentsummary:company_id:ne -> company:in14:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="FK_shipmentsummary_company", weight=3]; - shipmentsummary:manifest_id:w -> manifest:in9:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="FK_shipmentsummary_manifest", constraint=false, weight=1]; - shipmentsummary:entity_id:e -> entity:in7:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="FK_shipmentsummary_entity", constraint=false, weight=1]; - deliverysummary:company_id:nw -> company:in8:n [color="#58d18a", fontcolor="#58d18a", tooltip="FK_deliverysummary_company", weight=3]; - deliverysummary:manifest_id:e -> manifest:in4:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="FK_deliverysummary_manifest", constraint=false, weight=1]; - deliverysummary:supplier_id:w -> entity:in2:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="FK_deliverysummary_supplier", constraint=false, weight=1]; - returnsummary:company_id:e -> company:in10:n [color="#a98cff", fontcolor="#a98cff", tooltip="FK_returnsummary_company", weight=3]; - returnsummary:manifest_id:w -> manifest:in5:n [color="#ff6f6f", fontcolor="#ff6f6f", tooltip="FK_returnsummary_manifest", constraint=false, weight=1]; - returnsummary:supplier_id:w -> entity:in3:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="FK_returnsummary_supplier", constraint=false, weight=1]; - retouresummary:company_id:w -> company:in9:n [color="#5aa7e6", fontcolor="#5aa7e6", tooltip="FK_retouresummary_company", weight=3]; - retouresummary:manifest_id:e -> manifest:in7:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="FK_retouresummary_manifest", constraint=false, weight=1]; - retouresummary:entity_id:e -> entity:in4:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="FK_retouresummary_entity", constraint=false, weight=1]; - articlewarehousesummary:article_id:nw -> article:in11:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="FK_articlewarehousesummary_article", weight=3]; - articlewarehousesummary:warehouse_id:e -> warehouse:in0:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="FK_articlewarehousesummary_warehouse", constraint=false, weight=1]; - mcp_auth_code:google_auth_id:sw -> google_auth:in0:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="FK_mcp_auth_code_google_auth", weight=3]; - mcp_token:google_auth_id:w -> google_auth:in2:n [color="#56d68a", fontcolor="#56d68a", tooltip="FK_mcp_token_google_auth", weight=3]; - subagentschedule:company_id:w -> company:in21:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="FK_subagentschedule_company", weight=3]; + posterminalcategory:category_id:e -> category:in2:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="FK_posterminalcategory_category", constraint=false, weight=1]; + printer:company_id:w -> company:in20:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="FK_printer_company", weight=3]; + printeragent:company_id:e -> company:in0:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="FK_printeragent_company", weight=3]; + agentprinter:printeragent_id:sw -> printeragent:in0:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="FK_agentprinter_printeragent", weight=3]; + printerjob:agentprinter_id:sw -> agentprinter:in0:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_printerjob_agentprinter", weight=3]; + labeltemplate:company_id:w -> company:in16:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="FK_labeltemplate_company", weight=3]; + manifestattachment:manifest_id:sw -> manifest:in7:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="FK_manifestattachment_manifest", weight=3]; + manifestattachment:file_id:e -> file:in0:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="FK_manifestattachment_file", constraint=false, weight=1]; + propertyname:company_id:w -> company:in22:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="FK_propertyname_company", weight=3]; + articleproperty:article_id:w -> article:in2:n [color="#58d18a", fontcolor="#58d18a", tooltip="FK_articleproperty_article", weight=3]; + articleproperty:property_id:w -> propertyname:in0:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="FK_articleproperty_property", constraint=false, weight=1]; + attributename:company_id:w -> company:in21:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="FK_attributename_company", weight=3]; + attributevalue:attribute_id:sw -> attributename:in0:n [color="#a98cff", fontcolor="#a98cff", tooltip="FK_attributevalue_attribute", weight=3]; + articleattributevalue:article_id:w -> article:in1:n [color="#ff6f6f", fontcolor="#ff6f6f", tooltip="FK_articleattributevalue_article", weight=3]; + articleattributevalue:attributevalue_id:w -> attributevalue:in1:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="FK_articleattributevalue_attributevalue", constraint=false, weight=1]; + categoryproperty:category_id:e -> category:in3:n [color="#5aa7e6", fontcolor="#5aa7e6", tooltip="FK_categoryproperty_category", weight=3]; + categoryproperty:property_id:w -> propertyname:in1:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="FK_categoryproperty_property", constraint=false, weight=1]; + categoryattributevalue:category_id:e -> category:in4:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="FK_categoryattributevalue_category", weight=3]; + categoryattributevalue:attributevalue_id:w -> attributevalue:in0:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="FK_categoryattributevalue_attributevalue", constraint=false, weight=1]; + account:company_id:w -> company:in26:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="FK_account_company", weight=3]; + payment:account_id:e -> account:in0:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="FK_payment_account", constraint=false, weight=1]; + payment:manifest_id:w -> manifest:in9:n [color="#56d68a", fontcolor="#56d68a", tooltip="FK_payment_manifest", weight=3]; + carrier:company_id:e -> company:in1:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="FK_carrier_company", weight=3]; + shipping:manifest_id:sw -> manifest:in8:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_shipping_manifest", weight=3]; + shipping:carrier_id:w -> carrier:in0:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="FK_shipping_carrier", constraint=false, weight=1]; + package:shipping_id:e -> shipping:in0:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="FK_package_shipping", constraint=false, weight=1]; + package:entityaddress_id:w -> entityaddress:in0:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="FK_package_entityaddress", weight=3]; + ordersummary:company_id:nw -> company:in13:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="FK_ordersummary_company", weight=3]; + ordersummary:manifest_id:e -> manifest:in3:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_ordersummary_manifest", constraint=false, weight=1]; + ordersummary:entity_id:nw -> entity:in6:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="FK_ordersummary_entity", constraint=false, weight=1]; + quotesummary:company_id:nw -> company:in14:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="FK_quotesummary_company", weight=3]; + quotesummary:manifest_id:e -> manifest:in4:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="FK_quotesummary_manifest", constraint=false, weight=1]; + quotesummary:entity_id:e -> entity:in7:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="FK_quotesummary_entity", constraint=false, weight=1]; + shipmentsummary:company_id:se -> company:in15:n [color="#58d18a", fontcolor="#58d18a", tooltip="FK_shipmentsummary_company", weight=3]; + shipmentsummary:manifest_id:e -> manifest:in5:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="FK_shipmentsummary_manifest", constraint=false, weight=1]; + shipmentsummary:entity_id:e -> entity:in8:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="FK_shipmentsummary_entity", constraint=false, weight=1]; + deliverysummary:company_id:sw -> company:in12:n [color="#a98cff", fontcolor="#a98cff", tooltip="FK_deliverysummary_company", weight=3]; + deliverysummary:manifest_id:e -> manifest:in2:n [color="#ff6f6f", fontcolor="#ff6f6f", tooltip="FK_deliverysummary_manifest", constraint=false, weight=1]; + deliverysummary:supplier_id:sw -> entity:in4:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="FK_deliverysummary_supplier", constraint=false, weight=1]; + returnsummary:company_id:e -> company:in2:n [color="#5aa7e6", fontcolor="#5aa7e6", tooltip="FK_returnsummary_company", weight=3]; + returnsummary:manifest_id:w -> manifest:in0:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="FK_returnsummary_manifest", constraint=false, weight=1]; + returnsummary:supplier_id:e -> entity:in0:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="FK_returnsummary_supplier", constraint=false, weight=1]; + retouresummary:company_id:w -> company:in4:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="FK_retouresummary_company", weight=3]; + retouresummary:manifest_id:e -> manifest:in1:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="FK_retouresummary_manifest", constraint=false, weight=1]; + retouresummary:entity_id:w -> entity:in1:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="FK_retouresummary_entity", constraint=false, weight=1]; + articlewarehousesummary:article_id:e -> article:in0:n [color="#56d68a", fontcolor="#56d68a", tooltip="FK_articlewarehousesummary_article", weight=3]; + articlewarehousesummary:warehouse_id:sw -> warehouse:in1:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="FK_articlewarehousesummary_warehouse", constraint=false, weight=1]; + mcp_auth_code:google_auth_id:sw -> google_auth:in2:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_mcp_auth_code_google_auth", weight=3]; + mcp_token:google_auth_id:sw -> google_auth:in1:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="FK_mcp_token_google_auth", weight=3]; + subagentschedule:company_id:se -> company:in5:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="FK_subagentschedule_company", weight=3]; } \ No newline at end of file diff --git a/schema-graph.svg b/schema-graph.svg index 8c8a8ee..a239d84 100644 --- a/schema-graph.svg +++ b/schema-graph.svg @@ -4,355 +4,601 @@ - - + + schema - + cluster_identity - -Identity & Company + +Identity & Company cluster_i18n - -Localization + +Localization cluster_catalog - -Catalog + +Catalog cluster_media - -Media + +Media cluster_manifests - -Documents & Shipping + +Documents & Shipping cluster_commerce - -Pricing & Payments + +Pricing & Payments cluster_units - -Units + +Units cluster_pos - -POS + +POS cluster_ai - -AI & Models + +AI & Models cluster_attributes - -Properties & Attributes + +Properties & Attributes - + -companyhistory - - -companyhistory - - -id -INT -PK - - -company_id -INT -FK - - -companyuser_id -? -INT -FK - - -date -DATETIME2 - - -text -NVARCHAR(MAX) +customergroup + + + + + +customergroup + + +id +INT +PK + + +company_id +? +INT +FK + + +name +NVARCHAR(255) + + +sort +INT + + +updated_at +DATETIME2 - + company - - - - - - - - - - - - - - - - - - - - - - - - - - - - -company - - -id -INT -PK - - -default_language_id -? -INT -FK - - -taxrate -? -INT -FK - - -reducedtaxrate -? -INT -FK - - -taxfreerate -? -INT -FK - - -root_category_id -? -INT -FK - - -poscustomergroup_id -INT -FK - - -name -NVARCHAR(255) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +company + + +id +INT +PK + + +poscustomergroup_id +INT +FK + + +default_language_id +? +INT +FK + + +root_category_id +? +INT +FK + + +taxrate +? +INT +FK + + +reducedtaxrate +? +INT +FK + + +taxfreerate +? +INT +FK + + +name +NVARCHAR(255) - - -companyhistory:ne->company:n - - - + + +customergroup:w->company:n + + + + + +companyhistory + + +companyhistory + + +id +INT +PK + + +companyuser_id +? +INT +FK + + +company_id +INT +FK + + +date +DATETIME2 + + +text +NVARCHAR(MAX) + - + companyuser - - - - - - -companyuser - - -id -INT -PK - - -company_id -INT -FK - - -google_auth_id -INT -FK + + + + + + +companyuser + + +id +INT +PK + + +company_id +INT +FK + + +google_auth_id +INT +FK -companyhistory:w->companyuser:n +companyhistory:e->companyuser:n - - + + - + + +companyhistory:e->company:n + + + + + + + + +meta + + +meta + + +key +NVARCHAR(255) + + +value +NVARCHAR(MAX) + + + +numbers + + +numbers + + +id +INT +PK + + +company_id +INT +FK + + +type +NVARCHAR(100) + + +prefix +NVARCHAR(50) + + +padding +INT + + +next +INT + + + +numbers:se->company:n + + + + + + + + +entityaddress + + + +entityaddress + + +id +INT +PK + + +entity_id +INT +FK + + +first_name? +NVARCHAR(255) + + +name? +NVARCHAR(255) + + +street? +NVARCHAR(255) + + +streetnumber? +NVARCHAR(50) + + +zip? +NVARCHAR(50) + + +city? +NVARCHAR(255) + + +county? +NVARCHAR(255) + + +addresstype +NVARCHAR(50) + + +sort +INT + + -customergroup - - - - - -customergroup - - -id -INT -PK - - -company_id -? -INT -FK - - -name -NVARCHAR(255) - - -sort -INT - - -updated_at -DATETIME2 +entity + + + + + + + + + + + + + +entity + + +id +INT +PK + + +customergroup_id +INT +FK + + +first_name? +NVARCHAR(255) + + +name +NVARCHAR(255) + + +number? +NVARCHAR(100) + + +created_at +DATETIME2 + + +updated_at +DATETIME2 + + + +entityaddress:sw->entity:n + + + + + + + + +companyuser:ne->company:n + + + + + + + + +google_auth + + + + + +google_auth + + +id +INT +PK + + +selected_company +? +INT +FK + + +email +NVARCHAR(320) + + +picture? +NVARCHAR(2048) + + +picturebinary? +VARBINARY(MAX) + + +sub? +NVARCHAR(255) + + +created_at +DATETIME2 + + + +companyuser:e->google_auth:n + + + + + + + + +entity:e->customergroup:n + + + + + -company:e->customergroup:n +company:w->customergroup:n - - + + language - - - - - - -language - - -id -INT -PK - - -iso639 -NVARCHAR(10) + + + + + + +language + + +id +INT +PK + + +iso639 +NVARCHAR(10) company:w->language:n - - + + - + category - - - - - - - - - - - -category - - -id -INT -PK - - -parent_id -? -INT -FK - - -sort -INT - - -updated_at -DATETIME2 + + + + + + + + + + + +category + + +id +INT +PK + + +parent_id +? +INT +FK + + +sort +INT + + +updated_at +DATETIME2 -company:w->category:n +company:e->category:n - - + + tax - - - - - - - -tax - - -id -INT -PK - - -rate -DECIMAL(18,4) + + + + + + + +tax + + +id +INT +PK + + +rate +DECIMAL(18,4) company:w->tax:n - - + + @@ -360,8 +606,8 @@ company:w->tax:n - - + + @@ -369,670 +615,272 @@ company:w->tax:n - - + + - - -numbers - - -numbers - - -id -INT -PK - - -company_id -INT -FK - - -type -NVARCHAR(100) - - -prefix -NVARCHAR(50) - - -padding -INT - - -next -INT - - - -numbers:sw->company:n - - - - - - - - -meta - - -meta - - -key -NVARCHAR(255) - - -value -NVARCHAR(MAX) - - + companysetting - - -companysetting - - -id -INT -PK - - -company_id -INT -FK - - -key -NVARCHAR(255) - - -value -NVARCHAR(MAX) - - -frontend -BIT - - -writeonly -BIT + + +companysetting + + +id +INT +PK + + +company_id +INT +FK + + +key +NVARCHAR(255) + + +value +NVARCHAR(MAX) + + +frontend +BIT + + +writeonly +BIT -companysetting:sw->company:n +companysetting:nw->company:n - - - - - - - -entityaddress - - - -entityaddress - - -id -INT -PK - - -entity_id -INT -FK - - -first_name? -NVARCHAR(255) - - -name? -NVARCHAR(255) - - -street? -NVARCHAR(255) - - -streetnumber? -NVARCHAR(50) - - -zip? -NVARCHAR(50) - - -city? -NVARCHAR(255) - - -county? -NVARCHAR(255) - - -addresstype -NVARCHAR(50) - - -sort -INT - - - -entity - - - - - - - - - - - - - -entity - - -id -INT -PK - - -customergroup_id -INT -FK - - -first_name? -NVARCHAR(255) - - -name -NVARCHAR(255) - - -number? -NVARCHAR(100) - - -created_at -DATETIME2 - - -updated_at -DATETIME2 - - - -entityaddress:ne->entity:n - - - - - - - - -customergroup:w->company:n - - - - - - - - -entity:se->customergroup:n - - - + + - + entitycontact - - -entitycontact - - -id -INT -PK - - -entity_id -INT -FK - - -contacttype -NVARCHAR(50) - - -contact -NVARCHAR(255) - - -sort -INT + + +entitycontact + + +id +INT +PK + + +entity_id +INT +FK + + +contacttype +NVARCHAR(50) + + +contact +NVARCHAR(255) + + +sort +INT -entitycontact:sw->entity:n +entitycontact:se->entity:n - - + + - - -google_auth - - - - - -google_auth - - -id -INT -PK - - -selected_company -? -INT -FK - - -email -NVARCHAR(320) - - -picture? -NVARCHAR(2048) - - -picturebinary? -VARBINARY(MAX) - - -sub? -NVARCHAR(255) - - -created_at -DATETIME2 - -google_auth:w->company:n +google_auth:nw->company:n - - + + - + entityhistory - - -entityhistory - - -id -INT -PK - - -entity_id -INT -FK - - -companyuser_id -? -INT -FK - - -date -DATETIME2 - - -text -NVARCHAR(MAX) - - - -entityhistory:sw->entity:n - - - - - + + +entityhistory + + +id +INT +PK + + +companyuser_id +? +INT +FK + + +entity_id +INT +FK + + +date +DATETIME2 + + +text +NVARCHAR(MAX) entityhistory:sw->companyuser:n - - + + - - -companyuser:w->company:n - - - - - - - - -companyuser:e->google_auth:n - - - + + +entityhistory:se->entity:n + + + unittext - - -unittext - - -id -INT -PK - - -language_id -INT -FK - - -unit_id -INT -FK - - -symbol -NVARCHAR(50) - - -name -NVARCHAR(255) + + +unittext + + +id +INT +PK + + +language_id +INT +FK + + +unit_id +INT +FK + + +symbol +NVARCHAR(50) + + +name +NVARCHAR(255) unittext:w->language:n - - + + unit - - - - -unit - - -id -INT -PK - - -company_id -INT -FK - - -si -NVARCHAR(50) - - -basesi -NVARCHAR(50) - - -basesifactor -FLOAT + + + + +unit + + +id +INT +PK + + +company_id +INT +FK + + +si +NVARCHAR(50) + + +basesi +NVARCHAR(50) + + +basesifactor +FLOAT unittext:sw->unit:n - - - - - - - -articletext - - -articletext - - -id -INT -PK - - -language_id -INT -FK - - -article_id -INT -FK - - -name -NVARCHAR(255) - - -slug -NVARCHAR(255) - - -seo? -NVARCHAR(MAX) - - -desc? -NVARCHAR(MAX) - - - -articletext:w->language:n - - - - - - - - -article - - - - - - - - - - - - - - - -article - - -id -INT -PK - - -unit_id -INT -FK - - -brand_id -? -INT -FK - - -articlelist_id -? -INT -FK - - -optionslist_id -? -INT -FK - - -parentarticle_id -? -INT -FK - - -parentattributename_id -? -INT -FK - - -company_id -INT -FK - - -sku? -NVARCHAR(100) - - -unitamount -FLOAT - - -weight -FLOAT - - -track_stock -BIT - - -is_parent -BIT - - -parentsort -INT - - -created_at -DATETIME2 - - -updated_at -DATETIME2 - - - -articletext:e->article:n - - - + + - + categorytext - - -categorytext - - -id -INT -PK - - -language_id -INT -FK - - -category_id -INT -FK - - -name -NVARCHAR(255) - - -slug -NVARCHAR(255) - - -seo? -NVARCHAR(MAX) - - -desc? -NVARCHAR(MAX) + + +categorytext + + +id +INT +PK + + +language_id +INT +FK + + +category_id +INT +FK + + +name +NVARCHAR(255) + + +slug +NVARCHAR(255) + + +seo? +NVARCHAR(MAX) + + +desc? +NVARCHAR(MAX) categorytext:e->language:n - - + + @@ -1040,434 +888,201 @@ categorytext:e->category:n - - + + - - -brand - - - - -brand - - -id -INT -PK - - -company_id -INT -FK - - -name -NVARCHAR(255) - - -gsprpinfo? -NVARCHAR(MAX) + + +articletext + + +articletext + + +id +INT +PK + + +language_id +INT +FK + + +article_id +INT +FK + + +name +NVARCHAR(255) + + +slug +NVARCHAR(255) + + +seo? +NVARCHAR(MAX) + + +desc? +NVARCHAR(MAX) - - -brand:w->company:n - - - + + +articletext:w->language:n + + + - - -articlelist - - - - -articlelist - - -id -INT -PK - - - -articlelistarticle - - -articlelistarticle - - -id -INT -PK - - -articlelist_id -INT -FK - - -article_id -INT -FK - - -count -INT - - -sort -INT - - - -articlelistarticle:sw->articlelist:n - - - - - - - - -articlelistarticle:e->article:n - - - - - - - + -optionslist - - - - -optionslist - - -id -INT -PK +article + + + + + + + + + + + + + + + +article + + +id +INT +PK + + +company_id +INT +FK + + +parentattributename_id +? +INT +FK + + +unit_id +INT +FK + + +parentarticle_id +? +INT +FK + + +articlelist_id +? +INT +FK + + +optionslist_id +? +INT +FK + + +brand_id +? +INT +FK + + +sku? +NVARCHAR(100) + + +unitamount +FLOAT + + +weight +FLOAT + + +track_stock +BIT + + +is_parent +BIT + + +parentsort +INT + + +created_at +DATETIME2 + + +updated_at +DATETIME2 - - -optionslistarticle - - -optionslistarticle - - -id -INT -PK - - -optionslist_id -INT -FK - - -article_id -INT -FK - - -count -INT - - -sort -INT - - -mandated -BIT - - - -optionslistarticle:sw->optionslist:n - - - + + +articletext:e->article:n + + + - - -optionslistarticle:e->article:n - - - + + +categoryarticle + + +categoryarticle + + +id +INT +PK + + +category_id +INT +FK + + +article_id +INT +FK + + + +categoryarticle:se->category:n + + + - - -articlehistory - - -articlehistory - - -id -INT -PK - - -article_id -INT -FK - - -companyuser_id -? -INT -FK - - -date -DATETIME2 - - -text -NVARCHAR(MAX) - - -meta? -NVARCHAR(MAX) - - - -articlehistory:e->companyuser:n - - - - - - - - -articlehistory:se->article:n - - - - - - - - -articleean - - -articleean - - -id -INT -PK - - -article_id -INT -FK - - -ean -NVARCHAR(50) - - - -articleean:se->article:n - - - - - - - - -article:w->company:n - - - - - - - - -article:w->brand:n - - - - - - - - -article:w->articlelist:n - - - - - - - - -article:w->optionslist:n - - - - - - - - -article:e->article:e - - - - - - - - -article:e->unit:n - - - - - - - - -attributename - - - - - -attributename - - -id -INT -PK - - -company_id -INT -FK - - -name -NVARCHAR(255) - - -sort -INT - - -exclusive -BIT - - - -article:se->attributename:n - - - - - - - - -article:se->attributename:n - - - - - - - - -articlesupplier - - -articlesupplier - - -id -INT -PK - - -tax_id -? -INT -FK - - -article_id -INT -FK - - -entity_id -INT -FK - - -net? -FLOAT - - -sku? -NVARCHAR(100) - - -stock? -FLOAT - - -pu -FLOAT - - -sort -INT - - - -articlesupplier:e->entity:n - - - - - - - - -articlesupplier:sw->article:n - - - - - - - - -articlesupplier:w->tax:n - - - + + +categoryarticle:se->article:n + + + @@ -1475,231 +1090,601 @@ category:e->category:e - - + + - + + +articlehistory + + +articlehistory + + +id +INT +PK + + +companyuser_id +? +INT +FK + + +article_id +INT +FK + + +date +DATETIME2 + + +text +NVARCHAR(MAX) + + +meta? +NVARCHAR(MAX) + + + +articlehistory:e->companyuser:n + + + + + + + + +articlehistory:se->article:n + + + + + + + + +article:w->company:n + + + + + + + + +article:e->article:e + + + + + + + + +articlelist + + + + +articlelist + + +id +INT +PK + + + +article:e->articlelist:n + + + + + + + + +optionslist + + + + +optionslist + + +id +INT +PK + + + +article:e->optionslist:n + + + + + + + -categoryarticle - - -categoryarticle - - -id -INT -PK - - -article_id -INT -FK - - -category_id -INT -FK +brand + + + + +brand + + +id +INT +PK + + +company_id +INT +FK + + +name +NVARCHAR(255) + + +gsprpinfo? +NVARCHAR(MAX) - - -categoryarticle:sw->article:n - - - + + +article:e->brand:n + + + - - -categoryarticle:nw->category:n - - - + + +article:w->unit:n + + + + + + + + +attributename + + + + + +attributename + + +id +INT +PK + + +company_id +INT +FK + + +name +NVARCHAR(255) + + +sort +INT + + +exclusive +BIT + + + +article:sw->attributename:n + + + + + + + + +article:sw->attributename:n + + + + + + + + +articleean + + +articleean + + +id +INT +PK + + +article_id +INT +FK + + +ean +NVARCHAR(50) + + + +articleean:sw->article:n + + + + + + + + +articlesupplier + + +articlesupplier + + +id +INT +PK + + +entity_id +INT +FK + + +article_id +INT +FK + + +tax_id +? +INT +FK + + +net? +FLOAT + + +sku? +NVARCHAR(100) + + +stock? +FLOAT + + +pu +FLOAT + + +sort +INT + + + +articlesupplier:e->entity:n + + + + + + + + +articlesupplier:sw->article:n + + + + + + + + +articlesupplier:ne->tax:n + + + + + + + + +articlelistarticle + + +articlelistarticle + + +id +INT +PK + + +article_id +INT +FK + + +articlelist_id +INT +FK + + +count +INT + + +sort +INT + + + +articlelistarticle:sw->article:n + + + + + + + + +articlelistarticle:se->articlelist:n + + + + + + + + +optionslistarticle + + +optionslistarticle + + +id +INT +PK + + +article_id +INT +FK + + +optionslist_id +INT +FK + + +count +INT + + +sort +INT + + +mandated +BIT + + + +optionslistarticle:e->article:n + + + + + + + + +optionslistarticle:se->optionslist:n + + + + + + + + +brand:e->company:n + + + pic - - - - - - -pic - - -id -INT -PK - - -w -INT - - -h -INT - - -size -BIGINT - - -hash -NVARCHAR(64) - - -thumbw -INT - - -thumbh -INT - - -thumbhash -NVARCHAR(64) - - -smallthumbw -INT - - -smallthumbh -INT - - -smallthumbbinary -VARBINARY(MAX) - - -source -NVARCHAR(MAX) + + + + + + +pic + + +id +INT +PK + + +w +INT + + +h +INT + + +size +BIGINT + + +hash +NVARCHAR(64) + + +thumbw +INT + + +thumbh +INT + + +thumbhash +NVARCHAR(64) + + +smallthumbw +INT + + +smallthumbh +INT + + +smallthumbbinary +VARBINARY(MAX) + + +source +NVARCHAR(MAX) - + -brandpic - - -brandpic - - -id -INT -PK - - -brand_id -INT -FK - - -pic_id -INT -FK - - -sort -INT +attributevaluepic + + +attributevaluepic + + +id +INT +PK + + +attributevalue_id +INT +FK + + +pic_id +INT +FK + + +sort +INT - - -brandpic:e->brand:n - - - + + +attributevaluepic:w->pic:n + + + - - -brandpic:w->pic:n - - - - + + +attributevalue + + + + + +attributevalue + + +id +INT +PK + + +attribute_id +INT +FK + + +value +NVARCHAR(255) + + +sort +INT - - - -articlepic - - -articlepic - - -id -INT -PK - - -pic_id -INT -FK - - -article_id -INT -FK - - -sort -INT - - - -articlepic:se->article:n - - - - - - - - -articlepic:w->pic:n - - - + + +attributevaluepic:e->attributevalue:n + + + - + categorypic - - -categorypic - - -id -INT -PK - - -pic_id -INT -FK - - -category_id -INT -FK - - -sort -INT + + +categorypic + + +id +INT +PK + + +pic_id +INT +FK + + +category_id +INT +FK + + +sort +INT categorypic:sw->category:n - - + + @@ -1707,533 +1692,974 @@ categorypic:w->pic:n - - + + - + + +articlepic + + +articlepic + + +id +INT +PK + + +pic_id +INT +FK + + +article_id +INT +FK + + +sort +INT + + + +articlepic:sw->article:n + + + + + + + + +articlepic:w->pic:n + + + + + + + -attributevaluepic - - -attributevaluepic - - -id -INT -PK - - -pic_id -INT -FK - - -attributevalue_id -INT -FK - - -sort -INT +brandpic + + +brandpic + + +id +INT +PK + + +pic_id +INT +FK + + +brand_id +INT +FK + + +sort +INT - - -attributevaluepic:w->pic:n - - - + + +brandpic:e->brand:n + + + - - -attributevalue - - - - - -attributevalue - - -id -INT -PK - - -attribute_id -INT -FK - - -value -NVARCHAR(255) - - -sort -INT - - - -attributevaluepic:w->attributevalue:n - - - + + +brandpic:w->pic:n + + + file - - - -file - - -id -INT -PK - - -hash -NVARCHAR(64) - - -mimetype -NVARCHAR(255) - - -name -NVARCHAR(255) - - -size -BIGINT - - -created -DATETIME2 - - -modified -DATETIME2 - - -content -VARBINARY(MAX) - - -thumbavif? -VARBINARY(MAX) - - -thumbx? -INT - - -thumby? -INT - - -meta? -NVARCHAR(MAX) + + + +file + + +id +INT +PK + + +hash +NVARCHAR(64) + + +mimetype +NVARCHAR(255) + + +name +NVARCHAR(255) + + +size +BIGINT + + +created +DATETIME2 + + +modified +DATETIME2 + + +content +VARBINARY(MAX) + + +thumbavif? +VARBINARY(MAX) + + +thumbx? +INT + + +thumby? +INT + + +meta? +NVARCHAR(MAX) - + -articlewarehousesummary - - -articlewarehousesummary - - -id -INT -PK - - -article_id -INT -FK - - -warehouse_id -INT -FK - - -stock -FLOAT +deliverysummary + + +deliverysummary + + +id +INT +PK + + +supplier_id +? +INT +FK + + +company_id +INT +FK + + +manifest_id +INT +FK + + +delivery_number +NVARCHAR(100) + + +external_number? +NVARCHAR(100) + + +delivery_date +DATETIME2 + + +created_at +DATETIME2 + + +supplier_name? +NVARCHAR(255) + + +positions +INT + + +gross_total +FLOAT + + +net_total +FLOAT + + +total_quantity +FLOAT + + +stock_positions +INT + + +booked_positions +INT + + +unbooked_positions +INT + + +stock_quantity +FLOAT + + +booked_quantity +FLOAT + + +unbooked_quantity +FLOAT + + +is_booked +TINYINT - - -articlewarehousesummary:nw->article:n - - - + + +deliverysummary:sw->entity:n + + + - - -warehouse - - - - -warehouse - - -id -INT -PK - - -company_id -INT -FK - - -name -NVARCHAR(255) - - -sort -INT + + +deliverysummary:sw->company:n + + + + - - -articlewarehousesummary:e->warehouse:n - - - + + + +manifest + + + + + + + + + + + + +manifest + + +id +INT +PK + + +entity_id +? +INT +FK + + +company_id +INT +FK + + +number +NVARCHAR(100) + + +type +NVARCHAR(100) + + +external_number? +NVARCHAR(100) + + +date +DATETIME2 + + +created_at +DATETIME2 + + + +deliverysummary:e->manifest:n + + + + + + + + +ordersummary + + +ordersummary + + +id +INT +PK + + +entity_id +? +INT +FK + + +company_id +INT +FK + + +manifest_id +INT +FK + + +order_number +NVARCHAR(100) + + +order_date +DATETIME2 + + +created_at +DATETIME2 + + +customer_name? +NVARCHAR(255) + + +positions +INT + + +gross_total +FLOAT + + +paid_total +FLOAT + + +ordered_quantity +FLOAT + + +shipped_quantity +FLOAT + + +is_paid +TINYINT + + +is_shipped +TINYINT + + + +ordersummary:nw->entity:n + + + + + + + + +ordersummary:nw->company:n + + + + + + + + +ordersummary:e->manifest:n + + + + + + + + +quotesummary + + +quotesummary + + +id +INT +PK + + +entity_id +? +INT +FK + + +company_id +INT +FK + + +manifest_id +INT +FK + + +quote_number +NVARCHAR(100) + + +quote_date +DATETIME2 + + +created_at +DATETIME2 + + +customer_name? +NVARCHAR(255) + + +positions +INT + + +gross_total +FLOAT + + +total_quantity +FLOAT + + + +quotesummary:e->entity:n + + + + + + + + +quotesummary:nw->company:n + + + + + + + + +quotesummary:e->manifest:n + + + + + + + + +shipmentsummary + + +shipmentsummary + + +id +INT +PK + + +entity_id +? +INT +FK + + +company_id +INT +FK + + +manifest_id +INT +FK + + +shipment_number +NVARCHAR(100) + + +shipment_date +DATETIME2 + + +created_at +DATETIME2 + + +order_id? +INT + + +order_number? +NVARCHAR(100) + + +customer_name? +NVARCHAR(255) + + +positions +INT + + +total_quantity +FLOAT + + +package_count +INT + + +carrier? +NVARCHAR(100) + + +tracking_code? +NVARCHAR(255) + + +state +NVARCHAR(50) + + + +shipmentsummary:e->entity:n + + + + + + + + +shipmentsummary:se->company:n + + + + + + + + +shipmentsummary:e->manifest:n + + + + + + + + +package + + +package + + +id +INT +PK + + +entityaddress_id +INT +FK + + +shipping_id +INT +FK + + +number +NVARCHAR(100) + + +type +NVARCHAR(100) + + +state +NVARCHAR(100) + + +details? +NVARCHAR(MAX) + + + +package:w->entityaddress:n + + + + + + + + +shipping + + + +shipping + + +id +INT +PK + + +carrier_id +INT +FK + + +manifest_id +INT +FK + + +number +NVARCHAR(100) + + +tracking_code? +NVARCHAR(255) + + +type +NVARCHAR(100) + + +state +NVARCHAR(100) + + + +package:e->shipping:n + + + - + manifestarticle - - - -manifestarticle - - -id -INT -PK - - -manifest_id -INT -FK - - -sku? -NVARCHAR(100) - - -name? -NVARCHAR(255) - - -pu -FLOAT - - -amount -FLOAT - - -net -FLOAT - - -comment? -NVARCHAR(MAX) - - -rebate -FLOAT - - -taxrate -FLOAT - - -shippable -TINYINT - - - -manifest - - - - - - - - - - - - -manifest - - -id -INT -PK - - -company_id -INT -FK - - -entity_id -? -INT -FK - - -number -NVARCHAR(100) - - -type -NVARCHAR(100) - - -external_number? -NVARCHAR(100) - - -date -DATETIME2 - - -created_at -DATETIME2 + + + +manifestarticle + + +id +INT +PK + + +manifest_id +INT +FK + + +sku? +NVARCHAR(100) + + +name? +NVARCHAR(255) + + +pu +FLOAT + + +amount +FLOAT + + +net +FLOAT + + +comment? +NVARCHAR(MAX) + + +rebate +FLOAT + + +taxrate +FLOAT + + +shippable +TINYINT -manifestarticle:se->manifest:n +manifestarticle:ne->manifest:n - - + + - - -manifest:e->company:n - - - + + +manifestattachment + + +manifestattachment + + +id +INT +PK + + +manifest_id +INT +FK + + +file_id +INT +FK + + +sort +INT + + + +manifestattachment:e->file:n + + + + + + + + +manifestattachment:sw->manifest:n + + + -manifest:e->entity:n +manifest:w->entity:n - - + + - - -manifestattachment - - -manifestattachment - - -id -INT -PK - - -file_id -INT -FK - - -manifest_id -INT -FK - - -sort -INT - - - -manifestattachment:e->file:n - - - + + +manifest:w->company:n + + + - - -manifestattachment:nw->manifest:n - - - - - - - - -shipping - - - -shipping - - -id -INT -PK - - -manifest_id -INT -FK - - -carrier_id -INT -FK - - -number -NVARCHAR(100) - - -tracking_code? -NVARCHAR(255) - - -type -NVARCHAR(100) - - -state -NVARCHAR(100) - - -shipping:nw->manifest:n - - - + +shipping:sw->manifest:n + + + - + carrier - - - -carrier - - -id -INT -PK - - -company_id -INT -FK - - -name -NVARCHAR(255) - - -sort -INT - - -has_packages -BIT - - -is_reverse -BIT + + + +carrier + + +id +INT +PK + + +company_id +INT +FK + + +name +NVARCHAR(255) + + +sort +INT + + +has_packages +BIT + + +is_reverse +BIT - -shipping:se->carrier:n - - - + +shipping:w->carrier:n + + + + + + + + +warehouse + + + + +warehouse + + +id +INT +PK + + +company_id +INT +FK + + +name +NVARCHAR(255) + + +sort +INT + + + +warehouse:w->company:n + + + - + ledger - - -ledger - - -id -INT -PK - - -manifestarticle_id -INT -FK - - -warehouse_id -? -INT -FK - - -transaction_type -NVARCHAR(100) - - -amount -FLOAT - - -transaction_text -NVARCHAR(MAX) - - -hash_version -INT - - -prev_hash? -NVARCHAR(128) - - -created_at -DATETIME2 + + +ledger + + +id +INT +PK + + +manifestarticle_id +INT +FK + + +warehouse_id +? +INT +FK + + +transaction_type +NVARCHAR(100) + + +amount +FLOAT + + +transaction_text +NVARCHAR(MAX) + + +hash_version +INT + + +prev_hash? +NVARCHAR(128) + + +created_at +DATETIME2 ledger:w->manifestarticle:n - - + + @@ -2241,735 +2667,376 @@ ledger:sw->warehouse:n - - + + - - -warehouse:se->company:n - - - - - - - - -deliverysummary - - -deliverysummary - - -id -INT -PK - - -manifest_id -INT -FK - - -company_id -INT -FK - - -supplier_id -? -INT -FK - - -delivery_number -NVARCHAR(100) - - -external_number? -NVARCHAR(100) - - -delivery_date -DATETIME2 - - -created_at -DATETIME2 - - -supplier_name? -NVARCHAR(255) - - -positions -INT - - -gross_total -FLOAT - - -net_total -FLOAT - - -total_quantity -FLOAT - - -stock_positions -INT - - -booked_positions -INT - - -unbooked_positions -INT - - -stock_quantity -FLOAT - - -booked_quantity -FLOAT - - -unbooked_quantity -FLOAT - - -is_booked -TINYINT - - - -deliverysummary:nw->company:n - - - - - - - - -deliverysummary:w->entity:n - - - - - - - - -deliverysummary:e->manifest:n - - - - - - - - -package - - -package - - -id -INT -PK - - -shipping_id -INT -FK - - -entityaddress_id -INT -FK - - -number -NVARCHAR(100) - - -type -NVARCHAR(100) - - -state -NVARCHAR(100) - - -details? -NVARCHAR(MAX) - - - -package:e->entityaddress:n - - - - - - - - -package:w->shipping:n - - - - - - - - -ordersummary - - -ordersummary - - -id -INT -PK - - -manifest_id -INT -FK - - -company_id -INT -FK - - -entity_id -? -INT -FK - - -order_number -NVARCHAR(100) - - -order_date -DATETIME2 - - -created_at -DATETIME2 - - -customer_name? -NVARCHAR(255) - - -positions -INT - - -gross_total -FLOAT - - -paid_total -FLOAT - - -ordered_quantity -FLOAT - - -shipped_quantity -FLOAT - - -is_paid -TINYINT - - -is_shipped -TINYINT - - - -ordersummary:sw->company:n - - - - - - - - -ordersummary:w->entity:n - - - - - - - - -ordersummary:e->manifest:n - - - - - - - - -quotesummary - - -quotesummary - - -id -INT -PK - - -manifest_id -INT -FK - - -company_id -INT -FK - - -entity_id -? -INT -FK - - -quote_number -NVARCHAR(100) - - -quote_date -DATETIME2 - - -created_at -DATETIME2 - - -customer_name? -NVARCHAR(255) - - -positions -INT - - -gross_total -FLOAT - - -total_quantity -FLOAT - - - -quotesummary:se->company:n - - - - - - - - -quotesummary:e->entity:n - - - - - - - - -quotesummary:e->manifest:n - - - - - - - + -shipmentsummary - - -shipmentsummary - - -id -INT -PK - - -manifest_id -INT -FK - - -company_id -INT -FK - - -entity_id -? -INT -FK - - -shipment_number -NVARCHAR(100) - - -shipment_date -DATETIME2 - - -created_at -DATETIME2 - - -order_id? -INT - - -order_number? -NVARCHAR(100) - - -customer_name? -NVARCHAR(255) - - -positions -INT - - -total_quantity -FLOAT - - -package_count -INT - - -carrier? -NVARCHAR(100) - - -tracking_code? -NVARCHAR(255) - - -state -NVARCHAR(50) +articlewarehousesummary + + +articlewarehousesummary + + +id +INT +PK + + +warehouse_id +INT +FK + + +article_id +INT +FK + + +stock +FLOAT - - -shipmentsummary:ne->company:n - - - + + +articlewarehousesummary:e->article:n + + + - - -shipmentsummary:e->entity:n - - - + + +articlewarehousesummary:sw->warehouse:n + + + - - -shipmentsummary:w->manifest:n - - - + + +payment + + +payment + + +id +INT +PK + + +manifest_id +INT +FK + + +account_id +INT +FK + + +type +NVARCHAR(100) + + +amount +FLOAT + + +created_at +DATETIME2 + + + +payment:w->manifest:n + + + - + account - - - -account - - -id -INT -PK - - -company_id -INT -FK - - -type -NVARCHAR(100) - - -name -NVARCHAR(255) + + + +account + + +id +INT +PK + + +company_id +INT +FK + + +type +NVARCHAR(100) + + +name +NVARCHAR(255) - - -account:w->company:n - - - + + +payment:e->account:n + + + price - - -price - - -id -INT -PK - - -tax_id -INT -FK - - -article_id -INT -FK - - -customergroup_id -INT -FK - - -minmultiplier -DECIMAL(18,4) - - -net -FLOAT + + +price + + +id +INT +PK + + +customergroup_id +INT +FK + + +article_id +INT +FK + + +tax_id +INT +FK + + +minmultiplier +DECIMAL(18,4) + + +net +FLOAT -price:e->customergroup:n +price:w->customergroup:n - - + + -price:sw->article:n +price:se->article:n - - + + -price:w->tax:n +price:e->tax:n - - + + - - -payment - - -payment - - -id -INT -PK - - -account_id -INT -FK - - -manifest_id -INT -FK - - -type -NVARCHAR(100) - - -amount -FLOAT - - -created_at -DATETIME2 - - - -payment:e->manifest:n - - - - - - - - -payment:w->account:n - - - + + +account:w->company:n + + + -unit:e->company:n +unit:w->company:n - - + + + + + + + +labeltemplate + + +labeltemplate + + +id +INT +PK + + +company_id +INT +FK + + +name +NVARCHAR(255) + + +width +INT + + +height +INT + + +dpi +INT + + +type +NVARCHAR(50) + + +content +NVARCHAR(MAX) + + +thumb? +VARBINARY(MAX) + + + +labeltemplate:w->company:n + + + - + posterminalobituary - - -posterminalobituary - - -id -INT -PK - - -posterminal_id -INT -FK - - -entity_type -NVARCHAR(50) - - -entity_id -INT - - -created_at -DATETIME2 + + +posterminalobituary + + +id +INT +PK + + +posterminal_id +INT +FK + + +entity_type +NVARCHAR(50) + + +entity_id +INT + + +created_at +DATETIME2 - + posterminal - - - - - -posterminal - - -id -INT -PK - - -root_category_id -? -INT -FK - - -company_id -INT -FK - - -name -NVARCHAR(255) - - -token? -NVARCHAR(255) - - -pairingpin? -NVARCHAR(50) - - -paired_at? -DATETIME2 - - -clientserial? -NVARCHAR(255) - - -maxknownobituaryid? -INT - - -sync_root_articles -BIT - - -lastseen_at? -DATETIME2 - - -updated_at -DATETIME2 + + + + + +posterminal + + +id +INT +PK + + +company_id +INT +FK + + +root_category_id +? +INT +FK + + +name +NVARCHAR(255) + + +token? +NVARCHAR(255) + + +pairingpin? +NVARCHAR(50) + + +paired_at? +DATETIME2 + + +clientserial? +NVARCHAR(255) + + +maxknownobituaryid? +INT + + +sync_root_articles +BIT + + +lastseen_at? +DATETIME2 + + +updated_at +DATETIME2 + + +sort +INT + + +enabled +BIT + + +clientversion? +NVARCHAR(255) -posterminalobituary:ne->posterminal:n +posterminalobituary:sw->posterminal:n - - + + @@ -2977,82 +3044,82 @@ posterminal:w->company:n - - + + -posterminal:w->category:n +posterminal:e->category:n - - + + - + posterminalsyncqueue - - -posterminalsyncqueue - - -id -INT -PK - - -posterminal_id -INT -FK - - -entity_type -NVARCHAR(50) - - -entity_id -INT + + +posterminalsyncqueue + + +id +INT +PK + + +posterminal_id +INT +FK + + +entity_type +NVARCHAR(50) + + +entity_id +INT posterminalsyncqueue:sw->posterminal:n - - + + - + posterminalcategory - - -posterminalcategory - - -id -INT -PK - - -category_id -INT -FK - - -posterminal_id -INT -FK + + +posterminalcategory + + +id +INT +PK + + +posterminal_id +INT +FK + + +category_id +INT +FK posterminalcategory:e->category:n - - + + @@ -3060,980 +3127,1082 @@ posterminalcategory:sw->posterminal:n - - - - - - - -labeltemplate - - -labeltemplate - - -id -INT -PK - - -company_id -INT -FK - - -name -NVARCHAR(255) - - -width -INT - - -height -INT - - -dpi -INT - - -type -NVARCHAR(50) - - -content -NVARCHAR(MAX) - - -thumb? -VARBINARY(MAX) - - - -labeltemplate:w->company:n - - - + + printer - - -printer - - -id -INT -PK - - -company_id -INT -FK - - -address -NVARCHAR(255) - - -type -NVARCHAR(50) - - -model? -NVARCHAR(255) - - -serial? -NVARCHAR(255) - - -sort -INT - - -desc? -NVARCHAR(MAX) + + +printer + + +id +INT +PK + + +company_id +INT +FK + + +address +NVARCHAR(255) + + +type +NVARCHAR(50) + + +model? +NVARCHAR(255) + + +serial? +NVARCHAR(255) + + +sort +INT + + +desc? +NVARCHAR(MAX) printer:w->company:n - - - - - - - -subagentschedule - - -subagentschedule - - -id -INT -PK - - -company_id -INT -FK - - -desc? -NVARCHAR(MAX) - - -prompt -NVARCHAR(MAX) - - -skills? -NVARCHAR(MAX) - - -lastagentnote? -NVARCHAR(MAX) - - -max_itr -INT - - -cron -NVARCHAR(100) - - -created_at -DATETIME2 - - -lastrun_at? -DATETIME2 - - - -subagentschedule:w->company:n - - - - - - - - -modelconversation - - - -modelconversation - - -id -INT -PK - - -companyuser_id -INT -FK - - -provider -NVARCHAR(100) - - -model -NVARCHAR(255) - - -created_at -DATETIME2 - - -updated_at -DATETIME2 - - -state -NVARCHAR(20) - - -started_by? -INT - - - -modelconversation:w->companyuser:n - - - - - - - - -modelmessage - - -modelmessage - - -id -INT -PK - - -conversation_id -INT -FK - - -created_at -DATETIME2 - - -role -NVARCHAR(20) - - -message -NVARCHAR(MAX) - - - -modelmessage:sw->modelconversation:n - - - + + - + modelvfs - - -modelvfs - - -id -INT -PK - - -company_id -INT -FK - - -name -NVARCHAR(255) - - -mime -NVARCHAR(100) - - -content -VARBINARY(MAX) - - -meta -NVARCHAR(MAX) - - -created_at -DATETIME2 - - -updated_at -DATETIME2 + + +modelvfs + + +id +INT +PK + + +company_id +INT +FK + + +name +NVARCHAR(255) + + +mime +NVARCHAR(100) + + +content +VARBINARY(MAX) + + +meta +NVARCHAR(MAX) + + +created_at +DATETIME2 + + +updated_at +DATETIME2 -modelvfs:w->company:n +modelvfs:e->company:n - - + + - - -articleproperty - - -articleproperty - - -id -INT -PK - - -article_id -INT -FK - - -property_id -INT -FK - - -value -NVARCHAR(MAX) - - -sort -INT + + +subagentschedule + + +subagentschedule + + +id +INT +PK + + +company_id +INT +FK + + +desc? +NVARCHAR(MAX) + + +prompt +NVARCHAR(MAX) + + +skills? +NVARCHAR(MAX) + + +lastagentnote? +NVARCHAR(MAX) + + +max_itr +INT + + +cron +NVARCHAR(100) + + +created_at +DATETIME2 + + +lastrun_at? +DATETIME2 - - -articleproperty:w->article:n - - - + + +subagentschedule:se->company:n + + + - - -propertyname - - - - -propertyname - - -id -INT -PK - - -company_id -INT -FK - - -name -NVARCHAR(255) - - -sort -INT - - -exclusive -BIT + + +modelconversation + + + +modelconversation + + +id +INT +PK + + +companyuser_id +INT +FK + + +provider +NVARCHAR(100) + + +model +NVARCHAR(255) + + +created_at +DATETIME2 + + +updated_at +DATETIME2 + + +state +NVARCHAR(20) + + +started_by? +INT - - -articleproperty:e->propertyname:n - - - + + +modelconversation:e->companyuser:n + + + - - -articleattributevalue - - -articleattributevalue - - -id -INT -PK - - -article_id -INT -FK - - -attributevalue_id -INT -FK - - -sort -INT + + +modelmessage + + +modelmessage + + +id +INT +PK + + +conversation_id +INT +FK + + +created_at +DATETIME2 + + +role +NVARCHAR(20) + + +message +NVARCHAR(MAX) - - -articleattributevalue:e->article:n - - - - - - - - -articleattributevalue:w->attributevalue:n - - - - - - - - -categoryproperty - - -categoryproperty - - -id -INT -PK - - -category_id -INT -FK - - -property_id -INT -FK - - -value -NVARCHAR(MAX) - - -sort -INT - - - -categoryproperty:w->category:n - - - - - - - - -categoryproperty:e->propertyname:n - - - - - - - - -categoryattributevalue - - -categoryattributevalue - - -id -INT -PK - - -category_id -INT -FK - - -attributevalue_id -INT -FK - - -sort -INT - - - -categoryattributevalue:w->category:n - - - - - - - - -categoryattributevalue:e->attributevalue:n - - - - - - - - -propertyname:e->company:n - - - - - - - - -attributename:w->company:n - - - + + +modelmessage:se->modelconversation:n + + + + +attributevalue:sw->attributename:n + + + + + + + + +attributename:w->company:n + + + + + + + + +propertyname + + + + +propertyname + + +id +INT +PK + + +company_id +INT +FK + + +name +NVARCHAR(255) + + +sort +INT + + +exclusive +BIT + + + +propertyname:w->company:n + + + + + + + + +categoryproperty + + +categoryproperty + + +id +INT +PK + + +property_id +INT +FK + + +category_id +INT +FK + + +value +NVARCHAR(MAX) + + +sort +INT + + + +categoryproperty:e->category:n + + + + + + + + +categoryproperty:w->propertyname:n + + + + + + + + +categoryattributevalue + + +categoryattributevalue + + +id +INT +PK + + +attributevalue_id +INT +FK + + +category_id +INT +FK + + +sort +INT + + + +categoryattributevalue:e->category:n + + + + + + + + +categoryattributevalue:w->attributevalue:n + + + + + + + + +articleproperty + + +articleproperty + + +id +INT +PK + + +property_id +INT +FK + + +article_id +INT +FK + + +value +NVARCHAR(MAX) + + +sort +INT + + -attributevalue:se->attributename:n - - - +articleproperty:w->article:n + + + - + + +articleproperty:w->propertyname:n + + + + + + + + +articleattributevalue + + +articleattributevalue + + +id +INT +PK + + +attributevalue_id +INT +FK + + +article_id +INT +FK + + +sort +INT + + + +articleattributevalue:w->article:n + + + + + + + + +articleattributevalue:w->attributevalue:n + + + + + + + -returnsummary - - -returnsummary - - -id -INT -PK - - -manifest_id -INT -FK - - -company_id -INT -FK - - -supplier_id -? -INT -FK - - -return_number -NVARCHAR(100) - - -external_number? -NVARCHAR(100) - - -return_date -DATETIME2 - - -created_at -DATETIME2 - - -supplier_name? -NVARCHAR(255) - - -positions -INT - - -gross_total -FLOAT - - -net_total -FLOAT - - -total_quantity -FLOAT - - -stock_positions -INT - - -booked_positions -INT - - -unbooked_positions -INT - - -stock_quantity -FLOAT - - -booked_quantity -FLOAT - - -unbooked_quantity -FLOAT - - -is_booked -TINYINT +printeragent + + + +printeragent + + +id +INT +PK + + +company_id +INT +FK + + +name +NVARCHAR(255) + + +token? +NVARCHAR(255) + + +pairingpin? +NVARCHAR(50) + + +paired_at? +DATETIME2 + + +agentversion? +NVARCHAR(255) + + +lastseen_at? +DATETIME2 + + +updated_at +DATETIME2 + + +sort +INT + + +enabled +BIT - - -returnsummary:e->company:n - - - - - - - - -returnsummary:w->entity:n - - - - - - - - -returnsummary:w->manifest:n - - - - - - - - -retouresummary - - -retouresummary - - -id -INT -PK - - -manifest_id -INT -FK - - -company_id -INT -FK - - -entity_id -? -INT -FK - - -retoure_number -NVARCHAR(100) - - -retoure_date -DATETIME2 - - -created_at -DATETIME2 - - -customer_name? -NVARCHAR(255) - - -positions -INT - - -gross_total -FLOAT - - -paid_total -FLOAT - - -ordered_quantity -FLOAT - - -shipped_quantity -FLOAT - - -is_paid -TINYINT - - -is_shipped -TINYINT - - - -retouresummary:w->company:n - - - - - - - - -retouresummary:e->entity:n - - - - - - - - -retouresummary:e->manifest:n - - - + + +printeragent:e->company:n + + + - + carrier:e->company:n - - - + + + - + + +agentprinter + + + +agentprinter + + +id +INT +PK + + +printeragent_id +INT +FK + + +type +NVARCHAR(50) + + +model? +NVARCHAR(255) + + +serial? +NVARCHAR(255) + + +enabled +BIT + + +sort +INT + + + +agentprinter:sw->printeragent:n + + + + + + + -mcp_client - - -mcp_client - - -id -INT -PK - - -client_id -NVARCHAR(100) - - -client_secret? -NVARCHAR(255) - - -client_name -NVARCHAR(255) - - -redirect_uris -NVARCHAR(MAX) - - -grant_types? -NVARCHAR(255) - - -response_types? -NVARCHAR(255) - - -scope? -NVARCHAR(255) - - -created_at -DATETIME2 +printerjob + + +printerjob + + +id +INT +PK + + +agentprinter_id +INT +FK + + +type +NVARCHAR(50) + + +state +NVARCHAR(50) + + +data +NVARCHAR(MAX) + + +created_at +DATETIME2 + + +completed_at? +DATETIME2 - + + +printerjob:sw->agentprinter:n + + + + + + + -healthcheck - - -healthcheck - - -id -INT -PK - - -sequence -BIGINT - - -updated_at -DATETIME2 +returnsummary + + +returnsummary + + +id +INT +PK + + +supplier_id +? +INT +FK + + +company_id +INT +FK + + +manifest_id +INT +FK + + +return_number +NVARCHAR(100) + + +external_number? +NVARCHAR(100) + + +return_date +DATETIME2 + + +created_at +DATETIME2 + + +supplier_name? +NVARCHAR(255) + + +positions +INT + + +gross_total +FLOAT + + +net_total +FLOAT + + +total_quantity +FLOAT + + +stock_positions +INT + + +booked_positions +INT + + +unbooked_positions +INT + + +stock_quantity +FLOAT + + +booked_quantity +FLOAT + + +unbooked_quantity +FLOAT + + +is_booked +TINYINT - + + +returnsummary:e->entity:n + + + + + + + + +returnsummary:e->company:n + + + + + + + + +returnsummary:w->manifest:n + + + + + + + -mcp_auth_code - - -mcp_auth_code - - -code -NVARCHAR(128) -PK - - -google_auth_id -INT -FK - - -client_id -NVARCHAR(100) - - -redirect_uri -NVARCHAR(2048) - - -code_challenge -NVARCHAR(255) - - -code_challenge_method -NVARCHAR(32) - - -scope? -NVARCHAR(255) - - -expires_at -DATETIME2 - - -used -TINYINT - - -created_at -DATETIME2 +retouresummary + + +retouresummary + + +id +INT +PK + + +entity_id +? +INT +FK + + +company_id +INT +FK + + +manifest_id +INT +FK + + +retoure_number +NVARCHAR(100) + + +retoure_date +DATETIME2 + + +created_at +DATETIME2 + + +customer_name? +NVARCHAR(255) + + +positions +INT + + +gross_total +FLOAT + + +paid_total +FLOAT + + +ordered_quantity +FLOAT + + +shipped_quantity +FLOAT + + +is_paid +TINYINT + + +is_shipped +TINYINT - + -mcp_auth_code:sw->google_auth:n - - - +retouresummary:w->entity:n + + + + + + + + +retouresummary:w->company:n + + + + + + + + +retouresummary:e->manifest:n + + + mcp_token - - -mcp_token - - -id -INT -PK - - -google_auth_id -INT -FK - - -refresh_token -NVARCHAR(128) - - -client_id -NVARCHAR(100) - - -scope? -NVARCHAR(255) - - -expires_at -DATETIME2 - - -revoked -TINYINT - - -created_at -DATETIME2 + + +mcp_token + + +id +INT +PK + + +google_auth_id +INT +FK + + +refresh_token +NVARCHAR(128) + + +client_id +NVARCHAR(100) + + +scope? +NVARCHAR(255) + + +expires_at +DATETIME2 + + +revoked +TINYINT + + +created_at +DATETIME2 - -mcp_token:w->google_auth:n - - - + +mcp_token:sw->google_auth:n + + + - + +mcp_auth_code + + +mcp_auth_code + + +code +NVARCHAR(128) +PK + + +google_auth_id +INT +FK + + +client_id +NVARCHAR(100) + + +redirect_uri +NVARCHAR(2048) + + +code_challenge +NVARCHAR(255) + + +code_challenge_method +NVARCHAR(32) + + +scope? +NVARCHAR(255) + + +expires_at +DATETIME2 + + +used +TINYINT + + +created_at +DATETIME2 + + + +mcp_auth_code:sw->google_auth:n + + + + + + + + +mcp_client + + +mcp_client + + +id +INT +PK + + +client_id +NVARCHAR(100) + + +client_secret? +NVARCHAR(255) + + +client_name +NVARCHAR(255) + + +redirect_uris +NVARCHAR(MAX) + + +grant_types? +NVARCHAR(255) + + +response_types? +NVARCHAR(255) + + +scope? +NVARCHAR(255) + + +created_at +DATETIME2 + + + +healthcheck + + +healthcheck + + +id +INT +PK + + +sequence +BIGINT + + +updated_at +DATETIME2 + + + __legend - - - -Legend - -PK - -primary key - -FK - -foreign key - -? - -nullable column - -— - -FOREIGN KEY constraint - -- - - -naming convention only, no constraint + + + +Legend + +PK + +primary key + +FK + +foreign key + +? + +nullable column + +— + +FOREIGN KEY constraint + +- - + +naming convention only, no constraint diff --git a/scripts/test-companyuser-roles.js b/scripts/test-companyuser-roles.js new file mode 100644 index 0000000..541b5d4 --- /dev/null +++ b/scripts/test-companyuser-roles.js @@ -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 }); +} diff --git a/src/index.js b/src/index.js index d976bd6..6466d21 100644 --- a/src/index.js +++ b/src/index.js @@ -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'; @@ -334,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 = { diff --git a/src/providers/mssql/downgrade/6_to_5.js b/src/providers/mssql/downgrade/6_to_5.js new file mode 100644 index 0000000..2b2c929 --- /dev/null +++ b/src/providers/mssql/downgrade/6_to_5.js @@ -0,0 +1,18 @@ +/** + * Schema 6 → 5 (MSSQL): + * - Drops autoinvite table + * - Drops companyuser.role + * @param {{ query: (sql: string, params?: object) => Promise, assertVersion: (v: number) => Promise, setVersion: (v: number) => Promise }} 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); +} diff --git a/src/providers/mssql/queries/autoinvite.js b/src/providers/mssql/queries/autoinvite.js new file mode 100644 index 0000000..e1ca01d --- /dev/null +++ b/src/providers/mssql/queries/autoinvite.js @@ -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 +`; diff --git a/src/providers/mssql/queries/company.js b/src/providers/mssql/queries/company.js index 73d0d1b..7f37a3c 100644 --- a/src/providers/mssql/queries/company.js +++ b/src/providers/mssql/queries/company.js @@ -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 diff --git a/src/providers/mssql/queries/companyuser.js b/src/providers/mssql/queries/companyuser.js index 1e2c966..0c43227 100644 --- a/src/providers/mssql/queries/companyuser.js +++ b/src/providers/mssql/queries/companyuser.js @@ -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,71 @@ 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, + 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 +`; diff --git a/src/providers/mssql/schema.js b/src/providers/mssql/schema.js index 4e6323a..de92539 100644 --- a/src/providers/mssql/schema.js +++ b/src/providers/mssql/schema.js @@ -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 = ` IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'companyuser') BEGIN @@ -873,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 @@ -1711,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, diff --git a/src/providers/mssql/upgrade/5_to_6.js b/src/providers/mssql/upgrade/5_to_6.js new file mode 100644 index 0000000..748d9ca --- /dev/null +++ b/src/providers/mssql/upgrade/5_to_6.js @@ -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, assertVersion: (v: number) => Promise, setVersion: (v: number) => Promise }} 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); +} diff --git a/src/providers/sqlite3/downgrade/6_to_5.js b/src/providers/sqlite3/downgrade/6_to_5.js new file mode 100644 index 0000000..15d94cb --- /dev/null +++ b/src/providers/sqlite3/downgrade/6_to_5.js @@ -0,0 +1,15 @@ +/** + * Schema 6 → 5 (SQLite): + * - Drops autoinvite table + * - Drops companyuser.role + * @param {{ exec: (sql: string) => void, assertVersion: (v: number) => Promise, setVersion: (v: number) => Promise }} 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); +} diff --git a/src/providers/sqlite3/queries/autoinvite.js b/src/providers/sqlite3/queries/autoinvite.js new file mode 100644 index 0000000..d154136 --- /dev/null +++ b/src/providers/sqlite3/queries/autoinvite.js @@ -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; diff --git a/src/providers/sqlite3/queries/companyuser.js b/src/providers/sqlite3/queries/companyuser.js index 07089b7..1832dce 100644 --- a/src/providers/sqlite3/queries/companyuser.js +++ b/src/providers/sqlite3/queries/companyuser.js @@ -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; \ No newline at end of file +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; diff --git a/src/providers/sqlite3/schema.js b/src/providers/sqlite3/schema.js index acb10a3..4d628dd 100644 --- a/src/providers/sqlite3/schema.js +++ b/src/providers/sqlite3/schema.js @@ -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 = ` 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 @@ -1266,6 +1279,7 @@ export const SCHEMA_SCRIPTS = [ INIT_LEDGER, INIT_NUMBERS, INIT_COMPANYUSER, + INIT_AUTOINVITE, INIT_MODELCONVERSATION, INIT_MODELMESSAGE, INIT_MODELVFS, diff --git a/src/providers/sqlite3/upgrade/5_to_6.js b/src/providers/sqlite3/upgrade/5_to_6.js new file mode 100644 index 0000000..29e8552 --- /dev/null +++ b/src/providers/sqlite3/upgrade/5_to_6.js @@ -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, setVersion: (v: number) => Promise }} 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); +} diff --git a/src/store/autoinvite/add.js b/src/store/autoinvite/add.js new file mode 100644 index 0000000..7d0819a --- /dev/null +++ b/src/store/autoinvite/add.js @@ -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 }; + }); +} diff --git a/src/store/autoinvite/list.js b/src/store/autoinvite/list.js new file mode 100644 index 0000000..6a8c9dc --- /dev/null +++ b/src/store/autoinvite/list.js @@ -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>} 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; + }; +} diff --git a/src/store/autoinvite/remove.js b/src/store/autoinvite/remove.js new file mode 100644 index 0000000..617e4ef --- /dev/null +++ b/src/store/autoinvite/remove.js @@ -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} + * @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 }); + }); +} diff --git a/src/store/company/create.js b/src/store/company/create.js index e804ae3..3e2cfa2 100644 --- a/src/store/company/create.js +++ b/src/store/company/create.js @@ -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 { diff --git a/src/store/companyuser/add.js b/src/store/companyuser/add.js index b755af2..7991d79 100644 --- a/src/store/companyuser/add.js +++ b/src/store/companyuser/add.js @@ -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} 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; diff --git a/src/store/companyuser/addByEmail.js b/src/store/companyuser/addByEmail.js new file mode 100644 index 0000000..ad40bc1 --- /dev/null +++ b/src/store/companyuser/addByEmail.js @@ -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 }; + }); +} diff --git a/src/store/companyuser/list.js b/src/store/companyuser/list.js new file mode 100644 index 0000000..25ba95b --- /dev/null +++ b/src/store/companyuser/list.js @@ -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>} 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; + }; +} diff --git a/src/store/companyuser/remove.js b/src/store/companyuser/remove.js new file mode 100644 index 0000000..b38dad1 --- /dev/null +++ b/src/store/companyuser/remove.js @@ -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 }; + }); +} diff --git a/src/store/companyuser/requireAdmin.js b/src/store/companyuser/requireAdmin.js new file mode 100644 index 0000000..c2c05ac --- /dev/null +++ b/src/store/companyuser/requireAdmin.js @@ -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; + }; +} diff --git a/src/store/companyuser/resolveCompanyAccess.js b/src/store/companyuser/resolveCompanyAccess.js index 073de9e..634cb15 100644 --- a/src/store/companyuser/resolveCompanyAccess.js +++ b/src/store/companyuser/resolveCompanyAccess.js @@ -30,6 +30,7 @@ export default function resolveCompanyAccess(provider) { languageId: row.languageId, posCustomerGroupId: row.posCustomerGroupId ?? null, companyuserId: row.companyuserId, + role: row.role, }; }; } diff --git a/src/store/companyuser/roles.js b/src/store/companyuser/roles.js new file mode 100644 index 0000000..4e0c449 --- /dev/null +++ b/src/store/companyuser/roles.js @@ -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; +} diff --git a/src/store/companyuser/setRole.js b/src/store/companyuser/setRole.js new file mode 100644 index 0000000..f40ab8e --- /dev/null +++ b/src/store/companyuser/setRole.js @@ -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 }; + }); +} diff --git a/src/store/companyuser/withTransaction.js b/src/store/companyuser/withTransaction.js new file mode 100644 index 0000000..6beb348 --- /dev/null +++ b/src/store/companyuser/withTransaction.js @@ -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)); +} diff --git a/src/store/google_auth/add.js b/src/store/google_auth/add.js index 9592d61..28f8f83 100644 --- a/src/store/google_auth/add.js +++ b/src/store/google_auth/add.js @@ -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} 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; + }); } diff --git a/src/store/google_auth/setSelectedCompany.js b/src/store/google_auth/setSelectedCompany.js index 9a91eb9..747b49a 100644 --- a/src/store/google_auth/setSelectedCompany.js +++ b/src/store/google_auth/setSelectedCompany.js @@ -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} + * @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 }); }; }