Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 23 additions & 9 deletions api/internal/features/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,12 @@ func (c *Controller) HandleAppMention(ctx context.Context, event *slackevents.Ap
return err
}

// Provide help message or process command in mention
_ = user // Future: could route to help system
return nil
dmChannel, err := c.slack.OpenDMChannel(user.SlackUserID)
if err != nil {
return fmt.Errorf("open dm: %w", err)
}

return c.slack.PostMessage(dmChannel, buildHelpBlocks(), "Signal Help")
}

// HandleBlockAction routes block action events (button clicks) to features.
Expand Down Expand Up @@ -198,8 +201,15 @@ func (c *Controller) ensureUser(ctx context.Context, user *domain.User) (*domain
}

func (c *Controller) handleOpenPreferences(ctx context.Context, cmd *slack.SlashCommand, user *domain.User) error {
// Send help message with available commands and preferences link
blocks := []slack.Block{
dmChannel, err := c.slack.OpenDMChannel(user.SlackUserID)
if err != nil {
return fmt.Errorf("open dm: %w", err)
}
return c.slack.PostMessage(dmChannel, buildHelpBlocks(), "Signal Help")
}

func buildHelpBlocks() []slack.Block {
return []slack.Block{
slack.NewHeaderBlock(
slack.NewTextBlockObject("plain_text", "🧘 Signal — Calm Slack for Neurodivergent Professionals", true, false),
),
Expand All @@ -217,7 +227,7 @@ func (c *Controller) handleOpenPreferences(ctx context.Context, cmd *slack.Slash
slack.NewTextBlockObject("mrkdwn",
"*/signal*\nOpen this help menu", false, false),
slack.NewTextBlockObject("mrkdwn",
"*/translate [message]*\nTranslate ambiguous workplace language", false, false),
"*/translate [message]*\nDecode ambiguous workplace language", false, false),
},
nil,
),
Expand All @@ -233,14 +243,18 @@ func (c *Controller) handleOpenPreferences(ctx context.Context, cmd *slack.Slash
),
slack.NewSectionBlock(
slack.NewTextBlockObject("mrkdwn",
"*/digest*\nForce-send your Quiet Hours Digest now\n\n*Need help?* Visit the support page at /support or open an issue on GitHub.",
"*/digest*\nSend an instant digest\n\n*@Signal help*\nShow this menu\n\n*Need help?* Visit <https://github.com/LSUDOKOS/signal|GitHub> or configure preferences from your Slack App Home.",
nil,
),
slack.NewDividerBlock(),
slack.NewSectionBlock(
slack.NewTextBlockObject("mrkdwn",
"*Need help?* Visit <https://github.com/LSUDOKOS/signal|GitHub> or open your Slack App Home to configure preferences.",
false, false,
),
nil, nil,
),
}

return c.slack.PostMessage(cmd.ChannelID, blocks, "Signal Help")
}

func isFocusAction(actionID string) bool {
Expand Down
173 changes: 170 additions & 3 deletions api/internal/httpapi/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,17 @@ func (s *Server) registerRoutes() {
s.router.Get("/health", s.handleHealth)

// OAuth
s.router.Get("/oauth/start", s.handleOAuthStart)
s.router.Get("/oauth/slack", s.handleSlackOAuth)

// API v1
s.router.Route("/api/v1", func(r chi.Router) {
r.Get("/users/{userID}/preferences", s.handleGetPreferences)
r.Put("/users/{userID}/preferences", s.handleUpdatePreferences)
r.Get("/user/by-slack", s.handleGetUserBySlack)
r.Put("/user/by-slack", s.handleUpdateUserBySlack)
r.Get("/preferences/by-slack", s.handleGetPreferencesBySlack)
r.Put("/preferences/by-slack", s.handleUpdatePreferencesBySlack)
})

// Prometheus metrics (real metrics defined in observability/metrics.go)
Expand All @@ -82,6 +87,33 @@ func (s *Server) Handler() http.Handler {
return s.router
}

func (s *Server) handleOAuthStart(w http.ResponseWriter, r *http.Request) {
scopes := []string{
"channels:history",
"channels:read",
"chat:write",
"commands",
"groups:history",
"groups:read",
"im:history",
"im:read",
"im:write",
"mpim:history",
"mpim:read",
"reactions:read",
"search:read",
"team:read",
"users:read",
"users:read.email",
"users.profile:read",
"users.profile:write",
}
// Slack redirects to the frontend's OAuth callback page, which calls the API
redirectURI := fmt.Sprintf("%s/oauth/callback", s.config.FrontendURL)
authURL := GetSlackAuthURL(s.config.SlackClientID, redirectURI, scopes)
http.Redirect(w, r, authURL, http.StatusFound)
}

func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
Expand All @@ -105,7 +137,7 @@ func (s *Server) handleSlackOAuth(w http.ResponseWriter, r *http.Request) {
"client_id": {s.config.SlackClientID},
"client_secret": {s.config.SlackClientSecret},
"code": {code},
"redirect_uri": {fmt.Sprintf("%s/oauth/slack", s.config.FrontendURL)},
"redirect_uri": {fmt.Sprintf("%s/oauth/callback", s.config.FrontendURL)},
},
)
if err != nil {
Expand All @@ -124,6 +156,9 @@ func (s *Server) handleSlackOAuth(w http.ResponseWriter, r *http.Request) {
ID string `json:"id"`
Name string `json:"name"`
} `json:"team"`
AuthedUser struct {
ID string `json:"id"`
} `json:"authed_user"`
}

if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
Expand Down Expand Up @@ -159,8 +194,10 @@ func (s *Server) handleSlackOAuth(w http.ResponseWriter, r *http.Request) {
}
}

// Redirect to frontend with success
http.Redirect(w, r, fmt.Sprintf("%s/app-home?install=success", s.config.FrontendURL), http.StatusFound)
// Redirect to frontend with success and user identifiers
redirectURL := fmt.Sprintf("%s/app-home?install=success&slack_user_id=%s&team_id=%s",
s.config.FrontendURL, tokenResp.AuthedUser.ID, tokenResp.Team.ID)
http.Redirect(w, r, redirectURL, http.StatusFound)
}

func (s *Server) handleGetPreferences(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -215,6 +252,136 @@ func (s *Server) handleUpdatePreferences(w http.ResponseWriter, r *http.Request)
respondJSON(w, http.StatusOK, prefs)
}

func (s *Server) handleGetUserBySlack(w http.ResponseWriter, r *http.Request) {
slackUserID := r.URL.Query().Get("slack_user_id")
teamID := r.URL.Query().Get("team_id")
if slackUserID == "" {
respondError(w, http.StatusBadRequest, "missing slack_user_id query parameter")
return
}

user, err := s.userRepo.GetBySlackID(r.Context(), slackUserID, teamID)
if err != nil {
respondError(w, http.StatusNotFound, "user not found")
return
}

respondJSON(w, http.StatusOK, user)
}

func (s *Server) handleUpdateUserBySlack(w http.ResponseWriter, r *http.Request) {
slackUserID := r.URL.Query().Get("slack_user_id")
teamID := r.URL.Query().Get("team_id")
if slackUserID == "" {
respondError(w, http.StatusBadRequest, "missing slack_user_id query parameter")
return
}

var update struct {
Neurotype string `json:"neurotype"`
}
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}

user, err := s.userRepo.GetBySlackID(r.Context(), slackUserID, teamID)
if err != nil {
user = &domain.User{
SlackUserID: slackUserID,
SlackTeamID: teamID,
Neurotype: update.Neurotype,
}
if err := s.userRepo.Create(r.Context(), user); err != nil {
respondError(w, http.StatusInternalServerError, "failed to create user")
return
}
respondJSON(w, http.StatusOK, user)
return
}

user.Neurotype = update.Neurotype
if err := s.userRepo.Update(r.Context(), user); err != nil {
slog.Error("failed to update user", "error", err)
respondError(w, http.StatusInternalServerError, "failed to update user")
return
}

respondJSON(w, http.StatusOK, user)
}

func (s *Server) handleGetPreferencesBySlack(w http.ResponseWriter, r *http.Request) {
slackUserID := r.URL.Query().Get("slack_user_id")
teamID := r.URL.Query().Get("team_id")
if slackUserID == "" {
respondError(w, http.StatusBadRequest, "missing slack_user_id query parameter")
return
}

user, err := s.userRepo.GetBySlackID(r.Context(), slackUserID, teamID)
if err != nil {
// User not found; return defaults
respondJSON(w, http.StatusOK, domain.UserPreferences{
FocusModeEnabled: true,
FocusThreshold: 50,
TranslatorEnabled: true,
DigestEnabled: false,
DigestHour: 16,
DeepWorkAutoDetect: false,
QuietHoursStart: "22:00",
QuietHoursEnd: "08:00",
})
return
}

prefs, err := s.prefsRepo.Get(r.Context(), user.ID)
if err != nil {
respondJSON(w, http.StatusOK, domain.UserPreferences{UserID: user.ID})
return
}

respondJSON(w, http.StatusOK, prefs)
}

func (s *Server) handleUpdatePreferencesBySlack(w http.ResponseWriter, r *http.Request) {
slackUserID := r.URL.Query().Get("slack_user_id")
teamID := r.URL.Query().Get("team_id")
if slackUserID == "" {
respondError(w, http.StatusBadRequest, "missing slack_user_id query parameter")
return
}

user, err := s.userRepo.GetBySlackID(r.Context(), slackUserID, teamID)
if err != nil {
// Create user record if not exists
user = &domain.User{
SlackUserID: slackUserID,
SlackTeamID: teamID,
}
if err := s.userRepo.Create(r.Context(), user); err != nil {
slog.Error("failed to create user", "error", err, "slack_user_id", slackUserID)
respondError(w, http.StatusInternalServerError, "failed to create user")
return
}
}

var prefs domain.UserPreferences
if err := json.NewDecoder(r.Body).Decode(&prefs); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}

prefs.UserID = user.ID
if err := s.prefsRepo.Upsert(r.Context(), &prefs); err != nil {
slog.Error("failed to update preferences", "error", err, "user", user.ID)
respondError(w, http.StatusInternalServerError, "failed to update preferences")
return
}

slog.Info("preferences updated via slack", "user", user.ID)
respondJSON(w, http.StatusOK, prefs)
}

func respondJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
Expand Down
Loading
Loading