fix: tech review bugs (#5919)

* fix: root files not appearing as JIJ & pass/fail remaining doesn’t update the flags from other files

* feat: revert back to lazy loading sources

* feat: try fix checklist freezing up/unclickable + project_type filter

* fix: 10 classes then lazy load
This commit is contained in:
Calum H.
2026-04-27 17:33:39 +01:00
committed by GitHub
parent 6afda48e70
commit a2eed001b2
10 changed files with 1294 additions and 162 deletions

View File

@@ -89,7 +89,7 @@ const { addNotification } = injectNotificationManager()
const emit = defineEmits<{
refetch: []
loadFileSources: [reportId: string]
loadIssueSources: [issueIds: string[]]
markComplete: [projectId: string]
showMaliciousSummary: [unsafeFiles: UnsafeFile[]]
}>()
@@ -182,9 +182,55 @@ async function updateIssueDetails(data: { detail_id: string; verdict: 'safe' | '
const severityOrder = { severe: 3, high: 2, medium: 1, low: 0 } as Record<string, number>
const detailDecisions = reactive<Map<string, 'safe' | 'malware'>>(new Map())
type DetailDecision = 'safe' | 'malware'
const detailDecisions = reactive<Map<string, DetailDecision>>(new Map())
const updatingDetails = reactive<Set<string>>(new Set())
function verdictToDecision(verdict: 'safe' | 'unsafe'): DetailDecision {
return verdict === 'safe' ? 'safe' : 'malware'
}
function getAllDetails(): Labrinth.TechReview.Internal.ReportIssueDetail[] {
return props.item.reports.flatMap((report) => report.issues.flatMap((issue) => issue.details))
}
function applyDecisionToRelatedDetails(
detailIds: string[],
decision: DetailDecision,
): { otherMatchedCount: number } {
const allDetails = getAllDetails()
const selectedDetailIds = new Set(detailIds)
const updatedDetailIds = new Set<string>()
for (const detailId of detailIds) {
const detail = allDetails.find((candidate) => candidate.id === detailId)
let matchingDetails: Labrinth.TechReview.Internal.ReportIssueDetail[] = []
if (detail?.key) {
matchingDetails = allDetails.filter((candidate) => candidate.key === detail.key)
} else if (detail) {
matchingDetails = [detail]
}
if (matchingDetails.length === 0) {
detailDecisions.set(detailId, decision)
updatedDetailIds.add(detailId)
continue
}
for (const matchingDetail of matchingDetails) {
detailDecisions.set(matchingDetail.id, decision)
updatedDetailIds.add(matchingDetail.id)
}
}
return {
otherMatchedCount: [...updatedDetailIds].filter((detailId) => !selectedDetailIds.has(detailId))
.length,
}
}
function getFileHighestSeverity(
file: FlattenedFileReport,
): Labrinth.TechReview.Internal.DelphiSeverity {
@@ -325,7 +371,6 @@ function formatFileSize(bytes: number): string {
function viewFileFlags(file: FlattenedFileReport) {
selectedFileId.value = file.id
currentTab.value = 'File'
emit('loadFileSources', file.id)
}
function backToFileList() {
@@ -416,10 +461,7 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
try {
await updateIssueDetails(detailIds.map((detailId) => ({ detail_id: detailId, verdict })))
const decision = verdict === 'safe' ? 'safe' : 'malware'
for (const detailId of detailIds) {
detailDecisions.set(detailId, decision)
}
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict))
addNotification({
type: 'success',
@@ -464,37 +506,10 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
try {
await updateIssueDetails([{ detail_id: detailId, verdict }])
const decision = verdict === 'safe' ? 'safe' : 'malware'
let detailKey: string | null = null
for (const report of props.item.reports) {
for (const issue of report.issues) {
const detail = issue.details.find((d) => d.id === detailId)
if (detail) {
detailKey = detail.key
break
}
}
if (detailKey) break
}
let otherMatchedCount = 0
if (detailKey) {
for (const report of props.item.reports) {
for (const issue of report.issues) {
for (const detail of issue.details) {
if (detail.key === detailKey) {
detailDecisions.set(detail.id, decision)
if (detail.id !== detailId) {
otherMatchedCount++
}
}
}
}
}
} else {
detailDecisions.set(detailId, decision)
}
const { otherMatchedCount } = applyDecisionToRelatedDetails(
[detailId],
verdictToDecision(verdict),
)
// Only collapse if the prior state was 'pending' (new decision, not updating existing)
if (priorDecision === 'pending') {
@@ -547,7 +562,10 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
}
const expandedClasses = reactive<Set<string>>(new Set())
const autoExpandedFileIds = reactive<Set<string>>(new Set())
const showCopyFeedback = reactive<Map<string, boolean>>(new Map())
const highlightedSourceCache = reactive<Map<string, { source: string; lines: string[] }>>(new Map())
const LAZY_LOAD_CLASS_SOURCE_MINIMUM = 10
interface ClassGroup {
key: string
@@ -582,6 +600,10 @@ function splitJarSegments(jar: string | null, currentFileName: string | null): s
return segments
}
function isRootJarGroup(jarGroup: JarGroup): boolean {
return jarGroup.segments.length === 0
}
const groupedByClass = computed<ClassGroup[]>(() => {
if (!selectedFile.value) return []
@@ -647,18 +669,28 @@ const groupedByJar = computed<JarGroup[]>(() => {
}
return Array.from(jarMap.values()).sort((a, b) => {
const aRoot = isRootJarGroup(a)
const bRoot = isRootJarGroup(b)
if (aRoot !== bRoot) return aRoot ? -1 : 1
const aSeverity = getHighestSeverityInClass(a.classes.flatMap((classItem) => classItem.flags))
const bSeverity = getHighestSeverityInClass(b.classes.flatMap((classItem) => classItem.flags))
return (severityOrder[bSeverity] ?? 0) - (severityOrder[aSeverity] ?? 0)
})
})
// Auto-expand if there's only one class in the file
// Auto-expand/load source for small files; keep larger files lazy.
watch(
groupedByClass,
(classes) => {
if (classes.length === 1) {
expandedClasses.add(classes[0].key)
[selectedFileId, groupedByClass],
([fileId, classes]) => {
if (!fileId || classes.length === 0 || autoExpandedFileIds.has(fileId)) return
autoExpandedFileIds.add(fileId)
if (classes.length < LAZY_LOAD_CLASS_SOURCE_MINIMUM) {
for (const classItem of classes) {
expandClass(classItem)
}
}
},
{ immediate: true },
@@ -676,14 +708,6 @@ function getHighestSeverityInClass(
)
}
function toggleClass(classKey: string) {
if (expandedClasses.has(classKey)) {
expandedClasses.delete(classKey)
} else {
expandedClasses.add(classKey)
}
}
function getClassDecompiledSource(classItem: ClassGroup): string | undefined {
for (const flag of classItem.flags) {
const source = props.decompiledSources.get(flag.detail.id)
@@ -692,6 +716,43 @@ function getClassDecompiledSource(classItem: ClassGroup): string | undefined {
return undefined
}
function getHighlightedClassSource(classItem: ClassGroup): string[] {
const source = getClassDecompiledSource(classItem)
if (!source) return []
const cached = highlightedSourceCache.get(classItem.key)
if (cached?.source === source) return cached.lines
const lines = highlightCodeLines(source, 'java')
highlightedSourceCache.set(classItem.key, { source, lines })
return lines
}
function isClassLoadingSource(classItem: ClassGroup): boolean {
return classItem.flags.some((flag) => props.loadingIssues.has(flag.issueId))
}
function loadClassSources(classItem: ClassGroup) {
const issueIds = [...new Set(classItem.flags.map((flag) => flag.issueId))]
if (issueIds.length > 0) {
emit('loadIssueSources', issueIds)
}
}
function expandClass(classItem: ClassGroup) {
if (expandedClasses.has(classItem.key)) return
expandedClasses.add(classItem.key)
loadClassSources(classItem)
}
function toggleClass(classItem: ClassGroup) {
if (expandedClasses.has(classItem.key)) {
expandedClasses.delete(classItem.key)
} else {
expandClass(classItem)
}
}
function handleThreadUpdate() {
emit('refetch')
}
@@ -1203,7 +1264,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
>
<div
class="flex cursor-pointer items-center justify-between p-4 transition-colors duration-200 hover:bg-surface-4"
@click="toggleClass(classItem.key)"
@click="toggleClass(classItem)"
>
<div class="my-auto flex items-center gap-2">
<ButtonStyled type="transparent" circular>
@@ -1245,7 +1306,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
<Transition name="fade">
<div
v-if="classItem.flags.some((f) => loadingIssues.has(f.issueId))"
v-if="isClassLoadingSource(classItem)"
class="rounded-full border border-solid border-surface-5 bg-surface-3 px-2.5 py-1"
>
<span class="flex items-center gap-1.5 text-sm font-medium text-secondary">
@@ -1258,7 +1319,10 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
</div>
<Collapsible :collapsed="!expandedClasses.has(classItem.key)">
<div class="mt-2 flex flex-col gap-2 px-4 pb-4">
<div
v-if="expandedClasses.has(classItem.key)"
class="mt-2 flex flex-col gap-2 px-4 pb-4"
>
<div
v-for="flag in classItem.flags"
:key="`${flag.issueId}-${flag.detail.id}`"
@@ -1347,7 +1411,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
</div>
<div
v-if="getClassDecompiledSource(classItem)"
v-if="getHighlightedClassSource(classItem).length > 0"
class="relative inset-0 overflow-hidden rounded-lg border border-solid border-surface-5 bg-surface-4"
>
<ButtonStyled circular type="transparent">
@@ -1363,10 +1427,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
<div class="overflow-x-auto bg-surface-3 py-3">
<div
v-for="(line, n) in highlightCodeLines(
getClassDecompiledSource(classItem)!,
'java',
)"
v-for="(line, n) in getHighlightedClassSource(classItem)"
:key="n"
class="flex font-mono text-[13px] leading-[1.6]"
>
@@ -1382,6 +1443,15 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
</div>
</div>
</div>
<div
v-else-if="isClassLoadingSource(classItem)"
class="rounded-lg border border-solid border-surface-5 bg-surface-3 p-4"
>
<p class="flex items-center gap-2 text-sm text-secondary">
<LoaderCircleIcon class="size-4 animate-spin" />
Loading source...
</p>
</div>
<div
v-else
class="rounded-lg border border-solid border-surface-5 bg-surface-3 p-4"

View File

@@ -83,9 +83,10 @@ if (import.meta.client) {
const loadingIssues = reactive<Set<string>>(new Set())
const decompiledSources = reactive<Map<string, string>>(new Map())
const loadedIssues = reactive<Set<string>>(new Set())
async function loadIssueSource(issueId: string): Promise<void> {
if (loadingIssues.has(issueId)) return
if (loadingIssues.has(issueId) || loadedIssues.has(issueId)) return
loadingIssues.add(issueId)
@@ -98,6 +99,7 @@ async function loadIssueSource(issueId: string): Promise<void> {
setCachedSource(detail.id, detail.decompiled_source)
}
}
loadedIssues.add(issueId)
} catch (error) {
console.error('Failed to load issue source:', error)
} finally {
@@ -105,37 +107,40 @@ async function loadIssueSource(issueId: string): Promise<void> {
}
}
function tryLoadCachedSourcesForFile(reportId: string): void {
if (!reviewItem.value) return
function findIssuesByIds(issueIds: Set<string>): Labrinth.TechReview.Internal.FileIssue[] {
const issues: Labrinth.TechReview.Internal.FileIssue[] = []
const report = reviewItem.value.reports.find((r) => r.id === reportId)
if (report) {
if (!reviewItem.value) return []
for (const report of reviewItem.value.reports) {
for (const issue of report.issues) {
for (const detail of issue.details) {
if (!decompiledSources.has(detail.id)) {
const cached = getCachedSource(detail.id)
if (cached) {
decompiledSources.set(detail.id, cached)
}
}
if (issueIds.has(issue.id)) {
issues.push(issue)
}
}
}
return issues
}
function handleLoadFileSources(reportId: string): void {
tryLoadCachedSourcesForFile(reportId)
function handleLoadIssueSources(issueIds: string[]): void {
const uniqueIssueIds = new Set(issueIds)
const issues = findIssuesByIds(uniqueIssueIds)
if (!reviewItem.value) return
const report = reviewItem.value.reports.find((r) => r.id === reportId)
if (report) {
for (const issue of report.issues) {
const hasUncached = issue.details.some((d) => !decompiledSources.has(d.id))
if (hasUncached) {
loadIssueSource(issue.id)
for (const issue of issues) {
for (const detail of issue.details) {
if (!decompiledSources.has(detail.id)) {
const cached = getCachedSource(detail.id)
if (cached) {
decompiledSources.set(detail.id, cached)
}
}
}
const hasUncached = issue.details.some((detail) => !decompiledSources.has(detail.id))
if (hasUncached) {
loadIssueSource(issue.id)
}
}
}
@@ -292,7 +297,7 @@ function refetch() {
:loading-issues="loadingIssues"
:decompiled-sources="decompiledSources"
@refetch="refetch"
@load-file-sources="handleLoadFileSources"
@load-issue-sources="handleLoadIssueSources"
@mark-complete="handleMarkComplete"
@show-malicious-summary="handleShowMaliciousSummary"
/>

View File

@@ -104,9 +104,10 @@ clearExpiredCache()
const loadingIssues = reactive<Set<string>>(new Set())
const decompiledSources = reactive<Map<string, string>>(new Map())
const loadedIssues = reactive<Set<string>>(new Set())
async function loadIssueSource(issueId: string): Promise<void> {
if (loadingIssues.has(issueId)) return
if (loadingIssues.has(issueId) || loadedIssues.has(issueId)) return
loadingIssues.add(issueId)
@@ -119,6 +120,7 @@ async function loadIssueSource(issueId: string): Promise<void> {
setCachedSource(detail.id, detail.decompiled_source)
}
}
loadedIssues.add(issueId)
} catch (error) {
console.error('Failed to load issue source:', error)
} finally {
@@ -126,38 +128,39 @@ async function loadIssueSource(issueId: string): Promise<void> {
}
}
function tryLoadCachedSourcesForFile(reportId: string): void {
function findIssuesByIds(issueIds: Set<string>): Labrinth.TechReview.Internal.FileIssue[] {
const issues: Labrinth.TechReview.Internal.FileIssue[] = []
for (const review of reviewItems.value) {
const report = review.reports.find((r) => r.id === reportId)
if (report) {
for (const report of review.reports) {
for (const issue of report.issues) {
for (const detail of issue.details) {
if (!decompiledSources.has(detail.id)) {
const cached = getCachedSource(detail.id)
if (cached) {
decompiledSources.set(detail.id, cached)
}
}
if (issueIds.has(issue.id)) {
issues.push(issue)
}
}
return
}
}
return issues
}
function handleLoadFileSources(reportId: string): void {
tryLoadCachedSourcesForFile(reportId)
function handleLoadIssueSources(issueIds: string[]): void {
const uniqueIssueIds = new Set(issueIds)
const issues = findIssuesByIds(uniqueIssueIds)
for (const review of reviewItems.value) {
const report = review.reports.find((r) => r.id === reportId)
if (report) {
for (const issue of report.issues) {
const hasUncached = issue.details.some((d) => !decompiledSources.has(d.id))
if (hasUncached) {
loadIssueSource(issue.id)
for (const issue of issues) {
for (const detail of issue.details) {
if (!decompiledSources.has(detail.id)) {
const cached = getCachedSource(detail.id)
if (cached) {
decompiledSources.set(detail.id, cached)
}
}
return
}
const hasUncached = issue.details.some((detail) => !decompiledSources.has(detail.id))
if (hasUncached) {
loadIssueSource(issue.id)
}
}
}
@@ -225,8 +228,32 @@ const responseFilterTypes: ComboboxOption<string>[] = [
{ value: 'Read', label: 'Read' },
]
const currentProjectTypeFilter = ref('All project types')
const projectTypeFilterTypes: ComboboxOption<string>[] = [
{ value: 'All project types', label: 'All project types' },
{ value: 'Modpacks', label: 'Modpacks' },
{ value: 'Mods', label: 'Mods' },
{ value: 'Resource Packs', label: 'Resource Packs' },
{ value: 'Data Packs', label: 'Data Packs' },
{ value: 'Plugins', label: 'Plugins' },
{ value: 'Shaders', label: 'Shaders' },
{ value: 'Servers', label: 'Servers' },
]
const inOtherQueueFilter = ref(true)
const techReviewQueryKey = computed(
() =>
[
'tech-reviews',
currentSortType.value,
currentResponseFilter.value,
inOtherQueueFilter.value,
currentFilterType.value,
currentProjectTypeFilter.value,
] as const,
)
const fuse = computed(() => {
if (!reviewItems.value || reviewItems.value.length === 0) return null
return new Fuse(reviewItems.value, {
@@ -294,6 +321,27 @@ function toApiSort(label: string): Labrinth.TechReview.Internal.SearchProjectsSo
}
}
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'
default:
return undefined
}
}
const {
data: infiniteData,
isLoading,
@@ -303,13 +351,7 @@ const {
refetch,
} = useInfiniteQuery({
enabled: true,
queryKey: [
'tech-reviews',
currentSortType,
currentResponseFilter,
inOtherQueueFilter,
currentFilterType,
],
queryKey: techReviewQueryKey,
queryFn: async ({ pageParam = 0 }) => {
const filter: Labrinth.TechReview.Internal.SearchProjectsFilter = {
project_type: [],
@@ -332,6 +374,11 @@ const {
filter.issue_type = [currentFilterType.value]
}
const projectType = toApiProjectType(currentProjectTypeFilter.value)
if (projectType) {
filter.project_type = [projectType]
}
return await client.labrinth.tech_review_internal.searchProjects({
limit: API_PAGE_SIZE,
page: pageParam,
@@ -430,7 +477,7 @@ function handleMarkComplete(projectId: string) {
const threadId = projectData?.thread?.id
queryClient.setQueryData(
['tech-reviews', currentSortType, currentResponseFilter, inOtherQueueFilter, currentFilterType],
techReviewQueryKey.value,
(
oldData:
| {
@@ -445,7 +492,8 @@ function handleMarkComplete(projectId: string) {
...oldData,
pages: oldData.pages.map((page) => ({
...page,
project_reports: page.project_reports.filter((pr) => pr.project_id !== projectId),
// Keep the raw page length stable; getNextPageParam uses it to know if more API pages exist.
project_reports: page.project_reports,
projects: Object.fromEntries(
Object.entries(page.projects).filter(([id]) => id !== projectId),
),
@@ -492,8 +540,28 @@ function handleShowMaliciousSummary(unsafeFiles: UnsafeFile[]) {
maliciousSummaryModalRef.value?.show()
}
watch([currentSortType, currentResponseFilter, inOtherQueueFilter, currentFilterType], () => {
goToPage(1)
watch(
[
currentSortType,
currentResponseFilter,
inOtherQueueFilter,
currentFilterType,
currentProjectTypeFilter,
],
() => {
goToPage(1)
},
)
watch(totalPages, (pages) => {
if (pages === 0) {
goToPage(1)
return
}
if (currentPage.value > pages) {
goToPage(pages)
}
})
// TODO: Reimpl when backend is available
@@ -597,6 +665,23 @@ watch([currentSortType, currentResponseFilter, inOtherQueueFilter, currentFilter
</template>
</Combobox>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-secondary">Project type</span>
<Combobox
v-model="currentProjectTypeFilter"
class="!w-full"
:options="projectTypeFilterTypes"
:placeholder="formatMessage(commonMessages.filterByLabel)"
searchable
>
<template #selected>
<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">{{ currentProjectTypeFilter }}</span>
</span>
</template>
</Combobox>
</div>
</div>
</template>
</FloatingPanel>
@@ -635,7 +720,7 @@ watch([currentSortType, currentResponseFilter, inOtherQueueFilter, currentFilter
:loading-issues="loadingIssues"
:decompiled-sources="decompiledSources"
@refetch="refetch"
@load-file-sources="handleLoadFileSources"
@load-issue-sources="handleLoadIssueSources"
@mark-complete="handleMarkComplete"
@show-malicious-summary="handleShowMaliciousSummary"
/>

View File

@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n m.id AS \"project_id: DBProjectId\",\n MIN(t.id) AS \"thread_id!: DBThreadId\"\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n INNER JOIN versions v ON v.mod_id = m.id\n INNER JOIN files f ON f.version_id = v.id\n INNER JOIN delphi_reports dr ON dr.file_id = f.id\n INNER JOIN delphi_report_issues dri ON dri.report_id = dr.id\n INNER JOIN delphi_report_issue_details drid\n ON drid.issue_id = dri.id\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON m.id = didv.project_id AND drid.key = didv.detail_key\n LEFT JOIN threads_messages tm_last\n ON tm_last.thread_id = t.id\n AND tm_last.id = (\n SELECT id FROM threads_messages\n WHERE thread_id = t.id\n ORDER BY created DESC\n LIMIT 1\n )\n LEFT JOIN users u_last\n ON u_last.id = tm_last.author_id\n WHERE\n (\n cardinality($4::text[]) = 0\n OR (\n 'minecraft_java_server' = ANY($4::text[])\n AND (\n m.components ? 'minecraft_server'\n OR m.components ? 'minecraft_java_server'\n )\n )\n OR EXISTS (\n SELECT 1\n FROM versions type_v\n INNER JOIN loaders_versions type_lv\n ON type_lv.version_id = type_v.id\n INNER JOIN loaders_project_types type_lpt\n ON type_lpt.joining_loader_id = type_lv.loader_id\n INNER JOIN project_types type_pt\n ON type_pt.id = type_lpt.joining_project_type_id\n WHERE\n type_v.mod_id = m.id\n AND type_pt.name = ANY($4::text[])\n AND (\n type_pt.name != 'modpack'\n OR NOT (\n m.components ? 'minecraft_server'\n OR m.components ? 'minecraft_java_server'\n )\n )\n )\n )\n AND m.status NOT IN ('draft', 'rejected', 'withheld')\n AND (cardinality($6::text[]) = 0 OR m.status = ANY($6::text[]))\n AND (cardinality($7::text[]) = 0 OR dri.issue_type = ANY($7::text[]))\n AND (didv.verdict IS NULL OR didv.verdict = 'pending'::delphi_report_issue_status)\n AND (\n $5::text IS NULL\n OR ($5::text = 'unreplied' AND (tm_last.id IS NULL OR u_last.role IS NULL OR u_last.role NOT IN ('moderator', 'admin')))\n OR ($5::text = 'replied' AND tm_last.id IS NOT NULL AND u_last.role IS NOT NULL AND u_last.role IN ('moderator', 'admin'))\n )\n GROUP BY m.id\n ORDER BY\n CASE WHEN $3 = 'created_asc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END ASC,\n CASE WHEN $3 = 'created_desc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END DESC,\n CASE WHEN $3 = 'severity_asc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END ASC,\n CASE WHEN $3 = 'severity_desc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END DESC,\n -- tie-breaker: oldest reports\n MIN(dr.created) ASC\n LIMIT $1 OFFSET $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id: DBProjectId",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "thread_id!: DBThreadId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Text",
"TextArray",
"Text",
"TextArray",
"TextArray"
]
},
"nullable": [
false,
null
]
},
"hash": "0545adc0340800b9fb4c23eb5ec2b30d5bba824f80cd25dbf08ca9f86c32ea1f"
}

View File

@@ -1,34 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n m.id AS \"project_id: DBProjectId\",\n MIN(t.id) AS \"thread_id!: DBThreadId\"\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n INNER JOIN versions v ON v.mod_id = m.id\n INNER JOIN files f ON f.version_id = v.id\n INNER JOIN delphi_reports dr ON dr.file_id = f.id\n INNER JOIN delphi_report_issues dri ON dri.report_id = dr.id\n INNER JOIN delphi_report_issue_details drid\n ON drid.issue_id = dri.id\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON m.id = didv.project_id AND drid.key = didv.detail_key\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON c.id = mc.joining_category_id\n LEFT JOIN threads_messages tm_last\n ON tm_last.thread_id = t.id\n AND tm_last.id = (\n SELECT id FROM threads_messages\n WHERE thread_id = t.id\n ORDER BY created DESC\n LIMIT 1\n )\n LEFT JOIN users u_last\n ON u_last.id = tm_last.author_id\n WHERE\n (cardinality($4::int[]) = 0 OR c.project_type = ANY($4::int[]))\n AND m.status NOT IN ('draft', 'rejected', 'withheld')\n AND (cardinality($6::text[]) = 0 OR m.status = ANY($6::text[]))\n AND (cardinality($7::text[]) = 0 OR dri.issue_type = ANY($7::text[]))\n AND (didv.verdict IS NULL OR didv.verdict = 'pending'::delphi_report_issue_status)\n AND (\n $5::text IS NULL\n OR ($5::text = 'unreplied' AND (tm_last.id IS NULL OR u_last.role IS NULL OR u_last.role NOT IN ('moderator', 'admin')))\n OR ($5::text = 'replied' AND tm_last.id IS NOT NULL AND u_last.role IS NOT NULL AND u_last.role IN ('moderator', 'admin'))\n )\n GROUP BY m.id\n ORDER BY\n CASE WHEN $3 = 'created_asc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END ASC,\n CASE WHEN $3 = 'created_desc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END DESC,\n CASE WHEN $3 = 'severity_asc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END ASC,\n CASE WHEN $3 = 'severity_desc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END DESC,\n -- tie-breaker: oldest reports\n MIN(dr.created) ASC\n LIMIT $1 OFFSET $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id: DBProjectId",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "thread_id!: DBThreadId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Text",
"Int4Array",
"Text",
"TextArray",
"TextArray"
]
},
"nullable": [
false,
null
]
},
"hash": "30a5fa3f44e56c412d07625ea9110238c533a1994e95c805a3babc39cde23004"
}

View File

@@ -14,7 +14,7 @@ use crate::{
models::{
DBFileId, DBProjectId, DBThread, DBThreadId, DBUser, DBVersion,
DBVersionId, DelphiReportId, DelphiReportIssueDetailsId,
DelphiReportIssueId, ProjectTypeId,
DelphiReportIssueId,
delphi_report_item::{
DBDelphiReport, DelphiSeverity, DelphiStatus, DelphiVerdict,
ReportIssueDetail,
@@ -72,7 +72,7 @@ fn default_sort_by() -> SearchProjectsSort {
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SearchProjectsFilter {
#[serde(default)]
pub project_type: Vec<ProjectTypeId>,
pub project_type: Vec<String>,
#[serde(default)]
pub replied_to: Option<RepliedTo>,
#[serde(default)]
@@ -715,8 +715,6 @@ async fn search_projects(
ON drid.issue_id = dri.id
LEFT JOIN delphi_issue_detail_verdicts didv
ON m.id = didv.project_id AND drid.key = didv.detail_key
LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id
LEFT JOIN categories c ON c.id = mc.joining_category_id
LEFT JOIN threads_messages tm_last
ON tm_last.thread_id = t.id
AND tm_last.id = (
@@ -728,7 +726,36 @@ async fn search_projects(
LEFT JOIN users u_last
ON u_last.id = tm_last.author_id
WHERE
(cardinality($4::int[]) = 0 OR c.project_type = ANY($4::int[]))
(
cardinality($4::text[]) = 0
OR (
'minecraft_java_server' = ANY($4::text[])
AND (
m.components ? 'minecraft_server'
OR m.components ? 'minecraft_java_server'
)
)
OR EXISTS (
SELECT 1
FROM versions type_v
INNER JOIN loaders_versions type_lv
ON type_lv.version_id = type_v.id
INNER JOIN loaders_project_types type_lpt
ON type_lpt.joining_loader_id = type_lv.loader_id
INNER JOIN project_types type_pt
ON type_pt.id = type_lpt.joining_project_type_id
WHERE
type_v.mod_id = m.id
AND type_pt.name = ANY($4::text[])
AND (
type_pt.name != 'modpack'
OR NOT (
m.components ? 'minecraft_server'
OR m.components ? 'minecraft_java_server'
)
)
)
)
AND m.status NOT IN ('draft', 'rejected', 'withheld')
AND (cardinality($6::text[]) = 0 OR m.status = ANY($6::text[]))
AND (cardinality($7::text[]) = 0 OR dri.issue_type = ANY($7::text[]))
@@ -751,12 +778,7 @@ async fn search_projects(
limit,
offset,
&sort_by,
&search_req
.filter
.project_type
.iter()
.map(|ty| ty.0)
.collect::<Vec<_>>(),
&search_req.filter.project_type,
replied_to_filter.as_deref(),
&search_req
.filter