voltjs-framework 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +1265 -0
- package/bin/volt.js +139 -0
- package/package.json +56 -0
- package/src/api/graphql.js +399 -0
- package/src/api/rest.js +204 -0
- package/src/api/websocket.js +285 -0
- package/src/cli/build.js +111 -0
- package/src/cli/create.js +371 -0
- package/src/cli/db.js +106 -0
- package/src/cli/dev.js +114 -0
- package/src/cli/generate.js +278 -0
- package/src/cli/lint.js +172 -0
- package/src/cli/routes.js +118 -0
- package/src/cli/start.js +42 -0
- package/src/cli/test.js +138 -0
- package/src/core/app.js +701 -0
- package/src/core/config.js +232 -0
- package/src/core/middleware.js +133 -0
- package/src/core/plugins.js +88 -0
- package/src/core/react-renderer.js +244 -0
- package/src/core/renderer.js +337 -0
- package/src/core/router.js +183 -0
- package/src/database/index.js +461 -0
- package/src/database/migration.js +192 -0
- package/src/database/model.js +285 -0
- package/src/database/query.js +394 -0
- package/src/database/seeder.js +89 -0
- package/src/index.js +156 -0
- package/src/security/auth.js +425 -0
- package/src/security/cors.js +80 -0
- package/src/security/csrf.js +125 -0
- package/src/security/encryption.js +110 -0
- package/src/security/helmet.js +103 -0
- package/src/security/index.js +75 -0
- package/src/security/rateLimit.js +119 -0
- package/src/security/sanitizer.js +113 -0
- package/src/security/xss.js +110 -0
- package/src/ui/component.js +224 -0
- package/src/ui/reactive.js +503 -0
- package/src/ui/template.js +448 -0
- package/src/utils/cache.js +216 -0
- package/src/utils/collection.js +772 -0
- package/src/utils/cron.js +213 -0
- package/src/utils/date.js +223 -0
- package/src/utils/events.js +181 -0
- package/src/utils/excel.js +482 -0
- package/src/utils/form.js +547 -0
- package/src/utils/hash.js +121 -0
- package/src/utils/http.js +461 -0
- package/src/utils/logger.js +186 -0
- package/src/utils/mail.js +347 -0
- package/src/utils/paginator.js +179 -0
- package/src/utils/pdf.js +417 -0
- package/src/utils/queue.js +199 -0
- package/src/utils/schema.js +985 -0
- package/src/utils/sms.js +243 -0
- package/src/utils/storage.js +348 -0
- package/src/utils/string.js +236 -0
- package/src/utils/validation.js +318 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoltJS Mail Module
|
|
3
|
+
*
|
|
4
|
+
* Send emails via SMTP with zero external dependencies.
|
|
5
|
+
* Uses raw TCP sockets for SMTP communication.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const { Mail } = require('voltjs');
|
|
9
|
+
*
|
|
10
|
+
* const mailer = new Mail({
|
|
11
|
+
* host: 'smtp.gmail.com',
|
|
12
|
+
* port: 587,
|
|
13
|
+
* secure: false,
|
|
14
|
+
* user: 'you@gmail.com',
|
|
15
|
+
* password: 'your-app-password',
|
|
16
|
+
* from: 'My App <you@gmail.com>',
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* await mailer.send({
|
|
20
|
+
* to: 'user@example.com',
|
|
21
|
+
* subject: 'Welcome!',
|
|
22
|
+
* html: '<h1>Welcome to our app!</h1>',
|
|
23
|
+
* });
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const net = require('net');
|
|
29
|
+
const tls = require('tls');
|
|
30
|
+
const crypto = require('crypto');
|
|
31
|
+
|
|
32
|
+
class Mail {
|
|
33
|
+
constructor(config = {}) {
|
|
34
|
+
this._config = {
|
|
35
|
+
host: config.host || process.env.MAIL_HOST || 'localhost',
|
|
36
|
+
port: config.port || parseInt(process.env.MAIL_PORT) || 587,
|
|
37
|
+
secure: config.secure !== undefined ? config.secure : false,
|
|
38
|
+
user: config.user || process.env.MAIL_USER || null,
|
|
39
|
+
password: config.password || process.env.MAIL_PASSWORD || null,
|
|
40
|
+
from: config.from || process.env.MAIL_FROM || 'VoltJS <noreply@voltjs.dev>',
|
|
41
|
+
timeout: config.timeout || 10000,
|
|
42
|
+
};
|
|
43
|
+
this._templates = new Map();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Send an email */
|
|
47
|
+
async send(options) {
|
|
48
|
+
const message = {
|
|
49
|
+
from: options.from || this._config.from,
|
|
50
|
+
to: Array.isArray(options.to) ? options.to : [options.to],
|
|
51
|
+
cc: options.cc ? (Array.isArray(options.cc) ? options.cc : [options.cc]) : [],
|
|
52
|
+
bcc: options.bcc ? (Array.isArray(options.bcc) ? options.bcc : [options.bcc]) : [],
|
|
53
|
+
subject: options.subject || '(no subject)',
|
|
54
|
+
text: options.text || '',
|
|
55
|
+
html: options.html || '',
|
|
56
|
+
attachments: options.attachments || [],
|
|
57
|
+
replyTo: options.replyTo || null,
|
|
58
|
+
headers: options.headers || {},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// If template is specified, render it
|
|
62
|
+
if (options.template) {
|
|
63
|
+
const rendered = this._renderTemplate(options.template, options.data || {});
|
|
64
|
+
if (!message.html) message.html = rendered;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return this._sendSMTP(message);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Send email using a template */
|
|
71
|
+
async sendTemplate(templateName, to, data = {}, options = {}) {
|
|
72
|
+
const html = this._renderTemplate(templateName, data);
|
|
73
|
+
return this.send({
|
|
74
|
+
to,
|
|
75
|
+
subject: options.subject || data.subject || templateName,
|
|
76
|
+
html,
|
|
77
|
+
...options,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Register an email template */
|
|
82
|
+
template(name, htmlTemplate) {
|
|
83
|
+
this._templates.set(name, htmlTemplate);
|
|
84
|
+
return this;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Send to multiple recipients */
|
|
88
|
+
async sendBulk(recipients, options) {
|
|
89
|
+
const results = [];
|
|
90
|
+
for (const recipient of recipients) {
|
|
91
|
+
try {
|
|
92
|
+
const to = typeof recipient === 'string' ? recipient : recipient.email;
|
|
93
|
+
const data = typeof recipient === 'object' ? recipient : {};
|
|
94
|
+
await this.send({ ...options, to, data: { ...options.data, ...data } });
|
|
95
|
+
results.push({ to, status: 'sent' });
|
|
96
|
+
} catch (err) {
|
|
97
|
+
results.push({ to: recipient, status: 'failed', error: err.message });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return results;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Verify SMTP connection */
|
|
104
|
+
async verify() {
|
|
105
|
+
try {
|
|
106
|
+
const socket = await this._connect();
|
|
107
|
+
await this._readResponse(socket);
|
|
108
|
+
await this._command(socket, `EHLO ${this._config.host}`);
|
|
109
|
+
socket.destroy();
|
|
110
|
+
return true;
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ===== SMTP IMPLEMENTATION =====
|
|
117
|
+
|
|
118
|
+
async _sendSMTP(message) {
|
|
119
|
+
const socket = await this._connect();
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
// Read greeting
|
|
123
|
+
await this._readResponse(socket);
|
|
124
|
+
|
|
125
|
+
// EHLO
|
|
126
|
+
await this._command(socket, `EHLO ${this._config.host}`);
|
|
127
|
+
|
|
128
|
+
// STARTTLS if not already secure
|
|
129
|
+
if (!this._config.secure && this._config.port !== 465) {
|
|
130
|
+
try {
|
|
131
|
+
await this._command(socket, 'STARTTLS');
|
|
132
|
+
// Upgrade to TLS — in production, you'd upgrade the socket here
|
|
133
|
+
} catch {
|
|
134
|
+
// STARTTLS not supported, continue unencrypted
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// AUTH
|
|
139
|
+
if (this._config.user && this._config.password) {
|
|
140
|
+
await this._command(socket, 'AUTH LOGIN');
|
|
141
|
+
await this._command(socket, Buffer.from(this._config.user).toString('base64'));
|
|
142
|
+
await this._command(socket, Buffer.from(this._config.password).toString('base64'));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// MAIL FROM
|
|
146
|
+
const fromAddr = this._extractEmail(message.from);
|
|
147
|
+
await this._command(socket, `MAIL FROM:<${fromAddr}>`);
|
|
148
|
+
|
|
149
|
+
// RCPT TO
|
|
150
|
+
const allRecipients = [...message.to, ...message.cc, ...message.bcc];
|
|
151
|
+
for (const to of allRecipients) {
|
|
152
|
+
const toAddr = this._extractEmail(to);
|
|
153
|
+
await this._command(socket, `RCPT TO:<${toAddr}>`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// DATA
|
|
157
|
+
await this._command(socket, 'DATA');
|
|
158
|
+
|
|
159
|
+
// Build message
|
|
160
|
+
const emailData = this._buildMessage(message);
|
|
161
|
+
await this._command(socket, emailData + '\r\n.');
|
|
162
|
+
|
|
163
|
+
// QUIT
|
|
164
|
+
await this._command(socket, 'QUIT');
|
|
165
|
+
|
|
166
|
+
socket.destroy();
|
|
167
|
+
return { success: true, messageId: this._generateMessageId() };
|
|
168
|
+
} catch (err) {
|
|
169
|
+
socket.destroy();
|
|
170
|
+
throw new Error(`Failed to send email: ${err.message}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
_connect() {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
const options = {
|
|
177
|
+
host: this._config.host,
|
|
178
|
+
port: this._config.port,
|
|
179
|
+
timeout: this._config.timeout,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const socket = this._config.secure
|
|
183
|
+
? tls.connect(options, () => resolve(socket))
|
|
184
|
+
: net.connect(options, () => resolve(socket));
|
|
185
|
+
|
|
186
|
+
socket.on('error', reject);
|
|
187
|
+
socket.on('timeout', () => {
|
|
188
|
+
socket.destroy();
|
|
189
|
+
reject(new Error('SMTP connection timed out'));
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
_command(socket, data) {
|
|
195
|
+
return new Promise((resolve, reject) => {
|
|
196
|
+
socket.write(data + '\r\n', (err) => {
|
|
197
|
+
if (err) return reject(err);
|
|
198
|
+
this._readResponse(socket).then(resolve).catch(reject);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
_readResponse(socket) {
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
205
|
+
const timeout = setTimeout(() => reject(new Error('SMTP response timeout')), this._config.timeout);
|
|
206
|
+
|
|
207
|
+
socket.once('data', (data) => {
|
|
208
|
+
clearTimeout(timeout);
|
|
209
|
+
const response = data.toString();
|
|
210
|
+
const code = parseInt(response.substring(0, 3));
|
|
211
|
+
if (code >= 400) {
|
|
212
|
+
reject(new Error(`SMTP error ${code}: ${response}`));
|
|
213
|
+
} else {
|
|
214
|
+
resolve(response);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
_buildMessage(message) {
|
|
221
|
+
const boundary = `----VoltJS_${crypto.randomBytes(16).toString('hex')}`;
|
|
222
|
+
const messageId = this._generateMessageId();
|
|
223
|
+
|
|
224
|
+
let msg = '';
|
|
225
|
+
msg += `Message-ID: <${messageId}>\r\n`;
|
|
226
|
+
msg += `Date: ${new Date().toUTCString()}\r\n`;
|
|
227
|
+
msg += `From: ${message.from}\r\n`;
|
|
228
|
+
msg += `To: ${message.to.join(', ')}\r\n`;
|
|
229
|
+
if (message.cc.length > 0) msg += `Cc: ${message.cc.join(', ')}\r\n`;
|
|
230
|
+
msg += `Subject: ${this._encodeSubject(message.subject)}\r\n`;
|
|
231
|
+
if (message.replyTo) msg += `Reply-To: ${message.replyTo}\r\n`;
|
|
232
|
+
msg += 'MIME-Version: 1.0\r\n';
|
|
233
|
+
msg += `X-Mailer: VoltJS/1.0\r\n`;
|
|
234
|
+
|
|
235
|
+
// Custom headers
|
|
236
|
+
for (const [key, value] of Object.entries(message.headers)) {
|
|
237
|
+
msg += `${key}: ${value}\r\n`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (message.attachments.length > 0) {
|
|
241
|
+
msg += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n\r\n`;
|
|
242
|
+
|
|
243
|
+
// Body part
|
|
244
|
+
msg += `--${boundary}\r\n`;
|
|
245
|
+
if (message.html) {
|
|
246
|
+
msg += 'Content-Type: text/html; charset=UTF-8\r\n\r\n';
|
|
247
|
+
msg += message.html;
|
|
248
|
+
} else {
|
|
249
|
+
msg += 'Content-Type: text/plain; charset=UTF-8\r\n\r\n';
|
|
250
|
+
msg += message.text;
|
|
251
|
+
}
|
|
252
|
+
msg += '\r\n';
|
|
253
|
+
|
|
254
|
+
// Attachments
|
|
255
|
+
for (const att of message.attachments) {
|
|
256
|
+
msg += `--${boundary}\r\n`;
|
|
257
|
+
msg += `Content-Type: ${att.type || 'application/octet-stream'}; name="${att.name}"\r\n`;
|
|
258
|
+
msg += `Content-Disposition: attachment; filename="${att.name}"\r\n`;
|
|
259
|
+
msg += 'Content-Transfer-Encoding: base64\r\n\r\n';
|
|
260
|
+
msg += (att.data instanceof Buffer ? att.data : Buffer.from(att.data)).toString('base64');
|
|
261
|
+
msg += '\r\n';
|
|
262
|
+
}
|
|
263
|
+
msg += `--${boundary}--`;
|
|
264
|
+
} else if (message.html) {
|
|
265
|
+
msg += 'Content-Type: text/html; charset=UTF-8\r\n\r\n';
|
|
266
|
+
msg += message.html;
|
|
267
|
+
} else {
|
|
268
|
+
msg += 'Content-Type: text/plain; charset=UTF-8\r\n\r\n';
|
|
269
|
+
msg += message.text;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return msg;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
_extractEmail(str) {
|
|
276
|
+
const match = String(str).match(/<(.+?)>/);
|
|
277
|
+
return match ? match[1] : str.trim();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
_encodeSubject(subject) {
|
|
281
|
+
// Encode non-ASCII subjects
|
|
282
|
+
if (/[^\x00-\x7F]/.test(subject)) {
|
|
283
|
+
return `=?UTF-8?B?${Buffer.from(subject).toString('base64')}?=`;
|
|
284
|
+
}
|
|
285
|
+
return subject;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
_generateMessageId() {
|
|
289
|
+
return `${Date.now()}.${crypto.randomBytes(8).toString('hex')}@voltjs.dev`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
_renderTemplate(name, data) {
|
|
293
|
+
let template = this._templates.get(name);
|
|
294
|
+
if (!template) {
|
|
295
|
+
throw new Error(`Email template "${name}" not found`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Simple template rendering: {{variable}}
|
|
299
|
+
return template.replace(/\{\{\s*(.+?)\s*\}\}/g, (_, key) => {
|
|
300
|
+
const keys = key.split('.');
|
|
301
|
+
let value = data;
|
|
302
|
+
for (const k of keys) {
|
|
303
|
+
value = value?.[k];
|
|
304
|
+
}
|
|
305
|
+
return value !== undefined ? String(value) : '';
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ===== STATIC HELPERS =====
|
|
310
|
+
|
|
311
|
+
/** Quick send without instantiation */
|
|
312
|
+
static async quickSend(config, options) {
|
|
313
|
+
const mailer = new Mail(config);
|
|
314
|
+
return mailer.send(options);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Create a beautiful HTML email from a simple template */
|
|
318
|
+
static htmlTemplate(title, body, options = {}) {
|
|
319
|
+
const primaryColor = options.primaryColor || '#667eea';
|
|
320
|
+
const footerText = options.footer || 'Sent with VoltJS';
|
|
321
|
+
|
|
322
|
+
return `
|
|
323
|
+
<!DOCTYPE html>
|
|
324
|
+
<html>
|
|
325
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
|
|
326
|
+
<body style="margin:0;padding:0;background:#f4f4f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
|
327
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f7;padding:40px 0;">
|
|
328
|
+
<tr><td align="center">
|
|
329
|
+
<table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
|
|
330
|
+
<tr><td style="background:${primaryColor};padding:30px;text-align:center;">
|
|
331
|
+
<h1 style="margin:0;color:#ffffff;font-size:24px;">${title}</h1>
|
|
332
|
+
</td></tr>
|
|
333
|
+
<tr><td style="padding:30px;color:#333;font-size:16px;line-height:1.6;">
|
|
334
|
+
${body}
|
|
335
|
+
</td></tr>
|
|
336
|
+
<tr><td style="padding:20px 30px;text-align:center;color:#888;font-size:12px;border-top:1px solid #eee;">
|
|
337
|
+
${footerText}
|
|
338
|
+
</td></tr>
|
|
339
|
+
</table>
|
|
340
|
+
</td></tr>
|
|
341
|
+
</table>
|
|
342
|
+
</body>
|
|
343
|
+
</html>`;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
module.exports = { Mail };
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoltJS Paginator
|
|
3
|
+
*
|
|
4
|
+
* Pagination helper for database results and arrays.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* const { Paginator } = require('voltjs');
|
|
8
|
+
*
|
|
9
|
+
* const result = Paginator.paginate(allItems, { page: 2, perPage: 20 });
|
|
10
|
+
* // {
|
|
11
|
+
* // data: [...],
|
|
12
|
+
* // meta: { page: 2, perPage: 20, total: 150, totalPages: 8, hasNext: true, hasPrev: true }
|
|
13
|
+
* // }
|
|
14
|
+
*
|
|
15
|
+
* // As middleware:
|
|
16
|
+
* app.get('/users', async (req, res) => {
|
|
17
|
+
* const page = Paginator.fromRequest(req);
|
|
18
|
+
* const users = await User.paginate(page.page, page.perPage);
|
|
19
|
+
* res.json(users);
|
|
20
|
+
* });
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
class Paginator {
|
|
26
|
+
/** Paginate an array of items */
|
|
27
|
+
static paginate(items, options = {}) {
|
|
28
|
+
const page = Math.max(1, parseInt(options.page) || 1);
|
|
29
|
+
const perPage = Math.min(Math.max(1, parseInt(options.perPage) || 20), options.maxPerPage || 100);
|
|
30
|
+
const total = items.length;
|
|
31
|
+
const totalPages = Math.ceil(total / perPage);
|
|
32
|
+
const offset = (page - 1) * perPage;
|
|
33
|
+
|
|
34
|
+
const data = items.slice(offset, offset + perPage);
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
data,
|
|
38
|
+
meta: {
|
|
39
|
+
page,
|
|
40
|
+
perPage,
|
|
41
|
+
total,
|
|
42
|
+
totalPages,
|
|
43
|
+
from: total > 0 ? offset + 1 : 0,
|
|
44
|
+
to: Math.min(offset + perPage, total),
|
|
45
|
+
hasNext: page < totalPages,
|
|
46
|
+
hasPrev: page > 1,
|
|
47
|
+
nextPage: page < totalPages ? page + 1 : null,
|
|
48
|
+
prevPage: page > 1 ? page - 1 : null,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Create pagination from pre-sliced data (database query) */
|
|
54
|
+
static fromQuery(data, total, options = {}) {
|
|
55
|
+
const page = Math.max(1, parseInt(options.page) || 1);
|
|
56
|
+
const perPage = Math.max(1, parseInt(options.perPage) || 20);
|
|
57
|
+
const totalPages = Math.ceil(total / perPage);
|
|
58
|
+
const offset = (page - 1) * perPage;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
data,
|
|
62
|
+
meta: {
|
|
63
|
+
page,
|
|
64
|
+
perPage,
|
|
65
|
+
total,
|
|
66
|
+
totalPages,
|
|
67
|
+
from: total > 0 ? offset + 1 : 0,
|
|
68
|
+
to: Math.min(offset + perPage, total),
|
|
69
|
+
hasNext: page < totalPages,
|
|
70
|
+
hasPrev: page > 1,
|
|
71
|
+
nextPage: page < totalPages ? page + 1 : null,
|
|
72
|
+
prevPage: page > 1 ? page - 1 : null,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Extract pagination params from request */
|
|
78
|
+
static fromRequest(req, defaults = {}) {
|
|
79
|
+
const query = req.query || {};
|
|
80
|
+
return {
|
|
81
|
+
page: parseInt(query.page) || defaults.page || 1,
|
|
82
|
+
perPage: Math.min(
|
|
83
|
+
parseInt(query.per_page || query.perPage || query.limit) || defaults.perPage || 20,
|
|
84
|
+
defaults.maxPerPage || 100
|
|
85
|
+
),
|
|
86
|
+
sort: query.sort || defaults.sort || null,
|
|
87
|
+
order: (query.order || defaults.order || 'asc').toLowerCase(),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Generate SQL LIMIT/OFFSET clause */
|
|
92
|
+
static sql(options = {}) {
|
|
93
|
+
const page = Math.max(1, parseInt(options.page) || 1);
|
|
94
|
+
const perPage = Math.max(1, parseInt(options.perPage) || 20);
|
|
95
|
+
const offset = (page - 1) * perPage;
|
|
96
|
+
return { limit: perPage, offset, sql: `LIMIT ${perPage} OFFSET ${offset}` };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Generate pagination links (for API responses) */
|
|
100
|
+
static links(baseURL, meta) {
|
|
101
|
+
const links = {};
|
|
102
|
+
const separator = baseURL.includes('?') ? '&' : '?';
|
|
103
|
+
|
|
104
|
+
links.first = `${baseURL}${separator}page=1&per_page=${meta.perPage}`;
|
|
105
|
+
links.last = `${baseURL}${separator}page=${meta.totalPages}&per_page=${meta.perPage}`;
|
|
106
|
+
|
|
107
|
+
if (meta.hasNext) {
|
|
108
|
+
links.next = `${baseURL}${separator}page=${meta.nextPage}&per_page=${meta.perPage}`;
|
|
109
|
+
}
|
|
110
|
+
if (meta.hasPrev) {
|
|
111
|
+
links.prev = `${baseURL}${separator}page=${meta.prevPage}&per_page=${meta.perPage}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return links;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Generate page numbers for UI rendering */
|
|
118
|
+
static pageNumbers(currentPage, totalPages, maxVisible = 7) {
|
|
119
|
+
const pages = [];
|
|
120
|
+
|
|
121
|
+
if (totalPages <= maxVisible) {
|
|
122
|
+
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
|
123
|
+
return pages;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const half = Math.floor(maxVisible / 2);
|
|
127
|
+
let start = Math.max(1, currentPage - half);
|
|
128
|
+
let end = Math.min(totalPages, start + maxVisible - 1);
|
|
129
|
+
|
|
130
|
+
if (end - start < maxVisible - 1) {
|
|
131
|
+
start = Math.max(1, end - maxVisible + 1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (start > 1) {
|
|
135
|
+
pages.push(1);
|
|
136
|
+
if (start > 2) pages.push('...');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (let i = start; i <= end; i++) pages.push(i);
|
|
140
|
+
|
|
141
|
+
if (end < totalPages) {
|
|
142
|
+
if (end < totalPages - 1) pages.push('...');
|
|
143
|
+
pages.push(totalPages);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return pages;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Cursor-based pagination */
|
|
150
|
+
static cursorPaginate(items, options = {}) {
|
|
151
|
+
const perPage = Math.max(1, parseInt(options.perPage) || 20);
|
|
152
|
+
const cursor = options.cursor || null;
|
|
153
|
+
const cursorField = options.cursorField || 'id';
|
|
154
|
+
|
|
155
|
+
let filtered = items;
|
|
156
|
+
if (cursor) {
|
|
157
|
+
const cursorIndex = items.findIndex(item => String(item[cursorField]) === String(cursor));
|
|
158
|
+
if (cursorIndex >= 0) {
|
|
159
|
+
filtered = items.slice(cursorIndex + 1);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const data = filtered.slice(0, perPage);
|
|
164
|
+
const hasMore = filtered.length > perPage;
|
|
165
|
+
const nextCursor = data.length > 0 ? data[data.length - 1][cursorField] : null;
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
data,
|
|
169
|
+
meta: {
|
|
170
|
+
perPage,
|
|
171
|
+
hasMore,
|
|
172
|
+
nextCursor: hasMore ? nextCursor : null,
|
|
173
|
+
prevCursor: cursor,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = { Paginator };
|