feat: Implement initial structure for Directus Mayo API extension

- Added main router in src/index.js to register endpoints.
- Implemented GET /mayo-api/products to fetch product list with pagination and filters.
- Implemented GET /mayo-api/dictionaries to fetch various dictionaries for frontend use.
- Created separate files for routes, repositories, serializers, and utilities to maintain clean architecture.
- Added utility functions for async handling, pagination, and order search parsing.
- Introduced serializers for products and dictionaries to format data for frontend consumption.
- Established repository functions for database queries related to products and dictionaries.
- Updated package.json to include license information.
- Created documentation for the API extension detailing current state and future implementation plans.
This commit is contained in:
2026-05-20 22:38:51 +02:00
parent 2005e327f1
commit fb08705883
18 changed files with 1258 additions and 340 deletions

View File

@@ -0,0 +1,16 @@
# Directus Mayo API Extension
Custom Directus endpoint extension for Duck Prod Manager.
The extension exposes frontend-facing API routes under:
```txt
/mayo-api
```
Current implemented routes:
```txt
GET /mayo-api/products
GET /mayo-api/dictionaries
```

View File

@@ -0,0 +1,56 @@
@baseUrl = https://bartool.ovh/dpm-api
@token = _gEp4JyW6uBY7xyKVDo2vKUtCLfPGr3D
### Health check - Directus server
GET {{baseUrl}}/server/health
### Logowanie
# @name login
POST {{baseUrl}}/auth/login
Content-Type: application/json
{
"email": "bartoolina@gmail.com",
"password": "20madafaka"
}
###
@auth_token = {{login.response.body.data.access_token}}
### Mayo API - products
GET {{baseUrl}}/mayo-api/products
Authorization: Bearer {{auth_token}}
### Mayo API - products with pagination
GET {{baseUrl}}/mayo-api/products?limit=30&offset=0
Authorization: Bearer {{auth_token}}
### Mayo API - products by order search
# orderSearch is a JSON string. This example matches order number 0143/2025/1.
GET {{baseUrl}}/mayo-api/products?search=0143/2025/1&orderSearch=[{"raw":"0143/2025/1","type":"fullOrderId","match":"exact","orderNumber":143,"orderNumberPrefix":null,"orderYear":2025,"orderIndex":1}]
Authorization: Bearer {{auth_token}}
### Mayo API - products by year
GET {{baseUrl}}/mayo-api/products?year=2025
Authorization: Bearer {{auth_token}}
### Mayo API - products by model id
GET {{baseUrl}}/mayo-api/products?model=2
Authorization: Bearer {{auth_token}}
### Mayo API - products by client id
GET {{baseUrl}}/mayo-api/products?client=1
Authorization: Bearer {{auth_token}}
### Mayo API - products by production list id
GET {{baseUrl}}/mayo-api/products?productionList=1
Authorization: Bearer {{auth_token}}
### Mayo API - products by finish
GET {{baseUrl}}/mayo-api/products?finish=MAT
Authorization: Bearer {{auth_token}}
### Mayo API - dictionaries
GET {{baseUrl}}/mayo-api/dictionaries
Authorization: Bearer {{auth_token}}

View File

@@ -0,0 +1,252 @@
export default {
id: "mayo-api",
handler: (router, context) => {
const { services, getSchema, database } = context;
const { ItemsService } = services;
router.get("/products", async (req, res) => {
const productsService = new ItemsService("mayo_products", {
schema: await getSchema(),
accountability: req.accountability,
});
const partsService = new ItemsService("mayo_parts", {
schema: await getSchema(),
accountability: req.accountability,
});
try {
const products = await productsService.readByQuery({
fields: [
"id",
"note",
"model_id.name",
"model_id.strings_count",
"model_id.scale",
],
limit: -1,
});
const productIds = products.map((product) => product.id);
const parts = await partsService.readByQuery({
fields: [
"id",
"product_id",
"part_type",
"top_color_id.name",
"back_color_id.name",
"top_finish",
"back_finish",
],
filter: {
product_id: {
_in: productIds,
},
},
limit: -1,
});
const partsByProductId = new Map();
for (const part of parts) {
const productId =
typeof part.product_id === "object"
? part.product_id.id
: part.product_id;
if (!partsByProductId.has(productId)) {
partsByProductId.set(productId, []);
}
partsByProductId.get(productId).push(part);
}
const response = products.map((product) => ({
...product,
parts: partsByProductId.get(product.id) ?? [],
}));
res.json(response);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get("/products-new", async (req, res) => {
try {
const products = await database("mayo_products as product")
.select(
"product.id",
"product.note",
"model.name as model_name",
"model.strings_count as model_strings_count",
"model.scale as model_scale",
)
.leftJoin("mayo_models as model", "product.model_id", "model.id");
const productIds = products.map((product) => product.id);
const parts = productIds.length
? await database("mayo_parts as part")
.select(
"part.id",
"part.product_id",
"part.part_type",
"part.top_finish",
"part.back_finish",
"top_color.name as top_color_name",
"back_color.name as back_color_name",
)
.leftJoin(
"mayo_colors as top_color",
"part.top_color_id",
"top_color.id",
)
.leftJoin(
"mayo_colors as back_color",
"part.back_color_id",
"back_color.id",
)
.whereIn("part.product_id", productIds)
: [];
const partsByProductId = new Map();
for (const part of parts) {
if (!partsByProductId.has(part.product_id)) {
partsByProductId.set(part.product_id, []);
}
partsByProductId.get(part.product_id).push({
id: part.id,
product_id: part.product_id,
part_type: part.part_type,
top_color_id: {
name: part.top_color_name,
},
back_color_id: {
name: part.back_color_name,
},
top_finish: part.top_finish,
back_finish: part.back_finish,
});
}
const response = products.map((product) => ({
id: product.id,
note: product.note,
model_id: {
name: product.model_name,
strings_count: product.model_strings_count,
scale: product.model_scale,
},
parts: partsByProductId.get(product.id) ?? [],
}));
res.json(response);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get("/orders", async (req, res) => {
const itemsService = new ItemsService("mayo_order_products", {
schema: await getSchema(),
accountability: req.accountability,
});
try {
const items = await itemsService.readByQuery({
fields: [
"id",
"product_order_index",
"order_id.order_number",
"order_id.order_year",
"order_id.client_id.name",
"product_id.id",
"product_id.note",
"product_id.model_id.name",
"product_id.model_id.strings_count",
"product_id.model_id.scale",
],
limit: -1,
});
res.json(items);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get("/my-products", async (req, res) => {
try {
const products = await database("mayo_products as mp")
.select(
"mp.id",
"mm.name as model",
"mo.order_number",
"mo.order_year",
"mop.product_order_index as order_index",
)
.leftJoin("mayo_order_products as mop", "mop.product_id", "mp.id")
.leftJoin("mayo_orders as mo", "mo.id", "mop.order_id")
.leftJoin("mayo_models as mm", "mm.id", "mp.model_id");
const productIds = products.map((product) => product.id);
const parts = productIds.length
? await database("mayo_parts as part")
.select("part.product_id", "part.part_type")
.whereIn("part.product_id", productIds)
: [];
const partTypesByProductId = new Map();
for (const part of parts) {
if (!partTypesByProductId.has(part.product_id)) {
partTypesByProductId.set(part.product_id, []);
}
if (part.part_type) {
partTypesByProductId.get(part.product_id).push(part.part_type);
}
}
const response = products.map((product) => ({
...product,
part_types: partTypesByProductId.get(product.id) ?? [],
}));
res.json(response);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get("/my-products2", async (req, res) => {
const productsService = new ItemsService("mayo_products", {
schema: await getSchema(),
accountability: req.accountability,
});
const partsService = new ItemsService("mayo_parts", {
schema: await getSchema(),
accountability: req.accountability,
});
try {
const products = await productsService.readByQuery({
fields: [
"id",
"note",
"model_id.name",
"model_id.strings_count",
"model_id.scale",
],
limit: -1,
});
res.json(products);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
},
};

View File

@@ -3,6 +3,7 @@
"description": "Please enter a description for your extension",
"icon": "extension",
"version": "1.0.1",
"license": "UNLICENSED",
"keywords": [
"directus",
"directus-extension",

View File

@@ -1,252 +1,11 @@
import { registerGetDictionaries } from './routes/dictionaries.get.js'
import { registerGetProducts } from './routes/products.get.js'
export default {
id: "mayo-api",
id: 'mayo-api',
handler: (router, context) => {
const { services, getSchema, database } = context;
const { ItemsService } = services;
router.get("/products", async (req, res) => {
const productsService = new ItemsService("mayo_products", {
schema: await getSchema(),
accountability: req.accountability,
});
const partsService = new ItemsService("mayo_parts", {
schema: await getSchema(),
accountability: req.accountability,
});
try {
const products = await productsService.readByQuery({
fields: [
"id",
"note",
"model_id.name",
"model_id.strings_count",
"model_id.scale",
],
limit: -1,
});
const productIds = products.map((product) => product.id);
const parts = await partsService.readByQuery({
fields: [
"id",
"product_id",
"part_type",
"top_color_id.name",
"back_color_id.name",
"top_finish",
"back_finish",
],
filter: {
product_id: {
_in: productIds,
},
},
limit: -1,
});
const partsByProductId = new Map();
for (const part of parts) {
const productId =
typeof part.product_id === "object"
? part.product_id.id
: part.product_id;
if (!partsByProductId.has(productId)) {
partsByProductId.set(productId, []);
}
partsByProductId.get(productId).push(part);
}
const response = products.map((product) => ({
...product,
parts: partsByProductId.get(product.id) ?? [],
}));
res.json(response);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get("/products-new", async (req, res) => {
try {
const products = await database("mayo_products as product")
.select(
"product.id",
"product.note",
"model.name as model_name",
"model.strings_count as model_strings_count",
"model.scale as model_scale",
)
.leftJoin("mayo_models as model", "product.model_id", "model.id");
const productIds = products.map((product) => product.id);
const parts = productIds.length
? await database("mayo_parts as part")
.select(
"part.id",
"part.product_id",
"part.part_type",
"part.top_finish",
"part.back_finish",
"top_color.name as top_color_name",
"back_color.name as back_color_name",
)
.leftJoin(
"mayo_colors as top_color",
"part.top_color_id",
"top_color.id",
)
.leftJoin(
"mayo_colors as back_color",
"part.back_color_id",
"back_color.id",
)
.whereIn("part.product_id", productIds)
: [];
const partsByProductId = new Map();
for (const part of parts) {
if (!partsByProductId.has(part.product_id)) {
partsByProductId.set(part.product_id, []);
}
partsByProductId.get(part.product_id).push({
id: part.id,
product_id: part.product_id,
part_type: part.part_type,
top_color_id: {
name: part.top_color_name,
},
back_color_id: {
name: part.back_color_name,
},
top_finish: part.top_finish,
back_finish: part.back_finish,
});
}
const response = products.map((product) => ({
id: product.id,
note: product.note,
model_id: {
name: product.model_name,
strings_count: product.model_strings_count,
scale: product.model_scale,
},
parts: partsByProductId.get(product.id) ?? [],
}));
res.json(response);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get("/orders", async (req, res) => {
const itemsService = new ItemsService("mayo_order_products", {
schema: await getSchema(),
accountability: req.accountability,
});
try {
const items = await itemsService.readByQuery({
fields: [
"id",
"product_order_index",
"order_id.order_number",
"order_id.order_year",
"order_id.client_id.name",
"product_id.id",
"product_id.note",
"product_id.model_id.name",
"product_id.model_id.strings_count",
"product_id.model_id.scale",
],
limit: -1,
});
res.json(items);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get("/my-products", async (req, res) => {
try {
const products = await database("mayo_products as mp")
.select(
"mp.id",
"mm.name as model",
"mo.order_number",
"mo.order_year",
"mop.product_order_index as order_index",
)
.leftJoin("mayo_order_products as mop", "mop.product_id", "mp.id")
.leftJoin("mayo_orders as mo", "mo.id", "mop.order_id")
.leftJoin("mayo_models as mm", "mm.id", "mp.model_id");
const productIds = products.map((product) => product.id);
const parts = productIds.length
? await database("mayo_parts as part")
.select("part.product_id", "part.part_type")
.whereIn("part.product_id", productIds)
: [];
const partTypesByProductId = new Map();
for (const part of parts) {
if (!partTypesByProductId.has(part.product_id)) {
partTypesByProductId.set(part.product_id, []);
}
if (part.part_type) {
partTypesByProductId.get(part.product_id).push(part.part_type);
}
}
const response = products.map((product) => ({
...product,
part_types: partTypesByProductId.get(product.id) ?? [],
}));
res.json(response);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get("/my-products2", async (req, res) => {
const productsService = new ItemsService("mayo_products", {
schema: await getSchema(),
accountability: req.accountability,
});
const partsService = new ItemsService("mayo_parts", {
schema: await getSchema(),
accountability: req.accountability,
});
try {
const products = await productsService.readByQuery({
fields: [
"id",
"note",
"model_id.name",
"model_id.strings_count",
"model_id.scale",
],
limit: -1,
});
res.json(products);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
registerGetProducts(router, context)
registerGetDictionaries(router, context)
},
};
}

View File

@@ -0,0 +1,19 @@
export async function getDictionaries({ database }) {
const [models, clients, productionLists, operations, colors, finishes] = await Promise.all([
database('mayo_product_models').select('id', 'name').orderBy('name', 'asc'),
database('mayo_clients').select('id', 'name', 'country').orderBy('name', 'asc'),
database('mayo_production_lists').select('id', 'name').orderBy('name', 'asc'),
database('mayo_production_operations').select('id', 'code', 'name').orderBy('name', 'asc'),
database('mayo_colors').select('id', 'name').orderBy('name', 'asc'),
database('mayo_finishes').select('id', 'code', 'name').orderBy('name', 'asc'),
])
return {
models,
clients,
productionLists,
operations,
colors,
finishes,
}
}

View File

@@ -0,0 +1,250 @@
import { serializeProduct } from '../serializers/product.serializer.js'
import { createTimelinePreview } from '../serializers/timeline.serializer.js'
import { parseOrderSearch } from '../utils/order-search.js'
import { createPageInfo, parsePagination } from '../utils/pagination.js'
export async function getProducts({ database }, query = {}) {
const { limit, offset } = parsePagination(query)
const orderSearch = parseOrderSearch(query.orderSearch)
const baseQuery = createProductsBaseQuery(database, query, orderSearch)
const [{ total }] = await baseQuery
.clone()
.clearSelect()
.clearOrder()
.countDistinct({ total: 'order_item.id' })
const rows = await baseQuery
.clone()
.select(
'order_item.id as order_item_id',
'order_item.order_index',
'product.id as product_id',
'ord.order_number',
'ord.order_year',
'model.name as model_name',
'client.name as client_name',
)
.orderBy('ord.order_year', 'desc')
.orderBy('ord.order_number', 'desc')
.orderBy('order_item.order_index', 'asc')
.limit(limit)
.offset(offset)
const productIds = rows.map((row) => row.product_id)
const orderItemIds = rows.map((row) => row.order_item_id)
const [productionListsByOrderItemId, finishesByProductId, eventsByProductId] = await Promise.all([
readProductionListsByOrderItemId(database, orderItemIds),
readFinishesByProductId(database, productIds),
readEventsByProductId(database, productIds),
])
const items = rows.map((row) =>
serializeProduct(row, {
finish: serializeFinish(finishesByProductId.get(row.product_id)),
productionLists: productionListsByOrderItemId.get(row.order_item_id) ?? [],
timelinePreview: createTimelinePreview(eventsByProductId.get(row.product_id) ?? []),
}),
)
return {
items,
pageInfo: createPageInfo({
limit,
offset,
total: Number(total),
}),
}
}
function createProductsBaseQuery(database, query, orderSearch) {
const productsQuery = database('mayo_order_items as order_item')
.join('mayo_products as product', 'order_item.product_id', 'product.id')
.join('mayo_orders as ord', 'order_item.order_id', 'ord.id')
.leftJoin('mayo_product_models as model', 'product.product_model_id', 'model.id')
.leftJoin('mayo_clients as client', 'ord.client_id', 'client.id')
applyFilters(productsQuery, query, orderSearch)
return productsQuery
}
function applyFilters(productsQuery, query, orderSearch) {
if (query.model) {
productsQuery.where('product.product_model_id', query.model)
}
if (query.client) {
productsQuery.where('ord.client_id', query.client)
}
if (query.year) {
productsQuery.where('ord.order_year', Number(query.year))
}
if (query.productionList) {
productsQuery.whereExists(function existsProductionList() {
this.select(1)
.from('mayo_product_production_lists as product_list')
.whereRaw('product_list.order_item_id = order_item.id')
.where('product_list.production_list_id', query.productionList)
})
}
if (query.finish) {
productsQuery.whereExists(function existsFinish() {
this.select(1)
.from('mayo_product_parts as part')
.leftJoin('mayo_finishes as top_finish', 'part.top_finish_id', 'top_finish.id')
.leftJoin('mayo_finishes as back_finish', 'part.back_finish_id', 'back_finish.id')
.whereRaw('part.product_id = product.id')
.where((finishQuery) => {
finishQuery.where('top_finish.code', query.finish).orWhere('back_finish.code', query.finish)
})
})
}
applyOrderSearchFilter(productsQuery, query.search, orderSearch)
}
function applyOrderSearchFilter(productsQuery, search, orderSearch) {
if (!String(search ?? '').trim()) {
return
}
if (!orderSearch.length) {
productsQuery.whereRaw('1 = 0')
return
}
productsQuery.where((conditionsQuery) => {
for (const condition of orderSearch) {
conditionsQuery.orWhere((conditionQuery) => {
applyOrderSearchCondition(conditionQuery, condition)
})
}
})
}
function applyOrderSearchCondition(conditionQuery, condition) {
if (condition.type === 'orderNumberPrefix' && condition.orderNumberPrefix) {
conditionQuery.where('ord.order_number', 'like', `${condition.orderNumberPrefix}%`)
return
}
if (condition.orderNumber !== null && condition.orderNumber !== undefined) {
conditionQuery.whereRaw('CAST("ord"."order_number" AS INTEGER) = ?', [condition.orderNumber])
}
if (condition.orderYear !== null && condition.orderYear !== undefined) {
conditionQuery.where('ord.order_year', condition.orderYear)
}
if (condition.orderIndex !== null && condition.orderIndex !== undefined) {
conditionQuery.where('order_item.order_index', condition.orderIndex)
}
}
async function readProductionListsByOrderItemId(database, orderItemIds) {
const result = new Map()
if (!orderItemIds.length) {
return result
}
const rows = await database('mayo_product_production_lists as product_list')
.select('product_list.order_item_id', 'production_list.name')
.join(
'mayo_production_lists as production_list',
'product_list.production_list_id',
'production_list.id',
)
.whereIn('product_list.order_item_id', orderItemIds)
.orderBy('production_list.name', 'asc')
for (const row of rows) {
if (!result.has(row.order_item_id)) {
result.set(row.order_item_id, [])
}
result.get(row.order_item_id).push(row.name)
}
return result
}
async function readFinishesByProductId(database, productIds) {
const result = new Map()
if (!productIds.length) {
return result
}
const rows = await database('mayo_product_parts as part')
.select(
'part.product_id',
'top_finish.code as top_finish_code',
'back_finish.code as back_finish_code',
)
.leftJoin('mayo_finishes as top_finish', 'part.top_finish_id', 'top_finish.id')
.leftJoin('mayo_finishes as back_finish', 'part.back_finish_id', 'back_finish.id')
.whereIn('part.product_id', productIds)
for (const row of rows) {
if (!result.has(row.product_id)) {
result.set(row.product_id, new Set())
}
const finishes = result.get(row.product_id)
if (row.top_finish_code) finishes.add(row.top_finish_code)
if (row.back_finish_code) finishes.add(row.back_finish_code)
}
return result
}
async function readEventsByProductId(database, productIds) {
const result = new Map()
if (!productIds.length) {
return result
}
const rows = await database('mayo_production_events as event')
.select(
'event.id',
'event.event_date as date',
'part.product_id',
'part_type.code as part_type',
'operation.id as operation_id',
'operation.code as operation_code',
'operation.name as operation_name',
'operation.description as operation_description',
)
.join('mayo_product_parts as part', 'event.product_part_id', 'part.id')
.join('mayo_part_types as part_type', 'part.part_type_id', 'part_type.id')
.leftJoin('mayo_production_operations as operation', 'event.operation_id', 'operation.id')
.whereIn('part.product_id', productIds)
.whereNotNull('event.operation_id')
.orderBy('event.event_date', 'asc')
.orderBy('event.ordinal', 'asc')
for (const row of rows) {
if (!result.has(row.product_id)) {
result.set(row.product_id, [])
}
result.get(row.product_id).push(row)
}
return result
}
function serializeFinish(finishes) {
if (!finishes?.size) {
return null
}
return [...finishes].sort().join('+')
}

View File

@@ -0,0 +1,13 @@
import { getDictionaries } from '../repositories/dictionaries.repository.js'
import { serializeDictionaries } from '../serializers/dictionaries.serializer.js'
import { asyncHandler } from '../utils/async-handler.js'
export function registerGetDictionaries(router, context) {
router.get(
'/dictionaries',
asyncHandler(async (req, res) => {
const dictionaries = await getDictionaries(context)
res.json(serializeDictionaries(dictionaries))
}),
)
}

View File

@@ -0,0 +1,12 @@
import { getProducts } from '../repositories/products.repository.js'
import { asyncHandler } from '../utils/async-handler.js'
export function registerGetProducts(router, context) {
router.get(
'/products',
asyncHandler(async (req, res) => {
const result = await getProducts(context, req.query)
res.json(result)
}),
)
}

View File

@@ -0,0 +1,54 @@
export function serializeDictionaries(dictionaries) {
return {
models: dictionaries.models.map(serializeModel),
clients: dictionaries.clients.map(serializeClient),
finishes: dictionaries.finishes.map(serializeFinish),
productionLists: dictionaries.productionLists.map(serializeProductionList),
operations: dictionaries.operations.map(serializeOperation),
colors: dictionaries.colors.map(serializeColor),
}
}
function serializeModel(model) {
return {
id: model.id,
name: model.name,
}
}
function serializeClient(client) {
return {
id: client.id,
name: client.name,
country: client.country,
}
}
function serializeFinish(finish) {
return {
value: finish.code,
label: finish.name,
}
}
function serializeProductionList(productionList) {
return {
id: productionList.id,
name: productionList.name,
}
}
function serializeOperation(operation) {
return {
id: operation.id,
code: operation.code,
name: operation.name,
}
}
function serializeColor(color) {
return {
id: color.id,
name: color.name,
}
}

View File

@@ -0,0 +1,22 @@
export function serializeProduct(row, { finish, productionLists, timelinePreview }) {
const orderNumber = formatOrderNumber(row.order_number)
const orderIndex = Number(row.order_index)
const orderYear = Number(row.order_year)
return {
id: row.product_id,
orderId: `${orderNumber}/${orderYear}/${orderIndex}`,
orderNumber,
orderYear,
orderIndex,
model: row.model_name ?? null,
client: row.client_name ?? null,
finish,
productionLists,
timelinePreview,
}
}
function formatOrderNumber(value) {
return String(value ?? '').padStart(4, '0')
}

View File

@@ -0,0 +1,29 @@
export function createTimelinePreview(events) {
return {
body: createPartTimelinePreview(events, 'BODY'),
neck: createPartTimelinePreview(events, 'NECK'),
}
}
function createPartTimelinePreview(events, partType) {
return events
.filter((event) => event.part_type === partType)
.map((event) => ({
id: event.id,
code: createOperationCode(event),
label: event.operation_name,
date: event.date,
status: 'done',
}))
}
function createOperationCode(event) {
if (event.operation_code) {
return event.operation_code
}
const source = event.operation_description || event.operation_name || ''
const firstWord = source.trim().split(/\s+/)[0] ?? ''
return firstWord.slice(0, 3).toUpperCase()
}

View File

@@ -0,0 +1,9 @@
export function asyncHandler(handler) {
return async (req, res, next) => {
try {
await handler(req, res, next)
} catch (error) {
next(error)
}
}
}

View File

@@ -0,0 +1,16 @@
export function parseOrderSearch(value) {
if (!value) {
return []
}
if (Array.isArray(value)) {
return value
}
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}

View File

@@ -0,0 +1,28 @@
const DEFAULT_LIMIT = 30
const MAX_LIMIT = 100
export function parsePagination(query = {}) {
const limit = clampNumber(query.limit, DEFAULT_LIMIT, 1, MAX_LIMIT)
const offset = clampNumber(query.offset, 0, 0, Number.MAX_SAFE_INTEGER)
return { limit, offset }
}
export function createPageInfo({ limit, offset, total }) {
return {
limit,
offset,
hasMore: offset + limit < total,
total,
}
}
function clampNumber(value, fallback, min, max) {
const parsed = Number.parseInt(value, 10)
if (!Number.isFinite(parsed)) {
return fallback
}
return Math.min(Math.max(parsed, min), max)
}