diff --git a/apps/labrinth/.sqlx/query-034efaedb02e28d7e3a48f474f3c2145a8fb9183e4e3b6d82f705468e50b8214.json b/apps/labrinth/.sqlx/query-034efaedb02e28d7e3a48f474f3c2145a8fb9183e4e3b6d82f705468e50b8214.json new file mode 100644 index 00000000000..284f004cbaa --- /dev/null +++ b/apps/labrinth/.sqlx/query-034efaedb02e28d7e3a48f474f3c2145a8fb9183e4e3b6d82f705468e50b8214.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO threads_issues (\n id,\n thread_id,\n created_by,\n why\n )\n VALUES ($1, $2, $3, $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "034efaedb02e28d7e3a48f474f3c2145a8fb9183e4e3b6d82f705468e50b8214" +} diff --git a/apps/labrinth/.sqlx/query-04dbb91ad93de31a6eab62f712743dc3b3e4c93605769fd7d653beff95bf46e0.json b/apps/labrinth/.sqlx/query-04dbb91ad93de31a6eab62f712743dc3b3e4c93605769fd7d653beff95bf46e0.json new file mode 100644 index 00000000000..27d705733c9 --- /dev/null +++ b/apps/labrinth/.sqlx/query-04dbb91ad93de31a6eab62f712743dc3b3e4c93605769fd7d653beff95bf46e0.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT status, slug\n FROM mods\n WHERE id = $1\n FOR UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "04dbb91ad93de31a6eab62f712743dc3b3e4c93605769fd7d653beff95bf46e0" +} diff --git a/apps/labrinth/.sqlx/query-1622fdd617535795f3bd9600eba0ade0cde67da2111cf88096c875476a4d297e.json b/apps/labrinth/.sqlx/query-1622fdd617535795f3bd9600eba0ade0cde67da2111cf88096c875476a4d297e.json new file mode 100644 index 00000000000..13675d58692 --- /dev/null +++ b/apps/labrinth/.sqlx/query-1622fdd617535795f3bd9600eba0ade0cde67da2111cf88096c875476a4d297e.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM threads_issues WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "1622fdd617535795f3bd9600eba0ade0cde67da2111cf88096c875476a4d297e" +} diff --git a/apps/labrinth/.sqlx/query-32b564ef51064665b5ba3320f685cfe1c7f370a7de5d5d731201612e38fab606.json b/apps/labrinth/.sqlx/query-32b564ef51064665b5ba3320f685cfe1c7f370a7de5d5d731201612e38fab606.json new file mode 100644 index 00000000000..763fce19387 --- /dev/null +++ b/apps/labrinth/.sqlx/query-32b564ef51064665b5ba3320f685cfe1c7f370a7de5d5d731201612e38fab606.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET status = $1,\n approved = CASE WHEN $3 THEN COALESCE(approved, NOW()) ELSE approved END\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "32b564ef51064665b5ba3320f685cfe1c7f370a7de5d5d731201612e38fab606" +} diff --git a/apps/labrinth/.sqlx/query-4613cebc157a79883bf409d98223caf7d41faa3844353d5d5f3947d6c5434ebd.json b/apps/labrinth/.sqlx/query-4613cebc157a79883bf409d98223caf7d41faa3844353d5d5f3947d6c5434ebd.json new file mode 100644 index 00000000000..56e158f8f54 --- /dev/null +++ b/apps/labrinth/.sqlx/query-4613cebc157a79883bf409d98223caf7d41faa3844353d5d5f3947d6c5434ebd.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT mod_id FROM threads WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + true + ] + }, + "hash": "4613cebc157a79883bf409d98223caf7d41faa3844353d5d5f3947d6c5434ebd" +} diff --git a/apps/labrinth/.sqlx/query-ff7ff5b202609f1e68dc4a18eed1183b1077b15577b52083c2cf0b2cc0818a29.json b/apps/labrinth/.sqlx/query-48ec43b768e372807e3873bdc0d2466a348cbed0cfc2447524c04e0f64f06029.json similarity index 66% rename from apps/labrinth/.sqlx/query-ff7ff5b202609f1e68dc4a18eed1183b1077b15577b52083c2cf0b2cc0818a29.json rename to apps/labrinth/.sqlx/query-48ec43b768e372807e3873bdc0d2466a348cbed0cfc2447524c04e0f64f06029.json index 18441a79506..a5ce31f7227 100644 --- a/apps/labrinth/.sqlx/query-ff7ff5b202609f1e68dc4a18eed1183b1077b15577b52083c2cf0b2cc0818a29.json +++ b/apps/labrinth/.sqlx/query-48ec43b768e372807e3873bdc0d2466a348cbed0cfc2447524c04e0f64f06029.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT DISTINCT mod_id, mg.image_url, mg.raw_image_url, mg.featured, mg.name, mg.description, mg.created, mg.ordering\n FROM mods_gallery mg\n INNER JOIN mods m ON mg.mod_id = m.id\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n ", + "query": "\n SELECT DISTINCT mod_id, mg.id gallery_id, mg.image_url, mg.raw_image_url, mg.featured, mg.name, mg.description, mg.created, mg.ordering\n FROM mods_gallery mg\n INNER JOIN mods m ON mg.mod_id = m.id\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n ", "describe": { "columns": [ { @@ -10,36 +10,41 @@ }, { "ordinal": 1, + "name": "gallery_id", + "type_info": "Int4" + }, + { + "ordinal": 2, "name": "image_url", "type_info": "Varchar" }, { - "ordinal": 2, + "ordinal": 3, "name": "raw_image_url", "type_info": "Text" }, { - "ordinal": 3, + "ordinal": 4, "name": "featured", "type_info": "Bool" }, { - "ordinal": 4, + "ordinal": 5, "name": "name", "type_info": "Varchar" }, { - "ordinal": 5, + "ordinal": 6, "name": "description", "type_info": "Varchar" }, { - "ordinal": 6, + "ordinal": 7, "name": "created", "type_info": "Timestamptz" }, { - "ordinal": 7, + "ordinal": 8, "name": "ordering", "type_info": "Int8" } @@ -54,6 +59,7 @@ false, false, false, + false, true, true, true, @@ -61,5 +67,5 @@ false ] }, - "hash": "ff7ff5b202609f1e68dc4a18eed1183b1077b15577b52083c2cf0b2cc0818a29" + "hash": "48ec43b768e372807e3873bdc0d2466a348cbed0cfc2447524c04e0f64f06029" } diff --git a/apps/labrinth/.sqlx/query-4b70a15870e361f09e4c45fcebbb7abe942cdbf08743b35d02e2b922862f7c6a.json b/apps/labrinth/.sqlx/query-4b70a15870e361f09e4c45fcebbb7abe942cdbf08743b35d02e2b922862f7c6a.json new file mode 100644 index 00000000000..c9ed0370678 --- /dev/null +++ b/apps/labrinth/.sqlx/query-4b70a15870e361f09e4c45fcebbb7abe942cdbf08743b35d02e2b922862f7c6a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id\n FROM mods\n WHERE id = $1\n FOR UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4b70a15870e361f09e4c45fcebbb7abe942cdbf08743b35d02e2b922862f7c6a" +} diff --git a/apps/labrinth/.sqlx/query-4ba07b347fdc231d59e66677f6bce7eab8a066d0d90f93e19abf62cad6b22eeb.json b/apps/labrinth/.sqlx/query-4ba07b347fdc231d59e66677f6bce7eab8a066d0d90f93e19abf62cad6b22eeb.json new file mode 100644 index 00000000000..b66117e82bc --- /dev/null +++ b/apps/labrinth/.sqlx/query-4ba07b347fdc231d59e66677f6bce7eab8a066d0d90f93e19abf62cad6b22eeb.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT thread.mod_id\n FROM threads_issues issue\n INNER JOIN threads thread ON thread.id = issue.thread_id\n WHERE issue.id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + true + ] + }, + "hash": "4ba07b347fdc231d59e66677f6bce7eab8a066d0d90f93e19abf62cad6b22eeb" +} diff --git a/apps/labrinth/.sqlx/query-4bc29c1316daef64e05fd10b9c34dfcad8f45097d93f41597cb9bf906171cf51.json b/apps/labrinth/.sqlx/query-4bc29c1316daef64e05fd10b9c34dfcad8f45097d93f41597cb9bf906171cf51.json new file mode 100644 index 00000000000..765e97bbd2a --- /dev/null +++ b/apps/labrinth/.sqlx/query-4bc29c1316daef64e05fd10b9c34dfcad8f45097d93f41597cb9bf906171cf51.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tSELECT EXISTS (\n\t\t\t\tSELECT 1\n\t\t\t\tFROM threads_messages message\n\t\t\t\tWHERE message.thread_id = $1\n\t\t\t\t\tAND message.created > $3\n\t\t\t\t\tAND message.body->>'type' = 'text'\n\t\t\t\t\tAND COALESCE(message.body->>'private', 'false') = 'false'\n\t\t\t\t\tAND LENGTH(TRIM(message.body->>'body')) > 0\n\t\t\t\t\tAND message.author_id IN (\n\t\t\t\t\t\tSELECT member.user_id\n\t\t\t\t\t\tFROM mods project\n\t\t\t\t\t\tLEFT JOIN organizations organization\n\t\t\t\t\t\t\tON organization.id = project.organization_id\n\t\t\t\t\t\tINNER JOIN team_members member\n\t\t\t\t\t\t\tON member.team_id = project.team_id\n\t\t\t\t\t\t\t\tOR member.team_id = organization.team_id\n\t\t\t\t\t\tWHERE project.id = $2 AND member.accepted\n\t\t\t\t\t)\n\t\t\t) AS \"has_reply!\"\n\t\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "has_reply!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Timestamptz" + ] + }, + "nullable": [ + null + ] + }, + "hash": "4bc29c1316daef64e05fd10b9c34dfcad8f45097d93f41597cb9bf906171cf51" +} diff --git a/apps/labrinth/.sqlx/query-4d054c6ee9be77d93f638c7882531473c354ea829a522d64370ad2580f27cf6b.json b/apps/labrinth/.sqlx/query-4d054c6ee9be77d93f638c7882531473c354ea829a522d64370ad2580f27cf6b.json new file mode 100644 index 00000000000..76053489ea9 --- /dev/null +++ b/apps/labrinth/.sqlx/query-4d054c6ee9be77d93f638c7882531473c354ea829a522d64370ad2580f27cf6b.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\tSELECT thread.mod_id, issue.thread_id, issue.created_at,\n\t\t\tfacet.what AS \"what: sqlx::types::Json\"\n\t\tFROM threads_issue_facets facet\n\t\tINNER JOIN threads_issues issue ON issue.id = facet.issue_id\n\t\tINNER JOIN threads thread ON thread.id = issue.thread_id\n\t\tWHERE facet.id = $1\n\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "thread_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "what: sqlx::types::Json", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + true, + false, + false, + false + ] + }, + "hash": "4d054c6ee9be77d93f638c7882531473c354ea829a522d64370ad2580f27cf6b" +} diff --git a/apps/labrinth/.sqlx/query-6a5340b4dd676537d71ec52cbc74e9f1b258aa9c878e22c35114a06140a1aa3d.json b/apps/labrinth/.sqlx/query-6a5340b4dd676537d71ec52cbc74e9f1b258aa9c878e22c35114a06140a1aa3d.json new file mode 100644 index 00000000000..a510cd7a235 --- /dev/null +++ b/apps/labrinth/.sqlx/query-6a5340b4dd676537d71ec52cbc74e9f1b258aa9c878e22c35114a06140a1aa3d.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT team_id, user_id, role\n FROM team_members\n WHERE\n team_id = $1\n OR team_id = (\n SELECT team_id\n FROM organizations\n WHERE id = $2\n )\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "team_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "role", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "6a5340b4dd676537d71ec52cbc74e9f1b258aa9c878e22c35114a06140a1aa3d" +} diff --git a/apps/labrinth/.sqlx/query-6fbfb47078c1eda4819df932f76be69a561a4f267537ac69248f603e7e20a2f0.json b/apps/labrinth/.sqlx/query-6fbfb47078c1eda4819df932f76be69a561a4f267537ac69248f603e7e20a2f0.json new file mode 100644 index 00000000000..c048c122703 --- /dev/null +++ b/apps/labrinth/.sqlx/query-6fbfb47078c1eda4819df932f76be69a561a4f267537ac69248f603e7e20a2f0.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM threads_issue_facets WHERE id = ANY($1))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + null + ] + }, + "hash": "6fbfb47078c1eda4819df932f76be69a561a4f267537ac69248f603e7e20a2f0" +} diff --git a/apps/labrinth/.sqlx/query-7a019ce642b8073d5834e3a94822b04390a76bac652c64e225565e2a4923e728.json b/apps/labrinth/.sqlx/query-7a019ce642b8073d5834e3a94822b04390a76bac652c64e225565e2a4923e728.json new file mode 100644 index 00000000000..2300e39b279 --- /dev/null +++ b/apps/labrinth/.sqlx/query-7a019ce642b8073d5834e3a94822b04390a76bac652c64e225565e2a4923e728.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE threads_issue_facets\n SET verdict = $2, user_addressed = $3\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "7a019ce642b8073d5834e3a94822b04390a76bac652c64e225565e2a4923e728" +} diff --git a/apps/labrinth/.sqlx/query-7bd11bb105e2f3072160b6ce7349a71fc88f9e779f8d84be372430269c2b06c3.json b/apps/labrinth/.sqlx/query-7bd11bb105e2f3072160b6ce7349a71fc88f9e779f8d84be372430269c2b06c3.json new file mode 100644 index 00000000000..974ddb9f301 --- /dev/null +++ b/apps/labrinth/.sqlx/query-7bd11bb105e2f3072160b6ce7349a71fc88f9e779f8d84be372430269c2b06c3.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO threads_issue_facets (\n id,\n issue_id,\n what,\n verdict\n )\n SELECT facet_id, $1, what, verdict\n FROM UNNEST(\n $2::int8[],\n $3::jsonb[],\n $4::varchar[]\n ) AS facet(facet_id, what, verdict)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8Array", + "JsonbArray", + "VarcharArray" + ] + }, + "nullable": [] + }, + "hash": "7bd11bb105e2f3072160b6ce7349a71fc88f9e779f8d84be372430269c2b06c3" +} diff --git a/apps/labrinth/.sqlx/query-9be4613d7af7c3d061bbcc8a5784f6850842a93071bf9ed776dbef216e3c3812.json b/apps/labrinth/.sqlx/query-9be4613d7af7c3d061bbcc8a5784f6850842a93071bf9ed776dbef216e3c3812.json new file mode 100644 index 00000000000..967e6f9c24b --- /dev/null +++ b/apps/labrinth/.sqlx/query-9be4613d7af7c3d061bbcc8a5784f6850842a93071bf9ed776dbef216e3c3812.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM threads_issue_facets WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "9be4613d7af7c3d061bbcc8a5784f6850842a93071bf9ed776dbef216e3c3812" +} diff --git a/apps/labrinth/.sqlx/query-a06e1d9f6f95e4c4c2b98310ebddcc9d963cc033582bf2e945e8bf3a301b4247.json b/apps/labrinth/.sqlx/query-a06e1d9f6f95e4c4c2b98310ebddcc9d963cc033582bf2e945e8bf3a301b4247.json new file mode 100644 index 00000000000..909e6ad42df --- /dev/null +++ b/apps/labrinth/.sqlx/query-a06e1d9f6f95e4c4c2b98310ebddcc9d963cc033582bf2e945e8bf3a301b4247.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT pg_advisory_xact_lock($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "pg_advisory_xact_lock", + "type_info": "Void" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "a06e1d9f6f95e4c4c2b98310ebddcc9d963cc033582bf2e945e8bf3a301b4247" +} diff --git a/apps/labrinth/.sqlx/query-a11edc6b4f33ca20fa8d03d6a104d290979589d5404e10b0f0fa4c6a60c4fba5.json b/apps/labrinth/.sqlx/query-a11edc6b4f33ca20fa8d03d6a104d290979589d5404e10b0f0fa4c6a60c4fba5.json new file mode 100644 index 00000000000..5c4589e1f2d --- /dev/null +++ b/apps/labrinth/.sqlx/query-a11edc6b4f33ca20fa8d03d6a104d290979589d5404e10b0f0fa4c6a60c4fba5.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n issue.id,\n issue.thread_id,\n issue.created_by,\n issue.why,\n facet.user_addressed,\n facet.moderator_verified,\n issue.created_at,\n facet.id AS \"facet_id!\",\n facet.what AS \"what: Json\",\n facet.verdict\n FROM threads_issues issue\n INNER JOIN threads_issue_facets facet\n ON facet.issue_id = issue.id\n WHERE issue.thread_id = ANY($1)\n ORDER BY issue.created_at, issue.id, facet.id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "thread_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "created_by", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "why", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "user_addressed", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "moderator_verified", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "facet_id!", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "what: Json", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "verdict", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "a11edc6b4f33ca20fa8d03d6a104d290979589d5404e10b0f0fa4c6a60c4fba5" +} diff --git a/apps/labrinth/.sqlx/query-af507761229768301fe0f1678c8a0af688631d1ea349314e62e4556a9ad2cb1c.json b/apps/labrinth/.sqlx/query-af507761229768301fe0f1678c8a0af688631d1ea349314e62e4556a9ad2cb1c.json new file mode 100644 index 00000000000..4fba8cbe7e6 --- /dev/null +++ b/apps/labrinth/.sqlx/query-af507761229768301fe0f1678c8a0af688631d1ea349314e62e4556a9ad2cb1c.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM mods WHERE id = $1) AS \"exists!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "af507761229768301fe0f1678c8a0af688631d1ea349314e62e4556a9ad2cb1c" +} diff --git a/apps/labrinth/.sqlx/query-b377db61ca0dc8dde1eb4e55aa0ed932e80ff2262fa1eb5fdd1511aae3774808.json b/apps/labrinth/.sqlx/query-b377db61ca0dc8dde1eb4e55aa0ed932e80ff2262fa1eb5fdd1511aae3774808.json new file mode 100644 index 00000000000..c51de67d41e --- /dev/null +++ b/apps/labrinth/.sqlx/query-b377db61ca0dc8dde1eb4e55aa0ed932e80ff2262fa1eb5fdd1511aae3774808.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n issue.id,\n issue.thread_id,\n issue.created_by,\n issue.why,\n facet.user_addressed,\n facet.moderator_verified,\n issue.created_at,\n facet.id AS \"facet_id!\",\n facet.what AS \"what: Json\",\n facet.verdict\n FROM threads_issues issue\n INNER JOIN threads_issue_facets facet\n ON facet.issue_id = issue.id\n WHERE issue.thread_id = $1\n ORDER BY issue.id, facet.id\n FOR UPDATE OF issue, facet\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "thread_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "created_by", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "why", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "user_addressed", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "moderator_verified", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "facet_id!", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "what: Json", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "verdict", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "b377db61ca0dc8dde1eb4e55aa0ed932e80ff2262fa1eb5fdd1511aae3774808" +} diff --git a/apps/labrinth/.sqlx/query-b63be585098b8d80b613f05b229a3b5c06a19502763a2972af8f1018f16f61f4.json b/apps/labrinth/.sqlx/query-b63be585098b8d80b613f05b229a3b5c06a19502763a2972af8f1018f16f61f4.json new file mode 100644 index 00000000000..b360508fb07 --- /dev/null +++ b/apps/labrinth/.sqlx/query-b63be585098b8d80b613f05b229a3b5c06a19502763a2972af8f1018f16f61f4.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id\n FROM mods\n WHERE organization_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "b63be585098b8d80b613f05b229a3b5c06a19502763a2972af8f1018f16f61f4" +} diff --git a/apps/labrinth/.sqlx/query-d1aecab83032c7125381944b11ec0f064792b9314f737fe86c85f7527a83aa68.json b/apps/labrinth/.sqlx/query-d1aecab83032c7125381944b11ec0f064792b9314f737fe86c85f7527a83aa68.json new file mode 100644 index 00000000000..3f6abd4f4aa --- /dev/null +++ b/apps/labrinth/.sqlx/query-d1aecab83032c7125381944b11ec0f064792b9314f737fe86c85f7527a83aa68.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS (\n SELECT 1\n FROM mods project\n INNER JOIN team_members member\n ON member.team_id = project.team_id\n WHERE\n project.id = $1\n AND member.user_id = $2\n AND member.accepted\n UNION\n SELECT 1\n FROM mods project\n INNER JOIN organizations organization\n ON organization.id = project.organization_id\n INNER JOIN team_members member\n ON member.team_id = organization.team_id\n WHERE\n project.id = $1\n AND member.user_id = $2\n AND member.accepted\n ) AS \"exists!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "d1aecab83032c7125381944b11ec0f064792b9314f737fe86c85f7527a83aa68" +} diff --git a/apps/labrinth/.sqlx/query-dc0550f3c988f5a08edd5f3b6028c53b575aed95305d47b99c38fce2fddf2475.json b/apps/labrinth/.sqlx/query-dc0550f3c988f5a08edd5f3b6028c53b575aed95305d47b99c38fce2fddf2475.json new file mode 100644 index 00000000000..d78be20a533 --- /dev/null +++ b/apps/labrinth/.sqlx/query-dc0550f3c988f5a08edd5f3b6028c53b575aed95305d47b99c38fce2fddf2475.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM threads_issues WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "dc0550f3c988f5a08edd5f3b6028c53b575aed95305d47b99c38fce2fddf2475" +} diff --git a/apps/labrinth/.sqlx/query-e05063b59efc9892796844b8c162bbcbe050a65128b3c89346510bde79d43487.json b/apps/labrinth/.sqlx/query-e05063b59efc9892796844b8c162bbcbe050a65128b3c89346510bde79d43487.json new file mode 100644 index 00000000000..55ea586f3fa --- /dev/null +++ b/apps/labrinth/.sqlx/query-e05063b59efc9892796844b8c162bbcbe050a65128b3c89346510bde79d43487.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE threads_issue_facets\n SET\n user_addressed = COALESCE($2, user_addressed),\n moderator_verified = COALESCE($3, moderator_verified),\n verdict = CASE WHEN $3 = TRUE THEN 'resolved' ELSE verdict END\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Bool", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "e05063b59efc9892796844b8c162bbcbe050a65128b3c89346510bde79d43487" +} diff --git a/apps/labrinth/migrations/20260916120001_thread_issues.sql b/apps/labrinth/migrations/20260916120001_thread_issues.sql new file mode 100644 index 00000000000..66ce4480d89 --- /dev/null +++ b/apps/labrinth/migrations/20260916120001_thread_issues.sql @@ -0,0 +1,22 @@ +CREATE TABLE threads_issues ( + id BIGINT PRIMARY KEY, + thread_id BIGINT NOT NULL REFERENCES threads(id) ON DELETE CASCADE, + created_by BIGINT NOT NULL REFERENCES users(id) ON UPDATE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + why JSONB NOT NULL, + user_addressed BOOLEAN NOT NULL DEFAULT FALSE, + moderator_verified BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE INDEX threads_issues_thread_id + ON threads_issues(thread_id); + +CREATE TABLE threads_issue_facets ( + id BIGINT PRIMARY KEY, + issue_id BIGINT NOT NULL REFERENCES threads_issues(id) ON DELETE CASCADE, + what JSONB NOT NULL, + verdict VARCHAR(64) NOT NULL +); + +CREATE INDEX threads_issue_facets_issue_id_id + ON threads_issue_facets(issue_id, id); diff --git a/apps/labrinth/migrations/20261007120000_thread_issue_facet_flags.sql b/apps/labrinth/migrations/20261007120000_thread_issue_facet_flags.sql new file mode 100644 index 00000000000..c64c8d8398a --- /dev/null +++ b/apps/labrinth/migrations/20261007120000_thread_issue_facet_flags.sql @@ -0,0 +1,14 @@ +ALTER TABLE threads_issue_facets + ADD COLUMN user_addressed BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN moderator_verified BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE threads_issue_facets facet +SET + user_addressed = issue.user_addressed, + moderator_verified = issue.moderator_verified +FROM threads_issues issue +WHERE facet.issue_id = issue.id; + +ALTER TABLE threads_issues + DROP COLUMN user_addressed, + DROP COLUMN moderator_verified; diff --git a/apps/labrinth/src/database/models/ids.rs b/apps/labrinth/src/database/models/ids.rs index eea55f45ef2..1de4fed2678 100644 --- a/apps/labrinth/src/database/models/ids.rs +++ b/apps/labrinth/src/database/models/ids.rs @@ -5,7 +5,8 @@ use crate::models::ids::{ OAuthAccessTokenId, OAuthClientAuthorizationId, OAuthClientId, OAuthRedirectUriId, OrganizationId, PasskeyId, PatId, PayoutId, ProductId, ProductPriceId, ProjectId, ReportId, SessionId, TeamId, TeamMemberId, - ThreadId, ThreadMessageId, UserSubscriptionId, VersionId, + ThreadId, ThreadIssueFacetId, ThreadIssueId, ThreadMessageId, + UserSubscriptionId, VersionId, }; use ariadne::ids::base62_impl::to_base62; use ariadne::ids::{UserId, random_base62_rng, random_base62_rng_range}; @@ -255,6 +256,15 @@ db_id_interface!( ThreadId, generator: generate_thread_id @ "threads", ); +db_id_interface!( + ThreadIssueFacetId, + generator: generate_thread_issue_facet_id @ "threads_issue_facets", + bulk_generator: generate_many_thread_issue_facet_ids, +); +db_id_interface!( + ThreadIssueId, + generator: generate_thread_issue_id @ "threads_issues", +); db_id_interface!( ThreadMessageId, generator: generate_thread_message_id @ "threads_messages", diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index d75f649b0af..84f40118d96 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -33,6 +33,7 @@ pub mod project_item; pub mod report_item; pub mod session_item; pub mod team_item; +pub mod thread_issue_item; pub mod thread_item; pub mod user_item; pub mod user_limits; @@ -58,6 +59,7 @@ pub use project_disclosure_item::DBProjectDisclosure; pub use project_item::DBProject; pub use team_item::DBTeam; pub use team_item::DBTeamMember; +pub use thread_issue_item::DBThreadIssue; pub use thread_item::{DBThread, DBThreadMessage}; pub use user_item::DBUser; pub use version_item::DBVersion; diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs index d4e04b78268..4eac3a9070e 100644 --- a/apps/labrinth/src/database/models/project_item.rs +++ b/apps/labrinth/src/database/models/project_item.rs @@ -66,6 +66,8 @@ impl LinkUrl { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct DBGalleryItem { + #[serde(default)] + pub id: Option, pub image_url: String, pub raw_image_url: String, pub featured: bool, @@ -742,7 +744,7 @@ impl DBProject { let mods_gallery: DashMap> = sqlx::query!( " - SELECT DISTINCT mod_id, mg.image_url, mg.raw_image_url, mg.featured, mg.name, mg.description, mg.created, mg.ordering + SELECT DISTINCT mod_id, mg.id gallery_id, mg.image_url, mg.raw_image_url, mg.featured, mg.name, mg.description, mg.created, mg.ordering FROM mods_gallery mg INNER JOIN mods m ON mg.mod_id = m.id WHERE m.id = ANY($1) OR m.slug = ANY($2) @@ -754,6 +756,7 @@ impl DBProject { acc.entry(DBProjectId(m.mod_id)) .or_default() .push(DBGalleryItem { + id: Some(m.gallery_id), image_url: m.image_url, raw_image_url: m.raw_image_url, featured: m.featured.unwrap_or(false), diff --git a/apps/labrinth/src/database/models/report_item.rs b/apps/labrinth/src/database/models/report_item.rs index 6e748d89f20..2c35a97c214 100644 --- a/apps/labrinth/src/database/models/report_item.rs +++ b/apps/labrinth/src/database/models/report_item.rs @@ -2,6 +2,7 @@ use crate::database::PgTransaction; use super::ids::*; use chrono::{DateTime, Utc}; +use eyre::WrapErr; pub struct DBReport { pub id: DBReportId, @@ -126,7 +127,7 @@ impl DBReport { pub async fn remove_full( id: DBReportId, transaction: &mut PgTransaction<'_>, - ) -> Result, sqlx::error::Error> { + ) -> eyre::Result> { let result = sqlx::query!( " SELECT EXISTS(SELECT 1 FROM reports WHERE id = $1) @@ -134,7 +135,8 @@ impl DBReport { id as DBReportId ) .fetch_one(&mut *transaction) - .await?; + .await + .wrap_err("checking whether report exists")?; if !result.exists.unwrap_or(false) { return Ok(None); @@ -148,14 +150,16 @@ impl DBReport { id as DBReportId ) .fetch_optional(&mut *transaction) - .await?; + .await + .wrap_err("fetching report thread")?; if let Some(thread_id) = thread_id { crate::database::models::DBThread::remove_full( DBThreadId(thread_id.id), transaction, ) - .await?; + .await + .wrap_err("removing report thread")?; } sqlx::query!( @@ -165,7 +169,8 @@ impl DBReport { id as DBReportId, ) .execute(&mut *transaction) - .await?; + .await + .wrap_err("removing report")?; Ok(Some(())) } diff --git a/apps/labrinth/src/database/models/thread_issue_item.rs b/apps/labrinth/src/database/models/thread_issue_item.rs new file mode 100644 index 00000000000..0bcd8ae8e85 --- /dev/null +++ b/apps/labrinth/src/database/models/thread_issue_item.rs @@ -0,0 +1,338 @@ +use super::ids::{ + DBThreadId, DBThreadIssueFacetId, DBThreadIssueId, DBUserId, + generate_many_thread_issue_facet_ids, generate_thread_issue_id, +}; +use crate::database::PgTransaction; +use crate::models::thread_issues::{ + ThreadIssueContext, ThreadIssueFacet, ThreadIssueTarget, ThreadIssueVerdict, +}; +use chrono::{DateTime, Utc}; +use eyre::{Result, WrapErr, eyre}; +use serde::{Deserialize, Serialize}; +use sqlx::types::Json; + +pub struct ThreadIssueBuilder { + pub facets: Vec, + pub why: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct DBThreadIssue { + pub id: DBThreadIssueId, + pub thread_id: DBThreadId, + pub created_by: DBUserId, + pub why: serde_json::Value, + pub facets: Vec, + pub verdict: ThreadIssueVerdict, + pub created_at: DateTime, +} + +impl DBThreadIssue { + pub async fn get_many_for_threads<'a, E>( + thread_ids: &[DBThreadId], + exec: E, + ) -> Result> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + if thread_ids.is_empty() { + return Ok(Vec::new()); + } + + let thread_ids = thread_ids.iter().map(|id| id.0).collect::>(); + let rows = sqlx::query!( + r#" + SELECT + issue.id, + issue.thread_id, + issue.created_by, + issue.why, + facet.user_addressed, + facet.moderator_verified, + issue.created_at, + facet.id AS "facet_id!", + facet.what AS "what: Json", + facet.verdict + FROM threads_issues issue + INNER JOIN threads_issue_facets facet + ON facet.issue_id = issue.id + WHERE issue.thread_id = ANY($1) + ORDER BY issue.created_at, issue.id, facet.id + "#, + &thread_ids, + ) + .fetch_all(exec) + .await + .wrap_err("fetching thread issues")?; + + let mut issues = Vec::::new(); + for row in rows { + let facet_verdict = + parse_verdict(row.id, row.facet_id, &row.verdict)?; + let what = row.what.0; + + if issues.last().is_none_or(|issue| issue.id.0 != row.id) { + issues.push(Self { + id: DBThreadIssueId(row.id), + thread_id: DBThreadId(row.thread_id), + created_by: DBUserId(row.created_by), + why: row.why, + facets: Vec::new(), + verdict: ThreadIssueVerdict::Open, + created_at: row.created_at, + }); + } + + issues + .last_mut() + .expect("thread issue was inserted above") + .facets + .push(ThreadIssueFacet { + id: DBThreadIssueFacetId(row.facet_id).into(), + what, + user_addressed: row.user_addressed, + moderator_verified: row.moderator_verified, + verdict: facet_verdict, + }); + } + + for issue in &mut issues { + issue.verdict = ThreadIssueVerdict::from_facets(&issue.facets); + } + + Ok(issues) + } + + pub async fn insert_many( + thread_id: DBThreadId, + created_by: DBUserId, + issues: Vec, + transaction: &mut PgTransaction<'_>, + ) -> Result> { + if issues.iter().any(|issue| issue.facets.is_empty()) { + return Err(eyre!("thread issues must contain at least one facet")); + } + + let mut ids = Vec::with_capacity(issues.len()); + for issue in issues { + let id = generate_thread_issue_id(&mut *transaction) + .await + .wrap_err("generating thread issue ID")?; + + sqlx::query!( + r#" + INSERT INTO threads_issues ( + id, + thread_id, + created_by, + why + ) + VALUES ($1, $2, $3, $4) + "#, + id as DBThreadIssueId, + thread_id as DBThreadId, + created_by as DBUserId, + issue.why, + ) + .execute(&mut *transaction) + .await + .wrap_err("inserting thread issue")?; + + let facet_ids = generate_many_thread_issue_facet_ids( + issue.facets.len(), + &mut *transaction, + ) + .await + .wrap_err("generating thread issue facet IDs")?; + let facet_ids = facet_ids.iter().map(|id| id.0).collect::>(); + let whats = issue + .facets + .into_iter() + .map(serde_json::to_value) + .collect::, _>>() + .wrap_err("serializing thread issue facets")?; + let verdicts = facet_ids + .iter() + .map(|_| ThreadIssueVerdict::Open.as_str().to_string()) + .collect::>(); + + sqlx::query!( + r#" + INSERT INTO threads_issue_facets ( + id, + issue_id, + what, + verdict + ) + SELECT facet_id, $1, what, verdict + FROM UNNEST( + $2::int8[], + $3::jsonb[], + $4::varchar[] + ) AS facet(facet_id, what, verdict) + "#, + id as DBThreadIssueId, + &facet_ids, + &whats, + &verdicts, + ) + .execute(&mut *transaction) + .await + .wrap_err("inserting thread issue facets")?; + + ids.push(id); + } + + Ok(ids) + } + + pub async fn update_facet_flags( + id: DBThreadIssueFacetId, + user_addressed: Option, + moderator_verified: Option, + transaction: &mut PgTransaction<'_>, + ) -> Result { + let result = sqlx::query!( + r#" + UPDATE threads_issue_facets + SET + user_addressed = COALESCE($2, user_addressed), + moderator_verified = COALESCE($3, moderator_verified), + verdict = CASE WHEN $3 = TRUE THEN 'resolved' ELSE verdict END + WHERE id = $1 + "#, + id as DBThreadIssueFacetId, + user_addressed, + moderator_verified, + ) + .execute(&mut *transaction) + .await + .wrap_err("updating thread issue facet")?; + + Ok(result.rows_affected() > 0) + } + + pub async fn remove( + id: DBThreadIssueId, + transaction: &mut PgTransaction<'_>, + ) -> Result { + let result = sqlx::query!( + "DELETE FROM threads_issues WHERE id = $1", + id as DBThreadIssueId, + ) + .execute(&mut *transaction) + .await + .wrap_err("deleting thread issue")?; + + Ok(result.rows_affected() > 0) + } + + pub async fn sync_project_verdicts( + context: &ThreadIssueContext<'_>, + transaction: &mut PgTransaction<'_>, + ) -> Result> { + let rows = sqlx::query!( + r#" + SELECT + issue.id, + issue.thread_id, + issue.created_by, + issue.why, + facet.user_addressed, + facet.moderator_verified, + issue.created_at, + facet.id AS "facet_id!", + facet.what AS "what: Json", + facet.verdict + FROM threads_issues issue + INNER JOIN threads_issue_facets facet + ON facet.issue_id = issue.id + WHERE issue.thread_id = $1 + ORDER BY issue.id, facet.id + FOR UPDATE OF issue, facet + "#, + DBThreadId::from(context.project.thread_id) as DBThreadId, + ) + .fetch_all(&mut *transaction) + .await + .wrap_err( + "locking project thread issues for verdict synchronization", + )?; + + let mut issues = Vec::::new(); + for row in rows { + let stored_verdict = + parse_verdict(row.id, row.facet_id, &row.verdict)?; + let what = row.what.0; + let mut user_addressed = row.user_addressed; + let verdict = + what.verdict(context, user_addressed, row.moderator_verified); + + if verdict == ThreadIssueVerdict::Open { + user_addressed = false; + } + + if verdict != stored_verdict || user_addressed != row.user_addressed + { + sqlx::query!( + r#" + UPDATE threads_issue_facets + SET verdict = $2, user_addressed = $3 + WHERE id = $1 + "#, + row.facet_id, + verdict.as_str(), + user_addressed, + ) + .execute(&mut *transaction) + .await + .wrap_err("updating project thread issue facet verdict")?; + } + + if issues.last().is_none_or(|issue| issue.id.0 != row.id) { + issues.push(Self { + id: DBThreadIssueId(row.id), + thread_id: DBThreadId(row.thread_id), + created_by: DBUserId(row.created_by), + why: row.why, + facets: Vec::new(), + verdict: ThreadIssueVerdict::Open, + created_at: row.created_at, + }); + } + + issues + .last_mut() + .expect("thread issue was inserted above") + .facets + .push(ThreadIssueFacet { + id: DBThreadIssueFacetId(row.facet_id).into(), + what, + user_addressed, + moderator_verified: row.moderator_verified, + verdict, + }); + } + + for issue in &mut issues { + issue.verdict = ThreadIssueVerdict::from_facets(&issue.facets); + } + + Ok(issues) + } +} + +fn parse_verdict( + issue_id: i64, + facet_id: i64, + verdict: &str, +) -> Result { + match verdict { + "open" => Ok(ThreadIssueVerdict::Open), + "addressed" => Ok(ThreadIssueVerdict::Addressed), + "resolved" => Ok(ThreadIssueVerdict::Resolved), + verdict => Err(eyre!( + "thread issue `{issue_id}` facet `{facet_id}` has invalid verdict `{verdict}`" + )), + } +} diff --git a/apps/labrinth/src/database/models/thread_item.rs b/apps/labrinth/src/database/models/thread_item.rs index cb866afc53e..45f4a1e71dc 100644 --- a/apps/labrinth/src/database/models/thread_item.rs +++ b/apps/labrinth/src/database/models/thread_item.rs @@ -21,6 +21,7 @@ pub struct DBThread { pub type_: ThreadType, pub messages: Vec, + pub issues: Vec, pub members: Vec, } @@ -49,6 +50,8 @@ impl ThreadMessageBuilder { let thread_message_id = generate_thread_message_id(transaction) .await .wrap_err("generating thread message id")?; + let body = serde_json::value::to_value(self.body.clone()) + .wrap_err("serializing thread message body")?; sqlx::query!( " @@ -61,8 +64,7 @@ impl ThreadMessageBuilder { ", thread_message_id as DBThreadMessageId, self.author_id.map(|x| x.0), - serde_json::value::to_value(self.body.clone()) - .wrap_err("serializing thread message body")?, + body, self.thread_id as DBThreadId, self.hide_identity ) @@ -121,22 +123,60 @@ impl ThreadBuilder { } impl DBThread { - pub async fn get<'a, E>( - id: DBThreadId, - exec: E, - ) -> Result, sqlx::Error> + /// Whether an accepted project or organization team member has posted a + /// public, nonempty text message in the thread after the given time. + pub async fn has_project_member_reply( + thread_id: DBThreadId, + project_id: DBProjectId, + after: DateTime, + transaction: &mut PgTransaction<'_>, + ) -> Result { + sqlx::query_scalar!( + r#" + SELECT EXISTS ( + SELECT 1 + FROM threads_messages message + WHERE message.thread_id = $1 + AND message.created > $3 + AND message.body->>'type' = 'text' + AND COALESCE(message.body->>'private', 'false') = 'false' + AND LENGTH(TRIM(message.body->>'body')) > 0 + AND message.author_id IN ( + SELECT member.user_id + FROM mods project + LEFT JOIN organizations organization + ON organization.id = project.organization_id + INNER JOIN team_members member + ON member.team_id = project.team_id + OR member.team_id = organization.team_id + WHERE project.id = $2 AND member.accepted + ) + ) AS "has_reply!" + "#, + thread_id as DBThreadId, + project_id as DBProjectId, + after, + ) + .fetch_one(&mut *transaction) + .await + .wrap_err("checking whether a project team member has replied") + } + + pub async fn get<'a, E>(id: DBThreadId, exec: E) -> Result> where E: crate::database::Executor<'a, Database = sqlx::Postgres> + Copy, { - Self::get_many(&[id], exec) + Ok(Self::get_many(&[id], exec) .await - .map(|x| x.into_iter().next()) + .wrap_err("fetching thread")? + .into_iter() + .next()) } pub async fn get_many<'a, E>( thread_ids: &[DBThreadId], exec: E, - ) -> Result, sqlx::Error> + ) -> Result> where E: crate::database::Executor<'a, Database = sqlx::Postgres> + Copy, { @@ -144,7 +184,7 @@ impl DBThread { let thread_ids_parsed: Vec = thread_ids.iter().map(|x| x.0).collect(); - let threads = sqlx::query!( + let mut threads = sqlx::query!( " SELECT t.id, t.thread_type, t.mod_id, t.report_id, ARRAY_AGG(DISTINCT tm.user_id) filter (where tm.user_id is not null) members, @@ -172,10 +212,28 @@ impl DBThread { messages.sort_by_key(|a| a.created); messages }, + issues: Vec::new(), members: x.members.unwrap_or_default().into_iter().map(DBUserId).collect(), }) .try_collect::>() - .await?; + .await + .wrap_err("fetching threads")?; + + let mut issues = + super::DBThreadIssue::get_many_for_threads(thread_ids, exec) + .await + .wrap_err("fetching thread issues")? + .into_iter() + .fold( + std::collections::HashMap::>::new(), + |mut issues, issue| { + issues.entry(issue.thread_id).or_default().push(issue); + issues + }, + ); + for thread in &mut threads { + thread.issues = issues.remove(&thread.id).unwrap_or_default(); + } Ok(threads) } @@ -183,7 +241,7 @@ impl DBThread { pub async fn remove_full( id: DBThreadId, transaction: &mut PgTransaction<'_>, - ) -> Result, sqlx::error::Error> { + ) -> Result> { sqlx::query!( " DELETE FROM threads_messages @@ -192,7 +250,8 @@ impl DBThread { id as DBThreadId, ) .execute(&mut *transaction) - .await?; + .await + .wrap_err("removing thread messages")?; sqlx::query!( " DELETE FROM threads_members @@ -201,7 +260,8 @@ impl DBThread { id as DBThreadId ) .execute(&mut *transaction) - .await?; + .await + .wrap_err("removing thread members")?; sqlx::query!( " DELETE FROM threads @@ -210,7 +270,8 @@ impl DBThread { id as DBThreadId, ) .execute(&mut *transaction) - .await?; + .await + .wrap_err("removing thread")?; Ok(Some(())) } @@ -220,19 +281,21 @@ impl DBThreadMessage { pub async fn get<'a, E>( id: DBThreadMessageId, exec: E, - ) -> Result, sqlx::Error> + ) -> Result> where E: crate::database::Executor<'a, Database = sqlx::Postgres>, { - Self::get_many(&[id], exec) + Ok(Self::get_many(&[id], exec) .await - .map(|x| x.into_iter().next()) + .wrap_err("fetching thread message")? + .into_iter() + .next()) } pub async fn get_many<'a, E>( message_ids: &[DBThreadMessageId], exec: E, - ) -> Result, sqlx::Error> + ) -> Result> where E: crate::database::Executor<'a, Database = sqlx::Postgres>, { @@ -258,7 +321,8 @@ impl DBThreadMessage { hide_identity: x.hide_identity, }) .try_collect::>() - .await?; + .await + .wrap_err("fetching thread messages")?; Ok(messages) } @@ -267,7 +331,10 @@ impl DBThreadMessage { id: DBThreadMessageId, private: bool, transaction: &mut PgTransaction<'_>, - ) -> Result, sqlx::error::Error> { + ) -> Result> { + let body = serde_json::to_value(MessageBody::Deleted { private }) + .wrap_err("serializing deleted thread message body")?; + sqlx::query!( " UPDATE threads_messages @@ -275,11 +342,11 @@ impl DBThreadMessage { WHERE id = $1 ", id as DBThreadMessageId, - serde_json::to_value(MessageBody::Deleted { private }) - .unwrap_or(serde_json::json!({})) + body, ) .execute(&mut *transaction) - .await?; + .await + .wrap_err("removing thread message")?; Ok(Some(())) } diff --git a/apps/labrinth/src/models/mod.rs b/apps/labrinth/src/models/mod.rs index bef06d49d98..c38b17aeb5c 100644 --- a/apps/labrinth/src/models/mod.rs +++ b/apps/labrinth/src/models/mod.rs @@ -21,5 +21,6 @@ pub use v3::projects; pub use v3::reports; pub use v3::sessions; pub use v3::teams; +pub use v3::thread_issues; pub use v3::threads; pub use v3::users; diff --git a/apps/labrinth/src/models/v2/threads.rs b/apps/labrinth/src/models/v2/threads.rs index 14153d6024c..efd87f6cf2c 100644 --- a/apps/labrinth/src/models/v2/threads.rs +++ b/apps/labrinth/src/models/v2/threads.rs @@ -92,6 +92,10 @@ impl From for LegacyMessageBody { crate::models::v3::threads::MessageBody::StatusChange { new_status, old_status, + } + | crate::models::v3::threads::MessageBody::AutoApproval { + new_status, + old_status, } => LegacyMessageBody::StatusChange { new_status, old_status, diff --git a/apps/labrinth/src/models/v3/ids.rs b/apps/labrinth/src/models/v3/ids.rs index 292c253b7fb..9c1a60d6491 100644 --- a/apps/labrinth/src/models/v3/ids.rs +++ b/apps/labrinth/src/models/v3/ids.rs @@ -1,5 +1,19 @@ use ariadne::ids::base62_id; +#[derive( + Copy, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + Debug, + Hash, + utoipa::ToSchema, +)] +#[serde(transparent)] +pub struct GalleryImageId(pub u32); + base62_id!(AttributionGroupId); base62_id!(ChargeId); base62_id!(CampaignDonationId); @@ -22,6 +36,8 @@ base62_id!(SessionId); base62_id!(TeamId); base62_id!(TeamMemberId); base62_id!(ThreadId); +base62_id!(ThreadIssueFacetId); +base62_id!(ThreadIssueId); base62_id!(ThreadMessageId); base62_id!(UserSubscriptionId); base62_id!(VersionId); diff --git a/apps/labrinth/src/models/v3/mod.rs b/apps/labrinth/src/models/v3/mod.rs index be3ce755f75..2fe50d39257 100644 --- a/apps/labrinth/src/models/v3/mod.rs +++ b/apps/labrinth/src/models/v3/mod.rs @@ -18,6 +18,7 @@ pub mod projects; pub mod reports; pub mod sessions; pub mod teams; +pub mod thread_issues; pub mod threads; pub mod user_limits; pub mod users; diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs index 6bd4a6878ac..ffef92eedc0 100644 --- a/apps/labrinth/src/models/v3/projects.rs +++ b/apps/labrinth/src/models/v3/projects.rs @@ -6,7 +6,8 @@ use crate::database::models::project_item::{LinkUrl, ProjectQueryResult}; use crate::database::models::version_item::VersionQueryResult; use crate::models::exp; use crate::models::ids::{ - FileId, OrganizationId, ProjectId, TeamId, ThreadId, VersionId, + FileId, GalleryImageId, OrganizationId, ProjectId, TeamId, ThreadId, + VersionId, }; use crate::routes::{FileHash, HashAlgorithm}; use ariadne::ids::UserId; @@ -205,6 +206,7 @@ impl From for Project { .gallery_items .into_iter() .map(|x| GalleryItem { + id: x.id.map(|id| GalleryImageId(id as u32)), url: x.image_url, raw_url: x.raw_image_url, featured: x.featured, @@ -381,6 +383,8 @@ impl Project { } #[derive(Serialize, Deserialize, Clone, Debug, utoipa::ToSchema)] pub struct GalleryItem { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, pub url: String, pub raw_url: String, pub featured: bool, diff --git a/apps/labrinth/src/models/v3/thread_issues.rs b/apps/labrinth/src/models/v3/thread_issues.rs new file mode 100644 index 00000000000..c3ea37e4b98 --- /dev/null +++ b/apps/labrinth/src/models/v3/thread_issues.rs @@ -0,0 +1,1558 @@ +use crate::models::disclosures::ProjectDisclosure; +use crate::models::exp::minecraft::Language; +use crate::models::ids::{ + FileId, GalleryImageId, TeamId, ThreadIssueFacetId, ThreadIssueId, + VersionId, +}; +use crate::models::projects::{Dependency, FileType, Project, Version}; +use crate::models::users::User; +use crate::util::non_empty_vec::NonEmptyVec; +use ariadne::ids::UserId; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Current project state used to evaluate moderation issues. +#[derive(Debug, Clone, Copy)] +pub struct ThreadIssueContext<'a> { + pub project: &'a Project, + pub versions: &'a [Version], + pub disclosures: &'a [ProjectDisclosure], + pub team_members: &'a [ThreadIssueTeamMember], +} + +#[derive(Debug, Clone)] +pub struct ThreadIssueTeamMember { + pub team_id: TeamId, + pub user_id: UserId, + pub role: String, +} + +/// Issue that a moderator has flagged on a project in its moderation thread. +/// +/// An issue groups related actionable facets under one reason. The creator +/// addresses each facet independently, and a moderator verifies each facet. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ThreadIssue { + pub id: ThreadIssueId, + pub created_by: Option, + pub created_at: DateTime, + /// Why the issue was raised. + /// + /// This is treated as an opaque JSON blob by the backend; it is up to the + /// frontend to define a schema for it, and to render/localize it properly. + pub why: serde_json::Value, + /// The immutable project parts that must be changed to resolve this issue. + pub facets: Vec, + /// Verdict derived from all facet verdicts. + pub verdict: ThreadIssueVerdict, +} + +#[derive( + Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] +pub struct ThreadIssueFacet { + pub id: ThreadIssueFacetId, + /// What part of a project this facet applies to. + pub what: ThreadIssueTarget, + /// Has the user confirmed that they've seen this facet and (attempted to) + /// resolve it? + pub user_addressed: bool, + /// Has a moderator seen the user's resolution and explicitly marked it as + /// resolved? + /// + /// If a moderator marks this facet as verified, then it will be locked to + /// [`ThreadIssueVerdict::Resolved`]. + pub moderator_verified: bool, + /// Verdict derived from the current project state and facet-level flags. + pub verdict: ThreadIssueVerdict, +} + +/// What part of a project must change for a [`ThreadIssueFacet`] to be resolved? +#[derive( + Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] +#[serde(tag = "type", content = "value", rename_all = "snake_case")] +pub enum ThreadIssueTarget { + ModifyTitle(TextTarget), + ModifySlug(TextTarget), + ModifySummary(TextTarget), + ModifyDescription(TextTarget), + ModifyLicense { + license: TextTarget, + url: TextTarget, + }, + ModifyIcon { + original_url: Option, + }, + RemoveIcon, + RemoveTags { + tags: NonEmptyVec, + }, + ModifyLinks { + links: HashMap, + }, + AddGalleryImages { + original_image_ids: Vec, + auto_resolve: bool, + }, + ModifyGalleryImage { + image_id: GalleryImageId, + original_url: String, + name: Option, + description: Option, + }, + RemoveGalleryImages { + image_ids: NonEmptyVec, + }, + AddProjectDisclosures { + disclosure_types: NonEmptyVec, + }, + RemoveProjectDisclosures { + disclosure_types: NonEmptyVec, + }, + ModifyProjectDisclosure { + disclosure_type: String, + metadata: JsonTarget, + }, + ModifyProjectDisclosureNote { + disclosure_type: String, + note: OptionalTextTarget, + }, + Version { + version_id: VersionId, + version_number: String, + target: VersionIssueTarget, + }, + ModifyTeamMemberRole { + team_id: TeamId, + user_id: UserId, + role: TextTarget, + }, + ModifyServerLanguages { + original: Vec, + suggestion: Option>, + }, + ModifyServerAddress { + platform: ServerAddressPlatform, + address: TextTarget, + }, + Acknowledge { + mode: ThreadIssueAcknowledgement, + }, + MarkAddressed, +} + +#[derive( + Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] +pub struct TextTarget { + /// Original value of this field when the [`ThreadIssueFacet`] was made. + pub original: String, + /// Moderator-proposed value for this field. + pub suggestion: Option, +} + +#[derive( + Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] +pub struct OptionalTextTarget { + pub original: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + pub suggestion: Option>, +} + +#[derive( + Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] +pub struct JsonTarget { + pub original: serde_json::Value, + pub suggestion: Option, +} + +#[derive( + Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] +#[serde(tag = "type", content = "value", rename_all = "snake_case")] +pub enum VersionIssueTarget { + Remove, + ModifyEnvironment(TextTarget), + ModifyGameVersions { + original: Vec, + suggestion: Option>, + }, + ModifyDependencies { + original: Vec, + suggestion: Option>, + }, + ModifyChangelog(TextTarget), + RemoveAdditionalFiles { + #[schema(value_type = Vec, min_items = 1)] + file_ids: NonEmptyVec, + }, + ModifyAdditionalFileType { + file_id: FileId, + filename: String, + original: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + suggestion: Option>, + }, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum ServerAddressPlatform { + MinecraftJava, + MinecraftBedrock, +} + +/// How should a user communicate that they've resolved a [`ThreadIssue`]? +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum ThreadIssueAcknowledgement { + /// They must tick a checkbox. + Checkbox, + /// They must reply to the thread with an explanation message. + Reply, +} + +/// Current state of the action required to resolve a [`ThreadIssueFacet`]. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum ThreadIssueValueState { + /// Current field value is the same as it was originally. + /// + /// It may have never been changed, or been changed multiple times, ending + /// up at the original value again. + SameAsOriginal, + /// Current field value is different to the original value. + DifferentToOriginal, + /// Current field value is equal to the moderator's proposed value. + SameAsSuggested, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum ThreadIssueVerdict { + Open, + Addressed, + Resolved, +} + +impl ThreadIssueVerdict { + pub fn as_str(self) -> &'static str { + match self { + Self::Open => "open", + Self::Addressed => "addressed", + Self::Resolved => "resolved", + } + } + + pub(crate) fn from_facets(facets: &[ThreadIssueFacet]) -> Self { + if !facets.is_empty() + && facets.iter().all(|facet| facet.verdict == Self::Resolved) + { + Self::Resolved + } else if !facets.is_empty() + && facets.iter().all(|facet| facet.verdict != Self::Open) + { + Self::Addressed + } else { + Self::Open + } + } +} + +impl ThreadIssueTarget { + pub fn verdict( + &self, + context: &ThreadIssueContext<'_>, + user_addressed: bool, + moderator_verified: bool, + ) -> ThreadIssueVerdict { + if moderator_verified { + return ThreadIssueVerdict::Resolved; + } + + match (self.value_state(context, user_addressed), user_addressed) { + (ThreadIssueValueState::SameAsSuggested, _) => { + ThreadIssueVerdict::Resolved + } + (ThreadIssueValueState::DifferentToOriginal, true) => { + ThreadIssueVerdict::Addressed + } + _ => ThreadIssueVerdict::Open, + } + } + + pub fn value_state( + &self, + context: &ThreadIssueContext<'_>, + user_addressed: bool, + ) -> ThreadIssueValueState { + let project = context.project; + match self { + Self::ModifyTitle(target) => value_state(target, &project.name), + Self::ModifySlug(target) => { + value_state(target, project.slug.as_deref().unwrap_or_default()) + } + Self::ModifySummary(target) => { + value_state(target, &project.summary) + } + Self::ModifyDescription(target) => { + value_state(target, &project.description) + } + Self::ModifyLicense { license, url } => { + let license_state = + value_state(license, project.license.id.as_str()); + let url_state = value_state( + url, + project.license.url.as_deref().unwrap_or_default(), + ); + + match (license_state, url_state) { + ( + ThreadIssueValueState::SameAsOriginal, + ThreadIssueValueState::SameAsOriginal, + ) => ThreadIssueValueState::SameAsOriginal, + ( + ThreadIssueValueState::SameAsSuggested, + ThreadIssueValueState::SameAsSuggested, + ) => ThreadIssueValueState::SameAsSuggested, + _ => ThreadIssueValueState::DifferentToOriginal, + } + } + Self::ModifyIcon { original_url } => { + if project.icon_url == *original_url { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::DifferentToOriginal + } + } + Self::RemoveIcon => { + if project.icon_url.is_none() { + ThreadIssueValueState::SameAsSuggested + } else { + ThreadIssueValueState::SameAsOriginal + } + } + Self::RemoveTags { tags } => { + let remaining = tags.iter().any(|tag| { + project.categories.contains(tag) + || project.additional_categories.contains(tag) + }); + if remaining { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::SameAsSuggested + } + } + Self::ModifyLinks { links } => { + let mut all_original = true; + let mut all_suggested = true; + for (platform, target) in links { + let current = project + .link_urls + .get(platform) + .map(|link| link.url.as_str()) + .unwrap_or_default(); + let state = value_state(target, current); + all_original &= + state == ThreadIssueValueState::SameAsOriginal; + all_suggested &= + state == ThreadIssueValueState::SameAsSuggested; + } + + if all_original { + ThreadIssueValueState::SameAsOriginal + } else if all_suggested { + ThreadIssueValueState::SameAsSuggested + } else { + ThreadIssueValueState::DifferentToOriginal + } + } + Self::AddGalleryImages { + original_image_ids, + auto_resolve, + } => { + if project.gallery.iter().any(|image| { + image.id.is_some_and(|id| !original_image_ids.contains(&id)) + }) { + if *auto_resolve { + ThreadIssueValueState::SameAsSuggested + } else { + ThreadIssueValueState::DifferentToOriginal + } + } else { + ThreadIssueValueState::SameAsOriginal + } + } + Self::ModifyGalleryImage { + image_id, + name, + description, + .. + } => { + let Some(image) = project + .gallery + .iter() + .find(|image| image.id.as_ref() == Some(image_id)) + else { + return ThreadIssueValueState::DifferentToOriginal; + }; + + let name_state = name.as_ref().map(|target| { + optional_text_value_state(target, &image.name) + }); + let description_state = description.as_ref().map(|target| { + optional_text_value_state(target, &image.description) + }); + + if name_state + .into_iter() + .chain(description_state) + .all(|state| state == ThreadIssueValueState::SameAsOriginal) + { + ThreadIssueValueState::SameAsOriginal + } else if name_state.into_iter().chain(description_state).all( + |state| state == ThreadIssueValueState::SameAsSuggested, + ) { + ThreadIssueValueState::SameAsSuggested + } else { + ThreadIssueValueState::DifferentToOriginal + } + } + Self::RemoveGalleryImages { image_ids } => { + let remaining = image_ids.iter().any(|id| { + project + .gallery + .iter() + .any(|image| image.id.as_ref() == Some(id)) + }); + + if remaining { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::SameAsSuggested + } + } + Self::AddProjectDisclosures { disclosure_types } => { + let present = disclosure_types + .iter() + .filter(|target| { + context.disclosures.iter().any(|disclosure| { + disclosure.to_parts().is_ok_and( + |(current_type, _)| current_type == *target, + ) + }) + }) + .count(); + + if present == disclosure_types.len() { + ThreadIssueValueState::SameAsSuggested + } else if present == 0 { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::DifferentToOriginal + } + } + Self::RemoveProjectDisclosures { disclosure_types } => { + let remaining = disclosure_types.iter().any(|target| { + context.disclosures.iter().any(|disclosure| { + disclosure.to_parts().is_ok_and(|(current_type, _)| { + current_type == target + }) + }) + }); + + if remaining { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::SameAsSuggested + } + } + Self::ModifyProjectDisclosure { + disclosure_type, + metadata, + } => { + let current = + context.disclosures.iter().find_map(|disclosure| { + let (current_type, mut current) = + disclosure.to_parts().ok()?; + if current_type != disclosure_type { + return None; + } + if let Some(current) = current.as_object_mut() { + current.remove("note"); + } + Some(current) + }); + + let mut original = metadata.original.clone(); + if let Some(original) = original.as_object_mut() { + original.remove("note"); + } + let suggestion = + metadata.suggestion.as_ref().map(|suggestion| { + let mut suggestion = suggestion.clone(); + if let Some(suggestion) = suggestion.as_object_mut() { + suggestion.remove("note"); + } + suggestion + }); + + match current { + Some(current) if suggestion.as_ref() == Some(¤t) => { + ThreadIssueValueState::SameAsSuggested + } + Some(current) if current == original => { + ThreadIssueValueState::SameAsOriginal + } + _ => ThreadIssueValueState::DifferentToOriginal, + } + } + Self::ModifyProjectDisclosureNote { + disclosure_type, + note, + } => { + let current = + context.disclosures.iter().find_map(|disclosure| { + let (current_type, metadata) = + disclosure.to_parts().ok()?; + if current_type != disclosure_type { + return None; + } + Some( + metadata + .get("note") + .and_then(serde_json::Value::as_str) + .map(String::from), + ) + }); + + current.map_or( + ThreadIssueValueState::DifferentToOriginal, + |current| optional_text_value_state(note, ¤t), + ) + } + Self::Version { + version_id, target, .. + } => { + let version = context + .versions + .iter() + .find(|version| version.id == *version_id); + + if matches!(target, VersionIssueTarget::Remove) { + if version.is_none() { + ThreadIssueValueState::SameAsSuggested + } else { + ThreadIssueValueState::SameAsOriginal + } + } else { + let Some(version) = version else { + return if matches!( + target, + VersionIssueTarget::RemoveAdditionalFiles { .. } + ) { + ThreadIssueValueState::SameAsSuggested + } else { + ThreadIssueValueState::DifferentToOriginal + }; + }; + + match target { + VersionIssueTarget::Remove => unreachable!(), + VersionIssueTarget::ModifyEnvironment(target) => { + value_state( + target, + version + .fields + .get("environment") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(), + ) + } + VersionIssueTarget::ModifyGameVersions { + original, + suggestion, + } => { + let current = version + .fields + .get("game_versions") + .and_then(serde_json::Value::as_array) + .map(|versions| { + versions + .iter() + .filter_map(|version| { + version.as_str().map(String::from) + }) + .collect::>() + }) + .unwrap_or_default(); + if suggestion.as_ref().is_some_and(|suggestion| { + suggestion.len() == current.len() + && suggestion.iter().all(|version| { + current.contains(version) + }) + }) { + ThreadIssueValueState::SameAsSuggested + } else if original.len() == current.len() + && original + .iter() + .all(|version| current.contains(version)) + { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::DifferentToOriginal + } + } + VersionIssueTarget::ModifyDependencies { + original, + suggestion, + } => { + if suggestion.as_ref().is_some_and(|suggestion| { + suggestion.len() == version.dependencies.len() + && suggestion.iter().all(|dependency| { + version + .dependencies + .contains(dependency) + }) + }) { + ThreadIssueValueState::SameAsSuggested + } else if original.len() + == version.dependencies.len() + && original.iter().all(|dependency| { + version.dependencies.contains(dependency) + }) + { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::DifferentToOriginal + } + } + VersionIssueTarget::ModifyChangelog(target) => { + value_state( + target, + version + .changelog + .as_deref() + .unwrap_or_default(), + ) + } + VersionIssueTarget::RemoveAdditionalFiles { + file_ids, + } => { + let remaining = file_ids.iter().any(|id| { + version + .files + .iter() + .any(|file| file.id.as_ref() == Some(id)) + }); + if remaining { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::SameAsSuggested + } + } + VersionIssueTarget::ModifyAdditionalFileType { + file_id, + original, + suggestion, + .. + } => { + let Some(file) = version + .files + .iter() + .find(|file| file.id.as_ref() == Some(file_id)) + else { + return ThreadIssueValueState::DifferentToOriginal; + }; + if suggestion.as_ref() == Some(&file.file_type) { + ThreadIssueValueState::SameAsSuggested + } else if file.file_type == *original { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::DifferentToOriginal + } + } + } + } + } + Self::ModifyTeamMemberRole { + team_id, + user_id, + role, + } => context + .team_members + .iter() + .find(|member| { + member.team_id == *team_id && member.user_id == *user_id + }) + .map_or(ThreadIssueValueState::DifferentToOriginal, |member| { + value_state(role, &member.role) + }), + Self::ModifyServerLanguages { + original, + suggestion, + } => { + let Some(server) = project.components.minecraft_server.as_ref() + else { + return ThreadIssueValueState::DifferentToOriginal; + }; + if suggestion.as_ref() == Some(&server.languages) { + ThreadIssueValueState::SameAsSuggested + } else if server.languages == *original { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::DifferentToOriginal + } + } + Self::ModifyServerAddress { platform, address } => { + let current = match platform { + ServerAddressPlatform::MinecraftJava => project + .components + .minecraft_java_server + .as_ref() + .map(|server| server.address.as_str()), + ServerAddressPlatform::MinecraftBedrock => project + .components + .minecraft_bedrock_server + .as_ref() + .map(|server| server.address.as_str()), + }; + current.map_or( + ThreadIssueValueState::DifferentToOriginal, + |current| value_state(address, current), + ) + } + Self::MarkAddressed => { + if user_addressed { + ThreadIssueValueState::DifferentToOriginal + } else { + ThreadIssueValueState::SameAsOriginal + } + } + Self::Acknowledge { mode } => match (mode, user_addressed) { + (_, false) => ThreadIssueValueState::SameAsOriginal, + (ThreadIssueAcknowledgement::Checkbox, true) => { + ThreadIssueValueState::SameAsSuggested + } + (ThreadIssueAcknowledgement::Reply, true) => { + ThreadIssueValueState::DifferentToOriginal + } + }, + } + } +} + +fn optional_text_value_state( + target: &OptionalTextTarget, + current: &Option, +) -> ThreadIssueValueState { + if target.suggestion.as_ref() == Some(current) { + ThreadIssueValueState::SameAsSuggested + } else if *current == target.original { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::DifferentToOriginal + } +} + +fn value_state(target: &TextTarget, current: &str) -> ThreadIssueValueState { + if target.suggestion.as_deref() == Some(current) { + ThreadIssueValueState::SameAsSuggested + } else if current == target.original { + ThreadIssueValueState::SameAsOriginal + } else { + ThreadIssueValueState::DifferentToOriginal + } +} + +impl ThreadIssue { + pub fn from( + data: crate::database::models::DBThreadIssue, + user: &User, + ) -> Self { + Self { + id: data.id.into(), + created_by: user.role.is_mod().then(|| data.created_by.into()), + why: data.why, + facets: data.facets, + verdict: data.verdict, + created_at: data.created_at, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::exp; + use crate::models::ids::{ProjectId, TeamId, ThreadId}; + use crate::models::projects::{ + GalleryItem, License, Link, MonetizationStatus, ProjectStatus, + SideTypesMigrationReviewStatus, + }; + + fn project() -> Project { + let now = Utc::now(); + Project { + id: ProjectId(1), + slug: Some("example-project".to_string()), + project_types: vec!["mod".to_string()], + games: vec!["minecraft".to_string()], + team_id: TeamId(2), + organization: None, + name: "Example Project".to_string(), + summary: "Example summary".to_string(), + description: "Example description".to_string(), + published: now, + updated: now, + approved: None, + queued: None, + status: ProjectStatus::Draft, + requested_status: None, + moderator_message: None, + license: License { + id: "MIT".to_string(), + name: "MIT License".to_string(), + url: Some("https://example.com/license".to_string()), + }, + downloads: 0, + followers: 0, + categories: vec!["technology".to_string()], + additional_categories: vec!["utility".to_string()], + loaders: vec!["fabric".to_string()], + versions: Vec::new(), + icon_url: Some("https://example.com/icon.png".to_string()), + raw_icon_url: None, + link_urls: HashMap::from([ + ( + "source".to_string(), + Link { + platform: "source".to_string(), + donation: false, + url: "https://example.com/source".to_string(), + }, + ), + ( + "issues".to_string(), + Link { + platform: "issues".to_string(), + donation: false, + url: "https://example.com/issues".to_string(), + }, + ), + ]), + gallery: vec![ + GalleryItem { + id: Some(GalleryImageId(1)), + url: "https://example.com/gallery/1".to_string(), + raw_url: "https://example.com/gallery/1/raw".to_string(), + featured: false, + name: None, + description: None, + created: now, + ordering: 0, + }, + GalleryItem { + id: Some(GalleryImageId(2)), + url: "https://example.com/gallery/2".to_string(), + raw_url: "https://example.com/gallery/2/raw".to_string(), + featured: false, + name: None, + description: None, + created: now, + ordering: 1, + }, + ], + color: None, + thread_id: ThreadId(3), + monetization_status: MonetizationStatus::Monetized, + side_types_migration_review_status: + SideTypesMigrationReviewStatus::Reviewed, + components: exp::ProjectQuery::default(), + fields: HashMap::new(), + } + } + + fn context(project: &Project) -> ThreadIssueContext<'_> { + ThreadIssueContext { + project, + versions: &[], + disclosures: &[], + team_members: &[], + } + } + + fn text_target(original: &str, suggestion: Option<&str>) -> TextTarget { + TextTarget { + original: original.to_string(), + suggestion: suggestion.map(str::to_string), + } + } + + fn optional_text_target( + original: Option<&str>, + suggestion: Option>, + ) -> OptionalTextTarget { + OptionalTextTarget { + original: original.map(str::to_string), + suggestion: suggestion + .map(|suggestion| suggestion.map(str::to_string)), + } + } + + fn non_empty(values: Vec) -> NonEmptyVec { + values.try_into().unwrap() + } + + #[test] + fn nullable_suggestions_round_trip() { + let target = OptionalTextTarget { + original: Some("Original".to_string()), + suggestion: Some(None), + }; + let serialized = serde_json::to_value(&target).unwrap(); + + assert_eq!( + serialized.get("suggestion"), + Some(&serde_json::Value::Null) + ); + assert_eq!( + serde_json::from_value::(serialized).unwrap(), + target + ); + } + + #[test] + fn text_target_value_states() { + let mut project = project(); + let target = ThreadIssueTarget::ModifyTitle(text_target( + "Example Project", + Some("Suggested Project"), + )); + + assert_eq!( + target.value_state(&context(&project), false), + ThreadIssueValueState::SameAsOriginal + ); + + project.name = "Another Project".to_string(); + assert_eq!( + target.value_state(&context(&project), false), + ThreadIssueValueState::DifferentToOriginal + ); + + project.name = "Suggested Project".to_string(); + assert_eq!( + target.value_state(&context(&project), false), + ThreadIssueValueState::SameAsSuggested + ); + } + + #[test] + fn absent_slug_uses_an_empty_current_value() { + let mut project = project(); + project.slug = None; + let target = ThreadIssueTarget::ModifySlug(text_target("", None)); + + assert_eq!( + target.value_state(&context(&project), false), + ThreadIssueValueState::SameAsOriginal + ); + } + + #[test] + fn license_value_states() { + let mut project = project(); + let target = ThreadIssueTarget::ModifyLicense { + license: text_target("MIT", Some("Apache-2.0")), + url: text_target( + "https://example.com/license", + Some("https://example.com/new-license"), + ), + }; + + assert_eq!( + target.value_state(&context(&project), false), + ThreadIssueValueState::SameAsOriginal + ); + + project.license.id = "Apache-2.0".to_string(); + assert_eq!( + target.value_state(&context(&project), false), + ThreadIssueValueState::DifferentToOriginal + ); + + project.license.url = + Some("https://example.com/new-license".to_string()); + assert_eq!( + target.value_state(&context(&project), false), + ThreadIssueValueState::SameAsSuggested + ); + + project.license.url = + Some("https://example.com/other-license".to_string()); + assert_eq!( + target.value_state(&context(&project), false), + ThreadIssueValueState::DifferentToOriginal + ); + } + + #[test] + fn link_value_states() { + let mut project = project(); + let target = ThreadIssueTarget::ModifyLinks { + links: HashMap::from([ + ( + "source".to_string(), + text_target( + "https://example.com/source", + Some("https://example.com/new-source"), + ), + ), + ( + "issues".to_string(), + text_target( + "https://example.com/issues", + Some("https://example.com/new-issues"), + ), + ), + ]), + }; + + assert_eq!( + target.value_state(&context(&project), false), + ThreadIssueValueState::SameAsOriginal + ); + + project.link_urls.get_mut("source").unwrap().url = + "https://example.com/new-source".to_string(); + assert_eq!( + target.value_state(&context(&project), false), + ThreadIssueValueState::DifferentToOriginal + ); + + project.link_urls.get_mut("issues").unwrap().url = + "https://example.com/new-issues".to_string(); + assert_eq!( + target.value_state(&context(&project), false), + ThreadIssueValueState::SameAsSuggested + ); + + project.link_urls.get_mut("issues").unwrap().url = + "https://example.com/other-issues".to_string(); + assert_eq!( + target.value_state(&context(&project), false), + ThreadIssueValueState::DifferentToOriginal + ); + } + + #[test] + fn icon_and_tag_value_states() { + let mut project = project(); + let icon = ThreadIssueTarget::ModifyIcon { + original_url: project.icon_url.clone(), + }; + let tags = ThreadIssueTarget::RemoveTags { + tags: non_empty(vec![ + "technology".to_string(), + "utility".to_string(), + ]), + }; + + assert_eq!( + icon.value_state(&context(&project), false), + ThreadIssueValueState::SameAsOriginal + ); + assert_eq!( + tags.value_state(&context(&project), false), + ThreadIssueValueState::SameAsOriginal + ); + + project.icon_url = None; + project.additional_categories.clear(); + assert_eq!( + icon.value_state(&context(&project), false), + ThreadIssueValueState::DifferentToOriginal + ); + assert_eq!( + icon.verdict(&context(&project), false, false), + ThreadIssueVerdict::Open + ); + assert_eq!( + tags.value_state(&context(&project), false), + ThreadIssueValueState::SameAsOriginal + ); + assert_eq!( + tags.verdict(&context(&project), true, false), + ThreadIssueVerdict::Open + ); + + project.categories.clear(); + assert_eq!( + tags.value_state(&context(&project), false), + ThreadIssueValueState::SameAsSuggested + ); + assert_eq!( + tags.verdict(&context(&project), false, false), + ThreadIssueVerdict::Resolved + ); + } + + #[test] + fn icon_must_be_removed() { + let mut project = project(); + let target = ThreadIssueTarget::RemoveIcon; + + assert_eq!( + target.verdict(&context(&project), true, false), + ThreadIssueVerdict::Open + ); + + project.icon_url = Some("https://example.com/new-icon.png".to_string()); + assert_eq!( + target.verdict(&context(&project), true, false), + ThreadIssueVerdict::Open + ); + + project.icon_url = None; + assert_eq!( + target.verdict(&context(&project), false, false), + ThreadIssueVerdict::Resolved + ); + } + + #[test] + fn gallery_addition_and_metadata_value_states() { + let mut project = project(); + let add = ThreadIssueTarget::AddGalleryImages { + auto_resolve: false, + original_image_ids: project + .gallery + .iter() + .filter_map(|image| image.id) + .collect(), + }; + let metadata = ThreadIssueTarget::ModifyGalleryImage { + image_id: GalleryImageId(1), + original_url: "https://example.com/gallery/1".to_string(), + name: Some(optional_text_target(None, Some(Some("Screenshot")))), + description: Some(optional_text_target( + None, + Some(Some("Description")), + )), + }; + + assert_eq!( + add.value_state(&context(&project), false), + ThreadIssueValueState::SameAsOriginal + ); + assert_eq!( + metadata.value_state(&context(&project), false), + ThreadIssueValueState::SameAsOriginal + ); + + let mut added = project.gallery[0].clone(); + added.id = Some(GalleryImageId(3)); + project.gallery.push(added); + project.gallery[0].name = Some("Screenshot".to_string()); + project.gallery[0].description = Some("Description".to_string()); + + assert_eq!( + add.value_state(&context(&project), false), + ThreadIssueValueState::DifferentToOriginal + ); + assert_eq!( + metadata.value_state(&context(&project), false), + ThreadIssueValueState::SameAsSuggested + ); + + project.gallery[0].description = Some("Unrelated text".to_string()); + assert_eq!( + metadata.value_state(&context(&project), false), + ThreadIssueValueState::DifferentToOriginal + ); + } + + #[test] + fn gallery_addition_is_independent_of_removal() { + let mut project = project(); + let add = ThreadIssueTarget::AddGalleryImages { + auto_resolve: false, + original_image_ids: project + .gallery + .iter() + .filter_map(|image| image.id) + .collect(), + }; + let remove = ThreadIssueTarget::RemoveGalleryImages { + image_ids: non_empty(vec![GalleryImageId(1)]), + }; + let mut added = project.gallery.remove(0); + + assert_eq!( + add.verdict(&context(&project), true, false), + ThreadIssueVerdict::Open + ); + assert_eq!( + remove.verdict(&context(&project), false, false), + ThreadIssueVerdict::Resolved + ); + + added.id = Some(GalleryImageId(3)); + project.gallery.push(added); + assert_eq!( + add.verdict(&context(&project), false, false), + ThreadIssueVerdict::Open + ); + assert_eq!( + add.verdict(&context(&project), true, false), + ThreadIssueVerdict::Addressed + ); + assert_eq!( + remove.verdict(&context(&project), false, false), + ThreadIssueVerdict::Resolved + ); + + project.gallery.pop(); + assert_eq!( + add.verdict(&context(&project), true, false), + ThreadIssueVerdict::Open + ); + } + + #[test] + fn all_gallery_targets_must_be_removed() { + let mut project = project(); + let target = ThreadIssueTarget::RemoveGalleryImages { + image_ids: non_empty(vec![GalleryImageId(1), GalleryImageId(2)]), + }; + + assert_eq!( + target.value_state(&context(&project), false), + ThreadIssueValueState::SameAsOriginal + ); + + project.gallery.remove(0); + assert_eq!( + target.value_state(&context(&project), false), + ThreadIssueValueState::SameAsOriginal + ); + assert_eq!( + target.verdict(&context(&project), true, false), + ThreadIssueVerdict::Open + ); + + project.gallery.clear(); + assert_eq!( + target.value_state(&context(&project), false), + ThreadIssueValueState::SameAsSuggested + ); + assert_eq!( + target.verdict(&context(&project), false, false), + ThreadIssueVerdict::Resolved + ); + } + + #[test] + fn removing_a_version_resolves_additional_file_removal() { + let project = project(); + let target = ThreadIssueTarget::Version { + version_id: VersionId(1), + version_number: "1.0.0".to_string(), + target: VersionIssueTarget::RemoveAdditionalFiles { + file_ids: non_empty(vec![FileId(1), FileId(2)]), + }, + }; + + assert_eq!( + target.verdict(&context(&project), false, false), + ThreadIssueVerdict::Resolved + ); + } + + #[test] + fn disclosure_value_states() { + let project = project(); + let mut disclosures = vec![ + ProjectDisclosure::Advertisements { + note: Some("Original note".to_string()), + }, + ProjectDisclosure::Archived { note: None }, + ]; + let remove = ThreadIssueTarget::RemoveProjectDisclosures { + disclosure_types: non_empty(vec![ + "advertisements".to_string(), + "archived".to_string(), + ]), + }; + let note = ThreadIssueTarget::ModifyProjectDisclosureNote { + disclosure_type: "advertisements".to_string(), + note: optional_text_target( + Some("Original note"), + Some(Some("Suggested note")), + ), + }; + let value_state = + |target: &ThreadIssueTarget, disclosures: &[ProjectDisclosure]| { + target.value_state( + &ThreadIssueContext { + project: &project, + versions: &[], + disclosures, + team_members: &[], + }, + false, + ) + }; + + assert_eq!( + value_state(&remove, &disclosures), + ThreadIssueValueState::SameAsOriginal + ); + assert_eq!( + value_state(¬e, &disclosures), + ThreadIssueValueState::SameAsOriginal + ); + + disclosures[0] = ProjectDisclosure::Advertisements { + note: Some("Suggested note".to_string()), + }; + assert_eq!( + value_state(¬e, &disclosures), + ThreadIssueValueState::SameAsSuggested + ); + + disclosures.pop(); + assert_eq!( + value_state(&remove, &disclosures), + ThreadIssueValueState::SameAsOriginal + ); + assert_eq!( + remove.verdict( + &ThreadIssueContext { + disclosures: &disclosures, + ..context(&project) + }, + true, + false, + ), + ThreadIssueVerdict::Open + ); + + disclosures.clear(); + assert_eq!( + value_state(&remove, &disclosures), + ThreadIssueValueState::SameAsSuggested + ); + assert_eq!( + remove.verdict(&context(&project), false, false), + ThreadIssueVerdict::Resolved + ); + } + + #[test] + fn team_member_role_value_states() { + let project = project(); + let mut members = vec![ThreadIssueTeamMember { + team_id: TeamId(2), + user_id: UserId(4), + role: "Developer".to_string(), + }]; + let target = ThreadIssueTarget::ModifyTeamMemberRole { + team_id: TeamId(2), + user_id: UserId(4), + role: text_target("Developer", Some("Artist")), + }; + let value_state = |members: &[ThreadIssueTeamMember]| { + target.value_state( + &ThreadIssueContext { + project: &project, + versions: &[], + disclosures: &[], + team_members: members, + }, + false, + ) + }; + + assert_eq!( + value_state(&members), + ThreadIssueValueState::SameAsOriginal + ); + members[0].role = "Artist".to_string(); + assert_eq!( + value_state(&members), + ThreadIssueValueState::SameAsSuggested + ); + members.clear(); + assert_eq!( + value_state(&members), + ThreadIssueValueState::DifferentToOriginal + ); + } + + #[test] + fn acknowledgement_value_states() { + let checkbox = ThreadIssueTarget::Acknowledge { + mode: ThreadIssueAcknowledgement::Checkbox, + }; + let reply = ThreadIssueTarget::Acknowledge { + mode: ThreadIssueAcknowledgement::Reply, + }; + + assert_eq!( + checkbox.value_state(&context(&project()), false), + ThreadIssueValueState::SameAsOriginal + ); + assert_eq!( + checkbox.value_state(&context(&project()), true), + ThreadIssueValueState::SameAsSuggested + ); + assert_eq!( + reply.value_state(&context(&project()), true), + ThreadIssueValueState::DifferentToOriginal + ); + } + + #[test] + fn verdict_tracks_resolution_actions() { + let mut project = project(); + let target = ThreadIssueTarget::ModifyTitle(text_target( + "Example Project", + Some("Suggested Project"), + )); + + assert_eq!( + target.verdict(&context(&project), true, false), + ThreadIssueVerdict::Open + ); + + project.name = "Another Project".to_string(); + assert_eq!( + target.verdict(&context(&project), false, false), + ThreadIssueVerdict::Open + ); + assert_eq!( + target.verdict(&context(&project), true, false), + ThreadIssueVerdict::Addressed + ); + + project.name = "Suggested Project".to_string(); + assert_eq!( + target.verdict(&context(&project), false, false), + ThreadIssueVerdict::Resolved + ); + + project.name = "Example Project".to_string(); + assert_eq!( + target.verdict(&context(&project), false, true), + ThreadIssueVerdict::Resolved + ); + } + + #[test] + fn issue_verdict_aggregates_facet_verdicts() { + let project = project(); + let facets = |flags: &[(bool, bool)]| { + flags + .iter() + .enumerate() + .map(|(id, &(user_addressed, moderator_verified))| { + let what = ThreadIssueTarget::Acknowledge { + mode: ThreadIssueAcknowledgement::Reply, + }; + let verdict = what.verdict( + &context(&project), + user_addressed, + moderator_verified, + ); + ThreadIssueFacet { + id: ThreadIssueFacetId(id as u64), + what, + user_addressed, + moderator_verified, + verdict, + } + }) + .collect::>() + }; + + assert_eq!( + ThreadIssueVerdict::from_facets(&[]), + ThreadIssueVerdict::Open + ); + assert_eq!( + ThreadIssueVerdict::from_facets(&facets(&[ + (false, true), + (true, true), + ])), + ThreadIssueVerdict::Resolved + ); + assert_eq!( + ThreadIssueVerdict::from_facets(&facets(&[ + (true, false), + (false, true), + ])), + ThreadIssueVerdict::Addressed + ); + assert_eq!( + ThreadIssueVerdict::from_facets(&facets(&[ + (false, false), + (false, true), + ])), + ThreadIssueVerdict::Open + ); + } + + #[test] + fn gallery_and_acknowledgement_verdicts() { + let mut project = project(); + let gallery = ThreadIssueTarget::RemoveGalleryImages { + image_ids: non_empty(vec![GalleryImageId(1)]), + }; + let checkbox = ThreadIssueTarget::Acknowledge { + mode: ThreadIssueAcknowledgement::Checkbox, + }; + let reply = ThreadIssueTarget::Acknowledge { + mode: ThreadIssueAcknowledgement::Reply, + }; + + assert_eq!( + gallery.verdict(&context(&project), true, false), + ThreadIssueVerdict::Open + ); + project.gallery.remove(0); + assert_eq!( + gallery.verdict(&context(&project), false, false), + ThreadIssueVerdict::Resolved + ); + assert_eq!( + checkbox.verdict(&context(&project), true, false), + ThreadIssueVerdict::Resolved + ); + assert_eq!( + reply.verdict(&context(&project), true, false), + ThreadIssueVerdict::Addressed + ); + } +} diff --git a/apps/labrinth/src/models/v3/threads.rs b/apps/labrinth/src/models/v3/threads.rs index cb38ba11fa2..5bef100510c 100644 --- a/apps/labrinth/src/models/v3/threads.rs +++ b/apps/labrinth/src/models/v3/threads.rs @@ -3,6 +3,7 @@ use crate::models::ids::{ ImageId, ProjectId, ReportId, ThreadId, ThreadMessageId, }; use crate::models::projects::ProjectStatus; +use crate::models::thread_issues::ThreadIssue; use crate::models::users::User; use ariadne::ids::UserId; use chrono::{DateTime, Utc}; @@ -16,6 +17,7 @@ pub struct Thread { pub project_id: Option, pub report_id: Option, pub messages: Vec, + pub issues: Vec, pub members: Vec, } @@ -46,6 +48,10 @@ pub enum MessageBody { new_status: ProjectStatus, old_status: ProjectStatus, }, + AutoApproval { + new_status: ProjectStatus, + old_status: ProjectStatus, + }, TechReview { verdict: DelphiVerdict, }, @@ -69,6 +75,7 @@ impl MessageBody { | Self::TechReviewExited | Self::TechReviewExitFileDeleted => true, Self::StatusChange { .. } + | Self::AutoApproval { .. } | Self::ThreadClosure | Self::ThreadReopen => false, } @@ -130,6 +137,11 @@ impl Thread { .filter(|x| user.role.is_mod() || !x.body.is_private()) .map(|x| ThreadMessage::from(x, user)) .collect(), + issues: data + .issues + .into_iter() + .map(|issue| ThreadIssue::from(issue, user)) + .collect(), members: users, } } diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review.rs b/apps/labrinth/src/routes/internal/moderation/tech_review.rs index 1394c5feaed..e5445d80fc7 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review.rs @@ -936,6 +936,7 @@ pub async fn search_projects( .messages .iter() .filter_map(|message| message.author_id) + .chain(thread.issues.iter().map(|issue| issue.created_by)) }) .collect::>(); let thread_authors = @@ -1045,6 +1046,7 @@ pub async fn get_project_report( .messages .iter() .filter_map(|message| message.author_id) + .chain(thread.issues.iter().map(|issue| issue.created_by)) }) .collect::>(); let thread_authors = @@ -1122,6 +1124,20 @@ pub async fn submit_report( .await .wrap_internal_err("failed to begin transaction")?; + sqlx::query!( + r#" + SELECT id + FROM mods + WHERE id = $1 + FOR UPDATE + "#, + project_id as DBProjectId, + ) + .fetch_optional(&mut txn) + .await + .wrap_internal_err("locking project for technical review submission")? + .wrap_not_found_err("project not found")?; + let pending_issue_details = sqlx::query!( r#" SELECT @@ -1259,22 +1275,22 @@ pub async fn submit_report( .wrap_internal_err("failed to add tech review message")?; } - txn.commit() - .await - .wrap_internal_err("failed to commit transaction")?; - if verdict == DelphiVerdict::Unsafe { - crate::routes::v3::projects::clear_project_cache_and_queue_search( - &redis, - &search_state, + crate::routes::v3::projects::mutation::finalize_mutation( project_id, - None, - None, + txn, + &redis, + Some(&user), ) - .await - .wrap_api_err( - "executing `projects::clear_project_cache_and_queue_search`", - )?; + .await?; + search_state + .queue + .push_project_change(project_id.into()) + .await; + } else { + txn.commit() + .await + .wrap_internal_err("failed to commit transaction")?; } Ok(()) diff --git a/apps/labrinth/src/routes/v2/project_creation.rs b/apps/labrinth/src/routes/v2/project_creation.rs index 1c685a0779f..b02428a0957 100644 --- a/apps/labrinth/src/routes/v2/project_creation.rs +++ b/apps/labrinth/src/routes/v2/project_creation.rs @@ -112,7 +112,7 @@ struct ProjectCreateData { #[validate(nested)] pub donation_urls: Option>, - /// An optional boolean. If true, the project will be created as a draft. + /// Must be true. Projects must be submitted for review after creation. pub is_draft: Option, /// The license id that the project follows @@ -174,6 +174,13 @@ pub async fn project_create( let redirect_client = redirect_client.clone(); let redirect_redis = redirect_redis.clone(); async move { + if !legacy_create.is_draft.unwrap_or(false) { + return Err(CreateError::InvalidInput( + "projects must be created as drafts and submitted for review separately" + .to_string(), + )); + } + // Side types will be applied to each version let client_side = legacy_create.client_side; let server_side = legacy_create.server_side; diff --git a/apps/labrinth/src/routes/v3/disclosures.rs b/apps/labrinth/src/routes/v3/disclosures.rs index 027fa04ef6c..8d295e2e0ad 100644 --- a/apps/labrinth/src/routes/v3/disclosures.rs +++ b/apps/labrinth/src/routes/v3/disclosures.rs @@ -7,14 +7,14 @@ use xredis::RedisPool; use crate::auth::checks::{is_team_member_project, is_visible_project}; use crate::auth::get_user_from_headers; -use crate::database::{DBProject, models as db_models}; +use crate::database::models as db_models; use crate::database::{PgPool, ReadOnlyPgPool}; use crate::models::disclosures::{ DisclosureLockStatus, ProjectDisclosure, ProjectDisclosureData, ProjectDisclosureType, }; use crate::models::pats::Scopes; -use crate::models::projects::ProjectStatus; + use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::routes::ApiError; @@ -306,24 +306,13 @@ pub async fn modify_project_disclosures( .wrap_internal_err("failed to remove project disclosure")?; } - if project.inner.status == ProjectStatus::Processing { - super::projects::validate::ensure_project_is_valid_for_review( - project.inner.id, - &pool, - &mut transaction, - &redis, - ) - .await?; - } - - transaction - .commit() - .await - .wrap_internal_err("failed to commit project disclosure changes")?; - - DBProject::clear_cache(project.inner.id, project.inner.slug, None, &redis) - .await - .wrap_internal_err("clearing cached data from Redis")?; + super::projects::mutation::finalize_mutation( + project.inner.id, + transaction, + &redis, + Some(&user), + ) + .await?; search_state .queue diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index 1d5235fc99f..6e90c004510 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -195,6 +195,9 @@ pub fn config(cfg: &mut web::ServiceConfig) { threads::thread_get_route, threads::threads_get_route, threads::thread_send_message_route, + threads::thread_issues_create, + threads::thread_issue_facet_edit, + threads::thread_issue_delete, threads::message_delete_route, users::all_projects, users::admin_user_email, diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index b0f0f1a8b93..81c0f0df4ce 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -863,10 +863,13 @@ pub async fn organization_delete( .await .wrap_internal_err("fetching query results from database")?; - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; + super::projects::mutation::finalize_mutations( + &organization_project_ids, + transaction, + &redis, + Some(&user), + ) + .await?; database::models::DBOrganization::clear_cache( organization.id, @@ -883,17 +886,10 @@ pub async fn organization_delete( } for project_id in organization_project_ids { - super::projects::clear_project_cache_and_queue_search( - &redis, - &search_state, - project_id, - None, - None, - ) - .await - .wrap_api_err( - "executing `projects::clear_project_cache_and_queue_search`", - )?; + search_state + .queue + .push_project_change(project_id.into()) + .await; } if !organization_project_teams.is_empty() { @@ -1060,10 +1056,13 @@ pub async fn organization_projects_add( "querying database for `organization_projects_add`", )?; - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; + super::projects::mutation::finalize_mutation( + project_item.inner.id, + transaction, + &redis, + Some(¤t_user), + ) + .await?; database::models::DBUser::clear_project_cache( &[current_user.id.into()], @@ -1077,17 +1076,10 @@ pub async fn organization_projects_add( ) .await .wrap_internal_err("clearing cached data from Redis")?; - super::projects::clear_project_cache_and_queue_search( - &redis, - &search_state, - project_item.inner.id, - project_item.inner.slug, - None, - ) - .await - .wrap_api_err( - "executing `projects::clear_project_cache_and_queue_search`", - )?; + search_state + .queue + .push_project_change(project_item.inner.id.into()) + .await; } else { return Err(ApiError::Auth(eyre::eyre!( "You do not have permission to add projects to this organization!", @@ -1283,10 +1275,13 @@ pub async fn organization_projects_remove( "querying database for `organization_projects_remove`", )?; - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; + super::projects::mutation::finalize_mutation( + project_item.inner.id, + transaction, + &redis, + Some(¤t_user), + ) + .await?; database::models::DBUser::clear_project_cache( &[current_user.id.into()], &redis, @@ -1299,17 +1294,10 @@ pub async fn organization_projects_remove( ) .await .wrap_internal_err("clearing cached data from Redis")?; - super::projects::clear_project_cache_and_queue_search( - &redis, - &search_state, - project_item.inner.id, - project_item.inner.slug, - None, - ) - .await - .wrap_api_err( - "executing `projects::clear_project_cache_and_queue_search`", - )?; + search_state + .queue + .push_project_change(project_item.inner.id.into()) + .await; } else { return Err(ApiError::Auth(eyre::eyre!( "You do not have permission to add projects to this organization!", diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index 4df69e1caa9..51925dd113d 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -22,6 +22,7 @@ use crate::models::projects::{ }; use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; use crate::models::threads::ThreadType; +use crate::models::users::User; use crate::models::v3::user_limits::UserLimits; use crate::queue::session::AuthQueue; use crate::search::SearchState; @@ -352,18 +353,26 @@ pub async fn project_create_internal( let project_id: ProjectId = models::generate_project_id(&mut transaction).await?.into(); + let current_user = get_user_from_headers( + &req, + &**client, + &redis, + &session_queue, + Scopes::PROJECT_CREATE, + ) + .await? + .1; let result = project_create_inner( - req, &mut payload, &mut transaction, &**file_host, &mut uploaded_files, &client, &redis, - &session_queue, &kafka_client, project_id, + ¤t_user, ) .await; @@ -376,19 +385,14 @@ pub async fn project_create_internal( return Err(e.into()); } } else { - transaction.commit().await?; - let slug = - models::DBProject::get_id(project_id.into(), &**client, &redis) - .await? - .and_then(|project| project.inner.slug); - super::projects::clear_project_cache_and_queue_search( - &redis, - &search_state, + super::projects::mutation::finalize_mutation( project_id.into(), - slug, - None, + transaction, + &redis, + Some(¤t_user), ) .await?; + search_state.queue.push_project_change(project_id).await; } result @@ -422,18 +426,26 @@ pub async fn project_create_with_id( let mut uploaded_files = Vec::new(); let (project_id,) = path.into_inner(); + let current_user = get_user_from_headers( + &req, + &**client, + &redis, + &session_queue, + Scopes::PROJECT_CREATE, + ) + .await? + .1; let result = project_create_inner( - req, &mut payload, &mut transaction, &**file_host, &mut uploaded_files, &client, &redis, - &session_queue, &kafka_client, project_id, + ¤t_user, ) .await; @@ -446,19 +458,14 @@ pub async fn project_create_with_id( return Err(e.into()); } } else { - transaction.commit().await?; - let slug = - models::DBProject::get_id(project_id.into(), &**client, &redis) - .await? - .and_then(|project| project.inner.slug); - super::projects::clear_project_cache_and_queue_search( - &redis, - &search_state, + super::projects::mutation::finalize_mutation( project_id.into(), - slug, - None, + transaction, + &redis, + Some(¤t_user), ) .await?; + search_state.queue.push_project_change(project_id).await; } result @@ -498,36 +505,25 @@ Project Creation Steps: #[allow(clippy::too_many_arguments)] async fn project_create_inner( - req: HttpRequest, payload: &mut Multipart, transaction: &mut PgTransaction<'_>, file_host: &dyn FileHost, uploaded_files: &mut Vec, pool: &PgPool, redis: &RedisPool, - session_queue: &AuthQueue, kafka_client: &KafkaClientState, project_id: ProjectId, + current_user: &User, ) -> Result { - // The currently logged in user - let (_, current_user) = get_user_from_headers( - &req, - pool, - redis, - session_queue, - Scopes::PROJECT_CREATE, - ) - .await?; - - require_verified_email(¤t_user)?; + require_verified_email(current_user)?; - let limits = UserLimits::get_for_projects(¤t_user, pool).await?; + let limits = UserLimits::get_for_projects(current_user, pool).await?; if limits.current >= limits.max { return Err(CreateError::LimitReached); } let daily_limits = - UserLimits::get_for_projects_per_day(¤t_user, Utc::now(), pool) + UserLimits::get_for_projects_per_day(current_user, Utc::now(), pool) .await?; if daily_limits.current >= daily_limits.max { return Err(CreateError::DailyProjectLimitReached); @@ -588,7 +584,7 @@ async fn project_create_inner( if versions_to_create > 0 { let project_version_limits = UserLimits::get_for_versions_per_project( - ¤t_user, + current_user, project_id.into(), pool, ) @@ -602,7 +598,7 @@ async fn project_create_inner( } let daily_version_limits = UserLimits::get_for_versions_per_day( - ¤t_user, + current_user, Utc::now(), pool, ) @@ -762,6 +758,7 @@ async fn project_create_inner( publicity: FileHostPublicity::Public, }); gallery_urls.push(crate::models::projects::GalleryItem { + id: None, url: upload_result.url, raw_url: upload_result.raw_url, featured: item.featured, @@ -984,6 +981,7 @@ async fn project_create_inner( gallery_items: gallery_urls .iter() .map(|x| models::project_item::DBGalleryItem { + id: None, image_url: x.url.clone(), raw_image_url: x.raw_url.clone(), featured: x.featured, diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index fa47a6f9a5a..fc01f0ff4ff 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -371,20 +371,14 @@ pub async fn create( .await .wrap_internal_err("failed to insert thread")?; - // and commit! - - txn.commit() - .await - .wrap_internal_err("failed to commit transaction")?; - - super::super::projects::clear_project_cache_and_queue_search( - &redis, - &search_state, + super::super::projects::mutation::finalize_mutation( project_id.into(), - Some(slug), - None, + txn, + &redis, + Some(&user), ) .await?; + search_state.queue.push_project_change(project_id).await; Ok(web::Json(project_id)) } diff --git a/apps/labrinth/src/routes/v3/projects/mod.rs b/apps/labrinth/src/routes/v3/projects/mod.rs index 912d98434c1..a4c7f33023b 100644 --- a/apps/labrinth/src/routes/v3/projects/mod.rs +++ b/apps/labrinth/src/routes/v3/projects/mod.rs @@ -53,6 +53,7 @@ use serde::{Deserialize, Serialize}; use validator::Validate; use xredis::RedisPool; +pub mod mutation; pub mod validate; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { @@ -557,27 +558,43 @@ pub async fn project_edit_internal( new_project.status, Some(ProjectStatus::Draft | ProjectStatus::Rejected) ); - let validate_for_review = !user.role.is_mod() - && (submit_for_review - || (project_item.inner.status == ProjectStatus::Processing - && !leave_review)); - if submit_for_review { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::Auth(eyre!( - "you do not have permission to submit this project for review" - ))); - } - if project_item.inner.status.is_approved() { - return Err(ApiError::Auth(eyre!( - "you do not have permission to submit this project for review" - ))); - } + + if submit_for_review && !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::Auth(eyre!( + "you do not have permission to submit this project for review" + ))); } let mut transaction = pool .begin() .await .wrap_internal_err("starting database transaction")?; + let locked_project = sqlx::query!( + r#" + SELECT status, slug + FROM mods + WHERE id = $1 + FOR UPDATE + "#, + id as db_ids::DBProjectId, + ) + .fetch_one(&mut transaction) + .await + .wrap_internal_err("locking project for editing")?; + project_item.inner.status = + ProjectStatus::from_string(&locked_project.status); + project_item.inner.slug = locked_project.slug; + + if submit_for_review && project_item.inner.status.is_approved() { + return Err(ApiError::Auth(eyre!( + "you do not have permission to submit this project for review" + ))); + } + + let validate_for_review = !user.role.is_mod() + && (submit_for_review + || (project_item.inner.status == ProjectStatus::Processing + && !leave_review)); if let Some(name) = &new_project.name { if !perms.contains(ProjectPermissions::EDIT_DETAILS) { @@ -1439,7 +1456,7 @@ pub async fn project_edit_internal( .wrap_api_err("deleting unused images")?; if submit_for_review { - submit_project_for_review( + reindex_versions |= submit_project_for_review( &reloaded_project, &user, team_member.as_ref().is_none_or(|member| !member.accepted), @@ -1450,10 +1467,15 @@ pub async fn project_edit_internal( .await?; } - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; + mutation::finalize_project_edit( + id, + project_item.inner.status, + project_item.inner.slug.clone(), + transaction, + &redis, + Some(&user), + ) + .await?; let mut project_refs = vec![ProjectId::from(project_item.inner.id).to_string()]; @@ -1466,41 +1488,20 @@ pub async fn project_edit_internal( crate::routes::clear_project_redirect_cache(&project_refs, &redis).await?; if became_unsearchable { - db_models::DBProject::clear_cache( - project_item.inner.id, - project_item.inner.slug, - None, - &redis, - ) - .await - .wrap_internal_err("clearing cached data from Redis")?; search_state .queue .push_project_removal(project_item.inner.id.into()) .await; } else if reindex_versions || became_searchable { - db_models::DBProject::clear_cache( - project_item.inner.id, - project_item.inner.slug, - None, - &redis, - ) - .await - .wrap_internal_err("clearing cached data from Redis")?; search_state .queue .push_project_with_all_versions_change(project_item.inner.id.into()) .await; } else { - clear_project_cache_and_queue_search( - &redis, - &search_state, - project_item.inner.id, - project_item.inner.slug, - None, - ) - .await - .wrap_api_err("executing `clear_project_cache_and_queue_search`")?; + search_state + .queue + .push_project_change(project_item.inner.id.into()) + .await; } Ok(HttpResponse::NoContent().body("")) @@ -1513,7 +1514,7 @@ async fn submit_project_for_review( sync_archival_disclosure: bool, transaction: &mut PgTransaction<'_>, redis: &RedisPool, -) -> Result<(), ApiError> { +) -> Result { let archival_disclosure = db_models::DBProjectDisclosure::get_many_for_project( project.inner.id, @@ -1527,6 +1528,32 @@ async fn submit_project_for_review( matches!(disclosure.disclosure, ProjectDisclosure::Archived { .. }) }); + if sync_archival_disclosure + && archival_disclosure + .is_some_and(|disclosure| disclosure.lock_status.allows_removal()) + { + db_models::DBProjectDisclosure::remove( + project.inner.id, + ProjectDisclosureType::Archived, + user.id.into(), + false, + &mut *transaction, + ) + .await + .wrap_internal_err("failed to remove archival disclosure")?; + } + + let state = + mutation::sync_project_state(project.inner.id, transaction, redis) + .await?; + let auto_approval_status = if user.role.is_mod() { + None + } else { + state.auto_approval_status() + }; + let auto_approved = auto_approval_status.is_some(); + let new_status = auto_approval_status.unwrap_or(ProjectStatus::Processing); + sqlx::query!( " UPDATE mods @@ -1539,7 +1566,7 @@ async fn submit_project_for_review( .await .wrap_internal_err("querying database for `project_edit_internal`")?; - if notify_team_members { + if notify_team_members || auto_approved { let notified_members = sqlx::query!( " SELECT tm.user_id id @@ -1558,7 +1585,7 @@ async fn submit_project_for_review( body: NotificationBody::StatusChange { project_id: project.inner.id.into(), old_status: project.inner.status, - new_status: ProjectStatus::Processing, + new_status, }, } .insert_many(notified_members.clone(), &mut *transaction, redis) @@ -1568,10 +1595,16 @@ async fn submit_project_for_review( )?; NotificationBuilder { - body: NotificationBody::ProjectStatusNeutral { - project_id: project.inner.id.into(), - old_status: project.inner.status, - new_status: ProjectStatus::Processing, + body: if auto_approved { + NotificationBody::ProjectStatusApproved { + project_id: project.inner.id.into(), + } + } else { + NotificationBody::ProjectStatusNeutral { + project_id: project.inner.id.into(), + old_status: project.inner.status, + new_status, + } }, } .insert_many(notified_members, &mut *transaction, redis) @@ -1582,10 +1615,21 @@ async fn submit_project_for_review( } ThreadMessageBuilder { - author_id: Some(user.id.into()), - body: MessageBody::StatusChange { - new_status: ProjectStatus::Processing, - old_status: project.inner.status, + author_id: if auto_approved { + None + } else { + Some(user.id.into()) + }, + body: if auto_approved { + MessageBody::AutoApproval { + old_status: project.inner.status, + new_status, + } + } else { + MessageBody::StatusChange { + new_status, + old_status: project.inner.status, + } }, thread_id: project.thread_id, hide_identity: false, @@ -1599,32 +1643,19 @@ async fn submit_project_for_review( sqlx::query!( " UPDATE mods - SET status = $1 + SET status = $1, + approved = CASE WHEN $3 THEN COALESCE(approved, NOW()) ELSE approved END WHERE (id = $2) ", - ProjectStatus::Processing.as_str(), + new_status.as_str(), project.inner.id as db_ids::DBProjectId, + auto_approved, ) .execute(&mut *transaction) .await .wrap_internal_err("querying database for `project_edit_internal`")?; - if sync_archival_disclosure - && archival_disclosure - .is_some_and(|disclosure| disclosure.lock_status.allows_removal()) - { - db_models::DBProjectDisclosure::remove( - project.inner.id, - ProjectDisclosureType::Archived, - user.id.into(), - false, - &mut *transaction, - ) - .await - .wrap_internal_err("failed to remove archival disclosure")?; - } - - Ok(()) + Ok(auto_approved) } pub async fn edit_project_categories( @@ -2246,7 +2277,9 @@ pub async fn projects_edit( } } - if project.inner.status == ProjectStatus::Processing { + if !user.role.is_mod() + && project.inner.status == ProjectStatus::Processing + { validate::ensure_project_is_valid_for_review( project.inner.id, &pool, @@ -2263,30 +2296,29 @@ pub async fn projects_edit( )); } - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; + let changed_project_ids = changed_projects + .iter() + .map(|(project_id, _, _)| *project_id) + .collect::>(); + mutation::finalize_mutations( + &changed_project_ids, + transaction, + &redis, + Some(&user), + ) + .await?; - for (project_id, slug, reindex_versions) in changed_projects { + for (project_id, _, reindex_versions) in changed_projects { if reindex_versions { - db_models::DBProject::clear_cache(project_id, slug, None, &redis) - .await - .wrap_internal_err("clearing cached data from Redis")?; search_state .queue .push_project_with_all_versions_change(project_id.into()) .await; } else { - clear_project_cache_and_queue_search( - &redis, - &search_state, - project_id, - slug, - None, - ) - .await - .wrap_api_err("executing `clear_project_cache_and_queue_search`")?; + search_state + .queue + .push_project_change(project_id.into()) + .await; } } @@ -2483,15 +2515,6 @@ pub async fn project_icon_edit_internal( } } - delete_old_images( - project_item.inner.icon_url, - project_item.inner.raw_icon_url, - FileHostPublicity::Public, - &**file_host, - ) - .await - .wrap_api_err("deleting old images")?; - let bytes = read_limited_from_payload( &mut payload, 524288, @@ -2532,19 +2555,35 @@ pub async fn project_icon_edit_internal( .await .wrap_internal_err("querying database for `project_icon_edit_internal`")?; - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; - clear_project_cache_and_queue_search( - &redis, - &search_state, + mutation::finalize_mutation( project_item.inner.id, - project_item.inner.slug, - None, + transaction, + &redis, + Some(&user), + ) + .await?; + + let old_icon_url = project_item + .inner + .icon_url + .filter(|url| url != &upload_result.url); + let old_raw_icon_url = project_item + .inner + .raw_icon_url + .filter(|url| url != &upload_result.raw_url); + delete_old_images( + old_icon_url, + old_raw_icon_url, + FileHostPublicity::Public, + &**file_host, ) .await - .wrap_api_err("executing `clear_project_cache_and_queue_search`")?; + .wrap_api_err("deleting old images")?; + + search_state + .queue + .push_project_change(project_item.inner.id.into()) + .await; Ok(HttpResponse::NoContent().body("")) } @@ -2647,15 +2686,6 @@ pub async fn delete_project_icon_internal( } } - delete_old_images( - project_item.inner.icon_url, - project_item.inner.raw_icon_url, - FileHostPublicity::Public, - &**file_host, - ) - .await - .wrap_api_err("deleting old images")?; - let mut transaction = pool .begin() .await @@ -2675,19 +2705,27 @@ pub async fn delete_project_icon_internal( "querying database for `delete_project_icon_internal`", )?; - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; - clear_project_cache_and_queue_search( - &redis, - &search_state, + mutation::finalize_mutation( project_item.inner.id, - project_item.inner.slug, - None, + transaction, + &redis, + Some(&user), + ) + .await?; + + delete_old_images( + project_item.inner.icon_url, + project_item.inner.raw_icon_url, + FileHostPublicity::Public, + &**file_host, ) .await - .wrap_api_err("executing `clear_project_cache_and_queue_search`")?; + .wrap_api_err("deleting old images")?; + + search_state + .queue + .push_project_change(project_item.inner.id.into()) + .await; Ok(HttpResponse::NoContent().body("")) } @@ -2883,6 +2921,7 @@ pub async fn add_gallery_item_internal( } let gallery_item = vec![db_models::project_item::DBGalleryItem { + id: None, image_url: upload_result.url.clone(), raw_image_url: upload_result.raw_url.clone(), featured: item.featured, @@ -2899,19 +2938,20 @@ pub async fn add_gallery_item_internal( .await .wrap_internal_err("inserting galleries into database")?; - let validation_error = - if project_item.inner.status == ProjectStatus::Processing { - validate::ensure_project_is_valid_for_review( - project_item.inner.id, - &pool, - &mut transaction, - &redis, - ) - .await - .err() - } else { - None - }; + let validation_error = if !user.role.is_mod() + && project_item.inner.status == ProjectStatus::Processing + { + validate::ensure_project_is_valid_for_review( + project_item.inner.id, + &pool, + &mut transaction, + &redis, + ) + .await + .err() + } else { + None + }; if let Some(error) = validation_error { delete_old_images( Some(upload_result.url), @@ -2924,19 +2964,17 @@ pub async fn add_gallery_item_internal( return Err(error); } - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; - clear_project_cache_and_queue_search( - &redis, - &search_state, + mutation::finalize_mutation( project_item.inner.id, - project_item.inner.slug, - None, + transaction, + &redis, + Some(&user), ) - .await - .wrap_api_err("executing `clear_project_cache_and_queue_search`")?; + .await?; + search_state + .queue + .push_project_change(project_item.inner.id.into()) + .await; Ok(HttpResponse::NoContent().body("")) } @@ -3161,30 +3199,17 @@ pub async fn edit_gallery_item_internal( )?; } - if project_item.inner.status == ProjectStatus::Processing { - validate::ensure_project_is_valid_for_review( - project_item.inner.id, - &pool, - &mut transaction, - &redis, - ) - .await?; - } - - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; - - clear_project_cache_and_queue_search( - &redis, - &search_state, + mutation::finalize_mutation( project_item.inner.id, - project_item.inner.slug, - None, + transaction, + &redis, + Some(&user), ) - .await - .wrap_api_err("executing `clear_project_cache_and_queue_search`")?; + .await?; + search_state + .queue + .push_project_change(project_item.inner.id.into()) + .await; Ok(HttpResponse::NoContent().body("")) } @@ -3321,20 +3346,13 @@ pub async fn delete_gallery_item_internal( "querying database for `delete_gallery_item_internal`", )?; - if project_item.inner.status == ProjectStatus::Processing { - validate::ensure_project_is_valid_for_review( - project_item.inner.id, - &pool, - &mut transaction, - &redis, - ) - .await?; - } - - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; + mutation::finalize_mutation( + project_item.inner.id, + transaction, + &redis, + Some(&user), + ) + .await?; delete_old_images( Some(item.image_url), @@ -3345,15 +3363,10 @@ pub async fn delete_gallery_item_internal( .await .wrap_api_err("deleting old images")?; - clear_project_cache_and_queue_search( - &redis, - &search_state, - project_item.inner.id, - project_item.inner.slug, - None, - ) - .await - .wrap_api_err("executing `clear_project_cache_and_queue_search`")?; + search_state + .queue + .push_project_change(project_item.inner.id.into()) + .await; Ok(HttpResponse::NoContent().body("")) } @@ -3464,6 +3477,21 @@ pub async fn project_delete_internal( .begin() .await .wrap_internal_err("failed to start transaction")?; + let locked_project = sqlx::query!( + r#" + SELECT status, slug + FROM mods + WHERE id = $1 + FOR UPDATE + "#, + project.inner.id as db_ids::DBProjectId, + ) + .fetch_one(&mut transaction) + .await + .wrap_internal_err("locking project for deletion")?; + let original_status = ProjectStatus::from_string(&locked_project.status); + let original_slug = locked_project.slug; + delphi::tech_review_queue::remove_projects( &[project.inner.id], delphi::tech_review_queue::TechReviewRemovalReason::FileDeleted, @@ -3474,12 +3502,12 @@ pub async fn project_delete_internal( // rejected & withheld projects are transferred to ghost so moderation data is preserved if matches!( - project.inner.status, + original_status, ProjectStatus::Rejected | ProjectStatus::Withheld ) { let deleted_user: db_ids::DBUserId = DELETED_USER.into(); - let deleted_slug = if let Some(slug) = &project.inner.slug { + let deleted_slug = if let Some(slug) = &original_slug { let candidate = format!( "{slug}--deleted-{}", ProjectId::from(project.inner.id) @@ -3605,10 +3633,15 @@ pub async fn project_delete_internal( .await .wrap_internal_err("failed to delete project followers")?; - transaction - .commit() - .await - .wrap_internal_err("failed to commit transaction")?; + mutation::finalize_project_edit( + project.inner.id, + original_status, + original_slug, + transaction, + &redis, + Some(&user), + ) + .await?; let mut cache_user_ids = affected_user_ids; cache_user_ids.push(deleted_user); @@ -3618,14 +3651,6 @@ pub async fn project_delete_internal( DBTeamMember::clear_cache(project.inner.team_id, &redis) .await .wrap_internal_err("clearing cached data from Redis")?; - db_models::DBProject::clear_cache( - project.inner.id, - project.inner.slug.clone(), - None, - &redis, - ) - .await - .wrap_internal_err("clearing cached data from Redis")?; search_state .queue .push_project_removal(project.inner.id.into()) diff --git a/apps/labrinth/src/routes/v3/projects/mutation.rs b/apps/labrinth/src/routes/v3/projects/mutation.rs new file mode 100644 index 00000000000..d2678fb71b9 --- /dev/null +++ b/apps/labrinth/src/routes/v3/projects/mutation.rs @@ -0,0 +1,359 @@ +use eyre::eyre; +use xredis::RedisPool; + +use crate::database::models::project_item::ProjectQueryResult; +use crate::database::models::{ + DBProjectId, DBTeamId, DBThreadIssue, DBThreadIssueFacetId, DBUserId, + DBVersionId, +}; +use crate::database::{PgTransaction, models as db_models}; +use crate::models::ids::ProjectId; +use crate::models::projects::{Project, ProjectStatus, Version}; +use crate::models::thread_issues::{ + ThreadIssueContext, ThreadIssueTarget, ThreadIssueTeamMember, + ThreadIssueValueState, ThreadIssueVerdict, +}; +use crate::models::users::User; +use crate::routes::ApiError; +use crate::routes::internal::delphi; +use crate::util::error::{ApiContext as _, Context as _}; +use crate::validate::project::{ + ProjectNagSeverity, validate_with_context as validate_project, +}; + +pub(crate) struct SyncedProjectState { + data: ProjectQueryResult, + project: Project, + versions: Vec, + disclosures: Vec, + team_members: Vec, + thread_issues: Vec, + has_required_nags: bool, +} + +impl SyncedProjectState { + pub(crate) fn auto_approval_status(&self) -> Option { + if !matches!( + self.project.status, + ProjectStatus::Rejected | ProjectStatus::Withheld + ) || self.thread_issues.is_empty() + || self + .thread_issues + .iter() + .any(|issue| issue.verdict != ThreadIssueVerdict::Resolved) + { + return None; + } + + Some( + self.project + .requested_status + .filter(|status| { + status.is_approved() && status.can_be_requested() + }) + .unwrap_or(ProjectStatus::Approved), + ) + } + + pub(crate) fn can_address_facet( + &self, + facet_id: DBThreadIssueFacetId, + ) -> Option { + let context = ThreadIssueContext { + project: &self.project, + versions: &self.versions, + disclosures: &self.disclosures, + team_members: &self.team_members, + }; + self.thread_issues + .iter() + .flat_map(|issue| &issue.facets) + .find(|facet| facet.id == facet_id.into()) + .map(|facet| { + matches!( + &facet.what, + ThreadIssueTarget::Acknowledge { .. } + | ThreadIssueTarget::MarkAddressed + ) || facet.what.value_state(&context, false) + != ThreadIssueValueState::SameAsOriginal + }) + } +} + +pub(crate) async fn sync_project_state( + project_id: DBProjectId, + transaction: &mut PgTransaction<'_>, + redis: &RedisPool, +) -> Result { + delphi::tech_review_queue::sync_projects( + &[project_id], + delphi::tech_review_queue::TechReviewRemovalReason::FileDeleted, + transaction, + ) + .await + .wrap_api_err("synchronizing project technical review state")?; + + sqlx::query!("SELECT pg_advisory_xact_lock($1)", project_id.0) + .fetch_one(&mut *transaction) + .await + .wrap_internal_err("locking project state synchronization")?; + + let mut projects = db_models::DBProject::get_many_uncached( + &[ProjectId::from(project_id)], + &mut *transaction, + redis, + ) + .await + .wrap_internal_err("reloading project for state synchronization")?; + let data = projects.pop().wrap_not_found_err("resource not found")?; + let project_ids = [project_id.0]; + let version_ids = sqlx::query!( + "SELECT id FROM versions WHERE mod_id = ANY($1::bigint[]) ORDER BY id", + &project_ids, + ) + .fetch_all(&mut *transaction) + .await + .wrap_internal_err("fetching all project version IDs")? + .into_iter() + .map(|row| DBVersionId(row.id)) + .collect::>(); + let versions = db_models::DBVersion::get_many_uncached( + &version_ids, + &mut *transaction, + redis, + ) + .await + .wrap_internal_err("reloading project versions for state synchronization")? + .into_iter() + .map(Version::from) + .collect::>(); + let project = Project::from(data.clone()); + let available_categories = + db_models::categories::Category::list(&mut *transaction, redis) + .await + .wrap_internal_err("fetching project categories")?; + let disclosures = db_models::DBProjectDisclosure::get_many_for_project( + data.inner.id, + false, + &mut *transaction, + ) + .await + .wrap_internal_err("fetching project disclosures")? + .into_iter() + .map(|disclosure| disclosure.disclosure) + .collect::>(); + let team_members = sqlx::query!( + r#" + SELECT team_id, user_id, role + FROM team_members + WHERE + team_id = $1 + OR team_id = ( + SELECT team_id + FROM organizations + WHERE id = $2 + ) + "#, + data.inner.team_id as DBTeamId, + data.inner.organization_id.map(|id| id.0), + ) + .fetch_all(&mut *transaction) + .await + .wrap_internal_err("fetching project team members")? + .into_iter() + .map(|member| ThreadIssueTeamMember { + team_id: DBTeamId(member.team_id).into(), + user_id: DBUserId(member.user_id).into(), + role: member.role, + }) + .collect::>(); + let has_required_nags = validate_project( + &project, + &versions, + &available_categories, + &disclosures, + ) + .iter() + .any(|nag| nag.severity == ProjectNagSeverity::Required); + + let context = ThreadIssueContext { + project: &project, + versions: &versions, + disclosures: &disclosures, + team_members: &team_members, + }; + let thread_issues = + db_models::DBThreadIssue::sync_project_verdicts(&context, transaction) + .await + .wrap_internal_err("synchronizing project thread issue verdicts")?; + + Ok(SyncedProjectState { + data, + project, + versions, + disclosures, + team_members, + thread_issues, + has_required_nags, + }) +} + +pub(crate) async fn finalize_mutation( + project_id: DBProjectId, + transaction: PgTransaction<'_>, + redis: &RedisPool, + project_editor: Option<&User>, +) -> Result<(), ApiError> { + finalize_mutations(&[project_id], transaction, redis, project_editor).await +} + +pub(crate) async fn finalize_mutations( + project_ids: &[DBProjectId], + mut transaction: PgTransaction<'_>, + redis: &RedisPool, + project_editor: Option<&User>, +) -> Result<(), ApiError> { + let mut project_ids = project_ids.to_vec(); + project_ids.sort_by_key(|project_id| project_id.0); + project_ids.dedup(); + + let mut states = Vec::with_capacity(project_ids.len()); + for project_id in project_ids { + states.push( + sync_project_state(project_id, &mut transaction, redis).await?, + ); + } + + if project_editor.is_some_and(|user| !user.role.is_mod()) { + for state in &states { + if state.project.status != ProjectStatus::Processing { + continue; + } + if state.has_required_nags { + return Err(ApiError::Request(eyre!( + "project cannot have required validation nags while in review" + ))); + } + if state + .thread_issues + .iter() + .any(|issue| issue.verdict == ThreadIssueVerdict::Open) + { + return Err(ApiError::Request(eyre!( + "project cannot have open moderation issues while in review" + ))); + } + } + } + + transaction + .commit() + .await + .wrap_internal_err("committing project mutation")?; + + for state in &states { + db_models::DBProject::clear_cache( + state.data.inner.id, + state.data.inner.slug.clone(), + Some(true), + redis, + ) + .await + .wrap_internal_err("clearing cached project data")?; + } + + Ok(()) +} + +pub(crate) async fn finalize_project_edit( + project_id: DBProjectId, + original_status: ProjectStatus, + original_slug: Option, + mut transaction: PgTransaction<'_>, + redis: &RedisPool, + project_editor: Option<&User>, +) -> Result<(), ApiError> { + let state = sync_project_state(project_id, &mut transaction, redis).await?; + + let entered_review = state.project.status == ProjectStatus::Processing + && original_status != ProjectStatus::Processing; + let entered_approved_state = + state.project.status.is_approved() && !original_status.is_approved(); + + let validate_review_state = entered_review + || project_editor.is_some_and(|user| { + !user.role.is_mod() + && state.project.status == ProjectStatus::Processing + }); + + if (validate_review_state || entered_approved_state) + && state.has_required_nags + { + return Err(ApiError::Request(eyre!( + "project must have no required validation nags before or during review or approval" + ))); + } + + if validate_review_state + && !project_editor.is_some_and(|user| user.role.is_mod()) + && state + .thread_issues + .iter() + .any(|issue| issue.verdict == ThreadIssueVerdict::Open) + { + return Err(ApiError::Request(eyre!( + "project must have no open moderation issues before or during review" + ))); + } + + if entered_approved_state { + if state + .thread_issues + .iter() + .any(|issue| issue.verdict != ThreadIssueVerdict::Resolved) + { + return Err(ApiError::Request(eyre!( + "all moderation issues must be resolved before approval" + ))); + } + + for facet in state.thread_issues.iter().flat_map(|issue| &issue.facets) + { + DBThreadIssue::update_facet_flags( + facet.id.into(), + None, + Some(true), + &mut transaction, + ) + .await + .wrap_internal_err("verifying project moderation issues")?; + } + } + + transaction + .commit() + .await + .wrap_internal_err("committing project mutation")?; + + db_models::DBProject::clear_cache( + project_id, + state.data.inner.slug.clone(), + Some(true), + redis, + ) + .await + .wrap_internal_err("clearing cached project data")?; + + if original_slug != state.data.inner.slug { + db_models::DBProject::clear_cache( + project_id, + original_slug, + Some(true), + redis, + ) + .await + .wrap_internal_err("clearing previous project slug cache")?; + } + + Ok(()) +} diff --git a/apps/labrinth/src/routes/v3/projects/validate.rs b/apps/labrinth/src/routes/v3/projects/validate.rs index af9e525e9e9..6bb2108beff 100644 --- a/apps/labrinth/src/routes/v3/projects/validate.rs +++ b/apps/labrinth/src/routes/v3/projects/validate.rs @@ -14,6 +14,7 @@ use crate::models::link_platform::LinkPlatform; use crate::models::pats::Scopes; use crate::models::projects::{Project, Version}; use crate::models::teams::ProjectPermissions; + use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::error::Context as _; diff --git a/apps/labrinth/src/routes/v3/teams.rs b/apps/labrinth/src/routes/v3/teams.rs index f5f7b9f9ab6..45d8b57c034 100644 --- a/apps/labrinth/src/routes/v3/teams.rs +++ b/apps/labrinth/src/routes/v3/teams.rs @@ -1,10 +1,12 @@ use crate::auth::checks::{is_visible_organization, is_visible_project}; use crate::auth::get_user_from_headers; use crate::database::DBProject; -use crate::database::PgPool; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::team_item::TeamAssociationId; -use crate::database::models::{DBOrganization, DBTeam, DBTeamMember, DBUser}; +use crate::database::models::{ + DBOrganization, DBProjectId, DBTeam, DBTeamMember, DBUser, +}; +use crate::database::{PgPool, PgTransaction}; use crate::models::ids::TeamId; use crate::models::notifications::NotificationBody; use crate::models::pats::Scopes; @@ -30,6 +32,31 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { .service(transfer_ownership_route); } +async fn affected_project_ids( + association: TeamAssociationId, + transaction: &mut PgTransaction<'_>, +) -> Result, ApiError> { + match association { + TeamAssociationId::Project(project_id) => Ok(vec![project_id]), + TeamAssociationId::Organization(organization_id) => { + sqlx::query_scalar!( + r#" + SELECT id + FROM mods + WHERE organization_id = $1 + "#, + organization_id as _, + ) + .fetch_all(&mut *transaction) + .await + .map(|project_ids| { + project_ids.into_iter().map(DBProjectId).collect() + }) + .wrap_internal_err("fetching projects affected by team mutation") + } + } +} + // Returns all members of a project, // including the team members of the project's team, but // also the members of the organization's team if the project is associated with an organization @@ -776,10 +803,15 @@ pub async fn add_team_member( } } - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; + let affected_project_ids = + affected_project_ids(team_association, &mut transaction).await?; + super::projects::mutation::finalize_mutations( + &affected_project_ids, + transaction, + &redis, + Some(¤t_user), + ) + .await?; DBTeamMember::clear_cache(team_id, &redis) .await .wrap_internal_err("clearing cached data from Redis")?; @@ -1003,10 +1035,15 @@ pub async fn edit_team_member( .await .wrap_internal_err("updating team member in database")?; - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; + let affected_project_ids = + affected_project_ids(team_association, &mut transaction).await?; + super::projects::mutation::finalize_mutations( + &affected_project_ids, + transaction, + &redis, + Some(¤t_user), + ) + .await?; DBTeamMember::clear_cache(id, &redis) .await .wrap_internal_err("clearing cached data from Redis")?; @@ -1217,10 +1254,22 @@ pub async fn transfer_ownership( )?; } - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; + if let Some(team_association_id) = team_association_id { + let affected_project_ids = + affected_project_ids(team_association_id, &mut transaction).await?; + super::projects::mutation::finalize_mutations( + &affected_project_ids, + transaction, + &redis, + Some(¤t_user), + ) + .await?; + } else { + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; + } DBTeamMember::clear_cache(id.into(), &redis) .await @@ -1406,10 +1455,15 @@ pub async fn remove_team_member( } } - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; + let affected_project_ids = + affected_project_ids(team_association, &mut transaction).await?; + super::projects::mutation::finalize_mutations( + &affected_project_ids, + transaction, + &redis, + Some(¤t_user), + ) + .await?; DBTeamMember::clear_cache(id, &redis) .await diff --git a/apps/labrinth/src/routes/v3/threads.rs b/apps/labrinth/src/routes/v3/threads.rs index cebe4a3a1ee..80ca33e9772 100644 --- a/apps/labrinth/src/routes/v3/threads.rs +++ b/apps/labrinth/src/routes/v3/threads.rs @@ -1,23 +1,31 @@ -use crate::auth::get_user_from_headers; +use crate::auth::{check_is_moderator_from_headers, get_user_from_headers}; use crate::database; use crate::database::PgPool; use crate::database::models::image_item; use crate::database::models::notification_item::NotificationBuilder; +use crate::database::models::thread_issue_item::ThreadIssueBuilder; use crate::database::models::thread_item::ThreadMessageBuilder; use crate::env::ENV; use crate::file_hosting::{FileHost, FileHostPublicity}; -use crate::models::ids::{ThreadId, ThreadMessageId}; +use crate::models::ids::{ + ThreadId, ThreadIssueFacetId, ThreadIssueId, ThreadMessageId, +}; use crate::models::images::{Image, ImageContext}; use crate::models::notifications::NotificationBody; use crate::models::pats::Scopes; use crate::models::projects::ProjectStatus; +use crate::models::thread_issues::{ + ThreadIssueAcknowledgement, ThreadIssueTarget, +}; use crate::models::threads::{MessageBody, Thread, ThreadType}; use crate::models::users::User; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::error::ApiContext as _; use crate::util::error::Context as _; -use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; +use actix_web::{ + HttpRequest, HttpResponse, delete, get, patch, post, put, web, +}; use futures::TryStreamExt; use serde::Deserialize; use xredis::RedisPool; @@ -25,6 +33,9 @@ use xredis::RedisPool; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(thread_get_route) .service(thread_send_message_route) + .service(thread_issues_create) + .service(thread_issue_facet_edit) + .service(thread_issue_delete) .service(message_delete_route) .service(threads_get_route); } @@ -216,14 +227,14 @@ pub async fn filter_authorized_threads( user_ids.append( &mut return_threads .iter() - .flat_map(|x| { - x.messages - .iter() - .filter_map(|x| x.author_id) - .collect::>() - }) + .flat_map(|x| x.messages.iter().filter_map(|x| x.author_id)) .collect::>(), ); + if user.role.is_mod() { + user_ids.extend(return_threads.iter().flat_map(|thread| { + thread.issues.iter().map(|issue| issue.created_by) + })); + } let users: Vec = database::models::DBUser::get_many_ids(&user_ids, &***pool, redis) @@ -251,6 +262,9 @@ pub async fn filter_authorized_threads( }) .collect::>(), ); + if user.role.is_mod() { + authors.extend(thread.issues.iter().map(|issue| issue.created_by)); + } final_threads.push(Thread::from( thread, @@ -322,6 +336,9 @@ pub async fn thread_get( }) .collect::>(), ); + if user.role.is_mod() { + authors.extend(data.issues.iter().map(|issue| issue.created_by)); + } let users: Vec = database::models::DBUser::get_many_ids(authors, &**pool, &redis) @@ -394,6 +411,426 @@ pub async fn threads_get( Ok(HttpResponse::Ok().json(threads)) } +#[derive(Deserialize, utoipa::ToSchema)] +pub struct NewThreadIssueFacet { + pub what: ThreadIssueTarget, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct NewThreadIssue { + pub facets: Vec, + pub why: serde_json::Value, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct NewThreadIssues { + pub issues: Vec, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct EditThreadIssueFacet { + pub user_addressed: Option, + pub moderator_verified: Option, +} + +async fn thread_project_id( + thread_id: database::models::DBThreadId, + pool: &PgPool, +) -> Result, ApiError> { + Ok(sqlx::query_scalar!( + "SELECT mod_id FROM threads WHERE id = $1", + thread_id as database::models::DBThreadId, + ) + .fetch_optional(pool) + .await + .wrap_internal_err("fetching thread project")? + .flatten() + .map(database::models::DBProjectId)) +} + +async fn thread_issue_project_id( + issue_id: database::models::DBThreadIssueId, + pool: &PgPool, +) -> Result, ApiError> { + Ok(sqlx::query_scalar!( + r#" + SELECT thread.mod_id + FROM threads_issues issue + INNER JOIN threads thread ON thread.id = issue.thread_id + WHERE issue.id = $1 + "#, + issue_id as database::models::DBThreadIssueId, + ) + .fetch_optional(pool) + .await + .wrap_internal_err("fetching thread issue project")? + .flatten() + .map(database::models::DBProjectId)) +} + +struct ThreadIssueFacetContext { + project_id: database::models::DBProjectId, + thread_id: database::models::DBThreadId, + created_at: chrono::DateTime, + what: ThreadIssueTarget, +} + +async fn thread_issue_facet_context( + facet_id: database::models::DBThreadIssueFacetId, + pool: &PgPool, +) -> Result, ApiError> { + Ok(sqlx::query!( + r#" + SELECT thread.mod_id, issue.thread_id, issue.created_at, + facet.what AS "what: sqlx::types::Json" + FROM threads_issue_facets facet + INNER JOIN threads_issues issue ON issue.id = facet.issue_id + INNER JOIN threads thread ON thread.id = issue.thread_id + WHERE facet.id = $1 + "#, + facet_id as database::models::DBThreadIssueFacetId, + ) + .fetch_optional(pool) + .await + .wrap_internal_err("fetching thread issue facet context")? + .and_then(|row| { + row.mod_id.map(|project_id| ThreadIssueFacetContext { + project_id: database::models::DBProjectId(project_id), + thread_id: database::models::DBThreadId(row.thread_id), + created_at: row.created_at, + what: row.what.0, + }) + })) +} + +async fn project_exists( + project_id: database::models::DBProjectId, + pool: &PgPool, +) -> Result { + sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM mods WHERE id = $1) AS "exists!""#, + project_id as database::models::DBProjectId, + ) + .fetch_one(pool) + .await + .wrap_internal_err("checking whether thread issue project exists") +} + +async fn is_project_team_member( + project_id: database::models::DBProjectId, + user_id: database::models::DBUserId, + transaction: &mut database::PgTransaction<'_>, +) -> Result { + sqlx::query_scalar!( + r#" + SELECT EXISTS ( + SELECT 1 + FROM mods project + INNER JOIN team_members member + ON member.team_id = project.team_id + WHERE + project.id = $1 + AND member.user_id = $2 + AND member.accepted + UNION + SELECT 1 + FROM mods project + INNER JOIN organizations organization + ON organization.id = project.organization_id + INNER JOIN team_members member + ON member.team_id = organization.team_id + WHERE + project.id = $1 + AND member.user_id = $2 + AND member.accepted + ) AS "exists!" + "#, + project_id as database::models::DBProjectId, + user_id as database::models::DBUserId, + ) + .fetch_one(&mut *transaction) + .await + .wrap_internal_err("checking project team membership") +} + +async fn prepare_thread_issue_mutation( + project_id: database::models::DBProjectId, + transaction: &mut database::PgTransaction<'_>, +) -> Result<(), ApiError> { + sqlx::query!("SELECT pg_advisory_xact_lock($1)", project_id.0) + .fetch_one(&mut *transaction) + .await + .wrap_internal_err("locking project thread issues")?; + + Ok(()) +} + +#[utoipa::path( + tag = "threads", + request_body = NewThreadIssues, + responses((status = NO_CONTENT)) +)] +#[put("/thread/{id}/issue")] +pub async fn thread_issues_create( + req: HttpRequest, + info: web::Path<(ThreadId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + web::Json(new_issues): web::Json, +) -> Result<(), ApiError> { + let moderator = check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::THREAD_WRITE, + ) + .await + .wrap_auth_err("authenticating API request")?; + + if new_issues.issues.is_empty() { + return Err(ApiError::Request(eyre::eyre!( + "must provide at least one thread issue" + ))); + } + if new_issues + .issues + .iter() + .any(|issue| issue.facets.is_empty()) + { + return Err(ApiError::Request(eyre::eyre!( + "each thread issue must have at least one facet" + ))); + } + + let thread_id: database::models::DBThreadId = info.into_inner().0.into(); + let project_id = thread_project_id(thread_id, &pool) + .await? + .wrap_not_found_err("resource not found")?; + if !project_exists(project_id, &pool).await? { + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); + } + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; + prepare_thread_issue_mutation(project_id, &mut transaction).await?; + + database::models::DBThreadIssue::insert_many( + thread_id, + moderator.id.into(), + new_issues + .issues + .into_iter() + .map(|issue| ThreadIssueBuilder { + facets: issue + .facets + .into_iter() + .map(|facet| facet.what) + .collect(), + why: issue.why, + }) + .collect(), + &mut transaction, + ) + .await + .wrap_internal_err("creating thread issues")?; + + super::projects::mutation::finalize_mutation( + project_id, + transaction, + &redis, + None, + ) + .await +} + +#[utoipa::path( + tag = "threads", + request_body = EditThreadIssueFacet, + responses((status = NO_CONTENT)) +)] +#[patch("/thread/issue/facet/{id}")] +pub async fn thread_issue_facet_edit( + req: HttpRequest, + info: web::Path<(ThreadIssueFacetId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + web::Json(edit): web::Json, +) -> Result<(), ApiError> { + if edit.user_addressed.is_none() && edit.moderator_verified.is_none() { + return Err(ApiError::Request(eyre::eyre!( + "must provide a thread issue field to update" + ))); + } + if edit.moderator_verified == Some(false) { + return Err(ApiError::Request(eyre::eyre!( + "a verified thread issue cannot be unverified" + ))); + } + + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::THREAD_WRITE, + ) + .await + .wrap_auth_err("authenticating API request")? + .1; + let facet_id: database::models::DBThreadIssueFacetId = + info.into_inner().0.into(); + let facet_context = thread_issue_facet_context(facet_id, &pool) + .await? + .wrap_not_found_err("resource not found")?; + let project_id = facet_context.project_id; + let project_exists = project_exists(project_id, &pool).await?; + + if edit.moderator_verified.is_some() && !user.role.is_mod() { + return Err(ApiError::Auth(eyre::eyre!( + "only moderators can verify thread issues" + ))); + } + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; + if project_exists { + prepare_thread_issue_mutation(project_id, &mut transaction).await?; + } + if edit.user_addressed.is_some() + && !user.role.is_mod() + && !is_project_team_member(project_id, user.id.into(), &mut transaction) + .await? + { + return Err(ApiError::Auth(eyre::eyre!( + "only project team members or moderators can address thread issues" + ))); + } + if edit.user_addressed == Some(true) + && matches!( + facet_context.what, + ThreadIssueTarget::Acknowledge { + mode: ThreadIssueAcknowledgement::Reply + } + ) + && !database::models::DBThread::has_project_member_reply( + facet_context.thread_id, + project_id, + facet_context.created_at, + &mut transaction, + ) + .await + .wrap_internal_err("checking project team reply requirement")? + { + return Err(ApiError::Request(eyre::eyre!( + "a project team member must send a message in the thread before addressing this issue" + ))); + } + if edit.user_addressed == Some(true) && !user.role.is_mod() { + let state = super::projects::mutation::sync_project_state( + project_id, + &mut transaction, + &redis, + ) + .await?; + if !state + .can_address_facet(facet_id) + .wrap_not_found_err("resource not found")? + { + return Err(ApiError::Request(eyre::eyre!( + "moderation issue requirements must be changed before addressing the issue" + ))); + } + } + let updated = database::models::DBThreadIssue::update_facet_flags( + facet_id, + edit.user_addressed, + edit.moderator_verified, + &mut transaction, + ) + .await + .wrap_internal_err("updating thread issue")?; + if !updated { + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); + } + + if project_exists { + let project_editor = + (edit.user_addressed == Some(false)).then_some(&user); + super::projects::mutation::finalize_mutation( + project_id, + transaction, + &redis, + project_editor, + ) + .await + } else { + transaction + .commit() + .await + .wrap_internal_err("committing thread issue update") + } +} + +#[utoipa::path(tag = "threads", responses((status = NO_CONTENT)))] +#[delete("/thread/issue/{id}")] +pub async fn thread_issue_delete( + req: HttpRequest, + info: web::Path<(ThreadIssueId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result<(), ApiError> { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::THREAD_WRITE, + ) + .await + .wrap_auth_err("authenticating API request")?; + + let issue_id: database::models::DBThreadIssueId = + info.into_inner().0.into(); + let project_id = thread_issue_project_id(issue_id, &pool) + .await? + .wrap_not_found_err("resource not found")?; + let project_exists = project_exists(project_id, &pool).await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; + if project_exists { + prepare_thread_issue_mutation(project_id, &mut transaction).await?; + } + if !database::models::DBThreadIssue::remove(issue_id, &mut transaction) + .await + .wrap_internal_err("deleting thread issue")? + { + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); + } + + if project_exists { + super::projects::mutation::finalize_mutation( + project_id, + transaction, + &redis, + None, + ) + .await + } else { + transaction + .commit() + .await + .wrap_internal_err("committing thread issue deletion") + } +} + #[derive(Deserialize, utoipa::ToSchema)] pub struct NewThreadMessage { pub body: MessageBody, diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index ddff1fef29d..129c6583caa 100644 --- a/apps/labrinth/src/routes/v3/version_creation.rs +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -24,6 +24,7 @@ use crate::models::projects::{ }; use crate::models::projects::{DependencyType, skip_nulls}; use crate::models::teams::ProjectPermissions; +use crate::models::users::User; use crate::models::v3::user_limits::UserLimits; use crate::queue::session::AuthQueue; use crate::search::SearchState; @@ -160,15 +161,24 @@ pub async fn version_create( let mut transaction = client.begin().await?; let mut uploaded_files = Vec::new(); + let user = get_user_from_headers( + &req, + &**client, + &redis, + &session_queue, + Scopes::VERSION_CREATE, + ) + .await? + .1; + let result = version_create_inner( - req, &mut payload, &mut transaction, &redis, &**file_host, &mut uploaded_files, + &user, &client, - &session_queue, &kafka_client, ) .await; @@ -186,9 +196,13 @@ pub async fn version_create( return Err(e.into()); } } else if let Ok((_, project_id, version_id)) = &result { - transaction.commit().await?; - models::DBProject::clear_cache(*project_id, None, Some(true), &redis) - .await?; + super::projects::mutation::finalize_mutation( + *project_id, + transaction, + &redis, + Some(&user), + ) + .await?; search_state .queue .push_version_changes( @@ -203,14 +217,13 @@ pub async fn version_create( #[allow(clippy::too_many_arguments)] async fn version_create_inner( - req: HttpRequest, payload: &mut Multipart, transaction: &mut PgTransaction<'_>, redis: &RedisPool, file_host: &dyn FileHost, uploaded_files: &mut Vec, + user: &User, pool: &PgPool, - session_queue: &AuthQueue, kafka_client: &KafkaClientState, ) -> Result<(HttpResponse, models::DBProjectId, models::DBVersionId), CreateError> { @@ -218,16 +231,6 @@ async fn version_create_inner( let mut version_builder = None; let mut selected_loaders = None; - let user = get_user_from_headers( - &req, - pool, - redis, - session_queue, - Scopes::VERSION_CREATE, - ) - .await? - .1; - let mut error = None; while let Some(item) = payload.next().await { let mut field: Field = item?; @@ -322,7 +325,7 @@ async fn version_create_inner( let project_version_limits = UserLimits::get_for_versions_per_project( - &user, + user, project_id, pool, ) @@ -333,7 +336,7 @@ async fn version_create_inner( let daily_version_limits = UserLimits::get_for_versions_per_day( - &user, + user, Utc::now(), pool, ) @@ -669,8 +672,17 @@ pub async fn upload_file_to_version( let version_id = url_data.into_inner().0; let db_version_id = models::DBVersionId::from(version_id); + let user = get_user_from_headers( + &req, + &**client, + &redis, + &session_queue, + Scopes::VERSION_WRITE, + ) + .await? + .1; + let result = upload_file_to_version_inner( - req, &mut payload, client.clone(), &mut transaction, @@ -678,7 +690,7 @@ pub async fn upload_file_to_version( &**file_host, &mut uploaded_files, db_version_id, - &session_queue, + &user, &kafka_client, ) .await; @@ -696,9 +708,13 @@ pub async fn upload_file_to_version( return Err(e.into()); } } else if let Ok((_, project_id)) = &result { - transaction.commit().await?; - models::DBProject::clear_cache(*project_id, None, Some(true), &redis) - .await?; + super::projects::mutation::finalize_mutation( + *project_id, + transaction, + &redis, + Some(&user), + ) + .await?; search_state .queue .push_version_changes((*project_id).into(), [version_id]) @@ -710,7 +726,6 @@ pub async fn upload_file_to_version( #[allow(clippy::too_many_arguments)] async fn upload_file_to_version_inner( - req: HttpRequest, payload: &mut Multipart, client: Data, transaction: &mut PgTransaction<'_>, @@ -718,22 +733,12 @@ async fn upload_file_to_version_inner( file_host: &dyn FileHost, uploaded_files: &mut Vec, version_id: models::DBVersionId, - session_queue: &AuthQueue, + user: &User, kafka_client: &KafkaClientState, ) -> Result<(HttpResponse, models::DBProjectId), CreateError> { let mut initial_file_data: Option = None; let mut file_builders: Vec = Vec::new(); - let user = get_user_from_headers( - &req, - &**client, - &redis, - session_queue, - Scopes::VERSION_WRITE, - ) - .await? - .1; - let result = models::DBVersion::get(version_id, &**client, &redis).await?; let Some(version) = result else { diff --git a/apps/labrinth/src/routes/v3/version_file.rs b/apps/labrinth/src/routes/v3/version_file.rs index 4217cac7b35..5718164ff13 100644 --- a/apps/labrinth/src/routes/v3/version_file.rs +++ b/apps/labrinth/src/routes/v3/version_file.rs @@ -10,7 +10,7 @@ use crate::models::pats::Scopes; use crate::models::projects::{ProjectStatus, VersionStatus, VersionType}; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; -use crate::routes::internal::delphi; + use crate::routes::{FileHash, HashAlgorithm}; use crate::util::error::ApiContext as _; use crate::util::error::Context; @@ -1113,18 +1113,13 @@ pub async fn delete_file( database::models::version_item::cleanup_unused_attribution_files_and_groups(&mut transaction) .await.wrap_internal_err("deleting version item from database")?; - delphi::tech_review_queue::sync_projects( - &[row.project_id], - delphi::tech_review_queue::TechReviewRemovalReason::FileDeleted, - &mut transaction, + super::projects::mutation::finalize_mutation( + row.project_id, + transaction, + &redis, + Some(&user), ) - .await - .wrap_api_err("executing `tech_review_queue::sync_projects`")?; - - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; + .await?; Ok(HttpResponse::NoContent().body("")) } else { diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index af7d2633d46..a1500cc0a2f 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -20,13 +20,13 @@ use crate::models::ids::VersionId; use crate::models::images::ImageContext; use crate::models::pats::Scopes; use crate::models::projects::{ - Dependency, FileType, ProjectStatus, VersionStatus, VersionType, + Dependency, FileType, VersionStatus, VersionType, }; use crate::models::projects::{Loader, skip_nulls}; use crate::models::teams::ProjectPermissions; use crate::queue::file_scan::get_files_missing_attribution; use crate::queue::session::AuthQueue; -use crate::routes::internal::delphi; + use crate::search::SearchState; use crate::util::error::Context; use crate::util::img; @@ -985,21 +985,16 @@ pub async fn version_edit_helper( .await .wrap_api_err("deleting unused images")?; - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; - database::models::DBVersion::clear_cache(&version_item, &redis) - .await - .wrap_internal_err("clearing cached data from Redis")?; - database::models::DBProject::clear_cache( + super::projects::mutation::finalize_mutation( version_item.inner.project_id, - None, - Some(true), + transaction, &redis, + Some(&user), ) - .await - .wrap_internal_err("clearing cached data from Redis")?; + .await?; + database::models::DBVersion::clear_cache(&version_item, &redis) + .await + .wrap_internal_err("clearing cached data from Redis")?; search_state .queue .push_version_changes( @@ -1318,15 +1313,6 @@ pub async fn version_delete( .wrap_request_err_with(|| { "the specified version does not exist!".to_string() })?; - let project = database::models::DBProject::get_id( - version.inner.project_id, - &**pool, - &redis, - ) - .await - .wrap_internal_err("fetching project from database")? - .wrap_not_found_err("resource not found")?; - let validate_for_review = project.inner.status == ProjectStatus::Processing; if !user.role.is_admin() { let team_member = @@ -1401,37 +1387,13 @@ pub async fn version_delete( .await .wrap_internal_err("deleting version from database")?; - delphi::tech_review_queue::sync_projects( - &[version.inner.project_id], - delphi::tech_review_queue::TechReviewRemovalReason::FileDeleted, - &mut transaction, - ) - .await - .wrap_api_err("executing `tech_review_queue::sync_projects`")?; - - if validate_for_review { - super::projects::validate::ensure_project_is_valid_for_review( - version.inner.project_id, - &pool, - &mut transaction, - &redis, - ) - .await?; - } - - transaction - .commit() - .await - .wrap_internal_err("committing database transaction")?; - - database::models::DBProject::clear_cache( + super::projects::mutation::finalize_mutation( version.inner.project_id, - None, - Some(true), + transaction, &redis, + Some(&user), ) - .await - .wrap_internal_err("clearing cached data from Redis")?; + .await?; search_state .queue .push_version_changes( diff --git a/apps/labrinth/src/util/mod.rs b/apps/labrinth/src/util/mod.rs index ee1e98419c4..efe8c1dbc9b 100644 --- a/apps/labrinth/src/util/mod.rs +++ b/apps/labrinth/src/util/mod.rs @@ -16,6 +16,7 @@ pub mod img; pub mod ip; pub mod kafka; pub mod neverbounce; +pub mod non_empty_vec; pub mod ratelimit; pub mod routes; pub mod sentry; diff --git a/apps/labrinth/src/util/non_empty_vec.rs b/apps/labrinth/src/util/non_empty_vec.rs new file mode 100644 index 00000000000..4fd55931891 --- /dev/null +++ b/apps/labrinth/src/util/non_empty_vec.rs @@ -0,0 +1,85 @@ +use derive_more::{Display, Error}; +use serde::{Deserialize, Deserializer, Serialize, de::Error as _}; +use std::ops::Deref; + +#[derive(Debug, Display, Error)] +#[display("must have at least one item")] +pub struct Empty; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct NonEmptyVec(Vec); + +impl Deref for NonEmptyVec { + type Target = [T]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl IntoIterator for NonEmptyVec { + type Item = T; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl TryFrom> for NonEmptyVec { + type Error = Empty; + + fn try_from(value: Vec) -> Result { + if value.is_empty() { + Err(Empty) + } else { + Ok(Self(value)) + } + } +} + +impl<'de, T> Deserialize<'de> for NonEmptyVec +where + T: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Vec::deserialize(deserializer) + .and_then(|value| Self::try_from(value).map_err(D::Error::custom)) + } +} + +impl utoipa::__dev::ComposeSchema for NonEmptyVec +where + T: utoipa::__dev::ComposeSchema, +{ + fn compose( + schemas: Vec>, + ) -> utoipa::openapi::RefOr { + let item = schemas + .first() + .cloned() + .unwrap_or_else(|| T::compose(schemas)); + utoipa::openapi::ArrayBuilder::new() + .items(item) + .min_items(Some(1)) + .into() + } +} + +impl utoipa::ToSchema for NonEmptyVec +where + T: utoipa::ToSchema + utoipa::__dev::ComposeSchema, +{ + fn schemas( + schemas: &mut Vec<( + String, + utoipa::openapi::RefOr, + )>, + ) { + T::schemas(schemas); + } +}