Browse Source

Initial commit

master
jbell 2 months ago
commit
801e3f90a8
12 changed files with 732 additions and 0 deletions
  1. +100
    -0
      src/api/comment.rs
  2. +153
    -0
      src/api/issue.rs
  3. +3
    -0
      src/api/mod.rs
  4. +45
    -0
      src/api/session.rs
  5. +12
    -0
      src/db/db.rs
  6. +1
    -0
      src/db/mod.rs
  7. +1
    -0
      src/models/mod.rs
  8. +149
    -0
      src/models/models.rs
  9. +38
    -0
      src/repositories/comment.rs
  10. +161
    -0
      src/repositories/issue.rs
  11. +3
    -0
      src/repositories/mod.rs
  12. +66
    -0
      src/repositories/session.rs

+ 100
- 0
src/api/comment.rs View File

@@ -0,0 +1,100 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use actix_web::{web, HttpRequest, HttpResponse, Responder};
use chrono::NaiveDateTime;
use crate::models::models::Comment;
use crate::repositories::comment::{create_comment, get_issue_comments};
use crate::repositories::session::get_username_from_session;

#[derive(Deserialize)]
pub struct AddCommentRequest {
pub content: String,
pub parent: Option<i64>,
pub issue_id: i32,
}

#[derive(Serialize)]
pub struct AddCommentResponse {
pub content: String,
pub parent: Option<i64>,
pub telegram_handle: String,
pub created_at: NaiveDateTime,
}

pub async fn add_comment(req: HttpRequest, data: web::Json<AddCommentRequest>) -> impl Responder {
if let Some(session) = req.cookie("session") {
if let Some(username) = get_username_from_session(session.value().to_string()) {
let new_comment = create_comment(data.content.clone(), data.parent, username.clone(), data.issue_id);
HttpResponse::Ok().json(
AddCommentResponse {
content: new_comment.content,
parent: new_comment.parent,
telegram_handle: username.clone(),
created_at: new_comment.created_at.unwrap()
})
} else {
HttpResponse::Unauthorized().into()
}
} else {
HttpResponse::Unauthorized().into()
}
}

#[derive(Deserialize)]
pub struct GetCommentsRequest {
pub issue_id: i32,
}

#[derive(Serialize, Clone)]
pub struct CommentWithChildren {
pub comment: Comment,
pub children: Vec<CommentWithChildren>,
}

#[derive(Serialize)]
pub struct GetCommentsResponse {
pub comments: Vec<CommentWithChildren>,
}

fn build_comment_tree(comments: Vec<Comment>) -> Vec<CommentWithChildren> {
let mut comment_map: HashMap<i64, CommentWithChildren> = HashMap::new();
let mut child_map: HashMap<i64, Vec<i64>> = HashMap::new();
let mut root_ids: Vec<i64> = Vec::new();

for comment in comments {
let id = comment.id;
let comment_with_children = CommentWithChildren {
comment: comment.clone(),
children: Vec::new(),
};
comment_map.insert(id, comment_with_children);

if let Some(parent_id) = comment.parent {
child_map.entry(parent_id).or_default().push(id);
} else {
root_ids.push(id);
}
}

fn build_tree(id: i64, comment_map: &mut HashMap<i64, CommentWithChildren>, child_map: &HashMap<i64, Vec<i64>>) -> CommentWithChildren {
let mut comment = comment_map.remove(&id).unwrap();
if let Some(child_ids) = child_map.get(&id) {
comment.children = child_ids.iter()
.map(|&child_id| build_tree(child_id, comment_map, child_map))
.collect();
}
comment
}

root_ids.into_iter()
.map(|id| build_tree(id, &mut comment_map, &child_map))
.collect()
}

pub async fn get_comments(data: web::Query<GetCommentsRequest>) -> impl Responder {
let comments = build_comment_tree(get_issue_comments(data.issue_id));

HttpResponse::Ok().json(GetCommentsResponse {
comments
})
}

+ 153
- 0
src/api/issue.rs View File

@@ -0,0 +1,153 @@
use serde::{Deserialize, Serialize};
use actix_web::{web, HttpRequest, HttpResponse, Responder};
use crate::models::models::IssueWithSummaryAndVotes;
use crate::repositories;
use crate::repositories::issue::{get_issue_vote_equity, get_vote_for_user, record_vote_for_issue, FilterOptions};
use crate::repositories::session::get_user_id;

#[derive(Serialize, Deserialize, Clone)]
enum IssueVote {
Positive,
Negative,
}

#[derive(Deserialize)]
pub struct CreateIssueRequest {
title: String,
paragraphs: Vec<String>,
}

#[derive(Serialize)]
pub struct CreateIssueResponse {
title: String,
paragraphs: Vec<String>,
}

pub async fn add_issue(req: HttpRequest, data: web::Json<CreateIssueRequest>) -> impl Responder {
if let Some(session_id) = req.cookie("session") {
if let Some(session) = repositories::session::get_session(session_id.value().to_string()) {
repositories::issue::create_issue(&data.title, &data.paragraphs, &session.username);
let resp = CreateIssueResponse {
title: String::from(&data.title),
paragraphs: data.paragraphs.clone(),
};
return HttpResponse::Ok().json(resp);
}
HttpResponse::Unauthorized().into()
} else {
HttpResponse::Unauthorized().into()
}
}

#[derive(Deserialize)]
pub struct IssuesPaginationRequest {
min_positive_votes: Option<i64>,
min_votes: Option<i64>,
offset: i32,
limit: i16,
}

#[derive(Serialize)]
pub struct IssuesPaginationResponse {
issues: Vec<IssueWithSummaryAndVotes>,
}

pub async fn list_issues(req: web::Query<IssuesPaginationRequest>) -> impl Responder {
let issues = repositories::issue::get_issues(
Option::from(FilterOptions {
min_positive_votes: req.min_positive_votes,
min_votes: req.min_votes,
}),
req.offset,
req.limit
);
HttpResponse::Ok().json(IssuesPaginationResponse { issues })
}

#[derive(Deserialize)]
pub struct GetParagraphsRequest {
issue_id: i32,
}

#[derive(Serialize)]
pub struct GetParagraphsResponse {
paragraphs: Vec<Option<String>>,
}

pub async fn get_paragraphs(query: web::Query<GetParagraphsRequest>) -> impl Responder {
let paragraphs = repositories::issue::get_paragraphs(query.issue_id);
HttpResponse::Ok().json(GetParagraphsResponse { paragraphs })
}

#[derive(Deserialize, Clone)]
pub struct VoteIssueRequest {
issue_id: i32,
vote: IssueVote,
}

#[derive(Serialize)]
pub struct VoteIssueResponse {
issue_id: i32,
equity: i64,
positive: Option<bool>,
}

pub async fn vote_issue(req: HttpRequest, data: web::Json<VoteIssueRequest>) -> impl Responder {
let positive_vote = match data.vote {
IssueVote::Positive => true,
IssueVote::Negative => false,
};
if let Some(session_id) = req.cookie("session") {
if let Some(user_id) = get_user_id(session_id.value().to_string()) {
record_vote_for_issue(positive_vote, data.issue_id, user_id);
let resp = VoteIssueResponse {
issue_id: data.issue_id,
equity: get_issue_vote_equity(data.issue_id),
positive: match data.vote {
IssueVote::Positive => Some(true),
IssueVote::Negative => Some(false),
},
};
HttpResponse::Ok().json(resp)
} else {
HttpResponse::Unauthorized().into()
}
} else {
HttpResponse::Unauthorized().into()
}
}

#[derive(Deserialize)]
pub struct GetUserVoteRequest {
issue_id: i32,
}

#[derive(Serialize)]
pub struct GetUserVoteResponse {
vote: Option<IssueVote>,
}

pub async fn get_user_vote(req: HttpRequest, query: web::Query<GetUserVoteRequest>) -> impl Responder {
let mut resp = GetUserVoteResponse { vote: None };
if let Some(session_cookie) = req.cookie("session") {
return if let Some(user_id) = get_user_id(session_cookie.value().to_string()) {
let vote = get_vote_for_user(query.issue_id, user_id);
resp = GetUserVoteResponse {
vote: match vote {
Some(val) => {
if val {
Some(IssueVote::Positive)
} else {
Some(IssueVote::Negative)
}
},
None => None,
}
};
HttpResponse::Ok().json(resp)
} else {
HttpResponse::Ok().json(GetUserVoteResponse { vote: None })
}
}
HttpResponse::Ok().json(resp)
}

+ 3
- 0
src/api/mod.rs View File

@@ -0,0 +1,3 @@
pub(crate) mod issue;
pub(crate) mod session;
pub(crate) mod comment;

+ 45
- 0
src/api/session.rs View File

@@ -0,0 +1,45 @@
use actix_web::{web, HttpRequest, HttpResponse, Responder};
use serde::{Deserialize, Serialize};
use crate::repositories;
use crate::repositories::session::get_username_from_session;

#[derive(Deserialize, Clone)]
pub struct AuthenticateRequest {
user_id: i64,
auth_date: i64,
username: String,
first_name: String,
last_name: String,
photo_url: String,
}

#[derive(Serialize)]
pub struct AuthenticateResponse {
session_id: String,
}

pub async fn authenticate(req: web::Json<AuthenticateRequest>) -> impl Responder {
let session_id = repositories::session::authenticate_session(
req.clone().user_id,
req.clone().auth_date,
req.clone().username,
req.clone().first_name,
req.clone().last_name,
req.clone().photo_url
);
let resp = AuthenticateResponse { session_id };
HttpResponse::Ok().json(resp)
}

#[derive(Serialize)]
pub struct GetUsernameResponse {
username: Option<String>,
}

pub async fn get_username(req: HttpRequest) -> impl Responder {
if let Some(session_cookie) = req.cookie("session") {
let username = get_username_from_session(session_cookie.value().to_string());
return HttpResponse::Ok().json(GetUsernameResponse { username })
}
HttpResponse::Unauthorized().into()
}

+ 12
- 0
src/db/db.rs View File

@@ -0,0 +1,12 @@
use std::env;
use diesel::Connection;
use diesel::PgConnection;
use dotenvy::dotenv;

pub fn establish_connection() -> PgConnection {
dotenv().ok();

let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
PgConnection::establish(&database_url)
.unwrap_or_else(|_| panic!("Error connecting to {}", database_url))
}

+ 1
- 0
src/db/mod.rs View File

@@ -0,0 +1 @@
pub(crate) mod db;

+ 1
- 0
src/models/mod.rs View File

@@ -0,0 +1 @@
pub mod models;

+ 149
- 0
src/models/models.rs View File

@@ -0,0 +1,149 @@
use diesel::sql_types::Integer;
use diesel::sql_types::Nullable;
use diesel::sql_types::SmallInt;
use diesel::sql_types::Timestamp;
use diesel::sql_types::Text;
use diesel::sql_types::BigInt;
use chrono::NaiveDateTime;
use diesel::{Insertable, Queryable, QueryableByName, Selectable};
use serde::Serialize;

#[derive(Queryable, Selectable, Serialize, Clone)]
#[diesel(table_name = crate::schema::issues)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct Issue {
pub id: i32,
pub title: String,
pub paragraph_count: Option<i16>,
pub telegram_handle: String,
pub created_at: Option<NaiveDateTime>
}

#[derive(QueryableByName, Serialize, Clone)]
pub struct IssueWithSummaryAndVotes {
#[diesel(sql_type = Integer)]
pub id: i32,
#[diesel(sql_type = Text)]
pub title: String,
#[diesel(sql_type = Text)]
pub summary: String,
#[diesel(sql_type = Nullable<SmallInt>)]
pub paragraph_count: Option<i16>,
#[diesel(sql_type = Text)]
pub telegram_handle: String,
#[diesel(sql_type = Nullable<Timestamp>)]
pub created_at: Option<NaiveDateTime>,
#[diesel(sql_type = BigInt)]
pub total_votes: i64,
#[diesel(sql_type = BigInt)]
pub positive_votes: i64,
}

#[derive(Insertable)]
#[diesel(table_name = crate::schema::issues)]
pub struct NewIssue {
pub title: String,
pub paragraph_count: i16,
pub telegram_handle: String,
}

#[derive(Queryable, Selectable)]
#[diesel(table_name = crate::schema::sessions)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct Session {
pub id: i64,
pub session_id: String,
pub auth_date: Option<i64>,
pub username: Option<String>,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub photo_url: Option<String>
}

#[derive(Insertable)]
#[diesel(table_name = crate::schema::sessions)]
pub struct NewSession {
pub user_id: Option<i64>,
pub session_id: String,
pub auth_date: i64,
pub username: Option<String>,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub photo_url: Option<String>
}

#[derive(Queryable, Selectable, Serialize, Clone)]
#[diesel(table_name = crate::schema::paragraphs)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct Paragraph {
pub id: i64,
pub content: String,
pub index: i32,
pub post_id: i32,
}

#[derive(Insertable)]
#[diesel(table_name = crate::schema::paragraphs)]
pub struct NewParagraph {
pub content: Option<String>,
pub index: i32,
pub post_id: i32,
}

#[derive(Queryable, Selectable, Serialize, Clone)]
#[diesel(table_name = crate::schema::issue_votes)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct IssueVote {
pub id: i64,
pub positive: Option<bool>,
pub issue_id: i32,
pub user_id: i64,
}

#[derive(Insertable)]
#[diesel(table_name = crate::schema::issue_votes)]
pub struct NewIssueVote {
pub positive: bool,
pub issue_id: i32,
pub user_id: i64,
}

#[derive(Queryable, Selectable, Serialize, Clone)]
#[diesel(table_name = crate::schema::comments)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct Comment {
pub id: i64,
pub content: String,
pub parent: Option<i64>,
pub telegram_handle: String,
pub issue_id: i32,
pub created_at: Option<NaiveDateTime>
}

#[derive(Insertable)]
#[diesel(table_name = crate::schema::comments)]
pub struct NewComment {
pub content: String,
pub parent: Option<i64>,
pub telegram_handle: String,
pub issue_id: i32,
pub created_at: NaiveDateTime
}

#[derive(Queryable, Selectable, Serialize, Clone)]
#[diesel(table_name = crate::schema::comment_votes)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct CommentVote {
pub id: i64,
pub positive: Option<bool>,
pub comment_id: i64,
pub user_id: i64,
}

#[derive(Insertable)]
#[diesel(table_name = crate::schema::comment_votes)]
pub struct NewCommentVote {
pub positive: Option<bool>,
pub comment_id: Option<i64>,
pub user_id: Option<i64>,
}

+ 38
- 0
src/repositories/comment.rs View File

@@ -0,0 +1,38 @@
use crate::db::db::establish_connection;
use crate::models::models::{Comment, NewComment};
use crate::schema::comments;
use chrono::Utc;
use diesel::{alias, ExpressionMethods, JoinOnDsl, QueryDsl, RunQueryDsl, SelectableHelper, NullableExpressionMethods};
use crate::schema;

pub fn get_issue_comments(issue_id: i32) -> Vec<Comment> {
let connection = &mut establish_connection();

let (c1, c2) = alias!(comments as c1, comments as c2);

comments::table
.left_join(c1.on(comments::parent.eq(comments::id.nullable())))
.filter(comments::issue_id.eq(issue_id))
.select(Comment::as_select())
.load::<Comment>(connection)
.unwrap_or_else(|_| Vec::new())
}

pub fn create_comment(content: String, parent: Option<i64>, telegram_handle: String, issue_id: i32) -> Comment {
let connection = &mut establish_connection();

let new_comment = NewComment {
content,
parent,
telegram_handle,
issue_id,
created_at: Utc::now().naive_utc()
};
let insertion = diesel::insert_into(comments::table)
.values(&new_comment)
.returning(Comment::as_select())
.get_result(connection)
.expect("Error saving comment");

insertion
}

+ 161
- 0
src/repositories/issue.rs View File

@@ -0,0 +1,161 @@
use crate::db::db::establish_connection;
use crate::models::models::{Issue, IssueWithSummaryAndVotes, NewIssue, NewIssueVote, NewParagraph, Paragraph};
use crate::schema::issue_votes::{issue_id, positive};
use crate::schema::paragraphs;
use crate::schema::paragraphs::{content, index};
use crate::schema::{issue_votes, issues};
use chrono::NaiveDateTime;
use diesel;
use diesel::associations::HasTable;
use diesel::dsl::count;
use diesel::result::DatabaseErrorKind;
use diesel::result::Error::DatabaseError;
use diesel::{sql_query, BoolExpressionMethods, ExpressionMethods, QueryDsl, RunQueryDsl, SelectableHelper};
use serde::Deserialize;

#[derive(Deserialize)]
pub struct FilterOptions {
pub min_positive_votes: Option<i64>,
pub min_votes: Option<i64>,
}

pub fn get_issues(filters: Option<FilterOptions>, offset: i32, limit: i16) -> Vec<IssueWithSummaryAndVotes> {
let connection = &mut establish_connection();

let query = sql_query("select i.id, title, content as summary, i.paragraph_count, telegram_handle, i.created_at,
count(v.id) as total_votes,
sum(case when v.positive then 1 else 0 end) as positive_votes
from issues i
inner join paragraphs p on i.id = p.post_id
inner join issue_votes v on i.id = v.issue_id
where p.index = 0
group by v.issue_id, title, telegram_handle, content, i.id;");

let mut issues_with_content: Vec<IssueWithSummaryAndVotes> = query
.load::<IssueWithSummaryAndVotes>(connection)
.unwrap_or_default()
.into_iter()
.collect();

if let Some(filter_info) = filters {
if let Some(positive_votes) = filter_info.min_positive_votes {
issues_with_content = issues_with_content
.iter()
.filter(|i| i.positive_votes >= positive_votes)
.map(|i| i.clone())
.collect()
}
if let Some(min_votes) = filter_info.min_votes {
issues_with_content = issues_with_content
.iter()
.filter(|i| i.total_votes >= min_votes)
.map(|i| i.clone())
.collect()
}
}

let len = issues_with_content.len();
let i1 = offset.max(0) as usize;
let i2 = i1.saturating_add(limit as usize).min(len);
issues_with_content[i1..i2].to_vec()
}

pub fn create_issue(title: &String, paragraphs: &Vec<String>, telegram_handle: &Option<String>) -> Option<Issue> {
let connection = &mut establish_connection();

if let Some(tg_handle) = telegram_handle {
let new_issue = NewIssue {
title: title.to_string(),
paragraph_count: paragraphs.len() as i16,
telegram_handle: tg_handle.to_string(),
};
let insertion = diesel::insert_into(issues::table)
.values(&new_issue)
.returning(Issue::as_select())
.get_result(connection)
.expect("Error saving new issue");

let _ = diesel::insert_into(paragraphs::table)
.values(paragraphs.iter().enumerate().map(|(i, p)|
NewParagraph {
content: Some(p.clone()),
index: i as i32,
post_id: insertion.id
}).collect::<Vec<NewParagraph>>())
.returning(Paragraph::as_select())
.get_results(connection)
.expect("Error saving paragraphs");

return Some(insertion)
}
None
}

pub fn get_paragraphs(other_issue_id: i32) -> Vec<Option<String>> {
let connection = &mut establish_connection();
paragraphs::table
.filter(paragraphs::post_id.eq(other_issue_id))
.order(index)
.select(content)
.load::<String>(connection)
.unwrap_or_default()
.into_iter()
.map(Some)
.collect::<Vec<Option<String>>>()
}


pub fn record_vote_for_issue(other_positive: bool, other_issue_id: i32, user_id: i64) -> bool {
let connection = &mut establish_connection();

let result = diesel::insert_into(issue_votes::table)
.values(NewIssueVote { positive: other_positive, issue_id: other_issue_id, user_id })
.execute(connection);

match result {
Ok(_) => true,
Err(DatabaseError(kind, _)) => {
if let DatabaseErrorKind::UniqueViolation = kind {
let _ = diesel::update(
issue_votes::table
.filter(
issue_votes::user_id.eq(user_id).and(issue_votes::issue_id.eq(issue_id))
)
)
.set(issue_votes::positive.eq(positive))
.execute(connection);
return true
}
false
},
_ => false
}
}

pub fn get_issue_vote_equity(issue_identifier: i32) -> i64 {
let connection = &mut establish_connection();
let positive_votes: i64 = issue_votes::table
.filter(issue_votes::issue_id.eq(issue_identifier).and(issue_votes::positive.eq(true)))
.count()
.get_result(connection)
.unwrap();
let negative_votes: i64 = issue_votes::table
.filter(issue_votes::issue_id.eq(issue_identifier).and(issue_votes::positive.eq(false)))
.count()
.get_result(connection)
.unwrap();

positive_votes - negative_votes
}

pub fn get_vote_for_user(other_issue_id: i32, other_user_id: i64) -> Option<bool> {
let connection = &mut establish_connection();
let result: Option<bool> = issue_votes::table
.filter(issue_votes::issue_id.eq(other_issue_id).and(issue_votes::user_id.eq(other_user_id)))
.limit(1)
.select(issue_votes::positive)
.get_result::<Option<bool>>(connection)
.unwrap_or(None);

result
}

+ 3
- 0
src/repositories/mod.rs View File

@@ -0,0 +1,3 @@
pub(crate) mod issue;
pub(crate) mod session;
pub(crate) mod comment;

+ 66
- 0
src/repositories/session.rs View File

@@ -0,0 +1,66 @@
use diesel::{OptionalExtension, QueryDsl, RunQueryDsl, SelectableHelper};
use diesel::ExpressionMethods;
use uuid::Uuid;
use crate::db::db::establish_connection;
use crate::models::models::{NewSession, Session};
use crate::schema::sessions;
use crate::schema::sessions::dsl::sessions as sessions_dsl;
use crate::schema::sessions::session_id;

pub fn get_session(sess_id: String) -> Option<Session> {
let connection = &mut establish_connection();
sessions_dsl
.filter(session_id.eq(sess_id))
.select(Session::as_select())
.first(connection)
.optional()
.expect("Error loading session")
}

pub fn authenticate_session(user_id: i64, auth_date: i64, username: String, first_name: String, last_name: String, photo_url: String) -> String {
let sess_id: Uuid = Uuid::new_v4();

let connection = &mut establish_connection();
let new_session = NewSession {
user_id: Some(user_id),
session_id: sess_id.clone().to_string(),
auth_date,
username: Some(username),
first_name: Some(first_name),
last_name: Some(last_name),
photo_url: Some(photo_url),
};

let result = diesel::insert_into(sessions::table)
.values(&new_session)
.returning(Session::as_select())
.get_result(connection)
.unwrap();

result.session_id
}

pub fn get_user_id(sess_id: String) -> Option<i64> {
let connection = &mut establish_connection();
let user_id: Option<i64> = sessions::table
.filter(session_id.eq(sess_id))
.limit(1)
.select(sessions::user_id)
.get_result(connection)
.optional()
.unwrap_or(None);

user_id
}

pub fn get_username_from_session(sess_id: String) -> Option<String> {
let connection = &mut establish_connection();
let username = sessions::table
.filter(sessions::session_id.eq(sess_id))
.limit(1)
.select(sessions::username)
.get_result(connection)
.unwrap_or_default();

username
}

Loading…
Cancel
Save