Add risk list and create risk

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-03-30 16:51:24 +02:00
parent 6210458fd9
commit 5e601a0ef0
21 changed files with 4384 additions and 8 deletions

View File

@@ -22,6 +22,8 @@ import { UpdatePolicyPage } from "./policies/UpdatePolicyPage";
import { VendorListPage } from "./vendors/VendorListPage";
import { VendorPage } from "./vendors/VendorPage";
import { MitigationNewPage } from "./mitigations/MitigationNewPage";
import { RiskListPage } from "./risks/RiskListPage";
import { NewRiskPage } from "./risks/NewRiskPage";
export function OrganizationsRoutes() {
return (
@@ -53,6 +55,8 @@ export function OrganizationsRoutes() {
path="policies/:policyId/update"
element={<UpdatePolicyPage />}
/>
<Route path="risks" element={<RiskListPage />} />
<Route path="risks/new" element={<NewRiskPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>

View File

@@ -0,0 +1,29 @@
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { Suspense } from "react";
import { lazy } from "@probo/react-lazy";
import { useLocation } from "react-router";
import { ErrorBoundaryWithLocation } from "../ErrorBoundary";
const NewRiskView = lazy(() => import("./NewRiskView"));
export function NewRiskViewSkeleton() {
return (
<PageTemplateSkeleton>
<div className="space-y-2">
<div className="h-96 bg-muted animate-pulse rounded-lg" />
</div>
</PageTemplateSkeleton>
);
}
export function NewRiskPage() {
const location = useLocation();
return (
<Suspense key={location.pathname} fallback={<NewRiskViewSkeleton />}>
<ErrorBoundaryWithLocation>
<NewRiskView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -0,0 +1,163 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router";
import { ConnectionHandler, graphql, useMutation } from "react-relay";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { PageTemplate } from "@/components/PageTemplate";
const createRiskMutation = graphql`
mutation NewRiskViewCreateRiskMutation(
$input: CreateRiskInput!
$connections: [ID!]!
) {
createRisk(input: $input) {
riskEdge @prependEdge(connections: $connections) {
node {
id
name
description
createdAt
updatedAt
}
}
}
}
`;
export default function NewRiskView() {
const navigate = useNavigate();
const { organizationId } = useParams<{ organizationId: string }>();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
const [commitMutation, isInFlight] = useMutation(createRiskMutation);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
toast({
title: "Error",
description: "Please enter a name for the risk.",
variant: "destructive",
});
return;
}
setIsSubmitting(true);
const input = {
organizationId: organizationId!,
name,
description,
};
commitMutation({
variables: {
input,
connections: [
ConnectionHandler.getConnectionID(
organizationId!,
"RiskListView_risks"
),
],
},
onCompleted: (response, errors) => {
setIsSubmitting(false);
if (errors) {
console.error("Error creating risk:", errors);
toast({
title: "Error",
description: "Failed to create risk. Please try again.",
variant: "destructive",
});
return;
}
toast({
title: "Success",
description: "Risk created successfully!",
});
navigate(`/organizations/${organizationId}/risks`);
},
onError: (error) => {
setIsSubmitting(false);
console.error("Error creating risk:", error);
toast({
title: "Error",
description: "Failed to create risk. Please try again.",
variant: "destructive",
});
},
});
};
return (
<PageTemplate
title="Create Risk"
description="Add a new risk to your organization"
>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle>Risk Details</CardTitle>
<CardDescription>
Enter the details of the risk you want to add to your organization.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
placeholder="Risk name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Describe the risk in detail"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={5}
/>
</div>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() =>
navigate(`/organizations/${organizationId}/risks`)
}
>
Cancel
</Button>
<Button type="submit" disabled={isInFlight || isSubmitting}>
{isInFlight || isSubmitting ? "Creating..." : "Create Risk"}
</Button>
</div>
</form>
</CardContent>
</Card>
</PageTemplate>
);
}

View File

@@ -0,0 +1,31 @@
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { Suspense } from "react";
import { lazy } from "@probo/react-lazy";
import { useLocation } from "react-router";
import { ErrorBoundaryWithLocation } from "../ErrorBoundary";
const RiskListView = lazy(() => import("./RiskListView"));
export function RiskViewSkeleton() {
return (
<PageTemplateSkeleton>
<div className="space-y-2">
{[1, 2].map((i) => (
<div key={i} className="h-20 bg-muted animate-pulse rounded-lg" />
))}
</div>
</PageTemplateSkeleton>
);
}
export function RiskListPage() {
const location = useLocation();
return (
<Suspense key={location.pathname} fallback={<RiskViewSkeleton />}>
<ErrorBoundaryWithLocation>
<RiskListView />
</ErrorBoundaryWithLocation>
</Suspense>
);
}

View File

@@ -0,0 +1,404 @@
"use client";
import {
graphql,
PreloadedQuery,
usePaginationFragment,
usePreloadedQuery,
useQueryLoader,
useMutation,
} from "react-relay";
import {
Suspense,
useCallback,
useEffect,
useState,
useTransition,
} from "react";
import type { RiskListViewQuery } from "./__generated__/RiskListViewQuery.graphql";
import { useParams, useSearchParams } from "react-router";
import { PageTemplate } from "@/components/PageTemplate";
import { RiskViewSkeleton } from "./RiskListPage";
import { RiskListViewPaginationQuery } from "./__generated__/RiskListViewPaginationQuery.graphql";
import { RiskListView_risks$key } from "./__generated__/RiskListView_risks.graphql";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Link } from "react-router";
import { Plus, Trash2 } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { RiskListViewDeleteMutation } from "./__generated__/RiskListViewDeleteMutation.graphql";
const defaultPageSize = 25;
const riskListViewQuery = graphql`
query RiskListViewQuery(
$organizationId: ID!
$first: Int
$after: CursorKey
$last: Int
$before: CursorKey
) {
organization: node(id: $organizationId) {
id
...RiskListView_risks
@arguments(first: $first, after: $after, last: $last, before: $before)
}
}
`;
const riskListFragment = graphql`
fragment RiskListView_risks on Organization
@refetchable(queryName: "RiskListViewPaginationQuery")
@argumentDefinitions(
first: { type: "Int" }
after: { type: "CursorKey" }
last: { type: "Int" }
before: { type: "CursorKey" }
) {
risks(first: $first, after: $after, last: $last, before: $before)
@connection(key: "RiskListView_risks") {
__id
edges {
node {
id
name
description
createdAt
updatedAt
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
`;
const deleteRiskMutation = graphql`
mutation RiskListViewDeleteMutation(
$input: DeleteRiskInput!
$connections: [ID!]!
) {
deleteRisk(input: $input) {
deletedRiskId @deleteEdge(connections: $connections)
}
}
`;
function LoadAboveButton({
isLoading,
hasMore,
onLoadMore,
}: {
isLoading: boolean;
hasMore: boolean;
onLoadMore: () => void;
}) {
if (!hasMore) {
return null;
}
return (
<div className="flex justify-center mb-6">
<Button
variant="outline"
onClick={onLoadMore}
disabled={isLoading}
className="w-full"
>
{isLoading ? "Loading..." : "Load above"}
</Button>
</div>
);
}
function LoadBelowButton({
isLoading,
hasMore,
onLoadMore,
}: {
isLoading: boolean;
hasMore: boolean;
onLoadMore: () => void;
}) {
if (!hasMore) {
return null;
}
return (
<div className="flex justify-center mt-6">
<Button
variant="outline"
onClick={onLoadMore}
disabled={isLoading || !hasMore}
className="w-full"
>
{isLoading ? "Loading..." : "Load below"}
</Button>
</div>
);
}
function RiskListViewContent({
queryRef,
}: {
queryRef: PreloadedQuery<RiskListViewQuery>;
}) {
const data = usePreloadedQuery(riskListViewQuery, queryRef);
const [, setSearchParams] = useSearchParams();
const [, startTransition] = useTransition();
const { organizationId } = useParams<{ organizationId: string }>();
const { toast } = useToast();
// State for delete confirmation dialog
const [riskToDelete, setRiskToDelete] = useState<{
id: string;
name: string;
} | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// Setup delete mutation
const [commitDeleteMutation] =
useMutation<RiskListViewDeleteMutation>(deleteRiskMutation);
const {
data: risksConnection,
loadNext,
loadPrevious,
hasNext,
hasPrevious,
isLoadingNext,
isLoadingPrevious,
} = usePaginationFragment<
RiskListViewPaginationQuery,
RiskListView_risks$key
>(riskListFragment, data.organization);
const risks = risksConnection?.risks?.edges?.map((edge) => edge.node) || [];
const pageInfo = risksConnection?.risks?.pageInfo;
const connectionId = risksConnection?.risks?.__id;
// Handle delete risk
const handleDeleteRisk = useCallback(() => {
if (!riskToDelete || !connectionId) return;
setIsDeleting(true);
commitDeleteMutation({
variables: {
input: {
riskId: riskToDelete.id,
},
connections: [connectionId],
},
onCompleted: (_, errors) => {
setIsDeleting(false);
setRiskToDelete(null);
if (errors) {
console.error("Error deleting risk:", errors);
toast({
title: "Error",
description: "Failed to delete risk. Please try again.",
variant: "destructive",
});
return;
}
toast({
title: "Success",
description: "Risk deleted successfully.",
});
},
onError: (error) => {
setIsDeleting(false);
setRiskToDelete(null);
console.error("Error deleting risk:", error);
toast({
title: "Error",
description: "Failed to delete risk. Please try again.",
variant: "destructive",
});
},
});
}, [riskToDelete, connectionId, commitDeleteMutation, toast]);
return (
<PageTemplate
title="Risks"
actions={
<Button asChild>
<Link to={`/organizations/${organizationId}/risks/new`}>
<Plus className="mr-2 h-4 w-4" />
New Risk
</Link>
</Button>
}
>
<div className="space-y-6">
<LoadAboveButton
isLoading={isLoadingPrevious}
hasMore={hasPrevious}
onLoadMore={() => {
startTransition(() => {
setSearchParams((prev) => {
prev.set("before", pageInfo?.startCursor || "");
prev.delete("after");
return prev;
});
loadPrevious(defaultPageSize);
});
}}
/>
<Card>
<CardContent className="p-0">
<div className="w-full overflow-auto">
<table className="w-full caption-bottom text-sm">
<thead className="[&_tr]:border-b">
<tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
Name
</th>
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
Description
</th>
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
Actions
</th>
</tr>
</thead>
<tbody className="[&_tr:last-child]:border-0">
{risks.length === 0 ? (
<tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td
colSpan={3}
className="text-center p-4 align-middle text-muted-foreground"
>
No risks found. Create a new risk to get started.
</td>
</tr>
) : (
risks.map((risk) => (
<tr
key={risk.id}
className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted cursor-pointer"
>
<td className="p-4 align-middle font-medium">
{risk.name}
</td>
<td className="p-4 align-middle">{risk.description}</td>
<td className="p-4 align-middle">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
setRiskToDelete({ id: risk.id, name: risk.name });
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
<LoadBelowButton
isLoading={isLoadingNext}
hasMore={hasNext}
onLoadMore={() => {
startTransition(() => {
setSearchParams((prev) => {
prev.set("after", pageInfo?.endCursor || "");
prev.delete("before");
return prev;
});
loadNext(defaultPageSize);
});
}}
/>
{/* Delete Confirmation Dialog */}
<Dialog
open={!!riskToDelete}
onOpenChange={(open) => !open && setRiskToDelete(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
This will permanently delete the risk &quot;{riskToDelete?.name}
&quot;. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setRiskToDelete(null)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
onClick={handleDeleteRisk}
disabled={isDeleting}
variant="destructive"
>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</PageTemplate>
);
}
export default function RiskListView() {
const [searchParams] = useSearchParams();
const [queryRef, loadQuery] =
useQueryLoader<RiskListViewQuery>(riskListViewQuery);
const { organizationId } = useParams();
useEffect(() => {
const after = searchParams.get("after");
const before = searchParams.get("before");
loadQuery({
organizationId: organizationId!,
first: before ? undefined : defaultPageSize,
after: after || undefined,
last: before ? defaultPageSize : undefined,
before: before || undefined,
});
}, [loadQuery, organizationId]);
if (!queryRef) {
return <RiskViewSkeleton />;
}
return (
<Suspense fallback={<RiskViewSkeleton />}>
<RiskListViewContent queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,192 @@
/**
* @generated SignedSource<<c3a526641ca53f4ef760eacadc5c3685>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type CreateRiskInput = {
description: string;
name: string;
organizationId: string;
};
export type NewRiskViewCreateRiskMutation$variables = {
connections: ReadonlyArray<string>;
input: CreateRiskInput;
};
export type NewRiskViewCreateRiskMutation$data = {
readonly createRisk: {
readonly riskEdge: {
readonly node: {
readonly createdAt: string;
readonly description: string;
readonly id: string;
readonly name: string;
readonly updatedAt: string;
};
};
};
};
export type NewRiskViewCreateRiskMutation = {
response: NewRiskViewCreateRiskMutation$data;
variables: NewRiskViewCreateRiskMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"concreteType": "RiskEdge",
"kind": "LinkedField",
"name": "riskEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Risk",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "NewRiskViewCreateRiskMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateRiskPayload",
"kind": "LinkedField",
"name": "createRisk",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "NewRiskViewCreateRiskMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateRiskPayload",
"kind": "LinkedField",
"name": "createRisk",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "prependEdge",
"key": "",
"kind": "LinkedHandle",
"name": "riskEdge",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "ce3ac503734a6f0eb26f88813ed02858",
"id": null,
"metadata": {},
"name": "NewRiskViewCreateRiskMutation",
"operationKind": "mutation",
"text": "mutation NewRiskViewCreateRiskMutation(\n $input: CreateRiskInput!\n) {\n createRisk(input: $input) {\n riskEdge {\n node {\n id\n name\n description\n createdAt\n updatedAt\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "c0d8d1f046ed41c7a429c54b32358d3f";
export default node;

View File

@@ -0,0 +1,132 @@
/**
* @generated SignedSource<<349d62a3a4ed3ea1d83c12f0c61437dd>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type DeleteRiskInput = {
riskId: string;
};
export type RiskListViewDeleteMutation$variables = {
connections: ReadonlyArray<string>;
input: DeleteRiskInput;
};
export type RiskListViewDeleteMutation$data = {
readonly deleteRisk: {
readonly deletedRiskId: string;
};
};
export type RiskListViewDeleteMutation = {
response: RiskListViewDeleteMutation$data;
variables: RiskListViewDeleteMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "deletedRiskId",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "RiskListViewDeleteMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "DeleteRiskPayload",
"kind": "LinkedField",
"name": "deleteRisk",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "RiskListViewDeleteMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "DeleteRiskPayload",
"kind": "LinkedField",
"name": "deleteRisk",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "deleteEdge",
"key": "",
"kind": "ScalarHandle",
"name": "deletedRiskId",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "525011737d0920883b3942d2603dc31f",
"id": null,
"metadata": {},
"name": "RiskListViewDeleteMutation",
"operationKind": "mutation",
"text": "mutation RiskListViewDeleteMutation(\n $input: DeleteRiskInput!\n) {\n deleteRisk(input: $input) {\n deletedRiskId\n }\n}\n"
}
};
})();
(node as any).hash = "4aee92e5abfd6733b44588bf01276560";
export default node;

View File

@@ -0,0 +1,309 @@
/**
* @generated SignedSource<<56b70bbecd785111cec0e7ff6ec2c226>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type RiskListViewPaginationQuery$variables = {
after?: string | null | undefined;
before?: string | null | undefined;
first?: number | null | undefined;
id: string;
last?: number | null | undefined;
};
export type RiskListViewPaginationQuery$data = {
readonly node: {
readonly " $fragmentSpreads": FragmentRefs<"RiskListView_risks">;
};
};
export type RiskListViewPaginationQuery = {
response: RiskListViewPaginationQuery$data;
variables: RiskListViewPaginationQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "before"
},
v2 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "first"
},
v3 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "id"
},
v4 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "last"
},
v5 = [
{
"kind": "Variable",
"name": "id",
"variableName": "id"
}
],
v6 = [
{
"kind": "Variable",
"name": "after",
"variableName": "after"
},
{
"kind": "Variable",
"name": "before",
"variableName": "before"
},
{
"kind": "Variable",
"name": "first",
"variableName": "first"
},
{
"kind": "Variable",
"name": "last",
"variableName": "last"
}
],
v7 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v8 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "RiskListViewPaginationQuery",
"selections": [
{
"alias": null,
"args": (v5/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"args": (v6/*: any*/),
"kind": "FragmentSpread",
"name": "RiskListView_risks"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
(v4/*: any*/),
(v3/*: any*/)
],
"kind": "Operation",
"name": "RiskListViewPaginationQuery",
"selections": [
{
"alias": null,
"args": (v5/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v7/*: any*/),
(v8/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v6/*: any*/),
"concreteType": "RiskConnection",
"kind": "LinkedField",
"name": "risks",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "RiskEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Risk",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v8/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
},
(v7/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": null
},
{
"alias": null,
"args": (v6/*: any*/),
"filters": null,
"handle": "connection",
"key": "RiskListView_risks",
"kind": "LinkedHandle",
"name": "risks"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "ea3c50c98dea1cce588bbb0e8705cbc4",
"id": null,
"metadata": {},
"name": "RiskListViewPaginationQuery",
"operationKind": "query",
"text": "query RiskListViewPaginationQuery(\n $after: CursorKey\n $before: CursorKey\n $first: Int\n $last: Int\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...RiskListView_risks_pbnwq\n id\n }\n}\n\nfragment RiskListView_risks_pbnwq on Organization {\n risks(first: $first, after: $after, last: $last, before: $before) {\n edges {\n node {\n id\n name\n description\n createdAt\n updatedAt\n __typename\n }\n cursor\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n id\n}\n"
}
};
})();
(node as any).hash = "4a665835c97d7b93725e8bbe708d7cfb";
export default node;

View File

@@ -0,0 +1,311 @@
/**
* @generated SignedSource<<2812fb6e9c5db33d4c616daf9a11b012>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type RiskListViewQuery$variables = {
after?: string | null | undefined;
before?: string | null | undefined;
first?: number | null | undefined;
last?: number | null | undefined;
organizationId: string;
};
export type RiskListViewQuery$data = {
readonly organization: {
readonly id: string;
readonly " $fragmentSpreads": FragmentRefs<"RiskListView_risks">;
};
};
export type RiskListViewQuery = {
response: RiskListViewQuery$data;
variables: RiskListViewQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "before"
},
v2 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "first"
},
v3 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "last"
},
v4 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "organizationId"
},
v5 = [
{
"kind": "Variable",
"name": "id",
"variableName": "organizationId"
}
],
v6 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v7 = [
{
"kind": "Variable",
"name": "after",
"variableName": "after"
},
{
"kind": "Variable",
"name": "before",
"variableName": "before"
},
{
"kind": "Variable",
"name": "first",
"variableName": "first"
},
{
"kind": "Variable",
"name": "last",
"variableName": "last"
}
],
v8 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "RiskListViewQuery",
"selections": [
{
"alias": "organization",
"args": (v5/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v6/*: any*/),
{
"args": (v7/*: any*/),
"kind": "FragmentSpread",
"name": "RiskListView_risks"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v4/*: any*/),
(v2/*: any*/),
(v0/*: any*/),
(v3/*: any*/),
(v1/*: any*/)
],
"kind": "Operation",
"name": "RiskListViewQuery",
"selections": [
{
"alias": "organization",
"args": (v5/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v8/*: any*/),
(v6/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v7/*: any*/),
"concreteType": "RiskConnection",
"kind": "LinkedField",
"name": "risks",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "RiskEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Risk",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v6/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
},
(v8/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": null
},
{
"alias": null,
"args": (v7/*: any*/),
"filters": null,
"handle": "connection",
"key": "RiskListView_risks",
"kind": "LinkedHandle",
"name": "risks"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "7b0cab0d71217df4a49ea2245b2705a8",
"id": null,
"metadata": {},
"name": "RiskListViewQuery",
"operationKind": "query",
"text": "query RiskListViewQuery(\n $organizationId: ID!\n $first: Int\n $after: CursorKey\n $last: Int\n $before: CursorKey\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ...RiskListView_risks_pbnwq\n }\n}\n\nfragment RiskListView_risks_pbnwq on Organization {\n risks(first: $first, after: $after, last: $last, before: $before) {\n edges {\n node {\n id\n name\n description\n createdAt\n updatedAt\n __typename\n }\n cursor\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n id\n}\n"
}
};
})();
(node as any).hash = "0fff20fd656047e6df52818ccf0a0f3b";
export default node;

View File

@@ -0,0 +1,244 @@
/**
* @generated SignedSource<<5cb8bd63e484e9c8d842ddf05618d475>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type RiskListView_risks$data = {
readonly id: string;
readonly risks: {
readonly __id: string;
readonly edges: ReadonlyArray<{
readonly node: {
readonly createdAt: string;
readonly description: string;
readonly id: string;
readonly name: string;
readonly updatedAt: string;
};
}>;
readonly pageInfo: {
readonly endCursor: string | null | undefined;
readonly hasNextPage: boolean;
readonly hasPreviousPage: boolean;
readonly startCursor: string | null | undefined;
};
};
readonly " $fragmentType": "RiskListView_risks";
};
export type RiskListView_risks$key = {
readonly " $data"?: RiskListView_risks$data;
readonly " $fragmentSpreads": FragmentRefs<"RiskListView_risks">;
};
const node: ReaderFragment = (function(){
var v0 = [
"risks"
],
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"argumentDefinitions": [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "before"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "first"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "last"
}
],
"kind": "Fragment",
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "bidirectional",
"path": (v0/*: any*/)
}
],
"refetch": {
"connection": {
"forward": {
"count": "first",
"cursor": "after"
},
"backward": {
"count": "last",
"cursor": "before"
},
"path": (v0/*: any*/)
},
"fragmentPathInResult": [
"node"
],
"operation": require('./RiskListViewPaginationQuery.graphql'),
"identifierInfo": {
"identifierField": "id",
"identifierQueryVariableName": "id"
}
}
},
"name": "RiskListView_risks",
"selections": [
{
"alias": "risks",
"args": null,
"concreteType": "RiskConnection",
"kind": "LinkedField",
"name": "__RiskListView_risks_connection",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "RiskEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Risk",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": null
},
(v1/*: any*/)
],
"type": "Organization",
"abstractKey": null
};
})();
(node as any).hash = "4a665835c97d7b93725e8bbe708d7cfb";
export default node;

View File

@@ -20,14 +20,15 @@ const (
MitigationEntityType
TaskEntityType
EvidenceEntityType
ControlStateTransitionEntityType
TaskStateTransitionEntityType
_ControlStateTransitionEntityType
_TaskStateTransitionEntityType
VendorEntityType
PeopleEntityType
EvidenceStateTransitionEntityType
_EvidenceStateTransitionEntityType
PolicyEntityType
UserEntityType
SessionEntityType
EmailEntityType
ControlEntityType
RiskEntityType
)

View File

@@ -0,0 +1,9 @@
CREATE TABLE risks (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
organization_id TEXT NOT NULL REFERENCES organizations(id),
name TEXT NOT NULL,
description TEXT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);

198
pkg/coredata/risk.go Normal file
View File

@@ -0,0 +1,198 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"context"
"fmt"
"maps"
"time"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
)
type (
Risk struct {
ID gid.GID
OrganizationID gid.GID
Name string
Description string
CreatedAt time.Time
UpdatedAt time.Time
}
Risks []*Risk
)
func (r *Risk) CursorKey(orderBy RiskOrderField) page.CursorKey {
switch orderBy {
case RiskOrderFieldCreatedAt:
return page.CursorKey{ID: r.ID, Value: r.CreatedAt}
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (r *Risks) LoadByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
cursor *page.Cursor[RiskOrderField],
) error {
q := `
SELECT
id,
organization_id,
name,
description,
created_at,
updated_at
FROM risks
WHERE %s
AND organization_id = @organization_id
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.NamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query risks: %w", err)
}
risks, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Risk])
if err != nil {
return fmt.Errorf("cannot collect risks: %w", err)
}
*r = risks
return nil
}
func (r *Risk) LoadByID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
riskID gid.GID,
) error {
q := `
SELECT
id,
organization_id,
name,
description,
created_at,
updated_at
FROM risks
WHERE %s
AND id = @id
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"id": r.ID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query risk: %w", err)
}
risk, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Risk])
if err != nil {
return fmt.Errorf("cannot collect risk: %w", err)
}
*r = risk
return nil
}
func (r *Risk) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
INSERT INTO risks (id, tenant_id, organization_id, name, description, created_at, updated_at)
VALUES (@id, @tenant_id, @organization_id, @name, @description, @created_at, @updated_at)
`
args := pgx.StrictNamedArgs{
"id": r.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": r.OrganizationID,
"name": r.Name,
"description": r.Description,
"created_at": r.CreatedAt,
"updated_at": r.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
return err
}
func (r *Risk) Update(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
UPDATE risks
SET
name = @name,
description = @description,
updated_at = @updated_at
WHERE %s
AND tenant_id = @tenant_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"name": r.Name,
"description": r.Description,
"updated_at": r.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
return err
}
func (r *Risk) Delete(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
DELETE FROM risks WHERE %s AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": r.ID,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
return err
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
type (
RiskOrderField string
)
const (
RiskOrderFieldCreatedAt RiskOrderField = "CREATED_AT"
)
func (p RiskOrderField) Column() string {
return string(p)
}
func (p RiskOrderField) String() string {
return string(p)
}
func (p RiskOrderField) MarshalText() ([]byte, error) {
return []byte(p.String()), nil
}
func (p *RiskOrderField) UnmarshalText(text []byte) error {
*p = RiskOrderField(text)
return nil
}

173
pkg/probo/risk_service.go Normal file
View File

@@ -0,0 +1,173 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package probo
import (
"context"
"fmt"
"time"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page"
"go.gearno.de/kit/pg"
)
type (
RiskService struct {
svc *TenantService
}
CreateRiskRequest struct {
OrganizationID gid.GID
Name string
Description string
}
UpdateRiskRequest struct {
ID gid.GID
Name *string
Description *string
}
)
func (s RiskService) Create(
ctx context.Context,
req CreateRiskRequest,
) (*coredata.Risk, error) {
now := time.Now()
riskID, err := gid.NewGID(s.svc.scope.GetTenantID(), coredata.RiskEntityType)
if err != nil {
return nil, fmt.Errorf("cannot create global id: %w", err)
}
risk := &coredata.Risk{
ID: riskID,
OrganizationID: req.OrganizationID,
Name: req.Name,
Description: req.Description,
CreatedAt: now,
UpdatedAt: now,
}
err = s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
return risk.Insert(ctx, conn, s.svc.scope)
},
)
if err != nil {
return nil, fmt.Errorf("cannot create risk: %w", err)
}
return risk, nil
}
func (s RiskService) Get(
ctx context.Context,
riskID gid.GID,
) (*coredata.Risk, error) {
risk := &coredata.Risk{}
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
return risk.LoadByID(ctx, conn, s.svc.scope, riskID)
},
)
if err != nil {
return nil, fmt.Errorf("cannot get risk: %w", err)
}
return risk, nil
}
func (s RiskService) Update(
ctx context.Context,
req UpdateRiskRequest,
) (*coredata.Risk, error) {
risk := &coredata.Risk{ID: req.ID}
err := s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
if err := risk.LoadByID(ctx, conn, s.svc.scope, req.ID); err != nil {
return fmt.Errorf("cannot load risk: %w", err)
}
if req.Name != nil {
risk.Name = *req.Name
}
if req.Description != nil {
risk.Description = *req.Description
}
if err := risk.Update(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot update risk: %w", err)
}
return nil
},
)
if err != nil {
return nil, fmt.Errorf("cannot update risk: %w", err)
}
return risk, nil
}
func (s RiskService) Delete(
ctx context.Context,
riskID gid.GID,
) error {
risk := &coredata.Risk{ID: riskID}
return s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
return risk.Delete(ctx, conn, s.svc.scope)
},
)
}
func (s RiskService) ListForOrganizationID(
ctx context.Context,
organizationID gid.GID,
cursor *page.Cursor[coredata.RiskOrderField],
) (*page.Page[*coredata.Risk, coredata.RiskOrderField], error) {
var risks coredata.Risks
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
return risks.LoadByOrganizationID(
ctx,
conn,
s.svc.scope,
organizationID,
cursor,
)
},
)
if err != nil {
return nil, fmt.Errorf("cannot list risks: %w", err)
}
return page.NewPage(risks, cursor), nil
}

View File

@@ -47,6 +47,7 @@ type (
Peoples *PeopleService
Policies *PolicyService
Controls *ControlService
Risks *RiskService
}
)
@@ -86,6 +87,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
tenantService.Policies = &PolicyService{svc: tenantService}
tenantService.Organizations = &OrganizationService{svc: tenantService}
tenantService.Controls = &ControlService{svc: tenantService}
tenantService.Risks = &RiskService{svc: tenantService}
return tenantService
}

View File

@@ -215,6 +215,14 @@ enum PolicyOrderField
NAME
}
enum RiskOrderField
@goModel(model: "github.com/getprobo/probo/pkg/coredata.RiskOrderField") {
CREATED_AT
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.RiskOrderFieldCreatedAt"
)
}
enum EvidenceOrderField
@goModel(model: "github.com/getprobo/probo/pkg/coredata.EvidenceOrderField") {
CREATED_AT
@@ -291,6 +299,14 @@ input PolicyOrder
field: PolicyOrderField!
}
input RiskOrder
@goModel(
model: "github.com/getprobo/probo/pkg/server/api/console/v1/types.RiskOrderBy"
) {
direction: OrderDirection!
field: RiskOrderField!
}
input EvidenceOrder
@goModel(
model: "github.com/getprobo/probo/pkg/server/api/console/v1/types.EvidenceOrderBy"
@@ -358,6 +374,14 @@ type Organization implements Node {
orderBy: MitigationOrder
): MitigationConnection! @goField(forceResolver: true)
risks(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: RiskOrder
): RiskConnection! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
}
@@ -487,6 +511,14 @@ type Policy implements Node {
updatedAt: Datetime!
}
type Risk implements Node {
id: ID!
name: String!
description: String!
createdAt: Datetime!
updatedAt: Datetime!
}
type Session {
id: ID!
expiresAt: Datetime!
@@ -606,6 +638,16 @@ type PolicyEdge {
node: Policy!
}
type RiskConnection {
edges: [RiskEdge!]!
pageInfo: PageInfo!
}
type RiskEdge {
cursor: CursorKey!
node: Risk!
}
# Root Types
type Query {
node(id: ID!): Node!
@@ -657,6 +699,11 @@ type Mutation {
assignTask(input: AssignTaskInput!): AssignTaskPayload!
unassignTask(input: UnassignTaskInput!): UnassignTaskPayload!
# Risk mutations
createRisk(input: CreateRiskInput!): CreateRiskPayload!
updateRisk(input: UpdateRiskInput!): UpdateRiskPayload!
deleteRisk(input: DeleteRiskInput!): DeleteRiskPayload!
# Evidence mutations
uploadEvidence(input: UploadEvidenceInput!): UploadEvidencePayload!
deleteEvidence(input: DeleteEvidenceInput!): DeleteEvidencePayload!
@@ -804,6 +851,22 @@ input UnassignTaskInput {
taskId: ID!
}
input CreateRiskInput {
organizationId: ID!
name: String!
description: String!
}
input UpdateRiskInput {
id: ID!
name: String
description: String
}
input DeleteRiskInput {
riskId: ID!
}
input UploadEvidenceInput {
taskId: ID!
name: String!
@@ -939,6 +1002,18 @@ type UnassignTaskPayload {
task: Task!
}
type CreateRiskPayload {
riskEdge: RiskEdge!
}
type UpdateRiskPayload {
risk: Risk!
}
type DeleteRiskPayload {
deletedRiskId: ID!
}
type UploadEvidencePayload {
evidenceEdge: EvidenceEdge!
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package types
import (
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/page"
)
type (
RiskOrderBy OrderBy[coredata.RiskOrderField]
)
func NewRiskConnection(p *page.Page[*coredata.Risk, coredata.RiskOrderField]) *RiskConnection {
var edges = make([]*RiskEdge, len(p.Data))
for i := range edges {
edges[i] = NewRiskEdge(p.Data[i], p.Cursor.OrderBy.Field)
}
return &RiskConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
}
}
func NewRiskEdge(r *coredata.Risk, orderBy coredata.RiskOrderField) *RiskEdge {
return &RiskEdge{
Cursor: r.CursorKey(orderBy),
Node: NewRisk(r),
}
}
func NewRisk(r *coredata.Risk) *Risk {
return &Risk{
ID: r.ID,
Name: r.Name,
Description: r.Description,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}

View File

@@ -113,6 +113,16 @@ type CreatePolicyPayload struct {
PolicyEdge *PolicyEdge `json:"policyEdge"`
}
type CreateRiskInput struct {
OrganizationID gid.GID `json:"organizationId"`
Name string `json:"name"`
Description string `json:"description"`
}
type CreateRiskPayload struct {
RiskEdge *RiskEdge `json:"riskEdge"`
}
type CreateTaskInput struct {
MitigationID gid.GID `json:"mitigationId"`
Name string `json:"name"`
@@ -182,6 +192,14 @@ type DeletePolicyPayload struct {
DeletedPolicyID gid.GID `json:"deletedPolicyId"`
}
type DeleteRiskInput struct {
RiskID gid.GID `json:"riskId"`
}
type DeleteRiskPayload struct {
DeletedRiskID gid.GID `json:"deletedRiskId"`
}
type DeleteTaskInput struct {
TaskID gid.GID `json:"taskId"`
}
@@ -313,6 +331,7 @@ type Organization struct {
Peoples *PeopleConnection `json:"peoples"`
Policies *PolicyConnection `json:"policies"`
Mitigations *MitigationConnection `json:"mitigations"`
Risks *RiskConnection `json:"risks"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
@@ -401,6 +420,27 @@ type RemoveUserPayload struct {
Success bool `json:"success"`
}
type Risk struct {
ID gid.GID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Risk) IsNode() {}
func (this Risk) GetID() gid.GID { return this.ID }
type RiskConnection struct {
Edges []*RiskEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
}
type RiskEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *Risk `json:"node"`
}
type Session struct {
ID gid.GID `json:"id"`
ExpiresAt time.Time `json:"expiresAt"`
@@ -497,6 +537,16 @@ type UpdatePolicyPayload struct {
Policy *Policy `json:"policy"`
}
type UpdateRiskInput struct {
ID gid.GID `json:"id"`
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
}
type UpdateRiskPayload struct {
Risk *Risk `json:"risk"`
}
type UpdateTaskInput struct {
TaskID gid.GID `json:"taskId"`
Name *string `json:"name,omitempty"`

View File

@@ -457,7 +457,7 @@ func (r *mutationResolver) CreateTask(ctx context.Context, input types.CreateTas
TimeEstimate: input.TimeEstimate,
})
if err != nil {
return nil, fmt.Errorf("cannot create task: %w", err)
panic(fmt.Errorf("cannot create task: %w", err))
}
return &types.CreateTaskPayload{
@@ -477,7 +477,7 @@ func (r *mutationResolver) UpdateTask(ctx context.Context, input types.UpdateTas
TimeEstimate: input.TimeEstimate,
})
if err != nil {
return nil, fmt.Errorf("cannot update task: %w", err)
panic(fmt.Errorf("cannot update task: %w", err))
}
return &types.UpdateTaskPayload{
@@ -491,7 +491,7 @@ func (r *mutationResolver) DeleteTask(ctx context.Context, input types.DeleteTas
err := svc.Tasks.Delete(ctx, input.TaskID)
if err != nil {
return nil, fmt.Errorf("cannot delete task: %w", err)
panic(fmt.Errorf("cannot delete task: %w", err))
}
return &types.DeleteTaskPayload{
@@ -505,7 +505,7 @@ func (r *mutationResolver) AssignTask(ctx context.Context, input types.AssignTas
task, err := svc.Tasks.Assign(ctx, input.TaskID, input.AssignedToID)
if err != nil {
return nil, fmt.Errorf("cannot assign task: %w", err)
panic(fmt.Errorf("cannot assign task: %w", err))
}
return &types.AssignTaskPayload{
@@ -519,7 +519,7 @@ func (r *mutationResolver) UnassignTask(ctx context.Context, input types.Unassig
task, err := svc.Tasks.Unassign(ctx, input.TaskID)
if err != nil {
return nil, fmt.Errorf("cannot unassign task: %w", err)
panic(fmt.Errorf("cannot unassign task: %w", err))
}
return &types.UnassignTaskPayload{
@@ -527,6 +527,62 @@ func (r *mutationResolver) UnassignTask(ctx context.Context, input types.Unassig
}, nil
}
// CreateRisk is the resolver for the createRisk field.
func (r *mutationResolver) CreateRisk(ctx context.Context, input types.CreateRiskInput) (*types.CreateRiskPayload, error) {
svc := r.GetTenantServiceIfAuthorized(ctx, input.OrganizationID.TenantID())
risk, err := svc.Risks.Create(
ctx,
probo.CreateRiskRequest{
OrganizationID: input.OrganizationID,
Name: input.Name,
Description: input.Description,
},
)
if err != nil {
panic(fmt.Errorf("cannot create risk: %w", err))
}
return &types.CreateRiskPayload{
RiskEdge: types.NewRiskEdge(risk, coredata.RiskOrderFieldCreatedAt),
}, nil
}
// UpdateRisk is the resolver for the updateRisk field.
func (r *mutationResolver) UpdateRisk(ctx context.Context, input types.UpdateRiskInput) (*types.UpdateRiskPayload, error) {
svc := r.GetTenantServiceIfAuthorized(ctx, input.ID.TenantID())
risk, err := svc.Risks.Update(
ctx,
probo.UpdateRiskRequest{
ID: input.ID,
Name: input.Name,
Description: input.Description,
},
)
if err != nil {
panic(fmt.Errorf("cannot update risk: %w", err))
}
return &types.UpdateRiskPayload{
Risk: types.NewRisk(risk),
}, nil
}
// DeleteRisk is the resolver for the deleteRisk field.
func (r *mutationResolver) DeleteRisk(ctx context.Context, input types.DeleteRiskInput) (*types.DeleteRiskPayload, error) {
svc := r.GetTenantServiceIfAuthorized(ctx, input.RiskID.TenantID())
err := svc.Risks.Delete(ctx, input.RiskID)
if err != nil {
panic(fmt.Errorf("cannot delete risk: %w", err))
}
return &types.DeleteRiskPayload{
DeletedRiskID: input.RiskID,
}, nil
}
// UploadEvidence is the resolver for the uploadEvidence field.
func (r *mutationResolver) UploadEvidence(ctx context.Context, input types.UploadEvidenceInput) (*types.UploadEvidencePayload, error) {
svc := r.GetTenantServiceIfAuthorized(ctx, input.TaskID.TenantID())
@@ -790,6 +846,31 @@ func (r *organizationResolver) Mitigations(ctx context.Context, obj *types.Organ
return types.NewMitigationConnection(page), nil
}
// Risks is the resolver for the risks field.
func (r *organizationResolver) Risks(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.RiskOrderBy) (*types.RiskConnection, error) {
svc := r.GetTenantServiceIfAuthorized(ctx, obj.ID.TenantID())
pageOrderBy := page.OrderBy[coredata.RiskOrderField]{
Field: coredata.RiskOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.RiskOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
page, err := svc.Risks.ListForOrganizationID(ctx, obj.ID, cursor)
if err != nil {
panic(fmt.Errorf("cannot list organization risks: %w", err))
}
return types.NewRiskConnection(page), nil
}
// Owner is the resolver for the owner field.
func (r *policyResolver) Owner(ctx context.Context, obj *types.Policy) (*types.People, error) {
svc := r.GetTenantServiceIfAuthorized(ctx, obj.ID.TenantID())
@@ -875,6 +956,12 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
}
return types.NewControl(control), nil
case coredata.RiskEntityType:
risk, err := svc.Risks.Get(ctx, id)
if err != nil {
return nil, err
}
return types.NewRisk(risk), nil
default:
}