diff --git a/.cursor/rules/no-schema-migrations.mdc b/.cursor/rules/no-schema-migrations.mdc new file mode 100644 index 0000000..9c0fa0e --- /dev/null +++ b/.cursor/rules/no-schema-migrations.mdc @@ -0,0 +1,36 @@ +--- +description: Kein Migrations-Code — immer frische DB per reset-db; Live-Anpassungen nur per sqlcmd +alwaysApply: true +--- + +# Schema: keine Migrationen im Code + +Wir gehen **immer von einer frischen Datenbank** aus. Schema-Änderungen gehören in `src/schema.js` (und ggf. `purge.js`), **nicht** als Upgrade-Pfade für bestehende DBs. + +## Verboten im Repo + +- `MIGRATE_*`-Scripts oder Kommentare wie „Existing DBs …“ +- `ALTER TABLE … ADD ` mit `IF NOT EXISTS (SELECT … FROM sys.columns …)` +- `ALTER` nur um ein bereits ausgerolltes Schema zu patchen +- Logik „wenn Spalte/Constraint fehlt, nachrüsten“ für fremde oder alte DB-Stande + +## Erlaubt + +- `CREATE TABLE` mit `IF NOT EXISTS` (Neuaufbau) +- Spalten direkt in `CREATE TABLE` definieren +- **Ausnahme Zirkular-FK:** Spalte in `CREATE TABLE`, FK in separatem `INIT_*` **nach** der referenzierten Tabelle — nur für den Erst-Init, nicht als Migration +- `purge.js`: Constraint-Drops, die `reset-db`/`purge-db` zum sauberen Droppen brauchen + +## Workflow + +1. Schema in `src/schema.js` anpassen +2. Lokal: `npm run reset-db` (purge + init) +3. **Eigene/geteilte DB** bei Bedarf **manuell** mit `sqlcmd` anpassen — **nicht** im Code committen + +## Beispiel sqlcmd (nur manuell, nicht im Repo) + +```bash +sqlcmd -S "$MSSQL_SERVER" -d "$MSSQL_DATABASE" -U "$MSSQL_USER" -P "$MSSQL_PASSWORD" -Q "…" +``` + +Nach sqlcmd-Anpassungen: Schema-Datei trotzdem so pflegen, dass `reset-db` den Zielzustand abbildet. diff --git a/README.md b/README.md index e4d7e31..23f2876 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,14 @@ const client = await new LibDashStore(pool); // runs initialize() (ensure schema SQL lives in `src/sql/.js`. Schema DDL: `src/schema.js` / `import 'libdashstore/schema'`. -ID fields in the JS API use camelCase `…Id` (DB columns stay snake_case). +ID fields in the JS API use camelCase `…Id` (DB columns stay snake_case). `googleAuthId` is always `google_auth.id` (the logged-in user), not `companyuser.id`. --- ## Schema +**Kein Migrations-Code:** Wir gehen von einer frischen DB aus (`npm run reset-db`). Schema nur in `src/schema.js`; Anpassungen an einer laufenden DB manuell per `sqlcmd`, nicht im Repo. + | Method | Description | | --- | --- | | `initialize()` | Runs all `SCHEMA_SCRIPTS` (`IF NOT EXISTS`). Does **not** purge. | @@ -29,10 +31,10 @@ ID fields in the JS API use camelCase `…Id` (DB columns stay snake_case). | Method | Signature | Returns | | --- | --- | --- | -| `create` | `({ iso639, name, taxrate, reducedtaxrate, authId })` | `{ id, rootCategoryId, languageId, taxrateId, reducedtaxrateId, taxfreerateId }` | +| `create` | `({ iso639, name, taxrate, reducedtaxrate, googleAuthId })` | `{ id, rootCategoryId, languageId, taxrateId, reducedtaxrateId, taxfreerateId }` | | `update` | `(id, { iso639, name, taxrate, reducedtaxrate })` | `{ id, rootCategoryId, languageId, taxrateId, reducedtaxrateId, taxfreerateId }` | | `delete` | `(companyId)` | `void` | -| `list` | `(authId)` | `{ id, name, rootCategoryId, languageId, taxrate, reducedtaxrate, taxfreerate }[]` | +| `list` | `(googleAuthId)` | `{ id, name, rootCategoryId, languageId, taxrate, reducedtaxrate, taxfreerate }[]` | ### `create` (transaction) @@ -43,11 +45,13 @@ For `POST /api/company`: 3. Ensures tax rows for `taxrate`, `reducedtaxrate`, and `0` → `taxfreerateId` 4. Creates root category (`parentId = null`) 5. Inserts company with all FKs set -6. Links creator as `companyuser` (`authId` → `company_id`) +6. Links creator as `companyuser` (`googleAuthId` → `company_id`) + +`company.poscustomergroup_id` (optional) points at the `customergroup` used for POS pricing in `getTreeForCompany` articles. ### `list` -Returns only companies where `authId` has a `companyuser` row. +Returns only companies where `googleAuthId` has a `companyuser` row. ### `update` (transaction) @@ -68,13 +72,13 @@ For `PATCH /api/company/:id`: | `getByEmail` | `(email)` | `{ id, email, createdAt, selectedCompanyId } \| undefined` | | `listAllowedEmails` | `()` | `{ id, email, createdAt, selectedCompanyId }[]` | | `removeAllowedEmail` | `(email)` | `void` | -| `setSelectedCompany` | `(authId, companyId)` | `void` | +| `setSelectedCompany` | `(googleAuthId, companyId)` | `void` | Backend mapping: - Login: `getByEmail(email)` - CLI user manager: `listAllowedEmails()` / `removeAllowedEmail(email)` / `add(email)` -- `PUT /api/auth/selected-company`: `setSelectedCompany(authId, companyId)` +- `PUT /api/auth/selected-company`: `setSelectedCompany(googleAuthId, companyId)` --- @@ -82,7 +86,7 @@ Backend mapping: | Method | Signature | Returns | | --- | --- | --- | -| `add` | `(companyId, authId)` | `id` | +| `add` | `(companyId, googleAuthId)` | `id` | | `getByEmail` | `(email)` | `id[]` | | `delete` | `(id)` | `void` | @@ -115,15 +119,21 @@ Backend mapping: | Method | Signature | Returns | | --- | --- | --- | -| `create` | `({ parentId, sort })` | `id` | +| `createUnderParent` | `({ googleAuthId, parentId, name })` | `{ id, parentId, sort, name }` | | `updateParentId` | `(id, parentId)` | `void` | | `delete` | `(id)` | `void` — blocked by FK if `categoryarticle` (or child categories) still reference it | -| `getTree` | `(rootId)` | `{ id, parentId, sort }[]` | -| `getTreeForCompany` | `(authId, companyId)` | `{ id, parentId, sort }[]` — membership check + company root | +| `getTreeForCompany` | `(googleAuthId, companyId)` | `{ languageId, tree, articles }` | -`getTreeForCompany` throws `Not authorized` if `authId` is not a `companyuser` of `companyId`, and `Company not found` if the company is missing or has no `root_category_id`. Reuses `GET_TREE` after access check. +### `createUnderParent` (transaction) -`getTree(rootId)` stays for low-level use (tests, internal) when the root id is already known. +1. `parentId` exists, else `Parent not found` +2. Walk ancestors to root; find `company` with `root_category_id` = root +3. `companyuser` for `(googleAuthId, companyId)`, else `Not authorized` +4. Insert `category` (next sibling `sort`) + `categorytext` (`name`, slugified `slug`, company default language) + +`resolveCategoryAccess(googleAuthId, categoryId)` is internal — reusable for future update/delete/move. + +`getTreeForCompany` throws `Not authorized` if `googleAuthId` is not a `companyuser` of `companyId`, and `Company not found` if the company is missing, has no `root_category_id`, or no `default_language_id`. --- @@ -131,12 +141,13 @@ Backend mapping: | Route | Store call | | --- | --- | -| `POST /api/company` | `company.create({ iso639, name, taxrate, reducedtaxrate, authId })` | -| `GET /api/company` | `company.list(authId)` | +| `POST /api/company` | `company.create({ iso639, name, taxrate, reducedtaxrate, googleAuthId })` | +| `GET /api/company` | `company.list(googleAuthId)` | | `PATCH /api/company/:id` | `company.update(id, { iso639, name, taxrate, reducedtaxrate })` | | `DELETE /api/company/:id` | `company.delete(id)` | -| `PUT /api/auth/selected-company` | `google_auth.setSelectedCompany(authId, companyId)` | -| `GET /api/category/tree?companyId=` | `category.getTreeForCompany(authId, companyId)` | +| `PUT /api/auth/selected-company` | `google_auth.setSelectedCompany(googleAuthId, companyId)` | +| `GET /api/category/tree?companyId=` | `category.getTreeForCompany(googleAuthId, companyId)` | +| `POST /api/category` | `category.createUnderParent({ googleAuthId, parentId, name })` | --- diff --git a/generate-schema-graph.js b/generate-schema-graph.js index ae17f12..e3813c3 100644 --- a/generate-schema-graph.js +++ b/generate-schema-graph.js @@ -88,6 +88,22 @@ function parseSchema(source) { }); } + const alterAddColumnRegex = + /ALTER TABLE\s+(\w+)\s+ADD\s+(\w+)\s+([\w]+(?:\([^)]*\))?)(?:\s+(NOT NULL|NULL))?\s*;/gi; + let alterColumnMatch; + + while ((alterColumnMatch = alterAddColumnRegex.exec(sqlBlock)) !== null) { + const tableName = alterColumnMatch[1]; + const name = alterColumnMatch[2]; + const type = alterColumnMatch[3]; + const nullable = alterColumnMatch[4]?.toUpperCase() !== 'NOT NULL'; + + const table = tables.get(tableName); + if (!table || table.columns.some((c) => c.name === name)) continue; + + table.columns.push({ name, type, nullable, isPk: false }); + } + const alterFkRegex = /ALTER TABLE\s+(\w+)\s+ADD CONSTRAINT\s+(\w+)\s+FOREIGN KEY\s*\(([^)]+)\)\s+REFERENCES\s+(\w+)\(([^)]+)\)(?:\s+ON DELETE\s+([\w\s]+?))?\s*;/gi; let alterMatch; diff --git a/schema-graph.dot b/schema-graph.dot index 2abde1f..f5f050a 100644 --- a/schema-graph.dot +++ b/schema-graph.dot @@ -10,20 +10,20 @@ digraph schema { style="rounded,filled"; fillcolor="#ffffff60"; color="#a0aec0"; fontcolor="#1a365d"; margin=14; meta [label=<
meta
keyNVARCHAR(255)
valueNVARCHAR(MAX)
>]; - customergroup [label=<
customergroup
idINT PK
company_idINT FK
nameNVARCHAR(255)
>]; - numbers [label=<
numbers
idINT PK
company_idINT FK
typeNVARCHAR(100)
prefixNVARCHAR(50)
paddingINT
nextINT
>]; entity [label=<
entity
idINT PK
customergroup_idINT FK
firstnameNVARCHAR(255)
lastnameNVARCHAR(255)
>]; - company [label=<
company
idINT PK
default_language_id?INT FK
taxrate?INT FK
reducedtaxrate?INT FK
taxfreerate?INT FK
root_category_id?INT FK
nameNVARCHAR(255)
>]; + numbers [label=<
numbers
idINT PK
company_idINT FK
typeNVARCHAR(100)
prefixNVARCHAR(50)
paddingINT
nextINT
>]; companyuser [label=<
companyuser
idINT PK
company_idINT FK
google_auth_idINT FK
>]; + company [label=<
company
idINT PK
poscustomergroup_id?INT FK
taxrate?INT FK
reducedtaxrate?INT FK
taxfreerate?INT FK
default_language_id?INT FK
root_category_id?INT FK
nameNVARCHAR(255)
>]; google_auth [label=<
google_auth
idINT PK
selected_company?INT FK
emailNVARCHAR(320)
created_atDATETIME2
>]; + customergroup [label=<
customergroup
idINT PK
company_idINT FK
nameNVARCHAR(255)
>]; } subgraph cluster_i18n { label="Localization"; labeljust="l"; fontsize=15; style="rounded,filled"; fillcolor="#ffffff60"; color="#a0aec0"; fontcolor="#1a365d"; margin=14; unittext [label=<
unittext
idINT PK
language_idINT FK
unit_idINT FK
symbolNVARCHAR(50)
nameNVARCHAR(255)
>]; - categorytext [label=<
categorytext
idINT PK
language_idINT FK
category_idINT FK
nameNVARCHAR(255)
slugNVARCHAR(255)
seo?NVARCHAR(MAX)
desc?NVARCHAR(MAX)
>]; language [label=<
language
idINT PK
iso639NVARCHAR(10)
>]; + 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 { @@ -56,8 +56,8 @@ digraph schema { fontcolor="#1a365d"; margin=14; tax [label=<
tax
idINT PK
rateDECIMAL(18,4)
>]; price [label=<
price
idINT PK
customergroup_idINT FK
tax_idINT FK
article_idINT FK
minmultiplierDECIMAL(18,4)
netFLOAT
>]; - manifestarticle [label=<
manifestarticle
idINT PK
tax_idINT FK
manifest_idINT FK
article_idINT FK
amountFLOAT
netFLOAT
transactedamountFLOAT
comment?NVARCHAR(MAX)
rebateFLOAT
>]; manifest [label=<
manifest
idINT PK
numberNVARCHAR(100)
typeNVARCHAR(100)
dateDATETIME2
>]; + manifestarticle [label=<
manifestarticle
idINT PK
tax_idINT FK
manifest_idINT FK
article_idINT FK
amountFLOAT
netFLOAT
transactedamountFLOAT
comment?NVARCHAR(MAX)
rebateFLOAT
>]; } subgraph cluster_units { label="Units"; labeljust="l"; fontsize=15; @@ -68,47 +68,48 @@ digraph schema { __legend [label=<
Legend
PKprimary key
FKforeign key
?nullable column
—FOREIGN KEY constraint
- -naming convention only, no constraint
>]; - google_auth:selected_company:sw -> company:in3:n [color="#c53030", fontcolor="#c53030", tooltip="FK_google_auth_selected_company"]; + google_auth:selected_company:sw -> company:in2:n [color="#c53030", fontcolor="#c53030", tooltip="FK_google_auth_selected_company"]; category:parent_id:e -> category:id:e [color="#2f855a", fontcolor="#2f855a", tooltip="FK_category_parent"]; - company:root_category_id:e -> category:in1:n [color="#2b6cb0", fontcolor="#2b6cb0", tooltip="FK_company_root_category"]; - company:default_language_id:w -> language:in3:n [color="#b7791f", fontcolor="#b7791f", tooltip="FK_company_default_language"]; + company:root_category_id:e -> category:in0:n [color="#2b6cb0", fontcolor="#2b6cb0", tooltip="FK_company_root_category"]; + company:default_language_id:e -> language:in0:n [color="#b7791f", fontcolor="#b7791f", tooltip="FK_company_default_language"]; company:taxrate:se -> tax:in2:n [color="#6b46c1", fontcolor="#6b46c1", tooltip="FK_company_taxrate"]; company:reducedtaxrate:se -> tax:in0:n [color="#c05621", fontcolor="#c05621", tooltip="FK_company_reducedtaxrate"]; company:taxfreerate:se -> tax:in1:n [color="#319795", fontcolor="#319795", tooltip="FK_company_taxfreerate"]; - customergroup:company_id:se -> company:in0:n [color="#97266d", fontcolor="#97266d", tooltip="FK_customergroup_company"]; - entity:customergroup_id:sw -> customergroup:in0:n [color="#4c51bf", fontcolor="#4c51bf", tooltip="FK_entity_customergroup"]; - unittext:unit_id:e -> unit:in0:n [color="#276749", fontcolor="#276749", tooltip="FK_unittext_unit"]; - unittext:language_id:se -> language:in0:n [color="#9c4221", fontcolor="#9c4221", tooltip="FK_unittext_language"]; - article:unit_id:w -> unit:in1:n [color="#0987a0", fontcolor="#0987a0", tooltip="FK_article_unit"]; - article:brand_id:e -> brand:in0:n [color="#553c9a", fontcolor="#553c9a", tooltip="FK_article_brand"]; - article:articlelist_id:se -> articlelist:in0:n [color="#742a2a", fontcolor="#742a2a", tooltip="FK_article_articlelist"]; - article:optionslist_id:se -> optionslist:in0:n [color="#22543d", fontcolor="#22543d", tooltip="FK_article_optionslist"]; - article:fatherarticle_id:e -> article:id:e [color="#1a365d", fontcolor="#1a365d", tooltip="FK_article_fatherarticle"]; - articlehistory:article_id:se -> article:in4:n [color="#702459", fontcolor="#702459", tooltip="FK_articlehistory_article"]; - articleean:article_id:sw -> article:in6:n [color="#744210", fontcolor="#744210", tooltip="FK_articleean_article"]; - articlepic:article_id:w -> article:in9:n [color="#285e61", fontcolor="#285e61", tooltip="FK_articlepic_article"]; - articlepic:pic_id:sw -> pic:in1:n [color="#d53f8c", fontcolor="#d53f8c", tooltip="FK_articlepic_pic"]; - brandpic:brand_id:sw -> brand:in1:n [color="#dd6b20", fontcolor="#dd6b20", tooltip="FK_brandpic_brand"]; - brandpic:pic_id:sw -> pic:in2:n [color="#38a169", fontcolor="#38a169", tooltip="FK_brandpic_pic"]; - articlelistarticle:articlelist_id:sw -> articlelist:in1:n [color="#3182ce", fontcolor="#3182ce", tooltip="FK_articlelistarticle_articlelist"]; - articlelistarticle:article_id:sw -> article:in7:n [color="#805ad5", fontcolor="#805ad5", tooltip="FK_articlelistarticle_article"]; - optionslistarticle:optionslist_id:sw -> optionslist:in1:n [color="#c53030", fontcolor="#c53030", tooltip="FK_optionslistarticle_optionslist"]; - optionslistarticle:article_id:sw -> article:in8:n [color="#2f855a", fontcolor="#2f855a", tooltip="FK_optionslistarticle_article"]; - categoryarticle:category_id:sw -> category:in3:n [color="#2b6cb0", fontcolor="#2b6cb0", tooltip="FK_categoryarticle_category"]; - categoryarticle:article_id:se -> article:in3:n [color="#b7791f", fontcolor="#b7791f", tooltip="FK_categoryarticle_article"]; - categorypic:category_id:w -> category:in4:n [color="#6b46c1", fontcolor="#6b46c1", tooltip="FK_categorypic_category"]; - categorypic:pic_id:se -> pic:in0:n [color="#c05621", fontcolor="#c05621", tooltip="FK_categorypic_pic"]; - categorytext:category_id:e -> category:in0:n [color="#319795", fontcolor="#319795", tooltip="FK_categorytext_category"]; - categorytext:language_id:se -> language:in1:n [color="#97266d", fontcolor="#97266d", tooltip="FK_categorytext_language"]; - articletext:article_id:e -> article:in0:n [color="#4c51bf", fontcolor="#4c51bf", tooltip="FK_articletext_article"]; - articletext:language_id:sw -> language:in2:n [color="#276749", fontcolor="#276749", tooltip="FK_articletext_language"]; - price:tax_id:sw -> tax:in3:n [color="#9c4221", fontcolor="#9c4221", tooltip="FK_price_tax"]; - price:article_id:se -> article:in1:n [color="#0987a0", fontcolor="#0987a0", tooltip="FK_price_article"]; - price:customergroup_id:w -> customergroup:in1:n [color="#553c9a", fontcolor="#553c9a", tooltip="FK_price_customergroup"]; - manifestarticle:manifest_id:se -> manifest:in0:n [color="#742a2a", fontcolor="#742a2a", tooltip="FK_manifestarticle_manifest"]; - manifestarticle:article_id:e -> article:in2:n [color="#22543d", fontcolor="#22543d", tooltip="FK_manifestarticle_article"]; - manifestarticle:tax_id:sw -> tax:in4:n [color="#1a365d", fontcolor="#1a365d", tooltip="FK_manifestarticle_tax"]; - numbers:company_id:se -> company:in1:n [color="#702459", fontcolor="#702459", tooltip="FK_numbers_company"]; - companyuser:google_auth_id:se -> google_auth:in0:n [color="#744210", fontcolor="#744210", tooltip="FK_companyuser_google_auth"]; - companyuser:company_id:sw -> company:in2:n [color="#285e61", fontcolor="#285e61", tooltip="FK_companyuser_company"]; + customergroup:company_id:nw -> company:in3:n [color="#97266d", fontcolor="#97266d", tooltip="FK_customergroup_company"]; + company:poscustomergroup_id:se -> customergroup:in1:n [color="#4c51bf", fontcolor="#4c51bf", tooltip="FK_company_poscustomergroup"]; + entity:customergroup_id:se -> customergroup:in0:n [color="#276749", fontcolor="#276749", tooltip="FK_entity_customergroup"]; + unittext:unit_id:se -> unit:in0:n [color="#9c4221", fontcolor="#9c4221", tooltip="FK_unittext_unit"]; + unittext:language_id:se -> language:in1:n [color="#0987a0", fontcolor="#0987a0", tooltip="FK_unittext_language"]; + article:unit_id:w -> unit:in1:n [color="#553c9a", fontcolor="#553c9a", tooltip="FK_article_unit"]; + article:brand_id:e -> brand:in0:n [color="#742a2a", fontcolor="#742a2a", tooltip="FK_article_brand"]; + article:articlelist_id:se -> articlelist:in0:n [color="#22543d", fontcolor="#22543d", tooltip="FK_article_articlelist"]; + article:optionslist_id:se -> optionslist:in0:n [color="#1a365d", fontcolor="#1a365d", tooltip="FK_article_optionslist"]; + article:fatherarticle_id:e -> article:id:e [color="#702459", fontcolor="#702459", tooltip="FK_article_fatherarticle"]; + articlehistory:article_id:se -> article:in4:n [color="#744210", fontcolor="#744210", tooltip="FK_articlehistory_article"]; + articleean:article_id:sw -> article:in6:n [color="#285e61", fontcolor="#285e61", tooltip="FK_articleean_article"]; + articlepic:article_id:w -> article:in9:n [color="#d53f8c", fontcolor="#d53f8c", tooltip="FK_articlepic_article"]; + articlepic:pic_id:sw -> pic:in1:n [color="#dd6b20", fontcolor="#dd6b20", tooltip="FK_articlepic_pic"]; + brandpic:brand_id:sw -> brand:in1:n [color="#38a169", fontcolor="#38a169", tooltip="FK_brandpic_brand"]; + brandpic:pic_id:sw -> pic:in2:n [color="#3182ce", fontcolor="#3182ce", tooltip="FK_brandpic_pic"]; + articlelistarticle:articlelist_id:sw -> articlelist:in1:n [color="#805ad5", fontcolor="#805ad5", tooltip="FK_articlelistarticle_articlelist"]; + articlelistarticle:article_id:sw -> article:in7:n [color="#c53030", fontcolor="#c53030", tooltip="FK_articlelistarticle_article"]; + optionslistarticle:optionslist_id:sw -> optionslist:in1:n [color="#2f855a", fontcolor="#2f855a", tooltip="FK_optionslistarticle_optionslist"]; + optionslistarticle:article_id:sw -> article:in8:n [color="#2b6cb0", fontcolor="#2b6cb0", tooltip="FK_optionslistarticle_article"]; + categoryarticle:category_id:sw -> category:in3:n [color="#b7791f", fontcolor="#b7791f", tooltip="FK_categoryarticle_category"]; + categoryarticle:article_id:se -> article:in3:n [color="#6b46c1", fontcolor="#6b46c1", tooltip="FK_categoryarticle_article"]; + categorypic:category_id:w -> category:in4:n [color="#c05621", fontcolor="#c05621", tooltip="FK_categorypic_category"]; + categorypic:pic_id:se -> pic:in0:n [color="#319795", fontcolor="#319795", tooltip="FK_categorypic_pic"]; + categorytext:category_id:se -> category:in1:n [color="#97266d", fontcolor="#97266d", tooltip="FK_categorytext_category"]; + categorytext:language_id:sw -> language:in2:n [color="#4c51bf", fontcolor="#4c51bf", tooltip="FK_categorytext_language"]; + articletext:article_id:e -> article:in2:n [color="#276749", fontcolor="#276749", tooltip="FK_articletext_article"]; + articletext:language_id:sw -> language:in3:n [color="#9c4221", fontcolor="#9c4221", tooltip="FK_articletext_language"]; + price:tax_id:sw -> tax:in3:n [color="#0987a0", fontcolor="#0987a0", tooltip="FK_price_tax"]; + price:article_id:e -> article:in0:n [color="#553c9a", fontcolor="#553c9a", tooltip="FK_price_article"]; + price:customergroup_id:sw -> customergroup:in2:n [color="#742a2a", fontcolor="#742a2a", tooltip="FK_price_customergroup"]; + manifestarticle:manifest_id:sw -> manifest:in0:n [color="#22543d", fontcolor="#22543d", tooltip="FK_manifestarticle_manifest"]; + manifestarticle:article_id:e -> article:in1:n [color="#1a365d", fontcolor="#1a365d", tooltip="FK_manifestarticle_article"]; + manifestarticle:tax_id:sw -> tax:in4:n [color="#702459", fontcolor="#702459", tooltip="FK_manifestarticle_tax"]; + numbers:company_id:se -> company:in0:n [color="#744210", fontcolor="#744210", tooltip="FK_numbers_company"]; + companyuser:google_auth_id:se -> google_auth:in0:n [color="#285e61", fontcolor="#285e61", tooltip="FK_companyuser_google_auth"]; + companyuser:company_id:se -> company:in1:n [color="#d53f8c", fontcolor="#d53f8c", tooltip="FK_companyuser_company"]; } \ No newline at end of file diff --git a/schema-graph.svg b/schema-graph.svg index 92cc07f..b43fd1a 100644 --- a/schema-graph.svg +++ b/schema-graph.svg @@ -4,306 +4,380 @@ - - + + schema - + cluster_identity - -Identity & Company + +Identity & Company cluster_i18n - -Localization + +Localization cluster_catalog - -Catalog + +Catalog cluster_media - -Media + +Media cluster_commerce - -Pricing & Documents + +Pricing & Documents cluster_units - -Units + +Units meta - - -meta - - -key -NVARCHAR(255) - - -value -NVARCHAR(MAX) + + +meta + + +key +NVARCHAR(255) + + +value +NVARCHAR(MAX) + + + +entity + + +entity + + +id +INT +PK + + +customergroup_id +INT +FK + + +firstname +NVARCHAR(255) + + +lastname +NVARCHAR(255) - + customergroup - - - - -customergroup - - -id -INT -PK - - -company_id -INT -FK - - -name -NVARCHAR(255) + + + + + +customergroup + + +id +INT +PK + + +company_id +INT +FK + + +name +NVARCHAR(255) - - -company - - - - - - -company - - -id -INT -PK - - -default_language_id -? -INT -FK - - -taxrate -? -INT -FK - - -reducedtaxrate -? -INT -FK - - -taxfreerate -? -INT -FK - - -root_category_id -? -INT -FK - - -name -NVARCHAR(255) - - - -customergroup:se->company:n - - - + + +entity:se->customergroup:n + + + numbers - - -numbers - - -id -INT -PK - - -company_id -INT -FK - - -type -NVARCHAR(100) - - -prefix -NVARCHAR(50) - - -padding -INT - - -next -INT + + +numbers + + +id +INT +PK + + +company_id +INT +FK + + +type +NVARCHAR(100) + + +prefix +NVARCHAR(50) + + +padding +INT + + +next +INT + + + +company + + + + + + +company + + +id +INT +PK + + +poscustomergroup_id +? +INT +FK + + +taxrate +? +INT +FK + + +reducedtaxrate +? +INT +FK + + +taxfreerate +? +INT +FK + + +default_language_id +? +INT +FK + + +root_category_id +? +INT +FK + + +name +NVARCHAR(255) - + numbers:se->company:n - - - + + + - + -entity - - -entity - - -id -INT -PK - - -customergroup_id -INT -FK - - -firstname -NVARCHAR(255) - - -lastname -NVARCHAR(255) +companyuser + + +companyuser + + +id +INT +PK + + +company_id +INT +FK + + +google_auth_id +INT +FK - + + +companyuser:se->company:n + + + + + + + + +google_auth + + + +google_auth + + +id +INT +PK + + +selected_company +? +INT +FK + + +email +NVARCHAR(320) + + +created_at +DATETIME2 + + + +companyuser:se->google_auth:n + + + + + + + -entity:sw->customergroup:n - - - +company:se->customergroup:n + + + - + language - - - - - - -language - - -id -INT -PK - - -iso639 -NVARCHAR(10) + + + + + + +language + + +id +INT +PK + + +iso639 +NVARCHAR(10) -company:w->language:n +company:e->language:n - - + + category - - - - - - - -category - - -id -INT -PK - - -parent_id -? -INT -FK - - -sort -INT + + + + + + + +category + + +id +INT +PK + + +parent_id +? +INT +FK + + +sort +INT company:e->category:n - - + + tax - - - - - - - -tax - - -id -INT -PK - - -rate -DECIMAL(18,4) + + + + + + + +tax + + +id +INT +PK + + +rate +DECIMAL(18,4) company:se->tax:n - - + + @@ -311,8 +385,8 @@ company:se->tax:n - - + + @@ -320,75 +394,8 @@ company:se->tax:n - - - - - - - -companyuser - - -companyuser - - -id -INT -PK - - -company_id -INT -FK - - -google_auth_id -INT -FK - - - -companyuser:sw->company:n - - - - - - - - -google_auth - - - -google_auth - - -id -INT -PK - - -selected_company -? -INT -FK - - -email -NVARCHAR(320) - - -created_at -DATETIME2 - - - -companyuser:se->google_auth:n - - - + + @@ -396,273 +403,282 @@ google_auth:sw->company:n - - + + + + + + + +customergroup:nw->company: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:se->language:n - - - + + + unit - - - - -unit - - -id -INT -PK - - -si -NVARCHAR(50) - - -basesi -NVARCHAR(50) - - -basesifactor -FLOAT + + + + +unit + + +id +INT +PK + + +si +NVARCHAR(50) + + +basesi +NVARCHAR(50) + + +basesifactor +FLOAT - -unittext:e->unit:n - - - + +unittext:se->unit: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:se->language:n - - - + +categorytext:sw->language:n + + + - -categorytext:e->category:n - - - + +categorytext:se->category: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 + + +id +INT +PK + + +language_id +INT +FK + + +article_id +INT +FK + + +name +NVARCHAR(255) + + +slug +NVARCHAR(255) + + +seo? +NVARCHAR(MAX) + + +desc? +NVARCHAR(MAX) - + articletext:sw->language:n - - - + + + article - - - - - - - - - - - - -article - - -id -INT -PK - - -unit_id -INT -FK - - -fatherarticle_id -? -INT -FK - - -articlelist_id -? -INT -FK - - -optionslist_id -? -INT -FK - - -brand_id -? -INT -FK - - -sku? -NVARCHAR(100) - - -unitamount -FLOAT - - -weight -FLOAT - - -is_father -BIT - - -fathersort -INT - - -created_at -DATETIME2 - - -updated_at -DATETIME2 + + + + + + + + + + + + +article + + +id +INT +PK + + +unit_id +INT +FK + + +fatherarticle_id +? +INT +FK + + +articlelist_id +? +INT +FK + + +optionslist_id +? +INT +FK + + +brand_id +? +INT +FK + + +sku? +NVARCHAR(100) + + +unitamount +FLOAT + + +weight +FLOAT + + +is_father +BIT + + +fathersort +INT + + +created_at +DATETIME2 + + +updated_at +DATETIME2 - + articletext:e->article:n - - - + + + @@ -670,677 +686,677 @@ category:e->category:e - - + + categoryarticle - - -categoryarticle - - -id -INT -PK - - -category_id -INT -FK - - -article_id -INT -FK + + +categoryarticle + + +id +INT +PK + + +category_id +INT +FK + + +article_id +INT +FK - + categoryarticle:sw->category:n - - - + + + - + categoryarticle:se->article:n - - - + + + articlehistory - - -articlehistory - - -id -INT -PK - - -article_id -INT -FK - - -date -DATETIME2 - - -text -NVARCHAR(MAX) + + +articlehistory + + +id +INT +PK + + +article_id +INT +FK + + +date +DATETIME2 + + +text +NVARCHAR(MAX) - + articlehistory:se->article:n - - - + + + - + article:e->article:e - - - + + + articlelist - - - - -articlelist - - -id -INT -PK + + + + +articlelist + + +id +INT +PK - + article:se->articlelist:n - - - + + + optionslist - - - - -optionslist - - -id -INT -PK + + + + +optionslist + + +id +INT +PK - + article:se->optionslist:n - - - + + + brand - - - - -brand - - -id -INT -PK - - -name -NVARCHAR(255) - - -gsprpinfo? -NVARCHAR(MAX) + + + + +brand + + +id +INT +PK + + +name +NVARCHAR(255) + + +gsprpinfo? +NVARCHAR(MAX) - + article:e->brand:n - - - + + + - + article:w->unit:n - - - + + + articleean - - -articleean - - -id -INT -PK - - -article_id -INT -FK - - -ean -NVARCHAR(50) + + +articleean + + +id +INT +PK + + +article_id +INT +FK + + +ean +NVARCHAR(50) - + articleean:sw->article:n - - - + + + articlelistarticle - - -articlelistarticle - - -id -INT -PK - - -article_id -INT -FK - - -articlelist_id -INT -FK - - -count -INT - - -sort -INT + + +articlelistarticle + + +id +INT +PK + + +article_id +INT +FK + + +articlelist_id +INT +FK + + +count +INT + + +sort +INT - + articlelistarticle:sw->article:n - - - + + + - + articlelistarticle:sw->articlelist:n - - - + + + optionslistarticle - - -optionslistarticle - - -id -INT -PK - - -article_id -INT -FK - - -optionslist_id -INT -FK - - -count -INT - - -sort -INT - - -mandated -BIT + + +optionslistarticle + + +id +INT +PK + + +article_id +INT +FK + + +optionslist_id +INT +FK + + +count +INT + + +sort +INT + + +mandated +BIT - + optionslistarticle:sw->article:n - - - + + + - + optionslistarticle:sw->optionslist:n - - - + + + categorypic - - -categorypic - - -id -INT -PK - - -category_id -INT -FK - - -pic_id -INT -FK - - -sort -INT + + +categorypic + + +id +INT +PK + + +category_id +INT +FK + + +pic_id +INT +FK + + +sort +INT - + categorypic:w->category:n - - - + + + pic - - - - - -pic - - -id -INT -PK - - -w -INT - - -h -INT - - -size -BIGINT - - -hash -NVARCHAR(64) - - -thumbw -INT - - -thumbh -INT - - -thumbhash -NVARCHAR(64) - - -source -NVARCHAR(MAX) + + + + + +pic + + +id +INT +PK + + +w +INT + + +h +INT + + +size +BIGINT + + +hash +NVARCHAR(64) + + +thumbw +INT + + +thumbh +INT + + +thumbhash +NVARCHAR(64) + + +source +NVARCHAR(MAX) - + categorypic:se->pic:n - - - + + + articlepic - - -articlepic - - -id -INT -PK - - -article_id -INT -FK - - -pic_id -INT -FK - - -sort -INT + + +articlepic + + +id +INT +PK + + +article_id +INT +FK + + +pic_id +INT +FK + + +sort +INT - + articlepic:w->article:n - - - + + + - + articlepic:sw->pic:n - - - + + + brandpic - - -brandpic - - -id -INT -PK - - -brand_id -INT -FK - - -pic_id -INT -FK - - -sort -INT + + +brandpic + + +id +INT +PK + + +brand_id +INT +FK + + +pic_id +INT +FK + + +sort +INT - + brandpic:sw->brand:n - - - + + + - + brandpic:sw->pic:n - - - + + + price - - -price - - -id -INT -PK - - -customergroup_id -INT -FK - - -tax_id -INT -FK - - -article_id -INT -FK - - -minmultiplier -DECIMAL(18,4) - - -net -FLOAT + + +price + + +id +INT +PK + + +customergroup_id +INT +FK + + +tax_id +INT +FK + + +article_id +INT +FK + + +minmultiplier +DECIMAL(18,4) + + +net +FLOAT - -price:w->customergroup:n - - - + +price:sw->customergroup:n + + + - -price:se->article:n - - - + +price:e->article:n + + + - + price:sw->tax:n - - - - - - - - -manifestarticle - - -manifestarticle - - -id -INT -PK - - -tax_id -INT -FK - - -manifest_id -INT -FK - - -article_id -INT -FK - - -amount -FLOAT - - -net -FLOAT - - -transactedamount -FLOAT - - -comment? -NVARCHAR(MAX) - - -rebate -FLOAT - - - -manifestarticle:e->article:n - - - - - - - - -manifestarticle:sw->tax:n - - - + + + - + manifest - - - -manifest - - -id -INT -PK - - -number -NVARCHAR(100) - - -type -NVARCHAR(100) - - -date -DATETIME2 + + + +manifest + + +id +INT +PK + + +number +NVARCHAR(100) + + +type +NVARCHAR(100) + + +date +DATETIME2 + + + +manifestarticle + + +manifestarticle + + +id +INT +PK + + +tax_id +INT +FK + + +manifest_id +INT +FK + + +article_id +INT +FK + + +amount +FLOAT + + +net +FLOAT + + +transactedamount +FLOAT + + +comment? +NVARCHAR(MAX) + + +rebate +FLOAT + + + +manifestarticle:e->article:n + + + + + + + + +manifestarticle:sw->tax:n + + + + + - -manifestarticle:se->manifest:n - - - + +manifestarticle:sw->manifest:n + + + __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-api.js b/scripts/test-api.js index 2d239af..8fce9f2 100644 --- a/scripts/test-api.js +++ b/scripts/test-api.js @@ -70,9 +70,9 @@ const MENU = { company: { label: 'company', items: [ - { value: 'company.create', label: 'create', hint: 'authId, iso639, name, taxes → result' }, + { value: 'company.create', label: 'create', hint: 'googleAuthId, iso639, name, taxes → result' }, { value: 'company.update', label: 'update', hint: 'id + fields → result' }, - { value: 'company.list', label: 'list', hint: 'authId → result[]' }, + { value: 'company.list', label: 'list', hint: 'googleAuthId → result[]' }, { value: 'company.delete', label: 'delete', hint: 'companyId' }, ], }, @@ -95,17 +95,16 @@ const MENU = { category: { label: 'category', items: [ - { value: 'category.create', label: 'create', hint: '{parentId, sort} → id' }, + { value: 'category.createUnderParent', label: 'createUnderParent', hint: 'googleAuthId, parentId, name → row' }, { value: 'category.updateParentId', label: 'updateParentId', hint: 'id, parentId' }, { value: 'category.delete', label: 'delete', hint: 'id' }, - { value: 'category.getTree', label: 'getTree', hint: 'rootId → rows' }, - { value: 'category.getTreeForCompany', label: 'getTreeForCompany', hint: 'authId, companyId → rows' }, + { value: 'category.getTreeForCompany', label: 'getTreeForCompany', hint: 'googleAuthId, companyId → rows' }, ], }, companyuser: { label: 'companyuser', items: [ - { value: 'companyuser.add', label: 'add', hint: 'companyId, authId → id' }, + { value: 'companyuser.add', label: 'add', hint: 'companyId, googleAuthId → id' }, { value: 'companyuser.delete', label: 'delete', hint: 'id' }, { value: 'companyuser.getByEmail', label: 'getByEmail', hint: 'email → ids' }, ], @@ -117,7 +116,7 @@ const MENU = { { value: 'google_auth.getByEmail', label: 'getByEmail', hint: 'email → row' }, { value: 'google_auth.listAllowedEmails', label: 'listAllowedEmails', hint: '→ rows' }, { value: 'google_auth.removeAllowedEmail', label: 'removeAllowedEmail', hint: 'email' }, - { value: 'google_auth.setSelectedCompany', label: 'setSelectedCompany', hint: 'authId, companyId' }, + { value: 'google_auth.setSelectedCompany', label: 'setSelectedCompany', hint: 'googleAuthId, companyId' }, ], }, schema: { @@ -180,7 +179,7 @@ async function runMethod(client, action) { if (reducedtaxrate == null) return; const existing = await client.google_auth.getByEmail(email); - const authId = existing + const googleAuthId = existing ? existing.id : await client.google_auth.add(email); @@ -189,16 +188,16 @@ async function runMethod(client, action) { name, taxrate, reducedtaxrate, - authId, + googleAuthId, }); const companyId = created.id; - await client.google_auth.setSelectedCompany(authId, companyId); + await client.google_auth.setSelectedCompany(googleAuthId, companyId); return [ 'created', { ...created, - authId, + googleAuthId, email, name, iso639, @@ -208,8 +207,8 @@ async function runMethod(client, action) { ]; } case 'company.create': { - const authId = await askInt('google_auth id'); - if (authId == null) return; + const googleAuthId = await askInt('google_auth id'); + if (googleAuthId == null) return; const name = await askText('Name', { placeholder: 'Acme', validate: (v) => (!v?.trim() ? 'Required' : undefined), @@ -231,7 +230,7 @@ async function runMethod(client, action) { name, taxrate, reducedtaxrate, - authId: Number(authId), + googleAuthId: Number(googleAuthId), }), ]; } @@ -263,9 +262,9 @@ async function runMethod(client, action) { ]; } case 'company.list': { - const authId = await askInt('google_auth id'); - if (authId == null) return; - return ['result[]', await client.company.list(Number(authId))]; + const googleAuthId = await askInt('google_auth id'); + if (googleAuthId == null) return; + return ['result[]', await client.company.list(Number(googleAuthId))]; } case 'company.delete': { const companyId = await askInt('Company id'); @@ -303,17 +302,23 @@ async function runMethod(client, action) { await client.lang.delete(Number(id)); return ['ok', true]; } - case 'category.create': { - const parentRaw = await askText('parentId (null as empty)', { - placeholder: '', - validate: (v) => - v?.trim() && !/^\d+$/.test(v.trim()) ? 'Integer or empty' : undefined, + case 'category.createUnderParent': { + const googleAuthId = await askInt('google_auth id'); + if (googleAuthId == null) return; + const parentId = await askInt('parent category id'); + if (parentId == null) return; + const name = await askText('name', { + validate: (v) => (!v?.trim() ? 'Required' : undefined), }); - if (parentRaw == null) return; - const sortRaw = await askInt('sort', '0'); - if (sortRaw == null) return; - const parentId = parentRaw === '' ? null : Number(parentRaw); - return ['id', await client.category.create({ parentId, sort: Number(sortRaw) })]; + if (name == null) return; + return [ + 'result', + await client.category.createUnderParent({ + googleAuthId: Number(googleAuthId), + parentId: Number(parentId), + name, + }), + ]; } case 'category.updateParentId': { const id = await askInt('Category id'); @@ -334,27 +339,22 @@ async function runMethod(client, action) { await client.category.delete(Number(id)); return ['ok', true]; } - case 'category.getTree': { - const rootId = await askInt('Root category id'); - if (rootId == null) return; - return ['rows', await client.category.getTree(Number(rootId))]; - } case 'category.getTreeForCompany': { - const authId = await askInt('google_auth id'); - if (authId == null) return; + const googleAuthId = await askInt('google_auth id'); + if (googleAuthId == null) return; const companyId = await askInt('company id'); if (companyId == null) return; return [ 'rows', - await client.category.getTreeForCompany(Number(authId), Number(companyId)), + await client.category.getTreeForCompany(Number(googleAuthId), Number(companyId)), ]; } case 'companyuser.add': { const companyId = await askInt('Company id'); if (companyId == null) return; - const authId = await askInt('Auth id'); - if (authId == null) return; - return ['id', await client.companyuser.add(Number(companyId), Number(authId))]; + const googleAuthId = await askInt('Auth id'); + if (googleAuthId == null) return; + return ['id', await client.companyuser.add(Number(companyId), Number(googleAuthId))]; } case 'companyuser.delete': { const id = await askInt('Company user id'); diff --git a/src/index.js b/src/index.js index 83a32dd..0fbbc9c 100644 --- a/src/index.js +++ b/src/index.js @@ -5,9 +5,8 @@ import companyCreate from './store/company/create.js'; import companyUpdate from './store/company/update.js'; import companyDelete from './store/company/delete.js'; import companyList from './store/company/list.js'; -import categoryGetTree from './store/category/getTree.js'; import categoryGetTreeForCompany from './store/category/getTreeForCompany.js'; -import categoryCreate from './store/category/create.js'; +import categoryCreateUnderParent from './store/category/createUnderParent.js'; import categoryUpdateParentId from './store/category/updateParentId.js'; import categoryDelete from './store/category/delete.js'; import langCreate from './store/lang/create.js'; @@ -45,9 +44,8 @@ export default class LibDashStore { }; this.category = { - getTree: categoryGetTree(pool), getTreeForCompany: categoryGetTreeForCompany(pool), - create: categoryCreate(pool), + createUnderParent: categoryCreateUnderParent(pool), updateParentId: categoryUpdateParentId(pool), delete: categoryDelete(pool), }; diff --git a/src/schema.js b/src/schema.js index 0815bdd..f23fc45 100644 --- a/src/schema.js +++ b/src/schema.js @@ -106,6 +106,7 @@ BEGIN taxrate INT NULL, reducedtaxrate INT NULL, taxfreerate INT NULL, + poscustomergroup_id INT NULL, CONSTRAINT UQ_company_name UNIQUE (name), CONSTRAINT FK_company_root_category FOREIGN KEY (root_category_id) REFERENCES category(id) ON DELETE SET NULL, CONSTRAINT FK_company_default_language FOREIGN KEY (default_language_id) REFERENCES language(id) ON DELETE SET NULL, @@ -128,6 +129,17 @@ BEGIN END `; +export const INIT_COMPANY_POS_CUSTOMERGROUP_FK = ` +IF NOT EXISTS ( + SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_company_poscustomergroup' +) +BEGIN + ALTER TABLE company + ADD CONSTRAINT FK_company_poscustomergroup + FOREIGN KEY (poscustomergroup_id) REFERENCES customergroup(id) ON DELETE NO ACTION; +END +`; + export const INIT_ENTITY = ` IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'entity') BEGIN @@ -516,6 +528,7 @@ export const SCHEMA_SCRIPTS = [ INIT_COMPANY, INIT_GOOGLE_AUTH, INIT_CUSTOMERGROUP, + INIT_COMPANY_POS_CUSTOMERGROUP_FK, INIT_ENTITY, INIT_UNIT, INIT_UNITTEXT, diff --git a/src/sql/category.js b/src/sql/category.js index eac2e22..580a602 100644 --- a/src/sql/category.js +++ b/src/sql/category.js @@ -1,4 +1,4 @@ -export const GET_TREE = ` +export const GET_TREE_FOR_COMPANY = ` WITH tree AS ( SELECT id, parent_id, sort FROM category @@ -10,17 +10,122 @@ WITH tree AS ( FROM category c INNER JOIN tree t ON c.parent_id = t.id ) -SELECT id, parent_id AS parentId, sort -FROM tree -ORDER BY sort, id +SELECT + t.id, + t.parent_id AS parentId, + t.sort, + ct.name, + p.thumbw AS thumbW, + p.thumbh AS thumbH, + p.thumbhash AS thumbHash +FROM tree t +LEFT JOIN categorytext ct + ON ct.category_id = t.id AND ct.language_id = @languageId +OUTER APPLY ( + SELECT TOP 1 cp.pic_id + FROM categorypic cp + WHERE cp.category_id = t.id + ORDER BY cp.sort ASC, cp.id ASC +) first_pic +LEFT JOIN pic p ON p.id = first_pic.pic_id +ORDER BY t.sort, t.id +`; + +export const GET_ROOT_CATEGORY_ARTICLES = ` +SELECT + a.id, + a.sku, + at.name, + at.slug, + pr.net, + t.rate AS taxRate, + CASE + WHEN a.optionslist_id IS NOT NULL THEN ( + SELECT COUNT(*) + FROM optionslistarticle ola + WHERE ola.optionslist_id = a.optionslist_id + ) + ELSE 0 + END AS optionsCount, + CASE + WHEN a.articlelist_id IS NOT NULL THEN ( + SELECT COUNT(*) + FROM articlelistarticle ala + WHERE ala.articlelist_id = a.articlelist_id + ) + ELSE 0 + END AS articleListCount, + ( + SELECT COUNT(*) + FROM article child + WHERE child.fatherarticle_id = a.id + ) AS childCount, + pic.thumbw AS thumbW, + pic.thumbh AS thumbH, + pic.thumbhash AS thumbHash +FROM categoryarticle ca +INNER JOIN article a ON a.id = ca.article_id +LEFT JOIN articletext at + ON at.article_id = a.id AND at.language_id = @languageId +LEFT JOIN price pr + ON pr.article_id = a.id + AND pr.customergroup_id = @posCustomerGroupId + AND pr.minmultiplier = 1 +LEFT JOIN tax t ON t.id = pr.tax_id +OUTER APPLY ( + SELECT TOP 1 ap.pic_id + FROM articlepic ap + WHERE ap.article_id = a.id + ORDER BY ap.sort ASC, ap.id ASC +) first_pic +LEFT JOIN pic ON pic.id = first_pic.pic_id +WHERE ca.category_id = @rootCategoryId +ORDER BY a.id `; export const CREATE = ` INSERT INTO category (parent_id, sort) -OUTPUT INSERTED.id +OUTPUT INSERTED.id, INSERTED.parent_id AS parentId, INSERTED.sort VALUES (@parentId, @sort) `; +export const RESOLVE_CATEGORY_ACCESS = ` +WITH ancestors AS ( + SELECT id, parent_id + FROM category + WHERE id = @categoryId + + UNION ALL + + SELECT c.id, c.parent_id + FROM category c + INNER JOIN ancestors a ON c.id = a.parent_id +), +root AS ( + SELECT TOP 1 id AS rootCategoryId + FROM ancestors + WHERE parent_id IS NULL +) +SELECT + p.id AS categoryId, + r.rootCategoryId, + co.id AS companyId, + co.default_language_id AS languageId, + cu.id AS companyuserId +FROM category p +LEFT JOIN root r ON 1 = 1 +LEFT JOIN company co ON co.root_category_id = r.rootCategoryId +LEFT JOIN companyuser cu + ON cu.company_id = co.id AND cu.google_auth_id = @googleAuthId +WHERE p.id = @categoryId +`; + +export const GET_NEXT_SIBLING_SORT = ` +SELECT ISNULL(MAX(sort), -1) + 1 AS sort +FROM category +WHERE parent_id = @parentId +`; + export const UPDATE_PARENT_ID = ` UPDATE category SET parent_id = @parentId diff --git a/src/sql/categorytext.js b/src/sql/categorytext.js new file mode 100644 index 0000000..e35a170 --- /dev/null +++ b/src/sql/categorytext.js @@ -0,0 +1,4 @@ +export const CREATE = ` +INSERT INTO categorytext (category_id, language_id, name, slug) +VALUES (@categoryId, @languageId, @name, @slug) +`; diff --git a/src/sql/company.js b/src/sql/company.js index 3f4f13b..32fcdbb 100644 --- a/src/sql/company.js +++ b/src/sql/company.js @@ -54,7 +54,7 @@ INNER JOIN companyuser cu ON cu.company_id = c.id LEFT JOIN tax tr ON tr.id = c.taxrate LEFT JOIN tax rtr ON rtr.id = c.reducedtaxrate LEFT JOIN tax tfr ON tfr.id = c.taxfreerate -WHERE cu.google_auth_id = @authId +WHERE cu.google_auth_id = @googleAuthId `; export const DELETE = ` diff --git a/src/sql/companyuser.js b/src/sql/companyuser.js index cd0082d..1e2c966 100644 --- a/src/sql/companyuser.js +++ b/src/sql/companyuser.js @@ -8,17 +8,19 @@ WHERE ga.email = @email export const GET_COMPANY_ACCESS = ` SELECT c.root_category_id AS rootCategoryId, + c.default_language_id AS languageId, + c.poscustomergroup_id AS posCustomerGroupId, cu.id AS companyuserId FROM company c LEFT JOIN companyuser cu - ON cu.company_id = c.id AND cu.google_auth_id = @authId + ON cu.company_id = c.id AND cu.google_auth_id = @googleAuthId WHERE c.id = @companyId `; export const ADD = ` INSERT INTO companyuser (google_auth_id, company_id) OUTPUT INSERTED.id -VALUES (@authId, @companyId) +VALUES (@googleAuthId, @companyId) `; export const DELETE = ` diff --git a/src/sql/google_auth.js b/src/sql/google_auth.js index 53adbac..3c5e215 100644 --- a/src/sql/google_auth.js +++ b/src/sql/google_auth.js @@ -24,5 +24,5 @@ WHERE email = @email export const SET_SELECTED_COMPANY = ` UPDATE google_auth SET selected_company = @companyId -WHERE id = @id +WHERE id = @googleAuthId `; diff --git a/src/sql/purge.js b/src/sql/purge.js index 1c85a01..71546b6 100644 --- a/src/sql/purge.js +++ b/src/sql/purge.js @@ -34,12 +34,19 @@ export const TABLES = [ export const PROCEDURES = ['getnextnumberwithprefixandpadding']; +/** Drop before tables — breaks company ↔ customergroup cycle for purge. */ +export const CONSTRAINTS = [ + `IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_company_poscustomergroup') + ALTER TABLE company DROP CONSTRAINT FK_company_poscustomergroup;`, +]; + export function buildPurgeSql() { const drops = [ ...PROCEDURES.map( (name) => `IF OBJECT_ID(N'dbo.${name}', N'P') IS NOT NULL DROP PROCEDURE dbo.[${name}];`, ), + ...CONSTRAINTS, ...TABLES.map( (name) => `IF OBJECT_ID(N'dbo.${name}', N'U') IS NOT NULL DROP TABLE dbo.[${name}];`, diff --git a/src/store/category/create.js b/src/store/category/create.js deleted file mode 100644 index a236859..0000000 --- a/src/store/category/create.js +++ /dev/null @@ -1,13 +0,0 @@ -import { CREATE } from '../../sql/category.js'; - -export default function create(pool) { - return async ({ parentId, sort }) => { - const result = await pool - .request() - .input('parentId', parentId) - .input('sort', sort) - .query(CREATE); - - return result.recordset[0].id; - }; -} diff --git a/src/store/category/createUnderParent.js b/src/store/category/createUnderParent.js new file mode 100644 index 0000000..f5864f3 --- /dev/null +++ b/src/store/category/createUnderParent.js @@ -0,0 +1,67 @@ +import { + CREATE, + GET_NEXT_SIBLING_SORT, +} from '../../sql/category.js'; +import { CREATE as CREATE_CATEGORYTEXT } from '../../sql/categorytext.js'; +import resolveCategoryAccess from './resolveCategoryAccess.js'; +import request from '../request.js'; + +function slugify(name) { + return name + .trim() + .toLowerCase() + .normalize('NFD') + .replace(/\p{M}/gu, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +export default function createUnderParent(pool) { + const access = resolveCategoryAccess(pool); + + return async ({ googleAuthId, parentId, name }) => { + const { languageId } = await access(googleAuthId, parentId); + + const tx = pool.transaction(); + await tx.begin(); + + try { + const sortResult = await request(pool, tx) + .input('parentId', parentId) + .query(GET_NEXT_SIBLING_SORT); + + const sort = sortResult.recordset[0].sort; + const slug = slugify(name); + + const category = await request(pool, tx) + .input('parentId', parentId) + .input('sort', sort) + .query(CREATE); + + const { id, parentId: resolvedParentId } = category.recordset[0]; + + await request(pool, tx) + .input('categoryId', id) + .input('languageId', languageId) + .input('name', name) + .input('slug', slug) + .query(CREATE_CATEGORYTEXT); + + await tx.commit(); + + return { + id, + parentId: resolvedParentId, + sort, + name, + }; + } catch (err) { + try { + await tx.rollback(); + } catch { + // ignore + } + throw err; + } + }; +} diff --git a/src/store/category/getTree.js b/src/store/category/getTree.js deleted file mode 100644 index 1acc2de..0000000 --- a/src/store/category/getTree.js +++ /dev/null @@ -1,12 +0,0 @@ -import { GET_TREE } from '../../sql/category.js'; - -export default function getTree(pool) { - return async (rootId) => { - const result = await pool - .request() - .input('rootId', rootId) - .query(GET_TREE); - - return result.recordset; - }; -} diff --git a/src/store/category/getTreeForCompany.js b/src/store/category/getTreeForCompany.js index e594e2d..4e0fda4 100644 --- a/src/store/category/getTreeForCompany.js +++ b/src/store/category/getTreeForCompany.js @@ -1,17 +1,36 @@ -import { GET_TREE } from '../../sql/category.js'; +import { + GET_ROOT_CATEGORY_ARTICLES, + GET_TREE_FOR_COMPANY, +} from '../../sql/category.js'; import resolveCompanyAccess from '../companyuser/resolveCompanyAccess.js'; export default function getTreeForCompany(pool) { const access = resolveCompanyAccess(pool); - return async (authId, companyId) => { - const { rootCategoryId } = await access(authId, companyId); + return async (googleAuthId, companyId) => { + const { rootCategoryId, languageId, posCustomerGroupId } = await access( + googleAuthId, + companyId, + ); - const result = await pool - .request() - .input('rootId', rootCategoryId) - .query(GET_TREE); + const [treeResult, articlesResult] = await Promise.all([ + pool + .request() + .input('rootId', rootCategoryId) + .input('languageId', languageId) + .query(GET_TREE_FOR_COMPANY), + pool + .request() + .input('rootCategoryId', rootCategoryId) + .input('languageId', languageId) + .input('posCustomerGroupId', posCustomerGroupId) + .query(GET_ROOT_CATEGORY_ARTICLES), + ]); - return result.recordset; + return { + languageId, + tree: treeResult.recordset, + articles: articlesResult.recordset, + }; }; } diff --git a/src/store/category/resolveCategoryAccess.js b/src/store/category/resolveCategoryAccess.js new file mode 100644 index 0000000..00c8a17 --- /dev/null +++ b/src/store/category/resolveCategoryAccess.js @@ -0,0 +1,28 @@ +import { RESOLVE_CATEGORY_ACCESS } from '../../sql/category.js'; + +export default function resolveCategoryAccess(pool) { + return async (googleAuthId, categoryId) => { + const result = await pool + .request() + .input('categoryId', categoryId) + .input('googleAuthId', googleAuthId) + .query(RESOLVE_CATEGORY_ACCESS); + + const row = result.recordset[0]; + if (!row) { + throw new Error('Parent not found'); + } + if (!row.companyId || !row.rootCategoryId || !row.languageId) { + throw new Error('Parent not found'); + } + if (!row.companyuserId) { + throw new Error('Not authorized'); + } + + return { + companyId: row.companyId, + rootCategoryId: row.rootCategoryId, + languageId: row.languageId, + }; + }; +} diff --git a/src/store/company/create.js b/src/store/company/create.js index 488475a..4e1eee0 100644 --- a/src/store/company/create.js +++ b/src/store/company/create.js @@ -11,7 +11,7 @@ export default function create(pool) { const ensureLang = langEnsure(pool); const ensureTax = taxEnsure(pool); - return async ({ iso639, name, taxrate, reducedtaxrate, authId }) => { + return async ({ iso639, name, taxrate, reducedtaxrate, googleAuthId }) => { const tx = pool.transaction(); await tx.begin(); @@ -46,7 +46,7 @@ export default function create(pool) { await request(pool, tx) .input('companyId', companyId) - .input('authId', authId) + .input('googleAuthId', googleAuthId) .query(ADD_COMPANYUSER); await tx.commit(); diff --git a/src/store/company/list.js b/src/store/company/list.js index 9551b63..e057f2a 100644 --- a/src/store/company/list.js +++ b/src/store/company/list.js @@ -1,10 +1,10 @@ import { LIST } from '../../sql/company.js'; export default function list(pool) { - return async (authId) => { + return async (googleAuthId) => { const result = await pool .request() - .input('authId', authId) + .input('googleAuthId', googleAuthId) .query(LIST); return result.recordset; }; diff --git a/src/store/companyuser/add.js b/src/store/companyuser/add.js index 525d2b8..13bd22b 100644 --- a/src/store/companyuser/add.js +++ b/src/store/companyuser/add.js @@ -1,11 +1,11 @@ import { ADD } from '../../sql/companyuser.js'; export default function add(pool) { - return async (companyId, authId) => { + return async (companyId, googleAuthId) => { const result = await pool .request() .input('companyId', companyId) - .input('authId', authId) + .input('googleAuthId', googleAuthId) .query(ADD); return result.recordset[0].id; diff --git a/src/store/companyuser/resolveCompanyAccess.js b/src/store/companyuser/resolveCompanyAccess.js index 838994f..f21b04d 100644 --- a/src/store/companyuser/resolveCompanyAccess.js +++ b/src/store/companyuser/resolveCompanyAccess.js @@ -1,10 +1,10 @@ import { GET_COMPANY_ACCESS } from '../../sql/companyuser.js'; export default function resolveCompanyAccess(pool) { - return async (authId, companyId) => { + return async (googleAuthId, companyId) => { const result = await pool .request() - .input('authId', authId) + .input('googleAuthId', googleAuthId) .input('companyId', companyId) .query(GET_COMPANY_ACCESS); @@ -15,10 +15,14 @@ export default function resolveCompanyAccess(pool) { if (!row.companyuserId) { throw new Error('Not authorized'); } - if (!row.rootCategoryId) { + if (!row.rootCategoryId || !row.languageId) { throw new Error('Company not found'); } - return { rootCategoryId: row.rootCategoryId }; + return { + rootCategoryId: row.rootCategoryId, + languageId: row.languageId, + posCustomerGroupId: row.posCustomerGroupId ?? null, + }; }; } diff --git a/src/store/google_auth/setSelectedCompany.js b/src/store/google_auth/setSelectedCompany.js index 699f133..203adf2 100644 --- a/src/store/google_auth/setSelectedCompany.js +++ b/src/store/google_auth/setSelectedCompany.js @@ -1,10 +1,10 @@ import { SET_SELECTED_COMPANY } from '../../sql/google_auth.js'; export default function setSelectedCompany(pool) { - return async (id, companyId) => { + return async (googleAuthId, companyId) => { await pool .request() - .input('id', id) + .input('googleAuthId', googleAuthId) .input('companyId', companyId) .query(SET_SELECTED_COMPANY); };