diff --git a/db/00-states.sql b/db/00-states.sql index 95751b5..ca6fecb 100644 --- a/db/00-states.sql +++ b/db/00-states.sql @@ -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); diff --git a/db/05-config.sql b/db/05-config.sql new file mode 100644 index 0000000..edf6d9e --- /dev/null +++ b/db/05-config.sql @@ -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); diff --git a/db/10-requests.sql b/db/10-requests.sql deleted file mode 100644 index 070ab26..0000000 --- a/db/10-requests.sql +++ /dev/null @@ -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) -); diff --git a/db/10-users.sql b/db/10-users.sql new file mode 100644 index 0000000..dc0aef7 --- /dev/null +++ b/db/10-users.sql @@ -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) +); diff --git a/db/20-requests.sql b/db/20-requests.sql new file mode 100644 index 0000000..c3f3cf5 --- /dev/null +++ b/db/20-requests.sql @@ -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) +); diff --git a/db/30-votes.sql b/db/30-votes.sql new file mode 100644 index 0000000..26c083d --- /dev/null +++ b/db/30-votes.sql @@ -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) +); diff --git a/db/40-scores.sql b/db/40-scores.sql new file mode 100644 index 0000000..74f7e3f --- /dev/null +++ b/db/40-scores.sql @@ -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) +); diff --git a/db/20-sessions.sql b/db/80-sessions.sql similarity index 100% rename from db/20-sessions.sql rename to db/80-sessions.sql diff --git a/db/90-views.sql b/db/90-views.sql index 862a5e2..327112b 100644 --- a/db/90-views.sql +++ b/db/90-views.sql @@ -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; diff --git a/db/95-procedures.sql b/db/95-procedures.sql new file mode 100644 index 0000000..17a3f6b --- /dev/null +++ b/db/95-procedures.sql @@ -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(); + $$; diff --git a/db/testdata.sql b/db/testdata.sql index 64d53e9..a06936a 100644 --- a/db/testdata.sql +++ b/db/testdata.sql @@ -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(); diff --git a/public/main.js b/public/main.js index 87ad2d4..c43eaef 100644 --- a/public/main.js +++ b/public/main.js @@ -15,13 +15,21 @@ function getRequests(count,allRequests) { function buildTable(requests,allRequests) { var requestsDivHTML = ''; - if (allRequests) requestsDivHTML += ``; + if (allRequests) requestsDivHTML += `"; for (request of requests) { requestsDivHTML += `\ \ `; - if (allRequests) requestsDivHTML += ``; + if (allRequests) requestsDivHTML += ``; + if (window.loggedIn) { + if (request.voted) { + requestsDivHTML += ``; + } else { + requestsDivHTML += ``; + } + } requestsDivHTML += ""; } requestsDivHTML += "
URLRequesterScoreState
State`; + if (window.loggedIn) requestsDivHTML += `Vote`; requestsDivHTML += "
${request.url}${request.requester}${request.score}${request.state}
${request.state}
"; @@ -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); }); } diff --git a/public/style.css b/public/style.css index 59a1850..72c9637 100644 --- a/public/style.css +++ b/public/style.css @@ -142,3 +142,7 @@ div#nav-userpic { .modalClose a { text-decoration: none; } + +.request-vote > button { + width: 100%; +} diff --git a/src/app.ts b/src/app.ts index 866d170..e5baef4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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) => response.send(val)) - .catch((e: any) => errorHandler(request,response,e)); + if (request.session.user) { + requests.getRequestsVoted(requestCount,request.session.user.id).then((val: Array) => response.send(val)) + .catch((e: any) => errorHandler(request,response,e)); + } else { + requests.getRequests(requestCount).then((val: Array) => 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) => response.send(val)) - .catch((e: any) => errorHandler(request,response,e)); + if (request.session.user) { + requests.getAllRequestsVoted(requestCount,request.session.user.id).then((val: Array) => response.send(val)) + .catch((e: any) => errorHandler(request,response,e)); + } else { + requests.getAllRequests(requestCount).then((val: Array) => 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, '/'); }); diff --git a/src/requests.ts b/src/requests.ts index fbc4754..b3421b2 100644 --- a/src/requests.ts +++ b/src/requests.ts @@ -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]); +}; diff --git a/views/main.eta b/views/main.eta index e6ae26a..ece0f4c 100644 --- a/views/main.eta +++ b/views/main.eta @@ -3,13 +3,14 @@ Learn Request Queue +
- <%- if (it.userName) { // Logged in -%> + <%- if (it.loggedIn) { -%>