fix: moderation/projects endpoint slow (#6639)

* fix: moderation/projects endpoint slow

* fix: re-add tech rev toggle off by default

* fix: i18n

* prepare

---------

Co-authored-by: aecsocket <43144841+aecsocket@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-07-06 19:16:52 +00:00
committed by GitHub
co-authored by aecsocket
parent c831e38e32
commit 8d20fd82db
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="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 justify-between">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<NuxtLink <NuxtLink :to="`/project/${projectRouteParam}`" target="_blank" tabindex="-1" class="flex">
:to="`/project/${queueEntry.project.slug}`"
target="_blank"
tabindex="-1"
class="flex"
>
<Avatar <Avatar
:src="queueEntry.project.icon_url" :src="queueEntry.project.icon_url"
size="4rem" size="4rem"
@@ -17,7 +12,7 @@
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<NuxtLink <NuxtLink
:to="`/project/${queueEntry.project.slug}`" :to="`/project/${projectRouteParam}`"
target="_blank" target="_blank"
class="text-lg font-semibold text-contrast hover:underline" class="text-lg font-semibold text-contrast hover:underline"
> >
@@ -177,7 +172,7 @@ function getDaysQueued(date: Date): number {
const queuedDate = computed(() => { const queuedDate = computed(() => {
return dayjs( return dayjs(
props.queueEntry.project.queued || props.queueEntry.project.queued ||
props.queueEntry.project.created || props.queueEntry.project.published ||
props.queueEntry.project.updated, props.queueEntry.project.updated,
) )
}) })
@@ -186,10 +181,14 @@ const daysInQueue = computed(() => {
return getDaysQueued(queuedDate.value.toDate()) return getDaysQueued(queuedDate.value.toDate())
}) })
const projectRouteParam = computed(
() => props.queueEntry.project.slug || props.queueEntry.project.id,
)
const formattedDate = computed(() => { const formattedDate = computed(() => {
const date = const date =
props.queueEntry.project.queued || props.queueEntry.project.queued ||
props.queueEntry.project.created || props.queueEntry.project.published ||
props.queueEntry.project.updated props.queueEntry.project.updated
if (!date) return 'Unknown' if (!date) return 'Unknown'
@@ -202,7 +201,7 @@ const formattedDate = computed(() => {
function copyLink() { function copyLink() {
const base = window.location.origin const base = window.location.origin
const projectUrl = `${base}/project/${props.queueEntry.project.slug}` const projectUrl = `${base}/project/${projectRouteParam.value}`
navigator.clipboard.writeText(projectUrl).then(() => { navigator.clipboard.writeText(projectUrl).then(() => {
addNotification({ addNotification({
type: 'success', type: 'success',
@@ -1949,7 +1949,10 @@ function generateModpackMessage(allFiles: {
const hasNextProject = ref(false) const hasNextProject = ref(false)
async function refreshModerationCaches(threadId?: string) { 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) { if (threadId) {
refreshes.push(queryClient.invalidateQueries({ queryKey: ['thread', 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 { ExtendedReport, OwnershipTarget } from '@modrinth/moderation'
import type { import type {
Organization, Organization,
@@ -197,14 +198,10 @@ export interface ModerationOwnershipOrganization {
export type ModerationOwnership = ModerationOwnershipUser | ModerationOwnershipOrganization export type ModerationOwnership = ModerationOwnershipUser | ModerationOwnershipOrganization
export interface ProjectWithOwnership { export type ProjectWithOwnership = Labrinth.Moderation.Internal.QueueProject
ownership: ModerationOwnership
external_dependencies_count: number
[key: string]: any
}
export interface ModerationProject { export interface ModerationProject {
project: any project: Omit<ProjectWithOwnership, 'ownership' | 'external_dependencies_count'>
ownership: ModerationOwnership | null ownership: ModerationOwnership | null
external_dependencies_count: number external_dependencies_count: number
} }
@@ -2672,6 +2672,9 @@
"layout.nav.upgrade-to-modrinth-plus": { "layout.nav.upgrade-to-modrinth-plus": {
"message": "Upgrade to Modrinth+" "message": "Upgrade to Modrinth+"
}, },
"moderation.exclude-technical-review": {
"message": "Exclude TR"
},
"moderation.moderate": { "moderation.moderate": {
"message": "Moderate" "message": "Moderate"
}, },
+167 -188
View File
@@ -18,6 +18,7 @@
<Combobox <Combobox
v-model="currentFilterType" v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]" class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
trigger-class="!h-10"
:options="filterTypes" :options="filterTypes"
:placeholder="formatMessage(commonMessages.filterByLabel)" :placeholder="formatMessage(commonMessages.filterByLabel)"
@select="goToPage(1)" @select="goToPage(1)"
@@ -26,7 +27,7 @@
<span class="flex flex-row gap-2 align-middle font-semibold"> <span class="flex flex-row gap-2 align-middle font-semibold">
<ListFilterIcon class="size-5 flex-shrink-0 text-secondary" /> <ListFilterIcon class="size-5 flex-shrink-0 text-secondary" />
<span class="truncate text-contrast" <span class="truncate text-contrast"
>{{ currentFilterType }} ({{ filteredProjects.length }})</span >{{ currentFilterType }} ({{ totalProjects }})</span
> >
</span> </span>
</template> </template>
@@ -35,6 +36,7 @@
<Combobox <Combobox
v-model="currentSortType" v-model="currentSortType"
class="!w-full flex-grow sm:!w-[240px] sm:flex-grow-0" class="!w-full flex-grow sm:!w-[240px] sm:flex-grow-0"
trigger-class="!h-10"
:options="sortTypes" :options="sortTypes"
:placeholder="formatMessage(commonMessages.sortByLabel)" :placeholder="formatMessage(commonMessages.sortByLabel)"
@select="goToPage(1)" @select="goToPage(1)"
@@ -54,6 +56,7 @@
<Combobox <Combobox
v-model="itemsPerPage" v-model="itemsPerPage"
class="!w-full flex-grow sm:!w-[160px] sm:flex-grow-0 lg:!w-[140px]" class="!w-full flex-grow sm:!w-[160px] sm:flex-grow-0 lg:!w-[140px]"
trigger-class="!h-10"
:options="itemsPerPageOptions" :options="itemsPerPageOptions"
placeholder="Items per page" placeholder="Items per page"
@select="goToPage(1)" @select="goToPage(1)"
@@ -69,7 +72,7 @@
<ButtonStyled color="orange"> <ButtonStyled color="orange">
<button <button
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto" 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()" @click="moderateAllInFilter()"
> >
<ScaleIcon class="flex-shrink-0" /> <ScaleIcon class="flex-shrink-0" />
@@ -80,17 +83,27 @@
</div> </div>
</div> </div>
<div v-if="totalPages > 1" class="flex items-center justify-between"> <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div> <div class="flex flex-wrap items-center gap-3">
Showing {{ itemsPerPage * (currentPage - 1) + 1 }}{{ <div v-if="totalProjects > 0">
itemsPerPage * (currentPage - 1) + Math.min(itemsPerPage, paginatedProjects.length) Showing {{ pageStart }}{{ pageEnd }} of {{ totalProjects }}
}} {{
of {{ filteredProjects.length }} currentFilterType === DEFAULT_FILTER_TYPE ? 'projects' : currentFilterType.toLowerCase()
{{ }}
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> </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" /> <ConfettiExplosion v-if="visible" />
</div> </div>
@@ -124,6 +137,7 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ListFilterIcon, ScaleIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets' import { ListFilterIcon, ScaleIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
import { import {
ButtonStyled, ButtonStyled,
@@ -132,20 +146,18 @@ import {
commonMessages, commonMessages,
defineMessages, defineMessages,
EmptyState, EmptyState,
injectModrinthClient,
injectNotificationManager, injectNotificationManager,
Pagination, Pagination,
StyledInput, StyledInput,
Toggle,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import Fuse from 'fuse.js' import { useQuery } from '@tanstack/vue-query'
import ConfettiExplosion from 'vue-confetti-explosion' import ConfettiExplosion from 'vue-confetti-explosion'
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue' import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
import { import { type ModerationProject, toModerationProjects } from '~/helpers/moderation.ts'
type ModerationProject,
type ProjectWithOwnership,
toModerationProjects,
} from '~/helpers/moderation.ts'
import { useModerationQueue } from '~/services/moderation-queue.ts' import { useModerationQueue } from '~/services/moderation-queue.ts'
useHead({ title: 'Projects queue - Modrinth' }) useHead({ title: 'Projects queue - Modrinth' })
@@ -155,6 +167,7 @@ const { addNotification } = injectNotificationManager()
const moderationQueue = useModerationQueue() const moderationQueue = useModerationQueue()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const client = injectModrinthClient()
const visible = ref(false) const visible = ref(false)
if (import.meta.client && history && history.state && history.state.confetti) { if (import.meta.client && history && history.state && history.state.confetti) {
@@ -173,37 +186,14 @@ const messages = defineMessages({
id: 'moderation.moderate', id: 'moderation.moderate',
defaultMessage: 'Moderate', defaultMessage: 'Moderate',
}, },
}) excludeTechnicalReview: {
id: 'moderation.exclude-technical-review',
const { data: allProjects, pending } = await useLazyAsyncData('moderation-projects', async () => { defaultMessage: 'Exclude TR',
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
}) })
const query = ref(route.query.q?.toString() || '') const query = ref(route.query.q?.toString() || '')
const excludeTechnicalReview = ref(false)
watch( watch(
query, query,
@@ -379,116 +369,106 @@ const itemsPerPage = computed({
}) })
const currentPage = ref(1) 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(() => { const {
if (!allProjects.value || allProjects.value.length === 0) return null data: moderationProjectsResponse,
return new Fuse(allProjects.value, { isPending: moderationProjectsPending,
keys: [ isPlaceholderData: moderationProjectsPlaceholder,
{ } = useQuery({
name: 'project.title', queryKey: moderationProjectsQueryKey,
weight: 3, queryFn: ({ queryKey }) => client.labrinth.moderation_internal.getProjects(queryKey[1]),
}, placeholderData: (previousData) => previousData,
{
name: 'project.slug',
weight: 2,
},
{
name: 'project.description',
weight: 2,
},
{
name: 'project.project_type',
weight: 1,
},
'ownership.name',
],
includeScore: true,
threshold: 0.4,
})
}) })
const searchResults = computed(() => { const pending = computed(
if (!query.value || !fuse.value) return null () => moderationProjectsPending.value || moderationProjectsPlaceholder.value,
return fuse.value.search(query.value).map((result) => result.item) )
}) const totalProjects = computed(() => moderationProjectsResponse.value?.total ?? 0)
const totalPages = computed(() => Math.ceil(totalProjects.value / itemsPerPage.value))
const baseFiltered = computed(() => { const filteredProjects = computed(() =>
if (!allProjects.value) return [] toModerationProjects(moderationProjectsResponse.value?.projects ?? []),
return query.value && searchResults.value ? searchResults.value : [...allProjects.value] )
}) const paginatedProjects = computed(() => filteredProjects.value)
const pageStart = computed(() =>
const typeFiltered = computed(() => { totalProjects.value === 0 ? 0 : (currentPage.value - 1) * itemsPerPage.value + 1,
if (currentFilterType.value === 'All projects') { )
return baseFiltered.value const pageEnd = computed(() =>
} else if (currentFilterType.value === 'Fucked up') { Math.min(
return baseFiltered.value.filter((queueItem) => queueItem.project.project_types.length === 0) (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> = { return projects
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')),
)
}) })
const filteredProjects = computed(() => { watch(totalPages, (pages) => {
const filtered = [...typeFiltered.value] if (pages === 0 && currentPage.value !== 1) {
currentPage.value = 1
if (currentSortType.value === 'Most external deps') { return
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
})
} }
return filtered if (pages > 0 && currentPage.value > pages) {
currentPage.value = pages
}
}) })
const paginatedProjects = computed(() => { watch(excludeTechnicalReview, () => {
if (!filteredProjects.value) return [] goToPage(1)
const start = (currentPage.value - 1) * itemsPerPage.value
const end = start + itemsPerPage.value
return filteredProjects.value.slice(start, end)
}) })
const emptyStateHeading = computed(() => { 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 let skippedCount = 0
while (moderationQueue.hasItems) { while (moderationQueue.hasItems) {
const currentId = moderationQueue.getCurrentProjectId() const currentId = moderationQueue.getCurrentProjectId()
if (!currentId) return null if (!currentId) return null
const project = filteredProjects.value.find((p) => p.project.id === currentId) const project = projectsById.value.get(currentId)
if (!project) {
await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++
continue
}
if (project.project.status !== 'processing') { if (project && project.project.status !== 'processing') {
await moderationQueue.completeCurrentProject(currentId, 'skipped') await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++ skippedCount++
continue continue
@@ -550,13 +525,13 @@ async function findFirstEligibleProject(): Promise<ModerationProject | null> {
if (!lockStatus.locked || lockStatus.expired || lockStatus.is_own_lock) { if (!lockStatus.locked || lockStatus.expired || lockStatus.is_own_lock) {
notifySkippedProjects(skippedCount) notifySkippedProjects(skippedCount)
return project return currentId
} }
await moderationQueue.completeCurrentProject(currentId, 'skipped') await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++ skippedCount++
} catch { } catch {
return project return currentId
} }
} }
@@ -565,17 +540,42 @@ async function findFirstEligibleProject(): Promise<ModerationProject | null> {
return 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() { async function moderateAllInFilter() {
// Start from the current page - get projects from current page onwards
const startIndex = (currentPage.value - 1) * itemsPerPage.value const startIndex = (currentPage.value - 1) * itemsPerPage.value
const projectsFromCurrentPage = filteredProjects.value.slice(startIndex) const projectIds = (await getFilteredProjectIds()).slice(startIndex)
const projectIds = projectsFromCurrentPage.map((queueItem) => queueItem.project.id)
await moderationQueue.setQueue(projectIds) await moderationQueue.setQueue(projectIds)
// Find first unlocked project const targetProjectId = await findFirstEligibleProject()
const targetProject = await findFirstEligibleProject()
if (!targetProject) { if (!targetProjectId) {
addNotification({ addNotification({
title: 'No projects available', title: 'No projects available',
text: 'All projects in queue are already moderated or locked by others.', text: 'All projects in queue are already moderated or locked by others.',
@@ -584,34 +584,22 @@ async function moderateAllInFilter() {
return return
} }
navigateTo({ await navigateToModerationProject(targetProjectId)
name: 'type-project',
params: {
type: 'project',
project: targetProject.project.slug,
},
state: {
showChecklist: true,
},
})
} }
async function startFromProject(projectId: string) { async function startFromProject(projectId: string) {
// Find the index of the clicked project in the filtered list const allFilteredProjectIds = await getFilteredProjectIds()
const projectIndex = filteredProjects.value.findIndex((p) => p.project.id === projectId) const projectIndex = allFilteredProjectIds.indexOf(projectId)
if (projectIndex === -1) { if (projectIndex === -1) {
// Project not found in filtered list, just moderate it alone
await moderationQueue.setSingleProject(projectId) await moderationQueue.setSingleProject(projectId)
} else { } else {
// Start queue from this project onwards const projectIds = allFilteredProjectIds.slice(projectIndex)
const projectsFromHere = filteredProjects.value.slice(projectIndex)
const projectIds = projectsFromHere.map((queueItem) => queueItem.project.id)
await moderationQueue.setQueue(projectIds) await moderationQueue.setQueue(projectIds)
} }
const targetProject = await findFirstEligibleProject() const targetProjectId = await findFirstEligibleProject()
if (!targetProject) { if (!targetProjectId) {
addNotification({ addNotification({
title: 'No projects available', title: 'No projects available',
text: 'All projects in queue are already moderated or locked by others.', text: 'All projects in queue are already moderated or locked by others.',
@@ -620,15 +608,6 @@ async function startFromProject(projectId: string) {
return return
} }
navigateTo({ await navigateToModerationProject(targetProjectId)
name: 'type-project',
params: {
type: 'project',
project: targetProject.project.slug,
},
state: {
showChecklist: true,
},
})
} }
</script> </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::edit_pat,
pats::delete_pat, pats::delete_pat,
moderation::get_projects, moderation::get_projects,
moderation::get_project_ids,
moderation::get_project_meta, moderation::get_project_meta,
moderation::set_project_meta, moderation::set_project_meta,
moderation::acquire_lock, 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 notifications;
mod openapi; mod openapi;
pub(crate) mod project_creation; pub(crate) mod project_creation;
@@ -26,7 +25,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.configure(super::internal::flows::config) .configure(super::internal::flows::config)
.configure(super::internal::pats::config) .configure(super::internal::pats::config)
.configure(super::internal::admin::config) .configure(super::internal::admin::config)
.configure(moderation::config)
.configure(notifications::config) .configure(notifications::config)
.configure(project_creation::config) .configure(project_creation::config)
.configure(projects::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' 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( public async acquireLock(
projectId: string, projectId: string,
): Promise<Labrinth.Moderation.Internal.LockAcquireResponse> { ): Promise<Labrinth.Moderation.Internal.LockAcquireResponse> {
@@ -1914,6 +1914,57 @@ export namespace Labrinth {
export namespace Moderation { export namespace Moderation {
export namespace Internal { 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 = { export type LockedByUser = {
id: string id: string
username: string username: string