diff --git a/account/src/lib/couch-db.ts b/account/src/lib/couch-db.ts index 3c0b953..dcb2994 100644 --- a/account/src/lib/couch-db.ts +++ b/account/src/lib/couch-db.ts @@ -25,7 +25,10 @@ export function updateLastLogin(user: T.LingdocsUser): T.LingdocsUser { }; } -function processAPIResponse(user: T.LingdocsUser, response: DocumentInsertResponse): T.LingdocsUser | undefined { +function processAPIResponse( + user: T.LingdocsUser, + response: DocumentInsertResponse +): T.LingdocsUser | undefined { if (response.ok !== true) return undefined; return { ...user, @@ -34,31 +37,44 @@ function processAPIResponse(user: T.LingdocsUser, response: DocumentInsertRespon }; } -export async function getLingdocsUser(field: "email" | "userId" | "githubId" | "googleId" | "twitterId", value: string): Promise { - const user = await usersDb.find({ - selector: field === "githubId" - ? { github: { id: value }} +export async function getLingdocsUser( + field: "email" | "userId" | "githubId" | "googleId" | "twitterId", + value: string +): Promise { + const user = await usersDb.find({ + selector: + field === "githubId" + ? { github: { id: value } } : field === "googleId" - ? { google: { id: value }} + ? { google: { id: value } } : field === "twitterId" - ? { twitter: { id: value }} + ? { twitter: { id: value } } : { [field]: value }, - }); - if (!user.docs.length) { - return undefined; - } - return user.docs[0] as T.LingdocsUser; + }); + if (!user.docs.length) { + return undefined; + } + return user.docs[0] as T.LingdocsUser; } export async function getAllLingdocsUsers(): Promise { const users = await usersDb.find({ - selector: { userId: { $exists: true }}, + selector: { userId: { $exists: true } }, limit: 5000, }); return users.docs as T.LingdocsUser[]; } -export async function insertLingdocsUser(user: T.LingdocsUser): Promise { +export async function getAllFeedback(): Promise { + const res = await feedbackDb.find({ + selector: { doc: { $exists: true } }, + }); + return res.docs as any[]; +} + +export async function insertLingdocsUser( + user: T.LingdocsUser +): Promise { try { const res = await usersDb.insert(user); const newUser = processAPIResponse(user, res); @@ -66,7 +82,7 @@ export async function insertLingdocsUser(user: T.LingdocsUser): Promise { export async function deleteCouchDbAuthUser(uuid: T.UUID): Promise { const authUsers = nano.db.use("_users"); - const user = await authUsers.find({ selector: { name: uuid }}); + const user = await authUsers.find({ selector: { name: uuid } }); if (!user.docs.length) return; const u = user.docs[0]; await authUsers.destroy(u._id, u._rev); } -export async function updateLingdocsUser(uuid: T.UUID, toUpdate: - // TODO: OR USE REDUCER?? - { name: string } | - { name?: string, email: string, emailVerified: T.Hash } | - { email: string, emailVerified: true } | - { emailVerified: T.Hash } | - { emailVerified: true } | - { password: T.Hash } | - { google: T.GoogleProfile | undefined } | - { github: T.GitHubProfile | undefined } | - { twitter: T.TwitterProfile | undefined } | - { - passwordReset: { - tokenHash: T.Hash, - requestedOn: T.TimeStamp, - }, - } | - { - level: "student", - wordlistDbName: T.WordlistDbName, - couchDbPassword: T.UserDbPassword, - upgradeToStudentRequest: undefined, - subscription?: T.StripeSubscription, - } | - { - level: "basic", - wordlistDbName: undefined, - couchDbPassword: undefined, - upgradeToStudentRequest: undefined, - subscription: undefined, - } | - { upgradeToStudentRequest: "waiting" } | - { upgradeToStudentRequest: "denied" } | - { tests: T.TestResult[] } +export async function updateLingdocsUser( + uuid: T.UUID, + toUpdate: // TODO: OR USE REDUCER?? + | { name: string } + | { name?: string; email: string; emailVerified: T.Hash } + | { email: string; emailVerified: true } + | { emailVerified: T.Hash } + | { emailVerified: true } + | { password: T.Hash } + | { google: T.GoogleProfile | undefined } + | { github: T.GitHubProfile | undefined } + | { twitter: T.TwitterProfile | undefined } + | { + passwordReset: { + tokenHash: T.Hash; + requestedOn: T.TimeStamp; + }; + } + | { + level: "student"; + wordlistDbName: T.WordlistDbName; + couchDbPassword: T.UserDbPassword; + upgradeToStudentRequest: undefined; + subscription?: T.StripeSubscription; + } + | { + level: "basic"; + wordlistDbName: undefined; + couchDbPassword: undefined; + upgradeToStudentRequest: undefined; + subscription: undefined; + } + | { upgradeToStudentRequest: "waiting" } + | { upgradeToStudentRequest: "denied" } + | { tests: T.TestResult[] } ): Promise { const user = await getLingdocsUser("userId", uuid); if (!user) throw new Error("unable to update - user not found " + uuid); @@ -145,7 +162,9 @@ export async function updateLingdocsUser(uuid: T.UUID, toUpdate: }); } -export async function addCouchDbAuthUser(uuid: T.UUID): Promise<{ password: T.UserDbPassword, userDbName: T.WordlistDbName }> { +export async function addCouchDbAuthUser( + uuid: T.UUID +): Promise<{ password: T.UserDbPassword; userDbName: T.WordlistDbName }> { const password = generateWordlistDbPassword(); const userDbName = getWordlistDbName(uuid); const usersDb = nano.db.use("_users"); @@ -199,43 +218,47 @@ export function getWordlistDbName(uid: T.UUID): T.WordlistDbName { function generateWordlistDbPassword(): T.UserDbPassword { function makeChunk(): string { - return Math.random().toString(36).slice(2) + return Math.random().toString(36).slice(2); } - const password = new Array(4).fill(0).reduce((acc: string): string => ( - acc + makeChunk() - ), ""); + const password = new Array(4) + .fill(0) + .reduce((acc: string): string => acc + makeChunk(), ""); return password as T.UserDbPassword; } function stringToHex(str: string) { - const arr1 = []; - for (let n = 0, l = str.length; n < l; n ++) { - const hex = Number(str.charCodeAt(n)).toString(16); - arr1.push(hex); - } - return arr1.join(''); + const arr1 = []; + for (let n = 0, l = str.length; n < l; n++) { + const hex = Number(str.charCodeAt(n)).toString(16); + arr1.push(hex); + } + return arr1.join(""); } /** * Adds new tests to a users record, only keeping up to amountToKeep records of the most * recent repeat passes/fails - * + * * @param existing - the existing tests in a users record * @param newResults - the tests to be added to a users record * @param amountToKeep - the amount of repeat tests to keep (defaults to 2) */ -function addNewTests(existing: Readonly, toAdd: T.TestResult[], amountToKeep = 2): T.TestResult[] { +function addNewTests( + existing: Readonly, + toAdd: T.TestResult[], + amountToKeep = 2 +): T.TestResult[] { const tests = [...existing]; // check to make sure that we're only adding test results that are not already added - const newTests = toAdd.filter((t) => !tests.some(x => x.time === t.time)); + const newTests = toAdd.filter((t) => !tests.some((x) => x.time === t.time)); newTests.forEach((nt) => { - const repeats = tests.filter(x => ((x.id === nt.id)) && (x.done === nt.done)); - if (repeats.length > (amountToKeep - 1)) { + const repeats = tests.filter((x) => x.id === nt.id && x.done === nt.done); + if (repeats.length > amountToKeep - 1) { // already have enough repeat passes saved, remove the oldest one - const i = tests.findIndex(x => x.time === repeats[0].time); + const i = tests.findIndex((x) => x.time === repeats[0].time); if (i > -1) tests.splice(i, 1); } tests.push(nt); }); return tests; -} \ No newline at end of file +} diff --git a/account/src/routers/auth-router.ts b/account/src/routers/auth-router.ts index d7aae05..47da142 100644 --- a/account/src/routers/auth-router.ts +++ b/account/src/routers/auth-router.ts @@ -2,25 +2,25 @@ import { Router } from "express"; import { PassportStatic } from "passport"; import { deleteLingdocsUser, + getAllFeedback, getAllLingdocsUsers, getLingdocsUser, updateLingdocsUser, } from "../lib/couch-db"; -import { createNewUser, canRemoveOneOutsideProvider, downgradeUser } from "../lib/user-utils"; +import { + createNewUser, + canRemoveOneOutsideProvider, + downgradeUser, +} from "../lib/user-utils"; import { getHash, getURLToken, compareToHash, getEmailTokenAndHash, } from "../lib/password-utils"; -import { - upgradeUser, - denyUserUpgradeRequest, -} from "../lib/user-utils"; +import { upgradeUser, denyUserUpgradeRequest } from "../lib/user-utils"; import { validateReCaptcha } from "../lib/recaptcha"; -import { - getTimestamp, -} from "../lib/time-utils"; +import { getTimestamp } from "../lib/time-utils"; import { sendPasswordResetEmail, sendVerificationEmail, @@ -56,10 +56,16 @@ const authRouter = (passport: PassportStatic) => { const name = req.body.name as string; const email = req.body.email as string; if (email !== req.user.email) { - if (name !== req.user.name) await updateLingdocsUser(req.user.userId, { name }); - const withSameEmail = (email !== "") && await getLingdocsUser("email", email); + if (name !== req.user.name) + await updateLingdocsUser(req.user.userId, { name }); + const withSameEmail = + email !== "" && (await getLingdocsUser("email", email)); if (withSameEmail) { - return res.render(page, { user: { ...req.user, email }, error: "email taken", removeProviderOption: canRemoveOneOutsideProvider(req.user) }); + return res.render(page, { + user: { ...req.user, email }, + error: "email taken", + removeProviderOption: canRemoveOneOutsideProvider(req.user), + }); } // TODO: ABSTRACT THE PROCESS OF GETTING A NEW EMAIL TOKEN AND MAILING! const { token, hash } = await getEmailTokenAndHash(); @@ -69,11 +75,19 @@ const authRouter = (passport: PassportStatic) => { emailVerified: hash, }); sendVerificationEmail(updated, token).catch(console.error); - return res.render(page, { user: updated, error: null, removeProviderOption: canRemoveOneOutsideProvider(req.user) }); + return res.render(page, { + user: updated, + error: null, + removeProviderOption: canRemoveOneOutsideProvider(req.user), + }); } const updated = await updateLingdocsUser(req.user.userId, { name }); // need to do this because sometimes the update seems slow? - return res.render(page, { user: updated, error: null, removeProviderOption: canRemoveOneOutsideProvider(req.user) }); + return res.render(page, { + user: updated, + error: null, + removeProviderOption: canRemoveOneOutsideProvider(req.user), + }); }); router.post("/login", async (req, res, next) => { @@ -83,22 +97,26 @@ const authRouter = (passport: PassportStatic) => { return res.render("login", { recaptcha: "fail", inProd }); } } - passport.authenticate("local", (err, user: T.LingdocsUser | undefined, info) => { - if (err) throw err; - if (!user && info.message === "email not found") { - return res.send({ ok: false, newSignup: true }); + passport.authenticate( + "local", + (err, user: T.LingdocsUser | undefined, info) => { + if (err) throw err; + if (!user && info.message === "email not found") { + return res.send({ ok: false, newSignup: true }); + } + if (!user) + res.send({ + ok: false, + message: "Incorrect password", + }); + else { + req.logIn(user, (err) => { + if (err) return next(err); + res.send({ ok: true, user }); + }); + } } - if (!user) res.send({ - ok: false, - message: "Incorrect password", - }); - else { - req.logIn(user, (err) => { - if (err) return next(err); - res.send({ ok: true, user }); - }); - } - })(req, res, next); + )(req, res, next); }); router.get( @@ -106,31 +124,42 @@ const authRouter = (passport: PassportStatic) => { passport.authenticate("google", { // @ts-ignore - needed for getting refreshToken] accessType: "offline", - scope: ["openid", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"], + scope: [ + "openid", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + ], }) ); - router.get('/github', passport.authenticate("github", { - scope: ["read:user", "user:email"], - })); - router.get('/twitter', passport.authenticate("twitter")); + router.get( + "/github", + passport.authenticate("github", { + scope: ["read:user", "user:email"], + }) + ); + router.get("/twitter", passport.authenticate("twitter")); - // all callback and remove routes/functions are the same for each provider + // all callback and remove routes/functions are the same for each provider outsideProviders.forEach((provider) => { router.get( `/${provider}/callback`, - passport.authenticate(provider, { successRedirect: '/user', failureRedirect: '/' }), + passport.authenticate(provider, { + successRedirect: "/user", + failureRedirect: "/", + }) ); router.post(`/${provider}/remove`, async (req, res, next) => { try { if (!req.user) return next("user not found"); - if (!canRemoveOneOutsideProvider(req.user)) return res.redirect("/user"); + if (!canRemoveOneOutsideProvider(req.user)) + return res.redirect("/user"); await updateLingdocsUser( req.user.userId, - // @ts-ignore - shouldn't need this + // @ts-ignore - shouldn't need this { [provider]: undefined } ); return res.redirect("/user"); - } catch(e) { + } catch (e) { next(e); } }); @@ -141,31 +170,50 @@ const authRouter = (passport: PassportStatic) => { const { email, password, name } = req.body; const existingUser = await getLingdocsUser("email", email); if (existingUser) return res.send("User Already Exists"); - const user = await createNewUser({ strategy: "local", email, passwordPlainText: password, name }); + const user = await createNewUser({ + strategy: "local", + email, + passwordPlainText: password, + name, + }); req.logIn(user, (err) => { if (err) return next(err); return res.send({ ok: true, user }); }); - } catch(e) { + } catch (e) { next(e); } }); - + router.get("/admin", async (req, res, next) => { try { if (!req.user || !req.user.admin) { return res.redirect("/"); } - const users = (await getAllLingdocsUsers()).sort((a, b) => ( - (a.accountCreated || 0) - (b.accountCreated || 0) - )); - const tests = getTestCompletionSummary(users) + const users = (await getAllLingdocsUsers()).sort( + (a, b) => (a.accountCreated || 0) - (b.accountCreated || 0) + ); + const tests = getTestCompletionSummary(users); res.render("admin", { users, tests }); } catch (e) { next(e); } }); + router.get("/grammar-feedback", async (req, res, next) => { + try { + if (!req.user || !req.user.admin) { + return res.redirect("/"); + } + const docs = await getAllFeedback(); + console.log("one doc"); + console.log(docs[0]); + res.render("grammar-feedback", { docs }); + } catch (e) { + next(e); + } + }); + router.get("/privacy", (req, res) => { res.render("privacy"); }); @@ -173,42 +221,49 @@ const authRouter = (passport: PassportStatic) => { /** * Grant request for upgrade to student */ - router.post("/admin/upgradeToStudent/:userId/:grantOrDeny", async (req, res, next) => { - try { - if (!req.user || !req.user.admin) { - return res.redirect("/"); + router.post( + "/admin/upgradeToStudent/:userId/:grantOrDeny", + async (req, res, next) => { + try { + if (!req.user || !req.user.admin) { + return res.redirect("/"); + } + const userId = req.params.userId as T.UUID; + const grantOrDeny = req.params.grantOrDeny as "grant" | "deny"; + if (grantOrDeny === "grant") { + await upgradeUser(userId); + } else { + await denyUserUpgradeRequest(userId); + } + res.redirect("/admin"); + } catch (e) { + next(e); } - const userId = req.params.userId as T.UUID; - const grantOrDeny = req.params.grantOrDeny as "grant" | "deny"; - if (grantOrDeny === "grant") { - await upgradeUser(userId); - } else { - await denyUserUpgradeRequest(userId); - } - res.redirect("/admin"); - } catch (e) { - next(e); } - }); + ); router.post("/downgradeToBasic", async (req, res, next) => { try { if (!req.user) { return res.send({ ok: false, error: "user not logged in" }); } - const subscription = "subscription" in req.user ? req.user.subscription : undefined; - await downgradeUser(req.user.userId, subscription - ? subscription.id - : undefined); + const subscription = + "subscription" in req.user ? req.user.subscription : undefined; + await downgradeUser( + req.user.userId, + subscription ? subscription.id : undefined + ); res.send({ ok: true, - message: `account downgraded to basic${subscription ? " and subscription cancelled" : ""}`, + message: `account downgraded to basic${ + subscription ? " and subscription cancelled" : "" + }`, }); } catch (e) { next(e); } }); - + router.delete("/admin/:userId", async (req, res, next) => { try { // TODO: MAKE PROPER MIDDLEWARE WITH TYPING @@ -250,11 +305,11 @@ const authRouter = (passport: PassportStatic) => { }); router.get("/password-reset", (req, res) => { - const email = req.query.email || "" + const email = req.query.email || ""; res.render("password-reset-request", { email, done: false }); }); - router.post("/password-reset", async(req, res, next) => { + router.post("/password-reset", async (req, res, next) => { const page = "password-reset-request"; const email = req.body.email || ""; try { @@ -275,10 +330,9 @@ const authRouter = (passport: PassportStatic) => { } const token = getURLToken(); const tokenHash = await getHash(token); - const u = await updateLingdocsUser( - user.userId, - { passwordReset: { tokenHash, requestedOn: getTimestamp() }}, - ); + const u = await updateLingdocsUser(user.userId, { + passwordReset: { tokenHash, requestedOn: getTimestamp() }, + }); await sendPasswordResetEmail(u, token); return res.render(page, { email, done: true }); } catch (e) { @@ -311,14 +365,23 @@ const authRouter = (passport: PassportStatic) => { return res.render(page, { ok: false, message: "not found" }); } const result = await compareToHash(token, user.passwordReset.tokenHash); - if (!result) return res.render(page, { ok: false, user: null, message: "invalid token" }); + if (!result) + return res.render(page, { + ok: false, + user: null, + message: "invalid token", + }); const passwordsMatch = password === passwordConfirmed; if (passwordsMatch) { const hash = await getHash(password); await updateLingdocsUser(user.userId, { password: hash }); return res.render(page, { ok: true, user, message: "password reset" }); } else { - return res.render(page, { ok: false, user, message: "passwords don't match" }); + return res.render(page, { + ok: false, + user, + message: "passwords don't match", + }); } }); @@ -326,35 +389,37 @@ const authRouter = (passport: PassportStatic) => { req.logOut(); res.redirect("/"); }); - + return router; -} +}; function getTestCompletionSummary(users: T.LingdocsUser[]) { - const tests: { id: string, passes: number, fails: number }[] = []; - users.forEach(u => { - const usersTests = removeDuplicateTests(u.tests) - usersTests.forEach(ut => { - const ti = tests.findIndex(x => x.id === ut.id); + const tests: { id: string; passes: number; fails: number }[] = []; + users.forEach((u) => { + const usersTests = removeDuplicateTests(u.tests); + usersTests.forEach((ut) => { + const ti = tests.findIndex((x) => x.id === ut.id); if (ti === -1) { tests.push({ id: ut.id, - ...ut.done ? { passes: 1, fails: 0 } : { passes: 0, fails: 1 }, + ...(ut.done ? { passes: 1, fails: 0 } : { passes: 0, fails: 1 }), }); - } - else tests[ti][ut.done ? "passes" : "fails"]++; + } else tests[ti][ut.done ? "passes" : "fails"]++; }); }); return tests; } function removeDuplicateTests(tests: T.TestResult[]): T.TestResult[] { - return tests.reduceRight((acc, curr) => { - const redundant = acc.filter(x => ((x.id === curr.id) && (x.done === curr.done))); - return redundant.length - ? acc - : [...acc, curr]; - }, [...tests]); + return tests.reduceRight( + (acc, curr) => { + const redundant = acc.filter( + (x) => x.id === curr.id && x.done === curr.done + ); + return redundant.length ? acc : [...acc, curr]; + }, + [...tests] + ); } // function getTestCompletionSummary(users: T.LingdocsUser[]) { @@ -369,4 +434,4 @@ function removeDuplicateTests(tests: T.TestResult[]): T.TestResult[] { // return tests; // } -export default authRouter; \ No newline at end of file +export default authRouter; diff --git a/account/views/grammar-feedback.ejs b/account/views/grammar-feedback.ejs new file mode 100644 index 0000000..097b1c3 --- /dev/null +++ b/account/views/grammar-feedback.ejs @@ -0,0 +1,49 @@ + + + + + + + Grammar Feedback ยท LingDocs + + + + +
+

LingDocs Grammar Feedback

+

<%= docs.length %> pieces of feedback

+ + + + + + + + + + + + <% for(var i=0; i < docs.length; i++) { %> + + + + + + + + <% } %> + +
DateUserChapterFeedbackRating
+ <%= new Date(docs[i].feedback.ts).toDateString() %> + + <%= docs[i].feedback.user %> + + <%= docs[i].feedback.chapter %> + + <%= docs[i].feedback.feedback %> + + <%= docs[i].feedback.rating %> +
+
+ + \ No newline at end of file