diff --git a/apps/console/src/pages/PeopleOverviewPage.tsx b/apps/console/src/pages/PeopleOverviewPage.tsx index db9cb98fb..a77a4ccc2 100644 --- a/apps/console/src/pages/PeopleOverviewPage.tsx +++ b/apps/console/src/pages/PeopleOverviewPage.tsx @@ -1,17 +1,24 @@ "use client"; import { Card } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/hooks/use-toast"; +import { HelpCircle } from "lucide-react"; import { graphql, PreloadedQuery, usePreloadedQuery, useQueryLoader, + useMutation, } from "react-relay"; -import { Suspense, useEffect } from "react"; +import { Suspense, useEffect, useState, useCallback } from "react"; import type { PeopleOverviewPageQuery as PeopleOverviewPageQueryType } from "./__generated__/PeopleOverviewPageQuery.graphql"; import { useParams } from "react-router"; import { Helmet } from "react-helmet-async"; import { useBreadcrumb } from "@/contexts/BreadcrumbContext"; +import { cn } from "@/lib/utils"; const peopleOverviewPageQuery = graphql` query PeopleOverviewPageQuery($peopleId: ID!) { @@ -20,13 +27,61 @@ const peopleOverviewPageQuery = graphql` id fullName primaryEmailAddress + additionalEmailAddresses + kind createdAt updatedAt + version } } } `; +const updatePeopleMutation = graphql` + mutation PeopleOverviewPageUpdatePeopleMutation($input: UpdatePeopleInput!) { + updatePeople(input: $input) { + id + fullName + primaryEmailAddress + additionalEmailAddresses + kind + 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}

} +
+
+ ); +} + function PeopleOverviewPageContent({ queryRef, }: { @@ -34,80 +89,198 @@ function PeopleOverviewPageContent({ }) { const data = usePreloadedQuery(peopleOverviewPageQuery, queryRef); const { setBreadcrumbSegment } = useBreadcrumb(); + const [editedFields, setEditedFields] = useState>(new Set()); + const [formData, setFormData] = useState({ + fullName: data.node.fullName || '', + primaryEmailAddress: data.node.primaryEmailAddress || '', + additionalEmailAddresses: data.node.additionalEmailAddresses || [], + kind: data.node.kind, + }); + const [commit] = useMutation(updatePeopleMutation); + const [_, loadQuery] = useQueryLoader(peopleOverviewPageQuery); + const { toast } = useToast(); + + const hasChanges = editedFields.size > 0; + + const handleSave = useCallback(() => { + commit({ + variables: { + input: { + id: data.node.id, + expectedVersion: data.node.version, + ...formData, + }, + }, + onCompleted: () => { + toast({ + title: "Success", + description: "Changes saved successfully", + variant: "default", + }); + setEditedFields(new Set()); + }, + onError: (error) => { + if (error.message?.includes('concurrent modification')) { + toast({ + title: "Error", + description: "Someone else modified this person. Reloading latest data.", + variant: "destructive", + }); + loadQuery({ peopleId: data.node.id! }); + } else { + toast({ + title: "Error", + description: error.message || "Failed to save changes", + variant: "destructive", + }); + } + }, + }); + }, [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)); + }; + + const handleCancel = () => { + setFormData({ + fullName: data.node.fullName || '', + primaryEmailAddress: data.node.primaryEmailAddress || '', + additionalEmailAddresses: data.node.additionalEmailAddresses || [], + kind: data.node.kind, + }); + setEditedFields(new Set()); + }; useEffect(() => { - if (data.node?.primaryEmailAddress) { - setBreadcrumbSegment("peoples/:id", data.node.primaryEmailAddress); + if (data.node?.fullName) { + setBreadcrumbSegment("peoples/:id", data.node.fullName); } - }, [data.node?.primaryEmailAddress, setBreadcrumbSegment]); + }, [data.node?.fullName, setBreadcrumbSegment]); return ( -
-
-
-

- {data.node?.fullName} -

-

View and manage person details

+ <> +
+
+ handleFieldChange('fullName', value)} + /> + + handleFieldChange('primaryEmailAddress', value)} + /> + +
+
+ + +
+
+ {formData.additionalEmailAddresses.map((email, index) => ( +
+ { + const newEmails = [...formData.additionalEmailAddresses]; + newEmails[index] = e.target.value; + handleFieldChange('additionalEmailAddresses', newEmails); + }} + /> + +
+ ))} + +
+
+ + +
+
+

Additional Information

+

+ Additional details about the person +

+
+ +
+
+
+ + +
+
+ + +
+
+
+
+
- - -
-
-

Personal Information

-

- Basic information about the person -

-
- -
-
- -

{data.node?.fullName}

-
-
- -

{data.node?.primaryEmailAddress}

-
-
-
-
- - -
-
-

System Information

-

- System-related information about the person -

-
- -
-
- -

- {new Date(data.node?.createdAt).toLocaleString()} -

-
-
- -

- {new Date(data.node?.updatedAt).toLocaleString()} -

-
-
-
-
-
+ + {hasChanges && ( +
+ + +
+ )} + ); } diff --git a/apps/console/src/pages/__generated__/PeopleOverviewPageQuery.graphql.ts b/apps/console/src/pages/__generated__/PeopleOverviewPageQuery.graphql.ts index 500a79255..28a2200e9 100644 --- a/apps/console/src/pages/__generated__/PeopleOverviewPageQuery.graphql.ts +++ b/apps/console/src/pages/__generated__/PeopleOverviewPageQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<<528ad6b0a104964c9d947ef1e9c41b46>> * @lightSyntaxTransform * @nogrep */ @@ -9,16 +9,20 @@ // @ts-nocheck import { ConcreteRequest } from 'relay-runtime'; +export type PeopleKind = "CONTRACTOR" | "EMPLOYEE"; export type PeopleOverviewPageQuery$variables = { peopleId: string; }; export type PeopleOverviewPageQuery$data = { readonly node: { + readonly additionalEmailAddresses?: ReadonlyArray; readonly createdAt?: any; readonly fullName?: string; readonly id?: string; + readonly kind?: PeopleKind; readonly primaryEmailAddress?: string; readonly updatedAt?: any; + readonly version?: number; }; }; export type PeopleOverviewPageQuery = { @@ -66,15 +70,36 @@ v5 = { "alias": null, "args": null, "kind": "ScalarField", - "name": "createdAt", + "name": "additionalEmailAddresses", "storageKey": null }, v6 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "kind", + "storageKey": null +}, +v7 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "createdAt", + "storageKey": null +}, +v8 = { "alias": null, "args": null, "kind": "ScalarField", "name": "updatedAt", "storageKey": null +}, +v9 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "version", + "storageKey": null }; return { "fragment": { @@ -98,7 +123,10 @@ return { (v3/*: any*/), (v4/*: any*/), (v5/*: any*/), - (v6/*: any*/) + (v6/*: any*/), + (v7/*: any*/), + (v8/*: any*/), + (v9/*: any*/) ], "type": "People", "abstractKey": null @@ -138,7 +166,10 @@ return { (v3/*: any*/), (v4/*: any*/), (v5/*: any*/), - (v6/*: any*/) + (v6/*: any*/), + (v7/*: any*/), + (v8/*: any*/), + (v9/*: any*/) ], "type": "People", "abstractKey": null @@ -149,16 +180,16 @@ return { ] }, "params": { - "cacheID": "0cd0dd96cb7a84c2016bfe1f088f8245", + "cacheID": "1383ca6e8c8b205082cc68223bec1833", "id": null, "metadata": {}, "name": "PeopleOverviewPageQuery", "operationKind": "query", - "text": "query PeopleOverviewPageQuery(\n $peopleId: ID!\n) {\n node(id: $peopleId) {\n __typename\n ... on People {\n id\n fullName\n primaryEmailAddress\n createdAt\n updatedAt\n }\n id\n }\n}\n" + "text": "query PeopleOverviewPageQuery(\n $peopleId: ID!\n) {\n node(id: $peopleId) {\n __typename\n ... on People {\n id\n fullName\n primaryEmailAddress\n additionalEmailAddresses\n kind\n createdAt\n updatedAt\n version\n }\n id\n }\n}\n" } }; })(); -(node as any).hash = "16b3f0915f69d8475d745b0c7f13b029"; +(node as any).hash = "fa229186ad9e51f3a60b23fa88a882aa"; export default node; diff --git a/apps/console/src/pages/__generated__/PeopleOverviewPageUpdatePeopleMutation.graphql.ts b/apps/console/src/pages/__generated__/PeopleOverviewPageUpdatePeopleMutation.graphql.ts new file mode 100644 index 000000000..f4ad1427f --- /dev/null +++ b/apps/console/src/pages/__generated__/PeopleOverviewPageUpdatePeopleMutation.graphql.ts @@ -0,0 +1,146 @@ +/** + * @generated SignedSource<> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type PeopleKind = "CONTRACTOR" | "EMPLOYEE"; +export type UpdatePeopleInput = { + additionalEmailAddresses?: ReadonlyArray | null | undefined; + expectedVersion: number; + fullName?: string | null | undefined; + id: string; + kind?: PeopleKind | null | undefined; + primaryEmailAddress?: string | null | undefined; +}; +export type PeopleOverviewPageUpdatePeopleMutation$variables = { + input: UpdatePeopleInput; +}; +export type PeopleOverviewPageUpdatePeopleMutation$data = { + readonly updatePeople: { + readonly additionalEmailAddresses: ReadonlyArray; + readonly fullName: string; + readonly id: string; + readonly kind: PeopleKind; + readonly primaryEmailAddress: string; + readonly updatedAt: any; + readonly version: number; + }; +}; +export type PeopleOverviewPageUpdatePeopleMutation = { + response: PeopleOverviewPageUpdatePeopleMutation$data; + variables: PeopleOverviewPageUpdatePeopleMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "input" + } +], +v1 = [ + { + "alias": null, + "args": [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } + ], + "concreteType": "People", + "kind": "LinkedField", + "name": "updatePeople", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "fullName", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "primaryEmailAddress", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "additionalEmailAddresses", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "kind", + "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": "PeopleOverviewPageUpdatePeopleMutation", + "selections": (v1/*: any*/), + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "PeopleOverviewPageUpdatePeopleMutation", + "selections": (v1/*: any*/) + }, + "params": { + "cacheID": "85b62bd3c79a01fc437649a9c8a217b3", + "id": null, + "metadata": {}, + "name": "PeopleOverviewPageUpdatePeopleMutation", + "operationKind": "mutation", + "text": "mutation PeopleOverviewPageUpdatePeopleMutation(\n $input: UpdatePeopleInput!\n) {\n updatePeople(input: $input) {\n id\n fullName\n primaryEmailAddress\n additionalEmailAddresses\n kind\n updatedAt\n version\n }\n}\n" + } +}; +})(); + +(node as any).hash = "3fba4023eb9fa1520ed88bb9b4501dc7"; + +export default node; diff --git a/pkg/api/console/v1/schema.graphql b/pkg/api/console/v1/schema.graphql index fb42949c6..99ca27a9d 100644 --- a/pkg/api/console/v1/schema.graphql +++ b/pkg/api/console/v1/schema.graphql @@ -100,6 +100,7 @@ type People implements Node { kind: PeopleKind! createdAt: Datetime! updatedAt: Datetime! + version: Int! } type VendorConnection { @@ -316,8 +317,9 @@ type Mutation { createVendor(input: CreateVendorInput!): Vendor! updateVendor(input: UpdateVendorInput!): Vendor! deleteVendor(input: DeleteVendorInput!): Void! - deletePeople(input: DeletePeopleInput!): Void! createPeople(input: CreatePeopleInput!): People! + updatePeople(input: UpdatePeopleInput!): People! + deletePeople(input: DeletePeopleInput!): Void! } input CreateVendorInput { @@ -341,6 +343,15 @@ input CreatePeopleInput { kind: PeopleKind! } +input UpdatePeopleInput { + id: ID! + expectedVersion: Int! + fullName: String + primaryEmailAddress: String + additionalEmailAddresses: [String!] + kind: PeopleKind +} + enum ServiceCriticality @goModel(model: "github.com/getprobo/probo/pkg/probo/coredata.ServiceCriticality") { LOW @goEnum(value: "github.com/getprobo/probo/pkg/probo/coredata.ServiceCriticalityLow") MEDIUM @goEnum(value: "github.com/getprobo/probo/pkg/probo/coredata.ServiceCriticalityMedium") diff --git a/pkg/api/console/v1/schema/schema.go b/pkg/api/console/v1/schema/schema.go index 9be4c9079..f508c3820 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 + UpdatePeople func(childComplexity int, input types.UpdatePeopleInput) int UpdateVendor func(childComplexity int, input types.UpdateVendorInput) int } @@ -189,6 +190,7 @@ type ComplexityRoot struct { Kind func(childComplexity int) int PrimaryEmailAddress func(childComplexity int) int UpdatedAt func(childComplexity int) int + Version func(childComplexity int) int } PeopleConnection struct { @@ -286,8 +288,9 @@ 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) + UpdatePeople(ctx context.Context, input types.UpdatePeopleInput) (*types.People, error) + DeletePeople(ctx context.Context, input types.DeletePeopleInput) (string, error) } type OrganizationResolver interface { Frameworks(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.FrameworkConnection, error) @@ -774,6 +777,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.DeleteVendor(childComplexity, args["input"].(types.DeleteVendorInput)), true + case "Mutation.updatePeople": + if e.complexity.Mutation.UpdatePeople == nil { + break + } + + args, err := ec.field_Mutation_updatePeople_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.UpdatePeople(childComplexity, args["input"].(types.UpdatePeopleInput)), true + case "Mutation.updateVendor": if e.complexity.Mutation.UpdateVendor == nil { break @@ -934,6 +949,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.People.UpdatedAt(childComplexity), true + case "People.version": + if e.complexity.People.Version == nil { + break + } + + return e.complexity.People.Version(childComplexity), true + case "PeopleConnection.edges": if e.complexity.PeopleConnection.Edges == nil { break @@ -1269,6 +1291,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputCreateVendorInput, ec.unmarshalInputDeletePeopleInput, ec.unmarshalInputDeleteVendorInput, + ec.unmarshalInputUpdatePeopleInput, ec.unmarshalInputUpdateVendorInput, ) first := true @@ -1469,6 +1492,7 @@ type People implements Node { kind: PeopleKind! createdAt: Datetime! updatedAt: Datetime! + version: Int! } type VendorConnection { @@ -1685,8 +1709,9 @@ type Mutation { createVendor(input: CreateVendorInput!): Vendor! updateVendor(input: UpdateVendorInput!): Vendor! deleteVendor(input: DeleteVendorInput!): Void! - deletePeople(input: DeletePeopleInput!): Void! createPeople(input: CreatePeopleInput!): People! + updatePeople(input: UpdatePeopleInput!): People! + deletePeople(input: DeletePeopleInput!): Void! } input CreateVendorInput { @@ -1710,6 +1735,15 @@ input CreatePeopleInput { kind: PeopleKind! } +input UpdatePeopleInput { + id: ID! + expectedVersion: Int! + fullName: String + primaryEmailAddress: String + additionalEmailAddresses: [String!] + kind: PeopleKind +} + enum ServiceCriticality @goModel(model: "github.com/getprobo/probo/pkg/probo/coredata.ServiceCriticality") { LOW @goEnum(value: "github.com/getprobo/probo/pkg/probo/coredata.ServiceCriticalityLow") MEDIUM @goEnum(value: "github.com/getprobo/probo/pkg/probo/coredata.ServiceCriticalityMedium") @@ -2143,6 +2177,29 @@ func (ec *executionContext) field_Mutation_deleteVendor_argsInput( return zeroVal, nil } +func (ec *executionContext) field_Mutation_updatePeople_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_updatePeople_argsInput(ctx, rawArgs) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_updatePeople_argsInput( + ctx context.Context, + rawArgs map[string]any, +) (types.UpdatePeopleInput, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + if tmp, ok := rawArgs["input"]; ok { + return ec.unmarshalNUpdatePeopleInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐUpdatePeopleInput(ctx, tmp) + } + + var zeroVal types.UpdatePeopleInput + 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{} @@ -5118,49 +5175,6 @@ func (ec *executionContext) fieldContext_Mutation_deleteVendor(ctx context.Conte return fc, nil } -func (ec *executionContext) _Mutation_deletePeople(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Mutation_deletePeople(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().DeletePeople(rctx, fc.Args["input"].(types.DeletePeopleInput)) - }) - 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.(string) - fc.Result = res - return ec.marshalNVoid2string(ctx, field.Selections, res) -} - -func (ec *executionContext) fieldContext_Mutation_deletePeople(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) { - return nil, errors.New("field of type Void does not have child fields") - }, - } - ctx = graphql.WithFieldContext(ctx, fc) - if fc.Args, err = ec.field_Mutation_deletePeople_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { - ec.Error(ctx, err) - return fc, err - } - return fc, nil -} - func (ec *executionContext) _Mutation_createPeople(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_createPeople(ctx, field) if err != nil { @@ -5208,6 +5222,8 @@ func (ec *executionContext) fieldContext_Mutation_createPeople(ctx context.Conte return ec.fieldContext_People_createdAt(ctx, field) case "updatedAt": return ec.fieldContext_People_updatedAt(ctx, field) + case "version": + return ec.fieldContext_People_version(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type People", field.Name) }, @@ -5220,6 +5236,110 @@ func (ec *executionContext) fieldContext_Mutation_createPeople(ctx context.Conte return fc, nil } +func (ec *executionContext) _Mutation_updatePeople(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_updatePeople(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().UpdatePeople(rctx, fc.Args["input"].(types.UpdatePeopleInput)) + }) + 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.People) + fc.Result = res + return ec.marshalNPeople2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐPeople(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_updatePeople(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_People_id(ctx, field) + case "fullName": + return ec.fieldContext_People_fullName(ctx, field) + case "primaryEmailAddress": + return ec.fieldContext_People_primaryEmailAddress(ctx, field) + case "additionalEmailAddresses": + return ec.fieldContext_People_additionalEmailAddresses(ctx, field) + case "kind": + return ec.fieldContext_People_kind(ctx, field) + case "createdAt": + return ec.fieldContext_People_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_People_updatedAt(ctx, field) + case "version": + return ec.fieldContext_People_version(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type People", field.Name) + }, + } + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_updatePeople_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_deletePeople(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_deletePeople(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().DeletePeople(rctx, fc.Args["input"].(types.DeletePeopleInput)) + }) + 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.(string) + fc.Result = res + return ec.marshalNVoid2string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_deletePeople(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) { + return nil, errors.New("field of type Void does not have child fields") + }, + } + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_deletePeople_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Organization_id(ctx context.Context, field graphql.CollectedField, obj *types.Organization) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Organization_id(ctx, field) if err != nil { @@ -5969,6 +6089,44 @@ func (ec *executionContext) fieldContext_People_updatedAt(_ context.Context, fie return fc, nil } +func (ec *executionContext) _People_version(ctx context.Context, field graphql.CollectedField, obj *types.People) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_People_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_People_version(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "People", + 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) _PeopleConnection_edges(ctx context.Context, field graphql.CollectedField, obj *types.PeopleConnection) (ret graphql.Marshaler) { fc, err := ec.fieldContext_PeopleConnection_edges(ctx, field) if err != nil { @@ -6146,6 +6304,8 @@ func (ec *executionContext) fieldContext_PeopleEdge_node(_ context.Context, fiel return ec.fieldContext_People_createdAt(ctx, field) case "updatedAt": return ec.fieldContext_People_updatedAt(ctx, field) + case "version": + return ec.fieldContext_People_version(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type People", field.Name) }, @@ -9595,6 +9755,68 @@ func (ec *executionContext) unmarshalInputDeleteVendorInput(ctx context.Context, return it, nil } +func (ec *executionContext) unmarshalInputUpdatePeopleInput(ctx context.Context, obj any) (types.UpdatePeopleInput, error) { + var it types.UpdatePeopleInput + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"id", "expectedVersion", "fullName", "primaryEmailAddress", "additionalEmailAddresses", "kind"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "id": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) + data, err := ec.unmarshalNID2githubᚗcomᚋgetproboᚋproboᚋpkgᚋgidᚐGID(ctx, v) + if err != nil { + 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 "fullName": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("fullName")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.FullName = data + case "primaryEmailAddress": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("primaryEmailAddress")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.PrimaryEmailAddress = data + case "additionalEmailAddresses": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("additionalEmailAddresses")) + data, err := ec.unmarshalOString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + it.AdditionalEmailAddresses = data + case "kind": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("kind")) + data, err := ec.unmarshalOPeopleKind2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚐPeopleKind(ctx, v) + if err != nil { + return it, err + } + it.Kind = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputUpdateVendorInput(ctx context.Context, obj any) (types.UpdateVendorInput, error) { var it types.UpdateVendorInput asMap := map[string]any{} @@ -10675,16 +10897,23 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } - case "deletePeople": + case "createPeople": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { - return ec._Mutation_deletePeople(ctx, field) + return ec._Mutation_createPeople(ctx, field) }) if out.Values[i] == graphql.Null { out.Invalids++ } - case "createPeople": + case "updatePeople": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { - return ec._Mutation_createPeople(ctx, field) + return ec._Mutation_updatePeople(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "deletePeople": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_deletePeople(ctx, field) }) if out.Values[i] == graphql.Null { out.Invalids++ @@ -10958,6 +11187,11 @@ func (ec *executionContext) _People(ctx context.Context, sel ast.SelectionSet, o if out.Values[i] == graphql.Null { out.Invalids++ } + case "version": + out.Values[i] = ec._People_version(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -12911,6 +13145,11 @@ func (ec *executionContext) marshalNTaskStateTransitionEdge2ᚖgithubᚗcomᚋge return ec._TaskStateTransitionEdge(ctx, sel, v) } +func (ec *executionContext) unmarshalNUpdatePeopleInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐUpdatePeopleInput(ctx context.Context, v any) (types.UpdatePeopleInput, error) { + res, err := ec.unmarshalInputUpdatePeopleInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + 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) @@ -13372,6 +13611,34 @@ func (ec *executionContext) marshalOInt2ᚖint(ctx context.Context, sel ast.Sele return res } +func (ec *executionContext) unmarshalOPeopleKind2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚐPeopleKind(ctx context.Context, v any) (*coredata.PeopleKind, error) { + if v == nil { + return nil, nil + } + tmp, err := graphql.UnmarshalString(v) + res := unmarshalOPeopleKind2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚐPeopleKind[tmp] + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOPeopleKind2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚐPeopleKind(ctx context.Context, sel ast.SelectionSet, v *coredata.PeopleKind) graphql.Marshaler { + if v == nil { + return graphql.Null + } + res := graphql.MarshalString(marshalOPeopleKind2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚐPeopleKind[*v]) + return res +} + +var ( + unmarshalOPeopleKind2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚐPeopleKind = map[string]coredata.PeopleKind{ + "EMPLOYEE": coredata.PeopleKindEmployee, + "CONTRACTOR": coredata.PeopleKindContractor, + } + marshalOPeopleKind2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚐPeopleKind = map[coredata.PeopleKind]string{ + coredata.PeopleKindEmployee: "EMPLOYEE", + coredata.PeopleKindContractor: "CONTRACTOR", + } +) + func (ec *executionContext) unmarshalORiskTier2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚐRiskTier(ctx context.Context, v any) (*coredata.RiskTier, error) { if v == nil { return nil, nil diff --git a/pkg/api/console/v1/types/people.go b/pkg/api/console/v1/types/people.go index 94a0623fa..7da75591a 100644 --- a/pkg/api/console/v1/types/people.go +++ b/pkg/api/console/v1/types/people.go @@ -48,5 +48,6 @@ func NewPeople(p *coredata.People) *People { Kind: p.Kind, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, + Version: p.Version, } } diff --git a/pkg/api/console/v1/types/types.go b/pkg/api/console/v1/types/types.go index 7b056c9d6..e4455d848 100644 --- a/pkg/api/console/v1/types/types.go +++ b/pkg/api/console/v1/types/types.go @@ -177,6 +177,7 @@ type People struct { Kind coredata.PeopleKind `json:"kind"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` + Version int `json:"version"` } func (People) IsNode() {} @@ -238,6 +239,15 @@ type TaskStateTransitionEdge struct { Node *TaskStateTransition `json:"node"` } +type UpdatePeopleInput struct { + ID gid.GID `json:"id"` + ExpectedVersion int `json:"expectedVersion"` + FullName *string `json:"fullName,omitempty"` + PrimaryEmailAddress *string `json:"primaryEmailAddress,omitempty"` + AdditionalEmailAddresses []string `json:"additionalEmailAddresses,omitempty"` + Kind *coredata.PeopleKind `json:"kind,omitempty"` +} + type UpdateVendorInput struct { ID gid.GID `json:"id"` ExpectedVersion int `json:"expectedVersion"` diff --git a/pkg/api/console/v1/v1_resolver.go b/pkg/api/console/v1/v1_resolver.go index 9f3472822..a6b12c733 100644 --- a/pkg/api/console/v1/v1_resolver.go +++ b/pkg/api/console/v1/v1_resolver.go @@ -110,16 +110,6 @@ func (r *mutationResolver) DeleteVendor(ctx context.Context, input types.DeleteV return "", nil } -// DeletePeople is the resolver for the deletePeople field. -func (r *mutationResolver) DeletePeople(ctx context.Context, input types.DeletePeopleInput) (string, error) { - err := r.svc.DeletePeople(ctx, input.PeopleID) - if err != nil { - return "", fmt.Errorf("cannot delete people: %w", err) - } - - return "", nil -} - // CreatePeople is the resolver for the createPeople field. func (r *mutationResolver) CreatePeople(ctx context.Context, input types.CreatePeopleInput) (*types.People, error) { people, err := r.svc.CreatePeople(ctx, probo.CreatePeopleRequest{ @@ -137,6 +127,33 @@ func (r *mutationResolver) CreatePeople(ctx context.Context, input types.CreateP return types.NewPeople(people), nil } +// UpdatePeople is the resolver for the updatePeople field. +func (r *mutationResolver) UpdatePeople(ctx context.Context, input types.UpdatePeopleInput) (*types.People, error) { + people, err := r.svc.UpdatePeople(ctx, probo.UpdatePeopleRequest{ + ID: input.ID, + ExpectedVersion: input.ExpectedVersion, + FullName: input.FullName, + PrimaryEmailAddress: input.PrimaryEmailAddress, + AdditionalEmailAddresses: &input.AdditionalEmailAddresses, + Kind: input.Kind, + }) + if err != nil { + return nil, fmt.Errorf("cannot update people: %w", err) + } + + return types.NewPeople(people), nil +} + +// DeletePeople is the resolver for the deletePeople field. +func (r *mutationResolver) DeletePeople(ctx context.Context, input types.DeletePeopleInput) (string, error) { + err := r.svc.DeletePeople(ctx, input.PeopleID) + if err != nil { + return "", fmt.Errorf("cannot delete people: %w", err) + } + + return "", nil +} + // Frameworks is the resolver for the frameworks field. func (r *organizationResolver) Frameworks(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.FrameworkConnection, error) { cursor := types.NewCursor(first, after, last, before) diff --git a/pkg/probo/coredata/migrations/20250218T171000Z.sql b/pkg/probo/coredata/migrations/20250218T171000Z.sql new file mode 100644 index 000000000..236195d03 --- /dev/null +++ b/pkg/probo/coredata/migrations/20250218T171000Z.sql @@ -0,0 +1 @@ +ALTER TABLE peoples ADD COLUMN version INTEGER NOT NULL DEFAULT 1; \ No newline at end of file diff --git a/pkg/probo/coredata/people.go b/pkg/probo/coredata/people.go index 7301fa646..291627738 100644 --- a/pkg/probo/coredata/people.go +++ b/pkg/probo/coredata/people.go @@ -16,6 +16,7 @@ package coredata import ( "context" + "errors" "fmt" "maps" "time" @@ -36,9 +37,18 @@ type ( AdditionalEmailAddresses []string CreatedAt time.Time UpdatedAt time.Time + Version int } Peoples []*People + + UpdatePeopleParams struct { + ExpectedVersion int + FullName *string + PrimaryEmailAddress *string + AdditionalEmailAddresses *[]string + Kind *PeopleKind + } ) func (p People) CursorKey() page.CursorKey { @@ -55,6 +65,7 @@ func (p *People) scan(r pgx.Row) error { &p.AdditionalEmailAddresses, &p.CreatedAt, &p.UpdatedAt, + &p.Version, ) } @@ -73,7 +84,8 @@ SELECT primary_email_address, additional_email_addresses, created_at, - updated_at + updated_at, + version FROM peoples WHERE @@ -113,7 +125,8 @@ INSERT INTO primary_email_address, additional_email_addresses, created_at, - updated_at + updated_at, + version ) VALUES ( @people_id, @@ -123,7 +136,8 @@ VALUES ( @primary_email_address, @additional_email_addresses, @created_at, - @updated_at + @updated_at, + @version ) ` @@ -136,6 +150,7 @@ VALUES ( "additional_email_addresses": p.AdditionalEmailAddresses, "created_at": p.CreatedAt, "updated_at": p.UpdatedAt, + "version": p.Version, } _, err := conn.Exec(ctx, q, args) return err @@ -175,7 +190,8 @@ SELECT primary_email_address, additional_email_addresses, created_at, - updated_at + updated_at, + version FROM peoples WHERE @@ -214,3 +230,68 @@ WHERE return nil } + +func (p *People) Update( + ctx context.Context, + conn pg.Conn, + scope *Scope, + params UpdatePeopleParams, +) error { + q := ` +UPDATE peoples SET + full_name = COALESCE(@full_name, full_name), + primary_email_address = COALESCE(@primary_email_address, primary_email_address), + additional_email_addresses = COALESCE(@additional_email_addresses, additional_email_addresses), + kind = COALESCE(@kind, kind), + updated_at = @updated_at, + version = version + 1 +WHERE %s + AND id = @people_id + AND version = @expected_version +RETURNING + id, + organization_id, + kind, + full_name, + primary_email_address, + additional_email_addresses, + created_at, + updated_at, + version +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.NamedArgs{ + "people_id": p.ID, + "expected_version": params.ExpectedVersion, + "updated_at": time.Now(), + } + + if params.FullName != nil { + args["full_name"] = *params.FullName + } + if params.PrimaryEmailAddress != nil { + args["primary_email_address"] = *params.PrimaryEmailAddress + } + if params.AdditionalEmailAddresses != nil { + args["additional_email_addresses"] = *params.AdditionalEmailAddresses + } + if params.Kind != nil { + args["kind"] = *params.Kind + } + + maps.Copy(args, scope.SQLArguments()) + + r := conn.QueryRow(ctx, q, args) + + p2 := People{} + if err := p2.scan(r); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrConcurrentModification + } + return err + } + + *p = p2 + return nil +} diff --git a/pkg/probo/update_people.go b/pkg/probo/update_people.go new file mode 100644 index 000000000..7009f1c9e --- /dev/null +++ b/pkg/probo/update_people.go @@ -0,0 +1,44 @@ +package probo + +import ( + "context" + + "github.com/getprobo/probo/pkg/gid" + "github.com/getprobo/probo/pkg/probo/coredata" + "go.gearno.de/kit/pg" +) + +type UpdatePeopleRequest struct { + ID gid.GID + ExpectedVersion int + Kind *coredata.PeopleKind + FullName *string + PrimaryEmailAddress *string + AdditionalEmailAddresses *[]string +} + +func (s Service) UpdatePeople( + ctx context.Context, + req UpdatePeopleRequest, +) (*coredata.People, error) { + params := coredata.UpdatePeopleParams{ + ExpectedVersion: req.ExpectedVersion, + Kind: req.Kind, + FullName: req.FullName, + PrimaryEmailAddress: req.PrimaryEmailAddress, + AdditionalEmailAddresses: req.AdditionalEmailAddresses, + } + + people := &coredata.People{ID: req.ID} + + err := s.pg.WithTx( + ctx, + func(conn pg.Conn) error { + return people.Update(ctx, conn, s.scope, params) + }) + if err != nil { + return nil, err + } + + return people, nil +}