From 3152ca770b133a004e5cc25e19d5ebf115ba13c2 Mon Sep 17 00:00:00 2001 From: Sung Won Cho Date: Tue, 24 Dec 2019 13:07:27 +0700 Subject: [PATCH] Alert when we stop learning (#375) * Send password reset alert * Send inactive reminder * Send subscription confirmation email --- CHANGELOG.md | 6 + browser/package-lock.json | 220 +++++++++++---- jslib/package-lock.json | 41 ++- jslib/src/operations/types.ts | 3 +- jslib/src/services/users.ts | 43 ++- pkg/cli/README.md | 4 +- pkg/server/app/app.go | 21 +- pkg/server/app/app_test.go | 24 +- pkg/server/app/email.go | 78 ++++-- pkg/server/app/email_test.go | 93 +++++-- pkg/server/app/testutils.go | 18 +- pkg/server/app/users.go | 41 +-- pkg/server/app/users_test.go | 10 +- pkg/server/database/models.go | 7 +- pkg/server/handlers/auth.go | 24 +- pkg/server/handlers/helpers.go | 35 --- pkg/server/handlers/routes_test.go | 24 +- pkg/server/handlers/subscription.go | 10 + pkg/server/handlers/user.go | 61 +++-- pkg/server/handlers/user_test.go | 42 ++- pkg/server/handlers/v3_auth.go | 2 +- pkg/server/handlers/v3_auth_test.go | 10 +- pkg/server/helpers/helpers.go | 18 -- pkg/server/job/job.go | 59 +++- pkg/server/job/job_test.go | 5 +- pkg/server/job/remind/inactive.go | 204 ++++++++++++++ pkg/server/job/remind/inactive_test.go | 194 +++++++++++++ pkg/server/job/remind/main_test.go | 35 +++ pkg/server/job/repetition/repetition.go | 71 +++-- pkg/server/job/repetition/repetition_test.go | 100 +++++-- pkg/server/mailer/backend.go | 1 - pkg/server/mailer/mailer.go | 31 ++- pkg/server/mailer/templates/main.go | 31 +++ pkg/server/mailer/templates/src/inactive.txt | 9 + .../templates/src/reset_password_alert.txt | 9 + .../src/subscription_confirmation.txt | 10 + pkg/server/mailer/types.go | 19 ++ pkg/server/main.go | 20 +- pkg/server/presenters/email_preference.go | 14 +- pkg/server/testutils/main.go | 14 +- .../server/token/main_test.go | 17 ++ pkg/server/token/token.go | 59 ++++ pkg/server/token/token_test.go | 65 +++++ scripts/server/makeDemoDigests/main.go | 254 ------------------ scripts/server/test.sh | 2 +- web/package-lock.json | 89 ++++-- web/src/components/App/index.tsx | 6 +- .../components/Common/EmailPreferenceForm.tsx | 4 +- .../EmailPreference/EmailPreference.scss | 60 +++++ web/src/components/EmailPreference/index.tsx | 112 ++++++++ .../Settings/Notifications/Form.scss | 45 ++++ .../Settings/Notifications/Form.tsx | 155 +++++++++++ .../Notifications/Notifications.scss} | 7 + .../Settings/Notifications/index.tsx | 110 ++++++++ web/src/components/Settings/Sidebar.tsx | 9 + web/src/components/Settings/index.tsx | 4 + web/src/libs/paths.ts | 8 +- web/src/routes.tsx | 11 +- web/src/store/auth/actions.ts | 3 +- web/src/store/auth/reducers.ts | 5 +- web/src/store/auth/type.ts | 5 +- 61 files changed, 2015 insertions(+), 676 deletions(-) create mode 100644 pkg/server/job/remind/inactive.go create mode 100644 pkg/server/job/remind/inactive_test.go create mode 100644 pkg/server/job/remind/main_test.go create mode 100644 pkg/server/mailer/templates/src/inactive.txt create mode 100644 pkg/server/mailer/templates/src/reset_password_alert.txt create mode 100644 pkg/server/mailer/templates/src/subscription_confirmation.txt rename web/src/components/Repetition/New/Repetition.scss => pkg/server/token/main_test.go (78%) create mode 100644 pkg/server/token/token.go create mode 100644 pkg/server/token/token_test.go delete mode 100644 scripts/server/makeDemoDigests/main.go create mode 100644 web/src/components/EmailPreference/EmailPreference.scss create mode 100644 web/src/components/EmailPreference/index.tsx create mode 100644 web/src/components/Settings/Notifications/Form.scss create mode 100644 web/src/components/Settings/Notifications/Form.tsx rename web/src/components/{Repetition/Edit/Repetition.scss => Settings/Notifications/Notifications.scss} (85%) create mode 100644 web/src/components/Settings/Notifications/index.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index e2c39177..78044ad1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ The following log documents the history of the server project. None +### 0.3.4 - 2019-12-24 + +#### Added + +- Send email when user has stopped learning (#375) + ### 0.3.3 - 2019-12-17 #### Added diff --git a/browser/package-lock.json b/browser/package-lock.json index 664eaad2..035b6222 100644 --- a/browser/package-lock.json +++ b/browser/package-lock.json @@ -2263,6 +2263,7 @@ "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-2.0.1.tgz", "integrity": "sha512-88em58dDVB/KzPEx1X0N3LwFfYZPyDc4B6eF38M1rk9VTZMbxXXgjugz8mmwpS9Ox4BDZ+t6t3QP5+/gazweIA==", "dev": true, + "optional": true, "requires": { "p-finally": "^1.0.0" } @@ -2310,6 +2311,7 @@ "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz", "integrity": "sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA==", "dev": true, + "optional": true, "requires": { "readable-stream": "^2.3.5", "safe-buffer": "^5.1.1" @@ -2343,7 +2345,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=", - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", @@ -2477,6 +2480,7 @@ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.4.3.tgz", "integrity": "sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==", "dev": true, + "optional": true, "requires": { "base64-js": "^1.0.2", "ieee754": "^1.1.4" @@ -2487,6 +2491,7 @@ "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", "dev": true, + "optional": true, "requires": { "buffer-alloc-unsafe": "^1.1.0", "buffer-fill": "^1.0.0" @@ -2496,7 +2501,8 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", - "dev": true + "dev": true, + "optional": true }, "buffer-crc32": { "version": "0.2.13", @@ -2514,7 +2520,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", "integrity": "sha1-+PeLdniYiO858gXNY39o5wISKyw=", - "dev": true + "dev": true, + "optional": true }, "buffer-from": { "version": "1.1.1", @@ -2706,6 +2713,7 @@ "resolved": "https://registry.npmjs.org/caw/-/caw-2.0.1.tgz", "integrity": "sha512-Cg8/ZSBEa8ZVY9HspcGUYaK63d/bN7rqS3CYCzEGUxuYv6UlmcjzDUz2fCFFHyTvUW5Pk0I+3hkA3iXlIj6guA==", "dev": true, + "optional": true, "requires": { "get-proxy": "^2.0.0", "isurl": "^1.0.0-alpha5", @@ -2935,6 +2943,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.8.1.tgz", "integrity": "sha1-Br42f+v9oMMwqh4qBy09yXYkJdQ=", "dev": true, + "optional": true, "requires": { "graceful-readlink": ">= 1.0.0" } @@ -3138,6 +3147,7 @@ "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.12.tgz", "integrity": "sha512-a1eOIcu8+7lUInge4Rpf/n4Krkf3Dd9lqhljRzII1/Zno/kRtUWnznPO3jOKBmTEktkt3fkxisUcivoj0ebzoA==", "dev": true, + "optional": true, "requires": { "ini": "^1.3.4", "proto-list": "~1.2.1" @@ -3167,6 +3177,7 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", "dev": true, + "optional": true, "requires": { "safe-buffer": "5.1.2" } @@ -3303,6 +3314,7 @@ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=", "dev": true, + "optional": true, "requires": { "lru-cache": "^4.0.1", "shebang-command": "^1.2.0", @@ -3353,6 +3365,7 @@ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz", "integrity": "sha512-DMxWJg0rnz7UgxKT0Q1HU/L9BeJI0M6ksor0OgqOnF+aRCDWg/N2641HmVyU9KVIu0OVVWOb2IpC9A+BJRnejg==", "dev": true, + "optional": true, "requires": { "mdn-data": "2.0.4", "source-map": "^0.6.1" @@ -3362,7 +3375,8 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true + "dev": true, + "optional": true } } }, @@ -3446,6 +3460,7 @@ "resolved": "https://registry.npmjs.org/decompress/-/decompress-4.2.0.tgz", "integrity": "sha1-eu3YVCflqS2s/lVnSnxQXpbQH50=", "dev": true, + "optional": true, "requires": { "decompress-tar": "^4.0.0", "decompress-tarbz2": "^4.0.0", @@ -3462,6 +3477,7 @@ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", "dev": true, + "optional": true, "requires": { "pify": "^3.0.0" }, @@ -3470,7 +3486,8 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", - "dev": true + "dev": true, + "optional": true } } }, @@ -3478,7 +3495,8 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", - "dev": true + "dev": true, + "optional": true } } }, @@ -3487,6 +3505,7 @@ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", "integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=", "dev": true, + "optional": true, "requires": { "mimic-response": "^1.0.0" } @@ -3496,6 +3515,7 @@ "resolved": "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz", "integrity": "sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==", "dev": true, + "optional": true, "requires": { "file-type": "^5.2.0", "is-stream": "^1.1.0", @@ -3506,7 +3526,8 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", "integrity": "sha1-LdvqfHP/42No365J3DOMBYwritY=", - "dev": true + "dev": true, + "optional": true } } }, @@ -3515,6 +3536,7 @@ "resolved": "https://registry.npmjs.org/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz", "integrity": "sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==", "dev": true, + "optional": true, "requires": { "decompress-tar": "^4.1.0", "file-type": "^6.1.0", @@ -3527,7 +3549,8 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/file-type/-/file-type-6.2.0.tgz", "integrity": "sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==", - "dev": true + "dev": true, + "optional": true } } }, @@ -3536,6 +3559,7 @@ "resolved": "https://registry.npmjs.org/decompress-targz/-/decompress-targz-4.1.1.tgz", "integrity": "sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==", "dev": true, + "optional": true, "requires": { "decompress-tar": "^4.1.1", "file-type": "^5.2.0", @@ -3546,7 +3570,8 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", "integrity": "sha1-LdvqfHP/42No365J3DOMBYwritY=", - "dev": true + "dev": true, + "optional": true } } }, @@ -3555,6 +3580,7 @@ "resolved": "https://registry.npmjs.org/decompress-unzip/-/decompress-unzip-4.0.1.tgz", "integrity": "sha1-3qrM39FK6vhVePczroIQ+bSEj2k=", "dev": true, + "optional": true, "requires": { "file-type": "^3.8.0", "get-stream": "^2.2.0", @@ -3566,13 +3592,15 @@ "version": "3.9.0", "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", "integrity": "sha1-JXoHg4TR24CHvESdEH1SpSZyuek=", - "dev": true + "dev": true, + "optional": true }, "get-stream": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", "integrity": "sha1-Xzj5PzRgCWZu4BUKBUFn+Rvdld4=", "dev": true, + "optional": true, "requires": { "object-assign": "^4.0.1", "pinkie-promise": "^2.0.0" @@ -3582,7 +3610,8 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", - "dev": true + "dev": true, + "optional": true } } }, @@ -3828,7 +3857,8 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=", - "dev": true + "dev": true, + "optional": true }, "duplexify": { "version": "3.6.1", @@ -3960,6 +3990,7 @@ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.0-next.1.tgz", "integrity": "sha512-7MmGr03N7Rnuid6+wyhD9sHNE2n4tFSwExnU2lQl3lIo2ShXWGePY80zYaoMOmILWv57H0amMjZGHNzzGG70Rw==", "dev": true, + "optional": true, "requires": { "es-to-primitive": "^1.2.1", "function-bind": "^1.1.1", @@ -3978,13 +4009,15 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", - "dev": true + "dev": true, + "optional": true }, "object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true + "dev": true, + "optional": true } } }, @@ -3993,6 +4026,7 @@ "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", "dev": true, + "optional": true, "requires": { "is-callable": "^1.1.4", "is-date-object": "^1.0.1", @@ -4118,6 +4152,7 @@ "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz", "integrity": "sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=", "dev": true, + "optional": true, "requires": { "cross-spawn": "^5.0.1", "get-stream": "^3.0.0", @@ -4196,6 +4231,7 @@ "resolved": "https://registry.npmjs.org/ext-list/-/ext-list-2.2.2.tgz", "integrity": "sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==", "dev": true, + "optional": true, "requires": { "mime-db": "^1.28.0" } @@ -4205,6 +4241,7 @@ "resolved": "https://registry.npmjs.org/ext-name/-/ext-name-5.0.0.tgz", "integrity": "sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==", "dev": true, + "optional": true, "requires": { "ext-list": "^2.0.0", "sort-keys-length": "^1.0.0" @@ -4349,6 +4386,7 @@ "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=", "dev": true, + "optional": true, "requires": { "pend": "~1.2.0" } @@ -4380,13 +4418,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz", "integrity": "sha1-q/c9+rc10EVECr/qLZHzieu/oik=", - "dev": true + "dev": true, + "optional": true }, "filenamify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-2.1.0.tgz", "integrity": "sha512-ICw7NTT6RsDp2rnYKVd8Fu4cr6ITzGy3+u4vUujPkabyaz+03F24NWEX7fs5fp+kBonlaqPH8fAO2NM+SXt/JA==", "dev": true, + "optional": true, "requires": { "filename-reserved-regex": "^2.0.0", "strip-outer": "^1.0.0", @@ -4572,7 +4612,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true + "dev": true, + "optional": true }, "fs-mkdirp-stream": { "version": "1.0.0", @@ -4622,7 +4663,8 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -4643,12 +4685,14 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4663,17 +4707,20 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -4790,7 +4837,8 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -4802,6 +4850,7 @@ "version": "1.0.0", "bundled": true, "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -4816,6 +4865,7 @@ "version": "3.0.4", "bundled": true, "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -4823,12 +4873,14 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "minipass": { "version": "2.3.5", "bundled": true, "dev": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -4847,6 +4899,7 @@ "version": "0.5.1", "bundled": true, "dev": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -4927,7 +4980,8 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -4939,6 +4993,7 @@ "version": "1.4.0", "bundled": true, "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -5024,7 +5079,8 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -5060,6 +5116,7 @@ "version": "1.0.2", "bundled": true, "dev": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -5079,6 +5136,7 @@ "version": "3.0.1", "bundled": true, "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -5122,12 +5180,14 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true } } }, @@ -5148,6 +5208,7 @@ "resolved": "https://registry.npmjs.org/get-proxy/-/get-proxy-2.1.0.tgz", "integrity": "sha512-zmZIaQTWnNQb4R4fJUEp/FC51eZsc6EkErspy3xtIYStaq8EB/hDIWipxsal+E8rz0qD7f2sL/NA9Xee4RInJw==", "dev": true, + "optional": true, "requires": { "npm-conf": "^1.1.0" } @@ -5156,13 +5217,15 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", "integrity": "sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=", - "dev": true + "dev": true, + "optional": true }, "get-stream": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", - "dev": true + "dev": true, + "optional": true }, "get-value": { "version": "2.0.6", @@ -5486,7 +5549,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=", - "dev": true + "dev": true, + "optional": true }, "gulp": { "version": "4.0.2", @@ -5740,6 +5804,7 @@ "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", "dev": true, + "optional": true, "requires": { "function-bind": "^1.1.1" } @@ -5764,7 +5829,8 @@ "version": "1.4.2", "resolved": "https://registry.npmjs.org/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz", "integrity": "sha512-3ToOva++HaW+eCpgqZrCfN51IPB+7bJNVT6CUATzueB5Heb8o6Nam0V3HG5dlDvZU1Gn5QLcbahiKw/XVk5JJw==", - "dev": true + "dev": true, + "optional": true }, "has-symbols": { "version": "1.0.0", @@ -5777,6 +5843,7 @@ "resolved": "https://registry.npmjs.org/has-to-string-tag-x/-/has-to-string-tag-x-1.4.1.tgz", "integrity": "sha512-vdbKfmw+3LoOYVr+mtxHaX5a96+0f3DljYd8JOqvOLsf5mw2Otda2qCDT9qRqLAhrjyQ0h7ual5nOiASpsGNFw==", "dev": true, + "optional": true, "requires": { "has-symbol-support-x": "^1.4.1" } @@ -6010,6 +6077,7 @@ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", "integrity": "sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=", "dev": true, + "optional": true, "requires": { "repeating": "^2.0.0" } @@ -6142,7 +6210,8 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz", "integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==", - "dev": true + "dev": true, + "optional": true }, "is-data-descriptor": { "version": "0.1.4", @@ -6168,7 +6237,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz", "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=", - "dev": true + "dev": true, + "optional": true }, "is-descriptor": { "version": "0.1.6", @@ -6211,6 +6281,7 @@ "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz", "integrity": "sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=", "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -6263,7 +6334,8 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz", "integrity": "sha1-q5124dtM7VHjXeDHLr7PCfc0zeg=", - "dev": true + "dev": true, + "optional": true }, "is-negated-glob": { "version": "1.0.0", @@ -6295,7 +6367,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.1.tgz", "integrity": "sha1-iVJojF7C/9awPsyF52ngKQMINHA=", - "dev": true + "dev": true, + "optional": true }, "is-path-cwd": { "version": "2.2.0", @@ -6313,7 +6386,8 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=", - "dev": true + "dev": true, + "optional": true }, "is-plain-object": { "version": "2.0.4", @@ -6336,6 +6410,7 @@ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz", "integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==", "dev": true, + "optional": true, "requires": { "has": "^1.0.3" } @@ -6353,7 +6428,8 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz", "integrity": "sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==", - "dev": true + "dev": true, + "optional": true }, "is-stream": { "version": "1.1.0", @@ -6376,6 +6452,7 @@ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", "dev": true, + "optional": true, "requires": { "has-symbols": "^1.0.1" }, @@ -6384,7 +6461,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", - "dev": true + "dev": true, + "optional": true } } }, @@ -6455,6 +6533,7 @@ "resolved": "https://registry.npmjs.org/isurl/-/isurl-1.0.0.tgz", "integrity": "sha512-1P/yWsxPlDtn7QeRD+ULKQPaIaN6yF368GZ2vDfv0AL0NwpStafjWCDDdn0k8wgFMWpVAqG7oJhxHnlud42i9w==", "dev": true, + "optional": true, "requires": { "has-to-string-tag-x": "^1.2.0", "is-object": "^1.0.1" @@ -6758,7 +6837,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==", - "dev": true + "dev": true, + "optional": true }, "lpad-align": { "version": "1.1.2", @@ -6778,6 +6858,7 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", "dev": true, + "optional": true, "requires": { "pseudomap": "^1.0.2", "yallist": "^2.1.2" @@ -6834,7 +6915,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=", - "dev": true + "dev": true, + "optional": true }, "map-visit": { "version": "1.0.0", @@ -6886,7 +6968,8 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", "integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==", - "dev": true + "dev": true, + "optional": true }, "mem": { "version": "4.3.0", @@ -6988,7 +7071,8 @@ "version": "1.42.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.42.0.tgz", "integrity": "sha512-UbfJCR4UAVRNgMpfImz05smAXK7+c+ZntjaA26ANtkXLlOe947Aag5zdIcKQULAiF9Cq4WxBi9jUs5zkA84bYQ==", - "dev": true + "dev": true, + "optional": true }, "mimic-fn": { "version": "2.1.0", @@ -7000,7 +7084,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", - "dev": true + "dev": true, + "optional": true }, "minimalistic-assert": { "version": "1.0.1", @@ -7288,6 +7373,7 @@ "resolved": "https://registry.npmjs.org/npm-conf/-/npm-conf-1.1.3.tgz", "integrity": "sha512-Yic4bZHJOt9RCFbRP3GgpqhScOY4HH3V2P8yBj6CeYq118Qr+BLXqT2JvpJ00mryLESpgOxf5XlFv4ZjXxLScw==", "dev": true, + "optional": true, "requires": { "config-chain": "^1.1.11", "pify": "^3.0.0" @@ -7358,7 +7444,8 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==", - "dev": true + "dev": true, + "optional": true }, "object-keys": { "version": "1.0.12", @@ -7598,6 +7685,7 @@ "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-1.2.1.tgz", "integrity": "sha1-XrOzU7f86Z8QGhA4iAuwVOu+o4Y=", "dev": true, + "optional": true, "requires": { "p-finally": "^1.0.0" } @@ -7747,7 +7835,8 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=", - "dev": true + "dev": true, + "optional": true }, "picomatch": { "version": "2.0.7", @@ -7893,7 +7982,8 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", "integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=", - "dev": true + "dev": true, + "optional": true }, "prr": { "version": "1.0.1", @@ -7905,7 +7995,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", - "dev": true + "dev": true, + "optional": true }, "public-encrypt": { "version": "4.0.3", @@ -8356,6 +8447,7 @@ "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=", "dev": true, + "optional": true, "requires": { "is-finite": "^1.0.0" } @@ -8565,6 +8657,7 @@ "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.5.tgz", "integrity": "sha1-z+kXyz0nS8/6x5J1ivUxc+sfq9w=", "dev": true, + "optional": true, "requires": { "commander": "~2.8.1" } @@ -8794,6 +8887,7 @@ "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", "integrity": "sha1-RBttTTRnmPG05J6JIK37oOVD+a0=", "dev": true, + "optional": true, "requires": { "is-plain-obj": "^1.0.0" } @@ -8803,6 +8897,7 @@ "resolved": "https://registry.npmjs.org/sort-keys-length/-/sort-keys-length-1.0.1.tgz", "integrity": "sha1-nLb09OnkgVWmqgZx7dM2/xR5oYg=", "dev": true, + "optional": true, "requires": { "sort-keys": "^1.0.0" } @@ -9072,6 +9167,7 @@ "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.0.tgz", "integrity": "sha512-FJ6b7EgdKxxbDxc79cOlok6Afd++TTs5szo+zJTUyow3ycrRfJVE2pq3vcN53XexvKZu/DJMDfeI/qMiZTrjTw==", "dev": true, + "optional": true, "requires": { "define-properties": "^1.1.3", "function-bind": "^1.1.1" @@ -9082,6 +9178,7 @@ "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.0.tgz", "integrity": "sha512-fXZTSV55dNBwv16uw+hh5jkghxSnc5oHq+5K/gXgizHwAvMetdAJlHqqoFC1FSDVPYWLkAKl2cxpUT41sV7nSg==", "dev": true, + "optional": true, "requires": { "define-properties": "^1.1.3", "function-bind": "^1.1.1" @@ -9118,6 +9215,7 @@ "resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-2.1.0.tgz", "integrity": "sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==", "dev": true, + "optional": true, "requires": { "is-natural-number": "^4.0.1" } @@ -9143,6 +9241,7 @@ "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", "integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==", "dev": true, + "optional": true, "requires": { "escape-string-regexp": "^1.0.2" } @@ -9204,6 +9303,7 @@ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz", "integrity": "sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==", "dev": true, + "optional": true, "requires": { "bl": "^1.0.0", "buffer-alloc": "^1.2.0", @@ -9218,13 +9318,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", "integrity": "sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0=", - "dev": true + "dev": true, + "optional": true }, "tempfile": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/tempfile/-/tempfile-2.0.0.tgz", "integrity": "sha1-awRGhWqbERTRhW/8vlCczLCXcmU=", "dev": true, + "optional": true, "requires": { "temp-dir": "^1.0.0", "uuid": "^3.0.1" @@ -9336,7 +9438,8 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", - "dev": true + "dev": true, + "optional": true }, "through2": { "version": "2.0.5", @@ -9377,7 +9480,8 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz", "integrity": "sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8=", - "dev": true + "dev": true, + "optional": true }, "timers-browserify": { "version": "2.0.11", @@ -9439,7 +9543,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz", "integrity": "sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==", - "dev": true + "dev": true, + "optional": true }, "to-fast-properties": { "version": "2.0.0", @@ -9515,6 +9620,7 @@ "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", "integrity": "sha1-42RqLqTokTEr9+rObPsFOAvAHCE=", "dev": true, + "optional": true, "requires": { "escape-string-regexp": "^1.0.2" } @@ -9600,6 +9706,7 @@ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", "dev": true, + "optional": true, "requires": { "safe-buffer": "^5.0.1" } @@ -9627,6 +9734,7 @@ "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.3.3.tgz", "integrity": "sha512-fUlAF7U9Ah1Q6EieQ4x4zLNejrRvDWUYmxXUpN3uziFYCHapjWFaCAnreY9bGgxzaMCFAPPpYNng57CypwJVhg==", "dev": true, + "optional": true, "requires": { "buffer": "^5.2.1", "through": "^2.3.8" @@ -9829,7 +9937,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/url-to-options/-/url-to-options-1.0.1.tgz", "integrity": "sha1-FQWgOiiaSMvXpDTvuu7FBV9WM6k=", - "dev": true + "dev": true, + "optional": true }, "use": { "version": "3.1.1", @@ -9867,7 +9976,8 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==", - "dev": true + "dev": true, + "optional": true }, "v8-compile-cache": { "version": "2.0.3", @@ -10328,7 +10438,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", - "dev": true + "dev": true, + "optional": true }, "yargs": { "version": "7.1.0", @@ -10381,6 +10492,7 @@ "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=", "dev": true, + "optional": true, "requires": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" diff --git a/jslib/package-lock.json b/jslib/package-lock.json index 47add51c..fc972d77 100644 --- a/jslib/package-lock.json +++ b/jslib/package-lock.json @@ -1592,7 +1592,8 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -1613,12 +1614,14 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1633,17 +1636,20 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -1760,7 +1766,8 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -1772,6 +1779,7 @@ "version": "1.0.0", "bundled": true, "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -1786,6 +1794,7 @@ "version": "3.0.4", "bundled": true, "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -1793,12 +1802,14 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "minipass": { "version": "2.3.5", "bundled": true, "dev": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -1817,6 +1828,7 @@ "version": "0.5.1", "bundled": true, "dev": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -1897,7 +1909,8 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -1909,6 +1922,7 @@ "version": "1.4.0", "bundled": true, "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -1994,7 +2008,8 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -2030,6 +2045,7 @@ "version": "1.0.2", "bundled": true, "dev": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -2049,6 +2065,7 @@ "version": "3.0.1", "bundled": true, "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -2092,12 +2109,14 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true } } }, diff --git a/jslib/src/operations/types.ts b/jslib/src/operations/types.ts index 93f1579d..0272a5f3 100644 --- a/jslib/src/operations/types.ts +++ b/jslib/src/operations/types.ts @@ -37,7 +37,8 @@ export interface NoteData { } export interface EmailPrefData { - digestWeekly: boolean; + inactiveReminder: boolean; + productUpdate: boolean; } export interface UserData { diff --git a/jslib/src/services/users.ts b/jslib/src/services/users.ts index 80ca5ac0..4dbad4c2 100644 --- a/jslib/src/services/users.ts +++ b/jslib/src/services/users.ts @@ -48,6 +48,17 @@ export interface GetEmailPreferenceParams { token?: string; } +export interface GetEmailPreferenceResponse { + inactive_reminder: boolean; + product_update: boolean; +} + +export interface UpdateEmailPreferenceParams { + token?: string; + inactiveReminder?: boolean; + productUpdate?: boolean; +} + export interface classicPresigninPayload { key: string; expiresAt: number; @@ -143,14 +154,33 @@ export default function init(config: HttpClientConfig) { return client.patch('/verify-email', payload); }, - updateEmailPreference: ({ token, digestFrequency }) => { - const payload = { digest_weekly: digestFrequency === 'weekly' }; + updateEmailPreference: ({ + token, + inactiveReminder, + productUpdate + }: UpdateEmailPreferenceParams): Promise => { + const payload: any = {}; + + if (inactiveReminder !== undefined) { + payload.inactive_reminder = inactiveReminder; + } + if (productUpdate !== undefined) { + payload.product_update = productUpdate; + } let endpoint = '/account/email-preference'; if (token) { endpoint = `${endpoint}?token=${token}`; } - return client.patch(endpoint, payload); + + return client + .patch(endpoint, payload) + .then(res => { + return { + inactiveReminder: res.inactive_reminder, + productUpdate: res.product_update + }; + }); }, getEmailPreference: ({ @@ -161,7 +191,12 @@ export default function init(config: HttpClientConfig) { endpoint = `${endpoint}?token=${token}`; } - return client.get(endpoint); + return client.get(endpoint).then(res => { + return { + inactiveReminder: res.inactive_reminder, + productUpdate: res.product_update + }; + }); }, getMe: (): Promise => { diff --git a/pkg/cli/README.md b/pkg/cli/README.md index 5d48a024..2d3df4ad 100644 --- a/pkg/cli/README.md +++ b/pkg/cli/README.md @@ -2,7 +2,7 @@ A simple command line interface for Dnote. -![Dnote](assets/cli.gif) +![Dnote](/assets/cli.gif) It is Designed to minimize environment switching for taking notes. @@ -30,4 +30,4 @@ Otherwise, you can download the binary for your platform manually from the [rele ## Commands -Please refer to [commands](/COMMANDS.md). +Please refer to [commands](./COMMANDS.md). diff --git a/pkg/server/app/app.go b/pkg/server/app/app.go index 29e21dcc..5d26d2b8 100644 --- a/pkg/server/app/app.go +++ b/pkg/server/app/app.go @@ -39,21 +39,26 @@ var ( ErrEmptyEmailBackend = errors.New("No EmailBackend was provided") ) -// App is an application configuration -type App struct { - DB *gorm.DB - Clock clock.Clock - StripeAPIBackend stripe.Backend - EmailTemplates mailer.Templates - EmailBackend mailer.Backend +// Config is an application configuration +type Config struct { WebURL string OnPremise bool DisableRegistration bool } +// App is an application context +type App struct { + DB *gorm.DB + Clock clock.Clock + StripeAPIBackend stripe.Backend + EmailTemplates mailer.Templates + EmailBackend mailer.Backend + Config Config +} + // Validate validates the app configuration func (a *App) Validate() error { - if a.WebURL == "" { + if a.Config.WebURL == "" { return ErrEmptyWebURL } if a.Clock == nil { diff --git a/pkg/server/app/app_test.go b/pkg/server/app/app_test.go index 5a2936f1..c1c07549 100644 --- a/pkg/server/app/app_test.go +++ b/pkg/server/app/app_test.go @@ -42,7 +42,9 @@ func TestValidate(t *testing.T) { StripeAPIBackend: nil, EmailTemplates: mailer.Templates{}, EmailBackend: &testutils.MockEmailbackendImplementation{}, - WebURL: "http://mock.url", + Config: Config{ + WebURL: "http://mock.url", + }, }, expectedErr: nil, }, @@ -53,7 +55,9 @@ func TestValidate(t *testing.T) { StripeAPIBackend: nil, EmailTemplates: mailer.Templates{}, EmailBackend: &testutils.MockEmailbackendImplementation{}, - WebURL: "http://mock.url", + Config: Config{ + WebURL: "http://mock.url", + }, }, expectedErr: ErrEmptyDB, }, @@ -64,7 +68,9 @@ func TestValidate(t *testing.T) { StripeAPIBackend: nil, EmailTemplates: mailer.Templates{}, EmailBackend: &testutils.MockEmailbackendImplementation{}, - WebURL: "http://mock.url", + Config: Config{ + WebURL: "http://mock.url", + }, }, expectedErr: ErrEmptyClock, }, @@ -75,7 +81,9 @@ func TestValidate(t *testing.T) { StripeAPIBackend: nil, EmailTemplates: nil, EmailBackend: &testutils.MockEmailbackendImplementation{}, - WebURL: "http://mock.url", + Config: Config{ + WebURL: "http://mock.url", + }, }, expectedErr: ErrEmptyEmailTemplates, }, @@ -86,7 +94,9 @@ func TestValidate(t *testing.T) { StripeAPIBackend: nil, EmailTemplates: mailer.Templates{}, EmailBackend: nil, - WebURL: "http://mock.url", + Config: Config{ + WebURL: "http://mock.url", + }, }, expectedErr: ErrEmptyEmailBackend, }, @@ -97,7 +107,9 @@ func TestValidate(t *testing.T) { StripeAPIBackend: nil, EmailTemplates: mailer.Templates{}, EmailBackend: &testutils.MockEmailbackendImplementation{}, - WebURL: "", + Config: Config{ + WebURL: "", + }, }, expectedErr: ErrEmptyWebURL, }, diff --git a/pkg/server/app/email.go b/pkg/server/app/email.go index e043e5ea..92cfc5d0 100644 --- a/pkg/server/app/email.go +++ b/pkg/server/app/email.go @@ -29,13 +29,13 @@ import ( var defaultSender = "sung@getdnote.com" -// getSenderEmail returns the sender email -func (a *App) getSenderEmail(want string) (string, error) { - if !a.OnPremise { +// GetSenderEmail returns the sender email +func (c Config) GetSenderEmail(want string) (string, error) { + if !c.OnPremise { return want, nil } - addr, err := a.getNoreplySender() + addr, err := getNoreplySender(c) if err != nil { return "", errors.Wrap(err, "getting sender email address") } @@ -59,8 +59,8 @@ func getDomainFromURL(rawURL string) (string, error) { return domain, nil } -func (a *App) getNoreplySender() (string, error) { - domain, err := getDomainFromURL(a.WebURL) +func getNoreplySender(c Config) (string, error) { + domain, err := getDomainFromURL(c.WebURL) if err != nil { return "", errors.Wrap(err, "parsing web url") } @@ -73,18 +73,18 @@ func (a *App) getNoreplySender() (string, error) { func (a *App) SendVerificationEmail(email, tokenValue string) error { body, err := a.EmailTemplates.Execute(mailer.EmailTypeEmailVerification, mailer.EmailKindText, mailer.EmailVerificationTmplData{ Token: tokenValue, - WebURL: a.WebURL, + WebURL: a.Config.WebURL, }) if err != nil { return errors.Wrapf(err, "executing reset verification template for %s", email) } - from, err := a.getSenderEmail(defaultSender) + from, err := a.Config.GetSenderEmail(defaultSender) if err != nil { return errors.Wrap(err, "getting the sender email") } - if err := a.EmailBackend.Queue("Verify your Dnote email address", from, []string{email}, "text/plain", body); err != nil { + if err := a.EmailBackend.Queue("Verify your Dnote email address", from, []string{email}, mailer.EmailKindText, body); err != nil { return errors.Wrapf(err, "queueing email for %s", email) } @@ -95,41 +95,85 @@ func (a *App) SendVerificationEmail(email, tokenValue string) error { func (a *App) SendWelcomeEmail(email string) error { body, err := a.EmailTemplates.Execute(mailer.EmailTypeWelcome, mailer.EmailKindText, mailer.WelcomeTmplData{ AccountEmail: email, - WebURL: a.WebURL, + WebURL: a.Config.WebURL, }) if err != nil { return errors.Wrapf(err, "executing reset verification template for %s", email) } - from, err := a.getSenderEmail(defaultSender) + from, err := a.Config.GetSenderEmail(defaultSender) if err != nil { return errors.Wrap(err, "getting the sender email") } - if err := a.EmailBackend.Queue("Welcome to Dnote!", from, []string{email}, "text/plain", body); err != nil { + if err := a.EmailBackend.Queue("Welcome to Dnote!", from, []string{email}, mailer.EmailKindText, body); err != nil { return errors.Wrapf(err, "queueing email for %s", email) } return nil } -// SendPasswordResetEmail sends verification email +// SendPasswordResetEmail sends password reset email func (a *App) SendPasswordResetEmail(email, tokenValue string) error { body, err := a.EmailTemplates.Execute(mailer.EmailTypeResetPassword, mailer.EmailKindText, mailer.EmailResetPasswordTmplData{ AccountEmail: email, Token: tokenValue, - WebURL: a.WebURL, + WebURL: a.Config.WebURL, }) if err != nil { - return errors.Wrapf(err, "executing reset verification template for %s", email) + return errors.Wrapf(err, "executing reset password template for %s", email) } - from, err := a.getSenderEmail(defaultSender) + from, err := a.Config.GetSenderEmail(defaultSender) if err != nil { return errors.Wrap(err, "getting the sender email") } - if err := a.EmailBackend.Queue("Reset your password", from, []string{email}, "text/plain", body); err != nil { + if err := a.EmailBackend.Queue("Reset your password", from, []string{email}, mailer.EmailKindText, body); err != nil { + return errors.Wrapf(err, "queueing email for %s", email) + } + + return nil +} + +// SendPasswordResetAlertEmail sends email that notifies users of a password change +func (a *App) SendPasswordResetAlertEmail(email string) error { + body, err := a.EmailTemplates.Execute(mailer.EmailTypeResetPasswordAlert, mailer.EmailKindText, mailer.EmailResetPasswordAlertTmplData{ + AccountEmail: email, + WebURL: a.Config.WebURL, + }) + if err != nil { + return errors.Wrapf(err, "executing reset password alert template for %s", email) + } + + from, err := a.Config.GetSenderEmail(defaultSender) + if err != nil { + return errors.Wrap(err, "getting the sender email") + } + + if err := a.EmailBackend.Queue("Dnote password changed", from, []string{email}, mailer.EmailKindText, body); err != nil { + return errors.Wrapf(err, "queueing email for %s", email) + } + + return nil +} + +// SendSubscriptionConfirmationEmail sends email that confirms subscription purchase +func (a *App) SendSubscriptionConfirmationEmail(email string) error { + body, err := a.EmailTemplates.Execute(mailer.EmailTypeSubscriptionConfirmation, mailer.EmailKindText, mailer.EmailTypeSubscriptionConfirmationTmplData{ + AccountEmail: email, + WebURL: a.Config.WebURL, + }) + if err != nil { + return errors.Wrapf(err, "executing subscription confirmation template for %s", email) + } + + from, err := a.Config.GetSenderEmail(defaultSender) + if err != nil { + return errors.Wrap(err, "getting the sender email") + } + + if err := a.EmailBackend.Queue("Welcome to Dnote Pro", from, []string{email}, mailer.EmailKindText, body); err != nil { return errors.Wrapf(err, "queueing email for %s", email) } diff --git a/pkg/server/app/email_test.go b/pkg/server/app/email_test.go index b6de47df..1f2d1752 100644 --- a/pkg/server/app/email_test.go +++ b/pkg/server/app/email_test.go @@ -28,15 +28,15 @@ import ( func TestSendVerificationEmail(t *testing.T) { testCases := []struct { - onPremise bool + onPremise bool expectedSender string }{ { - onPremise: false, + onPremise: false, expectedSender: "sung@getdnote.com", }, { - onPremise: true, + onPremise: true, expectedSender: "noreply@example.com", }, } @@ -45,9 +45,11 @@ func TestSendVerificationEmail(t *testing.T) { t.Run(fmt.Sprintf("self hosted %t", tc.onPremise), func(t *testing.T) { emailBackend := testutils.MockEmailbackendImplementation{} a := NewTest(&App{ - OnPremise: tc.onPremise, - WebURL: "http://example.com", EmailBackend: &emailBackend, + Config: Config{ + OnPremise: tc.onPremise, + WebURL: "http://example.com", + }, }) if err := a.SendVerificationEmail("alice@example.com", "mockTokenValue"); err != nil { @@ -63,15 +65,15 @@ func TestSendVerificationEmail(t *testing.T) { func TestSendWelcomeEmail(t *testing.T) { testCases := []struct { - onPremise bool + onPremise bool expectedSender string }{ { - onPremise: false, + onPremise: false, expectedSender: "sung@getdnote.com", }, { - onPremise: true, + onPremise: true, expectedSender: "noreply@example.com", }, } @@ -80,9 +82,11 @@ func TestSendWelcomeEmail(t *testing.T) { t.Run(fmt.Sprintf("self hosted %t", tc.onPremise), func(t *testing.T) { emailBackend := testutils.MockEmailbackendImplementation{} a := NewTest(&App{ - OnPremise: tc.onPremise, - WebURL: "http://example.com", EmailBackend: &emailBackend, + Config: Config{ + OnPremise: tc.onPremise, + WebURL: "http://example.com", + }, }) if err := a.SendWelcomeEmail("alice@example.com"); err != nil { @@ -98,15 +102,15 @@ func TestSendWelcomeEmail(t *testing.T) { func TestSendPasswordResetEmail(t *testing.T) { testCases := []struct { - onPremise bool + onPremise bool expectedSender string }{ { - onPremise: false, + onPremise: false, expectedSender: "sung@getdnote.com", }, { - onPremise: true, + onPremise: true, expectedSender: "noreply@example.com", }, } @@ -115,9 +119,11 @@ func TestSendPasswordResetEmail(t *testing.T) { t.Run(fmt.Sprintf("self hosted %t", tc.onPremise), func(t *testing.T) { emailBackend := testutils.MockEmailbackendImplementation{} a := NewTest(&App{ - OnPremise: tc.onPremise, - WebURL: "http://example.com", EmailBackend: &emailBackend, + Config: Config{ + OnPremise: tc.onPremise, + WebURL: "http://example.com", + }, }) if err := a.SendPasswordResetEmail("alice@example.com", "mockTokenValue"); err != nil { @@ -130,3 +136,60 @@ func TestSendPasswordResetEmail(t *testing.T) { }) } } + +func TestSendSubscriptionConfirmationEmail(t *testing.T) { + emailBackend := testutils.MockEmailbackendImplementation{} + a := NewTest(&App{ + EmailBackend: &emailBackend, + Config: Config{ + OnPremise: false, + WebURL: "http://example.com", + }, + }) + + if err := a.SendSubscriptionConfirmationEmail("alice@example.com"); err != nil { + t.Fatal(err, "failed to perform") + } + + assert.Equalf(t, len(emailBackend.Emails), 1, "email queue count mismatch") + assert.Equal(t, emailBackend.Emails[0].From, "sung@getdnote.com", "email sender mismatch") + assert.DeepEqual(t, emailBackend.Emails[0].To, []string{"alice@example.com"}, "email sender mismatch") +} + +func TestGetSenderEmail(t *testing.T) { + testCases := []struct { + onPremise bool + webURL string + candidate string + expectedSender string + }{ + { + onPremise: true, + webURL: "https://www.example.com", + candidate: "alice@getdnote.com", + expectedSender: "noreply@example.com", + }, + { + onPremise: false, + webURL: "https://www.getdnote.com", + candidate: "alice@getdnote.com", + expectedSender: "alice@getdnote.com", + }, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("on premise %t candidate %s", tc.onPremise, tc.candidate), func(t *testing.T) { + c := Config{ + OnPremise: tc.onPremise, + WebURL: tc.webURL, + } + + got, err := c.GetSenderEmail(tc.candidate) + if err != nil { + t.Fatal(err, "failed to perform") + } + + assert.Equal(t, got, tc.expectedSender, "result mismatch") + }) + } +} diff --git a/pkg/server/app/testutils.go b/pkg/server/app/testutils.go index 13d5351f..76749b14 100644 --- a/pkg/server/app/testutils.go +++ b/pkg/server/app/testutils.go @@ -32,12 +32,14 @@ func NewTest(appParams *App) App { a := App{ DB: testutils.DB, - WebURL: os.Getenv("WebURL"), Clock: clock.NewMock(), EmailTemplates: mailer.NewTemplates(&emailTmplDir), EmailBackend: &testutils.MockEmailbackendImplementation{}, StripeAPIBackend: nil, - OnPremise: false, + Config: Config{ + OnPremise: false, + WebURL: os.Getenv("WebURL"), + }, } // Allow to override with appParams @@ -53,14 +55,14 @@ func NewTest(appParams *App) App { if appParams != nil && appParams.StripeAPIBackend != nil { a.StripeAPIBackend = appParams.StripeAPIBackend } - if appParams != nil && appParams.OnPremise { - a.OnPremise = appParams.OnPremise + if appParams != nil && appParams.Config.OnPremise { + a.Config.OnPremise = appParams.Config.OnPremise } - if appParams != nil && appParams.WebURL != "" { - a.WebURL = appParams.WebURL + if appParams != nil && appParams.Config.WebURL != "" { + a.Config.WebURL = appParams.Config.WebURL } - if appParams != nil && appParams.DisableRegistration { - a.DisableRegistration = appParams.DisableRegistration + if appParams != nil && appParams.Config.DisableRegistration { + a.Config.DisableRegistration = appParams.Config.DisableRegistration } return a diff --git a/pkg/server/app/users.go b/pkg/server/app/users.go index 081ec2fe..75a9343d 100644 --- a/pkg/server/app/users.go +++ b/pkg/server/app/users.go @@ -21,31 +21,13 @@ package app import ( "time" - "github.com/dnote/dnote/pkg/server/crypt" "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/token" "github.com/jinzhu/gorm" "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" ) -func generateResetToken() (string, error) { - ret, err := crypt.GetRandomStr(16) - if err != nil { - return "", errors.Wrap(err, "generating random token") - } - - return ret, nil -} - -func generateVerificationCode() (string, error) { - ret, err := crypt.GetRandomStr(16) - if err != nil { - return "", errors.Wrap(err, "generating random token") - } - - return ret, nil -} - // TouchLastLoginAt updates the last login timestamp func (a *App) TouchLastLoginAt(user database.User, tx *gorm.DB) error { t := time.Now() @@ -56,23 +38,6 @@ func (a *App) TouchLastLoginAt(user database.User, tx *gorm.DB) error { return nil } -func createEmailVerificaitonToken(user database.User, tx *gorm.DB) error { - verificationCode, err := generateVerificationCode() - if err != nil { - return errors.Wrap(err, "generating verification code") - } - token := database.Token{ - UserID: user.ID, - Type: database.TokenTypeEmailVerification, - Value: verificationCode, - } - if err := tx.Save(&token).Error; err != nil { - return errors.Wrap(err, "saving verification token") - } - - return nil -} - func createEmailPreference(user database.User, tx *gorm.DB) error { p := database.EmailPreference{ UserID: user.ID, @@ -115,7 +80,7 @@ func (a *App) CreateUser(email, password string) (database.User, error) { // Grant all privileges if self-hosting var pro bool - if a.OnPremise { + if a.Config.OnPremise { pro = true } else { pro = false @@ -138,7 +103,7 @@ func (a *App) CreateUser(email, password string) (database.User, error) { return database.User{}, errors.Wrap(err, "saving account") } - if err := createEmailVerificaitonToken(user, tx); err != nil { + if _, err := token.Create(tx, user.ID, database.TokenTypeEmailPreference); err != nil { tx.Rollback() return database.User{}, errors.Wrap(err, "creating email verificaiton token") } diff --git a/pkg/server/app/users_test.go b/pkg/server/app/users_test.go index 4a75c571..13d8c0dd 100644 --- a/pkg/server/app/users_test.go +++ b/pkg/server/app/users_test.go @@ -30,15 +30,15 @@ import ( func TestCreateUser(t *testing.T) { testCases := []struct { - onPremise bool + onPremise bool expectedPro bool }{ { - onPremise: true, + onPremise: true, expectedPro: true, }, { - onPremise: false, + onPremise: false, expectedPro: false, }, } @@ -48,7 +48,9 @@ func TestCreateUser(t *testing.T) { defer testutils.ClearData() a := NewTest(&App{ - OnPremise: tc.onPremise, + Config: Config{ + OnPremise: tc.onPremise, + }, }) if _, err := a.CreateUser("alice@example.com", "pass1234"); err != nil { t.Fatal(errors.Wrap(err, "executing")) diff --git a/pkg/server/database/models.go b/pkg/server/database/models.go index 8e084f0a..97a83895 100644 --- a/pkg/server/database/models.go +++ b/pkg/server/database/models.go @@ -109,11 +109,12 @@ type Notification struct { UserID int `gorm:"index"` } -// EmailPreference is information about how often user wants to receive digest email +// EmailPreference is a preference per user for receiving email communication type EmailPreference struct { Model - UserID int `gorm:"index" json:"-"` - DigestWeekly bool `json:"digest_weekly"` // Deprecated: email digests now sends based on the repetition rule + UserID int `gorm:"index" json:"-"` + InactiveReminder bool `json:"inactive_reminder" gorm:"default:true"` + ProductUpdate bool `json:"product_update" gorm:"default:true"` } // Session represents a user session diff --git a/pkg/server/handlers/auth.go b/pkg/server/handlers/auth.go index 965d0fcd..d3c067cd 100644 --- a/pkg/server/handlers/auth.go +++ b/pkg/server/handlers/auth.go @@ -20,13 +20,14 @@ package handlers import ( "encoding/json" - "log" "net/http" "time" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/helpers" + "github.com/dnote/dnote/pkg/server/log" "github.com/dnote/dnote/pkg/server/mailer" + "github.com/dnote/dnote/pkg/server/token" "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" ) @@ -77,7 +78,7 @@ func (a *API) getMe(w http.ResponseWriter, r *http.Request) { if err := a.App.TouchLastLoginAt(user, tx); err != nil { tx.Rollback() // In case of an error, gracefully continue to avoid disturbing the service - log.Println("error touching last_login_at", err.Error()) + log.ErrorWrap(err, "error touching last_login_at") } tx.Commit() @@ -110,24 +111,13 @@ func (a *API) createResetToken(w http.ResponseWriter, r *http.Request) { return } - resetToken, err := generateResetToken() + resetToken, err := token.Create(a.App.DB, account.UserID, database.TokenTypeResetPassword) if err != nil { HandleError(w, errors.Wrap(err, "generating token").Error(), nil, http.StatusInternalServerError) return } - token := database.Token{ - UserID: account.UserID, - Value: resetToken, - Type: database.TokenTypeResetPassword, - } - - if err := a.App.DB.Save(&token).Error; err != nil { - HandleError(w, errors.Wrap(err, "saving token").Error(), nil, http.StatusInternalServerError) - return - } - - if err := a.App.SendPasswordResetEmail(account.Email.String, resetToken); err != nil { + if err := a.App.SendPasswordResetEmail(account.Email.String, resetToken.Value); err != nil { if errors.Cause(err) == mailer.ErrSMTPNotConfigured { respondInvalidSMTPConfig(w) } else { @@ -208,4 +198,8 @@ func (a *API) resetPassword(w http.ResponseWriter, r *http.Request) { } a.respondWithSession(a.App.DB, w, user.ID, http.StatusOK) + + if err := a.App.SendPasswordResetAlertEmail(account.Email.String); err != nil { + log.ErrorWrap(err, "sending password reset email") + } } diff --git a/pkg/server/handlers/helpers.go b/pkg/server/handlers/helpers.go index 87ddfabe..c248f34a 100644 --- a/pkg/server/handlers/helpers.go +++ b/pkg/server/handlers/helpers.go @@ -19,8 +19,6 @@ package handlers import ( - crand "crypto/rand" - "encoding/base64" "encoding/json" "net/http" "strings" @@ -31,39 +29,6 @@ import ( "github.com/pkg/errors" ) -const ( - demoUserEmail = "demo@dnote.io" -) - -func generateRandomToken(bits int) (string, error) { - b := make([]byte, bits) - - _, err := crand.Read(b) - if err != nil { - return "", errors.Wrap(err, "generating random bytes") - } - - return base64.URLEncoding.EncodeToString(b), nil -} - -func generateResetToken() (string, error) { - ret, err := generateRandomToken(16) - if err != nil { - return "", errors.Wrap(err, "generating random token") - } - - return ret, nil -} - -func generateVerificationCode() (string, error) { - ret, err := generateRandomToken(16) - if err != nil { - return "", errors.Wrap(err, "generating random token") - } - - return ret, nil -} - func paginate(conn *gorm.DB, page int) *gorm.DB { limit := 30 diff --git a/pkg/server/handlers/routes_test.go b/pkg/server/handlers/routes_test.go index 34720344..ab5a8d4e 100644 --- a/pkg/server/handlers/routes_test.go +++ b/pkg/server/handlers/routes_test.go @@ -702,7 +702,9 @@ func TestNewRouter_AppValidate(t *testing.T) { StripeAPIBackend: nil, EmailTemplates: mailer.Templates{}, EmailBackend: &testutils.MockEmailbackendImplementation{}, - WebURL: "http://mock.url", + Config: app.Config{ + WebURL: "http://mock.url", + }, }, expectedErr: nil, }, @@ -713,7 +715,9 @@ func TestNewRouter_AppValidate(t *testing.T) { StripeAPIBackend: nil, EmailTemplates: mailer.Templates{}, EmailBackend: &testutils.MockEmailbackendImplementation{}, - WebURL: "http://mock.url", + Config: app.Config{ + WebURL: "http://mock.url", + }, }, expectedErr: app.ErrEmptyDB, }, @@ -724,7 +728,9 @@ func TestNewRouter_AppValidate(t *testing.T) { StripeAPIBackend: nil, EmailTemplates: mailer.Templates{}, EmailBackend: &testutils.MockEmailbackendImplementation{}, - WebURL: "http://mock.url", + Config: app.Config{ + WebURL: "http://mock.url", + }, }, expectedErr: app.ErrEmptyClock, }, @@ -735,7 +741,9 @@ func TestNewRouter_AppValidate(t *testing.T) { StripeAPIBackend: nil, EmailTemplates: nil, EmailBackend: &testutils.MockEmailbackendImplementation{}, - WebURL: "http://mock.url", + Config: app.Config{ + WebURL: "http://mock.url", + }, }, expectedErr: app.ErrEmptyEmailTemplates, }, @@ -746,7 +754,9 @@ func TestNewRouter_AppValidate(t *testing.T) { StripeAPIBackend: nil, EmailTemplates: mailer.Templates{}, EmailBackend: nil, - WebURL: "http://mock.url", + Config: app.Config{ + WebURL: "http://mock.url", + }, }, expectedErr: app.ErrEmptyEmailBackend, }, @@ -757,7 +767,9 @@ func TestNewRouter_AppValidate(t *testing.T) { StripeAPIBackend: nil, EmailTemplates: mailer.Templates{}, EmailBackend: &testutils.MockEmailbackendImplementation{}, - WebURL: "", + Config: app.Config{ + WebURL: "", + }, }, expectedErr: app.ErrEmptyWebURL, }, diff --git a/pkg/server/handlers/subscription.go b/pkg/server/handlers/subscription.go index 5024070b..62a9a3d7 100644 --- a/pkg/server/handlers/subscription.go +++ b/pkg/server/handlers/subscription.go @@ -29,6 +29,7 @@ import ( "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/helpers" + "github.com/dnote/dnote/pkg/server/log" "github.com/jinzhu/gorm" "github.com/pkg/errors" "github.com/stripe/stripe-go" @@ -131,6 +132,11 @@ func (a *API) createSub(w http.ResponseWriter, r *http.Request) { HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError) return } + var account database.Account + if err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil { + HandleError(w, "getting user", err, http.StatusInternalServerError) + return + } var payload createSubPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { @@ -174,6 +180,10 @@ func (a *API) createSub(w http.ResponseWriter, r *http.Request) { return } + if err := a.App.SendSubscriptionConfirmationEmail(account.Email.String); err != nil { + log.ErrorWrap(err, "sending subscription confirmation email") + } + w.WriteHeader(http.StatusOK) } diff --git a/pkg/server/handlers/user.go b/pkg/server/handlers/user.go index f32f23a7..e7bdb681 100644 --- a/pkg/server/handlers/user.go +++ b/pkg/server/handlers/user.go @@ -28,6 +28,7 @@ import ( "github.com/dnote/dnote/pkg/server/log" "github.com/dnote/dnote/pkg/server/mailer" "github.com/dnote/dnote/pkg/server/presenters" + "github.com/dnote/dnote/pkg/server/token" "github.com/jinzhu/gorm" "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" @@ -155,24 +156,13 @@ func (a *API) createVerificationToken(w http.ResponseWriter, r *http.Request) { return } - tokenValue, err := generateVerificationCode() + tok, err := token.Create(a.App.DB, account.UserID, database.TokenTypeEmailVerification) if err != nil { - HandleError(w, "generating verification code", err, http.StatusInternalServerError) - return - } - - token := database.Token{ - UserID: account.UserID, - Value: tokenValue, - Type: database.TokenTypeEmailVerification, - } - - if err := a.App.DB.Save(&token).Error; err != nil { HandleError(w, "saving token", err, http.StatusInternalServerError) return } - if err := a.App.SendVerificationEmail(account.Email.String, tokenValue); err != nil { + if err := a.App.SendVerificationEmail(account.Email.String, tok.Value); err != nil { if errors.Cause(err) == mailer.ErrSMTPNotConfigured { respondInvalidSMTPConfig(w) } else { @@ -249,8 +239,25 @@ func (a *API) verifyEmail(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, session) } -type updateEmailPreferencePayload struct { - DigestWeekly bool `json:"digest_weekly"` +type emailPreferernceParams struct { + InactiveReminder *bool `json:"inactive_reminder"` + ProductUpdate *bool `json:"product_update"` +} + +func (p emailPreferernceParams) getInactiveReminder() bool { + if p.InactiveReminder == nil { + return false + } + + return *p.InactiveReminder +} + +func (p emailPreferernceParams) getProductUpdate() bool { + if p.ProductUpdate == nil { + return false + } + + return *p.ProductUpdate } func (a *API) updateEmailPreference(w http.ResponseWriter, r *http.Request) { @@ -260,30 +267,36 @@ func (a *API) updateEmailPreference(w http.ResponseWriter, r *http.Request) { return } - var params updateEmailPreferencePayload + var params emailPreferernceParams if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { HandleError(w, "decoding payload", err, http.StatusInternalServerError) return } - var frequency database.EmailPreference - if err := a.App.DB.Where(database.EmailPreference{UserID: user.ID}).FirstOrCreate(&frequency).Error; err != nil { - HandleError(w, "finding frequency", err, http.StatusInternalServerError) + var pref database.EmailPreference + if err := a.App.DB.Where(database.EmailPreference{UserID: user.ID}).FirstOrCreate(&pref).Error; err != nil { + HandleError(w, "finding pref", err, http.StatusInternalServerError) return } tx := a.App.DB.Begin() - frequency.DigestWeekly = params.DigestWeekly - if err := tx.Save(&frequency).Error; err != nil { + if params.InactiveReminder != nil { + pref.InactiveReminder = params.getInactiveReminder() + } + if params.ProductUpdate != nil { + pref.ProductUpdate = params.getProductUpdate() + } + + if err := tx.Save(&pref).Error; err != nil { tx.Rollback() - HandleError(w, "saving frequency", err, http.StatusInternalServerError) + HandleError(w, "saving pref", err, http.StatusInternalServerError) return } token, ok := r.Context().Value(helpers.KeyToken).(database.Token) if ok { - // Use token if the user was authenticated by token + // Mark token as used if the user was authenticated by token if err := tx.Model(&token).Update("used_at", time.Now()).Error; err != nil { tx.Rollback() HandleError(w, "updating reset token", err, http.StatusInternalServerError) @@ -293,7 +306,7 @@ func (a *API) updateEmailPreference(w http.ResponseWriter, r *http.Request) { tx.Commit() - respondJSON(w, http.StatusOK, frequency) + respondJSON(w, http.StatusOK, pref) } func (a *API) getEmailPreference(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/server/handlers/user_test.go b/pkg/server/handlers/user_test.go index f6b866ba..94d23d48 100644 --- a/pkg/server/handlers/user_test.go +++ b/pkg/server/handlers/user_test.go @@ -362,7 +362,6 @@ func TestVerifyEmail(t *testing.T) { func TestUpdateEmail(t *testing.T) { t.Run("success", func(t *testing.T) { - defer testutils.ClearData() // Setup @@ -396,7 +395,6 @@ func TestUpdateEmail(t *testing.T) { func TestUpdateEmailPreference(t *testing.T) { t.Run("with login", func(t *testing.T) { - defer testutils.ClearData() // Setup @@ -409,7 +407,7 @@ func TestUpdateEmailPreference(t *testing.T) { testutils.SetupEmailPreferenceData(u, false) // Execute - dat := `{"digest_weekly": true}` + dat := `{"inactive_reminder": true}` req := testutils.MakeReq(server.URL, "PATCH", "/account/email-preference", dat) res := testutils.HTTPAuthDo(t, req, u) @@ -418,16 +416,14 @@ func TestUpdateEmailPreference(t *testing.T) { var preference database.EmailPreference testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding account") - assert.Equal(t, preference.DigestWeekly, true, "preference mismatch") + assert.Equal(t, preference.InactiveReminder, true, "preference mismatch") }) t.Run("with an unused token", func(t *testing.T) { - defer testutils.ClearData() // Setup server := MustNewServer(t, &app.App{ - Clock: clock.NewMock(), }) defer server.Close() @@ -442,7 +438,7 @@ func TestUpdateEmailPreference(t *testing.T) { testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token") // Execute - dat := `{"digest_weekly": true}` + dat := `{"inactive_reminder": true}` url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue") req := testutils.MakeReq(server.URL, "PATCH", url, dat) res := testutils.HTTPDo(t, req) @@ -458,17 +454,15 @@ func TestUpdateEmailPreference(t *testing.T) { testutils.MustExec(t, testutils.DB.Where("id = ?", tok.ID).First(&token), "failed to find token") assert.Equal(t, preferenceCount, 1, "preference count mismatch") - assert.Equal(t, preference.DigestWeekly, true, "email mismatch") + assert.Equal(t, preference.InactiveReminder, true, "email mismatch") assert.NotEqual(t, token.UsedAt, (*time.Time)(nil), "token should have been used") }) t.Run("with nonexistent token", func(t *testing.T) { - defer testutils.ClearData() // Setup server := MustNewServer(t, &app.App{ - Clock: clock.NewMock(), }) defer server.Close() @@ -482,7 +476,7 @@ func TestUpdateEmailPreference(t *testing.T) { } testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token") - dat := `{"digest_weekly": false}` + dat := `{"inactive_reminder": false}` url := fmt.Sprintf("/account/email-preference?token=%s", "someNonexistentToken") req := testutils.MakeReq(server.URL, "PATCH", url, dat) @@ -494,11 +488,10 @@ func TestUpdateEmailPreference(t *testing.T) { var preference database.EmailPreference testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference") - assert.Equal(t, preference.DigestWeekly, true, "email mismatch") + assert.Equal(t, preference.InactiveReminder, true, "email mismatch") }) t.Run("with expired token", func(t *testing.T) { - defer testutils.ClearData() // Setup @@ -521,7 +514,7 @@ func TestUpdateEmailPreference(t *testing.T) { testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token") // Execute - dat := `{"digest_weekly": false}` + dat := `{"inactive_reminder": false}` url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue") req := testutils.MakeReq(server.URL, "PATCH", url, dat) res := testutils.HTTPDo(t, req) @@ -531,7 +524,7 @@ func TestUpdateEmailPreference(t *testing.T) { var preference database.EmailPreference testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference") - assert.Equal(t, preference.DigestWeekly, true, "email mismatch") + assert.Equal(t, preference.InactiveReminder, true, "email mismatch") }) t.Run("with a used but unexpired token", func(t *testing.T) { @@ -556,7 +549,7 @@ func TestUpdateEmailPreference(t *testing.T) { } testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token") - dat := `{"digest_weekly": false}` + dat := `{"inactive_reminder": false}` url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue") req := testutils.MakeReq(server.URL, "PATCH", url, dat) @@ -568,7 +561,7 @@ func TestUpdateEmailPreference(t *testing.T) { var preference database.EmailPreference testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference") - assert.Equal(t, preference.DigestWeekly, false, "DigestWeekly mismatch") + assert.Equal(t, preference.InactiveReminder, false, "InactiveReminder mismatch") }) t.Run("no user and no token", func(t *testing.T) { @@ -586,7 +579,7 @@ func TestUpdateEmailPreference(t *testing.T) { testutils.SetupEmailPreferenceData(u, true) // Execute - dat := `{"digest_weekly": false}` + dat := `{"inactive_reminder": false}` req := testutils.MakeReq(server.URL, "PATCH", "/account/email-preference", dat) res := testutils.HTTPDo(t, req) @@ -595,7 +588,7 @@ func TestUpdateEmailPreference(t *testing.T) { var preference database.EmailPreference testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference") - assert.Equal(t, preference.DigestWeekly, true, "email mismatch") + assert.Equal(t, preference.InactiveReminder, true, "email mismatch") }) t.Run("create a record if not exists", func(t *testing.T) { @@ -618,7 +611,7 @@ func TestUpdateEmailPreference(t *testing.T) { testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token") // Execute - dat := `{"digest_weekly": false}` + dat := `{"inactive_reminder": false}` url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue") req := testutils.MakeReq(server.URL, "PATCH", url, dat) res := testutils.HTTPDo(t, req) @@ -632,7 +625,7 @@ func TestUpdateEmailPreference(t *testing.T) { var preference database.EmailPreference testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference") - assert.Equal(t, preference.DigestWeekly, false, "email mismatch") + assert.Equal(t, preference.InactiveReminder, false, "email mismatch") }) } @@ -661,9 +654,10 @@ func TestGetEmailPreference(t *testing.T) { } expected := presenters.EmailPreference{ - DigestWeekly: pref.DigestWeekly, - CreatedAt: presenters.FormatTS(pref.CreatedAt), - UpdatedAt: presenters.FormatTS(pref.UpdatedAt), + InactiveReminder: pref.InactiveReminder, + ProductUpdate: pref.ProductUpdate, + CreatedAt: presenters.FormatTS(pref.CreatedAt), + UpdatedAt: presenters.FormatTS(pref.UpdatedAt), } assert.DeepEqual(t, got, expected, "payload mismatch") } diff --git a/pkg/server/handlers/v3_auth.go b/pkg/server/handlers/v3_auth.go index 736f2c28..e13cea48 100644 --- a/pkg/server/handlers/v3_auth.go +++ b/pkg/server/handlers/v3_auth.go @@ -176,7 +176,7 @@ func parseRegisterPaylaod(r *http.Request) (registerPayload, error) { } func (a *API) register(w http.ResponseWriter, r *http.Request) { - if a.App.DisableRegistration { + if a.App.Config.DisableRegistration { respondForbidden(w) return } diff --git a/pkg/server/handlers/v3_auth_test.go b/pkg/server/handlers/v3_auth_test.go index 00e5d01d..d1546168 100644 --- a/pkg/server/handlers/v3_auth_test.go +++ b/pkg/server/handlers/v3_auth_test.go @@ -100,7 +100,9 @@ func TestRegister(t *testing.T) { server := MustNewServer(t, &app.App{ Clock: clock.NewMock(), EmailBackend: &emailBackend, - OnPremise: tc.onPremise, + Config: app.Config{ + OnPremise: tc.onPremise, + }, }) defer server.Close() @@ -235,8 +237,10 @@ func TestRegisterDisabled(t *testing.T) { // Setup server := MustNewServer(t, &app.App{ - Clock: clock.NewMock(), - DisableRegistration: true, + Clock: clock.NewMock(), + Config: app.Config{ + DisableRegistration: true, + }, }) defer server.Close() diff --git a/pkg/server/helpers/helpers.go b/pkg/server/helpers/helpers.go index 56615f8b..773a73c8 100644 --- a/pkg/server/helpers/helpers.go +++ b/pkg/server/helpers/helpers.go @@ -20,27 +20,9 @@ package helpers import ( "github.com/google/uuid" - "github.com/jinzhu/gorm" "github.com/pkg/errors" ) -const ( - demoUserEmail = "demo@dnote.io" -) - -// GetDemoUserID returns ID of the demo user -func GetDemoUserID(db *gorm.DB) (int, error) { - - result := struct { - UserID int - }{} - if err := db.Table("accounts").Select("user_id").Where("email = ?", demoUserEmail).Scan(&result).Error; err != nil { - return result.UserID, errors.Wrap(err, "finding demo user") - } - - return result.UserID, nil -} - // GenUUID generates a new uuid v4 func GenUUID() (string, error) { id, err := uuid.NewRandom() diff --git a/pkg/server/job/job.go b/pkg/server/job/job.go index cb2be116..97ddd4dc 100644 --- a/pkg/server/job/job.go +++ b/pkg/server/job/job.go @@ -19,10 +19,13 @@ package job import ( - "log" + slog "log" "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/app" + "github.com/dnote/dnote/pkg/server/job/remind" "github.com/dnote/dnote/pkg/server/job/repetition" + "github.com/dnote/dnote/pkg/server/log" "github.com/dnote/dnote/pkg/server/mailer" "github.com/jinzhu/gorm" "github.com/pkg/errors" @@ -48,17 +51,17 @@ type Runner struct { Clock clock.Clock EmailTmpl mailer.Templates EmailBackend mailer.Backend - WebURL string + Config app.Config } // NewRunner returns a new runner -func NewRunner(db *gorm.DB, c clock.Clock, t mailer.Templates, b mailer.Backend, webURL string) (Runner, error) { +func NewRunner(db *gorm.DB, c clock.Clock, t mailer.Templates, b mailer.Backend, config app.Config) (Runner, error) { ret := Runner{ DB: db, EmailTmpl: t, EmailBackend: b, Clock: c, - WebURL: webURL, + Config: config, } if err := ret.validate(); err != nil { @@ -81,7 +84,7 @@ func (r *Runner) validate() error { if r.EmailBackend == nil { return ErrEmptyEmailBackend } - if r.WebURL == "" { + if r.Config.WebURL == "" { return ErrEmptyWebURL } @@ -101,6 +104,7 @@ func (r *Runner) schedule(ch chan error) { // Schedule jobs cr := cron.New() scheduleJob(cr, "* * * * *", func() { r.DoRepetition() }) + scheduleJob(cr, "0 8 * * *", func() { r.RemindNoRecentNotes() }) cr.Start() ch <- nil @@ -122,22 +126,53 @@ func (r *Runner) Do() error { return errors.Wrap(err, "scheduling jobs") } - log.Println("Started background tasks") + slog.Println("Started background tasks") return nil } // DoRepetition creates spaced repetitions and delivers the results based on the rules -func (r *Runner) DoRepetition() error { - p := repetition.Params{ +func (r *Runner) DoRepetition() { + c := repetition.Context{ DB: r.DB, Clock: r.Clock, EmailTmpl: r.EmailTmpl, EmailBackend: r.EmailBackend, - } - if err := repetition.Do(p); err != nil { - return errors.Wrap(err, "performing repetition job") + Config: r.Config, } - return nil + result, err := repetition.Do(c) + m := log.WithFields(log.Fields{ + "success_count": result.SuccessCount, + "failed_rule_uuids": result.FailedRuleUUIDs, + }) + + if err == nil { + m.Info("successfully processed repetition job") + } else { + m.ErrorWrap(err, "error processing repetition job") + } +} + +// RemindNoRecentNotes remind users if no notes have been added recently +func (r *Runner) RemindNoRecentNotes() { + c := remind.Context{ + DB: r.DB, + Clock: r.Clock, + EmailTmpl: r.EmailTmpl, + EmailBackend: r.EmailBackend, + Config: r.Config, + } + + result, err := remind.DoInactive(c) + m := log.WithFields(log.Fields{ + "success_count": result.SuccessCount, + "failed_user_ids": result.FailedUserIDs, + }) + + if err == nil { + m.Info("successfully processed no recent note reminder job") + } else { + m.ErrorWrap(err, "error processing no recent note reminder job") + } } diff --git a/pkg/server/job/job_test.go b/pkg/server/job/job_test.go index ee5bc61e..5c6ef720 100644 --- a/pkg/server/job/job_test.go +++ b/pkg/server/job/job_test.go @@ -24,6 +24,7 @@ import ( "github.com/dnote/dnote/pkg/assert" "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/mailer" "github.com/dnote/dnote/pkg/server/testutils" "github.com/jinzhu/gorm" @@ -91,7 +92,9 @@ func TestNewRunner(t *testing.T) { for idx, tc := range testCases { t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) { - _, err := NewRunner(tc.db, tc.clock, tc.emailTmpl, tc.emailBackend, tc.webURL) + _, err := NewRunner(tc.db, tc.clock, tc.emailTmpl, tc.emailBackend, app.Config{ + WebURL: tc.webURL, + }) assert.Equal(t, errors.Cause(err), tc.expectedErr, "error mismatch") }) diff --git a/pkg/server/job/remind/inactive.go b/pkg/server/job/remind/inactive.go new file mode 100644 index 00000000..e2d39600 --- /dev/null +++ b/pkg/server/job/remind/inactive.go @@ -0,0 +1,204 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package remind + +import ( + "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/app" + "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/log" + "github.com/dnote/dnote/pkg/server/mailer" + "github.com/jinzhu/gorm" + "github.com/pkg/errors" +) + +// Context holds data that repetition job needs in order to perform +type Context struct { + DB *gorm.DB + Clock clock.Clock + EmailTmpl mailer.Templates + EmailBackend mailer.Backend + Config app.Config +} + +type inactiveUserInfo struct { + userID int + email string + sampleNoteUUID string +} + +func (c *Context) sampleUserNote(userID int) (database.Note, error) { + var ret database.Note + // FIXME: ordering by random() requires a sequential scan on the whole table and does not scale + if err := c.DB.Where("user_id = ?", userID).Order("random() DESC").First(&ret).Error; err != nil { + return ret, errors.Wrap(err, "getting a random note") + } + + return ret, nil +} + +func (c *Context) getInactiveUserInfo() ([]inactiveUserInfo, error) { + ret := []inactiveUserInfo{} + + threshold := c.Clock.Now().AddDate(0, 0, -14).Unix() + + rows, err := c.DB.Raw(` +SELECT + notes.user_id AS user_id, + accounts.email, + SUM( + CASE + WHEN notes.created_at > to_timestamp(?) THEN 1 + ELSE 0 + END + ) AS recent_note_count, + COUNT(*) AS total_note_count +FROM notes +INNER JOIN accounts ON accounts.user_id = notes.user_id +WHERE accounts.email IS NOT NULL AND accounts.email_verified IS TRUE +GROUP BY notes.user_id, accounts.email`, threshold).Rows() + if err != nil { + return ret, errors.Wrap(err, "executing note count SQL query") + } + defer rows.Close() + for rows.Next() { + var userID, recentNoteCount, totalNoteCount int + var email string + if err := rows.Scan(&userID, &email, &recentNoteCount, &totalNoteCount); err != nil { + return nil, errors.Wrap(err, "scanning a row") + } + + if recentNoteCount == 0 && totalNoteCount > 0 { + note, err := c.sampleUserNote(userID) + if err != nil { + return nil, errors.Wrap(err, "sampling user note") + } + + ret = append(ret, inactiveUserInfo{ + userID: userID, + email: email, + sampleNoteUUID: note.UUID, + }) + } + } + + return ret, nil +} + +func (c *Context) canNotify(info inactiveUserInfo) (bool, error) { + var pref database.EmailPreference + if err := c.DB.Where("user_id = ?", info.userID).First(&pref).Error; err != nil { + return false, errors.Wrap(err, "getting email preference") + } + + if !pref.InactiveReminder { + return false, nil + } + + var notif database.Notification + conn := c.DB.Where("user_id = ? AND type = ?", info.userID, mailer.EmailTypeInactiveReminder).Order("created_at DESC").First(¬if) + + if conn.RecordNotFound() { + return true, nil + } else if err := conn.Error; err != nil { + return false, errors.Wrap(err, "checking cooldown") + } + + t := c.Clock.Now().AddDate(0, 0, -14) + if notif.CreatedAt.Before(t) { + return true, nil + } + + return false, nil +} + +func (c *Context) process(info inactiveUserInfo) error { + ok, err := c.canNotify(info) + if err != nil { + return errors.Wrap(err, "checking if user can be notified") + } + if !ok { + return nil + } + + sender, err := c.Config.GetSenderEmail("noreply@getdnote.com") + if err != nil { + return errors.Wrap(err, "getting sender email") + } + + tmplData := mailer.InactiveReminderTmplData{ + WebURL: c.Config.WebURL, + SampleNoteUUID: info.sampleNoteUUID, + Token: "blah", + } + body, err := c.EmailTmpl.Execute(mailer.EmailTypeInactiveReminder, mailer.EmailKindText, tmplData) + if err != nil { + return errors.Wrap(err, "executing inactive email template") + } + + if err := c.EmailBackend.Queue("Your knowledge base stopped growing", sender, []string{info.email}, mailer.EmailKindHTML, body); err != nil { + return errors.Wrap(err, "queueing email") + } + + if err := c.DB.Create(&database.Notification{ + Type: mailer.EmailTypeInactiveReminder, + UserID: info.userID, + }).Error; err != nil { + return errors.Wrap(err, "creating notification") + } + + return nil +} + +// Result holds the result of the job +type Result struct { + SuccessCount int + FailedUserIDs []int +} + +// DoInactive sends reminder for users with no recent notes +func DoInactive(c Context) (Result, error) { + log.Info("performing reminder for no recent notes") + + result := Result{} + items, err := c.getInactiveUserInfo() + if err != nil { + return result, errors.Wrap(err, "getting inactive user information") + } + + log.WithFields(log.Fields{ + "user_count": len(items), + }).Info("counted inactive users") + + for _, item := range items { + err := c.process(item) + + if err == nil { + result.SuccessCount = result.SuccessCount + 1 + } else { + log.WithFields(log.Fields{ + "user_id": item.userID, + }).ErrorWrap(err, "Could not process no recent notes reminder") + + result.FailedUserIDs = append(result.FailedUserIDs, item.userID) + } + } + + return result, nil +} diff --git a/pkg/server/job/remind/inactive_test.go b/pkg/server/job/remind/inactive_test.go new file mode 100644 index 00000000..7982c4e2 --- /dev/null +++ b/pkg/server/job/remind/inactive_test.go @@ -0,0 +1,194 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package remind + +import ( + "os" + "sort" + "testing" + "time" + + "github.com/dnote/dnote/pkg/assert" + "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/mailer" + "github.com/dnote/dnote/pkg/server/testutils" + "github.com/pkg/errors" +) + +func getTestContext(c clock.Clock, be *testutils.MockEmailbackendImplementation) Context { + emailTmplDir := os.Getenv("DNOTE_TEST_EMAIL_TEMPLATE_DIR") + + con := Context{ + DB: testutils.DB, + Clock: c, + EmailTmpl: mailer.NewTemplates(&emailTmplDir), + EmailBackend: be, + } + + return con +} + +func TestDoInactive(t *testing.T) { + defer testutils.ClearData() + + t1 := time.Now() + + // u1 is an active user + u1 := testutils.SetupUserData() + a1 := testutils.SetupAccountData(u1, "alice@example.com", "pass1234") + testutils.MustExec(t, testutils.DB.Model(&a1).Update("email_verified", true), "setting email verified") + testutils.MustExec(t, testutils.DB.Save(&database.EmailPreference{UserID: u1.ID, InactiveReminder: true}), "preparing email preference") + + b1 := database.Book{ + UserID: u1.ID, + Label: "js", + } + testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1") + n1 := database.Note{ + BookUUID: b1.UUID, + UserID: u1.ID, + } + testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1") + + // u2 is an inactive user + u2 := testutils.SetupUserData() + a2 := testutils.SetupAccountData(u2, "bob@example.com", "pass1234") + testutils.MustExec(t, testutils.DB.Model(&a2).Update("email_verified", true), "setting email verified") + testutils.MustExec(t, testutils.DB.Save(&database.EmailPreference{UserID: u2.ID, InactiveReminder: true}), "preparing email preference") + + b2 := database.Book{ + UserID: u2.ID, + Label: "css", + } + testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2") + n2 := database.Note{ + UserID: u2.ID, + BookUUID: b2.UUID, + } + testutils.MustExec(t, testutils.DB.Save(&n2), "preparing n2") + testutils.MustExec(t, testutils.DB.Model(&n2).Update("created_at", t1.AddDate(0, 0, -15)), "preparing n2") + + // u3 is an inactive user with inactive alert email preference disabled + u3 := testutils.SetupUserData() + a3 := testutils.SetupAccountData(u3, "alice@example.com", "pass1234") + testutils.MustExec(t, testutils.DB.Model(&a3).Update("email_verified", true), "setting email verified") + emailPref3 := database.EmailPreference{UserID: u3.ID} + testutils.MustExec(t, testutils.DB.Save(&emailPref3), "preparing email preference") + testutils.MustExec(t, testutils.DB.Model(&emailPref3).Update(map[string]interface{}{"inactive_reminder": false}), "updating email preference") + + b3 := database.Book{ + UserID: u3.ID, + Label: "js", + } + testutils.MustExec(t, testutils.DB.Save(&b3), "preparing b3") + n3 := database.Note{ + BookUUID: b3.UUID, + UserID: u3.ID, + } + testutils.MustExec(t, testutils.DB.Save(&n3), "preparing n3") + testutils.MustExec(t, testutils.DB.Model(&n3).Update("created_at", t1.AddDate(0, 0, -15)), "preparing n3") + + c := clock.NewMock() + c.SetNow(t1) + be := &testutils.MockEmailbackendImplementation{} + + con := getTestContext(c, be) + if _, err := DoInactive(con); err != nil { + t.Fatal(errors.Wrap(err, "performing")) + } + + assert.Equalf(t, len(be.Emails), 1, "email queue count mismatch") + assert.DeepEqual(t, be.Emails[0].To, []string{a2.Email.String}, "email address mismatch") +} + +func TestDoInactive_Cooldown(t *testing.T) { + defer testutils.ClearData() + + // setup sets up an inactive user + setup := func(t *testing.T, now time.Time, email string) database.User { + u := testutils.SetupUserData() + a := testutils.SetupAccountData(u, email, "pass1234") + testutils.MustExec(t, testutils.DB.Model(&a).Update("email_verified", true), "setting email verified") + testutils.MustExec(t, testutils.DB.Save(&database.EmailPreference{UserID: u.ID, InactiveReminder: true}), "preparing email preference") + + b := database.Book{ + UserID: u.ID, + Label: "css", + } + testutils.MustExec(t, testutils.DB.Save(&b), "preparing book") + n := database.Note{ + UserID: u.ID, + BookUUID: b.UUID, + } + testutils.MustExec(t, testutils.DB.Save(&n), "preparing note") + testutils.MustExec(t, testutils.DB.Model(&n).Update("created_at", now.AddDate(0, 0, -15)), "preparing note") + + return u + } + + // Set up + now := time.Now() + + setup(t, now, "alice@example.com") + + bob := setup(t, now, "bob@example.com") + bobNotif := database.Notification{Type: mailer.EmailTypeInactiveReminder, UserID: bob.ID} + testutils.MustExec(t, testutils.DB.Create(&bobNotif), "preparing inactive notification for bob") + testutils.MustExec(t, testutils.DB.Model(&bobNotif).Update("created_at", now.AddDate(0, 0, -7)), "preparing created_at for inactive notification for bob") + + chuck := setup(t, now, "chuck@example.com") + chuckNotif := database.Notification{Type: mailer.EmailTypeInactiveReminder, UserID: chuck.ID} + testutils.MustExec(t, testutils.DB.Create(&chuckNotif), "preparing inactive notification for chuck") + testutils.MustExec(t, testutils.DB.Model(&chuckNotif).Update("created_at", now.AddDate(0, 0, -15)), "preparing created_at for inactive notification for chuck") + + dan := setup(t, now, "dan@example.com") + danNotif1 := database.Notification{Type: mailer.EmailTypeInactiveReminder, UserID: dan.ID} + testutils.MustExec(t, testutils.DB.Create(&danNotif1), "preparing inactive notification 1 for dan") + testutils.MustExec(t, testutils.DB.Model(&danNotif1).Update("created_at", now.AddDate(0, 0, -10)), "preparing created_at for inactive notification for dan") + danNotif2 := database.Notification{Type: mailer.EmailTypeInactiveReminder, UserID: dan.ID} + testutils.MustExec(t, testutils.DB.Create(&danNotif2), "preparing inactive notification 2 for dan") + testutils.MustExec(t, testutils.DB.Model(&danNotif2).Update("created_at", now.AddDate(0, 0, -15)), "preparing created_at for inactive notification for dan") + + c := clock.NewMock() + c.SetNow(now) + be := &testutils.MockEmailbackendImplementation{} + + // Execute + con := getTestContext(c, be) + if _, err := DoInactive(con); err != nil { + t.Fatal(errors.Wrap(err, "performing")) + } + + // Test + assert.Equalf(t, len(be.Emails), 2, "email queue count mismatch") + + var recipients []string + for _, email := range be.Emails { + recipients = append(recipients, email.To[0]) + } + sort.SliceStable(recipients, func(i, j int) bool { + r1 := recipients[i] + r2 := recipients[j] + + return r1 < r2 + }) + + assert.DeepEqual(t, recipients, []string{"alice@example.com", "chuck@example.com"}, "email address mismatch") +} diff --git a/pkg/server/job/remind/main_test.go b/pkg/server/job/remind/main_test.go new file mode 100644 index 00000000..4452f7ae --- /dev/null +++ b/pkg/server/job/remind/main_test.go @@ -0,0 +1,35 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package remind + +import ( + "os" + "testing" + + "github.com/dnote/dnote/pkg/server/testutils" +) + +func TestMain(m *testing.M) { + testutils.InitTestDB() + + code := m.Run() + testutils.ClearData() + + os.Exit(code) +} diff --git a/pkg/server/job/repetition/repetition.go b/pkg/server/job/repetition/repetition.go index 2143a08c..51a2e3e5 100644 --- a/pkg/server/job/repetition/repetition.go +++ b/pkg/server/job/repetition/repetition.go @@ -24,6 +24,7 @@ import ( "time" "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/app" "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/log" "github.com/dnote/dnote/pkg/server/mailer" @@ -31,12 +32,13 @@ import ( "github.com/pkg/errors" ) -// Params holds data that repetition job needs in order to perform -type Params struct { +// Context holds data that repetition job needs in order to perform +type Context struct { DB *gorm.DB Clock clock.Clock EmailTmpl mailer.Templates EmailBackend mailer.Backend + Config app.Config } // BuildEmailParams is the params for building an email @@ -93,8 +95,7 @@ func BuildEmail(db *gorm.DB, emailTmpl mailer.Templates, p BuildEmailParams) (st RuleTitle: p.Rule.Title, WebURL: os.Getenv("WebURL"), } - - body, err := emailTmpl.Execute(mailer.EmailTypeWeeklyDigest, mailer.EmailKindHTML, tmplData) + body, err := emailTmpl.Execute(mailer.EmailTypeDigest, mailer.EmailKindHTML, tmplData) if err != nil { return "", "", errors.Wrap(err, "executing digest email template") } @@ -102,12 +103,12 @@ func BuildEmail(db *gorm.DB, emailTmpl mailer.Templates, p BuildEmailParams) (st return subject, body, nil } -func getEligibleRules(db *gorm.DB, now time.Time) ([]database.RepetitionRule, error) { +func (c Context) getEligibleRules(now time.Time) ([]database.RepetitionRule, error) { hour := now.Hour() minute := now.Minute() var ret []database.RepetitionRule - if err := db. + if err := c.DB. Where("users.cloud AND repetition_rules.hour = ? AND repetition_rules.minute = ? AND repetition_rules.enabled", hour, minute). Joins("INNER JOIN users ON users.id = repetition_rules.user_id"). Find(&ret).Error; err != nil { @@ -135,9 +136,9 @@ func build(tx *gorm.DB, rule database.RepetitionRule) (database.Digest, error) { return digest, nil } -func notify(p Params, now time.Time, user database.User, digest database.Digest, rule database.RepetitionRule) error { +func (c Context) notify(now time.Time, user database.User, digest database.Digest, rule database.RepetitionRule) error { var account database.Account - if err := p.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil { + if err := c.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil { return errors.Wrap(err, "getting account") } @@ -148,7 +149,7 @@ func notify(p Params, now time.Time, user database.User, digest database.Digest, return nil } - subject, body, err := BuildEmail(p.DB, p.EmailTmpl, BuildEmailParams{ + subject, body, err := BuildEmail(c.DB, c.EmailTmpl, BuildEmailParams{ Now: now, User: user, Digest: digest, @@ -158,15 +159,19 @@ func notify(p Params, now time.Time, user database.User, digest database.Digest, return errors.Wrap(err, "making email") } - if err := p.EmailBackend.Queue(subject, "noreply@getdnote.com", []string{account.Email.String}, mailer.EmailKindHTML, body); err != nil { + sender, err := c.Config.GetSenderEmail("noreply@getdnote.com") + if err != nil { + return errors.Wrap(err, "getting sender email") + } + + if err := c.EmailBackend.Queue(subject, sender, []string{account.Email.String}, mailer.EmailKindHTML, body); err != nil { return errors.Wrap(err, "queueing email") } - notif := database.Notification{ - Type: "email_weekly", + if err := c.DB.Create(&database.Notification{ + Type: mailer.EmailTypeDigest, UserID: user.ID, - } - if err := p.DB.Create(¬if).Error; err != nil { + }).Error; err != nil { return errors.Wrap(err, "creating notification") } @@ -201,12 +206,12 @@ func touchTimestamp(tx *gorm.DB, rule database.RepetitionRule, now time.Time) er return nil } -func process(p Params, now time.Time, rule database.RepetitionRule) error { +func (c Context) process(now time.Time, rule database.RepetitionRule) error { log.WithFields(log.Fields{ "uuid": rule.UUID, }).Info("processing repetition") - tx := p.DB.Begin() + tx := c.DB.Begin() if !checkCooldown(now, rule) { log.WithFields(log.Fields{ @@ -242,7 +247,7 @@ func process(p Params, now time.Time, rule database.RepetitionRule) error { return errors.Wrap(err, "committing transaction") } - if err := notify(p, now, user, digest, rule); err != nil { + if err := c.notify(now, user, digest, rule); err != nil { return errors.Wrap(err, "notifying user") } @@ -253,13 +258,20 @@ func process(p Params, now time.Time, rule database.RepetitionRule) error { return nil } -// Do creates spaced repetitions and delivers the results based on the rules -func Do(p Params) error { - now := p.Clock.Now().UTC() +// Result holds the result of the job +type Result struct { + SuccessCount int + FailedRuleUUIDs []string +} - rules, err := getEligibleRules(p.DB, now) +// Do creates spaced repetitions and delivers the results based on the rules +func Do(c Context) (Result, error) { + now := c.Clock.Now().UTC() + result := Result{} + + rules, err := c.getEligibleRules(now) if err != nil { - return errors.Wrap(err, "getting eligible repetition rules") + return result, errors.Wrap(err, "getting eligible repetition rules") } log.WithFields(log.Fields{ @@ -269,13 +281,22 @@ func Do(p Params) error { }).Info("processing rules") for _, rule := range rules { - if err := process(p, now, rule); err != nil { + err := c.process(now, rule) + + if err == nil { + result.SuccessCount = result.SuccessCount + 1 + } else { log.WithFields(log.Fields{ "rule uuid": rule.UUID, }).ErrorWrap(err, "Could not process the repetition rule") - continue + + result.FailedRuleUUIDs = append(result.FailedRuleUUIDs, rule.UUID) } } - return nil + if len(result.FailedRuleUUIDs) > 0 { + return result, errors.New("failed to process some rules") + } + + return result, nil } diff --git a/pkg/server/job/repetition/repetition_test.go b/pkg/server/job/repetition/repetition_test.go index ceacfbb9..9fa2435c 100644 --- a/pkg/server/job/repetition/repetition_test.go +++ b/pkg/server/job/repetition/repetition_test.go @@ -19,6 +19,7 @@ package repetition import ( + "os" "sort" "testing" "time" @@ -28,6 +29,7 @@ import ( "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/mailer" "github.com/dnote/dnote/pkg/server/testutils" + "github.com/pkg/errors" ) func assertLastActive(t *testing.T, ruleUUID string, lastActive int64) { @@ -38,18 +40,26 @@ func assertLastActive(t *testing.T, ruleUUID string, lastActive int64) { } func assertDigestCount(t *testing.T, rule database.RepetitionRule, expected int) { - var digestCount int testutils.MustExec(t, testutils.DB.Model(&database.Digest{}).Where("rule_id = ? AND user_id = ?", rule.ID, rule.UserID).Count(&digestCount), "counting digest") assert.Equal(t, digestCount, expected, "digest count mismatch") } -func getTestParams(c clock.Clock) Params { - return Params{ +func getTestContext(c clock.Clock, be *testutils.MockEmailbackendImplementation) Context { + emailTmplDir := os.Getenv("DNOTE_TEST_EMAIL_TEMPLATE_DIR") + + return Context{ DB: testutils.DB, Clock: c, - EmailTmpl: mailer.Templates{}, - EmailBackend: &testutils.MockEmailbackendImplementation{}, + EmailTmpl: mailer.NewTemplates(&emailTmplDir), + EmailBackend: be, + } +} + +func mustDo(t *testing.T, c Context) { + _, err := Do(c) + if err != nil { + t.Fatal(errors.Wrap(err, "performing")) } } @@ -59,6 +69,9 @@ func TestDo(t *testing.T) { // Set up user := testutils.SetupUserData() + a := testutils.SetupAccountData(user, "alice@example.com", "pass1234") + testutils.MustExec(t, testutils.DB.Model(&a).Update("email_verified", true), "updating email_verified") + t0 := time.Date(2009, time.November, 1, 0, 0, 0, 0, time.UTC) t1 := time.Date(2009, time.November, 4, 12, 2, 0, 0, time.UTC) r1 := database.RepetitionRule{ @@ -80,65 +93,84 @@ func TestDo(t *testing.T) { testutils.MustExec(t, testutils.DB.Save(&r1), "preparing rule1") c := clock.NewMock() + be := testutils.MockEmailbackendImplementation{} + con := getTestContext(c, &be) + // Test // 1 day later c.SetNow(time.Date(2009, time.November, 2, 12, 2, 1, 0, time.UTC)) - Do(getTestParams(c)) + mustDo(t, con) assertLastActive(t, r1.UUID, int64(0)) assertDigestCount(t, r1, 0) + assert.Equalf(t, len(be.Emails), 0, "email queue count mismatch") // 2 days later c.SetNow(time.Date(2009, time.November, 3, 12, 2, 1, 0, time.UTC)) - Do(getTestParams(c)) + mustDo(t, con) assertLastActive(t, r1.UUID, int64(0)) assertDigestCount(t, r1, 0) + assert.Equal(t, len(be.Emails), 0, "email queue count mismatch") // 3 days later - should be processed c.SetNow(time.Date(2009, time.November, 4, 12, 1, 1, 0, time.UTC)) - Do(getTestParams(c)) + mustDo(t, con) assertLastActive(t, r1.UUID, int64(0)) assertDigestCount(t, r1, 0) + assert.Equal(t, len(be.Emails), 0, "email queue count mismatch") c.SetNow(time.Date(2009, time.November, 4, 12, 2, 1, 0, time.UTC)) - Do(getTestParams(c)) + mustDo(t, con) assertLastActive(t, r1.UUID, int64(1257336120000)) assertDigestCount(t, r1, 1) + assert.Equal(t, len(be.Emails), 1, "email queue count mismatch") c.SetNow(time.Date(2009, time.November, 4, 12, 3, 1, 0, time.UTC)) - Do(getTestParams(c)) + mustDo(t, con) assertLastActive(t, r1.UUID, int64(1257336120000)) assertDigestCount(t, r1, 1) + assert.Equal(t, len(be.Emails), 1, "email queue count mismatch") // 4 day later c.SetNow(time.Date(2009, time.November, 5, 12, 2, 1, 0, time.UTC)) - Do(getTestParams(c)) + mustDo(t, con) assertLastActive(t, r1.UUID, int64(1257336120000)) assertDigestCount(t, r1, 1) + assert.Equal(t, len(be.Emails), 1, "email queue count mismatch") + // 5 days later c.SetNow(time.Date(2009, time.November, 6, 12, 2, 1, 0, time.UTC)) - Do(getTestParams(c)) + mustDo(t, con) assertLastActive(t, r1.UUID, int64(1257336120000)) assertDigestCount(t, r1, 1) + assert.Equal(t, len(be.Emails), 1, "email queue count mismatch") + // 6 days later - should be processed c.SetNow(time.Date(2009, time.November, 7, 12, 2, 1, 0, time.UTC)) - Do(getTestParams(c)) + mustDo(t, con) assertLastActive(t, r1.UUID, int64(1257595320000)) assertDigestCount(t, r1, 2) + assert.Equal(t, len(be.Emails), 2, "email queue count mismatch") + // 7 days later c.SetNow(time.Date(2009, time.November, 8, 12, 2, 1, 0, time.UTC)) - Do(getTestParams(c)) + mustDo(t, con) assertLastActive(t, r1.UUID, int64(1257595320000)) assertDigestCount(t, r1, 2) + assert.Equal(t, len(be.Emails), 2, "email queue count mismatch") + // 8 days later c.SetNow(time.Date(2009, time.November, 9, 12, 2, 1, 0, time.UTC)) - Do(getTestParams(c)) + mustDo(t, con) assertLastActive(t, r1.UUID, int64(1257595320000)) assertDigestCount(t, r1, 2) + assert.Equal(t, len(be.Emails), 2, "email queue count mismatch") + // 9 days later - should be processed c.SetNow(time.Date(2009, time.November, 10, 12, 2, 1, 0, time.UTC)) - Do(getTestParams(c)) + mustDo(t, con) assertLastActive(t, r1.UUID, int64(1257854520000)) assertDigestCount(t, r1, 3) + assert.Equal(t, len(be.Emails), 3, "email queue count mismatch") }) /* @@ -160,6 +192,9 @@ func TestDo(t *testing.T) { // Set up user := testutils.SetupUserData() + a := testutils.SetupAccountData(user, "alice@example.com", "pass1234") + testutils.MustExec(t, testutils.DB.Model(&a).Update("email_verified", true), "updating email_verified") + t0 := time.Date(2009, time.November, 1, 12, 2, 0, 0, time.UTC) t1 := time.Date(2009, time.November, 4, 12, 2, 0, 0, time.UTC) r1 := database.RepetitionRule{ @@ -182,7 +217,9 @@ func TestDo(t *testing.T) { c := clock.NewMock() c.SetNow(time.Date(2009, time.November, 10, 12, 2, 1, 0, time.UTC)) - Do(getTestParams(c)) + be := &testutils.MockEmailbackendImplementation{} + + mustDo(t, getTestContext(c, be)) var rule database.RepetitionRule testutils.MustExec(t, testutils.DB.Where("uuid = ?", r1.UUID).First(&rule), "finding rule1") @@ -190,6 +227,7 @@ func TestDo(t *testing.T) { assert.Equal(t, rule.LastActive, time.Date(2009, time.November, 10, 12, 2, 0, 0, time.UTC).UnixNano()/int64(time.Millisecond), "LastActive mismsatch") assert.Equal(t, rule.NextActive, time.Date(2009, time.November, 13, 12, 2, 0, 0, time.UTC).UnixNano()/int64(time.Millisecond), "NextActive mismsatch") assertDigestCount(t, r1, 1) + assert.Equal(t, len(be.Emails), 1, "email queue count mismatch") }) } @@ -198,6 +236,9 @@ func TestDo_Disabled(t *testing.T) { // Set up user := testutils.SetupUserData() + a := testutils.SetupAccountData(user, "alice@example.com", "pass1234") + testutils.MustExec(t, testutils.DB.Model(&a).Update("email_verified", true), "updating email_verified") + t0 := time.Date(2009, time.November, 1, 0, 0, 0, 0, time.UTC) t1 := time.Date(2009, time.November, 4, 12, 2, 0, 0, time.UTC) r1 := database.RepetitionRule{ @@ -221,11 +262,14 @@ func TestDo_Disabled(t *testing.T) { // Execute c := clock.NewMock() c.SetNow(time.Date(2009, time.November, 4, 12, 2, 0, 0, time.UTC)) - Do(getTestParams(c)) + be := &testutils.MockEmailbackendImplementation{} + + mustDo(t, getTestContext(c, be)) // Test assertLastActive(t, r1.UUID, int64(0)) assertDigestCount(t, r1, 0) + assert.Equal(t, len(be.Emails), 0, "email queue count mismatch") } func TestDo_BalancedStrategy(t *testing.T) { @@ -241,6 +285,8 @@ func TestDo_BalancedStrategy(t *testing.T) { setup := func() testData { user := testutils.SetupUserData() + a := testutils.SetupAccountData(user, "alice@example.com", "pass1234") + testutils.MustExec(t, testutils.DB.Model(&a).Update("email_verified", true), "updating email_verified") b1 := database.Book{ UserID: user.ID, @@ -313,13 +359,15 @@ func TestDo_BalancedStrategy(t *testing.T) { // Execute c := clock.NewMock() - c.SetNow(time.Date(2009, time.November, 8, 21, 0, 0, 0, time.UTC)) - Do(getTestParams(c)) + be := &testutils.MockEmailbackendImplementation{} + + mustDo(t, getTestContext(c, be)) // Test assertLastActive(t, r1.UUID, int64(1257714000000)) assertDigestCount(t, r1, 1) + assert.Equal(t, len(be.Emails), 1, "email queue count mismatch") var repetition database.Digest testutils.MustExec(t, testutils.DB.Where("rule_id = ? AND user_id = ?", r1.ID, r1.UserID).Preload("Notes").First(&repetition), "finding repetition") @@ -368,13 +416,15 @@ func TestDo_BalancedStrategy(t *testing.T) { // Execute c := clock.NewMock() - c.SetNow(time.Date(2009, time.November, 8, 21, 0, 1, 0, time.UTC)) - Do(getTestParams(c)) + be := &testutils.MockEmailbackendImplementation{} + + mustDo(t, getTestContext(c, be)) // Test assertLastActive(t, r1.UUID, int64(1257714000000)) assertDigestCount(t, r1, 1) + assert.Equal(t, len(be.Emails), 1, "email queue count mismatch") var repetition database.Digest testutils.MustExec(t, testutils.DB.Where("rule_id = ? AND user_id = ?", r1.ID, r1.UserID).Preload("Notes").First(&repetition), "finding repetition") @@ -422,13 +472,15 @@ func TestDo_BalancedStrategy(t *testing.T) { // Execute c := clock.NewMock() - c.SetNow(time.Date(2009, time.November, 8, 21, 0, 0, 0, time.UTC)) - Do(getTestParams(c)) + be := &testutils.MockEmailbackendImplementation{} + + mustDo(t, getTestContext(c, be)) // Test assertLastActive(t, r1.UUID, int64(1257714000000)) assertDigestCount(t, r1, 1) + assert.Equal(t, len(be.Emails), 1, "email queue count mismatch") var repetition database.Digest testutils.MustExec(t, testutils.DB.Where("rule_id = ? AND user_id = ?", r1.ID, r1.UserID).Preload("Notes").First(&repetition), "finding repetition") diff --git a/pkg/server/mailer/backend.go b/pkg/server/mailer/backend.go index 0969ae16..3d36a064 100644 --- a/pkg/server/mailer/backend.go +++ b/pkg/server/mailer/backend.go @@ -88,7 +88,6 @@ func (b *SimpleBackendImplementation) Queue(subject, from string, to []string, c m.SetHeader("To", to...) m.SetHeader("Subject", subject) m.SetBody(contentType, body) - // m.SetBody("text/html", body) p, err := getSMTPParams() if err != nil { diff --git a/pkg/server/mailer/mailer.go b/pkg/server/mailer/mailer.go index 6d9fe86e..5a2689f8 100644 --- a/pkg/server/mailer/mailer.go +++ b/pkg/server/mailer/mailer.go @@ -34,18 +34,24 @@ import ( var ( // EmailTypeResetPassword represents a reset password email EmailTypeResetPassword = "reset_password" - // EmailTypeWeeklyDigest represents a weekly digest email - EmailTypeWeeklyDigest = "digest" + // EmailTypeResetPasswordAlert represents a password change notification email + EmailTypeResetPasswordAlert = "reset_password_alert" + // EmailTypeDigest represents a weekly digest email + EmailTypeDigest = "digest" // EmailTypeEmailVerification represents an email verification email EmailTypeEmailVerification = "verify_email" // EmailTypeWelcome represents an welcome email EmailTypeWelcome = "welcome" + // EmailTypeInactiveReminder represents an inactivity reminder email + EmailTypeInactiveReminder = "inactive" + // EmailTypeSubscriptionConfirmation represents an inactivity reminder email + EmailTypeSubscriptionConfirmation = "subscription_confirmation" ) var ( // EmailKindHTML is the type of html email EmailKindHTML = "text/html" - // EmailKindHTML is the type of text email + // EmailKindText is the type of text email EmailKindText = "text/plain" ) @@ -87,7 +93,7 @@ func NewTemplates(srcDir *string) Templates { box = packr.New("emailTemplates", "./templates/src") } - weeklyDigestHTML, err := initHTMLTmpl(box, EmailTypeWeeklyDigest) + weeklyDigestHTML, err := initHTMLTmpl(box, EmailTypeDigest) if err != nil { panic(errors.Wrap(err, "initializing weekly digest template")) } @@ -103,12 +109,27 @@ func NewTemplates(srcDir *string) Templates { if err != nil { panic(errors.Wrap(err, "initializing password reset template")) } + passwordResetAlertText, err := initTextTmpl(box, EmailTypeResetPasswordAlert) + if err != nil { + panic(errors.Wrap(err, "initializing password reset template")) + } + inactiveReminderText, err := initTextTmpl(box, EmailTypeInactiveReminder) + if err != nil { + panic(errors.Wrap(err, "initializing password reset template")) + } + subscriptionConfirmationText, err := initTextTmpl(box, EmailTypeSubscriptionConfirmation) + if err != nil { + panic(errors.Wrap(err, "initializing password reset template")) + } T := Templates{} - T.set(EmailTypeWeeklyDigest, EmailKindHTML, weeklyDigestHTML) + T.set(EmailTypeDigest, EmailKindHTML, weeklyDigestHTML) T.set(EmailTypeResetPassword, EmailKindText, passwordResetText) + T.set(EmailTypeResetPasswordAlert, EmailKindText, passwordResetAlertText) T.set(EmailTypeEmailVerification, EmailKindText, verifyEmailText) T.set(EmailTypeWelcome, EmailKindText, welcomeText) + T.set(EmailTypeInactiveReminder, EmailKindText, inactiveReminderText) + T.set(EmailTypeSubscriptionConfirmation, EmailKindText, subscriptionConfirmationText) return T } diff --git a/pkg/server/mailer/templates/main.go b/pkg/server/mailer/templates/main.go index 7b04ebef..69653e4b 100644 --- a/pkg/server/mailer/templates/main.go +++ b/pkg/server/mailer/templates/main.go @@ -92,6 +92,20 @@ func (c Context) passwordResetHandler(w http.ResponseWriter, r *http.Request) { w.Write([]byte(body)) } +func (c Context) passwordResetAlertHandler(w http.ResponseWriter, r *http.Request) { + data := mailer.EmailResetPasswordAlertTmplData{ + AccountEmail: "alice@example.com", + WebURL: "http://localhost:3000", + } + body, err := c.Tmpl.Execute(mailer.EmailTypeResetPasswordAlert, mailer.EmailKindText, data) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Write([]byte(body)) +} + func (c Context) emailVerificationHandler(w http.ResponseWriter, r *http.Request) { data := mailer.EmailVerificationTmplData{ Token: "testToken", @@ -120,6 +134,21 @@ func (c Context) welcomeHandler(w http.ResponseWriter, r *http.Request) { w.Write([]byte(body)) } +func (c Context) inactiveHandler(w http.ResponseWriter, r *http.Request) { + data := mailer.InactiveReminderTmplData{ + SampleNoteUUID: "some-uuid", + WebURL: "http://localhost:3000", + Token: "some-random-token", + } + body, err := c.Tmpl.Execute(mailer.EmailTypeInactiveReminder, mailer.EmailKindText, data) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Write([]byte(body)) +} + func (c Context) homeHandler(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Email development server is running.")) } @@ -156,6 +185,8 @@ func main() { http.HandleFunc("/digest", ctx.digestHandler) http.HandleFunc("/email-verification", ctx.emailVerificationHandler) http.HandleFunc("/password-reset", ctx.passwordResetHandler) + http.HandleFunc("/password-reset-alert", ctx.passwordResetAlertHandler) http.HandleFunc("/welcome", ctx.welcomeHandler) + http.HandleFunc("/inactive-reminder", ctx.inactiveHandler) log.Fatal(http.ListenAndServe(":2300", nil)) } diff --git a/pkg/server/mailer/templates/src/inactive.txt b/pkg/server/mailer/templates/src/inactive.txt new file mode 100644 index 00000000..ae6ec6ec --- /dev/null +++ b/pkg/server/mailer/templates/src/inactive.txt @@ -0,0 +1,9 @@ +Hi, nothing has been added to your Dnote for some time. + +What about revisiting one of your previous knowledge? {{ .WebURL }}/notes/{{ .SampleNoteUUID }} + +Expand your knowledge base at {{ .WebURL }}/new or using Dnote apps. + +- Dnote team + +UNSUBSCRIBE: {{ .WebURL }}/settings/notifications?token={{ .Token }} diff --git a/pkg/server/mailer/templates/src/reset_password_alert.txt b/pkg/server/mailer/templates/src/reset_password_alert.txt new file mode 100644 index 00000000..f1d7389b --- /dev/null +++ b/pkg/server/mailer/templates/src/reset_password_alert.txt @@ -0,0 +1,9 @@ +Hi, + +This email is to notify you that the password for your Dnote account "{{ .AccountEmail }}" has changed. + +If you did not initiate this password change, please notify us by replying, and reset your password at {{ .WebURL }}/password-reset + +Thanks. + +- Sung (Maker of Dnote) diff --git a/pkg/server/mailer/templates/src/subscription_confirmation.txt b/pkg/server/mailer/templates/src/subscription_confirmation.txt new file mode 100644 index 00000000..63cb440c --- /dev/null +++ b/pkg/server/mailer/templates/src/subscription_confirmation.txt @@ -0,0 +1,10 @@ +Hi, thanks for signing up for Dnote Pro. + +Dnote Pro is your own simple personal knowledge base that helps you retain new information. + +Your account is "{{ .AccountEmail }}". Log in at {{ .WebURL }}/login + +I appreciate your support. It makes it possible to develop Dnote and help learners around the world. +Thank you for using my software. + +- Sung (Maker of Dnote) diff --git a/pkg/server/mailer/types.go b/pkg/server/mailer/types.go index ea05b6de..35a18fe4 100644 --- a/pkg/server/mailer/types.go +++ b/pkg/server/mailer/types.go @@ -72,8 +72,27 @@ type EmailResetPasswordTmplData struct { WebURL string } +// EmailResetPasswordAlertTmplData is a template data for reset password emails +type EmailResetPasswordAlertTmplData struct { + AccountEmail string + WebURL string +} + // WelcomeTmplData is a template data for welcome emails type WelcomeTmplData struct { AccountEmail string WebURL string } + +// InactiveReminderTmplData is a template data for welcome emails +type InactiveReminderTmplData struct { + SampleNoteUUID string + WebURL string + Token string +} + +// EmailTypeSubscriptionConfirmationTmplData is a template data for reset password emails +type EmailTypeSubscriptionConfirmationTmplData struct { + AccountEmail string + WebURL string +} diff --git a/pkg/server/main.go b/pkg/server/main.go index 452377c8..05e136ca 100644 --- a/pkg/server/main.go +++ b/pkg/server/main.go @@ -108,19 +108,21 @@ func initApp() app.App { db := initDB() return app.App{ - DB: db, - Clock: clock.New(), - StripeAPIBackend: nil, - EmailTemplates: mailer.NewTemplates(nil), - EmailBackend: &mailer.SimpleBackendImplementation{}, - WebURL: os.Getenv("WebURL"), - OnPremise: true, - DisableRegistration: os.Getenv("DisableRegistration") == "true", + DB: db, + Clock: clock.New(), + StripeAPIBackend: nil, + EmailTemplates: mailer.NewTemplates(nil), + EmailBackend: &mailer.SimpleBackendImplementation{}, + Config: app.Config{ + WebURL: os.Getenv("WebURL"), + OnPremise: true, + DisableRegistration: os.Getenv("DisableRegistration") == "true", + }, } } func runJob(a app.App) error { - jobRunner, err := job.NewRunner(a.DB, a.Clock, a.EmailTemplates, a.EmailBackend, a.WebURL) + jobRunner, err := job.NewRunner(a.DB, a.Clock, a.EmailTemplates, a.EmailBackend, a.Config) if err != nil { return errors.Wrap(err, "getting a job runner") } diff --git a/pkg/server/presenters/email_preference.go b/pkg/server/presenters/email_preference.go index 61b70089..c9fa5537 100644 --- a/pkg/server/presenters/email_preference.go +++ b/pkg/server/presenters/email_preference.go @@ -26,17 +26,19 @@ import ( // EmailPreference is a presented email digest type EmailPreference struct { - DigestWeekly bool `json:"digest_weekly"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + InactiveReminder bool `json:"inactive_reminder"` + ProductUpdate bool `json:"product_update"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // PresentEmailPreference presents a digest func PresentEmailPreference(p database.EmailPreference) EmailPreference { ret := EmailPreference{ - DigestWeekly: p.DigestWeekly, - CreatedAt: FormatTS(p.CreatedAt), - UpdatedAt: FormatTS(p.UpdatedAt), + InactiveReminder: p.InactiveReminder, + ProductUpdate: p.ProductUpdate, + CreatedAt: FormatTS(p.CreatedAt), + UpdatedAt: FormatTS(p.UpdatedAt), } return ret diff --git a/pkg/server/testutils/main.go b/pkg/server/testutils/main.go index 3d1508a7..c2550783 100644 --- a/pkg/server/testutils/main.go +++ b/pkg/server/testutils/main.go @@ -173,10 +173,10 @@ func SetupSession(t *testing.T, user database.User) database.Session { } // SetupEmailPreferenceData creates and returns a new email frequency for a user -func SetupEmailPreferenceData(user database.User, digestWeekly bool) database.EmailPreference { +func SetupEmailPreferenceData(user database.User, inactiveReminder bool) database.EmailPreference { frequency := database.EmailPreference{ - UserID: user.ID, - DigestWeekly: digestWeekly, + UserID: user.ID, + InactiveReminder: inactiveReminder, } if err := DB.Save(&frequency).Error; err != nil { @@ -297,6 +297,14 @@ type MockEmailbackendImplementation struct { Emails []MockEmail } +// Clear clears the mock email queue +func (b *MockEmailbackendImplementation) Clear() { + b.mu.Lock() + defer b.mu.Unlock() + + b.Emails = []MockEmail{} +} + // Queue is an implementation of Backend.Queue. func (b *MockEmailbackendImplementation) Queue(subject, from string, to []string, contentType, body string) error { b.mu.Lock() diff --git a/web/src/components/Repetition/New/Repetition.scss b/pkg/server/token/main_test.go similarity index 78% rename from web/src/components/Repetition/New/Repetition.scss rename to pkg/server/token/main_test.go index b6fbbf94..a996fe0d 100644 --- a/web/src/components/Repetition/New/Repetition.scss +++ b/pkg/server/token/main_test.go @@ -16,3 +16,20 @@ * along with Dnote. If not, see . */ +package token + +import ( + "os" + "testing" + + "github.com/dnote/dnote/pkg/server/testutils" +) + +func TestMain(m *testing.M) { + testutils.InitTestDB() + + code := m.Run() + testutils.ClearData() + + os.Exit(code) +} diff --git a/pkg/server/token/token.go b/pkg/server/token/token.go new file mode 100644 index 00000000..01ff4c02 --- /dev/null +++ b/pkg/server/token/token.go @@ -0,0 +1,59 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package token + +import ( + "crypto/rand" + "encoding/base64" + + "github.com/dnote/dnote/pkg/server/database" + "github.com/jinzhu/gorm" + "github.com/pkg/errors" +) + +// generateRandom generates random bits of given length +func generateRandom(bits int) (string, error) { + b := make([]byte, bits) + + _, err := rand.Read(b) + if err != nil { + return "", errors.Wrap(err, "reading random bytes") + } + + return base64.URLEncoding.EncodeToString(b), nil +} + +// Create generates a new token in the database +func Create(db *gorm.DB, userID int, kind string) (database.Token, error) { + val, err := generateRandom(16) + if err != nil { + return database.Token{}, errors.Wrap(err, "generating random bytes") + } + + token := database.Token{ + UserID: userID, + Value: val, + Type: kind, + } + if err := db.Save(&token).Error; err != nil { + return database.Token{}, errors.Wrap(err, "creating a token for unsubscribing") + } + + return token, nil +} diff --git a/pkg/server/token/token_test.go b/pkg/server/token/token_test.go new file mode 100644 index 00000000..758a8229 --- /dev/null +++ b/pkg/server/token/token_test.go @@ -0,0 +1,65 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +package token + +import ( + "fmt" + "testing" + + "github.com/dnote/dnote/pkg/assert" + "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/testutils" + "github.com/pkg/errors" +) + +func TestCreate(t *testing.T) { + testCases := []struct { + kind string + }{ + { + kind: database.TokenTypeEmailPreference, + }, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("token type %s", tc.kind), func(t *testing.T) { + defer testutils.ClearData() + + // Set up + u := testutils.SetupUserData() + + // Execute + tok, err := Create(testutils.DB, u.ID, tc.kind) + if err != nil { + t.Fatal(errors.Wrap(err, "performing")) + } + + // Test + var count int + testutils.MustExec(t, testutils.DB.Model(&database.Token{}).Count(&count), "counting token") + assert.Equalf(t, count, 1, "error mismatch") + + var tokenRecord database.Token + testutils.MustExec(t, testutils.DB.First(&tokenRecord), "finding token") + assert.Equalf(t, tokenRecord.UserID, tok.UserID, "UserID mismatch") + assert.Equalf(t, tokenRecord.Value, tok.Value, "Value mismatch") + assert.Equalf(t, tokenRecord.Type, tok.Type, "Type mismatch") + }) + } +} diff --git a/scripts/server/makeDemoDigests/main.go b/scripts/server/makeDemoDigests/main.go deleted file mode 100644 index 07059661..00000000 --- a/scripts/server/makeDemoDigests/main.go +++ /dev/null @@ -1,254 +0,0 @@ -/* Copyright (C) 2019 Monomax Software Pty Ltd - * - * This file is part of Dnote. - * - * Dnote is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Dnote is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with Dnote. If not, see . - */ - -package main - -import ( - "github.com/dnote/dnote/pkg/server/database" - "github.com/dnote/dnote/pkg/server/dbconn" - "github.com/dnote/dnote/pkg/server/helpers" - "os" - "time" -) - -func main() { - db, err := dbconn.Open(dbconn.Config{ - Host: os.Getenv("DBHost"), - Port: os.Getenv("DBPort"), - Name: os.Getenv("DBName"), - User: os.Getenv("DBUser"), - Password: os.Getenv("DBPassword"), - }) - if err != nil { - panic(err) - } - - tx := db.Begin() - userID, err := helpers.GetDemoUserID(db) - if err != nil { - panic(err) - } - - var d1Notes []database.Note - var d2Notes []database.Note - var d3Notes []database.Note - var d4Notes []database.Note - var d5Notes []database.Note - var d6Notes []database.Note - var d7Notes []database.Note - var d8Notes []database.Note - var d9Notes []database.Note - var d10Notes []database.Note - var d11Notes []database.Note - var d12Notes []database.Note - - if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d1Notes).Error; err != nil { - tx.Rollback() - panic(err) - } - if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d2Notes).Error; err != nil { - tx.Rollback() - panic(err) - } - if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d3Notes).Error; err != nil { - tx.Rollback() - panic(err) - } - if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d4Notes).Error; err != nil { - tx.Rollback() - panic(err) - } - if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d5Notes).Error; err != nil { - tx.Rollback() - panic(err) - } - if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d6Notes).Error; err != nil { - tx.Rollback() - panic(err) - } - if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d7Notes).Error; err != nil { - tx.Rollback() - panic(err) - } - if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d8Notes).Error; err != nil { - tx.Rollback() - panic(err) - } - if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d9Notes).Error; err != nil { - tx.Rollback() - panic(err) - } - if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d10Notes).Error; err != nil { - tx.Rollback() - panic(err) - } - if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d11Notes).Error; err != nil { - tx.Rollback() - panic(err) - } - if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d12Notes).Error; err != nil { - tx.Rollback() - panic(err) - } - - d1Date := time.Date(2019, time.January, 1, 0, 0, 0, 0, time.UTC) - d1 := database.Digest{ - UserID: userID, - Notes: d1Notes, - CreatedAt: d1Date, - UpdatedAt: d1Date, - } - if err := tx.Save(&d1).Error; err != nil { - tx.Rollback() - panic(err) - } - - d2Date := time.Date(2019, time.February, 4, 0, 0, 0, 0, time.UTC) - d2 := database.Digest{ - UserID: userID, - Notes: d2Notes, - CreatedAt: d2Date, - UpdatedAt: d2Date, - } - if err := tx.Save(&d2).Error; err != nil { - tx.Rollback() - panic(err) - } - - d3Date := time.Date(2019, time.February, 12, 0, 0, 0, 0, time.UTC) - d3 := database.Digest{ - UserID: userID, - Notes: d3Notes, - CreatedAt: d3Date, - UpdatedAt: d3Date, - } - if err := tx.Save(&d3).Error; err != nil { - tx.Rollback() - panic(err) - } - - d4Date := time.Date(2019, time.May, 12, 0, 0, 0, 0, time.UTC) - d4 := database.Digest{ - UserID: userID, - Notes: d4Notes, - CreatedAt: d4Date, - UpdatedAt: d4Date, - } - if err := tx.Save(&d4).Error; err != nil { - tx.Rollback() - panic(err) - } - - d5Date := time.Date(2019, time.March, 10, 0, 0, 0, 0, time.UTC) - d5 := database.Digest{ - UserID: userID, - Notes: d5Notes, - CreatedAt: d5Date, - UpdatedAt: d5Date, - } - if err := tx.Save(&d5).Error; err != nil { - tx.Rollback() - panic(err) - } - - d6Date := time.Date(2019, time.February, 20, 0, 0, 0, 0, time.UTC) - d6 := database.Digest{ - UserID: userID, - Notes: d6Notes, - CreatedAt: d6Date, - UpdatedAt: d6Date, - } - if err := tx.Save(&d6).Error; err != nil { - tx.Rollback() - panic(err) - } - - d7Date := time.Date(2019, time.April, 24, 0, 0, 0, 0, time.UTC) - d7 := database.Digest{ - UserID: userID, - Notes: d7Notes, - CreatedAt: d7Date, - UpdatedAt: d7Date, - } - if err := tx.Save(&d7).Error; err != nil { - tx.Rollback() - panic(err) - } - - d8Date := time.Date(2018, time.December, 6, 0, 0, 0, 0, time.UTC) - d8 := database.Digest{ - UserID: userID, - Notes: d8Notes, - CreatedAt: d8Date, - UpdatedAt: d8Date, - } - if err := tx.Save(&d8).Error; err != nil { - tx.Rollback() - panic(err) - } - - d9Date := time.Date(2018, time.November, 2, 0, 0, 0, 0, time.UTC) - d9 := database.Digest{ - UserID: userID, - Notes: d9Notes, - CreatedAt: d9Date, - UpdatedAt: d9Date, - } - if err := tx.Save(&d9).Error; err != nil { - tx.Rollback() - panic(err) - } - - d10Date := time.Date(2018, time.October, 12, 0, 0, 0, 0, time.UTC) - d10 := database.Digest{ - UserID: userID, - Notes: d10Notes, - CreatedAt: d10Date, - UpdatedAt: d10Date, - } - if err := tx.Save(&d10).Error; err != nil { - tx.Rollback() - panic(err) - } - - d11Date := time.Date(2018, time.October, 1, 0, 0, 0, 0, time.UTC) - d11 := database.Digest{ - UserID: userID, - Notes: d11Notes, - CreatedAt: d11Date, - UpdatedAt: d11Date, - } - if err := tx.Save(&d11).Error; err != nil { - tx.Rollback() - panic(err) - } - - d12Date := time.Date(2018, time.May, 17, 0, 0, 0, 0, time.UTC) - d12 := database.Digest{ - UserID: userID, - Notes: d12Notes, - CreatedAt: d12Date, - UpdatedAt: d12Date, - } - if err := tx.Save(&d12).Error; err != nil { - tx.Rollback() - panic(err) - } - - tx.Commit() -} diff --git a/scripts/server/test.sh b/scripts/server/test.sh index 62497108..ff098cd1 100755 --- a/scripts/server/test.sh +++ b/scripts/server/test.sh @@ -9,7 +9,7 @@ pushd "$dir/../../pkg/server" export DNOTE_TEST_EMAIL_TEMPLATE_DIR="$dir/../../pkg/server/mailer/templates/src" function run_test { - go test ./... -cover -p 1 + go test ./... -cover -p 1 } if [ "${WATCH-false}" == true ]; then diff --git a/web/package-lock.json b/web/package-lock.json index 4c9135c6..6544487c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -6227,7 +6227,8 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true + "bundled": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -6245,11 +6246,13 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true + "bundled": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -6262,15 +6265,18 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "concat-map": { "version": "0.0.1", - "bundled": true + "bundled": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -6373,7 +6379,8 @@ }, "inherits": { "version": "2.0.4", - "bundled": true + "bundled": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -6383,6 +6390,7 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -6395,17 +6403,20 @@ "minimatch": { "version": "3.0.4", "bundled": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "0.0.8", - "bundled": true + "bundled": true, + "optional": true }, "minipass": { "version": "2.9.0", "bundled": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -6422,6 +6433,7 @@ "mkdirp": { "version": "0.5.1", "bundled": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -6502,7 +6514,8 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true + "bundled": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -6512,6 +6525,7 @@ "once": { "version": "1.4.0", "bundled": true, + "optional": true, "requires": { "wrappy": "1" } @@ -6587,7 +6601,8 @@ }, "safe-buffer": { "version": "5.1.2", - "bundled": true + "bundled": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -6617,6 +6632,7 @@ "string-width": { "version": "1.0.2", "bundled": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -6634,6 +6650,7 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -6672,11 +6689,13 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true + "bundled": true, + "optional": true }, "yallist": { "version": "3.1.1", - "bundled": true + "bundled": true, + "optional": true } } }, @@ -8328,7 +8347,8 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -8743,7 +8763,8 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -8799,6 +8820,7 @@ "version": "3.0.1", "bundled": true, "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -8842,12 +8864,14 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true } } }, @@ -15150,7 +15174,8 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -15178,6 +15203,7 @@ "version": "1.1.11", "bundled": true, "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -15192,7 +15218,8 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "concat-map": { "version": "0.0.1", @@ -15203,7 +15230,8 @@ "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -15320,7 +15348,8 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -15332,6 +15361,7 @@ "version": "1.0.0", "bundled": true, "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -15346,6 +15376,7 @@ "version": "3.0.4", "bundled": true, "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -15353,12 +15384,14 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "minipass": { "version": "2.3.5", "bundled": true, "dev": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -15377,6 +15410,7 @@ "version": "0.5.1", "bundled": true, "dev": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -15457,7 +15491,8 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -15469,6 +15504,7 @@ "version": "1.4.0", "bundled": true, "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -15554,7 +15590,8 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -15590,6 +15627,7 @@ "version": "1.0.2", "bundled": true, "dev": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -15609,6 +15647,7 @@ "version": "3.0.1", "bundled": true, "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -15652,12 +15691,14 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true } } }, diff --git a/web/src/components/App/index.tsx b/web/src/components/App/index.tsx index 3d4b39af..b2305d84 100644 --- a/web/src/components/App/index.tsx +++ b/web/src/components/App/index.tsx @@ -64,8 +64,10 @@ function useFetchData() { }); useEffect(() => { - dispatch(getCurrentUser()); - }, [dispatch]); + if (!user.isFetched) { + dispatch(getCurrentUser()); + } + }, [dispatch, user.isFetched]); useEffect(() => { if (user.isFetched) { diff --git a/web/src/components/Common/EmailPreferenceForm.tsx b/web/src/components/Common/EmailPreferenceForm.tsx index 0aa5f2ec..3f885683 100644 --- a/web/src/components/Common/EmailPreferenceForm.tsx +++ b/web/src/components/Common/EmailPreferenceForm.tsx @@ -29,7 +29,7 @@ const digestWeekly = 'weekly'; const digestNever = 'never'; function getDigestFrequency(emailPreference: any): string { - if (emailPreference.digest_weekly) { + if (emailPreference.inactive_reminder) { return digestWeekly; } @@ -64,7 +64,7 @@ const EmailPreferenceForm: React.FunctionComponent = ({ setInProgress(true); services.users - .updateEmailPreference({ digestFrequency, token }) + .updateEmailPreference({ inactiveReminder: true, token }) .then(updatedPreference => { dispatch(receiveEmailPreference(updatedPreference)); diff --git a/web/src/components/EmailPreference/EmailPreference.scss b/web/src/components/EmailPreference/EmailPreference.scss new file mode 100644 index 00000000..b2d02e5d --- /dev/null +++ b/web/src/components/EmailPreference/EmailPreference.scss @@ -0,0 +1,60 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +@import '../App/responsive'; +@import '../App/theme'; +@import '../App/rem'; +@import '../App/font'; + +.wrapper { + text-align: center; + height: 100vh; + padding: rem(52px) 0; + background: $light-gray; +} + +.heading { + @include font-size('2x-large'); + color: $black; + font-weight: 300; + margin-top: rem(16px); +} + +.body { + text-align: left; + padding: rem(20px) rem(28px); + margin-top: rem(20px); + max-width: rem(700px); + margin-left: auto; + margin-right: auto; + background-color: #fff; +} + +.footer { + @include font-size('small'); + text-align: center; + margin-top: rem(20px); + + a { + color: $gray; + } +} + +.flash { + margin-bottom: rem(8px); +} diff --git a/web/src/components/EmailPreference/index.tsx b/web/src/components/EmailPreference/index.tsx new file mode 100644 index 00000000..cb08ccb6 --- /dev/null +++ b/web/src/components/EmailPreference/index.tsx @@ -0,0 +1,112 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React, { useState, useEffect } from 'react'; +import classnames from 'classnames'; +import { RouteComponentProps } from 'react-router'; +import { Link, withRouter } from 'react-router-dom'; +import Helmet from 'react-helmet'; + +import { parseSearchString } from 'jslib/helpers/url'; +import { getHomePath, getLoginPath } from 'web/libs/paths'; +import Form from '../Settings/Notifications/Form'; +import { getEmailPreference } from '../../store/auth'; +import Logo from '../Icons/Logo'; +import { useSelector, useDispatch } from '../../store'; +import Flash from '../Common/Flash'; +import styles from './EmailPreference.scss'; + +interface Props extends RouteComponentProps {} + +const EmailPreference: React.FunctionComponent = ({ location }) => { + const { token } = parseSearchString(location.search); + + const [successMsg, setSuccessMsg] = useState(''); + const [failureMsg, setFailureMsg] = useState(''); + + const dispatch = useDispatch(); + + const { emailPreference } = useSelector(state => { + return { + emailPreference: state.auth.emailPreference + }; + }); + + useEffect(() => { + if (!emailPreference.isFetched) { + dispatch(getEmailPreference(token)); + } + }, [dispatch, emailPreference.isFetched, token]); + + return ( +
+ + Email Preferences + + + + + +

Dnote email preferences

+ +
+
+ + Error fetching email preference: {emailPreference.errorMessage}. + Please try again after logging in. + + + + {successMsg} + + + + {failureMsg} + + + {emailPreference.isFetched && ( +
+ )} +
+
+ Back to Dnote home +
+
+
+ ); +}; + +export default withRouter(EmailPreference); diff --git a/web/src/components/Settings/Notifications/Form.scss b/web/src/components/Settings/Notifications/Form.scss new file mode 100644 index 00000000..42c3ab17 --- /dev/null +++ b/web/src/components/Settings/Notifications/Form.scss @@ -0,0 +1,45 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +@import '../../App/rem'; +@import '../../App/font'; +@import '../../App/theme'; + +.heading { + font-weight: 600; + @include font-size('regular'); +} + +.subtext { + @include font-size('small'); + color: $gray; + margin-top: rem(4px); +} + +.actions { + text-align: right; +} + +.section ~ .section { + margin-top: rem(16px); +} + +.label { + margin-left: rem(8px); + vertical-align: middle; +} diff --git a/web/src/components/Settings/Notifications/Form.tsx b/web/src/components/Settings/Notifications/Form.tsx new file mode 100644 index 00000000..90bd3f75 --- /dev/null +++ b/web/src/components/Settings/Notifications/Form.tsx @@ -0,0 +1,155 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React, { useState, useReducer } from 'react'; + +import services from 'web/libs/services'; +import { EmailPrefData } from 'jslib/operations/types'; +import Button from '../../Common/Button'; +import { receiveEmailPreference } from '../../../store/auth'; +import { useDispatch } from '../../../store'; +import styles from './Form.scss'; + +enum Action { + setInactiveReminder, + setProductUpdate +} + +function formReducer(state, action): EmailPrefData { + switch (action.type) { + case Action.setInactiveReminder: + return { + ...state, + inactiveReminder: action.data + }; + case Action.setProductUpdate: + return { + ...state, + productUpdate: action.data + }; + default: + return state; + } +} + +interface Props { + emailPreference: EmailPrefData; + setSuccessMsg: (string) => void; + setFailureMsg: (string) => void; + token?: string; +} + +const Form: React.FunctionComponent = ({ + emailPreference, + setSuccessMsg, + setFailureMsg, + token +}) => { + const [inProgress, setInProgress] = useState(false); + const dispatch = useDispatch(); + + const [formState, formDispatch] = useReducer(formReducer, emailPreference); + + function handleSubmit(e) { + e.preventDefault(); + + setSuccessMsg(''); + setFailureMsg(''); + setInProgress(true); + + services.users + .updateEmailPreference({ + inactiveReminder: formState.inactiveReminder, + productUpdate: formState.productUpdate, + token + }) + .then(updatedPreference => { + dispatch(receiveEmailPreference(updatedPreference)); + + setSuccessMsg('Updated email preference'); + setInProgress(false); + }) + .catch(err => { + setFailureMsg(`Failed to update. Error: ${err.message}`); + setInProgress(false); + }); + } + + return ( + +
+

Alerts

+

Email me when:

+
    +
  • + { + const { checked } = e.target; + + formDispatch({ + type: Action.setInactiveReminder, + data: checked + }); + }} + /> + +
  • +
+
+ +
+

News

+ +

Email me about:

+
    +
  • + { + const { checked } = e.target; + + formDispatch({ + type: Action.setProductUpdate, + data: checked + }); + }} + /> + +
  • +
+
+ +
+ +
+ + ); +}; + +export default Form; diff --git a/web/src/components/Repetition/Edit/Repetition.scss b/web/src/components/Settings/Notifications/Notifications.scss similarity index 85% rename from web/src/components/Repetition/Edit/Repetition.scss rename to web/src/components/Settings/Notifications/Notifications.scss index b6fbbf94..d818f925 100644 --- a/web/src/components/Repetition/Edit/Repetition.scss +++ b/web/src/components/Settings/Notifications/Notifications.scss @@ -16,3 +16,10 @@ * along with Dnote. If not, see . */ +@import '../../App/rem'; +@import '../../App/font'; +@import '../../App/theme'; + +.body { + padding: rem(16px) rem(20px); +} diff --git a/web/src/components/Settings/Notifications/index.tsx b/web/src/components/Settings/Notifications/index.tsx new file mode 100644 index 00000000..09363693 --- /dev/null +++ b/web/src/components/Settings/Notifications/index.tsx @@ -0,0 +1,110 @@ +/* Copyright (C) 2019 Monomax Software Pty Ltd + * + * This file is part of Dnote. + * + * Dnote is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Dnote is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Dnote. If not, see . + */ + +import React, { useState, useEffect } from 'react'; +import Helmet from 'react-helmet'; +import { withRouter, RouteComponentProps } from 'react-router'; + +import { parseSearchString } from 'jslib/helpers/url'; +import Flash from '../../Common/Flash'; +import { useSelector } from '../../../store'; +import Form from './Form'; +import settingsStyles from '../Settings.scss'; +import { useDispatch } from '../../../store'; +import { getEmailPreference } from '../../../store/auth'; +import styles from './Notifications.scss'; + +interface Props extends RouteComponentProps {} + +const Notifications: React.FunctionComponent = ({ location }) => { + const [successMsg, setSuccessMsg] = useState(''); + const [failureMsg, setFailureMsg] = useState(''); + const dispatch = useDispatch(); + + const { token } = parseSearchString(location.search); + + const { emailPreference } = useSelector(state => { + return { + emailPreference: state.auth.emailPreference + }; + }); + + useEffect(() => { + if (!emailPreference.isFetched) { + dispatch(getEmailPreference()); + } + }, [dispatch, emailPreference.isFetched]); + + return ( +
+ + Notifications + + +

Notifications

+ + { + setSuccessMsg(''); + }} + > + {successMsg} + + + { + setFailureMsg(''); + }} + > + {failureMsg} + + + + {emailPreference.errorMessage} + + +
+
+

+ Email Preferences +

+ +
+ {emailPreference.isFetched ? ( +
+ ) : ( + Loading email preferences... + )} +
+
+
+
+ ); +}; + +export default withRouter(Notifications); diff --git a/web/src/components/Settings/Sidebar.tsx b/web/src/components/Settings/Sidebar.tsx index 1b7f948d..11df6523 100644 --- a/web/src/components/Settings/Sidebar.tsx +++ b/web/src/components/Settings/Sidebar.tsx @@ -47,6 +47,15 @@ const Sidebar: React.FunctionComponent = () => { Billing +
  • + + Notifications + +
  • ; } + if (section === SettingSections.notifications) { + return ; + } return
    Not found
    ; } diff --git a/web/src/libs/paths.ts b/web/src/libs/paths.ts index 82fc0479..daec57e4 100644 --- a/web/src/libs/paths.ts +++ b/web/src/libs/paths.ts @@ -41,6 +41,7 @@ export const repetitionsPathDef = '/repetition'; export const repetitionPathDef = '/repetition/:repetitionUUID'; export const newRepetitionRulePathDef = '/repetition/new'; export const editRepetitionRulePathDef = '/repetition/:repetitionUUID/edit'; +export const emailPreferencePathDef = '/email-preferences'; // layout definitions export const noHeaderPaths = [ @@ -50,7 +51,8 @@ export const noHeaderPaths = [ verifyEmailPathDef, classicMigrationPathDef, passwordResetRequestPathDef, - passwordResetConfirmPathDef + passwordResetConfirmPathDef, + emailPreferencePathDef ]; export const noFooterPaths = [ loginPathDef, @@ -61,7 +63,8 @@ export const noFooterPaths = [ verifyEmailPathDef, classicMigrationPathDef, passwordResetRequestPathDef, - passwordResetConfirmPathDef + passwordResetConfirmPathDef, + emailPreferencePathDef ]; export const subscriptionPaths = [ subscriptionsPathDef, @@ -211,6 +214,7 @@ export function getPasswordResetConfirmPath(searchObj = {}): Location { export enum SettingSections { account = 'account', billing = 'billing', + notifications = 'notifications', about = 'about' } diff --git a/web/src/routes.tsx b/web/src/routes.tsx index 96cf3b88..a6b33386 100644 --- a/web/src/routes.tsx +++ b/web/src/routes.tsx @@ -42,6 +42,9 @@ import NewRepetition from './components/Repetition/New'; import EditRepetition from './components/Repetition/Edit'; import PasswordResetRequest from './components/PasswordReset/Request'; import PasswordResetConfirm from './components/PasswordReset/Confirm'; +import EmailPreference from './components/EmailPreference'; + +// paths import { notePathDef, homePathDef, @@ -61,7 +64,8 @@ import { classicMigrationPathDef, repetitionsPathDef, newRepetitionRulePathDef, - editRepetitionRulePathDef + editRepetitionRulePathDef, + emailPreferencePathDef } from './libs/paths'; const AuthenticatedHome = userOnly(Home); @@ -176,6 +180,11 @@ const routes = [ exact: true, component: AuthenticatedEditRepetition }, + { + path: emailPreferencePathDef, + exact: true, + component: EmailPreference + }, { component: NotFound } diff --git a/web/src/store/auth/actions.ts b/web/src/store/auth/actions.ts index df27bdf1..a4e52f0b 100644 --- a/web/src/store/auth/actions.ts +++ b/web/src/store/auth/actions.ts @@ -17,7 +17,8 @@ */ import services from 'web/libs/services'; -import { UserData, EmailPrefData, SourceData, SubscriptionData } from './type'; +import { EmailPrefData } from 'jslib/operations/types'; +import { UserData, SourceData, SubscriptionData } from './type'; import { ThunkAction } from '../types'; import { diff --git a/web/src/store/auth/reducers.ts b/web/src/store/auth/reducers.ts index 7b6be4e7..68c5295a 100644 --- a/web/src/store/auth/reducers.ts +++ b/web/src/store/auth/reducers.ts @@ -16,12 +16,12 @@ * along with Dnote. If not, see . */ +import { EmailPrefData } from 'jslib/operations/types'; import { RemoteData } from '../types'; import { AuthState, AuthActionType, UserData, - EmailPrefData, SubscriptionData, SourceData, RECEIVE_EMAIL_PREFERENCE, @@ -56,7 +56,8 @@ export const initialState: AuthState = { isFetching: false, isFetched: false, data: { - digestWeekly: false + inactiveReminder: false, + productUpdate: false }, errorMessage: '' }, diff --git a/web/src/store/auth/type.ts b/web/src/store/auth/type.ts index 4ca72141..9045018b 100644 --- a/web/src/store/auth/type.ts +++ b/web/src/store/auth/type.ts @@ -16,6 +16,7 @@ * along with Dnote. If not, see . */ +import { EmailPrefData } from 'jslib/operations/types'; import { RemoteData } from '../types'; export interface UserData { @@ -27,10 +28,6 @@ export interface UserData { classic: boolean; } -export interface EmailPrefData { - digestWeekly: boolean; -} - // TODO: type export type SubscriptionData = any; export type SourceData = any;