diff --git a/README.md b/README.md index e771602..de955b9 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ UniFi Voucher Site is a web-based platform for generating and managing UniFi net - **Web and API Services**: Access the service via a web interface or integrate with other systems using a REST API. - **Docker Support**: Easily deploy using Docker, with customizable environment settings. - **Home Assistant Add-on**: Seamlessly integrate with Home Assistant for centralized management. -- **Receipt Printing**: Supports printing vouchers with 80mm thermal printers. +- **Receipt Printing**: Supports printing vouchers with 80mm thermal printers. Via compatible PDFs or ESC/POS enabled network printers. +- **Bulk Printing**: Export/print multiple Vouchers in one go. - **Email Functionality**: Automatically send vouchers via SMTP. - **Localized Email/Print Templates** Fully localized templates, with support for multiple languages. - **Scan to Connect QR Codes** Quickly connect users via a phone's camera. (Available within Email and Print Layouts) diff --git a/modules/print.js b/modules/print.js index fee79f0..d4fe6b8 100644 --- a/modules/print.js +++ b/modules/print.js @@ -27,18 +27,27 @@ module.exports = { /** * Generates a voucher as a PDF * - * @param voucher + * @param content * @param language + * @param multiPage * @return {Promise} */ - pdf: (voucher, language) => { + pdf: (content, language, multiPage= false) => { return new Promise(async (resolve) => { // Create new translator const t = translation('print', language); + // Set vouchers based on multiPage parameter + let vouchers = []; + if(multiPage) { + vouchers = [...content]; + } else { + vouchers = [content]; + } + const doc = new PDFDocument({ bufferPages: true, - size: [226.77165354330398, size(voucher)], + size: [226.77165354330398, size(vouchers[0])], margins : { top: 20, bottom: 20, @@ -54,124 +63,148 @@ module.exports = { resolve(buffers); }); - doc.image('public/images/logo_grayscale_dark.png', 75, 15, {fit: [75, 75], align: 'center', valign: 'center'}); - doc.moveDown(6); + for(let item = 0; item < vouchers.length; item++) { + if(item > 0) { + doc.addPage({ + size: [226.77165354330398, size(vouchers[item])], + margins : { + top: 20, + bottom: 20, + left: 20, + right: 20 + } + }); - doc.font('Helvetica-Bold') - .fontSize(20) - .text(`${t('title')}`, { - align: 'center' - }); - doc.font('Helvetica-Bold') - .fontSize(15) - .text(`${voucher.code.slice(0, 5)}-${voucher.code.slice(5)}`, { - align: 'center' - }); + doc.moveDown(1); + } - doc.moveDown(2); + doc.image('public/images/logo_grayscale_dark.png', 75, 15, { + fit: [75, 75], + align: 'center', + valign: 'center' + }); + doc.moveDown(6); - if(variables.unifiSsid !== '') { - doc.font('Helvetica') - .fontSize(10) - .text(`${t('connect')}: `, { - continued: true + doc.font('Helvetica-Bold') + .fontSize(20) + .text(`${t('title')}`, { + align: 'center' }); doc.font('Helvetica-Bold') - .fontSize(10) - .text(variables.unifiSsid, { - continued: true + .fontSize(15) + .text(`${vouchers[item].code.slice(0, 5)}-${vouchers[item].code.slice(5)}`, { + align: 'center' }); - if(variables.unifiSsidPassword !== '') { - doc.font('Helvetica') - .fontSize(10) - .text(`,`); + doc.moveDown(2); + + if (variables.unifiSsid !== '') { doc.font('Helvetica') .fontSize(10) - .text(`${t('password')}: `, { + .text(`${t('connect')}: `, { continued: true }); doc.font('Helvetica-Bold') .fontSize(10) - .text(variables.unifiSsidPassword, { + .text(variables.unifiSsid, { continued: true }); - doc.font('Helvetica') - .fontSize(10) - .text(` ${t('or')},`); - } else { - doc.font('Helvetica') - .fontSize(10) - .text(` ${t('or')},`); - } - - doc.font('Helvetica') - .fontSize(10) - .text(`${t('scan')}:`); - doc.image(await qr(), 75, variables.unifiSsidPassword !== '' ? 215 : 205, {fit: [75, 75], align: 'center', valign: 'center'}); - doc.moveDown(6); + if (variables.unifiSsidPassword !== '') { + doc.font('Helvetica') + .fontSize(10) + .text(`,`); + doc.font('Helvetica') + .fontSize(10) + .text(`${t('password')}: `, { + continued: true + }); + doc.font('Helvetica-Bold') + .fontSize(10) + .text(variables.unifiSsidPassword, { + continued: true + }); + doc.font('Helvetica') + .fontSize(10) + .text(` ${t('or')},`); + } else { + doc.font('Helvetica') + .fontSize(10) + .text(` ${t('or')},`); + } - doc.moveDown(2); - } + doc.font('Helvetica') + .fontSize(10) + .text(`${t('scan')}:`); - doc.font('Helvetica-Bold') - .fontSize(12) - .text(`${t('details')}`); + doc.image(await qr(), 75, variables.unifiSsidPassword !== '' ? 215 : 205, { + fit: [75, 75], + align: 'center', + valign: 'center' + }); + doc.moveDown(6); - doc.font('Helvetica-Bold') - .fontSize(10) - .text(`--------------------------------------------------------`); + doc.moveDown(2); + } - doc.font('Helvetica-Bold') - .fontSize(10) - .text(`${t('type')}: `, { - continued: true - }); - doc.font('Helvetica') - .fontSize(10) - .text(voucher.quota === 0 ? t('multiUse') : t('singleUse')); - - doc.font('Helvetica-Bold') - .fontSize(10) - .text(`${t('duration')}: `, { - continued: true - }); - doc.font('Helvetica') - .fontSize(10) - .text(time(voucher.duration)); + doc.font('Helvetica-Bold') + .fontSize(12) + .text(`${t('details')}`); - if(voucher.qos_usage_quota) { doc.font('Helvetica-Bold') .fontSize(10) - .text(`${t('dataLimit')}: `, { - continued: true - }); - doc.font('Helvetica') - .fontSize(10) - .text(`${bytes(voucher.qos_usage_quota, 2)}`); - } + .text(`--------------------------------------------------------`); - if(voucher.qos_rate_max_down) { doc.font('Helvetica-Bold') .fontSize(10) - .text(`${t('downloadLimit')}: `, { + .text(`${t('type')}: `, { continued: true }); doc.font('Helvetica') .fontSize(10) - .text(`${bytes(voucher.qos_rate_max_down, 1, true)}`); - } + .text(vouchers[item].quota === 1 ? t('singleUse') : vouchers[item].quota === 0 ? t('multiUse') : t('multiUse')); - if(voucher.qos_rate_max_up) { doc.font('Helvetica-Bold') .fontSize(10) - .text(`${t('uploadLimit')}: `, { + .text(`${t('duration')}: `, { continued: true }); doc.font('Helvetica') .fontSize(10) - .text(`${bytes(voucher.qos_rate_max_up, 1, true)}`); + .text(time(vouchers[item].duration)); + + if (vouchers[item].qos_usage_quota) { + doc.font('Helvetica-Bold') + .fontSize(10) + .text(`${t('dataLimit')}: `, { + continued: true + }); + doc.font('Helvetica') + .fontSize(10) + .text(`${bytes(vouchers[item].qos_usage_quota, 2)}`); + } + + if (vouchers[item].qos_rate_max_down) { + doc.font('Helvetica-Bold') + .fontSize(10) + .text(`${t('downloadLimit')}: `, { + continued: true + }); + doc.font('Helvetica') + .fontSize(10) + .text(`${bytes(vouchers[item].qos_rate_max_down, 1, true)}`); + } + + if (vouchers[item].qos_rate_max_up) { + doc.font('Helvetica-Bold') + .fontSize(10) + .text(`${t('uploadLimit')}: `, { + continued: true + }); + doc.font('Helvetica') + .fontSize(10) + .text(`${bytes(vouchers[item].qos_rate_max_up, 1, true)}`); + } } doc.end(); @@ -260,7 +293,7 @@ module.exports = { printer.invert(true); printer.print(`${t('type')}:`); printer.invert(false); - printer.print(voucher.quota === 0 ? ` ${t('multiUse')}` : ` ${t('singleUse')}`); + printer.print(voucher.quota === 1 ? ` ${t('singleUse')}` : voucher.quota === 0 ? ` ${t('multiUse')}` : ` ${t('multiUse')}`); printer.newLine(); printer.setTextDoubleHeight(); @@ -307,7 +340,11 @@ module.exports = { try { await printer.execute(); log.info('[Printer] Data send to printer!'); - resolve(true); + + // Ensure cheap printers have cleared the buffer before allowing new actions + setTimeout(() => { + resolve(true); + }, 1500); } catch (error) { reject(error); } diff --git a/modules/unifi.js b/modules/unifi.js index 9e205fd..911394f 100644 --- a/modules/unifi.js +++ b/modules/unifi.js @@ -68,7 +68,7 @@ const startSession = () => { /** * UniFi module functions * - * @type {{create: (function(*, number=, boolean=): Promise<*>), list: (function(boolean=): Promise<*>), remove: (function(*, boolean=): Promise<*>)}} + * @type {{create: (function(*, number=, null=, boolean=): Promise<*>), remove: (function(*, boolean=): Promise<*>), list: (function(boolean=): Promise<*>), guests: (function(boolean=): Promise<*>)}} */ const unifiModule = { /** @@ -76,13 +76,14 @@ const unifiModule = { * * @param type * @param amount + * @param note * @param retry * @return {Promise} */ - create: (type, amount = 1, retry = true) => { + create: (type, amount = 1, note = null, retry = true) => { return new Promise((resolve, reject) => { startSession().then(() => { - controller.createVouchers(type.expiration, amount, parseInt(type.usage) === 1 ? 1 : 0, null, typeof type.upload !== "undefined" ? type.upload : null, typeof type.download !== "undefined" ? type.download : null, typeof type.megabytes !== "undefined" ? type.megabytes : null).then((voucher_data) => { + controller.createVouchers(type.expiration, amount, parseInt(type.usage) === 1 ? 1 : 0, note, typeof type.upload !== "undefined" ? type.upload : null, typeof type.download !== "undefined" ? type.download : null, typeof type.megabytes !== "undefined" ? type.megabytes : null).then((voucher_data) => { if(amount > 1) { log.info(`[UniFi] Created ${amount} vouchers`); resolve(true); @@ -107,7 +108,7 @@ const unifiModule = { log.info('[UniFi] Attempting re-authentication & retry...'); controller = null; - unifiModule.create(type, amount, false).then((e) => { + unifiModule.create(type, amount, note, false).then((e) => { resolve(e); }).catch((e) => { reject(e); diff --git a/server.js b/server.js index d249f58..06969b6 100644 --- a/server.js +++ b/server.js @@ -203,7 +203,7 @@ if(variables.serviceWeb) { } // Create voucher code - const voucherCode = await unifi.create(types(req.body['voucher-type'] === 'custom' ? `${req.body['voucher-duration']},${req.body['voucher-usage']},${req.body['voucher-upload-limit']},${req.body['voucher-download-limit']},${req.body['voucher-data-limit']};` : req.body['voucher-type'], true), parseInt(req.body['voucher-amount'])).catch((e) => { + const voucherCode = await unifi.create(types(req.body['voucher-type'] === 'custom' ? `${req.body['voucher-duration']},${req.body['voucher-usage']},${req.body['voucher-upload-limit']},${req.body['voucher-download-limit']},${req.body['voucher-data-limit']};` : req.body['voucher-type'], true), parseInt(req.body['voucher-amount']), req.body['voucher-note'] !== '' ? req.body['voucher-note'] : null).catch((e) => { res.cookie('flashMessage', JSON.stringify({type: 'error', message: e}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, `${req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : ''}/vouchers`); }); @@ -419,11 +419,15 @@ if(variables.serviceWeb) { voucher_custom: variables.voucherCustom, vouchers: cache.vouchers.filter((item) => { if(req.query.status === 'available') { - return item.used === 0; + return item.used === 0 && item.status !== 'EXPIRED'; } if(req.query.status === 'in-use') { - return item.used > 0; + return item.used > 0 && item.status !== 'EXPIRED'; + } + + if(req.query.status === 'expired') { + return item.status === 'EXPIRED'; } return true; @@ -443,6 +447,11 @@ if(variables.serviceWeb) { if (a.code < b.code) return 1; } + if(req.query.sort === 'note') { + if ((a.note || '') > (b.note || '')) return -1; + if ((a.note || '') < (b.note || '')) return 1; + } + if(req.query.sort === 'duration') { if (a.duration > b.duration) return -1; if (a.duration < b.duration) return 1; @@ -499,6 +508,80 @@ if(variables.serviceWeb) { status: status() }); }); + app.get('/bulk/print', [authorization.web], async (req, res) => { + if(variables.printerType === '') { + res.status(501).send(); + return; + } + + res.render('components/bulk-print', { + baseUrl: req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : '', + timeConvert: time, + bytesConvert: bytes, + languages, + defaultLanguage: variables.translationDefault, + vouchers: cache.vouchers, + updated: cache.updated + }); + }); + app.post('/bulk/print', [authorization.web], async (req, res) => { + if(variables.printerType === '') { + res.status(501).send(); + return; + } + + if(!req.body.vouchers) { + res.cookie('flashMessage', JSON.stringify({type: 'error', message: 'No selected vouchers to print!'}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, `${req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : ''}/vouchers`); + return; + } + + // Single checkboxes get send as string so conversion is needed + if(typeof req.body.vouchers === 'string') { + req.body.vouchers = [req.body.vouchers]; + } + + const vouchers = req.body.vouchers.map((voucher) => { + return cache.vouchers.find((e) => { + return e._id === voucher; + }); + }); + + if(!vouchers.includes(undefined)) { + if(variables.printerType === 'pdf') { + const buffers = await print.pdf(vouchers, req.body.language, true); + const pdfData = Buffer.concat(buffers); + res.writeHead(200, { + 'Content-Length': Buffer.byteLength(pdfData), + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment;filename=bulk_vouchers_${new Date().getTime()}.pdf` + }).end(pdfData); + } + + if(variables.printerType === 'escpos') { + let printSuccess = true; + + for(let voucher = 0; voucher < vouchers.length; voucher++) { + const printResult = await print.escpos(vouchers[voucher], req.body.language).catch((e) => { + res.cookie('flashMessage', JSON.stringify({type: 'error', message: e}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, `${req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : ''}/vouchers`); + }); + + if(!printResult) { + printSuccess = false; + break; + } + } + + if(printSuccess) { + res.cookie('flashMessage', JSON.stringify({type: 'info', message: `Vouchers send to printer!`}), {httpOnly: true, expires: new Date(Date.now() + 24 * 60 * 60 * 1000)}).redirect(302, `${req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : ''}/vouchers`); + } + } + } else { + res.status(404); + res.render('404', { + baseUrl: req.headers['x-ingress-path'] ? req.headers['x-ingress-path'] : '' + }); + } + }); } if(variables.serviceApi) { @@ -564,7 +647,7 @@ if(variables.serviceApi) { vouchers: cache.vouchers.map((voucher) => { return { code: `${voucher.code.slice(0, 5)}-${voucher.code.slice(5)}`, - type: voucher.quota === 0 ? 'multi' : 'single', + type: voucher.quota === 1 ? 'single' : voucher.quota === 0 ? 'multi' : 'multi', duration: voucher.duration, data_limit: voucher.qos_usage_quota ? voucher.qos_usage_quota : null, download_limit: voucher.qos_rate_max_down ? voucher.qos_rate_max_down : null, diff --git a/template/components/bulk-print.ejs b/template/components/bulk-print.ejs new file mode 100644 index 0000000..4426e58 --- /dev/null +++ b/template/components/bulk-print.ejs @@ -0,0 +1,108 @@ + diff --git a/template/components/details.ejs b/template/components/details.ejs index 3a6c002..647687d 100644 --- a/template/components/details.ejs +++ b/template/components/details.ejs @@ -26,20 +26,30 @@
Status
- <% if(voucher.used > 0) { %> -
- In Use -
- <% } else { %> -
- Available + <% if (voucher.status === 'EXPIRED') { %> +
+ Expired
+ <% } else {%> + <% if(voucher.used > 0) { %> +
+ In Use +
+ <% } else { %> +
+ Available +
+ <% } %> <% } %>
+
+
Notes
+
<%= voucher.note ? voucher.note : '-' %>
+
Type
-
<%= voucher.quota === 0 ? 'Multi-use' : 'Single-use' %>
+
<%= voucher.quota === 1 ? 'Single-use' : voucher.quota === 0 ? 'Multi-use (Unlimited)' : `Multi-use (${voucher.quota}x)` %>
Duration
diff --git a/template/email/voucher.ejs b/template/email/voucher.ejs index c54426b..74e1af5 100644 --- a/template/email/voucher.ejs +++ b/template/email/voucher.ejs @@ -140,7 +140,7 @@ <% } %>

<%= t('details') %>


-

<%= t('type') %>: <%= voucher.quota === 0 ? t('multiUse') : t('singleUse') %>

+

<%= t('type') %>: <%= voucher.quota === 1 ? t('singleUse') : voucher.quota === 0 ? t('multiUse') : t('multiUse') %>

<%= t('duration') %>: <%= timeConvert(voucher.duration) %>

<% if(voucher.qos_usage_quota) { %>

<%= t('dataLimit') %>: <%= bytesConvert(voucher.qos_usage_quota, 2) %>

diff --git a/template/voucher.ejs b/template/voucher.ejs index f50cd07..23a0bfa 100644 --- a/template/voucher.ejs +++ b/template/voucher.ejs @@ -66,16 +66,17 @@ <% } %>
-
+
-
+
@@ -91,6 +92,7 @@ @@ -99,16 +101,29 @@
-
- - Last Sync: <%= new Intl.DateTimeFormat('en-GB', {day: "numeric", month: "numeric", hour: "numeric", minute: "numeric", hour12: false}).format(new Date(updated)) %> - - - - Sync Vouchers - +
+
+ +   + + +
+
+ + Last Sync: <%= new Intl.DateTimeFormat('en-GB', {day: "numeric", month: "numeric", hour: "numeric", minute: "numeric", hour12: false}).format(new Date(updated)) %> + + + + Sync Vouchers + +
@@ -136,20 +151,31 @@

<%= voucher.code.slice(0, 5) %>-<%= voucher.code.slice(5) %> - <% if(voucher.used > 0) { %> -
- In Use + <% if (voucher.status === 'EXPIRED') { %> +
+ Expired
- <% } else { %> -
- Available + <% } else {%> + <% if(voucher.used > 0) { %> +
+ In Use +
+ <% } else { %> +
+ Available +
+ <% } %> + <% } %> + <% if (voucher.note) { %> + <% } %>

-

<%= voucher.quota === 0 ? 'Multi-use' : 'Single-use' %>

+

<%= voucher.quota === 1 ? 'Single-use' : voucher.quota === 0 ? 'Multi-use (Unlimited)' : `Multi-use (${voucher.quota}x)` %>

@@ -214,6 +240,7 @@
+
+
+ +
+ +
+