From 0ef59aa81b7509276decc2b403b8ca38c07c7683 Mon Sep 17 00:00:00 2001 From: Simeon J Morgan Date: Wed, 16 Nov 2022 22:27:18 +1100 Subject: [PATCH 01/13] Update node, postgres. Change env var names --- .qovery.yml | 15 --------------- Dockerfile | 2 +- README.md | 15 +++++++++++++-- docker-compose.yml | 2 +- src/example.env | 8 ++------ src/index.js | 22 +++++++++++----------- 6 files changed, 28 insertions(+), 36 deletions(-) delete mode 100644 .qovery.yml diff --git a/.qovery.yml b/.qovery.yml deleted file mode 100644 index 0abf6ae..0000000 --- a/.qovery.yml +++ /dev/null @@ -1,15 +0,0 @@ -application: - name: coffeebot-app - project: coffeebot-project - cloud_region: aws/ap-southeast-2 - publicly_accessible: true -databases: -- type: postgresql - version: "11.5" - name: coffee-db -routers: -- name: main - routes: - - application_name: coffeebot-app - paths: - - / diff --git a/Dockerfile b/Dockerfile index c4db73c..6f8fdf5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:13-alpine +FROM node:16-alpine RUN mkdir -p /usr/src/app diff --git a/README.md b/README.md index 79b129a..efd60c6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,13 @@ -No readme yet - added to try to get qovery to deploy -+1 +### CoffeeBot + +Coffeebot is a Slackbot used to track the coffee consumption of users in a slack workspace. This was +hacked out just before international coffee day, 2000. + +It was initially set up to try to run on Firebase, but that didn't work well because of the startup +time of workers. It was then set up to try to use Qovery, but Qovery wouldn't work - it just kept +producing unusable environments. It was finally set up as a simple docker container, and now runs +happily on a box running Caprover. + +It's poorly written, hacked together in a short space of time and given virtually no attention +thereafter, but a single instance has run happily for 1.5 years so it seems to be remarkably +stable considering. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index f0de603..8c4067f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: "3" services: db: - image: postgres:12-alpine + image: postgres:15-alpine volumes: - pgdata:/var/lib/postgresql/data ports: diff --git a/src/example.env b/src/example.env index 9e4bce8..1462eef 100644 --- a/src/example.env +++ b/src/example.env @@ -1,14 +1,10 @@ AUTH_KEY="qYe4VZgQMhsAzxqJzmFCJjZi4tVsznq2ogcN3HTm" -QOVERY_DATABASE_COFFEE_DB_USERNAME="coffeebot" -QOVERY_DATABASE_COFFEE_DB_HOST="db" -QOVERY_DATABASE_COFFEE_DB_DATABASE="coffeebot" -QOVERY_DATABASE_COFFEE_DB_PASSWORD="coffeebot_password" -QOVERY_DATABASE_COFFEE_DB_PORT=5432 - +POSTGRES_HOST="db" POSTGRES_USER="coffeebot" POSTGRES_PASSWORD="coffeebot_password" POSTGRES_DB="coffeebot" +POSTGRES_PORT=5432 AWS_ACCESS_KEY_ID= AWS_SECRET_KEY= diff --git a/src/index.js b/src/index.js index 6a406e6..e1f66e4 100644 --- a/src/index.js +++ b/src/index.js @@ -24,16 +24,16 @@ app.use(bodyParser()); const router = new Router(); const pool = new Pool({ - user: process.env.QOVERY_DATABASE_COFFEE_DB_USERNAME, - host: process.env.QOVERY_DATABASE_COFFEE_DB_HOST, - database: process.env.DATABASE_NAME, - password: process.env.QOVERY_DATABASE_COFFEE_DB_PASSWORD, - port: process.env.QOVERY_DATABASE_COFFEE_DB_PORT, + user: process.env.POSTGRES_USER, + host: process.env.POSTGRES_HOST, + database: process.env.POSTGRES_DB, + password: process.env.POSTGRES_PASSWORD, + port: process.env.POSTGRES_PORT, }); new CronJob( "00 00 02 * * *", - async function () { + async function() { await createBackup(); }, null, @@ -521,11 +521,11 @@ app.listen(3000, async () => { console.log("running on port 3000"); console.log({ - user: process.env.QOVERY_DATABASE_COFFEE_DB_USERNAME, - host: process.env.QOVERY_DATABASE_COFFEE_DB_HOST, - database: process.env.QOVERY_DATABASE_COFFEE_DB_DATABASE, - password: process.env.QOVERY_DATABASE_COFFEE_DB_PASSWORD, - port: process.env.QOVERY_DATABASE_COFFEE_DB_PORT, + user: process.env.POSTGRES_USER, + host: process.env.POSTGRES_HOST, + database: process.env.POSTGRES_DB, + password: process.env.POSTGRES_PASSWORD, + port: process.env.POSTGRES_PORT, key: process.env.AUTH_KEY, }); }); From 8327bcc0b4ab6508c96f40867ab33b36ede1e90a Mon Sep 17 00:00:00 2001 From: Simeon J Morgan Date: Wed, 16 Nov 2022 22:46:31 +1100 Subject: [PATCH 02/13] Update packages; add about --- package-lock.json | 6 + src/index.js | 17 +- src/package-lock.json | 1597 +++++++++++++++++++++++++++++++++++------ 3 files changed, 1387 insertions(+), 233 deletions(-) create mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b3de804 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "coffeebot", + "lockfileVersion": 2, + "requires": true, + "packages": {} +} diff --git a/src/index.js b/src/index.js index e1f66e4..09df763 100644 --- a/src/index.js +++ b/src/index.js @@ -55,10 +55,22 @@ function showHelp() { - \`/coffee -\` - subtract multiple coffees, max 2; but try not to add coffees you're not drinking - \`/coffee count\` - show the total number of coffees, and highest 5 coffee consumers - \`/coffee count-all\` - show the total number of coffees, and _all_ coffee consumers - - \`/coffee stats\` - see summary data from all coffees recorded since the beginning of the bot`, + - \`/coffee stats\` - see summary data from all coffees recorded since the beginning of the bot + - \`/coffee about\` - about coffeebot`, }; } +function showAbout() { + return { + response_type: "ephemeral", + text: `Coffeebot was written the night before international coffee 2020 as a combination between a joke and +an experiment in using firebase. Somehow, it has continued to be used since then. I hope you like it. + + - Simeon` + } +} + + // CREATE_DATABASE_QUERY = "CREATE DATABASE drinks ENCODING = 'UTF8'"; // CHECK_IF_DATABASE_EXISTS_QUERY = "SELECT datname FROM pg_catalog.pg_database WHERE datname = drinks;" CREATE_BACKUP_TABLE_QUERY = ` @@ -476,6 +488,9 @@ router.post("/addCoffee", async (ctx, next) => { if (ctx.request.body.text === "help") { ctx.body = showHelp(); return; + } else if (ctx.request.body.text === "about") { + ctx.body = showAbout(); + return; } else if (ctx.request.body.text === "count") { ctx.body = await showCoffeeCount(COUNT_DISPLAY_SIZE); return; diff --git a/src/package-lock.json b/src/package-lock.json index e709f14..adeabff 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -1,43 +1,1097 @@ { "name": "coffeebot", "version": "1.0.0", - "lockfileVersion": 1, + "lockfileVersion": 2, "requires": true, + "packages": { + "": { + "name": "coffeebot", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "aws-sdk": "^2.766.0", + "cron": "^1.8.2", + "dotenv": "^8.2.0", + "koa": "^2.13.0", + "koa-bodyparser": "^4.3.0", + "koa-router": "^9.4.0", + "luxon": "^1.25.0", + "pg": "^8.3.3" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/aws-sdk": { + "version": "2.1255.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1255.0.tgz", + "integrity": "sha512-S3oPXrBVOWquVL1bzH79bz88PgF4GqLcUbIph5yJ+pWW0OKNWGWKW1PDwtWi6ma+8mKXJ1gGKgy6R2hD57AsLw==", + "dependencies": { + "buffer": "4.9.2", + "events": "1.1.1", + "ieee754": "1.1.13", + "jmespath": "0.16.0", + "querystring": "0.2.0", + "sax": "1.2.1", + "url": "0.10.3", + "util": "^0.12.4", + "uuid": "8.0.0", + "xml2js": "0.4.19" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/buffer": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, + "node_modules/buffer-writer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", + "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cache-content-type": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", + "integrity": "sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==", + "dependencies": { + "mime-types": "^2.1.18", + "ylru": "^1.2.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dependencies": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/co-body": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/co-body/-/co-body-6.1.0.tgz", + "integrity": "sha512-m7pOT6CdLN7FuXUcpuz/8lfQ/L77x8SchHCF4G0RBTJO20Wzmhn5Sp4/5WsKy8OSpifBSUrmg83qEqaDHdyFuQ==", + "dependencies": { + "inflation": "^2.0.0", + "qs": "^6.5.2", + "raw-body": "^2.3.3", + "type-is": "^1.6.16" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookies": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.8.0.tgz", + "integrity": "sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/copy-to": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/copy-to/-/copy-to-2.0.1.tgz", + "integrity": "sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w==" + }, + "node_modules/cron": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/cron/-/cron-1.8.2.tgz", + "integrity": "sha512-Gk2c4y6xKEO8FSAUTklqtfSr7oTq0CiPQeLBG5Fl0qoXpZyMcj1SG59YL+hqq04bu6/IuEA7lMkYDAplQNKkyg==", + "dependencies": { + "moment-timezone": "^0.5.x" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==" + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", + "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==", + "engines": { + "node": ">=10" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==", + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "node_modules/get-intrinsic": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", + "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==", + "dependencies": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/http-assert": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", + "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", + "dependencies": { + "deep-equal": "~1.0.1", + "http-errors": "~1.8.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-errors/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", + "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" + }, + "node_modules/inflation": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/inflation/-/inflation-2.0.0.tgz", + "integrity": "sha512-m3xv4hJYR2oXw4o4Y5l6P5P16WYmazYof+el6Al3f+YlggGj6qT9kImBAnzDelRALnP5d3h4jGBPKzYCizjZZw==", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz", + "integrity": "sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==", + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "node_modules/jmespath": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", + "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa": { + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.13.4.tgz", + "integrity": "sha512-43zkIKubNbnrULWlHdN5h1g3SEKXOEzoAlRsHOTFpnlDu8JlAOZSMJBLULusuXRequboiwJcj5vtYXKB3k7+2g==", + "dependencies": { + "accepts": "^1.3.5", + "cache-content-type": "^1.0.0", + "content-disposition": "~0.5.2", + "content-type": "^1.0.4", + "cookies": "~0.8.0", + "debug": "^4.3.2", + "delegates": "^1.0.0", + "depd": "^2.0.0", + "destroy": "^1.0.4", + "encodeurl": "^1.0.2", + "escape-html": "^1.0.3", + "fresh": "~0.5.2", + "http-assert": "^1.3.0", + "http-errors": "^1.6.3", + "is-generator-function": "^1.0.7", + "koa-compose": "^4.1.0", + "koa-convert": "^2.0.0", + "on-finished": "^2.3.0", + "only": "~0.0.2", + "parseurl": "^1.3.2", + "statuses": "^1.5.0", + "type-is": "^1.6.16", + "vary": "^1.1.2" + }, + "engines": { + "node": "^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4" + } + }, + "node_modules/koa-bodyparser": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/koa-bodyparser/-/koa-bodyparser-4.3.0.tgz", + "integrity": "sha512-uyV8G29KAGwZc4q/0WUAjH+Tsmuv9ImfBUF2oZVyZtaeo0husInagyn/JH85xMSxM0hEk/mbCII5ubLDuqW/Rw==", + "dependencies": { + "co-body": "^6.0.0", + "copy-to": "^2.0.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/koa-compose": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz", + "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==" + }, + "node_modules/koa-convert": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-2.0.0.tgz", + "integrity": "sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==", + "dependencies": { + "co": "^4.6.0", + "koa-compose": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/koa-router": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/koa-router/-/koa-router-9.4.0.tgz", + "integrity": "sha512-RO/Y8XqSNM2J5vQeDaBI/7iRpL50C9QEudY4d3T4D1A2VMKLH0swmfjxDFPiIpVDLuNN6mVD9zBI1eFTHB6QaA==", + "dependencies": { + "debug": "^4.1.1", + "http-errors": "^1.7.3", + "koa-compose": "^4.1.0", + "methods": "^1.1.2", + "path-to-regexp": "^6.1.0" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/luxon": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.28.0.tgz", + "integrity": "sha512-TfTiyvZhwBYM/7QdAVDh+7dBTBA29v4ik0Ce9zda3Mnf8on1S5KJI8P2jKFZ8+5C0jhmr0KwJEO/Wdpm0VeWJQ==", + "engines": { + "node": "*" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.39", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.39.tgz", + "integrity": "sha512-hoB6suq4ISDj7BDgctiOy6zljBsdYT0++0ZzZm9rtxIvJhIbQ3nmbgSWe7dNFGurl6/7b1OUkHlmN9JWgXVz7w==", + "dependencies": { + "moment": ">= 2.9.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", + "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/only": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", + "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==" + }, + "node_modules/packet-reader": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz", + "integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", + "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==" + }, + "node_modules/pg": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz", + "integrity": "sha512-UXYN0ziKj+AeNNP7VDMwrehpACThH7LUl/p8TDFpEUuSejCUIwGSfxpHsPvtM6/WXFy6SU4E5RG4IJV/TZAGjw==", + "dependencies": { + "buffer-writer": "2.0.0", + "packet-reader": "1.0.0", + "pg-connection-string": "^2.5.0", + "pg-pool": "^3.5.2", + "pg-protocol": "^1.5.0", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-connection-string": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz", + "integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.5.2.tgz", + "integrity": "sha512-His3Fh17Z4eg7oANLob6ZvH8xIVen3phEZh2QuyrIl4dQSDVEabNducv6ysROKpDNPSD+12tONZVWfSgMvDD9w==", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.5.0.tgz", + "integrity": "sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ==" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==" + }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==", + "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/sax": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", + "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split2": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.1.0.tgz", + "integrity": "sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "engines": { + "node": ">=0.6.x" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/url": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", + "integrity": "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==", + "dependencies": { + "punycode": "1.3.2", + "querystring": "0.2.0" + } + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/uuid": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz", + "integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.9.tgz", + "integrity": "sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==", + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/xml2js": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", + "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~9.0.1" + } + }, + "node_modules/xmlbuilder": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", + "integrity": "sha512-7YXTQc3P2l9+0rjaUbLwMKRhtmwg1M1eDf6nag7urC7pIPYLD9W/jmzQ4ptRSUbodw5S0jfoGTflLemQibSpeQ==", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/ylru": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/ylru/-/ylru-1.3.2.tgz", + "integrity": "sha512-RXRJzMiK6U2ye0BlGGZnmpwJDPgakn6aNQ0A7gHRbD4I0uvK4TW6UqkK1V0pp9jskjJBAXd3dRrbzWkqJ+6cxA==", + "engines": { + "node": ">= 4.0.0" + } + } + }, "dependencies": { "accepts": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", - "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "requires": { - "mime-types": "~2.1.24", - "negotiator": "0.6.2" + "mime-types": "~2.1.34", + "negotiator": "0.6.3" } }, - "any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8=" + "available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==" }, "aws-sdk": { - "version": "2.766.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.766.0.tgz", - "integrity": "sha512-msEEF7veBrxU1TlhLL33du4oJdwJ6uAWogf9+S0v437AX2z6K5IFr5J78JBysBudRHO0L6aC8bat6OS3oPEQvQ==", + "version": "2.1255.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1255.0.tgz", + "integrity": "sha512-S3oPXrBVOWquVL1bzH79bz88PgF4GqLcUbIph5yJ+pWW0OKNWGWKW1PDwtWi6ma+8mKXJ1gGKgy6R2hD57AsLw==", "requires": { "buffer": "4.9.2", "events": "1.1.1", "ieee754": "1.1.13", - "jmespath": "0.15.0", + "jmespath": "0.16.0", "querystring": "0.2.0", "sax": "1.2.1", "url": "0.10.3", - "uuid": "3.3.2", + "util": "^0.12.4", + "uuid": "8.0.0", "xml2js": "0.4.19" } }, "base64-js": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", - "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==" + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, "buffer": { "version": "4.9.2", @@ -55,9 +1109,9 @@ "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==" }, "bytes": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", - "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" }, "cache-content-type": { "version": "1.0.1", @@ -68,15 +1122,24 @@ "ylru": "^1.2.0" } }, + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=" + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==" }, "co-body": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/co-body/-/co-body-6.0.0.tgz", - "integrity": "sha512-9ZIcixguuuKIptnY8yemEOuhb71L/lLf+Rl5JfJEUiDNJk0e02MBt7BPxR2GEh5mw8dPthQYR4jPI/BnS1MQgw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/co-body/-/co-body-6.1.0.tgz", + "integrity": "sha512-m7pOT6CdLN7FuXUcpuz/8lfQ/L77x8SchHCF4G0RBTJO20Wzmhn5Sp4/5WsKy8OSpifBSUrmg83qEqaDHdyFuQ==", "requires": { "inflation": "^2.0.0", "qs": "^6.5.2", @@ -85,11 +1148,11 @@ } }, "content-disposition": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", - "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "requires": { - "safe-buffer": "5.1.2" + "safe-buffer": "5.2.1" } }, "content-type": { @@ -104,19 +1167,12 @@ "requires": { "depd": "~2.0.0", "keygrip": "~1.1.0" - }, - "dependencies": { - "depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" - } } }, "copy-to": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/copy-to/-/copy-to-2.0.1.tgz", - "integrity": "sha1-JoD7uAaKSNCGVrYJgJK9r8kG9KU=" + "integrity": "sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w==" }, "cron": { "version": "1.8.2", @@ -127,102 +1183,140 @@ } }, "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "requires": { - "ms": "2.0.0" + "ms": "2.1.2" } }, "deep-equal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", - "integrity": "sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=" + "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==" }, "delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=" + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" }, "depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" }, "destroy": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", - "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" }, "dotenv": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", - "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==" + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", + "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==" }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" }, "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, "events": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", - "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=" + "integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==" + }, + "for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "requires": { + "is-callable": "^1.1.3" + } }, "fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "get-intrinsic": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", + "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==", + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.3" + } + }, + "gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "requires": { + "get-intrinsic": "^1.1.3" + } + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" + }, + "has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "requires": { + "has-symbols": "^1.0.2" + } }, "http-assert": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.4.1.tgz", - "integrity": "sha512-rdw7q6GTlibqVVbXr0CKelfV5iY8G2HqEUkhSk297BMbSpSL8crXC+9rjKoMcZZEsksX30le6f/4ul4E28gegw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", + "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", "requires": { "deep-equal": "~1.0.1", - "http-errors": "~1.7.2" - }, - "dependencies": { - "http-errors": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", - "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", - "requires": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.1.1", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.0" - } - } + "http-errors": "~1.8.0" } }, "http-errors": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.0.tgz", - "integrity": "sha512-4I8r0C5JDhT5VkvI47QktDW75rNlGVsUf/8hzjCC/wkWI/jdTRmBb9aI7erSG82r1bjKY3F6k28WnsVxB1C73A==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", "requires": { "depd": "~1.1.2", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.0" + "toidentifier": "1.0.1" }, "dependencies": { - "setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==" } } }, @@ -242,27 +1336,56 @@ "inflation": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/inflation/-/inflation-2.0.0.tgz", - "integrity": "sha1-i0F+R8KPklpFEz2RTKH9OJEH8w8=" + "integrity": "sha512-m3xv4hJYR2oXw4o4Y5l6P5P16WYmazYof+el6Al3f+YlggGj6qT9kImBAnzDelRALnP5d3h4jGBPKzYCizjZZw==" }, "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "requires": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + } + }, + "is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==" + }, "is-generator-function": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.7.tgz", - "integrity": "sha512-YZc5EwyO4f2kWCax7oegfuSr9mFz1ZvieNYBEjmukLxgXfBUbxAWGVF7GZf0zidYtoBl3WvC07YK0wT76a+Rtw==" + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "requires": { + "has-tostringtag": "^1.0.0" + } + }, + "is-typed-array": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz", + "integrity": "sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==", + "requires": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + } }, "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" }, "jmespath": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", - "integrity": "sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=" + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", + "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==" }, "keygrip": { "version": "1.1.0", @@ -273,18 +1396,18 @@ } }, "koa": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/koa/-/koa-2.13.0.tgz", - "integrity": "sha512-i/XJVOfPw7npbMv67+bOeXr3gPqOAw6uh5wFyNs3QvJ47tUx3M3V9rIE0//WytY42MKz4l/MXKyGkQ2LQTfLUQ==", + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.13.4.tgz", + "integrity": "sha512-43zkIKubNbnrULWlHdN5h1g3SEKXOEzoAlRsHOTFpnlDu8JlAOZSMJBLULusuXRequboiwJcj5vtYXKB3k7+2g==", "requires": { "accepts": "^1.3.5", "cache-content-type": "^1.0.0", "content-disposition": "~0.5.2", "content-type": "^1.0.4", "cookies": "~0.8.0", - "debug": "~3.1.0", + "debug": "^4.3.2", "delegates": "^1.0.0", - "depd": "^1.1.2", + "depd": "^2.0.0", "destroy": "^1.0.4", "encodeurl": "^1.0.2", "escape-html": "^1.0.3", @@ -293,7 +1416,7 @@ "http-errors": "^1.6.3", "is-generator-function": "^1.0.7", "koa-compose": "^4.1.0", - "koa-convert": "^1.2.0", + "koa-convert": "^2.0.0", "on-finished": "^2.3.0", "only": "~0.0.2", "parseurl": "^1.3.2", @@ -317,22 +1440,12 @@ "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==" }, "koa-convert": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-1.2.0.tgz", - "integrity": "sha1-2kCHXfSd4FOQmNFwC1CCDOvNIdA=", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-2.0.0.tgz", + "integrity": "sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==", "requires": { "co": "^4.6.0", - "koa-compose": "^3.0.0" - }, - "dependencies": { - "koa-compose": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-3.2.1.tgz", - "integrity": "sha1-qFzLQLfZhtjlo0Wzoazo6rz1Tec=", - "requires": { - "any-promise": "^1.1.0" - } - } + "koa-compose": "^4.1.0" } }, "koa-router": { @@ -345,78 +1458,68 @@ "koa-compose": "^4.1.0", "methods": "^1.1.2", "path-to-regexp": "^6.1.0" - }, - "dependencies": { - "debug": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz", - "integrity": "sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg==", - "requires": { - "ms": "2.1.2" - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - } } }, "luxon": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.25.0.tgz", - "integrity": "sha512-hEgLurSH8kQRjY6i4YLey+mcKVAWXbDNlZRmM6AgWDJ1cY3atl8Ztf5wEY7VBReFbmGnwQPz7KYJblL8B2k0jQ==" + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.28.0.tgz", + "integrity": "sha512-TfTiyvZhwBYM/7QdAVDh+7dBTBA29v4ik0Ce9zda3Mnf8on1S5KJI8P2jKFZ8+5C0jhmr0KwJEO/Wdpm0VeWJQ==" }, "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" }, "methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" }, "mime-db": { - "version": "1.44.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", - "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==" + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" }, "mime-types": { - "version": "2.1.27", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz", - "integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==", + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "requires": { - "mime-db": "1.44.0" + "mime-db": "1.52.0" } }, "moment": { - "version": "2.29.0", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.0.tgz", - "integrity": "sha512-z6IJ5HXYiuxvFTI6eiQ9dm77uE0gyy1yXNApVHqTcnIKfY9tIwEjlzsZ6u1LQXvVgKeTnv9Xm7NDvJ7lso3MtA==" + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==" }, "moment-timezone": { - "version": "0.5.31", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.31.tgz", - "integrity": "sha512-+GgHNg8xRhMXfEbv81iDtrVeTcWt0kWmTEY1XQK14dICTXnWJnT0dxdlPspwqF3keKMVPXwayEsk1DI0AA/jdA==", + "version": "0.5.39", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.39.tgz", + "integrity": "sha512-hoB6suq4ISDj7BDgctiOy6zljBsdYT0++0ZzZm9rtxIvJhIbQ3nmbgSWe7dNFGurl6/7b1OUkHlmN9JWgXVz7w==", "requires": { "moment": ">= 2.9.0" } }, "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "negotiator": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", - "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" + }, + "object-inspect": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", + "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==" }, "on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "requires": { "ee-first": "1.1.1" } @@ -424,7 +1527,7 @@ "only": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", - "integrity": "sha1-Kv3oTQPlC5qO3EROMGEKcCle37Q=" + "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==" }, "packet-reader": { "version": "1.0.0", @@ -437,29 +1540,28 @@ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" }, "path-to-regexp": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.0.tgz", - "integrity": "sha512-f66KywYG6+43afgE/8j/GoiNyygk/bnoCbps++3ErRKsIYkGGupyv07R2Ok5m9i67Iqc+T2g1eAUGUPzWhYTyg==" + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", + "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==" }, "pg": { - "version": "8.3.3", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.3.3.tgz", - "integrity": "sha512-wmUyoQM/Xzmo62wgOdQAn5tl7u+IA1ZYK7qbuppi+3E+Gj4hlUxVHjInulieWrd0SfHi/ADriTb5ILJ/lsJrSg==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz", + "integrity": "sha512-UXYN0ziKj+AeNNP7VDMwrehpACThH7LUl/p8TDFpEUuSejCUIwGSfxpHsPvtM6/WXFy6SU4E5RG4IJV/TZAGjw==", "requires": { "buffer-writer": "2.0.0", "packet-reader": "1.0.0", - "pg-connection-string": "^2.3.0", - "pg-pool": "^3.2.1", - "pg-protocol": "^1.2.5", + "pg-connection-string": "^2.5.0", + "pg-pool": "^3.5.2", + "pg-protocol": "^1.5.0", "pg-types": "^2.1.0", - "pgpass": "1.x", - "semver": "4.3.2" + "pgpass": "1.x" } }, "pg-connection-string": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.3.0.tgz", - "integrity": "sha512-ukMTJXLI7/hZIwTW7hGMZJ0Lj0S2XQBCJ4Shv4y1zgQ/vqVea+FLhzywvPj0ujSuofu+yA4MYHGZPTsgjBgJ+w==" + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz", + "integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==" }, "pg-int8": { "version": "1.0.1", @@ -467,14 +1569,15 @@ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==" }, "pg-pool": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.2.1.tgz", - "integrity": "sha512-BQDPWUeKenVrMMDN9opfns/kZo4lxmSWhIqo+cSAF7+lfi9ZclQbr9vfnlNaPr8wYF3UYjm5X0yPAhbcgqNOdA==" + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.5.2.tgz", + "integrity": "sha512-His3Fh17Z4eg7oANLob6ZvH8xIVen3phEZh2QuyrIl4dQSDVEabNducv6ysROKpDNPSD+12tONZVWfSgMvDD9w==", + "requires": {} }, "pg-protocol": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.2.5.tgz", - "integrity": "sha512-1uYCckkuTfzz/FCefvavRywkowa6M5FohNMF5OjKrqo9PSR8gYc8poVmwwYQaBxhmQdBjhtP514eXy9/Us2xKg==" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.5.0.tgz", + "integrity": "sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ==" }, "pg-types": { "version": "2.2.0", @@ -489,11 +1592,11 @@ } }, "pgpass": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.2.tgz", - "integrity": "sha1-Knu0G2BltnkH6R2hsHwYR8h3swY=", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", "requires": { - "split": "^1.0.0" + "split2": "^4.1.0" } }, "postgres-array": { @@ -504,7 +1607,7 @@ "postgres-bytea": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", - "integrity": "sha1-AntTPAqokOJtFy1Hz5zOzFIazTU=" + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==" }, "postgres-date": { "version": "1.0.7", @@ -522,47 +1625,55 @@ "punycode": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", - "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" + "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==" }, "qs": { - "version": "6.9.4", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz", - "integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ==" + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "requires": { + "side-channel": "^1.0.4" + } }, "querystring": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", - "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" + "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==" }, "raw-body": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.1.tgz", - "integrity": "sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", "requires": { - "bytes": "3.1.0", - "http-errors": "1.7.3", + "bytes": "3.1.2", + "http-errors": "2.0.0", "iconv-lite": "0.4.24", "unpipe": "1.0.0" }, "dependencies": { "http-errors": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", - "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", "requires": { - "depd": "~1.1.2", + "depd": "2.0.0", "inherits": "2.0.4", - "setprototypeof": "1.1.1", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.0" + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" } + }, + "statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" } } }, "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" }, "safer-buffer": { "version": "2.1.2", @@ -572,40 +1683,37 @@ "sax": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", - "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" - }, - "semver": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.2.tgz", - "integrity": "sha1-x6BxWKgL7dBSNVt3DYLWZA+AO+c=" + "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==" }, "setprototypeof": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", - "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, - "split": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", - "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", + "side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", "requires": { - "through": "2" + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" } }, + "split2": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.1.0.tgz", + "integrity": "sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==" + }, "statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" - }, - "through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==" }, "toidentifier": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", - "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" }, "tsscmp": { "version": "1.0.6", @@ -624,26 +1732,51 @@ "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" }, "url": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", - "integrity": "sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=", + "integrity": "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==", "requires": { "punycode": "1.3.2", "querystring": "0.2.0" } }, + "util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "requires": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "uuid": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", - "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz", + "integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==" }, "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" + }, + "which-typed-array": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.9.tgz", + "integrity": "sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==", + "requires": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0", + "is-typed-array": "^1.1.10" + } }, "xml2js": { "version": "0.4.19", @@ -657,7 +1790,7 @@ "xmlbuilder": { "version": "9.0.7", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", - "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=" + "integrity": "sha512-7YXTQc3P2l9+0rjaUbLwMKRhtmwg1M1eDf6nag7urC7pIPYLD9W/jmzQ4ptRSUbodw5S0jfoGTflLemQibSpeQ==" }, "xtend": { "version": "4.0.2", @@ -665,9 +1798,9 @@ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" }, "ylru": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ylru/-/ylru-1.2.1.tgz", - "integrity": "sha512-faQrqNMzcPCHGVC2aaOINk13K+aaBDUPjGWl0teOXywElLjyVAB6Oe2jj62jHYtwsU49jXhScYbvPENK+6zAvQ==" + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/ylru/-/ylru-1.3.2.tgz", + "integrity": "sha512-RXRJzMiK6U2ye0BlGGZnmpwJDPgakn6aNQ0A7gHRbD4I0uvK4TW6UqkK1V0pp9jskjJBAXd3dRrbzWkqJ+6cxA==" } } } From 03da933439522f46a7d2170c42ce2e3b2140a12a Mon Sep 17 00:00:00 2001 From: Simeon J Morgan Date: Sat, 19 Nov 2022 13:24:09 +1100 Subject: [PATCH 03/13] Add migration Add code to migrate to a new database structure that will support multiple teams. Hopefully --- .gitignore | 3 + src/index.js | 274 ++++++++++++++++++++++++++++++------------------- src/queries.js | 190 ++++++++++++++++++++++++++++++++++ 3 files changed, 360 insertions(+), 107 deletions(-) create mode 100644 src/queries.js diff --git a/.gitignore b/.gitignore index 65d21b1..8294e7c 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,6 @@ node_modules/ # Local docker volume mounts .volumes + +# Editor folders +.vscode diff --git a/src/index.js b/src/index.js index 09df763..97c263c 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,5 @@ require("dotenv").config(); +require("./queries"); const AUTH_KEY = process.env.AUTH_KEY; const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID; const AWS_SECRET_KEY = process.env.AWS_SECRET_KEY; @@ -10,12 +11,15 @@ const MAX_COFFEE_ADD = 5; const MAX_COFFEE_SUBTRACT = 2; const COUNT_DISPLAY_SIZE = 5; +TARGET_MIGRATION_LEVEL = 1; + const Koa = require("koa"); const Router = require("koa-router"); const bodyParser = require("koa-bodyparser"); const { DateTime } = require("luxon"); const { Pool } = require("pg"); const AWS = require("aws-sdk"); +const queries = require("./queries"); const CronJob = require("cron").CronJob; const app = new Koa(); @@ -33,7 +37,7 @@ const pool = new Pool({ new CronJob( "00 00 02 * * *", - async function() { + async function () { await createBackup(); }, null, @@ -70,71 +74,115 @@ an experiment in using firebase. Somehow, it has continued to be used since then } } +async function runMigrations(userId, userName, teamId, teamDomain) { + /*** + * Run migrations on the database if required. This can be run by any user (which is a bit + * dubious, but the command also isn't listed anywhere and should be idempotent so 🤷 gotta + * start somewhere) + * + * @param {string} userId - the slack user_id issuing the migrate command + * @param {string} userName - the slack user name issuing the migrate command + * @param {string} teamId - the workspace team_id from which the migrate command is being issued + * @param {string} teamDomain - the workspace team_domain from which the migrate command is being issued + */ -// CREATE_DATABASE_QUERY = "CREATE DATABASE drinks ENCODING = 'UTF8'"; -// CHECK_IF_DATABASE_EXISTS_QUERY = "SELECT datname FROM pg_catalog.pg_database WHERE datname = drinks;" -CREATE_BACKUP_TABLE_QUERY = ` -CREATE TABLE IF NOT EXISTS public.backups -( - id bigserial NOT NULL, - created_at timestamp with time zone NOT NULL, - backup_until timestamp with time zone NOT NULL, - successful BOOLEAN NOT NULL, - message TEXT, - PRIMARY KEY (id) -); -`; -GET_LAST_SUCCESSFUL_BACKUP_DATETIME_QUERY = - "SELECT backup_until FROM public.backups WHERE successful = TRUE ORDER BY backup_until DESC LIMIT 1"; -CREATE_BACKUP_ROW_QUERY = - "INSERT INTO public.backups (created_at, backup_until, successful, message) VALUES ($1, $2, $3, $4)"; - -CREATE_DRINK_TABLE_QUERY = ` -CREATE TABLE IF NOT EXISTS public.coffee -( - id bigserial NOT NULL, - user_id character varying(50) NOT NULL, - user_name character varying(200), - created_at timestamp with time zone NOT NULL, - PRIMARY KEY (id) -); -`; - -GET_DRINK_QUERY = - "SELECT user_id, user_name, created_at FROM coffee WHERE user_id = $1 AND user_name = $2 AND created_at = $3"; -ADD_DRINK_QUERY = "INSERT INTO coffee (user_id, user_name, created_at) VALUES($1, $2, $3)"; -COUNT_ALL_DRINKS_QUERY = "SELECT COUNT(*) FROM coffee WHERE created_at > $1 AND created_at < $2"; -COUNT_USER_DRINKS_QUERY = "SELECT COUNT(*) FROM coffee WHERE user_id = $1 AND created_at > $2 AND created_at < $3"; -COUNT_ALL_DRINKS_EVER_QUERY = "SELECT COUNT(*) FROM coffee" -AVERAGE_USER_DRINKS_EVER_QUERY = ` -SELECT user_name, COUNT(coffees_on_day) AS reporting_days, SUM(coffees_on_day) AS total_coffees, AVG(coffees_on_day) AS avg_coffees_per_day, STDDEV(coffees_on_day) AS stddev_coffees_per_day -FROM ( - SELECT user_name, COUNT(*) AS coffees_on_day - FROM coffee - GROUP BY user_name, date(created_at AT TIME ZONE 'Australia/Melbourne') -) AS coffees_per_day -GROUP BY user_name -ORDER BY avg_coffees_per_day DESC`; -TALLY_ALL_DRINKS_QUERY = - "SELECT user_name, COUNT(*) AS drink_count FROM coffee WHERE created_at > $1 AND created_at < $2 GROUP BY user_name ORDER BY drink_count DESC"; -DELETE_N_MOST_RECENT_DRINKS_FOR_USER_QUERY = - "DELETE FROM coffee WHERE id IN (SELECT id FROM coffee WHERE user_id = $1 AND created_at > $2 AND created_at < $3 ORDER BY id DESC LIMIT $4)"; -ALL_DRINKS_SINCE_DATETIME_QUERY = "SELECT id, user_id, user_name, created_at FROM coffee WHERE created_at > $1"; -ALL_DRINKS_QUERY = "SELECT id, user_id, user_name, created_at FROM coffee"; + const client = await pool.connect(); + + try { + await client.query(queries.BEGIN); + const dt = DateTime.local().setZone("Australia/Melbourne"); + const getCurrentMigrationLevelQuery = await client.query(queries.GET_MIGRATION_LEVEL); + + // If there are no migration rows, the max value is null + let currentMigrationLevel = getCurrentMigrationLevelQuery.rows[0].migration_level; + console.log(`Current migration level: ${currentMigrationLevel}`); + + if (currentMigrationLevel === null) { + console.log(`Applying migration level 1`); + await client.query(queries.CREATE_ABSTRACT_USER_TABLE_V1_QUERY); + await client.query(queries.CREATE_TEAM_TABLE_V1_QUERY); + await client.query(queries.CREATE_TEAM_USER_TABLE_V1_QUERY); + await client.query(queries.CREATE_ABSTRACT_USER_DRINK_TABLE_V1_QUERY); + + // Now to migrate the data. + // There is an assumption here that all the current data comes from + // the workspace from which the migration is being run. + // If that isn't the case... well, just make sure it is the case OK? + // This could no doubt be implemented directly in SQL, but that's a + // future thing to think about + + // Create the team record + insertTeamQuery = await client.query(queries.INSERT_OR_GET_TEAM_V1_QUERY, [dt.toISO(), teamId, teamDomain]); + dbTeamId = insertTeamQuery.rows[0].id + + // Get a list of distinct users from the drinks table + getDistinctUsersQuery = await client.query(queries.MIGRATION_V1_GET_DISTINCT_USERS_QUERY); + + for (row of getDistinctUsersQuery.rows) { + // Create an abstract user and get back the identifier + const insertAbstractUserQuery = await client.query(queries.INSERT_ABSTRACT_USER_V1_QUERY, [dt.toISO()]); + const dbAbstractUserId = insertAbstractUserQuery.rows[0].id + + // Create a user record for each distinct user from the drinks table + // on the current team_id pointing to the abstract user and team record + await client.query(queries.INSERT_USER_V1_QUERY, [dt.toISO(), row.user_id, row.user_name, dbTeamId, dbAbstractUserId]) + } + + // Once that has been done for all users, run a single SQL command + // to migrate all the drinks that have been recorded + await client.query(queries.MIGRATION_V1_COPY_DRINKS) + // Step 3 ... Profit? + await client.query(queries.MIGRATION_V1_SET_MIGRATION_LEVEL, [1, dt.toISO()]); + await client.query(queries.COMMIT); + console.log(`Migration level 1 applied successfully`); + } + // Add additional migrations here + console.log(`All necessary migrations applied successfully`); + return { + response_type: "ephemeral", + text: `Migrations ran successfully`, + }; + } catch (e) { + await client.query(queries.ROLLBACK); + console.log(`Migrations failed to apply: ${e}`); + return { + response_type: "ephemeral", + text: `Migrations failed to apply`, + }; + } finally { + await client.release(); + } +} + +async function areMigrationsPending() { + /** + * Check if any migrations are pending, and return an error if so + */ + const client = await pool.connect(); + try { + const getCurrentMigrationLevelQuery = await client.query(queries.GET_MIGRATION_LEVEL); + + // If there are no migration rows, the max value is null + const currentMigrationLevel = getCurrentMigrationLevelQuery.rows[0].migration_level; + return currentMigrationLevel === null || currentMigrationLevel < TARGET_MIGRATION_LEVEL; + } finally { + await client.release(); + } +} async function createBackup() { const client = await pool.connect(); try { const dt = DateTime.local().setZone("Australia/Melbourne"); - const getLastSuccessfulBackupQuery = await client.query(GET_LAST_SUCCESSFUL_BACKUP_DATETIME_QUERY); + const getLastSuccessfulBackupQuery = await client.query(queries.GET_LAST_SUCCESSFUL_BACKUP_DATETIME_QUERY); let backupFromDate = DateTime.fromSeconds(0); if (getLastSuccessfulBackupQuery.rows.length > 0) { backupFromDate = DateTime.fromJSDate(getLastSuccessfulBackupQuery.rows[0].backup_until); } - const getAllDrinksSinceDatetimeQuery = await client.query(ALL_DRINKS_SINCE_DATETIME_QUERY, [ + const getAllDrinksSinceDatetimeQuery = await client.query(queries.ALL_DRINKS_SINCE_DATETIME_QUERY, [ backupFromDate.toISO(), ]); allDrinksSinceDatetime = getAllDrinksSinceDatetimeQuery.rows; @@ -179,13 +227,13 @@ async function createBackup() { try { await s3.upload(params).promise(); - await client.query(CREATE_BACKUP_ROW_QUERY, [dt.toISO(), maxDate.toISO(), true, ""]); + await client.query(queries.CREATE_BACKUP_ROW_QUERY, [dt.toISO(), maxDate.toISO(), true, ""]); return { response_type: "ephemeral", text: `${allDrinksSinceDatetime.length} entries backed up. Filename: ${params.Key}.`, }; } catch (err) { - await client.query(CREATE_BACKUP_ROW_QUERY, [dt.toISO(), maxDate.toISO(), false, err]); + await client.query(queries.CREATE_BACKUP_ROW_QUERY, [dt.toISO(), maxDate.toISO(), false, err]); return { response_type: "ephemeral", text: `Backup error: ${err}` }; } } finally { @@ -198,7 +246,7 @@ async function createFullBackup() { try { const dt = DateTime.local().setZone("Australia/Melbourne"); - const getAllDrinksQuery = await client.query(ALL_DRINKS_QUERY); + const getAllDrinksQuery = await client.query(queries.ALL_DRINKS_QUERY); allDrinks = getAllDrinksQuery.rows; if (allDrinks.length === 0) { @@ -247,9 +295,11 @@ async function createDatabaseBitsIfMissing() { const client = await pool.connect(); try { console.log("Attempting to create drink table"); - await client.query(CREATE_DRINK_TABLE_QUERY); + await client.query(queries.CREATE_DRINK_TABLE_QUERY); console.log("Attempting to create backup table"); - await client.query(CREATE_BACKUP_TABLE_QUERY); + await client.query(queries.CREATE_BACKUP_TABLE_QUERY); + console.log("Attempting to create migrations table"); + await client.query(queries.CREATE_MIGRATION_TABLE_QUERY); console.log("All table creation complete"); } finally { await client.release(); @@ -260,10 +310,10 @@ async function showCoffeeStats() { const client = await pool.connect(); try { - const totalCoffeeCountQuery = await client.query(COUNT_ALL_DRINKS_EVER_QUERY); + const totalCoffeeCountQuery = await client.query(queries.COUNT_ALL_DRINKS_EVER_QUERY); const totalCoffeeCount = totalCoffeeCountQuery.rows[0].count; - const coffeeCountByUserQuery = await client.query(AVERAGE_USER_DRINKS_EVER_QUERY); + const coffeeCountByUserQuery = await client.query(queries.AVERAGE_USER_DRINKS_EVER_QUERY); let blocks = []; let textChunks = []; @@ -313,13 +363,13 @@ async function showCoffeeCount(numOfItems) { }); const start_of_tomorrow = dt.plus({ days: 1 }).set({ hour: 0, minute: 0, second: 0, millisecond: 0 }); - const totalCoffeeCountQuery = await client.query(COUNT_ALL_DRINKS_QUERY, [ + const totalCoffeeCountQuery = await client.query(queries.COUNT_ALL_DRINKS_QUERY, [ start_of_today.toISO(), start_of_tomorrow.toISO(), ]); const totalCoffeeCount = totalCoffeeCountQuery.rows[0].count; - const coffeeCountByUserQuery = await client.query(TALLY_ALL_DRINKS_QUERY, [ + const coffeeCountByUserQuery = await client.query(queries.TALLY_ALL_DRINKS_QUERY, [ start_of_today.toISO(), start_of_tomorrow.toISO(), ]); @@ -364,7 +414,30 @@ async function showCoffeeCount(numOfItems) { } } -async function addCoffee(userId, userName, inc) { +async function getOrCreateTeam(teamId, teamDomain) { + /** + * Create a 'team' record if one doesn't already exist for the team + * and return the database ID for the team (not the slack team_id) + */ + const client = await pool.connect(); + + try { + const dt = DateTime.local().setZone("Australia/Melbourne"); + const getOrCreateTeamQuery = client.query(queries.INSERT_OR_GET_TEAM_V1_QUERY, [to.toISO(), teamId, teamDomain]); + return getOrCreateTeamQuery.rows[0].id + } finally { + client.release(); + } +} + + + + +async function getOrCreateUser(userId, userName, dbTeamId) { + +} + +async function addCoffee(userId, userName, teamId, teamDomain, inc) { if (inc > MAX_COFFEE_ADD) { return { response_type: "ephemeral", @@ -392,10 +465,10 @@ async function addCoffee(userId, userName, inc) { if (inc > 0) { for (let idx = 0; idx < inc; idx++) { - await client.query(ADD_DRINK_QUERY, [userId, userName, dt.toISO()]); + await client.query(queries.ADD_DRINK_QUERY, [userId, userName, dt.toISO()]); } } else if (inc < 0) { - await client.query(DELETE_N_MOST_RECENT_DRINKS_FOR_USER_QUERY, [ + await client.query(queries.DELETE_N_MOST_RECENT_DRINKS_FOR_USER_QUERY, [ userId, start_of_today.toISO(), start_of_tomorrow.toISO(), @@ -403,12 +476,12 @@ async function addCoffee(userId, userName, inc) { ]); } - const totalCoffeeCountQuery = await client.query(COUNT_ALL_DRINKS_QUERY, [ + const totalCoffeeCountQuery = await client.query(queries.COUNT_ALL_DRINKS_QUERY, [ start_of_today.toISO(), start_of_tomorrow.toISO(), ]); const totalCoffeeCount = totalCoffeeCountQuery.rows[0].count; - const userCoffeeCountQuery = await client.query(COUNT_USER_DRINKS_QUERY, [ + const userCoffeeCountQuery = await client.query(queries.COUNT_USER_DRINKS_QUERY, [ userId, start_of_today.toISO(), start_of_tomorrow.toISO(), @@ -424,67 +497,52 @@ async function addCoffee(userId, userName, inc) { } } -PERMISSION_IMPORT = "IMPORT"; PERMISSION_SLACKACTION = "SLACK_ACTION"; async function hasPermission(ctx, action) { + /** + * Check that the current user has the specified permission, + * except that right now the 'permission' is ignored and it + * is just a dumb key check + */ return ctx.request.query.key === AUTH_KEY; } -router.post("/importFromFirebaseData", async (ctx, next) => { - if (!hasPermission(ctx, PERMISSION_IMPORT)) { +router.post("/addCoffee", async (ctx, next) => { + // The permissions are a lie - whatever action you + // pass it does the same thing + if (!hasPermission(ctx, PERMISSION_SLACKACTION)) { ctx.body = { result: "nope" }; return; } - if (ctx.request.body) { - let added = 0; - let existing = 0; - const client = await pool.connect(); - try { - await Promise.all( - ctx.request.body.map(async (dayData) => { - if (!dayData.timestamp) { - return; - } - await Promise.all( - dayData.coffeeTimes.map(async (row) => { - const checkIfExistsQuery = await client.query(GET_DRINK_QUERY, [ - row.user_id, - row.user_name, - row.timestamp, - ]); - if (checkIfExistsQuery.rows.length === 0) { - added++; - await client.query(ADD_DRINK_QUERY, [row.user_id, row.user_name, row.timestamp]); - } else { - existing++; - } - }) - ); - }) - ); - ctx.body = { added: added, existing: existing }; - } finally { - await client.release(); - } + if (ctx.request.body.command !== "/coffee") { + ctx.body = { + response_type: "ephemeral", + text: "Something has gone horribly wrong", + }; + return; } -}); -router.post("/addCoffee", async (ctx, next) => { - if (!hasPermission(ctx, PERMISSION_IMPORT)) { - ctx.body = { result: "nope" }; + if (ctx.request.body.text === "migrate") { + ctx.body = await runMigrations( + ctx.request.body.user_id, + ctx.request.body.user_name, + ctx.request.body.team_id, + ctx.request.body.team_domain, + ) return; } - if (ctx.request.body.command !== "/coffee") { + if (await areMigrationsPending()) { ctx.body = { response_type: "ephemeral", - text: "Something has gone horribly wrong", + text: "Migrations must be run before continuing", }; return; } + if (ctx.request.body.text === "help") { ctx.body = showHelp(); return; @@ -507,6 +565,8 @@ router.post("/addCoffee", async (ctx, next) => { ctx.body = await addCoffee( ctx.request.body.user_id, ctx.request.body.user_name, + ctx.request.body.team_id, + ctx.request.body.team_domain, parseInt(ctx.request.body.text, 10) ); return; diff --git a/src/queries.js b/src/queries.js new file mode 100644 index 0000000..2d3022d --- /dev/null +++ b/src/queries.js @@ -0,0 +1,190 @@ +module.exports = { + BEGIN: 'BEGIN;', + ROLLBACK: 'ROLLBACK', + COMMIT: 'COMMIT', + CREATE_ABSTRACT_USER_TABLE_V1_QUERY: ` + CREATE TABLE IF NOT EXISTS public.abstract_user_v1 + ( + id bigserial NOT NULL, + created_at timestamp with time zone NOT NULL, + PRIMARY KEY (id) + );`, + + CREATE_TEAM_TABLE_V1_QUERY: ` + CREATE TABLE IF NOT EXISTS public.team_v1 + ( + id bigserial NOT NULL, + created_at timestamp with time zone NOT NULL, + -- team_id is the slack team id + team_id character varying(50) NOT NULL, + team_domain character varying(200) NOT NULL, + -- label is an optional label for the team + -- to allow a particular header to be used on the + -- output. Not currently implemented. + label character varying(200), + PRIMARY KEY (id), + CONSTRAINT team_v1_team_id_unique UNIQUE (team_id) + );`, + + CREATE_TEAM_USER_TABLE_V1_QUERY: ` + CREATE TABLE IF NOT EXISTS public.user_v1 + ( + id bigserial NOT NULL, + created_at timestamp with time zone NOT NULL, + user_id character varying(50) NOT NULL, + user_name character varying(200) NOT NULL, + -- label is an optional label for the user to be used + -- instead of the slack name. Not currently implemented. + label character varying(200), + team_id bigint NOT NULL, + abstract_user_id bigint NOT NULL, + PRIMARY KEY (id), + CONSTRAINT user_v1_user_fk_team FOREIGN KEY(team_id) REFERENCES public.team_v1(id), + CONSTRAINT user_v1_user_fk_abstract_user FOREIGN KEY(abstract_user_id) REFERENCES public.abstract_user_v1(id), + CONSTRAINT user_v1_user_id_team_id_unique UNIQUE (user_id, team_id) + ); + CREATE INDEX user_v1_idx_user_id_team_id ON public.user_v1(user_id, team_id); + `, + + // This includes a `drink` column but it defaults + // to coffee and right now there is no way to log + // anything but a coffee. + // That's not necessarily because coffee is the superior + // drink, Jon. That's not what I'm saying. + CREATE_ABSTRACT_USER_DRINK_TABLE_V1_QUERY: ` + CREATE TABLE IF NOT EXISTS public.drink_v1 + ( + id bigserial NOT NULL, + created_at timestamp with time zone NOT NULL, + abstract_user_id bigint NOT NULL, + -- having both user_id and abstract_user_id is kind of + -- redundant, but I wasn't sure if it would be desirable + -- to link drinks back to the specific team at some point + user_id bigint NOT NULL, + drink character varying(20) DEFAULT 'coffee', + PRIMARY KEY (id), + CONSTRAINT coffee_v1_user_fk_abstract_user FOREIGN KEY(abstract_user_id) REFERENCES public.abstract_user_v1(id), + CONSTRAINT coffee_v1_user_fk_user FOREIGN KEY(user_id) REFERENCES public.user_v1(id) + ); + CREATE INDEX coffee_v1_idx_created_at ON public.drink_v1(created_at); + CREATE INDEX coffee_v1_idx_abstract_user_id ON public.drink_v1(abstract_user_id); + `, + + CREATE_MIGRATION_TABLE_QUERY: ` + CREATE TABLE IF NOT EXISTS public.migrations + ( + id int NOT NULL, + run_at timestamp with time zone NOT NULL, + PRIMARY KEY(id) + );`, + + GET_MIGRATION_LEVEL: "SELECT MAX(id) migration_level FROM public.migrations;", + + INSERT_ABSTRACT_USER_V1_QUERY: "INSERT INTO public.abstract_user_v1 (created_at) VALUES ($1) RETURNING id;", + INSERT_USER_V1_QUERY: ` + INSERT INTO public.user_v1 (created_at, user_id, user_name, team_id, abstract_user_id) + VALUES ($1, $2, $3, $4, $5) RETURNING id; + `, + + // Migration V1 functions + MIGRATION_V1_GET_DISTINCT_USERS_QUERY: "SELECT DISTINCT user_id, user_name FROM public.coffee;", + MIGRATION_V1_COPY_DRINKS: ` + INSERT INTO public.drink_v1 (created_at, abstract_user_id, user_id) + SELECT c.created_at, u1.abstract_user_id, u1.id + FROM public.coffee c + INNER JOIN public.user_v1 u1 ON u1.user_id = c.user_id + `, + + MIGRATION_V1_SET_MIGRATION_LEVEL: "INSERT INTO public.migrations (id, run_at) VALUES ($1, $2);", + + GET_ABSTRACT_USER_GIVEN_USER_ID_TEAM_ID_QUERY: ` + SELECT au1.id + FROM public.user_v1 u1 + INNER JOIN public.abstract_user_v1 au1 ON u1.abstract_user_id = au1.id + WHERE u1.user_id = $1 AND u1.team_id = $2 + `, + + INSERT_OR_GET_TEAM_V1_QUERY: ` + WITH insert_attempt AS ( + INSERT INTO public.team_v1 (created_at, team_id, team_domain) + VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING + RETURNING id + ) + SELECT id FROM insert_attempt + UNION + SELECT id FROM public.team_v1 WHERE team_id = $2 + `, + + + + /** + Design thoughts: + - if I link drinks -> user -> abstract user, then finding all drinks for user + means 'find user, then find abstract id, then find all other users with same abstract id, + the get all drinks for them`. + If using the abstract user id: + SELECT u1.user_name, count(*) + FROM user_v1 u1 + INNER JOIN drink_v1 d1 ON d1.abstract_user_id = u1.abstract_user_id + INNER JOIN team_v1 t1 ON t1.id = u1.team_id + WHERE t1.team_id = 'value from slack' + */ + + + + // CREATE_DATABASE_QUERY : "CREATE DATABASE drinks ENCODING = 'UTF8'", + // CHECK_IF_DATABASE_EXISTS_QUERY : "SELECT datname FROM pg_catalog.pg_database WHERE datname = drinks;" + CREATE_BACKUP_TABLE_QUERY: ` + CREATE TABLE IF NOT EXISTS public.backups + ( + id bigserial NOT NULL, + created_at timestamp with time zone NOT NULL, + backup_until timestamp with time zone NOT NULL, + successful BOOLEAN NOT NULL, + message TEXT, + PRIMARY KEY (id) + );`, + GET_LAST_SUCCESSFUL_BACKUP_DATETIME_QUERY: "SELECT backup_until FROM public.backups WHERE successful = TRUE ORDER BY backup_until DESC LIMIT 1", + CREATE_BACKUP_ROW_QUERY: "INSERT INTO public.backups (created_at, backup_until, successful, message) VALUES ($1, $2, $3, $4)", + + CREATE_DRINK_TABLE_QUERY: ` + CREATE TABLE IF NOT EXISTS public.coffee + ( + id bigserial NOT NULL, + user_id character varying(50) NOT NULL, + user_name character varying(200), + created_at timestamp with time zone NOT NULL, + PRIMARY KEY (id) + );`, + + ADD_DRINK_QUERY: "INSERT INTO coffee (user_id, user_name, created_at) VALUES($1, $2, $3);", + COUNT_ALL_DRINKS_QUERY: "SELECT COUNT(*) FROM coffee WHERE created_at > $1 AND created_at < $2;", + COUNT_USER_DRINKS_QUERY: "SELECT COUNT(*) FROM coffee WHERE user_id = $1 AND created_at > $2 AND created_at < $3;", + COUNT_ALL_DRINKS_EVER_QUERY: "SELECT COUNT(*) FROM coffee;", + AVERAGE_USER_DRINKS_EVER_QUERY: ` + SELECT user_name, COUNT(coffees_on_day) AS reporting_days, SUM(coffees_on_day) AS total_coffees, AVG(coffees_on_day) AS avg_coffees_per_day, STDDEV(coffees_on_day) AS stddev_coffees_per_day + FROM ( + SELECT user_name, COUNT(*) AS coffees_on_day + FROM coffee + GROUP BY user_name, date(created_at AT TIME ZONE 'Australia/Melbourne') + ) AS coffees_per_day + GROUP BY user_name + ORDER BY avg_coffees_per_day DESC;`, + TALLY_ALL_DRINKS_QUERY: "SELECT user_name, COUNT(*) AS drink_count FROM coffee WHERE created_at > $1 AND created_at < $2 GROUP BY user_name ORDER BY drink_count DESC;", + DELETE_N_MOST_RECENT_DRINKS_FOR_USER_QUERY: "DELETE FROM coffee WHERE id IN (SELECT id FROM coffee WHERE user_id = $1 AND created_at > $2 AND created_at < $3 ORDER BY id DESC LIMIT $4);", + ALL_DRINKS_SINCE_DATETIME_QUERY: "SELECT id, user_id, user_name, created_at FROM coffee WHERE created_at > $1;", + ALL_DRINKS_QUERY: "SELECT id, user_id, user_name, created_at FROM coffee;", + GET_USER_V1_QUERY: `SELECT abstract_user_id FROM user_v1 WHERE user_id = $1 AND team_id = $2;`, + INSERT_OR_GET_USER_V1_QUERY: ` + WITH insert_attempt AS ( + INSERT INTO user_v1 (created_at, user_id, user_name, team_id, ) + VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING + RETURNING id + ) + SELECT id FROM insert_attempt + UNION + SELECT id FROM team_v1 WHERE team_id = $2; + `, +} \ No newline at end of file From 63bf08260d5d8bbe0f45fb3ecf3835ea52cf8500 Mon Sep 17 00:00:00 2001 From: Simeon J Morgan Date: Sun, 20 Nov 2022 00:01:36 +1100 Subject: [PATCH 04/13] Add backup, linking Updates backup output to include all core tables with the new structure. Add support for linking users between workspaces so they can log on any workspace and it will appear on all others --- src/backup.js | 129 + src/example.env | 6 +- src/index.js | 619 ++-- src/migration.js | 115 + src/queries.js | 271 +- src/wordlist.js | 7796 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 8576 insertions(+), 360 deletions(-) create mode 100644 src/backup.js create mode 100644 src/migration.js create mode 100644 src/wordlist.js diff --git a/src/backup.js b/src/backup.js new file mode 100644 index 0000000..6724429 --- /dev/null +++ b/src/backup.js @@ -0,0 +1,129 @@ +const queries = require("./queries"); +const { DateTime } = require("luxon"); +const AWS = require("aws-sdk"); +const { LexModelBuildingService } = require("aws-sdk"); + +BACKUP_SINCE_QUERIES = [ + { tableName: "abstract_user_v2", query: queries.BACKUP_ABSTRACT_USER_SINCE_DATE_V2_QUERY }, + { tableName: "team_v2", query: queries.BACKUP_TEAM_SINCE_DATE_V2_QUERY }, + { tableName: "user_v2", query: queries.BACKUP_USER_SINCE_DATE_V2_QUERY }, + { tableName: "drink_v2", query: queries.BACKUP_DRINK_SINCE_DATE_V2_QUERY }, +] + +BACKUP_FULL_QUERIES = [ + { tableName: "abstract_user_v2", query: queries.BACKUP_ABSTRACT_USER_ALL_V2_QUERY }, + { tableName: "team_v2", query: queries.BACKUP_TEAM_ALL_V2_QUERY }, + { tableName: "user_v2", query: queries.BACKUP_USER_ALL_V2_QUERY }, + { tableName: "drink_v2", query: queries.BACKUP_DRINK_ALL_V2_QUERY },] + +async function createBackup(pool, awsDetails) { + console.log("Commencing incremental backup"); + const client = await pool.connect(); + + try { + const dt = DateTime.local().setZone("Australia/Melbourne"); + const getLastSuccessfulBackupQuery = await client.query(queries.GET_LAST_SUCCESSFUL_BACKUP_DATETIME_QUERY); + + let backupFromDate = DateTime.fromSeconds(0); + if (getLastSuccessfulBackupQuery.rows.length > 0) { + backupFromDate = DateTime.fromJSDate( + getLastSuccessfulBackupQuery.rows[0].backup_until + ); + } + + const rowsToBackUp = Array(); + + for ({ tableName, query } of BACKUP_SINCE_QUERIES) { + const queryResult = await client.query(query, [ + backupFromDate.toISO(), + ]); + + for (row of queryResult.rows) { + rowsToBackUp.push(JSON.stringify({ tableName: tableName, ...row })) + } + } + + const s3 = new AWS.S3({ + accessKeyId: awsDetails.AWS_ACCESS_KEY_ID, + secretAccessKey: awsDetails.AWS_SECRET_KEY, + region: awsDetails.AWS_REGION, + }); + + const params = { + Bucket: awsDetails.AWS_BUCKET_NAME, + Key: `${awsDetails.AWS_BACKUP_FOLDER}/${dt.toISO()}.v2.rows.incremental.json`, + Body: rowsToBackUp.join("\n"), + }; + + try { + await s3.upload(params).promise(); + await client.query(queries.CREATE_BACKUP_ROW_QUERY, [dt.toISO(), dt.toISO(), true, ""]); + const message = `${rowsToBackUp.length} rows backed up. Filename: ${params.Key}.`; + console.log(message); + return { + response_type: "ephemeral", + text: message, + }; + } catch (err) { + await client.query(queries.CREATE_BACKUP_ROW_QUERY, [dt.toISO(), dt.toISO(), false, err]); + const message = `Incremental backup error: ${err}`; + console.log(message); + return { response_type: "ephemeral", text: message }; + } + } finally { + await client.release(); + } +} + +async function createFullBackup(pool, awsDetails) { + console.log("Commencing full backup"); + const client = await pool.connect(); + + try { + const dt = DateTime.local().setZone("Australia/Melbourne"); + const rowsToBackUp = Array(); + + for ({ tableName, query } of BACKUP_FULL_QUERIES) { + const queryResult = await client.query(query); + + for (row of queryResult.rows) { + rowsToBackUp.push(JSON.stringify({ tableName: tableName, ...row })) + } + } + + const s3 = new AWS.S3({ + accessKeyId: awsDetails.AWS_ACCESS_KEY_ID, + secretAccessKey: awsDetails.AWS_SECRET_KEY, + region: awsDetails.AWS_REGION, + }); + + const params = { + Bucket: awsDetails.AWS_BUCKET_NAME, + Key: `${awsDetails.AWS_BACKUP_FOLDER}/${dt.toISO()}.v2.rows.full.json`, + Body: rowsToBackUp.join("\n"), + }; + + try { + await s3.upload(params).promise(); + await client.query(queries.CREATE_BACKUP_ROW_QUERY, [dt.toISO(), dt.toISO(), true, ""]); + const message = `${rowsToBackUp.length} rows backed up. Filename: ${params.Key}.`; + console.log(message); + return { + response_type: "ephemeral", + text: message, + }; + } catch (err) { + await client.query(queries.CREATE_BACKUP_ROW_QUERY, [dt.toISO(), dt.toISO(), false, err]); + const message = `Full backup error: ${err}`; + console.log(message); + return { response_type: "ephemeral", text: message }; + } + } finally { + await client.release(); + } +} + +module.exports = { + createBackup, + createFullBackup, +} \ No newline at end of file diff --git a/src/example.env b/src/example.env index 1462eef..00ce6db 100644 --- a/src/example.env +++ b/src/example.env @@ -1,4 +1,5 @@ -AUTH_KEY="qYe4VZgQMhsAzxqJzmFCJjZi4tVsznq2ogcN3HTm" +AUTH_KEY= +ADMIN_KEY= POSTGRES_HOST="db" POSTGRES_USER="coffeebot" @@ -11,3 +12,6 @@ AWS_SECRET_KEY= AWS_BUCKET_NAME="coffeebot-backups" AWS_BACKUP_FOLDER="coffeebot-backups" AWS_REGION= + +REQUEST_PASSTHROUGH_HOST="127.0.0.1" +REQUEST_PASSTHROUGH_PORT="80" \ No newline at end of file diff --git a/src/index.js b/src/index.js index 97c263c..146d297 100644 --- a/src/index.js +++ b/src/index.js @@ -1,27 +1,45 @@ require("dotenv").config(); -require("./queries"); + const AUTH_KEY = process.env.AUTH_KEY; -const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID; -const AWS_SECRET_KEY = process.env.AWS_SECRET_KEY; -const AWS_BUCKET_NAME = process.env.AWS_BUCKET_NAME; -const AWS_BACKUP_FOLDER = process.env.AWS_BACKUP_FOLDER; -const AWS_REGION = process.env.AWS_REGION; +const ADMIN_KEY = process.env.ADMIN_KEY; + +const awsDetails = { + AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID, + AWS_SECRET_KEY: process.env.AWS_SECRET_KEY, + AWS_BUCKET_NAME: process.env.AWS_BUCKET_NAME, + AWS_BACKUP_FOLDER: process.env.AWS_BACKUP_FOLDER, + AWS_REGION: process.env.AWS_REGION, +} + +const REQUEST_PASSTHROUGH_HOST = process.env.REQUEST_PASSTHROUGH_HOST; +const REQUEST_PASSTHROUGH_PORT = process.env.REQUEST_PASSTHROUGH_PORT; + +const GENERIC_FAILURE_RESPONSE = { + response_type: "ephemeral", + text: "I'm afraid I don't understand your command. Take another sip and try again.", +}; const MAX_COFFEE_ADD = 5; const MAX_COFFEE_SUBTRACT = 2; const COUNT_DISPLAY_SIZE = 5; -TARGET_MIGRATION_LEVEL = 1; +TARGET_MIGRATION_LEVEL = 2; const Koa = require("koa"); const Router = require("koa-router"); const bodyParser = require("koa-bodyparser"); const { DateTime } = require("luxon"); const { Pool } = require("pg"); -const AWS = require("aws-sdk"); -const queries = require("./queries"); const CronJob = require("cron").CronJob; +// http is used to forward through the incoming requests +// to the old coffeebot version in case rollback is needed +const http = require('http'); + +const queries = require("./queries"); +const migration = require("./migration"); +const backup = require("./backup"); +const wordlist = require("./wordlist"); const app = new Koa(); app.use(bodyParser()); @@ -38,17 +56,54 @@ const pool = new Pool({ new CronJob( "00 00 02 * * *", async function () { - await createBackup(); + await createBackup(pool, awsDetails); }, null, true, "Australia/Melbourne" ); +function passthroughRequest(ctx) { + /** + * Passes through/repeats the incoming request on to another + * host; basically just so the old coffeebot can continue + * to process incoming data in case the new one screws up + * or isn't working correctly. + */ + if (REQUEST_PASSTHROUGH_HOST === null) { + return; + } + + const data = JSON.stringify({ ...ctx.request.body }); + setTimeout(() => { + try { + const options = { + protocol: 'http:', + hostname: REQUEST_PASSTHROUGH_HOST, + port: REQUEST_PASSTHROUGH_PORT, + path: ctx.path, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': data.length + } + } + const request = http.request(options); + request.on('error', function (err) { + console.log(`Failed to replace request: inner error ${err}`); + }); + request.write(data); + request.end(); + } catch (e) { + console.log(`Failed to replay request: ${e}`); + } + }); +} + function showHelp() { return { response_type: "ephemeral", - text: `Ohai, and welcome to coffeebot. Coffeebot counts the coffees consumed by Common Coders because why not. + text: `Ohai, and welcome to coffeebot. Coffeebot counts the coffees consumed by teams because why not. The most important commands are: @@ -74,246 +129,33 @@ an experiment in using firebase. Somehow, it has continued to be used since then } } -async function runMigrations(userId, userName, teamId, teamDomain) { - /*** - * Run migrations on the database if required. This can be run by any user (which is a bit - * dubious, but the command also isn't listed anywhere and should be idempotent so 🤷 gotta - * start somewhere) - * - * @param {string} userId - the slack user_id issuing the migrate command - * @param {string} userName - the slack user name issuing the migrate command - * @param {string} teamId - the workspace team_id from which the migrate command is being issued - * @param {string} teamDomain - the workspace team_domain from which the migrate command is being issued - */ - - const client = await pool.connect(); - try { - await client.query(queries.BEGIN); - const dt = DateTime.local().setZone("Australia/Melbourne"); - const getCurrentMigrationLevelQuery = await client.query(queries.GET_MIGRATION_LEVEL); - - // If there are no migration rows, the max value is null - let currentMigrationLevel = getCurrentMigrationLevelQuery.rows[0].migration_level; - console.log(`Current migration level: ${currentMigrationLevel}`); - - if (currentMigrationLevel === null) { - console.log(`Applying migration level 1`); - await client.query(queries.CREATE_ABSTRACT_USER_TABLE_V1_QUERY); - await client.query(queries.CREATE_TEAM_TABLE_V1_QUERY); - await client.query(queries.CREATE_TEAM_USER_TABLE_V1_QUERY); - await client.query(queries.CREATE_ABSTRACT_USER_DRINK_TABLE_V1_QUERY); - - // Now to migrate the data. - // There is an assumption here that all the current data comes from - // the workspace from which the migration is being run. - // If that isn't the case... well, just make sure it is the case OK? - // This could no doubt be implemented directly in SQL, but that's a - // future thing to think about - - // Create the team record - insertTeamQuery = await client.query(queries.INSERT_OR_GET_TEAM_V1_QUERY, [dt.toISO(), teamId, teamDomain]); - dbTeamId = insertTeamQuery.rows[0].id - - // Get a list of distinct users from the drinks table - getDistinctUsersQuery = await client.query(queries.MIGRATION_V1_GET_DISTINCT_USERS_QUERY); - - for (row of getDistinctUsersQuery.rows) { - // Create an abstract user and get back the identifier - const insertAbstractUserQuery = await client.query(queries.INSERT_ABSTRACT_USER_V1_QUERY, [dt.toISO()]); - const dbAbstractUserId = insertAbstractUserQuery.rows[0].id - - // Create a user record for each distinct user from the drinks table - // on the current team_id pointing to the abstract user and team record - await client.query(queries.INSERT_USER_V1_QUERY, [dt.toISO(), row.user_id, row.user_name, dbTeamId, dbAbstractUserId]) - } - - // Once that has been done for all users, run a single SQL command - // to migrate all the drinks that have been recorded - await client.query(queries.MIGRATION_V1_COPY_DRINKS) - // Step 3 ... Profit? - await client.query(queries.MIGRATION_V1_SET_MIGRATION_LEVEL, [1, dt.toISO()]); - await client.query(queries.COMMIT); - console.log(`Migration level 1 applied successfully`); - } - // Add additional migrations here - console.log(`All necessary migrations applied successfully`); - return { - response_type: "ephemeral", - text: `Migrations ran successfully`, - }; - } catch (e) { - await client.query(queries.ROLLBACK); - console.log(`Migrations failed to apply: ${e}`); - return { - response_type: "ephemeral", - text: `Migrations failed to apply`, - }; - } finally { - await client.release(); - } -} - -async function areMigrationsPending() { - /** - * Check if any migrations are pending, and return an error if so - */ - const client = await pool.connect(); - try { - const getCurrentMigrationLevelQuery = await client.query(queries.GET_MIGRATION_LEVEL); - - // If there are no migration rows, the max value is null - const currentMigrationLevel = getCurrentMigrationLevelQuery.rows[0].migration_level; - return currentMigrationLevel === null || currentMigrationLevel < TARGET_MIGRATION_LEVEL; - } finally { - await client.release(); - } -} - -async function createBackup() { - const client = await pool.connect(); - - try { - const dt = DateTime.local().setZone("Australia/Melbourne"); - const getLastSuccessfulBackupQuery = await client.query(queries.GET_LAST_SUCCESSFUL_BACKUP_DATETIME_QUERY); - - let backupFromDate = DateTime.fromSeconds(0); - if (getLastSuccessfulBackupQuery.rows.length > 0) { - backupFromDate = DateTime.fromJSDate(getLastSuccessfulBackupQuery.rows[0].backup_until); - } - - const getAllDrinksSinceDatetimeQuery = await client.query(queries.ALL_DRINKS_SINCE_DATETIME_QUERY, [ - backupFromDate.toISO(), - ]); - allDrinksSinceDatetime = getAllDrinksSinceDatetimeQuery.rows; - - if (allDrinksSinceDatetime.length === 0) { - return { - response_type: "ephemeral", - text: `No entries since ${backupFromDate.toISO()} to back up.`, - }; - } - - const rowsToBackUp = Array(); - let maxDate = DateTime.fromSeconds(0); - - for (let idx = 0, len = allDrinksSinceDatetime.length; idx < len; idx++) { - rowsToBackUp.push( - JSON.stringify({ - id: allDrinksSinceDatetime[idx].id, - user_id: allDrinksSinceDatetime[idx].user_id, - user_name: allDrinksSinceDatetime[idx].user_name, - created_at: allDrinksSinceDatetime[idx].created_at, - }) - ); - let thisDate = DateTime.fromJSDate(allDrinksSinceDatetime[idx].created_at); - console.log(thisDate); - if (thisDate > maxDate) { - maxDate = thisDate; - } - } - - const s3 = new AWS.S3({ - accessKeyId: AWS_ACCESS_KEY_ID, - secretAccessKey: AWS_SECRET_KEY, - region: AWS_REGION, - }); - - const params = { - Bucket: AWS_BUCKET_NAME, - Key: `${AWS_BACKUP_FOLDER}/${maxDate.toISO()}.rows.incremental.json`, - Body: rowsToBackUp.join("\n"), - }; - - try { - await s3.upload(params).promise(); - await client.query(queries.CREATE_BACKUP_ROW_QUERY, [dt.toISO(), maxDate.toISO(), true, ""]); - return { - response_type: "ephemeral", - text: `${allDrinksSinceDatetime.length} entries backed up. Filename: ${params.Key}.`, - }; - } catch (err) { - await client.query(queries.CREATE_BACKUP_ROW_QUERY, [dt.toISO(), maxDate.toISO(), false, err]); - return { response_type: "ephemeral", text: `Backup error: ${err}` }; - } - } finally { - await client.release(); - } -} - -async function createFullBackup() { - const client = await pool.connect(); - - try { - const dt = DateTime.local().setZone("Australia/Melbourne"); - const getAllDrinksQuery = await client.query(queries.ALL_DRINKS_QUERY); - allDrinks = getAllDrinksQuery.rows; - - if (allDrinks.length === 0) { - return { - response_type: "ephemeral", - text: "No entries, ever, to back up - which seems weird and wrong" - }; - } - - rowsToBackUp = allDrinks.map(data => - JSON.stringify({ - id: data.id, - user_id: data.user_id, - user_name: data.user_name, - created_at: data.created_at, - }) - ) - - const s3 = new AWS.S3({ - accessKeyId: AWS_ACCESS_KEY_ID, - secretAccessKey: AWS_SECRET_KEY, - region: AWS_REGION, - }); - - const params = { - Bucket: AWS_BUCKET_NAME, - Key: `${AWS_BACKUP_FOLDER}/${dt.toISO()}.rows.full.json`, - Body: rowsToBackUp.join("\n"), - }; - - try { - await s3.upload(params).promise(); - return { - response_type: "ephemeral", - text: `${allDrinks.length} entries backed up. Filename: ${params.Key}.`, - }; - } catch (err) { - return { response_type: "ephemeral", text: `Backup error: ${err}` }; - } - } finally { - await client.release(); - } -} async function createDatabaseBitsIfMissing() { const client = await pool.connect(); try { - console.log("Attempting to create drink table"); - await client.query(queries.CREATE_DRINK_TABLE_QUERY); - console.log("Attempting to create backup table"); - await client.query(queries.CREATE_BACKUP_TABLE_QUERY); console.log("Attempting to create migrations table"); - await client.query(queries.CREATE_MIGRATION_TABLE_QUERY); + await client.query(queries.CREATE_MIGRATION_TABLE_VX_QUERY); console.log("All table creation complete"); } finally { await client.release(); } } -async function showCoffeeStats() { +async function showCoffeeStats(dbTeamId, dbTeamLabel) { const client = await pool.connect(); try { - const totalCoffeeCountQuery = await client.query(queries.COUNT_ALL_DRINKS_EVER_QUERY); + const totalCoffeeCountQuery = await client.query( + queries.COUNT_ALL_DRINKS_EVER_V2_QUERY, + [dbTeamId,] + ); const totalCoffeeCount = totalCoffeeCountQuery.rows[0].count; - const coffeeCountByUserQuery = await client.query(queries.AVERAGE_USER_DRINKS_EVER_QUERY); + const coffeeCountByUserQuery = await client.query( + queries.AVERAGE_USER_DRINKS_EVER_V2_QUERY, + [dbTeamId,] + ); let blocks = []; let textChunks = []; @@ -338,7 +180,7 @@ async function showCoffeeStats() { type: "section", text: { type: "mrkdwn", - text: `*Since CoffeeBot began it's glorious existence*, Common Coders have consumed ${totalCoffeeCount} coffees`, + text: `*Since CoffeeBot began it's glorious existence*, ${getTeamLabelOrGenericPlural(dbTeamLabel)} have consumed ${totalCoffeeCount} coffees`, }, }); @@ -351,7 +193,11 @@ async function showCoffeeStats() { } } -async function showCoffeeCount(numOfItems) { +function getTeamLabelOrGenericPlural(dbTeamLabel) { + return dbTeamLabel || 'workspace members'; +} + +async function showCoffeeCount(dbTeamId, dbTeamLabel, numOfItems) { const client = await pool.connect(); try { const dt = DateTime.local().setZone("Australia/Melbourne"); @@ -363,15 +209,17 @@ async function showCoffeeCount(numOfItems) { }); const start_of_tomorrow = dt.plus({ days: 1 }).set({ hour: 0, minute: 0, second: 0, millisecond: 0 }); - const totalCoffeeCountQuery = await client.query(queries.COUNT_ALL_DRINKS_QUERY, [ + const totalCoffeeCountQuery = await client.query(queries.COUNT_ALL_DRINKS_V2_QUERY, [ start_of_today.toISO(), start_of_tomorrow.toISO(), + dbTeamId, ]); const totalCoffeeCount = totalCoffeeCountQuery.rows[0].count; - const coffeeCountByUserQuery = await client.query(queries.TALLY_ALL_DRINKS_QUERY, [ + const coffeeCountByUserQuery = await client.query(queries.TALLY_ALL_DRINKS_V2_QUERY, [ start_of_today.toISO(), start_of_tomorrow.toISO(), + dbTeamId, ]); let blocks = []; @@ -401,7 +249,7 @@ async function showCoffeeCount(numOfItems) { type: "section", text: { type: "mrkdwn", - text: `*Today*, Common Coders have consumed ${totalCoffeeCount} coffees`, + text: `*Today*, ${getTeamLabelOrGenericPlural(dbTeamLabel)} have consumed ${totalCoffeeCount} coffees`, }, }); @@ -423,21 +271,69 @@ async function getOrCreateTeam(teamId, teamDomain) { try { const dt = DateTime.local().setZone("Australia/Melbourne"); - const getOrCreateTeamQuery = client.query(queries.INSERT_OR_GET_TEAM_V1_QUERY, [to.toISO(), teamId, teamDomain]); - return getOrCreateTeamQuery.rows[0].id + const getOrCreateTeamQuery = await client.query( + queries.INSERT_OR_GET_TEAM_V2_QUERY, + [dt.toISO(), teamId, teamDomain] + ); + return { dbTeamId: getOrCreateTeamQuery.rows[0].id, dbTeamLabel: getOrCreateTeamQuery.rows[0].label }; + } catch (e) { + console.log(`Error in getOrCreateTeam: ${e}`); } finally { - client.release(); + await client.release(); } } +async function getOrCreateUser(userId, userName, dbTeamId) { + /** + * Create a `user` and `abstract_user` record if they don't + * already exist for the user and return the IDs + */ + const client = await pool.connect(); + try { + // If there is an existing user set up, just return the details + getUserQuery = await client.query(queries.GET_USER_V2_QUERY, [userId, dbTeamId]); + if (getUserQuery.rows.length > 0) { + return { + dbAbstractUserId: getUserQuery.rows[0].abstract_user_id, + dbUserId: getUserQuery.rows[0].id, + dbUserIsAdmin: getUserQuery.rows[0].is_admin, + }; + } + // User doesn't exist - we need to create them + try { + await client.query(queries.BEGIN); + const dt = DateTime.local().setZone("Australia/Melbourne"); + + // Create a new abstract user to link to + const insertAbstractUserQuery = await client.query(queries.INSERT_ABSTRACT_USER_V2_QUERY, [dt.toISO()]); + const dbAbstractUserId = insertAbstractUserQuery.rows[0].id + + // Create the main user record - this is created with an insert with on conflict + // in case something else slipped in an insert on us + const getOrCreateUserQuery = await client.query( + queries.INSERT_OR_GET_USER_V2_QUERY, + [dt.toISO(), userId, userName, dbAbstractUserId, dbTeamId] + ); -async function getOrCreateUser(userId, userName, dbTeamId) { + await client.query(queries.COMMIT); + return { + dbAbstractUserId: getOrCreateUserQuery.rows[0].abstract_user_id, + dbUserId: getOrCreateUserQuery.rows[0].id, + dbUserIsAdmin: getOrCreateUserQuery.rows[0].is_admin, + }; + } catch (e) { + console.log(`Error in getOrCreateUser: ${e}`) + await client.query(queries.ROLLBACK); + } + } finally { + await client.release(); + } } -async function addCoffee(userId, userName, teamId, teamDomain, inc) { +async function addCoffee(dbAbstractUserId, dbUserId, dbTeamId, dbTeamLabel, inc) { if (inc > MAX_COFFEE_ADD) { return { response_type: "ephemeral", @@ -465,24 +361,26 @@ async function addCoffee(userId, userName, teamId, teamDomain, inc) { if (inc > 0) { for (let idx = 0; idx < inc; idx++) { - await client.query(queries.ADD_DRINK_QUERY, [userId, userName, dt.toISO()]); + await client.query( + queries.ADD_DRINK_V2_QUERY, + [dbAbstractUserId, dbUserId, dt.toISO()] + ); } } else if (inc < 0) { - await client.query(queries.DELETE_N_MOST_RECENT_DRINKS_FOR_USER_QUERY, [ - userId, - start_of_today.toISO(), - start_of_tomorrow.toISO(), - -1 * inc, - ]); + await client.query( + queries.DELETE_N_MOST_RECENT_DRINKS_FOR_USER_V2_QUERY, + [dbAbstractUserId, start_of_today.toISO(), start_of_tomorrow.toISO(), -1 * inc,] + ); } - const totalCoffeeCountQuery = await client.query(queries.COUNT_ALL_DRINKS_QUERY, [ + const totalCoffeeCountQuery = await client.query(queries.COUNT_ALL_DRINKS_V2_QUERY, [ start_of_today.toISO(), start_of_tomorrow.toISO(), + dbTeamId, ]); const totalCoffeeCount = totalCoffeeCountQuery.rows[0].count; - const userCoffeeCountQuery = await client.query(queries.COUNT_USER_DRINKS_QUERY, [ - userId, + const userCoffeeCountQuery = await client.query(queries.COUNT_USER_DRINKS_V2_QUERY, [ + dbAbstractUserId, start_of_today.toISO(), start_of_tomorrow.toISO(), ]); @@ -490,13 +388,148 @@ async function addCoffee(userId, userName, teamId, teamDomain, inc) { return { response_type: "ephemeral", - text: `That's coffee number ${userCoffeeCount} for you today, and number ${totalCoffeeCount} for CC today`, + text: `That's coffee number ${userCoffeeCount} for you today, and number ${totalCoffeeCount} for ${getTeamLabelOrGenericPlural(dbTeamLabel)} today`, }; } finally { await client.release(); } } +async function getLinkCode(dbAbstractUserId) { + const words = wordlist.getWords(4); + const dt = DateTime.local().setZone("Australia/Melbourne"); + const client = await pool.connect(); + + try { + await client.query(queries.INSERT_LINK_WORDS_V2_QUERY, [dbAbstractUserId, words, dt.toISO()]); + return { + response_type: "ephemeral", + text: `Your link code is ${words}. To link another workspace enter */coffee link ${words}*`, + }; + } catch (e) { + console.log(`Error in getLinkCode: ${e}`); + } finally { + await client.release(); + } +} + +async function linkUserByCode(dbAbstractUserId, words) { + const dt = DateTime.local().setZone("Australia/Melbourne"); + const dtLinkCutoff = dt.minus({ days: 1 }); + const client = await pool.connect(); + + try { + // This could probably all be done in a single query + // and there is also possibly a race condition here, although + // hopefully mitigated by a transaction error if two + // clients simultaneously try to delete the same link + // word record + client.query(queries.BEGIN); + const getAbstractUserForLinkWordQuery = await client.query( + queries.GET_ABSTRACT_USER_FOR_LINK_WORD_V2_QUERY, + [words, dtLinkCutoff.toISO()] + ); + + // Delete the link word if it exists; it has either been consumed, + // is too old, or never existing at this point + await client.query( + queries.DELETE_LINK_WORD_V2_QUERY, + [words,] + ); + + if (getAbstractUserForLinkWordQuery.rows.length < 1) { + return { + response_type: "ephemeral", + text: `The link code ${words} could not be found or is too old. Use */coffee link* to get a new link code`, + }; + } + + const newDbAbstractUserId = getAbstractUserForLinkWordQuery.rows[0].abstract_user_id; + await client.query( + queries.UPDATE_ABSTRACT_USER_FOR_DRINKS_V2_QUERY, + [dbAbstractUserId, newDbAbstractUserId] + ); + + await client.query( + queries.UPDATE_ABSTRACT_USER_FOR_USER_V2_QUERY, + [dbAbstractUserId, newDbAbstractUserId] + ); + + await client.query(queries.COMMIT); + return { + response_type: "ephemeral", + text: `Your slack user has been linked successfully`, + }; + } catch (e) { + console.log(`Error in linkUserByCode: ${e}`); + client.query(queries.ROLLBACK); + } finally { + await client.release(); + } +} + + +async function makeAdmin(dbUserId, identifierKey, dbUserId, dbUserIsAdmin, userId, userName) { + /** + * Given the correct 'admin key' (which is static, which is bad) + * sets the calling user to be an `admin` + */ + if (ADMIN_KEY === null || ADMIN_KEY === "") { + console.log(`Attempt to identify as admin without ADMIN_KEY set: ${dbUserId}:${userId}:${userName}`) + // We return the generic error message for failed attempts to identify + return GENERIC_FAILURE_RESPONSE; + } + + if (identifierKey !== ADMIN_KEY) { + console.log(`Failed attempt to identify as admin: ${dbUserId}:${userId}:${userName}`) + // We return the generic error message for failed attempts to identify + return GENERIC_FAILURE_RESPONSE; + } + + const client = await pool.connect(); + + try { + const makeAdminIfNotAlreadyQuery = await client.query(queries.MAKE_ADMIN_IF_NOT_ALREADY_V2_QUERY, [dbUserId,]); + if (makeAdminIfNotAlreadyQuery.rowCount == 0) { + console.log(`Failed to identify as admin; may already be admin? ${dbUserId}:${userId}:${userName} isAdmin ${dbUserIsAdmin}`) + return GENERIC_FAILURE_RESPONSE; + } else { + console.log(`Identified as admin: ${dbUserId}:${userId}:${userName}`) + return { ...GENERIC_FAILURE_RESPONSE, text: `${GENERIC_FAILURE_RESPONSE.text} ;)` }; + } + } catch (e) { + console.log(`makeAdmin failed for user ${dbUserId}:${userId}:${userName}: ${e}`); + } finally { + await client.release() + } +} + +async function setTeamLabel(dbTeamId, teamLabel, dbUserId, dbUserIsAdmin, userId, userName) { + /** + * Updates the label of the team to be used when outputting messages. (e.g. Common Coders) + * + * MUST ONLY BE CALLED BY AN ADMIN. THIS FUNCTION DOES ONLY A TRIVIAL SECURITY CHECK. + */ + if (!dbUserIsAdmin) { + console.log(`setTeamLabel was called with dbUserIsAdmin false (User: ${dbUserId}). Please check your values!`); + return GENERIC_FAILURE_RESPONSE; + } + const client = await pool.connect(); + + try { + console.log(`User setting team label. User: ${dbUserId}:${userId}:${userName} TeamId: ${dbTeamId} Label ${teamLabel}`); + await client.query(queries.SET_TEAM_LABEL_V2, [dbTeamId, teamLabel]); + return { + response_type: "in_channel", + text: `The workspace team name has been set to ${teamLabel} by ${userName}`, + }; + } catch (e) { + console.log(`setTeamLabel failed for user ${dbUserId}:${userId}:${userName}: ${e}`); + } finally { + await client.release() + } +} + PERMISSION_SLACKACTION = "SLACK_ACTION"; async function hasPermission(ctx, action) { @@ -516,6 +549,8 @@ router.post("/addCoffee", async (ctx, next) => { return; } + passthroughRequest(ctx); + if (ctx.request.body.command !== "/coffee") { ctx.body = { response_type: "ephemeral", @@ -525,7 +560,8 @@ router.post("/addCoffee", async (ctx, next) => { } if (ctx.request.body.text === "migrate") { - ctx.body = await runMigrations( + ctx.body = await migration.runMigrations( + pool, ctx.request.body.user_id, ctx.request.body.user_name, ctx.request.body.team_id, @@ -534,7 +570,7 @@ router.post("/addCoffee", async (ctx, next) => { return; } - if (await areMigrationsPending()) { + if (await migration.areMigrationsPending(pool)) { ctx.body = { response_type: "ephemeral", text: "Migrations must be run before continuing", @@ -542,6 +578,15 @@ router.post("/addCoffee", async (ctx, next) => { return; } + const { dbTeamId, dbTeamLabel } = await getOrCreateTeam( + ctx.request.body.team_id, + ctx.request.body.team_domain + ); + const { dbAbstractUserId, dbUserId, dbUserIsAdmin } = await getOrCreateUser( + ctx.request.body.user_id, + ctx.request.body.user_name, + dbTeamId + ); if (ctx.request.body.text === "help") { ctx.body = showHelp(); @@ -549,41 +594,67 @@ router.post("/addCoffee", async (ctx, next) => { } else if (ctx.request.body.text === "about") { ctx.body = showAbout(); return; + } else if (ctx.request.body.text === "link") { + ctx.body = await getLinkCode(dbAbstractUserId); + return; + } else if (ctx.request.body.text.startsWith("link ")) { + const linkWords = ctx.request.body.text.substring("link ".length); + ctx.body = await linkUserByCode(dbAbstractUserId, linkWords); + return; } else if (ctx.request.body.text === "count") { - ctx.body = await showCoffeeCount(COUNT_DISPLAY_SIZE); + ctx.body = await showCoffeeCount(dbTeamId, dbTeamLabel, COUNT_DISPLAY_SIZE); return; } else if (ctx.request.body.text === "count-all") { - ctx.body = await showCoffeeCount(null); + ctx.body = await showCoffeeCount(dbTeamId, dbTeamLabel, null); return; } else if (ctx.request.body.text === "stats") { - ctx.body = await showCoffeeStats(null); + ctx.body = await showCoffeeStats(dbTeamId, dbTeamLabel); return; } else if (ctx.request.body.text === "stomach-pump") { - ctx.body = await addCoffee(ctx.request.body.user_id, ctx.request.body.user_name, -1); + ctx.body = await addCoffee(dbAbstractUserId, dbUserId, dbTeamId, dbTeamLabel, -1); return; } else if (!isNaN(parseInt(ctx.request.body.text, 10))) { ctx.body = await addCoffee( - ctx.request.body.user_id, - ctx.request.body.user_name, - ctx.request.body.team_id, - ctx.request.body.team_domain, + dbAbstractUserId, + dbUserId, + dbTeamId, + dbTeamLabel, parseInt(ctx.request.body.text, 10) ); return; } else if (ctx.request.body.text === "") { - ctx.body = await addCoffee(ctx.request.body.user_id, ctx.request.body.user_name, 1); + ctx.body = await addCoffee(dbAbstractUserId, dbUserId, dbTeamId, dbTeamLabel, 1); return; - } else if (ctx.request.body.text === "backup") { - ctx.body = await createBackup(); + } else if (ctx.request.body.text.startsWith("auth ")) { + const identifierKey = ctx.request.body.text.substring("auth ".length); + ctx.body = await makeAdmin( + dbUserId, + identifierKey, + dbUserId, + dbUserIsAdmin, + ctx.request.body.user_id, + ctx.request.body.user_name + ); return; - } else if (ctx.request.body.text === "backup-all") { - ctx.body = await createFullBackup(); + } else if (dbUserIsAdmin && ctx.request.body.text.startsWith("teamlabel ")) { + const teamLabel = ctx.request.body.text.substring("teamlabel ".length); + ctx.body = await setTeamLabel( + dbTeamId, + teamLabel, + dbUserId, + dbUserIsAdmin, + ctx.request.body.user_id, + ctx.request.body.user_name + ); + return; + } else if (dbUserIsAdmin && ctx.request.body.text === "backup") { + ctx.body = await backup.createBackup(pool, awsDetails); + return; + } else if (dbUserIsAdmin && ctx.request.body.text === "backup-all") { + ctx.body = await backup.createFullBackup(pool, awsDetails); return; } else { - ctx.body = { - response_type: "ephemeral", - text: "I'm afraid I don't understand your command. Take another sip and try again.", - }; + ctx.body = GENERIC_FAILURE_RESPONSE; return; } }); diff --git a/src/migration.js b/src/migration.js new file mode 100644 index 0000000..d637c53 --- /dev/null +++ b/src/migration.js @@ -0,0 +1,115 @@ +const queries = require("./queries"); +const { DateTime } = require("luxon"); + +async function runMigrations(pool, userId, userName, teamId, teamDomain) { + /*** + * Run migrations on the database if required. This can be run by any user (which is a bit + * dubious, but the command also isn't listed anywhere and should be idempotent so 🤷 gotta + * start somewhere) + * + * @param {string} userId - the slack user_id issuing the migrate command + * @param {string} userName - the slack user name issuing the migrate command + * @param {string} teamId - the workspace team_id from which the migrate command is being issued + * @param {string} teamDomain - the workspace team_domain from which the migrate command is being issued + */ + + const client = await pool.connect(); + + try { + const dt = DateTime.local().setZone("Australia/Melbourne"); + const getCurrentMigrationLevelQuery = await client.query(queries.GET_MIGRATION_VX_LEVEL); + + // If there are no migration rows, the max value is null + let currentMigrationLevel = getCurrentMigrationLevelQuery.rows[0].migration_level; + console.log(`Current migration level: ${currentMigrationLevel}`); + + if (currentMigrationLevel === null) { + await client.query(queries.BEGIN); + console.log("Attempting to create drink table"); + await client.query(queries.CREATE_DRINK_TABLE_V1_QUERY); + console.log("Attempting to create backup table"); + await client.query(queries.CREATE_BACKUP_TABLE_QUERY); + await client.query(queries.MIGRATION_V2_SET_MIGRATION_LEVEL, [1, dt.toISO()]); + await client.query(queries.COMMIT); + currentMigrationLevel = 1; + } + if (currentMigrationLevel < 2) { + console.log(`Applying migration level 2`); + await client.query(queries.BEGIN); + await client.query(queries.CREATE_ABSTRACT_USER_TABLE_V2_QUERY); + await client.query(queries.CREATE_TEAM_TABLE_V2_QUERY); + await client.query(queries.CREATE_USER_TABLE_V2_QUERY); + await client.query(queries.CREATE_DRINK_TABLE_V2_QUERY); + await client.query(queries.CREATE_LINK_WORDS_TABLE_V2_QUERY); + + // Now to migrate the data. + // There is an assumption here that all the current data comes from + // the workspace from which the migration is being run. + // If that isn't the case... well, just make sure it is the case OK? + // This could no doubt be implemented directly in SQL, but that's a + // future thing to think about + + // Create the team record + const insertTeamQuery = await client.query(queries.INSERT_OR_GET_TEAM_V2_QUERY, [dt.toISO(), teamId, teamDomain]); + const dbTeamId = insertTeamQuery.rows[0].id; + + // Get a list of distinct users from the drinks table + const getDistinctUsersQuery = await client.query(queries.MIGRATION_V2_GET_DISTINCT_USERS_QUERY); + + for (row of getDistinctUsersQuery.rows) { + // Create an abstract user and get back the identifier + const insertAbstractUserQuery = await client.query(queries.INSERT_ABSTRACT_USER_V2_QUERY, [dt.toISO()]); + const dbAbstractUserId = insertAbstractUserQuery.rows[0].id + + // Create a user record for each distinct user from the drinks table + // on the current team_id pointing to the abstract user and team record + await client.query(queries.INSERT_USER_V2_QUERY, [dt.toISO(), row.user_id, row.user_name, dbTeamId, dbAbstractUserId]) + } + + // Once that has been done for all users, run a single SQL command + // to migrate all the drinks that have been recorded + await client.query(queries.MIGRATION_V2_COPY_DRINKS) + // Step 3 ... Profit? + await client.query(queries.MIGRATION_V2_SET_MIGRATION_LEVEL, [2, dt.toISO()]); + await client.query(queries.COMMIT); + console.log(`Migration level 2 applied successfully`); + currentMigrationLevel = 2; + } + // Add additional migrations here + console.log(`All necessary migrations applied successfully`); + return { + response_type: "ephemeral", + text: `Migrations ran successfully`, + }; + } catch (e) { + await client.query(queries.ROLLBACK); + console.log(`Migrations failed to apply: ${e}`); + return { + response_type: "ephemeral", + text: `Migrations failed to apply`, + }; + } finally { + await client.release(); + } +} + +async function areMigrationsPending(pool) { + /** + * Check if any migrations are pending, and return an error if so + */ + const client = await pool.connect(); + try { + const getCurrentMigrationLevelQuery = await client.query(queries.GET_MIGRATION_VX_LEVEL); + + // If there are no migration rows, the max value is null + const currentMigrationLevel = getCurrentMigrationLevelQuery.rows[0].migration_level; + return currentMigrationLevel === null || currentMigrationLevel < TARGET_MIGRATION_LEVEL; + } finally { + await client.release(); + } +} + +module.exports = { + runMigrations, + areMigrationsPending, +} \ No newline at end of file diff --git a/src/queries.js b/src/queries.js index 2d3022d..408e23b 100644 --- a/src/queries.js +++ b/src/queries.js @@ -1,17 +1,19 @@ module.exports = { + // CREATE_DATABASE_QUERY : "CREATE DATABASE drinks ENCODING = 'UTF8'", + // CHECK_IF_DATABASE_EXISTS_QUERY : "SELECT datname FROM pg_catalog.pg_database WHERE datname = drinks;" BEGIN: 'BEGIN;', ROLLBACK: 'ROLLBACK', COMMIT: 'COMMIT', - CREATE_ABSTRACT_USER_TABLE_V1_QUERY: ` - CREATE TABLE IF NOT EXISTS public.abstract_user_v1 + CREATE_ABSTRACT_USER_TABLE_V2_QUERY: ` + CREATE TABLE IF NOT EXISTS public.abstract_user_v2 ( id bigserial NOT NULL, created_at timestamp with time zone NOT NULL, PRIMARY KEY (id) );`, - CREATE_TEAM_TABLE_V1_QUERY: ` - CREATE TABLE IF NOT EXISTS public.team_v1 + CREATE_TEAM_TABLE_V2_QUERY: ` + CREATE TABLE IF NOT EXISTS public.team_v2 ( id bigserial NOT NULL, created_at timestamp with time zone NOT NULL, @@ -23,11 +25,11 @@ module.exports = { -- output. Not currently implemented. label character varying(200), PRIMARY KEY (id), - CONSTRAINT team_v1_team_id_unique UNIQUE (team_id) + CONSTRAINT team_v2_team_id_unique UNIQUE (team_id) );`, - CREATE_TEAM_USER_TABLE_V1_QUERY: ` - CREATE TABLE IF NOT EXISTS public.user_v1 + CREATE_USER_TABLE_V2_QUERY: ` + CREATE TABLE IF NOT EXISTS public.user_v2 ( id bigserial NOT NULL, created_at timestamp with time zone NOT NULL, @@ -36,14 +38,15 @@ module.exports = { -- label is an optional label for the user to be used -- instead of the slack name. Not currently implemented. label character varying(200), + is_admin bool NOT NULL DEFAULT FALSE, team_id bigint NOT NULL, abstract_user_id bigint NOT NULL, PRIMARY KEY (id), - CONSTRAINT user_v1_user_fk_team FOREIGN KEY(team_id) REFERENCES public.team_v1(id), - CONSTRAINT user_v1_user_fk_abstract_user FOREIGN KEY(abstract_user_id) REFERENCES public.abstract_user_v1(id), - CONSTRAINT user_v1_user_id_team_id_unique UNIQUE (user_id, team_id) + CONSTRAINT user_v2_user_fk_team FOREIGN KEY(team_id) REFERENCES public.team_v2(id), + CONSTRAINT user_v2_user_fk_abstract_user FOREIGN KEY(abstract_user_id) REFERENCES public.abstract_user_v2(id), + CONSTRAINT user_v2_team_id_user_id_unique UNIQUE (team_id, user_id) ); - CREATE INDEX user_v1_idx_user_id_team_id ON public.user_v1(user_id, team_id); + CREATE INDEX user_v2_idx_user_id_team_id ON public.user_v2(user_id, team_id); `, // This includes a `drink` column but it defaults @@ -51,8 +54,8 @@ module.exports = { // anything but a coffee. // That's not necessarily because coffee is the superior // drink, Jon. That's not what I'm saying. - CREATE_ABSTRACT_USER_DRINK_TABLE_V1_QUERY: ` - CREATE TABLE IF NOT EXISTS public.drink_v1 + CREATE_DRINK_TABLE_V2_QUERY: ` + CREATE TABLE IF NOT EXISTS public.drink_v2 ( id bigserial NOT NULL, created_at timestamp with time zone NOT NULL, @@ -63,14 +66,28 @@ module.exports = { user_id bigint NOT NULL, drink character varying(20) DEFAULT 'coffee', PRIMARY KEY (id), - CONSTRAINT coffee_v1_user_fk_abstract_user FOREIGN KEY(abstract_user_id) REFERENCES public.abstract_user_v1(id), - CONSTRAINT coffee_v1_user_fk_user FOREIGN KEY(user_id) REFERENCES public.user_v1(id) + CONSTRAINT drink_v2_user_fk_abstract_user FOREIGN KEY(abstract_user_id) REFERENCES public.abstract_user_v2(id), + CONSTRAINT drink_v2_user_fk_user FOREIGN KEY(user_id) REFERENCES public.user_v2(id) ); - CREATE INDEX coffee_v1_idx_created_at ON public.drink_v1(created_at); - CREATE INDEX coffee_v1_idx_abstract_user_id ON public.drink_v1(abstract_user_id); + CREATE INDEX drink_v2_idx_created_at ON public.drink_v2(created_at); + CREATE INDEX drink_v2_idx_abstract_user_id ON public.drink_v2(abstract_user_id); `, - CREATE_MIGRATION_TABLE_QUERY: ` + CREATE_LINK_WORDS_TABLE_V2_QUERY: ` + CREATE TABLE IF NOT EXISTS public.link_words_V2 + ( + abstract_user_id bigint NOT NULL, + words character varying(200), + created_at timestamp with time zone NOT NULL, + PRIMARY KEY (abstract_user_id), + CONSTRAINT link_words_v2_abstract_user_fk_abstract_user FOREIGN KEY(abstract_user_id) REFERENCES public.abstract_user_v2(id) + ); + CREATE INDEX link_words_v2_idx_words ON public.link_words_v2(words); + `, + + + + CREATE_MIGRATION_TABLE_VX_QUERY: ` CREATE TABLE IF NOT EXISTS public.migrations ( id int NOT NULL, @@ -78,63 +95,37 @@ module.exports = { PRIMARY KEY(id) );`, - GET_MIGRATION_LEVEL: "SELECT MAX(id) migration_level FROM public.migrations;", + GET_MIGRATION_VX_LEVEL: "SELECT MAX(id) migration_level FROM public.migrations;", - INSERT_ABSTRACT_USER_V1_QUERY: "INSERT INTO public.abstract_user_v1 (created_at) VALUES ($1) RETURNING id;", - INSERT_USER_V1_QUERY: ` - INSERT INTO public.user_v1 (created_at, user_id, user_name, team_id, abstract_user_id) + INSERT_ABSTRACT_USER_V2_QUERY: "INSERT INTO public.abstract_user_v2 (created_at) VALUES ($1) RETURNING id;", + INSERT_USER_V2_QUERY: ` + INSERT INTO public.user_v2 (created_at, user_id, user_name, team_id, abstract_user_id) VALUES ($1, $2, $3, $4, $5) RETURNING id; `, - // Migration V1 functions - MIGRATION_V1_GET_DISTINCT_USERS_QUERY: "SELECT DISTINCT user_id, user_name FROM public.coffee;", - MIGRATION_V1_COPY_DRINKS: ` - INSERT INTO public.drink_v1 (created_at, abstract_user_id, user_id) + // Migration V2 functions + MIGRATION_V2_GET_DISTINCT_USERS_QUERY: "SELECT DISTINCT user_id, user_name FROM public.coffee;", + MIGRATION_V2_COPY_DRINKS: ` + INSERT INTO public.drink_v2 (created_at, abstract_user_id, user_id) SELECT c.created_at, u1.abstract_user_id, u1.id FROM public.coffee c - INNER JOIN public.user_v1 u1 ON u1.user_id = c.user_id + INNER JOIN public.user_v2 u1 ON u1.user_id = c.user_id `, - MIGRATION_V1_SET_MIGRATION_LEVEL: "INSERT INTO public.migrations (id, run_at) VALUES ($1, $2);", + MIGRATION_V2_SET_MIGRATION_LEVEL: "INSERT INTO public.migrations (id, run_at) VALUES ($1, $2);", - GET_ABSTRACT_USER_GIVEN_USER_ID_TEAM_ID_QUERY: ` - SELECT au1.id - FROM public.user_v1 u1 - INNER JOIN public.abstract_user_v1 au1 ON u1.abstract_user_id = au1.id - WHERE u1.user_id = $1 AND u1.team_id = $2 - `, - - INSERT_OR_GET_TEAM_V1_QUERY: ` + INSERT_OR_GET_TEAM_V2_QUERY: ` WITH insert_attempt AS ( - INSERT INTO public.team_v1 (created_at, team_id, team_domain) + INSERT INTO public.team_v2 (created_at, team_id, team_domain) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING - RETURNING id + RETURNING id, label ) - SELECT id FROM insert_attempt + SELECT id, label FROM insert_attempt UNION - SELECT id FROM public.team_v1 WHERE team_id = $2 + SELECT id, label FROM public.team_v2 WHERE team_id = $2 `, - - - /** - Design thoughts: - - if I link drinks -> user -> abstract user, then finding all drinks for user - means 'find user, then find abstract id, then find all other users with same abstract id, - the get all drinks for them`. - If using the abstract user id: - SELECT u1.user_name, count(*) - FROM user_v1 u1 - INNER JOIN drink_v1 d1 ON d1.abstract_user_id = u1.abstract_user_id - INNER JOIN team_v1 t1 ON t1.id = u1.team_id - WHERE t1.team_id = 'value from slack' - */ - - - - // CREATE_DATABASE_QUERY : "CREATE DATABASE drinks ENCODING = 'UTF8'", - // CHECK_IF_DATABASE_EXISTS_QUERY : "SELECT datname FROM pg_catalog.pg_database WHERE datname = drinks;" CREATE_BACKUP_TABLE_QUERY: ` CREATE TABLE IF NOT EXISTS public.backups ( @@ -148,7 +139,7 @@ module.exports = { GET_LAST_SUCCESSFUL_BACKUP_DATETIME_QUERY: "SELECT backup_until FROM public.backups WHERE successful = TRUE ORDER BY backup_until DESC LIMIT 1", CREATE_BACKUP_ROW_QUERY: "INSERT INTO public.backups (created_at, backup_until, successful, message) VALUES ($1, $2, $3, $4)", - CREATE_DRINK_TABLE_QUERY: ` + CREATE_DRINK_TABLE_V1_QUERY: ` CREATE TABLE IF NOT EXISTS public.coffee ( id bigserial NOT NULL, @@ -158,33 +149,143 @@ module.exports = { PRIMARY KEY (id) );`, - ADD_DRINK_QUERY: "INSERT INTO coffee (user_id, user_name, created_at) VALUES($1, $2, $3);", - COUNT_ALL_DRINKS_QUERY: "SELECT COUNT(*) FROM coffee WHERE created_at > $1 AND created_at < $2;", - COUNT_USER_DRINKS_QUERY: "SELECT COUNT(*) FROM coffee WHERE user_id = $1 AND created_at > $2 AND created_at < $3;", - COUNT_ALL_DRINKS_EVER_QUERY: "SELECT COUNT(*) FROM coffee;", - AVERAGE_USER_DRINKS_EVER_QUERY: ` - SELECT user_name, COUNT(coffees_on_day) AS reporting_days, SUM(coffees_on_day) AS total_coffees, AVG(coffees_on_day) AS avg_coffees_per_day, STDDEV(coffees_on_day) AS stddev_coffees_per_day + ADD_DRINK_V2_QUERY: ` + INSERT INTO public.drink_v2 (abstract_user_id, user_id, created_at) + VALUES ($1, $2, $3); + `, + COUNT_ALL_DRINKS_V2_QUERY: ` + SELECT COUNT(*) + FROM public.user_v2 u1 + INNER JOIN public.drink_v2 d1 ON d1.abstract_user_id = u1.abstract_user_id + WHERE u1.team_id = $3 AND d1.created_at > $1 AND d1.created_at < $2; + `, + COUNT_USER_DRINKS_V2_QUERY: ` + SELECT COUNT(*) + FROM public.drink_v2 + WHERE abstract_user_id = $1 AND created_at > $2 AND created_at < $3; + `, + COUNT_ALL_DRINKS_EVER_V2_QUERY: ` + SELECT COUNT(*) + FROM public.drink_v2 d2 + INNER JOIN public.user_v2 u2 ON u2.abstract_user_id = d2.abstract_user_id + WHERE u2.team_id = $1; + `, + AVERAGE_USER_DRINKS_EVER_V2_QUERY: ` + SELECT + user_name, + COUNT(coffees_on_day) AS reporting_days, + SUM(coffees_on_day) AS total_coffees, + AVG(coffees_on_day) AS avg_coffees_per_day, + STDDEV(coffees_on_day) AS stddev_coffees_per_day FROM ( - SELECT user_name, COUNT(*) AS coffees_on_day - FROM coffee - GROUP BY user_name, date(created_at AT TIME ZONE 'Australia/Melbourne') + SELECT u2.user_name, COUNT(*) AS coffees_on_day + FROM public.drink_v2 d2 + INNER JOIN public.user_v2 u2 ON u2.abstract_user_id = d2.abstract_user_id + WHERE u2.team_id = $1 + GROUP BY u2.user_name, date(d2.created_at AT TIME ZONE 'Australia/Melbourne') ) AS coffees_per_day GROUP BY user_name - ORDER BY avg_coffees_per_day DESC;`, - TALLY_ALL_DRINKS_QUERY: "SELECT user_name, COUNT(*) AS drink_count FROM coffee WHERE created_at > $1 AND created_at < $2 GROUP BY user_name ORDER BY drink_count DESC;", - DELETE_N_MOST_RECENT_DRINKS_FOR_USER_QUERY: "DELETE FROM coffee WHERE id IN (SELECT id FROM coffee WHERE user_id = $1 AND created_at > $2 AND created_at < $3 ORDER BY id DESC LIMIT $4);", - ALL_DRINKS_SINCE_DATETIME_QUERY: "SELECT id, user_id, user_name, created_at FROM coffee WHERE created_at > $1;", - ALL_DRINKS_QUERY: "SELECT id, user_id, user_name, created_at FROM coffee;", - GET_USER_V1_QUERY: `SELECT abstract_user_id FROM user_v1 WHERE user_id = $1 AND team_id = $2;`, - INSERT_OR_GET_USER_V1_QUERY: ` - WITH insert_attempt AS ( - INSERT INTO user_v1 (created_at, user_id, user_name, team_id, ) - VALUES ($1, $2, $3) - ON CONFLICT DO NOTHING - RETURNING id - ) - SELECT id FROM insert_attempt - UNION - SELECT id FROM team_v1 WHERE team_id = $2; + ORDER BY avg_coffees_per_day DESC; + `, + TALLY_ALL_DRINKS_V2_QUERY: ` + SELECT u1.user_name, COUNT(*) AS drink_count + FROM public.user_v2 u1 + INNER JOIN public.drink_v2 d1 ON d1.abstract_user_id = u1.abstract_user_id + WHERE d1.created_at > $1 AND d1.created_at < $2 AND u1.team_id = $3 + GROUP BY u1.user_name + ORDER BY drink_count DESC; + `, + DELETE_N_MOST_RECENT_DRINKS_FOR_USER_V2_QUERY: ` + DELETE FROM drink_v2 + WHERE id IN ( + SELECT d1.id + FROM public.drink_v2 d1 + WHERE d1.abstract_user_id = $1 AND created_at > $2 AND created_at < $3 + ORDER BY d1.id DESC + LIMIT $4 + ); `, + + GET_USER_V2_QUERY: ` + SELECT id, abstract_user_id, is_admin + FROM public.user_v2 + WHERE user_id = $1 AND team_id = $2; + `, + INSERT_OR_GET_USER_V2_QUERY: ` + WITH insert_attempt AS ( + INSERT INTO public.user_v2 (created_at, user_id, user_name, abstract_user_id, team_id) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT DO NOTHING + RETURNING abstract_user_id, id, is_admin + ) + SELECT abstract_user_id, id, is_admin + FROM insert_attempt + UNION + SELECT abstract_user_id, id, is_admin + FROM public.user_v2 + WHERE user_id = $2 AND team_id = $5; + `, + + INSERT_LINK_WORDS_V2_QUERY: ` + INSERT INTO public.link_words_v2 (abstract_user_id, words, created_at) + VALUES ($1, $2, $3) + ON CONFLICT(abstract_user_id) DO UPDATE SET words = $2, created_at = $3; + `, + GET_ABSTRACT_USER_FOR_LINK_WORD_V2_QUERY: ` + SELECT abstract_user_id + FROM public.link_words_v2 + WHERE words = $1 AND created_at > $2; + `, + DELETE_LINK_WORD_V2_QUERY: ` + DELETE FROM public.link_words_v2 + WHERE words = $1; + `, + UPDATE_ABSTRACT_USER_FOR_DRINKS_V2_QUERY: ` + UPDATE public.drink_v2 + SET abstract_user_id = $2 + WHERE abstract_user_id = $1; + `, + UPDATE_ABSTRACT_USER_FOR_USER_V2_QUERY: ` + UPDATE public.user_v2 + SET abstract_user_id = $2 + WHERE abstract_user_id = $1; + `, + + BACKUP_ABSTRACT_USER_SINCE_DATE_V2_QUERY: ` + SELECT * FROM public.abstract_user_v2 WHERE created_at > $1; + `, + BACKUP_ABSTRACT_USER_ALL_V2_QUERY: ` + SELECT * FROM public.abstract_user_v2; + `, + BACKUP_TEAM_SINCE_DATE_V2_QUERY: ` + SELECT * FROM public.team_v2 WHERE created_at > $1; + `, + BACKUP_TEAM_ALL_V2_QUERY: ` + SELECT * FROM public.team_v2; + `, + BACKUP_USER_SINCE_DATE_V2_QUERY: ` + SELECT * FROM public.user_v2 WHERE created_at > $1; + `, + BACKUP_USER_ALL_V2_QUERY: ` + SELECT * FROM public.user_v2; + `, + BACKUP_DRINK_SINCE_DATE_V2_QUERY: ` + SELECT * FROM public.drink_v2 WHERE created_at > $1; + `, + BACKUP_DRINK_ALL_V2_QUERY: ` + SELECT * FROM public.drink_v2; + `, + + MAKE_ADMIN_IF_NOT_ALREADY_V2_QUERY: ` + UPDATE public.user_v2 + SET is_admin = TRUE + WHERE id = $1 AND is_admin = FALSE + RETURNING id, is_admin; + `, + + SET_TEAM_LABEL_V2: ` + UPDATE public.team_v2 + SET label = $2 + WHERE id = $1; + ` } \ No newline at end of file diff --git a/src/wordlist.js b/src/wordlist.js new file mode 100644 index 0000000..65bd9b9 --- /dev/null +++ b/src/wordlist.js @@ -0,0 +1,7796 @@ +const crypto = require("crypto"); + +function getWords(nWords) { + // Get n words from the diceware EFF 5-dice wordlist + const words = []; + for (let idx = 0; idx < nWords; idx++) { + words.push(wordlist[crypto.randomInt(0, wordlistLength)]); + } + return words.join("-"); +} + + +module.exports = { + getWords, +} + +const wordlist = [ + 'abacus', + 'abdomen', + 'abdominal', + 'abide', + 'abiding', + 'ability', + 'ablaze', + 'able', + 'abnormal', + 'abrasion', + 'abrasive', + 'abreast', + 'abridge', + 'abroad', + 'abruptly', + 'absence', + 'absentee', + 'absently', + 'absinthe', + 'absolute', + 'absolve', + 'abstain', + 'abstract', + 'absurd', + 'accent', + 'acclaim', + 'acclimate', + 'accompany', + 'account', + 'accuracy', + 'accurate', + 'accustom', + 'acetone', + 'achiness', + 'aching', + 'acid', + 'acorn', + 'acquaint', + 'acquire', + 'acre', + 'acrobat', + 'acronym', + 'acting', + 'action', + 'activate', + 'activator', + 'active', + 'activism', + 'activist', + 'activity', + 'actress', + 'acts', + 'acutely', + 'acuteness', + 'aeration', + 'aerobics', + 'aerosol', + 'aerospace', + 'afar', + 'affair', + 'affected', + 'affecting', + 'affection', + 'affidavit', + 'affiliate', + 'affirm', + 'affix', + 'afflicted', + 'affluent', + 'afford', + 'affront', + 'aflame', + 'afloat', + 'aflutter', + 'afoot', + 'afraid', + 'afterglow', + 'afterlife', + 'aftermath', + 'aftermost', + 'afternoon', + 'aged', + 'ageless', + 'agency', + 'agenda', + 'agent', + 'aggregate', + 'aghast', + 'agile', + 'agility', + 'aging', + 'agnostic', + 'agonize', + 'agonizing', + 'agony', + 'agreeable', + 'agreeably', + 'agreed', + 'agreeing', + 'agreement', + 'aground', + 'ahead', + 'ahoy', + 'aide', + 'aids', + 'aim', + 'ajar', + 'alabaster', + 'alarm', + 'albatross', + 'album', + 'alfalfa', + 'algebra', + 'algorithm', + 'alias', + 'alibi', + 'alienable', + 'alienate', + 'aliens', + 'alike', + 'alive', + 'alkaline', + 'alkalize', + 'almanac', + 'almighty', + 'almost', + 'aloe', + 'aloft', + 'aloha', + 'alone', + 'alongside', + 'aloof', + 'alphabet', + 'alright', + 'although', + 'altitude', + 'alto', + 'aluminum', + 'alumni', + 'always', + 'amaretto', + 'amaze', + 'amazingly', + 'amber', + 'ambiance', + 'ambiguity', + 'ambiguous', + 'ambition', + 'ambitious', + 'ambulance', + 'ambush', + 'amendable', + 'amendment', + 'amends', + 'amenity', + 'amiable', + 'amicably', + 'amid', + 'amigo', + 'amino', + 'amiss', + 'ammonia', + 'ammonium', + 'amnesty', + 'amniotic', + 'among', + 'amount', + 'amperage', + 'ample', + 'amplifier', + 'amplify', + 'amply', + 'amuck', + 'amulet', + 'amusable', + 'amused', + 'amusement', + 'amuser', + 'amusing', + 'anaconda', + 'anaerobic', + 'anagram', + 'anatomist', + 'anatomy', + 'anchor', + 'anchovy', + 'ancient', + 'android', + 'anemia', + 'anemic', + 'aneurism', + 'anew', + 'angelfish', + 'angelic', + 'anger', + 'angled', + 'angler', + 'angles', + 'angling', + 'angrily', + 'angriness', + 'anguished', + 'angular', + 'animal', + 'animate', + 'animating', + 'animation', + 'animator', + 'anime', + 'animosity', + 'ankle', + 'annex', + 'annotate', + 'announcer', + 'annoying', + 'annually', + 'annuity', + 'anointer', + 'another', + 'answering', + 'antacid', + 'antarctic', + 'anteater', + 'antelope', + 'antennae', + 'anthem', + 'anthill', + 'anthology', + 'antibody', + 'antics', + 'antidote', + 'antihero', + 'antiquely', + 'antiques', + 'antiquity', + 'antirust', + 'antitoxic', + 'antitrust', + 'antiviral', + 'antivirus', + 'antler', + 'antonym', + 'antsy', + 'anvil', + 'anybody', + 'anyhow', + 'anymore', + 'anyone', + 'anyplace', + 'anything', + 'anytime', + 'anyway', + 'anywhere', + 'aorta', + 'apache', + 'apostle', + 'appealing', + 'appear', + 'appease', + 'appeasing', + 'appendage', + 'appendix', + 'appetite', + 'appetizer', + 'applaud', + 'applause', + 'apple', + 'appliance', + 'applicant', + 'applied', + 'apply', + 'appointee', + 'appraisal', + 'appraiser', + 'apprehend', + 'approach', + 'approval', + 'approve', + 'apricot', + 'april', + 'apron', + 'aptitude', + 'aptly', + 'aqua', + 'aqueduct', + 'arbitrary', + 'arbitrate', + 'ardently', + 'area', + 'arena', + 'arguable', + 'arguably', + 'argue', + 'arise', + 'armadillo', + 'armband', + 'armchair', + 'armed', + 'armful', + 'armhole', + 'arming', + 'armless', + 'armoire', + 'armored', + 'armory', + 'armrest', + 'army', + 'aroma', + 'arose', + 'around', + 'arousal', + 'arrange', + 'array', + 'arrest', + 'arrival', + 'arrive', + 'arrogance', + 'arrogant', + 'arson', + 'art', + 'ascend', + 'ascension', + 'ascent', + 'ascertain', + 'ashamed', + 'ashen', + 'ashes', + 'ashy', + 'aside', + 'askew', + 'asleep', + 'asparagus', + 'aspect', + 'aspirate', + 'aspire', + 'aspirin', + 'astonish', + 'astound', + 'astride', + 'astrology', + 'astronaut', + 'astronomy', + 'astute', + 'atlantic', + 'atlas', + 'atom', + 'atonable', + 'atop', + 'atrium', + 'atrocious', + 'atrophy', + 'attach', + 'attain', + 'attempt', + 'attendant', + 'attendee', + 'attention', + 'attentive', + 'attest', + 'attic', + 'attire', + 'attitude', + 'attractor', + 'attribute', + 'atypical', + 'auction', + 'audacious', + 'audacity', + 'audible', + 'audibly', + 'audience', + 'audio', + 'audition', + 'augmented', + 'august', + 'authentic', + 'author', + 'autism', + 'autistic', + 'autograph', + 'automaker', + 'automated', + 'automatic', + 'autopilot', + 'available', + 'avalanche', + 'avatar', + 'avenge', + 'avenging', + 'avenue', + 'average', + 'aversion', + 'avert', + 'aviation', + 'aviator', + 'avid', + 'avoid', + 'await', + 'awaken', + 'award', + 'aware', + 'awhile', + 'awkward', + 'awning', + 'awoke', + 'awry', + 'axis', + 'babble', + 'babbling', + 'babied', + 'baboon', + 'backache', + 'backboard', + 'backboned', + 'backdrop', + 'backed', + 'backer', + 'backfield', + 'backfire', + 'backhand', + 'backing', + 'backlands', + 'backlash', + 'backless', + 'backlight', + 'backlit', + 'backlog', + 'backpack', + 'backpedal', + 'backrest', + 'backroom', + 'backshift', + 'backside', + 'backslid', + 'backspace', + 'backspin', + 'backstab', + 'backstage', + 'backtalk', + 'backtrack', + 'backup', + 'backward', + 'backwash', + 'backwater', + 'backyard', + 'bacon', + 'bacteria', + 'bacterium', + 'badass', + 'badge', + 'badland', + 'badly', + 'badness', + 'baffle', + 'baffling', + 'bagel', + 'bagful', + 'baggage', + 'bagged', + 'baggie', + 'bagginess', + 'bagging', + 'baggy', + 'bagpipe', + 'baguette', + 'baked', + 'bakery', + 'bakeshop', + 'baking', + 'balance', + 'balancing', + 'balcony', + 'balmy', + 'balsamic', + 'bamboo', + 'banana', + 'banish', + 'banister', + 'banjo', + 'bankable', + 'bankbook', + 'banked', + 'banker', + 'banking', + 'banknote', + 'bankroll', + 'banner', + 'bannister', + 'banshee', + 'banter', + 'barbecue', + 'barbed', + 'barbell', + 'barber', + 'barcode', + 'barge', + 'bargraph', + 'barista', + 'baritone', + 'barley', + 'barmaid', + 'barman', + 'barn', + 'barometer', + 'barrack', + 'barracuda', + 'barrel', + 'barrette', + 'barricade', + 'barrier', + 'barstool', + 'bartender', + 'barterer', + 'bash', + 'basically', + 'basics', + 'basil', + 'basin', + 'basis', + 'basket', + 'batboy', + 'batch', + 'bath', + 'baton', + 'bats', + 'battalion', + 'battered', + 'battering', + 'battery', + 'batting', + 'battle', + 'bauble', + 'bazooka', + 'blabber', + 'bladder', + 'blade', + 'blah', + 'blame', + 'blaming', + 'blanching', + 'blandness', + 'blank', + 'blaspheme', + 'blasphemy', + 'blast', + 'blatancy', + 'blatantly', + 'blazer', + 'blazing', + 'bleach', + 'bleak', + 'bleep', + 'blemish', + 'blend', + 'bless', + 'blighted', + 'blimp', + 'bling', + 'blinked', + 'blinker', + 'blinking', + 'blinks', + 'blip', + 'blissful', + 'blitz', + 'blizzard', + 'bloated', + 'bloating', + 'blob', + 'blog', + 'bloomers', + 'blooming', + 'blooper', + 'blot', + 'blouse', + 'blubber', + 'bluff', + 'bluish', + 'blunderer', + 'blunt', + 'blurb', + 'blurred', + 'blurry', + 'blurt', + 'blush', + 'blustery', + 'boaster', + 'boastful', + 'boasting', + 'boat', + 'bobbed', + 'bobbing', + 'bobble', + 'bobcat', + 'bobsled', + 'bobtail', + 'bodacious', + 'body', + 'bogged', + 'boggle', + 'bogus', + 'boil', + 'bok', + 'bolster', + 'bolt', + 'bonanza', + 'bonded', + 'bonding', + 'bondless', + 'boned', + 'bonehead', + 'boneless', + 'bonelike', + 'boney', + 'bonfire', + 'bonnet', + 'bonsai', + 'bonus', + 'bony', + 'boogeyman', + 'boogieman', + 'book', + 'boondocks', + 'booted', + 'booth', + 'bootie', + 'booting', + 'bootlace', + 'bootleg', + 'boots', + 'boozy', + 'borax', + 'boring', + 'borough', + 'borrower', + 'borrowing', + 'boss', + 'botanical', + 'botanist', + 'botany', + 'botch', + 'both', + 'bottle', + 'bottling', + 'bottom', + 'bounce', + 'bouncing', + 'bouncy', + 'bounding', + 'boundless', + 'bountiful', + 'bovine', + 'boxcar', + 'boxer', + 'boxing', + 'boxlike', + 'boxy', + 'breach', + 'breath', + 'breeches', + 'breeching', + 'breeder', + 'breeding', + 'breeze', + 'breezy', + 'brethren', + 'brewery', + 'brewing', + 'briar', + 'bribe', + 'brick', + 'bride', + 'bridged', + 'brigade', + 'bright', + 'brilliant', + 'brim', + 'bring', + 'brink', + 'brisket', + 'briskly', + 'briskness', + 'bristle', + 'brittle', + 'broadband', + 'broadcast', + 'broaden', + 'broadly', + 'broadness', + 'broadside', + 'broadways', + 'broiler', + 'broiling', + 'broken', + 'broker', + 'bronchial', + 'bronco', + 'bronze', + 'bronzing', + 'brook', + 'broom', + 'brought', + 'browbeat', + 'brownnose', + 'browse', + 'browsing', + 'bruising', + 'brunch', + 'brunette', + 'brunt', + 'brush', + 'brussels', + 'brute', + 'brutishly', + 'bubble', + 'bubbling', + 'bubbly', + 'buccaneer', + 'bucked', + 'bucket', + 'buckle', + 'buckshot', + 'buckskin', + 'bucktooth', + 'buckwheat', + 'buddhism', + 'buddhist', + 'budding', + 'buddy', + 'budget', + 'buffalo', + 'buffed', + 'buffer', + 'buffing', + 'buffoon', + 'buggy', + 'bulb', + 'bulge', + 'bulginess', + 'bulgur', + 'bulk', + 'bulldog', + 'bulldozer', + 'bullfight', + 'bullfrog', + 'bullhorn', + 'bullion', + 'bullish', + 'bullpen', + 'bullring', + 'bullseye', + 'bullwhip', + 'bully', + 'bunch', + 'bundle', + 'bungee', + 'bunion', + 'bunkbed', + 'bunkhouse', + 'bunkmate', + 'bunny', + 'bunt', + 'busboy', + 'bush', + 'busily', + 'busload', + 'bust', + 'busybody', + 'buzz', + 'cabana', + 'cabbage', + 'cabbie', + 'cabdriver', + 'cable', + 'caboose', + 'cache', + 'cackle', + 'cacti', + 'cactus', + 'caddie', + 'caddy', + 'cadet', + 'cadillac', + 'cadmium', + 'cage', + 'cahoots', + 'cake', + 'calamari', + 'calamity', + 'calcium', + 'calculate', + 'calculus', + 'caliber', + 'calibrate', + 'calm', + 'caloric', + 'calorie', + 'calzone', + 'camcorder', + 'cameo', + 'camera', + 'camisole', + 'camper', + 'campfire', + 'camping', + 'campsite', + 'campus', + 'canal', + 'canary', + 'cancel', + 'candied', + 'candle', + 'candy', + 'cane', + 'canine', + 'canister', + 'cannabis', + 'canned', + 'canning', + 'cannon', + 'cannot', + 'canola', + 'canon', + 'canopener', + 'canopy', + 'canteen', + 'canyon', + 'capable', + 'capably', + 'capacity', + 'cape', + 'capillary', + 'capital', + 'capitol', + 'capped', + 'capricorn', + 'capsize', + 'capsule', + 'caption', + 'captivate', + 'captive', + 'captivity', + 'capture', + 'caramel', + 'carat', + 'caravan', + 'carbon', + 'cardboard', + 'carded', + 'cardiac', + 'cardigan', + 'cardinal', + 'cardstock', + 'carefully', + 'caregiver', + 'careless', + 'caress', + 'caretaker', + 'cargo', + 'caring', + 'carless', + 'carload', + 'carmaker', + 'carnage', + 'carnation', + 'carnival', + 'carnivore', + 'carol', + 'carpenter', + 'carpentry', + 'carpool', + 'carport', + 'carried', + 'carrot', + 'carrousel', + 'carry', + 'cartel', + 'cartload', + 'carton', + 'cartoon', + 'cartridge', + 'cartwheel', + 'carve', + 'carving', + 'carwash', + 'cascade', + 'case', + 'cash', + 'casing', + 'casino', + 'casket', + 'cassette', + 'casually', + 'casualty', + 'catacomb', + 'catalog', + 'catalyst', + 'catalyze', + 'catapult', + 'cataract', + 'catatonic', + 'catcall', + 'catchable', + 'catcher', + 'catching', + 'catchy', + 'caterer', + 'catering', + 'catfight', + 'catfish', + 'cathedral', + 'cathouse', + 'catlike', + 'catnap', + 'catnip', + 'catsup', + 'cattail', + 'cattishly', + 'cattle', + 'catty', + 'catwalk', + 'caucasian', + 'caucus', + 'causal', + 'causation', + 'cause', + 'causing', + 'cauterize', + 'caution', + 'cautious', + 'cavalier', + 'cavalry', + 'caviar', + 'cavity', + 'cedar', + 'celery', + 'celestial', + 'celibacy', + 'celibate', + 'celtic', + 'cement', + 'census', + 'ceramics', + 'ceremony', + 'certainly', + 'certainty', + 'certified', + 'certify', + 'cesarean', + 'cesspool', + 'chafe', + 'chaffing', + 'chain', + 'chair', + 'chalice', + 'challenge', + 'chamber', + 'chamomile', + 'champion', + 'chance', + 'change', + 'channel', + 'chant', + 'chaos', + 'chaperone', + 'chaplain', + 'chapped', + 'chaps', + 'chapter', + 'character', + 'charbroil', + 'charcoal', + 'charger', + 'charging', + 'chariot', + 'charity', + 'charm', + 'charred', + 'charter', + 'charting', + 'chase', + 'chasing', + 'chaste', + 'chastise', + 'chastity', + 'chatroom', + 'chatter', + 'chatting', + 'chatty', + 'cheating', + 'cheddar', + 'cheek', + 'cheer', + 'cheese', + 'cheesy', + 'chef', + 'chemicals', + 'chemist', + 'chemo', + 'cherisher', + 'cherub', + 'chess', + 'chest', + 'chevron', + 'chevy', + 'chewable', + 'chewer', + 'chewing', + 'chewy', + 'chief', + 'chihuahua', + 'childcare', + 'childhood', + 'childish', + 'childless', + 'childlike', + 'chili', + 'chill', + 'chimp', + 'chip', + 'chirping', + 'chirpy', + 'chitchat', + 'chivalry', + 'chive', + 'chloride', + 'chlorine', + 'choice', + 'chokehold', + 'choking', + 'chomp', + 'chooser', + 'choosing', + 'choosy', + 'chop', + 'chosen', + 'chowder', + 'chowtime', + 'chrome', + 'chubby', + 'chuck', + 'chug', + 'chummy', + 'chump', + 'chunk', + 'churn', + 'chute', + 'cider', + 'cilantro', + 'cinch', + 'cinema', + 'cinnamon', + 'circle', + 'circling', + 'circular', + 'circulate', + 'circus', + 'citable', + 'citadel', + 'citation', + 'citizen', + 'citric', + 'citrus', + 'city', + 'civic', + 'civil', + 'clad', + 'claim', + 'clambake', + 'clammy', + 'clamor', + 'clamp', + 'clamshell', + 'clang', + 'clanking', + 'clapped', + 'clapper', + 'clapping', + 'clarify', + 'clarinet', + 'clarity', + 'clash', + 'clasp', + 'class', + 'clatter', + 'clause', + 'clavicle', + 'claw', + 'clay', + 'clean', + 'clear', + 'cleat', + 'cleaver', + 'cleft', + 'clench', + 'clergyman', + 'clerical', + 'clerk', + 'clever', + 'clicker', + 'client', + 'climate', + 'climatic', + 'cling', + 'clinic', + 'clinking', + 'clip', + 'clique', + 'cloak', + 'clobber', + 'clock', + 'clone', + 'cloning', + 'closable', + 'closure', + 'clothes', + 'clothing', + 'cloud', + 'clover', + 'clubbed', + 'clubbing', + 'clubhouse', + 'clump', + 'clumsily', + 'clumsy', + 'clunky', + 'clustered', + 'clutch', + 'clutter', + 'coach', + 'coagulant', + 'coastal', + 'coaster', + 'coasting', + 'coastland', + 'coastline', + 'coat', + 'coauthor', + 'cobalt', + 'cobbler', + 'cobweb', + 'cocoa', + 'coconut', + 'cod', + 'coeditor', + 'coerce', + 'coexist', + 'coffee', + 'cofounder', + 'cognition', + 'cognitive', + 'cogwheel', + 'coherence', + 'coherent', + 'cohesive', + 'coil', + 'coke', + 'cola', + 'cold', + 'coleslaw', + 'coliseum', + 'collage', + 'collapse', + 'collar', + 'collected', + 'collector', + 'collide', + 'collie', + 'collision', + 'colonial', + 'colonist', + 'colonize', + 'colony', + 'colossal', + 'colt', + 'coma', + 'come', + 'comfort', + 'comfy', + 'comic', + 'coming', + 'comma', + 'commence', + 'commend', + 'comment', + 'commerce', + 'commode', + 'commodity', + 'commodore', + 'common', + 'commotion', + 'commute', + 'commuting', + 'compacted', + 'compacter', + 'compactly', + 'compactor', + 'companion', + 'company', + 'compare', + 'compel', + 'compile', + 'comply', + 'component', + 'composed', + 'composer', + 'composite', + 'compost', + 'composure', + 'compound', + 'compress', + 'comprised', + 'computer', + 'computing', + 'comrade', + 'concave', + 'conceal', + 'conceded', + 'concept', + 'concerned', + 'concert', + 'conch', + 'concierge', + 'concise', + 'conclude', + 'concrete', + 'concur', + 'condense', + 'condiment', + 'condition', + 'condone', + 'conducive', + 'conductor', + 'conduit', + 'cone', + 'confess', + 'confetti', + 'confidant', + 'confident', + 'confider', + 'confiding', + 'configure', + 'confined', + 'confining', + 'confirm', + 'conflict', + 'conform', + 'confound', + 'confront', + 'confused', + 'confusing', + 'confusion', + 'congenial', + 'congested', + 'congrats', + 'congress', + 'conical', + 'conjoined', + 'conjure', + 'conjuror', + 'connected', + 'connector', + 'consensus', + 'consent', + 'console', + 'consoling', + 'consonant', + 'constable', + 'constant', + 'constrain', + 'constrict', + 'construct', + 'consult', + 'consumer', + 'consuming', + 'contact', + 'container', + 'contempt', + 'contend', + 'contented', + 'contently', + 'contents', + 'contest', + 'context', + 'contort', + 'contour', + 'contrite', + 'control', + 'contusion', + 'convene', + 'convent', + 'copartner', + 'cope', + 'copied', + 'copier', + 'copilot', + 'coping', + 'copious', + 'copper', + 'copy', + 'coral', + 'cork', + 'cornball', + 'cornbread', + 'corncob', + 'cornea', + 'corned', + 'corner', + 'cornfield', + 'cornflake', + 'cornhusk', + 'cornmeal', + 'cornstalk', + 'corny', + 'coronary', + 'coroner', + 'corporal', + 'corporate', + 'corral', + 'correct', + 'corridor', + 'corrode', + 'corroding', + 'corrosive', + 'corsage', + 'corset', + 'cortex', + 'cosigner', + 'cosmetics', + 'cosmic', + 'cosmos', + 'cosponsor', + 'cost', + 'cottage', + 'cotton', + 'couch', + 'cough', + 'could', + 'countable', + 'countdown', + 'counting', + 'countless', + 'country', + 'county', + 'courier', + 'covenant', + 'cover', + 'coveted', + 'coveting', + 'coyness', + 'cozily', + 'coziness', + 'cozy', + 'crabbing', + 'crabgrass', + 'crablike', + 'crabmeat', + 'cradle', + 'cradling', + 'crafter', + 'craftily', + 'craftsman', + 'craftwork', + 'crafty', + 'cramp', + 'cranberry', + 'crane', + 'cranial', + 'cranium', + 'crank', + 'crate', + 'crave', + 'craving', + 'crawfish', + 'crawlers', + 'crawling', + 'crayfish', + 'crayon', + 'crazed', + 'crazily', + 'craziness', + 'crazy', + 'creamed', + 'creamer', + 'creamlike', + 'crease', + 'creasing', + 'creatable', + 'create', + 'creation', + 'creative', + 'creature', + 'credible', + 'credibly', + 'credit', + 'creed', + 'creme', + 'creole', + 'crepe', + 'crept', + 'crescent', + 'crested', + 'cresting', + 'crestless', + 'crevice', + 'crewless', + 'crewman', + 'crewmate', + 'crib', + 'cricket', + 'cried', + 'crier', + 'crimp', + 'crimson', + 'cringe', + 'cringing', + 'crinkle', + 'crinkly', + 'crisped', + 'crisping', + 'crisply', + 'crispness', + 'crispy', + 'criteria', + 'critter', + 'croak', + 'crock', + 'crook', + 'croon', + 'crop', + 'cross', + 'crouch', + 'crouton', + 'crowbar', + 'crowd', + 'crown', + 'crucial', + 'crudely', + 'crudeness', + 'cruelly', + 'cruelness', + 'cruelty', + 'crumb', + 'crummiest', + 'crummy', + 'crumpet', + 'crumpled', + 'cruncher', + 'crunching', + 'crunchy', + 'crusader', + 'crushable', + 'crushed', + 'crusher', + 'crushing', + 'crust', + 'crux', + 'crying', + 'cryptic', + 'crystal', + 'cubbyhole', + 'cube', + 'cubical', + 'cubicle', + 'cucumber', + 'cuddle', + 'cuddly', + 'cufflink', + 'culinary', + 'culminate', + 'culpable', + 'culprit', + 'cultivate', + 'cultural', + 'culture', + 'cupbearer', + 'cupcake', + 'cupid', + 'cupped', + 'cupping', + 'curable', + 'curator', + 'curdle', + 'cure', + 'curfew', + 'curing', + 'curled', + 'curler', + 'curliness', + 'curling', + 'curly', + 'curry', + 'curse', + 'cursive', + 'cursor', + 'curtain', + 'curtly', + 'curtsy', + 'curvature', + 'curve', + 'curvy', + 'cushy', + 'cusp', + 'cussed', + 'custard', + 'custodian', + 'custody', + 'customary', + 'customer', + 'customize', + 'customs', + 'cut', + 'cycle', + 'cyclic', + 'cycling', + 'cyclist', + 'cylinder', + 'cymbal', + 'cytoplasm', + 'cytoplast', + 'dab', + 'dad', + 'daffodil', + 'dagger', + 'daily', + 'daintily', + 'dainty', + 'dairy', + 'daisy', + 'dallying', + 'dance', + 'dancing', + 'dandelion', + 'dander', + 'dandruff', + 'dandy', + 'danger', + 'dangle', + 'dangling', + 'daredevil', + 'dares', + 'daringly', + 'darkened', + 'darkening', + 'darkish', + 'darkness', + 'darkroom', + 'darling', + 'darn', + 'dart', + 'darwinism', + 'dash', + 'dastardly', + 'data', + 'datebook', + 'dating', + 'daughter', + 'daunting', + 'dawdler', + 'dawn', + 'daybed', + 'daybreak', + 'daycare', + 'daydream', + 'daylight', + 'daylong', + 'dayroom', + 'daytime', + 'dazzler', + 'dazzling', + 'deacon', + 'deafening', + 'deafness', + 'dealer', + 'dealing', + 'dealmaker', + 'dealt', + 'dean', + 'debatable', + 'debate', + 'debating', + 'debit', + 'debrief', + 'debtless', + 'debtor', + 'debug', + 'debunk', + 'decade', + 'decaf', + 'decal', + 'decathlon', + 'decay', + 'deceased', + 'deceit', + 'deceiver', + 'deceiving', + 'december', + 'decency', + 'decent', + 'deception', + 'deceptive', + 'decibel', + 'decidable', + 'decimal', + 'decimeter', + 'decipher', + 'deck', + 'declared', + 'decline', + 'decode', + 'decompose', + 'decorated', + 'decorator', + 'decoy', + 'decrease', + 'decree', + 'dedicate', + 'dedicator', + 'deduce', + 'deduct', + 'deed', + 'deem', + 'deepen', + 'deeply', + 'deepness', + 'deface', + 'defacing', + 'defame', + 'default', + 'defeat', + 'defection', + 'defective', + 'defendant', + 'defender', + 'defense', + 'defensive', + 'deferral', + 'deferred', + 'defiance', + 'defiant', + 'defile', + 'defiling', + 'define', + 'definite', + 'deflate', + 'deflation', + 'deflator', + 'deflected', + 'deflector', + 'defog', + 'deforest', + 'defraud', + 'defrost', + 'deftly', + 'defuse', + 'defy', + 'degraded', + 'degrading', + 'degrease', + 'degree', + 'dehydrate', + 'deity', + 'dejected', + 'delay', + 'delegate', + 'delegator', + 'delete', + 'deletion', + 'delicacy', + 'delicate', + 'delicious', + 'delighted', + 'delirious', + 'delirium', + 'deliverer', + 'delivery', + 'delouse', + 'delta', + 'deluge', + 'delusion', + 'deluxe', + 'demanding', + 'demeaning', + 'demeanor', + 'demise', + 'democracy', + 'democrat', + 'demote', + 'demotion', + 'demystify', + 'denatured', + 'deniable', + 'denial', + 'denim', + 'denote', + 'dense', + 'density', + 'dental', + 'dentist', + 'denture', + 'deny', + 'deodorant', + 'deodorize', + 'departed', + 'departure', + 'depict', + 'deplete', + 'depletion', + 'deplored', + 'deploy', + 'deport', + 'depose', + 'depraved', + 'depravity', + 'deprecate', + 'depress', + 'deprive', + 'depth', + 'deputize', + 'deputy', + 'derail', + 'deranged', + 'derby', + 'derived', + 'desecrate', + 'deserve', + 'deserving', + 'designate', + 'designed', + 'designer', + 'designing', + 'deskbound', + 'desktop', + 'deskwork', + 'desolate', + 'despair', + 'despise', + 'despite', + 'destiny', + 'destitute', + 'destruct', + 'detached', + 'detail', + 'detection', + 'detective', + 'detector', + 'detention', + 'detergent', + 'detest', + 'detonate', + 'detonator', + 'detoxify', + 'detract', + 'deuce', + 'devalue', + 'deviancy', + 'deviant', + 'deviate', + 'deviation', + 'deviator', + 'device', + 'devious', + 'devotedly', + 'devotee', + 'devotion', + 'devourer', + 'devouring', + 'devoutly', + 'dexterity', + 'dexterous', + 'diabetes', + 'diabetic', + 'diabolic', + 'diagnoses', + 'diagnosis', + 'diagram', + 'dial', + 'diameter', + 'diaper', + 'diaphragm', + 'diary', + 'dice', + 'dicing', + 'dictate', + 'dictation', + 'dictator', + 'difficult', + 'diffused', + 'diffuser', + 'diffusion', + 'diffusive', + 'dig', + 'dilation', + 'diligence', + 'diligent', + 'dill', + 'dilute', + 'dime', + 'diminish', + 'dimly', + 'dimmed', + 'dimmer', + 'dimness', + 'dimple', + 'diner', + 'dingbat', + 'dinghy', + 'dinginess', + 'dingo', + 'dingy', + 'dining', + 'dinner', + 'diocese', + 'dioxide', + 'diploma', + 'dipped', + 'dipper', + 'dipping', + 'directed', + 'direction', + 'directive', + 'directly', + 'directory', + 'direness', + 'dirtiness', + 'disabled', + 'disagree', + 'disallow', + 'disarm', + 'disarray', + 'disaster', + 'disband', + 'disbelief', + 'disburse', + 'discard', + 'discern', + 'discharge', + 'disclose', + 'discolor', + 'discount', + 'discourse', + 'discover', + 'discuss', + 'disdain', + 'disengage', + 'disfigure', + 'disgrace', + 'dish', + 'disinfect', + 'disjoin', + 'disk', + 'dislike', + 'disliking', + 'dislocate', + 'dislodge', + 'disloyal', + 'dismantle', + 'dismay', + 'dismiss', + 'dismount', + 'disobey', + 'disorder', + 'disown', + 'disparate', + 'disparity', + 'dispatch', + 'dispense', + 'dispersal', + 'dispersed', + 'disperser', + 'displace', + 'display', + 'displease', + 'disposal', + 'dispose', + 'disprove', + 'dispute', + 'disregard', + 'disrupt', + 'dissuade', + 'distance', + 'distant', + 'distaste', + 'distill', + 'distinct', + 'distort', + 'distract', + 'distress', + 'district', + 'distrust', + 'ditch', + 'ditto', + 'ditzy', + 'dividable', + 'divided', + 'dividend', + 'dividers', + 'dividing', + 'divinely', + 'diving', + 'divinity', + 'divisible', + 'divisibly', + 'division', + 'divisive', + 'divorcee', + 'dizziness', + 'dizzy', + 'doable', + 'docile', + 'dock', + 'doctrine', + 'document', + 'dodge', + 'dodgy', + 'doily', + 'doing', + 'dole', + 'dollar', + 'dollhouse', + 'dollop', + 'dolly', + 'dolphin', + 'domain', + 'domelike', + 'domestic', + 'dominion', + 'dominoes', + 'donated', + 'donation', + 'donator', + 'donor', + 'donut', + 'doodle', + 'doorbell', + 'doorframe', + 'doorknob', + 'doorman', + 'doormat', + 'doornail', + 'doorpost', + 'doorstep', + 'doorstop', + 'doorway', + 'doozy', + 'dork', + 'dormitory', + 'dorsal', + 'dosage', + 'dose', + 'dotted', + 'doubling', + 'douche', + 'dove', + 'down', + 'dowry', + 'doze', + 'drab', + 'dragging', + 'dragonfly', + 'dragonish', + 'dragster', + 'drainable', + 'drainage', + 'drained', + 'drainer', + 'drainpipe', + 'dramatic', + 'dramatize', + 'drank', + 'drapery', + 'drastic', + 'draw', + 'dreaded', + 'dreadful', + 'dreadlock', + 'dreamboat', + 'dreamily', + 'dreamland', + 'dreamless', + 'dreamlike', + 'dreamt', + 'dreamy', + 'drearily', + 'dreary', + 'drench', + 'dress', + 'drew', + 'dribble', + 'dried', + 'drier', + 'drift', + 'driller', + 'drilling', + 'drinkable', + 'drinking', + 'dripping', + 'drippy', + 'drivable', + 'driven', + 'driver', + 'driveway', + 'driving', + 'drizzle', + 'drizzly', + 'drone', + 'drool', + 'droop', + 'drop-down', + 'dropbox', + 'dropkick', + 'droplet', + 'dropout', + 'dropper', + 'drove', + 'drown', + 'drowsily', + 'drudge', + 'drum', + 'dry', + 'dubbed', + 'dubiously', + 'duchess', + 'duckbill', + 'ducking', + 'duckling', + 'ducktail', + 'ducky', + 'duct', + 'dude', + 'duffel', + 'dugout', + 'duh', + 'duke', + 'duller', + 'dullness', + 'duly', + 'dumping', + 'dumpling', + 'dumpster', + 'duo', + 'dupe', + 'duplex', + 'duplicate', + 'duplicity', + 'durable', + 'durably', + 'duration', + 'duress', + 'during', + 'dusk', + 'dust', + 'dutiful', + 'duty', + 'duvet', + 'dwarf', + 'dweeb', + 'dwelled', + 'dweller', + 'dwelling', + 'dwindle', + 'dwindling', + 'dynamic', + 'dynamite', + 'dynasty', + 'dyslexia', + 'dyslexic', + 'each', + 'eagle', + 'earache', + 'eardrum', + 'earflap', + 'earful', + 'earlobe', + 'early', + 'earmark', + 'earmuff', + 'earphone', + 'earpiece', + 'earplugs', + 'earring', + 'earshot', + 'earthen', + 'earthlike', + 'earthling', + 'earthly', + 'earthworm', + 'earthy', + 'earwig', + 'easeful', + 'easel', + 'easiest', + 'easily', + 'easiness', + 'easing', + 'eastbound', + 'eastcoast', + 'easter', + 'eastward', + 'eatable', + 'eaten', + 'eatery', + 'eating', + 'eats', + 'ebay', + 'ebony', + 'ebook', + 'ecard', + 'eccentric', + 'echo', + 'eclair', + 'eclipse', + 'ecologist', + 'ecology', + 'economic', + 'economist', + 'economy', + 'ecosphere', + 'ecosystem', + 'edge', + 'edginess', + 'edging', + 'edgy', + 'edition', + 'editor', + 'educated', + 'education', + 'educator', + 'eel', + 'effective', + 'effects', + 'efficient', + 'effort', + 'eggbeater', + 'egging', + 'eggnog', + 'eggplant', + 'eggshell', + 'egomaniac', + 'egotism', + 'egotistic', + 'either', + 'eject', + 'elaborate', + 'elastic', + 'elated', + 'elbow', + 'eldercare', + 'elderly', + 'eldest', + 'electable', + 'election', + 'elective', + 'elephant', + 'elevate', + 'elevating', + 'elevation', + 'elevator', + 'eleven', + 'elf', + 'eligible', + 'eligibly', + 'eliminate', + 'elite', + 'elitism', + 'elixir', + 'elk', + 'ellipse', + 'elliptic', + 'elm', + 'elongated', + 'elope', + 'eloquence', + 'eloquent', + 'elsewhere', + 'elude', + 'elusive', + 'elves', + 'email', + 'embargo', + 'embark', + 'embassy', + 'embattled', + 'embellish', + 'ember', + 'embezzle', + 'emblaze', + 'emblem', + 'embody', + 'embolism', + 'emboss', + 'embroider', + 'emcee', + 'emerald', + 'emergency', + 'emission', + 'emit', + 'emote', + 'emoticon', + 'emotion', + 'empathic', + 'empathy', + 'emperor', + 'emphases', + 'emphasis', + 'emphasize', + 'emphatic', + 'empirical', + 'employed', + 'employee', + 'employer', + 'emporium', + 'empower', + 'emptier', + 'emptiness', + 'empty', + 'emu', + 'enable', + 'enactment', + 'enamel', + 'enchanted', + 'enchilada', + 'encircle', + 'enclose', + 'enclosure', + 'encode', + 'encore', + 'encounter', + 'encourage', + 'encroach', + 'encrust', + 'encrypt', + 'endanger', + 'endeared', + 'endearing', + 'ended', + 'ending', + 'endless', + 'endnote', + 'endocrine', + 'endorphin', + 'endorse', + 'endowment', + 'endpoint', + 'endurable', + 'endurance', + 'enduring', + 'energetic', + 'energize', + 'energy', + 'enforced', + 'enforcer', + 'engaged', + 'engaging', + 'engine', + 'engorge', + 'engraved', + 'engraver', + 'engraving', + 'engross', + 'engulf', + 'enhance', + 'enigmatic', + 'enjoyable', + 'enjoyably', + 'enjoyer', + 'enjoying', + 'enjoyment', + 'enlarged', + 'enlarging', + 'enlighten', + 'enlisted', + 'enquirer', + 'enrage', + 'enrich', + 'enroll', + 'enslave', + 'ensnare', + 'ensure', + 'entail', + 'entangled', + 'entering', + 'entertain', + 'enticing', + 'entire', + 'entitle', + 'entity', + 'entomb', + 'entourage', + 'entrap', + 'entree', + 'entrench', + 'entrust', + 'entryway', + 'entwine', + 'enunciate', + 'envelope', + 'enviable', + 'enviably', + 'envious', + 'envision', + 'envoy', + 'envy', + 'enzyme', + 'epic', + 'epidemic', + 'epidermal', + 'epidermis', + 'epidural', + 'epilepsy', + 'epileptic', + 'epilogue', + 'epiphany', + 'episode', + 'equal', + 'equate', + 'equation', + 'equator', + 'equinox', + 'equipment', + 'equity', + 'equivocal', + 'eradicate', + 'erasable', + 'erased', + 'eraser', + 'erasure', + 'ergonomic', + 'errand', + 'errant', + 'erratic', + 'error', + 'erupt', + 'escalate', + 'escalator', + 'escapable', + 'escapade', + 'escapist', + 'escargot', + 'eskimo', + 'esophagus', + 'espionage', + 'espresso', + 'esquire', + 'essay', + 'essence', + 'essential', + 'establish', + 'estate', + 'esteemed', + 'estimate', + 'estimator', + 'estranged', + 'estrogen', + 'etching', + 'eternal', + 'eternity', + 'ethanol', + 'ether', + 'ethically', + 'ethics', + 'euphemism', + 'evacuate', + 'evacuee', + 'evade', + 'evaluate', + 'evaluator', + 'evaporate', + 'evasion', + 'evasive', + 'even', + 'everglade', + 'evergreen', + 'everybody', + 'everyday', + 'everyone', + 'evict', + 'evidence', + 'evident', + 'evil', + 'evoke', + 'evolution', + 'evolve', + 'exact', + 'exalted', + 'example', + 'excavate', + 'excavator', + 'exceeding', + 'exception', + 'excess', + 'exchange', + 'excitable', + 'exciting', + 'exclaim', + 'exclude', + 'excluding', + 'exclusion', + 'exclusive', + 'excretion', + 'excretory', + 'excursion', + 'excusable', + 'excusably', + 'excuse', + 'exemplary', + 'exemplify', + 'exemption', + 'exerciser', + 'exert', + 'exes', + 'exfoliate', + 'exhale', + 'exhaust', + 'exhume', + 'exile', + 'existing', + 'exit', + 'exodus', + 'exonerate', + 'exorcism', + 'exorcist', + 'expand', + 'expanse', + 'expansion', + 'expansive', + 'expectant', + 'expedited', + 'expediter', + 'expel', + 'expend', + 'expenses', + 'expensive', + 'expert', + 'expire', + 'expiring', + 'explain', + 'expletive', + 'explicit', + 'explode', + 'exploit', + 'explore', + 'exploring', + 'exponent', + 'exporter', + 'exposable', + 'expose', + 'exposure', + 'express', + 'expulsion', + 'exquisite', + 'extended', + 'extending', + 'extent', + 'extenuate', + 'exterior', + 'external', + 'extinct', + 'extortion', + 'extradite', + 'extras', + 'extrovert', + 'extrude', + 'extruding', + 'exuberant', + 'fable', + 'fabric', + 'fabulous', + 'facebook', + 'facecloth', + 'facedown', + 'faceless', + 'facelift', + 'faceplate', + 'faceted', + 'facial', + 'facility', + 'facing', + 'facsimile', + 'faction', + 'factoid', + 'factor', + 'factsheet', + 'factual', + 'faculty', + 'fade', + 'fading', + 'failing', + 'falcon', + 'fall', + 'false', + 'falsify', + 'fame', + 'familiar', + 'family', + 'famine', + 'famished', + 'fanatic', + 'fancied', + 'fanciness', + 'fancy', + 'fanfare', + 'fang', + 'fanning', + 'fantasize', + 'fantastic', + 'fantasy', + 'fascism', + 'fastball', + 'faster', + 'fasting', + 'fastness', + 'faucet', + 'favorable', + 'favorably', + 'favored', + 'favoring', + 'favorite', + 'fax', + 'feast', + 'federal', + 'fedora', + 'feeble', + 'feed', + 'feel', + 'feisty', + 'feline', + 'felt-tip', + 'feminine', + 'feminism', + 'feminist', + 'feminize', + 'femur', + 'fence', + 'fencing', + 'fender', + 'ferment', + 'fernlike', + 'ferocious', + 'ferocity', + 'ferret', + 'ferris', + 'ferry', + 'fervor', + 'fester', + 'festival', + 'festive', + 'festivity', + 'fetal', + 'fetch', + 'fever', + 'fiber', + 'fiction', + 'fiddle', + 'fiddling', + 'fidelity', + 'fidgeting', + 'fidgety', + 'fifteen', + 'fifth', + 'fiftieth', + 'fifty', + 'figment', + 'figure', + 'figurine', + 'filing', + 'filled', + 'filler', + 'filling', + 'film', + 'filter', + 'filth', + 'filtrate', + 'finale', + 'finalist', + 'finalize', + 'finally', + 'finance', + 'financial', + 'finch', + 'fineness', + 'finer', + 'finicky', + 'finished', + 'finisher', + 'finishing', + 'finite', + 'finless', + 'finlike', + 'fiscally', + 'fit', + 'five', + 'flaccid', + 'flagman', + 'flagpole', + 'flagship', + 'flagstick', + 'flagstone', + 'flail', + 'flakily', + 'flaky', + 'flame', + 'flammable', + 'flanked', + 'flanking', + 'flannels', + 'flap', + 'flaring', + 'flashback', + 'flashbulb', + 'flashcard', + 'flashily', + 'flashing', + 'flashy', + 'flask', + 'flatbed', + 'flatfoot', + 'flatly', + 'flatness', + 'flatten', + 'flattered', + 'flatterer', + 'flattery', + 'flattop', + 'flatware', + 'flatworm', + 'flavored', + 'flavorful', + 'flavoring', + 'flaxseed', + 'fled', + 'fleshed', + 'fleshy', + 'flick', + 'flier', + 'flight', + 'flinch', + 'fling', + 'flint', + 'flip', + 'flirt', + 'float', + 'flock', + 'flogging', + 'flop', + 'floral', + 'florist', + 'floss', + 'flounder', + 'flyable', + 'flyaway', + 'flyer', + 'flying', + 'flyover', + 'flypaper', + 'foam', + 'foe', + 'fog', + 'foil', + 'folic', + 'folk', + 'follicle', + 'follow', + 'fondling', + 'fondly', + 'fondness', + 'fondue', + 'font', + 'food', + 'fool', + 'footage', + 'football', + 'footbath', + 'footboard', + 'footer', + 'footgear', + 'foothill', + 'foothold', + 'footing', + 'footless', + 'footman', + 'footnote', + 'footpad', + 'footpath', + 'footprint', + 'footrest', + 'footsie', + 'footsore', + 'footwear', + 'footwork', + 'fossil', + 'foster', + 'founder', + 'founding', + 'fountain', + 'fox', + 'foyer', + 'fraction', + 'fracture', + 'fragile', + 'fragility', + 'fragment', + 'fragrance', + 'fragrant', + 'frail', + 'frame', + 'framing', + 'frantic', + 'fraternal', + 'frayed', + 'fraying', + 'frays', + 'freckled', + 'freckles', + 'freebase', + 'freebee', + 'freebie', + 'freedom', + 'freefall', + 'freehand', + 'freeing', + 'freeload', + 'freely', + 'freemason', + 'freeness', + 'freestyle', + 'freeware', + 'freeway', + 'freewill', + 'freezable', + 'freezing', + 'freight', + 'french', + 'frenzied', + 'frenzy', + 'frequency', + 'frequent', + 'fresh', + 'fretful', + 'fretted', + 'friction', + 'friday', + 'fridge', + 'fried', + 'friend', + 'frighten', + 'frightful', + 'frigidity', + 'frigidly', + 'frill', + 'fringe', + 'frisbee', + 'frisk', + 'fritter', + 'frivolous', + 'frolic', + 'from', + 'front', + 'frostbite', + 'frosted', + 'frostily', + 'frosting', + 'frostlike', + 'frosty', + 'froth', + 'frown', + 'frozen', + 'fructose', + 'frugality', + 'frugally', + 'fruit', + 'frustrate', + 'frying', + 'gab', + 'gaffe', + 'gag', + 'gainfully', + 'gaining', + 'gains', + 'gala', + 'gallantly', + 'galleria', + 'gallery', + 'galley', + 'gallon', + 'gallows', + 'gallstone', + 'galore', + 'galvanize', + 'gambling', + 'game', + 'gaming', + 'gamma', + 'gander', + 'gangly', + 'gangrene', + 'gangway', + 'gap', + 'garage', + 'garbage', + 'garden', + 'gargle', + 'garland', + 'garlic', + 'garment', + 'garnet', + 'garnish', + 'garter', + 'gas', + 'gatherer', + 'gathering', + 'gating', + 'gauging', + 'gauntlet', + 'gauze', + 'gave', + 'gawk', + 'gazing', + 'gear', + 'gecko', + 'geek', + 'geiger', + 'gem', + 'gender', + 'generic', + 'generous', + 'genetics', + 'genre', + 'gentile', + 'gentleman', + 'gently', + 'gents', + 'geography', + 'geologic', + 'geologist', + 'geology', + 'geometric', + 'geometry', + 'geranium', + 'gerbil', + 'geriatric', + 'germicide', + 'germinate', + 'germless', + 'germproof', + 'gestate', + 'gestation', + 'gesture', + 'getaway', + 'getting', + 'getup', + 'giant', + 'gibberish', + 'giblet', + 'giddily', + 'giddiness', + 'giddy', + 'gift', + 'gigabyte', + 'gigahertz', + 'gigantic', + 'giggle', + 'giggling', + 'giggly', + 'gigolo', + 'gilled', + 'gills', + 'gimmick', + 'girdle', + 'giveaway', + 'given', + 'giver', + 'giving', + 'gizmo', + 'gizzard', + 'glacial', + 'glacier', + 'glade', + 'gladiator', + 'gladly', + 'glamorous', + 'glamour', + 'glance', + 'glancing', + 'glandular', + 'glare', + 'glaring', + 'glass', + 'glaucoma', + 'glazing', + 'gleaming', + 'gleeful', + 'glider', + 'gliding', + 'glimmer', + 'glimpse', + 'glisten', + 'glitch', + 'glitter', + 'glitzy', + 'gloater', + 'gloating', + 'gloomily', + 'gloomy', + 'glorified', + 'glorifier', + 'glorify', + 'glorious', + 'glory', + 'gloss', + 'glove', + 'glowing', + 'glowworm', + 'glucose', + 'glue', + 'gluten', + 'glutinous', + 'glutton', + 'gnarly', + 'gnat', + 'goal', + 'goatskin', + 'goes', + 'goggles', + 'going', + 'goldfish', + 'goldmine', + 'goldsmith', + 'golf', + 'goliath', + 'gonad', + 'gondola', + 'gone', + 'gong', + 'good', + 'gooey', + 'goofball', + 'goofiness', + 'goofy', + 'google', + 'goon', + 'gopher', + 'gore', + 'gorged', + 'gorgeous', + 'gory', + 'gosling', + 'gossip', + 'gothic', + 'gotten', + 'gout', + 'gown', + 'grab', + 'graceful', + 'graceless', + 'gracious', + 'gradation', + 'graded', + 'grader', + 'gradient', + 'grading', + 'gradually', + 'graduate', + 'graffiti', + 'grafted', + 'grafting', + 'grain', + 'granddad', + 'grandkid', + 'grandly', + 'grandma', + 'grandpa', + 'grandson', + 'granite', + 'granny', + 'granola', + 'grant', + 'granular', + 'grape', + 'graph', + 'grapple', + 'grappling', + 'grasp', + 'grass', + 'gratified', + 'gratify', + 'grating', + 'gratitude', + 'gratuity', + 'gravel', + 'graveness', + 'graves', + 'graveyard', + 'gravitate', + 'gravity', + 'gravy', + 'gray', + 'grazing', + 'greasily', + 'greedily', + 'greedless', + 'greedy', + 'green', + 'greeter', + 'greeting', + 'grew', + 'greyhound', + 'grid', + 'grief', + 'grievance', + 'grieving', + 'grievous', + 'grill', + 'grimace', + 'grimacing', + 'grime', + 'griminess', + 'grimy', + 'grinch', + 'grinning', + 'grip', + 'gristle', + 'grit', + 'groggily', + 'groggy', + 'groin', + 'groom', + 'groove', + 'grooving', + 'groovy', + 'grope', + 'ground', + 'grouped', + 'grout', + 'grove', + 'grower', + 'growing', + 'growl', + 'grub', + 'grudge', + 'grudging', + 'grueling', + 'gruffly', + 'grumble', + 'grumbling', + 'grumbly', + 'grumpily', + 'grunge', + 'grunt', + 'guacamole', + 'guidable', + 'guidance', + 'guide', + 'guiding', + 'guileless', + 'guise', + 'gulf', + 'gullible', + 'gully', + 'gulp', + 'gumball', + 'gumdrop', + 'gumminess', + 'gumming', + 'gummy', + 'gurgle', + 'gurgling', + 'guru', + 'gush', + 'gusto', + 'gusty', + 'gutless', + 'guts', + 'gutter', + 'guy', + 'guzzler', + 'gyration', + 'habitable', + 'habitant', + 'habitat', + 'habitual', + 'hacked', + 'hacker', + 'hacking', + 'hacksaw', + 'had', + 'haggler', + 'haiku', + 'half', + 'halogen', + 'halt', + 'halved', + 'halves', + 'hamburger', + 'hamlet', + 'hammock', + 'hamper', + 'hamster', + 'hamstring', + 'handbag', + 'handball', + 'handbook', + 'handbrake', + 'handcart', + 'handclap', + 'handclasp', + 'handcraft', + 'handcuff', + 'handed', + 'handful', + 'handgrip', + 'handgun', + 'handheld', + 'handiness', + 'handiwork', + 'handlebar', + 'handled', + 'handler', + 'handling', + 'handmade', + 'handoff', + 'handpick', + 'handprint', + 'handrail', + 'handsaw', + 'handset', + 'handsfree', + 'handshake', + 'handstand', + 'handwash', + 'handwork', + 'handwoven', + 'handwrite', + 'handyman', + 'hangnail', + 'hangout', + 'hangover', + 'hangup', + 'hankering', + 'hankie', + 'hanky', + 'haphazard', + 'happening', + 'happier', + 'happiest', + 'happily', + 'happiness', + 'happy', + 'harbor', + 'hardcopy', + 'hardcore', + 'hardcover', + 'harddisk', + 'hardened', + 'hardener', + 'hardening', + 'hardhat', + 'hardhead', + 'hardiness', + 'hardly', + 'hardness', + 'hardship', + 'hardware', + 'hardwired', + 'hardwood', + 'hardy', + 'harmful', + 'harmless', + 'harmonica', + 'harmonics', + 'harmonize', + 'harmony', + 'harness', + 'harpist', + 'harsh', + 'harvest', + 'hash', + 'hassle', + 'haste', + 'hastily', + 'hastiness', + 'hasty', + 'hatbox', + 'hatchback', + 'hatchery', + 'hatchet', + 'hatching', + 'hatchling', + 'hate', + 'hatless', + 'hatred', + 'haunt', + 'haven', + 'hazard', + 'hazelnut', + 'hazily', + 'haziness', + 'hazing', + 'hazy', + 'headache', + 'headband', + 'headboard', + 'headcount', + 'headdress', + 'headed', + 'header', + 'headfirst', + 'headgear', + 'heading', + 'headlamp', + 'headless', + 'headlock', + 'headphone', + 'headpiece', + 'headrest', + 'headroom', + 'headscarf', + 'headset', + 'headsman', + 'headstand', + 'headstone', + 'headway', + 'headwear', + 'heap', + 'heat', + 'heave', + 'heavily', + 'heaviness', + 'heaving', + 'hedge', + 'hedging', + 'heftiness', + 'hefty', + 'helium', + 'helmet', + 'helper', + 'helpful', + 'helping', + 'helpless', + 'helpline', + 'hemlock', + 'hemstitch', + 'hence', + 'henchman', + 'henna', + 'herald', + 'herbal', + 'herbicide', + 'herbs', + 'heritage', + 'hermit', + 'heroics', + 'heroism', + 'herring', + 'herself', + 'hertz', + 'hesitancy', + 'hesitant', + 'hesitate', + 'hexagon', + 'hexagram', + 'hubcap', + 'huddle', + 'huddling', + 'huff', + 'hug', + 'hula', + 'hulk', + 'hull', + 'human', + 'humble', + 'humbling', + 'humbly', + 'humid', + 'humiliate', + 'humility', + 'humming', + 'hummus', + 'humongous', + 'humorist', + 'humorless', + 'humorous', + 'humpback', + 'humped', + 'humvee', + 'hunchback', + 'hundredth', + 'hunger', + 'hungrily', + 'hungry', + 'hunk', + 'hunter', + 'hunting', + 'huntress', + 'huntsman', + 'hurdle', + 'hurled', + 'hurler', + 'hurling', + 'hurray', + 'hurricane', + 'hurried', + 'hurry', + 'hurt', + 'husband', + 'hush', + 'husked', + 'huskiness', + 'hut', + 'hybrid', + 'hydrant', + 'hydrated', + 'hydration', + 'hydrogen', + 'hydroxide', + 'hyperlink', + 'hypertext', + 'hyphen', + 'hypnoses', + 'hypnosis', + 'hypnotic', + 'hypnotism', + 'hypnotist', + 'hypnotize', + 'hypocrisy', + 'hypocrite', + 'ibuprofen', + 'ice', + 'iciness', + 'icing', + 'icky', + 'icon', + 'icy', + 'idealism', + 'idealist', + 'idealize', + 'ideally', + 'idealness', + 'identical', + 'identify', + 'identity', + 'ideology', + 'idiocy', + 'idiom', + 'idly', + 'igloo', + 'ignition', + 'ignore', + 'iguana', + 'illicitly', + 'illusion', + 'illusive', + 'image', + 'imaginary', + 'imagines', + 'imaging', + 'imbecile', + 'imitate', + 'imitation', + 'immature', + 'immerse', + 'immersion', + 'imminent', + 'immobile', + 'immodest', + 'immorally', + 'immortal', + 'immovable', + 'immovably', + 'immunity', + 'immunize', + 'impaired', + 'impale', + 'impart', + 'impatient', + 'impeach', + 'impeding', + 'impending', + 'imperfect', + 'imperial', + 'impish', + 'implant', + 'implement', + 'implicate', + 'implicit', + 'implode', + 'implosion', + 'implosive', + 'imply', + 'impolite', + 'important', + 'importer', + 'impose', + 'imposing', + 'impotence', + 'impotency', + 'impotent', + 'impound', + 'imprecise', + 'imprint', + 'imprison', + 'impromptu', + 'improper', + 'improve', + 'improving', + 'improvise', + 'imprudent', + 'impulse', + 'impulsive', + 'impure', + 'impurity', + 'iodine', + 'iodize', + 'ion', + 'ipad', + 'iphone', + 'ipod', + 'irate', + 'irk', + 'iron', + 'irregular', + 'irrigate', + 'irritable', + 'irritably', + 'irritant', + 'irritate', + 'islamic', + 'islamist', + 'isolated', + 'isolating', + 'isolation', + 'isotope', + 'issue', + 'issuing', + 'italicize', + 'italics', + 'item', + 'itinerary', + 'itunes', + 'ivory', + 'ivy', + 'jab', + 'jackal', + 'jacket', + 'jackknife', + 'jackpot', + 'jailbird', + 'jailbreak', + 'jailer', + 'jailhouse', + 'jalapeno', + 'jam', + 'janitor', + 'january', + 'jargon', + 'jarring', + 'jasmine', + 'jaundice', + 'jaunt', + 'java', + 'jawed', + 'jawless', + 'jawline', + 'jaws', + 'jaybird', + 'jaywalker', + 'jazz', + 'jeep', + 'jeeringly', + 'jellied', + 'jelly', + 'jersey', + 'jester', + 'jet', + 'jiffy', + 'jigsaw', + 'jimmy', + 'jingle', + 'jingling', + 'jinx', + 'jitters', + 'jittery', + 'job', + 'jockey', + 'jockstrap', + 'jogger', + 'jogging', + 'john', + 'joining', + 'jokester', + 'jokingly', + 'jolliness', + 'jolly', + 'jolt', + 'jot', + 'jovial', + 'joyfully', + 'joylessly', + 'joyous', + 'joyride', + 'joystick', + 'jubilance', + 'jubilant', + 'judge', + 'judgingly', + 'judicial', + 'judiciary', + 'judo', + 'juggle', + 'juggling', + 'jugular', + 'juice', + 'juiciness', + 'juicy', + 'jujitsu', + 'jukebox', + 'july', + 'jumble', + 'jumbo', + 'jump', + 'junction', + 'juncture', + 'june', + 'junior', + 'juniper', + 'junkie', + 'junkman', + 'junkyard', + 'jurist', + 'juror', + 'jury', + 'justice', + 'justifier', + 'justify', + 'justly', + 'justness', + 'juvenile', + 'kabob', + 'kangaroo', + 'karaoke', + 'karate', + 'karma', + 'kebab', + 'keenly', + 'keenness', + 'keep', + 'keg', + 'kelp', + 'kennel', + 'kept', + 'kerchief', + 'kerosene', + 'kettle', + 'kick', + 'kiln', + 'kilobyte', + 'kilogram', + 'kilometer', + 'kilowatt', + 'kilt', + 'kimono', + 'kindle', + 'kindling', + 'kindly', + 'kindness', + 'kindred', + 'kinetic', + 'kinfolk', + 'king', + 'kinship', + 'kinsman', + 'kinswoman', + 'kissable', + 'kisser', + 'kissing', + 'kitchen', + 'kite', + 'kitten', + 'kitty', + 'kiwi', + 'kleenex', + 'knapsack', + 'knee', + 'knelt', + 'knickers', + 'knoll', + 'koala', + 'kooky', + 'kosher', + 'krypton', + 'kudos', + 'kung', + 'labored', + 'laborer', + 'laboring', + 'laborious', + 'labrador', + 'ladder', + 'ladies', + 'ladle', + 'ladybug', + 'ladylike', + 'lagged', + 'lagging', + 'lagoon', + 'lair', + 'lake', + 'lance', + 'landed', + 'landfall', + 'landfill', + 'landing', + 'landlady', + 'landless', + 'landline', + 'landlord', + 'landmark', + 'landmass', + 'landmine', + 'landowner', + 'landscape', + 'landside', + 'landslide', + 'language', + 'lankiness', + 'lanky', + 'lantern', + 'lapdog', + 'lapel', + 'lapped', + 'lapping', + 'laptop', + 'lard', + 'large', + 'lark', + 'lash', + 'lasso', + 'last', + 'latch', + 'late', + 'lather', + 'latitude', + 'latrine', + 'latter', + 'latticed', + 'launch', + 'launder', + 'laundry', + 'laurel', + 'lavender', + 'lavish', + 'laxative', + 'lazily', + 'laziness', + 'lazy', + 'lecturer', + 'left', + 'legacy', + 'legal', + 'legend', + 'legged', + 'leggings', + 'legible', + 'legibly', + 'legislate', + 'lego', + 'legroom', + 'legume', + 'legwarmer', + 'legwork', + 'lemon', + 'lend', + 'length', + 'lens', + 'lent', + 'leotard', + 'lesser', + 'letdown', + 'lethargic', + 'lethargy', + 'letter', + 'lettuce', + 'level', + 'leverage', + 'levers', + 'levitate', + 'levitator', + 'liability', + 'liable', + 'liberty', + 'librarian', + 'library', + 'licking', + 'licorice', + 'lid', + 'life', + 'lifter', + 'lifting', + 'liftoff', + 'ligament', + 'likely', + 'likeness', + 'likewise', + 'liking', + 'lilac', + 'lilly', + 'lily', + 'limb', + 'limeade', + 'limelight', + 'limes', + 'limit', + 'limping', + 'limpness', + 'line', + 'lingo', + 'linguini', + 'linguist', + 'lining', + 'linked', + 'linoleum', + 'linseed', + 'lint', + 'lion', + 'lip', + 'liquefy', + 'liqueur', + 'liquid', + 'lisp', + 'list', + 'litigate', + 'litigator', + 'litmus', + 'litter', + 'little', + 'livable', + 'lived', + 'lively', + 'liver', + 'livestock', + 'lividly', + 'living', + 'lizard', + 'lubricant', + 'lubricate', + 'lucid', + 'luckily', + 'luckiness', + 'luckless', + 'lucrative', + 'ludicrous', + 'lugged', + 'lukewarm', + 'lullaby', + 'lumber', + 'luminance', + 'luminous', + 'lumpiness', + 'lumping', + 'lumpish', + 'lunacy', + 'lunar', + 'lunchbox', + 'luncheon', + 'lunchroom', + 'lunchtime', + 'lung', + 'lurch', + 'lure', + 'luridness', + 'lurk', + 'lushly', + 'lushness', + 'luster', + 'lustfully', + 'lustily', + 'lustiness', + 'lustrous', + 'lusty', + 'luxurious', + 'luxury', + 'lying', + 'lyrically', + 'lyricism', + 'lyricist', + 'lyrics', + 'macarena', + 'macaroni', + 'macaw', + 'mace', + 'machine', + 'machinist', + 'magazine', + 'magenta', + 'maggot', + 'magical', + 'magician', + 'magma', + 'magnesium', + 'magnetic', + 'magnetism', + 'magnetize', + 'magnifier', + 'magnify', + 'magnitude', + 'magnolia', + 'mahogany', + 'maimed', + 'majestic', + 'majesty', + 'majorette', + 'majority', + 'makeover', + 'maker', + 'makeshift', + 'making', + 'malformed', + 'malt', + 'mama', + 'mammal', + 'mammary', + 'mammogram', + 'manager', + 'managing', + 'manatee', + 'mandarin', + 'mandate', + 'mandatory', + 'mandolin', + 'manger', + 'mangle', + 'mango', + 'mangy', + 'manhandle', + 'manhole', + 'manhood', + 'manhunt', + 'manicotti', + 'manicure', + 'manifesto', + 'manila', + 'mankind', + 'manlike', + 'manliness', + 'manly', + 'manmade', + 'manned', + 'mannish', + 'manor', + 'manpower', + 'mantis', + 'mantra', + 'manual', + 'many', + 'map', + 'marathon', + 'marauding', + 'marbled', + 'marbles', + 'marbling', + 'march', + 'mardi', + 'margarine', + 'margarita', + 'margin', + 'marigold', + 'marina', + 'marine', + 'marital', + 'maritime', + 'marlin', + 'marmalade', + 'maroon', + 'married', + 'marrow', + 'marry', + 'marshland', + 'marshy', + 'marsupial', + 'marvelous', + 'marxism', + 'mascot', + 'masculine', + 'mashed', + 'mashing', + 'massager', + 'masses', + 'massive', + 'mastiff', + 'matador', + 'matchbook', + 'matchbox', + 'matcher', + 'matching', + 'matchless', + 'material', + 'maternal', + 'maternity', + 'math', + 'mating', + 'matriarch', + 'matrimony', + 'matrix', + 'matron', + 'matted', + 'matter', + 'maturely', + 'maturing', + 'maturity', + 'mauve', + 'maverick', + 'maximize', + 'maximum', + 'maybe', + 'mayday', + 'mayflower', + 'moaner', + 'moaning', + 'mobile', + 'mobility', + 'mobilize', + 'mobster', + 'mocha', + 'mocker', + 'mockup', + 'modified', + 'modify', + 'modular', + 'modulator', + 'module', + 'moisten', + 'moistness', + 'moisture', + 'molar', + 'molasses', + 'mold', + 'molecular', + 'molecule', + 'molehill', + 'mollusk', + 'mom', + 'monastery', + 'monday', + 'monetary', + 'monetize', + 'moneybags', + 'moneyless', + 'moneywise', + 'mongoose', + 'mongrel', + 'monitor', + 'monkhood', + 'monogamy', + 'monogram', + 'monologue', + 'monopoly', + 'monorail', + 'monotone', + 'monotype', + 'monoxide', + 'monsieur', + 'monsoon', + 'monstrous', + 'monthly', + 'monument', + 'moocher', + 'moodiness', + 'moody', + 'mooing', + 'moonbeam', + 'mooned', + 'moonlight', + 'moonlike', + 'moonlit', + 'moonrise', + 'moonscape', + 'moonshine', + 'moonstone', + 'moonwalk', + 'mop', + 'morale', + 'morality', + 'morally', + 'morbidity', + 'morbidly', + 'morphine', + 'morphing', + 'morse', + 'mortality', + 'mortally', + 'mortician', + 'mortified', + 'mortify', + 'mortuary', + 'mosaic', + 'mossy', + 'most', + 'mothball', + 'mothproof', + 'motion', + 'motivate', + 'motivator', + 'motive', + 'motocross', + 'motor', + 'motto', + 'mountable', + 'mountain', + 'mounted', + 'mounting', + 'mourner', + 'mournful', + 'mouse', + 'mousiness', + 'moustache', + 'mousy', + 'mouth', + 'movable', + 'move', + 'movie', + 'moving', + 'mower', + 'mowing', + 'much', + 'muck', + 'mud', + 'mug', + 'mulberry', + 'mulch', + 'mule', + 'mulled', + 'mullets', + 'multiple', + 'multiply', + 'multitask', + 'multitude', + 'mumble', + 'mumbling', + 'mumbo', + 'mummified', + 'mummify', + 'mummy', + 'mumps', + 'munchkin', + 'mundane', + 'municipal', + 'muppet', + 'mural', + 'murkiness', + 'murky', + 'murmuring', + 'muscular', + 'museum', + 'mushily', + 'mushiness', + 'mushroom', + 'mushy', + 'music', + 'musket', + 'muskiness', + 'musky', + 'mustang', + 'mustard', + 'muster', + 'mustiness', + 'musty', + 'mutable', + 'mutate', + 'mutation', + 'mute', + 'mutilated', + 'mutilator', + 'mutiny', + 'mutt', + 'mutual', + 'muzzle', + 'myself', + 'myspace', + 'mystified', + 'mystify', + 'myth', + 'nacho', + 'nag', + 'nail', + 'name', + 'naming', + 'nanny', + 'nanometer', + 'nape', + 'napkin', + 'napped', + 'napping', + 'nappy', + 'narrow', + 'nastily', + 'nastiness', + 'national', + 'native', + 'nativity', + 'natural', + 'nature', + 'naturist', + 'nautical', + 'navigate', + 'navigator', + 'navy', + 'nearby', + 'nearest', + 'nearly', + 'nearness', + 'neatly', + 'neatness', + 'nebula', + 'nebulizer', + 'nectar', + 'negate', + 'negation', + 'negative', + 'neglector', + 'negligee', + 'negligent', + 'negotiate', + 'nemeses', + 'nemesis', + 'neon', + 'nephew', + 'nerd', + 'nervous', + 'nervy', + 'nest', + 'net', + 'neurology', + 'neuron', + 'neurosis', + 'neurotic', + 'neuter', + 'neutron', + 'never', + 'next', + 'nibble', + 'nickname', + 'nicotine', + 'niece', + 'nifty', + 'nimble', + 'nimbly', + 'nineteen', + 'ninetieth', + 'ninja', + 'nintendo', + 'ninth', + 'nuclear', + 'nuclei', + 'nucleus', + 'nugget', + 'nullify', + 'number', + 'numbing', + 'numbly', + 'numbness', + 'numeral', + 'numerate', + 'numerator', + 'numeric', + 'numerous', + 'nuptials', + 'nursery', + 'nursing', + 'nurture', + 'nutcase', + 'nutlike', + 'nutmeg', + 'nutrient', + 'nutshell', + 'nuttiness', + 'nutty', + 'nuzzle', + 'nylon', + 'oaf', + 'oak', + 'oasis', + 'oat', + 'obedience', + 'obedient', + 'obituary', + 'object', + 'obligate', + 'obliged', + 'oblivion', + 'oblivious', + 'oblong', + 'obnoxious', + 'oboe', + 'obscure', + 'obscurity', + 'observant', + 'observer', + 'observing', + 'obsessed', + 'obsession', + 'obsessive', + 'obsolete', + 'obstacle', + 'obstinate', + 'obstruct', + 'obtain', + 'obtrusive', + 'obtuse', + 'obvious', + 'occultist', + 'occupancy', + 'occupant', + 'occupier', + 'occupy', + 'ocean', + 'ocelot', + 'octagon', + 'octane', + 'october', + 'octopus', + 'ogle', + 'oil', + 'oink', + 'ointment', + 'okay', + 'old', + 'olive', + 'olympics', + 'omega', + 'omen', + 'ominous', + 'omission', + 'omit', + 'omnivore', + 'onboard', + 'oncoming', + 'ongoing', + 'onion', + 'online', + 'onlooker', + 'only', + 'onscreen', + 'onset', + 'onshore', + 'onslaught', + 'onstage', + 'onto', + 'onward', + 'onyx', + 'oops', + 'ooze', + 'oozy', + 'opacity', + 'opal', + 'open', + 'operable', + 'operate', + 'operating', + 'operation', + 'operative', + 'operator', + 'opium', + 'opossum', + 'opponent', + 'oppose', + 'opposing', + 'opposite', + 'oppressed', + 'oppressor', + 'opt', + 'opulently', + 'osmosis', + 'other', + 'otter', + 'ouch', + 'ought', + 'ounce', + 'outage', + 'outback', + 'outbid', + 'outboard', + 'outbound', + 'outbreak', + 'outburst', + 'outcast', + 'outclass', + 'outcome', + 'outdated', + 'outdoors', + 'outer', + 'outfield', + 'outfit', + 'outflank', + 'outgoing', + 'outgrow', + 'outhouse', + 'outing', + 'outlast', + 'outlet', + 'outline', + 'outlook', + 'outlying', + 'outmatch', + 'outmost', + 'outnumber', + 'outplayed', + 'outpost', + 'outpour', + 'output', + 'outrage', + 'outrank', + 'outreach', + 'outright', + 'outscore', + 'outsell', + 'outshine', + 'outshoot', + 'outsider', + 'outskirts', + 'outsmart', + 'outsource', + 'outspoken', + 'outtakes', + 'outthink', + 'outward', + 'outweigh', + 'outwit', + 'oval', + 'ovary', + 'oven', + 'overact', + 'overall', + 'overarch', + 'overbid', + 'overbill', + 'overbite', + 'overblown', + 'overboard', + 'overbook', + 'overbuilt', + 'overcast', + 'overcoat', + 'overcome', + 'overcook', + 'overcrowd', + 'overdraft', + 'overdrawn', + 'overdress', + 'overdrive', + 'overdue', + 'overeager', + 'overeater', + 'overexert', + 'overfed', + 'overfeed', + 'overfill', + 'overflow', + 'overfull', + 'overgrown', + 'overhand', + 'overhang', + 'overhaul', + 'overhead', + 'overhear', + 'overheat', + 'overhung', + 'overjoyed', + 'overkill', + 'overlabor', + 'overlaid', + 'overlap', + 'overlay', + 'overload', + 'overlook', + 'overlord', + 'overlying', + 'overnight', + 'overpass', + 'overpay', + 'overplant', + 'overplay', + 'overpower', + 'overprice', + 'overrate', + 'overreach', + 'overreact', + 'override', + 'overripe', + 'overrule', + 'overrun', + 'overshoot', + 'overshot', + 'oversight', + 'oversized', + 'oversleep', + 'oversold', + 'overspend', + 'overstate', + 'overstay', + 'overstep', + 'overstock', + 'overstuff', + 'oversweet', + 'overtake', + 'overthrow', + 'overtime', + 'overtly', + 'overtone', + 'overture', + 'overturn', + 'overuse', + 'overvalue', + 'overview', + 'overwrite', + 'owl', + 'oxford', + 'oxidant', + 'oxidation', + 'oxidize', + 'oxidizing', + 'oxygen', + 'oxymoron', + 'oyster', + 'ozone', + 'paced', + 'pacemaker', + 'pacific', + 'pacifier', + 'pacifism', + 'pacifist', + 'pacify', + 'padded', + 'padding', + 'paddle', + 'paddling', + 'padlock', + 'pagan', + 'pager', + 'paging', + 'pajamas', + 'palace', + 'palatable', + 'palm', + 'palpable', + 'palpitate', + 'paltry', + 'pampered', + 'pamperer', + 'pampers', + 'pamphlet', + 'panama', + 'pancake', + 'pancreas', + 'panda', + 'pandemic', + 'pang', + 'panhandle', + 'panic', + 'panning', + 'panorama', + 'panoramic', + 'panther', + 'pantomime', + 'pantry', + 'pants', + 'pantyhose', + 'paparazzi', + 'papaya', + 'paper', + 'paprika', + 'papyrus', + 'parabola', + 'parachute', + 'parade', + 'paradox', + 'paragraph', + 'parakeet', + 'paralegal', + 'paralyses', + 'paralysis', + 'paralyze', + 'paramedic', + 'parameter', + 'paramount', + 'parasail', + 'parasite', + 'parasitic', + 'parcel', + 'parched', + 'parchment', + 'pardon', + 'parish', + 'parka', + 'parking', + 'parkway', + 'parlor', + 'parmesan', + 'parole', + 'parrot', + 'parsley', + 'parsnip', + 'partake', + 'parted', + 'parting', + 'partition', + 'partly', + 'partner', + 'partridge', + 'party', + 'passable', + 'passably', + 'passage', + 'passcode', + 'passenger', + 'passerby', + 'passing', + 'passion', + 'passive', + 'passivism', + 'passover', + 'passport', + 'password', + 'pasta', + 'pasted', + 'pastel', + 'pastime', + 'pastor', + 'pastrami', + 'pasture', + 'pasty', + 'patchwork', + 'patchy', + 'paternal', + 'paternity', + 'path', + 'patience', + 'patient', + 'patio', + 'patriarch', + 'patriot', + 'patrol', + 'patronage', + 'patronize', + 'pauper', + 'pavement', + 'paver', + 'pavestone', + 'pavilion', + 'paving', + 'pawing', + 'payable', + 'payback', + 'paycheck', + 'payday', + 'payee', + 'payer', + 'paying', + 'payment', + 'payphone', + 'payroll', + 'pebble', + 'pebbly', + 'pecan', + 'pectin', + 'peculiar', + 'peddling', + 'pediatric', + 'pedicure', + 'pedigree', + 'pedometer', + 'pegboard', + 'pelican', + 'pellet', + 'pelt', + 'pelvis', + 'penalize', + 'penalty', + 'pencil', + 'pendant', + 'pending', + 'penholder', + 'penknife', + 'pennant', + 'penniless', + 'penny', + 'penpal', + 'pension', + 'pentagon', + 'pentagram', + 'pep', + 'perceive', + 'percent', + 'perch', + 'percolate', + 'perennial', + 'perfected', + 'perfectly', + 'perfume', + 'periscope', + 'perish', + 'perjurer', + 'perjury', + 'perkiness', + 'perky', + 'perm', + 'peroxide', + 'perpetual', + 'perplexed', + 'persecute', + 'persevere', + 'persuaded', + 'persuader', + 'pesky', + 'peso', + 'pessimism', + 'pessimist', + 'pester', + 'pesticide', + 'petal', + 'petite', + 'petition', + 'petri', + 'petroleum', + 'petted', + 'petticoat', + 'pettiness', + 'petty', + 'petunia', + 'phantom', + 'phobia', + 'phoenix', + 'phonebook', + 'phoney', + 'phonics', + 'phoniness', + 'phony', + 'phosphate', + 'photo', + 'phrase', + 'phrasing', + 'placard', + 'placate', + 'placidly', + 'plank', + 'planner', + 'plant', + 'plasma', + 'plaster', + 'plastic', + 'plated', + 'platform', + 'plating', + 'platinum', + 'platonic', + 'platter', + 'platypus', + 'plausible', + 'plausibly', + 'playable', + 'playback', + 'player', + 'playful', + 'playgroup', + 'playhouse', + 'playing', + 'playlist', + 'playmaker', + 'playmate', + 'playoff', + 'playpen', + 'playroom', + 'playset', + 'plaything', + 'playtime', + 'plaza', + 'pleading', + 'pleat', + 'pledge', + 'plentiful', + 'plenty', + 'plethora', + 'plexiglas', + 'pliable', + 'plod', + 'plop', + 'plot', + 'plow', + 'ploy', + 'pluck', + 'plug', + 'plunder', + 'plunging', + 'plural', + 'plus', + 'plutonium', + 'plywood', + 'poach', + 'pod', + 'poem', + 'poet', + 'pogo', + 'pointed', + 'pointer', + 'pointing', + 'pointless', + 'pointy', + 'poise', + 'poison', + 'poker', + 'poking', + 'polar', + 'police', + 'policy', + 'polio', + 'polish', + 'politely', + 'polka', + 'polo', + 'polyester', + 'polygon', + 'polygraph', + 'polymer', + 'poncho', + 'pond', + 'pony', + 'popcorn', + 'pope', + 'poplar', + 'popper', + 'poppy', + 'popsicle', + 'populace', + 'popular', + 'populate', + 'porcupine', + 'pork', + 'porous', + 'porridge', + 'portable', + 'portal', + 'portfolio', + 'porthole', + 'portion', + 'portly', + 'portside', + 'poser', + 'posh', + 'posing', + 'possible', + 'possibly', + 'possum', + 'postage', + 'postal', + 'postbox', + 'postcard', + 'posted', + 'poster', + 'posting', + 'postnasal', + 'posture', + 'postwar', + 'pouch', + 'pounce', + 'pouncing', + 'pound', + 'pouring', + 'pout', + 'powdered', + 'powdering', + 'powdery', + 'power', + 'powwow', + 'pox', + 'praising', + 'prance', + 'prancing', + 'pranker', + 'prankish', + 'prankster', + 'prayer', + 'praying', + 'preacher', + 'preaching', + 'preachy', + 'preamble', + 'precinct', + 'precise', + 'precision', + 'precook', + 'precut', + 'predator', + 'predefine', + 'predict', + 'preface', + 'prefix', + 'preflight', + 'preformed', + 'pregame', + 'pregnancy', + 'pregnant', + 'preheated', + 'prelaunch', + 'prelaw', + 'prelude', + 'premiere', + 'premises', + 'premium', + 'prenatal', + 'preoccupy', + 'preorder', + 'prepaid', + 'prepay', + 'preplan', + 'preppy', + 'preschool', + 'prescribe', + 'preseason', + 'preset', + 'preshow', + 'president', + 'presoak', + 'press', + 'presume', + 'presuming', + 'preteen', + 'pretended', + 'pretender', + 'pretense', + 'pretext', + 'pretty', + 'pretzel', + 'prevail', + 'prevalent', + 'prevent', + 'preview', + 'previous', + 'prewar', + 'prewashed', + 'prideful', + 'pried', + 'primal', + 'primarily', + 'primary', + 'primate', + 'primer', + 'primp', + 'princess', + 'print', + 'prior', + 'prism', + 'prison', + 'prissy', + 'pristine', + 'privacy', + 'private', + 'privatize', + 'prize', + 'proactive', + 'probable', + 'probably', + 'probation', + 'probe', + 'probing', + 'probiotic', + 'problem', + 'procedure', + 'process', + 'proclaim', + 'procreate', + 'procurer', + 'prodigal', + 'prodigy', + 'produce', + 'product', + 'profane', + 'profanity', + 'professed', + 'professor', + 'profile', + 'profound', + 'profusely', + 'progeny', + 'prognosis', + 'program', + 'progress', + 'projector', + 'prologue', + 'prolonged', + 'promenade', + 'prominent', + 'promoter', + 'promotion', + 'prompter', + 'promptly', + 'prone', + 'prong', + 'pronounce', + 'pronto', + 'proofing', + 'proofread', + 'proofs', + 'propeller', + 'properly', + 'property', + 'proponent', + 'proposal', + 'propose', + 'props', + 'prorate', + 'protector', + 'protegee', + 'proton', + 'prototype', + 'protozoan', + 'protract', + 'protrude', + 'proud', + 'provable', + 'proved', + 'proven', + 'provided', + 'provider', + 'providing', + 'province', + 'proving', + 'provoke', + 'provoking', + 'provolone', + 'prowess', + 'prowler', + 'prowling', + 'proximity', + 'proxy', + 'prozac', + 'prude', + 'prudishly', + 'prune', + 'pruning', + 'pry', + 'psychic', + 'public', + 'publisher', + 'pucker', + 'pueblo', + 'pug', + 'pull', + 'pulmonary', + 'pulp', + 'pulsate', + 'pulse', + 'pulverize', + 'puma', + 'pumice', + 'pummel', + 'punch', + 'punctual', + 'punctuate', + 'punctured', + 'pungent', + 'punisher', + 'punk', + 'pupil', + 'puppet', + 'puppy', + 'purchase', + 'pureblood', + 'purebred', + 'purely', + 'pureness', + 'purgatory', + 'purge', + 'purging', + 'purifier', + 'purify', + 'purist', + 'puritan', + 'purity', + 'purple', + 'purplish', + 'purposely', + 'purr', + 'purse', + 'pursuable', + 'pursuant', + 'pursuit', + 'purveyor', + 'pushcart', + 'pushchair', + 'pusher', + 'pushiness', + 'pushing', + 'pushover', + 'pushpin', + 'pushup', + 'pushy', + 'putdown', + 'putt', + 'puzzle', + 'puzzling', + 'pyramid', + 'pyromania', + 'python', + 'quack', + 'quadrant', + 'quail', + 'quaintly', + 'quake', + 'quaking', + 'qualified', + 'qualifier', + 'qualify', + 'quality', + 'qualm', + 'quantum', + 'quarrel', + 'quarry', + 'quartered', + 'quarterly', + 'quarters', + 'quartet', + 'quench', + 'query', + 'quicken', + 'quickly', + 'quickness', + 'quicksand', + 'quickstep', + 'quiet', + 'quill', + 'quilt', + 'quintet', + 'quintuple', + 'quirk', + 'quit', + 'quiver', + 'quizzical', + 'quotable', + 'quotation', + 'quote', + 'rabid', + 'race', + 'racing', + 'racism', + 'rack', + 'racoon', + 'radar', + 'radial', + 'radiance', + 'radiantly', + 'radiated', + 'radiation', + 'radiator', + 'radio', + 'radish', + 'raffle', + 'raft', + 'rage', + 'ragged', + 'raging', + 'ragweed', + 'raider', + 'railcar', + 'railing', + 'railroad', + 'railway', + 'raisin', + 'rake', + 'raking', + 'rally', + 'ramble', + 'rambling', + 'ramp', + 'ramrod', + 'ranch', + 'rancidity', + 'random', + 'ranged', + 'ranger', + 'ranging', + 'ranked', + 'ranking', + 'ransack', + 'ranting', + 'rants', + 'rare', + 'rarity', + 'rascal', + 'rash', + 'rasping', + 'ravage', + 'raven', + 'ravine', + 'raving', + 'ravioli', + 'ravishing', + 'reabsorb', + 'reach', + 'reacquire', + 'reaction', + 'reactive', + 'reactor', + 'reaffirm', + 'ream', + 'reanalyze', + 'reappear', + 'reapply', + 'reappoint', + 'reapprove', + 'rearrange', + 'rearview', + 'reason', + 'reassign', + 'reassure', + 'reattach', + 'reawake', + 'rebalance', + 'rebate', + 'rebel', + 'rebirth', + 'reboot', + 'reborn', + 'rebound', + 'rebuff', + 'rebuild', + 'rebuilt', + 'reburial', + 'rebuttal', + 'recall', + 'recant', + 'recapture', + 'recast', + 'recede', + 'recent', + 'recess', + 'recharger', + 'recipient', + 'recital', + 'recite', + 'reckless', + 'reclaim', + 'recliner', + 'reclining', + 'recluse', + 'reclusive', + 'recognize', + 'recoil', + 'recollect', + 'recolor', + 'reconcile', + 'reconfirm', + 'reconvene', + 'recopy', + 'record', + 'recount', + 'recoup', + 'recovery', + 'recreate', + 'rectal', + 'rectangle', + 'rectified', + 'rectify', + 'recycled', + 'recycler', + 'recycling', + 'reemerge', + 'reenact', + 'reenter', + 'reentry', + 'reexamine', + 'referable', + 'referee', + 'reference', + 'refill', + 'refinance', + 'refined', + 'refinery', + 'refining', + 'refinish', + 'reflected', + 'reflector', + 'reflex', + 'reflux', + 'refocus', + 'refold', + 'reforest', + 'reformat', + 'reformed', + 'reformer', + 'reformist', + 'refract', + 'refrain', + 'refreeze', + 'refresh', + 'refried', + 'refueling', + 'refund', + 'refurbish', + 'refurnish', + 'refusal', + 'refuse', + 'refusing', + 'refutable', + 'refute', + 'regain', + 'regalia', + 'regally', + 'reggae', + 'regime', + 'region', + 'register', + 'registrar', + 'registry', + 'regress', + 'regretful', + 'regroup', + 'regular', + 'regulate', + 'regulator', + 'rehab', + 'reheat', + 'rehire', + 'rehydrate', + 'reimburse', + 'reissue', + 'reiterate', + 'rejoice', + 'rejoicing', + 'rejoin', + 'rekindle', + 'relapse', + 'relapsing', + 'relatable', + 'related', + 'relation', + 'relative', + 'relax', + 'relay', + 'relearn', + 'release', + 'relenting', + 'reliable', + 'reliably', + 'reliance', + 'reliant', + 'relic', + 'relieve', + 'relieving', + 'relight', + 'relish', + 'relive', + 'reload', + 'relocate', + 'relock', + 'reluctant', + 'rely', + 'remake', + 'remark', + 'remarry', + 'rematch', + 'remedial', + 'remedy', + 'remember', + 'reminder', + 'remindful', + 'remission', + 'remix', + 'remnant', + 'remodeler', + 'remold', + 'remorse', + 'remote', + 'removable', + 'removal', + 'removed', + 'remover', + 'removing', + 'rename', + 'renderer', + 'rendering', + 'rendition', + 'renegade', + 'renewable', + 'renewably', + 'renewal', + 'renewed', + 'renounce', + 'renovate', + 'renovator', + 'rentable', + 'rental', + 'rented', + 'renter', + 'reoccupy', + 'reoccur', + 'reopen', + 'reorder', + 'repackage', + 'repacking', + 'repaint', + 'repair', + 'repave', + 'repaying', + 'repayment', + 'repeal', + 'repeated', + 'repeater', + 'repent', + 'rephrase', + 'replace', + 'replay', + 'replica', + 'reply', + 'reporter', + 'repose', + 'repossess', + 'repost', + 'repressed', + 'reprimand', + 'reprint', + 'reprise', + 'reproach', + 'reprocess', + 'reproduce', + 'reprogram', + 'reps', + 'reptile', + 'reptilian', + 'repugnant', + 'repulsion', + 'repulsive', + 'repurpose', + 'reputable', + 'reputably', + 'request', + 'require', + 'requisite', + 'reroute', + 'rerun', + 'resale', + 'resample', + 'rescuer', + 'reseal', + 'research', + 'reselect', + 'reseller', + 'resemble', + 'resend', + 'resent', + 'reset', + 'reshape', + 'reshoot', + 'reshuffle', + 'residence', + 'residency', + 'resident', + 'residual', + 'residue', + 'resigned', + 'resilient', + 'resistant', + 'resisting', + 'resize', + 'resolute', + 'resolved', + 'resonant', + 'resonate', + 'resort', + 'resource', + 'respect', + 'resubmit', + 'result', + 'resume', + 'resupply', + 'resurface', + 'resurrect', + 'retail', + 'retainer', + 'retaining', + 'retake', + 'retaliate', + 'retention', + 'rethink', + 'retinal', + 'retired', + 'retiree', + 'retiring', + 'retold', + 'retool', + 'retorted', + 'retouch', + 'retrace', + 'retract', + 'retrain', + 'retread', + 'retreat', + 'retrial', + 'retrieval', + 'retriever', + 'retry', + 'return', + 'retying', + 'retype', + 'reunion', + 'reunite', + 'reusable', + 'reuse', + 'reveal', + 'reveler', + 'revenge', + 'revenue', + 'reverb', + 'revered', + 'reverence', + 'reverend', + 'reversal', + 'reverse', + 'reversing', + 'reversion', + 'revert', + 'revisable', + 'revise', + 'revision', + 'revisit', + 'revivable', + 'revival', + 'reviver', + 'reviving', + 'revocable', + 'revoke', + 'revolt', + 'revolver', + 'revolving', + 'reward', + 'rewash', + 'rewind', + 'rewire', + 'reword', + 'rework', + 'rewrap', + 'rewrite', + 'rhyme', + 'ribbon', + 'ribcage', + 'rice', + 'riches', + 'richly', + 'richness', + 'rickety', + 'ricotta', + 'riddance', + 'ridden', + 'ride', + 'riding', + 'rifling', + 'rift', + 'rigging', + 'rigid', + 'rigor', + 'rimless', + 'rimmed', + 'rind', + 'rink', + 'rinse', + 'rinsing', + 'riot', + 'ripcord', + 'ripeness', + 'ripening', + 'ripping', + 'ripple', + 'rippling', + 'riptide', + 'rise', + 'rising', + 'risk', + 'risotto', + 'ritalin', + 'ritzy', + 'rival', + 'riverbank', + 'riverbed', + 'riverboat', + 'riverside', + 'riveter', + 'riveting', + 'roamer', + 'roaming', + 'roast', + 'robbing', + 'robe', + 'robin', + 'robotics', + 'robust', + 'rockband', + 'rocker', + 'rocket', + 'rockfish', + 'rockiness', + 'rocking', + 'rocklike', + 'rockslide', + 'rockstar', + 'rocky', + 'rogue', + 'roman', + 'romp', + 'rope', + 'roping', + 'roster', + 'rosy', + 'rotten', + 'rotting', + 'rotunda', + 'roulette', + 'rounding', + 'roundish', + 'roundness', + 'roundup', + 'roundworm', + 'routine', + 'routing', + 'rover', + 'roving', + 'royal', + 'rubbed', + 'rubber', + 'rubbing', + 'rubble', + 'rubdown', + 'ruby', + 'ruckus', + 'rudder', + 'rug', + 'ruined', + 'rule', + 'rumble', + 'rumbling', + 'rummage', + 'rumor', + 'runaround', + 'rundown', + 'runner', + 'running', + 'runny', + 'runt', + 'runway', + 'rupture', + 'rural', + 'ruse', + 'rush', + 'rust', + 'rut', + 'sabbath', + 'sabotage', + 'sacrament', + 'sacred', + 'sacrifice', + 'sadden', + 'saddlebag', + 'saddled', + 'saddling', + 'sadly', + 'sadness', + 'safari', + 'safeguard', + 'safehouse', + 'safely', + 'safeness', + 'saffron', + 'saga', + 'sage', + 'sagging', + 'saggy', + 'said', + 'saint', + 'sake', + 'salad', + 'salami', + 'salaried', + 'salary', + 'saline', + 'salon', + 'saloon', + 'salsa', + 'salt', + 'salutary', + 'salute', + 'salvage', + 'salvaging', + 'salvation', + 'same', + 'sample', + 'sampling', + 'sanction', + 'sanctity', + 'sanctuary', + 'sandal', + 'sandbag', + 'sandbank', + 'sandbar', + 'sandblast', + 'sandbox', + 'sanded', + 'sandfish', + 'sanding', + 'sandlot', + 'sandpaper', + 'sandpit', + 'sandstone', + 'sandstorm', + 'sandworm', + 'sandy', + 'sanitary', + 'sanitizer', + 'sank', + 'santa', + 'sapling', + 'sappiness', + 'sappy', + 'sarcasm', + 'sarcastic', + 'sardine', + 'sash', + 'sasquatch', + 'sassy', + 'satchel', + 'satiable', + 'satin', + 'satirical', + 'satisfied', + 'satisfy', + 'saturate', + 'saturday', + 'sauciness', + 'saucy', + 'sauna', + 'savage', + 'savanna', + 'saved', + 'savings', + 'savior', + 'savor', + 'saxophone', + 'say', + 'scabbed', + 'scabby', + 'scalded', + 'scalding', + 'scale', + 'scaling', + 'scallion', + 'scallop', + 'scalping', + 'scam', + 'scandal', + 'scanner', + 'scanning', + 'scant', + 'scapegoat', + 'scarce', + 'scarcity', + 'scarecrow', + 'scared', + 'scarf', + 'scarily', + 'scariness', + 'scarring', + 'scary', + 'scavenger', + 'scenic', + 'schedule', + 'schematic', + 'scheme', + 'scheming', + 'schilling', + 'schnapps', + 'scholar', + 'science', + 'scientist', + 'scion', + 'scoff', + 'scolding', + 'scone', + 'scoop', + 'scooter', + 'scope', + 'scorch', + 'scorebook', + 'scorecard', + 'scored', + 'scoreless', + 'scorer', + 'scoring', + 'scorn', + 'scorpion', + 'scotch', + 'scoundrel', + 'scoured', + 'scouring', + 'scouting', + 'scouts', + 'scowling', + 'scrabble', + 'scraggly', + 'scrambled', + 'scrambler', + 'scrap', + 'scratch', + 'scrawny', + 'screen', + 'scribble', + 'scribe', + 'scribing', + 'scrimmage', + 'script', + 'scroll', + 'scrooge', + 'scrounger', + 'scrubbed', + 'scrubber', + 'scruffy', + 'scrunch', + 'scrutiny', + 'scuba', + 'scuff', + 'sculptor', + 'sculpture', + 'scurvy', + 'scuttle', + 'secluded', + 'secluding', + 'seclusion', + 'second', + 'secrecy', + 'secret', + 'sectional', + 'sector', + 'secular', + 'securely', + 'security', + 'sedan', + 'sedate', + 'sedation', + 'sedative', + 'sediment', + 'seduce', + 'seducing', + 'segment', + 'seismic', + 'seizing', + 'seldom', + 'selected', + 'selection', + 'selective', + 'selector', + 'self', + 'seltzer', + 'semantic', + 'semester', + 'semicolon', + 'semifinal', + 'seminar', + 'semisoft', + 'semisweet', + 'senate', + 'senator', + 'send', + 'senior', + 'senorita', + 'sensation', + 'sensitive', + 'sensitize', + 'sensually', + 'sensuous', + 'sepia', + 'september', + 'septic', + 'septum', + 'sequel', + 'sequence', + 'sequester', + 'series', + 'sermon', + 'serotonin', + 'serpent', + 'serrated', + 'serve', + 'service', + 'serving', + 'sesame', + 'sessions', + 'setback', + 'setting', + 'settle', + 'settling', + 'setup', + 'sevenfold', + 'seventeen', + 'seventh', + 'seventy', + 'severity', + 'shabby', + 'shack', + 'shaded', + 'shadily', + 'shadiness', + 'shading', + 'shadow', + 'shady', + 'shaft', + 'shakable', + 'shakily', + 'shakiness', + 'shaking', + 'shaky', + 'shale', + 'shallot', + 'shallow', + 'shame', + 'shampoo', + 'shamrock', + 'shank', + 'shanty', + 'shape', + 'shaping', + 'share', + 'sharpener', + 'sharper', + 'sharpie', + 'sharply', + 'sharpness', + 'shawl', + 'sheath', + 'shed', + 'sheep', + 'sheet', + 'shelf', + 'shell', + 'shelter', + 'shelve', + 'shelving', + 'sherry', + 'shield', + 'shifter', + 'shifting', + 'shiftless', + 'shifty', + 'shimmer', + 'shimmy', + 'shindig', + 'shine', + 'shingle', + 'shininess', + 'shining', + 'shiny', + 'ship', + 'shirt', + 'shivering', + 'shock', + 'shone', + 'shoplift', + 'shopper', + 'shopping', + 'shoptalk', + 'shore', + 'shortage', + 'shortcake', + 'shortcut', + 'shorten', + 'shorter', + 'shorthand', + 'shortlist', + 'shortly', + 'shortness', + 'shorts', + 'shortwave', + 'shorty', + 'shout', + 'shove', + 'showbiz', + 'showcase', + 'showdown', + 'shower', + 'showgirl', + 'showing', + 'showman', + 'shown', + 'showoff', + 'showpiece', + 'showplace', + 'showroom', + 'showy', + 'shrank', + 'shrapnel', + 'shredder', + 'shredding', + 'shrewdly', + 'shriek', + 'shrill', + 'shrimp', + 'shrine', + 'shrink', + 'shrivel', + 'shrouded', + 'shrubbery', + 'shrubs', + 'shrug', + 'shrunk', + 'shucking', + 'shudder', + 'shuffle', + 'shuffling', + 'shun', + 'shush', + 'shut', + 'shy', + 'siamese', + 'siberian', + 'sibling', + 'siding', + 'sierra', + 'siesta', + 'sift', + 'sighing', + 'silenced', + 'silencer', + 'silent', + 'silica', + 'silicon', + 'silk', + 'silliness', + 'silly', + 'silo', + 'silt', + 'silver', + 'similarly', + 'simile', + 'simmering', + 'simple', + 'simplify', + 'simply', + 'sincere', + 'sincerity', + 'singer', + 'singing', + 'single', + 'singular', + 'sinister', + 'sinless', + 'sinner', + 'sinuous', + 'sip', + 'siren', + 'sister', + 'sitcom', + 'sitter', + 'sitting', + 'situated', + 'situation', + 'sixfold', + 'sixteen', + 'sixth', + 'sixties', + 'sixtieth', + 'sixtyfold', + 'sizable', + 'sizably', + 'size', + 'sizing', + 'sizzle', + 'sizzling', + 'skater', + 'skating', + 'skedaddle', + 'skeletal', + 'skeleton', + 'skeptic', + 'sketch', + 'skewed', + 'skewer', + 'skid', + 'skied', + 'skier', + 'skies', + 'skiing', + 'skilled', + 'skillet', + 'skillful', + 'skimmed', + 'skimmer', + 'skimming', + 'skimpily', + 'skincare', + 'skinhead', + 'skinless', + 'skinning', + 'skinny', + 'skintight', + 'skipper', + 'skipping', + 'skirmish', + 'skirt', + 'skittle', + 'skydiver', + 'skylight', + 'skyline', + 'skype', + 'skyrocket', + 'skyward', + 'slab', + 'slacked', + 'slacker', + 'slacking', + 'slackness', + 'slacks', + 'slain', + 'slam', + 'slander', + 'slang', + 'slapping', + 'slapstick', + 'slashed', + 'slashing', + 'slate', + 'slather', + 'slaw', + 'sled', + 'sleek', + 'sleep', + 'sleet', + 'sleeve', + 'slept', + 'sliceable', + 'sliced', + 'slicer', + 'slicing', + 'slick', + 'slider', + 'slideshow', + 'sliding', + 'slighted', + 'slighting', + 'slightly', + 'slimness', + 'slimy', + 'slinging', + 'slingshot', + 'slinky', + 'slip', + 'slit', + 'sliver', + 'slobbery', + 'slogan', + 'sloped', + 'sloping', + 'sloppily', + 'sloppy', + 'slot', + 'slouching', + 'slouchy', + 'sludge', + 'slug', + 'slum', + 'slurp', + 'slush', + 'sly', + 'small', + 'smartly', + 'smartness', + 'smasher', + 'smashing', + 'smashup', + 'smell', + 'smelting', + 'smile', + 'smilingly', + 'smirk', + 'smite', + 'smith', + 'smitten', + 'smock', + 'smog', + 'smoked', + 'smokeless', + 'smokiness', + 'smoking', + 'smoky', + 'smolder', + 'smooth', + 'smother', + 'smudge', + 'smudgy', + 'smuggler', + 'smuggling', + 'smugly', + 'smugness', + 'snack', + 'snagged', + 'snaking', + 'snap', + 'snare', + 'snarl', + 'snazzy', + 'sneak', + 'sneer', + 'sneeze', + 'sneezing', + 'snide', + 'sniff', + 'snippet', + 'snipping', + 'snitch', + 'snooper', + 'snooze', + 'snore', + 'snoring', + 'snorkel', + 'snort', + 'snout', + 'snowbird', + 'snowboard', + 'snowbound', + 'snowcap', + 'snowdrift', + 'snowdrop', + 'snowfall', + 'snowfield', + 'snowflake', + 'snowiness', + 'snowless', + 'snowman', + 'snowplow', + 'snowshoe', + 'snowstorm', + 'snowsuit', + 'snowy', + 'snub', + 'snuff', + 'snuggle', + 'snugly', + 'snugness', + 'speak', + 'spearfish', + 'spearhead', + 'spearman', + 'spearmint', + 'species', + 'specimen', + 'specked', + 'speckled', + 'specks', + 'spectacle', + 'spectator', + 'spectrum', + 'speculate', + 'speech', + 'speed', + 'spellbind', + 'speller', + 'spelling', + 'spendable', + 'spender', + 'spending', + 'spent', + 'spew', + 'sphere', + 'spherical', + 'sphinx', + 'spider', + 'spied', + 'spiffy', + 'spill', + 'spilt', + 'spinach', + 'spinal', + 'spindle', + 'spinner', + 'spinning', + 'spinout', + 'spinster', + 'spiny', + 'spiral', + 'spirited', + 'spiritism', + 'spirits', + 'spiritual', + 'splashed', + 'splashing', + 'splashy', + 'splatter', + 'spleen', + 'splendid', + 'splendor', + 'splice', + 'splicing', + 'splinter', + 'splotchy', + 'splurge', + 'spoilage', + 'spoiled', + 'spoiler', + 'spoiling', + 'spoils', + 'spoken', + 'spokesman', + 'sponge', + 'spongy', + 'sponsor', + 'spoof', + 'spookily', + 'spooky', + 'spool', + 'spoon', + 'spore', + 'sporting', + 'sports', + 'sporty', + 'spotless', + 'spotlight', + 'spotted', + 'spotter', + 'spotting', + 'spotty', + 'spousal', + 'spouse', + 'spout', + 'sprain', + 'sprang', + 'sprawl', + 'spray', + 'spree', + 'sprig', + 'spring', + 'sprinkled', + 'sprinkler', + 'sprint', + 'sprite', + 'sprout', + 'spruce', + 'sprung', + 'spry', + 'spud', + 'spur', + 'sputter', + 'spyglass', + 'squabble', + 'squad', + 'squall', + 'squander', + 'squash', + 'squatted', + 'squatter', + 'squatting', + 'squeak', + 'squealer', + 'squealing', + 'squeamish', + 'squeegee', + 'squeeze', + 'squeezing', + 'squid', + 'squiggle', + 'squiggly', + 'squint', + 'squire', + 'squirt', + 'squishier', + 'squishy', + 'stability', + 'stabilize', + 'stable', + 'stack', + 'stadium', + 'staff', + 'stage', + 'staging', + 'stagnant', + 'stagnate', + 'stainable', + 'stained', + 'staining', + 'stainless', + 'stalemate', + 'staleness', + 'stalling', + 'stallion', + 'stamina', + 'stammer', + 'stamp', + 'stand', + 'stank', + 'staple', + 'stapling', + 'starboard', + 'starch', + 'stardom', + 'stardust', + 'starfish', + 'stargazer', + 'staring', + 'stark', + 'starless', + 'starlet', + 'starlight', + 'starlit', + 'starring', + 'starry', + 'starship', + 'starter', + 'starting', + 'startle', + 'startling', + 'startup', + 'starved', + 'starving', + 'stash', + 'state', + 'static', + 'statistic', + 'statue', + 'stature', + 'status', + 'statute', + 'statutory', + 'staunch', + 'stays', + 'steadfast', + 'steadier', + 'steadily', + 'steadying', + 'steam', + 'steed', + 'steep', + 'steerable', + 'steering', + 'steersman', + 'stegosaur', + 'stellar', + 'stem', + 'stench', + 'stencil', + 'step', + 'stereo', + 'sterile', + 'sterility', + 'sterilize', + 'sterling', + 'sternness', + 'sternum', + 'stew', + 'stick', + 'stiffen', + 'stiffly', + 'stiffness', + 'stifle', + 'stifling', + 'stillness', + 'stilt', + 'stimulant', + 'stimulate', + 'stimuli', + 'stimulus', + 'stinger', + 'stingily', + 'stinging', + 'stingray', + 'stingy', + 'stinking', + 'stinky', + 'stipend', + 'stipulate', + 'stir', + 'stitch', + 'stock', + 'stoic', + 'stoke', + 'stole', + 'stomp', + 'stonewall', + 'stoneware', + 'stonework', + 'stoning', + 'stony', + 'stood', + 'stooge', + 'stool', + 'stoop', + 'stoplight', + 'stoppable', + 'stoppage', + 'stopped', + 'stopper', + 'stopping', + 'stopwatch', + 'storable', + 'storage', + 'storeroom', + 'storewide', + 'storm', + 'stout', + 'stove', + 'stowaway', + 'stowing', + 'straddle', + 'straggler', + 'strained', + 'strainer', + 'straining', + 'strangely', + 'stranger', + 'strangle', + 'strategic', + 'strategy', + 'stratus', + 'straw', + 'stray', + 'streak', + 'stream', + 'street', + 'strength', + 'strenuous', + 'strep', + 'stress', + 'stretch', + 'strewn', + 'stricken', + 'strict', + 'stride', + 'strife', + 'strike', + 'striking', + 'strive', + 'striving', + 'strobe', + 'strode', + 'stroller', + 'strongbox', + 'strongly', + 'strongman', + 'struck', + 'structure', + 'strudel', + 'struggle', + 'strum', + 'strung', + 'strut', + 'stubbed', + 'stubble', + 'stubbly', + 'stubborn', + 'stucco', + 'stuck', + 'student', + 'studied', + 'studio', + 'study', + 'stuffed', + 'stuffing', + 'stuffy', + 'stumble', + 'stumbling', + 'stump', + 'stung', + 'stunned', + 'stunner', + 'stunning', + 'stunt', + 'stupor', + 'sturdily', + 'sturdy', + 'styling', + 'stylishly', + 'stylist', + 'stylized', + 'stylus', + 'suave', + 'subarctic', + 'subatomic', + 'subdivide', + 'subdued', + 'subduing', + 'subfloor', + 'subgroup', + 'subheader', + 'subject', + 'sublease', + 'sublet', + 'sublevel', + 'sublime', + 'submarine', + 'submerge', + 'submersed', + 'submitter', + 'subpanel', + 'subpar', + 'subplot', + 'subprime', + 'subscribe', + 'subscript', + 'subsector', + 'subside', + 'subsiding', + 'subsidize', + 'subsidy', + 'subsoil', + 'subsonic', + 'substance', + 'subsystem', + 'subtext', + 'subtitle', + 'subtly', + 'subtotal', + 'subtract', + 'subtype', + 'suburb', + 'subway', + 'subwoofer', + 'subzero', + 'succulent', + 'such', + 'suction', + 'sudden', + 'sudoku', + 'suds', + 'sufferer', + 'suffering', + 'suffice', + 'suffix', + 'suffocate', + 'suffrage', + 'sugar', + 'suggest', + 'suing', + 'suitable', + 'suitably', + 'suitcase', + 'suitor', + 'sulfate', + 'sulfide', + 'sulfite', + 'sulfur', + 'sulk', + 'sullen', + 'sulphate', + 'sulphuric', + 'sultry', + 'superbowl', + 'superglue', + 'superhero', + 'superior', + 'superjet', + 'superman', + 'supermom', + 'supernova', + 'supervise', + 'supper', + 'supplier', + 'supply', + 'support', + 'supremacy', + 'supreme', + 'surcharge', + 'surely', + 'sureness', + 'surface', + 'surfacing', + 'surfboard', + 'surfer', + 'surgery', + 'surgical', + 'surging', + 'surname', + 'surpass', + 'surplus', + 'surprise', + 'surreal', + 'surrender', + 'surrogate', + 'surround', + 'survey', + 'survival', + 'survive', + 'surviving', + 'survivor', + 'sushi', + 'suspect', + 'suspend', + 'suspense', + 'sustained', + 'sustainer', + 'swab', + 'swaddling', + 'swagger', + 'swampland', + 'swan', + 'swapping', + 'swarm', + 'sway', + 'swear', + 'sweat', + 'sweep', + 'swell', + 'swept', + 'swerve', + 'swifter', + 'swiftly', + 'swiftness', + 'swimmable', + 'swimmer', + 'swimming', + 'swimsuit', + 'swimwear', + 'swinger', + 'swinging', + 'swipe', + 'swirl', + 'switch', + 'swivel', + 'swizzle', + 'swooned', + 'swoop', + 'swoosh', + 'swore', + 'sworn', + 'swung', + 'sycamore', + 'sympathy', + 'symphonic', + 'symphony', + 'symptom', + 'synapse', + 'syndrome', + 'synergy', + 'synopses', + 'synopsis', + 'synthesis', + 'synthetic', + 'syrup', + 'system', + 't-shirt', + 'tabasco', + 'tabby', + 'tableful', + 'tables', + 'tablet', + 'tableware', + 'tabloid', + 'tackiness', + 'tacking', + 'tackle', + 'tackling', + 'tacky', + 'taco', + 'tactful', + 'tactical', + 'tactics', + 'tactile', + 'tactless', + 'tadpole', + 'taekwondo', + 'tag', + 'tainted', + 'take', + 'taking', + 'talcum', + 'talisman', + 'tall', + 'talon', + 'tamale', + 'tameness', + 'tamer', + 'tamper', + 'tank', + 'tanned', + 'tannery', + 'tanning', + 'tantrum', + 'tapeless', + 'tapered', + 'tapering', + 'tapestry', + 'tapioca', + 'tapping', + 'taps', + 'tarantula', + 'target', + 'tarmac', + 'tarnish', + 'tarot', + 'tartar', + 'tartly', + 'tartness', + 'task', + 'tassel', + 'taste', + 'tastiness', + 'tasting', + 'tasty', + 'tattered', + 'tattle', + 'tattling', + 'tattoo', + 'taunt', + 'tavern', + 'thank', + 'that', + 'thaw', + 'theater', + 'theatrics', + 'thee', + 'theft', + 'theme', + 'theology', + 'theorize', + 'thermal', + 'thermos', + 'thesaurus', + 'these', + 'thesis', + 'thespian', + 'thicken', + 'thicket', + 'thickness', + 'thieving', + 'thievish', + 'thigh', + 'thimble', + 'thing', + 'think', + 'thinly', + 'thinner', + 'thinness', + 'thinning', + 'thirstily', + 'thirsting', + 'thirsty', + 'thirteen', + 'thirty', + 'thong', + 'thorn', + 'those', + 'thousand', + 'thrash', + 'thread', + 'threaten', + 'threefold', + 'thrift', + 'thrill', + 'thrive', + 'thriving', + 'throat', + 'throbbing', + 'throng', + 'throttle', + 'throwaway', + 'throwback', + 'thrower', + 'throwing', + 'thud', + 'thumb', + 'thumping', + 'thursday', + 'thus', + 'thwarting', + 'thyself', + 'tiara', + 'tibia', + 'tidal', + 'tidbit', + 'tidiness', + 'tidings', + 'tidy', + 'tiger', + 'tighten', + 'tightly', + 'tightness', + 'tightrope', + 'tightwad', + 'tigress', + 'tile', + 'tiling', + 'till', + 'tilt', + 'timid', + 'timing', + 'timothy', + 'tinderbox', + 'tinfoil', + 'tingle', + 'tingling', + 'tingly', + 'tinker', + 'tinkling', + 'tinsel', + 'tinsmith', + 'tint', + 'tinwork', + 'tiny', + 'tipoff', + 'tipped', + 'tipper', + 'tipping', + 'tiptoeing', + 'tiptop', + 'tiring', + 'tissue', + 'trace', + 'tracing', + 'track', + 'traction', + 'tractor', + 'trade', + 'trading', + 'tradition', + 'traffic', + 'tragedy', + 'trailing', + 'trailside', + 'train', + 'traitor', + 'trance', + 'tranquil', + 'transfer', + 'transform', + 'translate', + 'transpire', + 'transport', + 'transpose', + 'trapdoor', + 'trapeze', + 'trapezoid', + 'trapped', + 'trapper', + 'trapping', + 'traps', + 'trash', + 'travel', + 'traverse', + 'travesty', + 'tray', + 'treachery', + 'treading', + 'treadmill', + 'treason', + 'treat', + 'treble', + 'tree', + 'trekker', + 'tremble', + 'trembling', + 'tremor', + 'trench', + 'trend', + 'trespass', + 'triage', + 'trial', + 'triangle', + 'tribesman', + 'tribunal', + 'tribune', + 'tributary', + 'tribute', + 'triceps', + 'trickery', + 'trickily', + 'tricking', + 'trickle', + 'trickster', + 'tricky', + 'tricolor', + 'tricycle', + 'trident', + 'tried', + 'trifle', + 'trifocals', + 'trillion', + 'trilogy', + 'trimester', + 'trimmer', + 'trimming', + 'trimness', + 'trinity', + 'trio', + 'tripod', + 'tripping', + 'triumph', + 'trivial', + 'trodden', + 'trolling', + 'trombone', + 'trophy', + 'tropical', + 'tropics', + 'trouble', + 'troubling', + 'trough', + 'trousers', + 'trout', + 'trowel', + 'truce', + 'truck', + 'truffle', + 'trump', + 'trunks', + 'trustable', + 'trustee', + 'trustful', + 'trusting', + 'trustless', + 'truth', + 'try', + 'tubby', + 'tubeless', + 'tubular', + 'tucking', + 'tuesday', + 'tug', + 'tuition', + 'tulip', + 'tumble', + 'tumbling', + 'tummy', + 'turban', + 'turbine', + 'turbofan', + 'turbojet', + 'turbulent', + 'turf', + 'turkey', + 'turmoil', + 'turret', + 'turtle', + 'tusk', + 'tutor', + 'tutu', + 'tux', + 'tweak', + 'tweed', + 'tweet', + 'tweezers', + 'twelve', + 'twentieth', + 'twenty', + 'twerp', + 'twice', + 'twiddle', + 'twiddling', + 'twig', + 'twilight', + 'twine', + 'twins', + 'twirl', + 'twistable', + 'twisted', + 'twister', + 'twisting', + 'twisty', + 'twitch', + 'twitter', + 'tycoon', + 'tying', + 'tyke', + 'udder', + 'ultimate', + 'ultimatum', + 'ultra', + 'umbilical', + 'umbrella', + 'umpire', + 'unabashed', + 'unable', + 'unadorned', + 'unadvised', + 'unafraid', + 'unaired', + 'unaligned', + 'unaltered', + 'unarmored', + 'unashamed', + 'unaudited', + 'unawake', + 'unaware', + 'unbaked', + 'unbalance', + 'unbeaten', + 'unbend', + 'unbent', + 'unbiased', + 'unbitten', + 'unblended', + 'unblessed', + 'unblock', + 'unbolted', + 'unbounded', + 'unboxed', + 'unbraided', + 'unbridle', + 'unbroken', + 'unbuckled', + 'unbundle', + 'unburned', + 'unbutton', + 'uncanny', + 'uncapped', + 'uncaring', + 'uncertain', + 'unchain', + 'unchanged', + 'uncharted', + 'uncheck', + 'uncivil', + 'unclad', + 'unclaimed', + 'unclamped', + 'unclasp', + 'uncle', + 'unclip', + 'uncloak', + 'unclog', + 'unclothed', + 'uncoated', + 'uncoiled', + 'uncolored', + 'uncombed', + 'uncommon', + 'uncooked', + 'uncork', + 'uncorrupt', + 'uncounted', + 'uncouple', + 'uncouth', + 'uncover', + 'uncross', + 'uncrown', + 'uncrushed', + 'uncured', + 'uncurious', + 'uncurled', + 'uncut', + 'undamaged', + 'undated', + 'undaunted', + 'undead', + 'undecided', + 'undefined', + 'underage', + 'underarm', + 'undercoat', + 'undercook', + 'undercut', + 'underdog', + 'underdone', + 'underfed', + 'underfeed', + 'underfoot', + 'undergo', + 'undergrad', + 'underhand', + 'underline', + 'underling', + 'undermine', + 'undermost', + 'underpaid', + 'underpass', + 'underpay', + 'underrate', + 'undertake', + 'undertone', + 'undertook', + 'undertow', + 'underuse', + 'underwear', + 'underwent', + 'underwire', + 'undesired', + 'undiluted', + 'undivided', + 'undocked', + 'undoing', + 'undone', + 'undrafted', + 'undress', + 'undrilled', + 'undusted', + 'undying', + 'unearned', + 'unearth', + 'unease', + 'uneasily', + 'uneasy', + 'uneatable', + 'uneaten', + 'unedited', + 'unelected', + 'unending', + 'unengaged', + 'unenvied', + 'unequal', + 'unethical', + 'uneven', + 'unexpired', + 'unexposed', + 'unfailing', + 'unfair', + 'unfasten', + 'unfazed', + 'unfeeling', + 'unfiled', + 'unfilled', + 'unfitted', + 'unfitting', + 'unfixable', + 'unfixed', + 'unflawed', + 'unfocused', + 'unfold', + 'unfounded', + 'unframed', + 'unfreeze', + 'unfrosted', + 'unfrozen', + 'unfunded', + 'unglazed', + 'ungloved', + 'unglue', + 'ungodly', + 'ungraded', + 'ungreased', + 'unguarded', + 'unguided', + 'unhappily', + 'unhappy', + 'unharmed', + 'unhealthy', + 'unheard', + 'unhearing', + 'unheated', + 'unhelpful', + 'unhidden', + 'unhinge', + 'unhitched', + 'unholy', + 'unhook', + 'unicorn', + 'unicycle', + 'unified', + 'unifier', + 'uniformed', + 'uniformly', + 'unify', + 'unimpeded', + 'uninjured', + 'uninstall', + 'uninsured', + 'uninvited', + 'union', + 'uniquely', + 'unisexual', + 'unison', + 'unissued', + 'unit', + 'universal', + 'universe', + 'unjustly', + 'unkempt', + 'unkind', + 'unknotted', + 'unknowing', + 'unknown', + 'unlaced', + 'unlatch', + 'unlawful', + 'unleaded', + 'unlearned', + 'unleash', + 'unless', + 'unleveled', + 'unlighted', + 'unlikable', + 'unlimited', + 'unlined', + 'unlinked', + 'unlisted', + 'unlit', + 'unlivable', + 'unloaded', + 'unloader', + 'unlocked', + 'unlocking', + 'unlovable', + 'unloved', + 'unlovely', + 'unloving', + 'unluckily', + 'unlucky', + 'unmade', + 'unmanaged', + 'unmanned', + 'unmapped', + 'unmarked', + 'unmasked', + 'unmasking', + 'unmatched', + 'unmindful', + 'unmixable', + 'unmixed', + 'unmolded', + 'unmoral', + 'unmovable', + 'unmoved', + 'unmoving', + 'unnamable', + 'unnamed', + 'unnatural', + 'unneeded', + 'unnerve', + 'unnerving', + 'unnoticed', + 'unopened', + 'unopposed', + 'unpack', + 'unpadded', + 'unpaid', + 'unpainted', + 'unpaired', + 'unpaved', + 'unpeeled', + 'unpicked', + 'unpiloted', + 'unpinned', + 'unplanned', + 'unplanted', + 'unpleased', + 'unpledged', + 'unplowed', + 'unplug', + 'unpopular', + 'unproven', + 'unquote', + 'unranked', + 'unrated', + 'unraveled', + 'unreached', + 'unread', + 'unreal', + 'unreeling', + 'unrefined', + 'unrelated', + 'unrented', + 'unrest', + 'unretired', + 'unrevised', + 'unrigged', + 'unripe', + 'unrivaled', + 'unroasted', + 'unrobed', + 'unroll', + 'unruffled', + 'unruly', + 'unrushed', + 'unsaddle', + 'unsafe', + 'unsaid', + 'unsalted', + 'unsaved', + 'unsavory', + 'unscathed', + 'unscented', + 'unscrew', + 'unsealed', + 'unseated', + 'unsecured', + 'unseeing', + 'unseemly', + 'unseen', + 'unselect', + 'unselfish', + 'unsent', + 'unsettled', + 'unshackle', + 'unshaken', + 'unshaved', + 'unshaven', + 'unsheathe', + 'unshipped', + 'unsightly', + 'unsigned', + 'unskilled', + 'unsliced', + 'unsmooth', + 'unsnap', + 'unsocial', + 'unsoiled', + 'unsold', + 'unsolved', + 'unsorted', + 'unspoiled', + 'unspoken', + 'unstable', + 'unstaffed', + 'unstamped', + 'unsteady', + 'unsterile', + 'unstirred', + 'unstitch', + 'unstopped', + 'unstuck', + 'unstuffed', + 'unstylish', + 'unsubtle', + 'unsubtly', + 'unsuited', + 'unsure', + 'unsworn', + 'untagged', + 'untainted', + 'untaken', + 'untamed', + 'untangled', + 'untapped', + 'untaxed', + 'unthawed', + 'unthread', + 'untidy', + 'untie', + 'until', + 'untimed', + 'untimely', + 'untitled', + 'untoasted', + 'untold', + 'untouched', + 'untracked', + 'untrained', + 'untreated', + 'untried', + 'untrimmed', + 'untrue', + 'untruth', + 'unturned', + 'untwist', + 'untying', + 'unusable', + 'unused', + 'unusual', + 'unvalued', + 'unvaried', + 'unvarying', + 'unveiled', + 'unveiling', + 'unvented', + 'unviable', + 'unvisited', + 'unvocal', + 'unwanted', + 'unwarlike', + 'unwary', + 'unwashed', + 'unwatched', + 'unweave', + 'unwed', + 'unwelcome', + 'unwell', + 'unwieldy', + 'unwilling', + 'unwind', + 'unwired', + 'unwitting', + 'unwomanly', + 'unworldly', + 'unworn', + 'unworried', + 'unworthy', + 'unwound', + 'unwoven', + 'unwrapped', + 'unwritten', + 'unzip', + 'upbeat', + 'upchuck', + 'upcoming', + 'upcountry', + 'update', + 'upfront', + 'upgrade', + 'upheaval', + 'upheld', + 'uphill', + 'uphold', + 'uplifted', + 'uplifting', + 'upload', + 'upon', + 'upper', + 'upright', + 'uprising', + 'upriver', + 'uproar', + 'uproot', + 'upscale', + 'upside', + 'upstage', + 'upstairs', + 'upstart', + 'upstate', + 'upstream', + 'upstroke', + 'upswing', + 'uptake', + 'uptight', + 'uptown', + 'upturned', + 'upward', + 'upwind', + 'uranium', + 'urban', + 'urchin', + 'urethane', + 'urgency', + 'urgent', + 'urging', + 'urologist', + 'urology', + 'usable', + 'usage', + 'useable', + 'used', + 'uselessly', + 'user', + 'usher', + 'usual', + 'utensil', + 'utility', + 'utilize', + 'utmost', + 'utopia', + 'utter', + 'vacancy', + 'vacant', + 'vacate', + 'vacation', + 'vagabond', + 'vagrancy', + 'vagrantly', + 'vaguely', + 'vagueness', + 'valiant', + 'valid', + 'valium', + 'valley', + 'valuables', + 'value', + 'vanilla', + 'vanish', + 'vanity', + 'vanquish', + 'vantage', + 'vaporizer', + 'variable', + 'variably', + 'varied', + 'variety', + 'various', + 'varmint', + 'varnish', + 'varsity', + 'varying', + 'vascular', + 'vaseline', + 'vastly', + 'vastness', + 'veal', + 'vegan', + 'veggie', + 'vehicular', + 'velcro', + 'velocity', + 'velvet', + 'vendetta', + 'vending', + 'vendor', + 'veneering', + 'vengeful', + 'venomous', + 'ventricle', + 'venture', + 'venue', + 'venus', + 'verbalize', + 'verbally', + 'verbose', + 'verdict', + 'verify', + 'verse', + 'version', + 'versus', + 'vertebrae', + 'vertical', + 'vertigo', + 'very', + 'vessel', + 'vest', + 'veteran', + 'veto', + 'vexingly', + 'viability', + 'viable', + 'vibes', + 'vice', + 'vicinity', + 'victory', + 'video', + 'viewable', + 'viewer', + 'viewing', + 'viewless', + 'viewpoint', + 'vigorous', + 'village', + 'villain', + 'vindicate', + 'vineyard', + 'vintage', + 'violate', + 'violation', + 'violator', + 'violet', + 'violin', + 'viper', + 'viral', + 'virtual', + 'virtuous', + 'virus', + 'visa', + 'viscosity', + 'viscous', + 'viselike', + 'visible', + 'visibly', + 'vision', + 'visiting', + 'visitor', + 'visor', + 'vista', + 'vitality', + 'vitalize', + 'vitally', + 'vitamins', + 'vivacious', + 'vividly', + 'vividness', + 'vixen', + 'vocalist', + 'vocalize', + 'vocally', + 'vocation', + 'voice', + 'voicing', + 'void', + 'volatile', + 'volley', + 'voltage', + 'volumes', + 'voter', + 'voting', + 'voucher', + 'vowed', + 'vowel', + 'voyage', + 'wackiness', + 'wad', + 'wafer', + 'waffle', + 'waged', + 'wager', + 'wages', + 'waggle', + 'wagon', + 'wake', + 'waking', + 'walk', + 'walmart', + 'walnut', + 'walrus', + 'waltz', + 'wand', + 'wannabe', + 'wanted', + 'wanting', + 'wasabi', + 'washable', + 'washbasin', + 'washboard', + 'washbowl', + 'washcloth', + 'washday', + 'washed', + 'washer', + 'washhouse', + 'washing', + 'washout', + 'washroom', + 'washstand', + 'washtub', + 'wasp', + 'wasting', + 'watch', + 'water', + 'waviness', + 'waving', + 'wavy', + 'whacking', + 'whacky', + 'wham', + 'wharf', + 'wheat', + 'whenever', + 'whiff', + 'whimsical', + 'whinny', + 'whiny', + 'whisking', + 'whoever', + 'whole', + 'whomever', + 'whoopee', + 'whooping', + 'whoops', + 'why', + 'wick', + 'widely', + 'widen', + 'widget', + 'widow', + 'width', + 'wieldable', + 'wielder', + 'wife', + 'wifi', + 'wikipedia', + 'wildcard', + 'wildcat', + 'wilder', + 'wildfire', + 'wildfowl', + 'wildland', + 'wildlife', + 'wildly', + 'wildness', + 'willed', + 'willfully', + 'willing', + 'willow', + 'willpower', + 'wilt', + 'wimp', + 'wince', + 'wincing', + 'wind', + 'wing', + 'winking', + 'winner', + 'winnings', + 'winter', + 'wipe', + 'wired', + 'wireless', + 'wiring', + 'wiry', + 'wisdom', + 'wise', + 'wish', + 'wisplike', + 'wispy', + 'wistful', + 'wizard', + 'wobble', + 'wobbling', + 'wobbly', + 'wok', + 'wolf', + 'wolverine', + 'womanhood', + 'womankind', + 'womanless', + 'womanlike', + 'womanly', + 'womb', + 'woof', + 'wooing', + 'wool', + 'woozy', + 'word', + 'work', + 'worried', + 'worrier', + 'worrisome', + 'worry', + 'worsening', + 'worshiper', + 'worst', + 'wound', + 'woven', + 'wow', + 'wrangle', + 'wrath', + 'wreath', + 'wreckage', + 'wrecker', + 'wrecking', + 'wrench', + 'wriggle', + 'wriggly', + 'wrinkle', + 'wrinkly', + 'wrist', + 'writing', + 'written', + 'wrongdoer', + 'wronged', + 'wrongful', + 'wrongly', + 'wrongness', + 'wrought', + 'xbox', + 'xerox', + 'yahoo', + 'yam', + 'yanking', + 'yapping', + 'yard', + 'yarn', + 'yeah', + 'yearbook', + 'yearling', + 'yearly', + 'yearning', + 'yeast', + 'yelling', + 'yelp', + 'yen', + 'yesterday', + 'yiddish', + 'yield', + 'yin', + 'yippee', + 'yo-yo', + 'yodel', + 'yoga', + 'yogurt', + 'yonder', + 'yoyo', + 'yummy', + 'zap', + 'zealous', + 'zebra', + 'zen', + 'zeppelin', + 'zero', + 'zestfully', + 'zesty', + 'zigzagged', + 'zipfile', + 'zipping', + 'zippy', + 'zips', + 'zit', + 'zodiac', + 'zombie', + 'zone', + 'zoning', + 'zookeeper', + 'zoologist', + 'zoology', + 'zoom' +] + +const wordlistLength = wordlist.length; \ No newline at end of file From 50d829451b430c10cf70dfe9f9b766a14615b223 Mon Sep 17 00:00:00 2001 From: Simeon J Morgan Date: Sun, 20 Nov 2022 17:19:00 +1100 Subject: [PATCH 05/13] Add myinfo command --- src/index.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/index.js b/src/index.js index 146d297..9820ba1 100644 --- a/src/index.js +++ b/src/index.js @@ -530,6 +530,16 @@ async function setTeamLabel(dbTeamId, teamLabel, dbUserId, dbUserIsAdmin, userId } } +async function getMyInfo(dbTeamId, teamId, teamDomain, dbTeamLabel, dbAbstractUserId, dbUserId, dbUserIsAdmin, userId, userName) { + /** + * Outputs a summary of the current user context for debugging + */ + return { + response_type: "ephemeral", + text: `You are on team ${dbTeamId}:${teamId}:${teamDomain}:${dbTeamLabel}\nuser ${dbAbstractUserId}:${dbUserId}:${userId}:${userName}. Your is_admin value is ${dbUserIsAdmin}`, + }; +} + PERMISSION_SLACKACTION = "SLACK_ACTION"; async function hasPermission(ctx, action) { @@ -647,6 +657,19 @@ router.post("/addCoffee", async (ctx, next) => { ctx.request.body.user_name ); return; + } else if (dbUserIsAdmin && ctx.request.body.text == "myinfo") { + ctx.body = await getMyInfo( + dbTeamId, + ctx.request.body.team_id, + ctx.request.body.team_domain, + dbTeamLabel, + dbAbstractUserId, + dbUserId, + dbUserIsAdmin, + ctx.request.body.user_id, + ctx.request.body.user_name + ); + return; } else if (dbUserIsAdmin && ctx.request.body.text === "backup") { ctx.body = await backup.createBackup(pool, awsDetails); return; From b60f5f93901a253519861827e9988d75d2cdaa2c Mon Sep 17 00:00:00 2001 From: Simeon J Morgan Date: Sun, 20 Nov 2022 17:55:23 +1100 Subject: [PATCH 06/13] Add link command to help --- src/index.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/index.js b/src/index.js index 9820ba1..0c55b21 100644 --- a/src/index.js +++ b/src/index.js @@ -114,7 +114,9 @@ function showHelp() { - \`/coffee -\` - subtract multiple coffees, max 2; but try not to add coffees you're not drinking - \`/coffee count\` - show the total number of coffees, and highest 5 coffee consumers - \`/coffee count-all\` - show the total number of coffees, and _all_ coffee consumers - - \`/coffee stats\` - see summary data from all coffees recorded since the beginning of the bot + - \`/coffee stats\` - see summary data from all coffees recorded since the beginning of the bot (currently broken) + - \`/coffee link\` - get a code to link your user between workspaces, so you can log coffees from any of them + - \`/coffee link \` - use a link code to link your account between workspaces - \`/coffee about\` - about coffeebot`, }; } @@ -122,8 +124,8 @@ function showHelp() { function showAbout() { return { response_type: "ephemeral", - text: `Coffeebot was written the night before international coffee 2020 as a combination between a joke and -an experiment in using firebase. Somehow, it has continued to be used since then. I hope you like it. + text: `Coffeebot was written the night before international coffee day 2020 as a something between ` + + `a joke and an experiment in using firebase. Somehow, it has continued to be used since then. I hope you like it. - Simeon` } From 8a21a39688982482025a054d8e03c13e272b2919 Mon Sep 17 00:00:00 2001 From: Simeon J Morgan Date: Sun, 20 Nov 2022 19:45:13 +1100 Subject: [PATCH 07/13] Update the about message --- src/index.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/index.js b/src/index.js index 0c55b21..978ea47 100644 --- a/src/index.js +++ b/src/index.js @@ -121,11 +121,14 @@ function showHelp() { }; } -function showAbout() { +function showAbout(dbTeamLabel) { return { response_type: "ephemeral", - text: `Coffeebot was written the night before international coffee day 2020 as a something between ` + - `a joke and an experiment in using firebase. Somehow, it has continued to be used since then. I hope you like it. + text: `CoffeeBot is a helpful slack bot dedicated to capturing the coffee consumption habits of ${getTeamLabelOrGenericPlural(dbTeamLabel)}.\n` + + `It was written the night before international coffee day 2020 as a something between ` + + `a joke and an experiment in using firebase. Somehow, it has continued to be used since then (although no longer in Firebase).\n` + + `It was created based on the idea by Bec (of 2020 Common Code) that it would be cool to know how much coffee ` + + `team members drink. I hope you enjoy it. - Simeon` } @@ -535,7 +538,7 @@ async function setTeamLabel(dbTeamId, teamLabel, dbUserId, dbUserIsAdmin, userId async function getMyInfo(dbTeamId, teamId, teamDomain, dbTeamLabel, dbAbstractUserId, dbUserId, dbUserIsAdmin, userId, userName) { /** * Outputs a summary of the current user context for debugging - */ + */ return { response_type: "ephemeral", text: `You are on team ${dbTeamId}:${teamId}:${teamDomain}:${dbTeamLabel}\nuser ${dbAbstractUserId}:${dbUserId}:${userId}:${userName}. Your is_admin value is ${dbUserIsAdmin}`, @@ -604,7 +607,7 @@ router.post("/addCoffee", async (ctx, next) => { ctx.body = showHelp(); return; } else if (ctx.request.body.text === "about") { - ctx.body = showAbout(); + ctx.body = showAbout(dbTeamLabel); return; } else if (ctx.request.body.text === "link") { ctx.body = await getLinkCode(dbAbstractUserId); From fd5b49510645318139c82e7dc290c5da8f60fcc7 Mon Sep 17 00:00:00 2001 From: Simeon J Morgan Date: Sun, 20 Nov 2022 20:07:16 +1100 Subject: [PATCH 08/13] Remove stray package-lock.json --- package-lock.json | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index b3de804..0000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "coffeebot", - "lockfileVersion": 2, - "requires": true, - "packages": {} -} From 3122a2e981f3a5ef2f48ab3c45acf7fb7724b80d Mon Sep 17 00:00:00 2001 From: Simeon J Morgan Date: Sun, 20 Nov 2022 20:15:35 +1100 Subject: [PATCH 09/13] Auto-update user name --- src/index.js | 15 ++++++++++----- src/queries.js | 8 +++++++- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/index.js b/src/index.js index 978ea47..f3639c2 100644 --- a/src/index.js +++ b/src/index.js @@ -125,10 +125,10 @@ function showAbout(dbTeamLabel) { return { response_type: "ephemeral", text: `CoffeeBot is a helpful slack bot dedicated to capturing the coffee consumption habits of ${getTeamLabelOrGenericPlural(dbTeamLabel)}.\n` + - `It was written the night before international coffee day 2020 as a something between ` + - `a joke and an experiment in using firebase. Somehow, it has continued to be used since then (although no longer in Firebase).\n` + - `It was created based on the idea by Bec (of 2020 Common Code) that it would be cool to know how much coffee ` + - `team members drink. I hope you enjoy it. + `It was written the night before international coffee day 2020 as a something between ` + + `a joke and an experiment in using firebase. Somehow, it has continued to be used since then (although no longer in Firebase).\n` + + `It was created based on the idea by Bec (of 2020 Common Code) that it would be cool to know how much coffee ` + + `team members drink. I hope you enjoy it. - Simeon` } @@ -299,9 +299,14 @@ async function getOrCreateUser(userId, userName, dbTeamId) { // If there is an existing user set up, just return the details getUserQuery = await client.query(queries.GET_USER_V2_QUERY, [userId, dbTeamId]); if (getUserQuery.rows.length > 0) { + // If the user's slack name has changed, update it in the user table + const dbUserId = getUserQuery.rows[0].id; + if (getUserQuery.rows[0].user_name !== userName) { + await client.query(queries.UPDATE_USER_NAME_V2_QUERY, [dbUserId, userName]) + } return { dbAbstractUserId: getUserQuery.rows[0].abstract_user_id, - dbUserId: getUserQuery.rows[0].id, + dbUserId: dbUserId, dbUserIsAdmin: getUserQuery.rows[0].is_admin, }; } diff --git a/src/queries.js b/src/queries.js index 408e23b..f069f3b 100644 --- a/src/queries.js +++ b/src/queries.js @@ -207,7 +207,7 @@ module.exports = { `, GET_USER_V2_QUERY: ` - SELECT id, abstract_user_id, is_admin + SELECT id, abstract_user_id, is_admin, user_name FROM public.user_v2 WHERE user_id = $1 AND team_id = $2; `, @@ -226,6 +226,12 @@ module.exports = { WHERE user_id = $2 AND team_id = $5; `, + UPDATE_USER_NAME_V2_QUERY: ` + UPDATE public.user_v2 + SET user_name = $2 + WHERE id = $1; + `, + INSERT_LINK_WORDS_V2_QUERY: ` INSERT INTO public.link_words_v2 (abstract_user_id, words, created_at) VALUES ($1, $2, $3) From 6b454f46fbdae44530114f535ac55f37b6af29ce Mon Sep 17 00:00:00 2001 From: Simeon J Morgan Date: Mon, 21 Nov 2022 16:24:01 +1100 Subject: [PATCH 10/13] Stop outputing links as bold --- src/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.js b/src/index.js index f3639c2..f390d70 100644 --- a/src/index.js +++ b/src/index.js @@ -414,7 +414,7 @@ async function getLinkCode(dbAbstractUserId) { await client.query(queries.INSERT_LINK_WORDS_V2_QUERY, [dbAbstractUserId, words, dt.toISO()]); return { response_type: "ephemeral", - text: `Your link code is ${words}. To link another workspace enter */coffee link ${words}*`, + text: `Your link code is ${words}. To link another workspace enter /coffee link ${words}`, }; } catch (e) { console.log(`Error in getLinkCode: ${e}`); @@ -450,7 +450,7 @@ async function linkUserByCode(dbAbstractUserId, words) { if (getAbstractUserForLinkWordQuery.rows.length < 1) { return { response_type: "ephemeral", - text: `The link code ${words} could not be found or is too old. Use */coffee link* to get a new link code`, + text: `The link code ${words} could not be found or is too old. Use /coffee link to get a new link code`, }; } From b977f7a1cb8e6a4bba218795c0392e3a6410bf40 Mon Sep 17 00:00:00 2001 From: Simeon J Morgan Date: Tue, 20 Jun 2023 11:02:27 +1000 Subject: [PATCH 11/13] Fix incorrect function call in backup cron job; add weekly full backup. --- src/index.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index f390d70..cd7f6c1 100644 --- a/src/index.js +++ b/src/index.js @@ -53,10 +53,27 @@ const pool = new Pool({ port: process.env.POSTGRES_PORT, }); +/** + * Cron Jobs perform backups of data in a + * JSONL format to S3. Note there is no current + * mechanism to restore the data - it would need + * to be manually loaded into the database + * (although that should be fairly straightforward) + */ new CronJob( "00 00 02 * * *", async function () { - await createBackup(pool, awsDetails); + await backup.createBackup(pool, awsDetails); + }, + null, + true, + "Australia/Melbourne" +); + +new CronJob( + "00 00 03 * * SAT", + async function () { + await backup.createFullBackup(pool, awsDetails); }, null, true, From 3628f32df17d887e9012c2082627de5d92031f87 Mon Sep 17 00:00:00 2001 From: Simeon J Morgan Date: Tue, 20 Jun 2023 11:02:50 +1000 Subject: [PATCH 12/13] Allow having blank passthrough name to skip passthrough --- src/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index cd7f6c1..ffdab91 100644 --- a/src/index.js +++ b/src/index.js @@ -87,7 +87,7 @@ function passthroughRequest(ctx) { * to process incoming data in case the new one screws up * or isn't working correctly. */ - if (REQUEST_PASSTHROUGH_HOST === null) { + if (REQUEST_PASSTHROUGH_HOST === null || REQUEST_PASSTHROUGH_HOST === "") { return; } From 2b740d5de7d710ca72280a980694bc52ffba0a63 Mon Sep 17 00:00:00 2001 From: Simeon J Morgan Date: Tue, 20 Jun 2023 11:02:58 +1000 Subject: [PATCH 13/13] Add additional code comments --- src/index.js | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/index.js b/src/index.js index ffdab91..e56d6cd 100644 --- a/src/index.js +++ b/src/index.js @@ -118,6 +118,10 @@ function passthroughRequest(ctx) { } function showHelp() { + /** + * Post simple 'help' screen to Slack, visible only to the user who used the `/coffee help` command + * (because response_type is 'ephemeral') + */ return { response_type: "ephemeral", text: `Ohai, and welcome to coffeebot. Coffeebot counts the coffees consumed by teams because why not. @@ -139,6 +143,10 @@ function showHelp() { } function showAbout(dbTeamLabel) { + /** + * Post simple 'about' screen to Slack, visible only to the user who used the `/coffee help` command + * (because response_type is 'ephemeral') + */ return { response_type: "ephemeral", text: `CoffeeBot is a helpful slack bot dedicated to capturing the coffee consumption habits of ${getTeamLabelOrGenericPlural(dbTeamLabel)}.\n` + @@ -154,6 +162,11 @@ function showAbout(dbTeamLabel) { async function createDatabaseBitsIfMissing() { + /** + * Create something resembling a 'database migrations' table + * to track which groups of queries have executed to do database + * structure updates. + */ const client = await pool.connect(); try { console.log("Attempting to create migrations table"); @@ -165,6 +178,13 @@ async function createDatabaseBitsIfMissing() { } async function showCoffeeStats(dbTeamId, dbTeamLabel) { + /** + * Return slack-renderable statistics on reported coffee consumption + * for people in the team for the current workspace. + * NOTE: This (as at 2023-06-20) returns an error in slack. + * Don't know whether that is because of an internal error, + * or the blocks going to slack don't have valid structure/format. + */ const client = await pool.connect(); try { @@ -216,10 +236,14 @@ async function showCoffeeStats(dbTeamId, dbTeamLabel) { } function getTeamLabelOrGenericPlural(dbTeamLabel) { + // Helper to get a generic team label if one is not set return dbTeamLabel || 'workspace members'; } async function showCoffeeCount(dbTeamId, dbTeamLabel, numOfItems) { + /** + * Return slack-renderable daily coffee leaderboard + */ const client = await pool.connect(); try { const dt = DateTime.local().setZone("Australia/Melbourne"); @@ -361,6 +385,15 @@ async function getOrCreateUser(userId, userName, dbTeamId) { } async function addCoffee(dbAbstractUserId, dbUserId, dbTeamId, dbTeamLabel, inc) { + /** + * Add coffees to the count for a specific user (or subtract if negative inc value), + * and return the new day total for that user in a slack-renderable form. + * For subtraction, the most recent coffee records are deleted (i.e. lost forever). + * On reflection, just marking them inactive would probably have been better but + * that's not what I've done. + */ + + // sanity check actions if (inc > MAX_COFFEE_ADD) { return { response_type: "ephemeral", @@ -386,6 +419,7 @@ async function addCoffee(dbAbstractUserId, dbUserId, dbTeamId, dbTeamLabel, inc) }); const start_of_tomorrow = dt.plus({ days: 1 }).set({ hour: 0, minute: 0, second: 0, millisecond: 0 }); + // add or remove drinks if (inc > 0) { for (let idx = 0; idx < inc; idx++) { await client.query( @@ -400,6 +434,7 @@ async function addCoffee(dbAbstractUserId, dbUserId, dbTeamId, dbTeamLabel, inc) ); } + // calculate current drink count for the user and return as slack-renderable items const totalCoffeeCountQuery = await client.query(queries.COUNT_ALL_DRINKS_V2_QUERY, [ start_of_today.toISO(), start_of_tomorrow.toISO(), @@ -423,6 +458,16 @@ async function addCoffee(dbAbstractUserId, dbUserId, dbTeamId, dbTeamLabel, inc) } async function getLinkCode(dbAbstractUserId) { + /** + * Generate a link code for linking a user between workspaces. + * The flow for linking is that the user requests a link code, which gets + * written to the database for that user and returned to the user in slack + * (visible only to that user, because 'ephemeral') + * The user then passes that link code in another workspace connected to + * CoffeeBot to link the accounts between the two spaces. Linking the account + * means that a single coffee count for the user can be generated across the multiple + * workspaces, regardless of where they log the coffee. + */ const words = wordlist.getWords(4); const dt = DateTime.local().setZone("Australia/Melbourne"); const client = await pool.connect(); @@ -441,6 +486,11 @@ async function getLinkCode(dbAbstractUserId) { } async function linkUserByCode(dbAbstractUserId, words) { + /** + * Receive a link code for a user, and use it to link the user between workspaces. + * Note that I have _no idea_ what will happen if a link code is passed back + * to the same workspace. Hopefully it doesn't explode things... + */ const dt = DateTime.local().setZone("Australia/Melbourne"); const dtLinkCutoff = dt.minus({ days: 1 }); const client = await pool.connect();