From d94aeb2b2847453912a23116fe4a1169e40de8f7 Mon Sep 17 00:00:00 2001 From: Leandro Palazzolo Date: Sun, 19 Jan 2025 18:46:11 -0300 Subject: [PATCH 01/19] chore: migrate issues to tasks --- .../down.sql | 3 ++ .../2025-01-19-212212_issues-to-tasks/up.sql | 49 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 migrations/2025-01-19-212212_issues-to-tasks/down.sql create mode 100644 migrations/2025-01-19-212212_issues-to-tasks/up.sql diff --git a/migrations/2025-01-19-212212_issues-to-tasks/down.sql b/migrations/2025-01-19-212212_issues-to-tasks/down.sql new file mode 100644 index 0000000..ba5aa26 --- /dev/null +++ b/migrations/2025-01-19-212212_issues-to-tasks/down.sql @@ -0,0 +1,3 @@ +DELETE FROM tasks +WHERE type = 'dev' + AND id IN (SELECT id FROM issues); \ No newline at end of file diff --git a/migrations/2025-01-19-212212_issues-to-tasks/up.sql b/migrations/2025-01-19-212212_issues-to-tasks/up.sql new file mode 100644 index 0000000..c626424 --- /dev/null +++ b/migrations/2025-01-19-212212_issues-to-tasks/up.sql @@ -0,0 +1,49 @@ +INSERT INTO tasks ( + id, + number, + repository_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, + 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 THEN 'open' + ELSE '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; \ No newline at end of file From 00f3903b680c03c6aecd260b1a2e7646f6fc7290 Mon Sep 17 00:00:00 2001 From: Leandro Palazzolo Date: Sun, 19 Jan 2025 18:56:08 -0300 Subject: [PATCH 02/19] feat: add contributor role to existing users --- .../2025-01-19-212212_issues-to-tasks/down.sql | 6 +++++- migrations/2025-01-19-212212_issues-to-tasks/up.sql | 12 +++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/migrations/2025-01-19-212212_issues-to-tasks/down.sql b/migrations/2025-01-19-212212_issues-to-tasks/down.sql index ba5aa26..2c4803c 100644 --- a/migrations/2025-01-19-212212_issues-to-tasks/down.sql +++ b/migrations/2025-01-19-212212_issues-to-tasks/down.sql @@ -1,3 +1,7 @@ DELETE FROM tasks WHERE type = 'dev' - AND id IN (SELECT id FROM issues); \ No newline at end of file + 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/migrations/2025-01-19-212212_issues-to-tasks/up.sql b/migrations/2025-01-19-212212_issues-to-tasks/up.sql index c626424..17125f0 100644 --- a/migrations/2025-01-19-212212_issues-to-tasks/up.sql +++ b/migrations/2025-01-19-212212_issues-to-tasks/up.sql @@ -1,3 +1,5 @@ +-- Migrate issues to tasks + INSERT INTO tasks ( id, number, @@ -46,4 +48,12 @@ FROM JOIN repositories r ON - i.repository_id = r.id; \ No newline at end of file + 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; From 7922792099b0ecc3089420c767dc41bb5d54047a Mon Sep 17 00:00:00 2001 From: Leandro Palazzolo Date: Sun, 19 Jan 2025 19:06:29 -0300 Subject: [PATCH 03/19] chore: add trigger to add contributor role --- .../down.sql | 2 ++ .../2025-01-19-215731_contributor-role/up.sql | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 migrations/2025-01-19-215731_contributor-role/down.sql create mode 100644 migrations/2025-01-19-215731_contributor-role/up.sql diff --git a/migrations/2025-01-19-215731_contributor-role/down.sql b/migrations/2025-01-19-215731_contributor-role/down.sql new file mode 100644 index 0000000..dd524fe --- /dev/null +++ b/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/migrations/2025-01-19-215731_contributor-role/up.sql b/migrations/2025-01-19-215731_contributor-role/up.sql new file mode 100644 index 0000000..b4d3d7a --- /dev/null +++ b/migrations/2025-01-19-215731_contributor-role/up.sql @@ -0,0 +1,19 @@ +CREATE OR REPLACE FUNCTION assign_role_to_user() +RETURNS TRIGGER AS $$ +BEGIN + 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 + ); + 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(); From ffb59b79f899e94db775e3b3f3e35200536b6d55 Mon Sep 17 00:00:00 2001 From: Leandro Palazzolo Date: Sun, 19 Jan 2025 19:42:18 -0300 Subject: [PATCH 04/19] chore: update triggers --- .../2025-01-19-212212_issues-to-tasks/up.sql | 5 +++-- .../2025-01-19-223138_tasks-status/down.sql | 2 ++ migrations/2025-01-19-223138_tasks-status/up.sql | 16 ++++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 migrations/2025-01-19-223138_tasks-status/down.sql create mode 100644 migrations/2025-01-19-223138_tasks-status/up.sql diff --git a/migrations/2025-01-19-212212_issues-to-tasks/up.sql b/migrations/2025-01-19-212212_issues-to-tasks/up.sql index 17125f0..e892177 100644 --- a/migrations/2025-01-19-212212_issues-to-tasks/up.sql +++ b/migrations/2025-01-19-212212_issues-to-tasks/up.sql @@ -34,8 +34,9 @@ SELECT i.updated_at, 'dev' AS type, CASE - WHEN i.open THEN 'open' - ELSE 'completed' + 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/', diff --git a/migrations/2025-01-19-223138_tasks-status/down.sql b/migrations/2025-01-19-223138_tasks-status/down.sql new file mode 100644 index 0000000..61a86fe --- /dev/null +++ b/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/migrations/2025-01-19-223138_tasks-status/up.sql b/migrations/2025-01-19-223138_tasks-status/up.sql new file mode 100644 index 0000000..6393bd4 --- /dev/null +++ b/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 From 659426ee9b1f4e4363944e4c1ed331317285eacd Mon Sep 17 00:00:00 2001 From: Connor Campbell Date: Tue, 28 Jan 2025 00:24:34 +0000 Subject: [PATCH 05/19] chore: add constraint to tasks table + trigger only on not null assignee --- .../2025-01-19-215731_contributor-role/up.sql | 18 ++++++++++-------- .../down.sql | 2 ++ .../up.sql | 2 ++ 3 files changed, 14 insertions(+), 8 deletions(-) create mode 100644 migrations/2025-01-27-234215_add_unique_constraint_to_tasks/down.sql create mode 100644 migrations/2025-01-27-234215_add_unique_constraint_to_tasks/up.sql diff --git a/migrations/2025-01-19-215731_contributor-role/up.sql b/migrations/2025-01-19-215731_contributor-role/up.sql index b4d3d7a..c0bab5a 100644 --- a/migrations/2025-01-19-215731_contributor-role/up.sql +++ b/migrations/2025-01-19-215731_contributor-role/up.sql @@ -1,14 +1,16 @@ CREATE OR REPLACE FUNCTION assign_role_to_user() RETURNS TRIGGER AS $$ BEGIN - 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 - ); + 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; diff --git a/migrations/2025-01-27-234215_add_unique_constraint_to_tasks/down.sql b/migrations/2025-01-27-234215_add_unique_constraint_to_tasks/down.sql new file mode 100644 index 0000000..7f14109 --- /dev/null +++ b/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/migrations/2025-01-27-234215_add_unique_constraint_to_tasks/up.sql b/migrations/2025-01-27-234215_add_unique_constraint_to_tasks/up.sql new file mode 100644 index 0000000..de54584 --- /dev/null +++ b/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); From b847e4766f108a970c3f97e9cd7b8d94f33d6f77 Mon Sep 17 00:00:00 2001 From: Leandro Palazzolo Date: Wed, 29 Jan 2025 18:46:49 -0300 Subject: [PATCH 06/19] chore: add migrations --- .../2025-01-29-212647_repository-id/down.sql | 3 +++ .../2025-01-29-212647_repository-id/up.sql | 6 ++++++ migrations/2025-01-29-212911_project-id/down.sql | 8 ++++++++ migrations/2025-01-29-212911_project-id/up.sql | 16 ++++++++++++++++ 4 files changed, 33 insertions(+) create mode 100644 migrations/2025-01-29-212647_repository-id/down.sql create mode 100644 migrations/2025-01-29-212647_repository-id/up.sql create mode 100644 migrations/2025-01-29-212911_project-id/down.sql create mode 100644 migrations/2025-01-29-212911_project-id/up.sql diff --git a/migrations/2025-01-29-212647_repository-id/down.sql b/migrations/2025-01-29-212647_repository-id/down.sql new file mode 100644 index 0000000..a3b0aa8 --- /dev/null +++ b/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/migrations/2025-01-29-212647_repository-id/up.sql b/migrations/2025-01-29-212647_repository-id/up.sql new file mode 100644 index 0000000..f2d97f0 --- /dev/null +++ b/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/migrations/2025-01-29-212911_project-id/down.sql b/migrations/2025-01-29-212911_project-id/down.sql new file mode 100644 index 0000000..dec2645 --- /dev/null +++ b/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/migrations/2025-01-29-212911_project-id/up.sql b/migrations/2025-01-29-212911_project-id/up.sql new file mode 100644 index 0000000..52a14c0 --- /dev/null +++ b/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 From ada529052b55b6e09d7c455ed2d9fac8124dc384 Mon Sep 17 00:00:00 2001 From: Connor Campbell Date: Thu, 30 Jan 2025 15:11:15 +0000 Subject: [PATCH 07/19] feat: add user to task response --- src/api/tasks/db.rs | 114 ++++++++++++++++++++++++++++++++++------ src/api/tasks/models.rs | 2 + 2 files changed, 101 insertions(+), 15 deletions(-) diff --git a/src/api/tasks/db.rs b/src/api/tasks/db.rs index 275ad70..faa8441 100644 --- a/src/api/tasks/db.rs +++ b/src/api/tasks/db.rs @@ -1,6 +1,7 @@ use diesel::prelude::*; use crate::schema::tasks::dsl as tasks_dsl; +use crate::schema::users::dsl as users_dsl; use crate::schema::tasks_votes::dsl as tasks_votes_dsl; use crate::db::{ @@ -9,15 +10,16 @@ use crate::db::{ }; use crate::types::PaginationParams; use crate::utils; +use crate::api::users::models::User; -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>; + ) -> Result<(Vec, i64), DBError>; + fn by_id(&self, id: i32) -> Result, DBError>; fn create(&self, role: &NewTask) -> Result; fn update(&self, id: i32, role: &UpdateTask) -> Result; fn delete(&self, id: i32) -> Result<(), DBError>; @@ -30,7 +32,7 @@ impl DBTask for DBAccess { &self, params: QueryParams, pagination: PaginationParams, - ) -> Result<(Vec, i64), DBError> { + ) -> Result<(Vec, i64), DBError> { let conn = &mut self.get_db_conn(); let build_query = || { @@ -106,24 +108,106 @@ impl DBTask for DBAccess { let total_count = build_query().count().get_result::(conn)?; - let result = build_query() + let joined_query = build_query() + .left_join(users_dsl::users.on(tasks_dsl::assignee_user_id.eq(users_dsl::id.nullable()))) + .select(( + (tasks_dsl::tasks::all_columns()), + (users_dsl::users::all_columns().nullable()), + )) .order(tasks_dsl::created_at.desc()) .offset(pagination.offset) - .limit(pagination.limit) - .load::(conn)?; + .limit(pagination.limit); - Ok((result, total_count)) + let rows = joined_query.load::<(Task, Option)>(conn)?; + + let tasks_with_assignee = rows + .into_iter() + .map(|(task, user)| 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, + 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, + }) + .collect(); + + Ok((tasks_with_assignee, total_count)) } - fn by_id(&self, id: i32) -> Result, DBError> { + fn by_id(&self, id: i32) -> 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( + users_dsl::users.on(tasks_dsl::assignee_user_id.eq(users_dsl::id.nullable())) + ) + .filter(tasks_dsl::id.eq(id)) + .select(( + (tasks_dsl::tasks::all_columns()), + (users_dsl::users::all_columns().nullable()), + )) + .first::<(Task, Option)>(conn) + .optional()?; + + Ok(row.map(|(task, user)| 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, + 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, + })) } + fn create(&self, task: &NewTask) -> Result { let conn = &mut self.get_db_conn(); diff --git a/src/api/tasks/models.rs b/src/api/tasks/models.rs index 6c38f33..8574f14 100644 --- a/src/api/tasks/models.rs +++ b/src/api/tasks/models.rs @@ -1,6 +1,7 @@ use crate::schema::{tasks, tasks_votes}; use chrono::{DateTime, Utc}; use diesel::prelude::*; +use crate::api::users::models::User; use serde_derive::{Deserialize, Serialize}; // tasks @@ -128,6 +129,7 @@ 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 assignee_team_id: Option, pub funding_options: Option>>, pub contact: Option, From ef4e51ea56cc0769db0a028517c634d6fe9ab3d0 Mon Sep 17 00:00:00 2001 From: Connor Campbell Date: Fri, 31 Jan 2025 10:59:08 +0000 Subject: [PATCH 08/19] chore: add repository and project to task response --- src/api/tasks/db.rs | 95 +++++++++++++++++++++++++++++++++++++---- src/api/tasks/models.rs | 2 + 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/src/api/tasks/db.rs b/src/api/tasks/db.rs index faa8441..40915d9 100644 --- a/src/api/tasks/db.rs +++ b/src/api/tasks/db.rs @@ -2,6 +2,8 @@ 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 crate::db::{ @@ -11,6 +13,9 @@ 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, TaskResponse}; pub trait DBTask: Send + Sync + Clone + 'static { @@ -36,7 +41,21 @@ impl DBTask for DBAccess { 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())) + ) + .inner_join( + projects_dsl::projects + .on(repositories_dsl::project_id.eq(projects_dsl::id)) + ) + .left_join( + users_dsl::users + .on(tasks_dsl::assignee_user_id.eq(users_dsl::id.nullable())) + ) + .into_boxed(); + if let Some(repository_id) = params.repository_id { query = query.filter(tasks_dsl::repository_id.eq(repository_id)); @@ -108,21 +127,22 @@ impl DBTask for DBAccess { let total_count = build_query().count().get_result::(conn)?; - let joined_query = build_query() - .left_join(users_dsl::users.on(tasks_dsl::assignee_user_id.eq(users_dsl::id.nullable()))) + let query = build_query() .select(( (tasks_dsl::tasks::all_columns()), + (repositories_dsl::repositories::all_columns().nullable()), + (projects_dsl::projects::all_columns()), (users_dsl::users::all_columns().nullable()), )) .order(tasks_dsl::created_at.desc()) .offset(pagination.offset) .limit(pagination.limit); - let rows = joined_query.load::<(Task, Option)>(conn)?; + let rows = query.load::<(Task, Option, Project, Option)>(conn)?; let tasks_with_assignee = rows .into_iter() - .map(|(task, user)| TaskResponse { + .map(|(task, repo, project, user)| TaskResponse { id: task.id, number: task.number, repository_id: task.repository_id, @@ -135,7 +155,28 @@ impl DBTask for DBAccess { project_id: task.project_id, created_by_user_id: task.created_by_user_id, assignee_user_id: task.assignee_user_id, - user, + user, + repository: repo.map(|r| RepositoryResponse { + id: r.id, + slug: r.slug, + name: r.name, + url: r.url, + language_slug: r.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, + }, + created_at: r.created_at, + updated_at: r.updated_at, + }), assignee_team_id: task.assignee_team_id, funding_options: task.funding_options, contact: task.contact, @@ -163,18 +204,32 @@ impl DBTask for DBAccess { let conn = &mut self.get_db_conn(); let row = tasks_dsl::tasks + .left_join( - users_dsl::users.on(tasks_dsl::assignee_user_id.eq(users_dsl::id.nullable())) + repositories_dsl::repositories + .on(tasks_dsl::repository_id.eq(repositories_dsl::id.nullable())) + ) + + .inner_join( + projects_dsl::projects + .on(repositories_dsl::project_id.eq(projects_dsl::id)) + ) + + .left_join( + users_dsl::users + .on(tasks_dsl::assignee_user_id.eq(users_dsl::id.nullable())) ) .filter(tasks_dsl::id.eq(id)) .select(( (tasks_dsl::tasks::all_columns()), + (repositories_dsl::repositories::all_columns().nullable()), + (projects_dsl::projects::all_columns()), (users_dsl::users::all_columns().nullable()), )) - .first::<(Task, Option)>(conn) + .first::<(Task, Option, Project, Option)>(conn) .optional()?; - Ok(row.map(|(task, user)| TaskResponse { + Ok(row.map(|(task, repo, project, user)| TaskResponse { id: task.id, number: task.number, repository_id: task.repository_id, @@ -205,9 +260,31 @@ impl DBTask for DBAccess { issue_closed_at: task.issue_closed_at, created_at: task.created_at, updated_at: task.updated_at, + repository: repo.map(|r| RepositoryResponse { + id: r.id, + slug: r.slug, + name: r.name, + url: r.url, + language_slug: r.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, + }, + created_at: r.created_at, + updated_at: r.updated_at, + }), })) } + fn create(&self, task: &NewTask) -> Result { let conn = &mut self.get_db_conn(); diff --git a/src/api/tasks/models.rs b/src/api/tasks/models.rs index 8574f14..d5a0a69 100644 --- a/src/api/tasks/models.rs +++ b/src/api/tasks/models.rs @@ -2,6 +2,7 @@ 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 serde_derive::{Deserialize, Serialize}; // tasks @@ -130,6 +131,7 @@ pub struct TaskResponse { 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 assignee_team_id: Option, pub funding_options: Option>>, pub contact: Option, From e8ad5b5555464bb43f954f5e6b3b8f9581b36e00 Mon Sep 17 00:00:00 2001 From: Connor Campbell Date: Sat, 26 Jul 2025 05:42:34 +0100 Subject: [PATCH 09/19] chore: split migrations to allow issue import + return repo & project in task response --- .gitignore | 3 +- Makefile | 8 ++ .../down.sql | 0 .../2025-01-19-212212_issues-to-tasks/up.sql | 0 .../down.sql | 0 .../2025-01-19-215731_contributor-role/up.sql | 0 .../2025-01-19-223138_tasks-status/down.sql | 0 .../2025-01-19-223138_tasks-status/up.sql | 0 .../down.sql | 0 .../up.sql | 0 .../2025-01-29-212647_repository-id/down.sql | 0 .../2025-01-29-212647_repository-id/up.sql | 0 .../2025-01-29-212911_project-id/down.sql | 0 .../2025-01-29-212911_project-id/up.sql | 0 .../down.sql | 0 .../up.sql | 0 .../2025-01-30-000001_notifications/down.sql | 0 .../2025-01-30-000001_notifications/up.sql | 0 .../down.sql | 0 .../up.sql | 0 .../down.sql | 0 .../up.sql | 0 src/api/notifications/db.rs | 76 +++++++++++++++++-- src/api/tasks/models.rs | 35 --------- src/schema.rs | 14 ---- 25 files changed, 80 insertions(+), 56 deletions(-) rename {migrations => secondary_migrations}/2025-01-19-212212_issues-to-tasks/down.sql (100%) rename {migrations => secondary_migrations}/2025-01-19-212212_issues-to-tasks/up.sql (100%) rename {migrations => secondary_migrations}/2025-01-19-215731_contributor-role/down.sql (100%) rename {migrations => secondary_migrations}/2025-01-19-215731_contributor-role/up.sql (100%) rename {migrations => secondary_migrations}/2025-01-19-223138_tasks-status/down.sql (100%) rename {migrations => secondary_migrations}/2025-01-19-223138_tasks-status/up.sql (100%) rename {migrations => secondary_migrations}/2025-01-27-234215_add_unique_constraint_to_tasks/down.sql (100%) rename {migrations => secondary_migrations}/2025-01-27-234215_add_unique_constraint_to_tasks/up.sql (100%) rename {migrations => secondary_migrations}/2025-01-29-212647_repository-id/down.sql (100%) rename {migrations => secondary_migrations}/2025-01-29-212647_repository-id/up.sql (100%) rename {migrations => secondary_migrations}/2025-01-29-212911_project-id/down.sql (100%) rename {migrations => secondary_migrations}/2025-01-29-212911_project-id/up.sql (100%) rename {migrations => secondary_migrations}/2025-01-30-000000_user_subscriptions/down.sql (100%) rename {migrations => secondary_migrations}/2025-01-30-000000_user_subscriptions/up.sql (100%) rename {migrations => secondary_migrations}/2025-01-30-000001_notifications/down.sql (100%) rename {migrations => secondary_migrations}/2025-01-30-000001_notifications/up.sql (100%) rename {migrations => secondary_migrations}/2025-01-30-000003_add_email_notifications_flag/down.sql (100%) rename {migrations => secondary_migrations}/2025-01-30-000003_add_email_notifications_flag/up.sql (100%) rename {migrations => secondary_migrations}/2025-01-30-000004_notification_schedule/down.sql (100%) rename {migrations => secondary_migrations}/2025-01-30-000004_notification_schedule/up.sql (100%) 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/migrations/2025-01-19-212212_issues-to-tasks/down.sql b/secondary_migrations/2025-01-19-212212_issues-to-tasks/down.sql similarity index 100% rename from migrations/2025-01-19-212212_issues-to-tasks/down.sql rename to secondary_migrations/2025-01-19-212212_issues-to-tasks/down.sql diff --git a/migrations/2025-01-19-212212_issues-to-tasks/up.sql b/secondary_migrations/2025-01-19-212212_issues-to-tasks/up.sql similarity index 100% rename from migrations/2025-01-19-212212_issues-to-tasks/up.sql rename to secondary_migrations/2025-01-19-212212_issues-to-tasks/up.sql diff --git a/migrations/2025-01-19-215731_contributor-role/down.sql b/secondary_migrations/2025-01-19-215731_contributor-role/down.sql similarity index 100% rename from migrations/2025-01-19-215731_contributor-role/down.sql rename to secondary_migrations/2025-01-19-215731_contributor-role/down.sql diff --git a/migrations/2025-01-19-215731_contributor-role/up.sql b/secondary_migrations/2025-01-19-215731_contributor-role/up.sql similarity index 100% rename from migrations/2025-01-19-215731_contributor-role/up.sql rename to secondary_migrations/2025-01-19-215731_contributor-role/up.sql diff --git a/migrations/2025-01-19-223138_tasks-status/down.sql b/secondary_migrations/2025-01-19-223138_tasks-status/down.sql similarity index 100% rename from migrations/2025-01-19-223138_tasks-status/down.sql rename to secondary_migrations/2025-01-19-223138_tasks-status/down.sql diff --git a/migrations/2025-01-19-223138_tasks-status/up.sql b/secondary_migrations/2025-01-19-223138_tasks-status/up.sql similarity index 100% rename from migrations/2025-01-19-223138_tasks-status/up.sql rename to secondary_migrations/2025-01-19-223138_tasks-status/up.sql diff --git a/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 similarity index 100% rename from migrations/2025-01-27-234215_add_unique_constraint_to_tasks/down.sql rename to secondary_migrations/2025-01-27-234215_add_unique_constraint_to_tasks/down.sql diff --git a/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 similarity index 100% rename from migrations/2025-01-27-234215_add_unique_constraint_to_tasks/up.sql rename to secondary_migrations/2025-01-27-234215_add_unique_constraint_to_tasks/up.sql diff --git a/migrations/2025-01-29-212647_repository-id/down.sql b/secondary_migrations/2025-01-29-212647_repository-id/down.sql similarity index 100% rename from migrations/2025-01-29-212647_repository-id/down.sql rename to secondary_migrations/2025-01-29-212647_repository-id/down.sql diff --git a/migrations/2025-01-29-212647_repository-id/up.sql b/secondary_migrations/2025-01-29-212647_repository-id/up.sql similarity index 100% rename from migrations/2025-01-29-212647_repository-id/up.sql rename to secondary_migrations/2025-01-29-212647_repository-id/up.sql diff --git a/migrations/2025-01-29-212911_project-id/down.sql b/secondary_migrations/2025-01-29-212911_project-id/down.sql similarity index 100% rename from migrations/2025-01-29-212911_project-id/down.sql rename to secondary_migrations/2025-01-29-212911_project-id/down.sql diff --git a/migrations/2025-01-29-212911_project-id/up.sql b/secondary_migrations/2025-01-29-212911_project-id/up.sql similarity index 100% rename from migrations/2025-01-29-212911_project-id/up.sql rename to secondary_migrations/2025-01-29-212911_project-id/up.sql 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/src/api/notifications/db.rs b/src/api/notifications/db.rs index 8fe91d7..ab751cd 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,20 +25,79 @@ 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()))) + .inner_join(projects_dsl::projects.on(repositories_dsl::project_id.eq(projects_dsl::id))) + .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(), + users_dsl::users::all_columns().nullable(), )) - .load::<(Notification, Task)>(conn) + .load::<(Notification, Task, Option, Project, Option)>(conn) .map_err(DBError::from)? .into_iter() - .map(|(notification, task)| NotificationResponse { + .map(|(notification, task, repo, project, user)| NotificationResponse { id: notification.id, task_id: notification.task_id, - task: task.into(), + 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, + 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: repo.map(|r| RepositoryResponse { + id: r.id, + slug: r.slug, + name: r.name, + url: r.url, + language_slug: r.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, + }, + created_at: r.created_at, + updated_at: r.updated_at, + }), + }, created_at: notification.created_at, }) .collect(); diff --git a/src/api/tasks/models.rs b/src/api/tasks/models.rs index d5a0a69..e7918e0 100644 --- a/src/api/tasks/models.rs +++ b/src/api/tasks/models.rs @@ -210,38 +210,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/schema.rs b/src/schema.rs index ca590f2..a527cf5 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, @@ -200,7 +188,6 @@ 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!(tasks -> projects (project_id)); @@ -216,7 +203,6 @@ diesel::joinable!(users_projects_roles -> users (user_id)); diesel::allow_tables_to_appear_in_same_query!( issues, languages, - milestones, notification_schedule, notifications, projects, From f40ac860ab88c59896fd716441282eec09b550eb Mon Sep 17 00:00:00 2001 From: Connor Campbell Date: Sat, 26 Jul 2025 07:59:18 +0100 Subject: [PATCH 10/19] chore: allow for null project --- src/api/tasks/db.rs | 110 +++++++++++++++++++++------------------- src/api/tasks/routes.rs | 2 +- 2 files changed, 59 insertions(+), 53 deletions(-) diff --git a/src/api/tasks/db.rs b/src/api/tasks/db.rs index 40915d9..fd4cd25 100644 --- a/src/api/tasks/db.rs +++ b/src/api/tasks/db.rs @@ -46,9 +46,9 @@ impl DBTask for DBAccess { repositories_dsl::repositories .on(tasks_dsl::repository_id.eq(repositories_dsl::id.nullable())) ) - .inner_join( + .left_join( projects_dsl::projects - .on(repositories_dsl::project_id.eq(projects_dsl::id)) + .on(tasks_dsl::project_id.eq(projects_dsl::id.nullable())) ) .left_join( users_dsl::users @@ -131,14 +131,14 @@ impl DBTask for DBAccess { .select(( (tasks_dsl::tasks::all_columns()), (repositories_dsl::repositories::all_columns().nullable()), - (projects_dsl::projects::all_columns()), + (projects_dsl::projects::all_columns().nullable()), (users_dsl::users::all_columns().nullable()), )) .order(tasks_dsl::created_at.desc()) .offset(pagination.offset) .limit(pagination.limit); - let rows = query.load::<(Task, Option, Project, Option)>(conn)?; + let rows = query.load::<(Task, Option, Option, Option)>(conn)?; let tasks_with_assignee = rows .into_iter() @@ -156,27 +156,30 @@ impl DBTask for DBAccess { created_by_user_id: task.created_by_user_id, assignee_user_id: task.assignee_user_id, user, - repository: repo.map(|r| RepositoryResponse { - id: r.id, - slug: r.slug, - name: r.name, - url: r.url, - language_slug: r.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, - }, - created_at: r.created_at, - updated_at: r.updated_at, - }), + repository: match (repo, project) { + (Some(r), Some(p)) => Some(RepositoryResponse { + id: r.id, + slug: r.slug, + name: r.name, + url: r.url, + language_slug: r.language_slug, + project: 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, + }, + created_at: r.created_at, + updated_at: r.updated_at, + }), + _ => None, + }, assignee_team_id: task.assignee_team_id, funding_options: task.funding_options, contact: task.contact, @@ -210,10 +213,10 @@ impl DBTask for DBAccess { .on(tasks_dsl::repository_id.eq(repositories_dsl::id.nullable())) ) - .inner_join( - projects_dsl::projects - .on(repositories_dsl::project_id.eq(projects_dsl::id)) - ) + .left_join( + projects_dsl::projects + .on(tasks_dsl::project_id.eq(projects_dsl::id.nullable())) + ) .left_join( users_dsl::users @@ -223,10 +226,10 @@ impl DBTask for DBAccess { .select(( (tasks_dsl::tasks::all_columns()), (repositories_dsl::repositories::all_columns().nullable()), - (projects_dsl::projects::all_columns()), + (projects_dsl::projects::all_columns().nullable()), (users_dsl::users::all_columns().nullable()), )) - .first::<(Task, Option, Project, Option)>(conn) + .first::<(Task, Option, Option, Option)>(conn) .optional()?; Ok(row.map(|(task, repo, project, user)| TaskResponse { @@ -260,27 +263,30 @@ impl DBTask for DBAccess { issue_closed_at: task.issue_closed_at, created_at: task.created_at, updated_at: task.updated_at, - repository: repo.map(|r| RepositoryResponse { - id: r.id, - slug: r.slug, - name: r.name, - url: r.url, - language_slug: r.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, - }, - created_at: r.created_at, - updated_at: r.updated_at, - }), + repository: match (repo, project) { + (Some(r), Some(p)) => Some(RepositoryResponse { + id: r.id, + slug: r.slug, + name: r.name, + url: r.url, + language_slug: r.language_slug, + project: 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, + }, + created_at: r.created_at, + updated_at: r.updated_at, + }), + _ => None, + }, })) } diff --git a/src/api/tasks/routes.rs b/src/api/tasks/routes.rs index d1ef02d..3e56ac4 100644 --- a/src/api/tasks/routes.rs +++ b/src/api/tasks/routes.rs @@ -63,7 +63,7 @@ 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()) From df0c4d603831fbbcea876185185d5ccd1c0e4f71 Mon Sep 17 00:00:00 2001 From: Connor Campbell Date: Sat, 26 Jul 2025 10:15:30 +0100 Subject: [PATCH 11/19] feat: add wishlist comments --- .../down.sql | 2 + .../up.sql | 13 +++++ src/api/comments/db.rs | 52 +++++++++++++++++ src/api/comments/errors.rs | 57 +++++++++++++++++++ src/api/comments/handlers.rs | 43 ++++++++++++++ src/api/comments/mod.rs | 5 ++ src/api/comments/models.rs | 44 ++++++++++++++ src/api/comments/routes.rs | 28 +++++++++ src/api/mod.rs | 3 +- src/errors.rs | 3 + src/schema.rs | 15 +++++ src/utils.rs | 4 +- 12 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 secondary_migrations/2025-07-26-070123_create_task_comments/down.sql create mode 100644 secondary_migrations/2025-07-26-070123_create_task_comments/up.sql create mode 100644 src/api/comments/db.rs create mode 100644 src/api/comments/errors.rs create mode 100644 src/api/comments/handlers.rs create mode 100644 src/api/comments/mod.rs create mode 100644 src/api/comments/models.rs create mode 100644 src/api/comments/routes.rs 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/src/api/comments/db.rs b/src/api/comments/db.rs new file mode 100644 index 0000000..2249dcc --- /dev/null +++ b/src/api/comments/db.rs @@ -0,0 +1,52 @@ +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}; + +pub trait DBComment: Send + Sync + Clone + 'static { + fn by_task_id(&self, task_id_param: i32) -> Result, DBError>; + fn create(&self, comment: &NewComment) -> 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 = task_comments::table + .inner_join(users::table.on(task_comments::user_id.eq(users::id))) + .filter(task_comments::task_id.eq(task_id_param)) + .order(task_comments::created_at.asc()) + .select((Comment::as_select(), User::as_select())) + .load::<(Comment, User)>(conn)?; + + let results = comments_with_users.into_iter().map(|(comment, user)| { + CommentResponse { + id: comment.id, + content: comment.content, + created_at: comment.created_at, + user, + } + }).collect(); + + Ok(results) + } + + 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, + }) + } +} \ 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..7e7e40f --- /dev/null +++ b/src/api/comments/handlers.rs @@ -0,0 +1,43 @@ +use bytes::Buf; +use log::{error, info, 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; + +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 mut 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)) +} \ 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..580c92d --- /dev/null +++ b/src/api/comments/models.rs @@ -0,0 +1,44 @@ +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>, +} + +#[derive(Serialize, Debug)] +pub struct CommentResponse { + pub id: i32, + pub content: String, + pub created_at: DateTime, + pub user: User, // Embed user details directly + // Add replies field here later for threading +} + +#[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..aa35b11 --- /dev/null +++ b/src/api/comments/routes.rs @@ -0,0 +1,28 @@ +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 super::db::DBComment; +use super::handlers; + +fn with_db(db_pool: impl DBComment + DBUser) -> impl Filter + Clone { + warp::any().map(move || db_pool.clone()) +} + +pub fn routes(db_access: impl DBComment + DBUser) -> BoxedFilter<(impl Reply,)> { + let base = warp::path!("tasks" / i32 / "comments"); + + let get_comments = base + .and(warp::get()) + .and(with_db(db_access.clone())) + .and_then(handlers::get_comments_handler); + + let create_comment = base + .and(warp::post()) + .and(with_github_auth()) + .and(warp::body::aggregate()) + .and(with_db(db_access.clone())) + .and_then(handlers::create_comment_handler); + + get_comments.or(create_comment).boxed() +} \ No newline at end of file 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/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/schema.rs b/src/schema.rs index a527cf5..ccd7b56 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -87,6 +87,18 @@ 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, + } +} + diesel::table! { tasks (id) { id -> Int4, @@ -190,6 +202,8 @@ diesel::joinable!(issues -> repositories (repository_id)); diesel::joinable!(issues -> users (assignee_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)); @@ -208,6 +222,7 @@ diesel::allow_tables_to_appear_in_same_query!( 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) From 5494929f1f87b81f54c15edb0774a52771fca026 Mon Sep 17 00:00:00 2001 From: Connor Campbell Date: Sun, 27 Jul 2025 06:16:03 +0100 Subject: [PATCH 12/19] feat: threaded comment support --- src/api/comments/db.rs | 2 ++ src/api/comments/handlers.rs | 4 ++-- src/api/comments/models.rs | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/api/comments/db.rs b/src/api/comments/db.rs index 2249dcc..d0295d0 100644 --- a/src/api/comments/db.rs +++ b/src/api/comments/db.rs @@ -25,6 +25,7 @@ impl DBComment for DBAccess { content: comment.content, created_at: comment.created_at, user, + parent_comment_id: comment.parent_comment_id } }).collect(); @@ -47,6 +48,7 @@ impl DBComment for DBAccess { content: new_comment.content, created_at: new_comment.created_at, user, + parent_comment_id: new_comment.parent_comment_id }) } } \ No newline at end of file diff --git a/src/api/comments/handlers.rs b/src/api/comments/handlers.rs index 7e7e40f..1ee38d1 100644 --- a/src/api/comments/handlers.rs +++ b/src/api/comments/handlers.rs @@ -1,5 +1,5 @@ use bytes::Buf; -use log::{error, info, warn}; +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; @@ -20,7 +20,7 @@ pub async fn create_comment_handler( db_access: impl DBComment + DBUser, ) -> Result { let des = &mut serde_json::Deserializer::from_reader(buf.reader()); - let mut comment_payload: CreateCommentPayload = serde_path_to_error::deserialize(des).map_err(|e| { + 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)) diff --git a/src/api/comments/models.rs b/src/api/comments/models.rs index 580c92d..31b3536 100644 --- a/src/api/comments/models.rs +++ b/src/api/comments/models.rs @@ -24,6 +24,7 @@ pub struct CommentResponse { pub content: String, pub created_at: DateTime, pub user: User, // Embed user details directly + pub parent_comment_id: Option, // Add replies field here later for threading } From 0dcb0853b8e96259ee7e7010a485904f5a31ca01 Mon Sep 17 00:00:00 2001 From: Connor Campbell Date: Sun, 27 Jul 2025 06:18:55 +0100 Subject: [PATCH 13/19] feat: support augmented GET data when authed --- src/middlewares/github/auth.rs | 39 +++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) 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)), + } +} From 6e4dffb3766ddbad78d222192f34c0d8ea7c8a2d Mon Sep 17 00:00:00 2001 From: Connor Campbell Date: Sun, 27 Jul 2025 06:20:01 +0100 Subject: [PATCH 14/19] refactor: support vote swapping --- .../down.sql | 31 +++++++++++++ .../up.sql | 45 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 secondary_migrations/2025-07-27-025644_update_task_vote_trigger/down.sql create mode 100644 secondary_migrations/2025-07-27-025644_update_task_vote_trigger/up.sql 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(); From 5d8b14d3743c7302d3af8ea83e5017fa7e78d0ba Mon Sep 17 00:00:00 2001 From: Connor Campbell Date: Sun, 27 Jul 2025 06:22:23 +0100 Subject: [PATCH 15/19] refactor: handlers support optional auth + use DB user --- src/api/notifications/db.rs | 1 + src/api/tasks/db.rs | 34 ++++++-- src/api/tasks/handlers.rs | 159 ++++++++++++++++++++---------------- src/api/tasks/models.rs | 14 +++- src/api/tasks/routes.rs | 4 +- 5 files changed, 128 insertions(+), 84 deletions(-) diff --git a/src/api/notifications/db.rs b/src/api/notifications/db.rs index ab751cd..680a5ae 100644 --- a/src/api/notifications/db.rs +++ b/src/api/notifications/db.rs @@ -68,6 +68,7 @@ impl DBNotification for DBAccess { 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, diff --git a/src/api/tasks/db.rs b/src/api/tasks/db.rs index fd4cd25..afed2eb 100644 --- a/src/api/tasks/db.rs +++ b/src/api/tasks/db.rs @@ -5,6 +5,7 @@ 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, @@ -23,8 +24,9 @@ pub trait DBTask: Send + Sync + Clone + 'static { &self, params: QueryParams, pagination: PaginationParams, + current_user_id: Option ) -> Result<(Vec, i64), DBError>; - fn by_id(&self, id: i32) -> Result, 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>; @@ -37,6 +39,7 @@ impl DBTask for DBAccess { &self, params: QueryParams, pagination: PaginationParams, + current_user_id: Option, ) -> Result<(Vec, i64), DBError> { let conn = &mut self.get_db_conn(); @@ -54,6 +57,10 @@ impl DBTask for DBAccess { 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(); @@ -133,16 +140,17 @@ impl DBTask for DBAccess { (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); - let rows = query.load::<(Task, Option, Option, Option)>(conn)?; + let rows = query.load::<(Task, Option, Option, Option, Option)>(conn)?; let tasks_with_assignee = rows .into_iter() - .map(|(task, repo, project, user)| TaskResponse { + .map(|(task, repo, project, user, user_vote)| TaskResponse { id: task.id, number: task.number, repository_id: task.repository_id, @@ -190,6 +198,7 @@ impl DBTask for DBAccess { 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, @@ -203,7 +212,7 @@ impl DBTask for DBAccess { 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 row = tasks_dsl::tasks @@ -222,17 +231,22 @@ impl DBTask for DBAccess { 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)>(conn) + .first::<(Task, Option, Option, Option, Option)>(conn) .optional()?; - Ok(row.map(|(task, repo, project, user)| TaskResponse { + Ok(row.map(|(task, repo, project, user, user_vote)| TaskResponse { id: task.id, number: task.number, repository_id: task.repository_id, @@ -256,6 +270,7 @@ impl DBTask for DBAccess { 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, @@ -323,13 +338,18 @@ 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))) diff --git a/src/api/tasks/handlers.rs b/src/api/tasks/handlers.rs index 4f1b5a2..ab90d3f 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,45 +278,40 @@ 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( @@ -319,3 +333,4 @@ pub async fn delete_task_vote( } } } + diff --git a/src/api/tasks/models.rs b/src/api/tasks/models.rs index e7918e0..09219ce 100644 --- a/src/api/tasks/models.rs +++ b/src/api/tasks/models.rs @@ -142,6 +142,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, @@ -190,10 +191,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, } diff --git a/src/api/tasks/routes.rs b/src/api/tasks/routes.rs index 3e56ac4..dd212d3 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; @@ -26,6 +26,7 @@ pub fn routes(db_access: impl DBTask + DBUser + DBRole) -> BoxedFilter<(impl Rep 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); From 00946244c6be62a0492e9583ca3ad3d10b5ae70f Mon Sep 17 00:00:00 2001 From: Connor Campbell Date: Sun, 27 Jul 2025 07:13:18 +0100 Subject: [PATCH 16/19] feat: implement delete vote --- src/api/tasks/db.rs | 26 +++++++++++----- src/api/tasks/handlers.rs | 62 ++++++++++++++++++++++++++++++--------- src/api/tasks/routes.rs | 9 +++--- 3 files changed, 72 insertions(+), 25 deletions(-) diff --git a/src/api/tasks/db.rs b/src/api/tasks/db.rs index afed2eb..2667845 100644 --- a/src/api/tasks/db.rs +++ b/src/api/tasks/db.rs @@ -31,7 +31,8 @@ pub trait DBTask: Send + Sync + Clone + 'static { 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 { @@ -350,13 +351,24 @@ impl DBTask for DBAccess { 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 ab90d3f..87a74bb 100644 --- a/src/api/tasks/handlers.rs +++ b/src/api/tasks/handlers.rs @@ -314,22 +314,56 @@ pub async fn add_downvote_to_task( 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/routes.rs b/src/api/tasks/routes.rs index dd212d3..7f01586 100644 --- a/src/api/tasks/routes.rs +++ b/src/api/tasks/routes.rs @@ -22,7 +22,7 @@ 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()) @@ -72,11 +72,12 @@ pub fn routes(db_access: impl DBTask + DBUser + DBRole) -> BoxedFilter<(impl Rep .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) @@ -85,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() } From dae28b7114919f04b6625b592731e3f3cd797d99 Mon Sep 17 00:00:00 2001 From: Connor Campbell Date: Sun, 27 Jul 2025 09:03:43 +0100 Subject: [PATCH 17/19] feat: support comment deletion --- .../down.sql | 3 + .../up.sql | 5 + src/api/comments/db.rs | 119 ++++++++++++++++-- src/api/comments/handlers.rs | 38 ++++++ src/api/comments/models.rs | 5 +- src/api/comments/routes.rs | 25 +++- src/schema.rs | 1 + 7 files changed, 178 insertions(+), 18 deletions(-) create mode 100644 secondary_migrations/2025-07-27-061329_add_comment_status/down.sql create mode 100644 secondary_migrations/2025-07-27-061329_add_comment_status/up.sql 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/src/api/comments/db.rs b/src/api/comments/db.rs index d0295d0..f9b1b2e 100644 --- a/src/api/comments/db.rs +++ b/src/api/comments/db.rs @@ -3,35 +3,103 @@ 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; 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 = task_comments::table - .inner_join(users::table.on(task_comments::user_id.eq(users::id))) - .filter(task_comments::task_id.eq(task_id_param)) - .order(task_comments::created_at.asc()) + 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)| { - CommentResponse { - id: comment.id, - content: comment.content, - created_at: comment.created_at, - user, - parent_comment_id: comment.parent_comment_id + 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: 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, + }, + } + } 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: 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, + }, + } + } 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) @@ -48,7 +116,34 @@ impl DBComment for DBAccess { content: new_comment.content, created_at: new_comment.created_at, user, - parent_comment_id: new_comment.parent_comment_id + 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/handlers.rs b/src/api/comments/handlers.rs index 1ee38d1..b6945cf 100644 --- a/src/api/comments/handlers.rs +++ b/src/api/comments/handlers.rs @@ -7,6 +7,7 @@ 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)?; @@ -40,4 +41,41 @@ pub async fn create_comment_handler( 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/models.rs b/src/api/comments/models.rs index 31b3536..ccc4210 100644 --- a/src/api/comments/models.rs +++ b/src/api/comments/models.rs @@ -16,6 +16,7 @@ pub struct Comment { pub parent_comment_id: Option, pub created_at: DateTime, pub updated_at: Option>, + pub status: String, } #[derive(Serialize, Debug)] @@ -23,9 +24,9 @@ pub struct CommentResponse { pub id: i32, pub content: String, pub created_at: DateTime, - pub user: User, // Embed user details directly + pub user: User, pub parent_comment_id: Option, - // Add replies field here later for threading + pub status: String, } #[derive(Insertable, Deserialize, Debug)] diff --git a/src/api/comments/routes.rs b/src/api/comments/routes.rs index aa35b11..a3d4f72 100644 --- a/src/api/comments/routes.rs +++ b/src/api/comments/routes.rs @@ -2,27 +2,44 @@ 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) -> impl Filter + Clone { +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) -> BoxedFilter<(impl Reply,)> { +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(warp::post()) .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(create_comment).boxed() + get_comments + .or(get_comment_by_id) + .or(create_comment) + .or(delete_comment) + .boxed() } \ No newline at end of file diff --git a/src/schema.rs b/src/schema.rs index ccd7b56..a7f924d 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -96,6 +96,7 @@ diesel::table! { parent_comment_id -> Nullable, created_at -> Timestamptz, updated_at -> Nullable, + status -> Text, } } From 1e195d79c3e89ef9dd9bd1df76bb2ba4794667fc Mon Sep 17 00:00:00 2001 From: Connor Campbell Date: Sun, 27 Jul 2025 17:02:22 +0100 Subject: [PATCH 18/19] refactor: return project separately --- .../2025-01-19-212212_issues-to-tasks/up.sql | 2 + src/api/issues/db.rs | 24 +- src/api/notifications/db.rs | 136 ++++++----- src/api/repositories/models.rs | 2 +- src/api/tasks/db.rs | 231 +++++++++--------- src/api/tasks/models.rs | 2 + 6 files changed, 209 insertions(+), 188 deletions(-) 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 index e892177..e849226 100644 --- a/secondary_migrations/2025-01-19-212212_issues-to-tasks/up.sql +++ b/secondary_migrations/2025-01-19-212212_issues-to-tasks/up.sql @@ -4,6 +4,7 @@ INSERT INTO tasks ( id, number, repository_id, + project_id, title, description, labels, @@ -22,6 +23,7 @@ SELECT i.id, i.number, i.repository_id, + r.project_id, i.title, i.description, i.labels, 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/notifications/db.rs b/src/api/notifications/db.rs index 680a5ae..9dc0bef 100644 --- a/src/api/notifications/db.rs +++ b/src/api/notifications/db.rs @@ -26,9 +26,12 @@ impl DBNotification for DBAccess { let result = notifications_dsl::notifications .inner_join(tasks_dsl::tasks - .left_join(repositories_dsl::repositories.on(tasks_dsl::repository_id.eq(repositories_dsl::id.nullable()))) - .inner_join(projects_dsl::projects.on(repositories_dsl::project_id.eq(projects_dsl::id))) - .left_join(users_dsl::users.on(tasks_dsl::assignee_user_id.eq(users_dsl::id.nullable()))) + .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)) @@ -36,70 +39,77 @@ impl DBNotification for DBAccess { notifications_dsl::notifications::all_columns(), tasks_dsl::tasks::all_columns(), repositories_dsl::repositories::all_columns().nullable(), - projects_dsl::projects::all_columns(), + projects_dsl::projects::all_columns().nullable(), users_dsl::users::all_columns().nullable(), )) - .load::<(Notification, Task, Option, Project, Option)>(conn) + .load::<(Notification, Task, Option, Option, Option)>(conn) .map_err(DBError::from)? .into_iter() - .map(|(notification, task, repo, project, user)| 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: repo.map(|r| RepositoryResponse { - id: r.id, - slug: r.slug, - name: r.name, - url: r.url, - language_slug: r.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, - }, - created_at: r.created_at, - updated_at: r.updated_at, - }), - }, - 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 2667845..6be271b 100644 --- a/src/api/tasks/db.rs +++ b/src/api/tasks/db.rs @@ -151,62 +151,66 @@ impl DBTask for DBAccess { let tasks_with_assignee = rows .into_iter() - .map(|(task, repo, project, user, user_vote)| 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: match (repo, project) { - (Some(r), Some(p)) => Some(RepositoryResponse { - id: r.id, - slug: r.slug, - name: r.name, - url: r.url, - language_slug: r.language_slug, - project: 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, - }, - created_at: r.created_at, - updated_at: r.updated_at, - }), - _ => None, - }, - 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, + .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, + } }) .collect(); @@ -247,62 +251,65 @@ impl DBTask for DBAccess { .first::<(Task, Option, Option, Option, Option)>(conn) .optional()?; - Ok(row.map(|(task, repo, project, user, user_vote)| 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, - 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, - repository: match (repo, project) { - (Some(r), Some(p)) => Some(RepositoryResponse { - id: r.id, - slug: r.slug, - name: r.name, - url: r.url, - language_slug: r.language_slug, - project: 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, - }, - created_at: r.created_at, - updated_at: r.updated_at, - }), - _ => None, - }, + 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, + } })) } diff --git a/src/api/tasks/models.rs b/src/api/tasks/models.rs index 09219ce..ad44132 100644 --- a/src/api/tasks/models.rs +++ b/src/api/tasks/models.rs @@ -3,6 +3,7 @@ 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 @@ -132,6 +133,7 @@ pub struct TaskResponse { 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, From efd3d6bbf1ca37e9f3c8299e8f853cce073d36ed Mon Sep 17 00:00:00 2001 From: Connor Campbell Date: Thu, 15 Jan 2026 15:33:45 +0000 Subject: [PATCH 19/19] feat: user bios skills and interests --- .../down.sql | 12 ++++ .../up.sql | 14 +++++ src/api/comments/db.rs | 40 +++++++------- src/api/users/db.rs | 55 ++++++++++++------- src/api/users/handlers.rs | 43 ++++++++++++++- src/api/users/models.rs | 16 ++++++ src/api/users/routes.rs | 39 +++++++++++-- src/schema.rs | 5 ++ 8 files changed, 178 insertions(+), 46 deletions(-) create mode 100644 secondary_migrations/2026-01-14-075034_add_user_socials_bio_skills_interests/down.sql create mode 100644 secondary_migrations/2026-01-14-075034_add_user_socials_bio_skills_interests/up.sql 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 index f9b1b2e..084b81c 100644 --- a/src/api/comments/db.rs +++ b/src/api/comments/db.rs @@ -6,6 +6,24 @@ 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>; @@ -33,16 +51,7 @@ impl DBComment for DBAccess { content: "[deleted]".to_string(), created_at: comment.created_at, parent_comment_id: comment.parent_comment_id, - 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, - }, + user: deleted_user_placeholder() } } else { CommentResponse { @@ -75,16 +84,7 @@ impl DBComment for DBAccess { content: "[deleted]".to_string(), created_at: comment.created_at, parent_comment_id: comment.parent_comment_id, - 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, - }, + user: deleted_user_placeholder() } } else { CommentResponse { 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/schema.rs b/src/schema.rs index a7f924d..68be893 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -186,6 +186,11 @@ diesel::table! { github_id -> Nullable, email_notifications_enabled -> Bool, email -> Nullable, + bio -> Nullable, + skills -> Nullable>>, + interests -> Nullable>>, + telegram -> Nullable, + twitter -> Nullable, } }