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; if (response.ok !== true) return undefined;
return { return {
...user, ...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> { export async function getLingdocsUser(
const user = await usersDb.find({ field: "email" | "userId" | "githubId" | "googleId" | "twitterId",
selector: field === "githubId" value: string
? { github: { id: value }} ): Promise<undefined | T.LingdocsUser> {
const user = await usersDb.find({
selector:
field === "githubId"
? { github: { id: value } }
: field === "googleId" : field === "googleId"
? { google: { id: value }} ? { google: { id: value } }
: field === "twitterId" : field === "twitterId"
? { twitter: { id: value }} ? { twitter: { id: value } }
: { [field]: value }, : { [field]: value },
}); });
if (!user.docs.length) { if (!user.docs.length) {
return undefined; return undefined;
} }
return user.docs[0] as T.LingdocsUser; return user.docs[0] as T.LingdocsUser;
} }
export async function getAllLingdocsUsers(): Promise<T.LingdocsUser[]> { export async function getAllLingdocsUsers(): Promise<T.LingdocsUser[]> {
const users = await usersDb.find({ const users = await usersDb.find({
selector: { userId: { $exists: true }}, selector: { userId: { $exists: true } },
limit: 5000, limit: 5000,
}); });
return users.docs as T.LingdocsUser[]; 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 { try {
const res = await usersDb.insert(user); const res = await usersDb.insert(user);
const newUser = processAPIResponse(user, res); 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"); throw new Error("error inserting user");
} }
return newUser; return newUser;
} catch(e) { } catch (e) {
console.log("ERROR on insertLingdocsUser", user); console.log("ERROR on insertLingdocsUser", user);
throw new Error("error inserting user - on update"); 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> { export async function deleteCouchDbAuthUser(uuid: T.UUID): Promise<void> {
const authUsers = nano.db.use("_users"); 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; if (!user.docs.length) return;
const u = user.docs[0]; const u = user.docs[0];
await authUsers.destroy(u._id, u._rev); await authUsers.destroy(u._id, u._rev);
} }
export async function updateLingdocsUser(uuid: T.UUID, toUpdate: export async function updateLingdocsUser(
// TODO: OR USE REDUCER?? uuid: T.UUID,
{ name: string } | toUpdate: // TODO: OR USE REDUCER??
{ name?: string, email: string, emailVerified: T.Hash } | | { name: string }
{ email: string, emailVerified: true } | | { name?: string; email: string; emailVerified: T.Hash }
{ emailVerified: T.Hash } | | { email: string; emailVerified: true }
{ emailVerified: true } | | { emailVerified: T.Hash }
{ password: T.Hash } | | { emailVerified: true }
{ google: T.GoogleProfile | undefined } | | { password: T.Hash }
{ github: T.GitHubProfile | undefined } | | { google: T.GoogleProfile | undefined }
{ twitter: T.TwitterProfile | undefined } | | { github: T.GitHubProfile | undefined }
{ | { twitter: T.TwitterProfile | undefined }
passwordReset: { | {
tokenHash: T.Hash, passwordReset: {
requestedOn: T.TimeStamp, tokenHash: T.Hash;
}, requestedOn: T.TimeStamp;
} | };
{ }
level: "student", | {
wordlistDbName: T.WordlistDbName, level: "student";
couchDbPassword: T.UserDbPassword, wordlistDbName: T.WordlistDbName;
upgradeToStudentRequest: undefined, couchDbPassword: T.UserDbPassword;
subscription?: T.StripeSubscription, upgradeToStudentRequest: undefined;
} | subscription?: T.StripeSubscription;
{ }
level: "basic", | {
wordlistDbName: undefined, level: "basic";
couchDbPassword: undefined, wordlistDbName: undefined;
upgradeToStudentRequest: undefined, couchDbPassword: undefined;
subscription: undefined, upgradeToStudentRequest: undefined;
} | subscription: undefined;
{ upgradeToStudentRequest: "waiting" } | }
{ upgradeToStudentRequest: "denied" } | | { upgradeToStudentRequest: "waiting" }
{ tests: T.TestResult[] } | { upgradeToStudentRequest: "denied" }
| { tests: T.TestResult[] }
): Promise<T.LingdocsUser> { ): Promise<T.LingdocsUser> {
const user = await getLingdocsUser("userId", uuid); const user = await getLingdocsUser("userId", uuid);
if (!user) throw new Error("unable to update - user not found " + 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 password = generateWordlistDbPassword();
const userDbName = getWordlistDbName(uuid); const userDbName = getWordlistDbName(uuid);
const usersDb = nano.db.use("_users"); const usersDb = nano.db.use("_users");
@ -199,43 +218,47 @@ export function getWordlistDbName(uid: T.UUID): T.WordlistDbName {
function generateWordlistDbPassword(): T.UserDbPassword { function generateWordlistDbPassword(): T.UserDbPassword {
function makeChunk(): string { 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 => ( const password = new Array(4)
acc + makeChunk() .fill(0)
), ""); .reduce((acc: string): string => acc + makeChunk(), "");
return password as T.UserDbPassword; return password as T.UserDbPassword;
} }
function stringToHex(str: string) { function stringToHex(str: string) {
const arr1 = []; const arr1 = [];
for (let n = 0, l = str.length; n < l; n ++) { for (let n = 0, l = str.length; n < l; n++) {
const hex = Number(str.charCodeAt(n)).toString(16); const hex = Number(str.charCodeAt(n)).toString(16);
arr1.push(hex); arr1.push(hex);
} }
return arr1.join(''); return arr1.join("");
} }
/** /**
* Adds new tests to a users record, only keeping up to amountToKeep records of the most * Adds new tests to a users record, only keeping up to amountToKeep records of the most
* recent repeat passes/fails * recent repeat passes/fails
* *
* @param existing - the existing tests in a users record * @param existing - the existing tests in a users record
* @param newResults - the tests to be added to 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) * @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]; const tests = [...existing];
// check to make sure that we're only adding test results that are not already added // 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) => { newTests.forEach((nt) => {
const repeats = tests.filter(x => ((x.id === nt.id)) && (x.done === nt.done)); const repeats = tests.filter((x) => x.id === nt.id && x.done === nt.done);
if (repeats.length > (amountToKeep - 1)) { if (repeats.length > amountToKeep - 1) {
// already have enough repeat passes saved, remove the oldest one // 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); if (i > -1) tests.splice(i, 1);
} }
tests.push(nt); tests.push(nt);
}); });
return tests; return tests;
} }

View File

@ -2,25 +2,25 @@ import { Router } from "express";
import { PassportStatic } from "passport"; import { PassportStatic } from "passport";
import { import {
deleteLingdocsUser, deleteLingdocsUser,
getAllFeedback,
getAllLingdocsUsers, getAllLingdocsUsers,
getLingdocsUser, getLingdocsUser,
updateLingdocsUser, updateLingdocsUser,
} from "../lib/couch-db"; } from "../lib/couch-db";
import { createNewUser, canRemoveOneOutsideProvider, downgradeUser } from "../lib/user-utils"; import {
createNewUser,
canRemoveOneOutsideProvider,
downgradeUser,
} from "../lib/user-utils";
import { import {
getHash, getHash,
getURLToken, getURLToken,
compareToHash, compareToHash,
getEmailTokenAndHash, getEmailTokenAndHash,
} from "../lib/password-utils"; } from "../lib/password-utils";
import { import { upgradeUser, denyUserUpgradeRequest } from "../lib/user-utils";
upgradeUser,
denyUserUpgradeRequest,
} from "../lib/user-utils";
import { validateReCaptcha } from "../lib/recaptcha"; import { validateReCaptcha } from "../lib/recaptcha";
import { import { getTimestamp } from "../lib/time-utils";
getTimestamp,
} from "../lib/time-utils";
import { import {
sendPasswordResetEmail, sendPasswordResetEmail,
sendVerificationEmail, sendVerificationEmail,
@ -56,10 +56,16 @@ const authRouter = (passport: PassportStatic) => {
const name = req.body.name as string; const name = req.body.name as string;
const email = req.body.email as string; const email = req.body.email as string;
if (email !== req.user.email) { if (email !== req.user.email) {
if (name !== req.user.name) await updateLingdocsUser(req.user.userId, { name }); if (name !== req.user.name)
const withSameEmail = (email !== "") && await getLingdocsUser("email", email); await updateLingdocsUser(req.user.userId, { name });
const withSameEmail =
email !== "" && (await getLingdocsUser("email", email));
if (withSameEmail) { 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! // TODO: ABSTRACT THE PROCESS OF GETTING A NEW EMAIL TOKEN AND MAILING!
const { token, hash } = await getEmailTokenAndHash(); const { token, hash } = await getEmailTokenAndHash();
@ -69,11 +75,19 @@ const authRouter = (passport: PassportStatic) => {
emailVerified: hash, emailVerified: hash,
}); });
sendVerificationEmail(updated, token).catch(console.error); 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 }); const updated = await updateLingdocsUser(req.user.userId, { name });
// need to do this because sometimes the update seems slow? // 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) => { router.post("/login", async (req, res, next) => {
@ -83,22 +97,26 @@ const authRouter = (passport: PassportStatic) => {
return res.render("login", { recaptcha: "fail", inProd }); return res.render("login", { recaptcha: "fail", inProd });
} }
} }
passport.authenticate("local", (err, user: T.LingdocsUser | undefined, info) => { passport.authenticate(
if (err) throw err; "local",
if (!user && info.message === "email not found") { (err, user: T.LingdocsUser | undefined, info) => {
return res.send({ ok: false, newSignup: true }); 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({ )(req, res, next);
ok: false,
message: "Incorrect password",
});
else {
req.logIn(user, (err) => {
if (err) return next(err);
res.send({ ok: true, user });
});
}
})(req, res, next);
}); });
router.get( router.get(
@ -106,31 +124,42 @@ const authRouter = (passport: PassportStatic) => {
passport.authenticate("google", { passport.authenticate("google", {
// @ts-ignore - needed for getting refreshToken] // @ts-ignore - needed for getting refreshToken]
accessType: "offline", 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", { router.get(
scope: ["read:user", "user:email"], "/github",
})); passport.authenticate("github", {
router.get('/twitter', passport.authenticate("twitter")); 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) => { outsideProviders.forEach((provider) => {
router.get( router.get(
`/${provider}/callback`, `/${provider}/callback`,
passport.authenticate(provider, { successRedirect: '/user', failureRedirect: '/' }), passport.authenticate(provider, {
successRedirect: "/user",
failureRedirect: "/",
})
); );
router.post(`/${provider}/remove`, async (req, res, next) => { router.post(`/${provider}/remove`, async (req, res, next) => {
try { try {
if (!req.user) return next("user not found"); 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( await updateLingdocsUser(
req.user.userId, req.user.userId,
// @ts-ignore - shouldn't need this // @ts-ignore - shouldn't need this
{ [provider]: undefined } { [provider]: undefined }
); );
return res.redirect("/user"); return res.redirect("/user");
} catch(e) { } catch (e) {
next(e); next(e);
} }
}); });
@ -141,31 +170,50 @@ const authRouter = (passport: PassportStatic) => {
const { email, password, name } = req.body; const { email, password, name } = req.body;
const existingUser = await getLingdocsUser("email", email); const existingUser = await getLingdocsUser("email", email);
if (existingUser) return res.send("User Already Exists"); 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) => { req.logIn(user, (err) => {
if (err) return next(err); if (err) return next(err);
return res.send({ ok: true, user }); return res.send({ ok: true, user });
}); });
} catch(e) { } catch (e) {
next(e); next(e);
} }
}); });
router.get("/admin", async (req, res, next) => { router.get("/admin", async (req, res, next) => {
try { try {
if (!req.user || !req.user.admin) { if (!req.user || !req.user.admin) {
return res.redirect("/"); return res.redirect("/");
} }
const users = (await getAllLingdocsUsers()).sort((a, b) => ( const users = (await getAllLingdocsUsers()).sort(
(a.accountCreated || 0) - (b.accountCreated || 0) (a, b) => (a.accountCreated || 0) - (b.accountCreated || 0)
)); );
const tests = getTestCompletionSummary(users) const tests = getTestCompletionSummary(users);
res.render("admin", { users, tests }); res.render("admin", { users, tests });
} catch (e) { } catch (e) {
next(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) => { router.get("/privacy", (req, res) => {
res.render("privacy"); res.render("privacy");
}); });
@ -173,42 +221,49 @@ const authRouter = (passport: PassportStatic) => {
/** /**
* Grant request for upgrade to student * Grant request for upgrade to student
*/ */
router.post("/admin/upgradeToStudent/:userId/:grantOrDeny", async (req, res, next) => { router.post(
try { "/admin/upgradeToStudent/:userId/:grantOrDeny",
if (!req.user || !req.user.admin) { async (req, res, next) => {
return res.redirect("/"); 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) => { router.post("/downgradeToBasic", async (req, res, next) => {
try { try {
if (!req.user) { if (!req.user) {
return res.send({ ok: false, error: "user not logged in" }); return res.send({ ok: false, error: "user not logged in" });
} }
const subscription = "subscription" in req.user ? req.user.subscription : undefined; const subscription =
await downgradeUser(req.user.userId, subscription "subscription" in req.user ? req.user.subscription : undefined;
? subscription.id await downgradeUser(
: undefined); req.user.userId,
subscription ? subscription.id : undefined
);
res.send({ res.send({
ok: true, ok: true,
message: `account downgraded to basic${subscription ? " and subscription cancelled" : ""}`, message: `account downgraded to basic${
subscription ? " and subscription cancelled" : ""
}`,
}); });
} catch (e) { } catch (e) {
next(e); next(e);
} }
}); });
router.delete("/admin/:userId", async (req, res, next) => { router.delete("/admin/:userId", async (req, res, next) => {
try { try {
// TODO: MAKE PROPER MIDDLEWARE WITH TYPING // TODO: MAKE PROPER MIDDLEWARE WITH TYPING
@ -250,11 +305,11 @@ const authRouter = (passport: PassportStatic) => {
}); });
router.get("/password-reset", (req, res) => { router.get("/password-reset", (req, res) => {
const email = req.query.email || "" const email = req.query.email || "";
res.render("password-reset-request", { email, done: false }); 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 page = "password-reset-request";
const email = req.body.email || ""; const email = req.body.email || "";
try { try {
@ -275,10 +330,9 @@ const authRouter = (passport: PassportStatic) => {
} }
const token = getURLToken(); const token = getURLToken();
const tokenHash = await getHash(token); const tokenHash = await getHash(token);
const u = await updateLingdocsUser( const u = await updateLingdocsUser(user.userId, {
user.userId, passwordReset: { tokenHash, requestedOn: getTimestamp() },
{ passwordReset: { tokenHash, requestedOn: getTimestamp() }}, });
);
await sendPasswordResetEmail(u, token); await sendPasswordResetEmail(u, token);
return res.render(page, { email, done: true }); return res.render(page, { email, done: true });
} catch (e) { } catch (e) {
@ -311,14 +365,23 @@ const authRouter = (passport: PassportStatic) => {
return res.render(page, { ok: false, message: "not found" }); return res.render(page, { ok: false, message: "not found" });
} }
const result = await compareToHash(token, user.passwordReset.tokenHash); 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; const passwordsMatch = password === passwordConfirmed;
if (passwordsMatch) { if (passwordsMatch) {
const hash = await getHash(password); const hash = await getHash(password);
await updateLingdocsUser(user.userId, { password: hash }); await updateLingdocsUser(user.userId, { password: hash });
return res.render(page, { ok: true, user, message: "password reset" }); return res.render(page, { ok: true, user, message: "password reset" });
} else { } 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(); req.logOut();
res.redirect("/"); res.redirect("/");
}); });
return router; return router;
} };
function getTestCompletionSummary(users: T.LingdocsUser[]) { function getTestCompletionSummary(users: T.LingdocsUser[]) {
const tests: { id: string, passes: number, fails: number }[] = []; const tests: { id: string; passes: number; fails: number }[] = [];
users.forEach(u => { users.forEach((u) => {
const usersTests = removeDuplicateTests(u.tests) const usersTests = removeDuplicateTests(u.tests);
usersTests.forEach(ut => { usersTests.forEach((ut) => {
const ti = tests.findIndex(x => x.id === ut.id); const ti = tests.findIndex((x) => x.id === ut.id);
if (ti === -1) { if (ti === -1) {
tests.push({ tests.push({
id: ut.id, 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; return tests;
} }
function removeDuplicateTests(tests: T.TestResult[]): T.TestResult[] { function removeDuplicateTests(tests: T.TestResult[]): T.TestResult[] {
return tests.reduceRight((acc, curr) => { return tests.reduceRight(
const redundant = acc.filter(x => ((x.id === curr.id) && (x.done === curr.done))); (acc, curr) => {
return redundant.length const redundant = acc.filter(
? acc (x) => x.id === curr.id && x.done === curr.done
: [...acc, curr]; );
}, [...tests]); return redundant.length ? acc : [...acc, curr];
},
[...tests]
);
} }
// function getTestCompletionSummary(users: T.LingdocsUser[]) { // function getTestCompletionSummary(users: T.LingdocsUser[]) {
@ -369,4 +434,4 @@ function removeDuplicateTests(tests: T.TestResult[]): T.TestResult[] {
// return tests; // 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>