diff --git a/README.md b/README.md index aa3e4dc..fe3db67 100644 --- a/README.md +++ b/README.md @@ -193,12 +193,15 @@ Company-scoped customer groups. Auth via `resolveCompanyAccess`. | Method | Signature | Returns | | --- | --- | --- | -| `list` | `(googleAuthId, companyId)` | `{ id, name }[]` | -| `create` | `(googleAuthId, companyId, { name })` | `{ id, name }` | -| `update` | `(googleAuthId, companyId, id, { name })` | `{ id, name }` | +| `list` | `(googleAuthId, companyId)` | `{ id, name, sort }[]` | +| `create` | `(googleAuthId, companyId, { name })` | `{ id, name, sort }` | +| `update` | `(googleAuthId, companyId, id, { name })` | `{ id, name, sort }` | +| `order` | `(googleAuthId, companyId, { customergroupIds })` | `{ customergroupIds }` | | `delete` | `(googleAuthId, companyId, id)` | `void` | -Empty name → `Name required`. Deleting the POS group (or any group still referenced with `NO ACTION`) → `Customer group in use`. Cascades entities when the group is deleted. Errors: `Company not found`, `Not authorized`, `Name required`, `Customer group not found`, `Customer group in use`. +List ordered by `sort`, `name`, `id`. `create` appends with next `sort` (`0…n`). `order` sets `sort` to each id’s index `0…n-1`; `customergroupIds` must be exactly the company’s groups (same set, no dups) else `Invalid customer group order`. Implicit groups `POS` (company create) and `Lieferanten` (first supplier) cannot be renamed (`Name cannot be changed`). Creating a group with those reserved names → `Name not allowed`. + +Empty name → `Name required`. Deleting the POS group (or any group still referenced with `NO ACTION`) → `Customer group in use`. Cascades entities when the group is deleted. Errors: `Company not found`, `Not authorized`, `Name required`, `Name not allowed`, `Name cannot be changed`, `Invalid customer group order`, `Customer group not found`, `Customer group in use`. --- @@ -682,6 +685,7 @@ res.set('Content-Type', thumb.contentType).send(thumb.buffer); | `GET /api/customergroup` | `customergroup.list(googleAuthId, companyId)` | | `POST /api/customergroup` | `customergroup.create(googleAuthId, companyId, { name })` | | `PATCH /api/customergroup/:id` | `customergroup.update(googleAuthId, companyId, id, { name })` | +| `PUT /api/customergroup/order` | `customergroup.order(googleAuthId, companyId, { customergroupIds })` | | `DELETE /api/customergroup/:id` | `customergroup.delete(googleAuthId, companyId, id)` | | `GET /api/entity` | `entity.list(googleAuthId, companyId, { customergroupId?, role? })` | | `GET /api/entity/:id` | `entity.getById(googleAuthId, companyId, id)` | diff --git a/schema-graph.dot b/schema-graph.dot index 382b790..782f96f 100644 --- a/schema-graph.dot +++ b/schema-graph.dot @@ -9,7 +9,7 @@ digraph schema { label="Identity & Company"; labeljust="l"; fontsize=15; style="rounded,filled"; fillcolor="#ffffff60"; color="#a0aec0"; fontcolor="#1a365d"; margin=14; - customergroup [label=<
customergroup
idINT PK
company_id?INT FK
nameNVARCHAR(255)
>]; + customergroup [label=<
customergroup
idINT PK
company_id?INT FK
nameNVARCHAR(255)
sortINT
>]; 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
>]; diff --git a/schema-graph.svg b/schema-graph.svg index 132327c..e60985c 100644 --- a/schema-graph.svg +++ b/schema-graph.svg @@ -42,27 +42,31 @@ customergroup - - - - - -customergroup - - -id -INT -PK - - -company_id -? -INT -FK - - -name -NVARCHAR(255) + + + + + +customergroup + + +id +INT +PK + + +company_id +? +INT +FK + + +name +NVARCHAR(255) + + +sort +INT @@ -129,8 +133,8 @@ customergroup:w->company:n - - + + @@ -294,8 +298,8 @@ entity:nw->customergroup:n - - + + @@ -422,8 +426,8 @@ company:w->customergroup:n - - + + @@ -1676,8 +1680,8 @@ price:se->customergroup:n - - + + diff --git a/scripts/test-api.js b/scripts/test-api.js index 416faaa..3c7996f 100644 --- a/scripts/test-api.js +++ b/scripts/test-api.js @@ -164,6 +164,7 @@ const MENU = { { value: 'customergroup.list', label: 'list', hint: 'googleAuthId, companyId → rows' }, { value: 'customergroup.create', label: 'create', hint: 'googleAuthId, companyId, name → row' }, { value: 'customergroup.update', label: 'update', hint: 'googleAuthId, companyId, id, name → row' }, + { value: 'customergroup.order', label: 'order', hint: 'googleAuthId, companyId, JSON customergroupIds → { customergroupIds }' }, { value: 'customergroup.delete', label: 'delete', hint: 'googleAuthId, companyId, id' }, ], }, @@ -799,9 +800,11 @@ async function runMethod(client, action) { if (name == null) return; return [ 'result', - await client.customergroup.create(Number(googleAuthId), Number(companyId), { - name, - }), + await client.customergroup.create( + Number(googleAuthId), + Number(companyId), + { name }, + ), ]; } case 'customergroup.update': { @@ -825,6 +828,33 @@ async function runMethod(client, action) { ), ]; } + case 'customergroup.order': { + const googleAuthId = await askInt('google_auth id'); + if (googleAuthId == null) return; + const companyId = await askInt('company id'); + if (companyId == null) return; + const idsJson = await askText('customergroupIds JSON', { + placeholder: '[3,1,2]', + validate: (v) => { + if (!v?.trim()) return 'Required'; + try { + const parsed = JSON.parse(v); + if (!Array.isArray(parsed)) return 'Must be a JSON array'; + } catch { + return 'Invalid JSON'; + } + }, + }); + if (idsJson == null) return; + return [ + 'result', + await client.customergroup.order( + Number(googleAuthId), + Number(companyId), + { customergroupIds: JSON.parse(idsJson) }, + ), + ]; + } case 'customergroup.delete': { const googleAuthId = await askInt('google_auth id'); if (googleAuthId == null) return; diff --git a/src/index.js b/src/index.js index c0fbd7e..22b3e37 100644 --- a/src/index.js +++ b/src/index.js @@ -42,6 +42,7 @@ import brandDelete from './store/brand/delete.js'; import customergroupList from './store/customergroup/list.js'; import customergroupCreate from './store/customergroup/create.js'; import customergroupUpdate from './store/customergroup/update.js'; +import customergroupOrder from './store/customergroup/order.js'; import customergroupDelete from './store/customergroup/delete.js'; import entityList from './store/entity/list.js'; import entityGetById from './store/entity/getById.js'; @@ -181,6 +182,7 @@ export default class LibDashStore { list: customergroupList(pool), create: customergroupCreate(pool), update: customergroupUpdate(pool), + order: customergroupOrder(pool), delete: customergroupDelete(pool), }; diff --git a/src/schema.js b/src/schema.js index e3e94ef..78519ea 100644 --- a/src/schema.js +++ b/src/schema.js @@ -130,6 +130,7 @@ BEGIN CREATE TABLE customergroup ( id INT IDENTITY(1,1) PRIMARY KEY, name NVARCHAR(255) NOT NULL, + sort INT NOT NULL CONSTRAINT DF_customergroup_sort DEFAULT 0, -- Nullable only for company-create chicken-egg (POS group first, then company, then SET). -- Always set before commit; never left null for live rows. company_id INT NULL, diff --git a/src/sql/customergroup.js b/src/sql/customergroup.js index 88fa153..783069c 100644 --- a/src/sql/customergroup.js +++ b/src/sql/customergroup.js @@ -1,13 +1,13 @@ export const CREATE = ` -INSERT INTO customergroup (name, company_id) -OUTPUT INSERTED.id, INSERTED.name -VALUES (@name, @companyId) +INSERT INTO customergroup (name, company_id, sort) +OUTPUT INSERTED.id, INSERTED.name, INSERTED.sort +VALUES (@name, @companyId, @sort) `; export const CREATE_WITHOUT_COMPANY = ` -INSERT INTO customergroup (name) -OUTPUT INSERTED.id -VALUES (@name) +INSERT INTO customergroup (name, sort) +OUTPUT INSERTED.id, INSERTED.name, INSERTED.sort +VALUES (@name, @sort) `; export const SET_COMPANY = ` @@ -17,28 +17,46 @@ WHERE id = @id `; export const GET_BY_COMPANY_AND_NAME = ` -SELECT id, name +SELECT id, name, sort FROM customergroup WHERE company_id = @companyId AND name = @name `; export const LIST_BY_COMPANY = ` -SELECT id, name +SELECT id, name, sort FROM customergroup WHERE company_id = @companyId -ORDER BY name, id +ORDER BY sort ASC, name ASC, id ASC `; export const GET_BY_COMPANY_AND_ID = ` -SELECT id, name +SELECT id, name, sort FROM customergroup WHERE id = @id AND company_id = @companyId `; -export const UPDATE_NAME = ` +export const GET_NEXT_SORT = ` +SELECT ISNULL(MAX(sort), -1) + 1 AS sort +FROM customergroup +WHERE company_id = @companyId +`; + +export const LIST_IDS_BY_COMPANY = ` +SELECT id +FROM customergroup +WHERE company_id = @companyId +`; + +export const UPDATE = ` UPDATE customergroup SET name = @name -OUTPUT INSERTED.id, INSERTED.name +OUTPUT INSERTED.id, INSERTED.name, INSERTED.sort +WHERE id = @id AND company_id = @companyId +`; + +export const UPDATE_SORT = ` +UPDATE customergroup +SET sort = @sort WHERE id = @id AND company_id = @companyId `; diff --git a/src/store/company/create.js b/src/store/company/create.js index 977fbbb..5895d35 100644 --- a/src/store/company/create.js +++ b/src/store/company/create.js @@ -1,3 +1,4 @@ +import { POS_GROUP_NAME } from '../../sql/entity.js'; import { CREATE } from '../../sql/company.js'; import { CREATE as CREATE_CATEGORY } from '../../sql/category.js'; import { @@ -10,8 +11,6 @@ import langEnsure from '../lang/ensure.js'; import taxEnsure from '../tax/ensure.js'; import request from '../request.js'; -const POS_CUSTOMERGROUP_NAME = 'POS'; - export default function create(pool) { const nameTaken = getByName(pool); const ensureLang = langEnsure(pool); @@ -42,7 +41,8 @@ export default function create(pool) { // Circular FK: create POS group first (company_id null), company with // poscustomergroup_id NOT NULL, then link the group back to the company. const posGroup = await request(pool, tx) - .input('name', POS_CUSTOMERGROUP_NAME) + .input('name', POS_GROUP_NAME) + .input('sort', 0) .query(CREATE_CUSTOMERGROUP); const posCustomerGroupId = posGroup.recordset[0].id; diff --git a/src/store/customergroup/create.js b/src/store/customergroup/create.js index 63e5ee2..b3335da 100644 --- a/src/store/customergroup/create.js +++ b/src/store/customergroup/create.js @@ -1,6 +1,13 @@ -import { CREATE } from '../../sql/customergroup.js'; +import { CREATE, GET_NEXT_SORT } from '../../sql/customergroup.js'; +import { POS_GROUP_NAME, SUPPLIER_GROUP_NAME } from '../../sql/entity.js'; import resolveCompanyAccess from '../companyuser/resolveCompanyAccess.js'; +const RESERVED_NAMES = new Set([POS_GROUP_NAME, SUPPLIER_GROUP_NAME]); + +function shape(row) { + return { id: row.id, name: row.name, sort: row.sort }; +} + export default function create(pool) { const access = resolveCompanyAccess(pool); @@ -9,16 +16,25 @@ export default function create(pool) { if (!cleaned) { throw new Error('Name required'); } + if (RESERVED_NAMES.has(cleaned)) { + throw new Error('Name not allowed'); + } await access(googleAuthId, companyId); + const next = await pool + .request() + .input('companyId', companyId) + .query(GET_NEXT_SORT); + const sort = next.recordset[0].sort; + const result = await pool .request() .input('companyId', companyId) .input('name', cleaned) + .input('sort', sort) .query(CREATE); - const row = result.recordset[0]; - return { id: row.id, name: row.name }; + return shape(result.recordset[0]); }; } diff --git a/src/store/customergroup/list.js b/src/store/customergroup/list.js index c3b67a4..186f3a8 100644 --- a/src/store/customergroup/list.js +++ b/src/store/customergroup/list.js @@ -15,6 +15,7 @@ export default function list(pool) { return result.recordset.map((row) => ({ id: row.id, name: row.name, + sort: row.sort, })); }; } diff --git a/src/store/customergroup/order.js b/src/store/customergroup/order.js new file mode 100644 index 0000000..ef18020 --- /dev/null +++ b/src/store/customergroup/order.js @@ -0,0 +1,65 @@ +import { + LIST_IDS_BY_COMPANY, + UPDATE_SORT, +} from '../../sql/customergroup.js'; +import request from '../request.js'; +import resolveCompanyAccess from '../companyuser/resolveCompanyAccess.js'; + +export default function order(pool) { + const access = resolveCompanyAccess(pool); + + return async (googleAuthId, companyId, { customergroupIds } = {}) => { + if (!Array.isArray(customergroupIds)) { + throw new Error('Invalid customer group order'); + } + + await access(googleAuthId, companyId); + + const groups = await pool + .request() + .input('companyId', companyId) + .query(LIST_IDS_BY_COMPANY); + + const existing = new Set(groups.recordset.map((r) => r.id)); + + if (customergroupIds.length !== existing.size) { + throw new Error('Invalid customer group order'); + } + + const seen = new Set(); + for (const customergroupId of customergroupIds) { + if ( + !Number.isFinite(customergroupId) || + !existing.has(customergroupId) || + seen.has(customergroupId) + ) { + throw new Error('Invalid customer group order'); + } + seen.add(customergroupId); + } + + const tx = pool.transaction(); + await tx.begin(); + + try { + for (let sort = 0; sort < customergroupIds.length; sort++) { + await request(pool, tx) + .input('id', customergroupIds[sort]) + .input('companyId', companyId) + .input('sort', sort) + .query(UPDATE_SORT); + } + + await tx.commit(); + + return { customergroupIds }; + } catch (err) { + try { + await tx.rollback(); + } catch { + // ignore + } + throw err; + } + }; +} diff --git a/src/store/customergroup/update.js b/src/store/customergroup/update.js index f88fe7e..7fb86fe 100644 --- a/src/store/customergroup/update.js +++ b/src/store/customergroup/update.js @@ -1,6 +1,13 @@ -import { UPDATE_NAME } from '../../sql/customergroup.js'; +import { GET_BY_COMPANY_AND_ID, UPDATE } from '../../sql/customergroup.js'; +import { POS_GROUP_NAME, SUPPLIER_GROUP_NAME } from '../../sql/entity.js'; import resolveCompanyAccess from '../companyuser/resolveCompanyAccess.js'; +const LOCKED_NAMES = new Set([POS_GROUP_NAME, SUPPLIER_GROUP_NAME]); + +function shape(row) { + return { id: row.id, name: row.name, sort: row.sort }; +} + export default function update(pool) { const access = resolveCompanyAccess(pool); @@ -9,24 +16,39 @@ export default function update(pool) { throw new Error('Customer group not found'); } - const cleaned = name?.trim(); + const cleaned = name?.trim?.() ? name.trim() : null; if (!cleaned) { throw new Error('Name required'); } await access(googleAuthId, companyId); + const existing = await pool + .request() + .input('id', id) + .input('companyId', companyId) + .query(GET_BY_COMPANY_AND_ID); + + const current = existing.recordset[0]; + if (!current) { + throw new Error('Customer group not found'); + } + + if (LOCKED_NAMES.has(current.name) && cleaned !== current.name) { + throw new Error('Name cannot be changed'); + } + const result = await pool .request() .input('id', id) .input('companyId', companyId) .input('name', cleaned) - .query(UPDATE_NAME); + .query(UPDATE); const row = result.recordset[0]; if (!row) { throw new Error('Customer group not found'); } - return { id: row.id, name: row.name }; + return shape(row); }; } diff --git a/src/store/supplier/ensureLieferantenGroup.js b/src/store/supplier/ensureLieferantenGroup.js index bc7d97d..470a4cf 100644 --- a/src/store/supplier/ensureLieferantenGroup.js +++ b/src/store/supplier/ensureLieferantenGroup.js @@ -1,6 +1,7 @@ import { CREATE, GET_BY_COMPANY_AND_NAME, + GET_NEXT_SORT, } from '../../sql/customergroup.js'; import { SUPPLIER_GROUP_NAME } from '../../sql/entity.js'; @@ -17,9 +18,15 @@ export default async function ensureLieferantenGroup(pool, companyId, tx) { return existing.recordset[0].id; } + const next = await req() + .input('companyId', companyId) + .query(GET_NEXT_SORT); + const sort = next.recordset[0].sort; + const created = await req() .input('companyId', companyId) .input('name', SUPPLIER_GROUP_NAME) + .input('sort', sort) .query(CREATE); return created.recordset[0].id;