This commit is contained in:
seb
2026-11-02 00:19:19 +01:00
parent d3209d73c1
commit 17df88ced5
23 changed files with 1135 additions and 8 deletions

View File

@@ -98,6 +98,9 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/auth/logout.md`](routes/auth/logout.md) | `POST /auth/logout` |
| [`routes/auth/me.md`](routes/auth/me.md) | `GET /auth/me` |
| [`routes/auth/selected-company.md`](routes/auth/selected-company.md) | `PUT /auth/selected-company` |
| [`routes/autoinvite/list.md`](routes/autoinvite/list.md) | `GET /autoinvite` |
| [`routes/autoinvite/create.md`](routes/autoinvite/create.md) | `POST /autoinvite` |
| [`routes/autoinvite/delete.md`](routes/autoinvite/delete.md) | `DELETE /autoinvite/{id}` |
| [`routes/backup.md`](routes/backup.md) | `GET /backup` |
| [`routes/backup/_id/vfs.md`](routes/backup/_id/vfs.md) | `GET /backup/{id}/vfs` |
| [`routes/backup/_id/vfs/recover.md`](routes/backup/_id/vfs/recover.md) | `POST /backup/{id}/vfs/recover` |
@@ -140,6 +143,10 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/companysetting/set.md`](routes/companysetting/set.md) | `PUT /company/setting/{key}` |
| [`routes/company/delete.md`](routes/company/delete.md) | `DELETE /company/{id}` |
| [`routes/company/update.md`](routes/company/update.md) | `PUT /company/{id}` |
| [`routes/companyuser/list.md`](routes/companyuser/list.md) | `GET /companyuser` |
| [`routes/companyuser/create.md`](routes/companyuser/create.md) | `POST /companyuser` |
| [`routes/companyuser/delete.md`](routes/companyuser/delete.md) | `DELETE /companyuser/{id}` |
| [`routes/companyuser/update.md`](routes/companyuser/update.md) | `PATCH /companyuser/{id}` |
| [`routes/customergroup/list.md`](routes/customergroup/list.md) | `GET /customergroup` |
| [`routes/customergroup/create.md`](routes/customergroup/create.md) | `POST /customergroup` |
| [`routes/customergroup/order.md`](routes/customergroup/order.md) | `PUT /customergroup/order` |
@@ -352,10 +359,12 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A
| `article-<sku>` | `PUT /article/{sku}/brand`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/eans`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, `PUT /article/{sku}/images/order`, `PUT /article/{sku}/prices`, `POST /article/{sku}/supplier`, `PUT /article/{sku}/supplier/order`, `DELETE /article/{sku}/supplier/{id}`, `PATCH /article/{sku}/supplier/{id}`, `PUT /article/{sku}/track_stock`, `PUT /article/{sku}/weight`, `POST /stock/adjust` |
| `articles` | `POST /article`, `DELETE /article/{sku}`, `PATCH /article/{sku}`, `DELETE /category/{id}`, `DELETE /shipment/{id}`, `POST /stock/accept`, `POST /stock/adjust`, `POST /stock/ship` |
| `attributename` | `POST /attributename`, `PUT /attributename/order`, `DELETE /attributename/{id}`, `PATCH /attributename/{id}`, `DELETE /attributevalue/{id}/image`, `POST /attributevalue/{id}/image` |
| `autoinvite` | `POST /autoinvite`, `DELETE /autoinvite/{id}` |
| `brand` | `POST /brand`, `DELETE /brand/{id}`, `PATCH /brand/{id}`, `DELETE /brand/{id}/image`, `POST /brand/{id}/image` |
| `carrier` | `POST /carrier`, `PUT /carrier/order`, `DELETE /carrier/{id}`, `PATCH /carrier/{id}` |
| `category` | `POST /category`, `PUT /category/order`, `DELETE /category/{id}`, `PATCH /category/{id}`, `DELETE /category/{id}/image`, `POST /category/{id}/image`, `POST /category/{id}/image/generate` |
| `company` | `PUT /auth/selected-company`, `POST /company`, `DELETE /company/{id}`, `PUT /company/{id}` |
| `companyuser` | `POST /companyuser`, `DELETE /companyuser/{id}`, `PATCH /companyuser/{id}` |
| `delivery` | `DELETE /delivery/{id}/file/{fileId}`, `POST /delivery/{id}/pdf`, `POST /stock/accept` |
| `labeltemplate` | `POST /labeltemplate`, `DELETE /labeltemplate/{id}`, `PATCH /labeltemplate/{id}`, `PUT /labeltemplate/{id}` |
| `ledger` | `DELETE /shipment/{id}`, `POST /stock/accept`, `POST /stock/adjust`, `POST /stock/ship` |

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All)
Overview of all 307 API endpoints across all modules.
Overview of all 314 API endpoints across all modules.
### Notation
@@ -27,7 +27,7 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- `T16` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T17` = `{ id: int, name: str, sort: int, exclusive: bool, values: T30[], articleCount?: int }`
- `T18` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: int|null, first_message?: str|null }`
- `T19` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }`
- `T19` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num, role: str }`
- `T20` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null, shippable: bool }`
- `T21` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null, shippable?: bool }`
- `T22` = `{ id: int, agentprinterId: int, type: str, state: str, data: str, createdAt: str, printeragentId?: int|null, completedAt?: str|null }`
@@ -276,6 +276,18 @@ _none_
- Params: `body: { companyId: int }`
- Returns: `200 { user: T35 }`
### Section: /autoinvite
#### List auto-invitations — `GET /autoinvite`
_none_
- Returns: `200 { autoinvites: { id: int, email: str, role: str, createdAt: str }[] }`
#### Create auto-invitation — `POST /autoinvite`
- Params: `body: { email: str, role?: str }`
- Returns: `201 { autoinvite: { id: int, email: str, role: str } }`
#### Delete auto-invitation — `DELETE /autoinvite/{id}`
- Params: `id: int`
- Returns: `204 void`
### Section: /backup
#### List backups and service status — `GET /backup`
@@ -417,6 +429,21 @@ _none_
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `200 { company: T19 }`
### Section: /companyuser
#### List company users — `GET /companyuser`
_none_
- Returns: `200 { companyusers: { id: int, googleAuthId: int, email: str, role: str }[] }`
#### Add an existing user to the company — `POST /companyuser`
- Params: `body: { email: str, role?: str }`
- Returns: `201 { companyuser: { id: int, googleAuthId: int, email: str, role: str } }`
#### Remove a user from the company — `DELETE /companyuser/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Change a company user's role — `PATCH /companyuser/{id}`
- Params: `id: int, body: { role: str }`
- Returns: `200 { companyuser: { id: int, googleAuthId: int, role: str } }`
### Section: /customergroup
#### List customer groups — `GET /customergroup`
@@ -523,6 +550,8 @@ _none_
- `ledger`: `{}`
- `attributename`: `{}`
- `propertyname`: `{}`
- `companyuser`: `{}`
- `autoinvite`: `{}`
- `labeltemplate`: `{}`
- `printer`: `{}`
- `printeragent`: `{}`

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview
Overview of all 189 API endpoints across all modules.
Overview of all 196 API endpoints across all modules.
### Notation
@@ -183,6 +183,18 @@ _none_
- Params: `id: int`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T22[] } }`
### Section: /autoinvite
#### List auto-invitations — `GET /autoinvite`
_none_
- Returns: `200 { autoinvites: { id: int, email: str, role: str, createdAt: str }[] }`
#### Create auto-invitation — `POST /autoinvite`
- Params: `body: { email: str, role?: str }`
- Returns: `201 { autoinvite: { id: int, email: str, role: str } }`
#### Delete auto-invitation — `DELETE /autoinvite/{id}`
- Params: `id: int`
- Returns: `204 void`
### Section: /brand
#### List brands — `GET /brand`
@@ -268,7 +280,7 @@ _none_
#### List companies — `GET /company`
_none_
- Returns: `200 { companies: { id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }[] }`
- Returns: `200 { companies: { id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num, role: str }[] }`
#### Get company history — `GET /company/history`
- Params: `query: limit?: int`
- Returns: `200 { history: { id: int, date: str, text: str, companyuserId?: int|null }[], truncated: bool }`
@@ -289,7 +301,22 @@ _none_
- Returns: `200 { setting: { key: str, value: str|null } }`
#### Update company — `PUT /company/{id}`
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `200 { company: { id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num } }`
- Returns: `200 { company: { id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num, role: str } }`
### Section: /companyuser
#### List company users — `GET /companyuser`
_none_
- Returns: `200 { companyusers: { id: int, googleAuthId: int, email: str, role: str }[] }`
#### Add an existing user to the company — `POST /companyuser`
- Params: `body: { email: str, role?: str }`
- Returns: `201 { companyuser: { id: int, googleAuthId: int, email: str, role: str } }`
#### Remove a user from the company — `DELETE /companyuser/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Change a company user's role — `PATCH /companyuser/{id}`
- Params: `id: int, body: { role: str }`
- Returns: `200 { companyuser: { id: int, googleAuthId: int, role: str } }`
### Section: /customergroup

View File

@@ -43,6 +43,7 @@ Updated user
|--------|-------------|
| 400 | Invalid companyId — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized (the user is not a member of this company) — `{"error":"…"}` |
| 500 | Server error — `{"error":"…"}` |
---

View File

@@ -0,0 +1,55 @@
# `POST /autoinvite`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Create auto-invitation
Registers an email for automatic membership. When a user with this email registers,
the invitation is removed from the list and the user is added to the company with the given role.
Emails that are already registered are rejected (409); use `POST /companyuser` for those. Admin only.
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `email` | yes | string | |
| `role` | no | string | |
```json
{
"email": "newhire@example.com",
"role": "user"
}
```
## Signals
On success, emits SSE signal: `autoinvite` (see [`GET /events`](../events.md)).
## Response `201`
Created invitation
```json
{
"autoinvite": {
"id": 1,
"email": "…",
"role": "admin"
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | No company selected, invalid email or invalid role — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized (admin of the selected company required) — `{"error":"…"}` |
| 409 | User already registered or invitation already exists — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,36 @@
# `DELETE /autoinvite/{id}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Delete auto-invitation
Deletes a pending auto-invitation of the selected company. Admin only.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Signals
On success, emits SSE signal: `autoinvite` (see [`GET /events`](../events.md)).
## Response `204`
Deleted
_No content_
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid id or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized (admin of the selected company required) — `{"error":"…"}` |
| 404 | Invitation not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,36 @@
# `GET /autoinvite`
Protected. Requires a valid session cookie (`credentials: 'include'`).
List auto-invitations
Lists pending auto-invitations of the selected company. Admin only.
## Response `200`
Pending invitations
```json
{
"autoinvites": [
{
"id": 1,
"email": "…",
"role": "admin",
"createdAt": "…"
}
]
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | No company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized (admin of the selected company required) — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -42,7 +42,8 @@ Created; selected company updated
"iso639": "…",
"taxrate": 1,
"reducedtaxrate": 1,
"taxfreerate": 1
"taxfreerate": 1,
"role": "admin"
},
"user": {
"id": 1,

View File

@@ -21,7 +21,8 @@ Company list
"iso639": "…",
"taxrate": 1,
"reducedtaxrate": 1,
"taxfreerate": 1
"taxfreerate": 1,
"role": "admin"
}
]
}

View File

@@ -48,7 +48,8 @@ Updated
"iso639": "…",
"taxrate": 1,
"reducedtaxrate": 1,
"taxfreerate": 1
"taxfreerate": 1,
"role": "admin"
}
}
```

View File

@@ -0,0 +1,57 @@
# `POST /companyuser`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Add an existing user to the company
Adds an existing (registered) user, identified by email, to the selected company. Admin only.
The added user can then switch to this company via `PUT /auth/selected-company`.
For emails that are not registered yet use `POST /autoinvite`.
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `email` | yes | string | |
| `role` | no | string | |
```json
{
"email": "colleague@example.com",
"role": "user"
}
```
## Signals
On success, emits SSE signal: `companyuser` (see [`GET /events`](../events.md)).
## Response `201`
Created company user
```json
{
"companyuser": {
"id": 1,
"googleAuthId": 1,
"email": "…",
"role": "admin"
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | No company selected, invalid email or invalid role — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized (admin of the selected company required) — `{"error":"…"}` |
| 404 | No registered user with this email — `{"error":"…"}` |
| 409 | User already in company — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,40 @@
# `DELETE /companyuser/{id}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Remove a user from the company
Removes a member from the selected company. History entries stay but lose their author link;
if the removed user had this company selected, their selection is cleared.
The last admin cannot be removed. Admin only.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Signals
On success, emits SSE signal: `companyuser` (see [`GET /events`](../events.md)).
## Response `204`
Removed
_No content_
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid id or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized (admin of the selected company required) — `{"error":"…"}` |
| 404 | Company user not found — `{"error":"…"}` |
| 409 | Cannot remove the last admin — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,36 @@
# `GET /companyuser`
Protected. Requires a valid session cookie (`credentials: 'include'`).
List company users
Lists the members of the selected company with their role. Admin only.
## Response `200`
Company members
```json
{
"companyusers": [
{
"id": 1,
"googleAuthId": 1,
"email": "…",
"role": "admin"
}
]
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | No company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized (admin of the selected company required) — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,57 @@
# `PATCH /companyuser/{id}`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Change a company user's role
Changes the role of a member of the selected company. The last admin cannot be demoted. Admin only.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | |
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `role` | yes | string | |
```json
{
"role": "admin"
}
```
## Signals
On success, emits SSE signal: `companyuser` (see [`GET /events`](../events.md)).
## Response `200`
Updated company user
```json
{
"companyuser": {
"id": 1,
"googleAuthId": 1,
"role": "admin"
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid id, role or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized (admin of the selected company required) — `{"error":"…"}` |
| 404 | Company user not found — `{"error":"…"}` |
| 409 | Cannot demote the last admin — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -48,6 +48,8 @@ emits `{ sender, brandId }`.
| `printer` | `{}` | `POST /printer`, `PUT /printer/{id}`, `PATCH /printer/{id}`, `DELETE /printer/{id}` |
| `printeragent` | `{}` | `PUT /printeragent`, `PATCH /printeragent/{id}`, `DELETE /printeragent/{id}`, printer agent connect/disconnect/state |
| `agentprinter` | `{ printers }` or `{}` | `PATCH /agentprinter/{id}`, `PUT /agentprinter/order`, `DELETE /agentprinter/{id}`, agent state updates / printer discovery |
| `companyuser` | `{}` | `POST /companyuser`, `PATCH /companyuser/{id}`, `DELETE /companyuser/{id}` |
| `autoinvite` | `{}` | `POST /autoinvite`, `DELETE /autoinvite/{id}` |
| `warehouse` | `{}` | `POST /warehouse`, `PUT /warehouse/{id}`, `PATCH /warehouse/{id}`, `DELETE /warehouse/{id}` |
| `supplier` | `{}` | `POST /supplier`, `PATCH /supplier/{id}`, `DELETE /supplier/{id}` |
| `vfs` | `{}` | VFS uploads and filesystem mutations such as `POST /vfs/upload`, `POST /vfs/mkdir`, `POST /vfs/rename`, `PUT /vfs/{*path}`, `DELETE /vfs/{*path}` |

View File

@@ -59,6 +59,36 @@
* type: number
* taxfreerate:
* type: number
* role:
* type: string
* enum: [admin, user]
* description: The caller's role in this company
* CompanyUser:
* type: object
* required: [id, googleAuthId, email, role]
* properties:
* id:
* type: integer
* googleAuthId:
* type: integer
* email:
* type: string
* role:
* type: string
* enum: [admin, user]
* AutoInvite:
* type: object
* required: [id, email, role, createdAt]
* properties:
* id:
* type: integer
* email:
* type: string
* role:
* type: string
* enum: [admin, user]
* createdAt:
* type: string
* SuccessResponse:
* type: object
* required: [ok]

View File

@@ -53,6 +53,12 @@ export function register(app, basePath = '') {
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized (the user is not a member of this company)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Server error
* content:
@@ -79,6 +85,9 @@ export function register(app, basePath = '') {
res.status(200).json({ user });
} catch (error) {
if (error.message === 'Not authorized') {
return res.status(403).json({ error: error.message });
}
console.error('Set selected company failed:', error);
res.status(500).json({ error: 'Failed to set selected company' });
}

View File

@@ -0,0 +1,271 @@
import * as db from '../../db/autoinvite.js';
import { emit } from '../sse.js';
// #region Helpers
function requireSelectedCompany(req) {
const companyId = req.user?.selectedCompanyId;
if (companyId == null) {
return { error: 'No company selected' };
}
return { companyId };
}
function parsePositiveInt(val, field = 'id') {
const id = Number(val);
if (!Number.isInteger(id) || id <= 0) {
return { error: `${field} is required` };
}
return { id };
}
function autoinviteErrorStatus(error) {
if (error.message === 'No company selected') return 400;
if (error.message === 'Not authorized') return 403;
if (error.message === 'Company not found') return 404;
if (error.message === 'Invitation not found') return 404;
if (error.message === 'Invalid role') return 400;
if (error.message === 'Invalid email') return 400;
if (error.message === 'User already registered') return 409;
if (error.message === 'Invitation already exists') return 409;
return 500;
}
// #endregion
/**
* Registers auto-invitation routes. All routes require the caller to be an
* admin of the selected company.
* @param {import('express').Application} app
* @param {string} [basePath='']
*/
export function register(app, basePath = '') {
/**
* @openapi
* /autoinvite:
* get:
* tags: [AutoInvite]
* summary: List auto-invitations
* description: Lists pending auto-invitations of the selected company. Admin only.
* security:
* - cookieAuth: []
* x-markdown: autoinvite/list.md
* responses:
* 200:
* description: Pending invitations
* content:
* application/json:
* schema:
* type: object
* properties:
* autoinvites:
* type: array
* items:
* $ref: '#/components/schemas/AutoInvite'
* 400:
* description: No company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized (admin of the selected company required)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /autoinvite
app.get(`${basePath}/autoinvite`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
try {
const autoinvites = await db.list(req.user.id);
res.status(200).json({ autoinvites });
} catch (error) {
const status = autoinviteErrorStatus(error);
if (status === 500) console.error('List auto-invitations failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /autoinvite:
* post:
* tags: [AutoInvite]
* summary: Create auto-invitation
* description: |
* Registers an email for automatic membership. When a user with this email registers,
* the invitation is removed from the list and the user is added to the company with the given role.
* Emails that are already registered are rejected (409); use `POST /companyuser` for those. Admin only.
* security:
* - cookieAuth: []
* x-signals: [autoinvite]
* x-markdown: autoinvite/create.md
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [email]
* properties:
* email:
* type: string
* role:
* type: string
* enum: [admin, user]
* default: user
* example:
* email: newhire@example.com
* role: user
* responses:
* 201:
* description: Created invitation
* content:
* application/json:
* schema:
* type: object
* properties:
* autoinvite:
* type: object
* properties:
* id:
* type: integer
* email:
* type: string
* role:
* type: string
* enum: [admin, user]
* 400:
* description: No company selected, invalid email or invalid role
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized (admin of the selected company required)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: User already registered or invitation already exists
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /autoinvite
app.post(`${basePath}/autoinvite`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
try {
const autoinvite = await db.add(req.user.id, {
email: req.body?.email,
role: req.body?.role,
});
emit('autoinvite', { companyId: selected.companyId });
res.status(201).json({ autoinvite });
} catch (error) {
const status = autoinviteErrorStatus(error);
if (status === 500) console.error('Create auto-invitation failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /autoinvite/{id}:
* delete:
* tags: [AutoInvite]
* summary: Delete auto-invitation
* description: Deletes a pending auto-invitation of the selected company. Admin only.
* security:
* - cookieAuth: []
* x-signals: [autoinvite]
* x-markdown: autoinvite/delete.md
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 204:
* description: Deleted
* 400:
* description: Invalid id or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized (admin of the selected company required)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Invitation not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region DELETE /autoinvite/:id
app.delete(`${basePath}/autoinvite/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const parsedId = parsePositiveInt(req.params.id);
if (parsedId.error) {
return res.status(400).json({ error: parsedId.error });
}
try {
await db.remove(req.user.id, parsedId.id);
emit('autoinvite', { companyId: selected.companyId });
res.sendStatus(204);
} catch (error) {
const status = autoinviteErrorStatus(error);
if (status === 500) console.error('Delete auto-invitation failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
}

View File

@@ -0,0 +1,385 @@
import * as db from '../../db/companyuser.js';
import { clearCachedUser } from '../middleware/auth.js';
import { emit } from '../sse.js';
// #region Helpers
function requireSelectedCompany(req) {
const companyId = req.user?.selectedCompanyId;
if (companyId == null) {
return { error: 'No company selected' };
}
return { companyId };
}
function parsePositiveInt(val, field = 'id') {
const id = Number(val);
if (!Number.isInteger(id) || id <= 0) {
return { error: `${field} is required` };
}
return { id };
}
function companyuserErrorStatus(error) {
if (error.message === 'No company selected') return 400;
if (error.message === 'Not authorized') return 403;
if (error.message === 'Company not found') return 404;
if (error.message === 'Company user not found') return 404;
if (error.message === 'User not found') return 404;
if (error.message === 'Invalid role') return 400;
if (error.message === 'Invalid email') return 400;
if (error.message === 'User already in company') return 409;
if (error.message === 'Cannot demote the last admin') return 409;
if (error.message === 'Cannot remove the last admin') return 409;
return 500;
}
// #endregion
/**
* Registers company user (membership) routes. All routes require the caller
* to be an admin of the selected company.
* @param {import('express').Application} app
* @param {string} [basePath='']
*/
export function register(app, basePath = '') {
/**
* @openapi
* /companyuser:
* get:
* tags: [CompanyUser]
* summary: List company users
* description: Lists the members of the selected company with their role. Admin only.
* security:
* - cookieAuth: []
* x-markdown: companyuser/list.md
* responses:
* 200:
* description: Company members
* content:
* application/json:
* schema:
* type: object
* properties:
* companyusers:
* type: array
* items:
* $ref: '#/components/schemas/CompanyUser'
* 400:
* description: No company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized (admin of the selected company required)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /companyuser
app.get(`${basePath}/companyuser`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
try {
const companyusers = await db.list(req.user.id);
res.status(200).json({ companyusers });
} catch (error) {
const status = companyuserErrorStatus(error);
if (status === 500) console.error('List company users failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /companyuser:
* post:
* tags: [CompanyUser]
* summary: Add an existing user to the company
* description: |
* Adds an existing (registered) user, identified by email, to the selected company. Admin only.
* The added user can then switch to this company via `PUT /auth/selected-company`.
* For emails that are not registered yet use `POST /autoinvite`.
* security:
* - cookieAuth: []
* x-signals: [companyuser]
* x-markdown: companyuser/create.md
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [email]
* properties:
* email:
* type: string
* role:
* type: string
* enum: [admin, user]
* default: user
* example:
* email: colleague@example.com
* role: user
* responses:
* 201:
* description: Created company user
* content:
* application/json:
* schema:
* type: object
* properties:
* companyuser:
* $ref: '#/components/schemas/CompanyUser'
* 400:
* description: No company selected, invalid email or invalid role
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized (admin of the selected company required)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: No registered user with this email
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: User already in company
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /companyuser
app.post(`${basePath}/companyuser`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
try {
const companyuser = await db.addByEmail(req.user.id, {
email: req.body?.email,
role: req.body?.role,
});
emit('companyuser', { companyId: selected.companyId });
res.status(201).json({ companyuser });
} catch (error) {
const status = companyuserErrorStatus(error);
if (status === 500) console.error('Add company user failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /companyuser/{id}:
* patch:
* tags: [CompanyUser]
* summary: Change a company user's role
* description: Changes the role of a member of the selected company. The last admin cannot be demoted. Admin only.
* security:
* - cookieAuth: []
* x-signals: [companyuser]
* x-markdown: companyuser/update.md
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [role]
* properties:
* role:
* type: string
* enum: [admin, user]
* responses:
* 200:
* description: Updated company user
* content:
* application/json:
* schema:
* type: object
* properties:
* companyuser:
* type: object
* properties:
* id:
* type: integer
* googleAuthId:
* type: integer
* role:
* type: string
* enum: [admin, user]
* 400:
* description: Invalid id, role or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized (admin of the selected company required)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Company user not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Cannot demote the last admin
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region PATCH /companyuser/:id
app.patch(`${basePath}/companyuser/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const parsedId = parsePositiveInt(req.params.id);
if (parsedId.error) {
return res.status(400).json({ error: parsedId.error });
}
try {
const companyuser = await db.setRole(req.user.id, parsedId.id, req.body?.role);
clearCachedUser(companyuser.googleAuthId);
emit('companyuser', { companyId: selected.companyId });
res.status(200).json({ companyuser });
} catch (error) {
const status = companyuserErrorStatus(error);
if (status === 500) console.error('Update company user failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /companyuser/{id}:
* delete:
* tags: [CompanyUser]
* summary: Remove a user from the company
* description: |
* Removes a member from the selected company. History entries stay but lose their author link;
* if the removed user had this company selected, their selection is cleared.
* The last admin cannot be removed. Admin only.
* security:
* - cookieAuth: []
* x-signals: [companyuser]
* x-markdown: companyuser/delete.md
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 204:
* description: Removed
* 400:
* description: Invalid id or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized (admin of the selected company required)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Company user not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Cannot remove the last admin
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region DELETE /companyuser/:id
app.delete(`${basePath}/companyuser/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const parsedId = parsePositiveInt(req.params.id);
if (parsedId.error) {
return res.status(400).json({ error: parsedId.error });
}
try {
const removed = await db.remove(req.user.id, parsedId.id);
clearCachedUser(removed.googleAuthId);
emit('companyuser', { companyId: selected.companyId });
res.sendStatus(204);
} catch (error) {
const status = companyuserErrorStatus(error);
if (status === 500) console.error('Remove company user failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
}

View File

@@ -57,6 +57,8 @@ export function register(app, basePath = '') {
* | `printer` | `{}` | `POST /printer`, `PUT /printer/{id}`, `PATCH /printer/{id}`, `DELETE /printer/{id}` |
* | `printeragent` | `{}` | `PUT /printeragent`, `PATCH /printeragent/{id}`, `DELETE /printeragent/{id}`, printer agent connect/disconnect/state |
* | `agentprinter` | `{ printers }` or `{}` | `PATCH /agentprinter/{id}`, `PUT /agentprinter/order`, `DELETE /agentprinter/{id}`, agent state updates / printer discovery |
* | `companyuser` | `{}` | `POST /companyuser`, `PATCH /companyuser/{id}`, `DELETE /companyuser/{id}` |
* | `autoinvite` | `{}` | `POST /autoinvite`, `DELETE /autoinvite/{id}` |
* | `warehouse` | `{}` | `POST /warehouse`, `PUT /warehouse/{id}`, `PATCH /warehouse/{id}`, `DELETE /warehouse/{id}` |
* | `supplier` | `{}` | `POST /supplier`, `PATCH /supplier/{id}`, `DELETE /supplier/{id}` |
* | `vfs` | `{}` | VFS uploads and filesystem mutations such as `POST /vfs/upload`, `POST /vfs/mkdir`, `POST /vfs/rename`, `PUT /vfs/{*path}`, `DELETE /vfs/{*path}` |
@@ -95,6 +97,10 @@ export function register(app, basePath = '') {
* payload: "{}"
* - name: propertyname
* payload: "{}"
* - name: companyuser
* payload: "{}"
* - name: autoinvite
* payload: "{}"
* - name: labeltemplate
* payload: "{}"
* - name: printer

16
src/db/autoinvite.js Normal file
View File

@@ -0,0 +1,16 @@
import { getStore } from './store.js';
export async function list(googleAuthId) {
const store = await getStore();
return store.autoinvite.list(googleAuthId);
}
export async function add(googleAuthId, data) {
const store = await getStore();
return store.autoinvite.add(googleAuthId, data);
}
export async function remove(googleAuthId, id) {
const store = await getStore();
return store.autoinvite.remove(googleAuthId, id);
}

21
src/db/companyuser.js Normal file
View File

@@ -0,0 +1,21 @@
import { getStore } from './store.js';
export async function list(googleAuthId) {
const store = await getStore();
return store.companyuser.list(googleAuthId);
}
export async function addByEmail(googleAuthId, data) {
const store = await getStore();
return store.companyuser.addByEmail(googleAuthId, data);
}
export async function setRole(googleAuthId, id, role) {
const store = await getStore();
return store.companyuser.setRole(googleAuthId, id, role);
}
export async function remove(googleAuthId, id) {
const store = await getStore();
return store.companyuser.remove(googleAuthId, id);
}

View File

@@ -14,6 +14,7 @@ let schemaVersion;
const STORE_NAMESPACES = [
'companyuser',
'autoinvite',
'company',
'category',
'article',