flagging
This commit is contained in:
143
apps/web/src/app/api/flag/report/route.ts
Normal file
143
apps/web/src/app/api/flag/report/route.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDb } from "@/lib/db/index";
|
||||
import { flaggedContent, plants, diseases } from "@/lib/db/schema";
|
||||
import { inArray, sql } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* GET /api/flag/report
|
||||
*
|
||||
* Returns all flagged content grouped by content type, with resolved
|
||||
* plant/disease names for readability. Used by the generate-flagged-report script.
|
||||
*
|
||||
* Query params:
|
||||
* minFlags - Optional minimum flag count to include (default: 1)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const minFlags = parseInt(searchParams.get("minFlags") ?? "1", 10);
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Get all flagged entries
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(flaggedContent)
|
||||
.where(sql`flag_count >= ${minFlags}`)
|
||||
.orderBy(flaggedContent.contentType, flaggedContent.flagCount);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({
|
||||
total: 0,
|
||||
groups: {},
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve plant/disease names
|
||||
const plantIds = new Set<string>();
|
||||
const diseaseIds = new Set<string>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.contentType === "plant_image") {
|
||||
plantIds.add(row.contentId);
|
||||
} else {
|
||||
diseaseIds.add(row.contentId);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch plant names
|
||||
const plantMap = new Map<string, string>();
|
||||
if (plantIds.size > 0) {
|
||||
const plantRows = await db
|
||||
.select({ id: plants.id, name: plants.commonName })
|
||||
.from(plants)
|
||||
.where(inArray(plants.id, [...plantIds]));
|
||||
for (const p of plantRows) {
|
||||
plantMap.set(p.id, p.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch disease names + their plant references
|
||||
const diseaseMap = new Map<string, { name: string; plantId: string }>();
|
||||
if (diseaseIds.size > 0) {
|
||||
const diseaseRows = await db
|
||||
.select({
|
||||
id: diseases.id,
|
||||
name: diseases.name,
|
||||
plantId: diseases.plantId,
|
||||
})
|
||||
.from(diseases)
|
||||
.where(inArray(diseases.id, [...diseaseIds]));
|
||||
for (const d of diseaseRows) {
|
||||
diseaseMap.set(d.id, { name: d.name, plantId: d.plantId });
|
||||
}
|
||||
// Fetch plants for diseases that we don't already have
|
||||
for (const d of diseaseRows) {
|
||||
if (!plantMap.has(d.plantId)) {
|
||||
plantIds.add(d.plantId);
|
||||
}
|
||||
}
|
||||
if (plantIds.size > 0) {
|
||||
const plantRows = await db
|
||||
.select({ id: plants.id, name: plants.commonName })
|
||||
.from(plants)
|
||||
.where(inArray(plants.id, [...plantIds]));
|
||||
for (const p of plantRows) {
|
||||
plantMap.set(p.id, p.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group by content type
|
||||
const groups: Record<string, Array<Record<string, unknown>>> = {};
|
||||
for (const row of rows) {
|
||||
const type = row.contentType;
|
||||
if (!groups[type]) groups[type] = [];
|
||||
|
||||
let label = row.contentId;
|
||||
if (type === "plant_image") {
|
||||
label = plantMap.get(row.contentId) ?? row.contentId;
|
||||
} else {
|
||||
const disease = diseaseMap.get(row.contentId);
|
||||
if (disease) {
|
||||
const plantName = plantMap.get(disease.plantId) ?? disease.plantId;
|
||||
label = `${disease.name} (on ${plantName})`;
|
||||
}
|
||||
}
|
||||
|
||||
groups[type].push({
|
||||
id: row.id,
|
||||
contentType: row.contentType,
|
||||
contentId: row.contentId,
|
||||
fieldName: row.fieldName,
|
||||
label,
|
||||
notes: row.notes,
|
||||
flagCount: row.flagCount,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
total: rows.length,
|
||||
groups,
|
||||
items: rows.map((row) => ({
|
||||
id: row.id,
|
||||
contentType: row.contentType,
|
||||
contentId: row.contentId,
|
||||
fieldName: row.fieldName,
|
||||
notes: row.notes,
|
||||
flagCount: row.flagCount,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[Flag Report] Error fetching flagged content:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal Server Error", message: "Failed to fetch flagged content", status: 500 },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
147
apps/web/src/app/api/flag/route.ts
Normal file
147
apps/web/src/app/api/flag/route.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { getDb } from "@/lib/db/index";
|
||||
import { flaggedContent } from "@/lib/db/schema";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
/**
|
||||
* Content types that can be flagged for manual review.
|
||||
*/
|
||||
const VALID_CONTENT_TYPES = [
|
||||
"plant_image",
|
||||
"disease_image",
|
||||
"disease_symptoms",
|
||||
"disease_causes",
|
||||
"disease_treatment",
|
||||
"disease_prevention",
|
||||
] as const;
|
||||
|
||||
type FlagContentType = (typeof VALID_CONTENT_TYPES)[number];
|
||||
|
||||
interface FlagRequestBody {
|
||||
contentType: FlagContentType;
|
||||
contentId: string;
|
||||
fieldName: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/flag
|
||||
*
|
||||
* Flag content for manual review. If the same content_type + content_id + field_name
|
||||
* combination already exists, increments the flag_count. Otherwise creates a new entry.
|
||||
*
|
||||
* Body:
|
||||
* contentType - Type of content being flagged
|
||||
* contentId - The ID of the plant or disease
|
||||
* fieldName - The specific field name (e.g., "image", "symptoms")
|
||||
* notes - Optional notes/reason for flagging
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: FlagRequestBody = await request.json();
|
||||
|
||||
// ── Validate required fields ──
|
||||
|
||||
if (!body.contentType || !VALID_CONTENT_TYPES.includes(body.contentType)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Bad Request",
|
||||
message: `Invalid contentType. Must be one of: ${VALID_CONTENT_TYPES.join(", ")}`,
|
||||
status: 400,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!body.contentId ||
|
||||
typeof body.contentId !== "string" ||
|
||||
body.contentId.trim().length === 0
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bad Request", message: "contentId is required", status: 400 },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!body.fieldName ||
|
||||
typeof body.fieldName !== "string" ||
|
||||
body.fieldName.trim().length === 0
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bad Request", message: "fieldName is required", status: 400 },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// ── Check if this item was already flagged ──
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(flaggedContent)
|
||||
.where(
|
||||
and(
|
||||
eq(flaggedContent.contentType, body.contentType),
|
||||
eq(flaggedContent.contentId, body.contentId),
|
||||
eq(flaggedContent.fieldName, body.fieldName),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Increment flag count and update timestamp
|
||||
const current = existing[0];
|
||||
await db
|
||||
.update(flaggedContent)
|
||||
.set({
|
||||
flagCount: (current.flagCount ?? 0) + 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
...(body.notes ? { notes: body.notes } : {}),
|
||||
})
|
||||
.where(eq(flaggedContent.id, current.id));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
action: "incremented",
|
||||
flagCount: (current.flagCount ?? 0) + 1,
|
||||
message: "Flag count incremented. Thank you for your review input.",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Create new flag entry ──
|
||||
|
||||
const id = uuidv4();
|
||||
await db.insert(flaggedContent).values({
|
||||
id,
|
||||
contentType: body.contentType,
|
||||
contentId: body.contentId,
|
||||
fieldName: body.fieldName,
|
||||
notes: body.notes ?? "",
|
||||
flagCount: 1,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[Flag] New flag: type=${body.contentType} id=${body.contentId} field=${body.fieldName}`,
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
action: "created",
|
||||
flagCount: 1,
|
||||
message: "Content flagged for manual review. Thank you!",
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[Flag] Error flagging content:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal Server Error", message: "Failed to flag content", status: 500 },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import type { Disease, CausalAgentType, Prevalence, Severity } from "@/lib/types";
|
||||
import ImageLightbox from "@/components/ImageLightbox";
|
||||
import FlagButton from "@/components/FlagButton";
|
||||
|
||||
// ─── Severity badge ───
|
||||
|
||||
@@ -86,7 +87,7 @@ function DiseaseCard({
|
||||
</div>
|
||||
|
||||
{/* Disease image or placeholder */}
|
||||
<div className="mb-4 rounded-lg overflow-hidden border border-zinc-200 dark:border-zinc-700">
|
||||
<div className="mb-2 rounded-lg overflow-hidden border border-zinc-200 dark:border-zinc-700 relative">
|
||||
{disease.imageUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -128,6 +129,16 @@ function DiseaseCard({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Flag button for disease image */}
|
||||
<div className="flex justify-end mb-2">
|
||||
<FlagButton
|
||||
contentType="disease_image"
|
||||
contentId={disease.id}
|
||||
fieldName="image"
|
||||
label="disease image"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed mb-4">
|
||||
{disease.description}
|
||||
@@ -137,9 +148,18 @@ function DiseaseCard({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Symptoms */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-red-600 dark:text-red-400 mb-2 flex items-center gap-1">
|
||||
<span aria-hidden="true">⚠️</span> Symptoms
|
||||
</h4>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-red-600 dark:text-red-400 flex items-center gap-1">
|
||||
<span aria-hidden="true">⚠️</span> Symptoms
|
||||
</h4>
|
||||
<FlagButton
|
||||
contentType="disease_symptoms"
|
||||
contentId={disease.id}
|
||||
fieldName="symptoms"
|
||||
label="symptoms"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{disease.symptoms.map((symptom, i) => (
|
||||
<li
|
||||
@@ -155,9 +175,18 @@ function DiseaseCard({
|
||||
|
||||
{/* Causes */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-orange-600 dark:text-orange-400 mb-2 flex items-center gap-1">
|
||||
<span aria-hidden="true">🔍</span> Causes
|
||||
</h4>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-orange-600 dark:text-orange-400 flex items-center gap-1">
|
||||
<span aria-hidden="true">🔍</span> Causes
|
||||
</h4>
|
||||
<FlagButton
|
||||
contentType="disease_causes"
|
||||
contentId={disease.id}
|
||||
fieldName="causes"
|
||||
label="causes"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{disease.causes.map((cause, i) => (
|
||||
<li
|
||||
@@ -173,9 +202,18 @@ function DiseaseCard({
|
||||
|
||||
{/* Treatment Steps */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 mb-2 flex items-center gap-1">
|
||||
<span aria-hidden="true">💊</span> Treatment Steps
|
||||
</h4>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 flex items-center gap-1">
|
||||
<span aria-hidden="true">💊</span> Treatment Steps
|
||||
</h4>
|
||||
<FlagButton
|
||||
contentType="disease_treatment"
|
||||
contentId={disease.id}
|
||||
fieldName="treatment"
|
||||
label="treatment"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<ol className="space-y-1.5 list-decimal list-inside">
|
||||
{disease.treatment.map((step, i) => (
|
||||
<li key={i} className="text-sm text-zinc-600 dark:text-zinc-300">
|
||||
@@ -187,9 +225,18 @@ function DiseaseCard({
|
||||
|
||||
{/* Prevention Tips */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 mb-2 flex items-center gap-1">
|
||||
<span aria-hidden="true">🛡️</span> Prevention Tips
|
||||
</h4>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-leaf-green-600 dark:text-leaf-green-400 flex items-center gap-1">
|
||||
<span aria-hidden="true">🛡️</span> Prevention Tips
|
||||
</h4>
|
||||
<FlagButton
|
||||
contentType="disease_prevention"
|
||||
contentId={disease.id}
|
||||
fieldName="prevention"
|
||||
label="prevention tips"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{disease.prevention.map((tip, i) => (
|
||||
<li
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getPlantWithDiseases } from "@/lib/api/diseases-db";
|
||||
import { getPlantDescription } from "@/lib/display-helpers";
|
||||
import DiseaseCards from "./DiseaseCards";
|
||||
import PlantViewTracker from "@/components/PlantViewTracker";
|
||||
import FlagPlantImage from "@/components/FlagPlantImage";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ plantId: string }>;
|
||||
@@ -114,6 +115,7 @@ export default async function PlantDetailPage({ params }: Props) {
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<FlagPlantImage plantId={plantId} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
Reference in New Issue
Block a user