Compare commits

...
Author SHA1 Message Date
aecsocket 34de877c7d prepare 2026-07-06 20:06:12 +01:00
aecsocketandGitHub 187cad2578 Merge branch 'main' into cal/fix-moderation-lag 2026-07-06 19:36:22 +01:00
Calum H. (IMB11) 6c6bcd0b82 fix: i18n 2026-07-06 16:10:52 +01:00
Calum H. (IMB11) 1957549141 fix: re-add tech rev toggle off by default 2026-07-06 16:06:51 +01:00
Calum H. (IMB11) 8cbe4061a8 fix: moderation/projects endpoint slow 2026-07-06 15:45:01 +01:00
17 changed files with 1477 additions and 420 deletions
@@ -2,12 +2,7 @@
<div class="shadow-card rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<NuxtLink
:to="`/project/${queueEntry.project.slug}`"
target="_blank"
tabindex="-1"
class="flex"
>
<NuxtLink :to="`/project/${projectRouteParam}`" target="_blank" tabindex="-1" class="flex">
<Avatar
:src="queueEntry.project.icon_url"
size="4rem"
@@ -17,7 +12,7 @@
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-2">
<NuxtLink
:to="`/project/${queueEntry.project.slug}`"
:to="`/project/${projectRouteParam}`"
target="_blank"
class="text-lg font-semibold text-contrast hover:underline"
>
@@ -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',
@@ -1949,7 +1949,10 @@ function generateModpackMessage(allFiles: {
const hasNextProject = ref(false)
async function refreshModerationCaches(threadId?: string) {
const refreshes: Promise<unknown>[] = [invalidate(), refreshNuxtData('moderation-projects')]
const refreshes: Promise<unknown>[] = [
invalidate(),
queryClient.invalidateQueries({ queryKey: ['moderation-projects'] }),
]
if (threadId) {
refreshes.push(queryClient.invalidateQueries({ queryKey: ['thread', threadId] }))
+3 -6
View File
@@ -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<ProjectWithOwnership, 'ownership' | 'external_dependencies_count'>
ownership: ModerationOwnership | null
external_dependencies_count: number
}
@@ -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"
},
+167 -188
View File
@@ -18,6 +18,7 @@
<Combobox
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
trigger-class="!h-10"
:options="filterTypes"
:placeholder="formatMessage(commonMessages.filterByLabel)"
@select="goToPage(1)"
@@ -26,7 +27,7 @@
<span class="flex flex-row gap-2 align-middle font-semibold">
<ListFilterIcon class="size-5 flex-shrink-0 text-secondary" />
<span class="truncate text-contrast"
>{{ currentFilterType }} ({{ filteredProjects.length }})</span
>{{ currentFilterType }} ({{ totalProjects }})</span
>
</span>
</template>
@@ -35,6 +36,7 @@
<Combobox
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[240px] sm:flex-grow-0"
trigger-class="!h-10"
:options="sortTypes"
:placeholder="formatMessage(commonMessages.sortByLabel)"
@select="goToPage(1)"
@@ -54,6 +56,7 @@
<Combobox
v-model="itemsPerPage"
class="!w-full flex-grow sm:!w-[160px] sm:flex-grow-0 lg:!w-[140px]"
trigger-class="!h-10"
:options="itemsPerPageOptions"
placeholder="Items per page"
@select="goToPage(1)"
@@ -69,7 +72,7 @@
<ButtonStyled color="orange">
<button
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
:disabled="paginatedProjects?.length === 0"
:disabled="pending || paginatedProjects?.length === 0"
@click="moderateAllInFilter()"
>
<ScaleIcon class="flex-shrink-0" />
@@ -80,17 +83,27 @@
</div>
</div>
<div v-if="totalPages > 1" class="flex items-center justify-between">
<div>
Showing {{ itemsPerPage * (currentPage - 1) + 1 }}{{
itemsPerPage * (currentPage - 1) + Math.min(itemsPerPage, paginatedProjects.length)
}}
of {{ filteredProjects.length }}
{{
currentFilterType === DEFAULT_FILTER_TYPE ? 'projects' : currentFilterType.toLowerCase()
}}
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap items-center gap-3">
<div v-if="totalProjects > 0">
Showing {{ pageStart }}{{ pageEnd }} of {{ totalProjects }}
{{
currentFilterType === DEFAULT_FILTER_TYPE ? 'projects' : currentFilterType.toLowerCase()
}}
</div>
<div class="flex items-center gap-2 text-sm font-semibold text-secondary">
<Toggle id="moderation-exclude-technical-review" v-model="excludeTechnicalReview" small />
<label class="cursor-pointer" for="moderation-exclude-technical-review">
{{ formatMessage(messages.excludeTechnicalReview) }}
</label>
</div>
</div>
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
<Pagination
v-if="totalPages > 1"
:page="currentPage"
:count="totalPages"
@switch-page="goToPage"
/>
<ConfettiExplosion v-if="visible" />
</div>
@@ -124,6 +137,7 @@
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ListFilterIcon, ScaleIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
import {
ButtonStyled,
@@ -132,20 +146,18 @@ import {
commonMessages,
defineMessages,
EmptyState,
injectModrinthClient,
injectNotificationManager,
Pagination,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import Fuse from 'fuse.js'
import { useQuery } from '@tanstack/vue-query'
import ConfettiExplosion from 'vue-confetti-explosion'
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
import {
type ModerationProject,
type ProjectWithOwnership,
toModerationProjects,
} from '~/helpers/moderation.ts'
import { type ModerationProject, toModerationProjects } from '~/helpers/moderation.ts'
import { useModerationQueue } from '~/services/moderation-queue.ts'
useHead({ title: 'Projects queue - Modrinth' })
@@ -155,6 +167,7 @@ const { addNotification } = injectNotificationManager()
const moderationQueue = useModerationQueue()
const route = useRoute()
const router = useRouter()
const client = injectModrinthClient()
const visible = ref(false)
if (import.meta.client && history && history.state && history.state.confetti) {
@@ -173,37 +186,14 @@ const messages = defineMessages({
id: 'moderation.moderate',
defaultMessage: 'Moderate',
},
})
const { data: allProjects, pending } = await useLazyAsyncData('moderation-projects', async () => {
const startTime = performance.now()
let currentOffset = 0
const PROJECT_ENDPOINT_COUNT = 350
const allProjects: ModerationProject[] = []
let projects: ProjectWithOwnership[] = []
do {
projects = (await useBaseFetch(
`moderation/projects?count=${PROJECT_ENDPOINT_COUNT}&offset=${currentOffset}`,
{ internal: true },
)) as ProjectWithOwnership[]
if (projects.length === 0) break
allProjects.push(...toModerationProjects(projects))
currentOffset += projects.length
} while (projects.length === PROJECT_ENDPOINT_COUNT)
const duration = performance.now() - startTime
console.debug(
`Projects fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
)
return allProjects
excludeTechnicalReview: {
id: 'moderation.exclude-technical-review',
defaultMessage: 'Exclude TR',
},
})
const query = ref(route.query.q?.toString() || '')
const excludeTechnicalReview = ref(false)
watch(
query,
@@ -379,116 +369,106 @@ const itemsPerPage = computed({
})
const currentPage = ref(1)
const totalPages = computed(() =>
Math.ceil((filteredProjects.value?.length || 0) / itemsPerPage.value),
function toApiProjectType(label: string): string | undefined {
switch (label) {
case 'Modpacks':
return 'modpack'
case 'Mods':
return 'mod'
case 'Resource Packs':
return 'resourcepack'
case 'Data Packs':
return 'datapack'
case 'Plugins':
return 'plugin'
case 'Shaders':
return 'shader'
case 'Servers':
return 'minecraft_java_server'
case 'Fucked up':
return 'none'
default:
return undefined
}
}
function toApiSort(label: string): Labrinth.Moderation.Internal.ProjectsSort {
switch (label) {
case 'Newest':
return 'newest'
case 'Most external deps':
return 'most_external_deps'
case 'Least external deps':
return 'least_external_deps'
default:
return 'oldest'
}
}
const moderationProjectsRequest = computed<Labrinth.Moderation.Internal.ProjectsRequest>(() => ({
count: itemsPerPage.value,
offset: (currentPage.value - 1) * itemsPerPage.value,
exclude_technical_review: excludeTechnicalReview.value,
query: query.value || undefined,
project_type: toApiProjectType(currentFilterType.value),
sort: toApiSort(currentSortType.value),
}))
const moderationProjectsQueryKey = computed(
() => ['moderation-projects', moderationProjectsRequest.value] as const,
)
const fuse = computed(() => {
if (!allProjects.value || allProjects.value.length === 0) return null
return new Fuse(allProjects.value, {
keys: [
{
name: 'project.title',
weight: 3,
},
{
name: 'project.slug',
weight: 2,
},
{
name: 'project.description',
weight: 2,
},
{
name: 'project.project_type',
weight: 1,
},
'ownership.name',
],
includeScore: true,
threshold: 0.4,
})
const {
data: moderationProjectsResponse,
isPending: moderationProjectsPending,
isPlaceholderData: moderationProjectsPlaceholder,
} = useQuery({
queryKey: moderationProjectsQueryKey,
queryFn: ({ queryKey }) => client.labrinth.moderation_internal.getProjects(queryKey[1]),
placeholderData: (previousData) => previousData,
})
const searchResults = computed(() => {
if (!query.value || !fuse.value) return null
return fuse.value.search(query.value).map((result) => result.item)
})
const baseFiltered = computed(() => {
if (!allProjects.value) return []
return query.value && searchResults.value ? searchResults.value : [...allProjects.value]
})
const typeFiltered = computed(() => {
if (currentFilterType.value === 'All projects') {
return baseFiltered.value
} else if (currentFilterType.value === 'Fucked up') {
return baseFiltered.value.filter((queueItem) => queueItem.project.project_types.length === 0)
const pending = computed(
() => moderationProjectsPending.value || moderationProjectsPlaceholder.value,
)
const totalProjects = computed(() => moderationProjectsResponse.value?.total ?? 0)
const totalPages = computed(() => Math.ceil(totalProjects.value / itemsPerPage.value))
const filteredProjects = computed(() =>
toModerationProjects(moderationProjectsResponse.value?.projects ?? []),
)
const paginatedProjects = computed(() => filteredProjects.value)
const pageStart = computed(() =>
totalProjects.value === 0 ? 0 : (currentPage.value - 1) * itemsPerPage.value + 1,
)
const pageEnd = computed(() =>
Math.min(
(currentPage.value - 1) * itemsPerPage.value + paginatedProjects.value.length,
totalProjects.value,
),
)
const projectsById = computed(() => {
const projects = new Map<string, ModerationProject>()
for (const project of filteredProjects.value) {
projects.set(project.project.id, project)
}
const filterMap: Record<string, string> = {
Modpacks: 'modpack',
Mods: 'mod',
'Resource Packs': 'resourcepack',
'Data Packs': 'datapack',
Plugins: 'plugin',
Shaders: 'shader',
Servers: 'minecraft_java_server',
}
const projectType = filterMap[currentFilterType.value]
if (!projectType) return baseFiltered.value
return baseFiltered.value.filter(
(queueItem) =>
(queueItem.project.project_types.length > 0 &&
queueItem.project.project_types[0] === projectType) ||
(projectType === 'minecraft_java_server' &&
queueItem.project.project_types.includes('minecraft_java_server')),
)
return projects
})
const filteredProjects = computed(() => {
const filtered = [...typeFiltered.value]
if (currentSortType.value === 'Most external deps') {
filtered.sort((a, b) => {
const depsDiff = b.external_dependencies_count - a.external_dependencies_count
if (depsDiff !== 0) return depsDiff
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
return dateA - dateB
})
} else if (currentSortType.value === 'Least external deps') {
filtered.sort((a, b) => {
const depsDiff = a.external_dependencies_count - b.external_dependencies_count
if (depsDiff !== 0) return depsDiff
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
return dateA - dateB
})
} else if (currentSortType.value === 'Oldest') {
filtered.sort((a, b) => {
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
return dateA - dateB
})
} else {
filtered.sort((a, b) => {
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
return dateB - dateA
})
watch(totalPages, (pages) => {
if (pages === 0 && currentPage.value !== 1) {
currentPage.value = 1
return
}
return filtered
if (pages > 0 && currentPage.value > pages) {
currentPage.value = pages
}
})
const paginatedProjects = computed(() => {
if (!filteredProjects.value) return []
const start = (currentPage.value - 1) * itemsPerPage.value
const end = start + itemsPerPage.value
return filteredProjects.value.slice(start, end)
watch(excludeTechnicalReview, () => {
goToPage(1)
})
const emptyStateHeading = computed(() => {
@@ -525,21 +505,16 @@ function notifySkippedProjects(skippedCount: number) {
})
}
async function findFirstEligibleProject(): Promise<ModerationProject | null> {
async function findFirstEligibleProject(): Promise<string | null> {
let skippedCount = 0
while (moderationQueue.hasItems) {
const currentId = moderationQueue.getCurrentProjectId()
if (!currentId) return null
const project = filteredProjects.value.find((p) => p.project.id === currentId)
if (!project) {
await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++
continue
}
const project = projectsById.value.get(currentId)
if (project.project.status !== 'processing') {
if (project && project.project.status !== 'processing') {
await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++
continue
@@ -550,13 +525,13 @@ async function findFirstEligibleProject(): Promise<ModerationProject | null> {
if (!lockStatus.locked || lockStatus.expired || lockStatus.is_own_lock) {
notifySkippedProjects(skippedCount)
return project
return currentId
}
await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++
} catch {
return project
return currentId
}
}
@@ -565,17 +540,42 @@ async function findFirstEligibleProject(): Promise<ModerationProject | null> {
return null
}
function getProjectRouteParam(projectId: string): string {
return projectsById.value.get(projectId)?.project.slug || projectId
}
async function navigateToModerationProject(projectId: string) {
await navigateTo({
name: 'type-project',
params: {
type: 'project',
project: getProjectRouteParam(projectId),
},
state: {
showChecklist: true,
},
})
}
async function getFilteredProjectIds(): Promise<string[]> {
const response = await client.labrinth.moderation_internal.getProjectIds({
exclude_technical_review: excludeTechnicalReview.value,
query: query.value || undefined,
project_type: toApiProjectType(currentFilterType.value),
sort: toApiSort(currentSortType.value),
})
return response.ids
}
async function moderateAllInFilter() {
// Start from the current page - get projects from current page onwards
const startIndex = (currentPage.value - 1) * itemsPerPage.value
const projectsFromCurrentPage = filteredProjects.value.slice(startIndex)
const projectIds = projectsFromCurrentPage.map((queueItem) => queueItem.project.id)
const projectIds = (await getFilteredProjectIds()).slice(startIndex)
await moderationQueue.setQueue(projectIds)
// Find first unlocked project
const targetProject = await findFirstEligibleProject()
const targetProjectId = await findFirstEligibleProject()
if (!targetProject) {
if (!targetProjectId) {
addNotification({
title: 'No projects available',
text: 'All projects in queue are already moderated or locked by others.',
@@ -584,34 +584,22 @@ async function moderateAllInFilter() {
return
}
navigateTo({
name: 'type-project',
params: {
type: 'project',
project: targetProject.project.slug,
},
state: {
showChecklist: true,
},
})
await navigateToModerationProject(targetProjectId)
}
async function startFromProject(projectId: string) {
// Find the index of the clicked project in the filtered list
const projectIndex = filteredProjects.value.findIndex((p) => p.project.id === projectId)
const allFilteredProjectIds = await getFilteredProjectIds()
const projectIndex = allFilteredProjectIds.indexOf(projectId)
if (projectIndex === -1) {
// Project not found in filtered list, just moderate it alone
await moderationQueue.setSingleProject(projectId)
} else {
// Start queue from this project onwards
const projectsFromHere = filteredProjects.value.slice(projectIndex)
const projectIds = projectsFromHere.map((queueItem) => queueItem.project.id)
const projectIds = allFilteredProjectIds.slice(projectIndex)
await moderationQueue.setQueue(projectIds)
}
const targetProject = await findFirstEligibleProject()
const targetProjectId = await findFirstEligibleProject()
if (!targetProject) {
if (!targetProjectId) {
addNotification({
title: 'No projects available',
text: 'All projects in queue are already moderated or locked by others.',
@@ -620,15 +608,6 @@ async function startFromProject(projectId: string) {
return
}
navigateTo({
name: 'type-project',
params: {
type: 'project',
project: targetProject.project.slug,
},
state: {
showChecklist: true,
},
})
await navigateToModerationProject(targetProjectId)
}
</script>
@@ -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"
}
@@ -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"
}
@@ -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"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -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,
File diff suppressed because it is too large Load Diff
-2
View File
@@ -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)
-79
View File
@@ -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<u16>, Query, description = "Maximum number of projects to return")
),
responses(
(status = 200, description = "Expected response to a valid request", body = Vec<LegacyProject>),
(
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<PgPool>,
redis: web::Data<RedisPool>,
count: web::Query<ResultCount>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
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::<Vec<Project>>(response).await {
Ok(project) => {
let legacy_projects =
LegacyProject::from_many(project, &**pool, &redis).await?;
Ok(HttpResponse::Ok().json(legacy_projects))
}
Err(response) => Ok(response),
}
}
@@ -6,6 +6,34 @@ export class LabrinthModerationInternalModule extends AbstractModule {
return 'labrinth_moderation_internal'
}
public async getProjects(
params: Labrinth.Moderation.Internal.ProjectsRequest = {},
): Promise<Labrinth.Moderation.Internal.ProjectsResponse> {
return this.client.request<Labrinth.Moderation.Internal.ProjectsResponse>(
'/moderation/projects',
{
api: 'labrinth',
version: 'internal',
method: 'GET',
params,
},
)
}
public async getProjectIds(
params: Omit<Labrinth.Moderation.Internal.ProjectsRequest, 'count' | 'offset'> = {},
): Promise<Labrinth.Moderation.Internal.ProjectIdsResponse> {
return this.client.request<Labrinth.Moderation.Internal.ProjectIdsResponse>(
'/moderation/projects/ids',
{
api: 'labrinth',
version: 'internal',
method: 'GET',
params,
},
)
}
public async acquireLock(
projectId: string,
): Promise<Labrinth.Moderation.Internal.LockAcquireResponse> {
@@ -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