Redesign database and implement voting

master
Dessa Simpson 2020-08-07 21:32:06 -07:00
parent a0b65c7d5b
commit 80e9bc1bde
16 changed files with 376 additions and 41 deletions

View File

@ -1,10 +1,11 @@
CREATE TABLE states (
state varchar UNIQUE NOT NULL,
active bool NOT NULL DEFAULT TRUE, -- Whether request can be considered "active"
PRIMARY KEY(state)
);
INSERT INTO states (state) VALUES
('Requested'),
('Rejected'),
('Accepted'),
('Learned');
INSERT INTO states (state,active) VALUES
('Requested',TRUE),
('Rejected',FALSE),
('Accepted',TRUE),
('Learned',FALSE);

10
db/05-config.sql Normal file
View File

@ -0,0 +1,10 @@
CREATE TABLE config (
rowlock bool DEFAULT TRUE UNIQUE NOT NULL CHECK (rowlock = TRUE),
normaluservotepoints int NOT NULL,
followervotepoints int NOT NULL,
subscribervotepoints int NOT NULL,
PRIMARY KEY (rowLock)
);
INSERT INTO config (normalUserVotePoints,followerVotePoints,subscriberVotePoints)
VALUES (10,50,100);

View File

@ -1,9 +0,0 @@
CREATE TABLE requests (
id int GENERATED ALWAYS AS IDENTITY,
url varchar UNIQUE,
requester varchar NOT NULL,
score int DEFAULT 0,
state varchar NOT NULL DEFAULT 'Requested',
PRIMARY KEY(id),
FOREIGN KEY (state) REFERENCES states(state)
);

8
db/10-users.sql Normal file
View File

@ -0,0 +1,8 @@
CREATE TABLE users (
userId int NOT NULL,
displayName varchar NOT NULL,
imageUrl varchar,
isFollower boolean NOT NULL DEFAULT FALSE,
isSubscriber boolean NOT NULL DEFAULT FALSE,
PRIMARY KEY (userId)
);

9
db/20-requests.sql Normal file
View File

@ -0,0 +1,9 @@
CREATE TABLE requests (
url varchar NOT NULL UNIQUE,
requester int NOT NULL,
state varchar NOT NULL DEFAULT 'Requested',
reqTimestamp timestamptz NOT NULL DEFAULT NOW(),
PRIMARY KEY (url),
FOREIGN KEY (requester) REFERENCES users(userid),
FOREIGN KEY (state) REFERENCES states(state)
);

7
db/30-votes.sql Normal file
View File

@ -0,0 +1,7 @@
CREATE TABLE votes (
requestUrl varchar,
userId int,
PRIMARY KEY (requestUrl,userId),
FOREIGN KEY (requestUrl) REFERENCES requests(url),
FOREIGN KEY (userId) REFERENCES users(userId)
);

7
db/40-scores.sql Normal file
View File

@ -0,0 +1,7 @@
CREATE TABLE scores (
url varchar,
baseScore int NOT NULL DEFAULT 0,
score int NOT NULL DEFAULT 0,
PRIMARY KEY (url),
FOREIGN KEY (url) REFERENCES requests(url)
);

View File

@ -1,5 +1,111 @@
CREATE VIEW requests_vw AS
SELECT * FROM requests WHERE state = 'Requested' ORDER BY score DESC, id ASC;
CREATE OR REPLACE FUNCTION get_requests()
RETURNS TABLE (
url varchar,
requester varchar,
state varchar,
score int,
reqTimestamp timestamptz
)
LANGUAGE SQL
AS $$
SELECT url,displayName AS requester,state,score,reqTimestamp FROM requests
JOIN scores USING (url)
JOIN users ON requests.requester = users.userid
WHERE state IN (SELECT state FROM states WHERE active)
ORDER BY score DESC, reqTimestamp ASC;
$$;
CREATE VIEW requests_all_vw AS
SELECT * FROM requests ORDER BY score DESC, id ASC;
CREATE OR REPLACE FUNCTION get_requests_all()
RETURNS TABLE (
url varchar,
requester varchar,
state varchar,
score int,
reqTimestamp timestamptz
)
LANGUAGE SQL
AS $$
SELECT url,displayName AS requester,state,score,reqTimestamp FROM requests
JOIN scores USING (url)
JOIN users ON requests.requester = users.userid
ORDER BY score DESC, reqTimestamp ASC;
$$;
CREATE OR REPLACE FUNCTION get_requests_voted(votinguserid int)
RETURNS TABLE (
url varchar,
requester varchar,
state varchar,
score int,
reqTimestamp timestamptz,
voted bool
)
LANGUAGE SQL
AS $$
SELECT url,displayName AS requester,state,score,reqTimestamp,
(CASE WHEN votes.userid IS NULL THEN FALSE ELSE TRUE END) AS voted
FROM requests
JOIN scores USING (url)
JOIN users ON requests.requester = users.userid
LEFT JOIN votes ON (requests.url = votes.requesturl AND votes.userid = votinguserid)
WHERE state IN (SELECT state FROM states WHERE active)
ORDER BY score DESC, reqTimestamp ASC;
$$;
CREATE OR REPLACE FUNCTION get_requests_all_voted(votinguserid int)
RETURNS TABLE (
url varchar,
requester varchar,
state varchar,
score int,
reqTimestamp timestamptz,
voted bool
)
LANGUAGE SQL
AS $$
SELECT url,displayName AS requester,state,score,reqTimestamp,
(CASE WHEN votes.userid IS NULL THEN FALSE ELSE TRUE END) AS voted
FROM requests
JOIN scores USING (url)
JOIN users ON requests.requester = users.userid
LEFT JOIN votes ON (requests.url = votes.requesturl AND votes.userid = votinguserid)
ORDER BY score DESC, reqTimestamp ASC;
$$;
/*
Views to determine the score added by votes from different classes of users.
In order for songs with no votes to show up in this view (and thus have their
scores properly calculated), the query must be JOINed to requests, the URL must
be selected as requests.url rather than votes.requesturl (which is null in the
case of no votes), and users.is{follower,subscriber} must be COALESCEd to FALSE
as in the case of no votes, user.* will be NULL.
*/
CREATE OR REPLACE VIEW vote_score_normal_vw AS
SELECT requests.url,COUNT(votes.requesturl),(COUNT(votes.requesturl) * (SELECT normaluservotepoints FROM config)) AS votescore FROM requests
LEFT JOIN votes ON votes.requesturl = requests.url
LEFT JOIN users on votes.userid = users.userid
WHERE COALESCE(users.isfollower,FALSE) = FALSE AND COALESCE(users.issubscriber,FALSE) = FALSE
GROUP BY requests.url;
CREATE OR REPLACE VIEW vote_score_follower_vw AS
SELECT requests.url,COUNT(votes.requesturl),(COUNT(votes.requesturl) * (SELECT followervotepoints FROM config)) AS votescore FROM requests
LEFT JOIN votes ON votes.requesturl = requests.url
LEFT JOIN users on votes.userid = users.userid
WHERE COALESCE(users.isfollower,FALSE) = TRUE AND COALESCE(users.issubscriber,FALSE) = FALSE
GROUP BY requests.url;
CREATE OR REPLACE VIEW vote_score_subscriber_vw AS
SELECT requests.url,COUNT(votes.requesturl),(COUNT(votes.requesturl) * (SELECT subscribervotepoints FROM config)) AS votescore FROM requests
LEFT JOIN votes ON votes.requesturl = requests.url
LEFT JOIN users on votes.userid = users.userid
WHERE COALESCE(users.issubscriber,FALSE) = TRUE
GROUP BY requests.url;
CREATE OR REPLACE VIEW vote_score_all_vw AS
SELECT url,SUM(count) AS count, SUM(votescore) AS votescore FROM
(SELECT * FROM vote_score_normal_vw
UNION ALL SELECT * FROM vote_score_follower_vw
UNION ALL SELECT * FROM vote_score_subscriber_vw
) AS union_vote_score
GROUP BY url;

30
db/95-procedures.sql Normal file
View File

@ -0,0 +1,30 @@
CREATE OR REPLACE PROCEDURE update_scores()
LANGUAGE SQL
AS $$
UPDATE scores SET score = basescore + votescore
FROM vote_score_all_vw
WHERE scores.url = vote_score_all_vw.url;
$$;
CREATE OR REPLACE PROCEDURE add_request(url varchar,requester int)
LANGUAGE SQL
AS $$
INSERT INTO requests (url,requester) VALUES (url,requester);
INSERT INTO scores (url) VALUES (url);
INSERT INTO votes (requesturl,userid) VALUES (url,requester);
CALL update_scores();
$$;
CREATE OR REPLACE PROCEDURE add_vote(url varchar,voteuser int)
LANGUAGE SQL
AS $$
INSERT INTO votes (requesturl,userid) VALUES (url,voteuser);
CALL update_scores();
$$;
CREATE OR REPLACE PROCEDURE delete_vote(url varchar,voteuser int)
LANGUAGE SQL
AS $$
DELETE FROM votes WHERE requesturl = url AND userid = voteuser;
CALL update_scores();
$$;

View File

@ -1,5 +1,22 @@
INSERT INTO requests (url,requester,score,state) VALUES
('https://www.youtube.com/watch?v=dQw4w9WgXcQ','virtualdxs',0,'Rejected'),
('https://www.youtube.com/watch?v=C5oeWHngDS4','virtualdxs',500,'Requested'),
('https://www.youtube.com/watch?v=vXaJGBLRA_o','virtualdxs',0,'Requested');
INSERT INTO users (userId,displayName,isFollower,isSubscriber) VALUES
(001,'user',false,false),
(002,'follower',true,false),
(003,'subscriber',true,true),
(004,'subnonfollower',false,true);
/* INSERT INTO requests (url,requester,state) VALUES
('https://www.youtube.com/watch?v=dQw4w9WgXcQ',001,'Rejected'),
('https://www.youtube.com/watch?v=C5oeWHngDS4',002,'Requested'),
('https://www.youtube.com/watch?v=vXaJGBLRA_o',003,'Requested'); */
CALL add_request('https://www.youtube.com/watch?v=dQw4w9WgXcQ',001);
CALL add_request('https://www.youtube.com/watch?v=C5oeWHngDS4',002);
CALL add_request('https://www.youtube.com/watch?v=vXaJGBLRA_o',003);
UPDATE requests SET state = 'Rejected' WHERE url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
INSERT INTO votes (requestUrl,userid) VALUES
('https://www.youtube.com/watch?v=C5oeWHngDS4',001),
('https://www.youtube.com/watch?v=C5oeWHngDS4',003);
CALL update_scores();

View File

@ -15,13 +15,21 @@ function getRequests(count,allRequests) {
function buildTable(requests,allRequests) {
var requestsDivHTML = '<table><tr><th class="request-url">URL</th><th class="request-requester">Requester</th><th class="request-score">Score</th>';
if (allRequests) requestsDivHTML += `<th class="request-state">State</td></tr>`;
if (allRequests) requestsDivHTML += `<th class="request-state">State</td>`;
if (window.loggedIn) requestsDivHTML += `<th class="request-vote">Vote</td>`;
requestsDivHTML += "</tr>";
for (request of requests) {
requestsDivHTML += `<tr><td class="request-url"><a href="${request.url}">${request.url}</a></td>\
<td class="request-requester">${request.requester}</td>\
<td class="request-score">${request.score}</td>`;
if (allRequests) requestsDivHTML += `<td class="request-state">${request.state}</td></tr>`;
if (allRequests) requestsDivHTML += `<td class="request-state">${request.state}</td>`;
if (window.loggedIn) {
if (request.voted) {
requestsDivHTML += `<td class="request-vote"><button onclick="deleteVote('${request.url}')">Unvote</button></td>`;
} else {
requestsDivHTML += `<td class="request-vote"><button onclick="addVote('${request.url}')">Vote</button></td>`;
}
}
requestsDivHTML += "</tr>";
}
requestsDivHTML += "</table>";
@ -84,15 +92,41 @@ function validateAndSubmitRequest() {
}
fetch("/api/addRequest", { method: 'POST', body: new URLSearchParams({
url: url
})})
.then(response => {
updateTable();
document.getElementById("addRequestUrl").value = "";
response.text().then((message) => {
closeAddRequestModal();
showMessage(message);
});
});
}
function addVote(url) {
fetch("/api/addVote", { method: 'POST', body: new URLSearchParams({
url: url
})})
.then(response => {
if (!response.ok) {
response.text().then(addRequestErr);
return;
}
updateTable();
response.text().then(showMessage);
});
}
function deleteVote(url) {
fetch("/api/deleteVote", { method: 'POST', body: new URLSearchParams({
url: url
})})
.then(response => {
if (!response.ok) {
response.text().then(addRequestErr);
return;
}
closeAddRequestModal();
updateTable();
document.getElementById("addRequestUrl").value = "";
response.text().then(showMessage);
});
}

View File

@ -142,3 +142,7 @@ div#nav-userpic {
.modalClose a {
text-decoration: none;
}
.request-vote > button {
width: 100%;
}

View File

@ -23,15 +23,31 @@ app.use(session({
// API
app.get("/api/getRequests", async (request, response) => {
if (!request.session) {
throw new Error ("Missing request.session")
}
var requestCount = ( request.query.count ? parseInt(request.query.count as string, 10) : 5 );
requests.getRequests(requestCount).then((val: Array<any>) => response.send(val))
.catch((e: any) => errorHandler(request,response,e));
if (request.session.user) {
requests.getRequestsVoted(requestCount,request.session.user.id).then((val: Array<any>) => response.send(val))
.catch((e: any) => errorHandler(request,response,e));
} else {
requests.getRequests(requestCount).then((val: Array<any>) => response.send(val))
.catch((e: any) => errorHandler(request,response,e));
}
});
app.get("/api/getAllRequests", async (request, response) => {
if (!request.session) {
throw new Error ("Missing request.session")
}
var requestCount = ( request.query.count ? parseInt(request.query.count as string, 10) : 5 );
requests.getAllRequests(requestCount).then((val: Array<any>) => response.send(val))
.catch((e: any) => errorHandler(request,response,e));
if (request.session.user) {
requests.getAllRequestsVoted(requestCount,request.session.user.id).then((val: Array<any>) => response.send(val))
.catch((e: any) => errorHandler(request,response,e));
} else {
requests.getAllRequests(requestCount).then((val: Array<any>) => response.send(val))
.catch((e: any) => errorHandler(request,response,e));
}
});
app.post("/api/addRequest", async (request, response) => {
@ -44,10 +60,10 @@ app.post("/api/addRequest", async (request, response) => {
if (!request.body.url) {
response.status(400);
response.send("Missing url");
return
return;
}
var url = request.body.url as string;
var requester = request.session.user.display_name;
var requester = request.session.user.id;
requests.addRequest(url,requester).then((val: [number,string]) => {
response.status(val[0]);
response.send(val[1]);
@ -60,12 +76,12 @@ app.post("/api/updateRequestState", async (request, response) => {
if (!request.body.url) {
response.status(400);
response.send("Missing url");
return
return;
}
if (!request.body.state) {
response.status(400);
response.send("Missing scoreDiff");
return
return;
}
var url = request.body.url as string;
var state = request.body.state as string;
@ -81,12 +97,12 @@ app.post("/api/updateRequestScore", async (request, response) => {
if (!request.body.url) {
response.status(400);
response.send("Missing url");
return
return;
}
if (!request.body.scoreDiff) {
response.status(400);
response.send("Missing scoreDiff");
return
return;
}
var url = request.body.url as string;
var scoreDiff = parseInt(request.body.scoreDiff as string, 10);
@ -102,7 +118,7 @@ app.post("/api/deleteRequest", async (request, response) => {
if (!request.body.url) {
response.status(400);
response.send("Missing url");
return
return;
}
var url = request.body.url as string;
requests.deleteRequest(url).then((val: [number,string]) => {
@ -112,6 +128,48 @@ app.post("/api/deleteRequest", async (request, response) => {
.catch((e: any) => errorHandler(request,response,e));
});
app.post("/api/addVote", async (request,response) => {
response.type('text/plain');
if (!request.session || !request.session.user) {
response.status(401);
response.send("Must be logged in");
return;
}
if (!request.body.url) {
response.status(400);
response.send("Missing url");
return;
}
var url = request.body.url as string;
var user = request.session.user.id;
requests.addVote(url,user).then((val: [number,string]) => {
response.status(val[0]);
response.send(val[1]);
})
.catch((e: any) => errorHandler(request,response,e));
});
app.post("/api/deleteVote", async (request,response) => {
response.type('text/plain');
if (!request.session || !request.session.user) {
response.status(401);
response.send("Must be logged in");
return;
}
if (!request.body.url) {
response.status(400);
response.send("Missing url");
return;
}
var url = request.body.url as string;
var user = request.session.user.id;
requests.deleteVote(url,user).then((val: [number,string]) => {
response.status(val[0]);
response.send(val[1]);
})
.catch((e: any) => errorHandler(request,response,e));
});
// Twitch callback
app.get("/callback", async (request, response) => {
if (request.query.error) {
@ -131,6 +189,14 @@ app.get("/callback", async (request, response) => {
if (typeof tokenResponse == 'undefined') throw new Error('tokenResponse is undefined');
request.session.tokenpair = { access_token: tokenResponse.access_token, refresh_token: tokenResponse.refresh_token };
request.session.user = (await twitch.apiRequest(request.session.tokenpair,"GET","/users")).data[0];
const updateUserQuery = {
name: "updateUser",
text: "INSERT INTO users (userid,displayName,imageUrl) VALUES ($1,$2,$3)\
ON CONFLICT (userid) DO UPDATE SET displayName = $2, imageUrl = $3"
}
var query = Object.assign(updateUserQuery,{ values: [request.session.user.id,request.session.user.display_name,request.session.user.profile_image_url] });
db.query(query);
response.redirect(307, '/');
});

View File

@ -4,7 +4,7 @@ import db from "./db";
// getRequests
const getRequestsQuery = {
name: "getRequests",
text: "SELECT * FROM requests_vw LIMIT $1"
text: "SELECT * FROM get_requests() LIMIT $1"
}
export async function getRequests(count: number) {
@ -16,7 +16,7 @@ export async function getRequests(count: number) {
// getAllRequests
const getAllRequestsQuery = {
name: "getAllRequests",
text: "SELECT * FROM requests_all_vw LIMIT $1"
text: "SELECT * FROM get_requests_all() LIMIT $1"
}
export async function getAllRequests(count: number) {
@ -25,6 +25,30 @@ export async function getAllRequests(count: number) {
.then((result: pg.QueryResult) => result.rows);
};
// getRequestsVoted
const getRequestsVotedQuery = {
name: "getRequestsVoted",
text: "SELECT * FROM get_requests_voted($2) LIMIT $1"
}
export async function getRequestsVoted(count: number, user: number) {
var query = Object.assign(getRequestsVotedQuery, { values: [count,user] });
return db.query(query)
.then((result: pg.QueryResult) => result.rows);
};
// getAllRequestsVoted
const getAllRequestsVotedQuery = {
name: "getAllRequestsVoted",
text: "SELECT * FROM get_requests_all_voted($2) LIMIT $1"
}
export async function getAllRequestsVoted(count: number,user: number) {
var query = Object.assign(getAllRequestsVotedQuery, { values: [count,user] });
return db.query(query)
.then((result: pg.QueryResult) => result.rows);
};
// addRequest
const validUrlRegexes = [
/^https:\/\/www\.youtube\.com\/watch\?v=[a-zA-Z0-9_-]{11}$/
@ -37,7 +61,7 @@ const checkRequestExistsQuery = {
const addRequestQuery = {
name: "addRequest",
text: "INSERT INTO requests (url,requester) VALUES ($1,$2)"
text: "CALL add_request($1,$2)"
}
export async function addRequest(url: string, requester: string): Promise<[number,string]> {
@ -105,3 +129,23 @@ export async function deleteRequest(url: string): Promise<[number,string]> {
return db.query(query)
.then(() => [200,"Song request deleted."] as [number,string]);
};
const checkVoteExistsQuery = {
name: "checkVoteExists",
text: "SELECT * FROM votes WHERE requesturl = $1 AND userid = $2"
}
export async function addVote(url: string, user: string): Promise<[number,string]> {
var query = Object.assign(checkVoteExistsQuery, { values: [url,user] });
var result = await db.query(query);
if (result.rowCount > 0) {
return [200,`Song already voted on`]
}
return db.query("CALL add_vote($1,$2)",[url,user])
.then(() => [201,"Successfully cast vote."] as [number,string]);
};
export async function deleteVote(url: string, user: string): Promise<[number,string]> {
return db.query("CALL delete_vote($1,$2)",[url,user])
.then(() => [201,"Successfully retracted vote."] as [number,string]);
};

View File

@ -3,13 +3,14 @@
<head>
<link rel=stylesheet href=style.css />
<title>Learn Request Queue</title>
<script>window.loggedIn = <%= it.loggedIn %></script>
<script src="main.js" defer></script>
</head>
<body>
<div id="topbar">
<div id="logo">Learn Request Queue</div>
<div id="nav-requests"><a href="/">Requests</a></div>
<%- if (it.userName) { // Logged in -%>
<%- if (it.loggedIn) { -%>
<div id="nav-addrequest"><a href="#" onclick="openAddRequestModal()">Add Request</a></div>
<div id="nav-userpic"><img src="<%= it.userProfilePicture %>" /></div>
<div id="nav-username"><%= it.userName %></div>