Merge: t-paliad-077 fix /api/links/suggest 500 (sqlx for paliad.link_*)

This commit is contained in:
m
2026-04-30 03:18:05 +02:00
5 changed files with 113 additions and 96 deletions

View File

@@ -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")

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -35,6 +35,7 @@ type dbServices struct {
agenda *services.AgendaService
audit *services.AuditService
emailTemplate *services.EmailTemplateService
link *services.LinkService
}
var dbSvc *dbServices

View 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
}