This commit is contained in:
2026-06-06 17:02:45 -04:00
parent 47609e5e42
commit db4c656730
22 changed files with 6195 additions and 326 deletions

View 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 },
);
}
}

View 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 },
);
}
}

View File

@@ -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

View File

@@ -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">