backend integration started
This commit is contained in:
434
scripts/contract.mjs
Normal file
434
scripts/contract.mjs
Normal file
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* The contract check.
|
||||
*
|
||||
* Signs in once, calls every endpoint the console reads, and reports what came
|
||||
* back: the HTTP status, the envelope, whether `details` is an array or an
|
||||
* object or null, how many rows, and — the part that matters — the keys on the
|
||||
* first row against the keys `src/api/types.ts` says to expect.
|
||||
*
|
||||
* This exists because field-name drift is invisible until a real payload
|
||||
* arrives, and it is the single most likely way connecting to the backend goes
|
||||
* wrong. It has already happened once from fixtures alone: POS health returns
|
||||
* `terminal_id` while the sales split returns `terminalid`, and an index
|
||||
* signature on the type let the wrong one typecheck in silence.
|
||||
*
|
||||
* READ-ONLY. Nothing here writes. Every create, update and import is left to a
|
||||
* person on a scratch tenant, because several of them are unscoped and one of
|
||||
* them moves stock.
|
||||
*
|
||||
* Run:
|
||||
* npm run contract (prompts for the password, hidden)
|
||||
*
|
||||
* Or, for CI, set NEARLE_EMAIL and NEARLE_PASSWORD in the environment. Neither
|
||||
* is ever written into this file — see the note beside EMAIL below.
|
||||
*
|
||||
* Plain JavaScript on purpose: it runs with the node you already have, with no
|
||||
* install step and no TypeScript loader in the way.
|
||||
*
|
||||
* The credentials are read from the environment and never printed, logged or
|
||||
* written to a file. Put them in front of the command rather than in a script,
|
||||
* and they stay out of your shell history if your shell is configured for it.
|
||||
*/
|
||||
|
||||
import { createInterface } from 'node:readline';
|
||||
|
||||
const BASE = process.env['NEARLE_API'] ?? 'https://fiesta.nearle.app';
|
||||
|
||||
/**
|
||||
* `/web/pos`, not `/pos`.
|
||||
*
|
||||
* The `/v1/pos` group sits behind the terminal's session guard; the console's
|
||||
* copies of the same reads are registered under `/v1/web/pos`. Sweeping the
|
||||
* wrong one would report a surface the console never calls.
|
||||
*/
|
||||
const WEB = '/live/api/v1/web';
|
||||
const POS = '/live/api/v1/web/pos';
|
||||
const MOB = '/live/api/v1/mob';
|
||||
|
||||
/**
|
||||
* The account to sweep with.
|
||||
*
|
||||
* The email defaults because it is not a secret. The PASSWORD is never
|
||||
* defaulted and never written into this file: a password in source is
|
||||
* committed, synced to every machine that clones the repo, and survives in the
|
||||
* history after it is changed. It is read from the environment if set, and
|
||||
* otherwise typed at the prompt below, where it is not echoed and does not
|
||||
* reach the shell history.
|
||||
*/
|
||||
const EMAIL = process.env['NEARLE_EMAIL'] ?? 'care@nearle.in';
|
||||
|
||||
/** Reads a line without echoing it. */
|
||||
function askHidden(question) {
|
||||
return new Promise((resolve) => {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
||||
const onData = (char) => {
|
||||
// Stop echoing everything except the newline that ends the answer.
|
||||
if (char.toString() !== '\n' && char.toString() !== '\r' && char.toString() !== '\u0004') {
|
||||
process.stdout.write('\u001b[2K\u001b[200D' + question + '*'.repeat(rl.line.length));
|
||||
}
|
||||
};
|
||||
process.stdin.on('data', onData);
|
||||
rl.question(question, (answer) => {
|
||||
process.stdin.off('data', onData);
|
||||
rl.close();
|
||||
process.stdout.write('\n');
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const PASSWORD =
|
||||
process.env['NEARLE_PASSWORD'] ?? (await askHidden(`Password for ${EMAIL}: `));
|
||||
|
||||
if (!PASSWORD) {
|
||||
console.error('No password given — nothing to sign in with.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function call(path, init = {}) {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(init.params ?? {})) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
search.set(key, String(value));
|
||||
}
|
||||
const query = search.toString();
|
||||
const response = await fetch(`${BASE}${path}${query ? `?${query}` : ''}`, {
|
||||
method: init.method ?? 'GET',
|
||||
headers: init.body
|
||||
? { Accept: 'application/json', 'Content-Type': 'application/json' }
|
||||
: { Accept: 'application/json' },
|
||||
...(init.body ? { body: JSON.stringify(init.body) } : {}),
|
||||
});
|
||||
let envelope = {};
|
||||
try {
|
||||
envelope = await response.json();
|
||||
} catch {
|
||||
envelope = { message: 'not JSON' };
|
||||
}
|
||||
return { http: response.status, envelope };
|
||||
}
|
||||
|
||||
/**
|
||||
* A call that reports a dead host rather than crashing the run.
|
||||
*
|
||||
* A wrong `NEARLE_API`, a VPN that is not up, or one endpoint timing out should
|
||||
* leave the other twenty-four results on screen — a stack trace at check four
|
||||
* tells you nothing about checks five to twenty-five.
|
||||
*/
|
||||
async function attempt(path, init = {}) {
|
||||
try {
|
||||
return await call(path, init);
|
||||
} catch (cause) {
|
||||
return {
|
||||
http: 0,
|
||||
envelope: { status: false, message: `could not reach the server (${String(cause)})` },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Sign in ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
const login = await attempt(`${WEB}/users/applogin`, {
|
||||
method: 'POST',
|
||||
// `configid` is not optional: the lookup is `WHERE authname = ? AND configid = ?`.
|
||||
body: { authname: EMAIL, password: PASSWORD, configid: 1 },
|
||||
});
|
||||
|
||||
if (login.envelope.status !== true || !login.envelope.details) {
|
||||
console.error(
|
||||
`Sign-in failed — HTTP ${login.http}, code ${login.envelope.code}: ${login.envelope.message}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const me = login.envelope.details;
|
||||
const tenantid = Number(me['tenantid'] ?? 0);
|
||||
const locationid = Number(me['locationid'] ?? 0);
|
||||
const issuperadmin = me['issuperadmin'] === true;
|
||||
|
||||
console.log('── signed in ─────────────────────────────────────────────');
|
||||
console.log(`userid ${me['userid']} · roleid ${me['roleid']} · issuperadmin ${issuperadmin}`);
|
||||
console.log(`tenantid ${tenantid} · locationid ${locationid} · ${me['locationname'] ?? '—'}`);
|
||||
console.log('login keys:', Object.keys(me).sort().join(', '));
|
||||
console.log('');
|
||||
|
||||
/**
|
||||
* A tenant and a branch to probe the scoped endpoints with.
|
||||
*
|
||||
* A super admin has neither of their own, so one is borrowed from the platform
|
||||
* list. Override with NEARLE_TENANT / NEARLE_LOCATION to aim at a specific one.
|
||||
*/
|
||||
let probeTenant = Number(process.env['NEARLE_TENANT'] ?? 0) || tenantid;
|
||||
let probeLocation = Number(process.env['NEARLE_LOCATION'] ?? 0) || locationid;
|
||||
|
||||
if (!probeTenant) {
|
||||
const tenants = await attempt(`${WEB}/tenants/getalltenants`, {
|
||||
params: { pageno: 1, pagesize: 1 },
|
||||
});
|
||||
probeTenant = Number(tenants.envelope.details?.[0]?.['tenantid'] ?? 0);
|
||||
}
|
||||
if (probeTenant && !probeLocation) {
|
||||
const locations = await attempt(`${WEB}/tenants/gettenantlocations`, {
|
||||
params: { tenantid: probeTenant },
|
||||
});
|
||||
probeLocation = Number(locations.envelope.details?.[0]?.['locationid'] ?? 0);
|
||||
}
|
||||
|
||||
console.log(`probing with tenantid ${probeTenant} · locationid ${probeLocation}\n`);
|
||||
|
||||
/* ── What we expect ──────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The keys each row should carry, taken from `src/api/types.ts`.
|
||||
*
|
||||
* Only the ones the console actually reads are listed — a backend that returns
|
||||
* MORE than this is fine and normal, and is reported as extras rather than as a
|
||||
* failure. What matters is anything missing.
|
||||
*/
|
||||
const CHECKS = [
|
||||
{
|
||||
name: 'tenants/getalltenants',
|
||||
path: `${WEB}/tenants/getalltenants`,
|
||||
params: { pageno: 1, pagesize: 5 },
|
||||
expect: ['tenantid', 'tenantname', 'locationid', 'locationname', 'status'],
|
||||
},
|
||||
{
|
||||
name: 'tenants/search?pending',
|
||||
path: `${WEB}/tenants/search`,
|
||||
params: { status: 'pending' },
|
||||
expect: ['tenantid', 'tenantname'],
|
||||
},
|
||||
{
|
||||
name: 'tenants/gettenantlocations',
|
||||
path: `${WEB}/tenants/gettenantlocations`,
|
||||
params: { tenantid: probeTenant },
|
||||
needs: 'tenant',
|
||||
expect: ['locationid', 'tenantid', 'locationname', 'status'],
|
||||
},
|
||||
{
|
||||
name: 'utils/getappcategories',
|
||||
path: `${WEB}/utils/getappcategories`,
|
||||
expect: ['categoryid', 'categoryname'],
|
||||
},
|
||||
{
|
||||
name: 'orders/getlocationsummary',
|
||||
path: `${WEB}/orders/getlocationsummary`,
|
||||
params: { tenantid: probeTenant },
|
||||
needs: 'tenant',
|
||||
expect: ['locationid', 'locationname', 'total', 'delivered', 'cancelled'],
|
||||
},
|
||||
{
|
||||
name: 'orders/getordersummary',
|
||||
path: `${WEB}/orders/getordersummary`,
|
||||
params: { tenantid: probeTenant },
|
||||
needs: 'tenant',
|
||||
expect: ['total', 'delivered', 'cancelled'],
|
||||
},
|
||||
{
|
||||
name: 'catalogue/getbrands',
|
||||
path: `${WEB}/catalogue/getbrands`,
|
||||
expect: ['brand', 'product_count'],
|
||||
},
|
||||
{
|
||||
name: 'catalogue/getproducts',
|
||||
path: `${WEB}/catalogue/getproducts`,
|
||||
params: { pageno: 1, pagesize: 5 },
|
||||
expect: ['id', 'brand', 'product_name'],
|
||||
},
|
||||
{
|
||||
name: 'products/getimportedcatalogueproducts',
|
||||
path: `${WEB}/products/getimportedcatalogueproducts`,
|
||||
params: { tenantid: probeTenant },
|
||||
needs: 'tenant',
|
||||
expect: ['brand', 'catalogueid'],
|
||||
},
|
||||
{
|
||||
name: 'products/getproductcategories',
|
||||
path: `${WEB}/products/getproductcategories`,
|
||||
params: { tenantid: probeTenant },
|
||||
needs: 'tenant',
|
||||
expect: ['categoryid', 'categoryname'],
|
||||
},
|
||||
{
|
||||
name: 'products/gettenantcategories',
|
||||
path: `${WEB}/products/gettenantcategories`,
|
||||
params: { tenantid: probeTenant },
|
||||
needs: 'tenant',
|
||||
expect: ['categoryid', 'categoryname'],
|
||||
},
|
||||
{
|
||||
name: 'products/getlocationproducts',
|
||||
path: `${WEB}/products/getlocationproducts`,
|
||||
params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 },
|
||||
needs: 'location',
|
||||
expect: ['productid', 'productname', 'price', 'publishedat', 'status'],
|
||||
},
|
||||
{
|
||||
name: 'products/getallproducts',
|
||||
path: `${WEB}/products/getallproducts`,
|
||||
params: { tenantid: probeTenant, pageno: 1, pagesize: 5 },
|
||||
needs: 'tenant',
|
||||
expect: ['productid', 'productname'],
|
||||
},
|
||||
{
|
||||
name: 'products/getstockstatement',
|
||||
path: `${WEB}/products/getstockstatement`,
|
||||
params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 },
|
||||
needs: 'location',
|
||||
expect: ['productid', 'opening', 'credit', 'debit', 'closing'],
|
||||
},
|
||||
{
|
||||
name: 'products/getstockrequests',
|
||||
path: `${WEB}/products/getstockrequests`,
|
||||
params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 },
|
||||
needs: 'tenant',
|
||||
expect: ['requestid', 'productid', 'qty', 'status'],
|
||||
},
|
||||
{
|
||||
name: 'products/getsaletemplate',
|
||||
path: `${WEB}/products/getsaletemplate`,
|
||||
params: { tenantid: probeTenant, locationid: probeLocation },
|
||||
needs: 'tenant',
|
||||
expect: ['tenantid', 'locations', 'products'],
|
||||
},
|
||||
{
|
||||
name: 'customers/gettenantcustomers',
|
||||
path: `${WEB}/customers/gettenantcustomers`,
|
||||
params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 },
|
||||
needs: 'tenant',
|
||||
expect: ['customerid', 'firstname', 'contactno'],
|
||||
},
|
||||
{
|
||||
name: 'orders/getorders',
|
||||
path: `${WEB}/orders/tenant/getorders`,
|
||||
params: {
|
||||
tenantid: probeTenant,
|
||||
locationid: probeLocation,
|
||||
fromdate: isoDaysAgo(30),
|
||||
todate: isoDaysAgo(0),
|
||||
pageno: 1,
|
||||
pagesize: 5,
|
||||
},
|
||||
needs: 'tenant',
|
||||
expect: ['orderheaderid', 'orderstatus'],
|
||||
},
|
||||
{
|
||||
name: 'deliveries/getdeliveries',
|
||||
path: `${WEB}/deliveries/getdeliveries`,
|
||||
params: {
|
||||
tenantid: probeTenant,
|
||||
locationid: probeLocation,
|
||||
fromdate: isoDaysAgo(30),
|
||||
todate: isoDaysAgo(0),
|
||||
pageno: 1,
|
||||
pagesize: 5,
|
||||
},
|
||||
needs: 'tenant',
|
||||
expect: ['orderheaderid', 'orderstatus'],
|
||||
},
|
||||
{
|
||||
name: 'pos/sales/summary',
|
||||
path: `${POS}/sales/summary`,
|
||||
params: { locationid: probeLocation, fromdate: isoDaysAgo(7), todate: isoDaysAgo(0) },
|
||||
needs: 'location',
|
||||
expect: ['billcount', 'grosssales', 'taxcollected'],
|
||||
},
|
||||
{
|
||||
name: 'pos/sales',
|
||||
path: `${POS}/sales`,
|
||||
params: { locationid: probeLocation, pageno: 0, pagesize: 5 },
|
||||
needs: 'location',
|
||||
expect: ['bills', 'total'],
|
||||
},
|
||||
{
|
||||
name: 'pos/health/location',
|
||||
path: `${POS}/health/location`,
|
||||
params: { location_id: probeLocation },
|
||||
needs: 'location',
|
||||
expect: ['total', 'online', 'terminals'],
|
||||
},
|
||||
{
|
||||
name: 'tenants/getposusers',
|
||||
path: `${WEB}/tenants/getposusers`,
|
||||
params: { tenantid: probeTenant, locationid: probeLocation },
|
||||
needs: 'location',
|
||||
expect: ['users', 'location_id'],
|
||||
},
|
||||
{
|
||||
name: 'tenants/getstaffs (MOB)',
|
||||
path: `${MOB}/tenants/getstaffs`,
|
||||
params: { tenantid: probeTenant },
|
||||
needs: 'tenant',
|
||||
expect: ['userid', 'rolename', 'firstname'],
|
||||
},
|
||||
{ name: 'tenants/posroles', path: `${WEB}/tenants/posroles`, expect: ['role_id', 'role'] },
|
||||
{
|
||||
name: 'tenants/getstaffshifts',
|
||||
path: `${WEB}/tenants/getstaffshifts`,
|
||||
params: { tenantid: probeTenant, locationid: probeLocation },
|
||||
needs: 'location',
|
||||
expect: ['shifts', 'location_id'],
|
||||
},
|
||||
];
|
||||
|
||||
function isoDaysAgo(days) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - days);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/* ── Run ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
let mismatches = 0;
|
||||
let unreachable = 0;
|
||||
|
||||
for (const check of CHECKS) {
|
||||
if (check.needs === 'tenant' && !probeTenant) {
|
||||
console.log(`SKIP ${check.name} — no tenant to probe with`);
|
||||
continue;
|
||||
}
|
||||
if (check.needs === 'location' && !probeLocation) {
|
||||
console.log(`SKIP ${check.name} — no location to probe with`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { http, envelope } = await attempt(check.path, { params: check.params });
|
||||
const payload = envelope.details ?? envelope.data;
|
||||
|
||||
const shape = Array.isArray(payload)
|
||||
? `array(${payload.length})`
|
||||
: payload === null || payload === undefined
|
||||
? 'null'
|
||||
: typeof payload;
|
||||
|
||||
// The row to inspect: the first element of a list, or the object itself.
|
||||
const row = Array.isArray(payload) ? payload[0] : payload;
|
||||
const keys = row && typeof row === 'object' ? Object.keys(row) : [];
|
||||
const missing = check.expect.filter((key) => !keys.includes(key));
|
||||
|
||||
const ok = envelope.status !== false && http < 400 && missing.length === 0;
|
||||
if (!ok) mismatches += 1;
|
||||
if (http >= 400 || envelope.status === false) unreachable += 1;
|
||||
|
||||
console.log(
|
||||
`${ok ? 'OK ' : 'CHECK'} ${check.name.padEnd(38)} http ${http} · code ${envelope.code ?? '—'} · ${shape}`,
|
||||
);
|
||||
if (envelope.status === false || http >= 400) {
|
||||
console.log(` message: ${envelope.message ?? '(none)'}`);
|
||||
}
|
||||
if (missing.length > 0 && keys.length > 0) {
|
||||
console.log(` MISSING: ${missing.join(', ')}`);
|
||||
console.log(` got: ${keys.sort().join(', ')}`);
|
||||
}
|
||||
if (keys.length === 0 && shape !== 'null' && !Array.isArray(payload)) {
|
||||
console.log(` payload: ${JSON.stringify(payload).slice(0, 160)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`${CHECKS.length} checked · ${mismatches} to look at · ${unreachable} refused`);
|
||||
console.log(
|
||||
mismatches === 0
|
||||
? 'Every endpoint answered in the shape the console expects.'
|
||||
: 'Anything marked CHECK either refused the call or is missing a key the console reads.',
|
||||
);
|
||||
251
scripts/db.mjs
Normal file
251
scripts/db.mjs
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Direct database access, through Hasura.
|
||||
*
|
||||
* A scratchpad for reading and fixing rows that no screen exposes — setting a
|
||||
* password on an account that was spawned without one, flipping a status,
|
||||
* checking what the API is actually reading. It talks to the Hasura instance
|
||||
* the old console proxies to (`api.workolik.com`), using the admin secret from
|
||||
* `daily_merchant_web/.env`, which is gitignored and stays there.
|
||||
*
|
||||
* node scripts/db.mjs tables
|
||||
* node scripts/db.mjs user care@nearle.in
|
||||
* node scripts/db.mjs setpw care@nearle.in <password>
|
||||
* node scripts/db.mjs sql "select userid, authname from app_users limit 5"
|
||||
*
|
||||
* The secret is read from disk or the environment and never printed, never
|
||||
* written anywhere, and never passed on the command line.
|
||||
*
|
||||
* ── Read this before using `setpw` ────────────────────────────────────────
|
||||
* This points at PRODUCTION. Every write here is immediate and unversioned.
|
||||
* `setpw` refuses to run unless the account's password column is already
|
||||
* empty, so it can only ever complete a setup that was never finished — it
|
||||
* cannot overwrite a working login. Lift that guard only deliberately.
|
||||
*
|
||||
* Passwords in `app_users` are stored in clear. That is a property of this
|
||||
* backend, not of this script; anything written here is readable by anyone
|
||||
* with database access.
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const ENDPOINT = process.env.HASURA_URL ?? 'https://api.workolik.com/v1/graphql';
|
||||
|
||||
/** Where the old console keeps its gitignored secret, relative to this repo. */
|
||||
const ENV_CANDIDATES = [
|
||||
resolve(HERE, '../../../nearle-daily/daily_merchant_web/.env'),
|
||||
resolve(HERE, '../../daily_merchant_web/.env'),
|
||||
'D:/nearle-daily/daily_merchant_web/.env',
|
||||
];
|
||||
|
||||
function readSecret() {
|
||||
if (process.env.HASURA_ADMIN_SECRET) return process.env.HASURA_ADMIN_SECRET;
|
||||
|
||||
for (const path of ENV_CANDIDATES) {
|
||||
if (!existsSync(path)) continue;
|
||||
const line = readFileSync(path, 'utf8')
|
||||
.split(/\r?\n/)
|
||||
.find((row) => row.startsWith('HASURA_ADMIN_SECRET='));
|
||||
if (!line) continue;
|
||||
const value = line.slice('HASURA_ADMIN_SECRET='.length).trim().replace(/^["']|["']$/g, '');
|
||||
if (value) return value;
|
||||
}
|
||||
|
||||
console.error(
|
||||
'No admin secret found.\n' +
|
||||
'Expected HASURA_ADMIN_SECRET in one of:\n' +
|
||||
ENV_CANDIDATES.map((p) => ` ${p}`).join('\n') +
|
||||
'\nor set it in the environment for this command.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const SECRET = readSecret();
|
||||
|
||||
async function gql(query, variables = {}) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-hasura-admin-secret': SECRET },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
} catch (cause) {
|
||||
console.error(`Could not reach ${ENDPOINT} — ${cause.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!payload) {
|
||||
console.error(`Malformed response (HTTP ${response.status})`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (payload.errors) {
|
||||
for (const error of payload.errors) console.error(`✗ ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
/* ── Commands ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Every table Hasura has tracked. Start here if a query says "field not found". */
|
||||
async function tables() {
|
||||
const data = await gql(`{ __schema { queryType { fields { name } } } }`);
|
||||
const names = data.__schema.queryType.fields
|
||||
.map((field) => field.name)
|
||||
.filter((name) => !name.endsWith('_aggregate') && !name.endsWith('_by_pk'))
|
||||
.sort();
|
||||
console.log(names.join('\n'));
|
||||
console.log(`\n${names.length} tables`);
|
||||
}
|
||||
|
||||
const USER_FIELDS = `userid authname firstname lastname contactno roleid status tenantid locationid configid`;
|
||||
|
||||
async function findUser(email) {
|
||||
const data = await gql(
|
||||
`query ($email: String!) {
|
||||
app_users(where: { authname: { _eq: $email } }) { ${USER_FIELDS} password }
|
||||
}`,
|
||||
{ email },
|
||||
);
|
||||
return data.app_users ?? [];
|
||||
}
|
||||
|
||||
async function user(email) {
|
||||
const rows = await findUser(email);
|
||||
if (rows.length === 0) {
|
||||
console.log(`No account with authname "${email}".`);
|
||||
return;
|
||||
}
|
||||
for (const row of rows) {
|
||||
// The password itself is never printed — only whether one exists, which is
|
||||
// the only thing anyone needs to know from here.
|
||||
const { password, ...rest } = row;
|
||||
console.log({ ...rest, haspassword: String(password ?? '').trim() !== '' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes a password setup that was never finished.
|
||||
*
|
||||
* Refuses if a password is already set. An account that can sign in must not
|
||||
* be changeable from a scratchpad — that is a support action with a person
|
||||
* behind it, not a one-liner.
|
||||
*/
|
||||
async function setpw(email, password) {
|
||||
if (!password || password.length < 6) {
|
||||
console.error('Password must be at least 6 characters (the backend enforces this too).');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rows = await findUser(email);
|
||||
if (rows.length === 0) {
|
||||
console.error(`No account with authname "${email}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (rows.length > 1) {
|
||||
console.error(
|
||||
`${rows.length} accounts share that email (configid ${rows.map((r) => r.configid).join(', ')}).\n` +
|
||||
'Refusing to guess. Use `sql` with an explicit userid.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const row = rows[0];
|
||||
if (String(row.password ?? '').trim() !== '') {
|
||||
console.error(
|
||||
`userid ${row.userid} already has a password. This command only completes an unfinished setup.\n` +
|
||||
'To reset a working login, do it deliberately with `sql`.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (row.roleid === 7 || row.roleid === 8) {
|
||||
console.error(
|
||||
`userid ${row.userid} is a till account (roleid ${row.roleid}). Those sign in at the terminal with a PIN, not here.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = await gql(
|
||||
`mutation ($userid: Int!, $password: String!) {
|
||||
update_app_users(where: { userid: { _eq: $userid } }, _set: { password: $password }) {
|
||||
affected_rows
|
||||
}
|
||||
}`,
|
||||
{ userid: row.userid, password },
|
||||
);
|
||||
|
||||
const affected = data.update_app_users?.affected_rows ?? 0;
|
||||
if (affected !== 1) {
|
||||
console.error(`Expected to update 1 row, updated ${affected}. Nothing assumed — check manually.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`✓ Password set on userid ${row.userid} (${email}), roleid ${row.roleid}, tenantid ${row.tenantid}.`,
|
||||
);
|
||||
console.log(' Sign in at the console with it now.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Arbitrary read-only SQL, via Hasura's `run_sql`.
|
||||
*
|
||||
* Reads only. A statement that writes is refused here — writes go through a
|
||||
* named command above, where they can carry their own guard.
|
||||
*/
|
||||
async function sql(statement) {
|
||||
if (/^\s*(insert|update|delete|drop|alter|truncate|create)\b/i.test(statement)) {
|
||||
console.error('This command runs reads only. Add a named command for a write.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const endpoint = ENDPOINT.replace(/\/v1\/graphql$/, '/v2/query');
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-hasura-admin-secret': SECRET },
|
||||
body: JSON.stringify({
|
||||
type: 'run_sql',
|
||||
args: { source: 'default', sql: statement, read_only: true },
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload) {
|
||||
console.error(payload?.error ?? `HTTP ${response.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rows = payload.result ?? [];
|
||||
for (const row of rows) console.log(row.join('\t'));
|
||||
console.log(`\n${Math.max(0, rows.length - 1)} rows`);
|
||||
}
|
||||
|
||||
/* ── Dispatch ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
|
||||
const COMMANDS = {
|
||||
tables: () => tables(),
|
||||
user: () => user(rest[0]),
|
||||
setpw: () => setpw(rest[0], rest[1]),
|
||||
sql: () => sql(rest.join(' ')),
|
||||
};
|
||||
|
||||
if (!command || !COMMANDS[command]) {
|
||||
console.log(
|
||||
[
|
||||
'node scripts/db.mjs <command>',
|
||||
'',
|
||||
' tables every table Hasura has tracked',
|
||||
' user <email> show an account (never prints the password)',
|
||||
' setpw <email> <password> set a password on an account that has none',
|
||||
' sql "<select ...>" read-only SQL',
|
||||
].join('\n'),
|
||||
);
|
||||
process.exit(command ? 1 : 0);
|
||||
}
|
||||
|
||||
await COMMANDS[command]();
|
||||
Reference in New Issue
Block a user