refactor: simplify feedback from 4-level severity to action item / info callout

Replace FeedbackSeverity enum (Critical/Major/Minor/Suggestion) with a
simple isActionItem boolean. Annotations default to action items (things
the artist must fix). Any item can be toggled to an info callout (context
that doesn't need action). Progress bar and carry-forward only count
action items. Screenshot paste limited to 5MB with user notification.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leivur R. Djurhuus 2026-03-14 15:32:41 -05:00 committed by Leivur Djurhuus
parent 05061baf26
commit db82eb4fed
11 changed files with 212 additions and 217 deletions

View file

@ -247,7 +247,7 @@ enum AnnotationType { RECTANGLE ELLIPSE ARROW FREEHAND TEXT PIN SCREENSHOT
---
#### A4 — Revision History Timeline `[ ]`
#### A4 — Revision History Timeline `[x]`
A collapsible sidebar panel in the review page showing the full version history for a deliverable stage. The connective tissue between annotations, comparison, and feedback — provides the longitudinal view across all rounds.
@ -277,28 +277,26 @@ A collapsible sidebar panel in the review page showing the full version history
---
#### A5 — Feedback Checklist (Artist Action Items) `[ ]`
#### A5 — Feedback Checklist (Artist Action Items) `[x]`
Every annotation and actionable comment becomes a structured to-do item on a checklist for the assigned artist. Closes the feedback-to-fix loop.
**The feedback loop:**
1. Reviewer draws annotation or posts actionable comment
2. System auto-creates a FeedbackItem linked to the annotation/comment
3. Artist sees checklist — organized by severity, with direct links to annotation on the image
3. Artist sees checklist — action items with direct links to annotations on the image
4. Artist works through items — checks each off with optional resolution note
5. Artist submits new revision — unchecked items carry forward with a warning
5. Artist submits new revision — unchecked action items carry forward with a warning
6. Reviewer verifies — can confirm resolution or reopen
**Where the checklist appears (3 locations):**
1. **Review page — Feedback Panel** (primary): full checklist with severity indicators, thumbnail crops of annotated regions, resolve/reopen actions, progress bar
2. **My Work page** — feedback badge per assignment ("5 open items"), expandable inline checklist, deep-link to review page
3. **Stage card on deliverable page** — compact badge ("4/7 resolved"), color-coded by severity
**Two item types (simplified from 4-level severity):**
- **Action Item** (default) — something the artist needs to fix. Has checkbox, can be resolved/verified.
- **Info Callout** — context or reference that doesn't require action (e.g., "FYI the client prefers warmer tones"). No checkbox. Can be toggled from action item and vice versa.
**Severity levels:**
- **Critical** — must fix, blocks approval
- **Major** — should fix, significant quality issue
- **Minor** — nice to fix, small quality issue
- **Suggestion** — optional improvement
**Where the checklist appears (3 locations):**
1. **Review page — Feedback Panel** (primary): full checklist with action items first, then info callouts. Progress bar counts only action items. Filter by type and status.
2. **My Work page** — feedback badge per assignment ("5 open items")
3. **Stage card on deliverable page** — compact badge ("4/7 resolved") for action items
**New data model:**
```prisma
@ -313,7 +311,7 @@ model FeedbackItem {
commentId String?
comment Comment? @relation(...)
summary String
severity FeedbackSeverity @default(MAJOR)
isActionItem Boolean @default(true)
status FeedbackStatus @default(OPEN)
sortOrder Int @default(0)
assignedToId String?
@ -329,8 +327,7 @@ model FeedbackItem {
@@map("feedback_items")
}
enum FeedbackSeverity { CRITICAL MAJOR MINOR SUGGESTION }
enum FeedbackStatus { OPEN IN_PROGRESS RESOLVED VERIFIED REOPENED }
enum FeedbackStatus { OPEN IN_PROGRESS RESOLVED VERIFIED REOPENED }
```
**Key files:**

View file

@ -774,8 +774,7 @@ model FeedbackItem {
commentId String?
comment Comment? @relation(fields: [commentId], references: [id], onDelete: SetNull)
summary String
isActionItem Boolean @default(true)
severity FeedbackSeverity @default(MAJOR)
isActionItem Boolean @default(true) // true = action item (must fix), false = info callout
status FeedbackStatus @default(OPEN)
sortOrder Int @default(0)
assignedToId String?
@ -802,9 +801,6 @@ model FeedbackItem {
@@map("feedback_items")
}
enum FeedbackSeverity {
CRITICAL
MAJOR
MINOR
SUGGESTION
}
// FeedbackSeverity removed — replaced by isActionItem boolean
// Action items = things the artist must fix (default for annotations)
// Info callouts = context/reference that doesn't need action

View file

@ -10,7 +10,7 @@ import {
type Params = { params: Promise<{ stageId: string }> };
// GET /api/stages/:stageId/feedback
// Query params: ?revisionId=&status=&severity=&summary=true
// Query params: ?revisionId=&status=&isActionItem=true|false&summary=true
export async function GET(request: Request, { params }: Params) {
const { error } = await getAuthSession();
if (error) return error;
@ -20,7 +20,9 @@ export async function GET(request: Request, { params }: Params) {
const url = new URL(request.url);
const revisionId = url.searchParams.get("revisionId") ?? undefined;
const status = url.searchParams.get("status") ?? undefined;
const severity = url.searchParams.get("severity") ?? undefined;
const isActionItemParam = url.searchParams.get("isActionItem");
const isActionItem =
isActionItemParam === "true" ? true : isActionItemParam === "false" ? false : undefined;
const summaryOnly = url.searchParams.get("summary") === "true";
if (summaryOnly) {
@ -31,7 +33,7 @@ export async function GET(request: Request, { params }: Params) {
const items = await listFeedbackItems(stageId, {
revisionId,
status,
severity,
isActionItem,
});
return NextResponse.json(items);
} catch (e) {

View file

@ -5,22 +5,14 @@ import {
ChevronDown,
ChevronUp,
ClipboardList,
Filter,
Plus,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { TooltipProvider } from "@/components/ui/tooltip";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import {
useFeedbackItems,
useUpdateFeedback,
useResolveFeedback,
useVerifyFeedback,
useReopenFeedback,
@ -40,11 +32,9 @@ interface FeedbackChecklistProps {
}) => void;
}
type SeverityFilter = "ALL" | "CRITICAL" | "MAJOR" | "MINOR" | "SUGGESTION";
type TypeFilter = "ALL" | "ACTION" | "INFO";
type StatusFilter = "ALL" | "OPEN" | "RESOLVED";
const SEVERITY_ORDER = ["CRITICAL", "MAJOR", "MINOR", "SUGGESTION"] as const;
export function FeedbackChecklist({
stageId,
revisionId,
@ -52,16 +42,18 @@ export function FeedbackChecklist({
onAnnotationClick,
}: FeedbackChecklistProps) {
const [collapsed, setCollapsed] = useState(false);
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("ALL");
const [typeFilter, setTypeFilter] = useState<TypeFilter>("ALL");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
const { data: items = [], isLoading } = useFeedbackItems(stageId, revisionId);
const updateMutation = useUpdateFeedback(stageId);
const resolveMutation = useResolveFeedback(stageId);
const verifyMutation = useVerifyFeedback(stageId);
const reopenMutation = useReopenFeedback(stageId);
const deleteMutation = useDeleteFeedback(stageId);
const isPending =
updateMutation.isPending ||
resolveMutation.isPending ||
verifyMutation.isPending ||
reopenMutation.isPending ||
@ -71,8 +63,10 @@ export function FeedbackChecklist({
const filteredItems = useMemo(() => {
let result = [...items];
if (severityFilter !== "ALL") {
result = result.filter((i: any) => i.severity === severityFilter);
if (typeFilter === "ACTION") {
result = result.filter((i: any) => i.isActionItem);
} else if (typeFilter === "INFO") {
result = result.filter((i: any) => !i.isActionItem);
}
if (statusFilter === "OPEN") {
@ -89,23 +83,25 @@ export function FeedbackChecklist({
}
return result;
}, [items, severityFilter, statusFilter]);
}, [items, typeFilter, statusFilter]);
// Group by severity
const groupedItems = useMemo(() => {
const groups: Record<string, any[]> = {};
for (const sev of SEVERITY_ORDER) {
const group = filteredItems.filter((i: any) => i.severity === sev);
if (group.length > 0) groups[sev] = group;
}
return groups;
}, [filteredItems]);
// Separate action items and info callouts
const actionItems = useMemo(
() => filteredItems.filter((i: any) => i.isActionItem),
[filteredItems]
);
const infoItems = useMemo(
() => filteredItems.filter((i: any) => !i.isActionItem),
[filteredItems]
);
// Stats
const totalCount = items.length;
const resolvedCount = items.filter(
// Stats (only count action items for progress)
const allActionItems = items.filter((i: any) => i.isActionItem);
const totalCount = allActionItems.length;
const resolvedCount = allActionItems.filter(
(i: any) => i.status === "RESOLVED" || i.status === "VERIFIED"
).length;
const infoCount = items.filter((i: any) => !i.isActionItem).length;
const handleResolve = (itemId: string, resolutionNote?: string) => {
resolveMutation.mutate(
@ -134,7 +130,14 @@ export function FeedbackChecklist({
});
};
const hasActiveFilters = severityFilter !== "ALL" || statusFilter !== "ALL";
const handleToggleType = (itemId: string, isActionItem: boolean) => {
updateMutation.mutate(
{ itemId, data: { isActionItem } },
{
onError: (err) => toast.error("Failed to update", { description: err.message }),
}
);
};
return (
<TooltipProvider delayDuration={200}>
@ -152,7 +155,7 @@ export function FeedbackChecklist({
<div className="flex items-center gap-2">
<ClipboardList className="h-3.5 w-3.5 text-[var(--primary)]" />
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Feedback Checklist
Feedback
</span>
{totalCount > 0 && (
<Badge
@ -167,6 +170,14 @@ export function FeedbackChecklist({
{resolvedCount}/{totalCount}
</Badge>
)}
{infoCount > 0 && (
<Badge
variant="outline"
className="h-4 px-1.5 text-[9px] tabular-nums text-[var(--muted-foreground)]"
>
{infoCount} info
</Badge>
)}
</div>
{collapsed ? (
<ChevronDown className="h-3.5 w-3.5 text-[var(--muted-foreground)]" />
@ -177,7 +188,7 @@ export function FeedbackChecklist({
{!collapsed && (
<div className="px-3 pb-3">
{/* Progress bar */}
{/* Progress bar (action items only) */}
{totalCount > 0 && (
<FeedbackProgressBar
resolved={resolvedCount}
@ -187,7 +198,7 @@ export function FeedbackChecklist({
)}
{/* Filters */}
{totalCount > 0 && (
{items.length > 0 && (
<div className="mb-2 flex items-center gap-1.5">
{/* Status filter */}
<div className="flex rounded-md border">
@ -207,62 +218,35 @@ export function FeedbackChecklist({
))}
</div>
{/* Severity filter */}
<Popover>
<PopoverTrigger asChild>
<Button
size="sm"
variant="outline"
{/* Type filter */}
<div className="flex rounded-md border">
{(["ALL", "ACTION", "INFO"] as TypeFilter[]).map((t) => (
<button
key={t}
className={cn(
"h-5 gap-1 px-1.5 text-[10px]",
severityFilter !== "ALL" && "border-[var(--primary)] text-[var(--primary)]"
"px-2 py-0.5 text-[10px] font-medium transition-colors",
typeFilter === t
? "bg-[var(--primary)] text-[var(--primary-foreground)]"
: "text-[var(--muted-foreground)] hover:bg-[var(--muted)]"
)}
onClick={() => setTypeFilter(t)}
>
<Filter className="h-2.5 w-2.5" />
{severityFilter === "ALL" ? "Severity" : severityFilter}
</Button>
</PopoverTrigger>
<PopoverContent className="w-32 p-1" align="start">
{(["ALL", ...SEVERITY_ORDER] as SeverityFilter[]).map(
(sev) => (
<button
key={sev}
className={cn(
"flex w-full items-center rounded px-2 py-1 text-xs hover:bg-[var(--muted)]",
severityFilter === sev && "bg-[var(--muted)] font-medium"
)}
onClick={() => setSeverityFilter(sev)}
>
{sev === "ALL" ? "All severities" : sev}
</button>
)
)}
</PopoverContent>
</Popover>
{hasActiveFilters && (
<button
className="text-[10px] text-[var(--muted-foreground)] underline"
onClick={() => {
setSeverityFilter("ALL");
setStatusFilter("ALL");
}}
>
Clear
</button>
)}
{t === "ALL" ? "All" : t === "ACTION" ? "Actions" : "Info"}
</button>
))}
</div>
</div>
)}
{/* Item list grouped by severity */}
{/* Item list */}
{isLoading ? (
<div className="py-4 text-center text-xs text-[var(--muted-foreground)]">
Loading feedback items...
</div>
) : totalCount === 0 ? (
) : items.length === 0 ? (
<div className="py-4 text-center text-xs text-[var(--muted-foreground)]">
No feedback items yet. Annotations will automatically create
checklist items.
action items.
</div>
) : filteredItems.length === 0 ? (
<div className="py-4 text-center text-xs text-[var(--muted-foreground)]">
@ -270,13 +254,14 @@ export function FeedbackChecklist({
</div>
) : (
<div className="space-y-3">
{Object.entries(groupedItems).map(([severity, group]) => (
<div key={severity}>
{/* Action items first */}
{actionItems.length > 0 && (
<div>
<p className="mb-1 text-[9px] font-bold uppercase tracking-widest text-[var(--muted-foreground)]">
{severity} ({group.length})
Action Items ({actionItems.length})
</p>
<div className="space-y-1">
{group.map((item: any) => (
{actionItems.map((item: any) => (
<FeedbackItemCard
key={item.id}
item={item}
@ -284,13 +269,38 @@ export function FeedbackChecklist({
onVerify={handleVerify}
onReopen={handleReopen}
onDelete={handleDelete}
onToggleType={handleToggleType}
onAnnotationClick={onAnnotationClick}
isPending={isPending}
/>
))}
</div>
</div>
))}
)}
{/* Info callouts */}
{infoItems.length > 0 && (
<div>
<p className="mb-1 text-[9px] font-bold uppercase tracking-widest text-[var(--muted-foreground)]">
Info Callouts ({infoItems.length})
</p>
<div className="space-y-1">
{infoItems.map((item: any) => (
<FeedbackItemCard
key={item.id}
item={item}
onResolve={handleResolve}
onVerify={handleVerify}
onReopen={handleReopen}
onDelete={handleDelete}
onToggleType={handleToggleType}
onAnnotationClick={onAnnotationClick}
isPending={isPending}
/>
))}
</div>
</div>
)}
</div>
)}
</div>

View file

@ -6,10 +6,12 @@ import {
CheckCheck,
ChevronDown,
ChevronUp,
Info,
MapPin,
RotateCcw,
Trash2,
ArrowRight,
CircleDot,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -25,7 +27,7 @@ import { cn } from "@/lib/utils";
interface FeedbackItemData {
id: string;
summary: string;
severity: "CRITICAL" | "MAJOR" | "MINOR" | "SUGGESTION";
isActionItem: boolean;
status: "OPEN" | "IN_PROGRESS" | "RESOLVED" | "VERIFIED" | "REOPENED";
resolutionNote?: string | null;
annotation?: {
@ -47,6 +49,7 @@ interface FeedbackItemCardProps {
onVerify: (itemId: string) => void;
onReopen: (itemId: string) => void;
onDelete: (itemId: string) => void;
onToggleType?: (itemId: string, isActionItem: boolean) => void;
onAnnotationClick?: (annotation: {
id: string;
imageX: number;
@ -55,25 +58,6 @@ interface FeedbackItemCardProps {
isPending?: boolean;
}
const SEVERITY_STYLES: Record<string, { badge: string; border: string }> = {
CRITICAL: {
badge: "bg-red-500/10 text-red-600 border-red-500/30",
border: "border-l-red-500",
},
MAJOR: {
badge: "bg-orange-500/10 text-orange-600 border-orange-500/30",
border: "border-l-orange-500",
},
MINOR: {
badge: "bg-yellow-500/10 text-yellow-600 border-yellow-500/30",
border: "border-l-yellow-500",
},
SUGGESTION: {
badge: "bg-blue-500/10 text-blue-600 border-blue-500/30",
border: "border-l-blue-500",
},
};
const STATUS_LABELS: Record<string, string> = {
OPEN: "Open",
IN_PROGRESS: "In Progress",
@ -88,6 +72,7 @@ export function FeedbackItemCard({
onVerify,
onReopen,
onDelete,
onToggleType,
onAnnotationClick,
isPending,
}: FeedbackItemCardProps) {
@ -95,7 +80,6 @@ export function FeedbackItemCard({
const [resolutionNote, setResolutionNote] = useState("");
const isResolved = item.status === "RESOLVED" || item.status === "VERIFIED";
const styles = SEVERITY_STYLES[item.severity];
const handleResolve = () => {
onResolve(item.id, resolutionNote || undefined);
@ -107,37 +91,48 @@ export function FeedbackItemCard({
<div
className={cn(
"group relative rounded-lg border border-l-[3px] p-2.5 transition-colors",
styles.border,
item.isActionItem
? "border-l-[var(--accent)]"
: "border-l-[var(--muted-foreground)]",
isResolved && "opacity-60"
)}
>
<div className="flex items-start gap-2">
{/* Checkbox */}
<Checkbox
checked={isResolved}
disabled={isPending || item.status === "VERIFIED"}
onCheckedChange={(checked) => {
if (checked) {
if (item.summary.length < 50) {
onResolve(item.id);
{/* Checkbox (only for action items) */}
{item.isActionItem ? (
<Checkbox
checked={isResolved}
disabled={isPending || item.status === "VERIFIED"}
onCheckedChange={(checked) => {
if (checked) {
if (item.summary.length < 50) {
onResolve(item.id);
} else {
setExpanded(true);
}
} else {
setExpanded(true);
onReopen(item.id);
}
} else {
onReopen(item.id);
}
}}
className="mt-0.5"
/>
}}
className="mt-0.5"
/>
) : (
<Info className="mt-0.5 h-4 w-4 shrink-0 text-[var(--muted-foreground)]" />
)}
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<Badge
variant="outline"
className={cn("h-4 px-1 text-[9px] font-bold", styles.badge)}
className={cn(
"h-4 px-1 text-[9px] font-bold",
item.isActionItem
? "border-[var(--accent)]/30 bg-[var(--accent)]/10 text-[var(--accent)]"
: "border-[var(--muted-foreground)]/30 bg-[var(--muted)]/50 text-[var(--muted-foreground)]"
)}
>
{item.severity}
{item.isActionItem ? "Action" : "Info"}
</Badge>
{item.status !== "OPEN" && !isResolved && (
@ -189,7 +184,7 @@ export function FeedbackItemCard({
)}
{/* Expanded resolve form */}
{expanded && !isResolved && (
{expanded && !isResolved && item.isActionItem && (
<div className="mt-2 space-y-1.5">
<Textarea
placeholder="Resolution note (optional)..."
@ -238,7 +233,31 @@ export function FeedbackItemCard({
</Tooltip>
)}
{!isResolved && !expanded && (
{/* Toggle action item / info */}
{onToggleType && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
disabled={isPending}
onClick={() => onToggleType(item.id, !item.isActionItem)}
>
{item.isActionItem ? (
<Info className="h-3 w-3" />
) : (
<CircleDot className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{item.isActionItem ? "Change to info callout" : "Change to action item"}
</TooltipContent>
</Tooltip>
)}
{item.isActionItem && !isResolved && !expanded && (
<Tooltip>
<TooltipTrigger asChild>
<Button

View file

@ -8,13 +8,6 @@ interface FeedbackProgressBarProps {
className?: string;
}
const SEVERITY_COLORS: Record<string, string> = {
CRITICAL: "bg-red-500",
MAJOR: "bg-orange-500",
MINOR: "bg-yellow-500",
SUGGESTION: "bg-blue-500",
};
export function FeedbackProgressBar({
resolved,
total,
@ -27,7 +20,7 @@ export function FeedbackProgressBar({
<div className={cn("space-y-1", className)}>
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Feedback Progress
Action Items
</span>
<span
className={cn(

View file

@ -14,13 +14,6 @@ interface FeedbackIndicatorProps {
className?: string;
}
const SEVERITY_COLORS: Record<string, string> = {
CRITICAL: "bg-red-500/10 text-red-600 border-red-500/30",
MAJOR: "bg-orange-500/10 text-orange-600 border-orange-500/30",
MINOR: "bg-yellow-500/10 text-yellow-600 border-yellow-500/30",
SUGGESTION: "bg-blue-500/10 text-blue-600 border-blue-500/30",
};
export function FeedbackIndicator({
stageId,
className,
@ -30,9 +23,6 @@ export function FeedbackIndicator({
if (!summary || summary.total === 0) return null;
const allDone = summary.resolved === summary.total;
const severityStyle = summary.worstSeverity
? SEVERITY_COLORS[summary.worstSeverity]
: "";
return (
<Tooltip>
@ -43,7 +33,7 @@ export function FeedbackIndicator({
"h-4 gap-0.5 px-1 text-[9px] font-bold tabular-nums",
allDone
? "border-[var(--status-approved)]/30 bg-[var(--status-approved)]/10 text-[var(--status-approved)]"
: severityStyle,
: "border-[var(--accent)]/30 bg-[var(--accent)]/10 text-[var(--accent)]",
className
)}
>
@ -53,12 +43,9 @@ export function FeedbackIndicator({
<TooltipContent>
<p className="text-xs">
{allDone
? "All feedback resolved"
: `${summary.open} open feedback item${summary.open !== 1 ? "s" : ""}${
summary.worstSeverity
? ` (worst: ${summary.worstSeverity.toLowerCase()})`
: ""
}`}
? "All action items resolved"
: `${summary.open} open action item${summary.open !== 1 ? "s" : ""}`}
{summary.infoCount > 0 && ` · ${summary.infoCount} info callout${summary.infoCount !== 1 ? "s" : ""}`}
</p>
</TooltipContent>
</Tooltip>

View file

@ -349,6 +349,14 @@ export function useAnnotationState(
) => {
if (!revisionId || !stageId) return;
const MAX_SCREENSHOT_SIZE = 5 * 1024 * 1024; // 5MB
if (file.size > MAX_SCREENSHOT_SIZE) {
toast.error("Screenshot too large", {
description: `Maximum size is 5MB. Your screenshot is ${(file.size / 1024 / 1024).toFixed(1)}MB. Try cropping or reducing the capture area.`,
});
return;
}
const formData = new FormData();
formData.append("file", file);
formData.append("type", "current");

View file

@ -46,7 +46,7 @@ export function useFeedbackSummary(stageId: string) {
total: number;
resolved: number;
open: number;
worstSeverity: string | null;
infoCount: number;
}>(`/api/stages/${stageId}/feedback?summary=true`),
enabled: !!stageId,
});
@ -77,7 +77,7 @@ export function useCreateFeedback(stageId: string) {
}
/**
* Update a feedback item (summary, severity, status, assignment, sort).
* Update a feedback item (summary, isActionItem, status, assignment, sort).
*/
export function useUpdateFeedback(stageId: string) {
const queryClient = useQueryClient();

View file

@ -15,22 +15,22 @@ const FEEDBACK_INCLUDE = {
} as const;
/**
* List feedback items for a stage, optionally filtered by revision/status.
* List feedback items for a stage, optionally filtered by revision/status/type.
*/
export async function listFeedbackItems(
stageId: string,
filters?: { revisionId?: string; status?: string; severity?: string }
filters?: { revisionId?: string; status?: string; isActionItem?: boolean }
) {
const where: any = { deliverableStageId: stageId };
if (filters?.revisionId) where.revisionId = filters.revisionId;
if (filters?.status) where.status = filters.status;
if (filters?.severity) where.severity = filters.severity;
if (filters?.isActionItem !== undefined) where.isActionItem = filters.isActionItem;
return prisma.feedbackItem.findMany({
where,
include: FEEDBACK_INCLUDE,
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
orderBy: [{ isActionItem: "desc" }, { sortOrder: "asc" }, { createdAt: "asc" }],
});
}
@ -45,14 +45,13 @@ export async function getFeedbackItem(itemId: string) {
}
/**
* Create a feedback item manually (e.g., from the checklist panel).
* Create a feedback item manually.
*/
export async function createFeedbackItem(
stageId: string,
userId: string,
input: CreateFeedbackInput
) {
// Get current max sort order for this revision
const maxSort = await prisma.feedbackItem.aggregate({
where: { revisionId: input.revisionId },
_max: { sortOrder: true },
@ -65,7 +64,7 @@ export async function createFeedbackItem(
annotationId: input.annotationId ?? null,
commentId: input.commentId ?? null,
summary: input.summary,
severity: (input.severity as any) ?? "MAJOR",
isActionItem: input.isActionItem ?? true,
status: "OPEN",
sortOrder: (maxSort._max.sortOrder ?? 0) + 1,
assignedToId: input.assignedToId ?? null,
@ -77,7 +76,7 @@ export async function createFeedbackItem(
/**
* Auto-create a feedback item when an annotation is created.
* Called from the annotation creation flow.
* All annotations are action items by default.
*/
export async function createFeedbackFromAnnotation(
stageId: string,
@ -86,9 +85,8 @@ export async function createFeedbackFromAnnotation(
commentId: string,
commentText: string,
userId: string,
severity: "CRITICAL" | "MAJOR" | "MINOR" | "SUGGESTION" = "MAJOR"
isActionItem: boolean = true
) {
// Derive summary from comment text (first 200 chars)
const summary =
commentText.length > 200
? commentText.slice(0, 197) + "..."
@ -106,7 +104,7 @@ export async function createFeedbackFromAnnotation(
annotationId,
commentId,
summary,
severity,
isActionItem,
status: "OPEN",
sortOrder: (maxSort._max.sortOrder ?? 0) + 1,
createdById: userId,
@ -116,7 +114,7 @@ export async function createFeedbackFromAnnotation(
}
/**
* Update a feedback item (summary, severity, status, assignment, sort).
* Update a feedback item.
*/
export async function updateFeedbackItem(
itemId: string,
@ -126,7 +124,7 @@ export async function updateFeedbackItem(
where: { id: itemId },
data: {
...(input.summary !== undefined && { summary: input.summary }),
...(input.severity !== undefined && { severity: input.severity as any }),
...(input.isActionItem !== undefined && { isActionItem: input.isActionItem }),
...(input.status !== undefined && { status: input.status as any }),
...(input.assignedToId !== undefined && {
assignedToId: input.assignedToId,
@ -158,7 +156,7 @@ export async function resolveFeedbackItem(
}
/**
* Verify a resolved feedback item (reviewer confirms the fix).
* Verify a resolved feedback item.
*/
export async function verifyFeedbackItem(itemId: string, userId: string) {
return prisma.feedbackItem.update({
@ -173,7 +171,7 @@ export async function verifyFeedbackItem(itemId: string, userId: string) {
}
/**
* Reopen a feedback item (from any status back to OPEN).
* Reopen a feedback item.
*/
export async function reopenFeedbackItem(itemId: string) {
return prisma.feedbackItem.update({
@ -199,8 +197,7 @@ export async function deleteFeedbackItem(itemId: string) {
}
/**
* Carry forward unresolved CRITICAL and MAJOR items from a previous revision
* to a new revision. Returns the newly created carried-forward items.
* Carry forward unresolved action items from a previous revision to a new one.
*/
export async function carryForwardFeedback(
stageId: string,
@ -212,7 +209,7 @@ export async function carryForwardFeedback(
where: {
revisionId: previousRevisionId,
status: { in: ["OPEN", "IN_PROGRESS", "REOPENED"] },
severity: { in: ["CRITICAL", "MAJOR"] },
isActionItem: true, // only carry forward action items, not info callouts
},
});
@ -227,7 +224,7 @@ export async function carryForwardFeedback(
annotationId: item.annotationId,
commentId: item.commentId,
summary: item.summary,
severity: item.severity,
isActionItem: true,
status: "OPEN",
sortOrder: idx + 1,
assignedToId: item.assignedToId,
@ -248,23 +245,16 @@ export async function carryForwardFeedback(
export async function getFeedbackSummary(stageId: string) {
const items = await prisma.feedbackItem.findMany({
where: { deliverableStageId: stageId },
select: { status: true, severity: true },
select: { status: true, isActionItem: true },
});
const total = items.length;
const resolved = items.filter(
const actionItems = items.filter((i) => i.isActionItem);
const total = actionItems.length;
const resolved = actionItems.filter(
(i) => i.status === "RESOLVED" || i.status === "VERIFIED"
).length;
const open = total - resolved;
const infoCount = items.filter((i) => !i.isActionItem).length;
// Worst unresolved severity
const unresolvedSeverities = items
.filter((i) => i.status !== "RESOLVED" && i.status !== "VERIFIED")
.map((i) => i.severity);
const severityOrder = ["CRITICAL", "MAJOR", "MINOR", "SUGGESTION"] as const;
const worstSeverity =
severityOrder.find((s) => unresolvedSeverities.includes(s)) ?? null;
return { total, resolved, open, worstSeverity };
return { total, resolved, open, infoCount };
}

View file

@ -1,12 +1,5 @@
import { z } from "zod/v4";
const feedbackSeverityEnum = z.enum([
"CRITICAL",
"MAJOR",
"MINOR",
"SUGGESTION",
]);
const feedbackStatusEnum = z.enum([
"OPEN",
"IN_PROGRESS",
@ -20,7 +13,7 @@ export const createFeedbackSchema = z.object({
annotationId: z.string().optional(),
commentId: z.string().optional(),
summary: z.string().min(1, "Summary is required"),
severity: feedbackSeverityEnum.optional(),
isActionItem: z.boolean().optional(), // default true
assignedToId: z.string().optional(),
});
@ -28,7 +21,7 @@ export type CreateFeedbackInput = z.infer<typeof createFeedbackSchema>;
export const updateFeedbackSchema = z.object({
summary: z.string().min(1).optional(),
severity: feedbackSeverityEnum.optional(),
isActionItem: z.boolean().optional(),
status: feedbackStatusEnum.optional(),
assignedToId: z.string().nullable().optional(),
sortOrder: z.number().int().optional(),