feedback view
This commit is contained in:
parent
af5a731e43
commit
60d024abb6
|
@ -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,14 +37,18 @@ 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(
|
||||
field: "email" | "userId" | "githubId" | "googleId" | "twitterId",
|
||||
value: string
|
||||
): Promise<undefined | T.LingdocsUser> {
|
||||
const user = await usersDb.find({
|
||||
selector: field === "githubId"
|
||||
? { github: { id: value }}
|
||||
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) {
|
||||
|
@ -52,13 +59,22 @@ export async function getLingdocsUser(field: "email" | "userId" | "githubId" | "
|
|||
|
||||
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 } |
|
||||
{
|
||||
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[] }
|
||||
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,21 +218,21 @@ 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 ++) {
|
||||
for (let n = 0, l = str.length; n < l; n++) {
|
||||
const hex = Number(str.charCodeAt(n)).toString(16);
|
||||
arr1.push(hex);
|
||||
}
|
||||
return arr1.join('');
|
||||
return arr1.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -224,15 +243,19 @@ function stringToHex(str: string) {
|
|||
* @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);
|
||||
|
|
|
@ -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,12 +97,15 @@ const authRouter = (passport: PassportStatic) => {
|
|||
return res.render("login", { recaptcha: "fail", inProd });
|
||||
}
|
||||
}
|
||||
passport.authenticate("local", (err, user: T.LingdocsUser | undefined, info) => {
|
||||
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({
|
||||
if (!user)
|
||||
res.send({
|
||||
ok: false,
|
||||
message: "Incorrect password",
|
||||
});
|
||||
|
@ -98,7 +115,8 @@ const authRouter = (passport: PassportStatic) => {
|
|||
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", {
|
||||
router.get(
|
||||
"/github",
|
||||
passport.authenticate("github", {
|
||||
scope: ["read:user", "user:email"],
|
||||
}));
|
||||
router.get('/twitter', passport.authenticate("twitter"));
|
||||
})
|
||||
);
|
||||
router.get("/twitter", passport.authenticate("twitter"));
|
||||
|
||||
// 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
|
||||
{ [provider]: undefined }
|
||||
);
|
||||
return res.redirect("/user");
|
||||
} catch(e) {
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
@ -141,12 +170,17 @@ 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);
|
||||
}
|
||||
});
|
||||
|
@ -156,16 +190,30 @@ const authRouter = (passport: PassportStatic) => {
|
|||
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,7 +221,9 @@ const authRouter = (passport: PassportStatic) => {
|
|||
/**
|
||||
* Grant request for upgrade to student
|
||||
*/
|
||||
router.post("/admin/upgradeToStudent/:userId/:grantOrDeny", async (req, res, next) => {
|
||||
router.post(
|
||||
"/admin/upgradeToStudent/:userId/:grantOrDeny",
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
if (!req.user || !req.user.admin) {
|
||||
return res.redirect("/");
|
||||
|
@ -189,20 +239,25 @@ const authRouter = (passport: PassportStatic) => {
|
|||
} 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);
|
||||
|
@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -328,33 +391,35 @@ const authRouter = (passport: PassportStatic) => {
|
|||
});
|
||||
|
||||
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[]) {
|
||||
|
|
|
@ -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>
|
Loading…
Reference in New Issue