diff --git a/scripts/seed-example-projects/main.go b/scripts/seed-example-projects/main.go new file mode 100644 index 0000000..3c36b95 --- /dev/null +++ b/scripts/seed-example-projects/main.go @@ -0,0 +1,568 @@ +// 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 +}