From e65ce54e362740bd09f9787ba10b91523e0147d3 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Tue, 30 Jun 2026 11:15:40 -0400 Subject: [PATCH] feat(phase5): calendar channels - events, RSVPs, calendar view --- GUILDED_ROADMAP.md | 9 +- internal/channel/calendar.go | 319 ++++++++++++++++++++++ internal/channel/handlers.go | 1 + internal/db/db.go | 28 ++ web/src/components/CalendarView.tsx | 162 +++++++++++ web/src/components/ChannelList.tsx | 2 +- web/src/components/ChatArea.tsx | 7 +- web/src/components/CreateChannelModal.tsx | 7 +- web/src/stores/channel.ts | 2 +- web/tsconfig.app.tsbuildinfo | 2 +- 10 files changed, 531 insertions(+), 8 deletions(-) create mode 100644 internal/channel/calendar.go create mode 100644 web/src/components/CalendarView.tsx diff --git a/GUILDED_ROADMAP.md b/GUILDED_ROADMAP.md index 6056a91..d0f883d 100644 --- a/GUILDED_ROADMAP.md +++ b/GUILDED_ROADMAP.md @@ -128,10 +128,17 @@ --- -## Phase 5 — Guilded Signature Features (3–4 weeks) +## Phase 5 — Guilded Signature Features 🔄 IN PROGRESS > The features that set Guilded apart: calendar, docs, lists, scheduling. +| # | Feature | Status | +|---|---------|--------| +| 5.1 | Calendar Channels | In progress | +| 5.2 | Docs Channels | Pending | +| 5.3 | Lists Channels | Pending | +| 5.4 | Scheduling / Availability | Pending | + ### 5.1 Calendar Channels - **Backend:** New `calendar` channel type. New `events` table (channel_id, creator_id, title, description, start_time, end_time, recurrence_rule, location, color). New `event_rsvps` table (event_id, user_id, status: going/maybe/no, responded_at). - **Route:** CRUD for events. `POST /events/{eventID}/rsvp` (RSVP). `GET /channels/{channelID}/events?from=...&to=...`. diff --git a/internal/channel/calendar.go b/internal/channel/calendar.go new file mode 100644 index 0000000..c1b3725 --- /dev/null +++ b/internal/channel/calendar.go @@ -0,0 +1,319 @@ +package channel + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions" + "github.com/go-chi/chi/v5" +) + +type calendarEvent struct { + ID string `json:"id"` + ChannelID string `json:"channel_id"` + CreatorID string `json:"creator_id"` + Title string `json:"title"` + Description *string `json:"description"` + StartTime string `json:"start_time"` + EndTime *string `json:"end_time"` + RecurrenceRule *string `json:"recurrence_rule"` + Location *string `json:"location"` + Color *string `json:"color"` + GoingCount int `json:"going_count"` + MaybeCount int `json:"maybe_count"` + NoCount int `json:"no_count"` + UserStatus string `json:"user_status"` + CreatedAt string `json:"created_at"` +} + +type createEventRequest struct { + Title string `json:"title"` + Description *string `json:"description"` + StartTime string `json:"start_time"` + EndTime *string `json:"end_time"` + RecurrenceRule *string `json:"recurrence_rule"` + Location *string `json:"location"` + Color *string `json:"color"` +} + +func (h *Handler) registerCalendarRoutes(r chi.Router) { + r.Get("/{channelID}/events", h.ListEvents) + r.Post("/{channelID}/events", h.CreateEvent) + r.Patch("/events/{eventID}", h.UpdateEvent) + r.Delete("/events/{eventID}", h.DeleteEvent) + r.Post("/events/{eventID}/rsvp", h.SetRSVP) +} + +func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) { + channelID := chi.URLParam(r, "channelID") + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + serverID, err := h.serverIDForChannel(r.Context(), channelID) + if err != nil { + http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound) + return + } + if ok, _ := h.isMember(r.Context(), userID, serverID); !ok { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + allowed, _ := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.SEND_MESSAGES) + if !allowed { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + + var req createEventRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Title == "" || req.StartTime == "" { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + + event, err := h.scanEvent(h.db.QueryRowContext(r.Context(), ` + INSERT INTO events (channel_id, creator_id, title, description, start_time, end_time, recurrence_rule, location, color) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id, channel_id, creator_id, title, description, start_time, end_time, recurrence_rule, location, color, created_at + `, channelID, userID, req.Title, req.Description, req.StartTime, req.EndTime, req.RecurrenceRule, req.Location, req.Color)) + if err != nil { + http.Error(w, `{"error":"failed to create event"}`, http.StatusInternalServerError) + return + } + h.respondEvent(w, r, event, userID) +} + +func (h *Handler) ListEvents(w http.ResponseWriter, r *http.Request) { + channelID := chi.URLParam(r, "channelID") + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + serverID, err := h.serverIDForChannel(r.Context(), channelID) + if err != nil { + http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound) + return + } + if ok, _ := h.isMember(r.Context(), userID, serverID); !ok { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + + from := r.URL.Query().Get("from") + to := r.URL.Query().Get("to") + if from == "" || to == "" { + http.Error(w, `{"error":"from and to required"}`, http.StatusBadRequest) + return + } + + rows, err := h.db.QueryContext(r.Context(), ` + SELECT id, channel_id, creator_id, title, description, start_time, end_time, recurrence_rule, location, color, created_at + FROM events + WHERE channel_id = $1 AND start_time >= $2 AND start_time <= $3 + ORDER BY start_time + `, channelID, from, to) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + defer rows.Close() + + events := make([]calendarEvent, 0) + for rows.Next() { + e, err := h.scanEvent(rows) + if err != nil { + continue + } + events = append(events, h.withRSVPCounts(r.Context(), e, userID)) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(events) +} + +func (h *Handler) UpdateEvent(w http.ResponseWriter, r *http.Request) { + eventID := chi.URLParam(r, "eventID") + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + var channelID, creatorID string + err := h.db.QueryRowContext(r.Context(), `SELECT channel_id, creator_id FROM events WHERE id = $1`, eventID).Scan(&channelID, &creatorID) + if err != nil { + http.Error(w, `{"error":"event not found"}`, http.StatusNotFound) + return + } + serverID, err := h.serverIDForChannel(r.Context(), channelID) + if err != nil { + http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound) + return + } + allowed, _ := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS) + if !allowed && creatorID != userID { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + + var req createEventRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + + var nullEnd, nullRecurrence, nullLocation, nullColor, nullDesc interface{} + var query string + if req.Title != "" { + query = ` + UPDATE events SET title = $3, description = COALESCE($4, description), start_time = COALESCE($5, start_time), + end_time = COALESCE($6, end_time), recurrence_rule = COALESCE($7, recurrence_rule), location = COALESCE($8, location), + color = COALESCE($9, color), updated_at = NOW() + WHERE id = $1 + RETURNING id, channel_id, creator_id, title, description, start_time, end_time, recurrence_rule, location, color, created_at + ` + } else { + // patch partial + query = ` + UPDATE events SET description = COALESCE($4, description), start_time = COALESCE($5, start_time), + end_time = COALESCE($6, end_time), recurrence_rule = COALESCE($7, recurrence_rule), location = COALESCE($8, location), + color = COALESCE($9, color), updated_at = NOW() + WHERE id = $1 + RETURNING id, channel_id, creator_id, title, description, start_time, end_time, recurrence_rule, location, color, created_at + ` + } + // ponytail: pass nil where omitted; decoder will give zero values and COALESCE preserves existing + event, err := h.scanEvent(h.db.QueryRowContext(r.Context(), query, eventID, nullDesc, req.Title, req.Description, req.StartTime, nullEnd, nullRecurrence, nullLocation, nullColor)) + if err != nil { + http.Error(w, `{"error":"failed to update event"}`, http.StatusInternalServerError) + return + } + h.respondEvent(w, r, h.withRSVPCounts(r.Context(), event, userID), userID) +} + +func (h *Handler) DeleteEvent(w http.ResponseWriter, r *http.Request) { + eventID := chi.URLParam(r, "eventID") + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + var channelID, creatorID string + err := h.db.QueryRowContext(r.Context(), `SELECT channel_id, creator_id FROM events WHERE id = $1`, eventID).Scan(&channelID, &creatorID) + if err != nil { + http.Error(w, `{"error":"event not found"}`, http.StatusNotFound) + return + } + serverID, err := h.serverIDForChannel(r.Context(), channelID) + if err != nil { + http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound) + return + } + allowed, _ := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS) + if !allowed && creatorID != userID { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + + _, err = h.db.ExecContext(r.Context(), `DELETE FROM events WHERE id = $1`, eventID) + if err != nil { + http.Error(w, `{"error":"failed to delete event"}`, http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) SetRSVP(w http.ResponseWriter, r *http.Request) { + eventID := chi.URLParam(r, "eventID") + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + var req struct { + Status string `json:"status"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || (req.Status != "going" && req.Status != "maybe" && req.Status != "no") { + http.Error(w, `{"error":"invalid status"}`, http.StatusBadRequest) + return + } + + var channelID string + err := h.db.QueryRowContext(r.Context(), `SELECT channel_id FROM events WHERE id = $1`, eventID).Scan(&channelID) + if err != nil { + http.Error(w, `{"error":"event not found"}`, http.StatusNotFound) + return + } + serverID, err := h.serverIDForChannel(r.Context(), channelID) + if err != nil { + http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound) + return + } + if ok, _ := h.isMember(r.Context(), userID, serverID); !ok { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + + _, err = h.db.ExecContext(r.Context(), ` + INSERT INTO event_rsvps (event_id, user_id, status) VALUES ($1, $2, $3) + ON CONFLICT (event_id, user_id) DO UPDATE SET status = $3, responded_at = NOW() + `, eventID, userID, req.Status) + if err != nil { + http.Error(w, `{"error":"failed to save rsvp"}`, http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) scanEvent(row interface{ Scan(dest ...any) error }) (calendarEvent, error) { + var e calendarEvent + var desc, endTime, recurrence, location, color sql.NullString + err := row.Scan(&e.ID, &e.ChannelID, &e.CreatorID, &e.Title, &desc, &e.StartTime, &endTime, &recurrence, &location, &color, &e.CreatedAt) + if err != nil { + return e, err + } + if desc.Valid { + e.Description = &desc.String + } + if endTime.Valid { + e.EndTime = &endTime.String + } + if recurrence.Valid { + e.RecurrenceRule = &recurrence.String + } + if location.Valid { + e.Location = &location.String + } + if color.Valid { + e.Color = &color.String + } + return e, nil +} + +func (h *Handler) withRSVPCounts(ctx context.Context, e calendarEvent, userID string) calendarEvent { + row := h.db.QueryRowContext(ctx, ` + SELECT + SUM(CASE WHEN status = 'going' THEN 1 ELSE 0 END), + SUM(CASE WHEN status = 'maybe' THEN 1 ELSE 0 END), + SUM(CASE WHEN status = 'no' THEN 1 ELSE 0 END), + COALESCE(MAX(CASE WHEN user_id = $2 THEN status END), '') + FROM event_rsvps WHERE event_id = $1 + `, e.ID, userID) + var userStatus sql.NullString + _ = row.Scan(&e.GoingCount, &e.MaybeCount, &e.NoCount, &userStatus) + if userStatus.Valid { + e.UserStatus = userStatus.String + } + return e +} + +func (h *Handler) respondEvent(w http.ResponseWriter, r *http.Request, e calendarEvent, userID string) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(h.withRSVPCounts(r.Context(), e, userID)) +} diff --git a/internal/channel/handlers.go b/internal/channel/handlers.go index cfa3033..7e8b8fc 100644 --- a/internal/channel/handlers.go +++ b/internal/channel/handlers.go @@ -31,6 +31,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) { r.Get("/{channelID}/threads", h.ListThreads) r.Patch("/threads/{threadID}", h.UpdateThread) h.registerForumRoutes(r) + h.registerCalendarRoutes(r) h.registerOverrideRoutes(r) } diff --git a/internal/db/db.go b/internal/db/db.go index b0fcdf3..725b69d 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -380,6 +380,34 @@ CREATE TABLE IF NOT EXISTS blocks ( CREATE INDEX IF NOT EXISTS idx_blocks_blocker ON blocks(blocker_id); CREATE INDEX IF NOT EXISTS idx_blocks_blocked ON blocks(blocked_id); +-- Calendar events +CREATE TABLE IF NOT EXISTS events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ, + recurrence_rule VARCHAR(255), + location VARCHAR(255), + color VARCHAR(7), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_events_channel_time ON events(channel_id, start_time); + +CREATE TABLE IF NOT EXISTS event_rsvps ( + event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status VARCHAR(16) NOT NULL CHECK (status IN ('going', 'maybe', 'no')), + responded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (event_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_event_rsvps_event ON event_rsvps(event_id); + -- 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/CalendarView.tsx b/web/src/components/CalendarView.tsx new file mode 100644 index 0000000..3973e05 --- /dev/null +++ b/web/src/components/CalendarView.tsx @@ -0,0 +1,162 @@ +import { useEffect, useMemo, useState } from 'react'; +import { api } from '../lib/api.ts'; + +interface CalendarEvent { + id: string; + channel_id: string; + creator_id: string; + title: string; + description: string | null; + start_time: string; + end_time: string | null; + recurrence_rule: string | null; + location: string | null; + color: string | null; + going_count: number; + maybe_count: number; + no_count: number; + user_status: string; +} + +interface CalendarViewProps { + channelId: string; + channelName: string; +} + +export function CalendarView({ channelId, channelName }: CalendarViewProps) { + const [events, setEvents] = useState([]); + const [date, setDate] = useState(() => new Date()); + const [loading, setLoading] = useState(false); + const [showForm, setShowForm] = useState(false); + const [title, setTitle] = useState(''); + const [start, setStart] = useState(''); + const [end, setEnd] = useState(''); + const [description, setDescription] = useState(''); + const [selectedEvent, setSelectedEvent] = useState(null); + + const monthStart = useMemo(() => new Date(date.getFullYear(), date.getMonth(), 1), [date]); + const monthEnd = useMemo(() => new Date(date.getFullYear(), date.getMonth() + 1, 0), [date]); + const days: (number | null)[] = useMemo(() => { + const startDay = monthStart.getDay(); + const total = monthEnd.getDate(); + const arr: (number | null)[] = Array(startDay).fill(null); + for (let i = 1; i <= total; i++) arr.push(i); + return arr; + }, [monthStart, monthEnd]); + + useEffect(() => { + const from = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-01T00:00:00Z`; + const to = `${date.getFullYear()}-${String(date.getMonth() + 2).padStart(2, '0')}-01T00:00:00Z`; + setLoading(true); + api.get(`/channels/${channelId}/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`) + .then((data) => setEvents(Array.isArray(data) ? data : [])) + .finally(() => setLoading(false)); + }, [channelId, date]); + + const eventsForDay = (day: number) => { + const d = new Date(date.getFullYear(), date.getMonth(), day); + return events.filter((e) => { + const start = new Date(e.start_time); + return start.getFullYear() === d.getFullYear() && start.getMonth() === d.getMonth() && start.getDate() === d.getDate(); + }); + }; + + const handleCreate = async () => { + if (!title || !start) return; + const payload = { title, start_time: start, end_time: end || undefined, description: description || undefined }; + const ev = await api.post(`/channels/${channelId}/events`, payload); + setEvents((prev) => [...prev, ev]); + setShowForm(false); + setTitle(''); + setStart(''); + setEnd(''); + setDescription(''); + }; + + const handleRSVP = async (eventId: string, status: 'going' | 'maybe' | 'no') => { + await api.post(`/events/${eventId}/rsvp`, { status }); + setEvents((prev) => prev.map((e) => e.id === eventId ? { ...e, user_status: status } : e)); + }; + + const prevMonth = () => setDate((d) => new Date(d.getFullYear(), d.getMonth() - 1, 1)); + const nextMonth = () => setDate((d) => new Date(d.getFullYear(), d.getMonth() + 1, 1)); + + return ( +
+
+ ○ {channelName} + +
+ {showForm && ( +
+ setTitle(e.target.value)} placeholder="title" className="terminal-input w-full" /> + setStart(e.target.value)} className="terminal-input w-full" /> + setEnd(e.target.value)} className="terminal-input w-full" /> +