diff --git a/crates/remote/.sqlx/query-0b2d259fbe04067a03327d9cb9c1e6f4b95b3cce36a3553f46ecf577270d011e.json b/crates/remote/.sqlx/query-0b2d259fbe04067a03327d9cb9c1e6f4b95b3cce36a3553f46ecf577270d011e.json new file mode 100644 index 000000000..e106be34b --- /dev/null +++ b/crates/remote/.sqlx/query-0b2d259fbe04067a03327d9cb9c1e6f4b95b3cce36a3553f46ecf577270d011e.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n task_id AS \"task_id!: Uuid\",\n tag_id AS \"tag_id!: Uuid\"\n FROM task_tags\n WHERE task_id = $1 AND tag_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "task_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "tag_id!: Uuid", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "0b2d259fbe04067a03327d9cb9c1e6f4b95b3cce36a3553f46ecf577270d011e" +} diff --git a/crates/remote/.sqlx/query-0d628cb0f5618cbedaaae9b3d2c62dd578c6feb29209134db521252692fc7ab9.json b/crates/remote/.sqlx/query-0d628cb0f5618cbedaaae9b3d2c62dd578c6feb29209134db521252692fc7ab9.json new file mode 100644 index 000000000..ffc358d20 --- /dev/null +++ b/crates/remote/.sqlx/query-0d628cb0f5618cbedaaae9b3d2c62dd578c6feb29209134db521252692fc7ab9.json @@ -0,0 +1,50 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO task_comment_reactions (id, comment_id, user_id, emoji, created_at)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING\n id AS \"id!: Uuid\",\n comment_id AS \"comment_id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n emoji AS \"emoji!\",\n created_at AS \"created_at!: DateTime\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "comment_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "user_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "emoji!", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Varchar", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "0d628cb0f5618cbedaaae9b3d2c62dd578c6feb29209134db521252692fc7ab9" +} diff --git a/crates/remote/.sqlx/query-181f3db3dd396f36855237603fd72aed540df74e2ee7c61db5a100bb0a9c7474.json b/crates/remote/.sqlx/query-181f3db3dd396f36855237603fd72aed540df74e2ee7c61db5a100bb0a9c7474.json new file mode 100644 index 000000000..8a6ef601e --- /dev/null +++ b/crates/remote/.sqlx/query-181f3db3dd396f36855237603fd72aed540df74e2ee7c61db5a100bb0a9c7474.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\",\n sort_order AS \"sort_order!\",\n created_at AS \"created_at!: DateTime\"\n FROM project_statuses\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "project_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name!", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "color!", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "sort_order!", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "181f3db3dd396f36855237603fd72aed540df74e2ee7c61db5a100bb0a9c7474" +} diff --git a/crates/remote/.sqlx/query-28ece26c5f451c45799920a02f8b44be082ed8cd8d5596be7eb793136d2a9701.json b/crates/remote/.sqlx/query-28ece26c5f451c45799920a02f8b44be082ed8cd8d5596be7eb793136d2a9701.json new file mode 100644 index 000000000..7a574a9d9 --- /dev/null +++ b/crates/remote/.sqlx/query-28ece26c5f451c45799920a02f8b44be082ed8cd8d5596be7eb793136d2a9701.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM sprints WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "28ece26c5f451c45799920a02f8b44be082ed8cd8d5596be7eb793136d2a9701" +} diff --git a/crates/remote/.sqlx/query-2d677962b714958424f19127af6e27fb70effa3b20ab0ae3f7d9670daeff9088.json b/crates/remote/.sqlx/query-2d677962b714958424f19127af6e27fb70effa3b20ab0ae3f7d9670daeff9088.json new file mode 100644 index 000000000..99d3962db --- /dev/null +++ b/crates/remote/.sqlx/query-2d677962b714958424f19127af6e27fb70effa3b20ab0ae3f7d9670daeff9088.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM project_members WHERE project_id = $1 AND user_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "2d677962b714958424f19127af6e27fb70effa3b20ab0ae3f7d9670daeff9088" +} diff --git a/crates/remote/.sqlx/query-33757fe766dad45e63e351dfd1a9d9e9bb10e3fa553f616004b899b19cc8b260.json b/crates/remote/.sqlx/query-33757fe766dad45e63e351dfd1a9d9e9bb10e3fa553f616004b899b19cc8b260.json new file mode 100644 index 000000000..508a80976 --- /dev/null +++ b/crates/remote/.sqlx/query-33757fe766dad45e63e351dfd1a9d9e9bb10e3fa553f616004b899b19cc8b260.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO project_members (project_id, user_id, joined_at)\n VALUES ($1, $2, $3)\n RETURNING\n project_id AS \"project_id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n joined_at AS \"joined_at!: DateTime\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "project_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "joined_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "33757fe766dad45e63e351dfd1a9d9e9bb10e3fa553f616004b899b19cc8b260" +} diff --git a/crates/remote/.sqlx/query-33c79efd067130ad75aef93a70c6ba39b4cd6d5b20e427135785b1a2d5dcfa78.json b/crates/remote/.sqlx/query-33c79efd067130ad75aef93a70c6ba39b4cd6d5b20e427135785b1a2d5dcfa78.json new file mode 100644 index 000000000..cdedc1258 --- /dev/null +++ b/crates/remote/.sqlx/query-33c79efd067130ad75aef93a70c6ba39b4cd6d5b20e427135785b1a2d5dcfa78.json @@ -0,0 +1,75 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n label AS \"label!\",\n sequence_number AS \"sequence_number!\",\n start_date AS \"start_date!\",\n end_date AS \"end_date!\",\n status AS \"status!: SprintStatus\",\n created_at AS \"created_at!: DateTime\"\n FROM sprints\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "project_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "label!", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "sequence_number!", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "start_date!", + "type_info": "Date" + }, + { + "ordinal": 5, + "name": "end_date!", + "type_info": "Date" + }, + { + "ordinal": 6, + "name": "status!: SprintStatus", + "type_info": { + "Custom": { + "name": "sprint_status", + "kind": { + "Enum": [ + "planned", + "active", + "completed" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "33c79efd067130ad75aef93a70c6ba39b4cd6d5b20e427135785b1a2d5dcfa78" +} diff --git a/crates/remote/.sqlx/query-458b1b55024453163384ffeeb593a2554af85eddc598d95a86cc36de40f38092.json b/crates/remote/.sqlx/query-458b1b55024453163384ffeeb593a2554af85eddc598d95a86cc36de40f38092.json new file mode 100644 index 000000000..8407f45af --- /dev/null +++ b/crates/remote/.sqlx/query-458b1b55024453163384ffeeb593a2554af85eddc598d95a86cc36de40f38092.json @@ -0,0 +1,91 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE sprints\n SET\n label = $1,\n sequence_number = $2,\n start_date = $3,\n end_date = $4,\n status = $5\n WHERE id = $6\n RETURNING\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n label AS \"label!\",\n sequence_number AS \"sequence_number!\",\n start_date AS \"start_date!\",\n end_date AS \"end_date!\",\n status AS \"status!: SprintStatus\",\n created_at AS \"created_at!: DateTime\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "project_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "label!", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "sequence_number!", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "start_date!", + "type_info": "Date" + }, + { + "ordinal": 5, + "name": "end_date!", + "type_info": "Date" + }, + { + "ordinal": 6, + "name": "status!: SprintStatus", + "type_info": { + "Custom": { + "name": "sprint_status", + "kind": { + "Enum": [ + "planned", + "active", + "completed" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Int4", + "Date", + "Date", + { + "Custom": { + "name": "sprint_status", + "kind": { + "Enum": [ + "planned", + "active", + "completed" + ] + } + } + }, + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "458b1b55024453163384ffeeb593a2554af85eddc598d95a86cc36de40f38092" +} diff --git a/crates/remote/.sqlx/query-4c914789450e159a5c4b873f55d8943ce957f1d74c77234c82421b41ac413a25.json b/crates/remote/.sqlx/query-4c914789450e159a5c4b873f55d8943ce957f1d74c77234c82421b41ac413a25.json new file mode 100644 index 000000000..7bfea4077 --- /dev/null +++ b/crates/remote/.sqlx/query-4c914789450e159a5c4b873f55d8943ce957f1d74c77234c82421b41ac413a25.json @@ -0,0 +1,80 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n organization_id AS \"organization_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\",\n visibility AS \"visibility!: ProjectVisibility\",\n sprints_enabled AS \"sprints_enabled!\",\n sprint_duration_weeks AS \"sprint_duration_weeks?: i32\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM remote_projects\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "organization_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name!", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "color!", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "visibility!: ProjectVisibility", + "type_info": { + "Custom": { + "name": "project_visibility", + "kind": { + "Enum": [ + "whole_team", + "members_only" + ] + } + } + } + }, + { + "ordinal": 5, + "name": "sprints_enabled!", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "sprint_duration_weeks?: i32", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "updated_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "4c914789450e159a5c4b873f55d8943ce957f1d74c77234c82421b41ac413a25" +} diff --git a/crates/remote/.sqlx/query-4f12cc57c23bb1e38158107dc1dcf48f2a42555a40b3bc6e88b898c46e7e5c27.json b/crates/remote/.sqlx/query-4f12cc57c23bb1e38158107dc1dcf48f2a42555a40b3bc6e88b898c46e7e5c27.json new file mode 100644 index 000000000..aeb7b6e6d --- /dev/null +++ b/crates/remote/.sqlx/query-4f12cc57c23bb1e38158107dc1dcf48f2a42555a40b3bc6e88b898c46e7e5c27.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n comment_id AS \"comment_id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n emoji AS \"emoji!\",\n created_at AS \"created_at!: DateTime\"\n FROM task_comment_reactions\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "comment_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "user_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "emoji!", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "4f12cc57c23bb1e38158107dc1dcf48f2a42555a40b3bc6e88b898c46e7e5c27" +} diff --git a/crates/remote/.sqlx/query-55f4bb0946eade4f32b33f2a28820ff354a6640dd61eb7383c6fb694cd5cdef4.json b/crates/remote/.sqlx/query-55f4bb0946eade4f32b33f2a28820ff354a6640dd61eb7383c6fb694cd5cdef4.json new file mode 100644 index 000000000..3ef322c06 --- /dev/null +++ b/crates/remote/.sqlx/query-55f4bb0946eade4f32b33f2a28820ff354a6640dd61eb7383c6fb694cd5cdef4.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n task_id AS \"task_id!: Uuid\",\n author_id AS \"author_id!: Uuid\",\n message AS \"message!\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM task_comments\n WHERE task_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "task_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "author_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "message!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "55f4bb0946eade4f32b33f2a28820ff354a6640dd61eb7383c6fb694cd5cdef4" +} diff --git a/crates/remote/.sqlx/query-5d9313bad700501fb28505a6d22d9f698a18341fa759bf741037fc126e21effa.json b/crates/remote/.sqlx/query-5d9313bad700501fb28505a6d22d9f698a18341fa759bf741037fc126e21effa.json new file mode 100644 index 000000000..3614b0f59 --- /dev/null +++ b/crates/remote/.sqlx/query-5d9313bad700501fb28505a6d22d9f698a18341fa759bf741037fc126e21effa.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n project_id AS \"project_id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n notify_on_task_created AS \"notify_on_task_created!\",\n notify_on_task_assigned AS \"notify_on_task_assigned!\"\n FROM project_notification_preferences\n WHERE project_id = $1 AND user_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "project_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "notify_on_task_created!", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "notify_on_task_assigned!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "5d9313bad700501fb28505a6d22d9f698a18341fa759bf741037fc126e21effa" +} diff --git a/crates/remote/.sqlx/query-64575e3aa0fd9da1b2e4f16da827240d2fa653f9c9448d61895c7bb85b8f53cc.json b/crates/remote/.sqlx/query-64575e3aa0fd9da1b2e4f16da827240d2fa653f9c9448d61895c7bb85b8f53cc.json new file mode 100644 index 000000000..7b16cde15 --- /dev/null +++ b/crates/remote/.sqlx/query-64575e3aa0fd9da1b2e4f16da827240d2fa653f9c9448d61895c7bb85b8f53cc.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n task_id AS \"task_id!: Uuid\",\n author_id AS \"author_id!: Uuid\",\n message AS \"message!\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM task_comments\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "task_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "author_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "message!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "64575e3aa0fd9da1b2e4f16da827240d2fa653f9c9448d61895c7bb85b8f53cc" +} diff --git a/crates/remote/.sqlx/query-666db71abcd2989c45c3701605dcf94bc2a76ebb6c305c5966156bc7780bab36.json b/crates/remote/.sqlx/query-666db71abcd2989c45c3701605dcf94bc2a76ebb6c305c5966156bc7780bab36.json new file mode 100644 index 000000000..65721037e --- /dev/null +++ b/crates/remote/.sqlx/query-666db71abcd2989c45c3701605dcf94bc2a76ebb6c305c5966156bc7780bab36.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n comment_id AS \"comment_id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n emoji AS \"emoji!\",\n created_at AS \"created_at!: DateTime\"\n FROM task_comment_reactions\n WHERE comment_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "comment_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "user_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "emoji!", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "666db71abcd2989c45c3701605dcf94bc2a76ebb6c305c5966156bc7780bab36" +} diff --git a/crates/remote/.sqlx/query-670c1e8eb238f005bd30c189a9dd5ea8d63573f24ab2b1fd6eb4671c23fc9799.json b/crates/remote/.sqlx/query-670c1e8eb238f005bd30c189a9dd5ea8d63573f24ab2b1fd6eb4671c23fc9799.json new file mode 100644 index 000000000..9d6c9bc67 --- /dev/null +++ b/crates/remote/.sqlx/query-670c1e8eb238f005bd30c189a9dd5ea8d63573f24ab2b1fd6eb4671c23fc9799.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n project_id AS \"project_id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n joined_at AS \"joined_at!: DateTime\"\n FROM project_members\n WHERE project_id = $1 AND user_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "project_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "joined_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "670c1e8eb238f005bd30c189a9dd5ea8d63573f24ab2b1fd6eb4671c23fc9799" +} diff --git a/crates/remote/.sqlx/query-79cc45ae58ea9d7bf0129db0ea767871d49cb6d10d9984b936ab8210db6b4af7.json b/crates/remote/.sqlx/query-79cc45ae58ea9d7bf0129db0ea767871d49cb6d10d9984b936ab8210db6b4af7.json new file mode 100644 index 000000000..9430565c1 --- /dev/null +++ b/crates/remote/.sqlx/query-79cc45ae58ea9d7bf0129db0ea767871d49cb6d10d9984b936ab8210db6b4af7.json @@ -0,0 +1,55 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE project_statuses\n SET\n name = $1,\n color = $2,\n sort_order = $3\n WHERE id = $4\n RETURNING\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\",\n sort_order AS \"sort_order!\",\n created_at AS \"created_at!: DateTime\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "project_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name!", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "color!", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "sort_order!", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Int4", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "79cc45ae58ea9d7bf0129db0ea767871d49cb6d10d9984b936ab8210db6b4af7" +} diff --git a/crates/remote/.sqlx/query-829af3b588a3541292291e7c6bbae37519275805ba341919a4f0ea307c14ffbe.json b/crates/remote/.sqlx/query-829af3b588a3541292291e7c6bbae37519275805ba341919a4f0ea307c14ffbe.json new file mode 100644 index 000000000..eb1293b4d --- /dev/null +++ b/crates/remote/.sqlx/query-829af3b588a3541292291e7c6bbae37519275805ba341919a4f0ea307c14ffbe.json @@ -0,0 +1,98 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO remote_projects (\n id, organization_id, name, color, visibility,\n sprints_enabled, sprint_duration_weeks, created_at, updated_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n RETURNING\n id AS \"id!: Uuid\",\n organization_id AS \"organization_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\",\n visibility AS \"visibility!: ProjectVisibility\",\n sprints_enabled AS \"sprints_enabled!\",\n sprint_duration_weeks AS \"sprint_duration_weeks?: i32\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "organization_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name!", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "color!", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "visibility!: ProjectVisibility", + "type_info": { + "Custom": { + "name": "project_visibility", + "kind": { + "Enum": [ + "whole_team", + "members_only" + ] + } + } + } + }, + { + "ordinal": 5, + "name": "sprints_enabled!", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "sprint_duration_weeks?: i32", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "updated_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Varchar", + "Varchar", + { + "Custom": { + "name": "project_visibility", + "kind": { + "Enum": [ + "whole_team", + "members_only" + ] + } + } + }, + "Bool", + "Int4", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "829af3b588a3541292291e7c6bbae37519275805ba341919a4f0ea307c14ffbe" +} diff --git a/crates/remote/.sqlx/query-84cf9e92f192bbe275fda70881c4ae4d5c9826141a669ed7a04338d7ae870a71.json b/crates/remote/.sqlx/query-84cf9e92f192bbe275fda70881c4ae4d5c9826141a669ed7a04338d7ae870a71.json new file mode 100644 index 000000000..d4db3b777 --- /dev/null +++ b/crates/remote/.sqlx/query-84cf9e92f192bbe275fda70881c4ae4d5c9826141a669ed7a04338d7ae870a71.json @@ -0,0 +1,57 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO task_comments (id, task_id, author_id, message, created_at, updated_at)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING\n id AS \"id!: Uuid\",\n task_id AS \"task_id!: Uuid\",\n author_id AS \"author_id!: Uuid\",\n message AS \"message!\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "task_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "author_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "message!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Text", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "84cf9e92f192bbe275fda70881c4ae4d5c9826141a669ed7a04338d7ae870a71" +} diff --git a/crates/remote/.sqlx/query-877d201df7908d91a3c6bbef9dbd3cf4966a8ea833ac0e8d7449dcbce065cb5c.json b/crates/remote/.sqlx/query-877d201df7908d91a3c6bbef9dbd3cf4966a8ea833ac0e8d7449dcbce065cb5c.json new file mode 100644 index 000000000..38190660f --- /dev/null +++ b/crates/remote/.sqlx/query-877d201df7908d91a3c6bbef9dbd3cf4966a8ea833ac0e8d7449dcbce065cb5c.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO tags (id, project_id, name, color)\n VALUES ($1, $2, $3, $4)\n RETURNING\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "project_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name!", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "color!", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Varchar", + "Varchar" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "877d201df7908d91a3c6bbef9dbd3cf4966a8ea833ac0e8d7449dcbce065cb5c" +} diff --git a/crates/remote/.sqlx/query-89952b08b9a4a80b4b3e31be35c2d0ba50b64386087e1da2e0c3cd2363c71d97.json b/crates/remote/.sqlx/query-89952b08b9a4a80b4b3e31be35c2d0ba50b64386087e1da2e0c3cd2363c71d97.json new file mode 100644 index 000000000..8d514f744 --- /dev/null +++ b/crates/remote/.sqlx/query-89952b08b9a4a80b4b3e31be35c2d0ba50b64386087e1da2e0c3cd2363c71d97.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\",\n sort_order AS \"sort_order!\",\n created_at AS \"created_at!: DateTime\"\n FROM project_statuses\n WHERE project_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "project_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name!", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "color!", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "sort_order!", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "89952b08b9a4a80b4b3e31be35c2d0ba50b64386087e1da2e0c3cd2363c71d97" +} diff --git a/crates/remote/.sqlx/query-8d1bf70edc5118ee6cd4de1752b7e595b0453795859887e51f320c6af442b1d1.json b/crates/remote/.sqlx/query-8d1bf70edc5118ee6cd4de1752b7e595b0453795859887e51f320c6af442b1d1.json new file mode 100644 index 000000000..0f38d1ecd --- /dev/null +++ b/crates/remote/.sqlx/query-8d1bf70edc5118ee6cd4de1752b7e595b0453795859887e51f320c6af442b1d1.json @@ -0,0 +1,57 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO project_statuses (id, project_id, name, color, sort_order, created_at)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\",\n sort_order AS \"sort_order!\",\n created_at AS \"created_at!: DateTime\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "project_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name!", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "color!", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "sort_order!", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Varchar", + "Varchar", + "Int4", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "8d1bf70edc5118ee6cd4de1752b7e595b0453795859887e51f320c6af442b1d1" +} diff --git a/crates/remote/.sqlx/query-8e0386189bbcb3c592426135b70ae0184eb5febe08b629d521f2a8ed2ead0475.json b/crates/remote/.sqlx/query-8e0386189bbcb3c592426135b70ae0184eb5febe08b629d521f2a8ed2ead0475.json new file mode 100644 index 000000000..9bd822f15 --- /dev/null +++ b/crates/remote/.sqlx/query-8e0386189bbcb3c592426135b70ae0184eb5febe08b629d521f2a8ed2ead0475.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE tags\n SET\n name = $1,\n color = $2\n WHERE id = $3\n RETURNING\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "project_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name!", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "color!", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "8e0386189bbcb3c592426135b70ae0184eb5febe08b629d521f2a8ed2ead0475" +} diff --git a/crates/remote/.sqlx/query-9374a10407bbdf7f4d07cdc1eb5665c67c94e7e801e82cae64f5bd9a6e1e478e.json b/crates/remote/.sqlx/query-9374a10407bbdf7f4d07cdc1eb5665c67c94e7e801e82cae64f5bd9a6e1e478e.json new file mode 100644 index 000000000..21b119784 --- /dev/null +++ b/crates/remote/.sqlx/query-9374a10407bbdf7f4d07cdc1eb5665c67c94e7e801e82cae64f5bd9a6e1e478e.json @@ -0,0 +1,96 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE remote_projects\n SET\n name = $1,\n color = $2,\n visibility = $3,\n sprints_enabled = $4,\n sprint_duration_weeks = $5,\n updated_at = $6\n WHERE id = $7\n RETURNING\n id AS \"id!: Uuid\",\n organization_id AS \"organization_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\",\n visibility AS \"visibility!: ProjectVisibility\",\n sprints_enabled AS \"sprints_enabled!\",\n sprint_duration_weeks AS \"sprint_duration_weeks?: i32\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "organization_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name!", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "color!", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "visibility!: ProjectVisibility", + "type_info": { + "Custom": { + "name": "project_visibility", + "kind": { + "Enum": [ + "whole_team", + "members_only" + ] + } + } + } + }, + { + "ordinal": 5, + "name": "sprints_enabled!", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "sprint_duration_weeks?: i32", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "updated_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + { + "Custom": { + "name": "project_visibility", + "kind": { + "Enum": [ + "whole_team", + "members_only" + ] + } + } + }, + "Bool", + "Int4", + "Timestamptz", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "9374a10407bbdf7f4d07cdc1eb5665c67c94e7e801e82cae64f5bd9a6e1e478e" +} diff --git a/crates/remote/.sqlx/query-9de5b3576e3f018b88e611e3f9856266a1d6c35296c6fadce1916478237e0ad0.json b/crates/remote/.sqlx/query-9de5b3576e3f018b88e611e3f9856266a1d6c35296c6fadce1916478237e0ad0.json new file mode 100644 index 000000000..65c663cea --- /dev/null +++ b/crates/remote/.sqlx/query-9de5b3576e3f018b88e611e3f9856266a1d6c35296c6fadce1916478237e0ad0.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n task_id AS \"task_id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n lead AS \"lead!\"\n FROM task_assignees\n WHERE task_id = $1 AND user_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "task_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "lead!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "9de5b3576e3f018b88e611e3f9856266a1d6c35296c6fadce1916478237e0ad0" +} diff --git a/crates/remote/.sqlx/query-9ebdeece60e544032f3da1507a6476e00d7d4675ade9081811f42aa1dc892569.json b/crates/remote/.sqlx/query-9ebdeece60e544032f3da1507a6476e00d7d4675ade9081811f42aa1dc892569.json new file mode 100644 index 000000000..0fd3220cc --- /dev/null +++ b/crates/remote/.sqlx/query-9ebdeece60e544032f3da1507a6476e00d7d4675ade9081811f42aa1dc892569.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM project_statuses WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "9ebdeece60e544032f3da1507a6476e00d7d4675ade9081811f42aa1dc892569" +} diff --git a/crates/remote/.sqlx/query-a7e65932f06ee066e304db0fa693ebda5852505102aef3f2a6e9220e9010773b.json b/crates/remote/.sqlx/query-a7e65932f06ee066e304db0fa693ebda5852505102aef3f2a6e9220e9010773b.json new file mode 100644 index 000000000..baf307e9b --- /dev/null +++ b/crates/remote/.sqlx/query-a7e65932f06ee066e304db0fa693ebda5852505102aef3f2a6e9220e9010773b.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\"\n FROM tags\n WHERE project_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "project_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name!", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "color!", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "a7e65932f06ee066e304db0fa693ebda5852505102aef3f2a6e9220e9010773b" +} diff --git a/crates/remote/.sqlx/query-b58b79fdcadcfa0995db368d8ee2d781bcb039e230d54d4b73817c5ebdc6947a.json b/crates/remote/.sqlx/query-b58b79fdcadcfa0995db368d8ee2d781bcb039e230d54d4b73817c5ebdc6947a.json new file mode 100644 index 000000000..567c80f5b --- /dev/null +++ b/crates/remote/.sqlx/query-b58b79fdcadcfa0995db368d8ee2d781bcb039e230d54d4b73817c5ebdc6947a.json @@ -0,0 +1,75 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n label AS \"label!\",\n sequence_number AS \"sequence_number!\",\n start_date AS \"start_date!\",\n end_date AS \"end_date!\",\n status AS \"status!: SprintStatus\",\n created_at AS \"created_at!: DateTime\"\n FROM sprints\n WHERE project_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "project_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "label!", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "sequence_number!", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "start_date!", + "type_info": "Date" + }, + { + "ordinal": 5, + "name": "end_date!", + "type_info": "Date" + }, + { + "ordinal": 6, + "name": "status!: SprintStatus", + "type_info": { + "Custom": { + "name": "sprint_status", + "kind": { + "Enum": [ + "planned", + "active", + "completed" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "b58b79fdcadcfa0995db368d8ee2d781bcb039e230d54d4b73817c5ebdc6947a" +} diff --git a/crates/remote/.sqlx/query-b930c189b73c5bc1465072dcab348aeed93dab03934dba1cec2dab2b6444719b.json b/crates/remote/.sqlx/query-b930c189b73c5bc1465072dcab348aeed93dab03934dba1cec2dab2b6444719b.json new file mode 100644 index 000000000..f18333f86 --- /dev/null +++ b/crates/remote/.sqlx/query-b930c189b73c5bc1465072dcab348aeed93dab03934dba1cec2dab2b6444719b.json @@ -0,0 +1,54 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE task_comments\n SET\n message = $1,\n updated_at = $2\n WHERE id = $3\n RETURNING\n id AS \"id!: Uuid\",\n task_id AS \"task_id!: Uuid\",\n author_id AS \"author_id!: Uuid\",\n message AS \"message!\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "task_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "author_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "message!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Timestamptz", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "b930c189b73c5bc1465072dcab348aeed93dab03934dba1cec2dab2b6444719b" +} diff --git a/crates/remote/.sqlx/query-bb6bbb7dab683bdeb4f780c7ece238f3d7d5e5428f04c83ca1ceafc2a260016b.json b/crates/remote/.sqlx/query-bb6bbb7dab683bdeb4f780c7ece238f3d7d5e5428f04c83ca1ceafc2a260016b.json new file mode 100644 index 000000000..02c718cc6 --- /dev/null +++ b/crates/remote/.sqlx/query-bb6bbb7dab683bdeb4f780c7ece238f3d7d5e5428f04c83ca1ceafc2a260016b.json @@ -0,0 +1,117 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n status_id AS \"status_id!: Uuid\",\n sprint_id AS \"sprint_id?: Uuid\",\n title AS \"title!\",\n description AS \"description?\",\n priority AS \"priority!: TaskPriority\",\n start_date AS \"start_date?: DateTime\",\n target_date AS \"target_date?: DateTime\",\n completed_at AS \"completed_at?: DateTime\",\n sort_order AS \"sort_order!\",\n parent_task_id AS \"parent_task_id?: Uuid\",\n extension_metadata AS \"extension_metadata!: Value\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM tasks\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "project_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "status_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "sprint_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "title!", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "description?", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "priority!: TaskPriority", + "type_info": { + "Custom": { + "name": "task_priority", + "kind": { + "Enum": [ + "high", + "medium", + "low" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "start_date?: DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "target_date?: DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "completed_at?: DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "sort_order!", + "type_info": "Float8" + }, + { + "ordinal": 11, + "name": "parent_task_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 12, + "name": "extension_metadata!: Value", + "type_info": "Jsonb" + }, + { + "ordinal": 13, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 14, + "name": "updated_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + true, + false, + true, + true, + true, + false, + true, + false, + false, + false + ] + }, + "hash": "bb6bbb7dab683bdeb4f780c7ece238f3d7d5e5428f04c83ca1ceafc2a260016b" +} diff --git a/crates/remote/.sqlx/query-c1e3817984a9603fe411d0f2f46d15d98bd963a1d7b2b2457e78d8b1a61fbde9.json b/crates/remote/.sqlx/query-c1e3817984a9603fe411d0f2f46d15d98bd963a1d7b2b2457e78d8b1a61fbde9.json new file mode 100644 index 000000000..72bb04de0 --- /dev/null +++ b/crates/remote/.sqlx/query-c1e3817984a9603fe411d0f2f46d15d98bd963a1d7b2b2457e78d8b1a61fbde9.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM task_comments WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "c1e3817984a9603fe411d0f2f46d15d98bd963a1d7b2b2457e78d8b1a61fbde9" +} diff --git a/crates/remote/.sqlx/query-c37b4d08abc834d30c43f4441592aea95c33043dd4c5ecec7b9ec5edc08f1507.json b/crates/remote/.sqlx/query-c37b4d08abc834d30c43f4441592aea95c33043dd4c5ecec7b9ec5edc08f1507.json new file mode 100644 index 000000000..a67e38682 --- /dev/null +++ b/crates/remote/.sqlx/query-c37b4d08abc834d30c43f4441592aea95c33043dd4c5ecec7b9ec5edc08f1507.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n blocking_task_id AS \"blocking_task_id!: Uuid\",\n blocked_task_id AS \"blocked_task_id!: Uuid\",\n created_at AS \"created_at!: DateTime\"\n FROM task_dependencies\n WHERE blocking_task_id = $1 AND blocked_task_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "blocking_task_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "blocked_task_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "c37b4d08abc834d30c43f4441592aea95c33043dd4c5ecec7b9ec5edc08f1507" +} diff --git a/crates/remote/.sqlx/query-caab928adb0002b8cc873f1b5205442b717bc9830812104f25cc9036162ee4ff.json b/crates/remote/.sqlx/query-caab928adb0002b8cc873f1b5205442b717bc9830812104f25cc9036162ee4ff.json new file mode 100644 index 000000000..98e1ea4f0 --- /dev/null +++ b/crates/remote/.sqlx/query-caab928adb0002b8cc873f1b5205442b717bc9830812104f25cc9036162ee4ff.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n project_id AS \"project_id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n joined_at AS \"joined_at!: DateTime\"\n FROM project_members\n WHERE project_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "project_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "joined_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "caab928adb0002b8cc873f1b5205442b717bc9830812104f25cc9036162ee4ff" +} diff --git a/crates/remote/.sqlx/query-d41c4a79ca87974fb61c59318fc10ae743d39508b8b13816454d1d8dd06b2c8a.json b/crates/remote/.sqlx/query-d41c4a79ca87974fb61c59318fc10ae743d39508b8b13816454d1d8dd06b2c8a.json new file mode 100644 index 000000000..efc1f626e --- /dev/null +++ b/crates/remote/.sqlx/query-d41c4a79ca87974fb61c59318fc10ae743d39508b8b13816454d1d8dd06b2c8a.json @@ -0,0 +1,93 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO sprints (\n id, project_id, label, sequence_number, start_date, end_date, status, created_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n label AS \"label!\",\n sequence_number AS \"sequence_number!\",\n start_date AS \"start_date!\",\n end_date AS \"end_date!\",\n status AS \"status!: SprintStatus\",\n created_at AS \"created_at!: DateTime\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "project_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "label!", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "sequence_number!", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "start_date!", + "type_info": "Date" + }, + { + "ordinal": 5, + "name": "end_date!", + "type_info": "Date" + }, + { + "ordinal": 6, + "name": "status!: SprintStatus", + "type_info": { + "Custom": { + "name": "sprint_status", + "kind": { + "Enum": [ + "planned", + "active", + "completed" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Varchar", + "Int4", + "Date", + "Date", + { + "Custom": { + "name": "sprint_status", + "kind": { + "Enum": [ + "planned", + "active", + "completed" + ] + } + } + }, + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "d41c4a79ca87974fb61c59318fc10ae743d39508b8b13816454d1d8dd06b2c8a" +} diff --git a/crates/remote/.sqlx/query-d50f711e88b81327a04ec275c7369c8e971375d4cffa323dde2a89180f2df273.json b/crates/remote/.sqlx/query-d50f711e88b81327a04ec275c7369c8e971375d4cffa323dde2a89180f2df273.json new file mode 100644 index 000000000..ad1fe637f --- /dev/null +++ b/crates/remote/.sqlx/query-d50f711e88b81327a04ec275c7369c8e971375d4cffa323dde2a89180f2df273.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n project_id AS \"project_id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n notify_on_status_updated AS \"notify_on_status_updated!\",\n notify_on_completed AS \"notify_on_completed!\"\n FROM project_task_notification_preferences\n WHERE project_id = $1 AND user_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "project_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "notify_on_status_updated!", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "notify_on_completed!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "d50f711e88b81327a04ec275c7369c8e971375d4cffa323dde2a89180f2df273" +} diff --git a/crates/remote/.sqlx/query-d80fb704a4ef5648d416388a0f58f8a0bc8d520ff4a99ca77d27086a20b95ca7.json b/crates/remote/.sqlx/query-d80fb704a4ef5648d416388a0f58f8a0bc8d520ff4a99ca77d27086a20b95ca7.json new file mode 100644 index 000000000..2227adf53 --- /dev/null +++ b/crates/remote/.sqlx/query-d80fb704a4ef5648d416388a0f58f8a0bc8d520ff4a99ca77d27086a20b95ca7.json @@ -0,0 +1,80 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n organization_id AS \"organization_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\",\n visibility AS \"visibility!: ProjectVisibility\",\n sprints_enabled AS \"sprints_enabled!\",\n sprint_duration_weeks AS \"sprint_duration_weeks?: i32\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM remote_projects\n WHERE organization_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "organization_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name!", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "color!", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "visibility!: ProjectVisibility", + "type_info": { + "Custom": { + "name": "project_visibility", + "kind": { + "Enum": [ + "whole_team", + "members_only" + ] + } + } + } + }, + { + "ordinal": 5, + "name": "sprints_enabled!", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "sprint_duration_weeks?: i32", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "updated_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "d80fb704a4ef5648d416388a0f58f8a0bc8d520ff4a99ca77d27086a20b95ca7" +} diff --git a/crates/remote/.sqlx/query-d8b3ec775a9528aec7291f268c1548b55d706be157a61f782ee47c73c8841a62.json b/crates/remote/.sqlx/query-d8b3ec775a9528aec7291f268c1548b55d706be157a61f782ee47c73c8841a62.json new file mode 100644 index 000000000..7f3d6b6ff --- /dev/null +++ b/crates/remote/.sqlx/query-d8b3ec775a9528aec7291f268c1548b55d706be157a61f782ee47c73c8841a62.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM task_comment_reactions WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "d8b3ec775a9528aec7291f268c1548b55d706be157a61f782ee47c73c8841a62" +} diff --git a/crates/remote/.sqlx/query-dd0d0e3fd03f130aab947d13580796eee9a786e2ca01d339fd0e8356f8ad3824.json b/crates/remote/.sqlx/query-dd0d0e3fd03f130aab947d13580796eee9a786e2ca01d339fd0e8356f8ad3824.json new file mode 100644 index 000000000..4539a5e6d --- /dev/null +++ b/crates/remote/.sqlx/query-dd0d0e3fd03f130aab947d13580796eee9a786e2ca01d339fd0e8356f8ad3824.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM tags WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "dd0d0e3fd03f130aab947d13580796eee9a786e2ca01d339fd0e8356f8ad3824" +} diff --git a/crates/remote/.sqlx/query-eae47de30b9ce4c1221e6261b99a1c0e805f310985d27345bb7818c68ae002b3.json b/crates/remote/.sqlx/query-eae47de30b9ce4c1221e6261b99a1c0e805f310985d27345bb7818c68ae002b3.json new file mode 100644 index 000000000..b773f4126 --- /dev/null +++ b/crates/remote/.sqlx/query-eae47de30b9ce4c1221e6261b99a1c0e805f310985d27345bb7818c68ae002b3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM remote_projects WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "eae47de30b9ce4c1221e6261b99a1c0e805f310985d27345bb7818c68ae002b3" +} diff --git a/crates/remote/.sqlx/query-ed9caf19c563df9f71503972a3a7db3adfed0aa0aa9b709f0095afe486796924.json b/crates/remote/.sqlx/query-ed9caf19c563df9f71503972a3a7db3adfed0aa0aa9b709f0095afe486796924.json new file mode 100644 index 000000000..282dd8897 --- /dev/null +++ b/crates/remote/.sqlx/query-ed9caf19c563df9f71503972a3a7db3adfed0aa0aa9b709f0095afe486796924.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n task_id AS \"task_id!: Uuid\",\n user_id AS \"user_id!: Uuid\"\n FROM task_followers\n WHERE task_id = $1 AND user_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "task_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id!: Uuid", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "ed9caf19c563df9f71503972a3a7db3adfed0aa0aa9b709f0095afe486796924" +} diff --git a/crates/remote/.sqlx/query-fbd32cb35d27a0a60a48b61c9e7db73c3f3b21e62c597850df1ebfca0d22c159.json b/crates/remote/.sqlx/query-fbd32cb35d27a0a60a48b61c9e7db73c3f3b21e62c597850df1ebfca0d22c159.json new file mode 100644 index 000000000..770168b91 --- /dev/null +++ b/crates/remote/.sqlx/query-fbd32cb35d27a0a60a48b61c9e7db73c3f3b21e62c597850df1ebfca0d22c159.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n project_id AS \"project_id!: Uuid\",\n name AS \"name!\",\n color AS \"color!\"\n FROM tags\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "project_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name!", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "color!", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "fbd32cb35d27a0a60a48b61c9e7db73c3f3b21e62c597850df1ebfca0d22c159" +} diff --git a/crates/remote/migrations/20251118175644_remote-projects.sql b/crates/remote/migrations/20251118175644_remote-projects.sql new file mode 100644 index 000000000..276c62b92 --- /dev/null +++ b/crates/remote/migrations/20251118175644_remote-projects.sql @@ -0,0 +1,192 @@ +-- 1. ENUMS +-- We define enums for fields with a fixed set of options +CREATE TYPE project_visibility AS ENUM ('whole_team', 'members_only'); +CREATE TYPE task_priority AS ENUM ('high', 'medium', 'low'); +CREATE TYPE sprint_status AS ENUM ('planned', 'active', 'completed'); + +-- 3. PROJECTS +CREATE TABLE remote_projects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + color VARCHAR(7) NOT NULL DEFAULT '#000000', -- Hex code + visibility project_visibility NOT NULL DEFAULT 'whole_team', + + -- Sprint Settings + sprints_enabled BOOLEAN NOT NULL DEFAULT FALSE, + sprint_duration_weeks INTEGER DEFAULT 2, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 4. PROJECT MEMBERS +CREATE TABLE project_members ( + project_id UUID NOT NULL REFERENCES remote_projects(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + PRIMARY KEY (project_id, user_id) +); + +-- 5. PROJECT STATUSES +-- Configurable statuses per project (Backlog, Todo, etc.) +CREATE TABLE project_statuses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES remote_projects(id) ON DELETE CASCADE, + name VARCHAR(50) NOT NULL, + color VARCHAR(7) NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Prevents duplicate sort orders within the same project + CONSTRAINT project_statuses_project_sort_order_uniq + UNIQUE (project_id, sort_order) +); + + +-- 6. PROJECT NOTIFICATION PREFERENCES +CREATE TABLE project_notification_preferences ( + project_id UUID NOT NULL REFERENCES remote_projects(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + notify_on_task_created BOOLEAN NOT NULL DEFAULT TRUE, + notify_on_task_assigned BOOLEAN NOT NULL DEFAULT TRUE, + + PRIMARY KEY (project_id, user_id) +); + +-- 6. PROJECT NOTIFICATION PREFERENCES +CREATE TABLE project_task_notification_preferences ( + project_id UUID NOT NULL REFERENCES remote_projects(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + notify_on_status_updated BOOLEAN NOT NULL DEFAULT TRUE, + notify_on_completed BOOLEAN NOT NULL DEFAULT TRUE, + + PRIMARY KEY (project_id, user_id) +); + +-- 7. SPRINTS +CREATE TABLE sprints ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES remote_projects(id) ON DELETE CASCADE, + + label VARCHAR(100) NOT NULL, -- e.g. "Sprint 1" + sequence_number INTEGER NOT NULL, -- e.g. 1 + start_date DATE NOT NULL, + end_date DATE NOT NULL, + status sprint_status NOT NULL DEFAULT 'planned', + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 8. TASKS +CREATE TABLE tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES remote_projects(id) ON DELETE CASCADE, + + -- Status inherits from project_statuses + status_id UUID NOT NULL REFERENCES project_statuses(id), + + -- Sprint is nullable (Backlog tasks have no sprint) + sprint_id UUID REFERENCES sprints(id) ON DELETE SET NULL, + + title VARCHAR(255) NOT NULL, + description TEXT, + priority task_priority NOT NULL DEFAULT 'medium', + + start_date TIMESTAMPTZ, + target_date TIMESTAMPTZ, + + -- Completion status + completed_at TIMESTAMPTZ, -- NULL means not completed + + -- Ordering in lists/kanban + sort_order DOUBLE PRECISION NOT NULL DEFAULT 0, + + -- Parent Task (Self-referential) + parent_task_id UUID REFERENCES tasks(id) ON DELETE SET NULL, + + -- Extension Metadata (JSONB for flexibility) + extension_metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 9. TASK ASSIGNEES (Team members) +CREATE TABLE task_assignees ( + task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + lead BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (task_id, user_id) +); + +-- 10. TASK FOLLOWERS +CREATE TABLE task_followers ( + task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (task_id, user_id) +); + +-- 11. TASK DEPENDENCIES (Blocked By) +-- NOTE: Application logic must validate against circular dependencies before inserting. +CREATE TABLE task_dependencies ( + blocking_task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + blocked_task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + PRIMARY KEY (blocking_task_id, blocked_task_id), + -- Prevent a task from blocking itself + CONSTRAINT no_self_block CHECK (blocking_task_id != blocked_task_id) +); + +-- 12. TAGS +CREATE TABLE tags ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES remote_projects(id) ON DELETE CASCADE, + name VARCHAR(50) NOT NULL, + color VARCHAR(7) NOT NULL, + + UNIQUE (project_id, name) +); + +CREATE TABLE task_tags ( + task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (task_id, tag_id) +); + +-- 13. COMMENTS +CREATE TABLE task_comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + message TEXT NOT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 14. COMMENT REACTIONS +CREATE TABLE task_comment_reactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + comment_id UUID NOT NULL REFERENCES task_comments(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + emoji VARCHAR(32) NOT NULL, -- Store the emoji character or shortcode + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- One reaction type per user per comment + UNIQUE (comment_id, user_id, emoji) +); + +-- Indexes for common lookups +CREATE INDEX idx_tasks_project_id ON tasks(project_id); +CREATE INDEX idx_tasks_sprint_id ON tasks(sprint_id); +CREATE INDEX idx_tasks_status_id ON tasks(status_id); +CREATE INDEX idx_tasks_parent_task_id ON tasks(parent_task_id); +CREATE INDEX idx_task_comments_task_id ON task_comments(task_id); \ No newline at end of file diff --git a/crates/remote/src/db/mod.rs b/crates/remote/src/db/mod.rs index b2bfe032c..ee52e5b7c 100644 --- a/crates/remote/src/db/mod.rs +++ b/crates/remote/src/db/mod.rs @@ -8,8 +8,23 @@ pub mod oauth; pub mod oauth_accounts; pub mod organization_members; pub mod organizations; +pub mod project_members; +pub mod project_notification_preferences; +pub mod project_statuses; +pub mod project_task_notification_preferences; +pub mod project_tasks; pub mod projects; +pub mod remote_projects; +pub mod sprints; +pub mod tags; +pub mod task_assignees; +pub mod task_comment_reactions; +pub mod task_comments; +pub mod task_dependencies; +pub mod task_followers; +pub mod task_tags; pub mod tasks; +pub mod types; pub mod users; pub use listener::ActivityListener; diff --git a/crates/remote/src/db/project_members.rs b/crates/remote/src/db/project_members.rs new file mode 100644 index 000000000..76ca4ced5 --- /dev/null +++ b/crates/remote/src/db/project_members.rs @@ -0,0 +1,179 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use thiserror::Error; +use uuid::Uuid; + +use super::Tx; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectMember { + pub project_id: Uuid, + pub user_id: Uuid, + pub joined_at: DateTime, +} + +#[derive(Debug, Error)] +pub enum ProjectMemberError { + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +pub struct ProjectMemberRepository; + +impl ProjectMemberRepository { + pub async fn get( + tx: &mut Tx<'_>, + project_id: Uuid, + user_id: Uuid, + ) -> Result, ProjectMemberError> { + let record = sqlx::query_as!( + ProjectMember, + r#" + SELECT + project_id AS "project_id!: Uuid", + user_id AS "user_id!: Uuid", + joined_at AS "joined_at!: DateTime" + FROM project_members + WHERE project_id = $1 AND user_id = $2 + "#, + project_id, + user_id + ) + .fetch_optional(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn fetch( + pool: &PgPool, + project_id: Uuid, + user_id: Uuid, + ) -> Result, ProjectMemberError> { + let record = sqlx::query_as!( + ProjectMember, + r#" + SELECT + project_id AS "project_id!: Uuid", + user_id AS "user_id!: Uuid", + joined_at AS "joined_at!: DateTime" + FROM project_members + WHERE project_id = $1 AND user_id = $2 + "#, + project_id, + user_id + ) + .fetch_optional(pool) + .await?; + + Ok(record) + } + + pub async fn add( + tx: &mut Tx<'_>, + project_id: Uuid, + user_id: Uuid, + ) -> Result { + let joined_at = Utc::now(); + let record = sqlx::query_as!( + ProjectMember, + r#" + INSERT INTO project_members (project_id, user_id, joined_at) + VALUES ($1, $2, $3) + RETURNING + project_id AS "project_id!: Uuid", + user_id AS "user_id!: Uuid", + joined_at AS "joined_at!: DateTime" + "#, + project_id, + user_id, + joined_at + ) + .fetch_one(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn add_with_pool( + pool: &PgPool, + project_id: Uuid, + user_id: Uuid, + ) -> Result { + let mut tx = pool.begin().await?; + let record = Self::add(&mut tx, project_id, user_id).await?; + tx.commit().await?; + Ok(record) + } + + pub async fn remove( + tx: &mut Tx<'_>, + project_id: Uuid, + user_id: Uuid, + ) -> Result<(), ProjectMemberError> { + sqlx::query!( + "DELETE FROM project_members WHERE project_id = $1 AND user_id = $2", + project_id, + user_id + ) + .execute(&mut **tx) + .await?; + Ok(()) + } + + pub async fn remove_with_pool( + pool: &PgPool, + project_id: Uuid, + user_id: Uuid, + ) -> Result<(), ProjectMemberError> { + let mut tx = pool.begin().await?; + Self::remove(&mut tx, project_id, user_id).await?; + tx.commit().await?; + Ok(()) + } + + pub async fn list_by_project( + tx: &mut Tx<'_>, + project_id: Uuid, + ) -> Result, ProjectMemberError> { + let records = sqlx::query_as!( + ProjectMember, + r#" + SELECT + project_id AS "project_id!: Uuid", + user_id AS "user_id!: Uuid", + joined_at AS "joined_at!: DateTime" + FROM project_members + WHERE project_id = $1 + "#, + project_id + ) + .fetch_all(&mut **tx) + .await?; + + Ok(records) + } + + pub async fn fetch_by_project( + pool: &PgPool, + project_id: Uuid, + ) -> Result, ProjectMemberError> { + let records = sqlx::query_as!( + ProjectMember, + r#" + SELECT + project_id AS "project_id!: Uuid", + user_id AS "user_id!: Uuid", + joined_at AS "joined_at!: DateTime" + FROM project_members + WHERE project_id = $1 + "#, + project_id + ) + .fetch_all(pool) + .await?; + + Ok(records) + } +} diff --git a/crates/remote/src/db/project_notification_preferences.rs b/crates/remote/src/db/project_notification_preferences.rs new file mode 100644 index 000000000..304caf810 --- /dev/null +++ b/crates/remote/src/db/project_notification_preferences.rs @@ -0,0 +1,74 @@ +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use thiserror::Error; +use uuid::Uuid; + +use super::Tx; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectNotificationPreference { + pub project_id: Uuid, + pub user_id: Uuid, + pub notify_on_task_created: bool, + pub notify_on_task_assigned: bool, +} + +#[derive(Debug, Error)] +pub enum ProjectNotificationPreferenceError { + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +pub struct ProjectNotificationPreferenceRepository; + +impl ProjectNotificationPreferenceRepository { + pub async fn get( + tx: &mut Tx<'_>, + project_id: Uuid, + user_id: Uuid, + ) -> Result, ProjectNotificationPreferenceError> { + let record = sqlx::query_as!( + ProjectNotificationPreference, + r#" + SELECT + project_id AS "project_id!: Uuid", + user_id AS "user_id!: Uuid", + notify_on_task_created AS "notify_on_task_created!", + notify_on_task_assigned AS "notify_on_task_assigned!" + FROM project_notification_preferences + WHERE project_id = $1 AND user_id = $2 + "#, + project_id, + user_id + ) + .fetch_optional(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn fetch( + pool: &PgPool, + project_id: Uuid, + user_id: Uuid, + ) -> Result, ProjectNotificationPreferenceError> { + let record = sqlx::query_as!( + ProjectNotificationPreference, + r#" + SELECT + project_id AS "project_id!: Uuid", + user_id AS "user_id!: Uuid", + notify_on_task_created AS "notify_on_task_created!", + notify_on_task_assigned AS "notify_on_task_assigned!" + FROM project_notification_preferences + WHERE project_id = $1 AND user_id = $2 + "#, + project_id, + user_id + ) + .fetch_optional(pool) + .await?; + + Ok(record) + } +} diff --git a/crates/remote/src/db/project_statuses.rs b/crates/remote/src/db/project_statuses.rs new file mode 100644 index 000000000..e38ad0e99 --- /dev/null +++ b/crates/remote/src/db/project_statuses.rs @@ -0,0 +1,237 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use thiserror::Error; +use uuid::Uuid; + +use super::Tx; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectStatus { + pub id: Uuid, + pub project_id: Uuid, + pub name: String, + pub color: String, + pub sort_order: i32, + pub created_at: DateTime, +} + +#[derive(Debug, Error)] +pub enum ProjectStatusError { + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +pub struct ProjectStatusRepository; + +impl ProjectStatusRepository { + pub async fn find_by_id( + tx: &mut Tx<'_>, + id: Uuid, + ) -> Result, ProjectStatusError> { + let record = sqlx::query_as!( + ProjectStatus, + r#" + SELECT + id AS "id!: Uuid", + project_id AS "project_id!: Uuid", + name AS "name!", + color AS "color!", + sort_order AS "sort_order!", + created_at AS "created_at!: DateTime" + FROM project_statuses + WHERE id = $1 + "#, + id + ) + .fetch_optional(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn fetch_by_id( + pool: &PgPool, + id: Uuid, + ) -> Result, ProjectStatusError> { + let record = sqlx::query_as!( + ProjectStatus, + r#" + SELECT + id AS "id!: Uuid", + project_id AS "project_id!: Uuid", + name AS "name!", + color AS "color!", + sort_order AS "sort_order!", + created_at AS "created_at!: DateTime" + FROM project_statuses + WHERE id = $1 + "#, + id + ) + .fetch_optional(pool) + .await?; + + Ok(record) + } + + pub async fn create( + tx: &mut Tx<'_>, + project_id: Uuid, + name: String, + color: String, + sort_order: i32, + ) -> Result { + let id = Uuid::new_v4(); + let created_at = Utc::now(); + let record = sqlx::query_as!( + ProjectStatus, + r#" + INSERT INTO project_statuses (id, project_id, name, color, sort_order, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING + id AS "id!: Uuid", + project_id AS "project_id!: Uuid", + name AS "name!", + color AS "color!", + sort_order AS "sort_order!", + created_at AS "created_at!: DateTime" + "#, + id, + project_id, + name, + color, + sort_order, + created_at + ) + .fetch_one(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn create_with_pool( + pool: &PgPool, + project_id: Uuid, + name: String, + color: String, + sort_order: i32, + ) -> Result { + let mut tx = pool.begin().await?; + let record = Self::create(&mut tx, project_id, name, color, sort_order).await?; + tx.commit().await?; + Ok(record) + } + + pub async fn update( + tx: &mut Tx<'_>, + id: Uuid, + name: String, + color: String, + sort_order: i32, + ) -> Result { + let record = sqlx::query_as!( + ProjectStatus, + r#" + UPDATE project_statuses + SET + name = $1, + color = $2, + sort_order = $3 + WHERE id = $4 + RETURNING + id AS "id!: Uuid", + project_id AS "project_id!: Uuid", + name AS "name!", + color AS "color!", + sort_order AS "sort_order!", + created_at AS "created_at!: DateTime" + "#, + name, + color, + sort_order, + id + ) + .fetch_one(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn update_with_pool( + pool: &PgPool, + id: Uuid, + name: String, + color: String, + sort_order: i32, + ) -> Result { + let mut tx = pool.begin().await?; + let record = Self::update(&mut tx, id, name, color, sort_order).await?; + tx.commit().await?; + Ok(record) + } + + pub async fn delete(tx: &mut Tx<'_>, id: Uuid) -> Result<(), ProjectStatusError> { + sqlx::query!("DELETE FROM project_statuses WHERE id = $1", id) + .execute(&mut **tx) + .await?; + Ok(()) + } + + pub async fn delete_with_pool(pool: &PgPool, id: Uuid) -> Result<(), ProjectStatusError> { + let mut tx = pool.begin().await?; + Self::delete(&mut tx, id).await?; + tx.commit().await?; + Ok(()) + } + + pub async fn list_by_project( + tx: &mut Tx<'_>, + project_id: Uuid, + ) -> Result, ProjectStatusError> { + let records = sqlx::query_as!( + ProjectStatus, + r#" + SELECT + id AS "id!: Uuid", + project_id AS "project_id!: Uuid", + name AS "name!", + color AS "color!", + sort_order AS "sort_order!", + created_at AS "created_at!: DateTime" + FROM project_statuses + WHERE project_id = $1 + "#, + project_id + ) + .fetch_all(&mut **tx) + .await?; + + Ok(records) + } + + pub async fn fetch_by_project( + pool: &PgPool, + project_id: Uuid, + ) -> Result, ProjectStatusError> { + let records = sqlx::query_as!( + ProjectStatus, + r#" + SELECT + id AS "id!: Uuid", + project_id AS "project_id!: Uuid", + name AS "name!", + color AS "color!", + sort_order AS "sort_order!", + created_at AS "created_at!: DateTime" + FROM project_statuses + WHERE project_id = $1 + "#, + project_id + ) + .fetch_all(pool) + .await?; + + Ok(records) + } +} diff --git a/crates/remote/src/db/project_task_notification_preferences.rs b/crates/remote/src/db/project_task_notification_preferences.rs new file mode 100644 index 000000000..7f454ff34 --- /dev/null +++ b/crates/remote/src/db/project_task_notification_preferences.rs @@ -0,0 +1,76 @@ +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use thiserror::Error; +use uuid::Uuid; + +use super::Tx; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectTaskNotificationPreference { + pub project_id: Uuid, + pub user_id: Uuid, + pub notify_on_status_updated: bool, + pub notify_on_completed: bool, +} + +#[derive(Debug, Error)] +pub enum ProjectTaskNotificationPreferenceError { + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +pub struct ProjectTaskNotificationPreferenceRepository; + +impl ProjectTaskNotificationPreferenceRepository { + pub async fn get( + tx: &mut Tx<'_>, + project_id: Uuid, + user_id: Uuid, + ) -> Result, ProjectTaskNotificationPreferenceError> + { + let record = sqlx::query_as!( + ProjectTaskNotificationPreference, + r#" + SELECT + project_id AS "project_id!: Uuid", + user_id AS "user_id!: Uuid", + notify_on_status_updated AS "notify_on_status_updated!", + notify_on_completed AS "notify_on_completed!" + FROM project_task_notification_preferences + WHERE project_id = $1 AND user_id = $2 + "#, + project_id, + user_id + ) + .fetch_optional(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn fetch( + pool: &PgPool, + project_id: Uuid, + user_id: Uuid, + ) -> Result, ProjectTaskNotificationPreferenceError> + { + let record = sqlx::query_as!( + ProjectTaskNotificationPreference, + r#" + SELECT + project_id AS "project_id!: Uuid", + user_id AS "user_id!: Uuid", + notify_on_status_updated AS "notify_on_status_updated!", + notify_on_completed AS "notify_on_completed!" + FROM project_task_notification_preferences + WHERE project_id = $1 AND user_id = $2 + "#, + project_id, + user_id + ) + .fetch_optional(pool) + .await?; + + Ok(record) + } +} diff --git a/crates/remote/src/db/project_tasks.rs b/crates/remote/src/db/project_tasks.rs new file mode 100644 index 000000000..dbcf63070 --- /dev/null +++ b/crates/remote/src/db/project_tasks.rs @@ -0,0 +1,105 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::PgPool; +use thiserror::Error; +use uuid::Uuid; + +use super::{Tx, types::TaskPriority}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectTask { + pub id: Uuid, + pub project_id: Uuid, + pub status_id: Uuid, + pub sprint_id: Option, + pub title: String, + pub description: Option, + pub priority: TaskPriority, + pub start_date: Option>, + pub target_date: Option>, + pub completed_at: Option>, + pub sort_order: f64, + pub parent_task_id: Option, + pub extension_metadata: Value, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Error)] +pub enum ProjectTaskError { + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +pub struct ProjectTaskRepository; + +impl ProjectTaskRepository { + pub async fn find_by_id( + tx: &mut Tx<'_>, + id: Uuid, + ) -> Result, ProjectTaskError> { + let record = sqlx::query_as!( + ProjectTask, + r#" + SELECT + id AS "id!: Uuid", + project_id AS "project_id!: Uuid", + status_id AS "status_id!: Uuid", + sprint_id AS "sprint_id?: Uuid", + title AS "title!", + description AS "description?", + priority AS "priority!: TaskPriority", + start_date AS "start_date?: DateTime", + target_date AS "target_date?: DateTime", + completed_at AS "completed_at?: DateTime", + sort_order AS "sort_order!", + parent_task_id AS "parent_task_id?: Uuid", + extension_metadata AS "extension_metadata!: Value", + created_at AS "created_at!: DateTime", + updated_at AS "updated_at!: DateTime" + FROM tasks + WHERE id = $1 + "#, + id + ) + .fetch_optional(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn fetch_by_id( + pool: &PgPool, + id: Uuid, + ) -> Result, ProjectTaskError> { + let record = sqlx::query_as!( + ProjectTask, + r#" + SELECT + id AS "id!: Uuid", + project_id AS "project_id!: Uuid", + status_id AS "status_id!: Uuid", + sprint_id AS "sprint_id?: Uuid", + title AS "title!", + description AS "description?", + priority AS "priority!: TaskPriority", + start_date AS "start_date?: DateTime", + target_date AS "target_date?: DateTime", + completed_at AS "completed_at?: DateTime", + sort_order AS "sort_order!", + parent_task_id AS "parent_task_id?: Uuid", + extension_metadata AS "extension_metadata!: Value", + created_at AS "created_at!: DateTime", + updated_at AS "updated_at!: DateTime" + FROM tasks + WHERE id = $1 + "#, + id + ) + .fetch_optional(pool) + .await?; + + Ok(record) + } +} diff --git a/crates/remote/src/db/remote_projects.rs b/crates/remote/src/db/remote_projects.rs new file mode 100644 index 000000000..d129771e5 --- /dev/null +++ b/crates/remote/src/db/remote_projects.rs @@ -0,0 +1,297 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use thiserror::Error; +use uuid::Uuid; + +use super::{Tx, types::ProjectVisibility}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteProject { + pub id: Uuid, + pub organization_id: Uuid, + pub name: String, + pub color: String, + pub visibility: ProjectVisibility, + pub sprints_enabled: bool, + pub sprint_duration_weeks: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Error)] +pub enum RemoteProjectError { + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +pub struct RemoteProjectRepository; + +impl RemoteProjectRepository { + pub async fn find_by_id( + tx: &mut Tx<'_>, + id: Uuid, + ) -> Result, RemoteProjectError> { + let record = sqlx::query_as!( + RemoteProject, + r#" + SELECT + id AS "id!: Uuid", + organization_id AS "organization_id!: Uuid", + name AS "name!", + color AS "color!", + visibility AS "visibility!: ProjectVisibility", + sprints_enabled AS "sprints_enabled!", + sprint_duration_weeks AS "sprint_duration_weeks?: i32", + created_at AS "created_at!: DateTime", + updated_at AS "updated_at!: DateTime" + FROM remote_projects + WHERE id = $1 + "#, + id + ) + .fetch_optional(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn fetch_by_id( + pool: &PgPool, + id: Uuid, + ) -> Result, RemoteProjectError> { + let record = sqlx::query_as!( + RemoteProject, + r#" + SELECT + id AS "id!: Uuid", + organization_id AS "organization_id!: Uuid", + name AS "name!", + color AS "color!", + visibility AS "visibility!: ProjectVisibility", + sprints_enabled AS "sprints_enabled!", + sprint_duration_weeks AS "sprint_duration_weeks?: i32", + created_at AS "created_at!: DateTime", + updated_at AS "updated_at!: DateTime" + FROM remote_projects + WHERE id = $1 + "#, + id + ) + .fetch_optional(pool) + .await?; + + Ok(record) + } + + pub async fn create( + tx: &mut Tx<'_>, + organization_id: Uuid, + name: String, + color: String, + visibility: ProjectVisibility, + sprints_enabled: bool, + sprint_duration_weeks: Option, + ) -> Result { + let id = Uuid::new_v4(); + let now = Utc::now(); + let record = sqlx::query_as!( + RemoteProject, + r#" + INSERT INTO remote_projects ( + id, organization_id, name, color, visibility, + sprints_enabled, sprint_duration_weeks, created_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING + id AS "id!: Uuid", + organization_id AS "organization_id!: Uuid", + name AS "name!", + color AS "color!", + visibility AS "visibility!: ProjectVisibility", + sprints_enabled AS "sprints_enabled!", + sprint_duration_weeks AS "sprint_duration_weeks?: i32", + created_at AS "created_at!: DateTime", + updated_at AS "updated_at!: DateTime" + "#, + id, + organization_id, + name, + color, + visibility as ProjectVisibility, + sprints_enabled, + sprint_duration_weeks, + now, + now + ) + .fetch_one(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn create_with_pool( + pool: &PgPool, + organization_id: Uuid, + name: String, + color: String, + visibility: ProjectVisibility, + sprints_enabled: bool, + sprint_duration_weeks: Option, + ) -> Result { + let mut tx = pool.begin().await?; + let record = Self::create( + &mut tx, + organization_id, + name, + color, + visibility, + sprints_enabled, + sprint_duration_weeks, + ) + .await?; + tx.commit().await?; + Ok(record) + } + + pub async fn update( + tx: &mut Tx<'_>, + id: Uuid, + name: String, + color: String, + visibility: ProjectVisibility, + sprints_enabled: bool, + sprint_duration_weeks: Option, + ) -> Result { + let updated_at = Utc::now(); + let record = sqlx::query_as!( + RemoteProject, + r#" + UPDATE remote_projects + SET + name = $1, + color = $2, + visibility = $3, + sprints_enabled = $4, + sprint_duration_weeks = $5, + updated_at = $6 + WHERE id = $7 + RETURNING + id AS "id!: Uuid", + organization_id AS "organization_id!: Uuid", + name AS "name!", + color AS "color!", + visibility AS "visibility!: ProjectVisibility", + sprints_enabled AS "sprints_enabled!", + sprint_duration_weeks AS "sprint_duration_weeks?: i32", + created_at AS "created_at!: DateTime", + updated_at AS "updated_at!: DateTime" + "#, + name, + color, + visibility as ProjectVisibility, + sprints_enabled, + sprint_duration_weeks, + updated_at, + id + ) + .fetch_one(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn update_with_pool( + pool: &PgPool, + id: Uuid, + name: String, + color: String, + visibility: ProjectVisibility, + sprints_enabled: bool, + sprint_duration_weeks: Option, + ) -> Result { + let mut tx = pool.begin().await?; + let record = Self::update( + &mut tx, + id, + name, + color, + visibility, + sprints_enabled, + sprint_duration_weeks, + ) + .await?; + tx.commit().await?; + Ok(record) + } + + pub async fn delete(tx: &mut Tx<'_>, id: Uuid) -> Result<(), RemoteProjectError> { + sqlx::query!("DELETE FROM remote_projects WHERE id = $1", id) + .execute(&mut **tx) + .await?; + Ok(()) + } + + pub async fn delete_with_pool(pool: &PgPool, id: Uuid) -> Result<(), RemoteProjectError> { + let mut tx = pool.begin().await?; + Self::delete(&mut tx, id).await?; + tx.commit().await?; + Ok(()) + } + + pub async fn list_by_organization( + tx: &mut Tx<'_>, + organization_id: Uuid, + ) -> Result, RemoteProjectError> { + let records = sqlx::query_as!( + RemoteProject, + r#" + SELECT + id AS "id!: Uuid", + organization_id AS "organization_id!: Uuid", + name AS "name!", + color AS "color!", + visibility AS "visibility!: ProjectVisibility", + sprints_enabled AS "sprints_enabled!", + sprint_duration_weeks AS "sprint_duration_weeks?: i32", + created_at AS "created_at!: DateTime", + updated_at AS "updated_at!: DateTime" + FROM remote_projects + WHERE organization_id = $1 + "#, + organization_id + ) + .fetch_all(&mut **tx) + .await?; + + Ok(records) + } + + pub async fn fetch_by_organization( + pool: &PgPool, + organization_id: Uuid, + ) -> Result, RemoteProjectError> { + let records = sqlx::query_as!( + RemoteProject, + r#" + SELECT + id AS "id!: Uuid", + organization_id AS "organization_id!: Uuid", + name AS "name!", + color AS "color!", + visibility AS "visibility!: ProjectVisibility", + sprints_enabled AS "sprints_enabled!", + sprint_duration_weeks AS "sprint_duration_weeks?: i32", + created_at AS "created_at!: DateTime", + updated_at AS "updated_at!: DateTime" + FROM remote_projects + WHERE organization_id = $1 + "#, + organization_id + ) + .fetch_all(pool) + .await?; + + Ok(records) + } +} diff --git a/crates/remote/src/db/sprints.rs b/crates/remote/src/db/sprints.rs new file mode 100644 index 000000000..0e8fb193b --- /dev/null +++ b/crates/remote/src/db/sprints.rs @@ -0,0 +1,279 @@ +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use thiserror::Error; +use uuid::Uuid; + +use super::{Tx, types::SprintStatus}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Sprint { + pub id: Uuid, + pub project_id: Uuid, + pub label: String, + pub sequence_number: i32, + pub start_date: NaiveDate, + pub end_date: NaiveDate, + pub status: SprintStatus, + pub created_at: DateTime, +} + +#[derive(Debug, Error)] +pub enum SprintError { + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +pub struct SprintRepository; + +impl SprintRepository { + pub async fn find_by_id(tx: &mut Tx<'_>, id: Uuid) -> Result, SprintError> { + let record = sqlx::query_as!( + Sprint, + r#" + SELECT + id AS "id!: Uuid", + project_id AS "project_id!: Uuid", + label AS "label!", + sequence_number AS "sequence_number!", + start_date AS "start_date!", + end_date AS "end_date!", + status AS "status!: SprintStatus", + created_at AS "created_at!: DateTime" + FROM sprints + WHERE id = $1 + "#, + id + ) + .fetch_optional(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn fetch_by_id(pool: &PgPool, id: Uuid) -> Result, SprintError> { + let record = sqlx::query_as!( + Sprint, + r#" + SELECT + id AS "id!: Uuid", + project_id AS "project_id!: Uuid", + label AS "label!", + sequence_number AS "sequence_number!", + start_date AS "start_date!", + end_date AS "end_date!", + status AS "status!: SprintStatus", + created_at AS "created_at!: DateTime" + FROM sprints + WHERE id = $1 + "#, + id + ) + .fetch_optional(pool) + .await?; + + Ok(record) + } + + pub async fn create( + tx: &mut Tx<'_>, + project_id: Uuid, + label: String, + sequence_number: i32, + start_date: NaiveDate, + end_date: NaiveDate, + status: SprintStatus, + ) -> Result { + let id = Uuid::new_v4(); + let created_at = Utc::now(); + let record = sqlx::query_as!( + Sprint, + r#" + INSERT INTO sprints ( + id, project_id, label, sequence_number, start_date, end_date, status, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING + id AS "id!: Uuid", + project_id AS "project_id!: Uuid", + label AS "label!", + sequence_number AS "sequence_number!", + start_date AS "start_date!", + end_date AS "end_date!", + status AS "status!: SprintStatus", + created_at AS "created_at!: DateTime" + "#, + id, + project_id, + label, + sequence_number, + start_date, + end_date, + status as SprintStatus, + created_at + ) + .fetch_one(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn create_with_pool( + pool: &PgPool, + project_id: Uuid, + label: String, + sequence_number: i32, + start_date: NaiveDate, + end_date: NaiveDate, + status: SprintStatus, + ) -> Result { + let mut tx = pool.begin().await?; + let record = Self::create( + &mut tx, + project_id, + label, + sequence_number, + start_date, + end_date, + status, + ) + .await?; + tx.commit().await?; + Ok(record) + } + + pub async fn update( + tx: &mut Tx<'_>, + id: Uuid, + label: String, + sequence_number: i32, + start_date: NaiveDate, + end_date: NaiveDate, + status: SprintStatus, + ) -> Result { + let record = sqlx::query_as!( + Sprint, + r#" + UPDATE sprints + SET + label = $1, + sequence_number = $2, + start_date = $3, + end_date = $4, + status = $5 + WHERE id = $6 + RETURNING + id AS "id!: Uuid", + project_id AS "project_id!: Uuid", + label AS "label!", + sequence_number AS "sequence_number!", + start_date AS "start_date!", + end_date AS "end_date!", + status AS "status!: SprintStatus", + created_at AS "created_at!: DateTime" + "#, + label, + sequence_number, + start_date, + end_date, + status as SprintStatus, + id + ) + .fetch_one(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn update_with_pool( + pool: &PgPool, + id: Uuid, + label: String, + sequence_number: i32, + start_date: NaiveDate, + end_date: NaiveDate, + status: SprintStatus, + ) -> Result { + let mut tx = pool.begin().await?; + let record = Self::update( + &mut tx, + id, + label, + sequence_number, + start_date, + end_date, + status, + ) + .await?; + tx.commit().await?; + Ok(record) + } + + pub async fn delete(tx: &mut Tx<'_>, id: Uuid) -> Result<(), SprintError> { + sqlx::query!("DELETE FROM sprints WHERE id = $1", id) + .execute(&mut **tx) + .await?; + Ok(()) + } + + pub async fn delete_with_pool(pool: &PgPool, id: Uuid) -> Result<(), SprintError> { + let mut tx = pool.begin().await?; + Self::delete(&mut tx, id).await?; + tx.commit().await?; + Ok(()) + } + + pub async fn list_by_project( + tx: &mut Tx<'_>, + project_id: Uuid, + ) -> Result, SprintError> { + let records = sqlx::query_as!( + Sprint, + r#" + SELECT + id AS "id!: Uuid", + project_id AS "project_id!: Uuid", + label AS "label!", + sequence_number AS "sequence_number!", + start_date AS "start_date!", + end_date AS "end_date!", + status AS "status!: SprintStatus", + created_at AS "created_at!: DateTime" + FROM sprints + WHERE project_id = $1 + "#, + project_id + ) + .fetch_all(&mut **tx) + .await?; + + Ok(records) + } + + pub async fn fetch_by_project( + pool: &PgPool, + project_id: Uuid, + ) -> Result, SprintError> { + let records = sqlx::query_as!( + Sprint, + r#" + SELECT + id AS "id!: Uuid", + project_id AS "project_id!: Uuid", + label AS "label!", + sequence_number AS "sequence_number!", + start_date AS "start_date!", + end_date AS "end_date!", + status AS "status!: SprintStatus", + created_at AS "created_at!: DateTime" + FROM sprints + WHERE project_id = $1 + "#, + project_id + ) + .fetch_all(pool) + .await?; + + Ok(records) + } +} diff --git a/crates/remote/src/db/tags.rs b/crates/remote/src/db/tags.rs new file mode 100644 index 000000000..464463250 --- /dev/null +++ b/crates/remote/src/db/tags.rs @@ -0,0 +1,201 @@ +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use thiserror::Error; +use uuid::Uuid; + +use super::Tx; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Tag { + pub id: Uuid, + pub project_id: Uuid, + pub name: String, + pub color: String, +} + +#[derive(Debug, Error)] +pub enum TagError { + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +pub struct TagRepository; + +impl TagRepository { + pub async fn find_by_id(tx: &mut Tx<'_>, id: Uuid) -> Result, TagError> { + let record = sqlx::query_as!( + Tag, + r#" + SELECT + id AS "id!: Uuid", + project_id AS "project_id!: Uuid", + name AS "name!", + color AS "color!" + FROM tags + WHERE id = $1 + "#, + id + ) + .fetch_optional(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn fetch_by_id(pool: &PgPool, id: Uuid) -> Result, TagError> { + let record = sqlx::query_as!( + Tag, + r#" + SELECT + id AS "id!: Uuid", + project_id AS "project_id!: Uuid", + name AS "name!", + color AS "color!" + FROM tags + WHERE id = $1 + "#, + id + ) + .fetch_optional(pool) + .await?; + + Ok(record) + } + + pub async fn create( + tx: &mut Tx<'_>, + project_id: Uuid, + name: String, + color: String, + ) -> Result { + let id = Uuid::new_v4(); + let record = sqlx::query_as!( + Tag, + r#" + INSERT INTO tags (id, project_id, name, color) + VALUES ($1, $2, $3, $4) + RETURNING + id AS "id!: Uuid", + project_id AS "project_id!: Uuid", + name AS "name!", + color AS "color!" + "#, + id, + project_id, + name, + color + ) + .fetch_one(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn create_with_pool( + pool: &PgPool, + project_id: Uuid, + name: String, + color: String, + ) -> Result { + let mut tx = pool.begin().await?; + let record = Self::create(&mut tx, project_id, name, color).await?; + tx.commit().await?; + Ok(record) + } + + pub async fn update( + tx: &mut Tx<'_>, + id: Uuid, + name: String, + color: String, + ) -> Result { + let record = sqlx::query_as!( + Tag, + r#" + UPDATE tags + SET + name = $1, + color = $2 + WHERE id = $3 + RETURNING + id AS "id!: Uuid", + project_id AS "project_id!: Uuid", + name AS "name!", + color AS "color!" + "#, + name, + color, + id + ) + .fetch_one(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn update_with_pool( + pool: &PgPool, + id: Uuid, + name: String, + color: String, + ) -> Result { + let mut tx = pool.begin().await?; + let record = Self::update(&mut tx, id, name, color).await?; + tx.commit().await?; + Ok(record) + } + + pub async fn delete(tx: &mut Tx<'_>, id: Uuid) -> Result<(), TagError> { + sqlx::query!("DELETE FROM tags WHERE id = $1", id) + .execute(&mut **tx) + .await?; + Ok(()) + } + + pub async fn delete_with_pool(pool: &PgPool, id: Uuid) -> Result<(), TagError> { + let mut tx = pool.begin().await?; + Self::delete(&mut tx, id).await?; + tx.commit().await?; + Ok(()) + } + + pub async fn list_by_project(tx: &mut Tx<'_>, project_id: Uuid) -> Result, TagError> { + let records = sqlx::query_as!( + Tag, + r#" + SELECT + id AS "id!: Uuid", + project_id AS "project_id!: Uuid", + name AS "name!", + color AS "color!" + FROM tags + WHERE project_id = $1 + "#, + project_id + ) + .fetch_all(&mut **tx) + .await?; + + Ok(records) + } + + pub async fn fetch_by_project(pool: &PgPool, project_id: Uuid) -> Result, TagError> { + let records = sqlx::query_as!( + Tag, + r#" + SELECT + id AS "id!: Uuid", + project_id AS "project_id!: Uuid", + name AS "name!", + color AS "color!" + FROM tags + WHERE project_id = $1 + "#, + project_id + ) + .fetch_all(pool) + .await?; + + Ok(records) + } +} diff --git a/crates/remote/src/db/task_assignees.rs b/crates/remote/src/db/task_assignees.rs new file mode 100644 index 000000000..1362ec7de --- /dev/null +++ b/crates/remote/src/db/task_assignees.rs @@ -0,0 +1,71 @@ +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use thiserror::Error; +use uuid::Uuid; + +use super::Tx; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskAssignee { + pub task_id: Uuid, + pub user_id: Uuid, + pub lead: bool, +} + +#[derive(Debug, Error)] +pub enum TaskAssigneeError { + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +pub struct TaskAssigneeRepository; + +impl TaskAssigneeRepository { + pub async fn get( + tx: &mut Tx<'_>, + task_id: Uuid, + user_id: Uuid, + ) -> Result, TaskAssigneeError> { + let record = sqlx::query_as!( + TaskAssignee, + r#" + SELECT + task_id AS "task_id!: Uuid", + user_id AS "user_id!: Uuid", + lead AS "lead!" + FROM task_assignees + WHERE task_id = $1 AND user_id = $2 + "#, + task_id, + user_id + ) + .fetch_optional(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn fetch( + pool: &PgPool, + task_id: Uuid, + user_id: Uuid, + ) -> Result, TaskAssigneeError> { + let record = sqlx::query_as!( + TaskAssignee, + r#" + SELECT + task_id AS "task_id!: Uuid", + user_id AS "user_id!: Uuid", + lead AS "lead!" + FROM task_assignees + WHERE task_id = $1 AND user_id = $2 + "#, + task_id, + user_id + ) + .fetch_optional(pool) + .await?; + + Ok(record) + } +} diff --git a/crates/remote/src/db/task_comment_reactions.rs b/crates/remote/src/db/task_comment_reactions.rs new file mode 100644 index 000000000..4577a0fe4 --- /dev/null +++ b/crates/remote/src/db/task_comment_reactions.rs @@ -0,0 +1,180 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use thiserror::Error; +use uuid::Uuid; + +use super::Tx; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskCommentReaction { + pub id: Uuid, + pub comment_id: Uuid, + pub user_id: Uuid, + pub emoji: String, + pub created_at: DateTime, +} + +#[derive(Debug, Error)] +pub enum TaskCommentReactionError { + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +pub struct TaskCommentReactionRepository; + +impl TaskCommentReactionRepository { + pub async fn find_by_id( + tx: &mut Tx<'_>, + id: Uuid, + ) -> Result, TaskCommentReactionError> { + let record = sqlx::query_as!( + TaskCommentReaction, + r#" + SELECT + id AS "id!: Uuid", + comment_id AS "comment_id!: Uuid", + user_id AS "user_id!: Uuid", + emoji AS "emoji!", + created_at AS "created_at!: DateTime" + FROM task_comment_reactions + WHERE id = $1 + "#, + id + ) + .fetch_optional(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn fetch_by_id( + pool: &PgPool, + id: Uuid, + ) -> Result, TaskCommentReactionError> { + let record = sqlx::query_as!( + TaskCommentReaction, + r#" + SELECT + id AS "id!: Uuid", + comment_id AS "comment_id!: Uuid", + user_id AS "user_id!: Uuid", + emoji AS "emoji!", + created_at AS "created_at!: DateTime" + FROM task_comment_reactions + WHERE id = $1 + "#, + id + ) + .fetch_optional(pool) + .await?; + + Ok(record) + } + + pub async fn create( + tx: &mut Tx<'_>, + comment_id: Uuid, + user_id: Uuid, + emoji: String, + ) -> Result { + let id = Uuid::new_v4(); + let created_at = Utc::now(); + let record = sqlx::query_as!( + TaskCommentReaction, + r#" + INSERT INTO task_comment_reactions (id, comment_id, user_id, emoji, created_at) + VALUES ($1, $2, $3, $4, $5) + RETURNING + id AS "id!: Uuid", + comment_id AS "comment_id!: Uuid", + user_id AS "user_id!: Uuid", + emoji AS "emoji!", + created_at AS "created_at!: DateTime" + "#, + id, + comment_id, + user_id, + emoji, + created_at + ) + .fetch_one(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn create_with_pool( + pool: &PgPool, + comment_id: Uuid, + user_id: Uuid, + emoji: String, + ) -> Result { + let mut tx = pool.begin().await?; + let record = Self::create(&mut tx, comment_id, user_id, emoji).await?; + tx.commit().await?; + Ok(record) + } + + pub async fn delete(tx: &mut Tx<'_>, id: Uuid) -> Result<(), TaskCommentReactionError> { + sqlx::query!("DELETE FROM task_comment_reactions WHERE id = $1", id) + .execute(&mut **tx) + .await?; + Ok(()) + } + + pub async fn delete_with_pool(pool: &PgPool, id: Uuid) -> Result<(), TaskCommentReactionError> { + let mut tx = pool.begin().await?; + Self::delete(&mut tx, id).await?; + tx.commit().await?; + Ok(()) + } + + pub async fn list_by_comment( + tx: &mut Tx<'_>, + comment_id: Uuid, + ) -> Result, TaskCommentReactionError> { + let records = sqlx::query_as!( + TaskCommentReaction, + r#" + SELECT + id AS "id!: Uuid", + comment_id AS "comment_id!: Uuid", + user_id AS "user_id!: Uuid", + emoji AS "emoji!", + created_at AS "created_at!: DateTime" + FROM task_comment_reactions + WHERE comment_id = $1 + "#, + comment_id + ) + .fetch_all(&mut **tx) + .await?; + + Ok(records) + } + + pub async fn fetch_by_comment( + pool: &PgPool, + comment_id: Uuid, + ) -> Result, TaskCommentReactionError> { + let records = sqlx::query_as!( + TaskCommentReaction, + r#" + SELECT + id AS "id!: Uuid", + comment_id AS "comment_id!: Uuid", + user_id AS "user_id!: Uuid", + emoji AS "emoji!", + created_at AS "created_at!: DateTime" + FROM task_comment_reactions + WHERE comment_id = $1 + "#, + comment_id + ) + .fetch_all(pool) + .await?; + + Ok(records) + } +} diff --git a/crates/remote/src/db/task_comments.rs b/crates/remote/src/db/task_comments.rs new file mode 100644 index 000000000..749c7ea3b --- /dev/null +++ b/crates/remote/src/db/task_comments.rs @@ -0,0 +1,230 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use thiserror::Error; +use uuid::Uuid; + +use super::Tx; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskComment { + pub id: Uuid, + pub task_id: Uuid, + pub author_id: Uuid, + pub message: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Error)] +pub enum TaskCommentError { + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +pub struct TaskCommentRepository; + +impl TaskCommentRepository { + pub async fn find_by_id( + tx: &mut Tx<'_>, + id: Uuid, + ) -> Result, TaskCommentError> { + let record = sqlx::query_as!( + TaskComment, + r#" + SELECT + id AS "id!: Uuid", + task_id AS "task_id!: Uuid", + author_id AS "author_id!: Uuid", + message AS "message!", + created_at AS "created_at!: DateTime", + updated_at AS "updated_at!: DateTime" + FROM task_comments + WHERE id = $1 + "#, + id + ) + .fetch_optional(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn fetch_by_id( + pool: &PgPool, + id: Uuid, + ) -> Result, TaskCommentError> { + let record = sqlx::query_as!( + TaskComment, + r#" + SELECT + id AS "id!: Uuid", + task_id AS "task_id!: Uuid", + author_id AS "author_id!: Uuid", + message AS "message!", + created_at AS "created_at!: DateTime", + updated_at AS "updated_at!: DateTime" + FROM task_comments + WHERE id = $1 + "#, + id + ) + .fetch_optional(pool) + .await?; + + Ok(record) + } + + pub async fn create( + tx: &mut Tx<'_>, + task_id: Uuid, + author_id: Uuid, + message: String, + ) -> Result { + let id = Uuid::new_v4(); + let now = Utc::now(); + let record = sqlx::query_as!( + TaskComment, + r#" + INSERT INTO task_comments (id, task_id, author_id, message, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING + id AS "id!: Uuid", + task_id AS "task_id!: Uuid", + author_id AS "author_id!: Uuid", + message AS "message!", + created_at AS "created_at!: DateTime", + updated_at AS "updated_at!: DateTime" + "#, + id, + task_id, + author_id, + message, + now, + now + ) + .fetch_one(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn create_with_pool( + pool: &PgPool, + task_id: Uuid, + author_id: Uuid, + message: String, + ) -> Result { + let mut tx = pool.begin().await?; + let record = Self::create(&mut tx, task_id, author_id, message).await?; + tx.commit().await?; + Ok(record) + } + + pub async fn update( + tx: &mut Tx<'_>, + id: Uuid, + message: String, + ) -> Result { + let updated_at = Utc::now(); + let record = sqlx::query_as!( + TaskComment, + r#" + UPDATE task_comments + SET + message = $1, + updated_at = $2 + WHERE id = $3 + RETURNING + id AS "id!: Uuid", + task_id AS "task_id!: Uuid", + author_id AS "author_id!: Uuid", + message AS "message!", + created_at AS "created_at!: DateTime", + updated_at AS "updated_at!: DateTime" + "#, + message, + updated_at, + id + ) + .fetch_one(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn update_with_pool( + pool: &PgPool, + id: Uuid, + message: String, + ) -> Result { + let mut tx = pool.begin().await?; + let record = Self::update(&mut tx, id, message).await?; + tx.commit().await?; + Ok(record) + } + + pub async fn delete(tx: &mut Tx<'_>, id: Uuid) -> Result<(), TaskCommentError> { + sqlx::query!("DELETE FROM task_comments WHERE id = $1", id) + .execute(&mut **tx) + .await?; + Ok(()) + } + + pub async fn delete_with_pool(pool: &PgPool, id: Uuid) -> Result<(), TaskCommentError> { + let mut tx = pool.begin().await?; + Self::delete(&mut tx, id).await?; + tx.commit().await?; + Ok(()) + } + + pub async fn list_by_task( + tx: &mut Tx<'_>, + task_id: Uuid, + ) -> Result, TaskCommentError> { + let records = sqlx::query_as!( + TaskComment, + r#" + SELECT + id AS "id!: Uuid", + task_id AS "task_id!: Uuid", + author_id AS "author_id!: Uuid", + message AS "message!", + created_at AS "created_at!: DateTime", + updated_at AS "updated_at!: DateTime" + FROM task_comments + WHERE task_id = $1 + "#, + task_id + ) + .fetch_all(&mut **tx) + .await?; + + Ok(records) + } + + pub async fn fetch_by_task( + pool: &PgPool, + task_id: Uuid, + ) -> Result, TaskCommentError> { + let records = sqlx::query_as!( + TaskComment, + r#" + SELECT + id AS "id!: Uuid", + task_id AS "task_id!: Uuid", + author_id AS "author_id!: Uuid", + message AS "message!", + created_at AS "created_at!: DateTime", + updated_at AS "updated_at!: DateTime" + FROM task_comments + WHERE task_id = $1 + "#, + task_id + ) + .fetch_all(pool) + .await?; + + Ok(records) + } +} diff --git a/crates/remote/src/db/task_dependencies.rs b/crates/remote/src/db/task_dependencies.rs new file mode 100644 index 000000000..3e8e35b6b --- /dev/null +++ b/crates/remote/src/db/task_dependencies.rs @@ -0,0 +1,72 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use thiserror::Error; +use uuid::Uuid; + +use super::Tx; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskDependency { + pub blocking_task_id: Uuid, + pub blocked_task_id: Uuid, + pub created_at: DateTime, +} + +#[derive(Debug, Error)] +pub enum TaskDependencyError { + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +pub struct TaskDependencyRepository; + +impl TaskDependencyRepository { + pub async fn get( + tx: &mut Tx<'_>, + blocking_task_id: Uuid, + blocked_task_id: Uuid, + ) -> Result, TaskDependencyError> { + let record = sqlx::query_as!( + TaskDependency, + r#" + SELECT + blocking_task_id AS "blocking_task_id!: Uuid", + blocked_task_id AS "blocked_task_id!: Uuid", + created_at AS "created_at!: DateTime" + FROM task_dependencies + WHERE blocking_task_id = $1 AND blocked_task_id = $2 + "#, + blocking_task_id, + blocked_task_id + ) + .fetch_optional(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn fetch( + pool: &PgPool, + blocking_task_id: Uuid, + blocked_task_id: Uuid, + ) -> Result, TaskDependencyError> { + let record = sqlx::query_as!( + TaskDependency, + r#" + SELECT + blocking_task_id AS "blocking_task_id!: Uuid", + blocked_task_id AS "blocked_task_id!: Uuid", + created_at AS "created_at!: DateTime" + FROM task_dependencies + WHERE blocking_task_id = $1 AND blocked_task_id = $2 + "#, + blocking_task_id, + blocked_task_id + ) + .fetch_optional(pool) + .await?; + + Ok(record) + } +} diff --git a/crates/remote/src/db/task_followers.rs b/crates/remote/src/db/task_followers.rs new file mode 100644 index 000000000..bc9a295ce --- /dev/null +++ b/crates/remote/src/db/task_followers.rs @@ -0,0 +1,68 @@ +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use thiserror::Error; +use uuid::Uuid; + +use super::Tx; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskFollower { + pub task_id: Uuid, + pub user_id: Uuid, +} + +#[derive(Debug, Error)] +pub enum TaskFollowerError { + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +pub struct TaskFollowerRepository; + +impl TaskFollowerRepository { + pub async fn get( + tx: &mut Tx<'_>, + task_id: Uuid, + user_id: Uuid, + ) -> Result, TaskFollowerError> { + let record = sqlx::query_as!( + TaskFollower, + r#" + SELECT + task_id AS "task_id!: Uuid", + user_id AS "user_id!: Uuid" + FROM task_followers + WHERE task_id = $1 AND user_id = $2 + "#, + task_id, + user_id + ) + .fetch_optional(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn fetch( + pool: &PgPool, + task_id: Uuid, + user_id: Uuid, + ) -> Result, TaskFollowerError> { + let record = sqlx::query_as!( + TaskFollower, + r#" + SELECT + task_id AS "task_id!: Uuid", + user_id AS "user_id!: Uuid" + FROM task_followers + WHERE task_id = $1 AND user_id = $2 + "#, + task_id, + user_id + ) + .fetch_optional(pool) + .await?; + + Ok(record) + } +} diff --git a/crates/remote/src/db/task_tags.rs b/crates/remote/src/db/task_tags.rs new file mode 100644 index 000000000..55b41346b --- /dev/null +++ b/crates/remote/src/db/task_tags.rs @@ -0,0 +1,68 @@ +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use thiserror::Error; +use uuid::Uuid; + +use super::Tx; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskTag { + pub task_id: Uuid, + pub tag_id: Uuid, +} + +#[derive(Debug, Error)] +pub enum TaskTagError { + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +pub struct TaskTagRepository; + +impl TaskTagRepository { + pub async fn get( + tx: &mut Tx<'_>, + task_id: Uuid, + tag_id: Uuid, + ) -> Result, TaskTagError> { + let record = sqlx::query_as!( + TaskTag, + r#" + SELECT + task_id AS "task_id!: Uuid", + tag_id AS "tag_id!: Uuid" + FROM task_tags + WHERE task_id = $1 AND tag_id = $2 + "#, + task_id, + tag_id + ) + .fetch_optional(&mut **tx) + .await?; + + Ok(record) + } + + pub async fn fetch( + pool: &PgPool, + task_id: Uuid, + tag_id: Uuid, + ) -> Result, TaskTagError> { + let record = sqlx::query_as!( + TaskTag, + r#" + SELECT + task_id AS "task_id!: Uuid", + tag_id AS "tag_id!: Uuid" + FROM task_tags + WHERE task_id = $1 AND tag_id = $2 + "#, + task_id, + tag_id + ) + .fetch_optional(pool) + .await?; + + Ok(record) + } +} diff --git a/crates/remote/src/db/types.rs b/crates/remote/src/db/types.rs new file mode 100644 index 000000000..b096cc9b7 --- /dev/null +++ b/crates/remote/src/db/types.rs @@ -0,0 +1,27 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "project_visibility", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum ProjectVisibility { + WholeTeam, + MembersOnly, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "task_priority", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum TaskPriority { + High, + Medium, + Low, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "sprint_status", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum SprintStatus { + Planned, + Active, + Completed, +} diff --git a/crates/remote/src/routes/mod.rs b/crates/remote/src/routes/mod.rs index e1976ca1d..327eeb8a3 100644 --- a/crates/remote/src/routes/mod.rs +++ b/crates/remote/src/routes/mod.rs @@ -20,7 +20,13 @@ mod identity; mod oauth; pub(crate) mod organization_members; mod organizations; +mod project_members; +mod project_statuses; mod projects; +mod sprints; +mod tags; +mod task_comment_reactions; +mod task_comments; pub mod tasks; pub fn router(state: AppState) -> Router { @@ -57,6 +63,12 @@ pub fn router(state: AppState) -> Router { .merge(organizations::router()) .merge(organization_members::protected_router()) .merge(oauth::protected_router()) + .merge(sprints::router()) + .merge(project_statuses::router()) + .merge(tags::router()) + .merge(task_comments::router()) + .merge(task_comment_reactions::router()) + .merge(project_members::router()) .merge(crate::ws::router()) .layer(middleware::from_fn_with_state( state.clone(), diff --git a/crates/remote/src/routes/project_members.rs b/crates/remote/src/routes/project_members.rs new file mode 100644 index 000000000..784eca074 --- /dev/null +++ b/crates/remote/src/routes/project_members.rs @@ -0,0 +1,150 @@ +use axum::{ + Json, Router, + extract::{Extension, Path, State}, + http::StatusCode, + routing::{delete, get}, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tracing::instrument; +use uuid::Uuid; + +use super::{error::ErrorResponse, organization_members::ensure_member_access}; +use crate::{ + AppState, + auth::RequestContext, + db::{ + project_members::{ProjectMember, ProjectMemberRepository}, + remote_projects::RemoteProjectRepository, + }, +}; + +#[derive(Debug, Serialize)] +pub struct ProjectMemberResponse { + pub project_id: Uuid, + pub user_id: Uuid, + pub joined_at: DateTime, +} + +#[derive(Debug, Serialize)] +pub struct ListMembersResponse { + pub members: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct AddMemberRequest { + pub user_id: Uuid, +} + +pub fn router() -> Router { + Router::new() + .route( + "/projects/{project_id}/members", + get(list_members).post(add_member), + ) + .route( + "/projects/{project_id}/members/{user_id}", + delete(remove_member), + ) +} + +async fn ensure_project_access( + state: &AppState, + ctx: &RequestContext, + project_id: Uuid, +) -> Result { + let project = RemoteProjectRepository::fetch_by_id(state.pool(), project_id) + .await + .map_err(|error| { + tracing::error!(?error, %project_id, "failed to load project"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load project") + })? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "project not found"))?; + + ensure_member_access(state.pool(), project.organization_id, ctx.user.id).await?; + Ok(project.organization_id) +} + +#[instrument( + name = "project_members.list_members", + skip(state, ctx), + fields(project_id = %project_id, user_id = %ctx.user.id) +)] +async fn list_members( + State(state): State, + Extension(ctx): Extension, + Path(project_id): Path, +) -> Result, ErrorResponse> { + ensure_project_access(&state, &ctx, project_id).await?; + + let members = ProjectMemberRepository::fetch_by_project(state.pool(), project_id) + .await + .map_err(|error| { + tracing::error!(?error, %project_id, "failed to list project members"); + ErrorResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to list project members", + ) + })? + .into_iter() + .map(to_member_response) + .collect(); + + Ok(Json(ListMembersResponse { members })) +} + +#[instrument( + name = "project_members.add_member", + skip(state, ctx, payload), + fields(project_id = %project_id, user_id = %ctx.user.id) +)] +async fn add_member( + State(state): State, + Extension(ctx): Extension, + Path(project_id): Path, + Json(payload): Json, +) -> Result, ErrorResponse> { + let organization_id = ensure_project_access(&state, &ctx, project_id).await?; + + // Ensure target user is a member of the organization + ensure_member_access(state.pool(), organization_id, payload.user_id).await?; + + let member = ProjectMemberRepository::add_with_pool(state.pool(), project_id, payload.user_id) + .await + .map_err(|error| { + tracing::error!(?error, "failed to add project member"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") + })?; + + Ok(Json(to_member_response(member))) +} + +#[instrument( + name = "project_members.remove_member", + skip(state, ctx), + fields(project_id = %project_id, user_id = %ctx.user.id, target_user_id = %user_id) +)] +async fn remove_member( + State(state): State, + Extension(ctx): Extension, + Path((project_id, user_id)): Path<(Uuid, Uuid)>, +) -> Result { + ensure_project_access(&state, &ctx, project_id).await?; + + ProjectMemberRepository::remove_with_pool(state.pool(), project_id, user_id) + .await + .map_err(|error| { + tracing::error!(?error, "failed to remove project member"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") + })?; + + Ok(StatusCode::NO_CONTENT) +} + +fn to_member_response(member: ProjectMember) -> ProjectMemberResponse { + ProjectMemberResponse { + project_id: member.project_id, + user_id: member.user_id, + joined_at: member.joined_at, + } +} diff --git a/crates/remote/src/routes/project_statuses.rs b/crates/remote/src/routes/project_statuses.rs new file mode 100644 index 000000000..865c0b9eb --- /dev/null +++ b/crates/remote/src/routes/project_statuses.rs @@ -0,0 +1,219 @@ +use axum::{ + Json, Router, + extract::{Extension, Path, State}, + http::StatusCode, + routing::{get, patch}, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tracing::instrument; +use uuid::Uuid; + +use super::{error::ErrorResponse, organization_members::ensure_member_access}; +use crate::{ + AppState, + auth::RequestContext, + db::{ + project_statuses::{ProjectStatus, ProjectStatusRepository}, + remote_projects::RemoteProjectRepository, + }, +}; + +#[derive(Debug, Serialize)] +pub struct ProjectStatusResponse { + pub id: Uuid, + pub project_id: Uuid, + pub name: String, + pub color: String, + pub sort_order: i32, + pub created_at: DateTime, +} + +#[derive(Debug, Serialize)] +pub struct ListProjectStatusesResponse { + pub statuses: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct CreateProjectStatusRequest { + pub name: String, + pub color: String, + pub sort_order: i32, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateProjectStatusRequest { + pub name: String, + pub color: String, + pub sort_order: i32, +} + +pub fn router() -> Router { + Router::new() + .route( + "/projects/{project_id}/statuses", + get(list_statuses).post(create_status), + ) + .route( + "/statuses/{status_id}", + patch(update_status).delete(delete_status), + ) +} + +async fn ensure_project_access( + state: &AppState, + ctx: &RequestContext, + project_id: Uuid, +) -> Result<(), ErrorResponse> { + let project = RemoteProjectRepository::fetch_by_id(state.pool(), project_id) + .await + .map_err(|error| { + tracing::error!(?error, %project_id, "failed to load project"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load project") + })? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "project not found"))?; + + ensure_member_access(state.pool(), project.organization_id, ctx.user.id).await?; + Ok(()) +} + +#[instrument( + name = "project_statuses.list_statuses", + skip(state, ctx), + fields(project_id = %project_id, user_id = %ctx.user.id) +)] +async fn list_statuses( + State(state): State, + Extension(ctx): Extension, + Path(project_id): Path, +) -> Result, ErrorResponse> { + ensure_project_access(&state, &ctx, project_id).await?; + + let statuses = ProjectStatusRepository::fetch_by_project(state.pool(), project_id) + .await + .map_err(|error| { + tracing::error!(?error, %project_id, "failed to list project statuses"); + ErrorResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to list project statuses", + ) + })? + .into_iter() + .map(to_project_status_response) + .collect(); + + Ok(Json(ListProjectStatusesResponse { statuses })) +} + +#[instrument( + name = "project_statuses.create_status", + skip(state, ctx, payload), + fields(project_id = %project_id, user_id = %ctx.user.id) +)] +async fn create_status( + State(state): State, + Extension(ctx): Extension, + Path(project_id): Path, + Json(payload): Json, +) -> Result, ErrorResponse> { + ensure_project_access(&state, &ctx, project_id).await?; + + let status = ProjectStatusRepository::create_with_pool( + state.pool(), + project_id, + payload.name, + payload.color, + payload.sort_order, + ) + .await + .map_err(|error| { + tracing::error!(?error, "failed to create project status"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") + })?; + + Ok(Json(to_project_status_response(status))) +} + +#[instrument( + name = "project_statuses.update_status", + skip(state, ctx, payload), + fields(status_id = %status_id, user_id = %ctx.user.id) +)] +async fn update_status( + State(state): State, + Extension(ctx): Extension, + Path(status_id): Path, + Json(payload): Json, +) -> Result, ErrorResponse> { + let status = ProjectStatusRepository::fetch_by_id(state.pool(), status_id) + .await + .map_err(|error| { + tracing::error!(?error, %status_id, "failed to load project status"); + ErrorResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to load project status", + ) + })? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "project status not found"))?; + + ensure_project_access(&state, &ctx, status.project_id).await?; + + let updated_status = ProjectStatusRepository::update_with_pool( + state.pool(), + status_id, + payload.name, + payload.color, + payload.sort_order, + ) + .await + .map_err(|error| { + tracing::error!(?error, "failed to update project status"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") + })?; + + Ok(Json(to_project_status_response(updated_status))) +} + +#[instrument( + name = "project_statuses.delete_status", + skip(state, ctx), + fields(status_id = %status_id, user_id = %ctx.user.id) +)] +async fn delete_status( + State(state): State, + Extension(ctx): Extension, + Path(status_id): Path, +) -> Result { + let status = ProjectStatusRepository::fetch_by_id(state.pool(), status_id) + .await + .map_err(|error| { + tracing::error!(?error, %status_id, "failed to load project status"); + ErrorResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to load project status", + ) + })? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "project status not found"))?; + + ensure_project_access(&state, &ctx, status.project_id).await?; + + ProjectStatusRepository::delete_with_pool(state.pool(), status_id) + .await + .map_err(|error| { + tracing::error!(?error, "failed to delete project status"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") + })?; + + Ok(StatusCode::NO_CONTENT) +} + +fn to_project_status_response(status: ProjectStatus) -> ProjectStatusResponse { + ProjectStatusResponse { + id: status.id, + project_id: status.project_id, + name: status.name, + color: status.color, + sort_order: status.sort_order, + created_at: status.created_at, + } +} diff --git a/crates/remote/src/routes/projects.rs b/crates/remote/src/routes/projects.rs index 32157749a..cdfa246ee 100644 --- a/crates/remote/src/routes/projects.rs +++ b/crates/remote/src/routes/projects.rs @@ -4,19 +4,39 @@ use axum::{ http::StatusCode, routing::get, }; -use serde::Deserialize; -use serde_json::Value; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; use tracing::instrument; -use utils::api::projects::{ListProjectsResponse, RemoteProject}; use uuid::Uuid; use super::{error::ErrorResponse, organization_members::ensure_member_access}; use crate::{ AppState, auth::RequestContext, - db::projects::{CreateProjectData, Project, ProjectError, ProjectRepository}, + db::{ + remote_projects::{RemoteProject, RemoteProjectRepository}, + types::ProjectVisibility, + }, }; +#[derive(Debug, Serialize)] +pub struct RemoteProjectResponse { + pub id: Uuid, + pub organization_id: Uuid, + pub name: String, + pub color: String, + pub visibility: ProjectVisibility, + pub sprints_enabled: bool, + pub sprint_duration_weeks: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize)] +pub struct ListProjectsResponse { + pub projects: Vec, +} + #[derive(Debug, Deserialize)] struct ProjectsQuery { organization_id: Uuid, @@ -26,14 +46,30 @@ struct ProjectsQuery { struct CreateProjectRequest { organization_id: Uuid, name: String, - #[serde(default)] - metadata: Value, + color: String, + visibility: ProjectVisibility, + sprints_enabled: bool, + sprint_duration_weeks: Option, +} + +#[derive(Debug, Deserialize)] +struct UpdateProjectRequest { + name: String, + color: String, + visibility: ProjectVisibility, + sprints_enabled: bool, + sprint_duration_weeks: Option, } pub fn router() -> Router { Router::new() .route("/projects", get(list_projects).post(create_project)) - .route("/projects/{project_id}", get(get_project)) + .route( + "/projects/{project_id}", + get(get_project) + .patch(update_project) + .delete(delete_project), + ) } #[instrument( @@ -49,16 +85,15 @@ async fn list_projects( let target_org = params.organization_id; ensure_member_access(state.pool(), target_org, ctx.user.id).await?; - let projects = match ProjectRepository::list_by_organization(state.pool(), target_org).await { - Ok(rows) => rows.into_iter().map(to_remote_project).collect(), - Err(error) => { + let projects = RemoteProjectRepository::fetch_by_organization(state.pool(), target_org) + .await + .map_err(|error| { tracing::error!(?error, org_id = %target_org, "failed to list remote projects"); - return Err(ErrorResponse::new( - StatusCode::INTERNAL_SERVER_ERROR, - "failed to list projects", - )); - } - }; + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to list projects") + })? + .into_iter() + .map(to_remote_project_response) + .collect(); Ok(Json(ListProjectsResponse { projects })) } @@ -72,8 +107,8 @@ async fn get_project( State(state): State, Extension(ctx): Extension, Path(project_id): Path, -) -> Result, ErrorResponse> { - let record = ProjectRepository::fetch_by_id(state.pool(), project_id) +) -> Result, ErrorResponse> { + let record = RemoteProjectRepository::fetch_by_id(state.pool(), project_id) .await .map_err(|error| { tracing::error!(?error, %project_id, "failed to load project"); @@ -83,7 +118,7 @@ async fn get_project( ensure_member_access(state.pool(), record.organization_id, ctx.user.id).await?; - Ok(Json(to_remote_project(record))) + Ok(Json(to_remote_project_response(record))) } #[instrument( @@ -95,78 +130,115 @@ async fn create_project( State(state): State, Extension(ctx): Extension, Json(payload): Json, -) -> Result, ErrorResponse> { +) -> Result, ErrorResponse> { let CreateProjectRequest { organization_id, name, - metadata, + color, + visibility, + sprints_enabled, + sprint_duration_weeks, } = payload; ensure_member_access(state.pool(), organization_id, ctx.user.id).await?; - let mut tx = state.pool().begin().await.map_err(|error| { - tracing::error!(?error, "failed to start transaction for project creation"); + let project = RemoteProjectRepository::create_with_pool( + state.pool(), + organization_id, + name, + color, + visibility, + sprints_enabled, + sprint_duration_weeks, + ) + .await + .map_err(|error| { + tracing::error!(?error, "failed to create remote project"); ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") })?; - let metadata = normalize_metadata(metadata).ok_or_else(|| { - ErrorResponse::new(StatusCode::BAD_REQUEST, "metadata must be a JSON object") - })?; + Ok(Json(to_remote_project_response(project))) +} + +#[instrument( + name = "projects.update_project", + skip(state, ctx, payload), + fields(user_id = %ctx.user.id, project_id = %project_id) +)] +async fn update_project( + State(state): State, + Extension(ctx): Extension, + Path(project_id): Path, + Json(payload): Json, +) -> Result, ErrorResponse> { + let record = RemoteProjectRepository::fetch_by_id(state.pool(), project_id) + .await + .map_err(|error| { + tracing::error!(?error, %project_id, "failed to load project"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load project") + })? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "project not found"))?; + + ensure_member_access(state.pool(), record.organization_id, ctx.user.id).await?; - let project = match ProjectRepository::insert( - &mut tx, - CreateProjectData { - organization_id, - name, - metadata, - }, + let project = RemoteProjectRepository::update_with_pool( + state.pool(), + project_id, + payload.name, + payload.color, + payload.visibility, + payload.sprints_enabled, + payload.sprint_duration_weeks, ) .await - { - Ok(project) => project, - Err(error) => { - tx.rollback().await.ok(); - return Err(match error { - ProjectError::Conflict(message) => { - tracing::warn!(?message, "remote project conflict"); - ErrorResponse::new(StatusCode::CONFLICT, "project already exists") - } - ProjectError::InvalidMetadata => { - ErrorResponse::new(StatusCode::BAD_REQUEST, "invalid project metadata") - } - ProjectError::Database(err) => { - tracing::error!(?err, "failed to create remote project"); - ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") - } - }); - } - }; - - if let Err(error) = tx.commit().await { - tracing::error!(?error, "failed to commit remote project creation"); - return Err(ErrorResponse::new( - StatusCode::INTERNAL_SERVER_ERROR, - "internal server error", - )); - } + .map_err(|error| { + tracing::error!(?error, "failed to update remote project"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") + })?; - Ok(Json(to_remote_project(project))) + Ok(Json(to_remote_project_response(project))) } -fn to_remote_project(project: Project) -> RemoteProject { - RemoteProject { +#[instrument( + name = "projects.delete_project", + skip(state, ctx), + fields(user_id = %ctx.user.id, project_id = %project_id) +)] +async fn delete_project( + State(state): State, + Extension(ctx): Extension, + Path(project_id): Path, +) -> Result { + let record = RemoteProjectRepository::fetch_by_id(state.pool(), project_id) + .await + .map_err(|error| { + tracing::error!(?error, %project_id, "failed to load project"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load project") + })? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "project not found"))?; + + ensure_member_access(state.pool(), record.organization_id, ctx.user.id).await?; + + RemoteProjectRepository::delete_with_pool(state.pool(), project_id) + .await + .map_err(|error| { + tracing::error!(?error, "failed to delete remote project"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") + })?; + + Ok(StatusCode::NO_CONTENT) +} + +fn to_remote_project_response(project: RemoteProject) -> RemoteProjectResponse { + RemoteProjectResponse { id: project.id, organization_id: project.organization_id, name: project.name, - metadata: project.metadata, + color: project.color, + visibility: project.visibility, + sprints_enabled: project.sprints_enabled, + sprint_duration_weeks: project.sprint_duration_weeks, created_at: project.created_at, - } -} - -fn normalize_metadata(value: Value) -> Option { - match value { - Value::Null => Some(Value::Object(serde_json::Map::new())), - Value::Object(_) => Some(value), - _ => None, + updated_at: project.updated_at, } } diff --git a/crates/remote/src/routes/sprints.rs b/crates/remote/src/routes/sprints.rs new file mode 100644 index 000000000..88f278660 --- /dev/null +++ b/crates/remote/src/routes/sprints.rs @@ -0,0 +1,223 @@ +use axum::{ + Json, Router, + extract::{Extension, Path, State}, + http::StatusCode, + routing::{get, patch}, +}; +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; +use tracing::instrument; +use uuid::Uuid; + +use super::{error::ErrorResponse, organization_members::ensure_member_access}; +use crate::{ + AppState, + auth::RequestContext, + db::{ + remote_projects::RemoteProjectRepository, + sprints::{Sprint, SprintRepository}, + types::SprintStatus, + }, +}; + +#[derive(Debug, Serialize)] +pub struct SprintResponse { + pub id: Uuid, + pub project_id: Uuid, + pub label: String, + pub sequence_number: i32, + pub start_date: NaiveDate, + pub end_date: NaiveDate, + pub status: SprintStatus, + pub created_at: DateTime, +} + +#[derive(Debug, Serialize)] +pub struct ListSprintsResponse { + pub sprints: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct CreateSprintRequest { + pub label: String, + pub sequence_number: i32, + pub start_date: NaiveDate, + pub end_date: NaiveDate, + pub status: SprintStatus, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateSprintRequest { + pub label: String, + pub sequence_number: i32, + pub start_date: NaiveDate, + pub end_date: NaiveDate, + pub status: SprintStatus, +} + +pub fn router() -> Router { + Router::new() + .route( + "/projects/{project_id}/sprints", + get(list_sprints).post(create_sprint), + ) + .route( + "/sprints/{sprint_id}", + patch(update_sprint).delete(delete_sprint), + ) +} + +async fn ensure_project_access( + state: &AppState, + ctx: &RequestContext, + project_id: Uuid, +) -> Result<(), ErrorResponse> { + let project = RemoteProjectRepository::fetch_by_id(state.pool(), project_id) + .await + .map_err(|error| { + tracing::error!(?error, %project_id, "failed to load project"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load project") + })? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "project not found"))?; + + ensure_member_access(state.pool(), project.organization_id, ctx.user.id).await?; + Ok(()) +} + +#[instrument( + name = "sprints.list_sprints", + skip(state, ctx), + fields(project_id = %project_id, user_id = %ctx.user.id) +)] +async fn list_sprints( + State(state): State, + Extension(ctx): Extension, + Path(project_id): Path, +) -> Result, ErrorResponse> { + ensure_project_access(&state, &ctx, project_id).await?; + + let sprints = SprintRepository::fetch_by_project(state.pool(), project_id) + .await + .map_err(|error| { + tracing::error!(?error, %project_id, "failed to list sprints"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to list sprints") + })? + .into_iter() + .map(to_sprint_response) + .collect(); + + Ok(Json(ListSprintsResponse { sprints })) +} + +#[instrument( + name = "sprints.create_sprint", + skip(state, ctx, payload), + fields(project_id = %project_id, user_id = %ctx.user.id) +)] +async fn create_sprint( + State(state): State, + Extension(ctx): Extension, + Path(project_id): Path, + Json(payload): Json, +) -> Result, ErrorResponse> { + ensure_project_access(&state, &ctx, project_id).await?; + + let sprint = SprintRepository::create_with_pool( + state.pool(), + project_id, + payload.label, + payload.sequence_number, + payload.start_date, + payload.end_date, + payload.status, + ) + .await + .map_err(|error| { + tracing::error!(?error, "failed to create sprint"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") + })?; + + Ok(Json(to_sprint_response(sprint))) +} + +#[instrument( + name = "sprints.update_sprint", + skip(state, ctx, payload), + fields(sprint_id = %sprint_id, user_id = %ctx.user.id) +)] +async fn update_sprint( + State(state): State, + Extension(ctx): Extension, + Path(sprint_id): Path, + Json(payload): Json, +) -> Result, ErrorResponse> { + let sprint = SprintRepository::fetch_by_id(state.pool(), sprint_id) + .await + .map_err(|error| { + tracing::error!(?error, %sprint_id, "failed to load sprint"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load sprint") + })? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "sprint not found"))?; + + ensure_project_access(&state, &ctx, sprint.project_id).await?; + + let updated_sprint = SprintRepository::update_with_pool( + state.pool(), + sprint_id, + payload.label, + payload.sequence_number, + payload.start_date, + payload.end_date, + payload.status, + ) + .await + .map_err(|error| { + tracing::error!(?error, "failed to update sprint"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") + })?; + + Ok(Json(to_sprint_response(updated_sprint))) +} + +#[instrument( + name = "sprints.delete_sprint", + skip(state, ctx), + fields(sprint_id = %sprint_id, user_id = %ctx.user.id) +)] +async fn delete_sprint( + State(state): State, + Extension(ctx): Extension, + Path(sprint_id): Path, +) -> Result { + let sprint = SprintRepository::fetch_by_id(state.pool(), sprint_id) + .await + .map_err(|error| { + tracing::error!(?error, %sprint_id, "failed to load sprint"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load sprint") + })? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "sprint not found"))?; + + ensure_project_access(&state, &ctx, sprint.project_id).await?; + + SprintRepository::delete_with_pool(state.pool(), sprint_id) + .await + .map_err(|error| { + tracing::error!(?error, "failed to delete sprint"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") + })?; + + Ok(StatusCode::NO_CONTENT) +} + +fn to_sprint_response(sprint: Sprint) -> SprintResponse { + SprintResponse { + id: sprint.id, + project_id: sprint.project_id, + label: sprint.label, + sequence_number: sprint.sequence_number, + start_date: sprint.start_date, + end_date: sprint.end_date, + status: sprint.status, + created_at: sprint.created_at, + } +} diff --git a/crates/remote/src/routes/tags.rs b/crates/remote/src/routes/tags.rs new file mode 100644 index 000000000..7713103ce --- /dev/null +++ b/crates/remote/src/routes/tags.rs @@ -0,0 +1,190 @@ +use axum::{ + Json, Router, + extract::{Extension, Path, State}, + http::StatusCode, + routing::{get, patch}, +}; +use serde::{Deserialize, Serialize}; +use tracing::instrument; +use uuid::Uuid; + +use super::{error::ErrorResponse, organization_members::ensure_member_access}; +use crate::{ + AppState, + auth::RequestContext, + db::{ + remote_projects::RemoteProjectRepository, + tags::{Tag, TagRepository}, + }, +}; + +#[derive(Debug, Serialize)] +pub struct TagResponse { + pub id: Uuid, + pub project_id: Uuid, + pub name: String, + pub color: String, +} + +#[derive(Debug, Serialize)] +pub struct ListTagsResponse { + pub tags: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct CreateTagRequest { + pub name: String, + pub color: String, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateTagRequest { + pub name: String, + pub color: String, +} + +pub fn router() -> Router { + Router::new() + .route( + "/projects/{project_id}/tags", + get(list_tags).post(create_tag), + ) + .route("/tags/{tag_id}", patch(update_tag).delete(delete_tag)) +} + +async fn ensure_project_access( + state: &AppState, + ctx: &RequestContext, + project_id: Uuid, +) -> Result<(), ErrorResponse> { + let project = RemoteProjectRepository::fetch_by_id(state.pool(), project_id) + .await + .map_err(|error| { + tracing::error!(?error, %project_id, "failed to load project"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load project") + })? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "project not found"))?; + + ensure_member_access(state.pool(), project.organization_id, ctx.user.id).await?; + Ok(()) +} + +#[instrument( + name = "tags.list_tags", + skip(state, ctx), + fields(project_id = %project_id, user_id = %ctx.user.id) +)] +async fn list_tags( + State(state): State, + Extension(ctx): Extension, + Path(project_id): Path, +) -> Result, ErrorResponse> { + ensure_project_access(&state, &ctx, project_id).await?; + + let tags = TagRepository::fetch_by_project(state.pool(), project_id) + .await + .map_err(|error| { + tracing::error!(?error, %project_id, "failed to list tags"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to list tags") + })? + .into_iter() + .map(to_tag_response) + .collect(); + + Ok(Json(ListTagsResponse { tags })) +} + +#[instrument( + name = "tags.create_tag", + skip(state, ctx, payload), + fields(project_id = %project_id, user_id = %ctx.user.id) +)] +async fn create_tag( + State(state): State, + Extension(ctx): Extension, + Path(project_id): Path, + Json(payload): Json, +) -> Result, ErrorResponse> { + ensure_project_access(&state, &ctx, project_id).await?; + + let tag = + TagRepository::create_with_pool(state.pool(), project_id, payload.name, payload.color) + .await + .map_err(|error| { + tracing::error!(?error, "failed to create tag"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") + })?; + + Ok(Json(to_tag_response(tag))) +} + +#[instrument( + name = "tags.update_tag", + skip(state, ctx, payload), + fields(tag_id = %tag_id, user_id = %ctx.user.id) +)] +async fn update_tag( + State(state): State, + Extension(ctx): Extension, + Path(tag_id): Path, + Json(payload): Json, +) -> Result, ErrorResponse> { + let tag = TagRepository::fetch_by_id(state.pool(), tag_id) + .await + .map_err(|error| { + tracing::error!(?error, %tag_id, "failed to load tag"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load tag") + })? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "tag not found"))?; + + ensure_project_access(&state, &ctx, tag.project_id).await?; + + let updated_tag = + TagRepository::update_with_pool(state.pool(), tag_id, payload.name, payload.color) + .await + .map_err(|error| { + tracing::error!(?error, "failed to update tag"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") + })?; + + Ok(Json(to_tag_response(updated_tag))) +} + +#[instrument( + name = "tags.delete_tag", + skip(state, ctx), + fields(tag_id = %tag_id, user_id = %ctx.user.id) +)] +async fn delete_tag( + State(state): State, + Extension(ctx): Extension, + Path(tag_id): Path, +) -> Result { + let tag = TagRepository::fetch_by_id(state.pool(), tag_id) + .await + .map_err(|error| { + tracing::error!(?error, %tag_id, "failed to load tag"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load tag") + })? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "tag not found"))?; + + ensure_project_access(&state, &ctx, tag.project_id).await?; + + TagRepository::delete_with_pool(state.pool(), tag_id) + .await + .map_err(|error| { + tracing::error!(?error, "failed to delete tag"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") + })?; + + Ok(StatusCode::NO_CONTENT) +} + +fn to_tag_response(tag: Tag) -> TagResponse { + TagResponse { + id: tag.id, + project_id: tag.project_id, + name: tag.name, + color: tag.color, + } +} diff --git a/crates/remote/src/routes/task_comment_reactions.rs b/crates/remote/src/routes/task_comment_reactions.rs new file mode 100644 index 000000000..cd13c7419 --- /dev/null +++ b/crates/remote/src/routes/task_comment_reactions.rs @@ -0,0 +1,193 @@ +use axum::{ + Json, Router, + extract::{Extension, Path, State}, + http::StatusCode, + routing::{delete, get}, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tracing::instrument; +use uuid::Uuid; + +use super::{error::ErrorResponse, organization_members::ensure_member_access}; +use crate::{ + AppState, + auth::RequestContext, + db::{ + task_comment_reactions::{TaskCommentReaction, TaskCommentReactionRepository}, + task_comments::TaskCommentRepository, + tasks::SharedTaskRepository, + }, +}; + +#[derive(Debug, Serialize)] +pub struct TaskCommentReactionResponse { + pub id: Uuid, + pub comment_id: Uuid, + pub user_id: Uuid, + pub emoji: String, + pub created_at: DateTime, +} + +#[derive(Debug, Serialize)] +pub struct ListReactionsResponse { + pub reactions: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct CreateReactionRequest { + pub emoji: String, +} + +pub fn router() -> Router { + Router::new() + .route( + "/comments/{comment_id}/reactions", + get(list_reactions).post(create_reaction), + ) + .route("/reactions/{reaction_id}", delete(delete_reaction)) +} + +async fn ensure_task_access( + state: &AppState, + ctx: &RequestContext, + task_id: Uuid, +) -> Result<(), ErrorResponse> { + let organization_id = SharedTaskRepository::organization_id(state.pool(), task_id) + .await + .map_err(|error| { + tracing::error!(?error, %task_id, "failed to load task organization"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load task") + })? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "task not found"))?; + + ensure_member_access(state.pool(), organization_id, ctx.user.id).await?; + Ok(()) +} + +#[instrument( + name = "task_comment_reactions.list_reactions", + skip(state, ctx), + fields(comment_id = %comment_id, user_id = %ctx.user.id) +)] +async fn list_reactions( + State(state): State, + Extension(ctx): Extension, + Path(comment_id): Path, +) -> Result, ErrorResponse> { + let comment = TaskCommentRepository::fetch_by_id(state.pool(), comment_id) + .await + .map_err(|error| { + tracing::error!(?error, %comment_id, "failed to load comment"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load comment") + })? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "comment not found"))?; + + ensure_task_access(&state, &ctx, comment.task_id).await?; + + let reactions = TaskCommentReactionRepository::fetch_by_comment(state.pool(), comment_id) + .await + .map_err(|error| { + tracing::error!(?error, %comment_id, "failed to list reactions"); + ErrorResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to list reactions", + ) + })? + .into_iter() + .map(to_reaction_response) + .collect(); + + Ok(Json(ListReactionsResponse { reactions })) +} + +#[instrument( + name = "task_comment_reactions.create_reaction", + skip(state, ctx, payload), + fields(comment_id = %comment_id, user_id = %ctx.user.id) +)] +async fn create_reaction( + State(state): State, + Extension(ctx): Extension, + Path(comment_id): Path, + Json(payload): Json, +) -> Result, ErrorResponse> { + let comment = TaskCommentRepository::fetch_by_id(state.pool(), comment_id) + .await + .map_err(|error| { + tracing::error!(?error, %comment_id, "failed to load comment"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load comment") + })? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "comment not found"))?; + + ensure_task_access(&state, &ctx, comment.task_id).await?; + + let reaction = TaskCommentReactionRepository::create_with_pool( + state.pool(), + comment_id, + ctx.user.id, + payload.emoji, + ) + .await + .map_err(|error| { + tracing::error!(?error, "failed to create reaction"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") + })?; + + Ok(Json(to_reaction_response(reaction))) +} + +#[instrument( + name = "task_comment_reactions.delete_reaction", + skip(state, ctx), + fields(reaction_id = %reaction_id, user_id = %ctx.user.id) +)] +async fn delete_reaction( + State(state): State, + Extension(ctx): Extension, + Path(reaction_id): Path, +) -> Result { + let reaction = TaskCommentReactionRepository::fetch_by_id(state.pool(), reaction_id) + .await + .map_err(|error| { + tracing::error!(?error, %reaction_id, "failed to load reaction"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load reaction") + })? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "reaction not found"))?; + + if reaction.user_id != ctx.user.id { + return Err(ErrorResponse::new( + StatusCode::FORBIDDEN, + "you are not the author of this reaction", + )); + } + + let comment = TaskCommentRepository::fetch_by_id(state.pool(), reaction.comment_id) + .await + .map_err(|error| { + tracing::error!(?error, comment_id = %reaction.comment_id, "failed to load comment"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load comment") + })? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "comment not found"))?; + + ensure_task_access(&state, &ctx, comment.task_id).await?; + + TaskCommentReactionRepository::delete_with_pool(state.pool(), reaction_id) + .await + .map_err(|error| { + tracing::error!(?error, "failed to delete reaction"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") + })?; + + Ok(StatusCode::NO_CONTENT) +} + +fn to_reaction_response(reaction: TaskCommentReaction) -> TaskCommentReactionResponse { + TaskCommentReactionResponse { + id: reaction.id, + comment_id: reaction.comment_id, + user_id: reaction.user_id, + emoji: reaction.emoji, + created_at: reaction.created_at, + } +} diff --git a/crates/remote/src/routes/task_comments.rs b/crates/remote/src/routes/task_comments.rs new file mode 100644 index 000000000..194aaa0a3 --- /dev/null +++ b/crates/remote/src/routes/task_comments.rs @@ -0,0 +1,223 @@ +use axum::{ + Json, Router, + extract::{Extension, Path, State}, + http::StatusCode, + routing::{get, patch}, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tracing::instrument; +use uuid::Uuid; + +use super::{error::ErrorResponse, organization_members::ensure_member_access}; +use crate::{ + AppState, + auth::RequestContext, + db::{ + task_comments::{TaskComment, TaskCommentRepository}, + tasks::SharedTaskRepository, + }, +}; + +#[derive(Debug, Serialize)] +pub struct TaskCommentResponse { + pub id: Uuid, + pub task_id: Uuid, + pub author_id: Uuid, + pub message: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize)] +pub struct ListCommentsResponse { + pub comments: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct CreateCommentRequest { + pub message: String, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateCommentRequest { + pub message: String, +} + +pub fn router() -> Router { + Router::new() + .route( + "/tasks/{task_id}/comments", + get(list_comments).post(create_comment), + ) + .route( + "/comments/{comment_id}", + patch(update_comment).delete(delete_comment), + ) +} + +async fn ensure_task_access( + state: &AppState, + ctx: &RequestContext, + task_id: Uuid, +) -> Result<(), ErrorResponse> { + let organization_id = SharedTaskRepository::organization_id(state.pool(), task_id) + .await + .map_err(|error| { + tracing::error!(?error, %task_id, "failed to load task organization"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load task") + })? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "task not found"))?; + + ensure_member_access(state.pool(), organization_id, ctx.user.id).await?; + Ok(()) +} + +#[instrument( + name = "task_comments.list_comments", + skip(state, ctx), + fields(task_id = %task_id, user_id = %ctx.user.id) +)] +async fn list_comments( + State(state): State, + Extension(ctx): Extension, + Path(task_id): Path, +) -> Result, ErrorResponse> { + ensure_task_access(&state, &ctx, task_id).await?; + + let comments = TaskCommentRepository::fetch_by_task(state.pool(), task_id) + .await + .map_err(|error| { + tracing::error!(?error, %task_id, "failed to list task comments"); + ErrorResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to list task comments", + ) + })? + .into_iter() + .map(to_comment_response) + .collect(); + + Ok(Json(ListCommentsResponse { comments })) +} + +#[instrument( + name = "task_comments.create_comment", + skip(state, ctx, payload), + fields(task_id = %task_id, user_id = %ctx.user.id) +)] +async fn create_comment( + State(state): State, + Extension(ctx): Extension, + Path(task_id): Path, + Json(payload): Json, +) -> Result, ErrorResponse> { + ensure_task_access(&state, &ctx, task_id).await?; + + let comment = TaskCommentRepository::create_with_pool( + state.pool(), + task_id, + ctx.user.id, + payload.message, + ) + .await + .map_err(|error| { + tracing::error!(?error, "failed to create task comment"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") + })?; + + Ok(Json(to_comment_response(comment))) +} + +#[instrument( + name = "task_comments.update_comment", + skip(state, ctx, payload), + fields(comment_id = %comment_id, user_id = %ctx.user.id) +)] +async fn update_comment( + State(state): State, + Extension(ctx): Extension, + Path(comment_id): Path, + Json(payload): Json, +) -> Result, ErrorResponse> { + let comment = TaskCommentRepository::fetch_by_id(state.pool(), comment_id) + .await + .map_err(|error| { + tracing::error!(?error, %comment_id, "failed to load task comment"); + ErrorResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to load task comment", + ) + })? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "comment not found"))?; + + if comment.author_id != ctx.user.id { + return Err(ErrorResponse::new( + StatusCode::FORBIDDEN, + "you are not the author of this comment", + )); + } + + ensure_task_access(&state, &ctx, comment.task_id).await?; + + let updated_comment = + TaskCommentRepository::update_with_pool(state.pool(), comment_id, payload.message) + .await + .map_err(|error| { + tracing::error!(?error, "failed to update task comment"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") + })?; + + Ok(Json(to_comment_response(updated_comment))) +} + +#[instrument( + name = "task_comments.delete_comment", + skip(state, ctx), + fields(comment_id = %comment_id, user_id = %ctx.user.id) +)] +async fn delete_comment( + State(state): State, + Extension(ctx): Extension, + Path(comment_id): Path, +) -> Result { + let comment = TaskCommentRepository::fetch_by_id(state.pool(), comment_id) + .await + .map_err(|error| { + tracing::error!(?error, %comment_id, "failed to load task comment"); + ErrorResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to load task comment", + ) + })? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "comment not found"))?; + + if comment.author_id != ctx.user.id { + return Err(ErrorResponse::new( + StatusCode::FORBIDDEN, + "you are not the author of this comment", + )); + } + + ensure_task_access(&state, &ctx, comment.task_id).await?; + + TaskCommentRepository::delete_with_pool(state.pool(), comment_id) + .await + .map_err(|error| { + tracing::error!(?error, "failed to delete task comment"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") + })?; + + Ok(StatusCode::NO_CONTENT) +} + +fn to_comment_response(comment: TaskComment) -> TaskCommentResponse { + TaskCommentResponse { + id: comment.id, + task_id: comment.task_id, + author_id: comment.author_id, + message: comment.message, + created_at: comment.created_at, + updated_at: comment.updated_at, + } +}