From dbebc1f381825e76857ae138f47e62aa404ace5c Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Tue, 30 Jun 2026 11:19:32 -0400 Subject: [PATCH] feat(phase5): docs channels - CRUD, wiki sidebar, markdown editor --- GUILDED_ROADMAP.md | 2 +- internal/channel/docs.go | 206 ++++++++++++++++++++++ internal/channel/handlers.go | 1 + internal/db/db.go | 13 ++ web/src/components/ChannelList.tsx | 2 +- web/src/components/ChatArea.tsx | 7 +- web/src/components/CreateChannelModal.tsx | 7 +- web/src/components/DocsView.tsx | 134 ++++++++++++++ web/src/stores/channel.ts | 2 +- web/tsconfig.app.tsbuildinfo | 2 +- 10 files changed, 368 insertions(+), 8 deletions(-) create mode 100644 internal/channel/docs.go create mode 100644 web/src/components/DocsView.tsx diff --git a/GUILDED_ROADMAP.md b/GUILDED_ROADMAP.md index d0f883d..755b504 100644 --- a/GUILDED_ROADMAP.md +++ b/GUILDED_ROADMAP.md @@ -134,7 +134,7 @@ | # | Feature | Status | |---|---------|--------| -| 5.1 | Calendar Channels | In progress | +| 5.1 | Calendar Channels | ✅ Events, RSVPs, month grid | | 5.2 | Docs Channels | Pending | | 5.3 | Lists Channels | Pending | | 5.4 | Scheduling / Availability | Pending | diff --git a/internal/channel/docs.go b/internal/channel/docs.go new file mode 100644 index 0000000..3304a0e --- /dev/null +++ b/internal/channel/docs.go @@ -0,0 +1,206 @@ +package channel + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "github.com/go-chi/chi/v5" +) + +type document struct { + ID string `json:"id"` + ChannelID string `json:"channel_id"` + CreatorID string `json:"creator_id"` + Title string `json:"title"` + Content string `json:"content"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type createDocRequest struct { + Title string `json:"title"` + Content string `json:"content"` +} + +// ponytail: no version history, no lock/unlock. Add when concurrent editing arises. + +func (h *Handler) registerDocRoutes(r chi.Router) { + r.Get("/{channelID}/docs", h.ListDocs) + r.Post("/{channelID}/docs", h.CreateDoc) + r.Get("/docs/{docID}", h.GetDoc) + r.Patch("/docs/{docID}", h.UpdateDoc) + r.Delete("/docs/{docID}", h.DeleteDoc) +} + +func (h *Handler) ListDocs(w http.ResponseWriter, r *http.Request) { + channelID := chi.URLParam(r, "channelID") + userID, _ := middleware.UserIDFromContext(r.Context()) + allowed := h.checkAccess(r.Context(), w, channelID, userID) + if !allowed { + return + } + + rows, err := h.db.QueryContext(r.Context(), ` + SELECT id, channel_id, creator_id, title, created_at, updated_at + FROM documents WHERE channel_id = $1 + ORDER BY updated_at DESC + `, channelID) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + defer rows.Close() + + // ponytail: list returns title-only summary, not full content + type docSummary struct { + ID string `json:"id"` + ChannelID string `json:"channel_id"` + CreatorID string `json:"creator_id"` + Title string `json:"title"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + } + docs := make([]docSummary, 0) + for rows.Next() { + var d docSummary + if err := rows.Scan(&d.ID, &d.ChannelID, &d.CreatorID, &d.Title, &d.CreatedAt, &d.UpdatedAt); err != nil { + continue + } + docs = append(docs, d) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(docs) +} + +func (h *Handler) CreateDoc(w http.ResponseWriter, r *http.Request) { + channelID := chi.URLParam(r, "channelID") + userID, _ := middleware.UserIDFromContext(r.Context()) + allowed := h.checkAccess(r.Context(), w, channelID, userID) + if !allowed { + return + } + + var req createDocRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Title == "" { + http.Error(w, `{"error":"title required"}`, http.StatusBadRequest) + return + } + + var d document + err := h.db.QueryRowContext(r.Context(), ` + INSERT INTO documents (channel_id, creator_id, title, content) + VALUES ($1, $2, $3, $4) + RETURNING id, channel_id, creator_id, title, content, created_at, updated_at + `, channelID, userID, req.Title, req.Content).Scan( + &d.ID, &d.ChannelID, &d.CreatorID, &d.Title, &d.Content, &d.CreatedAt, &d.UpdatedAt, + ) + if err != nil { + http.Error(w, `{"error":"failed to create doc"}`, http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(d) +} + +func (h *Handler) GetDoc(w http.ResponseWriter, r *http.Request) { + docID := chi.URLParam(r, "docID") + // ponytail: check channel access via doc lookup + var channelID, creatorID, title, content, createdAt, updatedAt string + err := h.db.QueryRowContext(r.Context(), ` + SELECT id, channel_id, creator_id, title, content, created_at, updated_at + FROM documents WHERE id = $1 + `, docID).Scan(&docID, &channelID, &creatorID, &title, &content, &createdAt, &updatedAt) + if err != nil { + http.Error(w, `{"error":"doc not found"}`, http.StatusNotFound) + return + } + userID, _ := middleware.UserIDFromContext(r.Context()) + allowed := h.checkAccess(r.Context(), w, channelID, userID) + if !allowed { + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(document{ + ID: docID, ChannelID: channelID, CreatorID: creatorID, + Title: title, Content: content, CreatedAt: createdAt, UpdatedAt: updatedAt, + }) +} + +func (h *Handler) UpdateDoc(w http.ResponseWriter, r *http.Request) { + docID := chi.URLParam(r, "docID") + userID, _ := middleware.UserIDFromContext(r.Context()) + + // get channel_id first for access check + var channelID, creatorID string + err := h.db.QueryRowContext(r.Context(), `SELECT channel_id, creator_id FROM documents WHERE id = $1`, docID).Scan(&channelID, &creatorID) + if err != nil { + http.Error(w, `{"error":"doc not found"}`, http.StatusNotFound) + return + } + allowed := h.checkAccess(r.Context(), w, channelID, userID) + if !allowed { + return + } + + var req createDocRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + + var d document + err = h.db.QueryRowContext(r.Context(), ` + UPDATE documents SET title = COALESCE(NULLIF($2, ''), title), content = $3, updated_at = $4 + WHERE id = $1 + RETURNING id, channel_id, creator_id, title, content, created_at, updated_at + `, docID, req.Title, req.Content, time.Now().UTC().Format(time.RFC3339)).Scan( + &d.ID, &d.ChannelID, &d.CreatorID, &d.Title, &d.Content, &d.CreatedAt, &d.UpdatedAt, + ) + if err != nil { + http.Error(w, `{"error":"failed to update doc"}`, http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(d) +} + +func (h *Handler) DeleteDoc(w http.ResponseWriter, r *http.Request) { + docID := chi.URLParam(r, "docID") + userID, _ := middleware.UserIDFromContext(r.Context()) + + var channelID, creatorID string + err := h.db.QueryRowContext(r.Context(), `SELECT channel_id, creator_id FROM documents WHERE id = $1`, docID).Scan(&channelID, &creatorID) + if err != nil { + http.Error(w, `{"error":"doc not found"}`, http.StatusNotFound) + return + } + allowed := h.checkAccess(r.Context(), w, channelID, userID) + if !allowed { + return + } + + _, err = h.db.ExecContext(r.Context(), `DELETE FROM documents WHERE id = $1`, docID) + if err != nil { + http.Error(w, `{"error":"failed to delete doc"}`, http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ponytail: shared access check, reuses isMember. No permission fine-tuning. +func (h *Handler) checkAccess(ctx context.Context, w http.ResponseWriter, channelID, userID string) bool { + serverID, err := h.serverIDForChannel(ctx, channelID) + if err != nil { + http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound) + return false + } + ok, _ := h.isMember(ctx, userID, serverID) + if !ok { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return false + } + return true +} diff --git a/internal/channel/handlers.go b/internal/channel/handlers.go index 7e8b8fc..1a15d94 100644 --- a/internal/channel/handlers.go +++ b/internal/channel/handlers.go @@ -32,6 +32,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) { r.Patch("/threads/{threadID}", h.UpdateThread) h.registerForumRoutes(r) h.registerCalendarRoutes(r) + h.registerDocRoutes(r) h.registerOverrideRoutes(r) } diff --git a/internal/db/db.go b/internal/db/db.go index 725b69d..9d88aa1 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -408,6 +408,19 @@ CREATE TABLE IF NOT EXISTS event_rsvps ( CREATE INDEX IF NOT EXISTS idx_event_rsvps_event ON event_rsvps(event_id); +-- Documents (wiki/docs channels) +CREATE TABLE IF NOT EXISTS documents ( + 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, + content TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_documents_channel ON documents(channel_id, updated_at DESC); + -- 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/ChannelList.tsx b/web/src/components/ChannelList.tsx index eb7b600..92ec5b7 100644 --- a/web/src/components/ChannelList.tsx +++ b/web/src/components/ChannelList.tsx @@ -92,7 +92,7 @@ export function ChannelList() { channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s' }`} > - {channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : '#'} + {channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : '#'} {channel.name} { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }} diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx index 541538a..1c9199a 100644 --- a/web/src/components/ChatArea.tsx +++ b/web/src/components/ChatArea.tsx @@ -11,6 +11,7 @@ import { ThreadListPanel } from "./ThreadListPanel.tsx"; import { ThreadPanel } from "./ThreadPanel.tsx"; import { ForumView } from "./ForumView.tsx"; import { CalendarView } from "./CalendarView.tsx"; +import { DocsView } from "./DocsView.tsx"; import { UserProfileModal } from "./UserProfileModal.tsx"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -248,7 +249,9 @@ export function ChatArea() { ? `■ ${activeChannel.name}` : activeChannel.type === 'calendar' ? `○ ${activeChannel.name}` - : `# ${activeChannel.name}` + : activeChannel.type === 'docs' + ? `☰ ${activeChannel.name}` + : `# ${activeChannel.name}` : activeChannelId ? `#${activeChannelId}` : "[NO CHANNEL SELECTED]"} @@ -326,6 +329,8 @@ export function ChatArea() { setActiveThread(t)} /> ) : activeChannel && activeChannel.type === 'calendar' && activeChannelId ? ( + ) : activeChannel && activeChannel.type === 'docs' && activeChannelId ? ( + ) : ( <>
diff --git a/web/src/components/CreateChannelModal.tsx b/web/src/components/CreateChannelModal.tsx index 23b9da8..e25f11b 100644 --- a/web/src/components/CreateChannelModal.tsx +++ b/web/src/components/CreateChannelModal.tsx @@ -11,7 +11,7 @@ interface ChannelApiResponse { id: string; server_id: string; name: string; - type: 'text' | 'voice' | 'forum' | 'calendar'; + type: 'text' | 'voice' | 'forum' | 'calendar' | 'docs'; category: string; position: number; slowmode_seconds: number; @@ -29,7 +29,7 @@ const SLOWMODE_OPTIONS = [ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProps) { const [name, setName] = useState(''); - const [type, setType] = useState<'text' | 'voice' | 'forum' | 'calendar'>('text'); + const [type, setType] = useState<'text' | 'voice' | 'forum' | 'calendar' | 'docs'>('text'); const [category, setCategory] = useState('general'); const [slowmodeSeconds, setSlowmodeSeconds] = useState(0); const [loading, setLoading] = useState(false); @@ -107,13 +107,14 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
diff --git a/web/src/components/DocsView.tsx b/web/src/components/DocsView.tsx new file mode 100644 index 0000000..e3a9798 --- /dev/null +++ b/web/src/components/DocsView.tsx @@ -0,0 +1,134 @@ +import { useEffect, useState } from 'react'; +import { api } from '../lib/api.ts'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +interface DocSummary { + id: string; + channel_id: string; + creator_id: string; + title: string; + created_at: string; + updated_at: string; +} + +interface DocDetail extends DocSummary { + content: string; +} + +interface DocsViewProps { + channelId: string; + channelName: string; +} + +export function DocsView({ channelId, channelName }: DocsViewProps) { + const [docs, setDocs] = useState([]); + const [activeDoc, setActiveDoc] = useState(null); + const [editing, setEditing] = useState(false); + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + const [loading, setLoading] = useState(false); + const [showNew, setShowNew] = useState(false); + const [newTitle, setNewTitle] = useState(''); + + useEffect(() => { + setLoading(true); + api.get(`/channels/${channelId}/docs`) + .then((data) => setDocs(Array.isArray(data) ? data : [])) + .finally(() => setLoading(false)); + }, [channelId]); + + const openDoc = async (id: string) => { + const d = await api.get(`/channels/docs/${id}`); + setActiveDoc(d); + setTitle(d.title); + setContent(d.content); + setEditing(false); + }; + + const createDoc = async () => { + if (!newTitle.trim()) return; + const d = await api.post(`/channels/${channelId}/docs`, { title: newTitle, content: '' }); + setDocs((prev) => [{ id: d.id, channel_id: d.channel_id, creator_id: d.creator_id, title: d.title, created_at: d.created_at, updated_at: d.updated_at }, ...prev]); + setShowNew(false); + setNewTitle(''); + openDoc(d.id); + }; + + const saveDoc = async () => { + if (!activeDoc) return; + const d = await api.patch(`/channels/docs/${activeDoc.id}`, { title, content }); + setActiveDoc(d); + setDocs((prev) => prev.map((s) => s.id === d.id ? { ...s, title: d.title, updated_at: d.updated_at } : s)); + setEditing(false); + }; + + const deleteDoc = async (id: string) => { + if (!confirm('Delete this doc?')) return; + await api.delete(`/channels/docs/${id}`); + setDocs((prev) => prev.filter((d) => d.id !== id)); + if (activeDoc?.id === id) setActiveDoc(null); + }; + + return ( +
+
+ ☰ {channelName} + +
+ {showNew && ( +
+ setNewTitle(e.target.value)} placeholder="doc title" className="terminal-input flex-1" /> + +
+ )} +
+
+ {loading &&
[loading...]
} + {!loading && docs.length === 0 &&
[no docs]
} + {docs.map((d) => ( +
openDoc(d.id)} + className={`p-2 cursor-pointer truncate ${activeDoc?.id === d.id ? 'bg-gb-bg-t text-gb-fg' : 'text-gb-fg-s hover:bg-gb-bg-s'}`} + > + {d.title} +
updated {new Date(d.updated_at).toLocaleDateString()}
+
+ ))} +
+
+ {!activeDoc && ( +
+ select or create a doc +
+ )} + {activeDoc && !editing && ( +
+
+

{activeDoc.title}

+
+ + +
+
+
+ {activeDoc.content || '*empty*'} +
+
+ )} + {activeDoc && editing && ( +
+ setTitle(e.target.value)} className="terminal-input w-full text-sm font-bold" /> +