This commit is contained in:
seb
2026-10-17 05:10:25 +02:00
parent 988b4848cf
commit 7197a6ddb7
28 changed files with 1172 additions and 166 deletions

View File

@@ -1249,9 +1249,60 @@
* type: string
* amount:
* type: number
* Carrier:
* type: object
* required: [id, name, sort, hasPackages, isReverse]
* properties:
* id:
* type: integer
* name:
* type: string
* sort:
* type: integer
* description: Sort order
* hasPackages:
* type: boolean
* description: Whether this carrier uses packages
* isReverse:
* type: boolean
* description: Whether this carrier handles reverse shipments
* CarrierCreateInput:
* type: object
* required: [name]
* properties:
* name:
* type: string
* sort:
* type: integer
* description: Optional sort order
* hasPackages:
* type: boolean
* default: false
* isReverse:
* type: boolean
* default: false
* CarrierUpdateInput:
* type: object
* properties:
* name:
* type: string
* sort:
* type: integer
* hasPackages:
* type: boolean
* isReverse:
* type: boolean
* CarrierOrderInput:
* type: object
* required: [carrierIds]
* properties:
* carrierIds:
* type: array
* items:
* type: integer
* Shipping:
* type: object
* required: [id, manifestId, number, type, state]
* required: [id, manifestId, number, carrierId, type, state]
* properties:
* id:
* type: integer
@@ -1264,6 +1315,9 @@
* type: string
* nullable: true
* description: Carrier parcel tracking code
* carrierId:
* type: integer
* description: Carrier ID
* carrier:
* type: string
* nullable: true

505
src/api/routes/carrier.js Normal file
View File

@@ -0,0 +1,505 @@
import * as db from '../../db/carrier.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 carrierErrorStatus(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 === 'Carrier not found') return 404;
if (error.message === 'Name required') return 400;
if (error.message === 'Carrier already exists') return 409;
if (error.message === 'Carrier in use') return 409;
if (error.message === 'Invalid carrier order') return 400;
if (error.message === 'Invalid sort') return 400;
return 500;
}
// #endregion
/**
* Registers carrier routes for the authenticated user's selected company.
* @param {import('express').Application} app
* @param {string} [basePath='']
*/
export function register(app, basePath = '') {
/**
* @openapi
* /carrier:
* get:
* tags: [Carrier]
* summary: List carriers
* description: Lists carriers for the selected company, ordered by sort and name.
* security:
* - cookieAuth: []
* responses:
* 200:
* description: List of carriers
* content:
* application/json:
* schema:
* type: object
* properties:
* carriers:
* type: array
* items:
* $ref: '#/components/schemas/Carrier'
* 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
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /carrier
app.get(`${basePath}/carrier`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
try {
const carriers = await db.list(req.user.id);
res.status(200).json({ carriers });
} catch (error) {
const status = carrierErrorStatus(error);
if (status === 500) console.error('List carriers failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /carrier/order:
* put:
* tags: [Carrier]
* summary: Reorder carriers
* description: |
* Reorders carriers for the selected company.
* `carrierIds` must include all existing carrier IDs exactly once.
* security:
* - cookieAuth: []
* x-signals: [carrier]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/CarrierOrderInput'
* responses:
* 200:
* description: Applied carrier order
* content:
* application/json:
* schema:
* type: object
* properties:
* carrierIds:
* type: array
* items:
* type: integer
* 400:
* description: Invalid carrier order 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
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region PUT /carrier/order
app.put(`${basePath}/carrier/order`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const { carrierIds } = req.body ?? {};
try {
const result = await db.order(req.user.id, { carrierIds });
emit('carrier', { companyId: selected.companyId });
res.status(200).json(result);
} catch (error) {
const status = carrierErrorStatus(error);
if (status === 500) console.error('Order carriers failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /carrier/{id}:
* get:
* tags: [Carrier]
* summary: Get carrier by ID
* description: Retrieves a single carrier for the authenticated user's selected company.
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Carrier ID
* responses:
* 200:
* description: Carrier details
* content:
* application/json:
* schema:
* type: object
* properties:
* carrier:
* $ref: '#/components/schemas/Carrier'
* 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
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Carrier not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region GET /carrier/:id
app.get(`${basePath}/carrier/: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, 'id');
if (parsedId.error) {
return res.status(400).json({ error: parsedId.error });
}
try {
const carrier = await db.getById(req.user.id, parsedId.id);
if (!carrier) {
return res.status(404).json({ error: 'Carrier not found' });
}
res.status(200).json({ carrier });
} catch (error) {
const status = carrierErrorStatus(error);
if (status === 500) console.error('Get carrier failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
/**
* @openapi
* /carrier:
* post:
* tags: [Carrier]
* summary: Create carrier
* description: |
* Creates a new carrier for the selected company.
* Sort order will be automatically appended if omitted.
* security:
* - cookieAuth: []
* x-signals: [carrier]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/CarrierCreateInput'
* responses:
* 201:
* description: Created carrier
* content:
* application/json:
* schema:
* type: object
* properties:
* carrier:
* $ref: '#/components/schemas/Carrier'
* 400:
* description: Invalid input or missing fields
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Carrier already exists
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /carrier
app.post(`${basePath}/carrier`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const { name, sort, hasPackages, isReverse } = req.body ?? {};
try {
const carrier = await db.create(req.user.id, { name, sort, hasPackages, isReverse });
emit('carrier', { companyId: selected.companyId });
res.status(201).json({ carrier });
} catch (error) {
const status = carrierErrorStatus(error);
if (status === 500) console.error('Create carrier failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
async function handleUpdate(req, res) {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const parsedId = parsePositiveInt(req.params.id, 'id');
if (parsedId.error) {
return res.status(400).json({ error: parsedId.error });
}
const { name, sort, hasPackages, isReverse } = req.body ?? {};
try {
const carrier = await db.update(req.user.id, parsedId.id, {
...(name !== undefined ? { name } : {}),
...(sort !== undefined ? { sort } : {}),
...(hasPackages !== undefined ? { hasPackages } : {}),
...(isReverse !== undefined ? { isReverse } : {}),
});
emit('carrier', { companyId: selected.companyId });
res.status(200).json({ carrier });
} catch (error) {
const status = carrierErrorStatus(error);
if (status === 500) console.error('Update carrier failed:', error);
res.status(status).json({ error: error.message });
}
}
/**
* @openapi
* /carrier/{id}:
* patch:
* tags: [Carrier]
* summary: Update carrier
* description: Updates carrier name, sort order, hasPackages, and/or isReverse for the selected company.
* security:
* - cookieAuth: []
* x-signals: [carrier]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Carrier ID
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/CarrierUpdateInput'
* responses:
* 200:
* description: Updated carrier
* content:
* application/json:
* schema:
* type: object
* properties:
* carrier:
* $ref: '#/components/schemas/Carrier'
* 400:
* description: Invalid input or missing fields
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Carrier not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Carrier already exists
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region PATCH /carrier/:id
app.patch(`${basePath}/carrier/:id`, handleUpdate);
// #endregion
/**
* @openapi
* /carrier/{id}:
* delete:
* tags: [Carrier]
* summary: Delete carrier
* description: Deletes a carrier for the selected company, unless referenced in shipping.
* security:
* - cookieAuth: []
* x-signals: [carrier]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Carrier ID
* responses:
* 204:
* description: Carrier 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
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Carrier not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Carrier in use in shipping
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region DELETE /carrier/:id
app.delete(`${basePath}/carrier/: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, 'id');
if (parsedId.error) {
return res.status(400).json({ error: parsedId.error });
}
try {
await db.remove(req.user.id, parsedId.id);
emit('carrier', { companyId: selected.companyId });
res.status(204).end();
} catch (error) {
const status = carrierErrorStatus(error);
if (status === 500) console.error('Delete carrier failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
}

View File

@@ -151,12 +151,18 @@ export function register(app, basePath = '') {
* application/json:
* schema:
* type: object
* required: [type, state]
* required: [carrierId, type, state]
* properties:
* carrierId:
* type: integer
* description: Carrier ID
* type:
* type: string
* state:
* type: string
* trackingCode:
* type: string
* description: Carrier parcel tracking code
* warehouseId:
* type: integer
* description: Optional warehouse ID for stock deduction when state is shipped
@@ -329,8 +335,11 @@ export function register(app, basePath = '') {
* application/json:
* schema:
* type: object
* required: [type, state]
* required: [carrierId, type, state]
* properties:
* carrierId:
* type: integer
* description: Carrier ID
* type:
* type: string
* state:
@@ -338,9 +347,6 @@ export function register(app, basePath = '') {
* trackingCode:
* type: string
* description: Carrier parcel tracking code
* carrier:
* type: string
* description: Carrier name (e.g. DHL, UPS, DPD)
* warehouseId:
* type: integer
* description: Optional warehouse ID for stock deduction when state is shipped
@@ -436,9 +442,9 @@ export function register(app, basePath = '') {
* trackingCode:
* type: string
* description: Carrier parcel tracking code
* carrier:
* type: string
* description: Carrier name (e.g. DHL, UPS, DPD)
* carrierId:
* type: integer
* description: Carrier ID
* warehouseId:
* type: integer
* description: Warehouse ID for stock deduction when transitioning to shipped