@airoom/nextmin-node 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -213,21 +213,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
213
213
  const sortRaw = String(req.query.sort ?? '').trim();
214
214
  const sortTypeRaw = String(req.query.sortType ?? '').trim();
215
215
  const filter = {};
216
- const buildSortSpec = () => {
217
- const keys = (0, utils_1.splitCSV)(sortRaw);
218
- const dirs = (0, utils_1.splitCSV)(sortTypeRaw);
219
- const s = {};
220
- keys.forEach((k, i) => {
221
- if (!schema.attributes?.[k])
222
- return;
223
- const d = dirs[i] ?? dirs[dirs.length - 1] ?? 'desc';
224
- s[k] = /^(desc|-1)$/i.test(d) ? -1 : 1;
225
- });
226
- if (!Object.keys(s).length)
227
- s.createdAt = -1;
228
- return s;
229
- };
230
- // SEARCH
216
+ // ---------- SEARCH ----------
231
217
  const ors = [];
232
218
  const ands = [];
233
219
  if (q && searchKey && schema.attributes?.[searchKey]) {
@@ -277,7 +263,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
277
263
  filter[dateKey] = range;
278
264
  }
279
265
  }
280
- // arbitrary field filters ---- Direct field filters from req.query (e.g., ?isPublic=true&key=doctor) ----
266
+ // ---------- Direct field filters from req.query ----------
281
267
  const RESERVED = new Set([
282
268
  'page',
283
269
  'limit',
@@ -354,21 +340,23 @@ function mountCrudRoutes(ctx, modelNameLC) {
354
340
  if (!rdec.allow) {
355
341
  return res.status(403).json({ error: true, message: 'forbidden' });
356
342
  }
357
- const baseSort = buildSortSpec();
343
+ // ---------- SORT (updated; type-safe; honors client; allows virtual keys) ----------
344
+ const baseKeys = new Set(Object.keys(schema.attributes || {}));
345
+ const baseSort = (0, utils_1.parseSort)(sortRaw, sortTypeRaw, baseKeys);
358
346
  const finalFilter = (0, authorize_1.andFilter)(filter, rdec.queryFilter || {});
359
347
  const isUsersModel = modelNameLC === 'user' ||
360
348
  modelNameLC === 'users' ||
361
349
  (schema?.modelName ?? '').toLowerCase() === 'users';
362
350
  const currentUserId = req?.user?.id;
363
- // EXTENDED PATH
351
+ // ---------- EXTENDED PATH ----------
364
352
  if (schema.extends) {
365
353
  const baseName = String(schema.extends);
366
354
  const baseLC = baseName.toLowerCase();
367
355
  const baseSchema = ctx.getSchema(baseLC);
368
356
  const baseModel = ctx.getModel(baseLC);
369
- const baseKeys = new Set(Object.keys(baseSchema.attributes || {}));
370
- const { child: childFilter, base: extBaseFilter } = (0, utils_1.splitFilterForExtended)(finalFilter, baseKeys);
371
- const { child: childSort, base: extBaseSort } = (0, utils_1.splitSortForExtended)(baseSort, baseKeys);
357
+ const baseAttrKeys = new Set(Object.keys(baseSchema.attributes || {}));
358
+ const { child: childFilter, base: extBaseFilter } = (0, utils_1.splitFilterForExtended)(finalFilter, baseAttrKeys);
359
+ const { child: childSort, base: extBaseSort } = (0, utils_1.splitSortForExtended)(baseSort, baseAttrKeys);
372
360
  const needPrivateForHydrate = true;
373
361
  const requiresBaseProcessing = Object.keys(extBaseFilter).length > 0 ||
374
362
  Object.keys(extBaseSort).length > 0;
@@ -407,7 +395,26 @@ function mountCrudRoutes(ctx, modelNameLC) {
407
395
  }
408
396
  const combinedSort = { ...extBaseSort, ...childSort };
409
397
  if (Object.keys(combinedSort).length) {
410
- merged = (0, utils_1.sortInMemory)(merged, combinedSort);
398
+ merged = merged.sort((a, b) => {
399
+ for (const k of Object.keys(combinedSort)) {
400
+ const dir = combinedSort[k];
401
+ const av = a?.[k];
402
+ const bv = b?.[k];
403
+ const ax = av instanceof Date ? +av : av;
404
+ const bx = bv instanceof Date ? +bv : bv;
405
+ if (ax == null && bx == null)
406
+ continue;
407
+ if (ax == null)
408
+ return 1 * dir;
409
+ if (bx == null)
410
+ return -1 * dir;
411
+ if (ax > bx)
412
+ return 1 * dir;
413
+ if (ax < bx)
414
+ return -1 * dir;
415
+ }
416
+ return 0;
417
+ });
411
418
  }
412
419
  // **dedupe**: one row per base entity
413
420
  merged = (0, utils_1.dedupeBy)(merged, (r) => String(r.exId || r.id || r._id || ''));
@@ -423,9 +430,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
423
430
  paged = merged.slice(start, start + limit);
424
431
  }
425
432
  else {
426
- // If your model supports it, prefer a distinct count on baseId:
427
- // totalRows = await (model as any).countDistinct?.('baseId', childFilter) ?? merged.length;
428
- totalRows = merged.length; // fallback keeps UI consistent with deduped list
433
+ totalRows = merged.length;
429
434
  paged = merged.slice(0, limit);
430
435
  }
431
436
  const data = rdec.exposePrivate
@@ -439,7 +444,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
439
444
  sort: combinedSort,
440
445
  });
441
446
  }
442
- // NON-EXTENDED
447
+ // ---------- NON-EXTENDED ----------
443
448
  const sort = baseSort;
444
449
  const totalRows = await model.count(finalFilter);
445
450
  let rawRows = [];
@@ -1,2 +1,2 @@
1
- import type { RouterCtx } from "./ctx";
1
+ import type { RouterCtx } from './ctx';
2
2
  export declare function setupFileRoutes(ctx: RouterCtx): void;
@@ -14,23 +14,26 @@ function setupFileRoutes(ctx) {
14
14
  limits: { fileSize: 50 * 1024 * 1024, files: 20 },
15
15
  });
16
16
  const fileAuthMiddleware = (req, res, next) => ctx.authenticateMiddleware(req, res, next);
17
- ctx.router.post("/files", fileAuthMiddleware, upload.any(), async (req, res) => {
17
+ const fileApiKeyMiddleware = (req, res, next) => ctx.apiKeyMiddleware(req, res, next);
18
+ ctx.router.post('/file', fileApiKeyMiddleware, upload.any(), async (req, res) => {
18
19
  try {
19
20
  const files = req.files ?? [];
20
21
  if (!files.length) {
21
- return res.status(400).json({ error: true, message: "No files uploaded" });
22
+ return res
23
+ .status(400)
24
+ .json({ error: true, message: 'No files uploaded' });
22
25
  }
23
26
  const results = await Promise.all(files.map(async (f) => {
24
27
  const folder = shortFolder();
25
28
  const ext = (f.originalname.match(/\.([A-Za-z0-9]{1,8})$/)?.[1] ??
26
29
  (0, filename_1.extFromMime)(f.mimetype) ??
27
- "bin").toLowerCase();
30
+ 'bin').toLowerCase();
28
31
  const key = `${folder}/${shortUid()}.${ext}`;
29
32
  const out = await ctx.fileStorage.upload({
30
33
  key,
31
34
  body: f.buffer,
32
35
  contentType: f.mimetype,
33
- metadata: { originalName: f.originalname || "" },
36
+ metadata: { originalName: f.originalname || '' },
34
37
  });
35
38
  return {
36
39
  provider: out.provider,
@@ -46,38 +49,88 @@ function setupFileRoutes(ctx) {
46
49
  }));
47
50
  return res.json({
48
51
  success: true,
49
- message: "Files uploaded successfully",
52
+ message: 'Files uploaded successfully',
50
53
  data: results,
51
54
  });
52
55
  }
53
56
  catch (err) {
54
- return res.status(400).json({ error: true, message: err?.message ?? "Upload failed" });
57
+ return res
58
+ .status(400)
59
+ .json({ error: true, message: err?.message ?? 'Upload failed' });
55
60
  }
56
61
  });
57
- ctx.router.delete("/files/:key(*)", fileAuthMiddleware, async (req, res) => {
62
+ ctx.router.post('/files', fileAuthMiddleware, upload.any(), async (req, res) => {
58
63
  try {
59
- const key = String(req.params.key || "");
64
+ const files = req.files ?? [];
65
+ if (!files.length) {
66
+ return res
67
+ .status(400)
68
+ .json({ error: true, message: 'No files uploaded' });
69
+ }
70
+ const results = await Promise.all(files.map(async (f) => {
71
+ const folder = shortFolder();
72
+ const ext = (f.originalname.match(/\.([A-Za-z0-9]{1,8})$/)?.[1] ??
73
+ (0, filename_1.extFromMime)(f.mimetype) ??
74
+ 'bin').toLowerCase();
75
+ const key = `${folder}/${shortUid()}.${ext}`;
76
+ const out = await ctx.fileStorage.upload({
77
+ key,
78
+ body: f.buffer,
79
+ contentType: f.mimetype,
80
+ metadata: { originalName: f.originalname || '' },
81
+ });
82
+ return {
83
+ provider: out.provider,
84
+ bucket: out.bucket,
85
+ key: out.key,
86
+ url: out.url,
87
+ etag: out.etag,
88
+ contentType: out.contentType,
89
+ size: out.size,
90
+ metadata: out.metadata,
91
+ originalName: f.originalname,
92
+ };
93
+ }));
94
+ return res.json({
95
+ success: true,
96
+ message: 'Files uploaded successfully',
97
+ data: results,
98
+ });
99
+ }
100
+ catch (err) {
101
+ return res
102
+ .status(400)
103
+ .json({ error: true, message: err?.message ?? 'Upload failed' });
104
+ }
105
+ });
106
+ ctx.router.delete('/files/:key(*)', fileAuthMiddleware, async (req, res) => {
107
+ try {
108
+ const key = String(req.params.key || '');
60
109
  if (!key) {
61
- return res.status(400).json({ error: true, message: "Key is required" });
110
+ return res
111
+ .status(400)
112
+ .json({ error: true, message: 'Key is required' });
62
113
  }
63
114
  const { deleted } = await ctx.fileStorage.delete(key);
64
115
  return res.json({
65
116
  success: true,
66
- message: deleted ? "File deleted" : "Delete attempted",
117
+ message: deleted ? 'File deleted' : 'Delete attempted',
67
118
  key,
68
119
  deleted,
69
120
  });
70
121
  }
71
122
  catch (err) {
72
- return res.status(400).json({ error: true, message: err?.message ?? "Delete failed" });
123
+ return res
124
+ .status(400)
125
+ .json({ error: true, message: err?.message ?? 'Delete failed' });
73
126
  }
74
127
  });
75
128
  }
76
129
  function shortFolder() {
77
130
  const d = new Date();
78
131
  const y = d.getFullYear();
79
- const m = String(d.getMonth() + 1).padStart(2, "0");
80
- const day = String(d.getDate()).padStart(2, "0");
132
+ const m = String(d.getMonth() + 1).padStart(2, '0');
133
+ const day = String(d.getDate()).padStart(2, '0');
81
134
  return `uploads/${y}/${m}/${day}`;
82
135
  }
83
136
  function shortUid() {
@@ -2,6 +2,7 @@ import type { Request } from 'express';
2
2
  export type AnyRec = Record<string, any>;
3
3
  export type SortDir = 1 | -1;
4
4
  export type SortSpec = Record<string, SortDir>;
5
+ export declare const ALLOWED_VIRTUAL_ATTRS: Set<string>;
5
6
  export declare const isPlainObject: (v: unknown) => v is AnyRec;
6
7
  export declare const splitCSV: (raw: string) => string[];
7
8
  export declare function normalizeAttrType(attr: any): string;
@@ -46,16 +47,16 @@ export declare function buildPredicateForField(field: string, attr: any, raw: st
46
47
  } | {
47
48
  [field]: Date;
48
49
  } | null;
49
- export declare function parseSort(expr?: string): Record<string, 1 | -1> | undefined;
50
- export declare function parseQuery(req: Request): {
50
+ export declare const VIRTUAL_SORT_KEYS: Set<string>;
51
+ export declare function parseSort(sortRaw: unknown, sortTypeRaw: unknown, allowedKeys?: Set<string>): SortSpec;
52
+ export type ParsedQuery = {
51
53
  limit: number;
52
54
  page: number;
53
55
  skip: number;
54
- projection: {
55
- [k: string]: 1;
56
- } | undefined;
57
- sort: Record<string, 1 | -1> | undefined;
56
+ projection?: Record<string, 1>;
57
+ sort: SortSpec;
58
58
  };
59
+ export declare function parseQuery(req: Request, allowedKeys?: Set<string>): ParsedQuery;
59
60
  export declare function extractIds(val: unknown): string[];
60
61
  export declare function refInfoFromAttr(attr: any): {
61
62
  ref: string;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.dedupeBy = exports.splitCSV = exports.isPlainObject = void 0;
3
+ exports.dedupeBy = exports.VIRTUAL_SORT_KEYS = exports.splitCSV = exports.isPlainObject = exports.ALLOWED_VIRTUAL_ATTRS = void 0;
4
4
  exports.normalizeAttrType = normalizeAttrType;
5
5
  exports.toIdString = toIdString;
6
6
  exports.splitFilterForExtended = splitFilterForExtended;
@@ -12,6 +12,12 @@ exports.parseSort = parseSort;
12
12
  exports.parseQuery = parseQuery;
13
13
  exports.extractIds = extractIds;
14
14
  exports.refInfoFromAttr = refInfoFromAttr;
15
+ exports.ALLOWED_VIRTUAL_ATTRS = new Set([
16
+ 'createdAt',
17
+ 'updatedAt',
18
+ '_id',
19
+ 'id',
20
+ ]);
15
21
  const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
16
22
  exports.isPlainObject = isPlainObject;
17
23
  const splitCSV = (raw) => raw
@@ -206,35 +212,68 @@ function buildPredicateForField(field, attr, raw) {
206
212
  : { [field]: raw };
207
213
  }
208
214
  }
209
- function parseSort(expr) {
210
- if (!expr)
211
- return;
212
- const out = {};
213
- for (const raw of expr
215
+ exports.VIRTUAL_SORT_KEYS = new Set([
216
+ 'createdAt',
217
+ 'updatedAt',
218
+ '_id',
219
+ 'id',
220
+ ]);
221
+ function parseSort(sortRaw, sortTypeRaw, allowedKeys) {
222
+ const keys = String(sortRaw ?? '')
214
223
  .split(',')
215
224
  .map((s) => s.trim())
216
- .filter(Boolean)) {
217
- if (raw.startsWith('-'))
218
- out[raw.slice(1)] = -1;
219
- else if (raw.startsWith('+'))
220
- out[raw.slice(1)] = 1;
221
- else
222
- out[raw] = 1;
225
+ .filter(Boolean);
226
+ const dirs = String(sortTypeRaw ?? '')
227
+ .split(',')
228
+ .map((s) => s.trim())
229
+ .filter(Boolean);
230
+ const hasKey = (k) => (allowedKeys?.has(k) ?? false) || exports.VIRTUAL_SORT_KEYS.has(k);
231
+ const spec = {};
232
+ if (keys.length) {
233
+ keys.forEach((k, i) => {
234
+ let field = k;
235
+ let dir = null;
236
+ // compact + / - prefix
237
+ if (k.startsWith('-')) {
238
+ field = k.slice(1);
239
+ dir = -1;
240
+ }
241
+ else if (k.startsWith('+')) {
242
+ field = k.slice(1);
243
+ dir = 1;
244
+ }
245
+ if (dir === null) {
246
+ const d = dirs[i] ?? dirs[dirs.length - 1] ?? 'desc';
247
+ dir = /^(desc|-1)$/i.test(d)
248
+ ? -1
249
+ : /^(asc|1)$/i.test(d)
250
+ ? 1
251
+ : -1;
252
+ }
253
+ if (field && hasKey(field)) {
254
+ spec[field] = dir;
255
+ }
256
+ });
257
+ // client sent sort but none validated → honor “no default”
258
+ return spec;
223
259
  }
224
- return Object.keys(out).length ? out : undefined;
260
+ // no sort sent default
261
+ return { createdAt: -1 };
225
262
  }
226
- function parseQuery(req) {
227
- const limit = Math.min(parseInt(String(req.query.limit ?? '12'), 10) || 12, 100);
228
- const page = Math.max(parseInt(String(req.query.page ?? '1'), 10) || 1, 1);
263
+ function parseQuery(req, allowedKeys) {
264
+ const limit = Math.min(Number.parseInt(String(req.query.limit ?? '12'), 10) || 12, 100);
265
+ const page = Math.max(Number.parseInt(String(req.query.page ?? '1'), 10) || 1, 1);
229
266
  const skip = (page - 1) * limit;
230
267
  const fields = String(req.query.fields ?? '')
231
268
  .split(',')
232
269
  .map((s) => s.trim())
233
270
  .filter(Boolean);
234
- const projection = fields.length
271
+ const projection = fields.length > 0
235
272
  ? Object.fromEntries(fields.map((f) => [f, 1]))
236
273
  : undefined;
237
- const sort = parseSort(String(req.query.sort ?? '-createdAt'));
274
+ // NOTE: do NOT force a fallback like '-createdAt' here;
275
+ // parseSort will apply { createdAt: -1 } only if no sort is provided.
276
+ const sort = parseSort(req.query.sort, req.query.sortType, allowedKeys);
238
277
  return { limit, page, skip, projection, sort };
239
278
  }
240
279
  function extractIds(val) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@airoom/nextmin-node",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",