feat(phase5): scheduling/availability - DB, API, UI in server settings
This commit is contained in:
+1
-1
@@ -136,7 +136,7 @@
|
|||||||
|---|---------|--------|
|
|---|---------|--------|
|
||||||
| 5.1 | Calendar Channels | ✅ Events, RSVPs, month grid |
|
| 5.1 | Calendar Channels | ✅ Events, RSVPs, month grid |
|
||||||
| 5.2 | Docs Channels | ✅ CRUD, wiki sidebar, markdown editor |
|
| 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.4 | Scheduling / Availability | Pending |
|
||||||
|
|
||||||
### 5.1 Calendar Channels
|
### 5.1 Calendar Channels
|
||||||
|
|||||||
@@ -191,6 +191,9 @@ func main() {
|
|||||||
r.Route("/channels", func(r chi.Router) {
|
r.Route("/channels", func(r chi.Router) {
|
||||||
channel.NewHandler(database.DB, permissionsChecker).RegisterRoutes(r)
|
channel.NewHandler(database.DB, permissionsChecker).RegisterRoutes(r)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Availability
|
||||||
|
r.Get("/availability", authHandler.GetServerAvailability)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -46,6 +46,8 @@ func (h *Handler) RegisterProtectedRoutes(r chi.Router) {
|
|||||||
r.Get("/users/me/blocks", h.ListBlocks)
|
r.Get("/users/me/blocks", h.ListBlocks)
|
||||||
r.Post("/users/me/blocks", h.BlockUser)
|
r.Post("/users/me/blocks", h.BlockUser)
|
||||||
r.Delete("/users/me/blocks/{userID}", h.UnblockUser)
|
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
|
// @Summary Get public user profile
|
||||||
|
|||||||
@@ -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);
|
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
|
-- Message full-text search
|
||||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
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);
|
CREATE INDEX IF NOT EXISTS idx_messages_search ON messages USING GIN(search_vector);
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ interface AuditEntry {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AvailabilitySlot {
|
||||||
|
day_of_week: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
const ACTION_LABELS: Record<string, string> = {
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
KICK: 'kicked member',
|
KICK: 'kicked member',
|
||||||
BAN: 'banned member',
|
BAN: 'banned member',
|
||||||
@@ -38,14 +44,24 @@ const ACTION_LABELS: Record<string, string> = {
|
|||||||
BULK_DELETE: 'bulk deleted messages',
|
BULK_DELETE: 'bulk deleted messages',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||||
|
|
||||||
export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalProps) {
|
export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalProps) {
|
||||||
const [tab, setTab] = useState<'audit'>('audit');
|
const [tab, setTab] = useState<'audit' | 'availability'>('audit');
|
||||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const servers = useServerStore((s) => s.servers);
|
const servers = useServerStore((s) => s.servers);
|
||||||
const server = servers.find((s) => s.id === serverId);
|
const server = servers.find((s) => s.id === serverId);
|
||||||
|
|
||||||
|
// Availability state
|
||||||
|
const [slots, setSlots] = useState<AvailabilitySlot[]>([]);
|
||||||
|
const [availLoading, setAvailLoading] = useState(false);
|
||||||
|
const [availMessage, setAvailMessage] = useState<string | null>(null);
|
||||||
|
const [editDay, setEditDay] = useState('monday');
|
||||||
|
const [editStart, setEditStart] = useState('09:00');
|
||||||
|
const [editEnd, setEditEnd] = useState('17:00');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
@@ -58,12 +74,20 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
|
|||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab !== 'audit') return;
|
if (tab === 'audit') {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api.get<AuditEntry[]>(`/servers/${serverId}/audit-log`)
|
setError(null);
|
||||||
.then((data) => setEntries(Array.isArray(data) ? data : []))
|
api.get<AuditEntry[]>(`/servers/${serverId}/audit-log`)
|
||||||
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load audit log'))
|
.then((data) => setEntries(Array.isArray(data) ? data : []))
|
||||||
.finally(() => setLoading(false));
|
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load audit log'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
} else if (tab === 'availability') {
|
||||||
|
setAvailLoading(true);
|
||||||
|
api.get<AvailabilitySlot[]>(`/users/me/availability?server_id=${serverId}`)
|
||||||
|
.then((data) => setSlots(Array.isArray(data) ? data : []))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setAvailLoading(false));
|
||||||
|
}
|
||||||
}, [tab, serverId]);
|
}, [tab, serverId]);
|
||||||
|
|
||||||
const formatTime = (iso: string) => {
|
const formatTime = (iso: string) => {
|
||||||
@@ -71,6 +95,27 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
|
|||||||
return d.toLocaleString();
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
@@ -91,6 +136,12 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
|
|||||||
>
|
>
|
||||||
[AUDIT LOG]
|
[AUDIT LOG]
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTab('availability')}
|
||||||
|
className={`px-4 py-2 text-xs ${tab === 'availability' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f hover:text-gb-orange'}`}
|
||||||
|
>
|
||||||
|
[AVAILABILITY]
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
{tab === 'audit' && (
|
{tab === 'audit' && (
|
||||||
@@ -122,6 +173,65 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{tab === 'availability' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{availLoading && <p className="text-gb-fg-f text-xs">[loading...]</p>}
|
||||||
|
<div className="text-xs text-gb-fg-s mb-2">set your weekly availability for this server</div>
|
||||||
|
|
||||||
|
{/* Add slot form */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<select
|
||||||
|
value={editDay}
|
||||||
|
onChange={(e) => setEditDay(e.target.value)}
|
||||||
|
className="terminal-input text-xs py-1 px-2"
|
||||||
|
>
|
||||||
|
{DAYS.map((d) => <option key={d} value={d}>{d.slice(0,3)}</option>)}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={editStart}
|
||||||
|
onChange={(e) => setEditStart(e.target.value)}
|
||||||
|
className="terminal-input text-xs py-1 px-2 w-20"
|
||||||
|
/>
|
||||||
|
<span className="text-gb-fg-f text-xs">to</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={editEnd}
|
||||||
|
onChange={(e) => setEditEnd(e.target.value)}
|
||||||
|
className="terminal-input text-xs py-1 px-2 w-20"
|
||||||
|
/>
|
||||||
|
<button onClick={addSlot} className="px-2 py-1 bg-gb-orange text-gb-bg text-xs">[ADD]</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid by day */}
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{DAYS.map((day) => (
|
||||||
|
<div key={day} className="text-xs">
|
||||||
|
<div className="text-gb-orange font-bold mb-1 text-center">{day.slice(0,3)}</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{slotsForDay(day).map((s, i) => {
|
||||||
|
const globalIdx = slots.findIndex((x) => x === slots.filter((sl) => sl.day_of_week === day)[i]);
|
||||||
|
return (
|
||||||
|
<div key={globalIdx} className="bg-gb-bg-t rounded px-1 py-0.5 flex items-center justify-between gap-1 group">
|
||||||
|
<span className="text-gb-fg-s">{s.start_time}-{s.end_time}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeSlot(globalIdx)}
|
||||||
|
className="text-gb-fg-f hover:text-gb-red opacity-0 group-hover:opacity-100"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={saveSlots} className="px-3 py-1 bg-gb-orange text-gb-bg text-xs">[SAVE]</button>
|
||||||
|
{availMessage && <span className="text-xs text-gb-fg-s">{availMessage}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user