Consolidate frontend REST calls with GraphQL
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 1s
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 1s
Replace 5 separate REST calls on home page load with a single GraphQL query. Add homeData store that fetches posts, favorites, activities, spotify, and auth in one request. Convert all admin mutations and auth flows to use GraphQL. Add album images to Spotify GraphQL schema. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
22
backend/graph/activity.resolvers.go
Normal file
22
backend/graph/activity.resolvers.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package graph
|
||||
|
||||
// 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.88
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"adam-french.co.uk/backend/models"
|
||||
)
|
||||
|
||||
// ID is the resolver for the id field.
|
||||
func (r *activityResolver) ID(ctx context.Context, obj *models.Activity) (int, error) {
|
||||
return int(obj.ID), nil
|
||||
}
|
||||
|
||||
// Activity returns ActivityResolver implementation.
|
||||
func (r *Resolver) Activity() ActivityResolver { return &activityResolver{r} }
|
||||
|
||||
type activityResolver struct{ *Resolver }
|
||||
52
backend/graph/context.go
Normal file
52
backend/graph/context.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
userClaimsKey contextKey = "userClaims"
|
||||
ginContextKey contextKey = "ginContext"
|
||||
)
|
||||
|
||||
func UserClaimsFromCtx(ctx context.Context) *jwt.MapClaims {
|
||||
claims, ok := ctx.Value(userClaimsKey).(*jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
func UserIDFromCtx(ctx context.Context) (uint, bool) {
|
||||
claims := UserClaimsFromCtx(ctx)
|
||||
if claims == nil {
|
||||
return 0, false
|
||||
}
|
||||
idF, ok := (*claims)["id"].(float64)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return uint(idF), true
|
||||
}
|
||||
|
||||
func IsAdminFromCtx(ctx context.Context) bool {
|
||||
claims := UserClaimsFromCtx(ctx)
|
||||
if claims == nil {
|
||||
return false
|
||||
}
|
||||
admin, ok := (*claims)["admin"].(bool)
|
||||
return ok && admin
|
||||
}
|
||||
|
||||
func GinContextFromCtx(ctx context.Context) *gin.Context {
|
||||
gc, ok := ctx.Value(ginContextKey).(*gin.Context)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return gc
|
||||
}
|
||||
22
backend/graph/favorite.resolvers.go
Normal file
22
backend/graph/favorite.resolvers.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package graph
|
||||
|
||||
// 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.88
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"adam-french.co.uk/backend/models"
|
||||
)
|
||||
|
||||
// ID is the resolver for the id field.
|
||||
func (r *favoriteResolver) ID(ctx context.Context, obj *models.Favorite) (int, error) {
|
||||
return int(obj.ID), nil
|
||||
}
|
||||
|
||||
// Favorite returns FavoriteResolver implementation.
|
||||
func (r *Resolver) Favorite() FavoriteResolver { return &favoriteResolver{r} }
|
||||
|
||||
type favoriteResolver struct{ *Resolver }
|
||||
7656
backend/graph/generated.go
Normal file
7656
backend/graph/generated.go
Normal file
File diff suppressed because it is too large
Load Diff
27
backend/graph/message.resolvers.go
Normal file
27
backend/graph/message.resolvers.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package graph
|
||||
|
||||
// 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.88
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"adam-french.co.uk/backend/models"
|
||||
)
|
||||
|
||||
// ID is the resolver for the id field.
|
||||
func (r *messageResolver) ID(ctx context.Context, obj *models.Message) (int, error) {
|
||||
return int(obj.ID), nil
|
||||
}
|
||||
|
||||
// AuthorID is the resolver for the authorId field.
|
||||
func (r *messageResolver) AuthorID(ctx context.Context, obj *models.Message) (int, error) {
|
||||
return int(obj.AuthorID), nil
|
||||
}
|
||||
|
||||
// Message returns MessageResolver implementation.
|
||||
func (r *Resolver) Message() MessageResolver { return &messageResolver{r} }
|
||||
|
||||
type messageResolver struct{ *Resolver }
|
||||
25
backend/graph/middleware.go
Normal file
25
backend/graph/middleware.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"adam-french.co.uk/backend/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AuthContextMiddleware(auth *services.Auth) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := context.WithValue(c.Request.Context(), ginContextKey, c)
|
||||
|
||||
accessToken, err := c.Cookie("access_token")
|
||||
if err == nil {
|
||||
claims, err := auth.VerifyJWT(accessToken)
|
||||
if err == nil {
|
||||
ctx = context.WithValue(ctx, userClaimsKey, claims)
|
||||
}
|
||||
}
|
||||
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
80
backend/graph/model/models_gen.go
Normal file
80
backend/graph/model/models_gen.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"adam-french.co.uk/backend/models"
|
||||
)
|
||||
|
||||
type AuthPayload struct {
|
||||
User *models.User `json:"user"`
|
||||
}
|
||||
|
||||
type CreateActivityInput struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Link *string `json:"link,omitempty"`
|
||||
}
|
||||
|
||||
type CreateFavoriteInput struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Link *string `json:"link,omitempty"`
|
||||
}
|
||||
|
||||
type CreatePostInput struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type CreateUserInput struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type LoginInput struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type Mutation struct {
|
||||
}
|
||||
|
||||
type Query struct {
|
||||
}
|
||||
|
||||
type SpotifyAlbum struct {
|
||||
Name string `json:"name"`
|
||||
Images []*SpotifyImage `json:"images"`
|
||||
}
|
||||
|
||||
type SpotifyArtist struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type SpotifyImage struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type SpotifyPlaying struct {
|
||||
Playing bool `json:"playing"`
|
||||
Track *SpotifyTrack `json:"track,omitempty"`
|
||||
}
|
||||
|
||||
type SpotifyRecentItem struct {
|
||||
Track *SpotifyTrack `json:"track"`
|
||||
PlayedAt time.Time `json:"playedAt"`
|
||||
}
|
||||
|
||||
type SpotifyTrack struct {
|
||||
Name string `json:"name"`
|
||||
Artists []*SpotifyArtist `json:"artists"`
|
||||
Album *SpotifyAlbum `json:"album"`
|
||||
}
|
||||
|
||||
type UpdatePostInput struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
22
backend/graph/post.resolvers.go
Normal file
22
backend/graph/post.resolvers.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package graph
|
||||
|
||||
// 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.88
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"adam-french.co.uk/backend/models"
|
||||
)
|
||||
|
||||
// ID is the resolver for the id field.
|
||||
func (r *postResolver) ID(ctx context.Context, obj *models.Post) (int, error) {
|
||||
return int(obj.ID), nil
|
||||
}
|
||||
|
||||
// Post returns PostResolver implementation.
|
||||
func (r *Resolver) Post() PostResolver { return &postResolver{r} }
|
||||
|
||||
type postResolver struct{ *Resolver }
|
||||
12
backend/graph/resolver.go
Normal file
12
backend/graph/resolver.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package graph
|
||||
|
||||
import "adam-french.co.uk/backend/handlers"
|
||||
|
||||
// This file will not be regenerated automatically.
|
||||
//
|
||||
// It serves as dependency injection for your app, add any dependencies you require
|
||||
// here.
|
||||
|
||||
type Resolver struct {
|
||||
Store *handlers.Store
|
||||
}
|
||||
32
backend/graph/rowing.resolvers.go
Normal file
32
backend/graph/rowing.resolvers.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package graph
|
||||
|
||||
// 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.88
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"adam-french.co.uk/backend/models"
|
||||
)
|
||||
|
||||
// ID is the resolver for the id field.
|
||||
func (r *rowingResolver) ID(ctx context.Context, obj *models.Rowing) (int, error) {
|
||||
return int(obj.ID), nil
|
||||
}
|
||||
|
||||
// Time is the resolver for the time field.
|
||||
func (r *rowingResolver) Time(ctx context.Context, obj *models.Rowing) (int, error) {
|
||||
return int(obj.Time), nil
|
||||
}
|
||||
|
||||
// Distance is the resolver for the distance field.
|
||||
func (r *rowingResolver) Distance(ctx context.Context, obj *models.Rowing) (int, error) {
|
||||
return int(obj.Distance), nil
|
||||
}
|
||||
|
||||
// Rowing returns RowingResolver implementation.
|
||||
func (r *Resolver) Rowing() RowingResolver { return &rowingResolver{r} }
|
||||
|
||||
type rowingResolver struct{ *Resolver }
|
||||
456
backend/graph/schema.resolvers.go
Normal file
456
backend/graph/schema.resolvers.go
Normal file
@@ -0,0 +1,456 @@
|
||||
package graph
|
||||
|
||||
// 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.88
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"adam-french.co.uk/backend/graph/model"
|
||||
"adam-french.co.uk/backend/models"
|
||||
spotify "github.com/zmb3/spotify/v2"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Login is the resolver for the login field.
|
||||
func (r *mutationResolver) Login(ctx context.Context, input model.LoginInput) (*model.AuthPayload, error) {
|
||||
gc := GinContextFromCtx(ctx)
|
||||
if gc == nil {
|
||||
return nil, fmt.Errorf("could not get gin context")
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := r.Store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil {
|
||||
return nil, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(input.Password)); err != nil {
|
||||
return nil, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
tokens, err := r.Store.Auth.GenerateJWT(&user)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate tokens")
|
||||
}
|
||||
|
||||
gc.SetSameSite(http.SameSiteLaxMode)
|
||||
gc.SetCookie(
|
||||
"access_token",
|
||||
tokens.AccessToken,
|
||||
int(r.Store.Auth.Config.AccessTokenLifetime.Seconds()),
|
||||
r.Store.Auth.Config.Endpoint,
|
||||
r.Store.Auth.Config.Domain,
|
||||
true, true,
|
||||
)
|
||||
gc.SetCookie(
|
||||
"refresh_token",
|
||||
tokens.RefreshToken,
|
||||
int(r.Store.Auth.Config.RefreshTokenLifetime.Seconds()),
|
||||
r.Store.Auth.Config.Endpoint,
|
||||
r.Store.Auth.Config.Domain,
|
||||
true, true,
|
||||
)
|
||||
|
||||
return &model.AuthPayload{User: &user}, nil
|
||||
}
|
||||
|
||||
// Logout is the resolver for the logout field.
|
||||
func (r *mutationResolver) Logout(ctx context.Context) (bool, error) {
|
||||
gc := GinContextFromCtx(ctx)
|
||||
if gc == nil {
|
||||
return false, fmt.Errorf("could not get gin context")
|
||||
}
|
||||
|
||||
gc.SetSameSite(http.SameSiteLaxMode)
|
||||
gc.SetCookie("access_token", "", -1, "", "", true, true)
|
||||
gc.SetCookie("refresh_token", "", -1, "", "", true, true)
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// RefreshToken is the resolver for the refreshToken field.
|
||||
func (r *mutationResolver) RefreshToken(ctx context.Context) (*model.AuthPayload, error) {
|
||||
gc := GinContextFromCtx(ctx)
|
||||
if gc == nil {
|
||||
return nil, fmt.Errorf("could not get gin context")
|
||||
}
|
||||
|
||||
refreshToken, err := gc.Cookie("refresh_token")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unauthorized")
|
||||
}
|
||||
|
||||
claims, err := r.Store.Auth.VerifyJWT(refreshToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unauthorized")
|
||||
}
|
||||
|
||||
userIDF, ok := (*claims)["id"].(float64)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid token claims")
|
||||
}
|
||||
|
||||
var user models.User
|
||||
user.ID = uint(userIDF)
|
||||
if err := r.Store.DB.First(&user).Error; err != nil {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
tokens, err := r.Store.Auth.GenerateJWT(&user)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate tokens")
|
||||
}
|
||||
|
||||
gc.SetSameSite(http.SameSiteLaxMode)
|
||||
gc.SetCookie(
|
||||
"access_token",
|
||||
tokens.AccessToken,
|
||||
int(r.Store.Auth.Config.AccessTokenLifetime.Seconds()),
|
||||
r.Store.Auth.Config.Endpoint,
|
||||
r.Store.Auth.Config.Domain,
|
||||
true, true,
|
||||
)
|
||||
gc.SetCookie(
|
||||
"refresh_token",
|
||||
tokens.RefreshToken,
|
||||
int(r.Store.Auth.Config.RefreshTokenLifetime.Seconds()),
|
||||
r.Store.Auth.Config.Endpoint,
|
||||
r.Store.Auth.Config.Domain,
|
||||
true, true,
|
||||
)
|
||||
|
||||
return &model.AuthPayload{User: &user}, nil
|
||||
}
|
||||
|
||||
// CreatePost is the resolver for the createPost field.
|
||||
func (r *mutationResolver) CreatePost(ctx context.Context, input model.CreatePostInput) (*models.Post, error) {
|
||||
if !IsAdminFromCtx(ctx) {
|
||||
return nil, fmt.Errorf("admin access required")
|
||||
}
|
||||
|
||||
userID, ok := UserIDFromCtx(ctx)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unauthorized")
|
||||
}
|
||||
|
||||
post := models.Post{Title: input.Title, Content: input.Content, AuthorID: userID}
|
||||
if err := r.Store.DB.Create(&post).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &post, nil
|
||||
}
|
||||
|
||||
// UpdatePost is the resolver for the updatePost field.
|
||||
func (r *mutationResolver) UpdatePost(ctx context.Context, id int, input model.UpdatePostInput) (*models.Post, error) {
|
||||
if !IsAdminFromCtx(ctx) {
|
||||
return nil, fmt.Errorf("admin access required")
|
||||
}
|
||||
|
||||
userID, ok := UserIDFromCtx(ctx)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unauthorized")
|
||||
}
|
||||
|
||||
var post models.Post
|
||||
if err := r.Store.DB.First(&post, id).Error; err != nil {
|
||||
return nil, fmt.Errorf("post not found")
|
||||
}
|
||||
|
||||
if post.AuthorID != userID {
|
||||
return nil, fmt.Errorf("you can only update your own posts")
|
||||
}
|
||||
|
||||
post.Title = input.Title
|
||||
post.Content = input.Content
|
||||
if err := r.Store.DB.Save(&post).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &post, nil
|
||||
}
|
||||
|
||||
// DeletePost is the resolver for the deletePost field.
|
||||
func (r *mutationResolver) DeletePost(ctx context.Context, id int) (*models.Post, error) {
|
||||
if !IsAdminFromCtx(ctx) {
|
||||
return nil, fmt.Errorf("admin access required")
|
||||
}
|
||||
|
||||
userID, ok := UserIDFromCtx(ctx)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unauthorized")
|
||||
}
|
||||
|
||||
var post models.Post
|
||||
if err := r.Store.DB.First(&post, id).Error; err != nil {
|
||||
return nil, fmt.Errorf("post not found")
|
||||
}
|
||||
|
||||
if post.AuthorID != userID {
|
||||
return nil, fmt.Errorf("you can only delete your own posts")
|
||||
}
|
||||
|
||||
r.Store.DB.Delete(&post)
|
||||
return &post, nil
|
||||
}
|
||||
|
||||
// CreateUser is the resolver for the createUser field.
|
||||
func (r *mutationResolver) CreateUser(ctx context.Context, input model.CreateUserInput) (*models.User, error) {
|
||||
if !IsAdminFromCtx(ctx) {
|
||||
return nil, fmt.Errorf("admin access required")
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := models.User{Username: input.Username, Password: hashedPassword}
|
||||
if err := r.Store.DB.Create(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// DeleteUser is the resolver for the deleteUser field.
|
||||
func (r *mutationResolver) DeleteUser(ctx context.Context, id int) (*models.User, error) {
|
||||
if !IsAdminFromCtx(ctx) {
|
||||
return nil, fmt.Errorf("admin access required")
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := r.Store.DB.First(&user, id).Error; err != nil {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
if err := r.Store.DB.Delete(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// SetUserAdmin is the resolver for the setUserAdmin field.
|
||||
func (r *mutationResolver) SetUserAdmin(ctx context.Context, id int, admin bool) (*models.User, error) {
|
||||
if !IsAdminFromCtx(ctx) {
|
||||
return nil, fmt.Errorf("admin access required")
|
||||
}
|
||||
|
||||
callerID, ok := UserIDFromCtx(ctx)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unauthorized")
|
||||
}
|
||||
|
||||
if uint(id) == callerID {
|
||||
return nil, fmt.Errorf("cannot change your own admin status")
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := r.Store.DB.First(&user, id).Error; err != nil {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
user.Admin = admin
|
||||
if err := r.Store.DB.Save(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// CreateFavorite is the resolver for the createFavorite field.
|
||||
func (r *mutationResolver) CreateFavorite(ctx context.Context, input model.CreateFavoriteInput) (*models.Favorite, error) {
|
||||
if !IsAdminFromCtx(ctx) {
|
||||
return nil, fmt.Errorf("admin access required")
|
||||
}
|
||||
|
||||
favorite := models.Favorite{Type: input.Type, Name: input.Name, Link: input.Link}
|
||||
if err := r.Store.DB.Create(&favorite).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &favorite, nil
|
||||
}
|
||||
|
||||
// CreateActivity is the resolver for the createActivity field.
|
||||
func (r *mutationResolver) CreateActivity(ctx context.Context, input model.CreateActivityInput) (*models.Activity, error) {
|
||||
if !IsAdminFromCtx(ctx) {
|
||||
return nil, fmt.Errorf("admin access required")
|
||||
}
|
||||
|
||||
activity := models.Activity{Type: input.Type, Name: input.Name, Link: input.Link}
|
||||
if err := r.Store.DB.Create(&activity).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &activity, nil
|
||||
}
|
||||
|
||||
// Users is the resolver for the users field.
|
||||
func (r *queryResolver) Users(ctx context.Context) ([]*models.User, error) {
|
||||
var users []models.User
|
||||
if err := r.Store.DB.Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]*models.User, len(users))
|
||||
for i := range users {
|
||||
result[i] = &users[i]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// User is the resolver for the user field.
|
||||
func (r *queryResolver) User(ctx context.Context, id int) (*models.User, error) {
|
||||
var user models.User
|
||||
if err := r.Store.DB.First(&user, id).Error; err != nil {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// Posts is the resolver for the posts field.
|
||||
func (r *queryResolver) Posts(ctx context.Context) ([]*models.Post, error) {
|
||||
var posts []models.Post
|
||||
if err := r.Store.DB.Preload("Author").Order("created_at DESC").Find(&posts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]*models.Post, len(posts))
|
||||
for i := range posts {
|
||||
result[i] = &posts[i]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Post is the resolver for the post field.
|
||||
func (r *queryResolver) Post(ctx context.Context, id int) (*models.Post, error) {
|
||||
var post models.Post
|
||||
if err := r.Store.DB.Preload("Author").First(&post, id).Error; err != nil {
|
||||
return nil, fmt.Errorf("post not found")
|
||||
}
|
||||
return &post, nil
|
||||
}
|
||||
|
||||
// Activities is the resolver for the activities field.
|
||||
func (r *queryResolver) Activities(ctx context.Context) ([]*models.Activity, error) {
|
||||
var activities []models.Activity
|
||||
if err := r.Store.DB.Order("created_at DESC").Find(&activities).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]*models.Activity, len(activities))
|
||||
for i := range activities {
|
||||
result[i] = &activities[i]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Favorites is the resolver for the favorites field.
|
||||
func (r *queryResolver) Favorites(ctx context.Context) ([]*models.Favorite, error) {
|
||||
var favorites []models.Favorite
|
||||
if err := r.Store.DB.Order("created_at DESC").Find(&favorites).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]*models.Favorite, len(favorites))
|
||||
for i := range favorites {
|
||||
result[i] = &favorites[i]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RowingSessions is the resolver for the rowingSessions field.
|
||||
func (r *queryResolver) RowingSessions(ctx context.Context) ([]*models.Rowing, error) {
|
||||
var rows []models.Rowing
|
||||
if err := r.Store.DB.Order("created_at DESC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]*models.Rowing, len(rows))
|
||||
for i := range rows {
|
||||
result[i] = &rows[i]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Messages is the resolver for the messages field.
|
||||
func (r *queryResolver) Messages(ctx context.Context) ([]*models.Message, error) {
|
||||
var messages []models.Message
|
||||
if err := r.Store.DB.Order("created_at DESC").Find(&messages).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]*models.Message, len(messages))
|
||||
for i := range messages {
|
||||
result[i] = &messages[i]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SpotifyListening is the resolver for the spotifyListening field.
|
||||
func (r *queryResolver) SpotifyListening(ctx context.Context) (*model.SpotifyPlaying, error) {
|
||||
if r.Store.SpotifyClient == nil {
|
||||
return nil, fmt.Errorf("Spotify not authenticated")
|
||||
}
|
||||
|
||||
playing, err := r.Store.SpotifyClient.PlayerCurrentlyPlaying(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &model.SpotifyPlaying{Playing: playing.Playing}
|
||||
if playing.Item != nil {
|
||||
result.Track = mapSpotifyTrack(playing.Item)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SpotifyRecent is the resolver for the spotifyRecent field.
|
||||
func (r *queryResolver) SpotifyRecent(ctx context.Context) ([]*model.SpotifyRecentItem, error) {
|
||||
if r.Store.SpotifyClient == nil {
|
||||
return nil, fmt.Errorf("Spotify not authenticated")
|
||||
}
|
||||
|
||||
if r.Store.RecentSongsFresh() {
|
||||
return mapRecentItems(*r.Store.RecentSongs), nil
|
||||
}
|
||||
|
||||
opts := spotify.RecentlyPlayedOptions{Limit: 3}
|
||||
played, err := r.Store.SpotifyClient.PlayerRecentlyPlayedOpt(ctx, &opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.Store.RecentSongs = &played
|
||||
r.Store.RecentSongsFetchedAt = time.Now()
|
||||
|
||||
return mapRecentItems(played), nil
|
||||
}
|
||||
|
||||
// Me is the resolver for the me field.
|
||||
func (r *queryResolver) Me(ctx context.Context) (*models.User, error) {
|
||||
userID, ok := UserIDFromCtx(ctx)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unauthorized")
|
||||
}
|
||||
|
||||
var user models.User
|
||||
user.ID = userID
|
||||
if err := r.Store.DB.First(&user).Error; err != nil {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// Mutation returns MutationResolver implementation.
|
||||
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
|
||||
|
||||
// Query returns QueryResolver implementation.
|
||||
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
|
||||
|
||||
type mutationResolver struct{ *Resolver }
|
||||
type queryResolver struct{ *Resolver }
|
||||
|
||||
14
backend/graph/schema/activity.graphql
Normal file
14
backend/graph/schema/activity.graphql
Normal file
@@ -0,0 +1,14 @@
|
||||
type Activity {
|
||||
id: ID!
|
||||
createdAt: Time!
|
||||
updatedAt: Time!
|
||||
type: String!
|
||||
name: String!
|
||||
link: String
|
||||
}
|
||||
|
||||
input CreateActivityInput {
|
||||
type: String!
|
||||
name: String!
|
||||
link: String
|
||||
}
|
||||
8
backend/graph/schema/auth.graphql
Normal file
8
backend/graph/schema/auth.graphql
Normal file
@@ -0,0 +1,8 @@
|
||||
input LoginInput {
|
||||
username: String!
|
||||
password: String!
|
||||
}
|
||||
|
||||
type AuthPayload {
|
||||
user: User!
|
||||
}
|
||||
14
backend/graph/schema/favorite.graphql
Normal file
14
backend/graph/schema/favorite.graphql
Normal file
@@ -0,0 +1,14 @@
|
||||
type Favorite {
|
||||
id: ID!
|
||||
createdAt: Time!
|
||||
updatedAt: Time!
|
||||
type: String!
|
||||
name: String!
|
||||
link: String
|
||||
}
|
||||
|
||||
input CreateFavoriteInput {
|
||||
type: String!
|
||||
name: String!
|
||||
link: String
|
||||
}
|
||||
7
backend/graph/schema/message.graphql
Normal file
7
backend/graph/schema/message.graphql
Normal file
@@ -0,0 +1,7 @@
|
||||
type Message {
|
||||
id: ID!
|
||||
content: String!
|
||||
authorId: Int!
|
||||
fileUrl: String
|
||||
createdAt: Time!
|
||||
}
|
||||
18
backend/graph/schema/post.graphql
Normal file
18
backend/graph/schema/post.graphql
Normal file
@@ -0,0 +1,18 @@
|
||||
type Post {
|
||||
id: ID!
|
||||
createdAt: Time!
|
||||
updatedAt: Time!
|
||||
title: String!
|
||||
author: User
|
||||
content: String!
|
||||
}
|
||||
|
||||
input CreatePostInput {
|
||||
title: String!
|
||||
content: String!
|
||||
}
|
||||
|
||||
input UpdatePostInput {
|
||||
title: String!
|
||||
content: String!
|
||||
}
|
||||
9
backend/graph/schema/rowing.graphql
Normal file
9
backend/graph/schema/rowing.graphql
Normal file
@@ -0,0 +1,9 @@
|
||||
type Rowing {
|
||||
id: ID!
|
||||
createdAt: Time!
|
||||
date: Time!
|
||||
time: Int!
|
||||
distance: Int!
|
||||
timePer500m: Float!
|
||||
calories: Float!
|
||||
}
|
||||
29
backend/graph/schema/schema.graphql
Normal file
29
backend/graph/schema/schema.graphql
Normal file
@@ -0,0 +1,29 @@
|
||||
scalar Time
|
||||
|
||||
type Query {
|
||||
users: [User!]!
|
||||
user(id: ID!): User
|
||||
posts: [Post!]!
|
||||
post(id: ID!): Post
|
||||
activities: [Activity!]!
|
||||
favorites: [Favorite!]!
|
||||
rowingSessions: [Rowing!]!
|
||||
messages: [Message!]!
|
||||
spotifyListening: SpotifyPlaying
|
||||
spotifyRecent: [SpotifyRecentItem!]!
|
||||
me: User
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
login(input: LoginInput!): AuthPayload!
|
||||
logout: Boolean!
|
||||
refreshToken: AuthPayload!
|
||||
createPost(input: CreatePostInput!): Post!
|
||||
updatePost(id: ID!, input: UpdatePostInput!): Post!
|
||||
deletePost(id: ID!): Post!
|
||||
createUser(input: CreateUserInput!): User!
|
||||
deleteUser(id: ID!): User!
|
||||
setUserAdmin(id: ID!, admin: Boolean!): User!
|
||||
createFavorite(input: CreateFavoriteInput!): Favorite!
|
||||
createActivity(input: CreateActivityInput!): Activity!
|
||||
}
|
||||
28
backend/graph/schema/spotify.graphql
Normal file
28
backend/graph/schema/spotify.graphql
Normal file
@@ -0,0 +1,28 @@
|
||||
type SpotifyArtist {
|
||||
name: String!
|
||||
}
|
||||
|
||||
type SpotifyImage {
|
||||
url: String!
|
||||
}
|
||||
|
||||
type SpotifyAlbum {
|
||||
name: String!
|
||||
images: [SpotifyImage!]!
|
||||
}
|
||||
|
||||
type SpotifyTrack {
|
||||
name: String!
|
||||
artists: [SpotifyArtist!]!
|
||||
album: SpotifyAlbum!
|
||||
}
|
||||
|
||||
type SpotifyPlaying {
|
||||
playing: Boolean!
|
||||
track: SpotifyTrack
|
||||
}
|
||||
|
||||
type SpotifyRecentItem {
|
||||
track: SpotifyTrack!
|
||||
playedAt: Time!
|
||||
}
|
||||
12
backend/graph/schema/user.graphql
Normal file
12
backend/graph/schema/user.graphql
Normal file
@@ -0,0 +1,12 @@
|
||||
type User {
|
||||
id: ID!
|
||||
createdAt: Time!
|
||||
updatedAt: Time!
|
||||
username: String!
|
||||
admin: Boolean!
|
||||
}
|
||||
|
||||
input CreateUserInput {
|
||||
username: String!
|
||||
password: String!
|
||||
}
|
||||
51
backend/graph/spotify_helpers.go
Normal file
51
backend/graph/spotify_helpers.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"adam-french.co.uk/backend/graph/model"
|
||||
"github.com/zmb3/spotify/v2"
|
||||
)
|
||||
|
||||
func mapSpotifyImages(images []spotify.Image) []*model.SpotifyImage {
|
||||
result := make([]*model.SpotifyImage, len(images))
|
||||
for i, img := range images {
|
||||
result[i] = &model.SpotifyImage{URL: img.URL}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func mapSpotifyTrack(track *spotify.FullTrack) *model.SpotifyTrack {
|
||||
artists := make([]*model.SpotifyArtist, len(track.Artists))
|
||||
for i, a := range track.Artists {
|
||||
artists[i] = &model.SpotifyArtist{Name: a.Name}
|
||||
}
|
||||
return &model.SpotifyTrack{
|
||||
Name: track.Name,
|
||||
Artists: artists,
|
||||
Album: &model.SpotifyAlbum{
|
||||
Name: track.Album.Name,
|
||||
Images: mapSpotifyImages(track.Album.Images),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mapRecentItems(items []spotify.RecentlyPlayedItem) []*model.SpotifyRecentItem {
|
||||
result := make([]*model.SpotifyRecentItem, len(items))
|
||||
for i, item := range items {
|
||||
artists := make([]*model.SpotifyArtist, len(item.Track.Artists))
|
||||
for j, a := range item.Track.Artists {
|
||||
artists[j] = &model.SpotifyArtist{Name: a.Name}
|
||||
}
|
||||
result[i] = &model.SpotifyRecentItem{
|
||||
PlayedAt: item.PlayedAt,
|
||||
Track: &model.SpotifyTrack{
|
||||
Name: item.Track.Name,
|
||||
Artists: artists,
|
||||
Album: &model.SpotifyAlbum{
|
||||
Name: item.Track.Album.Name,
|
||||
Images: mapSpotifyImages(item.Track.Album.Images),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
22
backend/graph/user.resolvers.go
Normal file
22
backend/graph/user.resolvers.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package graph
|
||||
|
||||
// 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.88
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"adam-french.co.uk/backend/models"
|
||||
)
|
||||
|
||||
// ID is the resolver for the id field.
|
||||
func (r *userResolver) ID(ctx context.Context, obj *models.User) (int, error) {
|
||||
return int(obj.ID), nil
|
||||
}
|
||||
|
||||
// User returns UserResolver implementation.
|
||||
func (r *Resolver) User() UserResolver { return &userResolver{r} }
|
||||
|
||||
type userResolver struct{ *Resolver }
|
||||
Reference in New Issue
Block a user