diff --git a/.npmignore b/.npmignore
deleted file mode 100644
index a7ac4f6..0000000
--- a/.npmignore
+++ /dev/null
@@ -1,16 +0,0 @@
-.github
-.DS_Store
-.editorconfig
-.gitignore
-.gitmodules
-.lgtm.yml
-appveyor.yml
-codecov.yml
-.release
-.travis.yml
-.eslintrc.yaml
-.eslintrc.json
-.codeclimate.yml
-test/
-DEVELOP.md
-.prettierrc.yml
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c1f65fa..dea1ea8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
### Unreleased
+### [3.0.0-alpha.8.1] - 2026-03-15
+
+- feat(zone): use DataTable for list, added search/limit options
+- routes/zr: add extra data about ZR parse failures
+
### [3.0.0-alpha.8] - 2026-03-14
- lib/zone: add limit option
@@ -57,8 +62,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
[3.0.0-alpha.1]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.1
[3.0.0-alpha.2]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.2
[3.0.0-alpha.3]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.3
-[3.0.0-alpha.4]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.4
-[3.0.0-alpha.5]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.5
-[3.0.0-alpha.6]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.6
+[3.0.0-alpha.4]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.4
+[3.0.0-alpha.5]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.5
+[3.0.0-alpha.6]: https://github.com/NicTool/api/releases/tag/3.0.0-alpha.6
[3.0.0-alpha.7]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.7
[3.0.0-alpha.8]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.8
+[3.0.0-alpha.8.1]: https://github.com/NicTool/api/releases/tag/v3.0.0-alpha.8.1
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index 3547fbb..b321d59 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -2,7 +2,7 @@
This handcrafted artisanal software is brought to you by:
-| 
msimerson (15)|
+| 
msimerson (16)|
| :---: |
this file is generated by [.release](https://github.com/msimerson/.release).
diff --git a/lib/zone.js b/lib/zone.js
index e3250b0..6f2f356 100644
--- a/lib/zone.js
+++ b/lib/zone.js
@@ -4,6 +4,36 @@ import { mapToDbColumn } from './util.js'
const zoneDbMap = { id: 'nt_zone_id', gid: 'nt_group_id' }
const boolFields = ['deleted']
+function applyZoneFilters(query, params, filters = {}) {
+ let nextQuery = query
+ const nextParams = [...params]
+
+ const append = (sql) => {
+ nextQuery += `${/\bWHERE\b/.test(nextQuery) ? ' AND' : ' WHERE'} ${sql}`
+ }
+
+ const search = typeof filters.search === 'string' ? filters.search.trim() : ''
+ if (search) {
+ append('(zone LIKE ? OR description LIKE ?)')
+ const wildcard = `%${search}%`
+ nextParams.push(wildcard, wildcard)
+ }
+
+ const zoneLike = typeof filters.zone_like === 'string' ? filters.zone_like.trim() : ''
+ if (zoneLike) {
+ append('zone LIKE ?')
+ nextParams.push(`%${zoneLike}%`)
+ }
+
+ const descriptionLike = typeof filters.description_like === 'string' ? filters.description_like.trim() : ''
+ if (descriptionLike) {
+ append('description LIKE ?')
+ nextParams.push(`%${descriptionLike}%`)
+ }
+
+ return [nextQuery, nextParams]
+}
+
class Zone {
constructor() {
this.mysql = Mysql
@@ -20,12 +50,34 @@ class Zone {
async get(args) {
args = JSON.parse(JSON.stringify(args))
- if (args.deleted === undefined) args.deleted = false
+ args.deleted = args.deleted ?? false
+
+ const filters = {
+ search: args.search,
+ zone_like: args.zone_like,
+ description_like: args.description_like,
+ }
+ delete args.search
+ delete args.zone_like
+ delete args.description_like
+
+ const sortByMap = {
+ id: 'nt_zone_id',
+ zone: 'zone',
+ description: 'description',
+ last_modified: 'last_modified',
+ }
+ const sortBy = sortByMap[args.sort_by] ?? 'zone'
+ const sortDir = args.sort_dir === 'desc' ? 'DESC' : 'ASC'
+ delete args.sort_by
+ delete args.sort_dir
const limit = Number.isInteger(args.limit) ? args.limit : undefined
delete args.limit
+ const offset = Number.isInteger(args.offset) ? Math.max(0, args.offset) : 0
+ delete args.offset
- const sqlLimit = limit === undefined ? '' : ` LIMIT ${Math.max(1, limit)}`
+ const sqlLimit = limit === undefined ? '' : ` LIMIT ${Math.max(1, limit)} OFFSET ${offset}`
const [query, params] = Mysql.select(
`SELECT nt_zone_id AS id
@@ -46,7 +98,10 @@ class Zone {
mapToDbColumn(args, zoneDbMap),
)
- const rows = await Mysql.execute(`${query}${sqlLimit}`, params)
+ let [finalQuery, finalParams] = applyZoneFilters(query, params, filters)
+ finalQuery += ` ORDER BY ${sortBy} ${sortDir}`
+
+ const rows = await Mysql.execute(`${finalQuery}${sqlLimit}`, finalParams)
for (const row of rows) {
for (const b of boolFields) {
row[b] = row[b] === 1
@@ -77,6 +132,30 @@ class Zone {
return rows
}
+ async count(args = {}) {
+ args = JSON.parse(JSON.stringify(args))
+ args.deleted = args.deleted ?? false
+
+ const filters = {
+ search: args.search,
+ zone_like: args.zone_like,
+ description_like: args.description_like,
+ }
+ delete args.search
+ delete args.zone_like
+ delete args.description_like
+
+ const [query, params] = Mysql.select(
+ `SELECT COUNT(*) AS total
+ FROM nt_zone`,
+ mapToDbColumn(args, zoneDbMap),
+ )
+
+ const [finalQuery, finalParams] = applyZoneFilters(query, params, filters)
+ const rows = await Mysql.execute(finalQuery, finalParams)
+ return rows?.[0]?.total ?? 0
+ }
+
async put(args) {
if (!args.id) return false
const id = args.id
diff --git a/lib/zone_record.js b/lib/zone_record.js
index 25ae4e1..e6b391e 100644
--- a/lib/zone_record.js
+++ b/lib/zone_record.js
@@ -58,7 +58,6 @@ class ZoneRecord {
for (const b of boolFields) {
row[b] = row[b] === 1
}
-
if (args.deleted === false) delete row.deleted
}
diff --git a/package.json b/package.json
index c9ef7ba..bea6a7e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@nictool/api",
- "version": "3.0.0-alpha.8",
+ "version": "3.0.0-alpha.8.1",
"description": "NicTool API",
"main": "index.js",
"type": "module",
diff --git a/routes/zone.js b/routes/zone.js
index ea233dc..dd4d714 100644
--- a/routes/zone.js
+++ b/routes/zone.js
@@ -38,13 +38,32 @@ function ZoneRoutes(server) {
tags: ['api'],
},
handler: async (request, h) => {
+ const deleted = request.query.deleted === true
const getArgs = {
- deleted: request.query.deleted === true ? 1 : 0,
- limit: 1000,
+ deleted,
+ limit: Number.isInteger(request.query.limit) ? request.query.limit : 1000,
}
if (request.params.id) getArgs.id = parseInt(request.params.id, 10)
+ if (request.query.search) getArgs.search = request.query.search
+ if (Number.isInteger(request.query.offset)) getArgs.offset = request.query.offset
+ if (request.query.zone_like) getArgs.zone_like = request.query.zone_like
+ if (request.query.description_like) getArgs.description_like = request.query.description_like
+ if (request.query.sort_by) getArgs.sort_by = request.query.sort_by
+ if (request.query.sort_dir) getArgs.sort_dir = request.query.sort_dir
- const zones = await Zone.get(getArgs)
+ const countArgs = {
+ deleted,
+ ...(getArgs.id ? { id: getArgs.id } : {}),
+ ...(getArgs.search ? { search: getArgs.search } : {}),
+ ...(getArgs.zone_like ? { zone_like: getArgs.zone_like } : {}),
+ ...(getArgs.description_like ? { description_like: getArgs.description_like } : {}),
+ }
+
+ const [zones, filtered, total] = await Promise.all([
+ Zone.get(getArgs),
+ Zone.count(countArgs),
+ Zone.count(getArgs.id ? { deleted, id: getArgs.id } : { deleted }),
+ ])
return h
.response({
@@ -52,6 +71,12 @@ function ZoneRoutes(server) {
meta: {
api: meta.api,
msg: `here's your zone(s)`,
+ pagination: {
+ total,
+ filtered,
+ limit: getArgs.limit,
+ offset: getArgs.offset ?? 0,
+ },
},
})
.code(200)
@@ -103,7 +128,7 @@ function ZoneRoutes(server) {
},
handler: async (request, h) => {
const zones = await Zone.get({
- deleted: request.query.deleted === true ? 1 : 0,
+ deleted: request.query.deleted === true,
id: parseInt(request.params.id, 10),
})
diff --git a/routes/zone.test.js b/routes/zone.test.js
index 821548e..8ebe852 100644
--- a/routes/zone.test.js
+++ b/routes/zone.test.js
@@ -54,6 +54,16 @@ describe('zone routes', () => {
assert.equal(res.result.zone[0].zone, nsCase.zone)
})
+ it('GET /zone?search=... returns DB matches', async () => {
+ const res = await server.inject({
+ method: 'GET',
+ url: '/zone?search=route.example',
+ headers: auth.headers,
+ })
+ assert.equal(res.statusCode, 200)
+ assert.ok(res.result.zone.some((z) => z.zone === nsCase.zone))
+ })
+
it(`POST /zone (${case2Id})`, async () => {
const testCase = JSON.parse(JSON.stringify(nsCase))
testCase.id = case2Id // make it unique
diff --git a/routes/zone_record.js b/routes/zone_record.js
index aafd664..137dbb8 100644
--- a/routes/zone_record.js
+++ b/routes/zone_record.js
@@ -1,8 +1,32 @@
import validate from '@nictool/validate'
import ZoneRecord from '../lib/zone_record.js'
+import Zone from '../lib/zone.js'
import { meta } from '../lib/util.js'
+async function zoneRecordResponseFailAction(request, h, err) {
+ const detail = err?.details?.find(
+ (d) => Array.isArray(d.path) && d.path[0] === 'zone_record' && d.path[2] === 'owner',
+ )
+
+ if (detail) {
+ const index = detail.path[1]
+ const badRecord = request.response?.source?.zone_record?.[index]
+
+ if (badRecord) {
+ let zoneName = 'unknown'
+ if (Number.isInteger(badRecord.zid)) {
+ const zones = await Zone.get({ id: badRecord.zid, deleted: 0 })
+ if (zones.length > 0) zoneName = zones[0].zone
+ }
+
+ err.message = `${err.message}. Invalid zone record owner for zone "${zoneName}" (zone id: ${badRecord.zid ?? 'unknown'}, record id: ${badRecord.id ?? 'unknown'}, owner: "${badRecord.owner ?? 'unknown'}")`
+ }
+ }
+
+ throw err
+}
+
function ZoneRecordRoutes(server) {
server.route([
{
@@ -15,7 +39,7 @@ function ZoneRecordRoutes(server) {
},
response: {
schema: validate.zone_record.GET_res,
- failAction: 'log',
+ failAction: zoneRecordResponseFailAction,
},
tags: ['api'],
},