From 5f9bfa8fe2821d45165d7434cd0e68078d35ab2b Mon Sep 17 00:00:00 2001 From: phoenix Date: Sun, 9 Nov 2025 04:32:30 +0000 Subject: [PATCH] tsk-9: Schedule queued message endpoint (#22) Closes #9 Reviewed-on: https://git.kundeng.us/phoenix/textsender-api/pulls/22 Co-authored-by: phoenix Co-committed-by: phoenix --- cmd/api/main.go | 3 + go.mod | 5 +- go.sum | 4 +- internal/handler/endpoint/endpoint.go | 1 + internal/handler/mock_store_test.go | 38 +++++++ internal/handler/scheduled_message.go | 109 +++++++++++++++++++++ internal/handler/scheduled_message_test.go | 48 +++++++++ internal/store/scheduled_message_store.go | 36 +++++++ migrations/schema.sql | 8 ++ 9 files changed, 247 insertions(+), 5 deletions(-) create mode 100644 internal/handler/scheduled_message.go create mode 100644 internal/handler/scheduled_message_test.go create mode 100644 internal/store/scheduled_message_store.go diff --git a/cmd/api/main.go b/cmd/api/main.go index a96cdac..d04eec7 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -63,9 +63,11 @@ func main() { contactStore := store.NewContactStore(db.Pool) messageStore := store.NewMessageStore(db.Pool) + schStore := store.NewScheduledMessageStore(db.Pool) contactHandler := handler.NewContactHandler(contactStore) messageHandler := handler.NewMessageHandler(messageStore) + scheduledMessageHandler := handler.NewScheduledMessageHandler(schStore) router := chi.NewRouter() @@ -78,6 +80,7 @@ func main() { router.Handle(endpoint.GET_CONTACT, mdlware.AuthMiddleware(jwtService)(http.HandlerFunc(contactHandler.GetContact))) router.Handle(endpoint.ADD_MESSAGE, mdlware.AuthMiddleware(jwtService)(http.HandlerFunc(messageHandler.AddMessage))) router.Handle(endpoint.GET_MESSAGE, mdlware.AuthMiddleware(jwtService)(http.HandlerFunc(messageHandler.GetMessage))) + router.Handle(endpoint.ScheduleMessageEndpoint, mdlware.AuthMiddleware(jwtService)(http.HandlerFunc(scheduledMessageHandler.AddScheduledMessage))) // Start server server := &http.Server{ diff --git a/go.mod b/go.mod index 43a9468..bdc0a60 100644 --- a/go.mod +++ b/go.mod @@ -2,12 +2,11 @@ module git.kundeng.us/phoenix/textsender-api go 1.25.3 -require github.com/google/uuid v1.6.0 - require ( - git.kundeng.us/phoenix/textsender-models v0.0.4-2-2f78d9b1e6-556 + git.kundeng.us/phoenix/textsender-models v0.0.5-3-ca38ce876f-556 github.com/go-chi/chi/v5 v5.2.3 github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/jackc/pgx/v5 v5.7.6 github.com/joho/godotenv v1.5.1 diff --git a/go.sum b/go.sum index b1c2430..0770952 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.kundeng.us/phoenix/textsender-models v0.0.4-2-2f78d9b1e6-556 h1:BRF4JrIVZMa3kZOaRfHNLJTcspD1RWRoCTcabn+CAIA= -git.kundeng.us/phoenix/textsender-models v0.0.4-2-2f78d9b1e6-556/go.mod h1:lx5MCnOgGgsdpwzrfi9uph5xmkeb6H8AuexUNGss2no= +git.kundeng.us/phoenix/textsender-models v0.0.5-3-ca38ce876f-556 h1:dDgjre2FlukxeKZfJiP3gtIT4wyzpQ8glAYV9LpLtuU= +git.kundeng.us/phoenix/textsender-models v0.0.5-3-ca38ce876f-556/go.mod h1:lx5MCnOgGgsdpwzrfi9uph5xmkeb6H8AuexUNGss2no= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/internal/handler/endpoint/endpoint.go b/internal/handler/endpoint/endpoint.go index 3613aee..d139e64 100644 --- a/internal/handler/endpoint/endpoint.go +++ b/internal/handler/endpoint/endpoint.go @@ -5,3 +5,4 @@ const ADD_MESSAGE = "/api/v1/message/new" const GET_MESSAGE = "/api/v1/message" const GET_CONTACT = "/api/v1/contact" const ADD_CONTACT_ENDPOINT = "/api/v1/contact/new" +const ScheduleMessageEndpoint = "/api/v1/schedule/message" diff --git a/internal/handler/mock_store_test.go b/internal/handler/mock_store_test.go index da3fc88..6860ebd 100644 --- a/internal/handler/mock_store_test.go +++ b/internal/handler/mock_store_test.go @@ -9,6 +9,7 @@ import ( "git.kundeng.us/phoenix/textsender-models/pkg/contact" "git.kundeng.us/phoenix/textsender-models/pkg/message" + "git.kundeng.us/phoenix/textsender-models/pkg/message/scheduling" ) type Key struct { @@ -200,3 +201,40 @@ func (m *MockMessageStore) MessageExists(ctx context.Context, msg *message.Messa return exists, nil } + +type ScheduledMessageKey struct { + UserId uuid.UUID +} + +type MockScheduledMessageStore struct { + ScheduledMessages map[uuid.UUID]*scheduling.ScheduledMessage + ScheduledMessagesByKey map[ScheduledMessageKey]*scheduling.ScheduledMessage + mu sync.RWMutex + Error error // Optional: simulate errors +} + +func NewMockScheduledMessageStore() *MockScheduledMessageStore { + return &MockScheduledMessageStore{ + ScheduledMessages: make(map[uuid.UUID]*scheduling.ScheduledMessage), + ScheduledMessagesByKey: make(map[ScheduledMessageKey]*scheduling.ScheduledMessage), + } +} + +func (m *MockScheduledMessageStore) CreateScheduledMessage(ctx context.Context, schedMsg *scheduling.ScheduledMessage) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.Error != nil { + return m.Error + } + + if schedMsg.Id == uuid.Nil { + schedMsg.Id = uuid.New() + } + + key := ScheduledMessageKey{UserId: schedMsg.UserId} + + m.ScheduledMessages[schedMsg.Id] = schedMsg + m.ScheduledMessagesByKey[key] = schedMsg + return nil +} diff --git a/internal/handler/scheduled_message.go b/internal/handler/scheduled_message.go new file mode 100644 index 0000000..39ba32b --- /dev/null +++ b/internal/handler/scheduled_message.go @@ -0,0 +1,109 @@ +package handler + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + + "git.kundeng.us/phoenix/textsender-api/internal/store" + "git.kundeng.us/phoenix/textsender-models/pkg/message/scheduling" +) + +type RequestAddScheduledMessage struct { + Scheduled time.Time `json:"scheduled"` + Created time.Time `json:"created"` + Status string `json:"status"` + UserId uuid.UUID `json:"user_id"` +} + +type AddScheduledMessageResponse struct { + Message string `json:"message"` + Data []scheduling.ScheduledMessage `json:"data"` +} + +type ScheduledMessageHandler struct { + ScheduledMessageStore store.ScheduledMessageStore +} + +func NewScheduledMessageHandler(str store.ScheduledMessageStore) *ScheduledMessageHandler { + return &ScheduledMessageHandler{ScheduledMessageStore: str} +} + +func (c *ScheduledMessageHandler) AddScheduledMessage(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req RequestAddScheduledMessage + if err := ExtractFromRequest(r, &req); err != nil { + http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest) + } + defer r.Body.Close() + + scheduledMessage := scheduling.ScheduledMessage{Scheduled: req.Scheduled, Status: req.Status, UserId: req.UserId} + + var statusCode int + var resp AddScheduledMessageResponse + + if scheduledMessage.UserId == uuid.Nil { + statusCode = http.StatusBadRequest + resp.Message = "No UserId" + } else { + ctx := r.Context() + + if validStatus, err := isStatusValid(&scheduledMessage); err == nil { + if validStatus { + if valid, err := isScheduledTimeValid(&scheduledMessage); err == nil && valid { + if err = c.ScheduledMessageStore.CreateScheduledMessage(ctx, &scheduledMessage); err == nil { + statusCode = http.StatusCreated + resp.Data = append(resp.Data, scheduledMessage) + resp.Message = "Successful" + } else { + statusCode = http.StatusInternalServerError + resp.Message = err.Error() + } + } else { + statusCode = http.StatusBadRequest + resp.Message = "Invalid scheduled time" + } + } else { + statusCode = http.StatusBadRequest + resp.Message = "Invalid status" + } + } else { + statusCode = http.StatusInternalServerError + resp.Message = err.Error() + } + } + + RespondWithJSON(w, statusCode, &resp) +} + +func isScheduledTimeValid(schMsg *scheduling.ScheduledMessage) (bool, error) { + now := time.Now() + timeCutOff := now.Add(-5 * time.Minute) + futureCutOff := now.Add((24 * 7) * time.Hour) + + if schMsg.Scheduled.After(timeCutOff) { + if schMsg.Scheduled.Before(futureCutOff) { + return true, nil + } else { + return false, fmt.Errorf("Scheduled Time is too far in the future") + } + } else { + return false, nil + } +} + +func isStatusValid(schMsg *scheduling.ScheduledMessage) (bool, error) { + schMsg.Status = strings.ToUpper(schMsg.Status) + if schMsg.Status == scheduling.Pending { + return true, nil + } else { + return false, fmt.Errorf("Status is not valid for creating scheduled message %s", schMsg.Status) + } +} diff --git a/internal/handler/scheduled_message_test.go b/internal/handler/scheduled_message_test.go new file mode 100644 index 0000000..2e5ffa3 --- /dev/null +++ b/internal/handler/scheduled_message_test.go @@ -0,0 +1,48 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.kundeng.us/phoenix/textsender-models/pkg/message/scheduling" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + "git.kundeng.us/phoenix/textsender-api/internal/handler/endpoint" +) + +type CreateScheduledMessageRequest struct { + Scheduled time.Time `json:"scheduled"` + Status string `json:"status"` + UserId uuid.UUID `json:"user_id"` +} + +func TestCreateScheduledMessageWithMock(t *testing.T) { + now := time.Now() + mockStore := NewMockScheduledMessageStore() + handler := NewScheduledMessageHandler(mockStore) + + testUserId := uuid.New() + scheduled := now.Add(5 * time.Minute) + testBody := CreateScheduledMessageRequest{Status: scheduling.Pending, UserId: testUserId, Scheduled: scheduled} + jsonValue, _ := json.Marshal(testBody) + + req, _ := http.NewRequest("POST", endpoint.ScheduleMessageEndpoint, strings.NewReader(string(jsonValue))) + rr := httptest.NewRecorder() + + handler.AddScheduledMessage(rr, req) + + assert.Equal(t, http.StatusCreated, rr.Code) + + var response AddScheduledMessageResponse + err := json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err, "Error Creating message %v", err) + + assert.NotEmpty(t, response.Data, "No Message created") + + assert.NotNil(t, response.Data[0].Id, "Id should not be nil") +} diff --git a/internal/store/scheduled_message_store.go b/internal/store/scheduled_message_store.go new file mode 100644 index 0000000..5ac75fb --- /dev/null +++ b/internal/store/scheduled_message_store.go @@ -0,0 +1,36 @@ +package store + +import ( + "context" + // "fmt" + + // "github.com/google/uuid" + // "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "git.kundeng.us/phoenix/textsender-models/pkg/message/scheduling" +) + +type ScheduledMessageStore interface { + CreateScheduledMessage(ctx context.Context, schedMsg *scheduling.ScheduledMessage) error +} + +type PGScheduledMessageStore struct { + db *pgxpool.Pool +} + +func NewScheduledMessageStore(db *pgxpool.Pool) *PGScheduledMessageStore { + return &PGScheduledMessageStore{db: db} +} + +func (s *PGScheduledMessageStore) CreateScheduledMessage(ctx context.Context, schedMsg *scheduling.ScheduledMessage) error { + query := ` + INSERT INTO scheduled_messages (scheduled, status, user_id) + VALUES ($1, $2, $3) + RETURNING id, created + ` + + return s.db.QueryRow(ctx, query, schedMsg.Scheduled, schedMsg.Status, schedMsg.UserId).Scan( + &schedMsg.Id, &schedMsg.Created, + ) +} diff --git a/migrations/schema.sql b/migrations/schema.sql index 50798d1..57a4de6 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -13,3 +13,11 @@ CREATE TABLE messages ( content TEXT NOT NULL, user_id UUID NOT NULL ); + +CREATE TABLE scheduled_messages ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + scheduled timestamptz NOT NULL, + created timestamptz DEFAULT now(), + status TEXT CHECK (status IN ('PENDING', 'READY', 'PROCESSING', 'DONE')), + user_id UUID NOT NULL +);