Add a cron job for the monthly activity email

This this is running in plain Node (i.e. not with the Next.js
infrastructure), I had to make a couple of adjustments to some
existing files. Specifically, files with JSX code need to explicitly
import React, and code that shouldn't be run on the client-side
can't use `import "server-only"`, because that only checks that
it's being imported in a React server component.

Additionally, I've added esbuild to bundle it up into a single
file. Theoretically, this isn't really needed for Node.js scripts,
since there are no HTTP requests we're trying to optimise. However,
plain Node.js can't resolve module imports without file extensions
(e.g. `"../db/tables/subscribers"`), so all import specifiers in
*any* module that gets loaded by the cron jobs would need to use
file extensions that match those of the generated files (i.e. .js
rather than .ts), which is bound to cause its own issues in
Next.js. Thus, esbuild is a compromise that can resolve these
import specifiers for us.
This commit is contained in:
Vincent 2024-04-02 15:52:52 +02:00 коммит произвёл Vincent
Родитель 1c863edb2e
Коммит c158dc040c
15 изменённых файлов: 577 добавлений и 54 удалений

26
esbuild.cronjobs.js Normal file
Просмотреть файл

@ -0,0 +1,26 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { build } from "esbuild";
// Theoretically, since the cron jobs run in Node, we don't need a bundler,
// since there are no HTTP requests we're trying to optimise. However, plain
// Node.js can't resolve module imports without file extensions (e.g.
// `"../db/tables/subscribers"`), so all import specifiers in *any* module that
// gets loaded by the cron jobs would need to use file extensions, which should
// also match those of the generated files (i.e. .js rather than .ts). Some of
// those modules are also used in our website, where this is bound to cause its
// own issues in Next.js. Thus, esbuild is a compromise that can resolve these
// import specifiers for us.
build({
entryPoints: ["./src/scripts/cronjobs/**/*.tsx"],
tsconfig: "tsconfig.cronjobs.json",
bundle: true,
platform: "node",
format: "esm",
outdir: "dist/scripts/cronjobs/",
sourcemap: true,
target: "node20.12",
packages: "external",
});

338
package-lock.json сгенерированный
Просмотреть файл

@ -74,6 +74,7 @@
"@typescript-eslint/eslint-plugin": "^7.5.0", "@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0", "@typescript-eslint/parser": "^7.5.0",
"adm-zip": "^0.5.12", "adm-zip": "^0.5.12",
"esbuild": "^0.20.2",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-import-resolver-typescript": "^3.6.1", "eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-check-file": "^2.7.1", "eslint-plugin-check-file": "^2.7.1",
@ -3161,6 +3162,32 @@
"node": ">=0.1.90" "node": ">=0.1.90"
} }
}, },
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@csstools/css-parser-algorithms": { "node_modules/@csstools/css-parser-algorithms": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.6.1.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.6.1.tgz",
@ -3300,9 +3327,9 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.18.20", "version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
"integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -8809,6 +8836,88 @@
"node": ">=12.16" "node": ">=12.16"
} }
}, },
"node_modules/@swc/core": {
"version": "1.4.12",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.4.12.tgz",
"integrity": "sha512-QljRxTaUajSLB9ui93cZ38/lmThwIw/BPxjn+TphrYN6LPU3vu9/ykjgHtlpmaXDDcngL4K5i396E7iwwEUxYg==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.2",
"@swc/types": "^0.1.5"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.4.12",
"@swc/core-darwin-x64": "1.4.12",
"@swc/core-linux-arm-gnueabihf": "1.4.12",
"@swc/core-linux-arm64-gnu": "1.4.12",
"@swc/core-linux-arm64-musl": "1.4.12",
"@swc/core-linux-x64-gnu": "1.4.12",
"@swc/core-linux-x64-musl": "1.4.12",
"@swc/core-win32-arm64-msvc": "1.4.12",
"@swc/core-win32-ia32-msvc": "1.4.12",
"@swc/core-win32-x64-msvc": "1.4.12"
},
"peerDependencies": {
"@swc/helpers": "^0.5.0"
},
"peerDependenciesMeta": {
"@swc/helpers": {
"optional": true
}
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.4.12",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.4.12.tgz",
"integrity": "sha512-HLZIWNHWuFIlH+LEmXr1lBiwGQeCshKOGcqbJyz7xpqTh7m2IPAxPWEhr/qmMTMsjluGxeIsLrcsgreTyXtgNA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.4.12",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.4.12.tgz",
"integrity": "sha512-M5fBAtoOcpz2YQAFtNemrPod5BqmzAJc8pYtT3dVTn1MJllhmLHlphU8BQytvoGr1PHgJL8ZJBlBGdt70LQ7Mw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.2", "version": "0.5.2",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz",
@ -8817,6 +8926,17 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@swc/types": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.6.tgz",
"integrity": "sha512-/JLo/l2JsT/LRd80C3HfbmVpxOAJ11FO2RCEslFrgzLltoP9j8XIbsyDcfCt2WWyX+CM96rBoNM+IToAkFOugg==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3"
}
},
"node_modules/@testing-library/dom": { "node_modules/@testing-library/dom": {
"version": "9.3.4", "version": "9.3.4",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz",
@ -8942,6 +9062,38 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tsconfig/node10": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
"integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/@types/adm-zip": { "node_modules/@types/adm-zip": {
"version": "0.5.5", "version": "0.5.5",
"resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.5.tgz", "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.5.tgz",
@ -10510,6 +10662,14 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/argparse": { "node_modules/argparse": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@ -12487,6 +12647,14 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.3", "version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -13045,6 +13213,17 @@
"detect-port": "bin/detect-port.js" "detect-port": "bin/detect-port.js"
} }
}, },
"node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true,
"optional": true,
"peer": true,
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/diff-sequences": { "node_modules/diff-sequences": {
"version": "29.6.3", "version": "29.6.3",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
@ -13634,9 +13813,9 @@
} }
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.18.20", "version": "0.20.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
"integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"bin": { "bin": {
@ -13646,28 +13825,29 @@
"node": ">=12" "node": ">=12"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/android-arm": "0.18.20", "@esbuild/aix-ppc64": "0.20.2",
"@esbuild/android-arm64": "0.18.20", "@esbuild/android-arm": "0.20.2",
"@esbuild/android-x64": "0.18.20", "@esbuild/android-arm64": "0.20.2",
"@esbuild/darwin-arm64": "0.18.20", "@esbuild/android-x64": "0.20.2",
"@esbuild/darwin-x64": "0.18.20", "@esbuild/darwin-arm64": "0.20.2",
"@esbuild/freebsd-arm64": "0.18.20", "@esbuild/darwin-x64": "0.20.2",
"@esbuild/freebsd-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.20.2",
"@esbuild/linux-arm": "0.18.20", "@esbuild/freebsd-x64": "0.20.2",
"@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-arm": "0.20.2",
"@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-arm64": "0.20.2",
"@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-ia32": "0.20.2",
"@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-loong64": "0.20.2",
"@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-mips64el": "0.20.2",
"@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-ppc64": "0.20.2",
"@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-riscv64": "0.20.2",
"@esbuild/linux-x64": "0.18.20", "@esbuild/linux-s390x": "0.20.2",
"@esbuild/netbsd-x64": "0.18.20", "@esbuild/linux-x64": "0.20.2",
"@esbuild/openbsd-x64": "0.18.20", "@esbuild/netbsd-x64": "0.20.2",
"@esbuild/sunos-x64": "0.18.20", "@esbuild/openbsd-x64": "0.20.2",
"@esbuild/win32-arm64": "0.18.20", "@esbuild/sunos-x64": "0.20.2",
"@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-arm64": "0.20.2",
"@esbuild/win32-x64": "0.18.20" "@esbuild/win32-ia32": "0.20.2",
"@esbuild/win32-x64": "0.20.2"
} }
}, },
"node_modules/esbuild-plugin-alias": { "node_modules/esbuild-plugin-alias": {
@ -15661,19 +15841,6 @@
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
}, },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -26475,6 +26642,76 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true "dev": true
}, },
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/ts-node/node_modules/acorn": {
"version": "8.11.3",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
"integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
"dev": true,
"optional": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/ts-node/node_modules/acorn-walk": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz",
"integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==",
"dev": true,
"optional": true,
"peer": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/ts-pnp": { "node_modules/ts-pnp": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz", "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz",
@ -27024,6 +27261,14 @@
"uuid": "dist/bin/uuid" "uuid": "dist/bin/uuid"
} }
}, },
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/v8-to-istanbul": { "node_modules/v8-to-istanbul": {
"version": "9.2.0", "version": "9.2.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz",
@ -27751,6 +27996,17 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"optional": true,
"peer": true,
"engines": {
"node": ">=6"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

Просмотреть файл

@ -10,7 +10,7 @@
"scripts": { "scripts": {
"dev": "next dev --port=6060", "dev": "next dev --port=6060",
"dev:nimbus": "node --watch-path config/nimbus.yaml src/scripts/build/nimbusTypes.js", "dev:nimbus": "node --watch-path config/nimbus.yaml src/scripts/build/nimbusTypes.js",
"build": "npm run get-location-data && npm run build-glean && npm run build-nimbus && next build", "build": "npm run get-location-data && npm run build-glean && npm run build-nimbus && next build && npm run build-cronjobs",
"start": "next start", "start": "next start",
"lint": "stylelint '**/*.scss' && prettier --check './src' && next lint --max-warnings=0", "lint": "stylelint '**/*.scss' && prettier --check './src' && next lint --max-warnings=0",
"fix": "prettier --write './src' && next lint --fix && stylelint --fix '**/*.scss'", "fix": "prettier --write './src' && next lint --fix && stylelint --fix '**/*.scss'",
@ -25,6 +25,7 @@
"build-storybook": "npm run build-glean && storybook build", "build-storybook": "npm run build-glean && storybook build",
"create-location-data": "node src/scripts/build/uploadAutoCompleteLocations.js", "create-location-data": "node src/scripts/build/uploadAutoCompleteLocations.js",
"get-location-data": "node src/scripts/build/getAutoCompleteLocations.js", "get-location-data": "node src/scripts/build/getAutoCompleteLocations.js",
"build-cronjobs": "node esbuild.cronjobs.js",
"build-nimbus": "node src/scripts/build/nimbusTypes.js", "build-nimbus": "node src/scripts/build/nimbusTypes.js",
"build-glean": "glean translate src/telemetry/metrics.yaml --format typescript --output src/telemetry/generated && npm run build-glean-types", "build-glean": "glean translate src/telemetry/metrics.yaml --format typescript --output src/telemetry/generated && npm run build-glean-types",
"build-glean-types": "node src/scripts/build/gleanTypes.js", "build-glean-types": "node src/scripts/build/gleanTypes.js",
@ -106,6 +107,7 @@
"@typescript-eslint/eslint-plugin": "^7.5.0", "@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0", "@typescript-eslint/parser": "^7.5.0",
"adm-zip": "^0.5.12", "adm-zip": "^0.5.12",
"esbuild": "^0.20.2",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-import-resolver-typescript": "^3.6.1", "eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-check-file": "^2.7.1", "eslint-plugin-check-file": "^2.7.1",

Просмотреть файл

@ -0,0 +1,14 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/* c8 ignore start */
if (typeof process.env.NEXT_RUNTIME === "string") {
// server-only doesn't have type definitions because it doesn't do anything;
// TS wouldn't complain for regular imports, but with dynamic imports (which
// we need for the check to see if we're running in Next.js), it expects it to
// do something.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
void import("server-only");
}

Просмотреть файл

@ -6,7 +6,7 @@
* Functions that only return data we wouldn't mind sending to the client * Functions that only return data we wouldn't mind sending to the client
*/ */
import "server-only"; import "./notInClientComponent";
import { EmailAddressRow, SubscriberRow } from "knex/types/tables"; import { EmailAddressRow, SubscriberRow } from "knex/types/tables";
type SanitizationMarker = { type SanitizationMarker = {

Просмотреть файл

@ -36,6 +36,7 @@ export type FeatureFlagName =
| "FxaUidTelemetry" | "FxaUidTelemetry"
| "RebrandAnnouncement" | "RebrandAnnouncement"
| "MonitorAccountDeletion" | "MonitorAccountDeletion"
| "MonthlyActivityEmail"
| "RedesignedEmails" | "RedesignedEmails"
| "CsatSurvey" | "CsatSurvey"
| "CancellationFlow" | "CancellationFlow"
@ -64,6 +65,27 @@ export async function getEnabledFeatureFlags(
return enabledFlagNames.map((row) => row.name as FeatureFlagName); return enabledFlagNames.map((row) => row.name as FeatureFlagName);
} }
/**
* It is recommended to use `getEnabledFeatureFlags` if you want to know what
* features to show for a single person. This function is for use cases where
* you need to potentially use the allowlist in a different query (specifically
* `getSubscribersWaitingForMonthlyEmail`, at the time of writing).
*
* @param featureFlagName
*/
export async function getFeatureFlagData(
featureFlagName: FeatureFlagName,
): Promise<FeatureFlagRow | null> {
return (
(await knex("feature_flags")
.first()
.where("name", featureFlagName)
// The `.andWhereNull` alias doesn't seem to exist:
// https://github.com/knex/knex/issues/1881#issuecomment-275433906
.whereNull("deleted_at")) ?? null
);
}
async function getFeatureFlagByName(name: string) { async function getFeatureFlagByName(name: string) {
logger.info("getFeatureFlagByName", name); logger.info("getFeatureFlagByName", name);
const res = await knex("feature_flags").where("name", name); const res = await knex("feature_flags").where("name", name);

Просмотреть файл

@ -5,9 +5,11 @@
import createDbConnection from "../connect.js"; import createDbConnection from "../connect.js";
import { destroyOAuthToken } from '../../utils/fxa.js' import { destroyOAuthToken } from '../../utils/fxa.js'
import AppConstants from '../../appConstants.js' import AppConstants from '../../appConstants.js'
import { getFeatureFlagData } from "./featureFlags";
const knex = createDbConnection(); const knex = createDbConnection();
const { DELETE_UNVERIFIED_SUBSCRIBERS_TIMER } = AppConstants const { DELETE_UNVERIFIED_SUBSCRIBERS_TIMER } = AppConstants
const MONITOR_PREMIUM_CAPABILITY = "monitor";
/** /**
* @param {string[]} hashes * @param {string[]} hashes
@ -271,8 +273,54 @@ async function deleteResolutionsWithEmail (id, email) {
} }
/* c8 ignore stop */ /* c8 ignore stop */
/**
* @param {Partial<{ plusOnly: boolean; limit: number; }>} options
* @returns {Promise<import("knex/types/tables").SubscriberRow[]>}
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function getSubscribersWaitingForMonthlyEmail (options = {}) {
const flag = await getFeatureFlagData("MonthlyActivityEmail");
if (!flag?.is_enabled) {
return [];
}
let query = knex('subscribers')
.select()
.where((builder) => builder.whereNull("monthly_monitor_report").orWhere("monthly_monitor_report", true))
.andWhere(builder => builder.whereNull("monthly_monitor_report_at").orWhereRaw('"monthly_monitor_report_at" < NOW() - INTERVAL \'30 days\''));
if (Array.isArray(flag.allow_list) && flag.allow_list.length > 0) {
// The `.andWhereIn` alias doesn't exist:
// https://github.com/knex/knex/issues/1881#issuecomment-275433906
query = query.whereIn("primary_email", flag.allow_list)
}
if (options.plusOnly) {
// Note: This will only match people of whom the Monitor database knows that
// they have a Plus subscription. SubPlat is the source of truth, but
// our database is updated via a webhook and whenever the user logs
// in. Locally, you might want to set this via `/admin/dev/`.
query = query.andWhereRaw(
`(fxa_profile_json->'subscriptions')::jsonb \\? ?`,
MONITOR_PREMIUM_CAPABILITY,
);
}
if (typeof options.limit === "number") {
query = query.limit(options.limit);
}
const rows = await query;
return rows
}
/* c8 ignore stop */
/** /**
* @param {string} email * @param {string} email
* @deprecated Only used by the `send-email-to-unresolved-breach-subscribers.js`, which it looks like might not be sent anymore? Delete as a part of MNTOR-3077?
*/ */
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */ /* c8 ignore start */
@ -292,10 +340,10 @@ async function updateMonthlyEmailTimestamp (email) {
/* c8 ignore stop */ /* c8 ignore stop */
/** /**
* OBSOLETE: Delete as a part of MNTOR-3077
* Unsubscribe user from monthly unresolved breach emails * Unsubscribe user from monthly unresolved breach emails
* *
* @param {string} token User verification token * @param {string} token User verification token
* @deprecated Delete as a part of MNTOR-3077
*/ */
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */ /* c8 ignore start */
@ -306,6 +354,31 @@ async function updateMonthlyEmailOptout (token) {
} }
/* c8 ignore stop */ /* c8 ignore stop */
/**
* @param {import("knex/types/tables").SubscriberRow} subscriber
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function markMonthlyActivityEmailAsJustSent (subscriber) {
const affectedSubscribers = await knex("subscribers")
.update({
// @ts-ignore knex.fn.now() results in it being set to a date,
// even if it's not typed as a JS date object:
monthly_monitor_report_at: knex.fn.now(),
// @ts-ignore knex.fn.now() results in it being set to a date,
// even if it's not typed as a JS date object:
updated_at: knex.fn.now(),
})
.where("primary_email", subscriber.primary_email)
.andWhere("id", subscriber.id)
.returning("*");
if (affectedSubscribers.length !== 1) {
throw new Error(`Attempted to mark 1 user as having just been sent the monthly activity email, but instead found [${affectedSubscribers.length}] matching its ID and email address.`);
}
}
/* c8 ignore stop */
/** /**
* @param {number} subscriberId * @param {number} subscriberId
*/ */
@ -319,7 +392,9 @@ async function getOnerepProfileId (subscriberId) {
} }
/* c8 ignore stop */ /* c8 ignore stop */
// OBSOLETE: Delete as a part of MNTOR-3077 /**
* @deprecated OBSOLETE: Delete as a part of MNTOR-3077
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */ /* c8 ignore start */
function getSubscribersWithUnresolvedBreachesQuery () { function getSubscribersWithUnresolvedBreachesQuery () {
@ -330,7 +405,9 @@ function getSubscribersWithUnresolvedBreachesQuery () {
} }
/* c8 ignore stop */ /* c8 ignore stop */
// OBSOLETE: Delete as a part of MNTOR-3077 /**
* @deprecated OBSOLETE: Delete as a part of MNTOR-3077
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */ /* c8 ignore start */
async function getSubscribersWithUnresolvedBreaches (limit = 0) { async function getSubscribersWithUnresolvedBreaches (limit = 0) {
@ -343,7 +420,9 @@ async function getSubscribersWithUnresolvedBreaches (limit = 0) {
} }
/* c8 ignore stop */ /* c8 ignore stop */
// OBSOLETE: Delete as a part of MNTOR-3077 /**
* @deprecated OBSOLETE: Delete as a part of MNTOR-3077
*/
// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */ /* c8 ignore start */
async function getSubscribersWithUnresolvedBreachesCount () { async function getSubscribersWithUnresolvedBreachesCount () {
@ -407,8 +486,10 @@ export {
updateFxAProfileData, updateFxAProfileData,
setAllEmailsToPrimary, setAllEmailsToPrimary,
setBreachResolution, setBreachResolution,
getSubscribersWaitingForMonthlyEmail,
updateMonthlyEmailTimestamp, updateMonthlyEmailTimestamp,
updateMonthlyEmailOptout, updateMonthlyEmailOptout,
markMonthlyActivityEmailAsJustSent,
deleteUnverifiedSubscribers, deleteUnverifiedSubscribers,
deleteSubscriber, deleteSubscriber,
deleteResolutionsWithEmail, deleteResolutionsWithEmail,

Просмотреть файл

@ -2,7 +2,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import "server-only"; import "../app/functions/server/notInClientComponent";
import mjml2html from "mjml"; import mjml2html from "mjml";
import { ReactNode } from "react"; import { ReactNode } from "react";
// The `.node` works around the following error: // The `.node` works around the following error:

Просмотреть файл

@ -2,6 +2,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from "react";
import { ExtendedReactLocalization } from "../../app/hooks/l10n"; import { ExtendedReactLocalization } from "../../app/hooks/l10n";
import { import {
CONST_URL_PRIVACY_POLICY, CONST_URL_PRIVACY_POLICY,

Просмотреть файл

@ -2,6 +2,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from "react";
import { ExtendedReactLocalization } from "../../app/hooks/l10n"; import { ExtendedReactLocalization } from "../../app/hooks/l10n";
export type Props = { l10n: ExtendedReactLocalization; utm_campaign: string }; export type Props = { l10n: ExtendedReactLocalization; utm_campaign: string };

Просмотреть файл

@ -2,6 +2,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from "react";
import { DashboardSummary } from "../../../app/functions/server/dashboard"; import { DashboardSummary } from "../../../app/functions/server/dashboard";
import { SanitizedSubscriberRow } from "../../../app/functions/server/sanitize"; import { SanitizedSubscriberRow } from "../../../app/functions/server/sanitize";
import { ExtendedReactLocalization } from "../../../app/hooks/l10n"; import { ExtendedReactLocalization } from "../../../app/hooks/l10n";

6
src/knex-tables.d.ts поставляемый
Просмотреть файл

@ -107,11 +107,7 @@ declare module "knex/types/tables" {
}; };
monitoredEmails: { count: number }; monitoredEmails: { count: number };
}; };
// TODO: Find unknown type monthly_monitor_report_at: null | Date;
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
monthly_monitor_report_at: null | unknown;
// TODO: Find unknown type
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
monthly_monitor_report: boolean; monthly_monitor_report: boolean;
breach_resolution: breach_resolution:
| null | null

Просмотреть файл

@ -0,0 +1,111 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from "react";
import { SubscriberRow } from "knex/types/tables";
import {
getSubscribersWaitingForMonthlyEmail,
markMonthlyActivityEmailAsJustSent,
} from "../../db/tables/subscribers";
import { initEmail, sendEmail } from "../../utils/email";
import { renderEmail } from "../../emails/renderEmail";
import { MonthlyActivityEmail } from "../../emails/templates/monthlyActivity/MonthlyActivityEmail";
import { getEmailL10n } from "../../emails/getEmailL10n.node";
import { sanitizeSubscriberRow } from "../../app/functions/server/sanitize";
import { getDashboardSummary } from "../../app/functions/server/dashboard";
import { getLatestOnerepScanResults } from "../../db/tables/onerep_scans";
import { getSubscriberBreaches } from "../../app/functions/server/getSubscriberBreaches";
import { getLocale } from "../../app/functions/universal/getLocale";
void run();
async function run() {
const batchSize = Number.parseInt(
process.env.MONTHLY_ACTIVITY_EMAIL_BATCH_SIZE ?? "10",
10,
);
if (Number.isNaN(batchSize)) {
throw new Error(
`Could not send monthly activity emails, because the env var MONTHLY_ACTIVITY_EMAIL_BATCH_SIZE has a non-numeric value: [${process.env.MONTHLY_ACTIVITY_EMAIL_BATCH_SIZE}].`,
);
}
const subscribersToEmail = await getSubscribersWaitingForMonthlyEmail({
limit: batchSize,
// We're currently only sending this email to Plus subscribers. Down the
// road, we might also send it to a limited number of free users, but we
// still have to come up with the criteria to determine which:
plusOnly: true,
});
await initEmail();
await Promise.allSettled(
subscribersToEmail.map((subscriber) => {
return sendMonthlyActivityEmail(subscriber);
}),
);
console.log(
`[${new Date(Date.now()).toISOString()}] Sent [${subscribersToEmail.length}] monthly activity emails.`,
);
}
async function sendMonthlyActivityEmail(subscriber: SubscriberRow) {
// Update the last-sent date *first*, so that if something goes wrong, we
// don't keep resending the email a brazillion times.
await markMonthlyActivityEmailAsJustSent(subscriber);
const sanitizedSubscriber = sanitizeSubscriberRow(subscriber);
const l10n = getEmailL10n(sanitizedSubscriber);
const locale = getLocale(l10n);
/**
* Without an active user session, we don't know the user's country. This is
* our best guess based on their locale. At the time of writing, it's only
* used to determine whether to count SSN breaches (which we don't have
* recommendations for outside the US).
*/
const countryCodeGuess = locale.split("-")[1] ?? "us";
const latestScan = await getLatestOnerepScanResults(
subscriber.onerep_profile_id,
);
const subscriberBreaches = await getSubscriberBreaches({
fxaUid: subscriber.fxa_uid,
countryCode: countryCodeGuess,
});
const data = getDashboardSummary(latestScan.results, subscriberBreaches);
const dateFormatter = new Intl.DateTimeFormat(locale, {
month: "long",
});
const currentMonth = dateFormatter.format(new Date(Date.now()));
let subject = l10n.getString("email-monthly-free-subject", {
month: currentMonth,
});
if (subscriber.fxa_profile_json?.subscriptions?.includes("monitor")) {
if (
data.dataBreachFixedDataPointsNum +
data.dataBrokerManuallyResolvedDataPointsNum >
0
) {
subject = l10n.getString("email-monthly-plus-manual-subject", {
month: currentMonth,
});
}
subject = l10n.getString("email-monthly-plus-auto-subject", {
month: currentMonth,
});
}
await sendEmail(
sanitizedSubscriber.primary_email,
subject,
renderEmail(
<MonthlyActivityEmail
subscriber={sanitizedSubscriber}
data={data}
l10n={l10n}
/>,
),
);
}

Просмотреть файл

@ -9,12 +9,14 @@ import {
TEST_EMAIL_ADDRESSES TEST_EMAIL_ADDRESSES
} from '../db/seeds/testSubscribers.js' } from '../db/seeds/testSubscribers.js'
jest.mock("nodemailer", () => { jest.mock("nodemailer", () => {
return { return {
createTransport: jest.fn(), createTransport: jest.fn(),
}; };
}); });
// This module is unused in the code under test, but itself imports code that
// the transpiler struggles with:
jest.mock("../db/tables/featureFlags", () => ({}));
test('EmailUtils.sendEmail before .init() fails', async () => { test('EmailUtils.sendEmail before .init() fails', async () => {
expect.assertions(1); expect.assertions(1);

10
tsconfig.cronjobs.json Normal file
Просмотреть файл

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"include": ["src/scripts/cronjobs/**/*.ts", "src/scripts/cronjobs/**/*.tsx"],
"outDir": "dist/scripts/cronjobs/",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2020",
}
}