feat(phase4): rich profiles, badges DB, block list
This commit is contained in:
+194
-4
@@ -42,6 +42,156 @@ func (h *Handler) RegisterProtectedRoutes(r chi.Router) {
|
||||
r.Get("/auth/me", h.Me)
|
||||
r.Patch("/auth/me", h.UpdateProfile)
|
||||
r.Put("/auth/me/password", h.ChangePassword)
|
||||
r.Get("/users/{userID}/profile", h.GetPublicProfile)
|
||||
r.Get("/users/me/blocks", h.ListBlocks)
|
||||
r.Post("/users/me/blocks", h.BlockUser)
|
||||
r.Delete("/users/me/blocks/{userID}", h.UnblockUser)
|
||||
}
|
||||
|
||||
// @Summary Get public user profile
|
||||
// @Description Get public profile for any user
|
||||
// @Tags users
|
||||
// @Produce json
|
||||
// @Param userID path string true "User ID"
|
||||
// @Success 200 {object} PublicUserProfile
|
||||
// @Router /users/{userID}/profile [get]
|
||||
func (h *Handler) GetPublicProfile(w http.ResponseWriter, r *http.Request) {
|
||||
userID := chi.URLParam(r, "userID")
|
||||
profile, err := h.getPublicProfile(r.Context(), userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(profile)
|
||||
}
|
||||
|
||||
func (h *Handler) getPublicProfile(ctx context.Context, userID string) (PublicUserProfile, error) {
|
||||
var p PublicUserProfile
|
||||
var socialLinks []byte
|
||||
err := h.db.QueryRowContext(ctx, `
|
||||
SELECT id, username, display_name, email, avatar, bio, accent_color, status, status_text, created_at,
|
||||
banner_url, tagline, pronouns, social_links
|
||||
FROM users WHERE id = $1
|
||||
`, userID).Scan(&p.ID, &p.Username, &p.DisplayName, &p.Email, &p.Avatar, &p.Bio, &p.AccentColor, &p.Status, &p.StatusText, &p.CreatedAt, &p.BannerURL, &p.Tagline, &p.Pronouns, &socialLinks)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
if len(socialLinks) > 0 {
|
||||
_ = json.Unmarshal(socialLinks, &p.SocialLinks)
|
||||
}
|
||||
p.Badges = h.getUserBadges(ctx, userID)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (h *Handler) getUserBadges(ctx context.Context, userID string) []badgeResponse {
|
||||
rows, err := h.db.QueryContext(ctx, `
|
||||
SELECT b.id, b.name, b.icon, b.description, b.server_id
|
||||
FROM badges b
|
||||
JOIN user_badges ub ON ub.badge_id = b.id
|
||||
WHERE ub.user_id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
badges := make([]badgeResponse, 0)
|
||||
for rows.Next() {
|
||||
var b badgeResponse
|
||||
var serverID sql.NullString
|
||||
if err := rows.Scan(&b.ID, &b.Name, &b.Icon, &b.Description, &serverID); err != nil {
|
||||
continue
|
||||
}
|
||||
if serverID.Valid {
|
||||
b.ServerID = serverID.String
|
||||
}
|
||||
badges = append(badges, b)
|
||||
}
|
||||
return badges
|
||||
}
|
||||
|
||||
// @Summary List blocked users
|
||||
// @Description List users the current user has blocked
|
||||
// @Tags users
|
||||
// @Produce json
|
||||
// @Success 200 {array} blockResponse
|
||||
// @Router /users/me/blocks [get]
|
||||
func (h *Handler) ListBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
rows, err := h.db.QueryContext(r.Context(), `
|
||||
SELECT u.id, u.username, b.created_at::text
|
||||
FROM blocks b
|
||||
JOIN users u ON u.id = b.blocked_id
|
||||
WHERE b.blocker_id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
blocks := make([]blockResponse, 0)
|
||||
for rows.Next() {
|
||||
var b blockResponse
|
||||
if err := rows.Scan(&b.ID, &b.Username, &b.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
blocks = append(blocks, b)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(blocks)
|
||||
}
|
||||
|
||||
// @Summary Block a user
|
||||
// @Description Block a user by ID
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Success 204
|
||||
// @Router /users/me/blocks [post]
|
||||
func (h *Handler) BlockUser(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID == "" {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, err := h.db.ExecContext(r.Context(), `
|
||||
INSERT INTO blocks (blocker_id, blocked_id) VALUES ($1, $2) ON CONFLICT DO NOTHING
|
||||
`, userID, req.UserID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to block user"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// @Summary Unblock a user
|
||||
// @Description Unblock a user by ID
|
||||
// @Tags users
|
||||
// @Success 204
|
||||
// @Router /users/me/blocks/{userID} [delete]
|
||||
func (h *Handler) UnblockUser(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
targetID := chi.URLParam(r, "userID")
|
||||
_, err := h.db.ExecContext(r.Context(), `DELETE FROM blocks WHERE blocker_id = $1 AND blocked_id = $2`, userID, targetID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to unblock user"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
@@ -63,6 +213,36 @@ type updateProfileRequest struct {
|
||||
AccentColor string `json:"accent_color"`
|
||||
StatusText string `json:"status_text"`
|
||||
Status string `json:"status"`
|
||||
BannerURL string `json:"banner_url"`
|
||||
Tagline string `json:"tagline"`
|
||||
Pronouns string `json:"pronouns"`
|
||||
SocialLinks []struct {
|
||||
Platform string `json:"platform"`
|
||||
URL string `json:"url"`
|
||||
} `json:"social_links"`
|
||||
}
|
||||
|
||||
type PublicUserProfile struct {
|
||||
UserProfile
|
||||
BannerURL string `json:"banner_url"`
|
||||
Tagline string `json:"tagline"`
|
||||
Pronouns string `json:"pronouns"`
|
||||
SocialLinks []map[string]interface{} `json:"social_links"`
|
||||
Badges []badgeResponse `json:"badges"`
|
||||
}
|
||||
|
||||
type badgeResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Description string `json:"description"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
}
|
||||
|
||||
type blockResponse struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type changePasswordRequest struct {
|
||||
@@ -279,6 +459,14 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"display name too long"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Tagline) > 128 {
|
||||
http.Error(w, `{"error":"tagline too long"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Pronouns) > 40 {
|
||||
http.Error(w, `{"error":"pronouns too long"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.AccentColor != "" && len(req.AccentColor) != 7 {
|
||||
http.Error(w, `{"error":"accent color must be 7 char hex"}`, http.StatusBadRequest)
|
||||
return
|
||||
@@ -288,11 +476,13 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
socialLinksJSON, _ := json.Marshal(req.SocialLinks)
|
||||
_, err := h.db.ExecContext(r.Context(), `
|
||||
UPDATE users
|
||||
SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = COALESCE(NULLIF($6, ''), status)
|
||||
SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = COALESCE(NULLIF($6, ''), status),
|
||||
banner_url = NULLIF($7, ''), tagline = NULLIF($8, ''), pronouns = NULLIF($9, ''), social_links = $10
|
||||
WHERE id = $1
|
||||
`, userID, req.DisplayName, req.Bio, req.AccentColor, req.StatusText, req.Status)
|
||||
`, userID, req.DisplayName, req.Bio, req.AccentColor, req.StatusText, req.Status, req.BannerURL, req.Tagline, req.Pronouns, socialLinksJSON)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
@@ -368,8 +558,8 @@ func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ok, err = VerifyPassword(req.CurrentPassword, currentHash)
|
||||
if err != nil || !ok {
|
||||
verified, _ := VerifyPassword(req.CurrentPassword, currentHash)
|
||||
if !verified {
|
||||
http.Error(w, `{"error":"current password is incorrect"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -344,6 +344,42 @@ ALTER TABLE channels ADD COLUMN IF NOT EXISTS message_count INTEGER NOT NULL DEF
|
||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS last_message_at TIMESTAMPTZ;
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_parent ON channels(parent_channel_id);
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS banner_url TEXT;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS tagline VARCHAR(128);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS social_links JSONB DEFAULT '[]';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS pronouns VARCHAR(40);
|
||||
|
||||
-- Badges
|
||||
CREATE TABLE IF NOT EXISTS badges (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
server_id UUID REFERENCES servers(id) ON DELETE CASCADE,
|
||||
name VARCHAR(64) NOT NULL,
|
||||
icon TEXT,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_badges_server ON badges(server_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_badges (
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
badge_id UUID NOT NULL REFERENCES badges(id) ON DELETE CASCADE,
|
||||
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
granted_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
PRIMARY KEY (user_id, badge_id)
|
||||
);
|
||||
|
||||
-- Block list
|
||||
CREATE TABLE IF NOT EXISTS blocks (
|
||||
blocker_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
blocked_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (blocker_id, blocked_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_blocker ON blocks(blocker_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_blocked ON blocks(blocked_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);
|
||||
|
||||
Reference in New Issue
Block a user