From 2d5514e45d3984199a7b4c0c68372ee43702c20c Mon Sep 17 00:00:00 2001 From: gearnode Date: Tue, 18 Feb 2025 14:24:10 +0100 Subject: [PATCH] Add update vendor logic Signed-off-by: gearnode --- apps/console/package.json | 11 +- apps/console/src/components/ui/toast.tsx | 127 +++++ apps/console/src/components/ui/toaster.tsx | 33 ++ apps/console/src/hooks/use-toast.ts | 194 +++++++ apps/console/src/layouts/ConsoleLayout.tsx | 2 + apps/console/src/pages/VendorOverviewPage.tsx | 505 +++++++++++------- .../VendorOverviewPageQuery.graphql.ts | 22 +- ...verviewPageUpdateVendorMutation.graphql.ts | 192 +++++++ package-lock.json | 35 ++ pkg/api/console/v1/schema.graphql | 9 +- pkg/api/console/v1/schema/schema.go | 382 +++++++++---- pkg/api/console/v1/types/types.go | 6 +- pkg/api/console/v1/types/vendor.go | 1 + pkg/api/console/v1/v1_resolver.go | 22 + .../coredata/migrations/20250218T121700Z.sql | 1 + pkg/probo/coredata/vendor.go | 120 ++++- pkg/probo/update_vendor.go | 55 ++ 17 files changed, 1414 insertions(+), 303 deletions(-) create mode 100644 apps/console/src/components/ui/toast.tsx create mode 100644 apps/console/src/components/ui/toaster.tsx create mode 100644 apps/console/src/hooks/use-toast.ts create mode 100644 apps/console/src/pages/__generated__/VendorOverviewPageUpdateVendorMutation.graphql.ts create mode 100644 pkg/probo/coredata/migrations/20250218T121700Z.sql create mode 100644 pkg/probo/update_vendor.go diff --git a/apps/console/package.json b/apps/console/package.json index c39fe059a..ee238a536 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -12,12 +12,17 @@ }, "dependencies": { "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-progress": "^1.1.2", + "@radix-ui/react-radio-group": "^1.1.3", + "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -31,11 +36,7 @@ "react-router": "^7.1.5", "relay-runtime": "^18.2.0", "tailwind-merge": "^3.0.1", - "tailwindcss-animate": "^1.0.7", - "@radix-ui/react-checkbox": "^1.0.4", - "@radix-ui/react-label": "^2.0.2", - "@radix-ui/react-radio-group": "^1.1.3", - "@radix-ui/react-select": "^2.0.0" + "tailwindcss-animate": "^1.0.7" }, "devDependencies": { "@babel/core": "^7.26.7", diff --git a/apps/console/src/components/ui/toast.tsx b/apps/console/src/components/ui/toast.tsx new file mode 100644 index 000000000..2ddb7c5a8 --- /dev/null +++ b/apps/console/src/components/ui/toast.tsx @@ -0,0 +1,127 @@ +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/apps/console/src/components/ui/toaster.tsx b/apps/console/src/components/ui/toaster.tsx new file mode 100644 index 000000000..6c67edff6 --- /dev/null +++ b/apps/console/src/components/ui/toaster.tsx @@ -0,0 +1,33 @@ +import { useToast } from "@/hooks/use-toast" +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ) + })} + +
+ ) +} diff --git a/apps/console/src/hooks/use-toast.ts b/apps/console/src/hooks/use-toast.ts new file mode 100644 index 000000000..02e111d81 --- /dev/null +++ b/apps/console/src/hooks/use-toast.ts @@ -0,0 +1,194 @@ +"use client" + +// Inspired by react-hot-toast library +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "@/components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } diff --git a/apps/console/src/layouts/ConsoleLayout.tsx b/apps/console/src/layouts/ConsoleLayout.tsx index fa851cfc4..f60a763f7 100644 --- a/apps/console/src/layouts/ConsoleLayout.tsx +++ b/apps/console/src/layouts/ConsoleLayout.tsx @@ -19,6 +19,7 @@ import { BreadcrumbProvider, useBreadcrumb, } from "@/contexts/BreadcrumbContext"; +import { Toaster } from "@/components/ui/toaster"; function BreadcrumbNavigation() { const location = useLocation(); @@ -90,6 +91,7 @@ export default function ConsoleLayout() {
+
diff --git a/apps/console/src/pages/VendorOverviewPage.tsx b/apps/console/src/pages/VendorOverviewPage.tsx index 8cd6d653f..b64f00cf5 100644 --- a/apps/console/src/pages/VendorOverviewPage.tsx +++ b/apps/console/src/pages/VendorOverviewPage.tsx @@ -2,19 +2,24 @@ import { Card } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; -import { HelpCircle, ArrowUpRight } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/hooks/use-toast"; +import { HelpCircle, ArrowUpRight, Pencil, Check, X } from "lucide-react"; import { graphql, PreloadedQuery, usePreloadedQuery, useQueryLoader, + useMutation, } from "react-relay"; -import { Suspense, useEffect, useState } from "react"; +import { Suspense, useEffect, useState, useCallback, useMemo } from "react"; import type { VendorOverviewPageQuery as VendorOverviewPageQueryType } from "./__generated__/VendorOverviewPageQuery.graphql"; import { useParams } from "react-router"; import { Helmet } from "react-helmet-async"; import { useBreadcrumb } from "@/contexts/BreadcrumbContext"; import { cn } from "@/lib/utils"; +import type { UpdateVendorInput } from "./__generated__/VendorOverviewPageUpdateVendorMutation.graphql"; const vendorOverviewPageQuery = graphql` query VendorOverviewPageQuery($vendorId: ID!) { @@ -32,11 +37,75 @@ const vendorOverviewPageQuery = graphql` privacyPolicyUrl createdAt updatedAt + version } } } `; +const updateVendorMutation = graphql` + mutation VendorOverviewPageUpdateVendorMutation($input: UpdateVendorInput!) { + updateVendor(input: $input) { + id + name + description + serviceStartAt + serviceTerminationAt + serviceCriticality + riskTier + statusPageUrl + termsOfServiceUrl + privacyPolicyUrl + updatedAt + version + } + } +`; + +function EditableField({ + label, + value, + onChange, + type = "text", + helpText, +}: { + label: string; + value: string; + onChange: (value: string) => void; + type?: string; + helpText?: string; +}) { + return ( +
+
+ + +
+
+ onChange(e.target.value)} + /> + {helpText &&

{helpText}

} +
+
+ ); +} + +// Format date for input field (YYYY-MM-DDTHH:mm) +function formatDateForInput(date: string | null | undefined): string { + if (!date) return ''; + return new Date(date).toISOString().slice(0, 16); +} + +// Format date for API (2006-01-02T15:04:05.999999999Z07:00) +function formatDateForAPI(dateStr: string): string { + if (!dateStr) return ''; + const date = new Date(dateStr); + return date.toISOString(); +} + function VendorOverviewPageContent({ queryRef, }: { @@ -44,8 +113,94 @@ function VendorOverviewPageContent({ }) { const data = usePreloadedQuery(vendorOverviewPageQuery, queryRef); const { setBreadcrumbSegment } = useBreadcrumb(); - const [selectedCriticality, setSelectedCriticality] = useState(data.node.serviceCriticality); - const [selectedRiskTier, setSelectedRiskTier] = useState(data.node.riskTier); + const [editedFields, setEditedFields] = useState>(new Set()); + const [formData, setFormData] = useState({ + name: data.node.name || '', + description: data.node.description || '', + // Format dates properly for datetime-local input + serviceStartAt: formatDateForInput(data.node.serviceStartAt), + serviceTerminationAt: formatDateForInput(data.node.serviceTerminationAt), + serviceCriticality: data.node.serviceCriticality, + riskTier: data.node.riskTier, + statusPageUrl: data.node.statusPageUrl || '', + termsOfServiceUrl: data.node.termsOfServiceUrl || '', + privacyPolicyUrl: data.node.privacyPolicyUrl || '', + }); + const [commit] = useMutation(updateVendorMutation); + const [_, loadQuery] = useQueryLoader(vendorOverviewPageQuery); + const { toast } = useToast(); + + const hasChanges = editedFields.size > 0; + + const handleSave = useCallback(() => { + const formattedData = { + ...formData, + serviceStartAt: formatDateForAPI(formData.serviceStartAt), + serviceTerminationAt: formData.serviceTerminationAt ? formatDateForAPI(formData.serviceTerminationAt) : null, + }; + + commit({ + variables: { + input: { + id: data.node.id, + expectedVersion: data.node.version, + ...formattedData, + }, + }, + onCompleted: () => { + toast({ + title: "Success", + description: "Changes saved successfully", + variant: "default", + }); + }, + onError: (error) => { + if (error.message?.includes('concurrent modification')) { + toast({ + title: "Error", + description: "Someone else modified this vendor. Reloading latest data.", + variant: "destructive", + }); + + loadQuery({ vendorId: data.node.id! }); + + } else { + toast({ + title: "Error", + description: error.message || "Failed to save changes", + variant: "destructive", + }); + } + }, + updater: (store) => { + // Clear any error states if needed + }, + }); + }, [commit, data.node.id, data.node.version, formData, loadQuery, toast]); + + const handleFieldChange = (field: keyof typeof formData, value: any) => { + setFormData(prev => ({ + ...prev, + [field]: value, + })); + setEditedFields(prev => new Set(prev).add(field)); + }; + + // Update the cancel handler to also format dates + const handleCancel = () => { + setFormData({ + name: data.node.name || '', + description: data.node.description || '', + serviceStartAt: formatDateForInput(data.node.serviceStartAt), + serviceTerminationAt: formatDateForInput(data.node.serviceTerminationAt), + serviceCriticality: data.node.serviceCriticality, + riskTier: data.node.riskTier, + statusPageUrl: data.node.statusPageUrl || '', + termsOfServiceUrl: data.node.termsOfServiceUrl || '', + privacyPolicyUrl: data.node.privacyPolicyUrl || '', + }); + setEditedFields(new Set()); + }; useEffect(() => { if (data.node?.name) { @@ -54,205 +209,185 @@ function VendorOverviewPageContent({ }, [data.node?.name, setBreadcrumbSegment]); return ( -
-
-
-

- {data.node.name} -

-

{data.node.description}

-
+ <> +
+
+ handleFieldChange('name', value)} + /> - -
-
-

Service Information

-

- Basic information about the vendor service -

-
+ handleFieldChange('description', value)} + /> +
-
- - -
-

- {new Date(data.node.serviceStartAt).toLocaleDateString()} +

Service Information

+

+ Basic information about the vendor service

- {data.node?.serviceTerminationAt && ( +
+ handleFieldChange('serviceStartAt', value)} + /> + + handleFieldChange('serviceTerminationAt', value)} + /> +
- +
-

- {new Date(data.node.serviceTerminationAt).toLocaleDateString()} +

+ + + +
+

+ {formData.serviceCriticality === 'HIGH' && + "Critical service - downtime severely impacts end-users"} + {formData.serviceCriticality === 'MEDIUM' && + "Important service - downtime moderately affects end-users"} + {formData.serviceCriticality === 'LOW' && + "Non-critical service - minimal end-user impact if down"}

- )} -
-
- - +
+
+ + +
+
+ + + +
+

+ {formData.riskTier === 'CRITICAL' && + "Handles sensitive data, critical for platform operation"} + {formData.riskTier === 'SIGNIFICANT' && + "No user data access, but important for platform management"} + {formData.riskTier === 'GENERAL' && + "General vendor with minimal risk"} +

-
- - - -
-

- {selectedCriticality === 'HIGH' && - "Critical service - downtime severely impacts end-users"} - {selectedCriticality === 'MEDIUM' && - "Important service - downtime moderately affects end-users"} - {selectedCriticality === 'LOW' && - "Non-critical service - minimal end-user impact if down"} -

+ + handleFieldChange('statusPageUrl', value)} + /> + + handleFieldChange('termsOfServiceUrl', value)} + /> + + handleFieldChange('privacyPolicyUrl', value)} + />
- -
-
- - -
-
- - - -
-

- {selectedRiskTier === 'CRITICAL' && - "Handles sensitive data, critical for platform operation"} - {selectedRiskTier === 'SIGNIFICANT' && - "No user data access, but important for platform management"} - {selectedRiskTier === 'GENERAL' && - "General vendor with minimal risk"} -

-
- - {data.node?.statusPageUrl && ( -
-
- - -
- - View Status Page - - -
- )} - - {data.node.termsOfServiceUrl && ( -
-
- - -
- - View Terms of Service - - -
- )} - - {data.node.privacyPolicyUrl && ( -
-
- - -
- - View Privacy Policy - - -
- )}
-
- + +
-
+ + {hasChanges && ( +
+ + +
+ )} + ); } diff --git a/apps/console/src/pages/__generated__/VendorOverviewPageQuery.graphql.ts b/apps/console/src/pages/__generated__/VendorOverviewPageQuery.graphql.ts index 29507e381..9689e7366 100644 --- a/apps/console/src/pages/__generated__/VendorOverviewPageQuery.graphql.ts +++ b/apps/console/src/pages/__generated__/VendorOverviewPageQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<180daebbc0ea7b69991d7bad2005ea13>> + * @generated SignedSource<<2d865a3f5deae54eed7c54b08df7bf0a>> * @lightSyntaxTransform * @nogrep */ @@ -28,6 +28,7 @@ export type VendorOverviewPageQuery$data = { readonly statusPageUrl?: string | null | undefined; readonly termsOfServiceUrl?: string | null | undefined; readonly updatedAt?: any; + readonly version?: number; }; }; export type VendorOverviewPageQuery = { @@ -133,6 +134,13 @@ v13 = { "kind": "ScalarField", "name": "updatedAt", "storageKey": null +}, +v14 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "version", + "storageKey": null }; return { "fragment": { @@ -163,7 +171,8 @@ return { (v10/*: any*/), (v11/*: any*/), (v12/*: any*/), - (v13/*: any*/) + (v13/*: any*/), + (v14/*: any*/) ], "type": "Vendor", "abstractKey": null @@ -210,7 +219,8 @@ return { (v10/*: any*/), (v11/*: any*/), (v12/*: any*/), - (v13/*: any*/) + (v13/*: any*/), + (v14/*: any*/) ], "type": "Vendor", "abstractKey": null @@ -221,16 +231,16 @@ return { ] }, "params": { - "cacheID": "d732d532dc450d86b3e790500c59e0ee", + "cacheID": "9163462a149138110e8573e0b28350f9", "id": null, "metadata": {}, "name": "VendorOverviewPageQuery", "operationKind": "query", - "text": "query VendorOverviewPageQuery(\n $vendorId: ID!\n) {\n node(id: $vendorId) {\n __typename\n ... on Vendor {\n id\n name\n description\n serviceStartAt\n serviceTerminationAt\n serviceCriticality\n riskTier\n statusPageUrl\n termsOfServiceUrl\n privacyPolicyUrl\n createdAt\n updatedAt\n }\n id\n }\n}\n" + "text": "query VendorOverviewPageQuery(\n $vendorId: ID!\n) {\n node(id: $vendorId) {\n __typename\n ... on Vendor {\n id\n name\n description\n serviceStartAt\n serviceTerminationAt\n serviceCriticality\n riskTier\n statusPageUrl\n termsOfServiceUrl\n privacyPolicyUrl\n createdAt\n updatedAt\n version\n }\n id\n }\n}\n" } }; })(); -(node as any).hash = "0b7427451e68a951e2eecbc3f5c59456"; +(node as any).hash = "56567f61e1016e70d6993ac132ca8382"; export default node; diff --git a/apps/console/src/pages/__generated__/VendorOverviewPageUpdateVendorMutation.graphql.ts b/apps/console/src/pages/__generated__/VendorOverviewPageUpdateVendorMutation.graphql.ts new file mode 100644 index 000000000..98cac8316 --- /dev/null +++ b/apps/console/src/pages/__generated__/VendorOverviewPageUpdateVendorMutation.graphql.ts @@ -0,0 +1,192 @@ +/** + * @generated SignedSource<<0c1d84470c9beda41d05161fad1608f2>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type RiskTier = "CRITICAL" | "GENERAL" | "SIGNIFICANT"; +export type ServiceCriticality = "HIGH" | "LOW" | "MEDIUM"; +export type UpdateVendorInput = { + description?: string | null | undefined; + expectedVersion: number; + id: string; + name?: string | null | undefined; + privacyPolicyUrl?: string | null | undefined; + riskTier?: RiskTier | null | undefined; + serviceCriticality?: ServiceCriticality | null | undefined; + serviceStartAt?: any | null | undefined; + serviceTerminationAt?: any | null | undefined; + statusPageUrl?: string | null | undefined; + termsOfServiceUrl?: string | null | undefined; +}; +export type VendorOverviewPageUpdateVendorMutation$variables = { + input: UpdateVendorInput; +}; +export type VendorOverviewPageUpdateVendorMutation$data = { + readonly updateVendor: { + readonly description: string; + readonly id: string; + readonly name: string; + readonly privacyPolicyUrl: string | null | undefined; + readonly riskTier: RiskTier; + readonly serviceCriticality: ServiceCriticality; + readonly serviceStartAt: any; + readonly serviceTerminationAt: any | null | undefined; + readonly statusPageUrl: string | null | undefined; + readonly termsOfServiceUrl: string | null | undefined; + readonly updatedAt: any; + readonly version: number; + }; +}; +export type VendorOverviewPageUpdateVendorMutation = { + response: VendorOverviewPageUpdateVendorMutation$data; + variables: VendorOverviewPageUpdateVendorMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "input" + } +], +v1 = [ + { + "alias": null, + "args": [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } + ], + "concreteType": "Vendor", + "kind": "LinkedField", + "name": "updateVendor", + "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": "serviceStartAt", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "serviceTerminationAt", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "serviceCriticality", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "riskTier", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "statusPageUrl", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "termsOfServiceUrl", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "privacyPolicyUrl", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "updatedAt", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "version", + "storageKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "VendorOverviewPageUpdateVendorMutation", + "selections": (v1/*: any*/), + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "VendorOverviewPageUpdateVendorMutation", + "selections": (v1/*: any*/) + }, + "params": { + "cacheID": "d0dd4f7b7219b20cc57a57e25b2b1197", + "id": null, + "metadata": {}, + "name": "VendorOverviewPageUpdateVendorMutation", + "operationKind": "mutation", + "text": "mutation VendorOverviewPageUpdateVendorMutation(\n $input: UpdateVendorInput!\n) {\n updateVendor(input: $input) {\n id\n name\n description\n serviceStartAt\n serviceTerminationAt\n serviceCriticality\n riskTier\n statusPageUrl\n termsOfServiceUrl\n privacyPolicyUrl\n updatedAt\n version\n }\n}\n" + } +}; +})(); + +(node as any).hash = "be093542424cdd4a329ec389312d0f83"; + +export default node; diff --git a/package-lock.json b/package-lock.json index 98069c1f1..a37900670 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -2935,6 +2936,40 @@ } } }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.6.tgz", + "integrity": "sha512-gN4dpuIVKEgpLn1z5FhzT9mYRUitbfZq9XqN/7kkBMUgFTzTG8x/KszWJugJXHcwxckY8xcKDZPz7kG3o6DsUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz", diff --git a/pkg/api/console/v1/schema.graphql b/pkg/api/console/v1/schema.graphql index af932103c..fb42949c6 100644 --- a/pkg/api/console/v1/schema.graphql +++ b/pkg/api/console/v1/schema.graphql @@ -116,8 +116,6 @@ type Vendor implements Node { id: ID! name: String! description: String! - createdAt: Datetime! - updatedAt: Datetime! serviceStartAt: Datetime! serviceTerminationAt: Datetime serviceCriticality: ServiceCriticality! @@ -125,6 +123,9 @@ type Vendor implements Node { statusPageUrl: String termsOfServiceUrl: String privacyPolicyUrl: String + createdAt: Datetime! + updatedAt: Datetime! + version: Int! } type FrameworkConnection { @@ -313,6 +314,7 @@ type Query { type Mutation { createVendor(input: CreateVendorInput!): Vendor! + updateVendor(input: UpdateVendorInput!): Vendor! deleteVendor(input: DeleteVendorInput!): Void! deletePeople(input: DeletePeopleInput!): Void! createPeople(input: CreatePeopleInput!): People! @@ -353,6 +355,7 @@ enum RiskTier @goModel(model: "github.com/getprobo/probo/pkg/probo/coredata.Risk input UpdateVendorInput { id: ID! + expectedVersion: Int! name: String description: String serviceStartAt: Datetime @@ -362,4 +365,4 @@ input UpdateVendorInput { statusPageUrl: String termsOfServiceUrl: String privacyPolicyUrl: String -} \ No newline at end of file +} diff --git a/pkg/api/console/v1/schema/schema.go b/pkg/api/console/v1/schema/schema.go index 11bd0c4a3..9be4c9079 100644 --- a/pkg/api/console/v1/schema/schema.go +++ b/pkg/api/console/v1/schema/schema.go @@ -160,6 +160,7 @@ type ComplexityRoot struct { CreateVendor func(childComplexity int, input types.CreateVendorInput) int DeletePeople func(childComplexity int, input types.DeletePeopleInput) int DeleteVendor func(childComplexity int, input types.DeleteVendorInput) int + UpdateVendor func(childComplexity int, input types.UpdateVendorInput) int } Organization struct { @@ -257,6 +258,7 @@ type ComplexityRoot struct { StatusPageURL func(childComplexity int) int TermsOfServiceURL func(childComplexity int) int UpdatedAt func(childComplexity int) int + Version func(childComplexity int) int } VendorConnection struct { @@ -282,6 +284,7 @@ type FrameworkResolver interface { } type MutationResolver interface { CreateVendor(ctx context.Context, input types.CreateVendorInput) (*types.Vendor, error) + UpdateVendor(ctx context.Context, input types.UpdateVendorInput) (*types.Vendor, error) DeleteVendor(ctx context.Context, input types.DeleteVendorInput) (string, error) DeletePeople(ctx context.Context, input types.DeletePeopleInput) (string, error) CreatePeople(ctx context.Context, input types.CreatePeopleInput) (*types.People, error) @@ -771,6 +774,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.DeleteVendor(childComplexity, args["input"].(types.DeleteVendorInput)), true + case "Mutation.updateVendor": + if e.complexity.Mutation.UpdateVendor == nil { + break + } + + args, err := ec.field_Mutation_updateVendor_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.UpdateVendor(childComplexity, args["input"].(types.UpdateVendorInput)), true + case "Organization.createdAt": if e.complexity.Organization.CreatedAt == nil { break @@ -1207,6 +1222,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Vendor.UpdatedAt(childComplexity), true + case "Vendor.version": + if e.complexity.Vendor.Version == nil { + break + } + + return e.complexity.Vendor.Version(childComplexity), true + case "VendorConnection.edges": if e.complexity.VendorConnection.Edges == nil { break @@ -1463,8 +1485,6 @@ type Vendor implements Node { id: ID! name: String! description: String! - createdAt: Datetime! - updatedAt: Datetime! serviceStartAt: Datetime! serviceTerminationAt: Datetime serviceCriticality: ServiceCriticality! @@ -1472,6 +1492,9 @@ type Vendor implements Node { statusPageUrl: String termsOfServiceUrl: String privacyPolicyUrl: String + createdAt: Datetime! + updatedAt: Datetime! + version: Int! } type FrameworkConnection { @@ -1660,6 +1683,7 @@ type Query { type Mutation { createVendor(input: CreateVendorInput!): Vendor! + updateVendor(input: UpdateVendorInput!): Vendor! deleteVendor(input: DeleteVendorInput!): Void! deletePeople(input: DeletePeopleInput!): Void! createPeople(input: CreatePeopleInput!): People! @@ -1700,6 +1724,7 @@ enum RiskTier @goModel(model: "github.com/getprobo/probo/pkg/probo/coredata.Risk input UpdateVendorInput { id: ID! + expectedVersion: Int! name: String description: String serviceStartAt: Datetime @@ -1709,7 +1734,8 @@ input UpdateVendorInput { statusPageUrl: String termsOfServiceUrl: String privacyPolicyUrl: String -}`, BuiltIn: false}, +} +`, BuiltIn: false}, } var parsedSchema = gqlparser.MustLoadSchema(sources...) @@ -2117,6 +2143,29 @@ func (ec *executionContext) field_Mutation_deleteVendor_argsInput( return zeroVal, nil } +func (ec *executionContext) field_Mutation_updateVendor_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_updateVendor_argsInput(ctx, rawArgs) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_updateVendor_argsInput( + ctx context.Context, + rawArgs map[string]any, +) (types.UpdateVendorInput, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + if tmp, ok := rawArgs["input"]; ok { + return ec.unmarshalNUpdateVendorInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐUpdateVendorInput(ctx, tmp) + } + + var zeroVal types.UpdateVendorInput + return zeroVal, nil +} + func (ec *executionContext) field_Organization_frameworks_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -4923,10 +4972,6 @@ func (ec *executionContext) fieldContext_Mutation_createVendor(ctx context.Conte return ec.fieldContext_Vendor_name(ctx, field) case "description": return ec.fieldContext_Vendor_description(ctx, field) - case "createdAt": - return ec.fieldContext_Vendor_createdAt(ctx, field) - case "updatedAt": - return ec.fieldContext_Vendor_updatedAt(ctx, field) case "serviceStartAt": return ec.fieldContext_Vendor_serviceStartAt(ctx, field) case "serviceTerminationAt": @@ -4941,6 +4986,12 @@ func (ec *executionContext) fieldContext_Mutation_createVendor(ctx context.Conte return ec.fieldContext_Vendor_termsOfServiceUrl(ctx, field) case "privacyPolicyUrl": return ec.fieldContext_Vendor_privacyPolicyUrl(ctx, field) + case "createdAt": + return ec.fieldContext_Vendor_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_Vendor_updatedAt(ctx, field) + case "version": + return ec.fieldContext_Vendor_version(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Vendor", field.Name) }, @@ -4953,6 +5004,77 @@ func (ec *executionContext) fieldContext_Mutation_createVendor(ctx context.Conte return fc, nil } +func (ec *executionContext) _Mutation_updateVendor(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_updateVendor(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().UpdateVendor(rctx, fc.Args["input"].(types.UpdateVendorInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*types.Vendor) + fc.Result = res + return ec.marshalNVendor2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐVendor(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_updateVendor(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Vendor_id(ctx, field) + case "name": + return ec.fieldContext_Vendor_name(ctx, field) + case "description": + return ec.fieldContext_Vendor_description(ctx, field) + case "serviceStartAt": + return ec.fieldContext_Vendor_serviceStartAt(ctx, field) + case "serviceTerminationAt": + return ec.fieldContext_Vendor_serviceTerminationAt(ctx, field) + case "serviceCriticality": + return ec.fieldContext_Vendor_serviceCriticality(ctx, field) + case "riskTier": + return ec.fieldContext_Vendor_riskTier(ctx, field) + case "statusPageUrl": + return ec.fieldContext_Vendor_statusPageUrl(ctx, field) + case "termsOfServiceUrl": + return ec.fieldContext_Vendor_termsOfServiceUrl(ctx, field) + case "privacyPolicyUrl": + return ec.fieldContext_Vendor_privacyPolicyUrl(ctx, field) + case "createdAt": + return ec.fieldContext_Vendor_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_Vendor_updatedAt(ctx, field) + case "version": + return ec.fieldContext_Vendor_version(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Vendor", field.Name) + }, + } + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_updateVendor_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_deleteVendor(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_deleteVendor(ctx, field) if err != nil { @@ -7215,82 +7337,6 @@ func (ec *executionContext) fieldContext_Vendor_description(_ context.Context, f return fc, nil } -func (ec *executionContext) _Vendor_createdAt(ctx context.Context, field graphql.CollectedField, obj *types.Vendor) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Vendor_createdAt(ctx, field) - if err != nil { - return graphql.Null - } - ctx = graphql.WithFieldContext(ctx, fc) - resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { - ctx = rctx // use context from middleware stack in children - return obj.CreatedAt, nil - }) - if err != nil { - ec.Error(ctx, err) - return graphql.Null - } - if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } - return graphql.Null - } - res := resTmp.(time.Time) - fc.Result = res - return ec.marshalNDatetime2timeᚐTime(ctx, field.Selections, res) -} - -func (ec *executionContext) fieldContext_Vendor_createdAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "Vendor", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type Datetime does not have child fields") - }, - } - return fc, nil -} - -func (ec *executionContext) _Vendor_updatedAt(ctx context.Context, field graphql.CollectedField, obj *types.Vendor) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Vendor_updatedAt(ctx, field) - if err != nil { - return graphql.Null - } - ctx = graphql.WithFieldContext(ctx, fc) - resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { - ctx = rctx // use context from middleware stack in children - return obj.UpdatedAt, nil - }) - if err != nil { - ec.Error(ctx, err) - return graphql.Null - } - if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } - return graphql.Null - } - res := resTmp.(time.Time) - fc.Result = res - return ec.marshalNDatetime2timeᚐTime(ctx, field.Selections, res) -} - -func (ec *executionContext) fieldContext_Vendor_updatedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "Vendor", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type Datetime does not have child fields") - }, - } - return fc, nil -} - func (ec *executionContext) _Vendor_serviceStartAt(ctx context.Context, field graphql.CollectedField, obj *types.Vendor) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Vendor_serviceStartAt(ctx, field) if err != nil { @@ -7545,6 +7591,120 @@ func (ec *executionContext) fieldContext_Vendor_privacyPolicyUrl(_ context.Conte return fc, nil } +func (ec *executionContext) _Vendor_createdAt(ctx context.Context, field graphql.CollectedField, obj *types.Vendor) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Vendor_createdAt(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.CreatedAt, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(time.Time) + fc.Result = res + return ec.marshalNDatetime2timeᚐTime(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Vendor_createdAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Vendor", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Datetime does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Vendor_updatedAt(ctx context.Context, field graphql.CollectedField, obj *types.Vendor) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Vendor_updatedAt(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.UpdatedAt, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(time.Time) + fc.Result = res + return ec.marshalNDatetime2timeᚐTime(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Vendor_updatedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Vendor", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Datetime does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Vendor_version(ctx context.Context, field graphql.CollectedField, obj *types.Vendor) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Vendor_version(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Version, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(int) + fc.Result = res + return ec.marshalNInt2int(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Vendor_version(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Vendor", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _VendorConnection_edges(ctx context.Context, field graphql.CollectedField, obj *types.VendorConnection) (ret graphql.Marshaler) { fc, err := ec.fieldContext_VendorConnection_edges(ctx, field) if err != nil { @@ -7714,10 +7874,6 @@ func (ec *executionContext) fieldContext_VendorEdge_node(_ context.Context, fiel return ec.fieldContext_Vendor_name(ctx, field) case "description": return ec.fieldContext_Vendor_description(ctx, field) - case "createdAt": - return ec.fieldContext_Vendor_createdAt(ctx, field) - case "updatedAt": - return ec.fieldContext_Vendor_updatedAt(ctx, field) case "serviceStartAt": return ec.fieldContext_Vendor_serviceStartAt(ctx, field) case "serviceTerminationAt": @@ -7732,6 +7888,12 @@ func (ec *executionContext) fieldContext_VendorEdge_node(_ context.Context, fiel return ec.fieldContext_Vendor_termsOfServiceUrl(ctx, field) case "privacyPolicyUrl": return ec.fieldContext_Vendor_privacyPolicyUrl(ctx, field) + case "createdAt": + return ec.fieldContext_Vendor_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_Vendor_updatedAt(ctx, field) + case "version": + return ec.fieldContext_Vendor_version(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Vendor", field.Name) }, @@ -9440,7 +9602,7 @@ func (ec *executionContext) unmarshalInputUpdateVendorInput(ctx context.Context, asMap[k] = v } - fieldsInOrder := [...]string{"id", "name", "description", "serviceStartAt", "serviceTerminationAt", "serviceCriticality", "riskTier", "statusPageUrl", "termsOfServiceUrl", "privacyPolicyUrl"} + fieldsInOrder := [...]string{"id", "expectedVersion", "name", "description", "serviceStartAt", "serviceTerminationAt", "serviceCriticality", "riskTier", "statusPageUrl", "termsOfServiceUrl", "privacyPolicyUrl"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -9454,6 +9616,13 @@ func (ec *executionContext) unmarshalInputUpdateVendorInput(ctx context.Context, return it, err } it.ID = data + case "expectedVersion": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("expectedVersion")) + data, err := ec.unmarshalNInt2int(ctx, v) + if err != nil { + return it, err + } + it.ExpectedVersion = data case "name": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("name")) data, err := ec.unmarshalOString2ᚖstring(ctx, v) @@ -10492,6 +10661,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "updateVendor": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_updateVendor(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "deleteVendor": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_deleteVendor(ctx, field) @@ -11346,16 +11522,6 @@ func (ec *executionContext) _Vendor(ctx context.Context, sel ast.SelectionSet, o if out.Values[i] == graphql.Null { out.Invalids++ } - case "createdAt": - out.Values[i] = ec._Vendor_createdAt(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ - } - case "updatedAt": - out.Values[i] = ec._Vendor_updatedAt(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ - } case "serviceStartAt": out.Values[i] = ec._Vendor_serviceStartAt(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -11379,6 +11545,21 @@ func (ec *executionContext) _Vendor(ctx context.Context, sel ast.SelectionSet, o out.Values[i] = ec._Vendor_termsOfServiceUrl(ctx, field, obj) case "privacyPolicyUrl": out.Values[i] = ec._Vendor_privacyPolicyUrl(ctx, field, obj) + case "createdAt": + out.Values[i] = ec._Vendor_createdAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "updatedAt": + out.Values[i] = ec._Vendor_updatedAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "version": + out.Values[i] = ec._Vendor_version(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -12730,6 +12911,11 @@ func (ec *executionContext) marshalNTaskStateTransitionEdge2ᚖgithubᚗcomᚋge return ec._TaskStateTransitionEdge(ctx, sel, v) } +func (ec *executionContext) unmarshalNUpdateVendorInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐUpdateVendorInput(ctx context.Context, v any) (types.UpdateVendorInput, error) { + res, err := ec.unmarshalInputUpdateVendorInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) marshalNVendor2githubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐVendor(ctx context.Context, sel ast.SelectionSet, v types.Vendor) graphql.Marshaler { return ec._Vendor(ctx, sel, &v) } diff --git a/pkg/api/console/v1/types/types.go b/pkg/api/console/v1/types/types.go index 25c256c10..7b056c9d6 100644 --- a/pkg/api/console/v1/types/types.go +++ b/pkg/api/console/v1/types/types.go @@ -240,6 +240,7 @@ type TaskStateTransitionEdge struct { type UpdateVendorInput struct { ID gid.GID `json:"id"` + ExpectedVersion int `json:"expectedVersion"` Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` ServiceStartAt *time.Time `json:"serviceStartAt,omitempty"` @@ -255,8 +256,6 @@ type Vendor struct { ID gid.GID `json:"id"` Name string `json:"name"` Description string `json:"description"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` ServiceStartAt time.Time `json:"serviceStartAt"` ServiceTerminationAt *time.Time `json:"serviceTerminationAt,omitempty"` ServiceCriticality coredata.ServiceCriticality `json:"serviceCriticality"` @@ -264,6 +263,9 @@ type Vendor struct { StatusPageURL *string `json:"statusPageUrl,omitempty"` TermsOfServiceURL *string `json:"termsOfServiceUrl,omitempty"` PrivacyPolicyURL *string `json:"privacyPolicyUrl,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Version int `json:"version"` } func (Vendor) IsNode() {} diff --git a/pkg/api/console/v1/types/vendor.go b/pkg/api/console/v1/types/vendor.go index 2c785a8c6..38b2e8bfa 100644 --- a/pkg/api/console/v1/types/vendor.go +++ b/pkg/api/console/v1/types/vendor.go @@ -53,5 +53,6 @@ func NewVendor(v *coredata.Vendor) *Vendor { StatusPageURL: v.StatusPageURL, TermsOfServiceURL: v.TermsOfServiceURL, PrivacyPolicyURL: v.PrivacyPolicyURL, + Version: v.Version, } } diff --git a/pkg/api/console/v1/v1_resolver.go b/pkg/api/console/v1/v1_resolver.go index 56c0b47d9..9f3472822 100644 --- a/pkg/api/console/v1/v1_resolver.go +++ b/pkg/api/console/v1/v1_resolver.go @@ -78,6 +78,28 @@ func (r *mutationResolver) CreateVendor(ctx context.Context, input types.CreateV return types.NewVendor(vendor), nil } +// UpdateVendor is the resolver for the updateVendor field. +func (r *mutationResolver) UpdateVendor(ctx context.Context, input types.UpdateVendorInput) (*types.Vendor, error) { + vendor, err := r.svc.UpdateVendor(ctx, probo.UpdateVendorRequest{ + ID: input.ID, + ExpectedVersion: input.ExpectedVersion, + Name: input.Name, + Description: input.Description, + ServiceStartAt: input.ServiceStartAt, + ServiceTerminationAt: input.ServiceTerminationAt, + ServiceCriticality: input.ServiceCriticality, + RiskTier: input.RiskTier, + StatusPageURL: input.StatusPageURL, + TermsOfServiceURL: input.TermsOfServiceURL, + PrivacyPolicyURL: input.PrivacyPolicyURL, + }) + if err != nil { + return nil, fmt.Errorf("cannot update vendor: %w", err) + } + + return types.NewVendor(vendor), nil +} + // DeleteVendor is the resolver for the deleteVendor field. func (r *mutationResolver) DeleteVendor(ctx context.Context, input types.DeleteVendorInput) (string, error) { err := r.svc.DeleteVendor(ctx, input.VendorID) diff --git a/pkg/probo/coredata/migrations/20250218T121700Z.sql b/pkg/probo/coredata/migrations/20250218T121700Z.sql new file mode 100644 index 000000000..64aa8ce85 --- /dev/null +++ b/pkg/probo/coredata/migrations/20250218T121700Z.sql @@ -0,0 +1 @@ +ALTER TABLE vendors ADD COLUMN version INTEGER NOT NULL DEFAULT 1; \ No newline at end of file diff --git a/pkg/probo/coredata/vendor.go b/pkg/probo/coredata/vendor.go index 9db1df8a1..9b2ab74e3 100644 --- a/pkg/probo/coredata/vendor.go +++ b/pkg/probo/coredata/vendor.go @@ -16,6 +16,7 @@ package coredata import ( "context" + "errors" "fmt" "maps" "time" @@ -26,6 +27,8 @@ import ( "go.gearno.de/kit/pg" ) +var ErrConcurrentModification = errors.New("concurrent modification") + type ( Vendor struct { ID gid.GID @@ -41,9 +44,23 @@ type ( PrivacyPolicyURL *string CreatedAt time.Time UpdatedAt time.Time + Version int } Vendors []*Vendor + + UpdateVendorParams struct { + ExpectedVersion int + Name *string + Description *string + ServiceStartAt *time.Time + ServiceTerminationAt *time.Time + ServiceCriticality *ServiceCriticality + RiskTier *RiskTier + StatusPageURL *string + TermsOfServiceURL *string + PrivacyPolicyURL *string + } ) func (v Vendor) CursorKey() page.CursorKey { @@ -65,6 +82,7 @@ func (v *Vendor) scan(r pgx.Row) error { &v.PrivacyPolicyURL, &v.CreatedAt, &v.UpdatedAt, + &v.Version, ) } @@ -88,7 +106,8 @@ SELECT terms_of_service_url, privacy_policy_url, created_at, - updated_at + updated_at, + version FROM vendors WHERE @@ -133,7 +152,8 @@ INSERT INTO terms_of_service_url, privacy_policy_url, created_at, - updated_at + updated_at, + version ) VALUES ( @vendor_id, @@ -148,7 +168,8 @@ VALUES ( @terms_of_service_url, @privacy_policy_url, @created_at, - @updated_at + @updated_at, + 1 ) ` @@ -210,7 +231,8 @@ SELECT terms_of_service_url, privacy_policy_url, created_at, - updated_at + updated_at, + version FROM vendors WHERE @@ -249,3 +271,93 @@ WHERE return nil } + +func (v *Vendor) Update( + ctx context.Context, + conn pg.Conn, + scope *Scope, + params UpdateVendorParams, +) error { + q := ` +UPDATE vendors SET + name = COALESCE(@name, name), + description = COALESCE(@description, description), + service_start_at = COALESCE(@service_start_at, service_start_at), + service_termination_at = COALESCE(@service_termination_at, service_termination_at), + service_criticality = COALESCE(@service_criticality, service_criticality), + risk_tier = COALESCE(@risk_tier, risk_tier), + status_page_url = COALESCE(@status_page_url, status_page_url), + terms_of_service_url = COALESCE(@terms_of_service_url, terms_of_service_url), + privacy_policy_url = COALESCE(@privacy_policy_url, privacy_policy_url), + updated_at = @updated_at, + version = version + 1 +WHERE %s + AND id = @vendor_id + AND version = @expected_version +RETURNING + id, + organization_id, + name, + description, + service_start_at, + service_termination_at, + service_criticality, + risk_tier, + status_page_url, + terms_of_service_url, + privacy_policy_url, + created_at, + updated_at, + version +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.NamedArgs{ + "vendor_id": v.ID, + "expected_version": params.ExpectedVersion, + "updated_at": time.Now(), + } + + if params.Name != nil { + args["name"] = *params.Name + } + if params.Description != nil { + args["description"] = *params.Description + } + if params.ServiceStartAt != nil { + args["service_start_at"] = *params.ServiceStartAt + } + if params.ServiceTerminationAt != nil { + args["service_termination_at"] = *params.ServiceTerminationAt + } + if params.ServiceCriticality != nil { + args["service_criticality"] = *params.ServiceCriticality + } + if params.RiskTier != nil { + args["risk_tier"] = *params.RiskTier + } + if params.StatusPageURL != nil { + args["status_page_url"] = *params.StatusPageURL + } + if params.TermsOfServiceURL != nil { + args["terms_of_service_url"] = *params.TermsOfServiceURL + } + if params.PrivacyPolicyURL != nil { + args["privacy_policy_url"] = *params.PrivacyPolicyURL + } + + maps.Copy(args, scope.SQLArguments()) + + r := conn.QueryRow(ctx, q, args) + + v2 := Vendor{} + if err := v2.scan(r); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrConcurrentModification + } + return err + } + + *v = v2 + return nil +} diff --git a/pkg/probo/update_vendor.go b/pkg/probo/update_vendor.go new file mode 100644 index 000000000..7ce205f54 --- /dev/null +++ b/pkg/probo/update_vendor.go @@ -0,0 +1,55 @@ +package probo + +import ( + "context" + "time" + + "github.com/getprobo/probo/pkg/gid" + "github.com/getprobo/probo/pkg/probo/coredata" + "go.gearno.de/kit/pg" +) + +type UpdateVendorRequest struct { + ID gid.GID + ExpectedVersion int + Name *string + Description *string + ServiceStartAt *time.Time + ServiceTerminationAt *time.Time + ServiceCriticality *coredata.ServiceCriticality + RiskTier *coredata.RiskTier + StatusPageURL *string + TermsOfServiceURL *string + PrivacyPolicyURL *string +} + +func (s Service) UpdateVendor( + ctx context.Context, + req UpdateVendorRequest, +) (*coredata.Vendor, error) { + params := coredata.UpdateVendorParams{ + ExpectedVersion: req.ExpectedVersion, + Name: req.Name, + Description: req.Description, + ServiceStartAt: req.ServiceStartAt, + ServiceTerminationAt: req.ServiceTerminationAt, + ServiceCriticality: req.ServiceCriticality, + RiskTier: req.RiskTier, + StatusPageURL: req.StatusPageURL, + TermsOfServiceURL: req.TermsOfServiceURL, + PrivacyPolicyURL: req.PrivacyPolicyURL, + } + + vendor := &coredata.Vendor{ID: req.ID} + + err := s.pg.WithTx( + ctx, + func(conn pg.Conn) error { + return vendor.Update(ctx, conn, s.scope, params) + }) + if err != nil { + return nil, err + } + + return vendor, nil +}