Merge: t-paliad-077 fix /api/links/suggest 500 (sqlx for paliad.link_*)
This commit is contained in:
@@ -138,6 +138,7 @@ func main() {
|
||||
Agenda: services.NewAgendaService(pool, users),
|
||||
Audit: services.NewAuditService(pool),
|
||||
EmailTemplate: emailTemplateSvc,
|
||||
Link: services.NewLinkService(pool),
|
||||
}
|
||||
log.Println("Phase B services initialised")
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ type Services struct {
|
||||
Agenda *services.AgendaService
|
||||
Audit *services.AuditService
|
||||
EmailTemplate *services.EmailTemplateService
|
||||
Link *services.LinkService
|
||||
}
|
||||
|
||||
func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc *Services) {
|
||||
@@ -82,6 +83,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
agenda: svc.Agenda,
|
||||
audit: svc.Audit,
|
||||
emailTemplate: svc.EmailTemplate,
|
||||
link: svc.Link,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/auth"
|
||||
"mgit.msbls.de/m/patholo/internal/services"
|
||||
)
|
||||
|
||||
type linkCategory struct {
|
||||
@@ -223,6 +221,9 @@ type linkSuggestion struct {
|
||||
}
|
||||
|
||||
func handleLinkSuggest(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
var req linkSuggestion
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
@@ -233,18 +234,14 @@ func handleLinkSuggest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
email := extractEmailFromCookie(r)
|
||||
|
||||
row := map[string]string{
|
||||
"title": req.Title,
|
||||
"url": req.URL,
|
||||
"category": req.Category,
|
||||
"description": req.Description,
|
||||
"suggested_by": email,
|
||||
"status": "pending",
|
||||
}
|
||||
|
||||
if err := supabaseInsert("patholo_link_suggestions", row); err != nil {
|
||||
if err := dbSvc.link.InsertSuggestion(r.Context(), services.LinkSuggestionInput{
|
||||
Title: req.Title,
|
||||
URL: req.URL,
|
||||
Category: req.Category,
|
||||
Description: req.Description,
|
||||
SuggestedBy: extractEmailFromCookie(r),
|
||||
}); err != nil {
|
||||
log.Printf("link suggest insert: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not save suggestion"})
|
||||
return
|
||||
}
|
||||
@@ -259,6 +256,9 @@ type linkFeedback struct {
|
||||
}
|
||||
|
||||
func handleLinkFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
var req linkFeedback
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
@@ -269,16 +269,13 @@ func handleLinkFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
email := extractEmailFromCookie(r)
|
||||
|
||||
row := map[string]string{
|
||||
"link_id": req.LinkID,
|
||||
"feedback_type": req.Type,
|
||||
"message": req.Message,
|
||||
"submitted_by": email,
|
||||
}
|
||||
|
||||
if err := supabaseInsert("patholo_link_feedback", row); err != nil {
|
||||
if err := dbSvc.link.InsertFeedback(r.Context(), services.LinkFeedbackInput{
|
||||
LinkID: req.LinkID,
|
||||
FeedbackType: req.Type,
|
||||
Message: req.Message,
|
||||
SubmittedBy: extractEmailFromCookie(r),
|
||||
}); err != nil {
|
||||
log.Printf("link feedback insert: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not save feedback"})
|
||||
return
|
||||
}
|
||||
@@ -286,8 +283,16 @@ func handleLinkFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleSuggestionCount intentionally swallows DB errors and returns 0 so the
|
||||
// admin badge never breaks page rendering — the count is informational, not
|
||||
// load-bearing. When DATABASE_URL isn't set the service is nil and we return 0
|
||||
// rather than 503: knowledge-platform-only deployments still serve the page.
|
||||
func handleSuggestionCount(w http.ResponseWriter, r *http.Request) {
|
||||
count, err := supabaseCount("patholo_link_suggestions", "status=eq.pending")
|
||||
if dbSvc == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]int{"count": 0})
|
||||
return
|
||||
}
|
||||
count, err := dbSvc.link.CountPendingSuggestions(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]int{"count": 0})
|
||||
return
|
||||
@@ -326,72 +331,3 @@ func extractEmailFromCookie(r *http.Request) string {
|
||||
return claims.Email
|
||||
}
|
||||
|
||||
// supabaseInsert inserts a row into a Supabase table via PostgREST.
|
||||
func supabaseInsert(table string, row any) error {
|
||||
if authClient == nil {
|
||||
return fmt.Errorf("auth client not initialized")
|
||||
}
|
||||
body, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/rest/v1/%s", authClient.URL, table)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("apikey", authClient.AnonKey)
|
||||
req.Header.Set("Authorization", "Bearer "+authClient.AnonKey)
|
||||
req.Header.Set("Prefer", "return=minimal")
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("supabase error %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// supabaseCount returns the count of rows matching a filter.
|
||||
func supabaseCount(table, filter string) (int, error) {
|
||||
if authClient == nil {
|
||||
return 0, fmt.Errorf("auth client not initialized")
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/rest/v1/%s?%s&select=id", authClient.URL, table, filter)
|
||||
req, err := http.NewRequest("HEAD", url, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("apikey", authClient.AnonKey)
|
||||
req.Header.Set("Authorization", "Bearer "+authClient.AnonKey)
|
||||
req.Header.Set("Prefer", "count=exact")
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
rangeHeader := resp.Header.Get("Content-Range")
|
||||
if rangeHeader == "" {
|
||||
return 0, nil
|
||||
}
|
||||
// Format: "*/count" or "0-N/count"
|
||||
parts := strings.Split(rangeHeader, "/")
|
||||
if len(parts) != 2 {
|
||||
return 0, nil
|
||||
}
|
||||
var count int
|
||||
fmt.Sscanf(parts[1], "%d", &count)
|
||||
return count, nil
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ type dbServices struct {
|
||||
agenda *services.AgendaService
|
||||
audit *services.AuditService
|
||||
emailTemplate *services.EmailTemplateService
|
||||
link *services.LinkService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
|
||||
77
internal/services/link_service.go
Normal file
77
internal/services/link_service.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// LinkService persists user-submitted Link Hub suggestions and feedback into
|
||||
// paliad.link_suggestions / paliad.link_feedback. The earlier implementation
|
||||
// posted these via Supabase PostgREST against a public-schema patholo_link_*
|
||||
// table; that surface no longer exists after the rebrand to paliad schema, so
|
||||
// writes go through the same sqlx pool every other paliad service uses.
|
||||
type LinkService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewLinkService(db *sqlx.DB) *LinkService {
|
||||
return &LinkService{db: db}
|
||||
}
|
||||
|
||||
// LinkSuggestionInput is the payload of a /api/links/suggest call.
|
||||
// Description and SuggestedBy are optional — empty strings hit the column
|
||||
// defaults set in the migration.
|
||||
type LinkSuggestionInput struct {
|
||||
Title string
|
||||
URL string
|
||||
Category string
|
||||
Description string
|
||||
SuggestedBy string
|
||||
}
|
||||
|
||||
// InsertSuggestion creates a row with status='pending' so admins can triage
|
||||
// before promoting the link into the curated hub.
|
||||
func (s *LinkService) InsertSuggestion(ctx context.Context, in LinkSuggestionInput) error {
|
||||
const q = `INSERT INTO paliad.link_suggestions
|
||||
(title, url, category, description, suggested_by, status)
|
||||
VALUES ($1, $2, $3, $4, $5, 'pending')`
|
||||
if _, err := s.db.ExecContext(ctx, q,
|
||||
in.Title, in.URL, in.Category, in.Description, in.SuggestedBy,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert link suggestion: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LinkFeedbackInput is the payload of a /api/links/feedback call.
|
||||
type LinkFeedbackInput struct {
|
||||
LinkID string
|
||||
FeedbackType string
|
||||
Message string
|
||||
SubmittedBy string
|
||||
}
|
||||
|
||||
func (s *LinkService) InsertFeedback(ctx context.Context, in LinkFeedbackInput) error {
|
||||
const q = `INSERT INTO paliad.link_feedback
|
||||
(link_id, feedback_type, message, submitted_by)
|
||||
VALUES ($1, $2, $3, $4)`
|
||||
if _, err := s.db.ExecContext(ctx, q,
|
||||
in.LinkID, in.FeedbackType, in.Message, in.SubmittedBy,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert link feedback: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountPendingSuggestions powers the admin badge on the Link Hub.
|
||||
func (s *LinkService) CountPendingSuggestions(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
if err := s.db.GetContext(ctx, &n,
|
||||
`SELECT count(*) FROM paliad.link_suggestions WHERE status = 'pending'`,
|
||||
); err != nil {
|
||||
return 0, fmt.Errorf("count pending link suggestions: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
Reference in New Issue
Block a user