diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8248b41..2878687 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,8 @@ // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile "image": "mcr.microsoft.com/devcontainers/go:1-1.22-bookworm", "features": { - "ghcr.io/devcontainers/features/node:1": {} + "ghcr.io/devcontainers/features/node:1": {}, + "ghcr.io/balazs23/devcontainers-features/bazel:1": {} } // Features to add to the dev container. More info: https://containers.dev/features. diff --git a/server/go.mod b/server/go.mod index 498dad4..486af4b 100644 --- a/server/go.mod +++ b/server/go.mod @@ -13,5 +13,6 @@ require ( github.com/gorilla/websocket v1.5.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pgvector/pgvector-go v0.2.2 github.com/sosodev/duration v1.3.1 // indirect ) diff --git a/server/go.sum b/server/go.sum index a6a2189..0db62f2 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,3 +1,5 @@ +entgo.io/ent v0.13.1 h1:uD8QwN1h6SNphdCCzmkMN3feSUzNnVvV/WIkHKMbzOE= +entgo.io/ent v0.13.1/go.mod h1:qCEmo+biw3ccBn9OyL4ZK5dfpwg++l1Gxwac5B1206A= github.com/99designs/gqlgen v0.17.53 h1:FJOJaF96d7Y5EBpoaLG96fz1NR6B8bFdCZI1yZwYArM= github.com/99designs/gqlgen v0.17.53/go.mod h1:77/+pVe6zlTsz++oUg2m8VLgzdUPHxjoAG3BxI5y8Rc= github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= @@ -10,14 +12,36 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g= github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0= +github.com/go-pg/pg/v10 v10.11.0/go.mod h1:4BpHRoxE61y4Onpof3x1a2SQvi9c+q1dJnrNdMjsroA= +github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU= +github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pgvector/pgvector-go v0.2.2 h1:Q/oArmzgbEcio88q0tWQksv/u9Gnb1c3F1K2TnalxR0= +github.com/pgvector/pgvector-go v0.2.2/go.mod h1:u5sg3z9bnqVEdpe1pkTij8/rFhTaMCMNyQagPDLK8gQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= @@ -26,9 +50,39 @@ github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= +github.com/uptrace/bun v1.1.12 h1:sOjDVHxNTuM6dNGaba0wUuz7KvDE1BmNu9Gqs2gJSXQ= +github.com/uptrace/bun v1.1.12/go.mod h1:NPG6JGULBeQ9IU6yHp7YGELRa5Agmd7ATZdz4tGZ6z0= +github.com/uptrace/bun/dialect/pgdialect v1.1.12 h1:m/CM1UfOkoBTglGO5CUTKnIKKOApOYxkcP2qn0F9tJk= +github.com/uptrace/bun/dialect/pgdialect v1.1.12/go.mod h1:Ij6WIxQILxLlL2frUBxUBOZJtLElD2QQNDcu/PWDHTc= +github.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibggiLdTqjTtfHc1w= +github.com/uptrace/bun/driver/pgdriver v1.1.12/go.mod h1:ssYUP+qwSEgeDDS1xm2XBip9el1y9Mi5mTAvLoiADLM= github.com/vektah/gqlparser/v2 v2.5.16 h1:1gcmLTvs3JLKXckwCwlUagVn/IlV2bwqle0vJ0vy5p8= github.com/vektah/gqlparser/v2 v2.5.16/go.mod h1:1lz1OeCqgQbQepsGxPVywrjdBHW2T08PUS3pJqepRww= +github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94= +github.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ= +github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= +github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= +github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc= +github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= +gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +mellium.im/sasl v0.3.1 h1:wE0LW6g7U83vhvxjC1IY8DnXM+EU095yeo8XClvCdfo= +mellium.im/sasl v0.3.1/go.mod h1:xm59PUYpZHhgQ9ZqoJ5QaCqzWMi8IeS49dhp6plPCzw= diff --git a/server/graph/generated.go b/server/graph/generated.go index 16444da..6513602 100644 --- a/server/graph/generated.go +++ b/server/graph/generated.go @@ -82,7 +82,7 @@ type ComplexityRoot struct { Movie func(childComplexity int, id string) int Movies func(childComplexity int, page int, pageSize int) int Ratings func(childComplexity int, userID string) int - Recommendations func(childComplexity int, userID string) int + Recommendations func(childComplexity int, page int, pageSize int) int User func(childComplexity int, id string) int } @@ -108,7 +108,7 @@ type MutationResolver interface { type QueryResolver interface { Movie(ctx context.Context, id string) (*model.Movie, error) Movies(ctx context.Context, page int, pageSize int) (*model.MovieConnection, error) - Recommendations(ctx context.Context, userID string) ([]*model.Movie, error) + Recommendations(ctx context.Context, page int, pageSize int) (*model.MovieConnection, error) Ratings(ctx context.Context, userID string) ([]*model.Rating, error) User(ctx context.Context, id string) (*model.User, error) } @@ -293,7 +293,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return 0, false } - return e.complexity.Query.Recommendations(childComplexity, args["userId"].(string)), true + return e.complexity.Query.Recommendations(childComplexity, args["page"].(int), args["pageSize"].(int)), true case "Query.user": if e.complexity.Query.User == nil { @@ -769,32 +769,59 @@ func (ec *executionContext) field_Query_ratings_argsUserID( func (ec *executionContext) field_Query_recommendations_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} - arg0, err := ec.field_Query_recommendations_argsUserID(ctx, rawArgs) + arg0, err := ec.field_Query_recommendations_argsPage(ctx, rawArgs) if err != nil { return nil, err } - args["userId"] = arg0 + args["page"] = arg0 + arg1, err := ec.field_Query_recommendations_argsPageSize(ctx, rawArgs) + if err != nil { + return nil, err + } + args["pageSize"] = arg1 return args, nil } -func (ec *executionContext) field_Query_recommendations_argsUserID( +func (ec *executionContext) field_Query_recommendations_argsPage( ctx context.Context, rawArgs map[string]interface{}, -) (string, error) { +) (int, error) { // We won't call the directive if the argument is null. // Set call_argument_directives_with_null to true to call directives // even if the argument is null. - _, ok := rawArgs["userId"] + _, ok := rawArgs["page"] if !ok { - var zeroVal string + var zeroVal int return zeroVal, nil } - ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("userId")) - if tmp, ok := rawArgs["userId"]; ok { - return ec.unmarshalNID2string(ctx, tmp) + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("page")) + if tmp, ok := rawArgs["page"]; ok { + return ec.unmarshalNInt2int(ctx, tmp) } - var zeroVal string + var zeroVal int + return zeroVal, nil +} + +func (ec *executionContext) field_Query_recommendations_argsPageSize( + ctx context.Context, + rawArgs map[string]interface{}, +) (int, error) { + // We won't call the directive if the argument is null. + // Set call_argument_directives_with_null to true to call directives + // even if the argument is null. + _, ok := rawArgs["pageSize"] + if !ok { + var zeroVal int + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("pageSize")) + if tmp, ok := rawArgs["pageSize"]; ok { + return ec.unmarshalNInt2int(ctx, tmp) + } + + var zeroVal int return zeroVal, nil } @@ -1765,7 +1792,7 @@ func (ec *executionContext) _Query_recommendations(ctx context.Context, field gr }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Query().Recommendations(rctx, fc.Args["userId"].(string)) + return ec.resolvers.Query().Recommendations(rctx, fc.Args["page"].(int), fc.Args["pageSize"].(int)) }) if err != nil { ec.Error(ctx, err) @@ -1777,9 +1804,9 @@ func (ec *executionContext) _Query_recommendations(ctx context.Context, field gr } return graphql.Null } - res := resTmp.([]*model.Movie) + res := resTmp.(*model.MovieConnection) fc.Result = res - return ec.marshalNMovie2ᚕᚖgithubᚗcomᚋAzanulᚋNextᚑWatchᚋgraphᚋmodelᚐMovieᚄ(ctx, field.Selections, res) + return ec.marshalNMovieConnection2ᚖgithubᚗcomᚋAzanulᚋNextᚑWatchᚋgraphᚋmodelᚐMovieConnection(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_Query_recommendations(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -1790,22 +1817,14 @@ func (ec *executionContext) fieldContext_Query_recommendations(ctx context.Conte IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { - case "id": - return ec.fieldContext_Movie_id(ctx, field) - case "title": - return ec.fieldContext_Movie_title(ctx, field) - case "genre": - return ec.fieldContext_Movie_genre(ctx, field) - case "year": - return ec.fieldContext_Movie_year(ctx, field) - case "wiki": - return ec.fieldContext_Movie_wiki(ctx, field) - case "plot": - return ec.fieldContext_Movie_plot(ctx, field) - case "cast": - return ec.fieldContext_Movie_cast(ctx, field) + case "edges": + return ec.fieldContext_MovieConnection_edges(ctx, field) + case "pageInfo": + return ec.fieldContext_MovieConnection_pageInfo(ctx, field) + case "totalCount": + return ec.fieldContext_MovieConnection_totalCount(ctx, field) } - return nil, fmt.Errorf("no field named %q was found under type Movie", field.Name) + return nil, fmt.Errorf("no field named %q was found under type MovieConnection", field.Name) }, } defer func() { @@ -5195,50 +5214,6 @@ func (ec *executionContext) marshalNInt2int(ctx context.Context, sel ast.Selecti return res } -func (ec *executionContext) marshalNMovie2ᚕᚖgithubᚗcomᚋAzanulᚋNextᚑWatchᚋgraphᚋmodelᚐMovieᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.Movie) graphql.Marshaler { - ret := make(graphql.Array, len(v)) - var wg sync.WaitGroup - isLen1 := len(v) == 1 - if !isLen1 { - wg.Add(len(v)) - } - for i := range v { - i := i - fc := &graphql.FieldContext{ - Index: &i, - Result: &v[i], - } - ctx := graphql.WithFieldContext(ctx, fc) - f := func(i int) { - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - ret = nil - } - }() - if !isLen1 { - defer wg.Done() - } - ret[i] = ec.marshalNMovie2ᚖgithubᚗcomᚋAzanulᚋNextᚑWatchᚋgraphᚋmodelᚐMovie(ctx, sel, v[i]) - } - if isLen1 { - f(i) - } else { - go f(i) - } - - } - wg.Wait() - - for _, e := range ret { - if e == graphql.Null { - return graphql.Null - } - } - - return ret -} - func (ec *executionContext) marshalNMovie2ᚖgithubᚗcomᚋAzanulᚋNextᚑWatchᚋgraphᚋmodelᚐMovie(ctx context.Context, sel ast.SelectionSet, v *model.Movie) graphql.Marshaler { if v == nil { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { diff --git a/server/graph/resolver.go b/server/graph/resolver.go index 7dac6fd..68f1f2c 100644 --- a/server/graph/resolver.go +++ b/server/graph/resolver.go @@ -9,4 +9,5 @@ import "github.com/Azanul/Next-Watch/internal/services" type Resolver struct { services.RatingService services.MovieService + services.RecommendationService } diff --git a/server/graph/schema.graphqls b/server/graph/schema.graphqls index 6466f1d..c4a56f0 100644 --- a/server/graph/schema.graphqls +++ b/server/graph/schema.graphqls @@ -55,7 +55,7 @@ type PageInfo { type Query { movie(id: ID!): Movie movies(page: Int!, pageSize: Int!): MovieConnection! - recommendations(userId: ID!): [Movie!]! + recommendations(page: Int!, pageSize: Int!): MovieConnection! ratings(userId: ID!): [Rating!]! user(id: ID!): User! diff --git a/server/graph/schema.resolvers.go b/server/graph/schema.resolvers.go index b1c2011..8bb9738 100644 --- a/server/graph/schema.resolvers.go +++ b/server/graph/schema.resolvers.go @@ -22,10 +22,7 @@ func (r *mutationResolver) RateMovie(ctx context.Context, movieID string, score } // Validate inputs - var currentUserUUID, movieUUID uuid.UUID - if currentUserUUID, err = uuid.Parse(currentUser.ID); err != nil { - return nil, errors.New("Invalid user id") - } + var movieUUID uuid.UUID if movieUUID, err = uuid.Parse(movieID); err != nil { return nil, errors.New("Invalid movie id") } @@ -34,7 +31,7 @@ func (r *mutationResolver) RateMovie(ctx context.Context, movieID string, score } // Call service to rate movie - rating, err := r.RatingService.RateMovie(ctx, currentUserUUID, movieUUID, score) + rating, err := r.RatingService.RateMovie(ctx, currentUser.ID, movieUUID, score) if err != nil { return nil, err } @@ -72,7 +69,7 @@ func (r *mutationResolver) DeleteRating(ctx context.Context, id string) (bool, e // Check if the user is authorized to delete this rating isAdmin := currentUser.Role == "ADMIN" - isOwner := rating.UserID.String() == currentUser.ID + isOwner := rating.UserID == currentUser.ID if !isAdmin && !isOwner { return false, errors.New("not authorized to delete this rating") @@ -148,8 +145,48 @@ func (r *queryResolver) Movies(ctx context.Context, page int, pageSize int) (*mo } // Recommendations is the resolver for the recommendations field. -func (r *queryResolver) Recommendations(ctx context.Context, userID string) ([]*model.Movie, error) { - panic(fmt.Errorf("not implemented: Recommendations - recommendations")) +func (r *queryResolver) Recommendations(ctx context.Context, page int, pageSize int) (*model.MovieConnection, error) { + // Get current user from context + currentUser, err := auth.GetUserFromContext(ctx) + if err != nil { + return nil, err + } + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 10 // Default page size + } + + moviePage, err := r.RecommendationService.GetSimilarMovies(ctx, currentUser.Taste, page, pageSize) + if err != nil { + return nil, err + } + + edges := make([]*model.MovieEdge, len(moviePage.Movies)) + for i, movie := range moviePage.Movies { + edges[i] = &model.MovieEdge{ + Node: &model.Movie{ + ID: movie.ID.String(), + Title: movie.Title, + Genre: movie.Genre, + Year: movie.Year, + Wiki: movie.Wiki, + Plot: movie.Plot, + Cast: movie.Cast, + }, + } + } + + return &model.MovieConnection{ + Edges: edges, + PageInfo: &model.PageInfo{ + HasNextPage: moviePage.HasNextPage, + HasPreviousPage: moviePage.HasPreviousPage, + }, + TotalCount: moviePage.TotalCount, + }, nil } // Ratings is the resolver for the ratings field. diff --git a/server/internal/auth/auth.go b/server/internal/auth/auth.go index 0c97a65..b53e586 100644 --- a/server/internal/auth/auth.go +++ b/server/internal/auth/auth.go @@ -4,14 +4,14 @@ import ( "context" "errors" - "github.com/Azanul/Next-Watch/graph/model" + "github.com/Azanul/Next-Watch/internal/models" ) -func GetUserFromContext(ctx context.Context) (*model.User, error) { +func GetUserFromContext(ctx context.Context) (*models.User, error) { user := ctx.Value("user") if user == nil { return nil, errors.New("user not found") } - return user.(*model.User), nil + return user.(*models.User), nil } diff --git a/server/internal/models/models.go b/server/internal/models/models.go index 200dcd8..63325c5 100644 --- a/server/internal/models/models.go +++ b/server/internal/models/models.go @@ -4,23 +4,26 @@ import ( "time" "github.com/google/uuid" + "github.com/pgvector/pgvector-go" ) type Movie struct { - ID uuid.UUID `json:"id"` - Title string `json:"title"` - Genre string `json:"genre"` - Year int `json:"year"` - Wiki string `json:"wiki"` - Plot string `json:"plot"` - Cast string `json:"cast"` + ID uuid.UUID `json:"id"` + Title string `json:"title"` + Genre string `json:"genre"` + Year int `json:"year"` + Wiki string `json:"wiki"` + Plot string `json:"plot"` + Cast string `json:"cast"` + Embedding pgvector.Vector } type User struct { - ID uuid.UUID `json:"id"` - Email string `json:"email"` - Role string `json:"role"` - CreatedAt time.Time `json:"createdAt"` + ID uuid.UUID `json:"id"` + Email string `json:"email"` + Role string `json:"role"` + Taste pgvector.Vector `json:"-"` + CreatedAt time.Time `json:"createdAt"` } type Rating struct { diff --git a/server/internal/repository/movie_repository.go b/server/internal/repository/movie_repository.go index ba35412..d21fc38 100644 --- a/server/internal/repository/movie_repository.go +++ b/server/internal/repository/movie_repository.go @@ -8,6 +8,7 @@ import ( "github.com/Azanul/Next-Watch/internal/models" "github.com/google/uuid" + "github.com/pgvector/pgvector-go" ) type MovieRepository struct { @@ -113,24 +114,72 @@ func (r *MovieRepository) GetByTitle(ctx context.Context, title string) (*models return &movie, nil } +func (r *MovieRepository) GetSimilarMovies(ctx context.Context, embedding pgvector.Vector, page, pageSize int) (*MoviePage, error) { + offset := (page - 1) * pageSize + + // Query to get the movies similar to taste + query := `SELECT id, title, genre, release_year FROM movies + ORDER BY embedding <-> $1 + LIMIT $2 OFFSET $3` + + rows, err := r.db.QueryContext(ctx, query, embedding, pageSize+1, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var movies []*models.Movie + for rows.Next() { + var movie models.Movie + err := rows.Scan(&movie.ID, &movie.Title, &movie.Genre, &movie.Year, &movie.Wiki, &movie.Plot, &movie.Cast) + if err != nil { + return nil, err + } + movies = append(movies, &movie) + } + + if err = rows.Err(); err != nil { + return nil, err + } + + // Query to get the total count of movies + var totalCount int + err = r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM movies").Scan(&totalCount) + if err != nil { + return nil, err + } + + hasNextPage := len(movies) > pageSize + if hasNextPage { + movies = movies[:pageSize] + } + + return &MoviePage{ + Movies: movies, + TotalCount: totalCount, + HasNextPage: hasNextPage, + HasPreviousPage: page > 1, + }, nil +} + func (r *MovieRepository) Create(ctx context.Context, movie *models.Movie) error { - query := `INSERT INTO movies (id, title, genre, year, wiki, plot, cast) + query := `INSERT INTO movies (id, title, genre, year, wiki, plot, cast, embedding) VALUES ($1, $2, $3, $4, $5, $6)` movie.ID = uuid.New() _, err := r.db.ExecContext(ctx, query, - movie.ID, movie.Genre, movie.Year, movie.Wiki, movie.Plot, movie.Cast, + movie.ID, movie.Genre, movie.Year, movie.Wiki, movie.Plot, movie.Cast, movie.Embedding, ) return err } func (r *MovieRepository) Update(ctx context.Context, movie *models.Movie) error { query := `UPDATE movies - SET title = $1, genre = $2, year = $3, wiki = $4, plot = $5, cast = $6, - WHERE id = $7` + SET title = $1, genre = $2, year = $3, wiki = $4, plot = $5, cast = $6, embedding = $7 + WHERE id = $8` - _, err := r.db.ExecContext(ctx, query, movie.Title, movie.Genre, movie.Year, movie.Wiki, movie.Plot, movie.Cast, movie.ID) + _, err := r.db.ExecContext(ctx, query, movie.Title, movie.Genre, movie.Year, movie.Wiki, movie.Plot, movie.Cast, movie.Embedding, movie.ID) return err } diff --git a/server/internal/services/recommendation_service.go b/server/internal/services/recommendation_service.go new file mode 100644 index 0000000..a9389ff --- /dev/null +++ b/server/internal/services/recommendation_service.go @@ -0,0 +1,24 @@ +package services + +import ( + "context" + + "github.com/Azanul/Next-Watch/internal/repository" + "github.com/pgvector/pgvector-go" +) + +type RecommendationService struct { + ratingRepo *repository.RatingRepository + movieRepo *repository.MovieRepository +} + +func NewRecommendationService(ratingRepo *repository.RatingRepository, movieRepo *repository.MovieRepository) *RecommendationService { + return &RecommendationService{ + ratingRepo: ratingRepo, + movieRepo: movieRepo, + } +} + +func (s *RecommendationService) GetSimilarMovies(ctx context.Context, taste_embedding pgvector.Vector, page, pageSize int) (*repository.MoviePage, error) { + return s.movieRepo.GetSimilarMovies(ctx, taste_embedding, page, pageSize) +}