diff --git a/apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue b/apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue index 3c962040ed..b9a12f01af 100644 --- a/apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue +++ b/apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue @@ -56,6 +56,11 @@ interface FileDecisions { severity: string decision: 'safe' | 'malware' }> + unresolved: Array<{ + filePath: string + issueType: string + severity: string + }> maxSeverity: string } @@ -391,6 +396,15 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe') } } + // Auto-jump back to Files tab when all flags for current file are complete + if (selectedFile.value) { + const fileComplete = + getFileMarkedCount(selectedFile.value) === getFileDetailCount(selectedFile.value) + if (fileComplete) { + backToFileList() + } + } + if (verdict === 'safe') { addNotification({ type: 'success', @@ -541,6 +555,7 @@ const reviewSummaryPreview = computed(() => { const fileDecisions = new Map() let totalSafe = 0 let totalUnsafe = 0 + let totalUnresolved = 0 for (const report of props.item.reports) { if (!fileDecisions.has(report.id)) { @@ -548,6 +563,7 @@ const reviewSummaryPreview = computed(() => { fileName: report.file_name, fileSize: report.file_size, decisions: [], + unresolved: [], maxSeverity: 'low', }) } @@ -560,7 +576,20 @@ const reviewSummaryPreview = computed(() => { status: Labrinth.TechReview.Internal.DelphiReportIssueStatus } const decision = getDetailDecision(detailWithStatus.id, detailWithStatus.status) - if (decision === 'pending') continue + + if ((severityOrder[detail.severity] ?? 0) > (severityOrder[fileData.maxSeverity] ?? 0)) { + fileData.maxSeverity = detail.severity + } + + if (decision === 'pending') { + fileData.unresolved.push({ + filePath: detail.file_path, + issueType: issue.issue_type.replace(/_/g, ' '), + severity: detail.severity, + }) + totalUnresolved++ + continue + } fileData.decisions.push({ filePath: detail.file_path, @@ -569,10 +598,6 @@ const reviewSummaryPreview = computed(() => { decision, }) - if ((severityOrder[detail.severity] ?? 0) > (severityOrder[fileData.maxSeverity] ?? 0)) { - fileData.maxSeverity = detail.severity - } - if (decision === 'safe') totalSafe++ else totalUnsafe++ } @@ -580,33 +605,53 @@ const reviewSummaryPreview = computed(() => { } const totalDecisions = totalSafe + totalUnsafe - if (totalDecisions === 0) return '' + if (totalDecisions === 0 && totalUnresolved === 0) return '' const timestamp = dayjs().utc().format('MMMM D, YYYY [at] h:mm A [UTC]') let markdown = `## Tech Review Summary\n*${timestamp}*\n\n` for (const [, fileData] of fileDecisions) { - if (fileData.decisions.length === 0) continue + if (fileData.decisions.length === 0 && fileData.unresolved.length === 0) continue const fileSafe = fileData.decisions.filter((d) => d.decision === 'safe').length const fileUnsafe = fileData.decisions.filter((d) => d.decision === 'malware').length const fileVerdict = fileUnsafe > 0 ? 'Unsafe' : 'Safe' markdown += `### ${fileData.fileName}\n` - markdown += `> ${formatFileSize(fileData.fileSize)} • ${fileData.decisions.length} issues • Max severity: ${fileData.maxSeverity} • **Verdict:** ${fileVerdict}\n\n` - markdown += `
\nIssues (${fileSafe} safe, ${fileUnsafe} unsafe)\n\n` - markdown += `| Class | Issue Type | Severity | Decision |\n` - markdown += `|-------|------------|----------|----------|\n` - - for (const d of fileData.decisions) { - const decisionText = d.decision === 'safe' ? '✅ Safe' : '❌ Unsafe' - markdown += `| \`${d.filePath}\` | ${d.issueType} | ${capitalizeString(d.severity)} | ${decisionText} |\n` + markdown += `> ${formatFileSize(fileData.fileSize)} • ${fileData.decisions.length + fileData.unresolved.length} issues • Max severity: ${fileData.maxSeverity} • **Verdict:** ${fileVerdict}\n\n` + + // Show unresolved issues at the top + if (fileData.unresolved.length > 0) { + markdown += `
\n⏳ Unresolved (${fileData.unresolved.length} pending)\n\n` + markdown += `| Class | Issue Type | Severity |\n` + markdown += `|-------|------------|----------|\n` + + for (const u of fileData.unresolved) { + markdown += `| \`${u.filePath}\` | ${u.issueType} | ${capitalizeString(u.severity)} |\n` + } + + markdown += `\n
\n\n` } - markdown += `\n
\n\n` + if (fileData.decisions.length > 0) { + markdown += `
\nIssues (${fileSafe} safe, ${fileUnsafe} unsafe)\n\n` + markdown += `| Class | Issue Type | Severity | Decision |\n` + markdown += `|-------|------------|----------|----------|\n` + + for (const d of fileData.decisions) { + const decisionText = d.decision === 'safe' ? '✅ Safe' : '❌ Unsafe' + markdown += `| \`${d.filePath}\` | ${d.issueType} | ${capitalizeString(d.severity)} | ${decisionText} |\n` + } + + markdown += `\n
\n\n` + } } - markdown += `---\n\n**Total:** ${totalDecisions} issues reviewed (${totalSafe} safe, ${totalUnsafe} unsafe)\n\n` + markdown += `---\n\n**Total:** ${totalDecisions} issues reviewed (${totalSafe} safe, ${totalUnsafe} unsafe)` + if (totalUnresolved > 0) { + markdown += ` • **Unresolved:** ${totalUnresolved} pending` + } + markdown += `\n\n` return markdown }) diff --git a/apps/frontend/src/pages/moderation/technical-review/[id].vue b/apps/frontend/src/pages/moderation/technical-review/[id].vue new file mode 100644 index 0000000000..5d2de30f7c --- /dev/null +++ b/apps/frontend/src/pages/moderation/technical-review/[id].vue @@ -0,0 +1,183 @@ + + + diff --git a/apps/labrinth/.sqlx/query-402c0278d41f565d8a559c5ab0003aaa892f1b7257ff5cc4b79d671881c94101.json b/apps/labrinth/.sqlx/query-402c0278d41f565d8a559c5ab0003aaa892f1b7257ff5cc4b79d671881c94101.json new file mode 100644 index 0000000000..98caadef6a --- /dev/null +++ b/apps/labrinth/.sqlx/query-402c0278d41f565d8a559c5ab0003aaa892f1b7257ff5cc4b79d671881c94101.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n project_id AS \"project_id: DBProjectId\",\n project_thread_id AS \"project_thread_id: DBThreadId\",\n report AS \"report!: sqlx::types::Json\"\n FROM (\n SELECT DISTINCT ON (m.id)\n m.id AS project_id,\n t.id AS project_thread_id,\n MAX(dr.severity) AS severity,\n MIN(dr.created) AS earliest_report_created,\n MAX(dr.created) AS latest_report_created,\n\n jsonb_build_object(\n 'project_id', to_base62(m.id),\n 'max_severity', MAX(dr.severity),\n 'versions', (\n SELECT coalesce(jsonb_agg(jsonb_build_object(\n 'version_id', to_base62(v.id),\n 'files', (\n SELECT coalesce(jsonb_agg(jsonb_build_object(\n 'report_id', dr.id,\n 'file_id', to_base62(f.id),\n 'created', dr.created,\n 'flag_reason', 'delphi',\n 'severity', dr.severity,\n 'file_name', f.filename,\n 'file_size', f.size,\n 'download_url', f.url,\n 'issues', (\n SELECT coalesce(jsonb_agg(\n to_jsonb(dri)\n || jsonb_build_object(\n 'details', (\n SELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'data', didws.data,\n 'severity', didws.severity,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n )\n ), '[]'::jsonb)\n FROM delphi_report_issues dri\n WHERE\n dri.report_id = dr.id\n AND dri.issue_type != '__dummy'\n )\n )), '[]'::jsonb)\n FROM delphi_reports dr\n WHERE dr.file_id = f.id\n )\n )), '[]'::jsonb)\n FROM versions v\n INNER JOIN files f ON f.version_id = v.id\n WHERE v.mod_id = m.id\n )\n ) AS report\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\n INNER JOIN delphi_reports dr ON dr.file_id = f.id\n INNER JOIN delphi_issue_details_with_statuses didws\n ON didws.project_id = m.id AND didws.status = 'pending'\n\n WHERE\n m.id = $1\n AND m.status NOT IN ('draft', 'rejected', 'withheld')\n\n GROUP BY m.id, t.id\n ) t\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "project_id: DBProjectId", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "project_thread_id: DBThreadId", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "report!: sqlx::types::Json", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + null + ] + }, + "hash": "402c0278d41f565d8a559c5ab0003aaa892f1b7257ff5cc4b79d671881c94101" +} diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review.rs b/apps/labrinth/src/routes/internal/moderation/tech_review.rs index 52f29df373..49ade3b47a 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review.rs @@ -37,6 +37,7 @@ use eyre::eyre; pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { cfg.service(search_projects) + .service(get_project) .service(get_report) .service(get_issue) .service(submit_report) @@ -585,6 +586,199 @@ async fn search_projects( })) } +/// Gets tech review data for a specific project. +/// +/// Returns the same structure as [`search_projects`] but for a single project. +/// Returns 404 if the project is not in the tech review queue. +#[utoipa::path( + security(("bearer_auth" = [])), + responses((status = OK, body = inline(SearchResponse))) +)] +#[get("/project/{project_id}")] +async fn get_project( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + path: web::Path<(ProjectId,)>, +) -> Result, ApiError> { + let user = check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_READ, + ) + .await?; + + let (project_id,) = path.into_inner(); + let db_project_id = DBProjectId::from(project_id); + + let mut project_reports = Vec::::new(); + let mut project_ids = Vec::::new(); + let mut thread_ids = Vec::::new(); + + let row = sqlx::query!( + r#" + SELECT + project_id AS "project_id: DBProjectId", + project_thread_id AS "project_thread_id: DBThreadId", + report AS "report!: sqlx::types::Json" + FROM ( + SELECT DISTINCT ON (m.id) + m.id AS project_id, + t.id AS project_thread_id, + MAX(dr.severity) AS severity, + MIN(dr.created) AS earliest_report_created, + MAX(dr.created) AS latest_report_created, + + jsonb_build_object( + 'project_id', to_base62(m.id), + 'max_severity', MAX(dr.severity), + 'versions', ( + SELECT coalesce(jsonb_agg(jsonb_build_object( + 'version_id', to_base62(v.id), + 'files', ( + SELECT coalesce(jsonb_agg(jsonb_build_object( + 'report_id', dr.id, + 'file_id', to_base62(f.id), + 'created', dr.created, + 'flag_reason', 'delphi', + 'severity', dr.severity, + 'file_name', f.filename, + 'file_size', f.size, + 'download_url', f.url, + 'issues', ( + SELECT coalesce(jsonb_agg( + to_jsonb(dri) + || jsonb_build_object( + 'details', ( + SELECT coalesce(jsonb_agg( + jsonb_build_object( + 'id', didws.id, + 'issue_id', didws.issue_id, + 'key', didws.key, + 'file_path', didws.file_path, + 'data', didws.data, + 'severity', didws.severity, + 'status', didws.status + ) + ), '[]'::jsonb) + FROM delphi_issue_details_with_statuses didws + WHERE didws.issue_id = dri.id + ) + ) + ), '[]'::jsonb) + FROM delphi_report_issues dri + WHERE + dri.report_id = dr.id + AND dri.issue_type != '__dummy' + ) + )), '[]'::jsonb) + FROM delphi_reports dr + WHERE dr.file_id = f.id + ) + )), '[]'::jsonb) + FROM versions v + INNER JOIN files f ON f.version_id = v.id + WHERE v.mod_id = m.id + ) + ) AS report + FROM mods m + INNER JOIN threads t ON t.mod_id = m.id + INNER JOIN versions v ON v.mod_id = m.id + INNER JOIN files f ON f.version_id = v.id + + INNER JOIN delphi_reports dr ON dr.file_id = f.id + INNER JOIN delphi_issue_details_with_statuses didws + ON didws.project_id = m.id AND didws.status = 'pending' + + WHERE + m.id = $1 + AND m.status NOT IN ('draft', 'rejected', 'withheld') + + GROUP BY m.id, t.id + ) t + "#, + db_project_id as _, + ) + .fetch_optional(&**pool) + .await + .wrap_internal_err("failed to fetch project tech review")? + .ok_or(ApiError::NotFound)?; + + project_reports.push(row.report.0); + project_ids.push(row.project_id); + thread_ids.push(row.project_thread_id); + + let projects = DBProject::get_many_ids(&project_ids, &**pool, &redis) + .await + .wrap_internal_err("failed to fetch projects")? + .into_iter() + .map(|project| { + (ProjectId::from(project.inner.id), Project::from(project)) + }) + .collect::>(); + let db_threads = DBThread::get_many(&thread_ids, &**pool) + .await + .wrap_internal_err("failed to fetch threads")?; + let thread_author_ids = db_threads + .iter() + .flat_map(|thread| { + thread + .messages + .iter() + .filter_map(|message| message.author_id) + }) + .collect::>(); + let thread_authors = + DBUser::get_many_ids(&thread_author_ids, &**pool, &redis) + .await + .wrap_internal_err("failed to fetch thread authors")? + .into_iter() + .map(From::from) + .collect::>(); + let threads = db_threads + .into_iter() + .map(|thread| { + let thread = Thread::from(thread, thread_authors.clone(), &user); + (thread.id, thread) + }) + .collect::>(); + + let project_list: Vec = projects.values().cloned().collect(); + + let ownerships = get_projects_ownership(&project_list, &pool, &redis) + .await + .wrap_internal_err("failed to fetch project ownerships")?; + let ownership = projects + .keys() + .copied() + .zip(ownerships) + .collect::>(); + + Ok(web::Json(SearchResponse { + project_reports, + projects: projects + .into_iter() + .map(|(id, project)| { + ( + id, + ProjectModerationInfo { + id, + thread_id: project.thread_id, + name: project.name, + project_types: project.project_types, + icon_url: project.icon_url, + }, + ) + }) + .collect(), + threads, + ownership, + })) +} + /// See [`submit_report`]. #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct SubmitReport { diff --git a/packages/api-client/src/modules/labrinth/tech-review/internal.ts b/packages/api-client/src/modules/labrinth/tech-review/internal.ts index 7967b6ac13..9a05cb0e61 100644 --- a/packages/api-client/src/modules/labrinth/tech-review/internal.ts +++ b/packages/api-client/src/modules/labrinth/tech-review/internal.ts @@ -121,4 +121,31 @@ export class LabrinthTechReviewInternalModule extends AbstractModule { body: data, }) } + + /** + * Get tech review data for a specific project. + * + * Returns the same structure as searchProjects but for a single project. + * Returns 404 if the project is not in the tech review queue. + * + * @param projectId - The project ID to fetch tech review data for + * @returns Response object containing the project's reports and associated data + * + * @example + * ```typescript + * const response = await client.labrinth.tech_review_internal.getProject('project-123') + * // Access report: response.project_reports[0] + * // Access project: response.projects[projectId] + * ``` + */ + public async getProject(projectId: string): Promise { + return this.client.request( + `/moderation/tech-review/project/${projectId}`, + { + api: 'labrinth', + version: 'internal', + method: 'GET', + }, + ) + } }