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.
388 lines
13 KiB
Go
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
|
|
}
|