Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Deliver post and comments using next.js #24

Merged
merged 2 commits into from
Apr 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@ edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
sqlx = { version = "0.6.2", features = [ "runtime-actix-native-tls", "postgres", "chrono" ] }
sqlx = { version = "0.6.2", features = ["runtime-actix-native-tls", "postgres", "chrono"] }
regex = "1"
chrono = { version = "0.4", features = [ "serde" ] }
chrono = { version = "0.4", features = ["serde"] }
actix-web = "4.2.1"
actix-rt = "2.7.0"
serde = { version = "1.0", features = ["derive"] }
csv = "1.1.5"
tracing = "0.1"
tracing-log = "0.1"
tracing-subscriber = { version = "0.3", features = [ "registry", "env-filter", "fmt" ] }
tracing-subscriber = { version = "0.3", features = ["registry", "env-filter", "fmt"] }
tracing-actix-web = "0.6.1"
jsonwebtoken = "7"
serde_json = "1.0"
Expand Down
4 changes: 2 additions & 2 deletions api/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM rust:1.64.0-alpine3.16 as builder
FROM rust:1.66.1-alpine3.17 as builder

RUN apk upgrade --update-cache --available && \
apk add musl-dev openssl-dev pkgconfig && \
Expand All @@ -12,7 +12,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/src/api/target \
cargo install --path .

FROM alpine:latest
FROM alpine:3.17

ARG APP=/usr/src/app

Expand Down
129 changes: 124 additions & 5 deletions api/src/db.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use chrono::NaiveDateTime;
use chrono::{NaiveDateTime, Utc};
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use tracing::info;

#[derive(sqlx::Type, Debug, Serialize, Clone)]
#[sqlx(type_name = "varchar")]
Expand Down Expand Up @@ -368,14 +369,16 @@ pub async fn find_simple_user(
}

#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Post {
pub struct PostWithCommentsCount {
id: i32,
title: String,
content: String,
comments_count: i64,
}

pub async fn find_announcement(pool: &sqlx::PgPool) -> Result<Option<Post>, sqlx::Error> {
pub async fn find_announcement(
pool: &sqlx::PgPool,
) -> Result<Option<PostWithCommentsCount>, sqlx::Error> {
let post: Option<(i32, String, String)> =
sqlx::query_as("SELECT id, title, content FROM posts ORDER BY created_at DESC LIMIT 1")
.fetch_optional(pool)
Expand All @@ -389,7 +392,7 @@ pub async fn find_announcement(pool: &sqlx::PgPool) -> Result<Option<Post>, sqlx
.fetch_optional(pool)
.await?;
let comments_count = comment_result.map(|x| x.0).unwrap_or(0);
Ok(Some(Post {
Ok(Some(PostWithCommentsCount {
id: post.0,
content: post.2,
title: post.1,
Expand Down Expand Up @@ -598,7 +601,7 @@ pub struct SingleResultWithScramble {
pub scramble: String,
}

pub async fn fetch_record(
pub async fn find_record(
pool: &PgPool,
record_id: i32,
) -> Result<Option<RecordWithSingles>, sqlx::Error> {
Expand Down Expand Up @@ -647,3 +650,119 @@ pub async fn fetch_record(
user_slug: record_without_singles.user_slug,
}))
}

#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Comment {
id: i32,
user_name: String,
user_slug: String,
created_at: NaiveDateTime,
content: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct PostWithComments {
id: i32,
title: String,
content: String,
created_at: NaiveDateTime,
user_name: String,
user_slug: String,
comments: Vec<Comment>,
}

#[derive(Debug, Clone, sqlx::FromRow)]
struct Post {
id: i32,
title: String,
content: String,
created_at: NaiveDateTime,
user_name: String,
user_slug: String,
}

pub async fn find_post(
pool: &PgPool,
post_id: i32,
) -> Result<Option<PostWithComments>, sqlx::Error> {
let post: Option<Post> = sqlx::query_as(
"
SELECT posts.id, title, content, posts.created_at, u.name as user_name, u.slug as user_slug
FROM posts
INNER JOIN users u ON u.id = posts.user_id
WHERE posts.id = $1
",
)
.bind(post_id)
.fetch_optional(pool)
.await?;

if let Some(p) = post {
let comments: Vec<Comment> =
sqlx::query_as("
SELECT u.name as user_name, u.slug as user_slug, c.id as id, c.content as content, c.created_at as created_at
FROM comments c
INNER JOIN users u ON c.user_id = u.id
WHERE c.commentable_id = $1
AND c.commentable_type = 'Post'
ORDER BY c.created_at
")
.bind(post_id)
.fetch_all(pool)
.await?;

return Ok(Some(PostWithComments {
id: p.id,
title: p.title,
content: p.content,
created_at: p.created_at,
user_name: p.user_name,
user_slug: p.user_slug,
comments,
}));
} else {
return Ok(None);
}
}

pub async fn create_comment(
pool: &sqlx::PgPool,
post_id: i32,
user_id: i32,
content: String,
) -> Result<i32, sqlx::Error> {
let (comment_id,): (i32,) =
sqlx::query_as("INSERT INTO comments (commentable_id, commentable_type, user_id, content, created_at) VALUES ($1, 'Post', $2, $3, $4) RETURNING id")
.bind(post_id)
.bind(user_id)
.bind(content)
.bind(Utc::now().naive_utc())
.fetch_one(pool)
.await?;
Ok(comment_id)
}

pub async fn delete_comment(pool: &sqlx::PgPool, comment_id: i32) -> Result<bool, sqlx::Error> {
let result = sqlx::query("DELETE FROM comments WHERE id = $1")
.bind(comment_id)
.execute(pool)
.await?;

Ok(result.rows_affected() > 0)
}

#[derive(Debug, sqlx::FromRow)]
pub struct SimpleComment {
pub id: i32,
pub user_id: i32,
}

pub async fn fetch_comment(
pool: &sqlx::PgPool,
comment_id: i32,
) -> Result<Option<SimpleComment>, sqlx::Error> {
sqlx::query_as("SELECT id, user_id FROM comments WHERE id = $1")
.bind(comment_id)
.fetch_optional(pool)
.await
}
15 changes: 14 additions & 1 deletion api/src/handlers/mod.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
pub mod announcement;
pub mod post;
pub mod puzzle;
pub mod record;
pub mod single;
pub mod user;

use actix_web::web::{get, put, ServiceConfig};
use actix_web::web::{delete, get, post, put, ServiceConfig};

pub fn add_routes(cfg: &mut ServiceConfig) {
cfg.route(
Expand Down Expand Up @@ -36,5 +37,17 @@ pub fn add_routes(cfg: &mut ServiceConfig) {
.route(
"/api/records/{record_id}",
get().to(crate::handlers::record::record_api),
)
.route(
"/api/posts/{post_id}",
get().to(crate::handlers::post::post_api),
)
.route(
"/api/posts/{post_id}/comments",
post().to(crate::handlers::post::create_comment_api),
)
.route(
"/api/comments/{comment_id}",
delete().to(crate::handlers::post::delete_comment_api),
);
}
75 changes: 75 additions & 0 deletions api/src/handlers/post.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
use crate::app_state::AppState;
use crate::db;
use crate::error::{AppError, ErrorType};
use crate::extractors::LoggedInUser;
use actix_web::{web, HttpResponse};
use serde::Deserialize;

pub async fn post_api(
post_id: web::Path<i32>,
app_state: web::Data<AppState>,
) -> Result<HttpResponse, AppError> {
let post_id = post_id.into_inner();
let post = db::find_post(&app_state.pool, post_id).await?;

match post {
Some(post) => Ok(HttpResponse::Ok().json(post)),
None => Err(AppError {
cause: format!("can't find post {}", post_id),
message: format!("can't find post {}", post_id),
error_type: ErrorType::NotFound,
}),
}
}

#[derive(Deserialize)]
pub struct CreateComment {
content: String,
}

pub async fn create_comment_api(
post_id: web::Path<i32>,
body: web::Json<CreateComment>,
app_state: web::Data<AppState>,
current_user: LoggedInUser,
) -> Result<HttpResponse, AppError> {
let post = db::find_post(&app_state.pool, *post_id).await?;
match post {
Some(_post) => {
let comment_id = db::create_comment(
&app_state.pool,
*post_id,
current_user.user.id,
body.content.clone(),
)
.await?;
Ok(HttpResponse::Created().json(serde_json::json!({ "comment_id": comment_id })))
}
None => Err(AppError {
cause: format!("post {} not found", *post_id),
message: format!("post {} not found", *post_id),
error_type: ErrorType::NotFound,
}),
}
}

pub async fn delete_comment_api(
comment_id: web::Path<i32>,
app_state: web::Data<AppState>,
current_user: LoggedInUser,
) -> Result<HttpResponse, AppError> {
let comment = db::fetch_comment(&app_state.pool, *comment_id).await?;

match comment {
Some(c) if current_user.user.is_admin() || current_user.user.id == c.user_id => {
let deleted = db::delete_comment(&app_state.pool, *comment_id).await?;

Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": deleted })))
}
_ => Err(AppError {
cause: format!("comment {} not found", *comment_id),
message: format!("comment {} not found", *comment_id),
error_type: ErrorType::NotFound,
}),
}
}
2 changes: 1 addition & 1 deletion api/src/handlers/record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ pub async fn record_api(
app_state: web::Data<AppState>,
) -> Result<HttpResponse, AppError> {
let record_id = record_id.into_inner();
let record = db::fetch_record(&app_state.pool, record_id).await?;
let record = db::find_record(&app_state.pool, record_id).await?;

match record {
Some(record) => Ok(HttpResponse::Ok().json(record)),
Expand Down
27 changes: 24 additions & 3 deletions frontend/commons/http/HttpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,20 @@ export class RequestError extends Error {
async function request<T>(
url: string,
method: string,
jsonBody?: unknown,
jwtToken?: string
): Promise<T> {
const headers = new Headers();
if (jwtToken) {
headers.set("Authorization", `Bearer ${jwtToken}`);
}
headers.set("Content-Type", "application/json;charset=utf8");

const response = await fetch(url, { method: method, headers });
const response = await fetch(url, {
body: jsonBody ? JSON.stringify(jsonBody) : undefined,
method: method,
headers,
});
const content = await response.json();

if (response.ok) {
Expand All @@ -56,12 +62,27 @@ export async function get<T = unknown>(
url: string,
jwtToken?: string
): Promise<T> {
return request(url, "GET", jwtToken);
return request(url, "GET", undefined, jwtToken);
}

export async function post<T = unknown>(
url: string,
jsonBody: unknown,
jwtToken?: string
): Promise<T> {
return request(url, "POST", jsonBody, jwtToken);
}

export async function put<T = unknown>(
url: string,
jwtToken?: string
): Promise<T> {
return request(url, "PUT", jwtToken);
return request(url, "PUT", undefined, jwtToken);
}

export async function delete_<T = unknown>(
url: string,
jwtToken?: string
): Promise<T> {
return request(url, "DELETE", undefined, jwtToken);
}
19 changes: 13 additions & 6 deletions frontend/commons/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,19 @@ export function formatDate(time: string): string {
* @param time the time given in ISO8601
*/
export function formatDateAndTime(time: string): string {
return new Date(time).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric",
return formatDate(time) + " at " + formatTime(time);
}

/** Formats the time given in ISO8601 format
*
* e.g. "09:03"
*
* @param time the time given in ISO8601
*/
function formatTime(time: string): string {
return new Date(time).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
});
}

Expand Down
Loading