From af50671f6be0ea637cc8c6f2eb670081519e6f84 Mon Sep 17 00:00:00 2001 From: StealWonders <6114858+StealWonders@users.noreply.github.com> Date: Sun, 6 Aug 2023 13:56:05 +0200 Subject: [PATCH] feat: Adds subscriptions for review creation and acceptance --- internal/graphql/gqlserver/generated.go | 228 ++++++++++++++++++ internal/graphql/graphql.go | 12 +- .../graphql/resolvers/mutations.resolvers.go | 25 +- internal/graphql/resolvers/resolver.go | 14 +- .../resolvers/subscriptions.resolvers.go | 62 +++++ internal/graphql/schema/subscriptions.graphql | 6 + 6 files changed, 336 insertions(+), 11 deletions(-) create mode 100644 internal/graphql/resolvers/subscriptions.resolvers.go create mode 100644 internal/graphql/schema/subscriptions.graphql diff --git a/internal/graphql/gqlserver/generated.go b/internal/graphql/gqlserver/generated.go index 225e7f8..8573d42 100644 --- a/internal/graphql/gqlserver/generated.go +++ b/internal/graphql/gqlserver/generated.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "io" "strconv" "sync" "sync/atomic" @@ -49,6 +50,7 @@ type ResolverRoot interface { Occurrence() OccurrenceResolver Query() QueryResolver Review() ReviewResolver + Subscription() SubscriptionResolver Tag() TagResolver } @@ -183,6 +185,11 @@ type ComplexityRoot struct { ReviewCount func(childComplexity int) int } + Subscription struct { + ReviewAccepted func(childComplexity int) int + ReviewCreated func(childComplexity int) int + } + Tag struct { Description func(childComplexity int) int IsAllergy func(childComplexity int) int @@ -259,6 +266,10 @@ type ReviewResolver interface { Images(ctx context.Context, obj *ent.Review) ([]*ent.Image, error) } +type SubscriptionResolver interface { + ReviewCreated(ctx context.Context) (<-chan *ent.Review, error) + ReviewAccepted(ctx context.Context) (<-chan *ent.Review, error) +} type TagResolver interface { Key(ctx context.Context, obj *ent.Tag) (string, error) } @@ -986,6 +997,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.ReviewMetadataOccurrence.ReviewCount(childComplexity), true + case "Subscription.reviewAccepted": + if e.complexity.Subscription.ReviewAccepted == nil { + break + } + + return e.complexity.Subscription.ReviewAccepted(childComplexity), true + + case "Subscription.reviewCreated": + if e.complexity.Subscription.ReviewCreated == nil { + break + } + + return e.complexity.Subscription.ReviewCreated(childComplexity), true + case "Tag.description": if e.complexity.Tag.Description == nil { break @@ -1139,6 +1164,23 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { var buf bytes.Buffer data.MarshalGQL(&buf) + return &graphql.Response{ + Data: buf.Bytes(), + } + } + case ast.Subscription: + next := ec._Subscription(ctx, rc.Operation.SelectionSet) + + var buf bytes.Buffer + return func(ctx context.Context) *graphql.Response { + buf.Reset() + data := next(ctx) + + if data == nil { + return nil + } + data.MarshalGQL(&buf) + return &graphql.Response{ Data: buf.Bytes(), } @@ -1467,6 +1509,13 @@ scalar UUID # File Upload scalar Upload`, BuiltIn: false}, + {Name: "../schema/subscriptions.graphql", Input: `type Subscription { + # Subscription fires when a review is created + reviewCreated: Review + # Subscription fires when a review is accepted + reviewAccepted: Review +} +`, BuiltIn: false}, {Name: "../schema/types.graphql", Input: `enum TagPriority { HIDE LOW @@ -7070,6 +7119,156 @@ func (ec *executionContext) fieldContext_ReviewMetadataOccurrence_reviewCount(ct return fc, nil } +func (ec *executionContext) _Subscription_reviewCreated(ctx context.Context, field graphql.CollectedField) (ret func(ctx context.Context) graphql.Marshaler) { + fc, err := ec.fieldContext_Subscription_reviewCreated(ctx, field) + if err != nil { + return nil + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Subscription().ReviewCreated(rctx) + }) + if err != nil { + ec.Error(ctx, err) + return nil + } + if resTmp == nil { + return nil + } + return func(ctx context.Context) graphql.Marshaler { + select { + case res, ok := <-resTmp.(<-chan *ent.Review): + if !ok { + return nil + } + return graphql.WriterFunc(func(w io.Writer) { + w.Write([]byte{'{'}) + graphql.MarshalString(field.Alias).MarshalGQL(w) + w.Write([]byte{':'}) + ec.marshalOReview2ᚖgithubᚗcomᚋmensattᚋbackendᚋinternalᚋdatabaseᚋentᚐReview(ctx, field.Selections, res).MarshalGQL(w) + w.Write([]byte{'}'}) + }) + case <-ctx.Done(): + return nil + } + } +} + +func (ec *executionContext) fieldContext_Subscription_reviewCreated(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Subscription", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Review_id(ctx, field) + case "occurrence": + return ec.fieldContext_Review_occurrence(ctx, field) + case "displayName": + return ec.fieldContext_Review_displayName(ctx, field) + case "images": + return ec.fieldContext_Review_images(ctx, field) + case "stars": + return ec.fieldContext_Review_stars(ctx, field) + case "text": + return ec.fieldContext_Review_text(ctx, field) + case "createdAt": + return ec.fieldContext_Review_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_Review_updatedAt(ctx, field) + case "acceptedAt": + return ec.fieldContext_Review_acceptedAt(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Review", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Subscription_reviewAccepted(ctx context.Context, field graphql.CollectedField) (ret func(ctx context.Context) graphql.Marshaler) { + fc, err := ec.fieldContext_Subscription_reviewAccepted(ctx, field) + if err != nil { + return nil + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Subscription().ReviewAccepted(rctx) + }) + if err != nil { + ec.Error(ctx, err) + return nil + } + if resTmp == nil { + return nil + } + return func(ctx context.Context) graphql.Marshaler { + select { + case res, ok := <-resTmp.(<-chan *ent.Review): + if !ok { + return nil + } + return graphql.WriterFunc(func(w io.Writer) { + w.Write([]byte{'{'}) + graphql.MarshalString(field.Alias).MarshalGQL(w) + w.Write([]byte{':'}) + ec.marshalOReview2ᚖgithubᚗcomᚋmensattᚋbackendᚋinternalᚋdatabaseᚋentᚐReview(ctx, field.Selections, res).MarshalGQL(w) + w.Write([]byte{'}'}) + }) + case <-ctx.Done(): + return nil + } + } +} + +func (ec *executionContext) fieldContext_Subscription_reviewAccepted(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Subscription", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Review_id(ctx, field) + case "occurrence": + return ec.fieldContext_Review_occurrence(ctx, field) + case "displayName": + return ec.fieldContext_Review_displayName(ctx, field) + case "images": + return ec.fieldContext_Review_images(ctx, field) + case "stars": + return ec.fieldContext_Review_stars(ctx, field) + case "text": + return ec.fieldContext_Review_text(ctx, field) + case "createdAt": + return ec.fieldContext_Review_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_Review_updatedAt(ctx, field) + case "acceptedAt": + return ec.fieldContext_Review_acceptedAt(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Review", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _Tag_key(ctx context.Context, field graphql.CollectedField, obj *ent.Tag) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Tag_key(ctx, field) if err != nil { @@ -12069,6 +12268,28 @@ func (ec *executionContext) _ReviewMetadataOccurrence(ctx context.Context, sel a return out } +var subscriptionImplementors = []string{"Subscription"} + +func (ec *executionContext) _Subscription(ctx context.Context, sel ast.SelectionSet) func(ctx context.Context) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, subscriptionImplementors) + ctx = graphql.WithFieldContext(ctx, &graphql.FieldContext{ + Object: "Subscription", + }) + if len(fields) != 1 { + ec.Errorf(ctx, "must subscribe to exactly one stream") + return nil + } + + switch fields[0].Name { + case "reviewCreated": + return ec._Subscription_reviewCreated(ctx, fields[0]) + case "reviewAccepted": + return ec._Subscription_reviewAccepted(ctx, fields[0]) + default: + panic("unknown field " + strconv.Quote(fields[0].Name)) + } +} + var tagImplementors = []string{"Tag"} func (ec *executionContext) _Tag(ctx context.Context, sel ast.SelectionSet, obj *ent.Tag) graphql.Marshaler { @@ -13737,6 +13958,13 @@ func (ec *executionContext) marshalOOccurrenceStatus2ᚖgithubᚗcomᚋmensatt return res } +func (ec *executionContext) marshalOReview2ᚖgithubᚗcomᚋmensattᚋbackendᚋinternalᚋdatabaseᚋentᚐReview(ctx context.Context, sel ast.SelectionSet, v *ent.Review) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._Review(ctx, sel, v) +} + func (ec *executionContext) unmarshalOReviewFilter2ᚖgithubᚗcomᚋmensattᚋbackendᚋinternalᚋgraphqlᚋmodelsᚐReviewFilter(ctx context.Context, v interface{}) (*models.ReviewFilter, error) { if v == nil { return nil, nil diff --git a/internal/graphql/graphql.go b/internal/graphql/graphql.go index 4782a47..c138c70 100644 --- a/internal/graphql/graphql.go +++ b/internal/graphql/graphql.go @@ -49,11 +49,13 @@ func graphqlHandler(params *GraphQLParams) gin.HandlerFunc { gqlserver.NewExecutableSchema( gqlserver.Config{ Resolvers: &resolvers.Resolver{ - Database: params.Database, - JWTKeyStore: params.JWTKeyStore, - VCSBuildInfo: vscBuildInfo, - ImageUploader: params.ImageUploader, - ImageBaseURL: params.ImageBaseURL, + Database: params.Database, + JWTKeyStore: params.JWTKeyStore, + VCSBuildInfo: vscBuildInfo, + ImageUploader: params.ImageUploader, + ImageBaseURL: params.ImageBaseURL, + ReviewAcceptedChannels: map[string]chan *ent.Review{}, + ReviewCreatedChannels: map[string]chan *ent.Review{}, }, Directives: gqlserver.DirectiveRoot{ Authenticated: directives.Authenticated, diff --git a/internal/graphql/resolvers/mutations.resolvers.go b/internal/graphql/resolvers/mutations.resolvers.go index a570458..8015a1e 100644 --- a/internal/graphql/resolvers/mutations.resolvers.go +++ b/internal/graphql/resolvers/mutations.resolvers.go @@ -397,6 +397,13 @@ func (r *mutationResolver) CreateReview(ctx context.Context, input models.Create return nil, fmt.Errorf("failed to commit transaction: %w", err) } + // notify all subscribers + r.mutex.Lock() + for _, channel := range r.ReviewCreatedChannels { + channel <- review + } + r.mutex.Unlock() + return review, err } @@ -407,6 +414,8 @@ func (r *mutationResolver) UpdateReview(ctx context.Context, input models.Update return nil, err } + oldAcceptedAt := review.AcceptedAt + queryBuilder := r.Database.Review.UpdateOne(review) if input.Occurrence != nil { @@ -429,7 +438,21 @@ func (r *mutationResolver) UpdateReview(ctx context.Context, input models.Update queryBuilder = queryBuilder.SetAcceptedAt(time.Now()) // approved (not null) at current time } - return queryBuilder.Save(ctx) + review, err = queryBuilder.Save(ctx) + if err != nil { + return nil, err + } + + // notify all subscribers (if approved) + if input.Approved != nil && oldAcceptedAt == nil { + r.mutex.Lock() + for _, channel := range r.ReviewAcceptedChannels { + channel <- review + } + r.mutex.Unlock() + } + + return review, nil } // DeleteReview is the resolver for the deleteReview field. diff --git a/internal/graphql/resolvers/resolver.go b/internal/graphql/resolvers/resolver.go index 4d296f1..de9f15e 100644 --- a/internal/graphql/resolvers/resolver.go +++ b/internal/graphql/resolvers/resolver.go @@ -4,6 +4,7 @@ import ( ent "github.com/mensatt/backend/internal/database/ent" "github.com/mensatt/backend/pkg/imageuploader" "github.com/mensatt/backend/pkg/utils" + "sync" ) // This file will not be regenerated automatically. @@ -11,9 +12,12 @@ import ( // It serves as dependency injection for your app, add any dependencies you require here. type Resolver struct { - Database *ent.Client - JWTKeyStore *utils.JWTKeyStore - VCSBuildInfo *utils.VCSBuildInfo - ImageUploader *imageuploader.ImageUploader - ImageBaseURL string + Database *ent.Client + JWTKeyStore *utils.JWTKeyStore + VCSBuildInfo *utils.VCSBuildInfo + ImageUploader *imageuploader.ImageUploader + ImageBaseURL string + ReviewCreatedChannels map[string]chan *ent.Review + ReviewAcceptedChannels map[string]chan *ent.Review + mutex sync.Mutex } diff --git a/internal/graphql/resolvers/subscriptions.resolvers.go b/internal/graphql/resolvers/subscriptions.resolvers.go new file mode 100644 index 0000000..742b37d --- /dev/null +++ b/internal/graphql/resolvers/subscriptions.resolvers.go @@ -0,0 +1,62 @@ +package resolvers + +// This file will be automatically regenerated based on the schema, any resolver implementations +// will be copied through when generating and any unknown code will be moved to the end. +// Code generated by github.com/99designs/gqlgen version v0.17.33 + +import ( + "context" + + "github.com/google/uuid" + "github.com/mensatt/backend/internal/database/ent" + "github.com/mensatt/backend/internal/graphql/gqlserver" +) + +// ReviewCreated is the resolver for the reviewCreated field. +func (r *subscriptionResolver) ReviewCreated(ctx context.Context) (<-chan *ent.Review, error) { + // Create a new channel for every subscription identified by a unique id + id := uuid.New().String() + channel := make(chan *ent.Review) + + // Start a goroutine that will clean up the channel when unsubscribed + go func() { + <-ctx.Done() // Block until the channel is closed + r.mutex.Lock() + delete(r.ReviewCreatedChannels, id) + r.mutex.Unlock() + }() + + // Add the channel to the map of channels + r.mutex.Lock() + r.ReviewCreatedChannels[id] = channel + r.mutex.Unlock() + + return channel, nil +} + +// ReviewAccepted is the resolver for the reviewAccepted field. +func (r *subscriptionResolver) ReviewAccepted(ctx context.Context) (<-chan *ent.Review, error) { + // Create a new channel for every subscription identified by a unique id + id := uuid.New().String() + channel := make(chan *ent.Review) + + // Start a goroutine that will clean up the channel when unsubscribed + go func() { + <-ctx.Done() // Block until the channel is closed + r.mutex.Lock() + delete(r.ReviewAcceptedChannels, id) + r.mutex.Unlock() + }() + + // Add the channel to the map of channels + r.mutex.Lock() + r.ReviewAcceptedChannels[id] = channel + r.mutex.Unlock() + + return channel, nil +} + +// Subscription returns gqlserver.SubscriptionResolver implementation. +func (r *Resolver) Subscription() gqlserver.SubscriptionResolver { return &subscriptionResolver{r} } + +type subscriptionResolver struct{ *Resolver } diff --git a/internal/graphql/schema/subscriptions.graphql b/internal/graphql/schema/subscriptions.graphql new file mode 100644 index 0000000..bd2dcd6 --- /dev/null +++ b/internal/graphql/schema/subscriptions.graphql @@ -0,0 +1,6 @@ +type Subscription { + # Subscription fires when a review is created + reviewCreated: Review + # Subscription fires when a review is accepted + reviewAccepted: Review +}