diff --git a/api/internal/features/controller.go b/api/internal/features/controller.go index fd23a67..75db00f 100644 --- a/api/internal/features/controller.go +++ b/api/internal/features/controller.go @@ -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. @@ -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), ), @@ -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, ), @@ -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 or configure preferences from your Slack App Home.", + nil, + ), + slack.NewDividerBlock(), + slack.NewSectionBlock( + slack.NewTextBlockObject("mrkdwn", + "*Need help?* Visit 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 { diff --git a/api/internal/httpapi/handler.go b/api/internal/httpapi/handler.go index 7e3b6b2..fbcc6d6 100644 --- a/api/internal/httpapi/handler.go +++ b/api/internal/httpapi/handler.go @@ -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) @@ -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", @@ -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 { @@ -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 { @@ -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) { @@ -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) diff --git a/frontend/src/app/app-home/page.tsx b/frontend/src/app/app-home/page.tsx index 46f888a..2dc18c9 100644 --- a/frontend/src/app/app-home/page.tsx +++ b/frontend/src/app/app-home/page.tsx @@ -1,12 +1,13 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { toast } from "sonner"; -import { Brain, Save, ArrowLeft, Zap, Bell, Shield, Moon } from "lucide-react"; +import { Brain, Save, ArrowLeft, Zap, Bell, Shield, Moon, Loader2 } from "lucide-react"; import Link from "next/link"; +import { getPreferences, updatePreferences, updateUser, getUser, type UserPreferences, type User } from "@/lib/api"; const prefsSchema = z.object({ neurotype: z.enum(["adhd", "autism", "anxiety", "unspecified", "ally"]), @@ -32,6 +33,15 @@ const neurotypes = [ export default function AppHome() { const [saving, setSaving] = useState(false); + const [loading, setLoading] = useState(true); + + // Read slack_user_id and team_id from URL query params + const slackUserID = typeof window !== "undefined" + ? new URLSearchParams(window.location.search).get("slack_user_id") || "" + : ""; + const teamID = typeof window !== "undefined" + ? new URLSearchParams(window.location.search).get("team_id") || "" + : ""; const form = useForm({ resolver: zodResolver(prefsSchema), @@ -48,25 +58,62 @@ export default function AppHome() { }, }); + // Load preferences and user info from API on mount + useEffect(() => { + if (!slackUserID) { + setLoading(false); + return; + } + Promise.all([ + getPreferences(slackUserID, teamID), + getUser(slackUserID, teamID).catch(() => null), + ]) + .then(([prefs, user]) => { + form.reset({ + neurotype: (user?.neurotype as PrefsForm["neurotype"]) || "unspecified", + focus_mode_enabled: prefs.focus_mode_enabled ?? true, + focus_threshold: prefs.focus_threshold ?? 50, + translator_enabled: prefs.translator_enabled ?? true, + digest_enabled: prefs.digest_enabled ?? false, + digest_hour: prefs.digest_hour ?? 16, + deep_work_auto_detect: prefs.deep_work_auto_detect ?? false, + quiet_hours_start: prefs.quiet_hours_start || "22:00", + quiet_hours_end: prefs.quiet_hours_end || "08:00", + }); + }) + .catch(() => { + // Use defaults on error + }) + .finally(() => setLoading(false)); + }, [slackUserID, teamID, form]); + const watchNeurotype = form.watch("neurotype"); const watchDigest = form.watch("digest_enabled"); const onSubmit = async (data: PrefsForm) => { setSaving(true); try { - const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080"; - const response = await fetch(`${apiUrl}/api/v1/users/me/preferences`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), - }); - if (!response.ok) { - throw new Error(`API returned ${response.status}`); + const payload: Record = { + focus_mode_enabled: data.focus_mode_enabled, + focus_threshold: data.focus_threshold, + translator_enabled: data.translator_enabled, + digest_enabled: data.digest_enabled, + digest_hour: data.digest_hour, + deep_work_auto_detect: data.deep_work_auto_detect, + quiet_hours_start: data.quiet_hours_start, + quiet_hours_end: data.quiet_hours_end, + }; + if (slackUserID) { + await Promise.all([ + updatePreferences(slackUserID, teamID, payload), + updateUser(slackUserID, teamID, { neurotype: data.neurotype }), + ]); + } else { + await new Promise((r) => setTimeout(r, 800)); } toast.success("Preferences saved! Signal will adapt to your settings."); - } catch (err) { - toast.error("Could not save preferences. Is the API server running?"); - console.error("Save error:", err); + } catch { + toast.error("Failed to save preferences. Please try again."); } finally { setSaving(false); } @@ -89,6 +136,21 @@ export default function AppHome() {
+ {loading ? ( +
+ +

Loading your preferences...

+
+ ) : slackUserID ? null : ( +
+

+ Install Signal to save your preferences.{" "} + + Add to Slack + +

+
+ )}
{/* Neurotype */}
diff --git a/frontend/src/app/oauth/callback/page.tsx b/frontend/src/app/oauth/callback/page.tsx index c3cc375..1772bed 100644 --- a/frontend/src/app/oauth/callback/page.tsx +++ b/frontend/src/app/oauth/callback/page.tsx @@ -1,12 +1,11 @@ "use client"; import { Suspense, useEffect, useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { CheckCircle, XCircle, Loader2 } from "lucide-react"; +import { useSearchParams } from "next/navigation"; +import { Loader2 } from "lucide-react"; import Link from "next/link"; function OAuthCallbackContent() { - const router = useRouter(); const searchParams = useSearchParams(); const [status, setStatus] = useState<"loading" | "success" | "error">("loading"); const [error, setError] = useState(""); @@ -27,64 +26,27 @@ function OAuthCallbackContent() { return; } - // Exchange code for token (handled by backend) - fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080"}/oauth/slack?code=${code}`) - .then((res) => { - if (res.ok) { - setStatus("success"); - // Redirect to App Home after brief delay - setTimeout(() => router.push("/app-home"), 2000); - } else { - setStatus("error"); - setError("Failed to exchange authorization code."); - } - }) - .catch((err) => { - setStatus("error"); - setError("Could not connect to Signal backend. Is the API server running?"); - console.error("OAuth error:", err); - }); - }, [searchParams, router]); + // Redirect browser to the API's OAuth handler for token exchange + // The API will exchange the code and redirect to /app-home + window.location.href = `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080"}/oauth/slack?code=${code}`; + }, [searchParams]); return (
- {status === "loading" && ( -
- -

Installing Signal...

-

- Completing the setup. You'll be redirected shortly. -

-
- )} - - {status === "success" && ( -
- -

Signal Installed! 🎉

-

- You're being redirected to your preferences. -

- - Go to Preferences - -
- )} - - {status === "error" && ( -
- -

Installation Failed

-

{error}

-

- Please try again or contact support. -

- - Back to Home - -
- )} +
+ +

Installing Signal...

+

+ Completing the setup. You'll be redirected shortly. +

+ {status === "error" && ( + <> +

{error}

+ Back to Home + + )} +
); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 6b24ad4..a07efcb 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,9 +1,11 @@ "use client"; -import { ArrowRight, Brain, Shield, Search, Moon, Bell, Menu, X, MessageSquare, GitFork, Star } from "lucide-react"; + import { ArrowRight, Brain, Shield, Search, Moon, Bell, Menu, X, MessageSquare, GitFork, Star } from "lucide-react"; import { useState } from "react"; import { ThemeToggle } from "@/components/ThemeToggle"; +const SLACK_INSTALL_URL = `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080"}/oauth/start`; + const features = [ { icon: Brain, @@ -113,7 +115,7 @@ export default function Home() {
@@ -137,7 +139,7 @@ export default function Home() { setMobileMenuOpen(false)}>Stories setMobileMenuOpen(false)}>Docs @@ -178,7 +180,7 @@ export default function Home() {
@@ -309,7 +311,7 @@ export default function Home() { Join the first neurodivergent accessibility tool for Slack. Free during hackathon.

diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3b87541..e339fda 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -9,7 +9,7 @@ const api = axios.create({ }); export interface UserPreferences { - user_id: string; + user_id?: string; focus_mode_enabled: boolean; focus_threshold: number; translator_enabled: boolean; @@ -26,32 +26,45 @@ export interface User { slack_team_id: string; email: string; display_name: string; - neurotype: Neurotype; + neurotype: string; created_at: string; updated_at: string; } -export type Neurotype = - | "adhd" - | "autism" - | "anxiety" - | "unspecified" - | "ally"; - -export async function getPreferences(userId: string) { +export async function getPreferences(slackUserID: string, teamID = "") { const { data } = await api.get( - `/api/v1/users/${userId}/preferences` + "/api/v1/preferences/by-slack", + { params: { slack_user_id: slackUserID, team_id: teamID } } ); return data; } export async function updatePreferences( - userId: string, - prefs: Partial + slackUserID: string, + teamID = "", + prefs: Record ) { const { data } = await api.put( - `/api/v1/users/${userId}/preferences`, - prefs + "/api/v1/preferences/by-slack", + prefs, + { params: { slack_user_id: slackUserID, team_id: teamID } } + ); + return data; +} + +export async function updateUser(slackUserID: string, teamID = "", data: Record) { + const { data: user } = await api.put( + "/api/v1/user/by-slack", + data, + { params: { slack_user_id: slackUserID, team_id: teamID } } + ); + return user; +} + +export async function getUser(slackUserID: string, teamID = "") { + const { data } = await api.get( + "/api/v1/user/by-slack", + { params: { slack_user_id: slackUserID, team_id: teamID } } ); return data; }