diff --git a/apps/frontend/src/components/ui/moderation/ModerationQueueCard.vue b/apps/frontend/src/components/ui/moderation/ModerationQueueCard.vue index 8e2db41b96..61e0b9bc9c 100644 --- a/apps/frontend/src/components/ui/moderation/ModerationQueueCard.vue +++ b/apps/frontend/src/components/ui/moderation/ModerationQueueCard.vue @@ -2,12 +2,7 @@
- +
@@ -177,7 +172,7 @@ function getDaysQueued(date: Date): number { const queuedDate = computed(() => { return dayjs( props.queueEntry.project.queued || - props.queueEntry.project.created || + props.queueEntry.project.published || props.queueEntry.project.updated, ) }) @@ -186,10 +181,14 @@ const daysInQueue = computed(() => { return getDaysQueued(queuedDate.value.toDate()) }) +const projectRouteParam = computed( + () => props.queueEntry.project.slug || props.queueEntry.project.id, +) + const formattedDate = computed(() => { const date = props.queueEntry.project.queued || - props.queueEntry.project.created || + props.queueEntry.project.published || props.queueEntry.project.updated if (!date) return 'Unknown' @@ -202,7 +201,7 @@ const formattedDate = computed(() => { function copyLink() { const base = window.location.origin - const projectUrl = `${base}/project/${props.queueEntry.project.slug}` + const projectUrl = `${base}/project/${projectRouteParam.value}` navigator.clipboard.writeText(projectUrl).then(() => { addNotification({ type: 'success', diff --git a/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue b/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue index 21cda7a992..5d31c33671 100644 --- a/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue +++ b/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue @@ -1949,7 +1949,10 @@ function generateModpackMessage(allFiles: { const hasNextProject = ref(false) async function refreshModerationCaches(threadId?: string) { - const refreshes: Promise[] = [invalidate(), refreshNuxtData('moderation-projects')] + const refreshes: Promise[] = [ + invalidate(), + queryClient.invalidateQueries({ queryKey: ['moderation-projects'] }), + ] if (threadId) { refreshes.push(queryClient.invalidateQueries({ queryKey: ['thread', threadId] })) diff --git a/apps/frontend/src/helpers/moderation.ts b/apps/frontend/src/helpers/moderation.ts index 8979c8d6b1..65b94e7dac 100644 --- a/apps/frontend/src/helpers/moderation.ts +++ b/apps/frontend/src/helpers/moderation.ts @@ -1,3 +1,4 @@ +import type { Labrinth } from '@modrinth/api-client' import type { ExtendedReport, OwnershipTarget } from '@modrinth/moderation' import type { Organization, @@ -197,14 +198,10 @@ export interface ModerationOwnershipOrganization { export type ModerationOwnership = ModerationOwnershipUser | ModerationOwnershipOrganization -export interface ProjectWithOwnership { - ownership: ModerationOwnership - external_dependencies_count: number - [key: string]: any -} +export type ProjectWithOwnership = Labrinth.Moderation.Internal.QueueProject export interface ModerationProject { - project: any + project: Omit ownership: ModerationOwnership | null external_dependencies_count: number } diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index ae19e368bc..5b76a99673 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -2672,6 +2672,9 @@ "layout.nav.upgrade-to-modrinth-plus": { "message": "Upgrade to Modrinth+" }, + "moderation.exclude-technical-review": { + "message": "Exclude TR" + }, "moderation.moderate": { "message": "Moderate" }, diff --git a/apps/frontend/src/pages/moderation/index.vue b/apps/frontend/src/pages/moderation/index.vue index c1699d549f..f89d6d2c8c 100644 --- a/apps/frontend/src/pages/moderation/index.vue +++ b/apps/frontend/src/pages/moderation/index.vue @@ -18,6 +18,7 @@ {{ currentFilterType }} ({{ filteredProjects.length }}){{ currentFilterType }} ({{ totalProjects }}) @@ -35,6 +36,7 @@
-
-
- Showing {{ itemsPerPage * (currentPage - 1) + 1 }}–{{ - itemsPerPage * (currentPage - 1) + Math.min(itemsPerPage, paginatedProjects.length) - }} - of {{ filteredProjects.length }} - {{ - currentFilterType === DEFAULT_FILTER_TYPE ? 'projects' : currentFilterType.toLowerCase() - }} +
+
+
+ Showing {{ pageStart }}–{{ pageEnd }} of {{ totalProjects }} + {{ + currentFilterType === DEFAULT_FILTER_TYPE ? 'projects' : currentFilterType.toLowerCase() + }} +
+
+ + +
- +
@@ -124,6 +137,7 @@
diff --git a/apps/labrinth/.sqlx/query-079846a1e6a6b080e0e7f2e3efbb53b6526332433faf66de8716bc5cd2b12afd.json b/apps/labrinth/.sqlx/query-079846a1e6a6b080e0e7f2e3efbb53b6526332433faf66de8716bc5cd2b12afd.json new file mode 100644 index 0000000000..6872b92825 --- /dev/null +++ b/apps/labrinth/.sqlx/query-079846a1e6a6b080e0e7f2e3efbb53b6526332433faf66de8716bc5cd2b12afd.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n select id\n from mods\n where id = any($1)\n and status not in ('rejected', 'draft', 'withheld', 'withdrawn')\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false + ] + }, + "hash": "079846a1e6a6b080e0e7f2e3efbb53b6526332433faf66de8716bc5cd2b12afd" +} diff --git a/apps/labrinth/.sqlx/query-119a59fcf4bb2f19f89002c712a67c75d30056143c0bcabdbd74bb4c7b442082.json b/apps/labrinth/.sqlx/query-119a59fcf4bb2f19f89002c712a67c75d30056143c0bcabdbd74bb4c7b442082.json deleted file mode 100644 index fcd5b34f17..0000000000 --- a/apps/labrinth/.sqlx/query-119a59fcf4bb2f19f89002c712a67c75d30056143c0bcabdbd74bb4c7b442082.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n id,\n external_dependencies_count as \"external_dependencies_count!\"\n FROM (\n SELECT DISTINCT ON (m.id)\n m.id,\n m.queued,\n (\n SELECT COUNT(*)\n FROM versions v\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE v.mod_id = m.id\n AND d.dependency_file_name IS NOT NULL\n ) external_dependencies_count\n FROM mods m\n\n /* -- Temporarily, don't exclude projects in tech rev q\n\n -- exclude projects in tech review queue\n LEFT JOIN delphi_issue_details_with_statuses didws\n ON didws.project_id = m.id AND didws.status = 'pending'\n */\n\n WHERE\n m.status = $1\n /* AND didws.status IS NULL */ -- Temporarily don't exclude\n\n GROUP BY m.id\n ) t\n WHERE\n ($4::boolean IS NULL OR (external_dependencies_count > 0) = $4)\n ORDER BY queued ASC\n OFFSET $3\n LIMIT $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "external_dependencies_count!", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "Int8", - "Int8", - "Bool" - ] - }, - "nullable": [ - false, - null - ] - }, - "hash": "119a59fcf4bb2f19f89002c712a67c75d30056143c0bcabdbd74bb4c7b442082" -} diff --git a/apps/labrinth/.sqlx/query-1bff1c5714dd039814d7a9d9f25f4ceca0e84a42b3a4c414210739fec6308e2d.json b/apps/labrinth/.sqlx/query-1bff1c5714dd039814d7a9d9f25f4ceca0e84a42b3a4c414210739fec6308e2d.json new file mode 100644 index 0000000000..c55b8c6c7c --- /dev/null +++ b/apps/labrinth/.sqlx/query-1bff1c5714dd039814d7a9d9f25f4ceca0e84a42b3a4c414210739fec6308e2d.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id\n FROM mods\n WHERE\n status = $1\n AND (\n $3::boolean = false\n OR NOT EXISTS (\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.project_id = mods.id\n AND didws.status = 'pending'\n )\n )\n ORDER BY\n CASE WHEN $2 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,\n CASE WHEN $2 = 'oldest' THEN COALESCE(queued, published) END ASC NULLS LAST,\n id ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Bool" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1bff1c5714dd039814d7a9d9f25f4ceca0e84a42b3a4c414210739fec6308e2d" +} diff --git a/apps/labrinth/.sqlx/query-22fbd4bf1c2088903842b2c0070fd08431fc3f984263faed7aaeb311fa6ac2a9.json b/apps/labrinth/.sqlx/query-22fbd4bf1c2088903842b2c0070fd08431fc3f984263faed7aaeb311fa6ac2a9.json new file mode 100644 index 0000000000..38d43c6043 --- /dev/null +++ b/apps/labrinth/.sqlx/query-22fbd4bf1c2088903842b2c0070fd08431fc3f984263faed7aaeb311fa6ac2a9.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH moderation_projects AS (\n SELECT\n m.id,\n m.slug,\n m.name,\n m.summary,\n m.description,\n m.queued,\n m.published,\n m.organization_id,\n m.team_id,\n m.components\n FROM mods m\n WHERE\n m.status = $1\n AND (\n $7::boolean = false\n OR NOT EXISTS (\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.project_id = m.id\n AND didws.status = 'pending'\n )\n )\n ),\n external_dependencies AS (\n SELECT\n v.mod_id,\n COUNT(*) AS external_dependencies_count\n FROM versions v\n INNER JOIN moderation_projects mp ON mp.id = v.mod_id\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE d.dependency_file_name IS NOT NULL\n GROUP BY v.mod_id\n ),\n version_project_types AS (\n SELECT\n v.mod_id,\n ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types\n FROM versions v\n INNER JOIN moderation_projects mp ON mp.id = v.mod_id\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n WHERE v.status = ANY($2)\n GROUP BY v.mod_id\n ),\n queue_projects AS (\n SELECT\n mp.id,\n mp.slug,\n mp.name,\n mp.summary,\n mp.description,\n mp.queued,\n mp.published,\n search_organization.name AS organization_name,\n search_owner.username AS owner_name,\n CASE\n WHEN mp.components ? 'minecraft_server'\n THEN ARRAY_APPEND(\n ARRAY_REMOVE(\n COALESCE(vpt.project_types::text[], ARRAY[]::text[]),\n 'modpack'\n ),\n 'minecraft_java_server'\n )\n ELSE COALESCE(vpt.project_types::text[], ARRAY[]::text[])\n END AS project_types,\n COALESCE(ed.external_dependencies_count, 0) AS external_dependencies_count\n FROM moderation_projects mp\n LEFT JOIN organizations search_organization\n ON search_organization.id = mp.organization_id\n AND $3::text IS NOT NULL\n LEFT JOIN LATERAL (\n SELECT\n u.username\n FROM team_members tm\n INNER JOIN users u ON u.id = tm.user_id\n WHERE tm.team_id = mp.team_id\n AND tm.is_owner\n ORDER BY tm.ordering ASC\n LIMIT 1\n ) search_owner ON $3::text IS NOT NULL\n AND mp.organization_id IS NULL\n LEFT JOIN external_dependencies ed ON ed.mod_id = mp.id\n LEFT JOIN version_project_types vpt ON vpt.mod_id = mp.id\n )\n SELECT id\n FROM queue_projects\n WHERE\n (\n $3::text IS NULL\n OR name ILIKE '%' || $3 || '%'\n OR slug ILIKE '%' || $3 || '%'\n OR summary ILIKE '%' || $3 || '%'\n OR description ILIKE '%' || $3 || '%'\n OR owner_name ILIKE '%' || $3 || '%'\n OR organization_name ILIKE '%' || $3 || '%'\n OR EXISTS (\n SELECT 1\n FROM UNNEST(project_types) AS searched_project_type(project_type)\n WHERE searched_project_type.project_type ILIKE '%' || $3 || '%'\n )\n )\n AND (\n $4::text IS NULL\n OR ($4 = 'none' AND CARDINALITY(project_types) = 0)\n OR ($4 = 'minecraft_java_server' AND project_types @> ARRAY['minecraft_java_server']::text[])\n OR ($4 <> 'none' AND $4 <> 'minecraft_java_server' AND project_types[1] = $4)\n )\n AND ($5::boolean IS NULL OR (external_dependencies_count > 0) = $5)\n ORDER BY\n CASE WHEN $6 = 'most_external_deps' THEN external_dependencies_count END DESC,\n CASE WHEN $6 = 'least_external_deps' THEN external_dependencies_count END ASC,\n CASE WHEN $6 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,\n CASE WHEN $6 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(queued, published) END ASC NULLS LAST,\n id ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "TextArray", + "Text", + "Text", + "Bool", + "Text", + "Bool" + ] + }, + "nullable": [ + false + ] + }, + "hash": "22fbd4bf1c2088903842b2c0070fd08431fc3f984263faed7aaeb311fa6ac2a9" +} diff --git a/apps/labrinth/.sqlx/query-5a68c53bd00c08edf9dfb9ffdabc28f86cdd77cd06bbb10c9a8e5199509f9c4b.json b/apps/labrinth/.sqlx/query-5a68c53bd00c08edf9dfb9ffdabc28f86cdd77cd06bbb10c9a8e5199509f9c4b.json new file mode 100644 index 0000000000..450ec6ad79 --- /dev/null +++ b/apps/labrinth/.sqlx/query-5a68c53bd00c08edf9dfb9ffdabc28f86cdd77cd06bbb10c9a8e5199509f9c4b.json @@ -0,0 +1,135 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH filtered_projects AS (\n SELECT\n m.id,\n m.queued,\n m.published\n FROM mods m\n WHERE\n m.status = $1\n AND (\n $6::boolean = false\n OR NOT EXISTS (\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.project_id = m.id\n AND didws.status = 'pending'\n )\n )\n ),\n total AS (\n SELECT COUNT(*) AS total_count FROM filtered_projects\n ),\n page_ids AS (\n SELECT\n id,\n queued,\n published\n FROM filtered_projects\n ORDER BY\n CASE WHEN $5 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,\n CASE WHEN $5 = 'oldest' THEN COALESCE(queued, published) END ASC NULLS LAST,\n id ASC\n OFFSET $4\n LIMIT $3\n ),\n page_project_types AS (\n SELECT\n v.mod_id,\n ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types\n FROM versions v\n INNER JOIN page_ids page ON page.id = v.mod_id\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n WHERE v.status = ANY($2)\n GROUP BY v.mod_id\n ),\n page_external_dependencies AS (\n SELECT\n v.mod_id,\n COUNT(*) AS external_dependencies_count\n FROM versions v\n INNER JOIN page_ids page ON page.id = v.mod_id\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE d.dependency_file_name IS NOT NULL\n GROUP BY v.mod_id\n ),\n page_projects AS (\n SELECT\n m.id,\n m.slug,\n m.name,\n m.summary,\n m.icon_url,\n m.status,\n m.requested_status,\n m.queued,\n m.published,\n m.updated,\n m.organization_id,\n o.name AS organization_name,\n o.icon_url AS organization_icon_url,\n owner.user_id AS owner_id,\n owner.username AS owner_name,\n owner.avatar_url AS owner_icon_url,\n CASE\n WHEN m.components ? 'minecraft_server'\n THEN ARRAY_APPEND(\n ARRAY_REMOVE(\n COALESCE(ppt.project_types::text[], ARRAY[]::text[]),\n 'modpack'\n ),\n 'minecraft_java_server'\n )\n ELSE COALESCE(ppt.project_types::text[], ARRAY[]::text[])\n END AS project_types,\n COALESCE(ped.external_dependencies_count, 0) AS external_dependencies_count\n FROM page_ids page\n INNER JOIN mods m ON m.id = page.id\n LEFT JOIN organizations o ON o.id = m.organization_id\n LEFT JOIN LATERAL (\n SELECT\n tm.user_id,\n u.username,\n u.avatar_url\n FROM team_members tm\n INNER JOIN users u ON u.id = tm.user_id\n WHERE tm.team_id = m.team_id\n AND tm.is_owner\n ORDER BY tm.ordering ASC\n LIMIT 1\n ) owner ON m.organization_id IS NULL\n LEFT JOIN page_project_types ppt ON ppt.mod_id = m.id\n LEFT JOIN page_external_dependencies ped ON ped.mod_id = m.id\n )\n SELECT\n total.total_count AS \"total_count!\",\n page_projects.id AS \"id?\",\n page_projects.slug AS \"slug?\",\n page_projects.name AS \"name?\",\n page_projects.summary AS \"summary?\",\n page_projects.icon_url AS \"icon_url?\",\n page_projects.status AS \"status?\",\n page_projects.requested_status AS \"requested_status?\",\n page_projects.queued AS \"queued?\",\n page_projects.published AS \"published?\",\n page_projects.updated AS \"updated?\",\n page_projects.organization_id AS \"organization_id?\",\n page_projects.organization_name AS \"organization_name?\",\n page_projects.organization_icon_url AS \"organization_icon_url?\",\n page_projects.owner_id AS \"owner_id?\",\n page_projects.owner_name AS \"owner_name?\",\n page_projects.owner_icon_url AS \"owner_icon_url?\",\n page_projects.project_types AS \"project_types?: Vec\",\n page_projects.external_dependencies_count AS \"external_dependencies_count?\"\n FROM total\n LEFT JOIN page_projects ON true\n ORDER BY\n CASE WHEN $5 = 'newest' THEN COALESCE(page_projects.queued, page_projects.published) END DESC NULLS LAST,\n CASE WHEN $5 = 'oldest' THEN COALESCE(page_projects.queued, page_projects.published) END ASC NULLS LAST,\n page_projects.id ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "total_count!", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "id?", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "slug?", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "name?", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "summary?", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "icon_url?", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "status?", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "requested_status?", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "queued?", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "published?", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated?", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "organization_id?", + "type_info": "Int8" + }, + { + "ordinal": 12, + "name": "organization_name?", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "organization_icon_url?", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "owner_id?", + "type_info": "Int8" + }, + { + "ordinal": 15, + "name": "owner_name?", + "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "owner_icon_url?", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "project_types?: Vec", + "type_info": "TextArray" + }, + { + "ordinal": 18, + "name": "external_dependencies_count?", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "TextArray", + "Int8", + "Int8", + "Text", + "Bool" + ] + }, + "nullable": [ + null, + false, + true, + false, + false, + true, + false, + true, + true, + false, + false, + true, + false, + true, + false, + false, + true, + null, + null + ] + }, + "hash": "5a68c53bd00c08edf9dfb9ffdabc28f86cdd77cd06bbb10c9a8e5199509f9c4b" +} diff --git a/apps/labrinth/.sqlx/query-ca8493b59004511e8d26200638951d55a61c2b6466db69a81be975e150b3ef41.json b/apps/labrinth/.sqlx/query-ca8493b59004511e8d26200638951d55a61c2b6466db69a81be975e150b3ef41.json new file mode 100644 index 0000000000..91c2728ad9 --- /dev/null +++ b/apps/labrinth/.sqlx/query-ca8493b59004511e8d26200638951d55a61c2b6466db69a81be975e150b3ef41.json @@ -0,0 +1,138 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH moderation_projects AS (\n SELECT\n m.id,\n m.slug,\n m.name,\n m.summary,\n m.description,\n m.queued,\n m.published,\n m.organization_id,\n m.team_id,\n m.components\n FROM mods m\n WHERE\n m.status = $1\n AND (\n $9::boolean = false\n OR NOT EXISTS (\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.project_id = m.id\n AND didws.status = 'pending'\n )\n )\n ),\n external_dependencies AS (\n SELECT\n v.mod_id,\n COUNT(*) AS external_dependencies_count\n FROM versions v\n INNER JOIN moderation_projects mp ON mp.id = v.mod_id\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE d.dependency_file_name IS NOT NULL\n GROUP BY v.mod_id\n ),\n version_project_types AS (\n SELECT\n v.mod_id,\n ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types\n FROM versions v\n INNER JOIN moderation_projects mp ON mp.id = v.mod_id\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n WHERE v.status = ANY($2)\n GROUP BY v.mod_id\n ),\n queue_projects AS (\n SELECT\n mp.id,\n mp.slug,\n mp.name,\n mp.summary,\n mp.description,\n mp.queued,\n mp.published,\n search_organization.name AS organization_name,\n search_owner.username AS owner_name,\n CASE\n WHEN mp.components ? 'minecraft_server'\n THEN ARRAY_APPEND(\n ARRAY_REMOVE(\n COALESCE(vpt.project_types::text[], ARRAY[]::text[]),\n 'modpack'\n ),\n 'minecraft_java_server'\n )\n ELSE COALESCE(vpt.project_types::text[], ARRAY[]::text[])\n END AS project_types,\n COALESCE(ed.external_dependencies_count, 0) AS external_dependencies_count\n FROM moderation_projects mp\n LEFT JOIN organizations search_organization\n ON search_organization.id = mp.organization_id\n AND $3::text IS NOT NULL\n LEFT JOIN LATERAL (\n SELECT\n u.username\n FROM team_members tm\n INNER JOIN users u ON u.id = tm.user_id\n WHERE tm.team_id = mp.team_id\n AND tm.is_owner\n ORDER BY tm.ordering ASC\n LIMIT 1\n ) search_owner ON $3::text IS NOT NULL\n AND mp.organization_id IS NULL\n LEFT JOIN external_dependencies ed ON ed.mod_id = mp.id\n LEFT JOIN version_project_types vpt ON vpt.mod_id = mp.id\n ),\n filtered_projects AS (\n SELECT\n id,\n queued,\n published,\n project_types,\n external_dependencies_count\n FROM queue_projects\n WHERE\n (\n $3::text IS NULL\n OR name ILIKE '%' || $3 || '%'\n OR slug ILIKE '%' || $3 || '%'\n OR summary ILIKE '%' || $3 || '%'\n OR description ILIKE '%' || $3 || '%'\n OR owner_name ILIKE '%' || $3 || '%'\n OR organization_name ILIKE '%' || $3 || '%'\n OR EXISTS (\n SELECT 1\n FROM UNNEST(project_types) AS searched_project_type(project_type)\n WHERE searched_project_type.project_type ILIKE '%' || $3 || '%'\n )\n )\n AND (\n $4::text IS NULL\n OR ($4 = 'none' AND CARDINALITY(project_types) = 0)\n OR ($4 = 'minecraft_java_server' AND project_types @> ARRAY['minecraft_java_server']::text[])\n OR ($4 <> 'none' AND $4 <> 'minecraft_java_server' AND\n project_types[1] = $4\n )\n )\n AND ($5::boolean IS NULL OR (external_dependencies_count > 0) = $5)\n ),\n total AS (\n SELECT COUNT(*) AS total_count FROM filtered_projects\n ),\n page_ids AS (\n SELECT\n id,\n queued,\n published,\n project_types,\n external_dependencies_count\n FROM filtered_projects\n ORDER BY\n CASE WHEN $8 = 'most_external_deps' THEN external_dependencies_count END DESC,\n CASE WHEN $8 = 'least_external_deps' THEN external_dependencies_count END ASC,\n CASE WHEN $8 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,\n CASE WHEN $8 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(queued, published) END ASC NULLS LAST,\n id ASC\n OFFSET $7\n LIMIT $6\n ),\n page_projects AS (\n SELECT\n m.id,\n m.slug,\n m.name,\n m.summary,\n m.icon_url,\n m.status,\n m.requested_status,\n m.queued,\n m.published,\n m.updated,\n m.organization_id,\n m.team_id,\n o.name AS organization_name,\n o.icon_url AS organization_icon_url,\n owner.user_id AS owner_id,\n owner.username AS owner_name,\n owner.avatar_url AS owner_icon_url,\n page_ids.project_types,\n page_ids.external_dependencies_count\n FROM page_ids\n INNER JOIN mods m ON m.id = page_ids.id\n LEFT JOIN organizations o ON o.id = m.organization_id\n LEFT JOIN LATERAL (\n SELECT\n tm.user_id,\n u.username,\n u.avatar_url\n FROM team_members tm\n INNER JOIN users u ON u.id = tm.user_id\n WHERE tm.team_id = m.team_id\n AND tm.is_owner\n ORDER BY tm.ordering ASC\n LIMIT 1\n ) owner ON m.organization_id IS NULL\n )\n SELECT\n total.total_count AS \"total_count!\",\n page_projects.id AS \"id?\",\n page_projects.slug AS \"slug?\",\n page_projects.name AS \"name?\",\n page_projects.summary AS \"summary?\",\n page_projects.icon_url AS \"icon_url?\",\n page_projects.status AS \"status?\",\n page_projects.requested_status AS \"requested_status?\",\n page_projects.queued AS \"queued?\",\n page_projects.published AS \"published?\",\n page_projects.updated AS \"updated?\",\n page_projects.organization_id AS \"organization_id?\",\n page_projects.organization_name AS \"organization_name?\",\n page_projects.organization_icon_url AS \"organization_icon_url?\",\n page_projects.owner_id AS \"owner_id?\",\n page_projects.owner_name AS \"owner_name?\",\n page_projects.owner_icon_url AS \"owner_icon_url?\",\n page_projects.project_types AS \"project_types?: Vec\",\n page_projects.external_dependencies_count AS \"external_dependencies_count?\"\n FROM total\n LEFT JOIN page_projects ON true\n ORDER BY\n CASE WHEN $8 = 'most_external_deps' THEN page_projects.external_dependencies_count END DESC,\n CASE WHEN $8 = 'least_external_deps' THEN page_projects.external_dependencies_count END ASC,\n CASE WHEN $8 = 'newest' THEN COALESCE(page_projects.queued, page_projects.published) END DESC NULLS LAST,\n CASE WHEN $8 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(page_projects.queued, page_projects.published) END ASC NULLS LAST,\n page_projects.id ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "total_count!", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "id?", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "slug?", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "name?", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "summary?", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "icon_url?", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "status?", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "requested_status?", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "queued?", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "published?", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated?", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "organization_id?", + "type_info": "Int8" + }, + { + "ordinal": 12, + "name": "organization_name?", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "organization_icon_url?", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "owner_id?", + "type_info": "Int8" + }, + { + "ordinal": 15, + "name": "owner_name?", + "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "owner_icon_url?", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "project_types?: Vec", + "type_info": "TextArray" + }, + { + "ordinal": 18, + "name": "external_dependencies_count?", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "TextArray", + "Text", + "Text", + "Bool", + "Int8", + "Int8", + "Text", + "Bool" + ] + }, + "nullable": [ + null, + false, + true, + false, + false, + true, + false, + true, + true, + false, + false, + true, + false, + true, + false, + false, + true, + null, + null + ] + }, + "hash": "ca8493b59004511e8d26200638951d55a61c2b6466db69a81be975e150b3ef41" +} diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index ac0a212f29..e407df1972 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -101,6 +101,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { pats::edit_pat, pats::delete_pat, moderation::get_projects, + moderation::get_project_ids, moderation::get_project_meta, moderation::set_project_meta, moderation::acquire_lock, diff --git a/apps/labrinth/src/routes/internal/moderation/mod.rs b/apps/labrinth/src/routes/internal/moderation/mod.rs index b230830cf5..7b3dac3ca1 100644 --- a/apps/labrinth/src/routes/internal/moderation/mod.rs +++ b/apps/labrinth/src/routes/internal/moderation/mod.rs @@ -5,8 +5,8 @@ use crate::database::PgPool; use crate::database::models::DBModerationLock; use crate::database::models::moderation_external_item; use crate::database::redis::RedisPool; -use crate::models::ids::OrganizationId; -use crate::models::projects::{Project, ProjectStatus}; +use crate::models::ids::{OrganizationId, ProjectId}; +use crate::models::projects::{ProjectStatus, VersionStatus}; use crate::queue::moderation::{ApprovalType, IdentifiedFile, MissingMetadata}; use crate::queue::session::AuthQueue; use crate::util::error::Context; @@ -17,7 +17,7 @@ use crate::{ use actix_web::{HttpRequest, delete, get, post, web}; use ariadne::ids::{UserId, random_base62}; use chrono::{DateTime, Utc}; -use ownership::get_projects_ownership; +use eyre::eyre; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -27,6 +27,7 @@ pub mod tech_review; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(get_projects) + .service(get_project_ids) .service(get_project_meta) .service(set_project_meta) .service(acquire_lock) @@ -52,19 +53,74 @@ pub struct ProjectsRequestOptions { /// Whether to filter by modpacks that have external dependencies. #[serde(default)] pub has_external_dependencies: Option, + /// Whether to exclude projects with pending technical review issues. + #[serde(default)] + pub exclude_technical_review: bool, + /// Text query to search against project and owner fields. + #[serde(default)] + pub query: Option, + /// Project type to filter by. Use `none` for projects without a type. + #[serde(default)] + pub project_type: Option, + /// Sort order for the moderation queue. + #[serde(default)] + pub sort: Option, } fn default_count() -> u16 { 100 } -/// Project with extra information fetched from the database, to avoid having -/// clients make more round trips. +const MAX_PROJECTS_PER_PAGE: u16 = 200; + +#[derive( + Debug, Clone, Copy, Serialize, Deserialize, utoipa::ToSchema, Default, +)] +#[serde(rename_all = "snake_case")] +pub enum ModerationProjectsSort { + #[default] + Oldest, + Newest, + MostExternalDeps, + LeastExternalDeps, +} + +impl ModerationProjectsSort { + fn as_str(self) -> &'static str { + match self { + Self::Oldest => "oldest", + Self::Newest => "newest", + Self::MostExternalDeps => "most_external_deps", + Self::LeastExternalDeps => "least_external_deps", + } + } +} + #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] -pub struct FetchedProject { - /// Project info. - #[serde(flatten)] - pub project: Project, +pub struct ModerationProjectsResponse { + pub total: i64, + pub projects: Vec, +} + +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ModerationProjectIdsResponse { + pub ids: Vec, +} + +/// Lightweight project information for the moderation queue index. +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ModerationQueueProject { + pub id: ProjectId, + pub slug: Option, + pub name: String, + pub summary: String, + pub icon_url: Option, + pub status: ProjectStatus, + pub requested_status: Option, + pub queued: Option>, + pub published: DateTime, + pub updated: DateTime, + pub project_types: Vec, /// Who owns the project. pub ownership: Ownership, /// How many external file dependencies the project has. @@ -162,12 +218,16 @@ pub struct DeleteAllLocksResponse { #[utoipa::path( context_path = "/moderation", tag = "moderation", - params( + params( ("count" = Option, Query), ("offset" = Option, Query), - ("has_external_dependencies" = Option, Query) + ("has_external_dependencies" = Option, Query), + ("exclude_technical_review" = bool, Query), + ("query" = Option, Query), + ("project_type" = Option, Query), + ("sort" = Option, Query) ), - responses((status = OK, body = inline(Vec))) + responses((status = OK, body = ModerationProjectsResponse)) )] #[get("/projects")] pub async fn get_projects( @@ -176,7 +236,7 @@ pub async fn get_projects( redis: web::Data, request_opts: web::Query, session_queue: web::Data, -) -> Result>, ApiError> { +) -> Result, ApiError> { get_projects_internal(req, pool, redis, request_opts, session_queue).await } @@ -186,7 +246,7 @@ pub async fn get_projects_internal( redis: web::Data, request_opts: web::Query, session_queue: web::Data, -) -> Result>, ApiError> { +) -> Result, ApiError> { check_is_moderator_from_headers( &req, &**pool, @@ -196,102 +256,803 @@ pub async fn get_projects_internal( ) .await?; - use futures::stream::TryStreamExt; + let request_opts = request_opts.into_inner(); + let query = normalize_optional_string(request_opts.query.as_deref()); + let project_type = + normalize_optional_string(request_opts.project_type.as_deref()); + let sort = request_opts.sort.unwrap_or_default().as_str(); + let listed_version_statuses = listed_version_statuses(); + let count = request_opts.count.min(MAX_PROJECTS_PER_PAGE) as i64; + let offset = request_opts.offset as i64; + let exclude_technical_review = request_opts.exclude_technical_review; + let needs_filtered_queue = query.is_some() + || project_type.is_some() + || request_opts.has_external_dependencies.is_some() + || matches!(sort, "most_external_deps" | "least_external_deps"); - let project_rows = sqlx::query!( - r#" - SELECT - id, - external_dependencies_count as "external_dependencies_count!" - FROM ( - SELECT DISTINCT ON (m.id) - m.id, - m.queued, - ( - SELECT COUNT(*) - FROM versions v - INNER JOIN dependencies d ON d.dependent_id = v.id - WHERE v.mod_id = m.id - AND d.dependency_file_name IS NOT NULL - ) external_dependencies_count - FROM mods m - - /* -- Temporarily, don't exclude projects in tech rev q - - -- exclude projects in tech review queue - LEFT JOIN delphi_issue_details_with_statuses didws - ON didws.project_id = m.id AND didws.status = 'pending' - */ - - WHERE - m.status = $1 - /* AND didws.status IS NULL */ -- Temporarily don't exclude - - GROUP BY m.id - ) t - WHERE - ($4::boolean IS NULL OR (external_dependencies_count > 0) = $4) - ORDER BY queued ASC - OFFSET $3 - LIMIT $2 - "#, - ProjectStatus::Processing.as_str(), - request_opts.count as i64, - request_opts.offset as i64, - request_opts.has_external_dependencies, - ) - .fetch(&**pool) - .try_collect::>() - .await - .wrap_internal_err("failed to fetch projects awaiting review")?; - - let project_ids = project_rows - .iter() - .map(|m| database::models::DBProjectId(m.id)) - .collect::>(); - let project_metadata = project_rows - .into_iter() - .map(|m| { - ( - database::models::DBProjectId(m.id), - m.external_dependencies_count, + let (total, projects) = if needs_filtered_queue { + let project_rows = sqlx::query!( + r#" + WITH moderation_projects AS ( + SELECT + m.id, + m.slug, + m.name, + m.summary, + m.description, + m.queued, + m.published, + m.organization_id, + m.team_id, + m.components + FROM mods m + WHERE + m.status = $1 + AND ( + $9::boolean = false + OR NOT EXISTS ( + SELECT 1 + FROM delphi_issue_details_with_statuses didws + WHERE didws.project_id = m.id + AND didws.status = 'pending' + ) + ) + ), + external_dependencies AS ( + SELECT + v.mod_id, + COUNT(*) AS external_dependencies_count + FROM versions v + INNER JOIN moderation_projects mp ON mp.id = v.mod_id + INNER JOIN dependencies d ON d.dependent_id = v.id + WHERE d.dependency_file_name IS NOT NULL + GROUP BY v.mod_id + ), + version_project_types AS ( + SELECT + v.mod_id, + ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types + FROM versions v + INNER JOIN moderation_projects mp ON mp.id = v.mod_id + INNER JOIN loaders_versions lv ON v.id = lv.version_id + INNER JOIN loaders l ON lv.loader_id = l.id + INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id + INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id + WHERE v.status = ANY($2) + GROUP BY v.mod_id + ), + queue_projects AS ( + SELECT + mp.id, + mp.slug, + mp.name, + mp.summary, + mp.description, + mp.queued, + mp.published, + search_organization.name AS organization_name, + search_owner.username AS owner_name, + CASE + WHEN mp.components ? 'minecraft_server' + THEN ARRAY_APPEND( + ARRAY_REMOVE( + COALESCE(vpt.project_types::text[], ARRAY[]::text[]), + 'modpack' + ), + 'minecraft_java_server' + ) + ELSE COALESCE(vpt.project_types::text[], ARRAY[]::text[]) + END AS project_types, + COALESCE(ed.external_dependencies_count, 0) AS external_dependencies_count + FROM moderation_projects mp + LEFT JOIN organizations search_organization + ON search_organization.id = mp.organization_id + AND $3::text IS NOT NULL + LEFT JOIN LATERAL ( + SELECT + u.username + FROM team_members tm + INNER JOIN users u ON u.id = tm.user_id + WHERE tm.team_id = mp.team_id + AND tm.is_owner + ORDER BY tm.ordering ASC + LIMIT 1 + ) search_owner ON $3::text IS NOT NULL + AND mp.organization_id IS NULL + LEFT JOIN external_dependencies ed ON ed.mod_id = mp.id + LEFT JOIN version_project_types vpt ON vpt.mod_id = mp.id + ), + filtered_projects AS ( + SELECT + id, + queued, + published, + project_types, + external_dependencies_count + FROM queue_projects + WHERE + ( + $3::text IS NULL + OR name ILIKE '%' || $3 || '%' + OR slug ILIKE '%' || $3 || '%' + OR summary ILIKE '%' || $3 || '%' + OR description ILIKE '%' || $3 || '%' + OR owner_name ILIKE '%' || $3 || '%' + OR organization_name ILIKE '%' || $3 || '%' + OR EXISTS ( + SELECT 1 + FROM UNNEST(project_types) AS searched_project_type(project_type) + WHERE searched_project_type.project_type ILIKE '%' || $3 || '%' + ) + ) + AND ( + $4::text IS NULL + OR ($4 = 'none' AND CARDINALITY(project_types) = 0) + OR ($4 = 'minecraft_java_server' AND project_types @> ARRAY['minecraft_java_server']::text[]) + OR ($4 <> 'none' AND $4 <> 'minecraft_java_server' AND + project_types[1] = $4 + ) + ) + AND ($5::boolean IS NULL OR (external_dependencies_count > 0) = $5) + ), + total AS ( + SELECT COUNT(*) AS total_count FROM filtered_projects + ), + page_ids AS ( + SELECT + id, + queued, + published, + project_types, + external_dependencies_count + FROM filtered_projects + ORDER BY + CASE WHEN $8 = 'most_external_deps' THEN external_dependencies_count END DESC, + CASE WHEN $8 = 'least_external_deps' THEN external_dependencies_count END ASC, + CASE WHEN $8 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST, + CASE WHEN $8 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(queued, published) END ASC NULLS LAST, + id ASC + OFFSET $7 + LIMIT $6 + ), + page_projects AS ( + SELECT + m.id, + m.slug, + m.name, + m.summary, + m.icon_url, + m.status, + m.requested_status, + m.queued, + m.published, + m.updated, + m.organization_id, + m.team_id, + o.name AS organization_name, + o.icon_url AS organization_icon_url, + owner.user_id AS owner_id, + owner.username AS owner_name, + owner.avatar_url AS owner_icon_url, + page_ids.project_types, + page_ids.external_dependencies_count + FROM page_ids + INNER JOIN mods m ON m.id = page_ids.id + LEFT JOIN organizations o ON o.id = m.organization_id + LEFT JOIN LATERAL ( + SELECT + tm.user_id, + u.username, + u.avatar_url + FROM team_members tm + INNER JOIN users u ON u.id = tm.user_id + WHERE tm.team_id = m.team_id + AND tm.is_owner + ORDER BY tm.ordering ASC + LIMIT 1 + ) owner ON m.organization_id IS NULL ) - }) - .collect::>(); - - let projects = - database::DBProject::get_many_ids(&project_ids, &**pool, &redis) - .await - .wrap_internal_err("failed to fetch projects")? - .into_iter() - .map(crate::models::projects::Project::from) - .collect::>(); - - let ownerships = get_projects_ownership(&projects, &pool, &redis) + SELECT + total.total_count AS "total_count!", + page_projects.id AS "id?", + page_projects.slug AS "slug?", + page_projects.name AS "name?", + page_projects.summary AS "summary?", + page_projects.icon_url AS "icon_url?", + page_projects.status AS "status?", + page_projects.requested_status AS "requested_status?", + page_projects.queued AS "queued?", + page_projects.published AS "published?", + page_projects.updated AS "updated?", + page_projects.organization_id AS "organization_id?", + page_projects.organization_name AS "organization_name?", + page_projects.organization_icon_url AS "organization_icon_url?", + page_projects.owner_id AS "owner_id?", + page_projects.owner_name AS "owner_name?", + page_projects.owner_icon_url AS "owner_icon_url?", + page_projects.project_types AS "project_types?: Vec", + page_projects.external_dependencies_count AS "external_dependencies_count?" + FROM total + LEFT JOIN page_projects ON true + ORDER BY + CASE WHEN $8 = 'most_external_deps' THEN page_projects.external_dependencies_count END DESC, + CASE WHEN $8 = 'least_external_deps' THEN page_projects.external_dependencies_count END ASC, + CASE WHEN $8 = 'newest' THEN COALESCE(page_projects.queued, page_projects.published) END DESC NULLS LAST, + CASE WHEN $8 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(page_projects.queued, page_projects.published) END ASC NULLS LAST, + page_projects.id ASC + "#, + ProjectStatus::Processing.as_str(), + &listed_version_statuses, + query, + project_type, + request_opts.has_external_dependencies, + count, + offset, + sort, + exclude_technical_review, + ) + .fetch_all(&**pool) .await - .wrap_internal_err("failed to fetch project ownerships")?; + .wrap_internal_err("failed to fetch filtered projects awaiting review")?; - let map_project = - |(project, ownership): (Project, Ownership)| -> FetchedProject { - let external_dependencies_count = project_metadata - .get(&database::models::DBProjectId(project.id.0 as i64)) - .copied() - .unwrap_or_default(); - - FetchedProject { - ownership, - project, - external_dependencies_count, + let total = + project_rows.first().map(|row| row.total_count).unwrap_or(0); + let mut projects = Vec::new(); + for row in project_rows { + if let Some(project) = row_to_queue_project( + row.id, + row.slug, + row.name, + row.summary, + row.icon_url, + row.status, + row.requested_status, + row.queued, + row.published, + row.updated, + row.organization_id, + row.organization_name, + row.organization_icon_url, + row.owner_id, + row.owner_name, + row.owner_icon_url, + row.project_types, + row.external_dependencies_count, + )? { + projects.push(project); } - }; + } - let projects = projects - .into_iter() - .zip(ownerships) - .map(map_project) - .collect::>(); + (total, projects) + } else { + let project_rows = sqlx::query!( + r#" + WITH filtered_projects AS ( + SELECT + m.id, + m.queued, + m.published + FROM mods m + WHERE + m.status = $1 + AND ( + $6::boolean = false + OR NOT EXISTS ( + SELECT 1 + FROM delphi_issue_details_with_statuses didws + WHERE didws.project_id = m.id + AND didws.status = 'pending' + ) + ) + ), + total AS ( + SELECT COUNT(*) AS total_count FROM filtered_projects + ), + page_ids AS ( + SELECT + id, + queued, + published + FROM filtered_projects + ORDER BY + CASE WHEN $5 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST, + CASE WHEN $5 = 'oldest' THEN COALESCE(queued, published) END ASC NULLS LAST, + id ASC + OFFSET $4 + LIMIT $3 + ), + page_project_types AS ( + SELECT + v.mod_id, + ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types + FROM versions v + INNER JOIN page_ids page ON page.id = v.mod_id + INNER JOIN loaders_versions lv ON v.id = lv.version_id + INNER JOIN loaders l ON lv.loader_id = l.id + INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id + INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id + WHERE v.status = ANY($2) + GROUP BY v.mod_id + ), + page_external_dependencies AS ( + SELECT + v.mod_id, + COUNT(*) AS external_dependencies_count + FROM versions v + INNER JOIN page_ids page ON page.id = v.mod_id + INNER JOIN dependencies d ON d.dependent_id = v.id + WHERE d.dependency_file_name IS NOT NULL + GROUP BY v.mod_id + ), + page_projects AS ( + SELECT + m.id, + m.slug, + m.name, + m.summary, + m.icon_url, + m.status, + m.requested_status, + m.queued, + m.published, + m.updated, + m.organization_id, + o.name AS organization_name, + o.icon_url AS organization_icon_url, + owner.user_id AS owner_id, + owner.username AS owner_name, + owner.avatar_url AS owner_icon_url, + CASE + WHEN m.components ? 'minecraft_server' + THEN ARRAY_APPEND( + ARRAY_REMOVE( + COALESCE(ppt.project_types::text[], ARRAY[]::text[]), + 'modpack' + ), + 'minecraft_java_server' + ) + ELSE COALESCE(ppt.project_types::text[], ARRAY[]::text[]) + END AS project_types, + COALESCE(ped.external_dependencies_count, 0) AS external_dependencies_count + FROM page_ids page + INNER JOIN mods m ON m.id = page.id + LEFT JOIN organizations o ON o.id = m.organization_id + LEFT JOIN LATERAL ( + SELECT + tm.user_id, + u.username, + u.avatar_url + FROM team_members tm + INNER JOIN users u ON u.id = tm.user_id + WHERE tm.team_id = m.team_id + AND tm.is_owner + ORDER BY tm.ordering ASC + LIMIT 1 + ) owner ON m.organization_id IS NULL + LEFT JOIN page_project_types ppt ON ppt.mod_id = m.id + LEFT JOIN page_external_dependencies ped ON ped.mod_id = m.id + ) + SELECT + total.total_count AS "total_count!", + page_projects.id AS "id?", + page_projects.slug AS "slug?", + page_projects.name AS "name?", + page_projects.summary AS "summary?", + page_projects.icon_url AS "icon_url?", + page_projects.status AS "status?", + page_projects.requested_status AS "requested_status?", + page_projects.queued AS "queued?", + page_projects.published AS "published?", + page_projects.updated AS "updated?", + page_projects.organization_id AS "organization_id?", + page_projects.organization_name AS "organization_name?", + page_projects.organization_icon_url AS "organization_icon_url?", + page_projects.owner_id AS "owner_id?", + page_projects.owner_name AS "owner_name?", + page_projects.owner_icon_url AS "owner_icon_url?", + page_projects.project_types AS "project_types?: Vec", + page_projects.external_dependencies_count AS "external_dependencies_count?" + FROM total + LEFT JOIN page_projects ON true + ORDER BY + CASE WHEN $5 = 'newest' THEN COALESCE(page_projects.queued, page_projects.published) END DESC NULLS LAST, + CASE WHEN $5 = 'oldest' THEN COALESCE(page_projects.queued, page_projects.published) END ASC NULLS LAST, + page_projects.id ASC + "#, + ProjectStatus::Processing.as_str(), + &listed_version_statuses, + count, + offset, + sort, + exclude_technical_review, + ) + .fetch_all(&**pool) + .await + .wrap_internal_err("failed to fetch projects awaiting review")?; - Ok(web::Json(projects)) + let total = + project_rows.first().map(|row| row.total_count).unwrap_or(0); + let mut projects = Vec::new(); + for row in project_rows { + if let Some(project) = row_to_queue_project( + row.id, + row.slug, + row.name, + row.summary, + row.icon_url, + row.status, + row.requested_status, + row.queued, + row.published, + row.updated, + row.organization_id, + row.organization_name, + row.organization_icon_url, + row.owner_id, + row.owner_name, + row.owner_icon_url, + row.project_types, + row.external_dependencies_count, + )? { + projects.push(project); + } + } + + (total, projects) + }; + + Ok(web::Json(ModerationProjectsResponse { total, projects })) +} + +#[utoipa::path( + context_path = "/moderation", + tag = "moderation", + params( + ("has_external_dependencies" = Option, Query), + ("exclude_technical_review" = bool, Query), + ("query" = Option, Query), + ("project_type" = Option, Query), + ("sort" = Option, Query) + ), + responses((status = OK, body = ModerationProjectIdsResponse)) +)] +#[get("/projects/ids")] +pub async fn get_project_ids( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + request_opts: web::Query, + session_queue: web::Data, +) -> Result, ApiError> { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_READ, + ) + .await?; + + let request_opts = request_opts.into_inner(); + let query = normalize_optional_string(request_opts.query.as_deref()); + let project_type = + normalize_optional_string(request_opts.project_type.as_deref()); + let sort = request_opts.sort.unwrap_or_default().as_str(); + let listed_version_statuses = listed_version_statuses(); + let exclude_technical_review = request_opts.exclude_technical_review; + let needs_filtered_queue = query.is_some() + || project_type.is_some() + || request_opts.has_external_dependencies.is_some() + || matches!(sort, "most_external_deps" | "least_external_deps"); + + let ids = if needs_filtered_queue { + let project_rows = sqlx::query!( + r#" + WITH moderation_projects AS ( + SELECT + m.id, + m.slug, + m.name, + m.summary, + m.description, + m.queued, + m.published, + m.organization_id, + m.team_id, + m.components + FROM mods m + WHERE + m.status = $1 + AND ( + $7::boolean = false + OR NOT EXISTS ( + SELECT 1 + FROM delphi_issue_details_with_statuses didws + WHERE didws.project_id = m.id + AND didws.status = 'pending' + ) + ) + ), + external_dependencies AS ( + SELECT + v.mod_id, + COUNT(*) AS external_dependencies_count + FROM versions v + INNER JOIN moderation_projects mp ON mp.id = v.mod_id + INNER JOIN dependencies d ON d.dependent_id = v.id + WHERE d.dependency_file_name IS NOT NULL + GROUP BY v.mod_id + ), + version_project_types AS ( + SELECT + v.mod_id, + ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types + FROM versions v + INNER JOIN moderation_projects mp ON mp.id = v.mod_id + INNER JOIN loaders_versions lv ON v.id = lv.version_id + INNER JOIN loaders l ON lv.loader_id = l.id + INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id + INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id + WHERE v.status = ANY($2) + GROUP BY v.mod_id + ), + queue_projects AS ( + SELECT + mp.id, + mp.slug, + mp.name, + mp.summary, + mp.description, + mp.queued, + mp.published, + search_organization.name AS organization_name, + search_owner.username AS owner_name, + CASE + WHEN mp.components ? 'minecraft_server' + THEN ARRAY_APPEND( + ARRAY_REMOVE( + COALESCE(vpt.project_types::text[], ARRAY[]::text[]), + 'modpack' + ), + 'minecraft_java_server' + ) + ELSE COALESCE(vpt.project_types::text[], ARRAY[]::text[]) + END AS project_types, + COALESCE(ed.external_dependencies_count, 0) AS external_dependencies_count + FROM moderation_projects mp + LEFT JOIN organizations search_organization + ON search_organization.id = mp.organization_id + AND $3::text IS NOT NULL + LEFT JOIN LATERAL ( + SELECT + u.username + FROM team_members tm + INNER JOIN users u ON u.id = tm.user_id + WHERE tm.team_id = mp.team_id + AND tm.is_owner + ORDER BY tm.ordering ASC + LIMIT 1 + ) search_owner ON $3::text IS NOT NULL + AND mp.organization_id IS NULL + LEFT JOIN external_dependencies ed ON ed.mod_id = mp.id + LEFT JOIN version_project_types vpt ON vpt.mod_id = mp.id + ) + SELECT id + FROM queue_projects + WHERE + ( + $3::text IS NULL + OR name ILIKE '%' || $3 || '%' + OR slug ILIKE '%' || $3 || '%' + OR summary ILIKE '%' || $3 || '%' + OR description ILIKE '%' || $3 || '%' + OR owner_name ILIKE '%' || $3 || '%' + OR organization_name ILIKE '%' || $3 || '%' + OR EXISTS ( + SELECT 1 + FROM UNNEST(project_types) AS searched_project_type(project_type) + WHERE searched_project_type.project_type ILIKE '%' || $3 || '%' + ) + ) + AND ( + $4::text IS NULL + OR ($4 = 'none' AND CARDINALITY(project_types) = 0) + OR ($4 = 'minecraft_java_server' AND project_types @> ARRAY['minecraft_java_server']::text[]) + OR ($4 <> 'none' AND $4 <> 'minecraft_java_server' AND project_types[1] = $4) + ) + AND ($5::boolean IS NULL OR (external_dependencies_count > 0) = $5) + ORDER BY + CASE WHEN $6 = 'most_external_deps' THEN external_dependencies_count END DESC, + CASE WHEN $6 = 'least_external_deps' THEN external_dependencies_count END ASC, + CASE WHEN $6 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST, + CASE WHEN $6 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(queued, published) END ASC NULLS LAST, + id ASC + "#, + ProjectStatus::Processing.as_str(), + &listed_version_statuses, + query, + project_type, + request_opts.has_external_dependencies, + sort, + exclude_technical_review, + ) + .fetch_all(&**pool) + .await + .wrap_internal_err("failed to fetch filtered project ids awaiting review")?; + + project_rows + .into_iter() + .map(|row| ProjectId::from(database::models::DBProjectId(row.id))) + .collect() + } else { + let project_rows = sqlx::query!( + r#" + SELECT id + FROM mods + WHERE + status = $1 + AND ( + $3::boolean = false + OR NOT EXISTS ( + SELECT 1 + FROM delphi_issue_details_with_statuses didws + WHERE didws.project_id = mods.id + AND didws.status = 'pending' + ) + ) + ORDER BY + CASE WHEN $2 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST, + CASE WHEN $2 = 'oldest' THEN COALESCE(queued, published) END ASC NULLS LAST, + id ASC + "#, + ProjectStatus::Processing.as_str(), + sort, + exclude_technical_review, + ) + .fetch_all(&**pool) + .await + .wrap_internal_err("failed to fetch project ids awaiting review")?; + + project_rows + .into_iter() + .map(|row| ProjectId::from(database::models::DBProjectId(row.id))) + .collect() + }; + + Ok(web::Json(ModerationProjectIdsResponse { ids })) +} + +fn row_to_queue_project( + id: Option, + slug: Option, + name: Option, + summary: Option, + icon_url: Option, + status: Option, + requested_status: Option, + queued: Option>, + published: Option>, + updated: Option>, + organization_id: Option, + organization_name: Option, + organization_icon_url: Option, + owner_id: Option, + owner_name: Option, + owner_icon_url: Option, + project_types: Option>, + external_dependencies_count: Option, +) -> Result, ApiError> { + let Some(id) = id else { + return Ok(None); + }; + + let project_id = ProjectId::from(database::models::DBProjectId(id)); + let name = name.wrap_internal_err_with(|| { + eyre!("project {project_id} is missing `name` in moderation queue row") + })?; + let summary = summary.wrap_internal_err_with(|| { + eyre!( + "project {project_id} is missing `summary` in moderation queue row" + ) + })?; + let status = status.wrap_internal_err_with(|| { + eyre!( + "project {project_id} is missing `status` in moderation queue row" + ) + })?; + let published = published.wrap_internal_err_with(|| { + eyre!( + "project {project_id} is missing `published` in moderation queue row" + ) + })?; + let updated = updated.wrap_internal_err_with(|| { + eyre!( + "project {project_id} is missing `updated` in moderation queue row" + ) + })?; + let ownership = row_to_ownership( + project_id, + organization_id, + organization_name, + organization_icon_url, + owner_id, + owner_name, + owner_icon_url, + )?; + + Ok(Some(ModerationQueueProject { + id: project_id, + slug, + name, + summary, + icon_url, + status: ProjectStatus::from_string(&status), + requested_status: requested_status + .as_deref() + .map(ProjectStatus::from_string), + queued, + published, + updated, + project_types: project_types.unwrap_or_default(), + ownership, + external_dependencies_count: external_dependencies_count.unwrap_or(0), + })) +} + +fn normalize_optional_string(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +fn listed_version_statuses() -> Vec { + VersionStatus::iterator() + .filter(|status| status.is_listed()) + .map(|status| status.as_str().to_string()) + .collect() +} + +fn row_to_ownership( + project_id: ProjectId, + organization_id: Option, + organization_name: Option, + organization_icon_url: Option, + owner_id: Option, + owner_name: Option, + owner_icon_url: Option, +) -> Result { + if let Some(organization_id) = organization_id { + let organization_name = + organization_name.wrap_internal_err_with(|| { + eyre!( + "project {project_id} is owned by organization {} without a valid name", + OrganizationId::from(database::models::DBOrganizationId( + organization_id + )) + ) + })?; + + return Ok(Ownership::Organization { + id: OrganizationId::from(database::models::DBOrganizationId( + organization_id, + )), + name: organization_name, + icon_url: organization_icon_url, + }); + } + + let owner_id = owner_id.wrap_internal_err_with(|| { + eyre!("project {project_id} is owned by a team without a valid owner") + })?; + let owner_name = owner_name.wrap_internal_err_with(|| { + eyre!( + "project {project_id} is owned by a team owner without a valid name" + ) + })?; + + Ok(Ownership::User { + id: UserId::from(database::models::DBUserId(owner_id)), + name: owner_name, + icon_url: owner_icon_url, + }) } /// Get project moderation metadata. diff --git a/apps/labrinth/src/routes/v2/mod.rs b/apps/labrinth/src/routes/v2/mod.rs index 0738423db8..5c55d5c9be 100644 --- a/apps/labrinth/src/routes/v2/mod.rs +++ b/apps/labrinth/src/routes/v2/mod.rs @@ -1,4 +1,3 @@ -pub(crate) mod moderation; mod notifications; mod openapi; pub(crate) mod project_creation; @@ -26,7 +25,6 @@ pub fn config(cfg: &mut web::ServiceConfig) { .configure(super::internal::flows::config) .configure(super::internal::pats::config) .configure(super::internal::admin::config) - .configure(moderation::config) .configure(notifications::config) .configure(project_creation::config) .configure(projects::config) diff --git a/apps/labrinth/src/routes/v2/moderation.rs b/apps/labrinth/src/routes/v2/moderation.rs deleted file mode 100644 index 61b00e6a9d..0000000000 --- a/apps/labrinth/src/routes/v2/moderation.rs +++ /dev/null @@ -1,79 +0,0 @@ -use super::ApiError; -use crate::database::PgPool; -use crate::models::projects::Project; -use crate::models::v2::projects::LegacyProject; -use crate::queue::session::AuthQueue; -use crate::routes::internal; -use crate::{database::redis::RedisPool, routes::v2_reroute}; -use actix_web::{HttpRequest, HttpResponse, get, web}; -use serde::Deserialize; - -pub fn config(cfg: &mut actix_web::web::ServiceConfig) { - cfg.service(web::scope("/moderation").service(get_projects)); -} - -#[derive(Deserialize)] -pub struct ResultCount { - #[serde(default = "default_count")] - pub count: u16, -} - -fn default_count() -> u16 { - 100 -} - -/// List projects in the moderation queue. -#[utoipa::path( - context_path = "/moderation", - tag = "v2 moderation", - get, - operation_id = "getModerationProjects", - params( - ("count" = Option, Query, description = "Maximum number of projects to return") - ), - responses( - (status = 200, description = "Expected response to a valid request", body = Vec), - ( - status = 401, - description = "Incorrect token scopes or no authorization to access the requested item(s)" - ), - ( - status = 404, - description = "The requested item(s) were not found or no authorization to access the requested item(s)" - ) - ), - security(("bearer_auth" = ["PROJECT_READ"])) -)] -#[get("/projects")] -pub async fn get_projects( - req: HttpRequest, - pool: web::Data, - redis: web::Data, - count: web::Query, - session_queue: web::Data, -) -> Result { - let response = internal::moderation::get_projects_internal( - req, - pool.clone(), - redis.clone(), - web::Query(internal::moderation::ProjectsRequestOptions { - count: count.count, - offset: 0, - has_external_dependencies: None, - }), - session_queue, - ) - .await - .map(|resp| HttpResponse::Ok().json(resp)) - .or_else(v2_reroute::flatten_404_error)?; - - // Convert to V2 projects - match v2_reroute::extract_ok_json::>(response).await { - Ok(project) => { - let legacy_projects = - LegacyProject::from_many(project, &**pool, &redis).await?; - Ok(HttpResponse::Ok().json(legacy_projects)) - } - Err(response) => Ok(response), - } -} diff --git a/packages/api-client/src/modules/labrinth/moderation/internal.ts b/packages/api-client/src/modules/labrinth/moderation/internal.ts index a03b1ce7fe..33328f9969 100644 --- a/packages/api-client/src/modules/labrinth/moderation/internal.ts +++ b/packages/api-client/src/modules/labrinth/moderation/internal.ts @@ -6,6 +6,34 @@ export class LabrinthModerationInternalModule extends AbstractModule { return 'labrinth_moderation_internal' } + public async getProjects( + params: Labrinth.Moderation.Internal.ProjectsRequest = {}, + ): Promise { + return this.client.request( + '/moderation/projects', + { + api: 'labrinth', + version: 'internal', + method: 'GET', + params, + }, + ) + } + + public async getProjectIds( + params: Omit = {}, + ): Promise { + return this.client.request( + '/moderation/projects/ids', + { + api: 'labrinth', + version: 'internal', + method: 'GET', + params, + }, + ) + } + public async acquireLock( projectId: string, ): Promise { diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index 056508282b..c7e8da75fb 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -1914,6 +1914,57 @@ export namespace Labrinth { export namespace Moderation { export namespace Internal { + export type Ownership = + | { + kind: 'user' + id: string + name: string + icon_url: string | null + } + | { + kind: 'organization' + id: string + name: string + icon_url: string | null + } + + export type ProjectsSort = 'oldest' | 'newest' | 'most_external_deps' | 'least_external_deps' + + export type ProjectsRequest = { + count?: number + offset?: number + has_external_dependencies?: boolean + exclude_technical_review?: boolean + query?: string + project_type?: string + sort?: ProjectsSort + } + + export type QueueProject = { + id: string + slug: string | null + name: string + summary: string + icon_url: string | null + status: Projects.v2.ProjectStatus + requested_status: Projects.v2.ProjectStatus | null + queued: string | null + published: string + updated: string + project_types: string[] + ownership: Ownership + external_dependencies_count: number + } + + export type ProjectsResponse = { + total: number + projects: QueueProject[] + } + + export type ProjectIdsResponse = { + ids: string[] + } + export type LockedByUser = { id: string username: string