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 [label=<>];
meta [label=<>];
numbers [label=<>];
entityaddress [label=<| entityaddress |
|
|
|
|
|
| streetnumber? | NVARCHAR(50) |
|
|
|
|
|
|
>];
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;