diff --git a/internal/command/command.go b/internal/command/command.go index b1ad5b2..e2187a1 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -51,6 +51,9 @@ func NewRegistry() *CommandRegistry { aggCommand(), addFeedCommand(), feedsCommand(), + followCommand(), + followingCommand(), + unfollowCommand(), } for _, cmd := range commands { @@ -194,7 +197,7 @@ func aggCommand() *Command { } func addFeedCommand() *Command { - return newCommand("addfeed", func(s *state.State) error { + return newCommand("addfeed", requiresLoggedInUser(func(s *state.State, currentUser database.User) error { if len(s.Arguments) < 2 { return fmt.Errorf("Did not receive expected feed name and url") } @@ -212,12 +215,6 @@ func addFeedCommand() *Command { return fmt.Errorf("Feed url '%s' is not a valid url", feedUrl) } - currentUser, err := s.GetCurrentUser(context.Background()) - - if err != nil { - return fmt.Errorf("Currently logged in user does not exist. Cannot add feed for non-existent user.") - } - createFeedParams := database.CreateFeedParams{ ID: uuid.New(), UserID: currentUser.ID, @@ -233,14 +230,28 @@ func addFeedCommand() *Command { return err } - fmt.Printf("Successfully added feed '%s' with url '%s'\n", createdFeed.Name, createdFeed.Url) + createFollowParams := database.CreateFollowParams{ + ID: uuid.New(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + UserID: currentUser.ID, + FeedID: createdFeed.ID, + } + + _, err = s.Database.CreateFollow(context.Background(), createFollowParams) + + if err != nil { + return err + } + + fmt.Printf("Successfully added and followed feed '%s' with url '%s'\n", createdFeed.Name, createdFeed.Url) fmt.Printf(" Id => %s\n", createdFeed.ID) fmt.Printf(" UserId => %s\n", createdFeed.UserID) fmt.Printf(" CreatedAt => %s\n", createdFeed.CreatedAt) fmt.Printf(" UpdatedAt => %s\n", createdFeed.UpdatedAt) return nil - }) + })) } func feedsCommand() *Command { @@ -266,3 +277,84 @@ func feedsCommand() *Command { return nil }) } + +func followCommand() *Command { + return newCommand("follow", requiresLoggedInUser(func(s *state.State, currentUser database.User) error { + if len(s.Arguments) == 0 { + return fmt.Errorf("Did not receive expected url argument") + } + + urlForFeedToFollow := s.Arguments[0] + + feed, err := s.Database.GetFeedByUrl(context.Background(), urlForFeedToFollow) + + if err != nil { + return fmt.Errorf("Unable to find feed with url '%s'", urlForFeedToFollow) + } + + createFollowParams := database.CreateFollowParams{ + ID: uuid.New(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + FeedID: feed.ID, + UserID: currentUser.ID, + } + + createdFollow, err := s.Database.CreateFollow(context.Background(), createFollowParams) + + if err != nil { + return fmt.Errorf("Unable to follow feed '%s'", feed.Name) + } + + fmt.Printf("'%s' successfully followed feed '%s'", createdFollow.UserName, createdFollow.FeedName) + + return nil + })) +} + +func followingCommand() *Command { + return newCommand("following", requiresLoggedInUser(func(s *state.State, currentUser database.User) error { + follows, err := s.Database.GetFollowsForUser(context.Background(), currentUser.ID) + + if err != nil { + return fmt.Errorf("Unable to find follows for user") + } + + for _, follow := range follows { + fmt.Printf("* %s", follow.FeedName) + } + + return nil + })) +} + +func unfollowCommand() *Command { + return newCommand("unfollow", requiresLoggedInUser(func(s *state.State, currentUser database.User) error { + if len(s.Arguments) == 0 { + return fmt.Errorf("Did not receive expected feed url argument") + } + + feedUrl := s.Arguments[0] + + feed, err := s.Database.GetFeedByUrl(context.Background(), feedUrl) + + if err != nil { + return fmt.Errorf("Unable to find feed with url '%s'", feedUrl) + } + + deleteFollowParams := database.DeleteFollowForUserParams{ + UserID: currentUser.ID, + FeedID: feed.ID, + } + + err = s.Database.DeleteFollowForUser(context.Background(), deleteFollowParams) + + if err != nil { + return fmt.Errorf("Unable to unfollow feed '%s'", feed.Name) + } + + fmt.Printf("Successfully unfollowed feed '%s'", feed.Name) + + return nil + })) +} diff --git a/internal/command/middleware.go b/internal/command/middleware.go new file mode 100644 index 0000000..af941f3 --- /dev/null +++ b/internal/command/middleware.go @@ -0,0 +1,20 @@ +package command + +import ( + "context" + + "github.com/StevanFreeborn/gator/internal/database" + "github.com/StevanFreeborn/gator/internal/state" +) + +func requiresLoggedInUser(handler func(*state.State, database.User) error) CommandHandler { + return func(s *state.State) error { + loggedInUser, err := s.GetCurrentUser(context.Background()) + + if err != nil { + return err + } + + return handler(s, loggedInUser) + } +} diff --git a/internal/database/feeds.sql.go b/internal/database/feeds.sql.go index 6ea9520..a964550 100644 --- a/internal/database/feeds.sql.go +++ b/internal/database/feeds.sql.go @@ -88,3 +88,22 @@ func (q *Queries) GetAllFeeds(ctx context.Context) ([]Feed, error) { } return items, nil } + +const getFeedByUrl = `-- name: GetFeedByUrl :one +SELECT id, created_at, updated_at, name, url, user_id FROM feeds +WHERE url = $1 +` + +func (q *Queries) GetFeedByUrl(ctx context.Context, url string) (Feed, error) { + row := q.db.QueryRowContext(ctx, getFeedByUrl, url) + var i Feed + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Name, + &i.Url, + &i.UserID, + ) + return i, err +} diff --git a/internal/database/follows.sql.go b/internal/database/follows.sql.go new file mode 100644 index 0000000..a5887d6 --- /dev/null +++ b/internal/database/follows.sql.go @@ -0,0 +1,135 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: follows.sql + +package database + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +const createFollow = `-- name: CreateFollow :one +WITH inserted_follow as ( + INSERT INTO follows (id, created_at, updated_at, feed_id, user_id) + VALUES ( + $1, + $2, + $3, + $4, + $5 + ) + RETURNING id, created_at, updated_at, user_id, feed_id +) +SELECT inserted_follow.id, inserted_follow.created_at, inserted_follow.updated_at, inserted_follow.user_id, inserted_follow.feed_id, + feeds.name as feed_name, + users.name as user_name +FROM inserted_follow +INNER JOIN users + ON inserted_follow.user_id = users.id +INNER JOIN feeds + ON inserted_follow.feed_id = feeds.id +` + +type CreateFollowParams struct { + ID uuid.UUID + CreatedAt time.Time + UpdatedAt time.Time + FeedID uuid.UUID + UserID uuid.UUID +} + +type CreateFollowRow struct { + ID uuid.UUID + CreatedAt time.Time + UpdatedAt time.Time + UserID uuid.UUID + FeedID uuid.UUID + FeedName string + UserName string +} + +func (q *Queries) CreateFollow(ctx context.Context, arg CreateFollowParams) (CreateFollowRow, error) { + row := q.db.QueryRowContext(ctx, createFollow, + arg.ID, + arg.CreatedAt, + arg.UpdatedAt, + arg.FeedID, + arg.UserID, + ) + var i CreateFollowRow + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.UserID, + &i.FeedID, + &i.FeedName, + &i.UserName, + ) + return i, err +} + +const deleteFollowForUser = `-- name: DeleteFollowForUser :exec +DELETE FROM follows +WHERE feed_id = $1 AND user_id = $2 +` + +type DeleteFollowForUserParams struct { + FeedID uuid.UUID + UserID uuid.UUID +} + +func (q *Queries) DeleteFollowForUser(ctx context.Context, arg DeleteFollowForUserParams) error { + _, err := q.db.ExecContext(ctx, deleteFollowForUser, arg.FeedID, arg.UserID) + return err +} + +const getFollowsForUser = `-- name: GetFollowsForUser :many +SELECT follows.id, follows.created_at, follows.updated_at, follows.user_id, follows.feed_id, feeds.name as feed_name FROM follows +INNER JOIN feeds + ON follows.feed_id = feeds.id +WHERE follows.user_id = $1 +` + +type GetFollowsForUserRow struct { + ID uuid.UUID + CreatedAt time.Time + UpdatedAt time.Time + UserID uuid.UUID + FeedID uuid.UUID + FeedName string +} + +func (q *Queries) GetFollowsForUser(ctx context.Context, userID uuid.UUID) ([]GetFollowsForUserRow, error) { + rows, err := q.db.QueryContext(ctx, getFollowsForUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetFollowsForUserRow + for rows.Next() { + var i GetFollowsForUserRow + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.UserID, + &i.FeedID, + &i.FeedName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/database/models.go b/internal/database/models.go index 257fe55..613a51f 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -19,6 +19,14 @@ type Feed struct { UserID uuid.UUID } +type Follow struct { + ID uuid.UUID + CreatedAt time.Time + UpdatedAt time.Time + UserID uuid.UUID + FeedID uuid.UUID +} + type User struct { ID uuid.UUID CreatedAt time.Time diff --git a/sql/queries/feeds.sql b/sql/queries/feeds.sql index 3872061..59f4032 100644 --- a/sql/queries/feeds.sql +++ b/sql/queries/feeds.sql @@ -12,3 +12,7 @@ RETURNING *; -- name: GetAllFeeds :many SELECT id, created_at, updated_at, name, url, user_id FROM feeds; + +-- name: GetFeedByUrl :one +SELECT id, created_at, updated_at, name, url, user_id FROM feeds +WHERE url = $1; diff --git a/sql/queries/follows.sql b/sql/queries/follows.sql new file mode 100644 index 0000000..1aa216d --- /dev/null +++ b/sql/queries/follows.sql @@ -0,0 +1,30 @@ +-- name: CreateFollow :one +WITH inserted_follow as ( + INSERT INTO follows (id, created_at, updated_at, feed_id, user_id) + VALUES ( + $1, + $2, + $3, + $4, + $5 + ) + RETURNING * +) +SELECT inserted_follow.*, + feeds.name as feed_name, + users.name as user_name +FROM inserted_follow +INNER JOIN users + ON inserted_follow.user_id = users.id +INNER JOIN feeds + ON inserted_follow.feed_id = feeds.id; + +-- name: GetFollowsForUser :many +SELECT follows.*, feeds.name as feed_name FROM follows +INNER JOIN feeds + ON follows.feed_id = feeds.id +WHERE follows.user_id = $1; + +-- name: DeleteFollowForUser :exec +DELETE FROM follows +WHERE feed_id = $1 AND user_id = $2; diff --git a/sql/schema/003_follows.sql b/sql/schema/003_follows.sql new file mode 100644 index 0000000..3d517dc --- /dev/null +++ b/sql/schema/003_follows.sql @@ -0,0 +1,14 @@ +-- +goose Up +CREATE TABLE follows ( + id UUID PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + user_id UUID NOT NULL, + feed_id UUID NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(feed_id) REFERENCES feeds(id) ON DELETE CASCADE, + CONSTRAINT unique_user_feed UNIQUE(user_id, feed_id) +); + +-- +goose Down +DROP TABLE follows;