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:
Родитель
1c863edb2e
Коммит
c158dc040c
|
@ -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",
|
||||
});
|
|
@ -74,6 +74,7 @@
|
|||
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||
"@typescript-eslint/parser": "^7.5.0",
|
||||
"adm-zip": "^0.5.12",
|
||||
"esbuild": "^0.20.2",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.1",
|
||||
"eslint-plugin-check-file": "^2.7.1",
|
||||
|
@ -3161,6 +3162,32 @@
|
|||
"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": {
|
||||
"version": "2.6.1",
|
||||
"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": {
|
||||
"version": "0.18.20",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz",
|
||||
"integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
@ -8809,6 +8836,88 @@
|
|||
"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": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz",
|
||||
|
@ -8817,6 +8926,17 @@
|
|||
"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": {
|
||||
"version": "9.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz",
|
||||
|
@ -8942,6 +9062,38 @@
|
|||
"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": {
|
||||
"version": "0.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.5.tgz",
|
||||
|
@ -10510,6 +10662,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": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
|
@ -12487,6 +12647,14 @@
|
|||
"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": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
|
@ -13045,6 +13213,17 @@
|
|||
"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": {
|
||||
"version": "29.6.3",
|
||||
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
|
||||
|
@ -13634,9 +13813,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.18.20",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
|
||||
"integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
|
||||
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"bin": {
|
||||
|
@ -13646,28 +13825,29 @@
|
|||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/android-arm": "0.18.20",
|
||||
"@esbuild/android-arm64": "0.18.20",
|
||||
"@esbuild/android-x64": "0.18.20",
|
||||
"@esbuild/darwin-arm64": "0.18.20",
|
||||
"@esbuild/darwin-x64": "0.18.20",
|
||||
"@esbuild/freebsd-arm64": "0.18.20",
|
||||
"@esbuild/freebsd-x64": "0.18.20",
|
||||
"@esbuild/linux-arm": "0.18.20",
|
||||
"@esbuild/linux-arm64": "0.18.20",
|
||||
"@esbuild/linux-ia32": "0.18.20",
|
||||
"@esbuild/linux-loong64": "0.18.20",
|
||||
"@esbuild/linux-mips64el": "0.18.20",
|
||||
"@esbuild/linux-ppc64": "0.18.20",
|
||||
"@esbuild/linux-riscv64": "0.18.20",
|
||||
"@esbuild/linux-s390x": "0.18.20",
|
||||
"@esbuild/linux-x64": "0.18.20",
|
||||
"@esbuild/netbsd-x64": "0.18.20",
|
||||
"@esbuild/openbsd-x64": "0.18.20",
|
||||
"@esbuild/sunos-x64": "0.18.20",
|
||||
"@esbuild/win32-arm64": "0.18.20",
|
||||
"@esbuild/win32-ia32": "0.18.20",
|
||||
"@esbuild/win32-x64": "0.18.20"
|
||||
"@esbuild/aix-ppc64": "0.20.2",
|
||||
"@esbuild/android-arm": "0.20.2",
|
||||
"@esbuild/android-arm64": "0.20.2",
|
||||
"@esbuild/android-x64": "0.20.2",
|
||||
"@esbuild/darwin-arm64": "0.20.2",
|
||||
"@esbuild/darwin-x64": "0.20.2",
|
||||
"@esbuild/freebsd-arm64": "0.20.2",
|
||||
"@esbuild/freebsd-x64": "0.20.2",
|
||||
"@esbuild/linux-arm": "0.20.2",
|
||||
"@esbuild/linux-arm64": "0.20.2",
|
||||
"@esbuild/linux-ia32": "0.20.2",
|
||||
"@esbuild/linux-loong64": "0.20.2",
|
||||
"@esbuild/linux-mips64el": "0.20.2",
|
||||
"@esbuild/linux-ppc64": "0.20.2",
|
||||
"@esbuild/linux-riscv64": "0.20.2",
|
||||
"@esbuild/linux-s390x": "0.20.2",
|
||||
"@esbuild/linux-x64": "0.20.2",
|
||||
"@esbuild/netbsd-x64": "0.20.2",
|
||||
"@esbuild/openbsd-x64": "0.20.2",
|
||||
"@esbuild/sunos-x64": "0.20.2",
|
||||
"@esbuild/win32-arm64": "0.20.2",
|
||||
"@esbuild/win32-ia32": "0.20.2",
|
||||
"@esbuild/win32-x64": "0.20.2"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-plugin-alias": {
|
||||
|
@ -15661,19 +15841,6 @@
|
|||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
|
@ -26475,6 +26642,76 @@
|
|||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz",
|
||||
|
@ -27024,6 +27261,14 @@
|
|||
"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": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz",
|
||||
|
@ -27751,6 +27996,17 @@
|
|||
"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": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
"scripts": {
|
||||
"dev": "next dev --port=6060",
|
||||
"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",
|
||||
"lint": "stylelint '**/*.scss' && prettier --check './src' && next lint --max-warnings=0",
|
||||
"fix": "prettier --write './src' && next lint --fix && stylelint --fix '**/*.scss'",
|
||||
|
@ -25,6 +25,7 @@
|
|||
"build-storybook": "npm run build-glean && storybook build",
|
||||
"create-location-data": "node src/scripts/build/uploadAutoCompleteLocations.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-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",
|
||||
|
@ -106,6 +107,7 @@
|
|||
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||
"@typescript-eslint/parser": "^7.5.0",
|
||||
"adm-zip": "^0.5.12",
|
||||
"esbuild": "^0.20.2",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.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
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
import "./notInClientComponent";
|
||||
import { EmailAddressRow, SubscriberRow } from "knex/types/tables";
|
||||
|
||||
type SanitizationMarker = {
|
||||
|
|
|
@ -36,6 +36,7 @@ export type FeatureFlagName =
|
|||
| "FxaUidTelemetry"
|
||||
| "RebrandAnnouncement"
|
||||
| "MonitorAccountDeletion"
|
||||
| "MonthlyActivityEmail"
|
||||
| "RedesignedEmails"
|
||||
| "CsatSurvey"
|
||||
| "CancellationFlow"
|
||||
|
@ -64,6 +65,27 @@ export async function getEnabledFeatureFlags(
|
|||
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) {
|
||||
logger.info("getFeatureFlagByName", name);
|
||||
const res = await knex("feature_flags").where("name", name);
|
||||
|
|
|
@ -5,9 +5,11 @@
|
|||
import createDbConnection from "../connect.js";
|
||||
import { destroyOAuthToken } from '../../utils/fxa.js'
|
||||
import AppConstants from '../../appConstants.js'
|
||||
import { getFeatureFlagData } from "./featureFlags";
|
||||
|
||||
const knex = createDbConnection();
|
||||
const { DELETE_UNVERIFIED_SUBSCRIBERS_TIMER } = AppConstants
|
||||
const MONITOR_PREMIUM_CAPABILITY = "monitor";
|
||||
|
||||
/**
|
||||
* @param {string[]} hashes
|
||||
|
@ -271,8 +273,54 @@ async function deleteResolutionsWithEmail (id, email) {
|
|||
}
|
||||
/* 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
|
||||
* @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
|
||||
/* c8 ignore start */
|
||||
|
@ -292,10 +340,10 @@ async function updateMonthlyEmailTimestamp (email) {
|
|||
/* c8 ignore stop */
|
||||
|
||||
/**
|
||||
* OBSOLETE: Delete as a part of MNTOR-3077
|
||||
* Unsubscribe user from monthly unresolved breach emails
|
||||
*
|
||||
* @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
|
||||
/* c8 ignore start */
|
||||
|
@ -306,6 +354,31 @@ async function updateMonthlyEmailOptout (token) {
|
|||
}
|
||||
/* 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
|
||||
*/
|
||||
|
@ -319,7 +392,9 @@ async function getOnerepProfileId (subscriberId) {
|
|||
}
|
||||
/* 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
|
||||
/* c8 ignore start */
|
||||
function getSubscribersWithUnresolvedBreachesQuery () {
|
||||
|
@ -330,7 +405,9 @@ function getSubscribersWithUnresolvedBreachesQuery () {
|
|||
}
|
||||
/* 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
|
||||
/* c8 ignore start */
|
||||
async function getSubscribersWithUnresolvedBreaches (limit = 0) {
|
||||
|
@ -343,7 +420,9 @@ async function getSubscribersWithUnresolvedBreaches (limit = 0) {
|
|||
}
|
||||
/* 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
|
||||
/* c8 ignore start */
|
||||
async function getSubscribersWithUnresolvedBreachesCount () {
|
||||
|
@ -407,8 +486,10 @@ export {
|
|||
updateFxAProfileData,
|
||||
setAllEmailsToPrimary,
|
||||
setBreachResolution,
|
||||
getSubscribersWaitingForMonthlyEmail,
|
||||
updateMonthlyEmailTimestamp,
|
||||
updateMonthlyEmailOptout,
|
||||
markMonthlyActivityEmailAsJustSent,
|
||||
deleteUnverifiedSubscribers,
|
||||
deleteSubscriber,
|
||||
deleteResolutionsWithEmail,
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
* 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 "server-only";
|
||||
import "../app/functions/server/notInClientComponent";
|
||||
import mjml2html from "mjml";
|
||||
import { ReactNode } from "react";
|
||||
// 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
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import React from "react";
|
||||
import { ExtendedReactLocalization } from "../../app/hooks/l10n";
|
||||
import {
|
||||
CONST_URL_PRIVACY_POLICY,
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
* 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 { ExtendedReactLocalization } from "../../app/hooks/l10n";
|
||||
|
||||
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
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import React from "react";
|
||||
import { DashboardSummary } from "../../../app/functions/server/dashboard";
|
||||
import { SanitizedSubscriberRow } from "../../../app/functions/server/sanitize";
|
||||
import { ExtendedReactLocalization } from "../../../app/hooks/l10n";
|
||||
|
|
|
@ -107,11 +107,7 @@ declare module "knex/types/tables" {
|
|||
};
|
||||
monitoredEmails: { count: number };
|
||||
};
|
||||
// TODO: Find unknown type
|
||||
// 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_at: null | Date;
|
||||
monthly_monitor_report: boolean;
|
||||
breach_resolution:
|
||||
| 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
|
||||
} from '../db/seeds/testSubscribers.js'
|
||||
|
||||
|
||||
jest.mock("nodemailer", () => {
|
||||
return {
|
||||
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 () => {
|
||||
expect.assertions(1);
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
}
|
Загрузка…
Ссылка в новой задаче