feedback view

This commit is contained in:
adueck 2023-08-28 14:49:05 +04:00
parent af5a731e43
commit 60d024abb6
3 changed files with 296 additions and 159 deletions

View File

@ -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<undefined | T.LingdocsUser> {
const user = await usersDb.find({
selector: field === "githubId"
? { github: { id: value }}
export async function getLingdocsUser(
field: "email" | "userId" | "githubId" | "googleId" | "twitterId",
value: string
): Promise<undefined | T.LingdocsUser> {
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<T.LingdocsUser[]> {
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<T.LingdocsUser> {
export async function getAllFeedback(): Promise<any[]> {
const res = await feedbackDb.find({
selector: { doc: { $exists: true } },
});
return res.docs as any[];
}
export async function insertLingdocsUser(
user: T.LingdocsUser
): Promise<T.LingdocsUser> {
try {
const res = await usersDb.insert(user);
const newUser = processAPIResponse(user, res);
@ -66,7 +82,7 @@ export async function insertLingdocsUser(user: T.LingdocsUser): Promise<T.Lingdo
throw new Error("error inserting user");
}
return newUser;
} catch(e) {
} catch (e) {
console.log("ERROR on insertLingdocsUser", user);
throw new Error("error inserting user - on update");
}
@ -83,46 +99,47 @@ export async function deleteLingdocsUser(uuid: T.UUID): Promise<void> {
export async function deleteCouchDbAuthUser(uuid: T.UUID): Promise<void> {
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<T.LingdocsUser> {
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<T.TestResult[]>, toAdd: T.TestResult[], amountToKeep = 2): T.TestResult[] {
function addNewTests(
existing: Readonly<T.TestResult[]>,
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;
}
}

View File

@ -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;
export default authRouter;

View File

@ -0,0 +1,49 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="LingDocs Signin">
<title>Grammar Feedback · LingDocs</title>
<link href="/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.15.4/css/all.css" integrity="sha384-DyZ88mC6Up2uqS4h/KRgHuoeGwBcD4Ng9SiP4dIRy0EXTlnuz47vAwmeGwVChigm" crossorigin="anonymous">
</head>
<body>
<div class="container">
<h1 class="my-4">LingDocs Grammar Feedback</h1>
<p><%= docs.length %> pieces of feedback</p>
<table class="table">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">User</th>
<th scope="col">Chapter</th>
<th scope="col">Feedback</th>
<th scope="col">Rating</th>
</tr>
</thead>
<tbody>
<% for(var i=0; i < docs.length; i++) { %>
<tr>
<td>
<%= new Date(docs[i].feedback.ts).toDateString() %>
</td>
<td>
<%= docs[i].feedback.user %>
</td>
<td>
<%= docs[i].feedback.chapter %>
</td>
<td>
<%= docs[i].feedback.feedback %>
</td>
<td>
<%= docs[i].feedback.rating %>
</td>
</tr>
<% } %>
</tbody>
</table>
</div>
</body>
</html>