mirror of
https://github.com/dnote/dnote
synced 2026-03-14 22:45:50 +01:00
Alert when we stop learning (#375)
* Send password reset alert * Send inactive reminder * Send subscription confirmation email
This commit is contained in:
parent
4caf82a998
commit
3152ca770b
61 changed files with 2013 additions and 674 deletions
|
|
@ -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
|
||||
|
|
|
|||
220
browser/package-lock.json
generated
220
browser/package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
41
jslib/package-lock.json
generated
41
jslib/package-lock.json
generated
|
|
@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ export interface NoteData {
|
|||
}
|
||||
|
||||
export interface EmailPrefData {
|
||||
digestWeekly: boolean;
|
||||
inactiveReminder: boolean;
|
||||
productUpdate: boolean;
|
||||
}
|
||||
|
||||
export interface UserData {
|
||||
|
|
|
|||
|
|
@ -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<EmailPrefData> => {
|
||||
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<GetEmailPreferenceResponse>(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<EmailPrefData>(endpoint);
|
||||
return client.get<GetEmailPreferenceResponse>(endpoint).then(res => {
|
||||
return {
|
||||
inactiveReminder: res.inactive_reminder,
|
||||
productUpdate: res.product_update
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
getMe: (): Promise<UserData> => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
A simple command line interface for Dnote.
|
||||
|
||||

|
||||

|
||||
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
|
|
|
|||
204
pkg/server/job/remind/inactive.go
Normal file
204
pkg/server/job/remind/inactive.go
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
194
pkg/server/job/remind/inactive_test.go
Normal file
194
pkg/server/job/remind/inactive_test.go
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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")
|
||||
}
|
||||
35
pkg/server/job/remind/main_test.go
Normal file
35
pkg/server/job/remind/main_test.go
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
9
pkg/server/mailer/templates/src/inactive.txt
Normal file
9
pkg/server/mailer/templates/src/inactive.txt
Normal file
|
|
@ -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 }}
|
||||
9
pkg/server/mailer/templates/src/reset_password_alert.txt
Normal file
9
pkg/server/mailer/templates/src/reset_password_alert.txt
Normal file
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -16,3 +16,20 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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)
|
||||
}
|
||||
59
pkg/server/token/token.go
Normal file
59
pkg/server/token/token.go
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
65
pkg/server/token/token_test.go
Normal file
65
pkg/server/token/token_test.go
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
89
web/package-lock.json
generated
89
web/package-lock.json
generated
|
|
@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -64,8 +64,10 @@ function useFetchData() {
|
|||
});
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(getCurrentUser());
|
||||
}, [dispatch]);
|
||||
if (!user.isFetched) {
|
||||
dispatch(getCurrentUser());
|
||||
}
|
||||
}, [dispatch, user.isFetched]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user.isFetched) {
|
||||
|
|
|
|||
|
|
@ -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<Props> = ({
|
|||
setInProgress(true);
|
||||
|
||||
services.users
|
||||
.updateEmailPreference({ digestFrequency, token })
|
||||
.updateEmailPreference({ inactiveReminder: true, token })
|
||||
.then(updatedPreference => {
|
||||
dispatch(receiveEmailPreference(updatedPreference));
|
||||
|
||||
|
|
|
|||
60
web/src/components/EmailPreference/EmailPreference.scss
Normal file
60
web/src/components/EmailPreference/EmailPreference.scss
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@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);
|
||||
}
|
||||
112
web/src/components/EmailPreference/index.tsx
Normal file
112
web/src/components/EmailPreference/index.tsx
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Props> = ({ 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 (
|
||||
<div className={styles.wrapper}>
|
||||
<Helmet>
|
||||
<title>Email Preferences</title>
|
||||
</Helmet>
|
||||
|
||||
<Link to="/">
|
||||
<Logo fill="#252833" width={60} height={60} />
|
||||
</Link>
|
||||
<h1 className={styles.heading}>Dnote email preferences</h1>
|
||||
|
||||
<div className="container">
|
||||
<div className={styles.body}>
|
||||
<Flash
|
||||
kind="danger"
|
||||
wrapperClassName={styles.flash}
|
||||
when={emailPreference.errorMessage !== ''}
|
||||
>
|
||||
Error fetching email preference: {emailPreference.errorMessage}.
|
||||
Please try again after <Link to={getLoginPath()}>logging in</Link>.
|
||||
</Flash>
|
||||
|
||||
<Flash
|
||||
id="T-notifications-success"
|
||||
kind="success"
|
||||
wrapperClassName={classnames(styles.flash, 'T-success')}
|
||||
when={successMsg !== ''}
|
||||
>
|
||||
{successMsg}
|
||||
</Flash>
|
||||
|
||||
<Flash
|
||||
kind="danger"
|
||||
wrapperClassName={styles.flash}
|
||||
when={failureMsg !== ''}
|
||||
>
|
||||
{failureMsg}
|
||||
</Flash>
|
||||
|
||||
{emailPreference.isFetched && (
|
||||
<Form
|
||||
token={token}
|
||||
emailPreference={emailPreference.data}
|
||||
setSuccessMsg={setSuccessMsg}
|
||||
setFailureMsg={setFailureMsg}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<Link to={getHomePath()}>Back to Dnote home</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default withRouter(EmailPreference);
|
||||
45
web/src/components/Settings/Notifications/Form.scss
Normal file
45
web/src/components/Settings/Notifications/Form.scss
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@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;
|
||||
}
|
||||
155
web/src/components/Settings/Notifications/Form.tsx
Normal file
155
web/src/components/Settings/Notifications/Form.tsx
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Props> = ({
|
||||
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 (
|
||||
<form id="T-notifications-form" onSubmit={handleSubmit}>
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.heading}>Alerts</h3>
|
||||
<p className={styles.subtext}>Email me when:</p>
|
||||
<ul className="list-unstyled">
|
||||
<li>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="inactive-reminder"
|
||||
checked={formState.inactiveReminder}
|
||||
onChange={e => {
|
||||
const { checked } = e.target;
|
||||
|
||||
formDispatch({
|
||||
type: Action.setInactiveReminder,
|
||||
data: checked
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label className={styles.label} htmlFor="inactive-reminder">
|
||||
I stop learning new things
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.heading}>News</h3>
|
||||
|
||||
<p className={styles.subtext}>Email me about:</p>
|
||||
<ul className="list-unstyled">
|
||||
<li>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="product-update"
|
||||
checked={formState.productUpdate}
|
||||
onChange={e => {
|
||||
const { checked } = e.target;
|
||||
|
||||
formDispatch({
|
||||
type: Action.setProductUpdate,
|
||||
data: checked
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label className={styles.label} htmlFor="product-update">
|
||||
New features and updates
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button type="submit" kind="first" size="normal" isBusy={inProgress}>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Form;
|
||||
|
|
@ -16,3 +16,10 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@import '../../App/rem';
|
||||
@import '../../App/font';
|
||||
@import '../../App/theme';
|
||||
|
||||
.body {
|
||||
padding: rem(16px) rem(20px);
|
||||
}
|
||||
110
web/src/components/Settings/Notifications/index.tsx
Normal file
110
web/src/components/Settings/Notifications/index.tsx
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Props> = ({ 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 (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>Notifications</title>
|
||||
</Helmet>
|
||||
|
||||
<h1 className="sr-only">Notifications</h1>
|
||||
|
||||
<Flash
|
||||
id="T-notifications-success"
|
||||
when={successMsg !== ''}
|
||||
kind="success"
|
||||
onDismiss={() => {
|
||||
setSuccessMsg('');
|
||||
}}
|
||||
>
|
||||
{successMsg}
|
||||
</Flash>
|
||||
|
||||
<Flash
|
||||
when={failureMsg !== ''}
|
||||
kind="danger"
|
||||
onDismiss={() => {
|
||||
setFailureMsg('');
|
||||
}}
|
||||
>
|
||||
{failureMsg}
|
||||
</Flash>
|
||||
|
||||
<Flash when={emailPreference.errorMessage !== ''} kind="danger">
|
||||
{emailPreference.errorMessage}
|
||||
</Flash>
|
||||
|
||||
<div className={settingsStyles.wrapper}>
|
||||
<section className={settingsStyles.section}>
|
||||
<h2 className={settingsStyles['section-heading']}>
|
||||
Email Preferences
|
||||
</h2>
|
||||
|
||||
<div className={styles.body}>
|
||||
{emailPreference.isFetched ? (
|
||||
<Form
|
||||
emailPreference={emailPreference.data}
|
||||
setSuccessMsg={setSuccessMsg}
|
||||
setFailureMsg={setFailureMsg}
|
||||
token={token}
|
||||
/>
|
||||
) : (
|
||||
<span>Loading email preferences...</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default withRouter(Notifications);
|
||||
|
|
@ -47,6 +47,15 @@ const Sidebar: React.FunctionComponent<Props> = () => {
|
|||
Billing
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink
|
||||
className={styles.item}
|
||||
activeClassName={styles.active}
|
||||
to={getSettingsPath(SettingSections.notifications)}
|
||||
>
|
||||
Notifications
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink
|
||||
className={styles.item}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { SettingSections } from 'web/libs/paths';
|
|||
import Account from './Account';
|
||||
import Sidebar from './Sidebar';
|
||||
import About from './About';
|
||||
import Notifications from './Notifications';
|
||||
import Billing from './Billing';
|
||||
import styles from './Settings.scss';
|
||||
|
||||
|
|
@ -38,6 +39,9 @@ function renderContent(section: string): React.ReactNode {
|
|||
if (section === SettingSections.about) {
|
||||
return <About />;
|
||||
}
|
||||
if (section === SettingSections.notifications) {
|
||||
return <Notifications />;
|
||||
}
|
||||
|
||||
return <div>Not found</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -16,12 +16,12 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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: ''
|
||||
},
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue