Files
paliad/internal/handlers/links.go
m 67cd66e054 fix: audit quick wins — important + polish batch (t-paliad-017)
Items from docs/improvement-audit.md §2 + §3:

I-1  Hide Dokumente tab entirely from Akten detail (Phase H deferred);
     drop placeholder TSX panel, VALID_TABS entry, and orphaned
     akten.detail.soon.* i18n keys.
I-2  Add data-i18n keys for all 7 office labels on the landing page.
     EN mode now correctly renders "Milan" (was "Mailand").
I-3  Unify UPC URLs in Gerichtsverzeichnis to the canonical hyphenated
     form (unified-patent-court.org) matching links.go — 43 occurrences.
I-6  Add SEP/FRAND glossary category with 13 entries (FRAND, SEP,
     Standard-essentielles Patent, Patentpool, Anti-Suit, Anti-Anti-Suit,
     Injunction Gap, Orange-Book-Standard, Huawei/ZTE, RAND, ETSI IPR,
     Patent-Hold-up, Patent-Hold-out) + filter pill + suggest-modal option.
I-7  Refresh README: list migration 014 (checklist_instances), mark
     Phase I (Notizen) and Phase J (docs) shipped.
P-1  Remove HL Intern stub links (URL "#") and the now-empty "hl" category.
P-2  Dashboard heading: "Meine Mandate" → "Meine Akten" (matches CLAUDE.md
     naming convention). Onboarding hint updated likewise.
P-4  Drop "Hogan Lovells Patent Practice" from the footer — Paliad is the
     firm-agnostic brand.
P-5  Empty-state text on Fristen- and Termine-Kalender when the viewed
     month has no items.

Verified: bun run build clean, go build / vet / test ./... clean.
2026-04-18 09:14:43 +02:00

388 lines
13 KiB
Go

package handlers
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type linkCategory struct {
ID string `json:"id"`
NameDE string `json:"nameDE"`
NameEN string `json:"nameEN"`
}
type link struct {
ID string `json:"id"`
Category string `json:"category"`
Title string `json:"title"`
URL string `json:"url"`
DescDE string `json:"descDE"`
DescEN string `json:"descEN"`
}
type linksResponse struct {
Categories []linkCategory `json:"categories"`
Links []link `json:"links"`
}
var linkCategories = []linkCategory{
{ID: "gerichte", NameDE: "Gerichte & Ämter", NameEN: "Courts & Offices"},
{ID: "recherche", NameDE: "Recherche", NameEN: "Research"},
{ID: "upc", NameDE: "UPC", NameEN: "UPC"},
{ID: "gesetze", NameDE: "Gesetze", NameEN: "Legislation"},
}
var curatedLinks = []link{
// Gerichte & Ämter
{
ID: "upc-cms", Category: "gerichte",
Title: "UPC Case Management System",
URL: "https://www.unified-patent-court.org/en/registry/case-management-system",
DescDE: "Verfahrensverwaltung des Einheitlichen Patentgerichts. Klageschriften, Schriftsätze und Verfahrensstatus.",
DescEN: "Case management of the Unified Patent Court. Statements of claim, written pleadings, and case status.",
},
{
ID: "upc-register", Category: "gerichte",
Title: "UPC Register",
URL: "https://www.unified-patent-court.org/en/registry",
DescDE: "Öffentliches Register des UPC. Verfahrensdokumente und Entscheidungen.",
DescEN: "Public register of the UPC. Case documents and decisions.",
},
{
ID: "epo", Category: "gerichte",
Title: "Europäisches Patentamt (EPO)",
URL: "https://www.epo.org",
DescDE: "Europäisches Patentamt. Patentanmeldungen, Recherchen und Prüfungsverfahren.",
DescEN: "European Patent Office. Patent applications, searches, and examination procedures.",
},
{
ID: "dpma", Category: "gerichte",
Title: "DPMA",
URL: "https://www.dpma.de",
DescDE: "Deutsches Patent- und Markenamt. Nationale Patente, Marken und Designs.",
DescEN: "German Patent and Trade Mark Office. National patents, trade marks, and designs.",
},
{
ID: "bpatg", Category: "gerichte",
Title: "Bundespatentgericht",
URL: "https://www.bundespatentgericht.de",
DescDE: "Bundespatentgericht. Nichtigkeitsverfahren und Beschwerden gegen DPMA-Entscheidungen.",
DescEN: "Federal Patent Court. Nullity proceedings and appeals against DPMA decisions.",
},
{
ID: "euipo", Category: "gerichte",
Title: "EUIPO",
URL: "https://euipo.europa.eu",
DescDE: "Amt der Europäischen Union für geistiges Eigentum. EU-Marken und Gemeinschaftsgeschmacksmuster.",
DescEN: "European Union Intellectual Property Office. EU trade marks and Community designs.",
},
// Recherche
{
ID: "espacenet", Category: "recherche",
Title: "Espacenet",
URL: "https://worldwide.espacenet.com",
DescDE: "Weltweite Patentdatenbank des EPO. Über 150 Mio. Patentdokumente durchsuchbar.",
DescEN: "Worldwide patent database of the EPO. Over 150 million patent documents searchable.",
},
{
ID: "dpma-register", Category: "recherche",
Title: "DPMAregister",
URL: "https://register.dpma.de",
DescDE: "Amtliches Register für deutsche Patente, Marken und Designs. Rechts- und Verfahrensstand.",
DescEN: "Official register for German patents, trade marks, and designs. Legal and procedural status.",
},
{
ID: "depatisnet", Category: "recherche",
Title: "DEPATISnet",
URL: "https://depatisnet.dpma.de",
DescDE: "Recherchesystem des DPMA. Weltweite Patentrecherche mit Klassifikationssuche.",
DescEN: "DPMA search system. Worldwide patent searches with classification search.",
},
{
ID: "google-patents", Category: "recherche",
Title: "Google Patents",
URL: "https://patents.google.com",
DescDE: "Google-Patentsuche. Volltextrecherche und maschinelle Übersetzung von Patentschriften.",
DescEN: "Google patent search. Full-text search and machine translation of patent documents.",
},
{
ID: "patentscope", Category: "recherche",
Title: "Patentscope (WIPO)",
URL: "https://patentscope.wipo.int",
DescDE: "Internationale Patentdatenbank der WIPO. PCT-Anmeldungen und nationale Sammlungen.",
DescEN: "International patent database of WIPO. PCT applications and national collections.",
},
// UPC
{
ID: "upc-rop", Category: "upc",
Title: "Rules of Procedure",
URL: "https://www.unified-patent-court.org/en/court/legal-documents/rules-of-procedure",
DescDE: "Verfahrensordnung des Einheitlichen Patentgerichts. Vollständiger Regeltext mit Anhängen.",
DescEN: "Rules of Procedure of the Unified Patent Court. Complete rule text with annexes.",
},
{
ID: "upc-fees", Category: "upc",
Title: "Schedule of Fees",
URL: "https://www.unified-patent-court.org/en/court/legal-documents/court-fees",
DescDE: "Gebührenordnung des UPC. Feste und streitwertabhängige Gerichtsgebühren.",
DescEN: "UPC fee schedule. Fixed and value-based court fees.",
},
{
ID: "upc-practice", Category: "upc",
Title: "Practice Directions",
URL: "https://www.unified-patent-court.org/en/court/legal-documents/practice-directions",
DescDE: "Praxisanweisungen des UPC. Praktische Hinweise zu Verfahrensabläufen.",
DescEN: "UPC practice directions. Practical guidance on procedural matters.",
},
{
ID: "upc-website", Category: "upc",
Title: "UPC Website",
URL: "https://www.unified-patent-court.org",
DescDE: "Offizielle Website des Einheitlichen Patentgerichts. Nachrichten, Termine und Informationen.",
DescEN: "Official website of the Unified Patent Court. News, events, and information.",
},
{
ID: "youpc-judgments", Category: "upc",
Title: "UPC Case Law (youpc.org)",
URL: "https://youpc.org/judgments",
DescDE: "Durchsuchbare Datenbank aller UPC-Entscheidungen mit Zusammenfassungen und Themen-Tags.",
DescEN: "Searchable database of all UPC decisions with summaries and topic tags.",
},
// Gesetze
{
ID: "patg", Category: "gesetze",
Title: "PatG — Patentgesetz",
URL: "https://dejure.org/gesetze/PatG",
DescDE: "Deutsches Patentgesetz. Volltext mit Querverweisen und Rechtsprechung.",
DescEN: "German Patent Act. Full text with cross-references and case law.",
},
{
ID: "epue", Category: "gesetze",
Title: "EPÜ — Europäisches Patentübereinkommen",
URL: "https://dejure.org/gesetze/EPUe",
DescDE: "Europäisches Patentübereinkommen. Materialien und Querverweise auf dejure.org.",
DescEN: "European Patent Convention. Materials and cross-references on dejure.org.",
},
{
ID: "upca", Category: "gesetze",
Title: "UPCA — Übereinkommen über ein Einheitliches Patentgericht",
URL: "https://www.unified-patent-court.org/en/court/legal-documents/agreement",
DescDE: "Übereinkommen über ein Einheitliches Patentgericht (EPGÜ). Gründungsvertrag des UPC.",
DescEN: "Agreement on a Unified Patent Court. Founding treaty of the UPC.",
},
{
ID: "gkg", Category: "gesetze",
Title: "GKG — Gerichtskostengesetz",
URL: "https://dejure.org/gesetze/GKG",
DescDE: "Gerichtskostengesetz. Gebührentabellen und Streitwertvorschriften.",
DescEN: "Court Fees Act. Fee tables and dispute value provisions.",
},
{
ID: "rvg", Category: "gesetze",
Title: "RVG — Rechtsanwaltsvergütungsgesetz",
URL: "https://dejure.org/gesetze/RVG",
DescDE: "Rechtsanwaltsvergütungsgesetz. Anwaltsgebühren und Vergütungsverzeichnis.",
DescEN: "Lawyers' Remuneration Act. Attorney fees and remuneration schedule.",
},
{
ID: "zpo", Category: "gesetze",
Title: "ZPO — Zivilprozessordnung",
URL: "https://dejure.org/gesetze/ZPO",
DescDE: "Zivilprozessordnung. Verfahrensrechtliche Grundlage für Patentverletzungsklagen.",
DescEN: "Code of Civil Procedure. Procedural basis for patent infringement actions.",
},
}
func handleLinksPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/links.html")
}
func handleLinksAPI(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, linksResponse{
Categories: linkCategories,
Links: curatedLinks,
})
}
type linkSuggestion struct {
Title string `json:"title"`
URL string `json:"url"`
Category string `json:"category"`
Description string `json:"description"`
}
func handleLinkSuggest(w http.ResponseWriter, r *http.Request) {
var req linkSuggestion
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}
if req.Title == "" || req.URL == "" || req.Category == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title, url, and category are required"})
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 {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not save suggestion"})
return
}
writeJSON(w, http.StatusCreated, map[string]string{"status": "ok"})
}
type linkFeedback struct {
LinkID string `json:"linkId"`
Type string `json:"type"`
Message string `json:"message"`
}
func handleLinkFeedback(w http.ResponseWriter, r *http.Request) {
var req linkFeedback
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}
if req.LinkID == "" || req.Type == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "linkId and type are required"})
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 {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not save feedback"})
return
}
writeJSON(w, http.StatusCreated, map[string]string{"status": "ok"})
}
func handleSuggestionCount(w http.ResponseWriter, r *http.Request) {
count, err := supabaseCount("patholo_link_suggestions", "status=eq.pending")
if err != nil {
writeJSON(w, http.StatusOK, map[string]int{"count": 0})
return
}
writeJSON(w, http.StatusOK, map[string]int{"count": count})
}
// extractEmailFromCookie decodes the user's email from the session JWT.
func extractEmailFromCookie(r *http.Request) string {
cookie, err := r.Cookie("patholo_session")
if err != nil || cookie.Value == "" {
return ""
}
parts := strings.Split(cookie.Value, ".")
if len(parts) != 3 {
return ""
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return ""
}
var claims struct {
Email string `json:"email"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return ""
}
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
}