Files
mAi 0ac26fe0ee chore(seed): t-paliad-256 — wipe + seed Example Projects exercising chain code
Re-runnable Go script under scripts/seed-example-projects/ that wipes
every paliad.projects row (FK CASCADE handles dependents) and seeds 18
realistic patent-litigation projects across 3 example clients:

  SIEMENS  — UPC + LG cases incl. CCR (Widerklage) on EP3456789
  BAYER    — EPA Einspruch + BPatG Nichtigkeit on EP2222333
  BEISPL   — sparse DPMA demo on DE10987654

Every node carries the chain-code-driving fields (reference on client,
opponent_code on litigation, patent_number on patent, proceeding_type_id
on case), producing codes like SIEMENS.HUAW.789.INF.CFI and
SIEMENS.HUAW.789.CCR.CFI via services.BuildProjectCode.

One transaction wraps both wipe and seed; -dry-run rolls back so the
script can be sanity-checked before commit. Reference tables
(proceeding_types, deadline_rules, event_types, gerichte, checklists
templates, firms) are untouched.

Ran live against youpc Postgres 2026-05-25: 12 rows wiped, 18 seeded.
2026-05-25 14:25:16 +02:00

569 lines
16 KiB
Go

// Seed Example Projects (t-paliad-256 / m/paliad#87).
//
// Re-runnable test-data reset:
//
// 1. Wipes every row in paliad.projects (FK CASCADE handles the
// dependent rows: deadlines, appointments, parties, notes,
// project_events, project_teams, submission_drafts, approval_*,
// project_partner_units, user_pinned_projects, documents,
// user_calendar_bindings).
//
// 2. Inserts a small but realistic example tree (3 clients, 4
// litigations, 4 patents, 8 cases — 19 projects total) that
// exercises the auto-derived chain code: Client.Litigation.Patent.Case
// → e.g. SIEMENS.HUAW.789.INF.CFI.
//
// 3. Re-reads the projects and prints each row's chain code so the
// operator can eyeball the result without bouncing to SQL.
//
// Reference tables (proceeding_types, deadline_rules, event_types,
// gerichte, checklists templates, firms, profiles) are untouched.
//
// Run:
//
// DATABASE_URL='postgres://...' go run ./scripts/seed-example-projects
//
// One transaction wraps both wipe and seed so the DB is never in a
// half-wiped state. Re-running drops the previous example tree and
// reseeds fresh UUIDs — handy when project-code semantics change.
//
// Owner: m (matthias.siebels@hoganlovells.com). The script looks the
// auth user up by email so it works on any environment where that
// account exists; on a brand-new DB it falls back to NULL created_by.
package main
import (
"context"
"database/sql"
"errors"
"flag"
"fmt"
"os"
"strings"
"text/tabwriter"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/services"
)
// ownerEmail is the auth.users email the seed assigns as created_by.
// Living in code (not a flag) because the example tree is m-owned by
// convention; flip if the example data ever needs a service-account
// owner.
const ownerEmail = "matthias.siebels@hoganlovells.com"
// Proceeding-type IDs used by the seed. Resolved by code (not pinned
// to integer IDs in source) to survive DB renumbering. Loaded once at
// startup; missing codes fail fast with a clear message.
var proceedingCodes = []string{
"upc.inf.cfi",
"upc.ccr.cfi",
"upc.apl.merits",
"de.inf.lg",
"epa.opp.opd",
"de.null.bpatg",
"dpma.opp.dpma",
}
func main() {
dsn := flag.String("dsn", os.Getenv("DATABASE_URL"), "Postgres DSN (defaults to $DATABASE_URL)")
dryRun := flag.Bool("dry-run", false, "print intended actions, roll back transaction")
flag.Parse()
if *dsn == "" {
fmt.Fprintln(os.Stderr, "seed-example-projects: DATABASE_URL not set and -dsn empty")
os.Exit(1)
}
db, err := sqlx.Connect("postgres", *dsn)
if err != nil {
fmt.Fprintln(os.Stderr, "connect:", err)
os.Exit(1)
}
defer db.Close()
ctx := context.Background()
if err := run(ctx, db, *dryRun); err != nil {
fmt.Fprintln(os.Stderr, "seed-example-projects:", err)
os.Exit(1)
}
}
func run(ctx context.Context, db *sqlx.DB, dryRun bool) error {
ownerID, err := lookupOwner(ctx, db, ownerEmail)
if err != nil {
return fmt.Errorf("lookup owner: %w", err)
}
if ownerID == uuid.Nil {
fmt.Printf("note: %s not found in auth.users — created_by will be NULL\n", ownerEmail)
} else {
fmt.Printf("owner resolved: %s = %s\n", ownerEmail, ownerID)
}
procIDs, err := lookupProceedingTypes(ctx, db, proceedingCodes)
if err != nil {
return fmt.Errorf("lookup proceeding_types: %w", err)
}
tx, err := db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }() // no-op if Commit ran first
if err := wipe(ctx, tx); err != nil {
return fmt.Errorf("wipe: %w", err)
}
tree, err := seed(ctx, tx, ownerID, procIDs)
if err != nil {
return fmt.Errorf("seed: %w", err)
}
if dryRun {
fmt.Println("\n--- DRY RUN — rolling back ---")
return nil
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit: %w", err)
}
fmt.Println("seed committed.")
if err := report(ctx, db, tree); err != nil {
return fmt.Errorf("report: %w", err)
}
return nil
}
func lookupOwner(ctx context.Context, db *sqlx.DB, email string) (uuid.UUID, error) {
var id uuid.UUID
err := db.GetContext(ctx, &id, `SELECT id FROM auth.users WHERE email = $1`, email)
if errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, nil
}
if err != nil {
return uuid.Nil, err
}
return id, nil
}
func lookupProceedingTypes(ctx context.Context, db *sqlx.DB, codes []string) (map[string]int, error) {
rows, err := db.QueryxContext(ctx,
`SELECT id, code FROM paliad.proceeding_types WHERE code = ANY($1)`,
pgTextArray(codes))
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]int, len(codes))
for rows.Next() {
var id int
var code string
if err := rows.Scan(&id, &code); err != nil {
return nil, err
}
out[code] = id
}
for _, c := range codes {
if _, ok := out[c]; !ok {
return nil, fmt.Errorf("proceeding_types row missing for code=%q", c)
}
}
return out, nil
}
// pgTextArray is the lib/pq array adapter, repackaged inline so the
// script doesn't need a separate util import.
func pgTextArray(xs []string) any {
type arr = []string
return arr(xs)
}
// wipe deletes every paliad.projects row. FK CASCADE handles the
// dependent tables (verified live 2026-05-25 against information_schema:
// appointments, approval_requests, approval_policies, deadlines,
// documents, notes, parties, project_events, project_partner_units,
// project_teams, submission_drafts, user_pinned_projects,
// user_calendar_bindings, checklist_shares all cascade; projects.
// counterclaim_of and checklist_instances SET NULL; policy_audit_log
// SET NULL).
//
// Reference tables (proceeding_types, deadline_rules, event_types,
// gerichte, checklists, firms, partner_units, profiles) are not
// referenced from this delete.
func wipe(ctx context.Context, tx *sqlx.Tx) error {
res, err := tx.ExecContext(ctx, `DELETE FROM paliad.projects`)
if err != nil {
return err
}
n, _ := res.RowsAffected()
fmt.Printf("wiped: %d project rows (FK CASCADE handled dependents)\n", n)
return nil
}
// seededNode is one row of the seed result, kept so we can print the
// chain code after commit without re-querying for IDs.
type seededNode struct {
id uuid.UUID
title string
}
// seed inserts the example tree. Order matters because parent_id FKs
// must already exist — clients first, then litigations under them, then
// patents, then cases (with the CCR case referencing its sibling
// Klage case via counterclaim_of).
func seed(ctx context.Context, tx *sqlx.Tx, ownerID uuid.UUID, procIDs map[string]int) ([]seededNode, error) {
var nodes []seededNode
insertProject := func(p projectInsert) (uuid.UUID, error) {
id := uuid.New()
var createdBy any
if ownerID != uuid.Nil {
createdBy = ownerID
}
_, err := tx.ExecContext(ctx, `
INSERT INTO paliad.projects (
id, type, parent_id, title, reference, description, status,
created_by, industry, country, client_number, matter_number,
patent_number, filing_date, grant_date,
court, case_number, proceeding_type_id,
our_side, opponent_code, instance_level, counterclaim_of
) VALUES (
$1, $2, $3, $4, $5, $6, 'active',
$7, $8, $9, $10, $11,
$12, $13, $14,
$15, $16, $17,
$18, $19, $20, $21
)`,
id, p.Type, nullUUID(p.ParentID), p.Title, nullStr(p.Reference), nullStr(p.Description),
createdBy, nullStr(p.Industry), nullStr(p.Country), nullStr(p.ClientNumber), nullStr(p.MatterNumber),
nullStr(p.PatentNumber), nullDate(p.FilingDate), nullDate(p.GrantDate),
nullStr(p.Court), nullStr(p.CaseNumber), nullInt(p.ProceedingTypeID),
nullStr(p.OurSide), nullStr(p.OpponentCode), nullStr(p.InstanceLevel), nullUUID(p.CounterclaimOf),
)
if err != nil {
return uuid.Nil, fmt.Errorf("insert %s %q: %w", p.Type, p.Title, err)
}
nodes = append(nodes, seededNode{id: id, title: p.Title})
return id, nil
}
// --- Client 1: Siemens AG ----------------------------------------
siemens, err := insertProject(projectInsert{
Type: "client", Title: "Siemens AG", Reference: "SIEMENS",
Industry: "Telekommunikation / Industrieelektronik", Country: "DE",
Description: "Beispiel-Mandant — Telekommunikation & Halbleiter.",
})
if err != nil {
return nil, err
}
siemensHuawei, err := insertProject(projectInsert{
Type: "litigation", ParentID: siemens,
Title: "Siemens ./. Huawei Technologies", OpponentCode: "HUAW",
Description: "Patentstreit Mobilfunk-Standardpatent.", OurSide: "claimant",
})
if err != nil {
return nil, err
}
siemensHuaweiPatent, err := insertProject(projectInsert{
Type: "patent", ParentID: siemensHuawei,
Title: "EP3456789 — Funkkommunikationssystem mit Mehrfachantenne",
PatentNumber: "EP3456789",
FilingDate: "2018-03-12", GrantDate: "2022-11-09",
})
if err != nil {
return nil, err
}
upcInfCFI, err := insertProject(projectInsert{
Type: "case", ParentID: siemensHuaweiPatent,
Title: "UPC CFI München — Klage Siemens ./. Huawei (EP3456789)",
Court: "UPC Lokalkammer München",
CaseNumber: "UPC_CFI_123/2026",
ProceedingTypeID: procIDs["upc.inf.cfi"],
OurSide: "claimant",
InstanceLevel: "first",
})
if err != nil {
return nil, err
}
_, err = insertProject(projectInsert{
Type: "case", ParentID: siemensHuaweiPatent,
Title: "UPC CFI München — Widerklage Huawei ./. Siemens (EP3456789)",
Court: "UPC Lokalkammer München",
CaseNumber: "UPC_CFI_123/2026 (CCR)",
ProceedingTypeID: procIDs["upc.ccr.cfi"],
OurSide: "defendant", // we're respondent on the CCR
InstanceLevel: "first",
CounterclaimOf: upcInfCFI,
})
if err != nil {
return nil, err
}
_, err = insertProject(projectInsert{
Type: "case", ParentID: siemensHuaweiPatent,
Title: "UPC Berufungsgericht — Berufung Huawei (EP3456789)",
Court: "UPC Court of Appeal",
CaseNumber: "UPC_CoA_45/2027",
ProceedingTypeID: procIDs["upc.apl.merits"],
OurSide: "respondent",
InstanceLevel: "appeal",
})
if err != nil {
return nil, err
}
siemensBosch, err := insertProject(projectInsert{
Type: "litigation", ParentID: siemens,
Title: "Siemens ./. Robert Bosch GmbH", OpponentCode: "BOSCH",
Description: "Sensorik / autonomes Fahren.", OurSide: "claimant",
})
if err != nil {
return nil, err
}
siemensBoschPatent, err := insertProject(projectInsert{
Type: "patent", ParentID: siemensBosch,
Title: "EP1111222 — Sensoreinrichtung für autonomes Fahren",
PatentNumber: "EP1111222",
FilingDate: "2017-06-21", GrantDate: "2021-08-04",
})
if err != nil {
return nil, err
}
_, err = insertProject(projectInsert{
Type: "case", ParentID: siemensBoschPatent,
Title: "LG München I — Klage Siemens ./. Bosch (EP1111222)",
Court: "Landgericht München I",
CaseNumber: "7 O 12345/26",
ProceedingTypeID: procIDs["de.inf.lg"],
OurSide: "claimant",
InstanceLevel: "first",
})
if err != nil {
return nil, err
}
// --- Client 2: Bayer AG ------------------------------------------
bayer, err := insertProject(projectInsert{
Type: "client", Title: "Bayer AG", Reference: "BAYER",
Industry: "Pharma / Life Sciences", Country: "DE",
Description: "Beispiel-Mandant — pharmazeutische Wirkstoffe.",
})
if err != nil {
return nil, err
}
bayerNova, err := insertProject(projectInsert{
Type: "litigation", ParentID: bayer,
Title: "Bayer ./. Novartis Pharma", OpponentCode: "NOVA",
Description: "Wirkstoffverbindung X — Einspruch + Nichtigkeit.", OurSide: "claimant",
})
if err != nil {
return nil, err
}
bayerNovaPatent, err := insertProject(projectInsert{
Type: "patent", ParentID: bayerNova,
Title: "EP2222333 — Wirkstoffverbindung X",
PatentNumber: "EP2222333",
FilingDate: "2015-09-30", GrantDate: "2020-04-22",
})
if err != nil {
return nil, err
}
_, err = insertProject(projectInsert{
Type: "case", ParentID: bayerNovaPatent,
Title: "EPA Einspruch — Novartis ./. EP2222333",
Court: "Europäisches Patentamt — Einspruchsabteilung",
CaseNumber: "OPP-2026-0042",
ProceedingTypeID: procIDs["epa.opp.opd"],
OurSide: "respondent", // Bayer is patent owner defending the patent
InstanceLevel: "first",
})
if err != nil {
return nil, err
}
_, err = insertProject(projectInsert{
Type: "case", ParentID: bayerNovaPatent,
Title: "BPatG — Nichtigkeitsklage Novartis ./. EP2222333",
Court: "Bundespatentgericht",
CaseNumber: "5 Ni 12/26",
ProceedingTypeID: procIDs["de.null.bpatg"],
OurSide: "respondent",
InstanceLevel: "first",
})
if err != nil {
return nil, err
}
// --- Client 3: Beispiel AG (intentionally sparse) ----------------
// Demonstrates the empty-segment skip in BuildProjectCode — the
// case row has a proceeding_type set so the tail is present, but
// no instance_level / our_side, and the patent's number is national
// (DE) so the last-3-digits segment shows DE-style behaviour.
beispiel, err := insertProject(projectInsert{
Type: "client", Title: "Beispiel AG", Reference: "BEISPL",
Industry: "Unspezifiziert", Country: "DE",
Description: "Sparse-Beispiel — zeigt, wie fehlende Segmente übersprungen werden.",
})
if err != nil {
return nil, err
}
beispielWtb, err := insertProject(projectInsert{
Type: "litigation", ParentID: beispiel,
Title: "Beispiel ./. Wettbewerber GmbH", OpponentCode: "WTB",
Description: "Demo-Litigation ohne große Detailtiefe.",
})
if err != nil {
return nil, err
}
beispielWtbPatent, err := insertProject(projectInsert{
Type: "patent", ParentID: beispielWtb,
Title: "DE10987654 — Demo-Erfindung",
PatentNumber: "DE10987654",
})
if err != nil {
return nil, err
}
_, err = insertProject(projectInsert{
Type: "case", ParentID: beispielWtbPatent,
Title: "DPMA Einspruch — Wettbewerber ./. DE10987654",
Court: "Deutsches Patent- und Markenamt",
CaseNumber: "DPMA-EIN-987/26",
ProceedingTypeID: procIDs["dpma.opp.dpma"],
OurSide: "respondent",
InstanceLevel: "first",
})
if err != nil {
return nil, err
}
fmt.Printf("seeded: %d projects\n", len(nodes))
return nodes, nil
}
// projectInsert is the typed input for one insertProject call. Pointer
// fields are kept as plain strings here and converted via nullStr at
// bind time; keeps the call sites readable.
type projectInsert struct {
Type string
ParentID uuid.UUID
Title string
Reference string
Description string
Industry string
Country string
ClientNumber string
MatterNumber string
PatentNumber string
FilingDate string // YYYY-MM-DD
GrantDate string
Court string
CaseNumber string
ProceedingTypeID int
OurSide string
OpponentCode string
InstanceLevel string
CounterclaimOf uuid.UUID
}
func nullStr(s string) any {
if s == "" {
return nil
}
return s
}
func nullInt(i int) any {
if i == 0 {
return nil
}
return i
}
func nullUUID(u uuid.UUID) any {
if u == uuid.Nil {
return nil
}
return u
}
func nullDate(s string) any {
if s == "" {
return nil
}
t, err := time.Parse("2006-01-02", s)
if err != nil {
return nil
}
return t
}
// reportRow is one row of the post-seed report — only the fields the
// printout needs.
type reportRow struct {
ID uuid.UUID `db:"id"`
Type string `db:"type"`
Title string `db:"title"`
Path string `db:"path"`
}
// report prints the seeded tree with the auto-derived chain code for
// each row. Uses services.BuildProjectCode so the script verifies the
// same helper the live app uses (catches drift if the algorithm
// changes).
func report(ctx context.Context, db *sqlx.DB, _ []seededNode) error {
var rows []reportRow
err := db.SelectContext(ctx, &rows, `
SELECT id, type, title, path
FROM paliad.projects
ORDER BY path
`)
if err != nil {
return err
}
fmt.Println("\nresulting chain codes:")
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "TYPE\tTITLE\tCODE")
for _, r := range rows {
code, err := services.BuildProjectCode(ctx, db, r.ID)
if err != nil {
return fmt.Errorf("build code for %s: %w", r.ID, err)
}
indent := strings.Repeat(" ", pathDepth(r.Path)-1)
fmt.Fprintf(tw, "%s\t%s%s\t%s\n", r.Type, indent, r.Title, code)
}
return tw.Flush()
}
func pathDepth(p string) int {
if p == "" {
return 1
}
d := 1
for _, c := range p {
if c == '.' {
d++
}
}
return d
}