diff --git a/pkg/graph/generated/generated.go b/pkg/graph/generated/generated.go index 6881c4b812..47f7bcf75c 100644 --- a/pkg/graph/generated/generated.go +++ b/pkg/graph/generated/generated.go @@ -574,6 +574,7 @@ type ComplexityRoot struct { CreatePlanDocumentSolutionLinks func(childComplexity int, solutionID uuid.UUID, documentIDs []uuid.UUID) int CreatePlanTdl func(childComplexity int, input model.PlanTDLCreateInput) int CreateStandardCategories func(childComplexity int, modelPlanID uuid.UUID) int + DeleteMTOCategory func(childComplexity int, id uuid.UUID) int DeleteMTOMilestone func(childComplexity int, id uuid.UUID) int DeleteOperationalSolutionSubtask func(childComplexity int, id uuid.UUID) int DeletePlanCollaborator func(childComplexity int, id uuid.UUID) int @@ -2422,6 +2423,7 @@ type MutationResolver interface { RenameMTOCategory(ctx context.Context, id uuid.UUID, name string) (*models.MTOCategory, error) ReorderMTOCategory(ctx context.Context, id uuid.UUID, newOrder *int, parentID *uuid.UUID) (*models.MTOCategory, error) CreateStandardCategories(ctx context.Context, modelPlanID uuid.UUID) (bool, error) + DeleteMTOCategory(ctx context.Context, id uuid.UUID) (bool, error) CreateMTOMilestoneCustom(ctx context.Context, modelPlanID uuid.UUID, name string, mtoCategoryID *uuid.UUID) (*models.MTOMilestone, error) CreateMTOMilestoneCommon(ctx context.Context, modelPlanID uuid.UUID, commonMilestoneKey models.MTOCommonMilestoneKey, commonSolutions []models.MTOCommonSolutionKey) (*models.MTOMilestone, error) UpdateMTOMilestone(ctx context.Context, id uuid.UUID, changes map[string]interface{}) (*models.MTOMilestone, error) @@ -5170,6 +5172,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.CreateStandardCategories(childComplexity, args["modelPlanID"].(uuid.UUID)), true + case "Mutation.deleteMTOCategory": + if e.complexity.Mutation.DeleteMTOCategory == nil { + break + } + + args, err := ec.field_Mutation_deleteMTOCategory_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.DeleteMTOCategory(childComplexity, args["id"].(uuid.UUID)), true + case "Mutation.deleteMTOMilestone": if e.complexity.Mutation.DeleteMTOMilestone == nil { break @@ -16961,7 +16975,7 @@ type MTOCategories { extend type Mutation { """ - Allows you to create an MTOCategory or Subcategory if you provide a parent ID. + Allows you to create an MTOCategory or Subcategory if you provide a parent ID. Note, the parent must belong to the same model plan, or this will return an error """ createMTOCategory(modelPlanID: UUID!, name: String!, parentID: UUID): MTOCategory! @@ -16988,6 +17002,14 @@ extend type Mutation { """ createStandardCategories(modelPlanID: UUID!): Boolean! @hasRole(role: MINT_USER) + + """ + Deletes an MTO category. If the category has subcategories, it will delete them as well. + If the target category is a subcategory, it will only delete the subcategory and redirect + references to the parent category. + """ + deleteMTOCategory(id: UUID!): Boolean! + @hasRole(role: MINT_USER) }`, BuiltIn: false}, {Name: "../schema/types/mto_category_translation.graphql", Input: ` """ @@ -21787,6 +21809,21 @@ func (ec *executionContext) field_Mutation_createStandardCategories_args(ctx con return args, nil } +func (ec *executionContext) field_Mutation_deleteMTOCategory_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 uuid.UUID + if tmp, ok := rawArgs["id"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) + arg0, err = ec.unmarshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, tmp) + if err != nil { + return nil, err + } + } + args["id"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_deleteMTOMilestone_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -41314,6 +41351,85 @@ func (ec *executionContext) fieldContext_Mutation_createStandardCategories(ctx c return fc, nil } +func (ec *executionContext) _Mutation_deleteMTOCategory(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_deleteMTOCategory(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + directive0 := func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().DeleteMTOCategory(rctx, fc.Args["id"].(uuid.UUID)) + } + directive1 := func(ctx context.Context) (interface{}, error) { + role, err := ec.unmarshalNRole2githubᚗcomᚋcmsᚑenterpriseᚋmintᚑappᚋpkgᚋgraphᚋmodelᚐRole(ctx, "MINT_USER") + if err != nil { + return nil, err + } + if ec.directives.HasRole == nil { + return nil, errors.New("directive hasRole is not implemented") + } + return ec.directives.HasRole(ctx, nil, directive0, role) + } + + tmp, err := directive1(rctx) + if err != nil { + return nil, graphql.ErrorOnPath(ctx, err) + } + if tmp == nil { + return nil, nil + } + if data, ok := tmp.(bool); ok { + return data, nil + } + return nil, fmt.Errorf(`unexpected type %T from directive, should be bool`, tmp) + }) + 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.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_deleteMTOCategory(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 Boolean does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_deleteMTOCategory_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_createMTOMilestoneCustom(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_createMTOMilestoneCustom(ctx, field) if err != nil { @@ -139607,6 +139723,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "deleteMTOCategory": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_deleteMTOCategory(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "createMTOMilestoneCustom": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_createMTOMilestoneCustom(ctx, field) diff --git a/pkg/graph/resolvers/mto_category.go b/pkg/graph/resolvers/mto_category.go index 2b7c1bbffd..ddfba66a87 100644 --- a/pkg/graph/resolvers/mto_category.go +++ b/pkg/graph/resolvers/mto_category.go @@ -36,6 +36,29 @@ func MTOCategoryCreate(ctx context.Context, logger *zap.Logger, principal authen return storage.MTOCategoryCreate(store, logger, category) } +// MTOCategoryDelete removes an MTOCategory or SubCategory +func MTOCategoryDelete(ctx context.Context, logger *zap.Logger, principal authentication.Principal, store *storage.Store, + id uuid.UUID, +) error { + principalAccount := principal.Account() + if principalAccount == nil { + return fmt.Errorf("principal doesn't have an account, username %s", principal.String()) + } + + existing, err := storage.MTOCategoryGetByID(store, logger, id) + if err != nil { + return fmt.Errorf("unable to delete MTO category. Err %w", err) + } + + // Just check access, don't apply changes here + err = BaseStructPreDelete(logger, existing, principal, store, true) + if err != nil { + return fmt.Errorf("unable to delete MTO category. Err %w", err) + } + + return storage.MTOCategoryDelete(store, logger, id) +} + // MTOCategoryRename updates the name of MTOCategory or SubCategory func MTOCategoryRename(ctx context.Context, logger *zap.Logger, principal authentication.Principal, store *storage.Store, id uuid.UUID, diff --git a/pkg/graph/resolvers/mto_category.resolvers.go b/pkg/graph/resolvers/mto_category.resolvers.go index 8b7dc6d8b3..f3dbfd0065 100644 --- a/pkg/graph/resolvers/mto_category.resolvers.go +++ b/pkg/graph/resolvers/mto_category.resolvers.go @@ -65,6 +65,16 @@ func (r *mutationResolver) CreateStandardCategories(ctx context.Context, modelPl return MTOCreateStandardCategories(ctx, logger, principal, r.store, modelPlanID) } +// DeleteMTOCategory is the resolver for the deleteMTOCategory field. +func (r *mutationResolver) DeleteMTOCategory(ctx context.Context, id uuid.UUID) (bool, error) { + principal := appcontext.Principal(ctx) + logger := appcontext.ZLogger(ctx) + err := MTOCategoryDelete(ctx, logger, principal, r.store, id) + + success := err == nil + return success, err +} + // MTOCategory returns generated.MTOCategoryResolver implementation. func (r *Resolver) MTOCategory() generated.MTOCategoryResolver { return &mTOCategoryResolver{r} } diff --git a/pkg/graph/schema/types/mto_category.graphql b/pkg/graph/schema/types/mto_category.graphql index 2adc0806fe..f9adf3b70d 100644 --- a/pkg/graph/schema/types/mto_category.graphql +++ b/pkg/graph/schema/types/mto_category.graphql @@ -33,7 +33,7 @@ type MTOCategories { extend type Mutation { """ - Allows you to create an MTOCategory or Subcategory if you provide a parent ID. + Allows you to create an MTOCategory or Subcategory if you provide a parent ID. Note, the parent must belong to the same model plan, or this will return an error """ createMTOCategory(modelPlanID: UUID!, name: String!, parentID: UUID): MTOCategory! @@ -60,4 +60,12 @@ extend type Mutation { """ createStandardCategories(modelPlanID: UUID!): Boolean! @hasRole(role: MINT_USER) + + """ + Deletes an MTO category. If the category has subcategories, it will delete them as well. + If the target category is a subcategory, it will only delete the subcategory and redirect + references to the parent category. + """ + deleteMTOCategory(id: UUID!): Boolean! + @hasRole(role: MINT_USER) } \ No newline at end of file diff --git a/pkg/sqlqueries/SQL/mto/category/delete.sql b/pkg/sqlqueries/SQL/mto/category/delete.sql new file mode 100644 index 0000000000..0e1c92e2fd --- /dev/null +++ b/pkg/sqlqueries/SQL/mto/category/delete.sql @@ -0,0 +1,105 @@ +-- TODO: Move function creation to a migration, keeping it here for testing purposes + +/* + This SQL script sets up a trigger and a supporting function to handle custom cascading + deletion behavior for categories in the `mto_category` table, per the application requirements. + + Updated Assumption (per user's note): + ------------------------------------- + Instead of assigning milestones to a designated "Uncategorized" category with a known UUID, + we will set their `mto_category_id` to NULL (a "nil" UUID), indicating they are now uncategorized. + + Requirements: + - Deleting a subcategory: + * All milestones referencing that subcategory should be reassigned to its parent category. + - Deleting a top-level category: + * All milestones referencing the deleted top-level category or its direct subcategories + should have `mto_category_id` set to NULL, indicating they are now uncategorized. + * All direct subcategories of the deleted category should be deleted. + + Why the "CASCADE" Keyword Isn't Used: + ------------------------------------- + The standard ON DELETE CASCADE simply removes dependent rows. Here, we need to reassign + references rather than just delete them. Since this behavior is more complex, we implement + custom logic in a trigger and a PL/pgSQL function rather than relying on cascade deletes. + + Usage: + - Once this script is applied, a `DELETE FROM mto_category WHERE id = 'some-category-uuid';` + will trigger the logic to reassign milestones and remove subcategories as defined. + + Assumptions: + - When deleting a top-level category, milestones are "uncategorized" by setting their + `mto_category_id` to NULL. + - For subcategories, milestones are moved up to the parent category. + - This handles one level of subcategories. If a deeper hierarchy is required, + the logic should be extended to recursively handle all descendants. +*/ + +-- Drop existing trigger and function to allow clean re-creation +DROP TRIGGER IF EXISTS mto_category_before_delete ON mto_category; +DROP FUNCTION IF EXISTS rebalance_milestones_before_category_delete(); + +-- Create the trigger function that implements the custom cascading logic +CREATE OR REPLACE FUNCTION rebalance_milestones_before_category_delete() +RETURNS TRIGGER AS $$ +DECLARE + cat_model_plan_id UUID; + is_top_level BOOLEAN; +BEGIN + -- Determine if the category is top-level or a subcategory + SELECT model_plan_id, (parent_id IS NULL) INTO cat_model_plan_id, is_top_level + FROM mto_category + WHERE id = OLD.id; + + IF is_top_level THEN + -- TOP-LEVEL CATEGORY DELETION LOGIC: + -- Reassign all milestones referencing this category or its direct subcategories + -- to NULL, marking them as uncategorized. + + UPDATE mto_milestone + SET mto_category_id = NULL + WHERE mto_category_id IN ( + SELECT id FROM mto_category + WHERE parent_id = OLD.id + UNION ALL + SELECT OLD.id + ); + + -- Delete all direct subcategories of this category + DELETE FROM mto_category + WHERE parent_id = OLD.id; + + -- The category itself (OLD.id) will be deleted by the original DELETE statement + ELSE + -- SUBCATEGORY DELETION LOGIC: + -- Move all milestones from this subcategory to its parent category + UPDATE mto_milestone + SET mto_category_id = OLD.parent_id + WHERE mto_category_id = OLD.id; + + -- The subcategory (OLD.id) will be deleted by the original DELETE statement + END IF; + + RETURN OLD; -- Allow the DELETE to proceed after adjustments +END; +$$ LANGUAGE plpgsql; + +-- Create the trigger to invoke the above function before any category deletion +CREATE TRIGGER mto_category_before_delete +BEFORE DELETE ON mto_category +FOR EACH ROW +EXECUTE FUNCTION rebalance_milestones_before_category_delete(); + + +DELETE FROM mto_category +WHERE id = :id +RETURNING + id, + name, + parent_id, + model_plan_id, + position, + created_by, + created_dts, + modified_by, + modified_dts; diff --git a/pkg/sqlqueries/mto_category.go b/pkg/sqlqueries/mto_category.go index ddb7bac3b6..e018b6ddee 100644 --- a/pkg/sqlqueries/mto_category.go +++ b/pkg/sqlqueries/mto_category.go @@ -5,6 +5,9 @@ import _ "embed" //go:embed SQL/mto/category/create.sql var mtoCategoryCreateSQL string +//go:embed SQL/mto/category/delete.sql +var mtoCategoryDeleteSQL string + //go:embed SQL/mto/category/create_allow_conflicts.sql var mtoCategoryCreateAllowConflictsSQL string @@ -28,6 +31,7 @@ var mtoCategoryGetByParentIDLoaderSQL string type mtoCategoryScripts struct { Create string + Delete string CreateAllowConflicts string Update string GetByID string @@ -44,6 +48,7 @@ type mtoCategoryScripts struct { // MTOCategory houses all the sql for getting data for mto category from the database var MTOCategory = mtoCategoryScripts{ Create: mtoCategoryCreateSQL, + Delete: mtoCategoryDeleteSQL, CreateAllowConflicts: mtoCategoryCreateAllowConflictsSQL, Update: mtoCategoryUpdateSQL, GetByID: mtoCategoryGetByIDSQL, diff --git a/pkg/storage/mto_category.go b/pkg/storage/mto_category.go index fba3675ed5..669a6618ec 100644 --- a/pkg/storage/mto_category.go +++ b/pkg/storage/mto_category.go @@ -82,6 +82,17 @@ func MTOCategoryCreate(np sqlutils.NamedPreparer, _ *zap.Logger, MTOCategory *mo return returned, nil } +// MTOCategoryDelete deletes an existing MTOCategory from the database +func MTOCategoryDelete(np sqlutils.NamedPreparer, _ *zap.Logger, id uuid.UUID) error { + arg := map[string]interface{}{"id": id} + + _, procErr := sqlutils.GetProcedure[models.MTOCategory](np, sqlqueries.MTOCategory.Delete, arg) + if procErr != nil { + return fmt.Errorf("issue deleting MTOCategory object: %w", procErr) + } + return nil +} + // MTOCategoryCreateAllowConflicts creates a new MTOCategory in the database, but in the case of a conflict, instead just // returns the conflicting row (and doesn't return an error) // In all other ways, it is effectively equivalent to MTOCategoryCreate diff --git a/src/gql/generated/graphql.ts b/src/gql/generated/graphql.ts index dd27f29862..b37a299291 100644 --- a/src/gql/generated/graphql.ts +++ b/src/gql/generated/graphql.ts @@ -1260,6 +1260,12 @@ export type Mutation = { * still create the others */ createStandardCategories: Scalars['Boolean']['output']; + /** + * Deletes an MTO category. If the category has subcategories, it will delete them as well. + * If the target category is a subcategory, it will only delete the subcategory and redirect + * references to the parent category. + */ + deleteMTOCategory: Scalars['Boolean']['output']; deleteMTOMilestone: Scalars['Boolean']['output']; deleteOperationalSolutionSubtask: Scalars['Int']['output']; deletePlanCR: PlanCr; @@ -1446,6 +1452,12 @@ export type MutationCreateStandardCategoriesArgs = { }; +/** Mutations definition for the schema */ +export type MutationDeleteMtoCategoryArgs = { + id: Scalars['UUID']['input']; +}; + + /** Mutations definition for the schema */ export type MutationDeleteMtoMilestoneArgs = { id: Scalars['UUID']['input'];