From 9caedfb4fcd96629df77773e13c7e0425ce80bf2 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Tue, 4 Nov 2025 03:19:02 +0800 Subject: [PATCH 1/6] feat(schema): add visible_scope to Question --- ent/schema/question.go | 1 + 1 file changed, 1 insertion(+) diff --git a/ent/schema/question.go b/ent/schema/question.go index b9f58e5..1cbf46c 100644 --- a/ent/schema/question.go +++ b/ent/schema/question.go @@ -32,6 +32,7 @@ func (Question) Fields() []ent.Field { field.Text("reference_answer").Annotations( entgql.Directives(ScopeDirective("answer:read")), ).Comment("Reference answer"), + field.String("visible_scope").Optional().Comment("Only the users with this scope set can see the question. Empty means visible to everyone."), } } From 703549e9f17d6e46d16a49c6682a4a63acf97dc1 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Tue, 4 Nov 2025 03:20:01 +0800 Subject: [PATCH 2/6] chore(ent): regenerate schema --- ent/gql_collection.go | 5 +++ ent/gql_mutation_input.go | 12 ++++++ ent/gql_where_input.go | 62 ++++++++++++++++++++++++++++++ ent/internal/schema.go | 2 +- ent/migrate/schema.go | 3 +- ent/mutation.go | 80 ++++++++++++++++++++++++++++++++++++++- ent/question.go | 13 ++++++- ent/question/question.go | 8 ++++ ent/question/where.go | 80 +++++++++++++++++++++++++++++++++++++++ ent/question_create.go | 18 +++++++++ ent/question_update.go | 52 +++++++++++++++++++++++++ graph/ent.graphqls | 31 +++++++++++++++ 12 files changed, 361 insertions(+), 5 deletions(-) diff --git a/ent/gql_collection.go b/ent/gql_collection.go index 6b9e801..eee89ac 100644 --- a/ent/gql_collection.go +++ b/ent/gql_collection.go @@ -590,6 +590,11 @@ func (_q *QuestionQuery) collectField(ctx context.Context, oneNode bool, opCtx * selectedFields = append(selectedFields, question.FieldReferenceAnswer) fieldSeen[question.FieldReferenceAnswer] = struct{}{} } + case "visibleScope": + if _, ok := fieldSeen[question.FieldVisibleScope]; !ok { + selectedFields = append(selectedFields, question.FieldVisibleScope) + fieldSeen[question.FieldVisibleScope] = struct{}{} + } case "id": case "__typename": default: diff --git a/ent/gql_mutation_input.go b/ent/gql_mutation_input.go index b364120..158b918 100644 --- a/ent/gql_mutation_input.go +++ b/ent/gql_mutation_input.go @@ -187,6 +187,7 @@ type CreateQuestionInput struct { Title string Description string ReferenceAnswer string + VisibleScope *string DatabaseID int SubmissionIDs []int } @@ -200,6 +201,9 @@ func (i *CreateQuestionInput) Mutate(m *QuestionMutation) { m.SetTitle(i.Title) m.SetDescription(i.Description) m.SetReferenceAnswer(i.ReferenceAnswer) + if v := i.VisibleScope; v != nil { + m.SetVisibleScope(*v) + } m.SetDatabaseID(i.DatabaseID) if v := i.SubmissionIDs; len(v) > 0 { m.AddSubmissionIDs(v...) @@ -219,6 +223,8 @@ type UpdateQuestionInput struct { Title *string Description *string ReferenceAnswer *string + ClearVisibleScope bool + VisibleScope *string DatabaseID *int ClearSubmissions bool AddSubmissionIDs []int @@ -242,6 +248,12 @@ func (i *UpdateQuestionInput) Mutate(m *QuestionMutation) { if v := i.ReferenceAnswer; v != nil { m.SetReferenceAnswer(*v) } + if i.ClearVisibleScope { + m.ClearVisibleScope() + } + if v := i.VisibleScope; v != nil { + m.SetVisibleScope(*v) + } if v := i.DatabaseID; v != nil { m.SetDatabaseID(*v) } diff --git a/ent/gql_where_input.go b/ent/gql_where_input.go index 9abc121..cd61975 100644 --- a/ent/gql_where_input.go +++ b/ent/gql_where_input.go @@ -1371,6 +1371,23 @@ type QuestionWhereInput struct { ReferenceAnswerEqualFold *string `json:"referenceAnswerEqualFold,omitempty"` ReferenceAnswerContainsFold *string `json:"referenceAnswerContainsFold,omitempty"` + // "visible_scope" field predicates. + VisibleScope *string `json:"visibleScope,omitempty"` + VisibleScopeNEQ *string `json:"visibleScopeNEQ,omitempty"` + VisibleScopeIn []string `json:"visibleScopeIn,omitempty"` + VisibleScopeNotIn []string `json:"visibleScopeNotIn,omitempty"` + VisibleScopeGT *string `json:"visibleScopeGT,omitempty"` + VisibleScopeGTE *string `json:"visibleScopeGTE,omitempty"` + VisibleScopeLT *string `json:"visibleScopeLT,omitempty"` + VisibleScopeLTE *string `json:"visibleScopeLTE,omitempty"` + VisibleScopeContains *string `json:"visibleScopeContains,omitempty"` + VisibleScopeHasPrefix *string `json:"visibleScopeHasPrefix,omitempty"` + VisibleScopeHasSuffix *string `json:"visibleScopeHasSuffix,omitempty"` + VisibleScopeIsNil bool `json:"visibleScopeIsNil,omitempty"` + VisibleScopeNotNil bool `json:"visibleScopeNotNil,omitempty"` + VisibleScopeEqualFold *string `json:"visibleScopeEqualFold,omitempty"` + VisibleScopeContainsFold *string `json:"visibleScopeContainsFold,omitempty"` + // "database" edge predicates. HasDatabase *bool `json:"hasDatabase,omitempty"` HasDatabaseWith []*DatabaseWhereInput `json:"hasDatabaseWith,omitempty"` @@ -1643,6 +1660,51 @@ func (i *QuestionWhereInput) P() (predicate.Question, error) { if i.ReferenceAnswerContainsFold != nil { predicates = append(predicates, question.ReferenceAnswerContainsFold(*i.ReferenceAnswerContainsFold)) } + if i.VisibleScope != nil { + predicates = append(predicates, question.VisibleScopeEQ(*i.VisibleScope)) + } + if i.VisibleScopeNEQ != nil { + predicates = append(predicates, question.VisibleScopeNEQ(*i.VisibleScopeNEQ)) + } + if len(i.VisibleScopeIn) > 0 { + predicates = append(predicates, question.VisibleScopeIn(i.VisibleScopeIn...)) + } + if len(i.VisibleScopeNotIn) > 0 { + predicates = append(predicates, question.VisibleScopeNotIn(i.VisibleScopeNotIn...)) + } + if i.VisibleScopeGT != nil { + predicates = append(predicates, question.VisibleScopeGT(*i.VisibleScopeGT)) + } + if i.VisibleScopeGTE != nil { + predicates = append(predicates, question.VisibleScopeGTE(*i.VisibleScopeGTE)) + } + if i.VisibleScopeLT != nil { + predicates = append(predicates, question.VisibleScopeLT(*i.VisibleScopeLT)) + } + if i.VisibleScopeLTE != nil { + predicates = append(predicates, question.VisibleScopeLTE(*i.VisibleScopeLTE)) + } + if i.VisibleScopeContains != nil { + predicates = append(predicates, question.VisibleScopeContains(*i.VisibleScopeContains)) + } + if i.VisibleScopeHasPrefix != nil { + predicates = append(predicates, question.VisibleScopeHasPrefix(*i.VisibleScopeHasPrefix)) + } + if i.VisibleScopeHasSuffix != nil { + predicates = append(predicates, question.VisibleScopeHasSuffix(*i.VisibleScopeHasSuffix)) + } + if i.VisibleScopeIsNil { + predicates = append(predicates, question.VisibleScopeIsNil()) + } + if i.VisibleScopeNotNil { + predicates = append(predicates, question.VisibleScopeNotNil()) + } + if i.VisibleScopeEqualFold != nil { + predicates = append(predicates, question.VisibleScopeEqualFold(*i.VisibleScopeEqualFold)) + } + if i.VisibleScopeContainsFold != nil { + predicates = append(predicates, question.VisibleScopeContainsFold(*i.VisibleScopeContainsFold)) + } if i.HasDatabase != nil { p := question.HasDatabase() diff --git a/ent/internal/schema.go b/ent/internal/schema.go index ea40633..7d42d30 100644 --- a/ent/internal/schema.go +++ b/ent/internal/schema.go @@ -6,4 +6,4 @@ // Package internal holds a loadable version of the latest schema. package internal -const Schema = "{\"Schema\":\"github.com/database-playground/backend-v2/ent/schema\",\"Package\":\"github.com/database-playground/backend-v2/ent\",\"Schemas\":[{\"name\":\"Database\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"questions\",\"type\":\"Question\"}],\"fields\":[{\"name\":\"slug\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"immutable\":true,\"validators\":1,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"schema\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"validators\":1,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"SQL schema\"},{\"name\":\"relation_figure\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"validators\":1,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"relation figure\"}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"scope\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":3,\"Raw\":\"database:read\",\"VariableDefinition\":null}}],\"name\":\"scope\"}]}},\"EntSQL\":{\"increment_start\":12884901888}}},{\"name\":\"Event\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"ref_name\":\"events\",\"unique\":true,\"inverse\":true,\"required\":true}],\"fields\":[{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"type\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"validators\":1,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"triggered_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"TRIGGERED_AT\"}}},{\"name\":\"payload\",\"type\":{\"Type\":3,\"Ident\":\"map[string]interface {}\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"map[string]interface {}\",\"Kind\":21,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}}],\"indexes\":[{\"fields\":[\"type\"]},{\"fields\":[\"type\",\"user_id\"]}],\"annotations\":{\"EntGQL\":{\"QueryField\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"scope\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":3,\"Raw\":\"user:read\",\"VariableDefinition\":null}}],\"name\":\"scope\"}]},\"RelayConnection\":true},\"EntSQL\":{\"increment_start\":21474836480}}},{\"name\":\"Group\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"scope_sets\",\"type\":\"ScopeSet\"}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"validators\":1,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"scope\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":3,\"Raw\":\"group:read\",\"VariableDefinition\":null}}],\"name\":\"scope\"}]}},\"EntSQL\":{\"increment_start\":4294967296}}},{\"name\":\"Point\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"ref_name\":\"points\",\"unique\":true,\"inverse\":true,\"required\":true}],\"fields\":[{\"name\":\"points\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"granted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"GRANTED_AT\"}}},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true}],\"QueryField\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"scope\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":3,\"Raw\":\"user:read\",\"VariableDefinition\":null}}],\"name\":\"scope\"}]},\"RelayConnection\":true},\"EntSQL\":{\"increment_start\":25769803776}}},{\"name\":\"Question\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"database\",\"type\":\"Database\",\"ref_name\":\"questions\",\"unique\":true,\"inverse\":true,\"required\":true},{\"name\":\"submissions\",\"type\":\"Submission\",\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"scope\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":3,\"Raw\":\"submission:read\",\"VariableDefinition\":null}}],\"name\":\"scope\"}],\"RelayConnection\":true}}}],\"fields\":[{\"name\":\"category\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"validators\":1,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CATEGORY\"}},\"comment\":\"Question category, e.g. 'query'\"},{\"name\":\"difficulty\",\"type\":{\"Type\":6,\"Ident\":\"question.Difficulty\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"Unspecified\",\"V\":\"unspecified\"},{\"N\":\"Easy\",\"V\":\"easy\"},{\"N\":\"Medium\",\"V\":\"medium\"},{\"N\":\"Hard\",\"V\":\"hard\"}],\"default\":true,\"default_value\":\"medium\",\"default_kind\":24,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"DIFFICULTY\"}},\"comment\":\"Question difficulty, e.g. 'easy'\"},{\"name\":\"title\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Question title\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Question stem\"},{\"name\":\"reference_answer\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"scope\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":3,\"Raw\":\"answer:read\",\"VariableDefinition\":null}}],\"name\":\"scope\"}]}},\"comment\":\"Reference answer\"}],\"indexes\":[{\"fields\":[\"category\"]},{\"fields\":[\"difficulty\"]}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"scope\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":3,\"Raw\":\"question:read\",\"VariableDefinition\":null}}],\"name\":\"scope\"}]},\"RelayConnection\":true},\"EntSQL\":{\"increment_start\":17179869184}}},{\"name\":\"ScopeSet\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"groups\",\"type\":\"Group\",\"ref_name\":\"scope_sets\",\"inverse\":true}],\"fields\":[{\"name\":\"slug\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"immutable\":true,\"validators\":1,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"scopes\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"scope\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":3,\"Raw\":\"scopeset:read\",\"VariableDefinition\":null}}],\"name\":\"scope\"}]}},\"EntSQL\":{\"increment_start\":8589934592}}},{\"name\":\"Submission\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"question\",\"type\":\"Question\",\"ref_name\":\"submissions\",\"unique\":true,\"inverse\":true,\"required\":true},{\"name\":\"user\",\"type\":\"User\",\"ref_name\":\"submissions\",\"unique\":true,\"inverse\":true,\"required\":true}],\"fields\":[{\"name\":\"submitted_code\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"validators\":1,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"submission.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"pending\",\"V\":\"pending\"},{\"N\":\"success\",\"V\":\"success\"},{\"N\":\"failed\",\"V\":\"failed\"}],\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"query_result\",\"type\":{\"Type\":3,\"Ident\":\"*models.UserSQLExecutionResult\",\"PkgPath\":\"github.com/database-playground/backend-v2/models\",\"PkgName\":\"models\",\"Nillable\":true,\"RType\":{\"Name\":\"UserSQLExecutionResult\",\"Ident\":\"models.UserSQLExecutionResult\",\"Kind\":22,\"PkgPath\":\"github.com/database-playground/backend-v2/models\",\"Methods\":{}}},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"error\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"submitted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"SUBMITTED_AT\"}}}],\"annotations\":{\"EntGQL\":{\"QueryField\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"scope\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":3,\"Raw\":\"submissions:read\",\"VariableDefinition\":null}}],\"name\":\"scope\"}]},\"RelayConnection\":true},\"EntSQL\":{\"increment_start\":30064771072}}},{\"name\":\"User\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"group\",\"type\":\"Group\",\"unique\":true,\"required\":true},{\"name\":\"points\",\"type\":\"Point\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"events\",\"type\":\"Event\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"submissions\",\"type\":\"Submission\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"validators\":1,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"email\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"immutable\":true,\"validators\":1,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"EMAIL\"}}},{\"name\":\"avatar\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"scope\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":3,\"Raw\":\"user:read\",\"VariableDefinition\":null}}],\"name\":\"scope\"}]},\"RelayConnection\":true},\"EntSQL\":{\"increment_start\":0}}}],\"Features\":[\"namedges\",\"intercept\",\"schema/snapshot\",\"sql/globalid\"]}" +const Schema = "{\"Schema\":\"github.com/database-playground/backend-v2/ent/schema\",\"Package\":\"github.com/database-playground/backend-v2/ent\",\"Schemas\":[{\"name\":\"Database\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"questions\",\"type\":\"Question\"}],\"fields\":[{\"name\":\"slug\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"immutable\":true,\"validators\":1,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"schema\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"validators\":1,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"SQL schema\"},{\"name\":\"relation_figure\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"validators\":1,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"relation figure\"}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"scope\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":3,\"Raw\":\"database:read\",\"VariableDefinition\":null}}],\"name\":\"scope\"}]}},\"EntSQL\":{\"increment_start\":12884901888}}},{\"name\":\"Event\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"ref_name\":\"events\",\"unique\":true,\"inverse\":true,\"required\":true}],\"fields\":[{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"type\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"validators\":1,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"triggered_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"TRIGGERED_AT\"}}},{\"name\":\"payload\",\"type\":{\"Type\":3,\"Ident\":\"map[string]interface {}\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"map[string]interface {}\",\"Kind\":21,\"PkgPath\":\"\",\"Methods\":{}}},\"optional\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}}],\"indexes\":[{\"fields\":[\"type\"]},{\"fields\":[\"type\",\"user_id\"]}],\"annotations\":{\"EntGQL\":{\"QueryField\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"scope\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":3,\"Raw\":\"user:read\",\"VariableDefinition\":null}}],\"name\":\"scope\"}]},\"RelayConnection\":true},\"EntSQL\":{\"increment_start\":21474836480}}},{\"name\":\"Group\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"scope_sets\",\"type\":\"ScopeSet\"}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"validators\":1,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"scope\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":3,\"Raw\":\"group:read\",\"VariableDefinition\":null}}],\"name\":\"scope\"}]}},\"EntSQL\":{\"increment_start\":4294967296}}},{\"name\":\"Point\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"ref_name\":\"points\",\"unique\":true,\"inverse\":true,\"required\":true}],\"fields\":[{\"name\":\"points\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"granted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"GRANTED_AT\"}}},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true}],\"QueryField\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"scope\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":3,\"Raw\":\"user:read\",\"VariableDefinition\":null}}],\"name\":\"scope\"}]},\"RelayConnection\":true},\"EntSQL\":{\"increment_start\":25769803776}}},{\"name\":\"Question\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"database\",\"type\":\"Database\",\"ref_name\":\"questions\",\"unique\":true,\"inverse\":true,\"required\":true},{\"name\":\"submissions\",\"type\":\"Submission\",\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"scope\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":3,\"Raw\":\"submission:read\",\"VariableDefinition\":null}}],\"name\":\"scope\"}],\"RelayConnection\":true}}}],\"fields\":[{\"name\":\"category\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"validators\":1,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"CATEGORY\"}},\"comment\":\"Question category, e.g. 'query'\"},{\"name\":\"difficulty\",\"type\":{\"Type\":6,\"Ident\":\"question.Difficulty\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"Unspecified\",\"V\":\"unspecified\"},{\"N\":\"Easy\",\"V\":\"easy\"},{\"N\":\"Medium\",\"V\":\"medium\"},{\"N\":\"Hard\",\"V\":\"hard\"}],\"default\":true,\"default_value\":\"medium\",\"default_kind\":24,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"DIFFICULTY\"}},\"comment\":\"Question difficulty, e.g. 'easy'\"},{\"name\":\"title\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Question title\"},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Question stem\"},{\"name\":\"reference_answer\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"scope\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":3,\"Raw\":\"answer:read\",\"VariableDefinition\":null}}],\"name\":\"scope\"}]}},\"comment\":\"Reference answer\"},{\"name\":\"visible_scope\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Only the users with this scope set can see the question. Empty means visible to everyone.\"}],\"indexes\":[{\"fields\":[\"category\"]},{\"fields\":[\"difficulty\"]}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"scope\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":3,\"Raw\":\"question:read\",\"VariableDefinition\":null}}],\"name\":\"scope\"}]},\"RelayConnection\":true},\"EntSQL\":{\"increment_start\":17179869184}}},{\"name\":\"ScopeSet\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"groups\",\"type\":\"Group\",\"ref_name\":\"scope_sets\",\"inverse\":true}],\"fields\":[{\"name\":\"slug\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"immutable\":true,\"validators\":1,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"description\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"scopes\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"scope\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":3,\"Raw\":\"scopeset:read\",\"VariableDefinition\":null}}],\"name\":\"scope\"}]}},\"EntSQL\":{\"increment_start\":8589934592}}},{\"name\":\"Submission\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"question\",\"type\":\"Question\",\"ref_name\":\"submissions\",\"unique\":true,\"inverse\":true,\"required\":true},{\"name\":\"user\",\"type\":\"User\",\"ref_name\":\"submissions\",\"unique\":true,\"inverse\":true,\"required\":true}],\"fields\":[{\"name\":\"submitted_code\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"validators\":1,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"submission.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"pending\",\"V\":\"pending\"},{\"N\":\"success\",\"V\":\"success\"},{\"N\":\"failed\",\"V\":\"failed\"}],\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"query_result\",\"type\":{\"Type\":3,\"Ident\":\"*models.UserSQLExecutionResult\",\"PkgPath\":\"github.com/database-playground/backend-v2/models\",\"PkgName\":\"models\",\"Nillable\":true,\"RType\":{\"Name\":\"UserSQLExecutionResult\",\"Ident\":\"models.UserSQLExecutionResult\",\"Kind\":22,\"PkgPath\":\"github.com/database-playground/backend-v2/models\",\"Methods\":{}}},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"error\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"submitted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"SUBMITTED_AT\"}}}],\"annotations\":{\"EntGQL\":{\"QueryField\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"scope\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":3,\"Raw\":\"submissions:read\",\"VariableDefinition\":null}}],\"name\":\"scope\"}]},\"RelayConnection\":true},\"EntSQL\":{\"increment_start\":30064771072}}},{\"name\":\"User\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"group\",\"type\":\"Group\",\"unique\":true,\"required\":true},{\"name\":\"points\",\"type\":\"Point\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"events\",\"type\":\"Event\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}},{\"name\":\"submissions\",\"type\":\"Submission\",\"annotations\":{\"EntGQL\":{\"RelayConnection\":true}}}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"Skip\":48}}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"validators\":1,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"email\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"immutable\":true,\"validators\":1,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"annotations\":{\"EntGQL\":{\"OrderField\":\"EMAIL\"}}},{\"name\":\"avatar\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"annotations\":{\"EntGQL\":{\"MutationInputs\":[{\"IsCreate\":true},{}],\"QueryField\":{\"Directives\":[{\"arguments\":[{\"Comment\":null,\"Name\":\"scope\",\"Value\":{\"Children\":null,\"Comment\":null,\"Definition\":null,\"ExpectedType\":null,\"ExpectedTypeHasDefault\":false,\"Kind\":3,\"Raw\":\"user:read\",\"VariableDefinition\":null}}],\"name\":\"scope\"}]},\"RelayConnection\":true},\"EntSQL\":{\"increment_start\":0}}}],\"Features\":[\"namedges\",\"intercept\",\"schema/snapshot\",\"sql/globalid\"]}" diff --git a/ent/migrate/schema.go b/ent/migrate/schema.go index 8fd5552..f820f0b 100644 --- a/ent/migrate/schema.go +++ b/ent/migrate/schema.go @@ -102,6 +102,7 @@ var ( {Name: "title", Type: field.TypeString}, {Name: "description", Type: field.TypeString, Size: 2147483647}, {Name: "reference_answer", Type: field.TypeString, Size: 2147483647}, + {Name: "visible_scope", Type: field.TypeString, Nullable: true}, {Name: "database_questions", Type: field.TypeInt}, } // QuestionsTable holds the schema information for the "questions" table. @@ -112,7 +113,7 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "questions_databases_questions", - Columns: []*schema.Column{QuestionsColumns[6]}, + Columns: []*schema.Column{QuestionsColumns[7]}, RefColumns: []*schema.Column{DatabasesColumns[0]}, OnDelete: schema.NoAction, }, diff --git a/ent/mutation.go b/ent/mutation.go index 268dd9b..9e72bc0 100644 --- a/ent/mutation.go +++ b/ent/mutation.go @@ -2458,6 +2458,7 @@ type QuestionMutation struct { title *string description *string reference_answer *string + visible_scope *string clearedFields map[string]struct{} database *int cleareddatabase bool @@ -2747,6 +2748,55 @@ func (m *QuestionMutation) ResetReferenceAnswer() { m.reference_answer = nil } +// SetVisibleScope sets the "visible_scope" field. +func (m *QuestionMutation) SetVisibleScope(s string) { + m.visible_scope = &s +} + +// VisibleScope returns the value of the "visible_scope" field in the mutation. +func (m *QuestionMutation) VisibleScope() (r string, exists bool) { + v := m.visible_scope + if v == nil { + return + } + return *v, true +} + +// OldVisibleScope returns the old "visible_scope" field's value of the Question entity. +// If the Question object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *QuestionMutation) OldVisibleScope(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldVisibleScope is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldVisibleScope requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldVisibleScope: %w", err) + } + return oldValue.VisibleScope, nil +} + +// ClearVisibleScope clears the value of the "visible_scope" field. +func (m *QuestionMutation) ClearVisibleScope() { + m.visible_scope = nil + m.clearedFields[question.FieldVisibleScope] = struct{}{} +} + +// VisibleScopeCleared returns if the "visible_scope" field was cleared in this mutation. +func (m *QuestionMutation) VisibleScopeCleared() bool { + _, ok := m.clearedFields[question.FieldVisibleScope] + return ok +} + +// ResetVisibleScope resets all changes to the "visible_scope" field. +func (m *QuestionMutation) ResetVisibleScope() { + m.visible_scope = nil + delete(m.clearedFields, question.FieldVisibleScope) +} + // SetDatabaseID sets the "database" edge to the Database entity by id. func (m *QuestionMutation) SetDatabaseID(id int) { m.database = &id @@ -2874,7 +2924,7 @@ func (m *QuestionMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *QuestionMutation) Fields() []string { - fields := make([]string, 0, 5) + fields := make([]string, 0, 6) if m.category != nil { fields = append(fields, question.FieldCategory) } @@ -2890,6 +2940,9 @@ func (m *QuestionMutation) Fields() []string { if m.reference_answer != nil { fields = append(fields, question.FieldReferenceAnswer) } + if m.visible_scope != nil { + fields = append(fields, question.FieldVisibleScope) + } return fields } @@ -2908,6 +2961,8 @@ func (m *QuestionMutation) Field(name string) (ent.Value, bool) { return m.Description() case question.FieldReferenceAnswer: return m.ReferenceAnswer() + case question.FieldVisibleScope: + return m.VisibleScope() } return nil, false } @@ -2927,6 +2982,8 @@ func (m *QuestionMutation) OldField(ctx context.Context, name string) (ent.Value return m.OldDescription(ctx) case question.FieldReferenceAnswer: return m.OldReferenceAnswer(ctx) + case question.FieldVisibleScope: + return m.OldVisibleScope(ctx) } return nil, fmt.Errorf("unknown Question field %s", name) } @@ -2971,6 +3028,13 @@ func (m *QuestionMutation) SetField(name string, value ent.Value) error { } m.SetReferenceAnswer(v) return nil + case question.FieldVisibleScope: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetVisibleScope(v) + return nil } return fmt.Errorf("unknown Question field %s", name) } @@ -3000,7 +3064,11 @@ func (m *QuestionMutation) AddField(name string, value ent.Value) error { // ClearedFields returns all nullable fields that were cleared during this // mutation. func (m *QuestionMutation) ClearedFields() []string { - return nil + var fields []string + if m.FieldCleared(question.FieldVisibleScope) { + fields = append(fields, question.FieldVisibleScope) + } + return fields } // FieldCleared returns a boolean indicating if a field with the given name was @@ -3013,6 +3081,11 @@ func (m *QuestionMutation) FieldCleared(name string) bool { // ClearField clears the value of the field with the given name. It returns an // error if the field is not defined in the schema. func (m *QuestionMutation) ClearField(name string) error { + switch name { + case question.FieldVisibleScope: + m.ClearVisibleScope() + return nil + } return fmt.Errorf("unknown Question nullable field %s", name) } @@ -3035,6 +3108,9 @@ func (m *QuestionMutation) ResetField(name string) error { case question.FieldReferenceAnswer: m.ResetReferenceAnswer() return nil + case question.FieldVisibleScope: + m.ResetVisibleScope() + return nil } return fmt.Errorf("unknown Question field %s", name) } diff --git a/ent/question.go b/ent/question.go index 7358386..5fd0c73 100644 --- a/ent/question.go +++ b/ent/question.go @@ -27,6 +27,8 @@ type Question struct { Description string `json:"description,omitempty"` // Reference answer ReferenceAnswer string `json:"reference_answer,omitempty"` + // Only the users with this scope set can see the question. Empty means visible to everyone. + VisibleScope string `json:"visible_scope,omitempty"` // Edges holds the relations/edges for other nodes in the graph. // The values are being populated by the QuestionQuery when eager-loading is set. Edges QuestionEdges `json:"edges"` @@ -76,7 +78,7 @@ func (*Question) scanValues(columns []string) ([]any, error) { switch columns[i] { case question.FieldID: values[i] = new(sql.NullInt64) - case question.FieldCategory, question.FieldDifficulty, question.FieldTitle, question.FieldDescription, question.FieldReferenceAnswer: + case question.FieldCategory, question.FieldDifficulty, question.FieldTitle, question.FieldDescription, question.FieldReferenceAnswer, question.FieldVisibleScope: values[i] = new(sql.NullString) case question.ForeignKeys[0]: // database_questions values[i] = new(sql.NullInt64) @@ -131,6 +133,12 @@ func (_m *Question) assignValues(columns []string, values []any) error { } else if value.Valid { _m.ReferenceAnswer = value.String } + case question.FieldVisibleScope: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field visible_scope", values[i]) + } else if value.Valid { + _m.VisibleScope = value.String + } case question.ForeignKeys[0]: if value, ok := values[i].(*sql.NullInt64); !ok { return fmt.Errorf("unexpected type %T for edge-field database_questions", value) @@ -198,6 +206,9 @@ func (_m *Question) String() string { builder.WriteString(", ") builder.WriteString("reference_answer=") builder.WriteString(_m.ReferenceAnswer) + builder.WriteString(", ") + builder.WriteString("visible_scope=") + builder.WriteString(_m.VisibleScope) builder.WriteByte(')') return builder.String() } diff --git a/ent/question/question.go b/ent/question/question.go index daee526..9ec6b13 100644 --- a/ent/question/question.go +++ b/ent/question/question.go @@ -26,6 +26,8 @@ const ( FieldDescription = "description" // FieldReferenceAnswer holds the string denoting the reference_answer field in the database. FieldReferenceAnswer = "reference_answer" + // FieldVisibleScope holds the string denoting the visible_scope field in the database. + FieldVisibleScope = "visible_scope" // EdgeDatabase holds the string denoting the database edge name in mutations. EdgeDatabase = "database" // EdgeSubmissions holds the string denoting the submissions edge name in mutations. @@ -56,6 +58,7 @@ var Columns = []string{ FieldTitle, FieldDescription, FieldReferenceAnswer, + FieldVisibleScope, } // ForeignKeys holds the SQL foreign-keys that are owned by the "questions" @@ -145,6 +148,11 @@ func ByReferenceAnswer(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldReferenceAnswer, opts...).ToFunc() } +// ByVisibleScope orders the results by the visible_scope field. +func ByVisibleScope(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldVisibleScope, opts...).ToFunc() +} + // ByDatabaseField orders the results by database field. func ByDatabaseField(field string, opts ...sql.OrderTermOption) OrderOption { return func(s *sql.Selector) { diff --git a/ent/question/where.go b/ent/question/where.go index c4ef507..10b17a5 100644 --- a/ent/question/where.go +++ b/ent/question/where.go @@ -73,6 +73,11 @@ func ReferenceAnswer(v string) predicate.Question { return predicate.Question(sql.FieldEQ(FieldReferenceAnswer, v)) } +// VisibleScope applies equality check predicate on the "visible_scope" field. It's identical to VisibleScopeEQ. +func VisibleScope(v string) predicate.Question { + return predicate.Question(sql.FieldEQ(FieldVisibleScope, v)) +} + // CategoryEQ applies the EQ predicate on the "category" field. func CategoryEQ(v string) predicate.Question { return predicate.Question(sql.FieldEQ(FieldCategory, v)) @@ -353,6 +358,81 @@ func ReferenceAnswerContainsFold(v string) predicate.Question { return predicate.Question(sql.FieldContainsFold(FieldReferenceAnswer, v)) } +// VisibleScopeEQ applies the EQ predicate on the "visible_scope" field. +func VisibleScopeEQ(v string) predicate.Question { + return predicate.Question(sql.FieldEQ(FieldVisibleScope, v)) +} + +// VisibleScopeNEQ applies the NEQ predicate on the "visible_scope" field. +func VisibleScopeNEQ(v string) predicate.Question { + return predicate.Question(sql.FieldNEQ(FieldVisibleScope, v)) +} + +// VisibleScopeIn applies the In predicate on the "visible_scope" field. +func VisibleScopeIn(vs ...string) predicate.Question { + return predicate.Question(sql.FieldIn(FieldVisibleScope, vs...)) +} + +// VisibleScopeNotIn applies the NotIn predicate on the "visible_scope" field. +func VisibleScopeNotIn(vs ...string) predicate.Question { + return predicate.Question(sql.FieldNotIn(FieldVisibleScope, vs...)) +} + +// VisibleScopeGT applies the GT predicate on the "visible_scope" field. +func VisibleScopeGT(v string) predicate.Question { + return predicate.Question(sql.FieldGT(FieldVisibleScope, v)) +} + +// VisibleScopeGTE applies the GTE predicate on the "visible_scope" field. +func VisibleScopeGTE(v string) predicate.Question { + return predicate.Question(sql.FieldGTE(FieldVisibleScope, v)) +} + +// VisibleScopeLT applies the LT predicate on the "visible_scope" field. +func VisibleScopeLT(v string) predicate.Question { + return predicate.Question(sql.FieldLT(FieldVisibleScope, v)) +} + +// VisibleScopeLTE applies the LTE predicate on the "visible_scope" field. +func VisibleScopeLTE(v string) predicate.Question { + return predicate.Question(sql.FieldLTE(FieldVisibleScope, v)) +} + +// VisibleScopeContains applies the Contains predicate on the "visible_scope" field. +func VisibleScopeContains(v string) predicate.Question { + return predicate.Question(sql.FieldContains(FieldVisibleScope, v)) +} + +// VisibleScopeHasPrefix applies the HasPrefix predicate on the "visible_scope" field. +func VisibleScopeHasPrefix(v string) predicate.Question { + return predicate.Question(sql.FieldHasPrefix(FieldVisibleScope, v)) +} + +// VisibleScopeHasSuffix applies the HasSuffix predicate on the "visible_scope" field. +func VisibleScopeHasSuffix(v string) predicate.Question { + return predicate.Question(sql.FieldHasSuffix(FieldVisibleScope, v)) +} + +// VisibleScopeIsNil applies the IsNil predicate on the "visible_scope" field. +func VisibleScopeIsNil() predicate.Question { + return predicate.Question(sql.FieldIsNull(FieldVisibleScope)) +} + +// VisibleScopeNotNil applies the NotNil predicate on the "visible_scope" field. +func VisibleScopeNotNil() predicate.Question { + return predicate.Question(sql.FieldNotNull(FieldVisibleScope)) +} + +// VisibleScopeEqualFold applies the EqualFold predicate on the "visible_scope" field. +func VisibleScopeEqualFold(v string) predicate.Question { + return predicate.Question(sql.FieldEqualFold(FieldVisibleScope, v)) +} + +// VisibleScopeContainsFold applies the ContainsFold predicate on the "visible_scope" field. +func VisibleScopeContainsFold(v string) predicate.Question { + return predicate.Question(sql.FieldContainsFold(FieldVisibleScope, v)) +} + // HasDatabase applies the HasEdge predicate on the "database" edge. func HasDatabase() predicate.Question { return predicate.Question(func(s *sql.Selector) { diff --git a/ent/question_create.go b/ent/question_create.go index 38c241f..7e15dde 100644 --- a/ent/question_create.go +++ b/ent/question_create.go @@ -59,6 +59,20 @@ func (_c *QuestionCreate) SetReferenceAnswer(v string) *QuestionCreate { return _c } +// SetVisibleScope sets the "visible_scope" field. +func (_c *QuestionCreate) SetVisibleScope(v string) *QuestionCreate { + _c.mutation.SetVisibleScope(v) + return _c +} + +// SetNillableVisibleScope sets the "visible_scope" field if the given value is not nil. +func (_c *QuestionCreate) SetNillableVisibleScope(v *string) *QuestionCreate { + if v != nil { + _c.SetVisibleScope(*v) + } + return _c +} + // SetDatabaseID sets the "database" edge to the Database entity by ID. func (_c *QuestionCreate) SetDatabaseID(id int) *QuestionCreate { _c.mutation.SetDatabaseID(id) @@ -202,6 +216,10 @@ func (_c *QuestionCreate) createSpec() (*Question, *sqlgraph.CreateSpec) { _spec.SetField(question.FieldReferenceAnswer, field.TypeString, value) _node.ReferenceAnswer = value } + if value, ok := _c.mutation.VisibleScope(); ok { + _spec.SetField(question.FieldVisibleScope, field.TypeString, value) + _node.VisibleScope = value + } if nodes := _c.mutation.DatabaseIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.M2O, diff --git a/ent/question_update.go b/ent/question_update.go index 5c437b0..e6656d5 100644 --- a/ent/question_update.go +++ b/ent/question_update.go @@ -99,6 +99,26 @@ func (_u *QuestionUpdate) SetNillableReferenceAnswer(v *string) *QuestionUpdate return _u } +// SetVisibleScope sets the "visible_scope" field. +func (_u *QuestionUpdate) SetVisibleScope(v string) *QuestionUpdate { + _u.mutation.SetVisibleScope(v) + return _u +} + +// SetNillableVisibleScope sets the "visible_scope" field if the given value is not nil. +func (_u *QuestionUpdate) SetNillableVisibleScope(v *string) *QuestionUpdate { + if v != nil { + _u.SetVisibleScope(*v) + } + return _u +} + +// ClearVisibleScope clears the value of the "visible_scope" field. +func (_u *QuestionUpdate) ClearVisibleScope() *QuestionUpdate { + _u.mutation.ClearVisibleScope() + return _u +} + // SetDatabaseID sets the "database" edge to the Database entity by ID. func (_u *QuestionUpdate) SetDatabaseID(id int) *QuestionUpdate { _u.mutation.SetDatabaseID(id) @@ -229,6 +249,12 @@ func (_u *QuestionUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.ReferenceAnswer(); ok { _spec.SetField(question.FieldReferenceAnswer, field.TypeString, value) } + if value, ok := _u.mutation.VisibleScope(); ok { + _spec.SetField(question.FieldVisibleScope, field.TypeString, value) + } + if _u.mutation.VisibleScopeCleared() { + _spec.ClearField(question.FieldVisibleScope, field.TypeString) + } if _u.mutation.DatabaseCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.M2O, @@ -393,6 +419,26 @@ func (_u *QuestionUpdateOne) SetNillableReferenceAnswer(v *string) *QuestionUpda return _u } +// SetVisibleScope sets the "visible_scope" field. +func (_u *QuestionUpdateOne) SetVisibleScope(v string) *QuestionUpdateOne { + _u.mutation.SetVisibleScope(v) + return _u +} + +// SetNillableVisibleScope sets the "visible_scope" field if the given value is not nil. +func (_u *QuestionUpdateOne) SetNillableVisibleScope(v *string) *QuestionUpdateOne { + if v != nil { + _u.SetVisibleScope(*v) + } + return _u +} + +// ClearVisibleScope clears the value of the "visible_scope" field. +func (_u *QuestionUpdateOne) ClearVisibleScope() *QuestionUpdateOne { + _u.mutation.ClearVisibleScope() + return _u +} + // SetDatabaseID sets the "database" edge to the Database entity by ID. func (_u *QuestionUpdateOne) SetDatabaseID(id int) *QuestionUpdateOne { _u.mutation.SetDatabaseID(id) @@ -553,6 +599,12 @@ func (_u *QuestionUpdateOne) sqlSave(ctx context.Context) (_node *Question, err if value, ok := _u.mutation.ReferenceAnswer(); ok { _spec.SetField(question.FieldReferenceAnswer, field.TypeString, value) } + if value, ok := _u.mutation.VisibleScope(); ok { + _spec.SetField(question.FieldVisibleScope, field.TypeString, value) + } + if _u.mutation.VisibleScopeCleared() { + _spec.ClearField(question.FieldVisibleScope, field.TypeString) + } if _u.mutation.DatabaseCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.M2O, diff --git a/graph/ent.graphqls b/graph/ent.graphqls index 34ae787..52e5dd1 100644 --- a/graph/ent.graphqls +++ b/graph/ent.graphqls @@ -61,6 +61,10 @@ input CreateQuestionInput { Reference answer """ referenceAnswer: String! + """ + Only the users with this scope set can see the question. Empty means visible to everyone. + """ + visibleScope: String databaseID: ID! submissionIDs: [ID!] } @@ -786,6 +790,10 @@ type Question implements Node { Reference answer """ referenceAnswer: String! @scope(scope: "answer:read") + """ + Only the users with this scope set can see the question. Empty means visible to everyone. + """ + visibleScope: String database: Database! submissions( """ @@ -969,6 +977,24 @@ input QuestionWhereInput { referenceAnswerEqualFold: String referenceAnswerContainsFold: String """ + visible_scope field predicates + """ + visibleScope: String + visibleScopeNEQ: String + visibleScopeIn: [String!] + visibleScopeNotIn: [String!] + visibleScopeGT: String + visibleScopeGTE: String + visibleScopeLT: String + visibleScopeLTE: String + visibleScopeContains: String + visibleScopeHasPrefix: String + visibleScopeHasSuffix: String + visibleScopeIsNil: Boolean + visibleScopeNotNil: Boolean + visibleScopeEqualFold: String + visibleScopeContainsFold: String + """ database edge predicates """ hasDatabase: Boolean @@ -1254,6 +1280,11 @@ input UpdateQuestionInput { Reference answer """ referenceAnswer: String + """ + Only the users with this scope set can see the question. Empty means visible to everyone. + """ + visibleScope: String + clearVisibleScope: Boolean databaseID: ID addSubmissionIDs: [ID!] removeSubmissionIDs: [ID!] From de328fcfdd811bf04eef25a9adecec5a5c33e2f8 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Tue, 4 Nov 2025 03:20:14 +0800 Subject: [PATCH 3/6] feat(graph): implement visible_scope filtering --- graph/ent.resolvers.go | 22 +- graph/question.resolvers.go | 81 ++++++- graph/question_resolver_test.go | 392 ++++++++++++++++++++++++++++++++ 3 files changed, 492 insertions(+), 3 deletions(-) diff --git a/graph/ent.resolvers.go b/graph/ent.resolvers.go index afbe3ef..d77af85 100644 --- a/graph/ent.resolvers.go +++ b/graph/ent.resolvers.go @@ -6,10 +6,13 @@ package graph import ( "context" + "slices" "entgo.io/contrib/entgql" "github.com/database-playground/backend-v2/ent" + "github.com/database-playground/backend-v2/ent/question" "github.com/database-playground/backend-v2/graph/defs" + "github.com/database-playground/backend-v2/internal/auth" ) // Node is the resolver for the node field. @@ -56,9 +59,26 @@ func (r *queryResolver) Points(ctx context.Context, after *entgql.Cursor[int], f // Questions is the resolver for the questions field. func (r *queryResolver) Questions(ctx context.Context, after *entgql.Cursor[int], first *int, before *entgql.Cursor[int], last *int, orderBy *ent.QuestionOrder, where *ent.QuestionWhereInput) (*ent.QuestionConnection, error) { + tokenInfo, ok := auth.GetUser(ctx) + if !ok { + return nil, defs.ErrUnauthorized + } + entClient := r.EntClient(ctx) - return entClient.Question.Query().Paginate(ctx, after, first, before, last, ent.WithQuestionOrder(orderBy), ent.WithQuestionFilter(where.Filter)) + query := entClient.Question.Query() + + // If user does not have full access, filter questions by visible_scope + if !slices.Contains(tokenInfo.Scopes, "*") { + query = query.Where( + question.Or( + question.VisibleScopeIsNil(), + question.VisibleScopeIn(tokenInfo.Scopes...), + ), + ) + } + + return query.Paginate(ctx, after, first, before, last, ent.WithQuestionOrder(orderBy), ent.WithQuestionFilter(where.Filter)) } // ScopeSets is the resolver for the scopeSets field. diff --git a/graph/question.resolvers.go b/graph/question.resolvers.go index 9eeb217..2e32728 100644 --- a/graph/question.resolvers.go +++ b/graph/question.resolvers.go @@ -8,6 +8,7 @@ import ( "context" "errors" "fmt" + "strings" "entgo.io/ent/dialect/sql" "github.com/database-playground/backend-v2/ent" @@ -23,6 +24,30 @@ import ( "github.com/samber/lo" ) +// checkQuestionVisibleScope checks if the user has permission to access the question based on visible_scope. +// Returns nil if the user has access, or an error (ErrNotFound) if they don't. +func checkQuestionVisibleScope(ctx context.Context, question *ent.Question) error { + visibleScope := question.VisibleScope + // If visible_scope is empty, the question is visible to everyone + if strings.TrimSpace(visibleScope) == "" { + return nil + } + + // Get user from context + tokenInfo, ok := auth.GetUser(ctx) + if !ok { + // If no user context, but question has visible_scope, return not found + return defs.ErrNotFound + } + + // Check if user has the required scope + if !scope.ShouldAllow(visibleScope, tokenInfo.Scopes) { + return defs.ErrNotFound + } + + return nil +} + // CreateQuestion is the resolver for the createQuestion field. func (r *mutationResolver) CreateQuestion(ctx context.Context, input ent.CreateQuestionInput) (*ent.Question, error) { entClient := r.EntClient(ctx) @@ -39,7 +64,22 @@ func (r *mutationResolver) CreateQuestion(ctx context.Context, input ent.CreateQ func (r *mutationResolver) UpdateQuestion(ctx context.Context, id int, input ent.UpdateQuestionInput) (*ent.Question, error) { entClient := r.EntClient(ctx) - question, err := entClient.Question.UpdateOneID(id).SetInput(input).Save(ctx) + // First, get the question to check visible_scope + question, err := entClient.Question.Get(ctx, id) + if err != nil { + if ent.IsNotFound(err) { + return nil, defs.ErrNotFound + } + return nil, err + } + + // Check if user has permission to access this question + if err := checkQuestionVisibleScope(ctx, question); err != nil { + return nil, err + } + + // Update the question + question, err = entClient.Question.UpdateOneID(id).SetInput(input).Save(ctx) if err != nil { return nil, err } @@ -51,7 +91,22 @@ func (r *mutationResolver) UpdateQuestion(ctx context.Context, id int, input ent func (r *mutationResolver) DeleteQuestion(ctx context.Context, id int) (bool, error) { entClient := r.EntClient(ctx) - err := entClient.Question.DeleteOneID(id).Exec(ctx) + // First, get the question to check visible_scope + question, err := entClient.Question.Get(ctx, id) + if err != nil { + if ent.IsNotFound(err) { + return false, defs.ErrNotFound + } + return false, err + } + + // Check if user has permission to access this question + if err := checkQuestionVisibleScope(ctx, question); err != nil { + return false, err + } + + // Delete the question + err = entClient.Question.DeleteOneID(id).Exec(ctx) if err != nil { return false, err } @@ -102,6 +157,20 @@ func (r *mutationResolver) SubmitAnswer(ctx context.Context, id int, answer stri return nil, defs.ErrUnauthorized } + // Check if user has permission to access this question based on visible_scope + entClient := r.EntClient(ctx) + question, err := entClient.Question.Get(ctx, id) + if err != nil { + if ent.IsNotFound(err) { + return nil, defs.ErrNotFound + } + return nil, err + } + + if err := checkQuestionVisibleScope(ctx, question); err != nil { + return nil, err + } + submissionResult, err := r.submissionService.SubmitAnswer(ctx, submission.SubmitAnswerInput{ SubmitterID: user.UserID, QuestionID: id, @@ -127,6 +196,14 @@ func (r *queryResolver) Question(ctx context.Context, id int) (*ent.Question, er question, err := entClient.Question.Get(ctx, id) if err != nil { + if ent.IsNotFound(err) { + return nil, defs.ErrNotFound + } + return nil, err + } + + // Check if user has permission to access this question based on visible_scope + if err := checkQuestionVisibleScope(ctx, question); err != nil { return nil, err } diff --git a/graph/question_resolver_test.go b/graph/question_resolver_test.go index f3fc20f..8044871 100644 --- a/graph/question_resolver_test.go +++ b/graph/question_resolver_test.go @@ -1439,3 +1439,395 @@ func TestQuestionResolver_Statistics(t *testing.T) { require.Contains(t, err.Error(), defs.CodeUnauthorized) }) } + +func TestQuestionResolver_VisibleScope(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + cfg := Config{ + Resolvers: resolver, + Directives: DirectiveRoot{Scope: directive.ScopeDirective}, + } + srv := handler.New(NewExecutableSchema(cfg)) + srv.AddTransport(transport.POST{}) + gqlClient := client.New(srv) + + // Create test group and users + group, err := createTestGroup(t, entClient) + require.NoError(t, err) + + userWithScope, err := entClient.User.Create(). + SetName("userWithScope"). + SetEmail("userWithScope@example.com"). + SetGroup(group). + Save(context.Background()) + require.NoError(t, err) + + userWithoutScope, err := entClient.User.Create(). + SetName("userWithoutScope"). + SetEmail("userWithoutScope@example.com"). + SetGroup(group). + Save(context.Background()) + require.NoError(t, err) + + // Create test database + database := createTestDatabase(t, entClient) + + // Create question with visible_scope + questionWithScope, err := entClient.Question.Create(). + SetCategory("test-query"). + SetDifficulty("easy"). + SetTitle("Restricted Question"). + SetDescription("Write a SELECT query"). + SetReferenceAnswer("SELECT * FROM test;"). + SetVisibleScope("premium:read"). + SetDatabase(database). + Save(context.Background()) + require.NoError(t, err) + + // Create question without visible_scope (visible to everyone) + questionPublic, err := entClient.Question.Create(). + SetCategory("test-query"). + SetDifficulty("easy"). + SetTitle("Public Question"). + SetDescription("Write a SELECT query"). + SetReferenceAnswer("SELECT * FROM test;"). + SetDatabase(database). + Save(context.Background()) + require.NoError(t, err) + + t.Run("success - user with matching scope can access question", func(t *testing.T) { + var resp struct { + Question struct { + ID string + Title string + } + } + query := `query { question(id: ` + strconv.Itoa(questionWithScope.ID) + `) { id title } }` + + err := gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: userWithScope.ID, + Scopes: []string{"premium:read", "question:read"}, + })) + }) + require.NoError(t, err) + require.Equal(t, strconv.Itoa(questionWithScope.ID), resp.Question.ID) + require.Equal(t, "Restricted Question", resp.Question.Title) + }) + + t.Run("not found - user without matching scope cannot access question", func(t *testing.T) { + var resp struct { + Question struct { + ID string + } + } + query := `query { question(id: ` + strconv.Itoa(questionWithScope.ID) + `) { id } }` + + err := gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: userWithoutScope.ID, + Scopes: []string{"question:read"}, // Has question:read but not premium:read + })) + }) + require.Error(t, err) + require.Contains(t, err.Error(), defs.CodeNotFound) + }) + + t.Run("success - user can access public question without visible_scope", func(t *testing.T) { + var resp struct { + Question struct { + ID string + Title string + } + } + query := `query { question(id: ` + strconv.Itoa(questionPublic.ID) + `) { id title } }` + + err := gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: userWithoutScope.ID, + Scopes: []string{"question:read"}, + })) + }) + require.NoError(t, err) + require.Equal(t, strconv.Itoa(questionPublic.ID), resp.Question.ID) + require.Equal(t, "Public Question", resp.Question.Title) + }) + + t.Run("success - user with wildcard scope can access restricted question", func(t *testing.T) { + var resp struct { + Question struct { + ID string + Title string + } + } + query := `query { question(id: ` + strconv.Itoa(questionWithScope.ID) + `) { id title } }` + + err := gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: userWithoutScope.ID, + Scopes: []string{"*", "question:read"}, + })) + }) + require.NoError(t, err) + require.Equal(t, strconv.Itoa(questionWithScope.ID), resp.Question.ID) + }) + + t.Run("not found - unauthenticated user cannot access restricted question", func(t *testing.T) { + var resp struct { + Question struct { + ID string + } + } + query := `query { question(id: ` + strconv.Itoa(questionWithScope.ID) + `) { id } }` + + err := gqlClient.Post(query, &resp) + require.Error(t, err) + require.Contains(t, err.Error(), defs.CodeUnauthorized) + }) + + t.Run("success - update question with visible_scope by authorized user", func(t *testing.T) { + var resp struct { + UpdateQuestion struct { + ID string + Title string + } + } + query := `mutation { + updateQuestion(id: ` + strconv.Itoa(questionWithScope.ID) + `, input: { title: "Updated Title" }) { + id + title + } + }` + + err := gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: userWithScope.ID, + Scopes: []string{"premium:read", "question:write"}, + })) + }) + require.NoError(t, err) + require.Equal(t, strconv.Itoa(questionWithScope.ID), resp.UpdateQuestion.ID) + require.Equal(t, "Updated Title", resp.UpdateQuestion.Title) + }) + + t.Run("not found - update question with visible_scope by unauthorized user", func(t *testing.T) { + var resp struct { + UpdateQuestion struct { + ID string + } + } + query := `mutation { + updateQuestion(id: ` + strconv.Itoa(questionWithScope.ID) + `, input: { title: "Updated Title" }) { + id + } + }` + + err := gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: userWithoutScope.ID, + Scopes: []string{"question:write"}, // Has write scope but not premium:read + })) + }) + require.Error(t, err) + require.Contains(t, err.Error(), defs.CodeNotFound) + }) + + t.Run("success - delete question with visible_scope by authorized user", func(t *testing.T) { + // Create a new question for deletion test + questionToDelete, err := entClient.Question.Create(). + SetCategory("test-query"). + SetDifficulty("easy"). + SetTitle("Question To Delete"). + SetDescription("Write a SELECT query"). + SetReferenceAnswer("SELECT * FROM test;"). + SetVisibleScope("premium:read"). + SetDatabase(database). + Save(context.Background()) + require.NoError(t, err) + + var resp struct { + DeleteQuestion bool + } + query := `mutation { deleteQuestion(id: ` + strconv.Itoa(questionToDelete.ID) + `) }` + + err = gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: userWithScope.ID, + Scopes: []string{"premium:read", "question:write"}, + })) + }) + require.NoError(t, err) + require.True(t, resp.DeleteQuestion) + }) + + t.Run("not found - delete question with visible_scope by unauthorized user", func(t *testing.T) { + // Create a new question for deletion test + questionToDelete, err := entClient.Question.Create(). + SetCategory("test-query"). + SetDifficulty("easy"). + SetTitle("Question To Delete"). + SetDescription("Write a SELECT query"). + SetReferenceAnswer("SELECT * FROM test;"). + SetVisibleScope("premium:read"). + SetDatabase(database). + Save(context.Background()) + require.NoError(t, err) + + var resp struct { + DeleteQuestion bool + } + query := `mutation { deleteQuestion(id: ` + strconv.Itoa(questionToDelete.ID) + `) }` + + err = gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: userWithoutScope.ID, + Scopes: []string{"question:write"}, // Has write scope but not premium:read + })) + }) + require.Error(t, err) + require.Contains(t, err.Error(), defs.CodeNotFound) + }) + + t.Run("success - submit answer to question with visible_scope by authorized user", func(t *testing.T) { + var resp struct { + SubmitAnswer struct { + Result struct { + MatchAnswer bool + } + } + } + query := `mutation { + submitAnswer(id: ` + strconv.Itoa(questionWithScope.ID) + `, answer: "SELECT * FROM test;") { + result { + matchAnswer + } + } + }` + + err := gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: userWithScope.ID, + Scopes: []string{"premium:read", "submission:write"}, + })) + }) + require.NoError(t, err) + require.True(t, resp.SubmitAnswer.Result.MatchAnswer) + }) + + t.Run("not found - submit answer to question with visible_scope by unauthorized user", func(t *testing.T) { + var resp struct { + SubmitAnswer struct { + Result struct { + MatchAnswer bool + } + } + } + query := `mutation { + submitAnswer(id: ` + strconv.Itoa(questionWithScope.ID) + `, answer: "SELECT * FROM test;") { + result { + matchAnswer + } + } + }` + + err := gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: userWithoutScope.ID, + Scopes: []string{"submission:write"}, // Has write scope but not premium:read + })) + }) + require.Error(t, err) + require.Contains(t, err.Error(), defs.CodeNotFound) + }) + + t.Run("success - questions list filters by visible_scope", func(t *testing.T) { + // Create another restricted question + questionRestricted2, err := entClient.Question.Create(). + SetCategory("test-query"). + SetDifficulty("easy"). + SetTitle("Another Restricted Question"). + SetDescription("Write a SELECT query"). + SetReferenceAnswer("SELECT * FROM test;"). + SetVisibleScope("premium:read"). + SetDatabase(database). + Save(context.Background()) + require.NoError(t, err) + + var resp struct { + Questions struct { + Edges []struct { + Node struct { + ID string + Title string + } + } + TotalCount int + } + } + query := `query { + questions(first: 10) { + edges { + node { + id + title + } + } + totalCount + } + }` + + // User with premium:read scope should see restricted questions + err = gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: userWithScope.ID, + Scopes: []string{"premium:read", "question:read"}, + })) + }) + require.NoError(t, err) + // Should see public question and restricted questions + require.GreaterOrEqual(t, resp.Questions.TotalCount, 3) // questionPublic, questionWithScope, questionRestricted2 + + // User without premium:read scope should only see public questions + err = gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: userWithoutScope.ID, + Scopes: []string{"question:read"}, + })) + }) + require.NoError(t, err) + // Should only see public question (and any other public questions from previous tests) + foundPublic := false + for _, edge := range resp.Questions.Edges { + if edge.Node.ID == strconv.Itoa(questionPublic.ID) { + foundPublic = true + } + // Should not see restricted questions + require.NotEqual(t, strconv.Itoa(questionWithScope.ID), edge.Node.ID) + require.NotEqual(t, strconv.Itoa(questionRestricted2.ID), edge.Node.ID) + } + require.True(t, foundPublic, "Should find public question") + }) + + t.Run("success - questions list shows all questions for user with wildcard scope", func(t *testing.T) { + var resp struct { + Questions struct { + TotalCount int + } + } + query := `query { + questions(first: 10) { + totalCount + } + }` + + err := gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: userWithoutScope.ID, + Scopes: []string{"*", "question:read"}, + })) + }) + require.NoError(t, err) + // User with wildcard scope should see all questions + require.GreaterOrEqual(t, resp.Questions.TotalCount, 3) + }) +} From 2bb74d0570b263b53b36cb910d12862f9621c1b1 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Tue, 4 Nov 2025 04:21:24 +0800 Subject: [PATCH 4/6] fix(graph): correct totalQuestions and categories filtering --- graph/ent.resolvers.go | 19 +-- graph/question.resolvers.go | 73 ++++++--- graph/question_resolver_test.go | 72 +++++++++ graph/submission_statistics_test.go | 225 ++++++++++++++++++++++++++++ 4 files changed, 353 insertions(+), 36 deletions(-) diff --git a/graph/ent.resolvers.go b/graph/ent.resolvers.go index d77af85..c7bedec 100644 --- a/graph/ent.resolvers.go +++ b/graph/ent.resolvers.go @@ -6,13 +6,10 @@ package graph import ( "context" - "slices" "entgo.io/contrib/entgql" "github.com/database-playground/backend-v2/ent" - "github.com/database-playground/backend-v2/ent/question" "github.com/database-playground/backend-v2/graph/defs" - "github.com/database-playground/backend-v2/internal/auth" ) // Node is the resolver for the node field. @@ -59,24 +56,10 @@ func (r *queryResolver) Points(ctx context.Context, after *entgql.Cursor[int], f // Questions is the resolver for the questions field. func (r *queryResolver) Questions(ctx context.Context, after *entgql.Cursor[int], first *int, before *entgql.Cursor[int], last *int, orderBy *ent.QuestionOrder, where *ent.QuestionWhereInput) (*ent.QuestionConnection, error) { - tokenInfo, ok := auth.GetUser(ctx) - if !ok { - return nil, defs.ErrUnauthorized - } - entClient := r.EntClient(ctx) query := entClient.Question.Query() - - // If user does not have full access, filter questions by visible_scope - if !slices.Contains(tokenInfo.Scopes, "*") { - query = query.Where( - question.Or( - question.VisibleScopeIsNil(), - question.VisibleScopeIn(tokenInfo.Scopes...), - ), - ) - } + query = applyQuestionVisibleScopeFilter(ctx, query) return query.Paginate(ctx, after, first, before, last, ent.WithQuestionOrder(orderBy), ent.WithQuestionFilter(where.Filter)) } diff --git a/graph/question.resolvers.go b/graph/question.resolvers.go index 2e32728..cfbb8a0 100644 --- a/graph/question.resolvers.go +++ b/graph/question.resolvers.go @@ -8,6 +8,7 @@ import ( "context" "errors" "fmt" + "slices" "strings" "entgo.io/ent/dialect/sql" @@ -48,6 +49,30 @@ func checkQuestionVisibleScope(ctx context.Context, question *ent.Question) erro return nil } +// applyQuestionVisibleScopeFilter applies visible_scope filtering to a question query. +// If the user has wildcard scope "*", no filtering is applied. +// Otherwise, only questions with nil visible_scope or visible_scope matching user's scopes are included. +func applyQuestionVisibleScopeFilter(ctx context.Context, query *ent.QuestionQuery) *ent.QuestionQuery { + tokenInfo, ok := auth.GetUser(ctx) + if !ok { + // If no user context, only show questions without visible_scope + return query.Where(entQuestion.VisibleScopeIsNil()) + } + + // If user has full access, don't filter + if slices.Contains(tokenInfo.Scopes, "*") { + return query + } + + // Filter to show only questions with nil visible_scope or visible_scope matching user's scopes + return query.Where( + entQuestion.Or( + entQuestion.VisibleScopeIsNil(), + entQuestion.VisibleScopeIn(tokenInfo.Scopes...), + ), + ) +} + // CreateQuestion is the resolver for the createQuestion field. func (r *mutationResolver) CreateQuestion(ctx context.Context, input ent.CreateQuestionInput) (*ent.Question, error) { entClient := r.EntClient(ctx) @@ -260,7 +285,10 @@ func (r *queryResolver) Submission(ctx context.Context, id int) (*ent.Submission func (r *queryResolver) QuestionCategories(ctx context.Context) ([]string, error) { entClient := r.EntClient(ctx) - categories, err := entClient.Question.Query(). + query := entClient.Question.Query() + query = applyQuestionVisibleScopeFilter(ctx, query) + + categories, err := query. Unique(true). Select(entQuestion.FieldCategory). Strings(ctx) @@ -413,36 +441,45 @@ func (r *userResolver) SubmissionStatistics(ctx context.Context, obj *ent.User) Count int `json:"count,omitempty"` } - // total questions - totalQuestions, err := entClient.Question.Query().Count(ctx) + // total questions - filter by visible_scope + totalQuestionsQuery := entClient.Question.Query() + totalQuestionsQuery = applyQuestionVisibleScopeFilter(ctx, totalQuestionsQuery) + totalQuestions, err := totalQuestionsQuery.Count(ctx) if err != nil { return nil, fmt.Errorf("retrieving total questions: %w", err) } - // attempted - attemptedQuestions, err := entClient.Question.Query().Where( - entQuestion.HasSubmissionsWith(entSubmission.HasUserWith(user.ID(obj.ID))), - ).Count(ctx) + // attempted - filter by visible_scope + attemptedQuestionsQuery := entClient.Question.Query(). + Where(entQuestion.HasSubmissionsWith(entSubmission.HasUserWith(user.ID(obj.ID)))) + attemptedQuestionsQuery = applyQuestionVisibleScopeFilter(ctx, attemptedQuestionsQuery) + attemptedQuestions, err := attemptedQuestionsQuery.Count(ctx) if err != nil { return nil, fmt.Errorf("retrieving attempted questions: %w", err) } - // solved - solvedQuestions, err := entClient.Question.Query().Where( - entQuestion.HasSubmissionsWith(entSubmission.HasUserWith(user.ID(obj.ID)), entSubmission.StatusEQ(entSubmission.StatusSuccess)), - ).Count(ctx) + // solved - filter by visible_scope + solvedQuestionsQuery := entClient.Question.Query(). + Where( + entQuestion.HasSubmissionsWith(entSubmission.HasUserWith(user.ID(obj.ID)), entSubmission.StatusEQ(entSubmission.StatusSuccess)), + ) + solvedQuestionsQuery = applyQuestionVisibleScopeFilter(ctx, solvedQuestionsQuery) + solvedQuestions, err := solvedQuestionsQuery.Count(ctx) if err != nil { return nil, fmt.Errorf("retrieving solved questions: %w", err) } - // solved question by difficulty + // solved question by difficulty - filter by visible_scope + solvedByDifficultyQuery := entClient.Question.Query(). + Where( + entQuestion.HasSubmissionsWith( + entSubmission.HasUserWith(user.ID(obj.ID)), + entSubmission.StatusEQ(entSubmission.StatusSuccess), + ), + ) + solvedByDifficultyQuery = applyQuestionVisibleScopeFilter(ctx, solvedByDifficultyQuery) var solvedQuestionByDifficulty []tSQLSolvedQuestionByDifficulty - err = entClient.Question.Query().Where( - entQuestion.HasSubmissionsWith( - entSubmission.HasUserWith(user.ID(obj.ID)), - entSubmission.StatusEQ(entSubmission.StatusSuccess), - ), - ). + err = solvedByDifficultyQuery. GroupBy(entQuestion.FieldDifficulty). Aggregate(ent.Count()). Scan(ctx, &solvedQuestionByDifficulty) diff --git a/graph/question_resolver_test.go b/graph/question_resolver_test.go index 8044871..03fb0d3 100644 --- a/graph/question_resolver_test.go +++ b/graph/question_resolver_test.go @@ -899,6 +899,78 @@ func TestQueryResolver_QuestionCategories(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), defs.CodeUnauthorized) }) + + t.Run("success - filters categories by visible_scope", func(t *testing.T) { + // Create test database + database := createTestDatabase(t, entClient) + + // Create public question (no visible_scope) + _, err := entClient.Question.Create(). + SetCategory("public-category"). + SetDifficulty("easy"). + SetTitle("Public Question"). + SetDescription("Public question"). + SetReferenceAnswer("SELECT * FROM test;"). + SetDatabase(database). + Save(context.Background()) + require.NoError(t, err) + + // Create restricted question (with visible_scope) + _, err = entClient.Question.Create(). + SetCategory("premium-category"). + SetDifficulty("easy"). + SetTitle("Premium Question"). + SetDescription("Premium question"). + SetReferenceAnswer("SELECT * FROM test;"). + SetVisibleScope("premium:read"). + SetDatabase(database). + Save(context.Background()) + require.NoError(t, err) + + // User without premium:read scope should only see public category + var resp1 struct { + QuestionCategories []string + } + query := `query { questionCategories }` + + err = gqlClient.Post(query, &resp1, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: testUser.ID, + Scopes: []string{"question:read"}, // No premium:read + })) + }) + require.NoError(t, err) + require.Contains(t, resp1.QuestionCategories, "public-category") + require.NotContains(t, resp1.QuestionCategories, "premium-category") + + // User with premium:read scope should see both categories + var resp2 struct { + QuestionCategories []string + } + err = gqlClient.Post(query, &resp2, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: testUser.ID, + Scopes: []string{"question:read", "premium:read"}, + })) + }) + require.NoError(t, err) + require.Contains(t, resp2.QuestionCategories, "public-category") + require.Contains(t, resp2.QuestionCategories, "premium-category") + + // User with wildcard scope should see all categories + var resp3 struct { + QuestionCategories []string + } + err = gqlClient.Post(query, &resp3, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: testUser.ID, + Scopes: []string{"*", "question:read"}, + })) + }) + require.NoError(t, err) + require.Contains(t, resp3.QuestionCategories, "public-category") + require.Contains(t, resp3.QuestionCategories, "premium-category") + }) } func TestQuestionResolver_Statistics(t *testing.T) { diff --git a/graph/submission_statistics_test.go b/graph/submission_statistics_test.go index 82fc510..82da31b 100644 --- a/graph/submission_statistics_test.go +++ b/graph/submission_statistics_test.go @@ -540,6 +540,231 @@ func TestUserResolver_SubmissionStatistics(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "retrieving total questions") }) + + t.Run("filters by visible_scope - user without scope sees only public questions", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + cfg := Config{ + Resolvers: resolver, + Directives: DirectiveRoot{Scope: directive.ScopeDirective}, + } + srv := handler.New(NewExecutableSchema(cfg)) + srv.AddTransport(transport.POST{}) + gqlClient := client.New(srv) + + // Create test group and user + group, err := createTestGroup(t, entClient) + require.NoError(t, err) + + user, err := entClient.User.Create(). + SetName("testuser"). + SetEmail("test@example.com"). + SetGroup(group). + Save(context.Background()) + require.NoError(t, err) + + // Create test database + database := createTestDatabase(t, entClient) + + // Create public question (no visible_scope) + publicQuestion := createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyEasy) + + // Create restricted question (with visible_scope) + restrictedQuestion, err := entClient.Question.Create(). + SetCategory("premium-query"). + SetDifficulty(question.DifficultyEasy). + SetTitle("Premium Question"). + SetDescription("Premium question"). + SetReferenceAnswer("SELECT * FROM test;"). + SetVisibleScope("premium:read"). + SetDatabase(database). + Save(context.Background()) + require.NoError(t, err) + + // Create submissions for both questions + createTestSubmission(t, entClient, user, publicQuestion, "SELECT * FROM test;", submission.StatusSuccess, time.Now()) + createTestSubmission(t, entClient, user, restrictedQuestion, "SELECT * FROM test;", submission.StatusSuccess, time.Now()) + + // Query with user without premium:read scope + var resp struct { + User struct { + SubmissionStatistics struct { + TotalQuestions int `json:"totalQuestions"` + AttemptedQuestions int `json:"attemptedQuestions"` + SolvedQuestions int `json:"solvedQuestions"` + SolvedQuestionByDifficulty []struct { + Difficulty string `json:"difficulty"` + SolvedQuestions int `json:"solvedQuestions"` + } `json:"solvedQuestionByDifficulty"` + } `json:"submissionStatistics"` + } `json:"user"` + } + + query := `query { + user(id: ` + strconv.Itoa(user.ID) + `) { + submissionStatistics { + totalQuestions + attemptedQuestions + solvedQuestions + solvedQuestionByDifficulty { + difficulty + solvedQuestions + } + } + } + }` + + err = gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: 1, + Scopes: []string{"user:read"}, // No premium:read + })) + }) + + require.NoError(t, err) + // Should only count public question + require.Equal(t, 1, resp.User.SubmissionStatistics.TotalQuestions) + require.Equal(t, 1, resp.User.SubmissionStatistics.AttemptedQuestions) + require.Equal(t, 1, resp.User.SubmissionStatistics.SolvedQuestions) + }) + + t.Run("filters by visible_scope - user with scope sees all questions", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + cfg := Config{ + Resolvers: resolver, + Directives: DirectiveRoot{Scope: directive.ScopeDirective}, + } + srv := handler.New(NewExecutableSchema(cfg)) + srv.AddTransport(transport.POST{}) + gqlClient := client.New(srv) + + // Create test group and user + group, err := createTestGroup(t, entClient) + require.NoError(t, err) + + user, err := entClient.User.Create(). + SetName("testuser"). + SetEmail("test@example.com"). + SetGroup(group). + Save(context.Background()) + require.NoError(t, err) + + // Create test database + database := createTestDatabase(t, entClient) + + // Create public question (no visible_scope) + publicQuestion := createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyEasy) + + // Create restricted question (with visible_scope) + restrictedQuestion, err := entClient.Question.Create(). + SetCategory("premium-query"). + SetDifficulty(question.DifficultyEasy). + SetTitle("Premium Question"). + SetDescription("Premium question"). + SetReferenceAnswer("SELECT * FROM test;"). + SetVisibleScope("premium:read"). + SetDatabase(database). + Save(context.Background()) + require.NoError(t, err) + + // Create submissions for both questions + createTestSubmission(t, entClient, user, publicQuestion, "SELECT * FROM test;", submission.StatusSuccess, time.Now()) + createTestSubmission(t, entClient, user, restrictedQuestion, "SELECT * FROM test;", submission.StatusSuccess, time.Now()) + + // Query with user with premium:read scope + var resp struct { + User struct { + SubmissionStatistics struct { + TotalQuestions int `json:"totalQuestions"` + AttemptedQuestions int `json:"attemptedQuestions"` + SolvedQuestions int `json:"solvedQuestions"` + SolvedQuestionByDifficulty []struct { + Difficulty string `json:"difficulty"` + SolvedQuestions int `json:"solvedQuestions"` + } `json:"solvedQuestionByDifficulty"` + } `json:"submissionStatistics"` + } `json:"user"` + } + + query := `query { + user(id: ` + strconv.Itoa(user.ID) + `) { + submissionStatistics { + totalQuestions + attemptedQuestions + solvedQuestions + solvedQuestionByDifficulty { + difficulty + solvedQuestions + } + } + } + }` + + err = gqlClient.Post(query, &resp, func(bd *client.Request) { + bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{ + UserID: 1, + Scopes: []string{"user:read", "premium:read"}, // Has premium:read + })) + }) + + require.NoError(t, err) + // Should count both questions + require.Equal(t, 2, resp.User.SubmissionStatistics.TotalQuestions) + require.Equal(t, 2, resp.User.SubmissionStatistics.AttemptedQuestions) + require.Equal(t, 2, resp.User.SubmissionStatistics.SolvedQuestions) + }) + + t.Run("filters by visible_scope - user with wildcard scope sees all questions", func(t *testing.T) { + entClient := testhelper.NewEntSqliteClient(t) + resolver := NewTestResolver(t, entClient, &mockAuthStorage{}) + + // Create test group and user + group, err := createTestGroup(t, entClient) + require.NoError(t, err) + + user, err := entClient.User.Create(). + SetName("testuser"). + SetEmail("test@example.com"). + SetGroup(group). + Save(context.Background()) + require.NoError(t, err) + + // Create test database + database := createTestDatabase(t, entClient) + + // Create public question (no visible_scope) + publicQuestion := createTestQuestionWithDifficulty(t, entClient, database, question.DifficultyEasy) + + // Create restricted question (with visible_scope) + restrictedQuestion, err := entClient.Question.Create(). + SetCategory("premium-query"). + SetDifficulty(question.DifficultyEasy). + SetTitle("Premium Question"). + SetDescription("Premium question"). + SetReferenceAnswer("SELECT * FROM test;"). + SetVisibleScope("premium:read"). + SetDatabase(database). + Save(context.Background()) + require.NoError(t, err) + + // Create submissions for both questions + createTestSubmission(t, entClient, user, publicQuestion, "SELECT * FROM test;", submission.StatusSuccess, time.Now()) + createTestSubmission(t, entClient, user, restrictedQuestion, "SELECT * FROM test;", submission.StatusSuccess, time.Now()) + + // Test the resolver method directly with wildcard scope + userResolver := &userResolver{resolver} + stats, err := userResolver.SubmissionStatistics(auth.WithUser(context.Background(), auth.TokenInfo{ + UserID: user.ID, + Scopes: []string{"*"}, + }), user) + + require.NoError(t, err) + // Should count both questions + require.Equal(t, 2, stats.TotalQuestions) + require.Equal(t, 2, stats.AttemptedQuestions) + require.Equal(t, 2, stats.SolvedQuestions) + }) } // Helper function to create a question with specific difficulty From 3ce892ed74c93399688e1613f53f83a960b50137 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Tue, 4 Nov 2025 04:45:33 +0800 Subject: [PATCH 5/6] refactor(graph): use same logic in applyQuestionVisibleScopeFilter --- graph/question.resolvers.go | 50 ------------------------------ graph/questions_utils.go | 62 +++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 50 deletions(-) create mode 100644 graph/questions_utils.go diff --git a/graph/question.resolvers.go b/graph/question.resolvers.go index cfbb8a0..10fa43d 100644 --- a/graph/question.resolvers.go +++ b/graph/question.resolvers.go @@ -8,8 +8,6 @@ import ( "context" "errors" "fmt" - "slices" - "strings" "entgo.io/ent/dialect/sql" "github.com/database-playground/backend-v2/ent" @@ -25,54 +23,6 @@ import ( "github.com/samber/lo" ) -// checkQuestionVisibleScope checks if the user has permission to access the question based on visible_scope. -// Returns nil if the user has access, or an error (ErrNotFound) if they don't. -func checkQuestionVisibleScope(ctx context.Context, question *ent.Question) error { - visibleScope := question.VisibleScope - // If visible_scope is empty, the question is visible to everyone - if strings.TrimSpace(visibleScope) == "" { - return nil - } - - // Get user from context - tokenInfo, ok := auth.GetUser(ctx) - if !ok { - // If no user context, but question has visible_scope, return not found - return defs.ErrNotFound - } - - // Check if user has the required scope - if !scope.ShouldAllow(visibleScope, tokenInfo.Scopes) { - return defs.ErrNotFound - } - - return nil -} - -// applyQuestionVisibleScopeFilter applies visible_scope filtering to a question query. -// If the user has wildcard scope "*", no filtering is applied. -// Otherwise, only questions with nil visible_scope or visible_scope matching user's scopes are included. -func applyQuestionVisibleScopeFilter(ctx context.Context, query *ent.QuestionQuery) *ent.QuestionQuery { - tokenInfo, ok := auth.GetUser(ctx) - if !ok { - // If no user context, only show questions without visible_scope - return query.Where(entQuestion.VisibleScopeIsNil()) - } - - // If user has full access, don't filter - if slices.Contains(tokenInfo.Scopes, "*") { - return query - } - - // Filter to show only questions with nil visible_scope or visible_scope matching user's scopes - return query.Where( - entQuestion.Or( - entQuestion.VisibleScopeIsNil(), - entQuestion.VisibleScopeIn(tokenInfo.Scopes...), - ), - ) -} - // CreateQuestion is the resolver for the createQuestion field. func (r *mutationResolver) CreateQuestion(ctx context.Context, input ent.CreateQuestionInput) (*ent.Question, error) { entClient := r.EntClient(ctx) diff --git a/graph/questions_utils.go b/graph/questions_utils.go new file mode 100644 index 0000000..ecd3fd6 --- /dev/null +++ b/graph/questions_utils.go @@ -0,0 +1,62 @@ +package graph + +import ( + "context" + "slices" + "strings" + + "github.com/database-playground/backend-v2/ent" + entQuestion "github.com/database-playground/backend-v2/ent/question" + "github.com/database-playground/backend-v2/graph/defs" + "github.com/database-playground/backend-v2/internal/auth" +) + +// checkQuestionVisibleScope checks if the user has permission to access the question based on visible_scope. +// Returns nil if the user has access, or an error (ErrNotFound) if they don't. +func checkQuestionVisibleScope(ctx context.Context, question *ent.Question) error { + visibleScope := question.VisibleScope + // If visible_scope is empty, the question is visible to everyone + if strings.TrimSpace(visibleScope) == "" { + return nil + } + + // Get user from context + tokenInfo, ok := auth.GetUser(ctx) + if !ok { + // If no user context, but question has visible_scope, return not found + return defs.ErrNotFound + } + + // Check if user has the required scope + for _, scope := range tokenInfo.Scopes { + if scope == "*" || scope == visibleScope { + return nil + } + } + + return defs.ErrNotFound +} + +// applyQuestionVisibleScopeFilter applies visible_scope filtering to a question query. +// If the user has wildcard scope "*", no filtering is applied. +// Otherwise, only questions with nil visible_scope or visible_scope matching user's scopes are included. +func applyQuestionVisibleScopeFilter(ctx context.Context, query *ent.QuestionQuery) *ent.QuestionQuery { + tokenInfo, ok := auth.GetUser(ctx) + if !ok { + // If no user context, only show questions without visible_scope + return query.Where(entQuestion.VisibleScopeIsNil()) + } + + // If user has full access, don't filter + if slices.Contains(tokenInfo.Scopes, "*") { + return query + } + + // Filter to show only questions with nil visible_scope or visible_scope matching user's scopes + return query.Where( + entQuestion.Or( + entQuestion.VisibleScopeIsNil(), + entQuestion.VisibleScopeIn(tokenInfo.Scopes...), + ), + ) +} From fbbc6bec7af87cfe1851fd0f95fc6ffbeb374f51 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Tue, 4 Nov 2025 04:45:57 +0800 Subject: [PATCH 6/6] docs: update development guide --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8082b9f..52433f6 100644 --- a/README.md +++ b/README.md @@ -55,13 +55,13 @@ go run ./cmd/backend 您需要安裝 Docker 才能執行測試。 ```shell -go test -v ./... +go test ./... ``` 如果您更動了 GraphQL 或 ent schema,也需要重新產生程式碼: ```shell -go generate ./... +go generate . ``` Linting & Formatting: