{{ message }}
Repository navigation
Expand file tree
/
Copy pathtenant-audit.js
More file actions
executable file
·308 lines (280 loc) · 13.2 KB
/
Copy pathtenant-audit.js
File metadata and controls
executable file
·308 lines (280 loc) · 13.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
#!/usr/bin/env node
'use strict';
/*
* Find what a tenant database is missing, and optionally fill it in.
*
* A tenant is created by running the app's own install against a fresh
* database. That means a shop provisioned against an older build starts life
* without whatever was added since - and nothing ever goes back to fix it,
* because installs only run once.
*
* Most such gaps are harmless: branch settings are given defaults when they
* are read (branch.model.js), so a missing flag reads as false rather than as
* undefined. The ones that matter are the values with no read-time default,
* where missing means broken rather than off - a branch with no
* thermal_body_print prints an empty receipt.
*
* So this reports before it touches anything, and when it does act it only
* ever adds. A shop that has configured something has configured it; a
* migration that "corrects" a deliberate setting is worse than the gap it
* closes.
*
* node scripts/tenant-audit.js --db PosnicPro --license <id>
* node scripts/tenant-audit.js --db PosnicPro (every company)
*/
const fs = require('fs');
const path = require('path');
/*
* What a correctly provisioned branch has.
*
* `severity` is the whole point of this table:
*
* breaks no read-time default exists, so absence is a fault the shop will
* see - a blank receipt, a page that cannot render.
* cosmetic branch.model.js supplies a default on read, so absence changes
* nothing. Listed to be counted, not to be fixed.
*
* Only "breaks" rows are ever written, and only when they are absent.
*/
function branchExpectations() {
const json = path.join(__dirname, '..', 'api', 'src', 'json');
const read = (f) => {
try { return fs.readFileSync(path.join(json, f), 'utf8'); } catch (e) { return null; }
};
return [
{
field: 'thermal_body_print',
severity: 'breaks',
why: 'the receipt body; without it a sale prints an empty slip',
value: () => read('print_standard_html.txt'),
},
{
field: 'print_controls',
severity: 'breaks',
why: 'receipt titles and A4 column toggles; without it printing has no titles',
value: () => ({
receiving_title: '<span style="font-size: 14px !important;font-weight: 900;">PURCHASE INVOICE</span>',
receiving_return_title: '<span style="font-size: 14px !important;font-weight: 900;">PURCHASE RETURN INVOICE</span>',
sale_title: '<span style="font-size: 14px !important;font-weight: 900;">SALES RECEIPT</span>',
sale_return_title: '<span style="font-size: 14px !important;font-weight: 900;">SALES RETURN RECEIPT</span>',
a4: {
lineitem_hsn: 'on', lineitem_price: 'on', lineitem_qty: 'on',
lineitem_disc: 'on', lineitem_tax: 'on', lineitem_total: 'on',
print_qty: 'on', print_roundoff: 'on',
},
}),
},
{ field: 'time_zone', severity: 'cosmetic', why: 'defaults to Asia/Calcutta on read' },
{ field: 'time_format', severity: 'cosmetic', why: 'defaults to enable on read' },
{ field: 'currency_value', severity: 'cosmetic', why: 'defaults to INR on read' },
{ field: 'hardware_weight_machine_enable', severity: 'cosmetic', why: 'defaults to false on read' },
{ field: 'till_lock_enable', severity: 'cosmetic', why: 'defaults to false on read' },
{ field: 'till_lock_idle_minutes', severity: 'cosmetic', why: 'defaults to 0 on read' },
{ field: 'staff_shifts_enable', severity: 'cosmetic', why: 'defaults to true on read' },
{ field: 'staff_tips_enable', severity: 'cosmetic', why: 'defaults to false on read' },
{ field: 'staff_roster_enable', severity: 'cosmetic', why: 'defaults to true on read' },
{ field: 'cash_register_enable', severity: 'cosmetic', why: 'defaults to true on read' },
{ field: 'module_tax_enable', severity: 'cosmetic', why: 'defaults to true on read' },
{ field: 'module_credit_enable', severity: 'cosmetic', why: 'defaults to true on read' },
{ field: 'module_marketing_enable', severity: 'cosmetic', why: 'defaults to true on read' },
{ field: 'module_messaging_enable', severity: 'cosmetic', why: 'defaults to true on read' },
{ field: 'module_channels_enable', severity: 'cosmetic', why: 'defaults to true on read' },
{ field: 'module_online_ordering_enable', severity: 'cosmetic', why: 'defaults to true on read' },
{ field: 'module_kiosk_enable', severity: 'cosmetic', why: 'defaults to true on read' },
{ field: 'module_captain_enable', severity: 'cosmetic', why: 'defaults to true on read' },
{ field: 'module_delivery_partners_enable', severity: 'cosmetic', why: 'defaults to true on read' },
{ field: 'module_webshop_enable', severity: 'cosmetic', why: 'defaults to true on read' },
{ field: 'enable_notification_reminders', severity: 'cosmetic', why: 'defaults to false on read' },
{ field: 'sms_auto_send_time', severity: 'cosmetic', why: 'defaults to 10:00 am on read' },
];
}
// Collections a working shop has. Empty is fine and expected for a new shop;
// absent is what this looks for, because a query against a missing collection
// behaves differently from one against an empty one in some drivers.
const EXPECTED_COLLECTIONS = [
'branches', 'users', 'items', 'categories', 'customers',
'suppliers', 'unit', 'grouptax', 'sales', 'receivings',
];
/*
* The gap between one branch document and a correctly provisioned one.
*
* Pure, so it can be tested without a database - which matters here, because
* the only place to try this for real is a shop's live data.
*/
function auditBranch(branch, expectations) {
const gaps = [];
for (const rule of expectations) {
const value = branch ? branch[rule.field] : undefined;
const missing = value === undefined || value === null || value === ''
|| (Array.isArray(value) && value.length === 0);
if (missing) {
gaps.push({ field: rule.field, severity: rule.severity, why: rule.why });
}
}
return gaps;
}
function parseArgs(argv) {
const args = { apply: false };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--uri') args.uri = argv[++i];
else if (argv[i] === '--uri-file') args.uriFile = argv[++i];
else if (argv[i] === '--db') args.db = argv[++i];
else if (argv[i] === '--license') args.license = argv[++i];
else if (argv[i] === '--apply') args.apply = true;
}
return args;
}
/*
* Group branches by the company that owns them.
*
* Shops are not one database each: they share a database and are separated by
* a license id carried on every document. A report that lists branches without
* saying whose they are is unreadable the moment there is more than one
* customer in there, and a repair that ignored that boundary would be worse
* than unreadable.
*/
function groupByLicense(branches) {
const companies = new Map();
for (const branch of branches) {
const key = String(branch.license || 'no-license');
if (!companies.has(key)) companies.set(key, []);
companies.get(key).push(branch);
}
return companies;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
// The connection string carries credentials, so it can come from a file
// rather than the command line, where it would linger in shell history.
if (args.uriFile && !args.uri) {
args.uri = fs.readFileSync(args.uriFile, 'utf8').trim();
}
if (!args.uri || !args.db) {
console.error('Usage: node scripts/tenant-audit.js --db <database> [options]');
console.error('');
console.error(' --uri <mongodb-uri> connection string');
console.error(' --uri-file <path> read the connection string from a file');
console.error(' --license <id> one company only; omit for every company');
console.error(' --apply write missing values (default: report only)');
process.exit(2);
}
let MongoClient, ObjectId;
try {
({ MongoClient, ObjectId } = require('../api/node_modules/mongodb'));
} catch (e) {
({ MongoClient, ObjectId } = require('mongodb'));
}
const client = new MongoClient(args.uri, { serverSelectionTimeoutMS: 8000 });
await client.connect();
const db = client.db(args.db);
console.log('\n tenant: ' + args.db + (args.apply ? ' (APPLYING)' : ' (report only)'));
const present = (await db.listCollections().toArray()).map((c) => c.name);
const missingCollections = EXPECTED_COLLECTIONS.filter((c) => !present.includes(c));
console.log('\n collections: ' + present.length + ' present'
+ (missingCollections.length ? ', missing ' + missingCollections.join(', ') : ', none missing'));
const expectations = branchExpectations();
/*
* One company, or every company in the database.
*
* Shops share this database and are separated by a license id, so auditing
* "the database" means auditing every customer in it at once. That is fine
* to read and worth pausing over before writing.
*/
const query = {};
if (args.license) {
query.license = ObjectId.isValid(args.license) ? new ObjectId(args.license) : args.license;
}
const branches = await db.collection('branches').find(query).toArray();
if (!branches.length) {
console.log('\n Nothing matched. Check the database name'
+ (args.license ? ' and the license id.' : '.'));
await client.close();
return;
}
const companies = groupByLicense(branches);
console.log(' branches: ' + branches.length + ' across ' + companies.size + ' company(ies)'
+ (args.license ? ' (filtered to one license)' : ''));
let wrote = 0;
let needingWork = 0;
for (const [license, owned] of companies) {
const companyBreaks = owned.some((b) =>
auditBranch(b, expectations).some((g) => g.severity === 'breaks'));
if (companyBreaks) needingWork++;
console.log('\n ---- license ' + license + ' ' + owned.length + ' branch'
+ (owned.length === 1 ? '' : 'es') + (companyBreaks ? ' NEEDS WORK' : ''));
for (const branch of owned) {
const gaps = auditBranch(branch, expectations);
const breaks = gaps.filter((g) => g.severity === 'breaks');
const cosmetic = gaps.filter((g) => g.severity === 'cosmetic');
console.log('\n ' + (branch.branch_name || String(branch._id)));
/*
* The module map (MODULE_SYSTEM_ROADMAP M5): what this shop actually
* has switched on, resolved with the same parse-and-default rules the
* app gates on - the one shared map in setting.model. Read-only; the
* support question this answers is "what does that shop see?".
*/
try {
const SettingModel = require('../api/src/models/setting.model');
const toggleMap = SettingModel.moduleToggleMap();
const off = [];
for (const [key, def] of Object.entries(toggleMap)) {
const effective = branch[key] === undefined ? def.dflt : def.parse(branch[key]);
if (!effective) off.push(key.replace(/^(module_|staff_)/, '').replace(/_enable$/, ''));
}
console.log(' modules: ' + (off.length ? 'all on except ' + off.join(', ') : 'all on'));
} catch (e) { /* the audit must not die over a map read */ }
if (!gaps.length) { console.log(' nothing missing'); continue; }
for (const g of breaks) console.log(' MISSING ' + g.field + ' - ' + g.why);
if (cosmetic.length) {
console.log(' ' + cosmetic.length + ' absent but defaulted on read: '
+ cosmetic.map((g) => g.field).join(', '));
}
if (args.apply && breaks.length) {
const $set = {};
for (const g of breaks) {
const rule = expectations.find((e) => e.field === g.field);
const value = typeof rule.value === 'function' ? rule.value() : rule.value;
if (value === null || value === undefined) {
console.log(' could not source a value for ' + g.field + ', left alone');
continue;
}
$set[g.field] = value;
}
if (Object.keys($set).length) {
/*
* Written one field at a time, each guarded on still being absent.
*
* The read that produced this list happened seconds ago and the shop
* is live: somebody may have set one of these in between. Querying
* for null also matches a field that is not there at all, so this
* matches exactly the state the report described and nothing else.
*/
const filled = [];
for (const key of Object.keys($set)) {
const result = await db.collection('branches').updateOne(
{ _id: branch._id, [key]: { $in: [null, ''] } },
{ $set: { [key]: $set[key] } });
if (result.modifiedCount) { filled.push(key); wrote++; }
else console.log(' ' + key + ' was set by someone else meanwhile, left alone');
}
if (filled.length) console.log(' filled in: ' + filled.join(', '));
}
}
}
}
console.log('\n ' + needingWork + ' of ' + companies.size
+ ' company(ies) have something that breaks.');
if (!args.apply) {
console.log(' Nothing was written. Re-run with --apply to fill in the MISSING rows.');
} else {
console.log(' branch fields written: ' + wrote);
}
await client.close();
}
if (require.main === module) {
main().catch((err) => {
console.error(' failed: ' + err.message);
process.exit(1);
});
}
module.exports = { auditBranch, branchExpectations, groupByLicense, EXPECTED_COLLECTIONS };
You can’t perform that action at this time.
