mirror of
https://github.com/reconurge/flowsint.git
synced 2026-04-28 10:22:58 -05:00
feat: investigation view
This commit is contained in:
@@ -5,55 +5,32 @@ import { NextResponse } from "next/server"
|
||||
export async function GET(_: Request, { params }: { params: Promise<{ investigation_id: string }> }) {
|
||||
const { investigation_id } = await params
|
||||
const supabase = await createClient()
|
||||
|
||||
try {
|
||||
// Vérification de l'authentification
|
||||
const {
|
||||
data: { user },
|
||||
error: userError,
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user || userError) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
} const { data: individuals, error: indError } = await supabase
|
||||
.from("individuals")
|
||||
.select("*, ip_addresses(*), phone_numbers(*), social_accounts(*), emails(*), physical_addresses(*), vehicles(*), group_id")
|
||||
.eq("investigation_id", investigation_id)
|
||||
const { data: groups, error: groupsError } = await supabase
|
||||
.from("groups")
|
||||
}
|
||||
// Une seule requête à la vue matérialisée
|
||||
const { data: graphData, error: graphError } = await supabase
|
||||
.from("investigation_graph")
|
||||
.select("*")
|
||||
.eq("investigation_id", investigation_id)
|
||||
if (groupsError) {
|
||||
return NextResponse.json({ error: groupsError.message }, { status: 500 })
|
||||
.single();
|
||||
if (graphError) {
|
||||
return NextResponse.json({ error: graphError.message }, { status: 500 })
|
||||
}
|
||||
if (indError) {
|
||||
return NextResponse.json({ error: indError.message }, { status: 500 })
|
||||
}
|
||||
if (!individuals || individuals.length === 0) {
|
||||
if (!graphData || !graphData.individuals || graphData.individuals.length === 0) {
|
||||
return NextResponse.json({ nodes: [], edges: [] })
|
||||
}
|
||||
// Extraire les IDs des individus
|
||||
const individualIds = individuals.map((ind) => ind.id)
|
||||
// Récupérer les relations
|
||||
const { data: relations, error: relError } = await supabase
|
||||
.from("relationships")
|
||||
.select("id, individual_a, individual_b, relation_type, confidence_level")
|
||||
.in("individual_a", individualIds)
|
||||
.in("individual_b", individualIds)
|
||||
if (relError) {
|
||||
return NextResponse.json({ error: relError.message }, { status: 500 })
|
||||
}
|
||||
const nodes: NodeData[] = []
|
||||
const edges: EdgeData[] = []
|
||||
groups?.forEach(({ label, id }) => {
|
||||
nodes.push({
|
||||
id: id.toString(),
|
||||
position: { x: 200, y: 200 },
|
||||
data: { label: label.toString() },
|
||||
type: "group",
|
||||
width: 380,
|
||||
height: 200,
|
||||
})
|
||||
})
|
||||
// Construire les nœuds et les arêtes
|
||||
individuals.forEach((ind: any) => {
|
||||
graphData.individuals.forEach((ind: any) => {
|
||||
const individualId = ind.id.toString()
|
||||
nodes.push({
|
||||
id: individualId,
|
||||
@@ -63,105 +40,93 @@ export async function GET(_: Request, { params }: { params: Promise<{ investigat
|
||||
parentId: ind.group_id?.toString(),
|
||||
extent: "parent",
|
||||
})
|
||||
// Ajouter les emails
|
||||
ind.emails?.forEach((email: any) => {
|
||||
nodes.push({
|
||||
id: email.id.toString(),
|
||||
type: "email",
|
||||
data: { ...email, label: email.email },
|
||||
const relatedDataConfig = [
|
||||
{
|
||||
dataKey: 'emails',
|
||||
type: 'email',
|
||||
labelField: 'email',
|
||||
position: { x: 100, y: 100 },
|
||||
})
|
||||
edges.push({
|
||||
source: individualId,
|
||||
target: email.id.toString(),
|
||||
type: "custom",
|
||||
id: `${individualId}-${email.id}`.toString(),
|
||||
label: "email",
|
||||
})
|
||||
})
|
||||
// Ajouter les numéros de téléphone
|
||||
ind.phone_numbers?.forEach((phone: any) => {
|
||||
nodes.push({
|
||||
id: phone.id.toString(),
|
||||
type: "phone",
|
||||
data: { ...phone, label: phone.phone_number },
|
||||
edgeLabel: 'email'
|
||||
},
|
||||
{
|
||||
dataKey: 'phone_numbers',
|
||||
type: 'phone',
|
||||
labelField: 'phone_number',
|
||||
position: { x: -100, y: 100 },
|
||||
})
|
||||
edges.push({
|
||||
source: individualId,
|
||||
target: phone.id.toString(),
|
||||
type: "custom",
|
||||
id: `${individualId}-${phone.id}`.toString(),
|
||||
label: "phone",
|
||||
})
|
||||
})
|
||||
// Ajouter les comptes sociaux
|
||||
ind.social_accounts?.forEach((social: any) => {
|
||||
nodes.push({
|
||||
id: social.id.toString(),
|
||||
type: "social",
|
||||
data: { ...social, label: `${social.platform}: ${social.username}` },
|
||||
edgeLabel: 'phone'
|
||||
},
|
||||
{
|
||||
dataKey: 'social_accounts',
|
||||
type: 'social',
|
||||
labelField: (item: any) => `${item.platform}: ${item.username}`,
|
||||
position: { x: 100, y: -100 },
|
||||
})
|
||||
edges.push({
|
||||
source: individualId,
|
||||
target: social.id.toString(),
|
||||
type: "custom",
|
||||
id: `${individualId}-${social.id}`.toString(),
|
||||
label: "social",
|
||||
})
|
||||
})
|
||||
// Ajouter les adresses IP
|
||||
ind.ip_addresses?.forEach((ip: any) => {
|
||||
nodes.push({
|
||||
id: ip.id.toString(),
|
||||
type: "ip",
|
||||
data: { label: ip.ip_address },
|
||||
edgeLabel: 'social'
|
||||
},
|
||||
{
|
||||
dataKey: 'ip_addresses',
|
||||
type: 'ip',
|
||||
labelField: 'ip_address',
|
||||
position: { x: -100, y: -100 },
|
||||
})
|
||||
edges.push({
|
||||
source: individualId,
|
||||
target: ip.id.toString(),
|
||||
type: "custom",
|
||||
id: `${individualId}-${ip.id}`.toString(),
|
||||
label: "IP",
|
||||
})
|
||||
})
|
||||
// Ajouter les adresses IP
|
||||
ind.vehicles?.forEach((vehicle: any) => {
|
||||
nodes.push({
|
||||
id: vehicle.id.toString(),
|
||||
type: "vehicle",
|
||||
data: { label: `${vehicle.plate}-${vehicle.model || "Unknown"}`, ...vehicle },
|
||||
edgeLabel: 'IP'
|
||||
},
|
||||
{
|
||||
dataKey: 'vehicles',
|
||||
type: 'vehicle',
|
||||
labelField: (item: any) => `${item.plate}-${item.model || "Unknown"}`,
|
||||
position: { x: -100, y: -100 },
|
||||
})
|
||||
edges.push({
|
||||
source: individualId,
|
||||
target: vehicle.id.toString(),
|
||||
type: "custom",
|
||||
id: `${individualId}-${vehicle.id}`.toString(),
|
||||
label: vehicle.type,
|
||||
})
|
||||
})
|
||||
// Ajouter les adresses physiques
|
||||
ind.physical_addresses?.forEach((address: any) => {
|
||||
nodes.push({
|
||||
id: address.id.toString(),
|
||||
type: "address",
|
||||
data: { ...address, label: [address.address, address.city, address.country].join(", ") },
|
||||
edgeLabel: (item: any) => item.type
|
||||
},
|
||||
{
|
||||
dataKey: 'physical_addresses',
|
||||
type: 'address',
|
||||
labelField: (item: any) => [item.address, item.city, item.country].join(", "),
|
||||
position: { x: 100, y: 100 },
|
||||
})
|
||||
edges.push({
|
||||
source: individualId,
|
||||
target: address.id.toString(),
|
||||
type: "custom",
|
||||
id: `${individualId}-${address.id}`.toString(),
|
||||
label: "address",
|
||||
})
|
||||
})
|
||||
})
|
||||
// Ajouter les relations entre individus
|
||||
relations?.forEach(({ id, individual_a, individual_b, relation_type, confidence_level }) => {
|
||||
edgeLabel: 'address'
|
||||
}
|
||||
];
|
||||
|
||||
// Traiter chaque type de données associées
|
||||
relatedDataConfig.forEach(config => {
|
||||
const items = ind[config.dataKey];
|
||||
if (!items || !items.length) return;
|
||||
|
||||
items.forEach((item: any) => {
|
||||
// Déterminer le label
|
||||
let label;
|
||||
if (typeof config.labelField === 'function') {
|
||||
label = config.labelField(item);
|
||||
} else {
|
||||
label = item[config.labelField];
|
||||
}
|
||||
|
||||
// Ajouter le nœud
|
||||
nodes.push({
|
||||
id: item.id.toString(),
|
||||
type: config.type,
|
||||
data: { ...item, label },
|
||||
position: config.position,
|
||||
});
|
||||
|
||||
// Déterminer l'étiquette de l'arête
|
||||
let edgeLabel;
|
||||
if (typeof config.edgeLabel === 'function') {
|
||||
edgeLabel = config.edgeLabel(item);
|
||||
} else {
|
||||
edgeLabel = config.edgeLabel;
|
||||
}
|
||||
|
||||
// Ajouter l'arête
|
||||
edges.push({
|
||||
source: individualId,
|
||||
target: item.id.toString(),
|
||||
type: "custom",
|
||||
id: `${individualId}-${item.id}`.toString(),
|
||||
label: edgeLabel,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
graphData.relationships?.forEach(({ id, individual_a, individual_b, relation_type, confidence_level }: any) => {
|
||||
edges.push({
|
||||
source: individual_a.toString(),
|
||||
target: individual_b.toString(),
|
||||
@@ -171,9 +136,9 @@ export async function GET(_: Request, { params }: { params: Promise<{ investigat
|
||||
confidence_level,
|
||||
})
|
||||
})
|
||||
|
||||
return NextResponse.json({ nodes, edges })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -49,7 +49,6 @@ const DashboardPage = () => {
|
||||
<Button>New investigation</Button>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<RecentSketches />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 justify-between mb-6">
|
||||
@@ -95,7 +94,7 @@ const DashboardPage = () => {
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableHeader className="bg-accent">
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Owner</TableHead>
|
||||
|
||||
@@ -75,7 +75,7 @@ const DashboardPage = () => {
|
||||
<div className="flex items-center gap-2">
|
||||
<NewCase>
|
||||
<Button size="sm" className="gap-2">
|
||||
<PlusIcon className="h-4 w-4" /> Add
|
||||
<PlusIcon className="h-4 w-4" /> New
|
||||
</Button>
|
||||
</NewCase>
|
||||
<DropdownMenu>
|
||||
@@ -105,7 +105,7 @@ const DashboardPage = () => {
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableHeader className="bg-accent">
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
|
||||
@@ -94,12 +94,12 @@ const RecentSketches = () => {
|
||||
{investigations?.map((investigation: Investigation) => {
|
||||
return (
|
||||
<Link href={`/dashboard/projects/${investigation.project_id}/investigations/${investigation.id}`} key={investigation.id} className="group">
|
||||
<Card className="bg-background shadow-none h-full transition-all duration-200 hover:border-primary rounded-md">
|
||||
<Card className="bg-accent shadow-none h-full transition-all duration-200 hover:border-primary rounded-md">
|
||||
<CardContent className="p-4 relative">
|
||||
<h3 className="font-medium line-clamp-1 group-hover:text-primary transition-colors">
|
||||
{investigation?.project?.name}/{investigation.title}
|
||||
</h3>
|
||||
<span className="text-xs opacity-60 group-hover:text-primary">Last updated {formatDistanceToNow(investigation.last_updated_at, { addSuffix: true })}</span>
|
||||
<span className="text-xs opacity-60">Last updated {formatDistanceToNow(investigation.last_updated_at, { addSuffix: true })}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Eye, Users, Camera, Settings } from "lucide-react"
|
||||
import { Eye, Users, Camera, Settings, Waypoints } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
@@ -20,7 +20,7 @@ export function ProjectNavigation({ project_id }: { project_id: string }) {
|
||||
id: "sketches",
|
||||
name: "Sketches",
|
||||
href: `/dashboard/projects/${project_id}?filter=sketch`,
|
||||
icon: Users,
|
||||
icon: Waypoints,
|
||||
count: 24,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -302,7 +302,7 @@ const LayoutFlow = ({ refetch, theme }: LayoutFlowProps) => {
|
||||
onNodeClick,
|
||||
onPaneClick: handlePaneClick,
|
||||
onNodeContextMenu: handleNodeContextMenu,
|
||||
minZoom: 0.4,
|
||||
minZoom: 0.7,
|
||||
fitView: true,
|
||||
proOptions: { hideAttribution: true },
|
||||
edgeTypes,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { TypeBadge } from '@/components/type-badge'
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { AtSignIcon, CarIcon, Mail, Search, User, UserIcon, Users } from "lucide-react"
|
||||
import { AtSignIcon, CarIcon, LocateIcon, Mail, PhoneIcon, Search, User, UserIcon, Users } from "lucide-react"
|
||||
import { usePlatformIcons } from '@/lib/hooks/use-platform-icons'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
@@ -66,6 +66,24 @@ export function NodeRenderer({ node, setCurrentNode }: NodeProps) {
|
||||
<TypeBadge type={node?.type}></TypeBadge>
|
||||
</Button>
|
||||
)}
|
||||
{node.type === "phone" && (
|
||||
<Button variant={"ghost"} className='flex items-center justify-start p-4 !py-5 rounded-none text-left border-b' onClick={() => setCurrentNode(node)}>
|
||||
<Badge variant="secondary" className="h-7 w-7 p-0 rounded-full">
|
||||
<PhoneIcon className="h-4 w-4 opacity-60" />
|
||||
</Badge>
|
||||
<div className='grow truncate text-ellipsis'>{node?.data?.label}</div>
|
||||
<TypeBadge type={node?.type}></TypeBadge>
|
||||
</Button>
|
||||
)}
|
||||
{node.type === "ip" && (
|
||||
<Button variant={"ghost"} className='flex items-center justify-start p-4 !py-5 rounded-none text-left border-b' onClick={() => setCurrentNode(node)}>
|
||||
<Badge variant="secondary" className="h-7 w-7 p-0 rounded-full">
|
||||
<LocateIcon className="h-4 w-4 opacity-60" />
|
||||
</Badge>
|
||||
<div className='grow truncate text-ellipsis'>{node?.data?.label}</div>
|
||||
<TypeBadge type={node?.type}></TypeBadge>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -91,7 +109,7 @@ const NodesPanel = ({ nodes }: { nodes: Node[] }) => {
|
||||
<div className="sticky top-0 p-2 bg-background z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium">Nodes</h3>
|
||||
<Badge>{nodes?.length || 0}</Badge>
|
||||
<Badge variant={"outline"}>{nodes?.length || 0}</Badge>
|
||||
<div className="relative grow">
|
||||
<Search className="absolute left-2.5 top-1.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
|
||||
@@ -8,7 +8,8 @@ import { cn, typeColorMap } from '@/lib/utils';
|
||||
|
||||
export default memo(({ data, selected }: any) => {
|
||||
return (
|
||||
<BaseNode className={cn('p-.5 rounded-full', typeColorMap["ip_address"])} selected={selected}>
|
||||
<BaseNode className={cn('p-.5 rounded-full', "bg-gradient-to-br from-pink-50 to-pink-100 border-pink-200 shadow-md dark:from-pink-800 dark:to-pink-900 dark:border-pink-700 dark:shadow-pink-900/30",
|
||||
)} selected={selected}>
|
||||
<div className="flex items-center gap-2 p-1">
|
||||
<Badge variant="secondary" className="h-6 w-6 p-0 rounded-full">
|
||||
<LocateIcon className="h-4 w-4" />
|
||||
|
||||
@@ -151,7 +151,7 @@ const NodeContextMenu = memo(({ x, y, onClose }: NodeContextMenuProps) => {
|
||||
.select("*")
|
||||
.single()
|
||||
if (insertError) {
|
||||
toast.error("An error occured during the creation.")
|
||||
toast.error("An error occured during the creation." + insertError.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { cn, typeColorMap } from '@/lib/utils';
|
||||
|
||||
export default memo(({ data, selected }: any) => {
|
||||
return (
|
||||
<BaseNode className={cn('p-.5 rounded-full', typeColorMap["phone_number"])} selected={selected}>
|
||||
<BaseNode className={cn('p-.5 rounded-full', "rounded-full bg-gradient-to-br from-sky-50 to-sky-100 border-sky-200 shadow-md dark:from-sky-800 dark:to-sky-900 dark:border-sky-700 dark:shadow-sky-900/30")} selected={selected}>
|
||||
<div className="flex items-center gap-2 p-1">
|
||||
<Badge variant="secondary" className="h-6 w-6 p-0 rounded-full">
|
||||
<PhoneIcon className="h-4 w-4" />
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useParams } from "next/navigation"
|
||||
import SearchEmail from "./search-email"
|
||||
import { memo } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CopyButton } from "@/components/copy"
|
||||
|
||||
export default function ProfilePanel({ data, }: { data: any }) {
|
||||
const { project_id, investigation_id } = useParams()
|
||||
@@ -223,13 +224,18 @@ interface KeyValueDisplayProps {
|
||||
|
||||
function KeyValueDisplay({ data, className }: KeyValueDisplayProps) {
|
||||
return (
|
||||
<div className={cn("w-full overflow-y-auto h-full border-collapse ", className)}>
|
||||
{data && Object.entries(data).map(([key, value], index) => (
|
||||
<div key={index} className="flex w-full border-b border-border divide-x divide-border">
|
||||
<div className="w-1/2 bg-background px-4 p-2 text-sm font-medium text-muted-foreground">{key}</div>
|
||||
<div className="w-1/2 bg-background px-4 p-2 text-sm font-medium">{value?.toString() || ""}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className={cn("w-full overflow-y-auto overflow-x-hidden h-full border-collapse", className)}>
|
||||
{data && Object.entries(data)
|
||||
.filter(([key]) => !["id", "individual_id", "investigation_id", "group_id", "forceToolbarVisible"].includes(key))
|
||||
.map(([key, value], index) => {
|
||||
const val = Array.isArray(value) ? value.join(", ") : value?.toString() || null
|
||||
return (
|
||||
<div key={index} className="flex w-full items-center border-b border-border divide-x divide-border">
|
||||
<div className="w-1/2 bg-background px-4 p-2 text-sm text-muted-foreground font-normal">{key}</div>
|
||||
<div className="w-1/2 bg-background px-4 p-2 text-sm font-medium flex items-center justify-between"><div className="truncate font-semibold">{val || <span className="italic text-muted-foreground">N/A</span>}</div> <div>{val && <CopyButton className="h-6 w-6" content={val} />}</div></div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ export const BaseNode = forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative rounded-md border bg-background p-2 flex items-center justify-between text-card-foreground",
|
||||
"relative rounded-md ring ring-transparent bg-background p-2 flex items-center justify-between text-card-foreground truncate",
|
||||
className,
|
||||
selected ? "border-primary shadow-lg" : "",
|
||||
"hover:border-primary",
|
||||
selected ? "ring-primary shadow-lg" : "",
|
||||
"hover:ring-primary",
|
||||
)}
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
|
||||
@@ -476,10 +476,10 @@ export const nodesTypes = {
|
||||
|
||||
export const typeColorMap: Record<string, string> = {
|
||||
individual: "rounded-full bg-gradient-to-br from-slate-50 to-slate-100 border-slate-200 shadow-md dark:from-slate-800 dark:to-slate-900 dark:border-slate-700 dark:shadow-slate-900/30",
|
||||
phone_number: "bg-sky-100 text-sky-800 hover:bg-sky-100/80 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/40",
|
||||
phone: "bg-gradient-to-br from-sky-50 to-sky-100 border-sky-200 shadow-md dark:from-sky-800 dark:to-sky-900 dark:border-sky-700 dark:shadow-sky-900/30",
|
||||
address: "bg-amber-100 text-amber-800 hover:bg-amber-100/80 dark:bg-amber-900/30 dark:text-amber-300 dark:hover:bg-amber-900/40",
|
||||
email: "bg-gradient-to-br from-emerald-50 to-emerald-100 border-emerald-200 shadow-sm dark:from-emerald-950 dark:to-emerald-900 dark:border-emerald-800 dark:shadow-emerald-900/30",
|
||||
ip_address: "bg-slate-100 text-slate-800 hover:bg-slate-100/80 dark:bg-slate-800/50 dark:text-slate-300 dark:hover:bg-slate-800/60",
|
||||
ip: "bg-gradient-to-br from-pink-50 to-pink-100 border-pink-200 shadow-md dark:from-pink-800 dark:to-pink-900 dark:border-pink-700 dark:shadow-pink-900/30",
|
||||
social: "bg-gradient-to-br from-purple-50 to-purple-100 border-purple-200 shadow-sm dark:from-purple-950 dark:to-purple-900 dark:border-purple-800 dark:shadow-purple-900/30",
|
||||
organization: "bg-gradient-to-br from-orange-50 to-orange-100 border-orange-200 shadow-sm dark:from-orange-950 dark:to-orange-900 dark:border-orange-800 dark:shadow-orange-900/30",
|
||||
vehicle: "bg-orange-100 text-orange-800 hover:bg-orange-100/80 dark:bg-orange-900/30 dark:text-orange-300 dark:hover:bg-orange-900/40",
|
||||
|
||||
Reference in New Issue
Block a user