feat: add validation for minimum length on various fields and update type definitions

This commit is contained in:
Abhimanyu Saharan
2026-02-06 16:12:04 +05:30
parent ca614328ac
commit d86fe0a7a6
157 changed files with 12340 additions and 2977 deletions

View File

@@ -1,12 +1,21 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useAuth } from "@clerk/nextjs";
import { useQueryClient } from "@tanstack/react-query";
import { Clock } from "lucide-react";
import { Cell, Pie, PieChart } from "recharts";
import { ApiError } from "@/api/mutator";
import {
type listApprovalsApiV1BoardsBoardIdApprovalsGetResponse,
getListApprovalsApiV1BoardsBoardIdApprovalsGetQueryKey,
useListApprovalsApiV1BoardsBoardIdApprovalsGet,
useUpdateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch,
} from "@/api/generated/approvals/approvals";
import type { ApprovalRead } from "@/api/generated/model";
import {
ChartContainer,
ChartTooltip,
@@ -14,25 +23,17 @@ import {
type ChartConfig,
} from "@/components/charts/chart";
import { Button } from "@/components/ui/button";
import { getApiBaseUrl } from "@/lib/api-base";
import { cn } from "@/lib/utils";
const apiBase = getApiBaseUrl();
type Approval = {
id: string;
action_type: string;
payload?: Record<string, unknown> | null;
confidence: number;
rubric_scores?: Record<string, number> | null;
status: string;
created_at: string;
resolved_at?: string | null;
};
type Approval = ApprovalRead & { status: string };
const normalizeApproval = (approval: ApprovalRead): Approval => ({
...approval,
status: approval.status ?? "pending",
});
type BoardApprovalsPanelProps = {
boardId: string;
approvals?: Approval[];
approvals?: ApprovalRead[];
isLoading?: boolean;
error?: string | null;
onDecision?: (approvalId: string, status: "approved" | "rejected") => void;
@@ -192,54 +193,59 @@ export function BoardApprovalsPanel({
onDecision,
scrollable = false,
}: BoardApprovalsPanelProps) {
const { getToken, isSignedIn } = useAuth();
const [internalApprovals, setInternalApprovals] = useState<Approval[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { isSignedIn } = useAuth();
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const [updatingId, setUpdatingId] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const lastDecisionRef = useRef<string | null>(null);
const usingExternal = Array.isArray(externalApprovals);
const approvals = useMemo(
() => (usingExternal ? externalApprovals ?? [] : internalApprovals),
[externalApprovals, internalApprovals, usingExternal],
const approvalsKey = useMemo(
() => getListApprovalsApiV1BoardsBoardIdApprovalsGetQueryKey(boardId),
[boardId],
);
const loadingState = usingExternal ? externalLoading ?? false : isLoading;
const errorState = usingExternal ? externalError ?? null : error;
const loadApprovals = useCallback(async () => {
if (usingExternal) return;
if (!isSignedIn || !boardId) return;
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const res = await fetch(`${apiBase}/api/v1/boards/${boardId}/approvals`, {
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
});
if (!res.ok) throw new Error("Unable to load approvals.");
const data = (await res.json()) as Approval[];
setInternalApprovals(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to load approvals.");
} finally {
setIsLoading(false);
}
}, [boardId, getToken, isSignedIn, usingExternal]);
const approvalsQuery = useListApprovalsApiV1BoardsBoardIdApprovalsGet<
listApprovalsApiV1BoardsBoardIdApprovalsGetResponse,
ApiError
>(boardId, undefined, {
query: {
enabled: Boolean(!usingExternal && isSignedIn && boardId),
refetchInterval: 15_000,
refetchOnMount: "always",
retry: false,
},
});
useEffect(() => {
if (usingExternal) return;
loadApprovals();
if (!isSignedIn || !boardId) return;
const interval = setInterval(loadApprovals, 15000);
return () => clearInterval(interval);
}, [boardId, isSignedIn, loadApprovals, usingExternal]);
const updateApprovalMutation =
useUpdateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch<ApiError>();
const approvals = useMemo(() => {
const raw = usingExternal
? externalApprovals ?? []
: approvalsQuery.data?.status === 200
? approvalsQuery.data.data
: [];
return raw.map(normalizeApproval);
}, [approvalsQuery.data, externalApprovals, usingExternal]);
const loadingState = usingExternal
? externalLoading ?? false
: approvalsQuery.isLoading;
const errorState = usingExternal
? externalError ?? null
: error ?? approvalsQuery.error?.message ?? null;
const handleDecision = useCallback(
async (approvalId: string, status: "approved" | "rejected") => {
lastDecisionRef.current = approvalId;
(approvalId: string, status: "approved" | "rejected") => {
const pendingNext = [...approvals]
.filter((item) => item.id !== approvalId)
.filter((item) => item.status === "pending")
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]
?.id;
if (pendingNext) {
setSelectedId(pendingNext);
}
if (onDecision) {
onDecision(approvalId, status);
return;
@@ -248,33 +254,45 @@ export function BoardApprovalsPanel({
if (!isSignedIn || !boardId) return;
setUpdatingId(approvalId);
setError(null);
try {
const token = await getToken();
const res = await fetch(
`${apiBase}/api/v1/boards/${boardId}/approvals/${approvalId}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({ status }),
}
);
if (!res.ok) throw new Error("Unable to update approval.");
const updated = (await res.json()) as Approval;
setInternalApprovals((prev) =>
prev.map((item) => (item.id === approvalId ? updated : item))
);
} catch (err) {
setError(
err instanceof Error ? err.message : "Unable to update approval."
);
} finally {
setUpdatingId(null);
}
updateApprovalMutation.mutate(
{ boardId, approvalId, data: { status } },
{
onSuccess: (result) => {
if (result.status !== 200) return;
queryClient.setQueryData<listApprovalsApiV1BoardsBoardIdApprovalsGetResponse>(
approvalsKey,
(previous) => {
if (!previous || previous.status !== 200) return previous;
return {
...previous,
data: previous.data.map((item) =>
item.id === approvalId ? result.data : item,
),
};
},
);
},
onError: (err) => {
setError(err.message || "Unable to update approval.");
},
onSettled: () => {
setUpdatingId(null);
queryClient.invalidateQueries({ queryKey: approvalsKey });
},
},
);
},
[boardId, getToken, isSignedIn, onDecision, usingExternal]
[
approvals,
approvalsKey,
boardId,
isSignedIn,
onDecision,
queryClient,
updateApprovalMutation,
usingExternal,
],
);
const sortedApprovals = useMemo(() => {
@@ -298,32 +316,20 @@ export function BoardApprovalsPanel({
[sortedApprovals.pending, sortedApprovals.resolved]
);
useEffect(() => {
if (orderedApprovals.length === 0) {
setSelectedId(null);
return;
}
if (!selectedId || !orderedApprovals.some((item) => item.id === selectedId)) {
setSelectedId(orderedApprovals[0].id);
const effectiveSelectedId = useMemo(() => {
if (orderedApprovals.length === 0) return null;
if (selectedId && orderedApprovals.some((item) => item.id === selectedId)) {
return selectedId;
}
return orderedApprovals[0].id;
}, [orderedApprovals, selectedId]);
const selectedApproval = useMemo(() => {
if (!selectedId) return null;
return orderedApprovals.find((item) => item.id === selectedId) ?? null;
}, [orderedApprovals, selectedId]);
useEffect(() => {
if (!lastDecisionRef.current) return;
const resolvedId = lastDecisionRef.current;
const pendingNext = sortedApprovals.pending.find(
(item) => item.id !== resolvedId,
if (!effectiveSelectedId) return null;
return (
orderedApprovals.find((item) => item.id === effectiveSelectedId) ?? null
);
if (pendingNext) {
setSelectedId(pendingNext.id);
}
lastDecisionRef.current = null;
}, [sortedApprovals.pending]);
}, [effectiveSelectedId, orderedApprovals]);
const pendingCount = sortedApprovals.pending.length;
const resolvedCount = sortedApprovals.resolved.length;
@@ -369,7 +375,7 @@ export function BoardApprovalsPanel({
>
{orderedApprovals.map((approval) => {
const summary = approvalSummary(approval);
const isSelected = selectedId === approval.id;
const isSelected = effectiveSelectedId === approval.id;
const isPending = approval.status === "pending";
const titleRow = summary.rows.find(
(row) => row.label.toLowerCase() === "title"

View File

@@ -2,8 +2,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@clerk/nextjs";
import {
DialogFooter,
DialogHeader,
@@ -11,9 +9,19 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { getApiBaseUrl } from "@/lib/api-base";
const apiBase = getApiBaseUrl();
import {
answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost,
confirmOnboardingApiV1BoardsBoardIdOnboardingConfirmPost,
getOnboardingApiV1BoardsBoardIdOnboardingGet,
startOnboardingApiV1BoardsBoardIdOnboardingStartPost,
} from "@/api/generated/board-onboarding/board-onboarding";
import type {
BoardOnboardingRead,
BoardOnboardingReadDraftGoal,
BoardOnboardingReadMessages,
BoardRead,
} from "@/api/generated/model";
type BoardDraft = {
board_type?: string;
@@ -22,24 +30,55 @@ type BoardDraft = {
target_date?: string | null;
};
type BoardSummary = {
id: string;
name: string;
slug: string;
board_type?: string;
objective?: string | null;
success_metrics?: Record<string, unknown> | null;
target_date?: string | null;
goal_confirmed?: boolean;
type NormalizedMessage = {
role: string;
content: string;
};
type OnboardingSession = {
id: string;
board_id: string;
session_key: string;
status: string;
messages?: Array<{ role: string; content: string }> | null;
draft_goal?: BoardDraft | null;
const normalizeMessages = (
value?: BoardOnboardingReadMessages,
): NormalizedMessage[] | null => {
if (!value) return null;
if (!Array.isArray(value)) return null;
const items: NormalizedMessage[] = [];
for (const entry of value) {
if (!entry || typeof entry !== "object") continue;
const raw = entry as Record<string, unknown>;
const role = typeof raw.role === "string" ? raw.role : null;
const content = typeof raw.content === "string" ? raw.content : null;
if (!role || !content) continue;
items.push({ role, content });
}
return items.length ? items : null;
};
const normalizeDraftGoal = (value?: BoardOnboardingReadDraftGoal): BoardDraft | null => {
if (!value || typeof value !== "object") return null;
const raw = value as Record<string, unknown>;
const board_type = typeof raw.board_type === "string" ? raw.board_type : undefined;
const objective =
typeof raw.objective === "string" ? raw.objective : raw.objective === null ? null : undefined;
const target_date =
typeof raw.target_date === "string"
? raw.target_date
: raw.target_date === null
? null
: undefined;
let success_metrics: Record<string, unknown> | null = null;
if (raw.success_metrics === null || raw.success_metrics === undefined) {
success_metrics = null;
} else if (typeof raw.success_metrics === "object") {
success_metrics = raw.success_metrics as Record<string, unknown>;
}
return {
board_type,
objective: objective ?? null,
success_metrics,
target_date: target_date ?? null,
};
};
type QuestionOption = { id: string; label: string };
@@ -75,7 +114,7 @@ const normalizeQuestion = (value: unknown): Question | null => {
return { question: data.question, options };
};
const parseQuestion = (messages?: Array<{ role: string; content: string }> | null) => {
const parseQuestion = (messages?: NormalizedMessage[] | null) => {
if (!messages?.length) return null;
const lastAssistant = [...messages].reverse().find((msg) => msg.role === "assistant");
if (!lastAssistant?.content) return null;
@@ -99,66 +138,52 @@ export function BoardOnboardingChat({
onConfirmed,
}: {
boardId: string;
onConfirmed: (board: BoardSummary) => void;
onConfirmed: (board: BoardRead) => void;
}) {
const { getToken } = useAuth();
const [session, setSession] = useState<OnboardingSession | null>(null);
const [session, setSession] = useState<BoardOnboardingRead | null>(null);
const [loading, setLoading] = useState(false);
const [otherText, setOtherText] = useState("");
const [error, setError] = useState<string | null>(null);
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
const question = useMemo(() => parseQuestion(session?.messages), [session]);
const draft = session?.draft_goal ?? null;
const normalizedMessages = useMemo(
() => normalizeMessages(session?.messages),
[session?.messages],
);
const question = useMemo(() => parseQuestion(normalizedMessages), [normalizedMessages]);
const draft = useMemo(() => normalizeDraftGoal(session?.draft_goal), [session?.draft_goal]);
useEffect(() => {
setSelectedOptions([]);
setOtherText("");
}, [question?.question]);
const authFetch = useCallback(
async (url: string, options: RequestInit = {}) => {
const token = await getToken();
return fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers ?? {}),
},
});
},
[getToken]
);
const startSession = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await authFetch(`${apiBase}/api/v1/boards/${boardId}/onboarding/start`, {
method: "POST",
body: JSON.stringify({}),
});
if (!res.ok) throw new Error("Unable to start onboarding.");
const data = (await res.json()) as OnboardingSession;
setSession(data);
const result = await startOnboardingApiV1BoardsBoardIdOnboardingStartPost(
boardId,
{},
);
if (result.status !== 200) throw new Error("Unable to start onboarding.");
setSession(result.data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to start onboarding.");
} finally {
setLoading(false);
}
}, [authFetch, boardId]);
}, [boardId]);
const refreshSession = useCallback(async () => {
try {
const res = await authFetch(`${apiBase}/api/v1/boards/${boardId}/onboarding`);
if (!res.ok) return;
const data = (await res.json()) as OnboardingSession;
setSession(data);
const result = await getOnboardingApiV1BoardsBoardIdOnboardingGet(boardId);
if (result.status !== 200) return;
setSession(result.data);
} catch {
// ignore
}
}, [authFetch, boardId]);
}, [boardId]);
useEffect(() => {
startSession();
@@ -171,19 +196,15 @@ export function BoardOnboardingChat({
setLoading(true);
setError(null);
try {
const res = await authFetch(
`${apiBase}/api/v1/boards/${boardId}/onboarding/answer`,
const result = await answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost(
boardId,
{
method: "POST",
body: JSON.stringify({
answer: value,
other_text: freeText ?? null,
}),
}
answer: value,
other_text: freeText ?? null,
},
);
if (!res.ok) throw new Error("Unable to submit answer.");
const data = (await res.json()) as OnboardingSession;
setSession(data);
if (result.status !== 200) throw new Error("Unable to submit answer.");
setSession(result.data);
setOtherText("");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to submit answer.");
@@ -191,7 +212,7 @@ export function BoardOnboardingChat({
setLoading(false);
}
},
[authFetch, boardId]
[boardId],
);
const toggleOption = useCallback((label: string) => {
@@ -213,21 +234,17 @@ export function BoardOnboardingChat({
setLoading(true);
setError(null);
try {
const res = await authFetch(
`${apiBase}/api/v1/boards/${boardId}/onboarding/confirm`,
const result = await confirmOnboardingApiV1BoardsBoardIdOnboardingConfirmPost(
boardId,
{
method: "POST",
body: JSON.stringify({
board_type: draft.board_type ?? "goal",
objective: draft.objective ?? null,
success_metrics: draft.success_metrics ?? null,
target_date: draft.target_date ?? null,
}),
}
board_type: draft.board_type ?? "goal",
objective: draft.objective ?? null,
success_metrics: draft.success_metrics ?? null,
target_date: draft.target_date ?? null,
},
);
if (!res.ok) throw new Error("Unable to confirm board goal.");
const updated = await res.json();
onConfirmed(updated);
if (result.status !== 200) throw new Error("Unable to confirm board goal.");
onConfirmed(result.data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to confirm board goal.");
} finally {

View File

@@ -1,51 +1,42 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { BarChart3, Bot, LayoutGrid, Network } from "lucide-react";
import { ApiError } from "@/api/mutator";
import {
type healthzHealthzGetResponse,
useHealthzHealthzGet,
} from "@/api/generated/default/default";
import { cn } from "@/lib/utils";
import { getApiBaseUrl } from "@/lib/api-base";
export function DashboardSidebar() {
const pathname = usePathname();
const [systemStatus, setSystemStatus] = useState<
"unknown" | "operational" | "degraded"
>("unknown");
const [statusLabel, setStatusLabel] = useState("System status unavailable");
const healthQuery = useHealthzHealthzGet<healthzHealthzGetResponse, ApiError>({
query: {
refetchInterval: 30_000,
refetchOnMount: "always",
retry: false,
},
request: { cache: "no-store" },
});
useEffect(() => {
let isMounted = true;
const apiBase = getApiBaseUrl();
const checkHealth = async () => {
try {
const response = await fetch(`${apiBase}/healthz`, { cache: "no-store" });
if (!response.ok) {
throw new Error("Health check failed");
}
const data = (await response.json()) as { ok?: boolean };
if (!isMounted) return;
if (data?.ok) {
setSystemStatus("operational");
setStatusLabel("All systems operational");
} else {
setSystemStatus("degraded");
setStatusLabel("System degraded");
}
} catch {
if (!isMounted) return;
setSystemStatus("degraded");
setStatusLabel("System degraded");
}
};
checkHealth();
const interval = setInterval(checkHealth, 30000);
return () => {
isMounted = false;
clearInterval(interval);
};
}, []);
const okValue = healthQuery.data?.data?.ok;
const systemStatus: "unknown" | "operational" | "degraded" =
okValue === true
? "operational"
: okValue === false
? "degraded"
: healthQuery.isError
? "degraded"
: "unknown";
const statusLabel =
systemStatus === "operational"
? "All systems operational"
: systemStatus === "unknown"
? "System status unavailable"
: "System degraded";
return (
<aside className="flex h-full w-64 flex-col border-r border-slate-200 bg-white">

View File

@@ -5,10 +5,12 @@ import { useMemo, useState } from "react";
import { TaskCard } from "@/components/molecules/TaskCard";
import { cn } from "@/lib/utils";
type TaskStatus = "inbox" | "in_progress" | "review" | "done";
type Task = {
id: string;
title: string;
status: string;
status: TaskStatus;
priority: string;
description?: string | null;
due_at?: string | null;
@@ -20,10 +22,17 @@ type Task = {
type TaskBoardProps = {
tasks: Task[];
onTaskSelect?: (task: Task) => void;
onTaskMove?: (taskId: string, status: string) => void;
onTaskMove?: (taskId: string, status: TaskStatus) => void | Promise<void>;
};
const columns = [
const columns: Array<{
title: string;
status: TaskStatus;
dot: string;
accent: string;
text: string;
badge: string;
}> = [
{
title: "Inbox",
status: "inbox",
@@ -74,10 +83,15 @@ export function TaskBoard({
onTaskMove,
}: TaskBoardProps) {
const [draggingId, setDraggingId] = useState<string | null>(null);
const [activeColumn, setActiveColumn] = useState<string | null>(null);
const [activeColumn, setActiveColumn] = useState<TaskStatus | null>(null);
const grouped = useMemo(() => {
const buckets: Record<string, Task[]> = {};
const buckets: Record<TaskStatus, Task[]> = {
inbox: [],
in_progress: [],
review: [],
done: [],
};
for (const column of columns) {
buckets[column.status] = [];
}
@@ -104,7 +118,7 @@ export function TaskBoard({
};
const handleDrop =
(status: string) => (event: React.DragEvent<HTMLDivElement>) => {
(status: TaskStatus) => (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
setActiveColumn(null);
const raw = event.dataTransfer.getData("text/plain");
@@ -120,14 +134,14 @@ export function TaskBoard({
};
const handleDragOver =
(status: string) => (event: React.DragEvent<HTMLDivElement>) => {
(status: TaskStatus) => (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
if (activeColumn !== status) {
setActiveColumn(status);
}
};
const handleDragLeave = (status: string) => () => {
const handleDragLeave = (status: TaskStatus) => () => {
if (activeColumn === status) {
setActiveColumn(null);
}