diff --git a/.gitignore b/.gitignore index 0628dc4..bc1c2ac 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ .vscode .env .test.env -TODO.md \ No newline at end of file +TODO.md +digest.txt \ No newline at end of file diff --git a/Makefile b/Makefile index 5d0bdbb..bfe9b8c 100644 --- a/Makefile +++ b/Makefile @@ -47,10 +47,18 @@ db-down: db-migrate-up: DATABASE_URL="$(DATABASE_URL)" diesel migration run +.PHONY: db-secondary-migrate-up +db-secondary-migrate-up: + DATABASE_URL="$(DATABASE_URL)" diesel migration --migration-dir secondary_migrations run + .PHONY: db-migrate-down db-migrate-down: DATABASE_URL="$(DATABASE_URL)" diesel migration revert +.PHONY: db-secondary-migrate-down +db-secondary-migrate-down: + DATABASE_URL="$(DATABASE_URL)" diesel migration --migration-dir secondary_migrations revert + .PHONY: db-migrate-redo db-migrate-redo: DATABASE_URL="$(DATABASE_URL)" diesel migration redo diff --git a/secondary_migrations/2025-01-19-212212_issues-to-tasks/down.sql b/secondary_migrations/2025-01-19-212212_issues-to-tasks/down.sql new file mode 100644 index 0000000..2c4803c --- /dev/null +++ b/secondary_migrations/2025-01-19-212212_issues-to-tasks/down.sql @@ -0,0 +1,7 @@ +DELETE FROM tasks +WHERE type = 'dev' + AND id IN (SELECT id FROM issues); + +DELETE FROM users_projects_roles +WHERE role_id = 2 + AND user_id IN (SELECT DISTINCT assignee_id FROM issues WHERE assignee_id IS NOT NULL); diff --git a/secondary_migrations/2025-01-19-212212_issues-to-tasks/up.sql b/secondary_migrations/2025-01-19-212212_issues-to-tasks/up.sql new file mode 100644 index 0000000..e849226 --- /dev/null +++ b/secondary_migrations/2025-01-19-212212_issues-to-tasks/up.sql @@ -0,0 +1,62 @@ +-- Migrate issues to tasks + +INSERT INTO tasks ( + id, + number, + repository_id, + project_id, + title, + description, + labels, + open, + is_certified, + assignee_user_id, + issue_created_at, + issue_closed_at, + created_at, + updated_at, + type, + status, + url +) +SELECT + i.id, + i.number, + i.repository_id, + r.project_id, + i.title, + i.description, + i.labels, + i.open, + COALESCE(i.certified, false) AS is_certified, + i.assignee_id AS assignee_user_id, + i.issue_created_at, + i.issue_closed_at, + i.created_at, + i.updated_at, + 'dev' AS type, + CASE + WHEN i.open = TRUE AND i.assignee_id IS NULL THEN 'open' + WHEN i.open = TRUE AND i.assignee_id IS NOT NULL THEN 'in-progress' + WHEN i.open = FALSE THEN 'completed' + END AS status, + CONCAT( + 'https://github.com/', + r.slug, + '/issues/', + i.number + ) AS url +FROM + issues i +JOIN + repositories r +ON + i.repository_id = r.id; + +-- add CONTRIBUTOR role + +INSERT INTO users_projects_roles (user_id, role_id) +SELECT DISTINCT assignee_id AS user_id, 2 AS role_id +FROM issues +WHERE assignee_id IS NOT NULL +ON CONFLICT (user_id, project_id, role_id) DO NOTHING; diff --git a/secondary_migrations/2025-01-19-215731_contributor-role/down.sql b/secondary_migrations/2025-01-19-215731_contributor-role/down.sql new file mode 100644 index 0000000..dd524fe --- /dev/null +++ b/secondary_migrations/2025-01-19-215731_contributor-role/down.sql @@ -0,0 +1,2 @@ +DROP TRIGGER IF EXISTS trigger_assign_role_to_user ON tasks; +DROP FUNCTION IF EXISTS assign_role_to_user; diff --git a/secondary_migrations/2025-01-19-215731_contributor-role/up.sql b/secondary_migrations/2025-01-19-215731_contributor-role/up.sql new file mode 100644 index 0000000..c0bab5a --- /dev/null +++ b/secondary_migrations/2025-01-19-215731_contributor-role/up.sql @@ -0,0 +1,21 @@ +CREATE OR REPLACE FUNCTION assign_role_to_user() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.assignee_user_id IS NOT NULL THEN + INSERT INTO users_projects_roles (user_id, role_id) + SELECT NEW.assignee_user_id, 2 + WHERE NOT EXISTS ( + SELECT 1 + FROM users_projects_roles + WHERE user_id = NEW.assignee_user_id + AND role_id = 2 + ); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_assign_role_to_user +AFTER INSERT OR UPDATE OF assignee_user_id ON tasks +FOR EACH ROW +EXECUTE FUNCTION assign_role_to_user(); diff --git a/secondary_migrations/2025-01-19-223138_tasks-status/down.sql b/secondary_migrations/2025-01-19-223138_tasks-status/down.sql new file mode 100644 index 0000000..61a86fe --- /dev/null +++ b/secondary_migrations/2025-01-19-223138_tasks-status/down.sql @@ -0,0 +1,2 @@ +DROP TRIGGER IF EXISTS update_task_status_trigger ON tasks; +DROP FUNCTION IF EXISTS update_task_status; \ No newline at end of file diff --git a/secondary_migrations/2025-01-19-223138_tasks-status/up.sql b/secondary_migrations/2025-01-19-223138_tasks-status/up.sql new file mode 100644 index 0000000..6393bd4 --- /dev/null +++ b/secondary_migrations/2025-01-19-223138_tasks-status/up.sql @@ -0,0 +1,16 @@ + +CREATE OR REPLACE FUNCTION update_task_status() RETURNS TRIGGER AS $$ +BEGIN + NEW.status := CASE + WHEN NEW.open = TRUE AND NEW.assignee_user_id IS NULL THEN 'open' + WHEN NEW.open = TRUE AND NEW.assignee_user_id IS NOT NULL THEN 'in-progress' + WHEN NEW.open = FALSE THEN 'completed' + END; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER update_task_status_trigger +BEFORE INSERT OR UPDATE ON tasks +FOR EACH ROW +EXECUTE FUNCTION update_task_status(); \ No newline at end of file diff --git a/secondary_migrations/2025-01-27-234215_add_unique_constraint_to_tasks/down.sql b/secondary_migrations/2025-01-27-234215_add_unique_constraint_to_tasks/down.sql new file mode 100644 index 0000000..7f14109 --- /dev/null +++ b/secondary_migrations/2025-01-27-234215_add_unique_constraint_to_tasks/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE tasks +DROP CONSTRAINT tasks_repo_number_unique; diff --git a/secondary_migrations/2025-01-27-234215_add_unique_constraint_to_tasks/up.sql b/secondary_migrations/2025-01-27-234215_add_unique_constraint_to_tasks/up.sql new file mode 100644 index 0000000..de54584 --- /dev/null +++ b/secondary_migrations/2025-01-27-234215_add_unique_constraint_to_tasks/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE tasks +ADD CONSTRAINT tasks_repo_number_unique UNIQUE (repository_id, number); diff --git a/secondary_migrations/2025-01-29-212647_repository-id/down.sql b/secondary_migrations/2025-01-29-212647_repository-id/down.sql new file mode 100644 index 0000000..a3b0aa8 --- /dev/null +++ b/secondary_migrations/2025-01-29-212647_repository-id/down.sql @@ -0,0 +1,3 @@ + +ALTER TABLE tasks + DROP CONSTRAINT tasks_repository_id_fkey; diff --git a/secondary_migrations/2025-01-29-212647_repository-id/up.sql b/secondary_migrations/2025-01-29-212647_repository-id/up.sql new file mode 100644 index 0000000..f2d97f0 --- /dev/null +++ b/secondary_migrations/2025-01-29-212647_repository-id/up.sql @@ -0,0 +1,6 @@ + +ALTER TABLE tasks + ADD CONSTRAINT tasks_repository_id_fkey + FOREIGN KEY (repository_id) + REFERENCES repositories(id) + ON DELETE CASCADE; \ No newline at end of file diff --git a/secondary_migrations/2025-01-29-212911_project-id/down.sql b/secondary_migrations/2025-01-29-212911_project-id/down.sql new file mode 100644 index 0000000..dec2645 --- /dev/null +++ b/secondary_migrations/2025-01-29-212911_project-id/down.sql @@ -0,0 +1,8 @@ + +DROP TRIGGER IF EXISTS trigger_set_project_id ON tasks; +DROP FUNCTION IF EXISTS set_project_id_from_repository; +ALTER TABLE tasks + DROP CONSTRAINT tasks_repository_id_fkey; + +ALTER TABLE repositories + DROP CONSTRAINT unique_id_project_id; diff --git a/secondary_migrations/2025-01-29-212911_project-id/up.sql b/secondary_migrations/2025-01-29-212911_project-id/up.sql new file mode 100644 index 0000000..52a14c0 --- /dev/null +++ b/secondary_migrations/2025-01-29-212911_project-id/up.sql @@ -0,0 +1,16 @@ +ALTER TABLE repositories + ADD CONSTRAINT unique_id_project_id UNIQUE (id, project_id); + +CREATE OR REPLACE FUNCTION set_project_id_from_repository() +RETURNS TRIGGER AS $$ +BEGIN + NEW.project_id := (SELECT project_id FROM repositories WHERE id = NEW.repository_id); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_set_project_id +BEFORE INSERT OR UPDATE ON tasks +FOR EACH ROW +WHEN (NEW.repository_id IS NOT NULL AND NEW.project_id IS NULL) +EXECUTE FUNCTION set_project_id_from_repository(); \ No newline at end of file diff --git a/migrations/2025-01-30-000000_user_subscriptions/down.sql b/secondary_migrations/2025-01-30-000000_user_subscriptions/down.sql similarity index 100% rename from migrations/2025-01-30-000000_user_subscriptions/down.sql rename to secondary_migrations/2025-01-30-000000_user_subscriptions/down.sql diff --git a/migrations/2025-01-30-000000_user_subscriptions/up.sql b/secondary_migrations/2025-01-30-000000_user_subscriptions/up.sql similarity index 100% rename from migrations/2025-01-30-000000_user_subscriptions/up.sql rename to secondary_migrations/2025-01-30-000000_user_subscriptions/up.sql diff --git a/migrations/2025-01-30-000001_notifications/down.sql b/secondary_migrations/2025-01-30-000001_notifications/down.sql similarity index 100% rename from migrations/2025-01-30-000001_notifications/down.sql rename to secondary_migrations/2025-01-30-000001_notifications/down.sql diff --git a/migrations/2025-01-30-000001_notifications/up.sql b/secondary_migrations/2025-01-30-000001_notifications/up.sql similarity index 100% rename from migrations/2025-01-30-000001_notifications/up.sql rename to secondary_migrations/2025-01-30-000001_notifications/up.sql diff --git a/migrations/2025-01-30-000003_add_email_notifications_flag/down.sql b/secondary_migrations/2025-01-30-000003_add_email_notifications_flag/down.sql similarity index 100% rename from migrations/2025-01-30-000003_add_email_notifications_flag/down.sql rename to secondary_migrations/2025-01-30-000003_add_email_notifications_flag/down.sql diff --git a/migrations/2025-01-30-000003_add_email_notifications_flag/up.sql b/secondary_migrations/2025-01-30-000003_add_email_notifications_flag/up.sql similarity index 100% rename from migrations/2025-01-30-000003_add_email_notifications_flag/up.sql rename to secondary_migrations/2025-01-30-000003_add_email_notifications_flag/up.sql diff --git a/migrations/2025-01-30-000004_notification_schedule/down.sql b/secondary_migrations/2025-01-30-000004_notification_schedule/down.sql similarity index 100% rename from migrations/2025-01-30-000004_notification_schedule/down.sql rename to secondary_migrations/2025-01-30-000004_notification_schedule/down.sql diff --git a/migrations/2025-01-30-000004_notification_schedule/up.sql b/secondary_migrations/2025-01-30-000004_notification_schedule/up.sql similarity index 100% rename from migrations/2025-01-30-000004_notification_schedule/up.sql rename to secondary_migrations/2025-01-30-000004_notification_schedule/up.sql diff --git a/secondary_migrations/2025-07-26-070123_create_task_comments/down.sql b/secondary_migrations/2025-07-26-070123_create_task_comments/down.sql new file mode 100644 index 0000000..d69689d --- /dev/null +++ b/secondary_migrations/2025-07-26-070123_create_task_comments/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DROP TABLE IF EXISTS task_comments; \ No newline at end of file diff --git a/secondary_migrations/2025-07-26-070123_create_task_comments/up.sql b/secondary_migrations/2025-07-26-070123_create_task_comments/up.sql new file mode 100644 index 0000000..0b7b4ac --- /dev/null +++ b/secondary_migrations/2025-07-26-070123_create_task_comments/up.sql @@ -0,0 +1,13 @@ +-- Your SQL goes here +CREATE TABLE IF NOT EXISTS task_comments ( + id SERIAL PRIMARY KEY, + content TEXT NOT NULL, + task_id INT REFERENCES tasks(id) ON DELETE CASCADE NOT NULL, + user_id INT REFERENCES users(id) ON DELETE CASCADE NOT NULL, + parent_comment_id INT REFERENCES task_comments(id) ON DELETE CASCADE, -- For threaded replies + created_at TIMESTAMP WITH TIME ZONE DEFAULT (now() AT TIME ZONE 'utc') NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NULL +); + +-- Add an index on task_id for faster comment lookups +CREATE INDEX idx_task_comments_task_id ON task_comments(task_id); \ No newline at end of file diff --git a/secondary_migrations/2025-07-27-025644_update_task_vote_trigger/down.sql b/secondary_migrations/2025-07-27-025644_update_task_vote_trigger/down.sql new file mode 100644 index 0000000..ec4f0a3 --- /dev/null +++ b/secondary_migrations/2025-07-27-025644_update_task_vote_trigger/down.sql @@ -0,0 +1,31 @@ +-- This file should undo anything in `up.sql` +-- Revert to the original version that doesn't handle UPDATE +CREATE OR REPLACE FUNCTION update_task_votes() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW.vote > 0 THEN + UPDATE tasks SET upvotes = upvotes + 1 WHERE id = NEW.task_id; + ELSIF NEW.vote < 0 THEN + UPDATE tasks SET downvotes = downvotes + 1 WHERE id = NEW.task_id; + END IF; + + ELSIF TG_OP = 'DELETE' THEN + IF OLD.vote > 0 THEN + UPDATE tasks SET upvotes = upvotes - 1 WHERE id = OLD.task_id; + ELSIF OLD.vote < 0 THEN + UPDATE tasks SET downvotes = downvotes - 1 WHERE id = OLD.task_id; + END IF; + END IF; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +-- Drop and recreate original trigger +DROP TRIGGER IF EXISTS trigger_update_task_votes ON tasks_votes; + +CREATE TRIGGER trigger_update_task_votes +AFTER INSERT OR DELETE ON tasks_votes +FOR EACH ROW +EXECUTE FUNCTION update_task_votes(); diff --git a/secondary_migrations/2025-07-27-025644_update_task_vote_trigger/up.sql b/secondary_migrations/2025-07-27-025644_update_task_vote_trigger/up.sql new file mode 100644 index 0000000..2db14a7 --- /dev/null +++ b/secondary_migrations/2025-07-27-025644_update_task_vote_trigger/up.sql @@ -0,0 +1,45 @@ +-- Drop and replace the trigger function +CREATE OR REPLACE FUNCTION update_task_votes() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW.vote > 0 THEN + UPDATE tasks SET upvotes = upvotes + 1 WHERE id = NEW.task_id; + ELSIF NEW.vote < 0 THEN + UPDATE tasks SET downvotes = downvotes + 1 WHERE id = NEW.task_id; + END IF; + + ELSIF TG_OP = 'DELETE' THEN + IF OLD.vote > 0 THEN + UPDATE tasks SET upvotes = upvotes - 1 WHERE id = OLD.task_id; + ELSIF OLD.vote < 0 THEN + UPDATE tasks SET downvotes = downvotes - 1 WHERE id = OLD.task_id; + END IF; + + ELSIF TG_OP = 'UPDATE' THEN + IF OLD.vote != NEW.vote THEN + IF OLD.vote > 0 THEN + UPDATE tasks SET upvotes = upvotes - 1 WHERE id = OLD.task_id; + ELSIF OLD.vote < 0 THEN + UPDATE tasks SET downvotes = downvotes - 1 WHERE id = OLD.task_id; + END IF; + + IF NEW.vote > 0 THEN + UPDATE tasks SET upvotes = upvotes + 1 WHERE id = NEW.task_id; + ELSIF NEW.vote < 0 THEN + UPDATE tasks SET downvotes = downvotes + 1 WHERE id = NEW.task_id; + END IF; + END IF; + END IF; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +-- Recreate the trigger to include UPDATE +DROP TRIGGER IF EXISTS trigger_update_task_votes ON tasks_votes; + +CREATE TRIGGER trigger_update_task_votes +AFTER INSERT OR DELETE OR UPDATE ON tasks_votes +FOR EACH ROW +EXECUTE FUNCTION update_task_votes(); diff --git a/secondary_migrations/2025-07-27-061329_add_comment_status/down.sql b/secondary_migrations/2025-07-27-061329_add_comment_status/down.sql new file mode 100644 index 0000000..e692bbc --- /dev/null +++ b/secondary_migrations/2025-07-27-061329_add_comment_status/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE task_comments +DROP COLUMN status; \ No newline at end of file diff --git a/secondary_migrations/2025-07-27-061329_add_comment_status/up.sql b/secondary_migrations/2025-07-27-061329_add_comment_status/up.sql new file mode 100644 index 0000000..c14fcd4 --- /dev/null +++ b/secondary_migrations/2025-07-27-061329_add_comment_status/up.sql @@ -0,0 +1,5 @@ +-- Your SQL goes here +-- Add a status column to track if a comment is active or soft-deleted +ALTER TABLE task_comments +ADD COLUMN status TEXT NOT NULL DEFAULT 'active' +CONSTRAINT comment_status_check CHECK (status IN ('active', 'deleted')); diff --git a/secondary_migrations/2026-01-14-075034_add_user_socials_bio_skills_interests/down.sql b/secondary_migrations/2026-01-14-075034_add_user_socials_bio_skills_interests/down.sql new file mode 100644 index 0000000..93c1d9a --- /dev/null +++ b/secondary_migrations/2026-01-14-075034_add_user_socials_bio_skills_interests/down.sql @@ -0,0 +1,12 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE users + DROP CONSTRAINT IF EXISTS users_bio_not_blank, + DROP CONSTRAINT IF EXISTS users_telegram_not_blank, + DROP CONSTRAINT IF EXISTS users_twitter_not_blank; + +ALTER TABLE users + DROP COLUMN IF EXISTS twitter, + DROP COLUMN IF EXISTS telegram, + DROP COLUMN IF EXISTS interests, + DROP COLUMN IF EXISTS skills, + DROP COLUMN IF EXISTS bio; diff --git a/secondary_migrations/2026-01-14-075034_add_user_socials_bio_skills_interests/up.sql b/secondary_migrations/2026-01-14-075034_add_user_socials_bio_skills_interests/up.sql new file mode 100644 index 0000000..dcb5a55 --- /dev/null +++ b/secondary_migrations/2026-01-14-075034_add_user_socials_bio_skills_interests/up.sql @@ -0,0 +1,14 @@ +-- Your SQL goes here +ALTER TABLE users ADD COLUMN bio TEXT; +ALTER TABLE users ADD COLUMN skills TEXT[]; +ALTER TABLE users ADD COLUMN interests TEXT[]; +ALTER TABLE users ADD COLUMN telegram TEXT; +ALTER TABLE users ADD COLUMN twitter TEXT; + +ALTER TABLE users + ADD CONSTRAINT users_bio_not_blank + CHECK (bio IS NULL OR length(btrim(bio)) > 0), + ADD CONSTRAINT users_telegram_not_blank + CHECK (telegram IS NULL OR length(btrim(telegram)) > 0), + ADD CONSTRAINT users_twitter_not_blank + CHECK (twitter IS NULL OR length(btrim(twitter)) > 0); \ No newline at end of file diff --git a/src/api/comments/db.rs b/src/api/comments/db.rs new file mode 100644 index 0000000..084b81c --- /dev/null +++ b/src/api/comments/db.rs @@ -0,0 +1,149 @@ +use diesel::prelude::*; +use crate::schema::{task_comments, users}; +use crate::db::{errors::DBError, pool::{DBAccess, DBAccessor}}; +use crate::api::users::models::User; +use super::models::{Comment, NewComment, CommentResponse}; +use crate::schema::task_comments::dsl as comments_dsl; +use chrono::Utc; + +fn deleted_user_placeholder() -> User { + User { + id: -1, + username: "[deleted]".to_string(), + avatar: None, + github_id: None, + created_at: Utc::now(), + updated_at: None, + email: None, + email_notifications_enabled: false, + bio: None, + skills: None, + interests: None, + telegram: None, + twitter: None, + } +} + +pub trait DBComment: Send + Sync + Clone + 'static { + fn by_task_id(&self, task_id_param: i32) -> Result, DBError>; + fn by_comment_id(&self, id: i32) -> Result, DBError>; + fn create(&self, comment: &NewComment) -> Result; + fn has_replies(&self, comment_id: i32) -> Result; + fn hard_delete(&self, id: i32) -> Result; + fn soft_delete(&self, id: i32) -> Result; +} + +impl DBComment for DBAccess { + fn by_task_id(&self, task_id_param: i32) -> Result, DBError> { + let conn = &mut self.get_db_conn(); + let comments_with_users = comments_dsl::task_comments + .inner_join(users::table.on(comments_dsl::user_id.eq(users::id))) + .filter(comments_dsl::task_id.eq(task_id_param)) + .order(comments_dsl::created_at.asc()) + .select((Comment::as_select(), User::as_select())) + .load::<(Comment, User)>(conn)?; + + let results = comments_with_users.into_iter().map(|(comment, user)| { + if comment.status == "deleted" { + CommentResponse { + id: comment.id, + status: comment.status, + content: "[deleted]".to_string(), + created_at: comment.created_at, + parent_comment_id: comment.parent_comment_id, + user: deleted_user_placeholder() + } + } else { + CommentResponse { + id: comment.id, + status: comment.status, + content: comment.content, + created_at: comment.created_at, + parent_comment_id: comment.parent_comment_id, + user, + } + } + }).collect(); + Ok(results) + } + + fn by_comment_id(&self, id: i32) -> Result, DBError> { + let conn = &mut self.get_db_conn(); + let result = task_comments::table + .inner_join(users::table.on(task_comments::user_id.eq(users::id))) + .filter(task_comments::id.eq(id)) + .select((Comment::as_select(), User::as_select())) + .first::<(Comment, User)>(conn) + .optional()?; + + Ok(result.map(|(comment, user)| + if comment.status == "deleted" { + CommentResponse { + id: comment.id, + status: comment.status, + content: "[deleted]".to_string(), + created_at: comment.created_at, + parent_comment_id: comment.parent_comment_id, + user: deleted_user_placeholder() + } + } else { + CommentResponse { + id: comment.id, + status: comment.status, + content: comment.content, + created_at: comment.created_at, + parent_comment_id: comment.parent_comment_id, + user, + } + } + + )) + } + + fn create(&self, comment: &NewComment) -> Result { + let conn = &mut self.get_db_conn(); + let new_comment: Comment = diesel::insert_into(task_comments::table) + .values(comment) + .get_result(conn) + .map_err(DBError::from)?; + + let user = users::table + .find(new_comment.user_id) + .first::(conn)?; + + Ok(CommentResponse { + id: new_comment.id, + content: new_comment.content, + created_at: new_comment.created_at, + user, + parent_comment_id: new_comment.parent_comment_id, + status: new_comment.status + }) + } + + fn has_replies(&self, comment_id: i32) -> Result { + let conn = &mut self.get_db_conn(); + comments_dsl::task_comments + .filter(comments_dsl::parent_comment_id.eq(comment_id)) + .select(diesel::dsl::count_star()) + .first::(conn) + .map(|count| count > 0) + .map_err(DBError::from) + } + + fn hard_delete(&self, id: i32) -> Result { + let conn = &mut self.get_db_conn(); + diesel::delete(comments_dsl::task_comments.find(id)).execute(conn).map_err(DBError::from) + } + + fn soft_delete(&self, id: i32) -> Result { + let conn = &mut self.get_db_conn(); + + diesel::update(comments_dsl::task_comments.find(id)) + .set(( + comments_dsl::status.eq("deleted"), + )) + .get_result(conn) + .map_err(DBError::from) + } +} \ No newline at end of file diff --git a/src/api/comments/errors.rs b/src/api/comments/errors.rs new file mode 100644 index 0000000..6507a40 --- /dev/null +++ b/src/api/comments/errors.rs @@ -0,0 +1,57 @@ +use std::fmt; + +use serde_derive::Deserialize; +use thiserror::Error; +use warp::{ + http::StatusCode, + reject::Reject, + reply::{Reply, Response}, +}; + +use crate::errors::ErrorResponse; + +#[derive(Clone, Error, Debug, Deserialize, PartialEq)] +pub enum CommentError { + NotFound(i32), + TaskNotFound(i32), + UserNotFound(i32), + InvalidPayload(String), + CannotCreate(String), + CannotDelete(String), + UnauthorizedAction, +} + +impl fmt::Display for CommentError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CommentError::NotFound(id) => write!(f, "Comment #{id} not found"), + CommentError::TaskNotFound(id) => write!(f, "Task #{id} not found, cannot create comment"), + CommentError::UserNotFound(id) => write!(f, "User #{id} not found"), + CommentError::InvalidPayload(error) => write!(f, "Invalid payload: {error}"), + CommentError::CannotCreate(error) => write!(f, "Error creating the comment: {error}"), + CommentError::CannotDelete(error) => write!(f, "Error deleting the comment: {error}"), + CommentError::UnauthorizedAction => write!(f, "User is not authorized to perform this action"), + } + } +} + +impl Reject for CommentError {} + +impl Reply for CommentError { + fn into_response(self) -> Response { + let code = match self { + CommentError::NotFound(_) => StatusCode::NOT_FOUND, + CommentError::TaskNotFound(_) => StatusCode::NOT_FOUND, + CommentError::UserNotFound(_) => StatusCode::NOT_FOUND, + CommentError::InvalidPayload(_) => StatusCode::UNPROCESSABLE_ENTITY, + CommentError::CannotCreate(_) => StatusCode::INTERNAL_SERVER_ERROR, + CommentError::CannotDelete(_) => StatusCode::INTERNAL_SERVER_ERROR, + CommentError::UnauthorizedAction => StatusCode::FORBIDDEN, + }; + let message = self.to_string(); + + let json = warp::reply::json(&ErrorResponse { message }); + + warp::reply::with_status(json, code).into_response() + } +} \ No newline at end of file diff --git a/src/api/comments/handlers.rs b/src/api/comments/handlers.rs new file mode 100644 index 0000000..b6945cf --- /dev/null +++ b/src/api/comments/handlers.rs @@ -0,0 +1,81 @@ +use bytes::Buf; +use log::warn; +use warp::{http::StatusCode, reject, reply::{json, with_status}, Rejection, Reply}; +use crate::api::users::db::DBUser; +use crate::api::users::errors::UserError; +use crate::middlewares::github::model::GitHubUser; +use super::db::DBComment; +use super::models::{NewComment, CreateCommentPayload}; +use super::errors::CommentError; +use crate::api::roles::{db::DBRole, models::KudosRole, utils::user_has_at_least_one_role}; + +pub async fn get_comments_handler(id: i32, db_access: impl DBComment) -> Result { + let comments = db_access.by_task_id(id)?; + Ok(json(&comments)) +} + +pub async fn create_comment_handler( + task_id: i32, + user: GitHubUser, + buf: impl Buf, + db_access: impl DBComment + DBUser, +) -> Result { + let des = &mut serde_json::Deserializer::from_reader(buf.reader()); + let comment_payload: CreateCommentPayload = serde_path_to_error::deserialize(des).map_err(|e| { + let e = e.to_string(); + warn!("invalid comment '{e}'",); + reject::custom(CommentError::InvalidPayload(e)) + })?; + + + let db_user = db_access.by_github_id(user.id)? + .ok_or_else(|| reject::custom(UserError::GithubNotFound(user.id)))?; + + let new_comment = NewComment { + content: comment_payload.content, + parent_comment_id: comment_payload.parent_comment_id, + task_id, + user_id: db_user.id + }; + + + let saved = DBComment::create(&db_access, &new_comment)?; + Ok(with_status(json(&saved), StatusCode::CREATED)) +} + +pub async fn by_comment_id_handler( + comment_id: i32, + db_access: impl DBComment, +) -> Result { + match db_access.by_comment_id(comment_id)? { + Some(comment) => Ok(json(&comment)), + None => Err(reject::custom(CommentError::NotFound(comment_id))), + } +} + +pub async fn delete_comment_handler( + comment_id: i32, + user: GitHubUser, + db_access: impl DBComment + DBUser + DBRole, +) -> Result { + let db_user = db_access.by_github_id(user.id)? + .ok_or_else(|| reject::custom(UserError::GithubNotFound(user.id)))?; + + let comment = db_access.by_comment_id(comment_id)? + .ok_or_else(|| reject::custom(CommentError::NotFound(comment_id)))?; + + let user_roles = db_access.user_roles(&user.username)?; + let is_admin = user_has_at_least_one_role(user_roles, vec![KudosRole::Admin]).is_ok(); + + if comment.user.id != db_user.id && !is_admin { + return Err(reject::custom(CommentError::UnauthorizedAction)); + } + + if db_access.has_replies(comment_id)? { + db_access.soft_delete(comment_id)?; + } else { + db_access.hard_delete(comment_id)?; + } + + Ok(StatusCode::NO_CONTENT) +} \ No newline at end of file diff --git a/src/api/comments/mod.rs b/src/api/comments/mod.rs new file mode 100644 index 0000000..3d26471 --- /dev/null +++ b/src/api/comments/mod.rs @@ -0,0 +1,5 @@ +pub mod db; +pub mod handlers; +pub mod models; +pub mod routes; +pub mod errors; \ No newline at end of file diff --git a/src/api/comments/models.rs b/src/api/comments/models.rs new file mode 100644 index 0000000..ccc4210 --- /dev/null +++ b/src/api/comments/models.rs @@ -0,0 +1,46 @@ +use crate::schema::task_comments; +use crate::api::users::models::User; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use serde_derive::{Deserialize, Serialize}; + +#[derive(Queryable, Identifiable, Selectable, Debug, Serialize, Associations)] +#[diesel(table_name = task_comments)] +#[diesel(belongs_to(User))] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Comment { + pub id: i32, + pub content: String, + pub task_id: i32, + pub user_id: i32, + pub parent_comment_id: Option, + pub created_at: DateTime, + pub updated_at: Option>, + pub status: String, +} + +#[derive(Serialize, Debug)] +pub struct CommentResponse { + pub id: i32, + pub content: String, + pub created_at: DateTime, + pub user: User, + pub parent_comment_id: Option, + pub status: String, +} + +#[derive(Insertable, Deserialize, Debug)] +#[diesel(table_name = task_comments)] +pub struct NewComment { + pub content: String, + pub task_id: i32, + pub user_id: i32, + pub parent_comment_id: Option, +} + +#[derive(Deserialize, Debug)] +pub struct CreateCommentPayload { + pub content: String, + pub task_id: i32, + pub parent_comment_id: Option, +} \ No newline at end of file diff --git a/src/api/comments/routes.rs b/src/api/comments/routes.rs new file mode 100644 index 0000000..a3d4f72 --- /dev/null +++ b/src/api/comments/routes.rs @@ -0,0 +1,45 @@ +use std::convert::Infallible; +use warp::{Filter, Reply, filters::BoxedFilter}; +use crate::middlewares::github::auth::with_github_auth; +use crate::api::users::db::DBUser; +use crate::api::roles::db::DBRole; +use super::db::DBComment; +use super::handlers; + +fn with_db(db_pool: impl DBComment + DBUser + DBRole) -> impl Filter + Clone { + warp::any().map(move || db_pool.clone()) +} + +pub fn routes(db_access: impl DBComment + DBUser + DBRole) -> BoxedFilter<(impl Reply,)> { + let base = warp::path!("tasks" / i32 / "comments"); + let comment_id = warp::path!("comments" / i32); + + let get_comments = base + .and(warp::get()) + .and(with_db(db_access.clone())) + .and_then(handlers::get_comments_handler); + + let get_comment_by_id = base + .and(warp::get()) + .and(with_db(db_access.clone())) + .and_then(handlers::by_comment_id_handler); + + let create_comment = base + .and(with_github_auth()) + .and(warp::post()) + .and(warp::body::aggregate()) + .and(with_db(db_access.clone())) + .and_then(handlers::create_comment_handler); + + let delete_comment = comment_id + .and(with_github_auth()) + .and(warp::delete()) + .and(with_db(db_access.clone())) + .and_then(handlers::delete_comment_handler); + + get_comments + .or(get_comment_by_id) + .or(create_comment) + .or(delete_comment) + .boxed() +} \ No newline at end of file diff --git a/src/api/issues/db.rs b/src/api/issues/db.rs index c88c0fe..4a6f2fc 100644 --- a/src/api/issues/db.rs +++ b/src/api/issues/db.rs @@ -163,18 +163,18 @@ impl DBIssue for DBAccess { name: repo.name, url: repo.url, language_slug: repo.language_slug, - project: ProjectResponse { - id: project.id, - name: project.name, - slug: project.slug, - purposes: project.purposes, - stack_levels: project.stack_levels, - technologies: project.technologies, - avatar: project.avatar, - created_at: project.created_at, - updated_at: project.updated_at, - rewards: project.rewards, - }, + // project: ProjectResponse { + // id: project.id, + // name: project.name, + // slug: project.slug, + // purposes: project.purposes, + // stack_levels: project.stack_levels, + // technologies: project.technologies, + // avatar: project.avatar, + // created_at: project.created_at, + // updated_at: project.updated_at, + // rewards: project.rewards, + // }, created_at: repo.created_at, updated_at: repo.updated_at, }, diff --git a/src/api/mod.rs b/src/api/mod.rs index facca93..91a535c 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -7,4 +7,5 @@ pub mod subscriptions; pub mod tasks; pub mod teams; pub mod users; -pub mod notifications; \ No newline at end of file +pub mod notifications; +pub mod comments; \ No newline at end of file diff --git a/src/api/notifications/db.rs b/src/api/notifications/db.rs index 8fe91d7..9dc0bef 100644 --- a/src/api/notifications/db.rs +++ b/src/api/notifications/db.rs @@ -1,8 +1,13 @@ use diesel::prelude::*; use super::models::{Notification, DeleteNotification, NotificationResponse}; -use crate::schema::{notifications::dsl as notifications_dsl, tasks::dsl as tasks_dsl}; -use crate::api::tasks::models::{Task}; +use crate::schema::{notifications::dsl as notifications_dsl, tasks::dsl as tasks_dsl, users::dsl as users_dsl, repositories::dsl as repositories_dsl, projects::dsl as projects_dsl }; +use crate::api::tasks::models::{Task, TaskResponse}; +use crate::api::users::models::User; +use crate::api::projects::models::{ProjectResponse, Project}; +use crate::api::repositories::models::{RepositoryResponse, Repository}; + + use crate::db::{ errors::DBError, @@ -20,21 +25,91 @@ impl DBNotification for DBAccess { let conn = &mut self.get_db_conn(); let result = notifications_dsl::notifications - .inner_join(tasks_dsl::tasks) + .inner_join(tasks_dsl::tasks + .left_join(repositories_dsl::repositories.on(tasks_dsl::repository_id.eq(repositories_dsl::id.nullable()))) + .left_join( + projects_dsl::projects + .on(tasks_dsl::project_id.eq(projects_dsl::id.nullable())) + ) + .left_join(users_dsl::users.on(tasks_dsl::assignee_user_id.eq(users_dsl::id.nullable()))) + ) .filter(notifications_dsl::github_id.eq(github_id)) .filter(notifications_dsl::seen.eq(seen)) .select(( notifications_dsl::notifications::all_columns(), tasks_dsl::tasks::all_columns(), + repositories_dsl::repositories::all_columns().nullable(), + projects_dsl::projects::all_columns().nullable(), + users_dsl::users::all_columns().nullable(), )) - .load::<(Notification, Task)>(conn) + .load::<(Notification, Task, Option, Option, Option)>(conn) .map_err(DBError::from)? .into_iter() - .map(|(notification, task)| NotificationResponse { - id: notification.id, - task_id: notification.task_id, - task: task.into(), - created_at: notification.created_at, + .map(|(notification, task, repo, project, user)| { + + let project_response = project.map(|p| ProjectResponse { + id: p.id, + name: p.name, + slug: p.slug, + purposes: p.purposes, + stack_levels: p.stack_levels, + technologies: p.technologies, + avatar: p.avatar, + created_at: p.created_at, + updated_at: p.updated_at, + rewards: p.rewards, + }); + + let repository_response = repo.map(|r| RepositoryResponse { + id: r.id, + slug: r.slug, + name: r.name, + url: r.url, + language_slug: r.language_slug, + created_at: r.created_at, + updated_at: r.updated_at, + }); + + NotificationResponse { + id: notification.id, + task_id: notification.task_id, + task: TaskResponse { + id: task.id, + number: task.number, + repository_id: task.repository_id, + title: task.title, + description: task.description, + url: task.url, + labels: task.labels, + open: task.open, + type_: task.type_, + project_id: task.project_id, + created_by_user_id: task.created_by_user_id, + assignee_user_id: task.assignee_user_id, + user, + assignee_team_id: task.assignee_team_id, + funding_options: task.funding_options, + contact: task.contact, + skills: task.skills, + bounty: task.bounty, + approved_by: task.approved_by, + approved_at: task.approved_at, + status: task.status, + upvotes: task.upvotes, + user_vote: None, // TODO: return user votes if they exist + downvotes: task.downvotes, + is_featured: task.is_featured, + is_certified: task.is_certified, + featured_by_user_id: task.featured_by_user_id, + issue_created_at: task.issue_created_at, + issue_closed_at: task.issue_closed_at, + created_at: task.created_at, + updated_at: task.updated_at, + repository: repository_response, + project: project_response + }, + created_at: notification.created_at, + } }) .collect(); diff --git a/src/api/repositories/models.rs b/src/api/repositories/models.rs index b0ab2d6..c15d5c9 100644 --- a/src/api/repositories/models.rs +++ b/src/api/repositories/models.rs @@ -77,7 +77,7 @@ pub struct RepositoryResponse { pub name: String, pub url: String, pub language_slug: Option, - pub project: ProjectResponse, + // pub project: ProjectResponse, pub created_at: DateTime, pub updated_at: Option>, } diff --git a/src/api/tasks/db.rs b/src/api/tasks/db.rs index 275ad70..6be271b 100644 --- a/src/api/tasks/db.rs +++ b/src/api/tasks/db.rs @@ -1,7 +1,11 @@ use diesel::prelude::*; use crate::schema::tasks::dsl as tasks_dsl; +use crate::schema::users::dsl as users_dsl; +use crate::schema::repositories::dsl as repositories_dsl; +use crate::schema::projects::dsl as projects_dsl; use crate::schema::tasks_votes::dsl as tasks_votes_dsl; +use diesel::pg::upsert::excluded; use crate::db::{ errors::DBError, @@ -9,20 +13,26 @@ use crate::db::{ }; use crate::types::PaginationParams; use crate::utils; +use crate::api::users::models::User; +use crate::api::projects::models::{ProjectResponse, Project}; +use crate::api::repositories::models::{RepositoryResponse, Repository}; -use super::models::{NewTask, QueryParams, Task, TaskVote, TaskVoteDB, UpdateTask}; + +use super::models::{NewTask, QueryParams, Task, TaskVote, TaskVoteDB, UpdateTask, TaskResponse}; pub trait DBTask: Send + Sync + Clone + 'static { fn all( &self, params: QueryParams, pagination: PaginationParams, - ) -> Result<(Vec, i64), DBError>; - fn by_id(&self, id: i32) -> Result, DBError>; + current_user_id: Option + ) -> Result<(Vec, i64), DBError>; + fn by_id(&self, id: i32, current_user_id: Option) -> Result, DBError>; fn create(&self, role: &NewTask) -> Result; fn update(&self, id: i32, role: &UpdateTask) -> Result; fn delete(&self, id: i32) -> Result<(), DBError>; fn add_vote_to_task(&self, task_user: &TaskVoteDB) -> Result; - fn delete_task_vote(&self, id: i32) -> Result<(), DBError>; + // fn delete_task_vote(&self, id: i32) -> Result<(), DBError>; + fn delete_vote_by_user_and_task(&self, user_id: i32, task_id: i32) -> Result; } impl DBTask for DBAccess { @@ -30,11 +40,30 @@ impl DBTask for DBAccess { &self, params: QueryParams, pagination: PaginationParams, - ) -> Result<(Vec, i64), DBError> { + current_user_id: Option, + ) -> Result<(Vec, i64), DBError> { let conn = &mut self.get_db_conn(); let build_query = || { - let mut query = tasks_dsl::tasks.into_boxed(); + let mut query = tasks_dsl::tasks + .left_join( + repositories_dsl::repositories + .on(tasks_dsl::repository_id.eq(repositories_dsl::id.nullable())) + ) + .left_join( + projects_dsl::projects + .on(tasks_dsl::project_id.eq(projects_dsl::id.nullable())) + ) + .left_join( + users_dsl::users + .on(tasks_dsl::assignee_user_id.eq(users_dsl::id.nullable())) + ) + .left_join(tasks_votes_dsl::tasks_votes.on( + tasks_votes_dsl::task_id.eq(tasks_dsl::id) + .and(tasks_votes_dsl::user_id.eq(current_user_id.unwrap_or(-1))) + )) + .into_boxed(); + if let Some(repository_id) = params.repository_id { query = query.filter(tasks_dsl::repository_id.eq(repository_id)); @@ -106,24 +135,185 @@ impl DBTask for DBAccess { let total_count = build_query().count().get_result::(conn)?; - let result = build_query() + let query = build_query() + .select(( + (tasks_dsl::tasks::all_columns()), + (repositories_dsl::repositories::all_columns().nullable()), + (projects_dsl::projects::all_columns().nullable()), + (users_dsl::users::all_columns().nullable()), + (tasks_votes_dsl::vote.nullable()) + )) .order(tasks_dsl::created_at.desc()) .offset(pagination.offset) - .limit(pagination.limit) - .load::(conn)?; + .limit(pagination.limit); + + let rows = query.load::<(Task, Option, Option, Option, Option)>(conn)?; + + let tasks_with_assignee = rows + .into_iter() + .map(|(task, repo, project, user, user_vote)| { - Ok((result, total_count)) + let project_response = project.map(|p| ProjectResponse { + id: p.id, + name: p.name, + slug: p.slug, + purposes: p.purposes, + stack_levels: p.stack_levels, + technologies: p.technologies, + avatar: p.avatar, + created_at: p.created_at, + updated_at: p.updated_at, + rewards: p.rewards, + }); + + let repository_response = repo.map(|r| RepositoryResponse { + id: r.id, + slug: r.slug, + name: r.name, + url: r.url, + language_slug: r.language_slug, + created_at: r.created_at, + updated_at: r.updated_at, + }); + + TaskResponse { + id: task.id, + number: task.number, + repository_id: task.repository_id, + title: task.title, + description: task.description, + url: task.url, + labels: task.labels, + open: task.open, + type_: task.type_, + project_id: task.project_id, + created_by_user_id: task.created_by_user_id, + assignee_user_id: task.assignee_user_id, + user, + repository: repository_response, + project: project_response, + assignee_team_id: task.assignee_team_id, + funding_options: task.funding_options, + contact: task.contact, + skills: task.skills, + bounty: task.bounty, + approved_by: task.approved_by, + approved_at: task.approved_at, + status: task.status, + upvotes: task.upvotes, + downvotes: task.downvotes, + user_vote, + is_featured: task.is_featured, + is_certified: task.is_certified, + featured_by_user_id: task.featured_by_user_id, + issue_created_at: task.issue_created_at, + issue_closed_at: task.issue_closed_at, + created_at: task.created_at, + updated_at: task.updated_at, + } + }) + .collect(); + + Ok((tasks_with_assignee, total_count)) } - fn by_id(&self, id: i32) -> Result, DBError> { + fn by_id(&self, id: i32, current_user_id: Option) -> Result, DBError> { let conn = &mut self.get_db_conn(); - let result = tasks_dsl::tasks - .find(id) - .first::(conn) - .optional() - .map_err(DBError::from)?; - Ok(result) + + let row = tasks_dsl::tasks + + .left_join( + repositories_dsl::repositories + .on(tasks_dsl::repository_id.eq(repositories_dsl::id.nullable())) + ) + + .left_join( + projects_dsl::projects + .on(tasks_dsl::project_id.eq(projects_dsl::id.nullable())) + ) + + .left_join( + users_dsl::users + .on(tasks_dsl::assignee_user_id.eq(users_dsl::id.nullable())) + ) + .left_join(tasks_votes_dsl::tasks_votes.on( + tasks_votes_dsl::task_id.eq(tasks_dsl::id) + .and(tasks_votes_dsl::user_id.eq(current_user_id.unwrap_or(-1))) + )) + .filter(tasks_dsl::id.eq(id)) + .select(( + (tasks_dsl::tasks::all_columns()), + (repositories_dsl::repositories::all_columns().nullable()), + (projects_dsl::projects::all_columns().nullable()), + (users_dsl::users::all_columns().nullable()), + (tasks_votes_dsl::vote.nullable()) + )) + .first::<(Task, Option, Option, Option, Option)>(conn) + .optional()?; + + Ok(row.map(|(task, repo, project, user, user_vote)| { + let project_response = project.map(|p| ProjectResponse { + id: p.id, + name: p.name, + slug: p.slug, + purposes: p.purposes, + stack_levels: p.stack_levels, + technologies: p.technologies, + avatar: p.avatar, + created_at: p.created_at, + updated_at: p.updated_at, + rewards: p.rewards, + }); + + let repository_response = repo.map(|r| RepositoryResponse { + id: r.id, + slug: r.slug, + name: r.name, + url: r.url, + language_slug: r.language_slug, + created_at: r.created_at, + updated_at: r.updated_at, + }); + + TaskResponse { + id: task.id, + number: task.number, + repository_id: task.repository_id, + title: task.title, + description: task.description, + url: task.url, + labels: task.labels, + open: task.open, + type_: task.type_, + project_id: task.project_id, + created_by_user_id: task.created_by_user_id, + assignee_user_id: task.assignee_user_id, + user, + repository: repository_response, + project: project_response, + assignee_team_id: task.assignee_team_id, + funding_options: task.funding_options, + contact: task.contact, + skills: task.skills, + bounty: task.bounty, + approved_by: task.approved_by, + approved_at: task.approved_at, + status: task.status, + upvotes: task.upvotes, + downvotes: task.downvotes, + user_vote, + is_featured: task.is_featured, + is_certified: task.is_certified, + featured_by_user_id: task.featured_by_user_id, + issue_created_at: task.issue_created_at, + issue_closed_at: task.issue_closed_at, + created_at: task.created_at, + updated_at: task.updated_at, + } + })) } + + fn create(&self, task: &NewTask) -> Result { let conn = &mut self.get_db_conn(); @@ -156,20 +346,36 @@ impl DBTask for DBAccess { fn add_vote_to_task(&self, task_vote: &TaskVoteDB) -> Result { let conn = &mut self.get_db_conn(); - let vote= diesel::insert_into(tasks_votes_dsl::tasks_votes) + + let vote = diesel::insert_into(tasks_votes_dsl::tasks_votes) .values(task_vote) + .on_conflict((tasks_votes_dsl::user_id, tasks_votes_dsl::task_id)) + .do_update() + .set(tasks_votes_dsl::vote.eq(excluded(tasks_votes_dsl::vote))) .get_result(conn) .map_err(DBError::from)?; Ok(vote) } - fn delete_task_vote(&self, id: i32) -> Result<(), DBError> { - let conn = &mut self.get_db_conn(); - diesel::delete(tasks_votes_dsl::tasks_votes.filter(tasks_votes_dsl::id.eq(id))) - .execute(conn) - .map_err(DBError::from)?; + + // fn delete_task_vote(&self, id: i32) -> Result<(), DBError> { + // let conn = &mut self.get_db_conn(); + // diesel::delete(tasks_votes_dsl::tasks_votes.filter(tasks_votes_dsl::id.eq(id))) + // .execute(conn) + // .map_err(DBError::from)?; - Ok(()) + // Ok(()) + // } + + fn delete_vote_by_user_and_task(&self, user_id_param: i32, task_id_param: i32) -> Result { + let conn = &mut self.get_db_conn(); + diesel::delete( + tasks_votes_dsl::tasks_votes + .filter(tasks_votes_dsl::user_id.eq(user_id_param)) + .filter(tasks_votes_dsl::task_id.eq(task_id_param)) + ) + .execute(conn) + .map_err(DBError::from) } diff --git a/src/api/tasks/handlers.rs b/src/api/tasks/handlers.rs index 4f1b5a2..87a74bb 100644 --- a/src/api/tasks/handlers.rs +++ b/src/api/tasks/handlers.rs @@ -20,24 +20,42 @@ use crate::{ use super::{ db::DBTask, errors::TaskError, - models::{NewTaskVote, QueryParams, TaskVoteDB, UpdateTask}, + models::{VotePayload, QueryParams, TaskVoteDB, UpdateTask}, }; -pub async fn by_id(id: i32, db_access: impl DBTask) -> Result { +async fn get_user_id_from_auth( + github_user: Option, + db_access: &impl DBUser +) -> Result, Rejection> { + if let Some(user) = github_user { + match db_access.by_github_id(user.id)? { + Some(db_user) => Ok(Some(db_user.id)), + None => Ok(None), + } + } else { + Ok(None) + } +} + + +pub async fn by_id(id: i32, github_user: Option, db_access: impl DBTask + DBUser) -> Result { info!("getting task '{id}'"); - match db_access.by_id(id)? { + let current_user_id = get_user_id_from_auth(github_user, &db_access).await?; + match DBTask::by_id(&db_access, id, current_user_id)? { None => Err(warp::reject::custom(TaskError::NotFound(id)))?, - Some(repository) => Ok(json(&repository)), + Some(task) => Ok(json(&task)), } } pub async fn all_handler( - db_access: impl DBTask, + github_user: Option, + db_access: impl DBTask + DBUser, params: QueryParams, pagination: PaginationParams, ) -> Result { info!("getting all the roles"); - let (roles, total_count) = db_access.all(params, pagination.clone())?; + let current_user_id = get_user_id_from_auth(github_user, &db_access).await?; + let (tasks, total_count) = DBTask::all(&db_access, params, pagination.clone(), current_user_id)?; let has_next_page = pagination.offset + pagination.limit < total_count; let has_previous_page = pagination.offset > 0; @@ -45,7 +63,7 @@ pub async fn all_handler( total_count: Some(total_count), has_next_page, has_previous_page, - data: roles, + data: tasks, }; Ok(json(&response)) @@ -145,8 +163,11 @@ pub async fn update_handler( ], )?; + let db_user = db_access.by_github_id(user.id)? + .ok_or_else(|| reject::custom(UserError::GithubNotFound(user.id)))?; + task.type_.as_deref().map(validate_task_type).transpose()?; - match DBTask::by_id(&db_access, id)? { + match DBTask::by_id(&db_access, id, Some(db_user.id))? { Some(p) => match DBTask::update(&db_access, p.id, &task) { Ok(task) => { info!("task '{}' updated", task.id); @@ -166,7 +187,7 @@ pub async fn update_handler( pub async fn delete_handler( id: i32, user: GitHubUser, - db_access: impl DBTask + DBRole, + db_access: impl DBTask + DBRole + DBUser, ) -> Result { let user_roles = DBRole::user_roles(&db_access, &user.username)?; user_has_at_least_one_role( @@ -177,7 +198,10 @@ pub async fn delete_handler( ], )?; - match DBTask::by_id(&db_access, id)? { + let db_user = db_access.by_github_id(user.id)? + .ok_or_else(|| reject::custom(UserError::GithubNotFound(user.id)))?; + + match DBTask::by_id(&db_access, id, Some(db_user.id))? { Some(_) => { let _ = &DBTask::delete(&db_access, id)?; Ok(StatusCode::NO_CONTENT) @@ -202,45 +226,40 @@ pub async fn add_upvote_to_task( ], )?; + let db_user = db_access.by_github_id(user.id)? + .ok_or_else(|| reject::custom(UserError::GithubNotFound(user.id)))?; + let des = &mut serde_json::Deserializer::from_reader(buf.reader()); - let task_vote: NewTaskVote = serde_path_to_error::deserialize(des).map_err(|e| { + let task_vote: VotePayload = serde_path_to_error::deserialize(des).map_err(|e| { let e = e.to_string(); warn!("invalid task vote: '{e}'",); reject::custom(TaskError::InvalidPayload(e)) })?; - match DBUser::by_id(&db_access, task_vote.user_id)? { - Some(_) => match DBTask::by_id(&db_access, task_vote.task_id)? { - Some(_) => { - match DBTask::add_vote_to_task( - &db_access, - &TaskVoteDB { - user_id: task_vote.user_id, - task_id: task_vote.task_id, - vote: 1, - }, - ) { - Ok(task_vote) => { - info!("vote '{}' created", task_vote.id); - Ok(with_status(json(&task_vote), StatusCode::CREATED)) - } - Err(error) => { - error!("error creating the vote '{:?}': {}", task_vote, error); - if error.to_string().contains("unique_vote") { - Err(warp::reject::custom(TaskError::UserAlreadyVoted())) - } else { - Err(warp::reject::custom(TaskError::CannotCreate( - "error creating vote".to_owned(), - ))) - } - } + match DBTask::by_id(&db_access, task_vote.task_id, Some(db_user.id))? { + Some(_) => { + // 4. Create the vote using the SECURE user ID from the backend. + match DBTask::add_vote_to_task( + &db_access, + &TaskVoteDB { + user_id: db_user.id, // <-- Use the secure ID + task_id: task_vote.task_id, + vote: 1, // 1 for upvote + }, + ) { + Ok(task_vote) => { + info!("vote '{}' created/updated", task_vote.id); + Ok(with_status(json(&task_vote), StatusCode::OK)) // Use OK for upsert + } + Err(error) => { + error!("error creating the vote: {}", error); + Err(reject::custom(TaskError::CannotCreate( + "error creating vote".to_owned(), + ))) } } - None => Err(warp::reject::custom(TaskError::NotFound(task_vote.task_id))), - }, - None => Err(warp::reject::custom(TaskError::UserNotFound( - task_vote.user_id, - ))), + } + None => Err(reject::custom(TaskError::NotFound(task_vote.task_id))), } } pub async fn add_downvote_to_task( @@ -259,63 +278,93 @@ pub async fn add_downvote_to_task( ], )?; + let db_user = db_access.by_github_id(user.id)? + .ok_or_else(|| reject::custom(UserError::GithubNotFound(user.id)))?; + let des = &mut serde_json::Deserializer::from_reader(buf.reader()); - let task_vote: NewTaskVote = serde_path_to_error::deserialize(des).map_err(|e| { + let task_vote: VotePayload = serde_path_to_error::deserialize(des).map_err(|e| { let e = e.to_string(); warn!("invalid task vote: '{e}'",); reject::custom(TaskError::InvalidPayload(e)) })?; - match DBUser::by_id(&db_access, task_vote.user_id)? { - Some(_) => match DBTask::by_id(&db_access, task_vote.task_id)? { - Some(_) => { - match DBTask::add_vote_to_task( - &db_access, - &TaskVoteDB { - user_id: task_vote.user_id, - task_id: task_vote.task_id, - vote: -1, - }, - ) { - Ok(task_vote) => { - info!("vote '{}' created", task_vote.id); - Ok(with_status(json(&task_vote), StatusCode::CREATED)) - } - Err(error) => { - error!("error creating the vote '{:?}': {}", task_vote, error); - if error.to_string().contains("unique_vote") { - Err(warp::reject::custom(TaskError::UserAlreadyVoted())) - } else { - Err(warp::reject::custom(TaskError::CannotCreate( - "error creating vote".to_owned(), - ))) - } - } + match DBTask::by_id(&db_access, task_vote.task_id, Some(db_user.id))? { + Some(_) => { + // 4. Create the vote using the SECURE user ID from the backend. + match DBTask::add_vote_to_task( + &db_access, + &TaskVoteDB { + user_id: db_user.id, // <-- Use the secure ID + task_id: task_vote.task_id, + vote: -1, + }, + ) { + Ok(task_vote) => { + info!("vote '{}' created/updated", task_vote.id); + Ok(with_status(json(&task_vote), StatusCode::OK)) // Use OK for upsert + } + Err(error) => { + error!("error creating the vote: {}", error); + Err(reject::custom(TaskError::CannotCreate( + "error creating vote".to_owned(), + ))) } } - None => Err(warp::reject::custom(TaskError::NotFound(task_vote.task_id))), - }, - None => Err(warp::reject::custom(TaskError::UserNotFound( - task_vote.user_id, - ))), + } + None => Err(reject::custom(TaskError::NotFound(task_vote.task_id))), } } -pub async fn delete_task_vote( - id: i32, - _: GitHubUser, - db_access: impl DBTask, +// pub async fn delete_task_vote( +// id: i32, +// _: GitHubUser, +// db_access: impl DBTask, +// ) -> Result { +// // TODO: check if the user has that vote +// match db_access.delete_task_vote(id) { +// Ok(role) => { +// info!("task vote '{}' deleted", id); +// Ok(with_status(json(&role), StatusCode::NO_CONTENT)) +// } +// Err(error) => { +// error!("error deleting the task vote '{id}': {error}"); +// Err(warp::reject::custom(TaskError::CannotDelete( +// "error deleting the task vote".to_owned(), +// ))) +// } +// } +// } + +pub async fn delete_vote_handler( + user: GitHubUser, + buf: impl Buf, + db_access: impl DBTask + DBUser, ) -> Result { - // TODO: check if the user has that vote - match db_access.delete_task_vote(id) { - Ok(role) => { - info!("task vote '{}' deleted", id); - Ok(with_status(json(&role), StatusCode::NO_CONTENT)) + + let db_user = db_access.by_github_id(user.id)? + .ok_or_else(|| reject::custom(UserError::GithubNotFound(user.id)))?; + + + let des = &mut serde_json::Deserializer::from_reader(buf.reader()); + let vote_payload: VotePayload = serde_path_to_error::deserialize(des).map_err(|e| { + let e = e.to_string(); + warn!("invalid task vote delete payload: '{e}'"); + reject::custom(TaskError::InvalidPayload(e)) + })?; + + + match db_access.delete_vote_by_user_and_task(db_user.id, vote_payload.task_id) { + Ok(0) => { + warn!("No vote found to delete for user #{} on task #{}", db_user.id, vote_payload.task_id); + Ok(StatusCode::NO_CONTENT) + } + Ok(_) => { + info!("Vote deleted for user #{} on task #{}", db_user.id, vote_payload.task_id); + Ok(StatusCode::NO_CONTENT) } - Err(error) => { - error!("error deleting the task vote '{id}': {error}"); - Err(warp::reject::custom(TaskError::CannotDelete( - "error deleting the task vote".to_owned(), - ))) + Err(e) => { + error!("Failed to delete vote: {}", e); + Err(reject::custom(TaskError::CannotDelete("Failed to delete vote".into()))) } } } + diff --git a/src/api/tasks/models.rs b/src/api/tasks/models.rs index 6c38f33..ad44132 100644 --- a/src/api/tasks/models.rs +++ b/src/api/tasks/models.rs @@ -1,6 +1,9 @@ use crate::schema::{tasks, tasks_votes}; use chrono::{DateTime, Utc}; use diesel::prelude::*; +use crate::api::users::models::User; +use crate::api::repositories::models::RepositoryResponse; +use crate::api::projects::models::ProjectResponse; use serde_derive::{Deserialize, Serialize}; // tasks @@ -128,6 +131,9 @@ pub struct TaskResponse { pub project_id: Option, pub created_by_user_id: Option, pub assignee_user_id: Option, + pub user: Option, // return the user if there is one already assigned + pub repository: Option, + pub project: Option, pub assignee_team_id: Option, pub funding_options: Option>>, pub contact: Option, @@ -138,6 +144,7 @@ pub struct TaskResponse { pub status: String, pub upvotes: Option, pub downvotes: Option, + pub user_vote: Option, pub is_featured: Option, pub is_certified: Option, pub featured_by_user_id: Option, @@ -186,10 +193,15 @@ pub struct TaskVote { pub vote: i32 } -#[derive(Insertable, Serialize, Deserialize, Debug)] -#[diesel(table_name = tasks_votes)] -pub struct NewTaskVote { - pub user_id: i32, +// #[derive(Insertable, Serialize, Deserialize, Debug)] +// #[diesel(table_name = tasks_votes)] +// pub struct NewTaskVote { +// pub user_id: i32, +// pub task_id: i32, +// } + +#[derive(Deserialize, Debug)] +pub struct VotePayload { pub task_id: i32, } @@ -206,38 +218,3 @@ pub struct TaskVoteDB { pub struct UserVote { pub user_id: i32, } -impl From for TaskResponse { - fn from(task: Task) -> Self { - TaskResponse { - id: task.id, - number: task.number, - repository_id: task.repository_id, - title: task.title, - description: task.description, - url: task.url, - labels: task.labels, - open: task.open, - type_: task.type_, - project_id: task.project_id, - created_by_user_id: task.created_by_user_id, - assignee_user_id: task.assignee_user_id, - assignee_team_id: task.assignee_team_id, - funding_options: task.funding_options, - contact: task.contact, - skills: task.skills, - bounty: task.bounty, - approved_by: task.approved_by, - approved_at: task.approved_at, - status: task.status, - upvotes: task.upvotes, - downvotes: task.downvotes, - is_featured: task.is_featured, - is_certified: task.is_certified, - featured_by_user_id: task.featured_by_user_id, - issue_created_at: task.issue_created_at, - issue_closed_at: task.issue_closed_at, - created_at: task.created_at, - updated_at: task.updated_at, - } - } -} diff --git a/src/api/tasks/routes.rs b/src/api/tasks/routes.rs index d1ef02d..7f01586 100644 --- a/src/api/tasks/routes.rs +++ b/src/api/tasks/routes.rs @@ -4,7 +4,7 @@ use crate::api::roles::db::DBRole; use crate::api::users::db::DBUser; use warp::filters::BoxedFilter; use warp::{Filter, Reply}; -use crate::middlewares::github::auth::with_github_auth; +use crate::middlewares::github::auth::{with_github_auth, with_optional_github_auth}; use crate::types::PaginationParams; use super::db::DBTask; @@ -22,10 +22,11 @@ pub fn routes(db_access: impl DBTask + DBUser + DBRole) -> BoxedFilter<(impl Rep let task_id = warp::path!("tasks" / i32); let task_upvote = warp::path!("tasks" / "upvotes"); let task_downvote = warp::path!("tasks" / "downvotes"); - let task_vote_id = warp::path!("tasks" / "votes" / i32); // TODO: + let task_vote = warp::path!("tasks" / "vote"); let get_tasks = task .and(warp::get()) + .and(with_optional_github_auth()) .and(with_db(db_access.clone())) .and(warp::query::()) .and(warp::query::()) @@ -33,6 +34,7 @@ pub fn routes(db_access: impl DBTask + DBUser + DBRole) -> BoxedFilter<(impl Rep let get_task = task_id .and(warp::get()) + .and(with_optional_github_auth()) .and(with_db(db_access.clone())) .and_then(handlers::by_id); @@ -63,18 +65,19 @@ pub fn routes(db_access: impl DBTask + DBUser + DBRole) -> BoxedFilter<(impl Rep .and(with_db(db_access.clone())) .and_then(handlers::add_upvote_to_task); - let create_task_downvote = task_downvote + let create_task_downvote = task_downvote .and(with_github_auth()) .and(warp::post()) .and(warp::body::aggregate()) .and(with_db(db_access.clone())) .and_then(handlers::add_downvote_to_task); - let delete_task_vote = task_vote_id + let delete_vote = task_vote .and(with_github_auth()) .and(warp::delete()) + .and(warp::body::aggregate()) .and(with_db(db_access.clone())) - .and_then(handlers::delete_task_vote); + .and_then(handlers::delete_vote_handler); let route = get_tasks .or(get_task) @@ -83,7 +86,7 @@ pub fn routes(db_access: impl DBTask + DBUser + DBRole) -> BoxedFilter<(impl Rep .or(update_task) .or(create_task_upvote) .or(create_task_downvote) - .or(delete_task_vote); + .or(delete_vote); route.boxed() } diff --git a/src/api/users/db.rs b/src/api/users/db.rs index 4c04676..9dab861 100644 --- a/src/api/users/db.rs +++ b/src/api/users/db.rs @@ -1,7 +1,7 @@ use diesel::dsl::now; use diesel::prelude::*; -use super::models::{NewUser, QueryParams, UpdateUser, User}; +use super::models::{NewUser, QueryParams, UpdateUser, User, UpdateUserProfile}; use crate::schema::issues::dsl as issues_dsl; use crate::schema::repositories::dsl as repositories_dsl; use crate::schema::users::dsl as users_dsl; @@ -20,6 +20,8 @@ pub trait DBUser: Send + Sync + Clone + 'static { fn create(&self, user: &NewUser) -> Result; fn update(&self, id: i32, user: &UpdateUser) -> Result; fn delete(&self, id: i32) -> Result<(), DBError>; + fn update_profile(&self, id: i32, form: &UpdateUserProfile) -> Result; + fn update_email_notifications(&self, id: i32, enabled: bool) -> Result; } impl DBUser for DBAccess { @@ -47,24 +49,14 @@ impl DBUser for DBAccess { } fn by_username(&self, username: &str) -> Result, DBError> { let conn = &mut self.get_db_conn(); - let mut query = users_dsl::users.into_boxed(); - query = query.filter(users_dsl::username.eq(username)); - query = query.limit(1); - let result: Vec = query.load::(conn)?; - if result.is_empty() { - Ok(None) - } else { - Ok(Some(User { - id: result[0].id, - username: result[0].username.clone(), - avatar: result[0].avatar.clone(), - created_at: result[0].created_at, - updated_at: result[0].updated_at, - github_id: result[0].github_id, - email: result[0].email.clone(), - email_notifications_enabled: result[0].email_notifications_enabled, - })) - } + + let result = users_dsl::users + .filter(users_dsl::username.eq(username)) + .first::(conn) + .optional() + .map_err(DBError::from)?; + + Ok(result) } fn all(&self, params: QueryParams, pagination: PaginationParams) -> Result, DBError> { @@ -127,6 +119,31 @@ impl DBUser for DBAccess { Ok(user) } + fn update_email_notifications(&self, id: i32, enabled: bool) -> Result { + let conn = &mut self.get_db_conn(); + + let user = diesel::update(users_dsl::users.filter(users_dsl::id.eq(id))) + .set(( + users_dsl::email_notifications_enabled.eq(enabled), + users_dsl::updated_at.eq(now), + )) + .get_result::(conn) + .map_err(DBError::from)?; + + Ok(user) + } + + fn update_profile(&self, id: i32, form: &UpdateUserProfile) -> Result { + let conn = &mut self.get_db_conn(); + + let user = diesel::update(users_dsl::users.filter(users_dsl::id.eq(id))) + .set((form, users_dsl::updated_at.eq(now))) + .get_result::(conn) + .map_err(DBError::from)?; + + Ok(user) + } + fn delete(&self, id: i32) -> Result<(), DBError> { let conn = &mut self.get_db_conn(); diesel::delete(users_dsl::users.filter(users_dsl::id.eq(id))) diff --git a/src/api/users/handlers.rs b/src/api/users/handlers.rs index 910536a..635ca0e 100644 --- a/src/api/users/handlers.rs +++ b/src/api/users/handlers.rs @@ -12,7 +12,7 @@ use log::{error, info, warn}; use super::{ db::DBUser, errors::UserError, - models::{NewUser, QueryParams, UpdateUser}, + models::{NewUser, QueryParams, UpdateUser, UpdateUserProfile}, }; pub async fn by_id(id: i32, db_access: impl DBUser) -> Result { @@ -162,3 +162,44 @@ pub async fn delete_handler( None => Err(warp::reject::custom(UserError::NotFound(id))), } } + +pub async fn update_profile_me( + user: GitHubUser, + buf: impl Buf, + db_access: impl DBUser, +) -> Result, Rejection> { + let des = &mut serde_json::Deserializer::from_reader(buf.reader()); + let payload: UpdateUserProfile = serde_path_to_error::deserialize(des) + .map_err(|e| reject::custom(UserError::InvalidPayload(e.to_string())))?; + + let db_user = db_access + .by_github_id(user.id)? + .ok_or_else(|| warp::reject::custom(UserError::GithubNotFound(user.id)))?; + + Ok(with_status( + json(&db_access.update_profile(db_user.id, &payload)?), + StatusCode::OK, + )) +} + +pub async fn update_email_notifications_me( + user: GitHubUser, + buf: impl Buf, + db_access: impl DBUser, +) -> Result, Rejection> { + let des = &mut serde_json::Deserializer::from_reader(buf.reader()); + let payload: UpdateEmailNotificationsUser = serde_path_to_error::deserialize(des) + .map_err(|e| reject::custom(UserError::InvalidPayload(e.to_string())))?; + + let db_user = db_access + .by_github_id(user.id)? + .ok_or_else(|| warp::reject::custom(UserError::GithubNotFound(user.id)))?; + + // If you make this field required, drop unwrap_or. + let enabled = payload.email_notifications_enabled.unwrap_or(false); + + Ok(with_status( + json(&db_access.update_email_notifications(db_user.id, enabled)?), + StatusCode::OK, + )) +} diff --git a/src/api/users/models.rs b/src/api/users/models.rs index 6f135d6..c357d9d 100644 --- a/src/api/users/models.rs +++ b/src/api/users/models.rs @@ -26,6 +26,12 @@ pub struct User { pub github_id: Option, pub email_notifications_enabled: bool, pub email: Option, + + pub bio: Option, + pub skills: Option>>, + pub interests: Option>>, + pub telegram: Option, + pub twitter: Option, } #[derive(Insertable, Serialize, Deserialize, Debug)] @@ -57,4 +63,14 @@ pub struct UpdateEmailNotificationsUser { pub struct QueryParams { pub labels: Option, pub certified: Option, +} + +#[derive(AsChangeset, Serialize, Deserialize, Debug)] +#[diesel(table_name = users)] +pub struct UpdateUserProfile { + pub bio: Option, + pub skills: Option>>, + pub interests: Option>>, + pub telegram: Option, + pub twitter: Option, } \ No newline at end of file diff --git a/src/api/users/routes.rs b/src/api/users/routes.rs index 1a58563..5c988a9 100644 --- a/src/api/users/routes.rs +++ b/src/api/users/routes.rs @@ -19,7 +19,7 @@ fn with_db( pub fn routes(db_access: impl DBUser + DBRole) -> BoxedFilter<(impl Reply,)> { let user = warp::path!("users"); - let user_me = warp::path!("users" / "me"); + let user_me_base = warp::path("users").and(warp::path("me")); let user_id = warp::path!("users" / i32); let user_username = warp::path!("users" / "username" / String); @@ -35,13 +35,14 @@ pub fn routes(db_access: impl DBUser + DBRole) -> BoxedFilter<(impl Reply,)> { .and(with_db(db_access.clone())) .and_then(handlers::by_id); - let get_user_github = user_me + let get_user_github = user_me_base + .and(warp::path::end()) .and(warp::get()) .and(with_github_auth()) .and(with_db(db_access.clone())) .and_then(handlers::by_github); - let create_user_github = user_me + let create_user_github = user_me_base .and(warp::post()) .and(with_github_auth()) .and(with_db(db_access.clone())) @@ -66,13 +67,32 @@ pub fn routes(db_access: impl DBUser + DBRole) -> BoxedFilter<(impl Reply,)> { .and(with_db(db_access.clone())) .and_then(handlers::update_handler); - let update_user_github = user_me + let update_user_github = user_me_base + .and(warp::path::end()) .and(warp::put()) .and(with_github_auth()) .and(warp::body::aggregate()) .and(with_db(db_access.clone())) .and_then(handlers::update_user_github); + let update_email_notifications = user_me_base + .and(warp::path!("notifications")) + .and(warp::path::end()) + .and(warp::put()) + .and(with_github_auth()) + .and(warp::body::aggregate()) + .and(with_db(db_access.clone())) + .and_then(handlers::update_email_notifications_me); + + let update_profile = user_me_base + .and(warp::path!("profile")) + .and(warp::path::end()) + .and(warp::put()) + .and(with_github_auth()) + .and(warp::body::aggregate()) + .and(with_db(db_access.clone())) + .and_then(handlers::update_profile_me); + let delete_user = user_id .and(with_github_auth()) .and(warp::delete()) @@ -87,7 +107,14 @@ pub fn routes(db_access: impl DBUser + DBRole) -> BoxedFilter<(impl Reply,)> { .or(update_user) .or(get_user_github) .or(create_user_github) - .or(update_user_github); + .or(update_user_github) + .or(update_email_notifications) + .or(update_profile); + + let cors = warp::cors() + .allow_any_origin() + .allow_headers(vec!["content-type", "authorization"]) + .allow_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"]); - route.boxed() + route.with(cors).boxed() } diff --git a/src/errors.rs b/src/errors.rs index bf8d6c7..c6436fc 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -13,6 +13,7 @@ use crate::{ users::errors::UserError, subscriptions::errors::UserSubscriptionError, notifications::errors::NotificationError, + comments::errors::CommentError }, db::errors::DBError, middlewares::errors::AuthenticationError, @@ -40,6 +41,8 @@ pub async fn error_handler(err: Rejection) -> std::result::Result() { return Ok(e.clone().into_response()); + } else if let Some(e) = err.find::() { + return Ok(e.clone().into_response()); } // TODO: add more errors diff --git a/src/middlewares/github/auth.rs b/src/middlewares/github/auth.rs index 89b7ff2..4ae11b1 100644 --- a/src/middlewares/github/auth.rs +++ b/src/middlewares/github/auth.rs @@ -78,4 +78,41 @@ async fn authorize(headers: HeaderMap) -> Result Err(reject::custom(e)), } -} \ No newline at end of file +} + + + +pub fn with_optional_github_auth() -> impl Filter,), Error = Rejection> + Clone { + warp::filters::header::headers_cloned() + .and_then(optional_authorize) +} + + +async fn optional_authorize(headers: HeaderMap) -> Result, Rejection> { + match token_from_header(&headers, BEARER) { + Ok(token) => { + let mut response = surf::get("https://api.github.com/user") + .header(AUTHORIZATION, format!("{BEARER} {token}")) + .header(USER_AGENT, "MoreKudos") + .await + .map_err(|e| { error!("Error calling GitHub API: {e}"); AuthenticationError::GitHub })?; + + if response.status().is_success() { + let user_data: serde_json::Value = response.body_json().await.map_err(|_| reject::custom(AuthenticationError::GitHub))?; + let id = user_data.get("id").and_then(|v| v.as_i64()).ok_or_else(|| reject::custom(AuthenticationError::GitHub))?; + let username = user_data.get("login").and_then(|v| v.as_str()).ok_or_else(|| reject::custom(AuthenticationError::GitHub))?.to_string(); + let email = user_data.get("email").and_then(|v| v.as_str().map(|s| s.to_string())); + let avatar_url = user_data.get("avatar_url").and_then(|v| v.as_str()).ok_or_else(|| reject::custom(AuthenticationError::GitHub))?.to_string(); + + Ok(Some(GitHubUser { id, username, avatar_url, email })) + } else { + error!("Invalid GitHub token provided."); + Err(reject::custom(AuthenticationError::WrongCredentials)) + } + }, + Err(AuthenticationError::NoAuthHeader) | Err(AuthenticationError::InvalidAuthHeader) => { + Ok(None) + }, + Err(e) => Err(reject::custom(e)), + } +} diff --git a/src/schema.rs b/src/schema.rs index ca590f2..68be893 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -28,18 +28,6 @@ diesel::table! { } } -diesel::table! { - milestones (id) { - id -> Int4, - slug -> Text, - name -> Text, - url -> Nullable, - project_id -> Int4, - created_at -> Timestamptz, - updated_at -> Nullable, - } -} - diesel::table! { notification_schedule (id) { id -> Int4, @@ -99,6 +87,19 @@ diesel::table! { } } +diesel::table! { + task_comments (id) { + id -> Int4, + content -> Text, + task_id -> Int4, + user_id -> Int4, + parent_comment_id -> Nullable, + created_at -> Timestamptz, + updated_at -> Nullable, + status -> Text, + } +} + diesel::table! { tasks (id) { id -> Int4, @@ -185,6 +186,11 @@ diesel::table! { github_id -> Nullable, email_notifications_enabled -> Bool, email -> Nullable, + bio -> Nullable, + skills -> Nullable>>, + interests -> Nullable>>, + telegram -> Nullable, + twitter -> Nullable, } } @@ -200,9 +206,10 @@ diesel::table! { diesel::joinable!(issues -> repositories (repository_id)); diesel::joinable!(issues -> users (assignee_id)); -diesel::joinable!(milestones -> projects (project_id)); diesel::joinable!(notifications -> tasks (task_id)); diesel::joinable!(repositories -> projects (project_id)); +diesel::joinable!(task_comments -> tasks (task_id)); +diesel::joinable!(task_comments -> users (user_id)); diesel::joinable!(tasks -> projects (project_id)); diesel::joinable!(tasks -> repositories (repository_id)); diesel::joinable!(tasks_votes -> tasks (task_id)); @@ -216,12 +223,12 @@ diesel::joinable!(users_projects_roles -> users (user_id)); diesel::allow_tables_to_appear_in_same_query!( issues, languages, - milestones, notification_schedule, notifications, projects, repositories, roles, + task_comments, tasks, tasks_votes, team_memberships, diff --git a/src/utils.rs b/src/utils.rs index 9d7d50a..b89843e 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,5 +1,5 @@ use crate::{ - api::{health, issues, projects, repositories, users, roles, tasks, teams, subscriptions, notifications}, + api::{health, issues, projects, repositories, users, roles, tasks, teams, subscriptions, notifications, comments}, db::{ self, errors::DBError, @@ -28,6 +28,7 @@ pub fn setup_filters(db: DBAccess) -> BoxedFilter<(impl Reply,)> { let tasks_route = tasks::routes::routes(db.clone()); let subscriptions_route = subscriptions::routes::routes(db.clone()); let notifications_route = notifications::routes::routes(db.clone()); + let comments_route = comments::routes::routes(db.clone()); let cors = warp::cors() @@ -47,6 +48,7 @@ pub fn setup_filters(db: DBAccess) -> BoxedFilter<(impl Reply,)> { .or(tasks_route) .or(subscriptions_route) .or(notifications_route) + .or(comments_route) .recover(error_handler) .with(warp::log("api")) .with(cors)