diff --git a/GUILDED_ROADMAP.md b/GUILDED_ROADMAP.md index dbef479..c761466 100644 --- a/GUILDED_ROADMAP.md +++ b/GUILDED_ROADMAP.md @@ -136,7 +136,7 @@ |---|---------|--------| | 5.1 | Calendar Channels | ✅ Events, RSVPs, month grid | | 5.2 | Docs Channels | ✅ CRUD, wiki sidebar, markdown editor | -| 5.3 | Lists Channels | Pending | +| 5.3 | Lists Channels | ✅ TODO/DOING/DONE columns, CRUD | | 5.4 | Scheduling / Availability | Pending | ### 5.1 Calendar Channels diff --git a/cmd/server/main.go b/cmd/server/main.go index 5a3797e..ce7ee72 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -191,6 +191,9 @@ func main() { r.Route("/channels", func(r chi.Router) { channel.NewHandler(database.DB, permissionsChecker).RegisterRoutes(r) }) + + // Availability + r.Get("/availability", authHandler.GetServerAvailability) }) }) diff --git a/internal/auth/availability.go b/internal/auth/availability.go new file mode 100644 index 0000000..cb4d3bb --- /dev/null +++ b/internal/auth/availability.go @@ -0,0 +1,161 @@ +package auth + +import ( + "encoding/json" + "net/http" + "time" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "github.com/go-chi/chi/v5" +) + +type availabilitySlot struct { + DayOfWeek string `json:"day_of_week"` + StartTime string `json:"start_time"` // HH:MM + EndTime string `json:"end_time"` // HH:MM +} + +func (h *Handler) GetMyAvailability(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + serverID := r.URL.Query().Get("server_id") + if serverID == "" { + http.Error(w, "server_id query param required", http.StatusBadRequest) + return + } + + rows, err := h.db.Query( + `SELECT day_of_week, start_time, end_time FROM availability + WHERE user_id = $1 AND server_id = $2 + ORDER BY array_position(ARRAY['monday','tuesday','wednesday','thursday','friday','saturday','sunday'], day_of_week), start_time`, + userID, serverID, + ) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + var slots []availabilitySlot + for rows.Next() { + var s availabilitySlot + var st, et time.Time + if err := rows.Scan(&s.DayOfWeek, &st, &et); err != nil { + continue + } + s.StartTime = st.Format("15:04") + s.EndTime = et.Format("15:04") + slots = append(slots, s) + } + if slots == nil { + slots = []availabilitySlot{} + } + json.NewEncoder(w).Encode(slots) +} + +func (h *Handler) PutMyAvailability(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + serverID := r.URL.Query().Get("server_id") + if serverID == "" { + http.Error(w, "server_id query param required", http.StatusBadRequest) + return + } + + var slots []availabilitySlot + if err := json.NewDecoder(r.Body).Decode(&slots); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + + tx, err := h.db.Begin() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer tx.Rollback() + + if _, err := tx.Exec(`DELETE FROM availability WHERE user_id = $1 AND server_id = $2`, userID, serverID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + for _, s := range slots { + _, err := tx.Exec( + `INSERT INTO availability (user_id, server_id, day_of_week, start_time, end_time) VALUES ($1,$2,$3,$4::time,$5::time)`, + userID, serverID, s.DayOfWeek, s.StartTime, s.EndTime, + ) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + + if err := tx.Commit(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(slots) +} + +// GetServerAvailability returns all members' availability for a server, aggregated by user. +func (h *Handler) GetServerAvailability(w http.ResponseWriter, r *http.Request) { + serverID := chi.URLParam(r, "serverID") + + rows, err := h.db.Query( + `SELECT a.user_id, u.display_name, a.day_of_week, a.start_time, a.end_time + FROM availability a + JOIN users u ON u.id = a.user_id + WHERE a.server_id = $1 + ORDER BY u.display_name, array_position(ARRAY['monday','tuesday','wednesday','thursday','friday','saturday','sunday'], a.day_of_week), a.start_time`, + serverID, + ) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + type slot struct { + DayOfWeek string `json:"day_of_week"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + } + type userAvail struct { + UserID string `json:"user_id"` + Username string `json:"username"` + Slots []slot `json:"slots"` + } + + userMap := make(map[string]*userAvail) + for rows.Next() { + var uid, uname, dow string + var st, et time.Time + if err := rows.Scan(&uid, &uname, &dow, &st, &et); err != nil { + continue + } + ua, ok := userMap[uid] + if !ok { + ua = &userAvail{UserID: uid, Username: uname} + userMap[uid] = ua + } + ua.Slots = append(ua.Slots, slot{DayOfWeek: dow, StartTime: st.Format("15:04"), EndTime: et.Format("15:04")}) + } + + result := make([]userAvail, 0, len(userMap)) + for _, ua := range userMap { + result = append(result, *ua) + } + if result == nil { + result = []userAvail{} + } + json.NewEncoder(w).Encode(result) +} \ No newline at end of file diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go index e73d874..3e52f22 100644 --- a/internal/auth/handlers.go +++ b/internal/auth/handlers.go @@ -46,6 +46,8 @@ func (h *Handler) RegisterProtectedRoutes(r chi.Router) { r.Get("/users/me/blocks", h.ListBlocks) r.Post("/users/me/blocks", h.BlockUser) r.Delete("/users/me/blocks/{userID}", h.UnblockUser) + r.Get("/users/me/availability", h.GetMyAvailability) + r.Put("/users/me/availability", h.PutMyAvailability) } // @Summary Get public user profile diff --git a/internal/db/db.go b/internal/db/db.go index b2c6368..57b26e3 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -435,6 +435,18 @@ CREATE TABLE IF NOT EXISTS list_items ( CREATE INDEX IF NOT EXISTS idx_list_items_channel ON list_items(channel_id, position); +-- Weekly availability +CREATE TABLE IF NOT EXISTS availability ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + day_of_week VARCHAR(9) NOT NULL CHECK (day_of_week IN ('monday','tuesday','wednesday','thursday','friday','saturday','sunday')), + start_time TIME NOT NULL, + end_time TIME NOT NULL, + PRIMARY KEY (user_id, server_id, day_of_week, start_time) +); + +CREATE INDEX IF NOT EXISTS idx_availability_server ON availability(server_id, day_of_week); + -- Message full-text search ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_vector tsvector; CREATE INDEX IF NOT EXISTS idx_messages_search ON messages USING GIN(search_vector); diff --git a/web/src/components/ServerSettingsModal.tsx b/web/src/components/ServerSettingsModal.tsx index 252bc04..01eec46 100644 --- a/web/src/components/ServerSettingsModal.tsx +++ b/web/src/components/ServerSettingsModal.tsx @@ -20,6 +20,12 @@ interface AuditEntry { created_at: string; } +interface AvailabilitySlot { + day_of_week: string; + start_time: string; + end_time: string; +} + const ACTION_LABELS: Record = { KICK: 'kicked member', BAN: 'banned member', @@ -38,14 +44,24 @@ const ACTION_LABELS: Record = { BULK_DELETE: 'bulk deleted messages', }; +const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; + export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalProps) { - const [tab, setTab] = useState<'audit'>('audit'); + const [tab, setTab] = useState<'audit' | 'availability'>('audit'); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const servers = useServerStore((s) => s.servers); const server = servers.find((s) => s.id === serverId); + // Availability state + const [slots, setSlots] = useState([]); + const [availLoading, setAvailLoading] = useState(false); + const [availMessage, setAvailMessage] = useState(null); + const [editDay, setEditDay] = useState('monday'); + const [editStart, setEditStart] = useState('09:00'); + const [editEnd, setEditEnd] = useState('17:00'); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { @@ -58,12 +74,20 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr }, [onClose]); useEffect(() => { - if (tab !== 'audit') return; - setLoading(true); - api.get(`/servers/${serverId}/audit-log`) - .then((data) => setEntries(Array.isArray(data) ? data : [])) - .catch((err) => setError(err instanceof Error ? err.message : 'Failed to load audit log')) - .finally(() => setLoading(false)); + if (tab === 'audit') { + setLoading(true); + setError(null); + api.get(`/servers/${serverId}/audit-log`) + .then((data) => setEntries(Array.isArray(data) ? data : [])) + .catch((err) => setError(err instanceof Error ? err.message : 'Failed to load audit log')) + .finally(() => setLoading(false)); + } else if (tab === 'availability') { + setAvailLoading(true); + api.get(`/users/me/availability?server_id=${serverId}`) + .then((data) => setSlots(Array.isArray(data) ? data : [])) + .catch(() => {}) + .finally(() => setAvailLoading(false)); + } }, [tab, serverId]); const formatTime = (iso: string) => { @@ -71,6 +95,27 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr return d.toLocaleString(); }; + const addSlot = () => { + if (slots.some((s) => s.day_of_week === editDay && s.start_time === editStart)) return; + setSlots([...slots, { day_of_week: editDay, start_time: editStart, end_time: editEnd }]); + }; + + const removeSlot = (idx: number) => { + setSlots(slots.filter((_, i) => i !== idx)); + }; + + const saveSlots = async () => { + setAvailMessage(null); + try { + await api.put(`/users/me/availability?server_id=${serverId}`, slots); + setAvailMessage('[saved]'); + } catch (err) { + setAvailMessage(err instanceof Error ? `ERR: ${err.message}` : 'ERR: failed to save'); + } + }; + + const slotsForDay = (day: string) => slots.filter((s) => s.day_of_week === day); + return (
[AUDIT LOG] +
{tab === 'audit' && ( @@ -122,6 +173,65 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
)} + {tab === 'availability' && ( +
+ {availLoading &&

[loading...]

} +
set your weekly availability for this server
+ + {/* Add slot form */} +
+ + setEditStart(e.target.value)} + className="terminal-input text-xs py-1 px-2 w-20" + /> + to + setEditEnd(e.target.value)} + className="terminal-input text-xs py-1 px-2 w-20" + /> + +
+ + {/* Grid by day */} +
+ {DAYS.map((day) => ( +
+
{day.slice(0,3)}
+
+ {slotsForDay(day).map((s, i) => { + const globalIdx = slots.findIndex((x) => x === slots.filter((sl) => sl.day_of_week === day)[i]); + return ( +
+ {s.start_time}-{s.end_time} + +
+ ); + })} +
+
+ ))} +
+ +
+ + {availMessage && {availMessage}} +
+
+ )}