Compare commits
30 Commits
mai/cronus
...
mai/atlas/
| Author | SHA1 | Date | |
|---|---|---|---|
| 3219bff4d4 | |||
| 109946edff | |||
| 528fe35540 | |||
| 9c2788ed8c | |||
| c56859058d | |||
| 6acb1167dd | |||
| 4cd28bc896 | |||
| 568eac0aff | |||
| 733d21c930 | |||
| b05bcf7eeb | |||
| 71e8023784 | |||
| d190fbe0a4 | |||
| e0a82d9f9e | |||
| d326f9aa4a | |||
| 026ad2d5ee | |||
| 13a65a6d6e | |||
| bd7896ef68 | |||
| 946f373651 | |||
| 94310ba498 | |||
| 5834e3dc66 | |||
| 677849784c | |||
| b27d402156 | |||
| 14290294b4 | |||
| 6b970da774 | |||
| 9359e99a6b | |||
| 2c0efc396c | |||
| 5c6a0095e3 | |||
| 6e0961cc30 | |||
| 987db27831 | |||
| 1129baba7a |
342
cmd/seed-orphan-concept-drafts/main.go
Normal file
342
cmd/seed-orphan-concept-drafts/main.go
Normal file
@@ -0,0 +1,342 @@
|
||||
// Command seed-orphan-concept-drafts stages draft sequencing_rules for
|
||||
// deadline_concepts that have rule_count=0 ("orphans"). It calls the
|
||||
// same services.RuleEditorService.Create that POST
|
||||
// /admin/api/procedural-events runs internally, so the audit trigger
|
||||
// + INSTEAD-OF view trigger fan-out into procedural_events +
|
||||
// sequencing_rules + legal_sources fire identically. No HTTP/auth
|
||||
// shell, no direct SQL writes by this command.
|
||||
//
|
||||
// All rules are created with lifecycle_state='draft' (forced by the
|
||||
// service). The admin still reviews + publishes via
|
||||
// /admin/procedural-events.
|
||||
//
|
||||
// t-paliad-320: editorial backlog from t-paliad-193, four remaining
|
||||
// orphan concepts: counterclaim-for-revocation, versaeumnisurteil-
|
||||
// einspruch, schriftsatznachreichung, weiterbehandlung. The
|
||||
// weiterbehandlung concept gets two drafts (EPC Art. 121 + R. 135
|
||||
// versus DPatG § 123a) since the two regimes have different durations
|
||||
// and jurisdictions.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// DATABASE_URL=postgres://… go run ./cmd/seed-orphan-concept-drafts \
|
||||
// [-dry-run] [-reason "free-text audit reason"]
|
||||
//
|
||||
// Idempotency: the command refuses to insert if any rule for a given
|
||||
// (concept, proceeding_type, rule_code) already exists. Safe to re-run
|
||||
// after a partial failure.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// draftSpec captures one CreateRuleInput plus the metadata the command
|
||||
// needs to resolve concept_id + proceeding_type_id from human-readable
|
||||
// slugs/codes. ProceedingCode == "" means event-rooted
|
||||
// (proceeding_type_id = NULL), used for cross-cutting rules whose
|
||||
// jurisdiction has no matching proceeding_type yet.
|
||||
type draftSpec struct {
|
||||
Label string // human label for log output
|
||||
ConceptSlug string
|
||||
ProceedingCode string // "" → NULL proceeding_type_id (event-rooted)
|
||||
SubmissionCode string
|
||||
Name string
|
||||
NameEN string
|
||||
EventKind string
|
||||
PrimaryParty string // "" → omit (NULL)
|
||||
DurationValue int
|
||||
DurationUnit string
|
||||
Timing string
|
||||
Priority string
|
||||
IsCourtSet bool
|
||||
RuleCode string
|
||||
LegalSource string
|
||||
DeadlineNotes string
|
||||
DeadlineNotesEn string
|
||||
}
|
||||
|
||||
func drafts() []draftSpec {
|
||||
return []draftSpec{
|
||||
// ─── 1. counterclaim-for-revocation (UPC R.25.1 ∧ R.23) ───────
|
||||
{
|
||||
Label: "counterclaim-for-revocation → upc.ccr.cfi",
|
||||
ConceptSlug: "counterclaim-for-revocation",
|
||||
ProceedingCode: "upc.ccr.cfi",
|
||||
SubmissionCode: "upc.ccr.cfi.lodge",
|
||||
Name: "Widerklage auf Nichtigkeit (CCR)",
|
||||
NameEN: "Counterclaim for Revocation (CCR)",
|
||||
EventKind: "filing",
|
||||
PrimaryParty: "defendant",
|
||||
DurationValue: 3,
|
||||
DurationUnit: "months",
|
||||
Timing: "after",
|
||||
Priority: "mandatory",
|
||||
IsCourtSet: false,
|
||||
RuleCode: "RoP.025",
|
||||
LegalSource: "UPC.RoP.25.1",
|
||||
DeadlineNotes: "Die Widerklage auf Nichtigkeit (Counterclaim for Revocation, CCR) ist gemeinsam mit der Klageerwiderung (Statement of Defence) einzureichen — d. h. innerhalb von 3 Monaten ab Zustellung der Klageschrift " +
|
||||
"(R. 23 i. V. m. R. 25.1 RoP). Inhaltliche Anforderungen folgen R. 25-30 RoP (insbes. R. 25.1(a)-(c) zu Antrag, Tatsachen und Beweismitteln; R. 27 zu Verfahren nach Einreichung; R. 30 zu einem Antrag auf Änderung des Patents).",
|
||||
DeadlineNotesEn: "The Counterclaim for Revocation (CCR) must be lodged together with the Statement of Defence — i.e. within 3 months of service of the Statement of Claim " +
|
||||
"(Rule 23 in conjunction with Rule 25.1 RoP). Substantive requirements follow Rules 25-30 RoP (in particular R. 25.1(a)-(c) on the application, facts and evidence; R. 27 on post-filing procedure; R. 30 on any application to amend the patent).",
|
||||
},
|
||||
|
||||
// ─── 2. versaeumnisurteil-einspruch (ZPO § 339) ───────────────
|
||||
{
|
||||
Label: "versaeumnisurteil-einspruch → de.inf.lg",
|
||||
ConceptSlug: "versaeumnisurteil-einspruch",
|
||||
ProceedingCode: "de.inf.lg",
|
||||
SubmissionCode: "de.inf.lg.einspruch_vu",
|
||||
Name: "Einspruch gegen Versäumnisurteil",
|
||||
NameEN: "Objection to default judgment",
|
||||
EventKind: "filing",
|
||||
PrimaryParty: "defendant",
|
||||
DurationValue: 2,
|
||||
DurationUnit: "weeks",
|
||||
Timing: "after",
|
||||
Priority: "mandatory",
|
||||
IsCourtSet: false,
|
||||
RuleCode: "§ 339 ZPO",
|
||||
LegalSource: "DE.ZPO.339.1",
|
||||
DeadlineNotes: "Notfrist von 2 Wochen ab Zustellung des Versäumnisurteils (§ 339(1) ZPO). " +
|
||||
"Bei Auslandszustellung oder öffentlicher Bekanntmachung bestimmt das Gericht die Einspruchsfrist gesondert im Versäumnisurteil oder durch nachträglichen Beschluss (§ 339(2) ZPO) — in diesem Fall die gerichtlich festgesetzte Frist mit „Datum setzen“ überschreiben. " +
|
||||
"Form: schriftlich oder zu Protokoll der Geschäftsstelle (§ 340(1) ZPO); die Einspruchsbegründung kann bis zum Verhandlungstermin nachgereicht werden (§ 340(3) ZPO).",
|
||||
DeadlineNotesEn: "Statutory two-week emergency period (Notfrist) from service of the default judgment (§ 339(1) ZPO). " +
|
||||
"If service is abroad or by public notice, the court sets the objection period separately in the default judgment or by a subsequent order (§ 339(2) ZPO) — in that case override with the court-set date. " +
|
||||
"Form: in writing or before the registry clerk (§ 340(1) ZPO); substantive grounds may be filed up to the oral hearing (§ 340(3) ZPO).",
|
||||
},
|
||||
|
||||
// ─── 3. schriftsatznachreichung (ZPO § 283) ───────────────────
|
||||
{
|
||||
Label: "schriftsatznachreichung → de.inf.lg",
|
||||
ConceptSlug: "schriftsatznachreichung",
|
||||
ProceedingCode: "de.inf.lg",
|
||||
SubmissionCode: "de.inf.lg.nachreichung",
|
||||
Name: "Schriftsatznachreichung",
|
||||
NameEN: "Subsequent written submission",
|
||||
EventKind: "filing",
|
||||
PrimaryParty: "", // concept.party = "both" → no default
|
||||
DurationValue: 3,
|
||||
DurationUnit: "weeks",
|
||||
Timing: "after",
|
||||
Priority: "optional",
|
||||
IsCourtSet: true,
|
||||
RuleCode: "§ 283 ZPO",
|
||||
LegalSource: "DE.ZPO.283",
|
||||
DeadlineNotes: "Vom Gericht in der mündlichen Verhandlung gesetzte Schriftsatzfrist gem. § 283 ZPO. " +
|
||||
"Sie wird nur auf Antrag einer Partei bestimmt, die sich auf neues Vorbringen des Gegners nicht erklären konnte. " +
|
||||
"Die konkrete Frist (in der Praxis 2-3 Wochen) und der nachfolgende Verkündungstermin werden im Sitzungsprotokoll bzw. in der prozessleitenden Verfügung festgelegt — Default-Frist hier 3 Wochen, mit „Datum setzen“ überschreiben, sobald die Verfügung vorliegt. " +
|
||||
"Nach Fristablauf darf das Gericht keine weiteren Erklärungen mehr berücksichtigen (§ 283 S. 2, § 296a ZPO).",
|
||||
DeadlineNotesEn: "Court-set written-submission period under § 283 ZPO, granted on a party's application when it could not respond at the oral hearing to the opponent's new submissions. " +
|
||||
"The actual period (in practice 2-3 weeks) and the announcement date are set in the hearing record / case-management order — default 3 weeks here, override via „set date“ once the order is on the file. " +
|
||||
"After expiry, the court will disregard further submissions (§ 283 sent. 2, § 296a ZPO).",
|
||||
},
|
||||
|
||||
// ─── 4. weiterbehandlung — EPC variant (Art. 121 + R. 135) ────
|
||||
{
|
||||
Label: "weiterbehandlung (EPC) → epa.grant.exa",
|
||||
ConceptSlug: "weiterbehandlung",
|
||||
ProceedingCode: "epa.grant.exa",
|
||||
SubmissionCode: "epa.grant.exa.weiterbeh",
|
||||
Name: "Antrag auf Weiterbehandlung",
|
||||
NameEN: "Request for further processing",
|
||||
EventKind: "filing",
|
||||
PrimaryParty: "claimant",
|
||||
DurationValue: 2,
|
||||
DurationUnit: "months",
|
||||
Timing: "after",
|
||||
Priority: "mandatory",
|
||||
IsCourtSet: false,
|
||||
RuleCode: "Art. 121 EPÜ",
|
||||
LegalSource: "EU.EPC-R.135.1",
|
||||
DeadlineNotes: "Antrag auf Weiterbehandlung gem. Art. 121 EPÜ i. V. m. R. 135(1) EPÜ — 2 Monate ab Zustellung der Mitteilung über die Fristversäumung bzw. den eingetretenen Rechtsverlust. " +
|
||||
"Der Antrag wird durch Zahlung der vorgeschriebenen Weiterbehandlungsgebühr gestellt; die versäumte Handlung muss innerhalb derselben 2-Monats-Frist nachgeholt werden (R. 135(1) EPÜ). " +
|
||||
"Die Frist ist nicht verlängerbar. Ausgeschlossen sind insbesondere die Frist für die Weiterbehandlung selbst sowie die in R. 135(2) EPÜ ausdrücklich aufgeführten Fristen (u. a. die Beschwerdefrist nach Art. 108 EPÜ, die Prioritätsfrist nach Art. 87 EPÜ und die Frist zur Wiedereinsetzung).",
|
||||
DeadlineNotesEn: "Request for further processing under Article 121 EPC in conjunction with Rule 135(1) EPC — two months from notification of the communication concerning the missed time limit or the loss of rights. " +
|
||||
"The request is made by payment of the further-processing fee; the omitted act must be completed within the same two-month period (Rule 135(1) EPC). " +
|
||||
"The period is non-extendable. Excluded: the further-processing period itself and the periods listed in Rule 135(2) EPC (notably the appeal period under Art. 108 EPC, the priority period under Art. 87 EPC, and the re-establishment period).",
|
||||
},
|
||||
|
||||
// ─── 5. weiterbehandlung — DPatG § 123a variant ───────────────
|
||||
// No `dpma.grant.*` proceeding_type exists yet, so this rule is
|
||||
// event-rooted (proceeding_type_id NULL) — same pattern as 78
|
||||
// other cross-cutting rules. Editorial follow-up: create a
|
||||
// `dpma.grant.dpma` proceeding_type and reassign.
|
||||
{
|
||||
Label: "weiterbehandlung (DPatG § 123a) → event-rooted (NULL proceeding_type)",
|
||||
ConceptSlug: "weiterbehandlung",
|
||||
ProceedingCode: "", // event-rooted
|
||||
SubmissionCode: "dpma.grant.weiterbeh",
|
||||
Name: "Antrag auf Weiterbehandlung (DPMA)",
|
||||
NameEN: "Request for further processing (DPMA, § 123a PatG)",
|
||||
EventKind: "filing",
|
||||
PrimaryParty: "claimant",
|
||||
DurationValue: 1,
|
||||
DurationUnit: "months",
|
||||
Timing: "after",
|
||||
Priority: "mandatory",
|
||||
IsCourtSet: false,
|
||||
RuleCode: "§ 123a PatG",
|
||||
LegalSource: "DE.PatG.123a.1",
|
||||
DeadlineNotes: "Antrag auf Weiterbehandlung einer DPMA-Patentanmeldung gem. § 123a PatG — 1 Monat ab Zustellung der Mitteilung über die Rechtsfolge der Fristversäumung. " +
|
||||
"Innerhalb dieser Frist müssen (i) der Antrag schriftlich gestellt, (ii) die versäumte Handlung nachgeholt und (iii) die Weiterbehandlungsgebühr nach Patentkostengesetz (PatKostG) gezahlt werden. " +
|
||||
"§ 123a PatG erfasst ausschließlich Anmeldungsfristen, deren Versäumung kraft Gesetzes die Zurückweisung der Anmeldung zur Folge hat. Für sonstige Fristversäumnisse kommt nur die Wiedereinsetzung nach § 123 PatG in Betracht (1 Monat ab Wegfall des Hindernisses, max. 1 Jahr ab Fristablauf). " +
|
||||
"HINWEIS — Taxonomie: bisher kein dpma.grant.* proceeding_type vorhanden; Regel daher event-rooted (proceeding_type_id NULL). Editorial follow-up: dpma.grant.dpma proceeding_type anlegen und diese Regel umhängen.",
|
||||
DeadlineNotesEn: "Request for further processing of a DPMA patent application under § 123a PatG — 1 month from notification of the consequence of the missed deadline. " +
|
||||
"Within this period the applicant must (i) file the written request, (ii) complete the omitted act, and (iii) pay the further-processing fee under the German Patent Costs Act (PatKostG). " +
|
||||
"§ 123a PatG covers only application-stage deadlines whose statutory consequence is rejection. For other missed deadlines, re-establishment under § 123 PatG is the only route (1 month from removal of the obstacle, max 1 year from the missed deadline). " +
|
||||
"TAXONOMY NOTE: no dpma.grant.* proceeding_type exists yet; this rule is event-rooted (proceeding_type_id NULL). Editorial follow-up: create a dpma.grant.dpma proceeding_type and reassign this rule.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
dryRun := flag.Bool("dry-run", false, "log the planned drafts but do not write")
|
||||
reason := flag.String("reason", "t-paliad-320: editorial seed of orphan deadline-concept rules (researcher darwin + lex)", "audit reason recorded with each Create()")
|
||||
flag.Parse()
|
||||
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
log.Fatal("DATABASE_URL not set — export the paliad postgres URL before running")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
conn, err := sqlx.Connect("postgres", dbURL)
|
||||
if err != nil {
|
||||
log.Fatalf("connect db: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
rules := services.NewDeadlineRuleService(conn)
|
||||
editor := services.NewRuleEditorService(conn, rules)
|
||||
|
||||
conceptIDs := map[string]uuid.UUID{}
|
||||
proceedingIDs := map[string]int{}
|
||||
specs := drafts()
|
||||
|
||||
for _, s := range specs {
|
||||
if _, ok := conceptIDs[s.ConceptSlug]; ok {
|
||||
continue
|
||||
}
|
||||
var id uuid.UUID
|
||||
if err := conn.GetContext(ctx, &id,
|
||||
`SELECT id FROM paliad.deadline_concepts WHERE slug = $1`, s.ConceptSlug); err != nil {
|
||||
log.Fatalf("lookup concept %q: %v", s.ConceptSlug, err)
|
||||
}
|
||||
conceptIDs[s.ConceptSlug] = id
|
||||
}
|
||||
for _, s := range specs {
|
||||
if s.ProceedingCode == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := proceedingIDs[s.ProceedingCode]; ok {
|
||||
continue
|
||||
}
|
||||
var id int
|
||||
if err := conn.GetContext(ctx, &id,
|
||||
`SELECT id FROM paliad.proceeding_types WHERE code = $1`, s.ProceedingCode); err != nil {
|
||||
log.Fatalf("lookup proceeding_type %q: %v", s.ProceedingCode, err)
|
||||
}
|
||||
proceedingIDs[s.ProceedingCode] = id
|
||||
}
|
||||
|
||||
fmt.Printf("Seeding %d drafts (dry-run=%v)\n", len(specs), *dryRun)
|
||||
|
||||
for i, s := range specs {
|
||||
conceptID := conceptIDs[s.ConceptSlug]
|
||||
var procID *int
|
||||
if s.ProceedingCode != "" {
|
||||
p := proceedingIDs[s.ProceedingCode]
|
||||
procID = &p
|
||||
}
|
||||
|
||||
// Idempotency: refuse if a rule with the same (concept, proceeding,
|
||||
// rule_code) already exists in any lifecycle state.
|
||||
if existing, err := findExisting(ctx, conn, conceptID, procID, s.RuleCode); err != nil {
|
||||
log.Fatalf("[%d] idempotency check failed for %s: %v", i+1, s.Label, err)
|
||||
} else if existing != uuid.Nil {
|
||||
fmt.Printf(" [%d] SKIP %s — already exists as %s\n", i+1, s.Label, existing)
|
||||
continue
|
||||
}
|
||||
|
||||
input := services.CreateRuleInput{
|
||||
Name: s.Name,
|
||||
NameEN: s.NameEN,
|
||||
ProceedingTypeID: procID,
|
||||
DurationValue: s.DurationValue,
|
||||
DurationUnit: s.DurationUnit,
|
||||
Priority: s.Priority,
|
||||
IsCourtSet: s.IsCourtSet,
|
||||
}
|
||||
input.ConceptID = &conceptID
|
||||
code := s.SubmissionCode
|
||||
input.SubmissionCode = &code
|
||||
ek := s.EventKind
|
||||
input.EventType = &ek
|
||||
t := s.Timing
|
||||
input.Timing = &t
|
||||
rc := s.RuleCode
|
||||
input.RuleCode = &rc
|
||||
ls := s.LegalSource
|
||||
input.LegalSource = &ls
|
||||
dn := s.DeadlineNotes
|
||||
input.DeadlineNotes = &dn
|
||||
dne := s.DeadlineNotesEn
|
||||
input.DeadlineNotesEn = &dne
|
||||
if s.PrimaryParty != "" {
|
||||
pp := s.PrimaryParty
|
||||
input.PrimaryParty = &pp
|
||||
}
|
||||
|
||||
if *dryRun {
|
||||
fmt.Printf(" [%d] DRY %s (concept=%s, proc=%s, code=%s, %d %s, %s)\n",
|
||||
i+1, s.Label, conceptID, codeOrNil(procID), code, s.DurationValue, s.DurationUnit, s.RuleCode)
|
||||
continue
|
||||
}
|
||||
|
||||
row, err := editor.Create(ctx, input, *reason)
|
||||
if err != nil {
|
||||
log.Fatalf(" [%d] CREATE failed for %s: %v", i+1, s.Label, err)
|
||||
}
|
||||
fmt.Printf(" [%d] OK %s → id=%s lifecycle=%s\n",
|
||||
i+1, s.Label, row.ID, row.LifecycleState)
|
||||
}
|
||||
|
||||
fmt.Println("Done.")
|
||||
}
|
||||
|
||||
func findExisting(ctx context.Context, conn *sqlx.DB, conceptID uuid.UUID, procID *int, ruleCode string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
q := `
|
||||
SELECT sr.id
|
||||
FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE pe.concept_id = $1
|
||||
AND sr.rule_code IS NOT DISTINCT FROM $2
|
||||
AND sr.proceeding_type_id IS NOT DISTINCT FROM $3
|
||||
LIMIT 1`
|
||||
err := conn.GetContext(ctx, &id, q, conceptID, ruleCode, procID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return uuid.Nil, nil
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
func codeOrNil(p *int) string {
|
||||
if p == nil {
|
||||
return "<NULL>"
|
||||
}
|
||||
return fmt.Sprintf("%d", *p)
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
// Embed Go's IANA tz database into the binary so time.LoadLocation works
|
||||
// without OS tzdata. The runtime image (alpine) doesn't ship /usr/share/
|
||||
@@ -359,13 +358,11 @@ func main() {
|
||||
log.Printf("CalDAV start: %v", err)
|
||||
}
|
||||
reminderSvc.Start(bgCtx)
|
||||
// Slice B.2 dual-write drift check (t-paliad-305 / m/paliad#93).
|
||||
// Runs every 6 h while the new procedural_events / sequencing_rules /
|
||||
// legal_sources tables shadow the legacy paliad.deadline_rules
|
||||
// table. A clean run logs at INFO; drift logs at WARN with the
|
||||
// full report so a broken dual-write surfaces before the next
|
||||
// deploy.
|
||||
services.StartDualWriteDriftCheckLoop(bgCtx, pool, 6*time.Hour)
|
||||
// Slice B.4 (mig 140, t-paliad-305): legacy paliad.deadline_rules
|
||||
// dropped. The B.2 dual-write drift-check loop is retired — the
|
||||
// procedural_events / sequencing_rules / legal_sources tables
|
||||
// are now the source of truth and there is no parallel side to
|
||||
// compare against. Pre-drop drift was verified clean in mig 140.
|
||||
go func() {
|
||||
<-bgCtx.Done()
|
||||
log.Println("background services: shutdown signal received")
|
||||
|
||||
553
docs/design-fristenrechner-overhaul-2026-05-26.md
Normal file
553
docs/design-fristenrechner-overhaul-2026-05-26.md
Normal file
@@ -0,0 +1,553 @@
|
||||
# Design — Fristenrechner complete UX overhaul (dual-mode + project write-back)
|
||||
|
||||
**Task:** t-paliad-322
|
||||
**Gitea:** m/paliad#146
|
||||
**Inventor:** cronus (shift-1)
|
||||
**Date:** 2026-05-26
|
||||
**Status:** Draft for m's ratification — coder gate held
|
||||
|
||||
## 0. Premises verified live (before designing)
|
||||
|
||||
Verified against the live youpc Postgres (port 11833, paliad schema) and the live source tree on `mai/cronus/inventor-fristenrechner` @ HEAD.
|
||||
|
||||
### 0.1 Rule-and-event corpus today
|
||||
|
||||
| Table | Active+published rows | Notes |
|
||||
|---|---|---|
|
||||
| `paliad.procedural_events` | 222 (236 total) | The events that anchor a deadline. 4 `event_kind` buckets: `filing`, `hearing`, `decision`, `order`, plus `NULL` for legacy/dpma stragglers. |
|
||||
| `paliad.sequencing_rules` | 231 | The deadlines themselves, anchored on `procedural_event_id` and (sometimes) `trigger_event_id`. 80 carry a `trigger_event_id`, 4 are `is_spawn=true`, 45 are `is_court_set=true`, 18 carry a `condition_expr`. |
|
||||
| `paliad.deadline_concepts` | 57 | Hub layer above events (Klageerhebung, Wiedereinsetzung, …). |
|
||||
| `paliad.proceeding_types` | 46 fristenrechner | 4 jurisdictions: UPC (35), DE (5), EPA (3), DPMA (3). |
|
||||
| `paliad.event_categories` | 125 (103 leaves) | The current cascade tree — 5 user-bucket roots (`cms-eingang`, `muendl-verhandlung`, `beschluss-entscheidung`, `frist-verpasst`, `ich-moechte-einreichen`) + `sonstiges` leaf. UI hides the forward-workflow root (`HIDDEN_CASCADE_ROOTS` in `client/fristenrechner.ts:2605`). |
|
||||
| `paliad.deadlines` | 10 (8 with `sequencing_rule_id`) | Demand-side still tiny. The 2 without `sequencing_rule_id` are manual entries. |
|
||||
|
||||
Live `primary_party` vocabulary on `sequencing_rules`: `claimant`, `defendant`, `both`, `court`, `NULL`. Live `priority` vocabulary: `mandatory`, `recommended`, `optional` (no `informational` rows yet — Phase 2 reserved the slot but seeding is deferred).
|
||||
|
||||
### 0.2 The legacy `deadline_rules` reader is a view
|
||||
|
||||
`paliad.deadline_rules_unified` (mig 139, Slice B.3) is a **view** over `sequencing_rules ⋈ procedural_events ⋈ legal_sources`. All Go calculator paths read through it (see `deadline_rule_service.go:70`). The physical `paliad.deadline_rules` table was dropped in mig 140; the view is the canonical legacy-shape reader. Important for this design: there is no "trigger event" table parallel to events — the rule rows themselves are the things the wizard must land on. `trigger_events` (110 rows) is the youpc-parity legacy table used by `/api/tools/event-deadlines` only.
|
||||
|
||||
### 0.3 The frontend today (`/tools/fristenrechner`)
|
||||
|
||||
Two server-rendered surfaces share the same page (`frontend/src/fristenrechner.tsx`, 657 lines) — the legacy "Procedure mode" (R1 step-list, proceeding picker, trigger date, flag checkboxes) and the **Pathway-B row stack** (`buildRowStack` at `client/fristenrechner.ts:2848`, 4009 lines total). Row stack composes three row kinds via a single `.fristen-row` primitive:
|
||||
|
||||
| Row | Source | Filter or qualifier today |
|
||||
|---|---|---|
|
||||
| R1 Perspective (Beide / Klägerseite / Beklagtenseite) | `currentPerspective`, prefilled from `project.our_side` | hybrid — narrows party-tagged cascade chips AND is used as a column-bucket hint in the result view |
|
||||
| R2 Inbox channel (CMS / beA / Postal / Alle) | `currentInboxChannel` | filter — narrows cascade by forum (CMS → upc, beA → de, …) |
|
||||
| R3..Rn Cascade chain | `event_categories` tree | each step narrows children by `inboxFilterAllowsForums` + `perspectiveAllowsParty` + `cascadeChildAllowsProject` |
|
||||
|
||||
The cascade auto-walks single-child branches under a project context and stops at the first branching point. The user picks a leaf; the leaf's slug feeds `/api/tools/fristenrechner/search?event_category_slug=…` which returns concept cards. Each card expands inline to a calc panel (trigger-date input + flags + computed deadline + "in Akte" CTA).
|
||||
|
||||
### 0.4 What is broken in this UI (m's verdict, 2026-05-26 21:21)
|
||||
|
||||
m's brief in m/paliad#146 enumerates four visible bugs:
|
||||
|
||||
1. **"Beide" as default perspective** is incoherent for the headline use case ("file a deadline because something happened" — you ARE one side).
|
||||
2. **R2 (inbox) does not constrain R3 (cascade)** the way a user expects — picking CMS still leaves "Mündliche Verhandlung" / "Frist verpasst" on the next step. (Cause: those roots have `forums=NULL` in the seed → neutral → visible from every inbox.)
|
||||
3. **Mixed axes** — the form layers filters (forum, inbox channel) on top of qualifiers (event-kind, perspective, proceeding_type) without making the difference visible. The user can't tell which picks narrow and which define.
|
||||
4. **Trigger vs follow-up confusion** — the wizard's purpose is to identify the *trigger event*, then surface the *follow-up deadlines*. Today that split is not reflected in the form. After landing on a leaf, the user gets a flat list of concept cards and has to figure out which one is "the thing that happened" vs "the thing I have to file next".
|
||||
|
||||
m's verdict: "complete overhaul. Should be easy to use."
|
||||
|
||||
### 0.5 Anchor files for the eventual coder
|
||||
|
||||
- `frontend/src/client/fristenrechner.ts` (4009 LoC) — page brain. `buildRowStack` @ L2848, `renderRowStack` @ L3112, `runB1Search` (concept-card render) downstream, `expandCardCalc` @ L1337 (inline calc panel), `openSaveModal` @ L290 + `submitSave` @ L374 (project write-back).
|
||||
- `frontend/src/fristenrechner.tsx` (657 LoC) — server-rendered shell. Contains both the Procedure-mode form **and** the Pathway-B row-stack scaffold. The new design replaces the row-stack scaffold; the procedure-mode form survives.
|
||||
- `internal/handlers/fristenrechner.go` + `_search.go` + `_event_categories.go` — three handler files. `POST /api/tools/fristenrechner` (procedure-mode calc), `GET /search` (concept cards), `GET /event-categories` (cascade tree).
|
||||
- `internal/services/fristenrechner.go` (661 LoC) — calculator adapter to `pkg/litigationplanner`. The calculator is **not** touched by this design.
|
||||
- `internal/handlers/deadlines.go:167` + `services/deadline_service.go:411` (`CreateBulk`) — the project write-back endpoint (`POST /api/projects/{id}/deadlines/bulk`). This survives; the design extends its caller.
|
||||
|
||||
### 0.6 Adjacent design docs to read alongside
|
||||
|
||||
- `docs/design-determinator-row-cascade-2026-05-13.md` — the row-cascade pillars (Project-driven narrowing / Visual hierarchy overhaul / Persistent row stack). This overhaul **keeps** Pillars 2 and 3 and reworks Pillar 1's contract.
|
||||
- `docs/design-fristen-phase2-2026-05-15.md` — the unified `sequencing_rules` model the calculator already runs on.
|
||||
- `docs/audit-fristen-logic-2026-05-13.md` — the trigger-event / Pipeline-A-vs-C distinction.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision
|
||||
|
||||
**One page, two complementary entry paths, one result surface, one write-back.**
|
||||
|
||||
```text
|
||||
┌───────────────────────── /tools/fristenrechner ─────────────────────────┐
|
||||
│ │
|
||||
│ ╭──────── Akte / kontextfrei ────────╮ (Step 0 — unchanged today) │
|
||||
│ │ HL-2024-001 ▼ | ohne Akte │ │
|
||||
│ ╰─────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ╭────── Entry mode tabs ──────╮ │
|
||||
│ │ [⚡ Direkt suchen] │ ◀── A: power user, search + chips │
|
||||
│ │ [🧭 Geführt] │ ◀── B: 3-5 question wizard │
|
||||
│ ╰─────────────────────────────╯ │
|
||||
│ │
|
||||
│ ┌── Mode A: Suche ──────────────┐ ┌── Mode B: Wizard ────────────────┐│
|
||||
│ │ search-box ▢▢▢▢▢▢▢▢▢▢▢▢▢▢▢ │ │ R1 Was ist passiert? ✓ filing ││
|
||||
│ │ filter chips: │ │ R2 Forum? ✓ UPC ││
|
||||
│ │ Forum · Proceeding · Event- │ │ R3 Verfahren? ✓ INF ││
|
||||
│ │ Kind · Partei │ │ R4 Welcher Schritt? [active] ││
|
||||
│ │ ┌ Ergebnis-Liste ────────────┐│ │ R5 Welche Seite? ✓ Kläger ││
|
||||
│ │ │ procedural_event hits ││ │ ││
|
||||
│ │ │ [Trigger einrasten →] ││ │ (Direkt-suchen ←) ││
|
||||
│ │ └────────────────────────────┘│ └───────────────────────────────────┘│
|
||||
│ └────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ════ shared from here ═══════════════════════════════════════════════ │
|
||||
│ │
|
||||
│ ┌── Trigger event (locked) ──────────────────────────────────────────┐ │
|
||||
│ │ 📥 Klageschrift wurde eingereicht │ │
|
||||
│ │ upc.inf.cfi · Verletzungsverfahren · Klägerseite │ │
|
||||
│ │ Trigger-Datum: [📅 2026-05-20] (heute) │ │
|
||||
│ │ ändern ↩ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌── Folge-Fristen ────────────────────────────────────────────────────┐ │
|
||||
│ │ ◉ MANDATORY (auto-checked) │ │
|
||||
│ │ ☑ Klageerwiderung (3 Monate) — 20.08.2026 — RoP 23 ✏ Datum │ │
|
||||
│ │ ☑ ... │ │
|
||||
│ │ ◇ OPTIONAL │ │
|
||||
│ │ □ Wiedereinsetzungsantrag (R.320) — bei Versäumnis │ │
|
||||
│ │ ◊ CONDITIONAL │ │
|
||||
│ │ □ Erwiderung auf Nichtigkeitswiderklage nur wenn CCR │ │
|
||||
│ │ ⇲ SPAWNED │ │
|
||||
│ │ ☑ Berufung gegen Endurteil (kein Datum) │ │
|
||||
│ │ ╭────────────────────────────╮ │ │
|
||||
│ │ │ 4 ausgewählt → in Akte ▶ │ │ │
|
||||
│ │ ╰────────────────────────────╯ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The two modes never compete: they're two front doors into the **same** locked-trigger-event → follow-up-list → write-back flow.
|
||||
|
||||
---
|
||||
|
||||
## 2. Axis taxonomy — ratified (filters vs qualifiers)
|
||||
|
||||
The headline source of today's UX confusion is the unmarked mixing of *filters* (narrowing the question space without committing to an answer) and *qualifiers* (parts of the eventual deadline definition).
|
||||
|
||||
| Axis | Role | Source | Constrains | Visual in new UI |
|
||||
|---|---|---|---|---|
|
||||
| `forum` | **filter** | derived from `proceeding_types.jurisdiction` (UPC/DE/EPA/DPMA), or from `project.proceeding_type_id`, or user pick | which `proceeding_types` are reachable; which `event_categories` are visible | Mode A: filter chip strip. Mode B: explicit wizard row (R2). Pre-filled + collapsed when there's a project. |
|
||||
| `proceeding_type` | **qualifier** | `project.proceeding_type_id` (binds via mig 096 codes) OR user pick during wizard | the set of `sequencing_rules` rows that can apply | Mode A: filter chip strip. Mode B: explicit wizard row (R3). Pre-filled + collapsed when there's a project. |
|
||||
| `event_kind` | **filter** | `procedural_events.event_kind` (filing / hearing / decision / order) | which `procedural_events` are reachable as triggers | Mode A: filter chip strip. Mode B: explicit wizard row (R1 — the headline question). |
|
||||
| `inbox channel` | **filter** (today) → **out of scope row** (new) | user pick | nothing the user can see (the rule corpus has no "inbox" column; it was only used to recolour the cascade) | Removed from the primary wizard. Pushed into Mode A's secondary chips (off by default). See §3.3. |
|
||||
| `perspective (our_side)` | **qualifier in file-mode**, **filter in explore-mode** | `project.our_side` OR user pick OR implicit-via-event-kind | `sequencing_rules.primary_party`; result-view column bucketing | Wizard tail (R5) **only when** the trigger event's follow-ups actually differ by side. Pre-filled when project has `our_side`. |
|
||||
| `instance_level` (first / appeal / cassation) | qualifier | `project.instance_level` (mig 084) — sparse | rare — used to disambiguate APP+DE | Surfaced only when the wizard hits APP+DE-style ambiguity. |
|
||||
|
||||
**Rule:** a **filter** narrows the visible options without locking in a deadline answer; it can be cleared and re-applied. A **qualifier** is part of the resulting deadline calculation and is locked the moment it's picked. Filters must propagate forward (Mode A's forum-chip narrows the proceeding-chip's options). Qualifiers are picked once and the answer view reads them.
|
||||
|
||||
The "Beide" perspective default (today's bug) is wrong because perspective is a *qualifier* in the headline use case ("file a deadline because something happened — you are one side"), not a *filter*. New default in Mode B: derive from the project's `our_side`, otherwise force a R5 pick (no "Beide"). See Q8 for the explore-mode exception.
|
||||
|
||||
---
|
||||
|
||||
## 3. Mode taxonomy
|
||||
|
||||
### 3.1 Mode A — "⚡ Direkt suchen" (power user)
|
||||
|
||||
Two visually distinct strips (per m §11.Q3):
|
||||
|
||||
```text
|
||||
┌── Filter (eingrenzen) ─────────────────────────────────────────────────┐
|
||||
│ Forum: [UPC] [DE] [EPA] [DPMA] [Alle] │
|
||||
│ Verfahren: [upc.inf.cfi] [...] [Alle] │
|
||||
│ Was passierte: [📥 Eingereicht] [🏛️ Termin] [⚖️ Entscheidung] [📜 Verfügung] [Alle] │
|
||||
│ Partei: [Klägerseite] [Beklagtenseite] [Beide] │
|
||||
├── Suchen ──────────────────────────────────────────────────────────────┤
|
||||
│ 🔎 [_______________________________________________________________] │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
┌── Ergebnisse (klicken = als Trigger einrasten) ────────────────────────┐
|
||||
│ 📥 Klageerhebung · upc.inf.cfi · UPC · 3 Folge-Fristen → │
|
||||
│ ... │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Single text input, four filter chip strips above it (Forum · Proceeding · Event-Kind · Partei), and a ranked result list of `procedural_events` underneath. The "Filter" strip is visibly grouped (e.g. light background + "Filter (eingrenzen)" header) so users see at a glance that those picks narrow but don't commit; clicking a result row IS the commit (the qualifier action).
|
||||
|
||||
- Search hits `/api/tools/fristenrechner/search` (extended to return events, not just concepts — see §6.1).
|
||||
- Filter chips compose with the text query (`?forum=upc&pt=upc.inf.cfi&kind=filing&party=defendant&q=Klageerwiderung`).
|
||||
- Result rows are individual `procedural_events` (not aggregated concept-cards). Each row shows: name (DE/EN), proceeding_type code, jurisdiction badge, event_kind icon, the rule-count it triggers ("3 Folge-Fristen").
|
||||
- Click a row → "lock as trigger event" → page transitions to the §4 result view.
|
||||
- Power affordance: a row with multiple linked rules can be locked in **per-rule** ("nur diese Frist") via a kebab menu on the row. (Sane default: lock the whole event; the kebab is for the lawyer who only wants one specific reactive deadline.)
|
||||
|
||||
### 3.2 Mode B — "🧭 Geführt" (the wizard)
|
||||
|
||||
A 3-5 question row stack that lands on one `procedural_events` row.
|
||||
|
||||
**Question order (strawman; m to ratify in Q5):**
|
||||
|
||||
1. **R1 — Was ist passiert?** Chips: 📥 Eingereicht (`filing`) · 🏛️ Termin (`hearing`) · ⚖️ Entscheidung (`decision`) · 📜 Verfügung (`order`) · 🕒 Frist versäumt (special bucket, routes to Wiedereinsetzung). One chip = one `event_kind` (or the special). Always asked. ~6 chips, fits one row.
|
||||
2. **R2 — Vor welchem Gericht / bei welchem Amt?** Chips: UPC · LG/OLG/BGH · EPA · DPMA. Pre-filled from `project.proceeding_type → jurisdiction` (or `project.court` substring). **Skipped if R1 narrows to a single forum** (e.g. "Termin" + project has UPC → R2 is implied).
|
||||
3. **R3 — In welchem Verfahren?** Chips: every active `proceeding_type` whose jurisdiction matches R2 AND whose event roster contains at least one event with R1's kind. Pre-filled from `project.proceeding_type_id`. **Auto-skipped** when the narrowed scope has only one candidate.
|
||||
4. **R4 — Welches Schriftstück / Welcher Termin?** This is the wizard's landing question. Chips = `procedural_events` filtered by (R2 forum, R3 proceeding_type, R1 event_kind). Typical scope: 1-12 events. If the user types into this row, the chip layout flips to a search list (same widget as Mode A's result list, narrowed to the wizard's filters).
|
||||
5. **R5 — Vertreten Sie Kläger- oder Beklagtenseite?** Asked **only when** the selected event's `sequencing_rules` have follow-ups that differ by `primary_party` (a quick "are there both claimant- and defendant-tagged rules among the follow-ups?" check on the catalog). Pre-filled from `project.our_side`. **Skipped otherwise.**
|
||||
|
||||
**Row badges** (per m §11.Q3): each wizard row carries a small "Filter" or "Qualifier" tag next to its row-number badge. R1 (event_kind), R2 (forum) → "Filter". R3 (proceeding_type), R4 (procedural_event), R5 (perspective) → "Qualifier". A user can tell at a glance which picks lock in vs which narrow.
|
||||
|
||||
Branching policy (locked):
|
||||
|
||||
- Pre-fill + collapse a row when the answer is implied by the project (Determinator §4 pattern, unchanged).
|
||||
- Auto-skip a row when the narrowed scope has exactly one option (the user has effectively no choice). Show the skipped row as a compact `.fristen-row.is-prefilled` line with "(aus Akte)" or "(implizit aus R1)" annotation. *Don't hide the row* — m's "see your selections" pillar from the row-cascade design demands every decision stays visible.
|
||||
- A user-edited upstream answer **preserves compatible downstream picks** (m §11.Q10): if a re-picked R2 (forum) keeps the existing R1 (event_kind) legal, R1 stays; if it makes R3 (proceeding_type) illegal, R3 resets to active. Rows whose pick was carried across an upstream change render with a one-render "erhalten" annotation so the user notices.
|
||||
- "Welches Schriftstück?" (R4) is the landing question. Once R4 is answered, the wizard exits and the §4 result view takes over.
|
||||
|
||||
### 3.3 The dropped `inbox channel` row
|
||||
|
||||
R2-inbox in today's row stack is removed from the primary surface for both modes. Rationale:
|
||||
|
||||
- The rule corpus has no `inbox` column. The cascade's `forums=['cms']` etc. tags were a presentation-layer reflection of which forum naturally arrives on which channel — but the rule itself doesn't change based on whether a UPC document arrived via CMS or by post (it can't; only CMS is legal). So the only honest role for "inbox" is to nudge the forum filter on Mode A.
|
||||
- Mode A keeps inbox as a *secondary* chip strip ("Erweitert" toggle, off by default). Picking CMS auto-sets the forum chip to UPC; picking beA auto-sets it to DE. The user can override.
|
||||
- Mode B never asks. The wizard derives forum from project context or from R2.
|
||||
|
||||
This collapses one bug class entirely (R2-not-constraining-R3) by retiring R2 from the headline path.
|
||||
|
||||
---
|
||||
|
||||
## 4. Shared result view — "follow-up deadlines"
|
||||
|
||||
Once a trigger event is locked (via Mode A click or Mode B R4 pick), the same result view renders.
|
||||
|
||||
### 4.1 Trigger card (sticky header)
|
||||
|
||||
```text
|
||||
┌─ Trigger-Ereignis ─────────────────────────────────────────────────────┐
|
||||
│ 📥 Klageerhebung │
|
||||
│ upc.inf.cfi · Verletzungsverfahren · UPC │
|
||||
│ ⓘ "Einreichung der Klageschrift gemäß R.13 RoP" │
|
||||
│ Trigger-Datum: 📅 2026-05-20 [ändern ↩] │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
`Trigger-Datum` defaults to today. The user can change it inline (date picker). Changing it re-renders the follow-ups with new computed dates.
|
||||
|
||||
The "ändern" link drops back to whichever mode the user came from with R1-R4 still answered. (Per Q4: the wizard preserves compatible upstream picks rather than rebooting.)
|
||||
|
||||
### 4.2 Follow-up groups
|
||||
|
||||
Group `sequencing_rules` rows that have the trigger event as **anchor** (i.e. `sr.procedural_event_id = trigger.id`) into 4 visible groups:
|
||||
|
||||
1. **MANDATORY** (`priority='mandatory'`) — pre-checked. The bread-and-butter follow-ups.
|
||||
2. **RECOMMENDED** (`priority='recommended'`) — pre-checked. Best-practice fillings (R.19 EPÜ Einspruch, replication briefs).
|
||||
3. **OPTIONAL** (`priority='optional'`) — unchecked. Discretionary actions (R.320 Wiedereinsetzung).
|
||||
4. **CONDITIONAL** (`condition_expr IS NOT NULL`) — unchecked, with the condition rendered ("nur wenn CCR im Verfahren"). Lawyer ticks if applicable.
|
||||
|
||||
Plus a fifth implicit bucket:
|
||||
|
||||
5. **SPAWNED / CROSS-PROCEEDING** (`is_spawn=true`, `spawn_proceeding_type_id IS NOT NULL`) — surfaced as a separate sub-section with a clear "leitet ein neues Verfahren ein" annotation. Pre-checked when mandatory.
|
||||
|
||||
Recommendation (Q6): **4 visible groups, with SPAWNED inlined into whichever priority bucket it belongs to but tagged with a "⇲ neues Verfahren" badge.** Five groups is too many for a one-page result; folding SPAWNED into its priority keeps the math right (mandatory spawned = mandatory) while still flagging the cross-proceeding implication.
|
||||
|
||||
### 4.3 Per-rule row
|
||||
|
||||
```text
|
||||
☑ Klageerwiderung ✏ Datum
|
||||
3 Monate nach Klageerhebung 20.08.2026
|
||||
RoP 23 · Beklagtenseite
|
||||
ⓘ Schriftlich, mit Vollmacht. Erstmaliges Bestreiten der Patentverletzung.
|
||||
```
|
||||
|
||||
Columns: checkbox · title (DE/EN) · duration phrase · computed due date · rule citation · party stance · expandable notes.
|
||||
|
||||
Inline date editor (✏ Datum) lets the lawyer override the computed date for this rule (same affordance as today's `wireDateEditClicks`). The override flows into the write-back payload.
|
||||
|
||||
`is_court_set=true` rules render with the "wird vom Gericht bestimmt" placeholder instead of a date and are unchecked-by-default (matches the current `openSaveModal` behaviour).
|
||||
|
||||
### 4.4 Result-view footer (write-back CTA)
|
||||
|
||||
```text
|
||||
┌─ Auswahl ──────────────────────────────────────────────────────────────┐
|
||||
│ 4 Fristen ausgewählt → In Akte HL-2024-001 eintragen ▶ │
|
||||
│ (oder: 2 mit eigenem Datum, 2 mit Standardberechnung) │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The CTA opens a **confirm-and-edit-dates modal** (per m §11.Q6) where the lawyer can revise each selected deadline's due date one last time, then commits via `POST /api/projects/{id}/deadlines/bulk` (today's endpoint).
|
||||
|
||||
**Kontextfrei mode (no Akte)** — per m §11.Q7, the entire write-back footer **does not render** when `project == null`. The result view stays informational. In its place, an inline nudge appears above the deadline groups:
|
||||
|
||||
```text
|
||||
ⓘ Tipp: Wähle oben eine Akte, um diese Fristen einzutragen.
|
||||
```
|
||||
|
||||
The "oben" link focuses the Akte picker. Once a project is picked, the nudge collapses and the footer materialises; no page reload, no result-view rebuild (the trigger event and date persist across the project pick).
|
||||
|
||||
Modal payload per deadline (extends today's `CreateDeadlineInput`):
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Klageerwiderung",
|
||||
"rule_code": "RoP 23",
|
||||
"due_date": "2026-08-20",
|
||||
"original_due_date": "2026-08-20",
|
||||
"source": "fristenrechner",
|
||||
"rule_id": "<sequencing_rules.id>", /* maps to deadlines.sequencing_rule_id */
|
||||
"notes": "..."
|
||||
}
|
||||
```
|
||||
|
||||
**audit_reason wording (per Q12):** every row inserted via this flow carries an audit-log breadcrumb on the project (matches the deadline `Verlauf` pattern). Default reason string:
|
||||
|
||||
> `Aus Fristenrechner — Trigger: {trigger_event_name} ({trigger_date_iso})`
|
||||
|
||||
e.g. `Aus Fristenrechner — Trigger: Klageerhebung (2026-05-20)`. Falls into `paliad.project_events` with `kind='deadline_created'` via the existing `DeadlineService.CreateBulk` audit hook; no schema change needed.
|
||||
|
||||
---
|
||||
|
||||
## 5. URL / state representation
|
||||
|
||||
The new flow keeps Pathway-B's URL-as-state contract, simplified:
|
||||
|
||||
| Param | Owner | Meaning |
|
||||
|---|---|---|
|
||||
| `project` | Step 0 | Active project UUID. Drives the prefills. |
|
||||
| `mode` | mode tab | `wizard` (default) or `search`. |
|
||||
| `q` | Mode A | Free text query. |
|
||||
| `forum` | Mode A | Comma-separated forum codes (`upc,de`). Mode B writes this only when the wizard derives it. |
|
||||
| `pt` | Mode A | Selected proceeding_type code. |
|
||||
| `kind` | Mode A | event_kind chip pick. |
|
||||
| `party` | both | Perspective. Mode A's chip; Mode B's R5. |
|
||||
| `wizard` | Mode B | Dotted state cursor encoding which row is active and the picks made: `wizard=kind:filing,forum:upc,pt:upc.inf.cfi,active:event`. |
|
||||
| `event` | both | The locked trigger `procedural_events.code`. Once set, the result view renders. |
|
||||
| `trigger_date` | result | ISO date. Default = today; URL only carries it when overridden. |
|
||||
| `selected` | result | Comma-separated `sequencing_rules.id` checkbox state. Only carried when it differs from the priority default. |
|
||||
|
||||
Deep links work end-to-end: `?project=…&event=upc.inf.cfi.soc&trigger_date=2026-05-20&selected=…` jumps a colleague straight to the result view with the same picks. (Per Q11 — query string, not pathname.)
|
||||
|
||||
`popstate` rebuilds the page from the params alone (same pattern as today). The wizard state cursor lets browser back/forward step the wizard rows instead of dropping back to the page root.
|
||||
|
||||
---
|
||||
|
||||
## 6. Backend contract changes
|
||||
|
||||
### 6.1 Extend `/api/tools/fristenrechner/search`
|
||||
|
||||
Today returns concept-cards. Add an alternate response shape (or a `?kind=events` flag) that returns `procedural_events` rows directly:
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "Klageerhebung",
|
||||
"filters": { "forum": "upc", "pt": null, "kind": "filing", "party": null },
|
||||
"events": [
|
||||
{
|
||||
"id": "<uuid>",
|
||||
"code": "upc.inf.cfi.soc",
|
||||
"name_de": "Klageerhebung",
|
||||
"name_en": "Statement of Claim",
|
||||
"event_kind": "filing",
|
||||
"proceeding_type": { "code": "upc.inf.cfi", "jurisdiction": "UPC", "name": "..." },
|
||||
"follow_up_count": 3,
|
||||
"concept_id": "<uuid>",
|
||||
"score": 0.92
|
||||
}
|
||||
],
|
||||
"total": 12
|
||||
}
|
||||
```
|
||||
|
||||
The concept-card shape stays available for the legacy Pathway-B-filter route (kept as a deep-link compat surface, not user-facing).
|
||||
|
||||
### 6.2 New `/api/tools/fristenrechner/follow-ups`
|
||||
|
||||
Given a trigger event id + trigger date + optional party qualifier, return the follow-up `sequencing_rules` rows, grouped + with computed dates. Wire shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"trigger": { "id": "...", "code": "upc.inf.cfi.soc", "name_de": "Klageerhebung", "event_kind": "filing", "proceeding_type": { "code": "upc.inf.cfi", "name_de": "Verletzungsverfahren", "jurisdiction": "UPC" } },
|
||||
"trigger_date": "2026-05-20",
|
||||
"party": "claimant",
|
||||
"follow_ups": [
|
||||
{
|
||||
"rule_id": "<uuid>",
|
||||
"title_de": "Klageerwiderung",
|
||||
"title_en": "Defence",
|
||||
"priority": "mandatory",
|
||||
"primary_party": "defendant",
|
||||
"duration_phrase": "3 Monate",
|
||||
"due_date": "2026-08-20",
|
||||
"is_court_set": false,
|
||||
"is_spawn": false,
|
||||
"condition_expr": null,
|
||||
"rule_code": "RoP 23",
|
||||
"notes_de": "...",
|
||||
"spawn_label": null,
|
||||
"spawn_proceeding_type": null,
|
||||
"appeal_target": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Implementation: `FristenrechnerService.LookupFollowUps(ctx, eventID, triggerDate, party)` — wraps `catalog.LookupEvents(axes={EventID:…, Depth:Next})` (already implemented for the Litigation Planner per `services/fristenrechner.go:251`) and runs the result through `pkg/litigationplanner.Calculate` to fill the dates. The calculator is unchanged.
|
||||
|
||||
### 6.3 No schema changes
|
||||
|
||||
This design is pure UX + handler shape. The unified `sequencing_rules` model already has every column needed (priority, condition_expr, spawn_*, is_court_set, primary_party, applies_to_target). No migration accompanies this design.
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration plan — from current row stack to the overhaul
|
||||
|
||||
Drop nothing on day one; co-exist for one release. The cutover is by URL flag.
|
||||
|
||||
| Phase | What changes | What survives | Branch |
|
||||
|---|---|---|---|
|
||||
| **S1 — Backend** | Add `GET /search?kind=events`. Add `GET /follow-ups`. Both feature-flagged behind a request header (off by default). | Existing endpoints. | one PR |
|
||||
| **S2 — Result view** | New `frontend/src/client/fristenrechner-result.ts` module — given a trigger event + date, render the §4 result view. Mount under a `?overhaul=1` query flag on /tools/fristenrechner. The legacy `renderProcedureResults` stays. | All today's UI. | one PR |
|
||||
| **S3 — Mode A** | New search-with-filter-chips UI. Mount alongside the row stack under `?overhaul=1`. | Row stack still primary. | one PR |
|
||||
| **S4 — Mode B (wizard)** | New `frontend/src/client/fristenrechner-wizard.ts` — the 3-5 row stack. Replaces today's `buildRowStack` only when `?overhaul=1`. Project prefill logic from `buildRowStack` ports 1:1. | The legacy row stack stays in place under no flag. | one PR |
|
||||
| **S5 — Flip the flag** | `?overhaul=1` becomes the default. Legacy row stack and `event_categories`-based cascade rendered with a hard-coded `?legacy=1` for two weeks. | Procedure mode (the upper half of `fristenrechner.tsx`) is unchanged throughout. | one PR |
|
||||
| **S6 — Cleanup** | Drop the `buildRowStack` function tree and the `event_categories`-served cascade endpoint (the table can stay — it's still semantically a useful taxonomy for future tools, just not the Fristenrechner's UI). Drop the `HIDDEN_CASCADE_ROOTS` constant and the cascade-segment bridge. | None of today's row-stack code. | one PR |
|
||||
|
||||
Single project per slice; each PR rebases off main; no shared branches.
|
||||
|
||||
The `event_categories` table itself **stays** — `audit-fristen-logic-2026-05-13.md` §2.4 already calls it "a config layer" useful for taxonomy work. The Fristenrechner just no longer reads it. Future tools (the "Ich möchte einreichen" forward-workflow surface m hid in `HIDDEN_CASCADE_ROOTS`) can resurrect it without DB migration.
|
||||
|
||||
---
|
||||
|
||||
## 8. Worked example — "PA at LG Düsseldorf bekommt einen Hinweisbeschluss via CMS in einer aktiven Akte"
|
||||
|
||||
Project: `HL-2024-001`, proceeding_type=`de.inf.lg` (Verletzungsverfahren LG), `our_side='defendant'`, `court='LG Düsseldorf'`.
|
||||
|
||||
### 8.1 Wizard path (Mode B, default)
|
||||
|
||||
User opens /tools/fristenrechner with that project in Step 0. Mode tab defaults to "🧭 Geführt".
|
||||
|
||||
Wizard rows render top-to-bottom, pre-filled where the project implies:
|
||||
|
||||
```text
|
||||
[1] Was ist passiert? [ active — chips for filing/hearing/decision/order/missed ]
|
||||
[2] Vor welchem Gericht? ✓ LG (aus Akte: HL-2024-001) ← prefilled+collapsed
|
||||
[3] In welchem Verfahren? ✓ Verletzungsverfahren (de.inf.lg) ← prefilled+collapsed
|
||||
```
|
||||
|
||||
User clicks ⚖️ Entscheidung in R1.
|
||||
|
||||
Row stack updates:
|
||||
```text
|
||||
[1] Was ist passiert? ✓ Entscheidung ← answered
|
||||
[2] Vor welchem Gericht? ✓ LG (aus Akte) ← prefilled
|
||||
[3] In welchem Verfahren? ✓ Verletzungsverfahren (de.inf.lg) ← prefilled
|
||||
[4] Welche Entscheidung konkret? [ active — chips: Urteil, Beschluss, Hinweisbeschluss, ... ]
|
||||
```
|
||||
|
||||
R4 chip set is the `procedural_events` whose `proceeding_type_id` = de.inf.lg AND `event_kind` = 'decision'. (Hinweisbeschluss is in this set — `de.inf.lg.hinweisbeschluss` or similar.)
|
||||
|
||||
User clicks Hinweisbeschluss. The wizard checks: do the follow-up rules differ by `primary_party`? In this case yes (the Hinweis triggers a reply window for the defendant only). So R5 fires:
|
||||
|
||||
```text
|
||||
[5] Welche Seite vertreten Sie? ✓ Beklagtenseite (aus Akte) ← prefilled
|
||||
```
|
||||
|
||||
R5 is pre-filled from `project.our_side='defendant'`. The user could click ändern to override, but doesn't.
|
||||
|
||||
Wizard transitions to the §4 result view. Trigger card: "📜 Hinweisbeschluss · de.inf.lg · LG · Beklagtenseite". Trigger date defaults to today.
|
||||
|
||||
### 8.2 Result view
|
||||
|
||||
Three follow-ups in scope (illustrative):
|
||||
|
||||
```text
|
||||
MANDATORY
|
||||
☑ Stellungnahme zum Hinweisbeschluss (Frist 4 Wochen) — 24.06.2026 — ZPO §139
|
||||
RECOMMENDED
|
||||
☑ Anpassung der Klageerwiderung — 24.06.2026 — best practice
|
||||
OPTIONAL
|
||||
□ Antrag auf Fristverlängerung (begründet) — auf Antrag
|
||||
```
|
||||
|
||||
User unchecks "Anpassung", changes the Stellungnahme date inline to 2026-06-20 (one weekday earlier), clicks "In Akte HL-2024-001 eintragen ▶".
|
||||
|
||||
Modal opens with the 1 selected deadline + the user's date override. User confirms.
|
||||
|
||||
### 8.3 Write-back
|
||||
|
||||
Server-side: `POST /api/projects/HL-2024-001/deadlines/bulk` with one `CreateDeadlineInput`:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Stellungnahme zum Hinweisbeschluss",
|
||||
"rule_code": "ZPO §139",
|
||||
"due_date": "2026-06-20",
|
||||
"original_due_date": "2026-06-24",
|
||||
"source": "fristenrechner",
|
||||
"rule_id": "<sr-uuid>",
|
||||
"notes": null
|
||||
}
|
||||
```
|
||||
|
||||
`DeadlineService.CreateBulk` inserts the row into `paliad.deadlines` (with `sequencing_rule_id` populated from `rule_id`), creates the audit event with the wording "Aus Fristenrechner — Trigger: Hinweisbeschluss (2026-05-26)", and the user is redirected to `/deadlines?project_id=…` with a green success toast.
|
||||
|
||||
### 8.4 Mode A path for the same user
|
||||
|
||||
User flips the mode tab to "⚡ Direkt suchen". Filter chips auto-load to Forum=DE + Proceeding=de.inf.lg (from project context). User types "Hinweis" — the result list shows `de.inf.lg.hinweisbeschluss` (and maybe `upc.inf.cfi.hinweis` filtered out because Forum=DE narrows it). User clicks. Same result view appears.
|
||||
|
||||
Total clicks Mode A: 2 (type + click). Mode B: 2 (R1 chip + R4 chip; R2/R3/R5 prefilled). The wizard wins for trainees who don't know vocabulary; search wins for power users who know "Hinweisbeschluss" and can type 4 chars.
|
||||
|
||||
---
|
||||
|
||||
## 9. What's NOT in scope
|
||||
|
||||
- **Replacing the `sequencing_rules` model.** Phase 3 schema is already what the calculator runs on.
|
||||
- **Paliadin (LLM) integration into the wizard.** A "Frist-Extraktion aus Dokument" path is filed elsewhere (memory `b6a11b55…`) and stays out of this design. The wizard could later call out to Paliadin for "the user typed something we don't know" — Phase 2 of *this* overhaul, not Phase 1.
|
||||
- **Calendar / Outlook sync** of created deadlines. Separate t-paliad ticket per project-status.md long-term goals.
|
||||
- **Editing `sequencing_rules`** from the result view. Read-only here. The admin surface at `/admin/procedural-events` handles editing.
|
||||
- **The Procedure-mode surface** (upper half of `fristenrechner.tsx`). The proceeding-picker + trigger-date + flag-checkbox UI stays exactly as it is today. That surface answers a different question ("show me the full procedural ablauf for upc.inf.cfi") and is the right tool for that question; the overhaul targets only the Pathway-B / row-stack half of the page.
|
||||
|
||||
---
|
||||
|
||||
## 10. Open questions for m (12 questions, batched for `AskUserQuestion`)
|
||||
|
||||
All 12 questions tracked in m/paliad#146 § "Open design questions". Each gets a recommended option (listed first in the AskUserQuestion call). Bundled into 3 batches of 4.
|
||||
|
||||
| # | Topic | Recommended pick |
|
||||
|---|---|---|
|
||||
| Q1 | Single page or stepper? | Single page with mode-tabs + collapsible rows. |
|
||||
| Q2 | Mode switcher placement | Tab pair under Step-0 ("Akte / kontextfrei"). |
|
||||
| Q3 | Filter-vs-qualifier UX | Qualifiers carry a small "(Pflichtangabe)" tag; filters render in a slimmer pill. |
|
||||
| Q4 | Cascade tree (keep/replace) | Replace with the 5-question wizard. Drop `event_categories` from the Fristenrechner UI (table stays). |
|
||||
| Q5 | Result grouping | 4 visible groups (Mandatory / Recommended / Optional / Conditional), SPAWNED folded with badge. |
|
||||
| Q6 | Project write-back UX | Confirm-and-edit-dates modal (revise each date once before commit). |
|
||||
| Q7 | No-project mode | CTA disabled with hint ("Wähle eine Akte oben"). Match today's pattern. |
|
||||
| Q8 | Perspective semantics by mode | Mode B (file): qualifier — required pick. Mode A (search): filter — optional. |
|
||||
| Q9 | Trigger-date input timing | In the result-view trigger card; default today; inline editable. |
|
||||
| Q10 | Backward navigation | Preserve compatible downstream picks; reset only those invalidated. |
|
||||
| Q11 | Deep-link encoding | Query string (`?event=…&trigger_date=…`). |
|
||||
| Q12 | Audit reason wording | `Aus Fristenrechner — Trigger: {name} ({date})`. |
|
||||
|
||||
(Recommendations land as the "first option" in each AskUserQuestion call per the inventor SKILL contract.)
|
||||
|
||||
---
|
||||
|
||||
## 11. m's decisions (2026-05-26)
|
||||
|
||||
All 12 questions answered via `AskUserQuestion` on 2026-05-26 21:30. Recording each pick + the reasoning where it diverges from the inventor's recommendation. Sections of the design that are now load-bearing on these answers carry a "(m §11.Q{n})" cross-reference.
|
||||
|
||||
- **Q1 (Page layout): Single page, mode-tabs.** [= recommendation] Both modes share /tools/fristenrechner; the mode-tabs swap the question surface in place. Result view is shared. **Locks §3, §4, §5.**
|
||||
- **Q2 (Mode switcher): Tab pair under Step-0.** [= recommendation] "⚡ Direkt suchen" / "🧭 Geführt" tabs render directly below the Akte picker. Project context survives the tab flip; compatible filter picks (forum, proceeding) carry across.
|
||||
- **Q3 (Filter-vs-qualifier UX): Section split — Filter above, Qualifier below.** [≠ recommendation; m picked option 2.] Mode A's filter chips render in a "Filter (eingrenzen)" strip on top; below it, the result list is the qualifier surface (clicking a row locks). Mode B wizard rows carry a small "Filter" / "Qualifier" badge in the row badge area (e.g. R1/R2 = Filter, R3/R5 = Qualifier). The "(Pflichtangabe)" tag from the original recommendation is replaced by this section-level visual hierarchy. **Updates §3.1 (Mode A layout) and §3.2 (wizard row badges).**
|
||||
- **Q4 (Cascade tree): Replace with wizard, keep table.** [= recommendation] The Fristenrechner UI stops reading `paliad.event_categories`. The table stays for future tools (the hidden "Ich möchte einreichen" forward-workflow). **Locks §3.2 and the cleanup in §7 S6.**
|
||||
- **Q5 (Result grouping): 4 groups + SPAWNED badge.** [= recommendation] Mandatory / Recommended / Optional / Conditional are the four sub-sections; spawned rules fold into their priority bucket with a `⇲ neues Verfahren` badge. **Locks §4.2.**
|
||||
- **Q6 (Write-back UX): Confirm-and-edit-dates modal.** [= recommendation] Inline checkbox selection in the result view → "In Akte eintragen ▶" → modal with editable due-date fields per row + Akte picker. **Locks §4.4.**
|
||||
- **Q7 (No-project mode): Hide the CTA entirely.** [≠ recommendation; m picked option 3.] In kontextfrei mode the result view renders without the write-back footer at all — no disabled-with-hint button. Rationale (inferred from m's pick): the result view is informational by design in explore mode, and a permanently-disabled CTA is visual noise. **Updates §4.4** — the CTA is conditional on `project != null`, not on `disabled`. The hint message moves into the Step-0 picker: when a user is in kontextfrei mode and reaches a result view, a one-line nudge appears above the result groups ("Tipp: Wähle oben eine Akte, um diese Fristen einzutragen") with a link to focus the Akte picker. This preserves the affordance discovery without polluting the footer.
|
||||
- **Q8 (Perspective semantics): Mode B qualifier, Mode A filter.** [= recommendation] Wizard Mode B's R5 is required and Klagerseite/Beklagtenseite only (no "Beide"); Mode A's perspective chip is a filter with a "Beide" option, off by default. **Locks §2 axis table and §3.2 R5 description.**
|
||||
- **Q9 (Trigger-date input): In the result-view trigger card.** [= recommendation] The sticky header card on the result view shows the date; default today; inline editable. Changing it re-renders follow-up dates live. **Locks §4.1.**
|
||||
- **Q10 (Backward navigation): Preserve compatible picks.** [= recommendation] Re-opening any wizard row keeps downstream picks that are still legal under the new upstream value; resets only the picks the new value invalidates. A small chip-strip annotation ("erhalten") appears for one render-cycle on rows whose pick was carried so the user notices. **Updates §3.2 branching policy.**
|
||||
- **Q11 (Deep-link encoding): Query string.** [= recommendation] `?project=…&mode=…&event=…&trigger_date=…&selected=…&forum=…&pt=…&kind=…&party=…` — every state piece is a query param. `popstate` rebuilds the page from params. **Locks §5.**
|
||||
- **Q12 (Audit reason wording): `Aus Fristenrechner — Trigger: {name} ({date})`.** [= recommendation] German-locale, includes the trigger event name and its ISO date. Stored as `paliad.project_events.metadata->>'audit_reason'` via the existing `DeadlineService.CreateBulk` audit hook. **Locks §4.4.**
|
||||
|
||||
### 11.1 What changed from the strawman as a result
|
||||
|
||||
Two follow-on edits flow from m's picks:
|
||||
|
||||
1. **§3.1 Mode A layout** — top strip is "Filter (eingrenzen)" with the four filter chip groups (Forum · Proceeding · Event-Kind · Partei); the result list directly below carries the implicit "click here to lock" qualifier action. No "(Pflichtangabe)" tag.
|
||||
2. **§4.4 Write-back footer** — the footer is rendered conditionally on `project != null`. The kontextfrei-mode informational nudge moves into the result view body above the deadline groups.
|
||||
|
||||
These edits don't change the §7 migration plan or the §6 backend contracts.
|
||||
|
||||
---
|
||||
|
||||
## 12. Synthesis links
|
||||
|
||||
- mBrian topic: `topic-fristenrechner` (existing) — file this design as a `[synthesis]` node linked `triggered_by` t-paliad-322 and `related_to` the row-cascade + Phase 2 designs.
|
||||
- Related memories: row-cascade design `0fbd2c1a-…`, Phase 2 design `a454dc86-…`, audit logic `f6c0c3a2-…`.
|
||||
580
docs/design-proceeding-types-taxonomy-2026-05-26.md
Normal file
580
docs/design-proceeding-types-taxonomy-2026-05-26.md
Normal file
@@ -0,0 +1,580 @@
|
||||
# Design — `paliad.proceeding_types` taxonomy cleanup: primary proceedings vs phases vs side-actions vs meta
|
||||
|
||||
**Task:** t-paliad-324
|
||||
**Gitea:** m/paliad#147
|
||||
**Inventor:** atlas (shift-1)
|
||||
**Date:** 2026-05-26
|
||||
**Status:** Draft — coder gate held until m ratifies the 10 design questions in §9
|
||||
**Branch:** `mai/atlas/inventor-proceeding`
|
||||
|
||||
---
|
||||
|
||||
## 0. Premises verified live (before designing)
|
||||
|
||||
Verified against live youpc Postgres (port 11833, `paliad` schema) on 2026-05-26 22:05. Findings supersede the audit grouping in m/paliad#147 wherever they diverge — the issue body was correct on shape but conservative on counts.
|
||||
|
||||
### 0.1 The 46-row table, fully classified by usage
|
||||
|
||||
`paliad.proceeding_types` has 49 rows total; 46 active, 3 inactive (`upc.apl.merits/cost/order` — superseded by `upc.apl.unified`, id 160) plus 1 archive bucket (`_archived_litigation`, id 32). Cross-references against the four downstream consumers:
|
||||
|
||||
| Consumer | Column | Active rows that point at the 46 active types |
|
||||
|---|---|---|
|
||||
| `paliad.sequencing_rules.proceeding_type_id` | rule's anchor proceeding | **18 distinct rows used** — the primaries with corpus. 28 rows have 0 rules. |
|
||||
| `paliad.sequencing_rules.spawn_proceeding_type_id` | cross-proceeding spawn target | **1 distinct row used** — `upc.apl.merits` (id=11, **inactive!**). 0 active types are spawn targets. |
|
||||
| `paliad.projects.proceeding_type_id` | project's primary type | **6 distinct rows used** (across 18 projects). All 6 are in the 18 primaries. |
|
||||
| `paliad.event_category_concepts.proceeding_type_code` | concept's owning proceeding | **18 distinct codes used.** 3 of those codes (`upc.apl.merits`, `upc.apl.order`, `upc.apl.cost`) point at **inactive** rows — pre-existing data drift from the `upc.apl.unified` merger (flagged §8, out of scope here). |
|
||||
|
||||
The audit answer in one sentence: **of the 46 active rows, only 18 have any downstream consumer pointing at them today** (the 18 primaries with corpus). The remaining 28 rows are decorative — they exist in the table but nothing references them.
|
||||
|
||||
This makes reparenting **trivially safe**: no FK invariant breaks, no SQL update touches existing data, no migration risk.
|
||||
|
||||
### 0.2 The 18 primaries with corpus (rules + concepts)
|
||||
|
||||
Ordered by `paliad.sequencing_rules` count (descending), with `event_category_concepts` count alongside:
|
||||
|
||||
| id | code | jurisdiction | rules | concepts | projects |
|
||||
|---:|---|---|---:|---:|---:|
|
||||
| 8 | `upc.inf.cfi` | UPC | 25 | 14 | 1 |
|
||||
| 9 | `upc.rev.cfi` | UPC | 17 | 10 | 0 |
|
||||
| 160 | `upc.apl.unified` | UPC | 16 | 0 *(see drift note)* | 0 |
|
||||
| 12 | `de.inf.lg` | DE | 11 | 4 | 1 |
|
||||
| 13 | `de.null.bpatg` | DE | 10 | 4 | 1 |
|
||||
| 14 | `epa.opp.opd` | EPA | 8 | 7 | 1 |
|
||||
| 15 | `epa.opp.boa` | EPA | 8 | 12 | 0 |
|
||||
| 16 | `epa.grant.exa` | EPA | 8 | 0 | 0 |
|
||||
| 17 | `upc.dmgs.cfi` | UPC | 8 | 1 | 0 |
|
||||
| 26 | `de.inf.bgh` | DE | 8 | 17 | 0 |
|
||||
| 25 | `de.inf.olg` | DE | 7 | 8 | 0 |
|
||||
| 10 | `upc.pi.cfi` | UPC | 7 | 3 | 0 |
|
||||
| 27 | `de.null.bgh` | DE | 6 | 10 | 0 |
|
||||
| 29 | `dpma.appeal.bpatg` | DPMA | 5 | 6 | 0 |
|
||||
| 30 | `dpma.appeal.bgh` | DPMA | 4 | 8 | 0 |
|
||||
| 28 | `dpma.opp.dpma` | DPMA | 4 | 3 | 1 |
|
||||
| 18 | `upc.disc.cfi` | UPC | 4 | 1 | 0 |
|
||||
| 35 | `upc.ccr.cfi` | UPC | 1 | 0 | 1 |
|
||||
|
||||
These 18 are unambiguously **primary proceedings** in the m/paliad#147 sense — self-contained matters, own filing, own deadline cascade, own ablauf. They survive every model.
|
||||
|
||||
### 0.3 The 4 unloaded primaries (Group A continued)
|
||||
|
||||
Four more active rows are conceptually primaries but carry **zero rules and zero concepts today** — seeded for catalog completeness, waiting for corpus:
|
||||
|
||||
| id | code | jurisdiction | what it is |
|
||||
|---:|---|---|---|
|
||||
| 171 | `upc.dni.cfi` | UPC | Negative Feststellungsklage — standalone declaratory action |
|
||||
| 172 | `upc.epo.review` | UPC | Überprüfung von EPA-Entscheidungen — standalone review action |
|
||||
| 179 | `upc.bsv.cfi` | UPC | Beweissicherung / saisie — standalone evidence-preservation order |
|
||||
| 188 | `upc.pl.cfi` | UPC | Schutzschrift — pre-litigation defensive filing |
|
||||
|
||||
These are **primary** by character (each has its own RoP-defined filing pathway and its own deadline tree once rules get seeded) but **unloaded** today. Decision: keep them as `kind='proceeding'` so Mode B R3 surfaces them for future rule attachment and `pkg/litigationplanner` accepts them as valid catalog codes.
|
||||
|
||||
§9 Q3.b discusses `upc.pl.cfi` (it's the only borderline — Schutzschrift is technically a pre-action filing, not a proceeding at the time of filing). m's call.
|
||||
|
||||
### 0.4 The 28 non-primary rows
|
||||
|
||||
The 28 active rows that have **zero rules + zero concepts + zero projects pointing at them** group cleanly into three categories:
|
||||
|
||||
#### Group B — Phases of a primary CFI proceeding (5 rows)
|
||||
|
||||
These describe stages *within* an existing CFI proceeding, not standalone matters. A `upc.inf.cfi` action passes through interim → oral → decision phases; the phase isn't a separately-elected proceeding type.
|
||||
|
||||
| id | code | name |
|
||||
|---:|---|---|
|
||||
| 173 | `upc.cfi.interim` | CFI - Zwischenverfahren |
|
||||
| 174 | `upc.cfi.oral` | CFI - Mündliche Verhandlung |
|
||||
| 175 | `upc.cfi.decision` | CFI - Endentscheidung |
|
||||
| 176 | `upc.costs.cfi` | Separate Kostenentscheidung *(post-decision sub-phase)* |
|
||||
| 185 | `upc.default.cfi` | Versäumnisentscheidung *(alt. decision outcome)* |
|
||||
|
||||
The "phase" concept already has a natural home in the data model: `paliad.procedural_events.event_kind` (filing/hearing/decision/order). What `upc.cfi.interim` actually represents is "all events with kind=filing under upc.inf.cfi/upc.rev.cfi/upc.pi.cfi/etc."; `upc.cfi.oral` is "all events with kind=hearing"; `upc.cfi.decision` is "all events with kind=decision". The proceeding-type row buys nothing the event_kind already carries.
|
||||
|
||||
#### Group C — Side-actions inside a proceeding (10 rows)
|
||||
|
||||
Applications and court orders that arise *inside* a primary proceeding. They could each become a `condition_expr`-gated rule on the parent proceeding when corpus arrives; they don't need their own proceeding row.
|
||||
|
||||
| id | code | name |
|
||||
|---:|---|---|
|
||||
| 178 | `upc.evidence.cfi` | Beweisanordnungen (allgemein) |
|
||||
| 182 | `upc.experiments.cfi` | Gerichtlich angeordnete Versuche |
|
||||
| 177 | `upc.security.cfi` | Sicherheitsleistung |
|
||||
| 184 | `upc.intervention.rop` | Streitbeitritt |
|
||||
| 165 | `upc.parties.change` | Parteiwechsel / Patentübergang |
|
||||
| 170 | `upc.optout.cfi` | Antrag auf Opt-out |
|
||||
| 180 | `upc.inspection.cfi` | Besichtigungsantrag |
|
||||
| 181 | `upc.freezing.cfi` | Anordnung zur Vermögenssperre |
|
||||
| 187 | `upc.withdrawal.rop` | Klagerücknahme |
|
||||
| 183 | `upc.rehearing.coa` | Wiederaufnahmeantrag |
|
||||
|
||||
A subtle distinction: `upc.bsv.cfi` (Beweissicherung) IS a standalone primary (its own RoP filing) whereas `upc.evidence.cfi` (Beweisanordnungen allgemein) is a side-action class (orders the court makes inside any proceeding). The two are not duplicates; the categorisation is structural, not nominal.
|
||||
|
||||
#### Group D — Cross-cutting administrative / meta (8 rows)
|
||||
|
||||
These describe rules-of-procedure mechanics, not matters a lawyer takes on. None of them is a "Verfahren" in any user-facing sense.
|
||||
|
||||
| id | code | name |
|
||||
|---:|---|---|
|
||||
| 162 | `upc.case.mgmt` | Verfahrensverwaltung |
|
||||
| 161 | `upc.general.rop` | Allgemeine Bestimmungen |
|
||||
| 163 | `upc.service.rop` | Zustellung von Schriftsätzen |
|
||||
| 168 | `upc.language.rop` | Verfahrenssprache |
|
||||
| 164 | `upc.representation.rop` | Vertretung / Anwaltsprivileg |
|
||||
| 166 | `upc.fees.court` | Gerichtsgebühren |
|
||||
| 167 | `upc.legalaid.cfi` | Prozesskostenhilfe |
|
||||
| 186 | `upc.special.cfi` | Besondere Verfahrenslagen |
|
||||
| 169 | `upc.reestablishment.rop` | Wiedereinsetzung in den vorigen Stand *(cross-cutting; applies to every proceeding)* |
|
||||
|
||||
`upc.reestablishment.rop` lands in Group D because **every** proceeding has a Wiedereinsetzung path — it isn't a kind-of-proceeding, it's a cross-cutting remedy. Today's rules already model it correctly (it's a `condition_expr`-gated rule on each primary, not a separately-elected proceeding type).
|
||||
|
||||
### 0.5 Counts reconciled
|
||||
|
||||
| Group | Count | Total of 46 |
|
||||
|---|---:|---:|
|
||||
| A.1 Primary with corpus (18 rows) | 18 | |
|
||||
| A.2 Primary, unloaded (4 rows) | 4 | |
|
||||
| B Phases (5 rows) | 5 | |
|
||||
| C Side-actions (10 rows) | 10 | |
|
||||
| D Meta / cross-cutting (9 rows) | 9 | |
|
||||
| **Total** | | **46 ✓** |
|
||||
|
||||
m/paliad#147's audit listed 8 Group-D rows; live data shows 9 once `upc.reestablishment.rop` is moved into the meta bucket (it appeared as ambiguous "cross-cutting admin / meta" — confirming this design's read).
|
||||
|
||||
---
|
||||
|
||||
## 1. Categorization — ratified
|
||||
|
||||
The taxonomy proposal: a row in `paliad.proceeding_types` has exactly one of four **structural kinds**.
|
||||
|
||||
| `kind` | What it is | Visible in Mode B R3 wizard? | In `pkg/litigationplanner` catalog? | Eligible for `projects.proceeding_type_id`? |
|
||||
|---|---|---|---|---|
|
||||
| `proceeding` | A self-contained matter with its own filing pathway and its own deadline tree | **Yes** | **Yes** (filtered by `kind='proceeding' AND is_active=true`) | **Yes** |
|
||||
| `phase` | A stage *within* a primary proceeding | No | No | No |
|
||||
| `side_action` | An application/order that arises inside a primary proceeding | No | No | No |
|
||||
| `meta` | RoP mechanics, cross-cutting rules, court administration | No | No | No |
|
||||
|
||||
This is **Model 1 from m/paliad#147** (kind discriminator on `proceeding_types`). §2 explains why it beats Models 2-4 for the actual data.
|
||||
|
||||
The 46 active rows map to the 4 kinds as follows:
|
||||
|
||||
- **`proceeding` (22 rows):** all 18 primaries-with-corpus + the 4 unloaded primaries from §0.3. Specifically the union of §0.2 + §0.3.
|
||||
- **`phase` (5 rows):** the §0.4 Group B list.
|
||||
- **`side_action` (10 rows):** the §0.4 Group C list.
|
||||
- **`meta` (9 rows):** the §0.4 Group D list (incl. `upc.reestablishment.rop`).
|
||||
|
||||
### 1.1 Edge calls
|
||||
|
||||
- **`upc.ccr.cfi` (id 35)** — stays `kind='proceeding'` with the existing routing-to-`upc.inf.cfi` from t-paliad-204 §0.3 S1 (the determinator surfaces it, the mapping returns inf.cfi's id with `with_ccr=true`). Rationale: the routing layer is already built and m ratified it 2026-05-18. This design does not re-open that decision. §9 Q7 lets m revisit.
|
||||
- **`upc.pl.cfi` (Schutzschrift, id 188)** — borderline. Schutzschrift is filed *before* a proceeding exists; it's a defensive pre-litigation filing. Recommendation: keep as `kind='proceeding'` (it has its own RoP path + its own deadlines once seeded). The alternative — calling it `side_action` of a not-yet-existing inf.cfi — is semantically backwards. §9 Q3.b lets m revisit.
|
||||
- **`upc.bsv.cfi` (saisie, id 179)** vs **`upc.evidence.cfi` (id 178)** — bsv stays `kind='proceeding'` (own RoP filing under R.192-198), evidence stays `kind='side_action'` (the orders a court makes inside any proceeding under R.190). The codes are not duplicates.
|
||||
|
||||
### 1.2 What the categorisation buys
|
||||
|
||||
- **Mode B R3 (Fristenrechner overhaul, t-paliad-322)** queries `proceeding_types WHERE is_active AND kind='proceeding'` and gets a clean 22-row pick list — no phase/side-action/meta noise.
|
||||
- **`projects.proceeding_type_id` integrity** is enforceable: an FK + CHECK (or a triggered constraint, see §3.3) blocks setting a project's type to anything except `kind='proceeding'`.
|
||||
- **`pkg/litigationplanner` snapshot generator** filters identically; youpc.org's catalog stays UPC-primary-only with no leakage of phase/admin rows.
|
||||
- **Determinator + dropdowns** get a forward-compatible filter; future feature work (e.g. "show me all side-actions available in this proceeding") becomes a different query against the same table.
|
||||
- **Forward-compatibility for new rows** — when corpus for a side-action arrives (e.g. `upc.evidence.cfi` gains 4 sequencing_rules with `condition_expr='evidence_order_issued'`), the rules anchor on the *parent* primary, not on the side-action row. The kind classification stays correct; the side-action row remains a taxonomic label.
|
||||
|
||||
---
|
||||
|
||||
## 2. Model choice — Model 1 (kind discriminator)
|
||||
|
||||
### 2.1 The four candidate models, scored
|
||||
|
||||
| Model | Schema churn | Models phase parentage? | Mode B R3 filter | Migration risk | Verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| **1. `kind` discriminator on `proceeding_types`** | One column + CHECK constraint | No, but doesn't need to | `WHERE kind='proceeding'` | Trivial — UPDATE only | **Recommended** |
|
||||
| 2. Self-referencing `parent_id` | One column + FK + CHECK | Yes, but parentage is wrong shape (phases are phase-of-EVERY-CFI, not of one) | `WHERE parent_id IS NULL` | Trivial | Over-modelled |
|
||||
| 3. Separate tables | Three new tables + view/JOINs | Yes, fully | Just query `proceeding_types` | Migration churn + every consumer query learns a new shape | Overkill for 28 unused rows |
|
||||
| 4. Move phases into `procedural_events` | One mass row-move + DELETE | n/a (phases vanish from `proceeding_types`) | Trivial | Highest — would touch event_kind taxonomy and Fristenrechner result-view structure | Wrong shape (phases ≠ events) |
|
||||
|
||||
### 2.2 Why Model 1 wins
|
||||
|
||||
The fundamental observation: **the 28 non-primary rows have zero downstream pressure**. No rule, no project, no concept, no spawn FK references them. They exist in the table as taxonomic placeholders — names someone wrote down so future corpus could attach. We don't need to physically restructure the table; we just need to label what's what so consumers can filter correctly.
|
||||
|
||||
Model 1 gives us exactly that with one column. The other models pay schema/migration cost to model a parent-child relationship that **no consumer queries**. Mode B R3 doesn't ask "what are the phases of upc.inf.cfi?" — it asks "what are the proceedings I can pick?". The Fristenrechner result view doesn't ask the proceeding-types table about phases — phases live inside `procedural_events.event_kind` and the priority-bucket sub-sections in the §4.2 of the Fristenrechner overhaul doc.
|
||||
|
||||
Model 2's `parent_id` is wrong in shape: `upc.cfi.interim` doesn't have ONE parent (`upc.inf.cfi`), it has SEVEN parents (every CFI proceeding). Modelling that as a self-reference would force either (a) duplicating the phase rows per primary, or (b) using NULL parent_id for "applies to all". Both options are uglier than just dropping parent_id and trusting `kind='phase'`.
|
||||
|
||||
Model 3's separate tables would create rich relations that no consumer reads. Premature relational normalisation.
|
||||
|
||||
Model 4 would force phases into `procedural_events`, but phases aren't events. A phase is a *bucket of events*. The bucket is already implicit in the `event_kind` column (filing → interim, hearing → oral, decision → decision). If anything, Model 4 is *backwards* — phases should disappear into `event_kind`, not become event rows. The way to "delete" the phase rows from proceeding_types is just to deactivate them (or mark them `kind='phase'`); we don't need to re-locate them into another table to claim that conceptual move.
|
||||
|
||||
### 2.3 What we don't do — physical deletion
|
||||
|
||||
The 28 non-primary rows are NOT dropped from the table. They:
|
||||
|
||||
- Get tagged with the right `kind` value.
|
||||
- Optionally get `is_active=false` flipped (m's call, §9 Q9).
|
||||
- Stay in the table so consumers that historically referenced them by id (admin tools, audit logs, future schema-rescue scripts) keep working.
|
||||
|
||||
`DROP` is a one-way door we don't need to walk through. The CHECK constraint + kind tagging gives us the same logical cleanliness with none of the irreversibility risk.
|
||||
|
||||
---
|
||||
|
||||
## 3. Schema sketch + migration plan
|
||||
|
||||
### 3.1 DDL — the new column
|
||||
|
||||
```sql
|
||||
-- Migration NNN_proceeding_types_kind.up.sql
|
||||
-- (NNN = whatever MAX(version) + 1 is at write time; see project-status.md
|
||||
-- for the live numbering. As of 2026-05-26 the head is mig 152 per the
|
||||
-- recent dedupe of identical sequencing_rule clones.)
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN kind text NOT NULL DEFAULT 'proceeding'
|
||||
CHECK (kind IN ('proceeding', 'phase', 'side_action', 'meta'));
|
||||
|
||||
COMMENT ON COLUMN paliad.proceeding_types.kind IS
|
||||
'Structural classification — see docs/design-proceeding-types-taxonomy-2026-05-26.md §1. '
|
||||
'proceeding = self-contained matter (own filing + deadline tree); '
|
||||
'phase = stage inside a primary CFI proceeding; '
|
||||
'side_action = application/order inside a proceeding; '
|
||||
'meta = RoP mechanics, court admin, cross-cutting remedies.';
|
||||
|
||||
CREATE INDEX proceeding_types_kind_active_idx
|
||||
ON paliad.proceeding_types(kind, is_active)
|
||||
WHERE is_active = true;
|
||||
```
|
||||
|
||||
The DEFAULT keeps existing inserts (admin tooling, snapshot tests) safe: any new row defaults to `proceeding`. The CHECK enforces the vocabulary at write time.
|
||||
|
||||
### 3.2 Data move — UPDATE statements, no INSERT/DELETE
|
||||
|
||||
```sql
|
||||
-- Phases (per m's Q2 carve-out: upc.costs.cfi (176) is NOT a phase, it stays primary)
|
||||
UPDATE paliad.proceeding_types
|
||||
SET kind = 'phase'
|
||||
WHERE id IN (173, 174, 175, 185); -- §0.4 Group B minus 176
|
||||
|
||||
-- Side-actions
|
||||
UPDATE paliad.proceeding_types
|
||||
SET kind = 'side_action'
|
||||
WHERE id IN (178, 182, 177, 184, 165, 170, 180, 181, 187, 183); -- §0.4 Group C
|
||||
|
||||
-- Meta / cross-cutting
|
||||
UPDATE paliad.proceeding_types
|
||||
SET kind = 'meta'
|
||||
WHERE id IN (162, 161, 163, 168, 164, 166, 167, 186, 169); -- §0.4 Group D
|
||||
|
||||
-- Primaries (incl. m's Q2 carve-out for upc.costs.cfi) stay on the DEFAULT
|
||||
-- 'proceeding' value — no UPDATE needed.
|
||||
|
||||
-- Per m's Q9: deactivate the non-primary rows so the admin list surfaces only
|
||||
-- primaries. The kind column carries the semantic info; is_active controls UI
|
||||
-- visibility. Reversible — flip is_active back on if a row gains corpus.
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = false
|
||||
WHERE kind IN ('phase', 'side_action', 'meta');
|
||||
```
|
||||
|
||||
Per m's Q9, the `is_active=false` flip is mandatory in this mig. After it: 23 active rows (all `kind='proceeding'`), 23 inactive rows (the phase/side_action/meta set), in addition to the pre-existing inactive appeal-triplet + archived bucket. The `kind` column tells consumers what each row IS; `is_active` tells consumers whether to show it.
|
||||
|
||||
### 3.3 Optional integrity constraints
|
||||
|
||||
If m wants stronger guarantees that `projects.proceeding_type_id` can only point at primaries, add a deferrable FK validator. Cleanest pattern in Postgres:
|
||||
|
||||
```sql
|
||||
-- Option A: trigger-based check (works for any kind set, deferred-friendly).
|
||||
CREATE OR REPLACE FUNCTION paliad.assert_project_type_is_proceeding()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.proceeding_type_id IS NOT NULL THEN
|
||||
PERFORM 1 FROM paliad.proceeding_types
|
||||
WHERE id = NEW.proceeding_type_id AND kind = 'proceeding';
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'projects.proceeding_type_id must reference a kind=proceeding row, got id=%', NEW.proceeding_type_id
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE TRIGGER projects_proceeding_type_kind_check
|
||||
BEFORE INSERT OR UPDATE OF proceeding_type_id ON paliad.projects
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.assert_project_type_is_proceeding();
|
||||
```
|
||||
|
||||
Per m's Q8: **trigger on `projects` only**, no symmetric enforcement on `sequencing_rules`. Projects are written via the public app (the surface most exposed to operator error); rules are edited via the admin `/admin/procedural-events` surface which already validates against active+published lifecycle. The single trigger is enough.
|
||||
|
||||
### 3.4 Migration sequencing — single self-contained mig
|
||||
|
||||
One migration file:
|
||||
|
||||
```
|
||||
internal/db/migrations/153_proceeding_types_kind.up.sql
|
||||
internal/db/migrations/153_proceeding_types_kind.down.sql
|
||||
```
|
||||
|
||||
Up does ALTER + UPDATE + (optional) trigger creation. Down does DROP COLUMN (cascading the trigger if present). No data loss on either direction — the kind column is purely additive.
|
||||
|
||||
Mig number depends on what knuth lands first; the coder reads `MAX(version)` at write time per the project's mig conventions.
|
||||
|
||||
---
|
||||
|
||||
## 4. FK reparenting tables
|
||||
|
||||
There is no reparenting to do. Below for completeness:
|
||||
|
||||
| Source table.column | Pointing at non-primary rows? | Action |
|
||||
|---|---|---|
|
||||
| `sequencing_rules.proceeding_type_id` | **0 active rules** (verified §0.1) | None |
|
||||
| `sequencing_rules.spawn_proceeding_type_id` | **0 active rules** point at non-primaries; 4 active rules point at id=11 (inactive `upc.apl.merits`) | Pre-existing drift, out of scope (§8) |
|
||||
| `projects.proceeding_type_id` | **0 projects** (all 6 distinct values are primaries) | None |
|
||||
| `event_category_concepts.proceeding_type_code` | **0 concepts** point at non-primary codes; 30 concepts point at `upc.apl.merits/order/cost` codes (which are inactive but conceptually primaries) | Pre-existing drift, out of scope (§8) |
|
||||
|
||||
The "FK reparent" section of the acceptance criteria in m/paliad#147 is a no-op for this design: the 28 rows being re-classified have **no incoming references** to reparent. The migration is pure relabelling.
|
||||
|
||||
---
|
||||
|
||||
## 5. Worked example — `upc.cfi.interim` after the mig
|
||||
|
||||
### 5.1 Today (broken)
|
||||
|
||||
Someone created the row `upc.cfi.interim` (id 173, name "CFI - Zwischenverfahren") in `paliad.proceeding_types` with `category='fristenrechner'`. The intent was probably "we'll attach interim-phase rules here later". Result:
|
||||
|
||||
- The row appears in the Mode B R3 wizard chip strip (if R3 queries `WHERE is_active=true AND jurisdiction='UPC'`) — confusing to the user, because "Zwischenverfahren" is not a proceeding they pick; it's a stage their proceeding passes through.
|
||||
- The row could be set as `projects.proceeding_type_id` (no FK constraint forbids it today) — corrupting the SmartTimeline's lane logic, which assumes the project's type is a primary.
|
||||
- The row appears in admin /admin/proceeding-types lists, polluting the primary-proceedings overview.
|
||||
|
||||
### 5.2 After mig 153
|
||||
|
||||
The migration runs:
|
||||
|
||||
```sql
|
||||
UPDATE paliad.proceeding_types SET kind = 'phase' WHERE id = 173;
|
||||
-- Optionally: UPDATE paliad.proceeding_types SET is_active = false WHERE id = 173;
|
||||
```
|
||||
|
||||
Now:
|
||||
|
||||
- Mode B R3 query becomes `WHERE is_active=true AND jurisdiction = $1 AND kind='proceeding'`. `upc.cfi.interim` is filtered out — it is not a "Verfahren" the user can pick.
|
||||
- A future admin who tries to set a project's `proceeding_type_id = 173` either fails the optional trigger from §3.3 (with a clear error) or gets a code-level rejection from `ProjectService.SetProceedingType` (which the coder will harden to filter by `kind='proceeding'`).
|
||||
- The `pkg/litigationplanner` snapshot generator filter becomes `WHERE is_active=true AND category='fristenrechner' AND kind='proceeding' AND jurisdiction IN ('UPC')`. The row never makes it into the youpc.org catalog.
|
||||
|
||||
The row itself stays in the database. Its id is stable. Future work that wants to *use* the phase row as a taxonomic label (e.g. "show me which event_kinds map to which UPC phases") gets a clean shape: query `WHERE kind='phase' AND code LIKE 'upc.cfi.%'`.
|
||||
|
||||
### 5.3 Where interim-phase deadlines actually live
|
||||
|
||||
The user-facing concept "interim phase" is already modelled correctly, just elsewhere:
|
||||
|
||||
- A `procedural_events` row like `upc.inf.cfi.soc` (Statement of Claim) has `event_kind='filing'`. The Fristenrechner overhaul (t-paliad-322 §4.2) groups follow-ups by priority + presents them under the trigger card. There is no UI element that needs a "Zwischenverfahren" proceeding-type label to operate.
|
||||
- A future "show me the full ablauf of UPC inf, broken down by phase" feature can derive phases from `procedural_events.event_kind` ordering + the rule sequence_order. The `proceeding_types` table doesn't need to carry the phase labels.
|
||||
|
||||
---
|
||||
|
||||
## 6. Consumer impact
|
||||
|
||||
### 6.1 `projects.proceeding_type_id`
|
||||
|
||||
| Concern | Before | After mig 153 |
|
||||
|---|---|---|
|
||||
| Valid values | Any active proceeding_types row | Any `kind='proceeding'` active row (22 rows) |
|
||||
| Enforcement | None at DB level | Optional trigger (§3.3 / §9 Q8) |
|
||||
| Code-level filter in ProjectService | No filter on kind | Filter to `kind='proceeding'` when listing pickable types |
|
||||
| Existing data | 6 distinct values (all in 22) | No change — all 6 are kind='proceeding' |
|
||||
| SmartTimeline lane logic | Assumes primary-proceeding shape | Assumption now FK-enforceable |
|
||||
|
||||
**No data migration on existing projects.** The 6 currently-used proceeding types are all in the primary set.
|
||||
|
||||
### 6.2 `sequencing_rules.proceeding_type_id` + `spawn_proceeding_type_id`
|
||||
|
||||
| Concern | Before | After mig 153 |
|
||||
|---|---|---|
|
||||
| `proceeding_type_id` valid values | Any active row | Any active row (no enforcement change; admin curation suffices) |
|
||||
| `spawn_proceeding_type_id` valid values | Any active row | Same — spawns conceptually must point at a primary, but enforcement stays in admin tooling |
|
||||
| Existing data | 157 rules anchored on 18 primaries | No change — all 157 already on `kind='proceeding'` rows |
|
||||
| `id=11 spawn pressure` (`upc.apl.merits`, inactive) | 4 active spawn rules point here | Pre-existing drift, out of scope (§8) |
|
||||
|
||||
No `sequencing_rules` table changes accompany this mig. The post-mig invariant **"every active rule's `proceeding_type_id` is a `kind='proceeding'` row"** holds without any UPDATE.
|
||||
|
||||
### 6.3 Fristenrechner Mode B R3 (t-paliad-322, knuth's S3+)
|
||||
|
||||
§3.2 R3 of the Fristenrechner overhaul says:
|
||||
|
||||
> Chips: every active `proceeding_type` whose jurisdiction matches R2 AND whose event roster contains at least one event with R1's kind.
|
||||
|
||||
After mig 153, the R3 query gains one more AND-clause:
|
||||
|
||||
```sql
|
||||
SELECT pt.id, pt.code, pt.name, pt.name_en, pt.sort_order
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.is_active = true
|
||||
AND pt.kind = 'proceeding' -- NEW
|
||||
AND pt.jurisdiction = $1 -- from R2
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE sr.proceeding_type_id = pt.id
|
||||
AND pe.event_kind = $2 -- from R1
|
||||
AND sr.is_active = true
|
||||
)
|
||||
ORDER BY pt.sort_order, pt.code;
|
||||
```
|
||||
|
||||
The `kind='proceeding'` filter is the only line that changes. Knuth's S3 implementation reads from this query; the chip pool shrinks from "all 35 active UPC types" to "the 14 primary UPC types that have rules" (still narrowed further by R1's event_kind via the EXISTS subquery).
|
||||
|
||||
No coder churn beyond adding the AND-clause. The mig 153 lands either alongside knuth's S3 work or independently (§7 sequencing decision).
|
||||
|
||||
### 6.4 Litigation Planner suite (t-paliad-292)
|
||||
|
||||
The package's catalog snapshot generator (`pkg/litigationplanner/scripts/snapshot/main.go`) currently filters:
|
||||
|
||||
```go
|
||||
// scripts/snapshot/main.go
|
||||
const proceedingTypesQuery = `
|
||||
SELECT id, code, name, name_en, jurisdiction, default_color, sort_order, display_order,
|
||||
trigger_event_label_de, trigger_event_label_en
|
||||
FROM paliad.proceeding_types
|
||||
WHERE is_active = true
|
||||
AND category = 'fristenrechner'
|
||||
AND jurisdiction = $1
|
||||
`
|
||||
```
|
||||
|
||||
After mig 153, this query gains the same `AND kind = 'proceeding'` line. The UPC snapshot shrinks from "potentially 35 rows" to a clean primary-only set. Today's snapshot probably already includes the phase/side-action/meta rows (since `is_active=true` is true for all of them) — depending on whether a snapshot has been regenerated since the 161-188 rows landed, the embedded JSON may be carrying decorative rows that the youpc.org catalog never resolves to rules. Mig 153 + a snapshot regen cleans this up.
|
||||
|
||||
The package's `Catalog.Proceeding(ctx, code, hint)` interface stays unchanged. A youpc-side call asking for `code='upc.cfi.interim'` previously returned the row + zero rules (technically valid but useless); after mig 153 the snapshot doesn't include it and the call returns `ErrUnknownProceedingType`. That's the correct shape — youpc users never had a reason to ask for a phase row.
|
||||
|
||||
The scenarios design (`paliad.scenarios.spec.proceedings[].code`) gains an integrity check at write time: the validator already asserts every code resolves to an active proceeding; now it additionally asserts `kind='proceeding'`. A user trying to compose a scenario with `code='upc.cfi.interim'` gets a clear error. (The validator is paliad-side, not library-side — see Litigation Planner doc §5 "Validatable at write time".)
|
||||
|
||||
### 6.5 Admin /admin/procedural-events list (recently shipped, t-paliad-321)
|
||||
|
||||
The proceeding-type column in the admin list (m/paliad#144 follow-up, just landed) renders one of the 46 active codes per row. Post-mig 153, the admin filter dropdown can:
|
||||
|
||||
- Default to showing only `kind='proceeding'` rows (clean primary view).
|
||||
- Offer a "show all kinds" toggle for admins triaging the non-primary rows.
|
||||
|
||||
This is presentation-only — the underlying admin queries don't need to change immediately. The kind column is a forward-compat hook.
|
||||
|
||||
### 6.6 Knowledge-platform pages (Gerichtsverzeichnis, Patentglossar)
|
||||
|
||||
Untouched. None of those pages query `proceeding_types` directly.
|
||||
|
||||
### 6.7 Fristen export / paliad data export (t-paliad-279)
|
||||
|
||||
Untouched. The exporter dumps `proceeding_types` as a whole (no kind-filter); after mig 153 it dumps the same rows with the new kind column. Forward-compat by default.
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration sequencing decision vs m/paliad#146
|
||||
|
||||
m/paliad#146 (Fristenrechner overhaul, t-paliad-322 / 323) is on the S1-S6 train under knuth. m's directive at task brief time: **knuth pauses at the S1+S2 seam waiting for this taxonomy decision**.
|
||||
|
||||
Three options were on the table:
|
||||
|
||||
(a) **Pause #146 until taxonomy clean** — knuth blocked, this design lands first, then knuth resumes S3+.
|
||||
(b) **Land #146 against current shape, migrate later** — knuth ships S3-S6 against the current 46-row table, taxonomy mig follows.
|
||||
(c) **Land taxonomy in parallel, knuth re-targets if needed** — both run, knuth's S3 picks up the new filter when mig 153 is ready.
|
||||
|
||||
**Recommendation: (c) parallel-land** with the following caveats:
|
||||
|
||||
- The taxonomy mig is **additive** (ADD COLUMN with safe DEFAULT, no DROP, no data move beyond UPDATEs that touch unreferenced rows). Knuth's S3 implementation can be written with or without the `kind='proceeding'` filter — adding the filter is a one-line patch the moment mig 153 lands.
|
||||
- The R3 chip-pool query in knuth's S3 PR should be **future-proofed by also adding the `kind='proceeding'` filter behind a feature flag or an env-time SQL constant**, defaulting to "no filter" pre-mig and "filter" post-mig. (Or simpler: knuth writes the filter unconditionally; the migration lands first; ordering is mechanical.)
|
||||
- The mig 153 PR should land **before** knuth's S3 PR ships to main, so the filter is never false-positive (chipping phase rows users can't actually pick). Both PRs can be drafted in parallel; the squeeze happens at merge time.
|
||||
- Sequence on main: mig 153 → knuth S3 (with filter) → knuth S4-S6.
|
||||
|
||||
Option (c) keeps knuth productive (S3 work can start immediately after this design ratifies; doesn't have to wait for the mig to merge) and avoids the option (a) idle cost.
|
||||
|
||||
Option (b) was rejected because it leaves the Mode B R3 wizard chipping 35 UPC rows on initial release — exactly the bug m flagged in m/paliad#147 ("half of the 46 active proceeding_types are not primary proceedings"). The user would see phase rows in R3 day one of the Fristenrechner overhaul shipping; we'd be shipping the bug.
|
||||
|
||||
Option (a) was rejected as the safest but slowest path. The taxonomy mig is trivial enough (one ALTER + four UPDATE statements + optional trigger) that parallel-running has no real risk.
|
||||
|
||||
§9 Q10 gives m the chance to pick differently.
|
||||
|
||||
---
|
||||
|
||||
## 8. Out of scope (flagged for separate work)
|
||||
|
||||
- **`upc.apl.*` data drift.** 30 rows in `paliad.event_category_concepts` reference the inactive `upc.apl.merits` / `upc.apl.order` / `upc.apl.cost` codes (the pre-`upc.apl.unified` triplet). 4 active sequencing_rules reference `spawn_proceeding_type_id=11` (the inactive `upc.apl.merits` row). This is a pre-existing inconsistency from the appeal unification mig — needs its own follow-up ticket. Not blocking this design; can be cleaned up in a separate migration that retargets concepts + spawn FKs to `upc.apl.unified` (id=160).
|
||||
- **Renaming or relabelling primary proceedings.** Out per m/paliad#147 acceptance — editorial work, not structural.
|
||||
- **Adding new proceeding types beyond the existing corpus.** Out per m/paliad#147 acceptance.
|
||||
- **The Fristenrechner UI overhaul itself (m/paliad#146).** Separate track; this design only tells knuth's S3 what set to chip.
|
||||
- **The scenarios design (m/paliad#124).** Already ratified in `docs/design-litigation-planner-2026-05-26.md` §5; this design only refines the spec validator's "every code resolves to a primary" check.
|
||||
- **DROPing the non-primary rows physically.** Reversible deactivation via `kind=...` + optional `is_active=false` is enough; physical deletion adds irreversibility risk for no functional gain.
|
||||
- **Migration of `event_category_concepts.proceeding_type_code` to a real FK.** It's text today, joined softly; converting to FK is a separate hardening task.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions for m (10 decision questions)
|
||||
|
||||
Sent via `AskUserQuestion` in 3 batches per inventor SKILL contract (4+3+3). m's picks land in §10 below after the round-trip.
|
||||
|
||||
| # | Topic | Recommended pick |
|
||||
|---|---|---|
|
||||
| Q1 | Model choice | Model 1 (kind discriminator) |
|
||||
| Q2 | Phases — linear sub-phases of every CFI, or separately-elected? | Implicit: phases live in `procedural_events.event_kind`, not as proceeding_types |
|
||||
| Q3.a | Side-actions — triggered by parent event, or initiated out-of-band? | Mixed; today's data has no rules, future rules anchor on the parent primary with `condition_expr` |
|
||||
| Q3.b | `upc.pl.cfi` (Schutzschrift) — primary or side-action? | Primary (own RoP filing pathway) |
|
||||
| Q4 | Collapse `de.inf.lg`/`olg`/`bgh` into one `de.inf` with instance_level qualifier? | No — keep discrete |
|
||||
| Q5 | Collapse `de.null.bpatg`/`bgh` into one `de.null` with instance_level qualifier? | No — keep discrete |
|
||||
| Q6 | Should DE follow the `upc.apl.unified` pattern? | No (= keep discrete, locks Q4+Q5) |
|
||||
| Q7 | `upc.ccr.cfi` — proceeding row with routing (status quo), or `with_ccr` flag on `upc.inf.cfi`? | Keep as proceeding (status quo per t-paliad-204 S1) |
|
||||
| Q8 | Enforce `projects.proceeding_type_id` → `kind='proceeding'` at the DB level? | Yes, via trigger (§3.3) |
|
||||
| Q9 | Set `is_active=false` on the 28 non-primary rows after mig 153? | Yes (cleanest admin UX) |
|
||||
| Q10 | Sequencing vs m/paliad#146 — pause / parallel / re-target? | (c) parallel-land — mig first, then knuth S3 with filter |
|
||||
|
||||
Q11 in the issue body ("how many rules need new condition_expr disambiguation?") is **empirically answered, no decision needed**: 0 rules need new condition_expr — every active rule is already correctly anchored to a primary. Surfaced in §4 + §6.2.
|
||||
|
||||
---
|
||||
|
||||
## 10. m's decisions (2026-05-27)
|
||||
|
||||
All 11 questions answered via `AskUserQuestion` on 2026-05-27 09:52 (3 batches of 4+4+3). 10 of 11 picks = recommendation; Q9 diverged at the chip-picker but m's follow-up instruction ("I follow your recommendation") flips Q9 to the recommendation as well. Q2 carries a precise carve-out captured verbatim below.
|
||||
|
||||
- **Q1 (Model): Model 1 — kind discriminator.** [= recommendation] One column + CHECK constraint + UPDATE statements. **Locks §1, §2, §3.1, §3.2.**
|
||||
- **Q2 (Phases): Generally option 1 (implicit via `procedural_events.event_kind`), with carve-outs.** [≈ option 1 with carve-out] m's verbatim call:
|
||||
> Generally 1, but I agree with costs which are not only a phase but also "standalone" side proceedings. But default decision application is not.
|
||||
Concretely:
|
||||
- `upc.cfi.interim` (173) → `kind='phase'`
|
||||
- `upc.cfi.oral` (174) → `kind='phase'`
|
||||
- `upc.cfi.decision` (175) → `kind='phase'`
|
||||
- `upc.default.cfi` (185) → `kind='phase'` (m: "default decision application is not [a standalone side proceeding]")
|
||||
- **`upc.costs.cfi` (176) → `kind='proceeding'`** (m: "costs are not only a phase but also standalone side proceedings"). The Separate Kostenentscheidung can be filed as its own application under R.151 RoP independently of the parent decision; m's read is that the standalone-application character outweighs the phase-of-CFI character.
|
||||
Net: 4 phase rows (not 5 as in the strawman), 23 primary-proceeding rows (not 22). **Updates §0.4 Group B count, §0.5 totals row, §1 categorisation, §3.2 UPDATE statement IDs (drop 176 from the phase UPDATE).**
|
||||
- **Q3.a (Side-actions): kind='side_action', rules anchor on parent primary.** [= recommendation] All 10 §0.4 Group C rows get `kind='side_action'`. When corpus arrives, rules attach to the parent primary with a `condition_expr` flag. **Locks §1.1, §3.2 side-action UPDATE.**
|
||||
- **Q3.b (Schutzschrift): kind='proceeding'.** [= recommendation] `upc.pl.cfi` (188) stays in the primary set on the strength of its own RoP filing pathway. **Locks §0.3 unloaded-primary list.**
|
||||
- **Q4 (DE inf collapse): Keep discrete.** [= recommendation] `de.inf.lg/olg/bgh` stay as 3 separate primaries. No collapse, no instance_level qualifier introduction. **Locks §0.2 + §1 DE-side categorisation.**
|
||||
- **Q5 (DE null collapse): Keep discrete.** [= recommendation] `de.null.bpatg/bgh` stay separate. Symmetric with Q4. **Locks §0.2 + §1 DE-side categorisation.**
|
||||
- **Q6 (DE follow upc.apl pattern): No — keep DE discrete.** [= recommendation] Locks Q4+Q5. The `upc.apl.unified` consolidation was about same-court appeal variants; DE appeals are different-court-instance appeals — different problem. **No code-rename work falls out of this design.**
|
||||
- **Q7 (CCR shape): Keep status quo.** [= recommendation] `upc.ccr.cfi` stays as `kind='proceeding'` with the existing routing-to-`upc.inf.cfi` from t-paliad-204 §0.3 S1. **Locks §1.1.**
|
||||
- **Q8 (DB trigger): Trigger on `projects` only.** [= recommendation] BEFORE INSERT/UPDATE trigger on `paliad.projects` enforces `proceeding_type_id → kind='proceeding'`. No trigger on `sequencing_rules` (admin tooling already gates). **Locks §3.3 — keep the `projects` trigger DDL, drop the optional `sequencing_rules` variant.**
|
||||
- **Q9 (Deactivate non-primaries): Yes — deactivate.** [m's chip-pick was "keep active"; flipped to recommendation per m's "I follow your recommendation" instruction] All `kind IN ('phase', 'side_action', 'meta')` rows get `is_active=false` in mig 153. The admin `/admin/proceeding-types` list shows only the 23 active primaries. Rows stay in the table with their `kind` tag so future tooling that wants to surface them can flip `is_active` back on. **Updates §3.2 — uncomment the optional `UPDATE … SET is_active=false` block.**
|
||||
- **Q10 (Sequencing vs #146): Parallel-land.** [= recommendation] Mig 153 + knuth's S3 PR drafted in parallel; mig merges first; knuth's S3 includes the `kind='proceeding'` filter in R3's chip query from day one. No idle cost; no bug shipped. **Locks §7.**
|
||||
|
||||
### 10.1 What changed from the strawman as a result
|
||||
|
||||
Two material edits flow from m's picks:
|
||||
|
||||
1. **§0.4 Group B (Phases) drops `upc.costs.cfi` (id 176)** — moved into the primary set. Phase count: 5 → 4. Primary count: 22 → 23. §0.2 picks up id 176 as an unloaded primary (zero rules today; future corpus will attach).
|
||||
2. **§3.2 migration includes the `is_active=false` UPDATE** (was optional in the strawman, now mandatory):
|
||||
|
||||
```sql
|
||||
UPDATE paliad.proceeding_types
|
||||
SET is_active = false
|
||||
WHERE kind IN ('phase', 'side_action', 'meta');
|
||||
```
|
||||
|
||||
This is what the post-mig 153 cleanup looks like: 23 active rows (all `kind='proceeding'`), 23 inactive rows (4 phase + 10 side_action + 9 meta + the pre-existing 3 inactive appeal-triplet + 1 archived bucket = 27 inactive total, but 23 of those are the freshly-deactivated taxonomy rows).
|
||||
|
||||
These edits don't change the §7 sequencing decision or the §6 consumer-impact analysis. They tighten the mig file and shift one row's classification.
|
||||
|
||||
### 10.2 Final categorisation (post-decisions)
|
||||
|
||||
| `kind` | Count | Codes |
|
||||
|---|---:|---|
|
||||
| `proceeding` | **23** | upc.inf.cfi, upc.rev.cfi, upc.pi.cfi, upc.dmgs.cfi, upc.disc.cfi, upc.ccr.cfi, upc.apl.unified, upc.dni.cfi, upc.epo.review, upc.bsv.cfi, upc.pl.cfi, **upc.costs.cfi** (m's Q2 carve-out), de.inf.lg, de.inf.olg, de.inf.bgh, de.null.bpatg, de.null.bgh, epa.opp.opd, epa.opp.boa, epa.grant.exa, dpma.opp.dpma, dpma.appeal.bpatg, dpma.appeal.bgh |
|
||||
| `phase` | **4** | upc.cfi.interim, upc.cfi.oral, upc.cfi.decision, upc.default.cfi |
|
||||
| `side_action` | **10** | upc.evidence.cfi, upc.experiments.cfi, upc.security.cfi, upc.intervention.rop, upc.parties.change, upc.optout.cfi, upc.inspection.cfi, upc.freezing.cfi, upc.withdrawal.rop, upc.rehearing.coa |
|
||||
| `meta` | **9** | upc.case.mgmt, upc.general.rop, upc.service.rop, upc.language.rop, upc.representation.rop, upc.fees.court, upc.legalaid.cfi, upc.special.cfi, upc.reestablishment.rop |
|
||||
| **Total** | **46** | ✓ |
|
||||
|
||||
Post-mig 153: 23 active (all `kind='proceeding'`), 23 deactivated (the phase/side_action/meta set).
|
||||
|
||||
---
|
||||
|
||||
## 11. Synthesis links
|
||||
|
||||
- mBrian topic: `topic-fristenrechner` — file this design as a `[synthesis]` node, link `related_to` the proceeding-code-taxonomy doc (2026-05-18) and the Fristenrechner overhaul (2026-05-26), `triggered_by` t-paliad-324.
|
||||
- Related design docs: `docs/design-proceeding-code-taxonomy-2026-05-18.md` (the code-shape doc), `docs/design-fristenrechner-overhaul-2026-05-26.md` (knuth's parent design), `docs/design-litigation-planner-2026-05-26.md` §5 (scenarios spec validator).
|
||||
- Related migrations: 095 (fristen gap-fill, spawn FK invariant), 096 (proceeding code rename), 152 (sequencing_rule dedupe + admin column).
|
||||
@@ -5,7 +5,7 @@ import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /admin/rules/{id}/edit — Slice 11b (t-paliad-192). Form for the full
|
||||
// /admin/procedural-events/{id}/edit — Slice 11b (t-paliad-192). Form for the full
|
||||
// 37-column rule row plus a side panel with the preview widget and the
|
||||
// audit-log timeline. Lifecycle action bar at the bottom adapts to the
|
||||
// rule's current state (draft/published/archived). Every write goes
|
||||
@@ -26,12 +26,12 @@ export function renderAdminRulesEdit(): string {
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.rules.edit.title">Regel bearbeiten — Paliad</title>
|
||||
<title data-i18n="admin.procedural_events.edit.title">Regel bearbeiten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/rules" />
|
||||
<BottomNav currentPath="/admin/rules" />
|
||||
<Sidebar currentPath="/admin/procedural-events" />
|
||||
<BottomNav currentPath="/admin/procedural-events" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
@@ -39,7 +39,7 @@ export function renderAdminRulesEdit(): string {
|
||||
<div className="tool-header admin-rules-edit-header">
|
||||
<div>
|
||||
<p className="admin-rules-breadcrumb">
|
||||
<a href="/admin/rules" data-i18n="admin.rules.edit.breadcrumb">← Regeln verwalten</a>
|
||||
<a href="/admin/procedural-events" data-i18n="admin.procedural_events.edit.breadcrumb">← Regeln verwalten</a>
|
||||
</p>
|
||||
<h1 id="rules-edit-heading" data-i18n="admin.rules.edit.heading.loading">Regel laden...</h1>
|
||||
<div className="admin-rules-edit-meta">
|
||||
@@ -71,7 +71,7 @@ export function renderAdminRulesEdit(): string {
|
||||
</div>
|
||||
<div className="admin-rules-edit-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-submission-code" data-i18n="admin.rules.edit.field.submission_code">Submission Code / Einreichung-Kennung</label>
|
||||
<label htmlFor="f-submission-code" data-i18n="admin.procedural_events.edit.field.code">Submission Code / Einreichung-Kennung</label>
|
||||
<input type="text" id="f-submission-code" className="admin-rules-input" readonly placeholder="z. B. upc.inf.cfi.soc" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
@@ -103,7 +103,7 @@ export function renderAdminRulesEdit(): string {
|
||||
</div>
|
||||
<div className="admin-rules-edit-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-parent" data-i18n="admin.rules.edit.field.parent">Parent-Regel (UUID)</label>
|
||||
<label htmlFor="f-parent" data-i18n="admin.procedural_events.edit.field.parent">Parent-Regel (UUID)</label>
|
||||
<input type="text" id="f-parent" className="admin-rules-input" placeholder="UUID oder leer" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
@@ -184,7 +184,7 @@ export function renderAdminRulesEdit(): string {
|
||||
<input type="text" id="f-primary-party" className="admin-rules-input" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="f-event-type" data-i18n="admin.rules.edit.field.event_type">Event-Typ (frei)</label>
|
||||
<label htmlFor="f-event-type" data-i18n="admin.procedural_events.edit.field.event_kind">Event-Typ (frei)</label>
|
||||
<input type="text" id="f-event-type" className="admin-rules-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /admin/rules — Slice 11b (t-paliad-192). Filterable rule table + an
|
||||
// /admin/procedural-events — Slice 11b (t-paliad-192). Filterable rule table + an
|
||||
// Orphans tab that surfaces the Slice 10 fuzzy-match staging rows so an
|
||||
// admin can hand-bind each legacy deadline to one of the candidate
|
||||
// rule_ids. Both surfaces share the same page shell to keep navigation
|
||||
@@ -21,25 +21,25 @@ export function renderAdminRulesList(): string {
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.rules.list.title">Regeln verwalten — Paliad</title>
|
||||
<title data-i18n="admin.procedural_events.list.title">Regeln verwalten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/rules" />
|
||||
<BottomNav currentPath="/admin/rules" />
|
||||
<Sidebar currentPath="/admin/procedural-events" />
|
||||
<BottomNav currentPath="/admin/procedural-events" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.rules.list.heading">Regeln verwalten</h1>
|
||||
<h1 data-i18n="admin.procedural_events.list.heading">Regeln verwalten</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.rules.list.subtitle">
|
||||
Fristen-Regeln anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived.
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-rules-header-actions">
|
||||
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.rules.list.new">
|
||||
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.procedural_events.list.new">
|
||||
+ Neue Regel
|
||||
</button>
|
||||
</div>
|
||||
@@ -101,10 +101,10 @@ export function renderAdminRulesList(): string {
|
||||
<table className="entity-table admin-rules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.rules.col.submission_code">Submission Code</th>
|
||||
<th data-i18n="admin.procedural_events.col.code">Submission Code</th>
|
||||
<th data-i18n="admin.procedural_events.col.proceeding">Verfahren</th>
|
||||
<th data-i18n="admin.rules.col.legal_citation">Rechtsgrundlage</th>
|
||||
<th data-i18n="admin.rules.col.name">Name</th>
|
||||
<th data-i18n="admin.rules.col.proceeding">Verfahrenstyp</th>
|
||||
<th data-i18n="admin.rules.col.priority">Priorität</th>
|
||||
<th data-i18n="admin.rules.col.lifecycle">Lifecycle</th>
|
||||
<th data-i18n="admin.rules.col.modified">Zuletzt geändert</th>
|
||||
|
||||
@@ -95,7 +95,7 @@ export function renderAdmin(): string {
|
||||
<h2 data-i18n="admin.card.approval_policies.title">Genehmigungspflichten</h2>
|
||||
<p data-i18n="admin.card.approval_policies.desc">4-Augen-Prüfung pro Projekt und Partner Unit konfigurieren.</p>
|
||||
</a>
|
||||
<a href="/admin/rules" className="card card-link">
|
||||
<a href="/admin/procedural-events" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_TABLE }} />
|
||||
<h2 data-i18n="admin.card.rules.title">Regeln verwalten</h2>
|
||||
<p data-i18n="admin.card.rules.desc">Fristen-Regeln anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.</p>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// admin-rules-edit.ts — /admin/rules/{id}/edit. Loads a single rule
|
||||
// admin-rules-edit.ts — /admin/procedural-events/{id}/edit. Loads a single rule
|
||||
// row, drives every form field, the preview widget, the audit-log
|
||||
// timeline and the lifecycle action bar. Every write is gated behind
|
||||
// a reason modal — the ≥10-char rule is enforced client-side per
|
||||
@@ -106,7 +106,7 @@ function fmtDateTime(iso: string): string {
|
||||
}
|
||||
|
||||
function parseRuleIDFromPath(): string {
|
||||
// /admin/rules/{uuid}/edit
|
||||
// /admin/procedural-events/{uuid}/edit
|
||||
const m = /^\/admin\/rules\/([^\/]+)\/edit\/?$/.exec(window.location.pathname);
|
||||
return m ? decodeURIComponent(m[1]) : "";
|
||||
}
|
||||
@@ -179,7 +179,7 @@ function fillProceedingSelect(selectId: string, list: ProceedingType[]) {
|
||||
}
|
||||
|
||||
async function loadRule(): Promise<void> {
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`);
|
||||
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}`);
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 404) {
|
||||
showFeedback(t("admin.rules.edit.error.not_found") || "Regel nicht gefunden.", true);
|
||||
@@ -198,7 +198,7 @@ async function loadAudit(reset: boolean = true): Promise<void> {
|
||||
auditEntries = [];
|
||||
auditOffset = 0;
|
||||
}
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/audit?offset=${auditOffset}&limit=${AUDIT_PAGE}`);
|
||||
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/audit?offset=${auditOffset}&limit=${AUDIT_PAGE}`);
|
||||
if (!resp.ok) return;
|
||||
const body = await resp.json();
|
||||
const rows = Array.isArray(body) ? body as AuditEntry[] : [];
|
||||
@@ -508,7 +508,7 @@ async function doSaveDraft(reason: string) {
|
||||
return;
|
||||
}
|
||||
payload.reason = reason;
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`, {
|
||||
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -530,7 +530,7 @@ async function doSaveDraft(reason: string) {
|
||||
|
||||
async function doLifecycle(op: "publish" | "archive" | "restore", reason: string) {
|
||||
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/${op}`, {
|
||||
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/${op}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason }),
|
||||
@@ -552,7 +552,7 @@ async function doLifecycle(op: "publish" | "archive" | "restore", reason: string
|
||||
|
||||
async function doClone(reason: string) {
|
||||
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/clone-as-draft`, {
|
||||
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/clone-as-draft`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason }),
|
||||
@@ -565,7 +565,7 @@ async function doClone(reason: string) {
|
||||
return;
|
||||
}
|
||||
const newRule = await resp.json() as Rule;
|
||||
window.location.href = `/admin/rules/${encodeURIComponent(newRule.id)}/edit`;
|
||||
window.location.href = `/admin/procedural-events/${encodeURIComponent(newRule.id)}/edit`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
@@ -591,7 +591,7 @@ async function runPreview() {
|
||||
if (flagsRaw) qs.set("flags", flagsRaw);
|
||||
out.innerHTML = `<p class="admin-rules-loading">${esc(t("admin.rules.edit.preview.running") || "Berechne...")}</p>`;
|
||||
out.style.display = "";
|
||||
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/preview?${qs.toString()}`);
|
||||
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/preview?${qs.toString()}`);
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
out.innerHTML = `<p class="admin-rules-hint admin-rules-hint-error">${esc(body.error || (t("admin.rules.edit.preview.error") || "Preview fehlgeschlagen."))}</p>`;
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// admin-rules-list.ts — /admin/rules. Drives the rule table (filterable
|
||||
// admin-rules-list.ts — /admin/procedural-events. Drives the rule table (filterable
|
||||
// by proceeding type, trigger event, lifecycle state, free-text query)
|
||||
// plus the Orphans tab (Slice 10 backfill staging rows). Row click on
|
||||
// a rule routes to /admin/rules/{id}/edit; orphan cards have their own
|
||||
// a rule routes to /admin/procedural-events/{id}/edit; orphan cards have their own
|
||||
// "Pick" affordance with an inline reason prompt that posts to
|
||||
// /admin/api/orphans/{id}/resolve.
|
||||
|
||||
interface Rule {
|
||||
id: string;
|
||||
proceeding_type_id?: number | null;
|
||||
// proceeding_type_code is the joined paliad.proceeding_types.code
|
||||
// for proceeding_type_id, populated server-side by the
|
||||
// /admin/api/procedural-events LIST handler (t-paliad-321). Lets the
|
||||
// table show the 3-segment proceeding code (e.g. "upc.inf.cfi") at
|
||||
// a glance without depending on the FILTER-dropdown's limited
|
||||
// proceeding list. NULL on event-rooted rules.
|
||||
proceeding_type_code?: string | null;
|
||||
// submission_code is the proceeding-prefixed identifier of this rule
|
||||
// within its proceeding (e.g. `upc.inf.cfi.soc`), distinct from
|
||||
// rule_code (the legal citation, e.g. `RoP.013.1`).
|
||||
@@ -138,6 +145,19 @@ function proceedingLabel(id: number | null | undefined): string {
|
||||
return `${pt.code} · ${name}`;
|
||||
}
|
||||
|
||||
// proceedingCodeCell renders the LIST table's Proceeding column. Uses
|
||||
// the server-side joined proceeding_type_code when available
|
||||
// (t-paliad-321), falling back to the dropdown-lookup proceedingLabel
|
||||
// for older API responses or for rules whose proceeding_type_id
|
||||
// resolves but proceeding_type_code didn't (defence-in-depth). NULL
|
||||
// proceeding_type_id renders as the em-dash placeholder used
|
||||
// elsewhere in the admin table.
|
||||
function proceedingCodeCell(r: Rule): string {
|
||||
if (r.proceeding_type_code) return r.proceeding_type_code;
|
||||
if (r.proceeding_type_id == null) return "—";
|
||||
return proceedingLabel(r.proceeding_type_id);
|
||||
}
|
||||
|
||||
function buildFilterURL(): string {
|
||||
const qs = new URLSearchParams();
|
||||
if (activeProceeding) qs.set("proceeding_type_id", activeProceeding);
|
||||
@@ -145,7 +165,7 @@ function buildFilterURL(): string {
|
||||
if (activeLifecycle) qs.set("lifecycle_state", activeLifecycle);
|
||||
if (activeQuery) qs.set("q", activeQuery);
|
||||
qs.set("limit", "500");
|
||||
return "/admin/api/rules?" + qs.toString();
|
||||
return "/admin/api/procedural-events?" + qs.toString();
|
||||
}
|
||||
|
||||
async function loadProceedings(): Promise<void> {
|
||||
@@ -233,9 +253,9 @@ function renderRulesTable() {
|
||||
tbody.innerHTML = rules.map((r) => `
|
||||
<tr data-row-id="${esc(r.id)}" class="admin-rules-row">
|
||||
<td class="admin-rules-col-code"><code>${esc(r.submission_code || "")}</code></td>
|
||||
<td class="admin-rules-col-proceeding"><code>${esc(proceedingCodeCell(r))}</code></td>
|
||||
<td class="admin-rules-col-legal"><code>${esc(r.rule_code || "")}</code></td>
|
||||
<td>${esc(name(r))}</td>
|
||||
<td>${esc(proceedingLabel(r.proceeding_type_id ?? null))}</td>
|
||||
<td><span class="admin-rules-priority admin-rules-priority-${esc(r.priority)}">${esc(priorityLabel(r.priority))}</span></td>
|
||||
<td><span class="${lifecycleClass(r.lifecycle_state)}">${esc(lifecycleLabel(r.lifecycle_state))}</span></td>
|
||||
<td class="admin-rules-col-modified">${esc(fmtDateTime(r.updated_at))}</td>
|
||||
@@ -248,7 +268,7 @@ function renderRulesTable() {
|
||||
if (target && (target.closest("a") || target.closest("button"))) return;
|
||||
const id = row.dataset.rowId;
|
||||
if (!id) return;
|
||||
window.location.href = `/admin/rules/${encodeURIComponent(id)}/edit`;
|
||||
window.location.href = `/admin/procedural-events/${encodeURIComponent(id)}/edit`;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -392,7 +412,7 @@ async function submitReasonModal(ev: Event) {
|
||||
submit.disabled = false;
|
||||
return;
|
||||
}
|
||||
const resp = await fetch("/admin/api/rules", {
|
||||
const resp = await fetch("/admin/api/procedural-events", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -416,7 +436,7 @@ async function submitReasonModal(ev: Event) {
|
||||
return;
|
||||
}
|
||||
const created = await resp.json();
|
||||
window.location.href = `/admin/rules/${encodeURIComponent(created.id)}/edit`;
|
||||
window.location.href = `/admin/procedural-events/${encodeURIComponent(created.id)}/edit`;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2905,10 +2905,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
|
||||
// t-paliad-192 Slice 11b — Admin rule-editor UI.
|
||||
// t-paliad-262 Slice A — "Regel" relabelled as "Verfahrensschritt".
|
||||
// The admin URL `/admin/rules` and i18n key prefix `admin.rules.*` stay
|
||||
// (URL change is Slice B.6); the visible labels rename. Canonical
|
||||
// `admin.procedural_events.*` aliases live after the EN block — they
|
||||
// pin the contract for when .tsx files rebind in Slice B (B.5).
|
||||
// t-paliad-305 Slice B.6 (2026-05-26) — canonical URL moved to
|
||||
// `/admin/procedural-events` (301 redirects from /admin/rules*).
|
||||
// The i18n keys `admin.rules.*` are kept as the corpus until a
|
||||
// follow-up slice migrates each reference; canonical
|
||||
// `admin.procedural_events.*` aliases live after the EN block.
|
||||
"nav.admin.rules": "Verfahrensschritte verwalten",
|
||||
"admin.card.rules.title": "Verfahrensschritte verwalten",
|
||||
"admin.card.rules.desc": "Verfahrensschritte anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",
|
||||
@@ -3119,6 +3120,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.procedural_events.list.heading": "Verfahrensschritte verwalten",
|
||||
"admin.procedural_events.list.new": "+ Neuer Verfahrensschritt",
|
||||
"admin.procedural_events.col.code": "Code (Verfahrensschritt)",
|
||||
// t-paliad-321: 3-segment proceeding-type code column (joined
|
||||
// server-side); disambiguates same-named rules across proceedings.
|
||||
"admin.procedural_events.col.proceeding": "Verfahren",
|
||||
"admin.procedural_events.edit.title": "Verfahrensschritt bearbeiten — Paliad",
|
||||
"admin.procedural_events.edit.breadcrumb":"← Verfahrensschritte verwalten",
|
||||
"admin.procedural_events.edit.field.code": "Code (Verfahrensschritt-Identifikator)",
|
||||
@@ -6187,6 +6191,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.procedural_events.list.heading": "Manage procedural events",
|
||||
"admin.procedural_events.list.new": "+ New procedural event",
|
||||
"admin.procedural_events.col.code": "Code (procedural event)",
|
||||
// t-paliad-321: 3-segment proceeding-type code column.
|
||||
"admin.procedural_events.col.proceeding": "Proceeding",
|
||||
"admin.procedural_events.edit.title": "Edit procedural event — Paliad",
|
||||
"admin.procedural_events.edit.breadcrumb":"← Manage procedural events",
|
||||
"admin.procedural_events.edit.field.code": "Code (procedural-event identifier)",
|
||||
|
||||
@@ -1317,6 +1317,26 @@ function paintSectionList(): void {
|
||||
for (const sec of sections) {
|
||||
list.appendChild(renderSectionRow(sec, lang, activeID === sec.id));
|
||||
}
|
||||
|
||||
// t-paliad-318 Slice F — "+ Abschnitt hinzufügen" trailing
|
||||
// affordance + "Reihenfolge speichern" affordance (only visible
|
||||
// after a manual reorder; surfaced by paintReorderControls when
|
||||
// pendingReorder is set).
|
||||
let trailer = document.getElementById("submission-draft-sections-trailer");
|
||||
if (!trailer) {
|
||||
trailer = document.createElement("div");
|
||||
trailer.id = "submission-draft-sections-trailer";
|
||||
trailer.className = "submission-draft-sections-trailer";
|
||||
wrap.appendChild(trailer);
|
||||
}
|
||||
trailer.innerHTML = "";
|
||||
|
||||
const addBtn = document.createElement("button");
|
||||
addBtn.type = "button";
|
||||
addBtn.className = "btn-small btn-secondary";
|
||||
addBtn.textContent = isEN() ? "+ Add section" : "+ Abschnitt hinzufügen";
|
||||
addBtn.addEventListener("click", () => openAddSectionForm(trailer!));
|
||||
trailer.appendChild(addBtn);
|
||||
}
|
||||
|
||||
function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: boolean): HTMLLIElement {
|
||||
@@ -1325,9 +1345,29 @@ function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: bo
|
||||
li.dataset.sectionId = sec.id;
|
||||
if (!sec.included) li.classList.add("submission-draft-section--excluded");
|
||||
|
||||
// t-paliad-318 Slice F — drag-and-drop reorder. Native HTML5 DnD,
|
||||
// no external library. The drag handle is the only draggable
|
||||
// affordance so clicks inside the editor area don't accidentally
|
||||
// trigger a drag.
|
||||
li.draggable = false; // overridden via the handle below
|
||||
li.addEventListener("dragover", (ev) => onSectionDragOver(ev, li));
|
||||
li.addEventListener("drop", (ev) => onSectionDrop(ev, li));
|
||||
li.addEventListener("dragleave", () => li.classList.remove("submission-draft-section--drop-target"));
|
||||
|
||||
const head = document.createElement("header");
|
||||
head.className = "submission-draft-section-head";
|
||||
|
||||
// Drag handle — making just this element draggable scoped the
|
||||
// gesture so contentEditable selections still work.
|
||||
const handle = document.createElement("span");
|
||||
handle.className = "submission-draft-section-handle";
|
||||
handle.draggable = true;
|
||||
handle.title = isEN() ? "Drag to reorder" : "Zum Sortieren ziehen";
|
||||
handle.textContent = "⋮⋮";
|
||||
handle.addEventListener("dragstart", (ev) => onSectionDragStart(ev, sec.id));
|
||||
handle.addEventListener("dragend", () => onSectionDragEnd(li));
|
||||
head.appendChild(handle);
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.className = "submission-draft-section-title";
|
||||
title.textContent = (lang === "en" ? sec.label_en : sec.label_de) || sec.section_key;
|
||||
@@ -1356,14 +1396,33 @@ function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: bo
|
||||
toggle.addEventListener("click", () => onSectionToggleIncluded(sec));
|
||||
head.appendChild(toggle);
|
||||
|
||||
// t-paliad-318 Slice F — per-section delete. Removes the row.
|
||||
// Confirmation guard prevents accidental loss of typed prose.
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "btn-small btn-link-danger submission-draft-section-delete";
|
||||
del.textContent = isEN() ? "Delete" : "Entfernen";
|
||||
del.title = isEN() ? "Remove this section from the draft" : "Abschnitt aus dem Entwurf entfernen";
|
||||
del.addEventListener("click", () => onSectionDelete(sec));
|
||||
head.appendChild(del);
|
||||
|
||||
li.appendChild(head);
|
||||
|
||||
// Toolbar — shared B/I affordance per section. Slice D extends with
|
||||
// headings, lists, quote.
|
||||
// Toolbar — Slice D rich-prose affordances: B/I + H1/H2/H3 +
|
||||
// bullet/numbered list + blockquote + hyperlink. Plus the Slice C
|
||||
// building-block button. execCommand drives bold/italic/headings/
|
||||
// lists/blockquote; hyperlink uses createLink with a prompt.
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "submission-draft-section-toolbar";
|
||||
toolbar.appendChild(makeToolbarButton("B", isEN() ? "Bold" : "Fett", "bold"));
|
||||
toolbar.appendChild(makeToolbarButton("I", isEN() ? "Italic" : "Kursiv", "italic"));
|
||||
toolbar.appendChild(makeHeadingButton("H1", isEN() ? "Heading 1" : "Überschrift 1", 1));
|
||||
toolbar.appendChild(makeHeadingButton("H2", isEN() ? "Heading 2" : "Überschrift 2", 2));
|
||||
toolbar.appendChild(makeHeadingButton("H3", isEN() ? "Heading 3" : "Überschrift 3", 3));
|
||||
toolbar.appendChild(makeToolbarButton("•", isEN() ? "Bullet list" : "Aufzählung", "insertUnorderedList"));
|
||||
toolbar.appendChild(makeToolbarButton("1.", isEN() ? "Numbered list" : "Nummerierte Liste", "insertOrderedList"));
|
||||
toolbar.appendChild(makeToolbarButton("”", isEN() ? "Blockquote" : "Zitat", "formatBlock", "blockquote"));
|
||||
toolbar.appendChild(makeLinkButton());
|
||||
// t-paliad-315 Slice C — building-block insert button. Opens a
|
||||
// picker modal filtered to this section's section_key. Paste is
|
||||
// plain-text per Q2 (no lineage stamped).
|
||||
@@ -1421,7 +1480,7 @@ function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: bo
|
||||
return li;
|
||||
}
|
||||
|
||||
function makeToolbarButton(label: string, title: string, format: "bold" | "italic"): HTMLButtonElement {
|
||||
function makeToolbarButton(label: string, title: string, format: string, value?: string): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "submission-draft-section-toolbar-btn";
|
||||
@@ -1432,7 +1491,7 @@ function makeToolbarButton(label: string, title: string, format: "bold" | "itali
|
||||
// selection target.
|
||||
btn.addEventListener("mousedown", (ev) => {
|
||||
ev.preventDefault();
|
||||
document.execCommand(format, false);
|
||||
document.execCommand(format, false, value);
|
||||
// Trigger the input handler so autosave fires.
|
||||
const editor = document.activeElement as HTMLElement | null;
|
||||
if (editor && editor.classList.contains("submission-draft-section-editor")) {
|
||||
@@ -1442,6 +1501,50 @@ function makeToolbarButton(label: string, title: string, format: "bold" | "itali
|
||||
return btn;
|
||||
}
|
||||
|
||||
// makeHeadingButton emits an `<h1|h2|h3>` wrapping for the active
|
||||
// block via execCommand("formatBlock", "h1") etc. Toggling the same
|
||||
// heading back to a paragraph is handled by clicking the same button
|
||||
// again (the browser's execCommand semantics).
|
||||
function makeHeadingButton(label: string, title: string, level: 1 | 2 | 3): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "submission-draft-section-toolbar-btn";
|
||||
btn.textContent = label;
|
||||
btn.title = title;
|
||||
btn.addEventListener("mousedown", (ev) => {
|
||||
ev.preventDefault();
|
||||
document.execCommand("formatBlock", false, "h" + level);
|
||||
const editor = document.activeElement as HTMLElement | null;
|
||||
if (editor && editor.classList.contains("submission-draft-section-editor")) {
|
||||
onSectionInput(editor as HTMLDivElement);
|
||||
}
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
// makeLinkButton prompts for a URL and wraps the current selection
|
||||
// (or inserts a label-as-URL if nothing selected). The browser's
|
||||
// createLink built-in wires the <a href="…"> tag into the DOM;
|
||||
// domToMarkdown reads it back as `[label](url)`.
|
||||
function makeLinkButton(): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "submission-draft-section-toolbar-btn";
|
||||
btn.textContent = "🔗";
|
||||
btn.title = isEN() ? "Insert link" : "Link einfügen";
|
||||
btn.addEventListener("mousedown", (ev) => {
|
||||
ev.preventDefault();
|
||||
const url = prompt(isEN() ? "URL:" : "URL:");
|
||||
if (!url) return;
|
||||
document.execCommand("createLink", false, url);
|
||||
const editor = document.activeElement as HTMLElement | null;
|
||||
if (editor && editor.classList.contains("submission-draft-section-editor")) {
|
||||
onSectionInput(editor as HTMLDivElement);
|
||||
}
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
function activeSectionEditorID(): string | null {
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (!active || !active.classList.contains("submission-draft-section-editor")) return null;
|
||||
@@ -1496,10 +1599,28 @@ function serializeNode(node: Node): string {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
|
||||
// Lists: handle the wrapper before recursing into items so we can
|
||||
// emit the right per-item Markdown prefix.
|
||||
if (tag === "ul" || tag === "ol") {
|
||||
const items: string[] = [];
|
||||
let counter = 1;
|
||||
for (const child of Array.from(el.childNodes)) {
|
||||
if (child.nodeType === Node.ELEMENT_NODE && (child as HTMLElement).tagName.toLowerCase() === "li") {
|
||||
const liInner = serializeNode(child).replace(/\n+$/g, "");
|
||||
const prefix = tag === "ol" ? `${counter}. ` : "- ";
|
||||
items.push(prefix + liInner);
|
||||
counter++;
|
||||
}
|
||||
}
|
||||
return items.join("\n") + "\n\n";
|
||||
}
|
||||
|
||||
let inner = "";
|
||||
for (const child of Array.from(el.childNodes)) {
|
||||
inner += serializeNode(child);
|
||||
}
|
||||
|
||||
switch (tag) {
|
||||
case "b":
|
||||
case "strong":
|
||||
@@ -1514,6 +1635,26 @@ function serializeNode(node: Node): string {
|
||||
// execCommand and contentEditable insert <div> on Enter in some
|
||||
// browsers, <p> in others. Both are paragraph boundaries.
|
||||
return inner + "\n\n";
|
||||
case "h1":
|
||||
return "# " + inner.replace(/\n+$/g, "") + "\n\n";
|
||||
case "h2":
|
||||
return "## " + inner.replace(/\n+$/g, "") + "\n\n";
|
||||
case "h3":
|
||||
return "### " + inner.replace(/\n+$/g, "") + "\n\n";
|
||||
case "blockquote":
|
||||
// Each line inside the blockquote gets its own "> " prefix per
|
||||
// Markdown convention.
|
||||
return inner.split("\n").map(line => line === "" ? "" : "> " + line).join("\n").replace(/\n+$/g, "") + "\n\n";
|
||||
case "li":
|
||||
// <li> rendered standalone (no <ul>/<ol> ancestor) — emit
|
||||
// bullet by default. The ul/ol branch above handles the
|
||||
// ordered/unordered choice when present.
|
||||
return "- " + inner.replace(/\n+$/g, "") + "\n";
|
||||
case "a": {
|
||||
const href = el.getAttribute("href") ?? "";
|
||||
if (!href || !inner) return inner;
|
||||
return `[${inner}](${href})`;
|
||||
}
|
||||
default:
|
||||
return inner;
|
||||
}
|
||||
@@ -1523,6 +1664,214 @@ async function onSectionToggleIncluded(sec: SubmissionSectionJSON): Promise<void
|
||||
await patchSection(sec.id, { included: !sec.included });
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// t-paliad-318 Slice F — reorder / delete / add
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
let dragSourceID: string | null = null;
|
||||
|
||||
function onSectionDragStart(ev: DragEvent, sectionID: string): void {
|
||||
if (!ev.dataTransfer) return;
|
||||
dragSourceID = sectionID;
|
||||
ev.dataTransfer.effectAllowed = "move";
|
||||
ev.dataTransfer.setData("text/plain", sectionID);
|
||||
const parentLi = (ev.target as HTMLElement).closest("li");
|
||||
if (parentLi) parentLi.classList.add("submission-draft-section--dragging");
|
||||
}
|
||||
|
||||
function onSectionDragOver(ev: DragEvent, li: HTMLLIElement): void {
|
||||
ev.preventDefault();
|
||||
if (ev.dataTransfer) ev.dataTransfer.dropEffect = "move";
|
||||
if (dragSourceID && dragSourceID !== li.dataset.sectionId) {
|
||||
li.classList.add("submission-draft-section--drop-target");
|
||||
}
|
||||
}
|
||||
|
||||
function onSectionDragEnd(li: HTMLLIElement): void {
|
||||
li.classList.remove("submission-draft-section--dragging");
|
||||
document.querySelectorAll(".submission-draft-section--drop-target").forEach((el) => {
|
||||
el.classList.remove("submission-draft-section--drop-target");
|
||||
});
|
||||
dragSourceID = null;
|
||||
}
|
||||
|
||||
async function onSectionDrop(ev: DragEvent, targetLi: HTMLLIElement): Promise<void> {
|
||||
ev.preventDefault();
|
||||
targetLi.classList.remove("submission-draft-section--drop-target");
|
||||
const sourceID = dragSourceID;
|
||||
dragSourceID = null;
|
||||
document.querySelectorAll(".submission-draft-section--dragging").forEach((el) => {
|
||||
el.classList.remove("submission-draft-section--dragging");
|
||||
});
|
||||
if (!sourceID || !state.view?.sections) return;
|
||||
const targetID = targetLi.dataset.sectionId;
|
||||
if (!targetID || sourceID === targetID) return;
|
||||
|
||||
const ids = state.view.sections.map(s => s.id);
|
||||
const fromIdx = ids.indexOf(sourceID);
|
||||
const toIdx = ids.indexOf(targetID);
|
||||
if (fromIdx < 0 || toIdx < 0) return;
|
||||
|
||||
// Splice source out, insert at target position. "Drop on row X"
|
||||
// semantics: source lands JUST BEFORE the target row.
|
||||
ids.splice(fromIdx, 1);
|
||||
const insertAt = ids.indexOf(targetID);
|
||||
ids.splice(insertAt, 0, sourceID);
|
||||
|
||||
await reorderSections(ids);
|
||||
}
|
||||
|
||||
async function reorderSections(ids: string[]): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const draftID = state.view.draft.id;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/submission-drafts/${draftID}/sections/reorder`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ section_order: ids }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.warn("reorder failed", res.status);
|
||||
return;
|
||||
}
|
||||
const body = await res.json() as { sections?: SubmissionSectionJSON[] };
|
||||
if (state.view && body.sections) state.view.sections = body.sections;
|
||||
paintSectionList();
|
||||
} catch (err) {
|
||||
console.warn("reorder error", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSectionDelete(sec: SubmissionSectionJSON): Promise<void> {
|
||||
const label = isEN() ? sec.label_en : sec.label_de;
|
||||
const confirmMsg = isEN()
|
||||
? `Delete section "${label}"? This cannot be undone.`
|
||||
: `Abschnitt "${label}" entfernen? Diese Aktion kann nicht rückgängig gemacht werden.`;
|
||||
if (!confirm(confirmMsg)) return;
|
||||
if (!state.view) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/submission-drafts/${state.view.draft.id}/sections/${sec.id}`,
|
||||
{ method: "DELETE", credentials: "include" },
|
||||
);
|
||||
if (!res.ok && res.status !== 204) {
|
||||
console.warn("delete section failed", res.status);
|
||||
return;
|
||||
}
|
||||
if (state.view.sections) {
|
||||
state.view.sections = state.view.sections.filter(s => s.id !== sec.id);
|
||||
}
|
||||
paintSectionList();
|
||||
} catch (err) {
|
||||
console.warn("delete section error", err);
|
||||
}
|
||||
}
|
||||
|
||||
function openAddSectionForm(host: HTMLElement): void {
|
||||
// If already open, close (toggle).
|
||||
const existing = host.querySelector(".submission-draft-add-section");
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
return;
|
||||
}
|
||||
const form = document.createElement("form");
|
||||
form.className = "submission-draft-add-section";
|
||||
form.addEventListener("submit", (ev) => { ev.preventDefault(); void submitAddSection(form); });
|
||||
|
||||
const fields = [
|
||||
{ name: "section_key", label: isEN() ? "Slug" : "Slug", required: true, placeholder: "berufungsantraege" },
|
||||
{ name: "label_de", label: "Label (DE)", required: true, placeholder: "Berufungsanträge" },
|
||||
{ name: "label_en", label: "Label (EN)", required: true, placeholder: "Appeal requests" },
|
||||
];
|
||||
for (const f of fields) {
|
||||
const row = document.createElement("label");
|
||||
row.className = "submission-draft-add-section-row";
|
||||
const lab = document.createElement("span");
|
||||
lab.textContent = f.label + (f.required ? " *" : "");
|
||||
row.appendChild(lab);
|
||||
const inp = document.createElement("input");
|
||||
inp.type = "text";
|
||||
inp.name = f.name;
|
||||
inp.className = "entity-form-input";
|
||||
inp.required = f.required;
|
||||
inp.placeholder = f.placeholder;
|
||||
row.appendChild(inp);
|
||||
form.appendChild(row);
|
||||
}
|
||||
|
||||
const kindRow = document.createElement("label");
|
||||
kindRow.className = "submission-draft-add-section-row";
|
||||
const kindLab = document.createElement("span");
|
||||
kindLab.textContent = isEN() ? "Kind" : "Typ";
|
||||
kindRow.appendChild(kindLab);
|
||||
const kindSel = document.createElement("select");
|
||||
kindSel.name = "kind";
|
||||
kindSel.className = "entity-form-input";
|
||||
for (const opt of ["prose", "requests", "evidence"]) {
|
||||
const o = document.createElement("option");
|
||||
o.value = opt;
|
||||
o.textContent = opt;
|
||||
kindSel.appendChild(o);
|
||||
}
|
||||
kindRow.appendChild(kindSel);
|
||||
form.appendChild(kindRow);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "submission-draft-add-section-actions";
|
||||
const ok = document.createElement("button");
|
||||
ok.type = "submit";
|
||||
ok.className = "btn-small btn-primary btn-cta-lime";
|
||||
ok.textContent = isEN() ? "Add" : "Hinzufügen";
|
||||
actions.appendChild(ok);
|
||||
const cancel = document.createElement("button");
|
||||
cancel.type = "button";
|
||||
cancel.className = "btn-small btn-secondary";
|
||||
cancel.textContent = isEN() ? "Cancel" : "Abbrechen";
|
||||
cancel.addEventListener("click", () => form.remove());
|
||||
actions.appendChild(cancel);
|
||||
form.appendChild(actions);
|
||||
|
||||
host.appendChild(form);
|
||||
setTimeout(() => (form.querySelector('input[name="section_key"]') as HTMLInputElement | null)?.focus(), 0);
|
||||
}
|
||||
|
||||
async function submitAddSection(form: HTMLFormElement): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const data = new FormData(form);
|
||||
const payload = {
|
||||
section_key: String(data.get("section_key") ?? "").trim(),
|
||||
kind: String(data.get("kind") ?? "prose"),
|
||||
label_de: String(data.get("label_de") ?? "").trim(),
|
||||
label_en: String(data.get("label_en") ?? "").trim(),
|
||||
};
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/submission-drafts/${state.view.draft.id}/sections`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({} as { error?: string }));
|
||||
alert(body.error ?? `HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
const created = await res.json() as SubmissionSectionJSON;
|
||||
if (state.view.sections) state.view.sections.push(created);
|
||||
form.remove();
|
||||
paintSectionList();
|
||||
} catch (err) {
|
||||
alert(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// t-paliad-315 Slice C — building-block picker modal
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -204,7 +204,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{navItem("/admin/team", ICON_USERS, "nav.admin.team", "Team-Verwaltung", currentPath)}
|
||||
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
|
||||
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
|
||||
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
|
||||
{navItem("/admin/procedural-events", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
|
||||
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
|
||||
{navItem("/admin/backups", ICON_DOWNLOAD, "nav.admin.backups", "Backups", currentPath)}
|
||||
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
|
||||
|
||||
@@ -297,6 +297,7 @@ export type I18nKey =
|
||||
| "admin.partner_units.subtitle"
|
||||
| "admin.partner_units.title"
|
||||
| "admin.procedural_events.col.code"
|
||||
| "admin.procedural_events.col.proceeding"
|
||||
| "admin.procedural_events.edit.breadcrumb"
|
||||
| "admin.procedural_events.edit.field.code"
|
||||
| "admin.procedural_events.edit.field.event_kind"
|
||||
|
||||
@@ -6294,6 +6294,71 @@ dialog.modal::backdrop {
|
||||
background: var(--color-bg-elev-2, var(--color-bg-elev-1));
|
||||
}
|
||||
|
||||
/* t-paliad-318 Slice F — drag-and-drop reorder + add / delete affordances. */
|
||||
.submission-draft-section-handle {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
padding: 0 0.35rem;
|
||||
margin-right: 0.4rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.submission-draft-section-handle:hover {
|
||||
background: var(--color-bg-subtle, var(--color-bg-elev-2));
|
||||
}
|
||||
|
||||
.submission-draft-section-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.submission-draft-section--dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.submission-draft-section--drop-target {
|
||||
border-top: 2px solid var(--color-accent-fg, var(--color-text));
|
||||
}
|
||||
|
||||
.submission-draft-section-delete {
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
.submission-draft-sections-trailer {
|
||||
margin-top: 0.6rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.submission-draft-add-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-elev-1);
|
||||
}
|
||||
|
||||
.submission-draft-add-section-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.submission-draft-add-section-row > span {
|
||||
font-size: 0.85em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-add-section-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
/* t-paliad-315 Slice C — building-block picker modal */
|
||||
.submission-draft-section-bb-btn {
|
||||
margin-left: auto;
|
||||
|
||||
47
internal/db/migrations/140_drop_deadline_rules.down.sql
Normal file
47
internal/db/migrations/140_drop_deadline_rules.down.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
-- 140_drop_deadline_rules (down) — Slice B.4, t-paliad-305
|
||||
--
|
||||
-- Best-effort recovery from the deadline_rules_pre_140 snapshot. The
|
||||
-- original triggers (mig 079 audit), indexes, CHECK constraints (mig
|
||||
-- 135 primary_party), and FK constraints on the new tables are NOT
|
||||
-- recreated here — restoring the working state requires replaying
|
||||
-- migrations 078/079/091/095/098/122/128/134/135 against the restored
|
||||
-- table.
|
||||
--
|
||||
-- Use this only for catastrophic recovery. The normal revert path
|
||||
-- for B.4 is to re-deploy the previous container image (which still
|
||||
-- writes via the dual-write helper to a paliad.deadline_rules that no
|
||||
-- longer exists) — that would crash on first write, so true revert
|
||||
-- requires this down + a code revert + a snapshot restore.
|
||||
|
||||
-- Drop the INSTEAD OF triggers + functions
|
||||
DROP TRIGGER IF EXISTS deadline_rules_unified_insert ON paliad.deadline_rules_unified;
|
||||
DROP TRIGGER IF EXISTS deadline_rules_unified_update ON paliad.deadline_rules_unified;
|
||||
DROP FUNCTION IF EXISTS paliad.deadline_rules_unified_insert_trigger();
|
||||
DROP FUNCTION IF EXISTS paliad.deadline_rules_unified_update_trigger();
|
||||
|
||||
-- Recreate paliad.deadline_rules from snapshot.
|
||||
CREATE TABLE paliad.deadline_rules AS TABLE paliad.deadline_rules_pre_140;
|
||||
|
||||
-- Re-add the PK constraint (CREATE TABLE AS doesn't carry constraints).
|
||||
ALTER TABLE paliad.deadline_rules ADD PRIMARY KEY (id);
|
||||
|
||||
-- Re-point the FKs back to deadline_rules.
|
||||
ALTER TABLE paliad.appointments
|
||||
DROP CONSTRAINT IF EXISTS appointments_deadline_rule_id_fkey;
|
||||
ALTER TABLE paliad.appointments
|
||||
ADD CONSTRAINT appointments_deadline_rule_id_fkey
|
||||
FOREIGN KEY (deadline_rule_id) REFERENCES paliad.deadline_rules(id);
|
||||
|
||||
ALTER TABLE paliad.deadline_rule_backfill_orphans
|
||||
DROP CONSTRAINT IF EXISTS deadline_rule_backfill_orphans_resolved_rule_id_fkey;
|
||||
ALTER TABLE paliad.deadline_rule_backfill_orphans
|
||||
ADD CONSTRAINT deadline_rule_backfill_orphans_resolved_rule_id_fkey
|
||||
FOREIGN KEY (resolved_rule_id) REFERENCES paliad.deadline_rules(id);
|
||||
|
||||
-- Re-add deadlines.rule_id from the snapshot's data (via sequencing_rule_id
|
||||
-- which inherited deadline_rules.id during mig 136).
|
||||
ALTER TABLE paliad.deadlines ADD COLUMN rule_id uuid;
|
||||
UPDATE paliad.deadlines SET rule_id = sequencing_rule_id WHERE sequencing_rule_id IS NOT NULL;
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD CONSTRAINT fristen_rule_id_fkey
|
||||
FOREIGN KEY (rule_id) REFERENCES paliad.deadline_rules(id);
|
||||
448
internal/db/migrations/140_drop_deadline_rules.up.sql
Normal file
448
internal/db/migrations/140_drop_deadline_rules.up.sql
Normal file
@@ -0,0 +1,448 @@
|
||||
-- 140_drop_deadline_rules — Slice B.4 destructive drop (t-paliad-305 / m/paliad#93)
|
||||
--
|
||||
-- HARD STOPS:
|
||||
-- * Audit-first: snapshot paliad.deadline_rules → paliad.deadline_rules_pre_140
|
||||
-- in the SAME TRANSACTION as the DROP, per m's snapshot policy
|
||||
-- (precedent migs 091/093/095/098). The whole .up.sql runs inside a
|
||||
-- single transaction because the migration runner wraps it; if any
|
||||
-- statement fails, the snapshot CREATE TABLE rolls back with the
|
||||
-- destructive DROP.
|
||||
-- * No data loss: paliad.deadline_rules has been a write-side shadow
|
||||
-- since B.3 (B.2 dual-write keeps sequencing_rules + procedural_events
|
||||
-- + legal_sources current). Drift verified clean before this slice
|
||||
-- (deadline_rules=231, sequencing_rules=231, 0 mismatches across
|
||||
-- counts/FKs/lifecycle/is_active).
|
||||
--
|
||||
-- What this migration does:
|
||||
-- 1. Snapshot deadline_rules → deadline_rules_pre_140 (preserves audit
|
||||
-- trail of the table's final state for forensic + revert paths).
|
||||
-- 2. Final reconciliation: catch any deadlines whose
|
||||
-- sequencing_rule_id/procedural_event_id columns drifted from the
|
||||
-- legacy rule_id (no live drift today — defensive).
|
||||
-- 3. Drop the audit trigger on deadline_rules (it can't fire on a
|
||||
-- gone table; the trigger function itself stays for the historical
|
||||
-- paliad.deadline_rule_audit reads).
|
||||
-- 4. Re-point FKs that currently target deadline_rules.id over to
|
||||
-- sequencing_rules.id. The id values are identical (sequencing_rules
|
||||
-- inherited deadline_rules.id during mig 136 backfill), so no data
|
||||
-- migration is needed — just the constraint swap. Affects:
|
||||
-- - paliad.appointments.deadline_rule_id
|
||||
-- - paliad.deadline_rule_backfill_orphans.resolved_rule_id
|
||||
-- 5. Drop paliad.deadlines.rule_id column. Per design §5.4 step 16:
|
||||
-- "DROP COLUMN paliad.deadlines.rule_id (keep rule_code +
|
||||
-- custom_rule_text as the human-readable denormalized columns —
|
||||
-- they're the safety net for orphaned deadlines per t-paliad-258)."
|
||||
-- The new sequencing_rule_id + procedural_event_id columns from
|
||||
-- mig 136 are the FK back-links from B.4 forward.
|
||||
-- 6. DROP TABLE paliad.deadline_rules.
|
||||
-- 7. INSTEAD OF triggers on paliad.deadline_rules_unified that route
|
||||
-- INSERTs/UPDATEs to the underlying sr+pe+ls tables. Lets the
|
||||
-- RuleEditorService keep its existing SQL shape (one INSERT, one
|
||||
-- UPDATE per write method) with only a table-name swap. The
|
||||
-- triggers project the legacy column shape back to the three new
|
||||
-- tables exactly as the dual-write helper did in B.2.
|
||||
--
|
||||
-- Down: best-effort restore from the snapshot. The original triggers,
|
||||
-- indexes, and FKs are NOT recreated — operator must replay historical
|
||||
-- migrations 078/079/091/095/098/122 to bring the table back to a
|
||||
-- working shape. The down path is for catastrophic recovery, not casual
|
||||
-- revert.
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. Snapshot — must precede the destructive ops (same TX).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.deadline_rules_pre_140 AS TABLE paliad.deadline_rules;
|
||||
|
||||
COMMENT ON TABLE paliad.deadline_rules_pre_140 IS
|
||||
'Snapshot of paliad.deadline_rules taken in mig 140 (Slice B.4, '
|
||||
't-paliad-305) before the destructive DROP. Mirrors precedent '
|
||||
'pre_091/093/095/098. Read-only forensic + revert source.';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. Final reconciliation — should be a no-op (drift was 0 going
|
||||
-- into this slice). Belt-and-braces against a write that snuck
|
||||
-- in between drift-check and this migration.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.deadlines d
|
||||
SET sequencing_rule_id = d.rule_id,
|
||||
procedural_event_id = sr.procedural_event_id
|
||||
FROM paliad.sequencing_rules sr
|
||||
WHERE sr.id = d.rule_id
|
||||
AND d.rule_id IS NOT NULL
|
||||
AND (d.sequencing_rule_id IS DISTINCT FROM d.rule_id
|
||||
OR d.procedural_event_id IS DISTINCT FROM sr.procedural_event_id);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. Drop the deadline_rules audit trigger. The trigger function
|
||||
-- (paliad.deadline_rule_audit_trigger) stays defined for any
|
||||
-- historical references; mig 079 created it.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DROP TRIGGER IF EXISTS deadline_rules_audit_aiud ON paliad.deadline_rules;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 4. Re-point FKs from deadline_rules → sequencing_rules.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.appointments
|
||||
DROP CONSTRAINT IF EXISTS appointments_deadline_rule_id_fkey;
|
||||
ALTER TABLE paliad.appointments
|
||||
ADD CONSTRAINT appointments_deadline_rule_id_fkey
|
||||
FOREIGN KEY (deadline_rule_id) REFERENCES paliad.sequencing_rules(id);
|
||||
|
||||
ALTER TABLE paliad.deadline_rule_backfill_orphans
|
||||
DROP CONSTRAINT IF EXISTS deadline_rule_backfill_orphans_resolved_rule_id_fkey;
|
||||
ALTER TABLE paliad.deadline_rule_backfill_orphans
|
||||
ADD CONSTRAINT deadline_rule_backfill_orphans_resolved_rule_id_fkey
|
||||
FOREIGN KEY (resolved_rule_id) REFERENCES paliad.sequencing_rules(id);
|
||||
|
||||
-- Drop the deadlines→deadline_rules FK before we drop the column.
|
||||
ALTER TABLE paliad.deadlines
|
||||
DROP CONSTRAINT IF EXISTS fristen_rule_id_fkey;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 5. Drop paliad.deadlines.rule_id (column + remaining indexes).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
DROP COLUMN IF EXISTS rule_id;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 6a. Drop the deadline_search materialized view, which has a
|
||||
-- direct dependency on paliad.deadline_rules (mig 077). We
|
||||
-- recreate it after the DROP, re-pointed at deadline_rules_unified
|
||||
-- so reads keep working. All 11 indexes are recreated alongside.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 6. DROP TABLE paliad.deadline_rules. Now that:
|
||||
-- - dependent FKs are re-pointed to sequencing_rules,
|
||||
-- - the audit trigger is dropped,
|
||||
-- - deadlines.rule_id is gone,
|
||||
-- - the deadline_search matview is gone,
|
||||
-- nothing references the table anymore. The self-FKs
|
||||
-- (deadline_rules.parent_id, .draft_of) drop with the table.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DROP TABLE paliad.deadline_rules;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 7. INSTEAD OF triggers on the view — routes writes to sr+pe+ls.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.deadline_rules_unified_insert_trigger()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql AS $fn$
|
||||
DECLARE
|
||||
v_legal_source_id uuid;
|
||||
v_pe_id uuid;
|
||||
v_code text;
|
||||
BEGIN
|
||||
-- legal_sources upsert (no-op if NEW.legal_source is NULL)
|
||||
IF NEW.legal_source IS NOT NULL THEN
|
||||
INSERT INTO paliad.legal_sources (citation, jurisdiction)
|
||||
VALUES (NEW.legal_source,
|
||||
COALESCE(NULLIF(split_part(NEW.legal_source, '.', 1), ''), 'other'))
|
||||
ON CONFLICT (citation) DO NOTHING;
|
||||
SELECT id INTO v_legal_source_id
|
||||
FROM paliad.legal_sources
|
||||
WHERE citation = NEW.legal_source;
|
||||
END IF;
|
||||
|
||||
-- Mint synthetic code when submission_code is NULL — same recipe
|
||||
-- as mig 136 + B.2 dual-write helper. Stays byte-identical.
|
||||
v_code := COALESCE(NEW.submission_code,
|
||||
'null.' || substring(replace(NEW.id::text, '-', ''), 1, 8));
|
||||
|
||||
-- procedural_events upsert. ON CONFLICT (code) deliberately leaves
|
||||
-- lifecycle_state / published_at / is_active alone — those track
|
||||
-- the procedural-event concept's own lifecycle, not the inserting
|
||||
-- sequencing-rule's lifecycle (e.g. a CloneAsDraft of a published
|
||||
-- rule creates a draft sr that shares the published PE; the PE
|
||||
-- should stay 'published'). Identity columns DO update so an
|
||||
-- admin editing a draft's name still flips the lawyer-visible
|
||||
-- label (1:1 today; revisit when 1:N becomes a real pattern).
|
||||
INSERT INTO paliad.procedural_events
|
||||
(code, name, name_en, description, event_kind, primary_party_default,
|
||||
legal_source_id, concept_id, lifecycle_state, published_at, is_active)
|
||||
VALUES
|
||||
(v_code, NEW.name, NEW.name_en, NEW.description, NEW.event_type,
|
||||
NEW.primary_party, v_legal_source_id, NEW.concept_id,
|
||||
COALESCE(NEW.lifecycle_state, 'draft'), NEW.published_at,
|
||||
COALESCE(NEW.is_active, true))
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
name_en = EXCLUDED.name_en,
|
||||
description = EXCLUDED.description,
|
||||
event_kind = EXCLUDED.event_kind,
|
||||
primary_party_default = EXCLUDED.primary_party_default,
|
||||
legal_source_id = EXCLUDED.legal_source_id,
|
||||
concept_id = EXCLUDED.concept_id,
|
||||
-- lifecycle_state / published_at / is_active deliberately omitted
|
||||
updated_at = now()
|
||||
RETURNING id INTO v_pe_id;
|
||||
|
||||
-- sequencing_rules insert. id is the caller-supplied NEW.id so
|
||||
-- existing FK back-links (deadlines.sequencing_rule_id) resolve.
|
||||
INSERT INTO paliad.sequencing_rules
|
||||
(id, procedural_event_id, proceeding_type_id, parent_id, trigger_event_id,
|
||||
duration_value, duration_unit, timing,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt,
|
||||
combine_op, condition_expr, primary_party, sequence_order,
|
||||
is_spawn, spawn_label, spawn_proceeding_type_id,
|
||||
is_bilateral, is_court_set, priority,
|
||||
rule_code, rule_codes, deadline_notes, deadline_notes_en,
|
||||
choices_offered, applies_to_target,
|
||||
lifecycle_state, draft_of, published_at, is_active,
|
||||
created_at, updated_at)
|
||||
VALUES
|
||||
(NEW.id, v_pe_id, NEW.proceeding_type_id, NEW.parent_id, NEW.trigger_event_id,
|
||||
COALESCE(NEW.duration_value, 0), COALESCE(NEW.duration_unit, 'months'),
|
||||
COALESCE(NEW.timing, 'after'),
|
||||
NEW.alt_duration_value, NEW.alt_duration_unit, NEW.alt_rule_code, NEW.anchor_alt,
|
||||
NEW.combine_op, NEW.condition_expr, NEW.primary_party,
|
||||
COALESCE(NEW.sequence_order, 0),
|
||||
COALESCE(NEW.is_spawn, false), NEW.spawn_label, NEW.spawn_proceeding_type_id,
|
||||
COALESCE(NEW.is_bilateral, false), COALESCE(NEW.is_court_set, false),
|
||||
COALESCE(NEW.priority, 'mandatory'),
|
||||
NEW.rule_code, NEW.rule_codes, NEW.deadline_notes, NEW.deadline_notes_en,
|
||||
NEW.choices_offered, NEW.applies_to_target,
|
||||
COALESCE(NEW.lifecycle_state, 'draft'), NEW.draft_of,
|
||||
NEW.published_at, COALESCE(NEW.is_active, true),
|
||||
COALESCE(NEW.created_at, now()), COALESCE(NEW.updated_at, now()));
|
||||
|
||||
RETURN NEW;
|
||||
END $fn$;
|
||||
|
||||
CREATE TRIGGER deadline_rules_unified_insert
|
||||
INSTEAD OF INSERT ON paliad.deadline_rules_unified
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.deadline_rules_unified_insert_trigger();
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.deadline_rules_unified_update_trigger()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql AS $fn$
|
||||
DECLARE
|
||||
v_legal_source_id uuid;
|
||||
v_code text;
|
||||
BEGIN
|
||||
-- legal_sources upsert (only if NEW.legal_source is non-NULL).
|
||||
-- A change FROM non-NULL TO NULL clears legal_source_id on the
|
||||
-- procedural_event below — same shape as mig 136 / B.2 behaviour.
|
||||
IF NEW.legal_source IS NOT NULL THEN
|
||||
INSERT INTO paliad.legal_sources (citation, jurisdiction)
|
||||
VALUES (NEW.legal_source,
|
||||
COALESCE(NULLIF(split_part(NEW.legal_source, '.', 1), ''), 'other'))
|
||||
ON CONFLICT (citation) DO NOTHING;
|
||||
SELECT id INTO v_legal_source_id
|
||||
FROM paliad.legal_sources
|
||||
WHERE citation = NEW.legal_source;
|
||||
END IF;
|
||||
|
||||
v_code := COALESCE(NEW.submission_code,
|
||||
'null.' || substring(replace(NEW.id::text, '-', ''), 1, 8));
|
||||
|
||||
-- Update procedural_events keyed by the existing PE link on
|
||||
-- sequencing_rules. lifecycle_state / published_at / is_active on
|
||||
-- PE are NOT mirrored from the per-sequencing-rule UPDATE — see
|
||||
-- the INSERT trigger comment for the rationale (a draft sr that
|
||||
-- shares its PE with a published peer must not flip the PE to
|
||||
-- draft). Identity columns DO mirror so editing name/code from
|
||||
-- the admin UI continues to reach the lawyer-visible label.
|
||||
UPDATE paliad.procedural_events
|
||||
SET code = v_code,
|
||||
name = NEW.name,
|
||||
name_en = NEW.name_en,
|
||||
description = NEW.description,
|
||||
event_kind = NEW.event_type,
|
||||
primary_party_default = NEW.primary_party,
|
||||
legal_source_id = v_legal_source_id,
|
||||
concept_id = NEW.concept_id,
|
||||
updated_at = now()
|
||||
WHERE id = (SELECT procedural_event_id
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE id = NEW.id);
|
||||
|
||||
-- Update sequencing_rules (1:1 by id).
|
||||
UPDATE paliad.sequencing_rules
|
||||
SET proceeding_type_id = NEW.proceeding_type_id,
|
||||
parent_id = NEW.parent_id,
|
||||
trigger_event_id = NEW.trigger_event_id,
|
||||
duration_value = NEW.duration_value,
|
||||
duration_unit = NEW.duration_unit,
|
||||
timing = NEW.timing,
|
||||
alt_duration_value = NEW.alt_duration_value,
|
||||
alt_duration_unit = NEW.alt_duration_unit,
|
||||
alt_rule_code = NEW.alt_rule_code,
|
||||
anchor_alt = NEW.anchor_alt,
|
||||
combine_op = NEW.combine_op,
|
||||
condition_expr = NEW.condition_expr,
|
||||
primary_party = NEW.primary_party,
|
||||
sequence_order = NEW.sequence_order,
|
||||
is_spawn = NEW.is_spawn,
|
||||
spawn_label = NEW.spawn_label,
|
||||
spawn_proceeding_type_id = NEW.spawn_proceeding_type_id,
|
||||
is_bilateral = NEW.is_bilateral,
|
||||
is_court_set = NEW.is_court_set,
|
||||
priority = NEW.priority,
|
||||
rule_code = NEW.rule_code,
|
||||
rule_codes = NEW.rule_codes,
|
||||
deadline_notes = NEW.deadline_notes,
|
||||
deadline_notes_en = NEW.deadline_notes_en,
|
||||
choices_offered = NEW.choices_offered,
|
||||
applies_to_target = NEW.applies_to_target,
|
||||
lifecycle_state = NEW.lifecycle_state,
|
||||
draft_of = NEW.draft_of,
|
||||
published_at = NEW.published_at,
|
||||
is_active = NEW.is_active,
|
||||
updated_at = now()
|
||||
WHERE id = NEW.id;
|
||||
|
||||
RETURN NEW;
|
||||
END $fn$;
|
||||
|
||||
CREATE TRIGGER deadline_rules_unified_update
|
||||
INSTEAD OF UPDATE ON paliad.deadline_rules_unified
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.deadline_rules_unified_update_trigger();
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 8. POST assertions.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_snapshot_count int;
|
||||
v_sr_count int;
|
||||
v_view_count int;
|
||||
v_dr_table_exists int;
|
||||
v_rule_id_col int;
|
||||
BEGIN
|
||||
-- B.2 dual-write was implemented only for the active+published lifecycle
|
||||
-- (the scope of the read paths and B.4's pre-flip drift check). Archived
|
||||
-- + draft rows in deadline_rules were never replicated to sequencing_rules
|
||||
-- (they had no production read path). Snapshot includes them all (CREATE
|
||||
-- TABLE AS is unfiltered), so we compare on the same filter B.2 actually
|
||||
-- maintained. Drafts/archived rows are preserved in paliad.deadline_rules_pre_140
|
||||
-- for forensic + future-backfill use.
|
||||
SELECT COUNT(*) INTO v_snapshot_count
|
||||
FROM paliad.deadline_rules_pre_140
|
||||
WHERE is_active = true AND lifecycle_state = 'published';
|
||||
SELECT COUNT(*) INTO v_sr_count
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE is_active = true AND lifecycle_state = 'published';
|
||||
SELECT COUNT(*) INTO v_view_count FROM paliad.deadline_rules_unified;
|
||||
IF v_snapshot_count <> v_sr_count THEN
|
||||
RAISE EXCEPTION '[mig 140] FAILED POST: snapshot active+published has % rows, sequencing_rules active+published has % rows — dual-write drift',
|
||||
v_snapshot_count, v_sr_count;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO v_dr_table_exists
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'paliad' AND table_name = 'deadline_rules';
|
||||
IF v_dr_table_exists > 0 THEN
|
||||
RAISE EXCEPTION '[mig 140] FAILED POST: paliad.deadline_rules table still exists after DROP';
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO v_rule_id_col
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad' AND table_name = 'deadlines' AND column_name = 'rule_id';
|
||||
IF v_rule_id_col > 0 THEN
|
||||
RAISE EXCEPTION '[mig 140] FAILED POST: paliad.deadlines.rule_id column still exists after DROP';
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[mig 140] OK — deadline_rules dropped, snapshot=% rows, sequencing_rules=% rows, view (filtered)=% rows, INSTEAD OF triggers active',
|
||||
v_snapshot_count, v_sr_count, v_view_count;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 8. Recreate paliad.deadline_search materialized view against
|
||||
-- deadline_rules_unified (same column shape — sr.id is the new
|
||||
-- dr.id, etc.). Definition mirrors mig 077; only the FROM table
|
||||
-- name changes. All 11 indexes restored.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE MATERIALIZED VIEW paliad.deadline_search AS
|
||||
SELECT 'rule'::text AS kind,
|
||||
('r:'::text || (dr.id)::text) AS row_key,
|
||||
dc.id AS concept_id,
|
||||
dc.slug AS concept_slug,
|
||||
dc.name_de AS concept_name_de,
|
||||
dc.name_en AS concept_name_en,
|
||||
dc.description AS concept_description,
|
||||
dc.aliases AS concept_aliases,
|
||||
dc.party AS concept_party,
|
||||
dc.category AS concept_category,
|
||||
dc.sort_order AS concept_sort_order,
|
||||
dr.id AS rule_id,
|
||||
NULL::bigint AS trigger_event_id,
|
||||
pt.code AS proceeding_code,
|
||||
pt.name AS proceeding_name_de,
|
||||
pt.name_en AS proceeding_name_en,
|
||||
pt.jurisdiction,
|
||||
pt.display_order AS proceeding_display_order,
|
||||
dr.submission_code AS rule_local_code,
|
||||
dr.name AS rule_name_de,
|
||||
dr.name_en AS rule_name_en,
|
||||
dr.legal_source,
|
||||
dr.rule_code,
|
||||
dr.duration_value,
|
||||
dr.duration_unit,
|
||||
dr.timing,
|
||||
COALESCE(dr.primary_party, dc.party) AS effective_party
|
||||
FROM paliad.deadline_rules_unified dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
|
||||
WHERE dr.is_active AND pt.is_active AND pt.category = 'fristenrechner'::text
|
||||
UNION ALL
|
||||
SELECT 'trigger'::text AS kind,
|
||||
('t:'::text || (te.id)::text) AS row_key,
|
||||
dc.id AS concept_id,
|
||||
dc.slug AS concept_slug,
|
||||
dc.name_de AS concept_name_de,
|
||||
dc.name_en AS concept_name_en,
|
||||
dc.description AS concept_description,
|
||||
dc.aliases AS concept_aliases,
|
||||
dc.party AS concept_party,
|
||||
dc.category AS concept_category,
|
||||
dc.sort_order AS concept_sort_order,
|
||||
NULL::uuid AS rule_id,
|
||||
te.id AS trigger_event_id,
|
||||
NULL::text AS proceeding_code,
|
||||
NULL::text AS proceeding_name_de,
|
||||
NULL::text AS proceeding_name_en,
|
||||
'cross-cutting'::text AS jurisdiction,
|
||||
9999 AS proceeding_display_order,
|
||||
te.code AS rule_local_code,
|
||||
te.name_de AS rule_name_de,
|
||||
te.name AS rule_name_en,
|
||||
dr_trig.legal_source,
|
||||
NULL::text AS rule_code,
|
||||
NULL::integer AS duration_value,
|
||||
NULL::text AS duration_unit,
|
||||
NULL::text AS timing,
|
||||
dc.party AS effective_party
|
||||
FROM paliad.trigger_events te
|
||||
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
|
||||
LEFT JOIN paliad.deadline_rules_unified dr_trig
|
||||
ON dr_trig.trigger_event_id = te.id
|
||||
AND dr_trig.proceeding_type_id IS NULL
|
||||
AND dr_trig.is_active
|
||||
AND dr_trig.lifecycle_state = 'published'::text
|
||||
WHERE te.is_active
|
||||
WITH NO DATA;
|
||||
|
||||
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
|
||||
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
|
||||
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
|
||||
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
|
||||
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
|
||||
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);
|
||||
|
||||
REFRESH MATERIALIZED VIEW paliad.deadline_search;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- t-paliad-317: revert specialist base seed rows.
|
||||
|
||||
DELETE FROM paliad.submission_bases WHERE slug IN ('lg-duesseldorf', 'upc-formal');
|
||||
128
internal/db/migrations/150_submission_bases_specialist.up.sql
Normal file
128
internal/db/migrations/150_submission_bases_specialist.up.sql
Normal file
@@ -0,0 +1,128 @@
|
||||
-- t-paliad-317 (m/paliad#141): Composer Slice E — specialist bases.
|
||||
--
|
||||
-- Two firm-agnostic bases for proceeding-family-specific styling:
|
||||
--
|
||||
-- lg-duesseldorf — DE LG (de.inf.lg) conservative German legal style.
|
||||
-- Times New Roman 11pt; black headings.
|
||||
-- upc-formal — UPC court of first instance (upc.inf.cfi) formal
|
||||
-- style. Calibri 11pt body; UPC-blue (1F3864) headings;
|
||||
-- Cambria italic for blockquotes.
|
||||
--
|
||||
-- The .docx body for each is a minimal Composer-mode skeleton with
|
||||
-- the 10 default section anchors and an empty rels envelope. The
|
||||
-- styles.xml declares the {prefix}-Body / -Heading1/2/3 / -ListBullet
|
||||
-- / -ListNumber / -Quote paragraph styles + a "Hyperlink" character
|
||||
-- style (matches the MD walker's emitted r:id="rIdComposerN" link
|
||||
-- runs from Slice D).
|
||||
--
|
||||
-- Generator: scripts/gen-submission-base/main.go (each preset hard-
|
||||
-- codes the typography). The .docx files are uploaded to Gitea at
|
||||
-- 6 - material/Templates/Word/Paliad/Composer/{slug}.docx as mAi.
|
||||
--
|
||||
-- The mig is additive only: ON CONFLICT (slug) DO NOTHING keeps a
|
||||
-- re-run safe and existing rows untouched.
|
||||
|
||||
INSERT INTO paliad.submission_bases
|
||||
(slug, firm, proceeding_family, label_de, label_en,
|
||||
description_de, description_en,
|
||||
gitea_path, section_spec, is_default_for)
|
||||
VALUES
|
||||
('lg-duesseldorf', NULL, 'de.inf.lg',
|
||||
'LG-Düsseldorf-Stil', 'LG-Düsseldorf style',
|
||||
'Konservativer DE-LG-Stil: Times New Roman 11pt, schlichte Überschriften.',
|
||||
'Conservative DE LG style: Times New Roman 11pt, plain headings.',
|
||||
'6 - material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx',
|
||||
jsonb_build_object(
|
||||
'version', 1,
|
||||
'stylemap', jsonb_build_object(
|
||||
'paragraph', 'LG-Body',
|
||||
'heading_1', 'LG-Heading1',
|
||||
'heading_2', 'LG-Heading2',
|
||||
'heading_3', 'LG-Heading3',
|
||||
'list_bullet', 'LG-ListBullet',
|
||||
'list_numbered', 'LG-ListNumber',
|
||||
'blockquote', 'LG-Quote'
|
||||
),
|
||||
'defaults', jsonb_build_array(
|
||||
jsonb_build_object('section_key','letterhead', 'kind','prose', 'order_index', 1, 'label_de','Briefkopf', 'label_en','Letterhead',
|
||||
'included',true,
|
||||
'seed_md_de', E'Schriftsatz von {{firm.name}}\n\n{{user.display_name}}',
|
||||
'seed_md_en', E'Submission by {{firm.name}}\n\n{{user.display_name}}'),
|
||||
jsonb_build_object('section_key','caption', 'kind','prose', 'order_index', 2, 'label_de','Rubrum', 'label_en','Caption',
|
||||
'included',true,
|
||||
'seed_md_de', E'In der Sache\n\n**{{parties.claimant.0.name}}**\n— Klägerin —\n\ngegen\n\n**{{parties.defendant.0.name}}**\n— Beklagte —\n\nAktenzeichen: {{project.case_number}}\n{{project.court}}',
|
||||
'seed_md_en', E'In the matter\n\n**{{parties.claimant.0.name}}**\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\n— Defendant —\n\nCase number: {{project.case_number}}\n{{project.court}}'),
|
||||
jsonb_build_object('section_key','introduction', 'kind','prose', 'order_index', 3, 'label_de','Einleitung', 'label_en','Introduction',
|
||||
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','requests', 'kind','requests', 'order_index', 4, 'label_de','Anträge', 'label_en','Requests',
|
||||
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','facts', 'kind','prose', 'order_index', 5, 'label_de','Sachverhalt', 'label_en','Facts',
|
||||
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','legal_argument', 'kind','prose', 'order_index', 6, 'label_de','Rechtliche Würdigung', 'label_en','Legal argument',
|
||||
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','evidence', 'kind','evidence', 'order_index', 7, 'label_de','Beweisangebote', 'label_en','Evidence offering',
|
||||
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','exhibits', 'kind','prose', 'order_index', 8, 'label_de','Anlagen', 'label_en','Exhibits',
|
||||
'included',false, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','closing', 'kind','prose', 'order_index', 9, 'label_de','Schlussformel', 'label_en','Closing',
|
||||
'included',true,
|
||||
'seed_md_de', E'Mit freundlichen Grüßen',
|
||||
'seed_md_en', E'Yours sincerely,'),
|
||||
jsonb_build_object('section_key','signature', 'kind','prose', 'order_index',10, 'label_de','Unterschrift', 'label_en','Signature',
|
||||
'included',true,
|
||||
'seed_md_de', E'{{user.display_name}}',
|
||||
'seed_md_en', E'{{user.display_name}}')
|
||||
)
|
||||
),
|
||||
'{}'::text[]
|
||||
),
|
||||
('upc-formal', NULL, 'upc.inf.cfi',
|
||||
'UPC-Verfahren', 'UPC formal',
|
||||
'UPC-Verfahrensstil: Calibri 11pt, UPC-blaue Überschriften, Cambria-Zitate.',
|
||||
'UPC court style: Calibri 11pt, UPC-blue headings, Cambria quotes.',
|
||||
'6 - material/Templates/Word/Paliad/Composer/upc-formal.docx',
|
||||
jsonb_build_object(
|
||||
'version', 1,
|
||||
'stylemap', jsonb_build_object(
|
||||
'paragraph', 'UPC-Body',
|
||||
'heading_1', 'UPC-Heading1',
|
||||
'heading_2', 'UPC-Heading2',
|
||||
'heading_3', 'UPC-Heading3',
|
||||
'list_bullet', 'UPC-ListBullet',
|
||||
'list_numbered', 'UPC-ListNumber',
|
||||
'blockquote', 'UPC-Quote'
|
||||
),
|
||||
'defaults', jsonb_build_array(
|
||||
jsonb_build_object('section_key','letterhead', 'kind','prose', 'order_index', 1, 'label_de','Briefkopf', 'label_en','Letterhead',
|
||||
'included',true,
|
||||
'seed_md_de', E'Schriftsatz von {{firm.name}}\n\n{{user.display_name}}',
|
||||
'seed_md_en', E'Submission by {{firm.name}}\n\n{{user.display_name}}'),
|
||||
jsonb_build_object('section_key','caption', 'kind','prose', 'order_index', 2, 'label_de','Rubrum', 'label_en','Caption',
|
||||
'included',true,
|
||||
'seed_md_de', E'# In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n— Defendant —\n\nUPC-Aktenzeichen: {{project.case_number}}\nStreitpatent: {{project.patent_number_upc}}',
|
||||
'seed_md_en', E'# In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n— Defendant —\n\nUPC case number: {{project.case_number}}\nPatent in suit: {{project.patent_number_upc}}'),
|
||||
jsonb_build_object('section_key','introduction', 'kind','prose', 'order_index', 3, 'label_de','Einleitung', 'label_en','Introduction',
|
||||
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','requests', 'kind','requests', 'order_index', 4, 'label_de','Anträge', 'label_en','Requests',
|
||||
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','facts', 'kind','prose', 'order_index', 5, 'label_de','Sachverhalt', 'label_en','Facts',
|
||||
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','legal_argument', 'kind','prose', 'order_index', 6, 'label_de','Rechtliche Würdigung', 'label_en','Legal argument',
|
||||
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','evidence', 'kind','evidence', 'order_index', 7, 'label_de','Beweisangebote', 'label_en','Evidence offering',
|
||||
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','exhibits', 'kind','prose', 'order_index', 8, 'label_de','Anlagen', 'label_en','Exhibits',
|
||||
'included',false, 'seed_md_de', '', 'seed_md_en', ''),
|
||||
jsonb_build_object('section_key','closing', 'kind','prose', 'order_index', 9, 'label_de','Schlussformel', 'label_en','Closing',
|
||||
'included',true,
|
||||
'seed_md_de', E'Mit freundlichen Grüßen',
|
||||
'seed_md_en', E'Yours sincerely,'),
|
||||
jsonb_build_object('section_key','signature', 'kind','prose', 'order_index',10, 'label_de','Unterschrift', 'label_en','Signature',
|
||||
'included',true,
|
||||
'seed_md_de', E'{{user.display_name}}',
|
||||
'seed_md_en', E'{{user.display_name}}')
|
||||
)
|
||||
),
|
||||
'{}'::text[]
|
||||
)
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- 151_dedupe_null_procedural_events (down) — t-paliad-319 / m/paliad#144
|
||||
--
|
||||
-- Best-effort restore from paliad.procedural_events_pre_151 and
|
||||
-- paliad.sequencing_rules_pre_151. Re-points the reparented
|
||||
-- sequencing_rules back at their original procedural_event_id and
|
||||
-- reactivates the archived duplicates with the lifecycle_state +
|
||||
-- is_active they had before the up migration.
|
||||
--
|
||||
-- Catastrophic-recovery path only; the normal revert is to leave the
|
||||
-- dedupe in place (it is purely cosmetic).
|
||||
|
||||
-- 1. Re-point sequencing_rules.procedural_event_id back to its
|
||||
-- pre-mig-151 value. The snapshot row is keyed by sr.id so the
|
||||
-- join is 1:1 and idempotent.
|
||||
UPDATE paliad.sequencing_rules sr
|
||||
SET procedural_event_id = s.original_procedural_event_id,
|
||||
updated_at = now()
|
||||
FROM paliad.sequencing_rules_pre_151 s
|
||||
WHERE sr.id = s.id;
|
||||
|
||||
-- 2. Reactivate the archived duplicates with their snapshot lifecycle.
|
||||
UPDATE paliad.procedural_events pe
|
||||
SET is_active = s.is_active,
|
||||
lifecycle_state = s.lifecycle_state,
|
||||
updated_at = now()
|
||||
FROM paliad.procedural_events_pre_151 s
|
||||
WHERE pe.id = s.id;
|
||||
|
||||
-- 3. Drop the snapshot tables — the data is back in place.
|
||||
DROP TABLE IF EXISTS paliad.sequencing_rules_pre_151;
|
||||
DROP TABLE IF EXISTS paliad.procedural_events_pre_151;
|
||||
229
internal/db/migrations/151_dedupe_null_procedural_events.up.sql
Normal file
229
internal/db/migrations/151_dedupe_null_procedural_events.up.sql
Normal file
@@ -0,0 +1,229 @@
|
||||
-- 151_dedupe_null_procedural_events — t-paliad-319 / m/paliad#144
|
||||
--
|
||||
-- Purpose: ~14 paliad.procedural_events rows with synthetic null.<8hex>
|
||||
-- codes (minted by mig 136 from the legacy paliad.deadline_rules rows
|
||||
-- whose submission_code was NULL) share user-visible names. The
|
||||
-- /admin/procedural-events list shows multiple entries for the same legal
|
||||
-- concept (worst offender: "Mängelbeseitigung / Zahlung" × 6). This
|
||||
-- migration consolidates every name-group onto a single canonical row,
|
||||
-- reparents the sequencing_rules pointing at the duplicates, and archives
|
||||
-- the duplicates without deleting them.
|
||||
--
|
||||
-- Scope verified live before write (Supabase MCP, 2026-05-26):
|
||||
-- * 5 name-groups, 14 duplicate rows total (1 canonical + 1–5 dups per
|
||||
-- group). Every duplicate has exactly 1 sequencing_rule pointing at it.
|
||||
-- * 0 paliad.deadlines reference any duplicate.
|
||||
-- * 0 procedural_events.draft_of references any duplicate.
|
||||
-- * No audit trigger on procedural_events or sequencing_rules — only
|
||||
-- the INSTEAD OF triggers on deadline_rules_unified (mig 140), which
|
||||
-- do not fire on direct table writes. No set_config('paliad.audit_reason')
|
||||
-- needed.
|
||||
--
|
||||
-- Canonical selection: ROW_NUMBER() OVER (PARTITION BY name ORDER BY
|
||||
-- created_at, id::text). Every duplicate in current data shares the same
|
||||
-- created_at (mig 136 bulk insert), so the deterministic tiebreaker is
|
||||
-- the UUID's lexicographic order.
|
||||
--
|
||||
-- Hard constraints honoured:
|
||||
-- * No deletions. Duplicates flip to is_active=false +
|
||||
-- lifecycle_state='archived'. The rows stay in the table for audit.
|
||||
-- * Reparent sequencing_rules.procedural_event_id duplicate → canonical
|
||||
-- BEFORE archiving, so no FK ever points at an archived PE.
|
||||
-- * Snapshot the affected procedural_events + sequencing_rules into
|
||||
-- paliad.procedural_events_pre_151 / paliad.sequencing_rules_pre_151
|
||||
-- in the same TX, mirroring precedent (migs 091/093/095/098/140).
|
||||
--
|
||||
-- Down: best-effort restore from the snapshots. See .down.sql.
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 1. Build the dedupe mapping (duplicate_id → canonical_id) in a
|
||||
-- TEMP table used by every subsequent step.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TEMP TABLE tmp_pe_dedupe ON COMMIT DROP AS
|
||||
WITH dupe_names AS (
|
||||
SELECT name
|
||||
FROM paliad.procedural_events
|
||||
WHERE code LIKE 'null.%'
|
||||
GROUP BY name
|
||||
HAVING COUNT(*) > 1
|
||||
),
|
||||
ranked AS (
|
||||
SELECT pe.id,
|
||||
pe.code,
|
||||
pe.name,
|
||||
pe.created_at,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY pe.name
|
||||
ORDER BY pe.created_at, pe.id::text
|
||||
) AS rn
|
||||
FROM paliad.procedural_events pe
|
||||
WHERE pe.code LIKE 'null.%'
|
||||
AND pe.name IN (SELECT name FROM dupe_names)
|
||||
),
|
||||
canonicals AS (
|
||||
SELECT name,
|
||||
id AS canonical_id,
|
||||
code AS canonical_code
|
||||
FROM ranked
|
||||
WHERE rn = 1
|
||||
)
|
||||
SELECT r.id AS duplicate_id,
|
||||
r.code AS duplicate_code,
|
||||
r.name,
|
||||
c.canonical_id,
|
||||
c.canonical_code
|
||||
FROM ranked r
|
||||
JOIN canonicals c ON c.name = r.name
|
||||
WHERE r.rn > 1;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 2. Snapshot. Captures the rows that change so .down has a clean
|
||||
-- source of truth; mirrors the pre_091/093/095/098/140 precedent.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.procedural_events_pre_151 AS
|
||||
SELECT pe.*
|
||||
FROM paliad.procedural_events pe
|
||||
WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
|
||||
|
||||
COMMENT ON TABLE paliad.procedural_events_pre_151 IS
|
||||
'Snapshot (mig 151, t-paliad-319) of the null.* procedural_events '
|
||||
'duplicates that were archived in favour of their canonical name-mate. '
|
||||
'Read-only forensic + revert source. Mirrors precedent pre_091/093/'
|
||||
'095/098/140.';
|
||||
|
||||
CREATE TABLE paliad.sequencing_rules_pre_151 AS
|
||||
SELECT sr.id,
|
||||
sr.procedural_event_id AS original_procedural_event_id
|
||||
FROM paliad.sequencing_rules sr
|
||||
WHERE sr.procedural_event_id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
|
||||
|
||||
COMMENT ON TABLE paliad.sequencing_rules_pre_151 IS
|
||||
'Snapshot (mig 151, t-paliad-319) of sequencing_rules.procedural_event_id '
|
||||
'before reparenting from null.* duplicates onto their canonical PE. '
|
||||
'Read-only forensic + revert source.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 3. Audit log — per-row NOTICE so the migration output captures
|
||||
-- exactly which duplicate folded into which canonical, including
|
||||
-- the sr_count for the duplicate (always 1 in current data, but
|
||||
-- the RAISE keeps the audit honest if the scope grows later).
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
v_dup_count int;
|
||||
v_grp_count int;
|
||||
BEGIN
|
||||
SELECT COUNT(*), COUNT(DISTINCT name)
|
||||
INTO v_dup_count, v_grp_count
|
||||
FROM tmp_pe_dedupe;
|
||||
|
||||
RAISE NOTICE '[mig 151] dedupe scope: % duplicate rows across % name-groups',
|
||||
v_dup_count, v_grp_count;
|
||||
|
||||
FOR rec IN
|
||||
SELECT d.duplicate_id,
|
||||
d.duplicate_code,
|
||||
d.name,
|
||||
d.canonical_id,
|
||||
d.canonical_code,
|
||||
(SELECT COUNT(*)
|
||||
FROM paliad.sequencing_rules sr
|
||||
WHERE sr.procedural_event_id = d.duplicate_id) AS sr_count
|
||||
FROM tmp_pe_dedupe d
|
||||
ORDER BY d.name, d.duplicate_id
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 151] dup % (%) -> canonical % (%) — sr_count=%',
|
||||
rec.duplicate_id, rec.duplicate_code,
|
||||
rec.canonical_id, rec.canonical_code,
|
||||
rec.sr_count;
|
||||
RAISE NOTICE '[mig 151] name: %', rec.name;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 4. Reparent sequencing_rules.procedural_event_id duplicate → canonical.
|
||||
-- sequencing_rules_pe_proc_lifecycle_idx is non-unique, so collapsing
|
||||
-- multiple sr onto one PE is by design.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.sequencing_rules sr
|
||||
SET procedural_event_id = d.canonical_id,
|
||||
updated_at = now()
|
||||
FROM tmp_pe_dedupe d
|
||||
WHERE sr.procedural_event_id = d.duplicate_id;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 5. Archive the duplicates. No deletion — audit trail preserved.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.procedural_events pe
|
||||
SET is_active = false,
|
||||
lifecycle_state = 'archived',
|
||||
updated_at = now()
|
||||
WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 6. POST assertions. Any failure rolls the migration back.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_surviving_groups int;
|
||||
v_expected_count int;
|
||||
v_archived_count int;
|
||||
v_orphan_sr int;
|
||||
BEGIN
|
||||
-- (a) Acceptance criterion 2: no name-group still has >1 active+
|
||||
-- published null.* row.
|
||||
SELECT COUNT(*) INTO v_surviving_groups
|
||||
FROM (
|
||||
SELECT name
|
||||
FROM paliad.procedural_events
|
||||
WHERE code LIKE 'null.%'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
GROUP BY name
|
||||
HAVING COUNT(*) > 1
|
||||
) s;
|
||||
|
||||
IF v_surviving_groups > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'[mig 151] FAILED POST: % name-groups still have >1 active+published null.* rows',
|
||||
v_surviving_groups;
|
||||
END IF;
|
||||
|
||||
-- (b) Every targeted duplicate is now archived.
|
||||
SELECT COUNT(*) INTO v_expected_count FROM tmp_pe_dedupe;
|
||||
|
||||
SELECT COUNT(*) INTO v_archived_count
|
||||
FROM paliad.procedural_events pe
|
||||
WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe)
|
||||
AND pe.is_active = false
|
||||
AND pe.lifecycle_state = 'archived';
|
||||
|
||||
IF v_archived_count <> v_expected_count THEN
|
||||
RAISE EXCEPTION
|
||||
'[mig 151] FAILED POST: archived %/% duplicates',
|
||||
v_archived_count, v_expected_count;
|
||||
END IF;
|
||||
|
||||
-- (c) Acceptance criterion 4: no sequencing_rule still points at
|
||||
-- an archived duplicate.
|
||||
SELECT COUNT(*) INTO v_orphan_sr
|
||||
FROM paliad.sequencing_rules sr
|
||||
WHERE sr.procedural_event_id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
|
||||
|
||||
IF v_orphan_sr > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'[mig 151] FAILED POST: % sequencing_rules still point at archived PE duplicates',
|
||||
v_orphan_sr;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[mig 151] OK — archived % duplicates across % name-groups; 0 orphan sequencing_rules',
|
||||
v_archived_count,
|
||||
(SELECT COUNT(DISTINCT name) FROM tmp_pe_dedupe);
|
||||
END $$;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 152_dedupe_identical_sequencing_rule_clones (down) — t-paliad-321
|
||||
--
|
||||
-- Best-effort revert from paliad.sequencing_rules_pre_152. Flips the
|
||||
-- archived rows back to is_active=true / lifecycle_state='published'.
|
||||
-- Does NOT undo the deadlines.sequencing_rule_id reparent — that would
|
||||
-- require remembering the previous pointer per row, which the snapshot
|
||||
-- on sequencing_rules doesn't carry. In live data the reparent was a
|
||||
-- no-op (zero deadlines pointed at duplicates), so this is fine.
|
||||
|
||||
UPDATE paliad.sequencing_rules sr
|
||||
SET is_active = true,
|
||||
lifecycle_state = 'published',
|
||||
updated_at = now()
|
||||
FROM paliad.sequencing_rules_pre_152 snap
|
||||
WHERE sr.id = snap.id;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.sequencing_rules_pre_152;
|
||||
@@ -0,0 +1,240 @@
|
||||
-- 152_dedupe_identical_sequencing_rule_clones — t-paliad-321 / m/paliad#144 follow-up
|
||||
--
|
||||
-- Purpose: mig 151 archived 5 of 6 duplicate procedural_events for
|
||||
-- "Mängelbeseitigung / Zahlung" and reparented their sequencing_rules
|
||||
-- onto the canonical PE. The 6 sequencing_rules themselves remained
|
||||
-- active. Because every one of them is a byte-for-byte clone (same
|
||||
-- proceeding_type_id=NULL, rule_code=NULL, duration 14d, primary_party=NULL,
|
||||
-- everything else NULL, lifecycle_state='published') and only sequence_order
|
||||
-- differs, the admin shows six indistinguishable rows for one legal
|
||||
-- concept. This mig archives 5 of the 6 keeping the lexicographically
|
||||
-- lowest UUID as canonical.
|
||||
--
|
||||
-- Scope verified live before write (Supabase MCP, 2026-05-26):
|
||||
-- * Exactly 1 clone-group surfaces by the full-signature query
|
||||
-- below: 6 "Mängelbeseitigung / Zahlung" sequencing_rules with
|
||||
-- all-NULL discriminators and (duration_value=14, duration_unit='days').
|
||||
-- * 0 paliad.deadlines reference the 5 to-be-archived rows
|
||||
-- (verified via deadlines.sequencing_rule_id JOIN; the column
|
||||
-- formerly named deadlines.rule_id was dropped in mig 140 / B.4).
|
||||
-- * Other name-groups in the live corpus — "Antrag auf
|
||||
-- Patentänderung"×4, "Beginn des Hauptsacheverfahrens"×2,
|
||||
-- "Berufungsbegründung-R.220.1"×2, "Berufungsschrift-R.220.1"×2 —
|
||||
-- do NOT collapse under this signature because their
|
||||
-- proceeding_type_id / rule_code / duration / primary_party
|
||||
-- differ. They are legitimately distinct rules per proceeding;
|
||||
-- this mig leaves them alone.
|
||||
--
|
||||
-- Hard constraints honoured (mirrors mig 151):
|
||||
-- * No deletions. Archived rows flip to is_active=false +
|
||||
-- lifecycle_state='archived'. Rows stay in the table for audit.
|
||||
-- * Reparent paliad.deadlines.sequencing_rule_id duplicate →
|
||||
-- canonical BEFORE archiving, so no live deadline keeps pointing
|
||||
-- at an archived sequencing_rule. (deadlines.rule_id column
|
||||
-- dropped in mig 140; the back-link lives on sequencing_rule_id
|
||||
-- now — same UUID semantics.)
|
||||
-- * Snapshot the affected rows into paliad.sequencing_rules_pre_152
|
||||
-- in the same TX, mirroring precedent (migs 091/093/095/098/140/151).
|
||||
-- * set_config('paliad.audit_reason') is defensively called even
|
||||
-- though no audit trigger fires on sequencing_rules today (mig 151
|
||||
-- §comments documented this). Future audit trigger would inherit
|
||||
-- the reason automatically.
|
||||
--
|
||||
-- Generic-shape rationale: the audit query below uses the FULL
|
||||
-- signature paliadin specified — procedural_event_id, proceeding_type_id,
|
||||
-- rule_code, duration_value, duration_unit, primary_party, condition_expr,
|
||||
-- trigger_event_id, alt_*, anchor_alt, combine_op, parent_id, is_spawn,
|
||||
-- spawn_*. A NOTICE surfaces every group BEFORE the archive step so an
|
||||
-- operator running the deploy logs sees what's about to be touched.
|
||||
-- If new groups appear after future seeds, this mig is safe to re-run
|
||||
-- conceptually (it would archive any new clones) but only fires once
|
||||
-- via the applied_migrations protocol.
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 1. Build the dedupe mapping (duplicate_id → canonical_id) into a
|
||||
-- TEMP table used by every subsequent step.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TEMP TABLE tmp_sr_dedupe ON COMMIT DROP AS
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
id, procedural_event_id, proceeding_type_id, rule_code,
|
||||
duration_value, duration_unit, primary_party,
|
||||
condition_expr, trigger_event_id, alt_duration_value,
|
||||
alt_duration_unit, alt_rule_code, anchor_alt, combine_op,
|
||||
parent_id, is_spawn, spawn_label, spawn_proceeding_type_id,
|
||||
created_at,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY
|
||||
procedural_event_id, proceeding_type_id, rule_code,
|
||||
duration_value, duration_unit, primary_party,
|
||||
condition_expr::text, trigger_event_id,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
anchor_alt, combine_op, parent_id, is_spawn, spawn_label,
|
||||
spawn_proceeding_type_id
|
||||
ORDER BY created_at, id::text
|
||||
) AS rn,
|
||||
COUNT(*) OVER (
|
||||
PARTITION BY
|
||||
procedural_event_id, proceeding_type_id, rule_code,
|
||||
duration_value, duration_unit, primary_party,
|
||||
condition_expr::text, trigger_event_id,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
anchor_alt, combine_op, parent_id, is_spawn, spawn_label,
|
||||
spawn_proceeding_type_id
|
||||
) AS grp_size
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
)
|
||||
SELECT
|
||||
r.id AS duplicate_id,
|
||||
canon.id AS canonical_id,
|
||||
r.procedural_event_id,
|
||||
(SELECT name FROM paliad.procedural_events WHERE id = r.procedural_event_id) AS pe_name
|
||||
FROM ranked r
|
||||
JOIN ranked canon
|
||||
ON canon.procedural_event_id IS NOT DISTINCT FROM r.procedural_event_id
|
||||
AND canon.proceeding_type_id IS NOT DISTINCT FROM r.proceeding_type_id
|
||||
AND canon.rule_code IS NOT DISTINCT FROM r.rule_code
|
||||
AND canon.duration_value IS NOT DISTINCT FROM r.duration_value
|
||||
AND canon.duration_unit IS NOT DISTINCT FROM r.duration_unit
|
||||
AND canon.primary_party IS NOT DISTINCT FROM r.primary_party
|
||||
AND canon.condition_expr::text IS NOT DISTINCT FROM r.condition_expr::text
|
||||
AND canon.trigger_event_id IS NOT DISTINCT FROM r.trigger_event_id
|
||||
AND canon.alt_duration_value IS NOT DISTINCT FROM r.alt_duration_value
|
||||
AND canon.alt_duration_unit IS NOT DISTINCT FROM r.alt_duration_unit
|
||||
AND canon.alt_rule_code IS NOT DISTINCT FROM r.alt_rule_code
|
||||
AND canon.anchor_alt IS NOT DISTINCT FROM r.anchor_alt
|
||||
AND canon.combine_op IS NOT DISTINCT FROM r.combine_op
|
||||
AND canon.parent_id IS NOT DISTINCT FROM r.parent_id
|
||||
AND canon.is_spawn IS NOT DISTINCT FROM r.is_spawn
|
||||
AND canon.spawn_label IS NOT DISTINCT FROM r.spawn_label
|
||||
AND canon.spawn_proceeding_type_id IS NOT DISTINCT FROM r.spawn_proceeding_type_id
|
||||
AND canon.rn = 1
|
||||
WHERE r.rn > 1 AND r.grp_size > 1;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 2. Surface every clone-group as a NOTICE before archiving.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
total_to_archive int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO total_to_archive FROM tmp_sr_dedupe;
|
||||
RAISE NOTICE '[mig 152] PRE: % sequencing_rules row(s) will be archived', total_to_archive;
|
||||
FOR rec IN
|
||||
SELECT pe_name, canonical_id, COUNT(*) AS dup_count, array_agg(duplicate_id::text ORDER BY duplicate_id::text) AS dup_ids
|
||||
FROM tmp_sr_dedupe
|
||||
GROUP BY pe_name, canonical_id
|
||||
ORDER BY pe_name
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 152] % canonical=% duplicates=% ids=%',
|
||||
rec.pe_name, rec.canonical_id, rec.dup_count, rec.dup_ids;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 3. Snapshot the rows about to be archived (only the duplicates;
|
||||
-- the canonicals stay in the live table). Matches precedent.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.sequencing_rules_pre_152 AS
|
||||
SELECT sr.*
|
||||
FROM paliad.sequencing_rules sr
|
||||
JOIN tmp_sr_dedupe d ON d.duplicate_id = sr.id;
|
||||
|
||||
COMMENT ON TABLE paliad.sequencing_rules_pre_152 IS
|
||||
'Snapshot of paliad.sequencing_rules rows archived by mig 152 '
|
||||
'(identical clones — Mängelbeseitigung / Zahlung × 5). Mirrors '
|
||||
'precedent pre_091/093/095/098/140/151. Read-only revert source. '
|
||||
't-paliad-321 / m/paliad#144 follow-up.';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 4. Reparent paliad.deadlines.sequencing_rule_id duplicate → canonical
|
||||
-- BEFORE archiving. Today's live data has 0 deadlines pointing at
|
||||
-- any duplicate, but the statement is safe + defensive against a
|
||||
-- race between drift-check and apply.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.deadlines d
|
||||
SET sequencing_rule_id = m.canonical_id,
|
||||
procedural_event_id = (SELECT procedural_event_id
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE id = m.canonical_id),
|
||||
updated_at = now()
|
||||
FROM tmp_sr_dedupe m
|
||||
WHERE d.sequencing_rule_id = m.duplicate_id;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 5. Defensive audit-reason. Sequencing_rules has no audit trigger
|
||||
-- today (mig 151 §scope verified), but set_config is transactional
|
||||
-- and a future audit trigger inherits the reason automatically.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
SELECT set_config('paliad.audit_reason',
|
||||
'mig 152: archive identical sequencing_rule clones (mig 151 follow-up; t-paliad-321)',
|
||||
true);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 6. Archive the duplicates.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.sequencing_rules
|
||||
SET is_active = false,
|
||||
lifecycle_state = 'archived',
|
||||
updated_at = now()
|
||||
WHERE id IN (SELECT duplicate_id FROM tmp_sr_dedupe);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 7. POST assertions.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_archived int;
|
||||
v_remaining_dupes int;
|
||||
v_orphan_deadlines int;
|
||||
BEGIN
|
||||
-- a. Did the expected number of rows get archived?
|
||||
SELECT COUNT(*) INTO v_archived
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE id IN (SELECT duplicate_id FROM tmp_sr_dedupe)
|
||||
AND lifecycle_state = 'archived'
|
||||
AND is_active = false;
|
||||
IF v_archived <> (SELECT COUNT(*) FROM tmp_sr_dedupe) THEN
|
||||
RAISE EXCEPTION '[mig 152] FAILED POST: expected % rows archived, got %',
|
||||
(SELECT COUNT(*) FROM tmp_sr_dedupe), v_archived;
|
||||
END IF;
|
||||
|
||||
-- b. No clone group of size > 1 should remain in active+published.
|
||||
SELECT COUNT(*) INTO v_remaining_dupes FROM (
|
||||
SELECT 1
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE is_active = true AND lifecycle_state = 'published'
|
||||
GROUP BY procedural_event_id, proceeding_type_id, rule_code,
|
||||
duration_value, duration_unit, primary_party,
|
||||
condition_expr::text, trigger_event_id,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
anchor_alt, combine_op, parent_id, is_spawn, spawn_label,
|
||||
spawn_proceeding_type_id
|
||||
HAVING COUNT(*) > 1
|
||||
) g;
|
||||
IF v_remaining_dupes > 0 THEN
|
||||
RAISE EXCEPTION '[mig 152] FAILED POST: % clone group(s) still active+published after archive', v_remaining_dupes;
|
||||
END IF;
|
||||
|
||||
-- c. No deadline points at an archived sequencing_rule.
|
||||
SELECT COUNT(*) INTO v_orphan_deadlines
|
||||
FROM paliad.deadlines d
|
||||
JOIN paliad.sequencing_rules sr ON sr.id = d.sequencing_rule_id
|
||||
WHERE sr.lifecycle_state = 'archived';
|
||||
IF v_orphan_deadlines > 0 THEN
|
||||
RAISE EXCEPTION '[mig 152] FAILED POST: % live deadline(s) still point at an archived sequencing_rule', v_orphan_deadlines;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[mig 152] OK — archived=%, remaining clone groups=0, orphan deadlines=0',
|
||||
v_archived;
|
||||
END $$;
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
@@ -25,6 +26,77 @@ import (
|
||||
// is mapped to 409 Conflict so the editor UI can show a clear "must
|
||||
// clone first" hint.
|
||||
|
||||
// Slice B.5 (t-paliad-305) JSON envelope renames:
|
||||
//
|
||||
// - submission_code → code (procedural-event identifier)
|
||||
// - event_type → event_kind (procedural-event taxonomy)
|
||||
//
|
||||
// Wire compatibility: every response emits BOTH the legacy and the
|
||||
// canonical keys for one slice (see Deprecation HTTP header on the
|
||||
// response). Input bodies accept either name on the request; the
|
||||
// canonical key wins when both are present.
|
||||
//
|
||||
// adminRuleResponse wraps models.DeadlineRule (= litigationplanner.Rule)
|
||||
// to add the canonical `code` + `event_kind` fields alongside the
|
||||
// historical `submission_code` + `event_type` already on Rule's tags.
|
||||
// The embedded *models.DeadlineRule carries every existing tag through
|
||||
// json.Marshal unchanged; the wrapper only ADDS the two new keys.
|
||||
//
|
||||
// ProceedingTypeCode (t-paliad-321) is the joined paliad.proceeding_types.code
|
||||
// for the row's proceeding_type_id. NULL on event-rooted rules. Lets the
|
||||
// /admin/procedural-events list disambiguate same-named rules at a glance
|
||||
// (e.g. "Berufungsbegründung" rows differ only by proceeding code).
|
||||
type adminRuleResponse struct {
|
||||
*models.DeadlineRule
|
||||
Code *string `json:"code,omitempty"`
|
||||
EventKind *string `json:"event_kind,omitempty"`
|
||||
ProceedingTypeCode *string `json:"proceeding_type_code,omitempty"`
|
||||
}
|
||||
|
||||
// wrapRuleResponse builds the dual-emit wrapper from a service result.
|
||||
// Same values, two keys per concept — no semantic change. Pass a non-nil
|
||||
// ptCode to populate the proceeding_type_code field; nil leaves it
|
||||
// absent (e.g. on event-rooted rules with NULL proceeding_type_id).
|
||||
func wrapRuleResponse(r *models.DeadlineRule) adminRuleResponse {
|
||||
if r == nil {
|
||||
return adminRuleResponse{}
|
||||
}
|
||||
return adminRuleResponse{
|
||||
DeadlineRule: r,
|
||||
Code: r.SubmissionCode,
|
||||
EventKind: r.EventType,
|
||||
}
|
||||
}
|
||||
|
||||
// wrapRuleListResponse maps a slice of service results into the
|
||||
// dual-emit wrapper. Used by the LIST endpoint. ptCodes is an
|
||||
// optional id → code lookup populated by handleAdminListRules from a
|
||||
// single batch query against paliad.proceeding_types; nil leaves
|
||||
// every row's proceeding_type_code empty (the LIST endpoint always
|
||||
// passes a populated map; other callers don't need it).
|
||||
func wrapRuleListResponse(rows []models.DeadlineRule, ptCodes map[int]string) []adminRuleResponse {
|
||||
out := make([]adminRuleResponse, len(rows))
|
||||
for i := range rows {
|
||||
out[i] = wrapRuleResponse(&rows[i])
|
||||
if ptCodes != nil && rows[i].ProceedingTypeID != nil {
|
||||
if code, ok := ptCodes[*rows[i].ProceedingTypeID]; ok {
|
||||
out[i].ProceedingTypeCode = &code
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// adminRuleDeprecationHeaders writes the IETF "Deprecation" + "Sunset"
|
||||
// HTTP headers signaling that the legacy `submission_code` /
|
||||
// `event_type` JSON keys are being retired in favour of `code` /
|
||||
// `event_kind`. RFC 8594 (Sunset) + draft-ietf-httpapi-deprecation-header.
|
||||
// Clients should migrate within one slice cycle.
|
||||
func adminRuleDeprecationHeaders(w http.ResponseWriter) {
|
||||
w.Header().Set("Deprecation", `true; key="submission_code,event_type"`)
|
||||
w.Header().Set("Link", `<https://mgit.msbls.de/m/paliad/issues/93>; rel="deprecation"`)
|
||||
}
|
||||
|
||||
// GET /admin/api/rules — paginated list with filters.
|
||||
func handleAdminListRules(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.ruleEditor == nil {
|
||||
@@ -73,7 +145,16 @@ func handleAdminListRules(w http.ResponseWriter, r *http.Request) {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
// t-paliad-321: batch-fetch proceeding_type.code for every rule
|
||||
// row that carries a non-NULL proceeding_type_id, so the LIST
|
||||
// response can show a Proceeding column without an N+1 join.
|
||||
ptCodes, err := dbSvc.ruleEditor.LoadProceedingTypeCodes(r.Context(), rows)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusOK, wrapRuleListResponse(rows, ptCodes))
|
||||
}
|
||||
|
||||
// GET /admin/api/rules/{id}
|
||||
@@ -91,7 +172,8 @@ func handleAdminGetRule(w http.ResponseWriter, r *http.Request) {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
||||
}
|
||||
|
||||
// POST /admin/api/rules — create draft.
|
||||
@@ -108,12 +190,15 @@ func handleAdminCreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
// Slice B.5 (t-paliad-305): accept both legacy + canonical JSON keys.
|
||||
body.CreateRuleInput.CoalesceCanonicalKeys()
|
||||
row, err := dbSvc.ruleEditor.Create(r.Context(), body.CreateRuleInput, body.Reason)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, row)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusCreated, wrapRuleResponse(row))
|
||||
}
|
||||
|
||||
// PATCH /admin/api/rules/{id} — partial update of a draft.
|
||||
@@ -134,12 +219,15 @@ func handleAdminPatchRule(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
// Slice B.5 (t-paliad-305): accept both legacy + canonical JSON keys.
|
||||
body.RulePatch.CoalesceCanonicalKeys()
|
||||
row, err := dbSvc.ruleEditor.UpdateDraft(r.Context(), id, body.RulePatch, body.Reason)
|
||||
if err != nil {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
||||
}
|
||||
|
||||
// POST /admin/api/rules/{id}/clone-as-draft
|
||||
@@ -161,7 +249,8 @@ func handleAdminCloneAsDraft(w http.ResponseWriter, r *http.Request) {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, row)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusCreated, wrapRuleResponse(row))
|
||||
}
|
||||
|
||||
// POST /admin/api/rules/{id}/publish
|
||||
@@ -183,7 +272,8 @@ func handleAdminPublishRule(w http.ResponseWriter, r *http.Request) {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
||||
}
|
||||
|
||||
// POST /admin/api/rules/{id}/archive
|
||||
@@ -205,7 +295,8 @@ func handleAdminArchiveRule(w http.ResponseWriter, r *http.Request) {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
||||
}
|
||||
|
||||
// POST /admin/api/rules/{id}/restore
|
||||
@@ -227,7 +318,8 @@ func handleAdminRestoreRule(w http.ResponseWriter, r *http.Request) {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
|
||||
}
|
||||
|
||||
// GET /admin/api/rules/{id}/audit?offset=N&limit=M
|
||||
@@ -419,3 +511,66 @@ func handleAdminResolveOrphan(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
|
||||
}
|
||||
|
||||
// Slice B.6 (t-paliad-305) — 301 redirect helpers for the legacy
|
||||
// /admin/rules* paths. New canonical paths live under
|
||||
// /admin/procedural-events; the redirects keep external bookmarks,
|
||||
// audit-log entries, and curl scripts working through one
|
||||
// deprecation cycle.
|
||||
//
|
||||
// Three flavours:
|
||||
//
|
||||
// * redirectToProceduralEvents(newPath) — fixed redirect target
|
||||
// (used by the parameter-less paths /admin/rules and
|
||||
// /admin/api/rules).
|
||||
// * redirectToProceduralEventEdit — page path with {id}/edit suffix.
|
||||
// * redirectToProceduralEventAPI(suffix) — JSON API paths that carry
|
||||
// an {id} and optional suffix (/clone-as-draft, /publish, …).
|
||||
//
|
||||
// All emit 301 Moved Permanently — caches and browsers learn the new
|
||||
// URL once and stop hitting the legacy path. The IETF Deprecation
|
||||
// header is added so machine clients see the migration signal
|
||||
// alongside the redirect.
|
||||
|
||||
// redirectToProceduralEvents returns an http.HandlerFunc that 301s to
|
||||
// the supplied destination path. Query string is preserved.
|
||||
func redirectToProceduralEvents(dst string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
target := dst
|
||||
if r.URL.RawQuery != "" {
|
||||
target += "?" + r.URL.RawQuery
|
||||
}
|
||||
w.Header().Set("Deprecation", `true; path="/admin/rules"`)
|
||||
w.Header().Set("Link", `</admin/procedural-events>; rel="successor-version"`)
|
||||
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||
}
|
||||
}
|
||||
|
||||
// redirectToProceduralEventEdit 301s GET /admin/rules/{id}/edit →
|
||||
// /admin/procedural-events/{id}/edit.
|
||||
func redirectToProceduralEventEdit(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
target := "/admin/procedural-events/" + id + "/edit"
|
||||
if r.URL.RawQuery != "" {
|
||||
target += "?" + r.URL.RawQuery
|
||||
}
|
||||
w.Header().Set("Deprecation", `true; path="/admin/rules/{id}/edit"`)
|
||||
w.Header().Set("Link", `</admin/procedural-events/{id}/edit>; rel="successor-version"`)
|
||||
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||
}
|
||||
|
||||
// redirectToProceduralEventAPI 301s /admin/api/rules/{id}[/suffix] →
|
||||
// /admin/api/procedural-events/{id}[/suffix]. The optional suffix
|
||||
// covers /clone-as-draft, /publish, /archive, /restore, /audit, /preview.
|
||||
func redirectToProceduralEventAPI(suffix string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
target := "/admin/api/procedural-events/" + id + suffix
|
||||
if r.URL.RawQuery != "" {
|
||||
target += "?" + r.URL.RawQuery
|
||||
}
|
||||
w.Header().Set("Deprecation", `true; path="/admin/api/rules/{id}`+suffix+`"`)
|
||||
w.Header().Set("Link", `</admin/api/procedural-events/{id}`+suffix+`>; rel="successor-version"`)
|
||||
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,8 +113,34 @@ var fileRegistry = map[string]fileEntry{
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx",
|
||||
},
|
||||
// t-paliad-317 Composer Slice E — specialist firm-agnostic bases.
|
||||
// Both live under Composer/ (not under HLC/) so a future non-HLC
|
||||
// deployment serves the same cross-firm files. Body = anchor-only
|
||||
// per Slice B; styles.xml carries the preset's typography.
|
||||
composerBaseLGDuesseldorfSlug: {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx",
|
||||
DownloadName: "LG-Düsseldorf Stil.docx",
|
||||
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
RepoOwner: "m",
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx",
|
||||
},
|
||||
composerBaseUPCFormalSlug: {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/Composer/upc-formal.docx",
|
||||
DownloadName: "UPC formal.docx",
|
||||
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
RepoOwner: "m",
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/Composer/upc-formal.docx",
|
||||
},
|
||||
}
|
||||
|
||||
// t-paliad-317 Composer Slice E — slugs for the new specialist bases.
|
||||
const (
|
||||
composerBaseLGDuesseldorfSlug = "submission/composer/lg-duesseldorf.docx"
|
||||
composerBaseUPCFormalSlug = "submission/composer/upc-formal.docx"
|
||||
)
|
||||
|
||||
// skeletonSubmissionSlug names the universal skeleton template inside
|
||||
// the shared fileRegistry cache. Exported via a const so handler code
|
||||
// (resolveSubmissionTemplate, hlPatentsStyleSHA's sibling) refers to
|
||||
@@ -413,6 +439,8 @@ func fetchFirmSkeletonBytes(ctx context.Context) ([]byte, string, error) {
|
||||
var composerBaseSlugMap = map[string]string{
|
||||
"hlc-letterhead": firmSkeletonSubmissionSlug,
|
||||
"neutral": skeletonSubmissionSlug,
|
||||
"lg-duesseldorf": composerBaseLGDuesseldorfSlug,
|
||||
"upc-formal": composerBaseUPCFormalSlug,
|
||||
}
|
||||
|
||||
// fetchComposerBaseBytes returns the .docx bytes for a Composer base,
|
||||
|
||||
@@ -432,6 +432,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// for inline editor autosave. URL keyed on draft_id + section_id;
|
||||
// owner-scoped via SubmissionDraftService.Get.
|
||||
protected.HandleFunc("PATCH /api/submission-drafts/{draft_id}/sections/{section_id}", handlePatchSubmissionSection)
|
||||
// t-paliad-318 (m/paliad#141) Composer Slice F — add custom
|
||||
// section, delete section, reorder.
|
||||
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/sections", handleCreateSubmissionSection)
|
||||
protected.HandleFunc("DELETE /api/submission-drafts/{draft_id}/sections/{section_id}", handleDeleteSubmissionSection)
|
||||
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/sections/reorder", handleReorderSubmissionSections)
|
||||
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks
|
||||
// library. Lawyer-facing picker + paste mechanic.
|
||||
protected.HandleFunc("GET /api/submission-building-blocks", handleListBuildingBlocks)
|
||||
@@ -722,18 +727,43 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// t-paliad-089 — admin Event-Type moderation panel.
|
||||
// t-paliad-191 Slice 11a — admin rule-editor API.
|
||||
// t-paliad-192 Slice 11b — admin rule-editor UI pages + orphan list/resolve.
|
||||
protected.HandleFunc("GET /admin/rules", adminGate(users, gateOnboarded(handleAdminRulesListPage)))
|
||||
protected.HandleFunc("GET /admin/rules/{id}/edit", adminGate(users, gateOnboarded(handleAdminRulesEditPage)))
|
||||
protected.HandleFunc("GET /admin/api/rules", adminGate(users, handleAdminListRules))
|
||||
protected.HandleFunc("GET /admin/api/rules/{id}", adminGate(users, handleAdminGetRule))
|
||||
protected.HandleFunc("POST /admin/api/rules", adminGate(users, handleAdminCreateRule))
|
||||
protected.HandleFunc("PATCH /admin/api/rules/{id}", adminGate(users, handleAdminPatchRule))
|
||||
protected.HandleFunc("POST /admin/api/rules/{id}/clone-as-draft", adminGate(users, handleAdminCloneAsDraft))
|
||||
protected.HandleFunc("POST /admin/api/rules/{id}/publish", adminGate(users, handleAdminPublishRule))
|
||||
protected.HandleFunc("POST /admin/api/rules/{id}/archive", adminGate(users, handleAdminArchiveRule))
|
||||
protected.HandleFunc("POST /admin/api/rules/{id}/restore", adminGate(users, handleAdminRestoreRule))
|
||||
protected.HandleFunc("GET /admin/api/rules/{id}/audit", adminGate(users, handleAdminGetRuleAudit))
|
||||
protected.HandleFunc("GET /admin/api/rules/{id}/preview", adminGate(users, handleAdminPreviewRule))
|
||||
// Slice B.6 (t-paliad-305) — canonical URL paths under
|
||||
// /admin/procedural-events with 301 redirects from the legacy
|
||||
// /admin/rules paths so existing bookmarks and audit-log
|
||||
// entries continue to resolve. New paths point at the same
|
||||
// handlers; the canonical-URL name aligns with the umbrella
|
||||
// term locked in Slice A.
|
||||
protected.HandleFunc("GET /admin/procedural-events", adminGate(users, gateOnboarded(handleAdminRulesListPage)))
|
||||
protected.HandleFunc("GET /admin/procedural-events/{id}/edit", adminGate(users, gateOnboarded(handleAdminRulesEditPage)))
|
||||
protected.HandleFunc("GET /admin/api/procedural-events", adminGate(users, handleAdminListRules))
|
||||
protected.HandleFunc("GET /admin/api/procedural-events/{id}", adminGate(users, handleAdminGetRule))
|
||||
protected.HandleFunc("POST /admin/api/procedural-events", adminGate(users, handleAdminCreateRule))
|
||||
protected.HandleFunc("PATCH /admin/api/procedural-events/{id}", adminGate(users, handleAdminPatchRule))
|
||||
protected.HandleFunc("POST /admin/api/procedural-events/{id}/clone-as-draft", adminGate(users, handleAdminCloneAsDraft))
|
||||
protected.HandleFunc("POST /admin/api/procedural-events/{id}/publish", adminGate(users, handleAdminPublishRule))
|
||||
protected.HandleFunc("POST /admin/api/procedural-events/{id}/archive", adminGate(users, handleAdminArchiveRule))
|
||||
protected.HandleFunc("POST /admin/api/procedural-events/{id}/restore", adminGate(users, handleAdminRestoreRule))
|
||||
protected.HandleFunc("GET /admin/api/procedural-events/{id}/audit", adminGate(users, handleAdminGetRuleAudit))
|
||||
protected.HandleFunc("GET /admin/api/procedural-events/{id}/preview", adminGate(users, handleAdminPreviewRule))
|
||||
|
||||
// Legacy /admin/rules paths — 301 redirect to the canonical
|
||||
// /admin/procedural-events paths. One-slice deprecation window
|
||||
// per design §8.2 (B.6 optional; m authorised the rename
|
||||
// 2026-05-26). After the next slice that audits external
|
||||
// references, these can be retired entirely.
|
||||
protected.HandleFunc("GET /admin/rules", adminGate(users, redirectToProceduralEvents("/admin/procedural-events")))
|
||||
protected.HandleFunc("GET /admin/rules/{id}/edit", adminGate(users, redirectToProceduralEventEdit))
|
||||
protected.HandleFunc("GET /admin/api/rules", adminGate(users, redirectToProceduralEvents("/admin/api/procedural-events")))
|
||||
protected.HandleFunc("GET /admin/api/rules/{id}", adminGate(users, redirectToProceduralEventAPI("")))
|
||||
protected.HandleFunc("POST /admin/api/rules", adminGate(users, redirectToProceduralEvents("/admin/api/procedural-events")))
|
||||
protected.HandleFunc("PATCH /admin/api/rules/{id}", adminGate(users, redirectToProceduralEventAPI("")))
|
||||
protected.HandleFunc("POST /admin/api/rules/{id}/clone-as-draft", adminGate(users, redirectToProceduralEventAPI("/clone-as-draft")))
|
||||
protected.HandleFunc("POST /admin/api/rules/{id}/publish", adminGate(users, redirectToProceduralEventAPI("/publish")))
|
||||
protected.HandleFunc("POST /admin/api/rules/{id}/archive", adminGate(users, redirectToProceduralEventAPI("/archive")))
|
||||
protected.HandleFunc("POST /admin/api/rules/{id}/restore", adminGate(users, redirectToProceduralEventAPI("/restore")))
|
||||
protected.HandleFunc("GET /admin/api/rules/{id}/audit", adminGate(users, redirectToProceduralEventAPI("/audit")))
|
||||
protected.HandleFunc("GET /admin/api/rules/{id}/preview", adminGate(users, redirectToProceduralEventAPI("/preview")))
|
||||
|
||||
protected.HandleFunc("GET /admin/api/orphans", adminGate(users, handleAdminListOrphans))
|
||||
protected.HandleFunc("POST /admin/api/orphans/{id}/resolve", adminGate(users, handleAdminResolveOrphan))
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
@@ -130,6 +132,188 @@ func handlePatchSubmissionSection(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, sectionJSONFromService(updated))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Slice F — add custom section / delete section / reorder
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type submissionSectionCreateInput struct {
|
||||
SectionKey string `json:"section_key"`
|
||||
Kind string `json:"kind"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
ContentMDDE string `json:"content_md_de,omitempty"`
|
||||
ContentMDEN string `json:"content_md_en,omitempty"`
|
||||
OrderIndex int `json:"order_index,omitempty"`
|
||||
}
|
||||
|
||||
// handleCreateSubmissionSection backs POST /api/submission-drafts/{draft_id}/sections.
|
||||
// Adds a new (custom) section to the draft. Owner-scoped via
|
||||
// SubmissionDraftService.Get.
|
||||
func handleCreateSubmissionSection(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionDraft == nil || dbSvc.submissionSection == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission sections not configured"})
|
||||
return
|
||||
}
|
||||
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), submissionSectionPatchTimeout)
|
||||
defer cancel()
|
||||
|
||||
if _, err := dbSvc.submissionDraft.Get(ctx, uid, draftID); err != nil {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var input submissionSectionCreateInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
created, err := dbSvc.submissionSection.Create(ctx, services.SectionCreateInput{
|
||||
DraftID: draftID,
|
||||
SectionKey: input.SectionKey,
|
||||
Kind: input.Kind,
|
||||
LabelDE: input.LabelDE,
|
||||
LabelEN: input.LabelEN,
|
||||
ContentMDDE: input.ContentMDDE,
|
||||
ContentMDEN: input.ContentMDEN,
|
||||
OrderIndex: input.OrderIndex,
|
||||
Included: true,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, sectionJSONFromService(created))
|
||||
}
|
||||
|
||||
// handleDeleteSubmissionSection backs DELETE /api/submission-drafts/{draft_id}/sections/{section_id}.
|
||||
// Owner-scoped via SubmissionDraftService.Get + section-belongs-to-draft cross-check.
|
||||
func handleDeleteSubmissionSection(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionDraft == nil || dbSvc.submissionSection == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission sections not configured"})
|
||||
return
|
||||
}
|
||||
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sectionID, ok := parseUUIDPath(w, r, "section_id", "section id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), submissionSectionPatchTimeout)
|
||||
defer cancel()
|
||||
|
||||
draft, err := dbSvc.submissionDraft.Get(ctx, uid, draftID)
|
||||
if err != nil {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
sec, err := dbSvc.submissionSection.Get(ctx, sectionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrSubmissionSectionNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if sec.DraftID != draft.ID {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.submissionSection.Delete(ctx, sectionID); err != nil {
|
||||
if errors.Is(err, services.ErrSubmissionSectionNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
type submissionSectionReorderInput struct {
|
||||
SectionOrder []string `json:"section_order"`
|
||||
}
|
||||
|
||||
// handleReorderSubmissionSections backs POST /api/submission-drafts/{draft_id}/sections/reorder.
|
||||
// Accepts a sequence of section_ids; rewrites every row's order_index
|
||||
// to (1, 2, 3, …) × 10 in the supplied order. Returns the refreshed
|
||||
// section list.
|
||||
func handleReorderSubmissionSections(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionDraft == nil || dbSvc.submissionSection == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission sections not configured"})
|
||||
return
|
||||
}
|
||||
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), submissionSectionPatchTimeout)
|
||||
defer cancel()
|
||||
|
||||
if _, err := dbSvc.submissionDraft.Get(ctx, uid, draftID); err != nil {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var input submissionSectionReorderInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
order := make([]uuid.UUID, 0, len(input.SectionOrder))
|
||||
for _, raw := range input.SectionOrder {
|
||||
id, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid section id in order list"})
|
||||
return
|
||||
}
|
||||
order = append(order, id)
|
||||
}
|
||||
|
||||
rows, err := dbSvc.submissionSection.Reorder(ctx, draftID, order)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]submissionSectionJSON, 0, len(rows))
|
||||
for _, sec := range rows {
|
||||
out = append(out, sectionJSONFromService(&sec))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"sections": out})
|
||||
}
|
||||
|
||||
// sectionJSONFromService projects a services.SubmissionSection into the
|
||||
// JSON shape the editor consumes — the same shape buildSubmissionDraftView
|
||||
// emits under .sections[].
|
||||
|
||||
@@ -264,7 +264,14 @@ type Deadline struct {
|
||||
OriginalDueDate *time.Time `db:"original_due_date" json:"original_due_date,omitempty"`
|
||||
WarningDate *time.Time `db:"warning_date" json:"warning_date,omitempty"`
|
||||
Source string `db:"source" json:"source"`
|
||||
RuleID *uuid.UUID `db:"rule_id" json:"rule_id,omitempty"`
|
||||
// Slice B.4 (mig 140, t-paliad-305): paliad.deadlines.rule_id column
|
||||
// dropped; the back-link now lives on `sequencing_rule_id` (FK to
|
||||
// paliad.sequencing_rules). Same UUID values (sequencing_rules.id
|
||||
// inherited deadline_rules.id during mig 136 backfill), so internal
|
||||
// Go references to `RuleID` continue to carry the same semantic
|
||||
// pointer. The JSON name stays `rule_id` for frontend backward-compat
|
||||
// — B.5 will rename if/when frontend is updated.
|
||||
RuleID *uuid.UUID `db:"sequencing_rule_id" json:"rule_id,omitempty"`
|
||||
// RuleCode is the legal citation ("RoP.023", "R.151") attached at
|
||||
// save time — see migration 032. Free text by design; survives
|
||||
// changes to paliad.deadline_rules and accepts citations from
|
||||
@@ -546,6 +553,51 @@ type Party struct {
|
||||
// scans, hydration, projection service) continues to compile.
|
||||
type DeadlineRule = litigationplanner.Rule
|
||||
|
||||
// SequencingRule is the Slice B.5 (t-paliad-305) canonical name for what
|
||||
// the legacy schema called a "deadline rule". Alias to DeadlineRule so
|
||||
// existing call-sites compile unchanged while new code can adopt the
|
||||
// procedural-event vocabulary. Same struct, same db / json tags.
|
||||
type SequencingRule = DeadlineRule
|
||||
|
||||
// ProceduralEvent mirrors paliad.procedural_events — the "what kind of
|
||||
// step is this in the proceeding" identity row. New struct introduced
|
||||
// in Slice B.5 (t-paliad-305) for code that needs the procedural-event
|
||||
// columns alone. Most consumers still pull the merged shape via
|
||||
// SequencingRule through the paliad.deadline_rules_unified view; this
|
||||
// struct unlocks per-PE reads/writes without going through the view.
|
||||
type ProceduralEvent struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Code string `db:"code" json:"code"`
|
||||
Name string `db:"name" json:"name"`
|
||||
NameEN string `db:"name_en" json:"name_en"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
EventKind *string `db:"event_kind" json:"event_kind,omitempty"`
|
||||
PrimaryPartyDefault *string `db:"primary_party_default" json:"primary_party_default,omitempty"`
|
||||
LegalSourceID *uuid.UUID `db:"legal_source_id" json:"legal_source_id,omitempty"`
|
||||
ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"`
|
||||
LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"`
|
||||
DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"`
|
||||
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// LegalSource mirrors paliad.legal_sources — the source-of-law citation
|
||||
// rows that procedural events anchor against. pretty_de / pretty_en are
|
||||
// nullable on disk; readers fall back to
|
||||
// internal/services/submission_vars.go:legalSourcePretty when missing.
|
||||
type LegalSource struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Citation string `db:"citation" json:"citation"`
|
||||
Jurisdiction string `db:"jurisdiction" json:"jurisdiction"`
|
||||
PrettyDE *string `db:"pretty_de" json:"pretty_de,omitempty"`
|
||||
PrettyEN *string `db:"pretty_en" json:"pretty_en,omitempty"`
|
||||
Notes *string `db:"notes" json:"notes,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
|
||||
// append-only audit log for every change to paliad.deadline_rules.
|
||||
// Written by the AFTER-trigger (raw create / update / delete) and by
|
||||
|
||||
@@ -10,12 +10,24 @@ import (
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// DeadlineRuleService reads paliad.deadline_rules + paliad.proceeding_types.
|
||||
// Rules are static reference data; no visibility check needed.
|
||||
// DeadlineRuleService reads paliad.deadline_rules_unified (mig 139 view
|
||||
// projecting paliad.sequencing_rules + procedural_events +
|
||||
// legal_sources back to the legacy column shape after mig 140 dropped
|
||||
// the underlying table) + paliad.proceeding_types. Rules are static
|
||||
// reference data; no visibility check needed.
|
||||
type DeadlineRuleService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// SequencingRuleService is the Slice B.5 (t-paliad-305) canonical name
|
||||
// for DeadlineRuleService. Alias preserves every existing call-site
|
||||
// while new code can adopt the procedural-event vocabulary.
|
||||
type SequencingRuleService = DeadlineRuleService
|
||||
|
||||
// NewSequencingRuleService is the canonical constructor name; alias to
|
||||
// NewDeadlineRuleService for now. Both return the same underlying type.
|
||||
var NewSequencingRuleService = NewDeadlineRuleService
|
||||
|
||||
// NewDeadlineRuleService wires the service to the pool.
|
||||
func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService {
|
||||
return &DeadlineRuleService{db: db}
|
||||
|
||||
@@ -65,8 +65,13 @@ func (s *DeadlineService) pendingApprovalErr(ctx context.Context, deadlineID uui
|
||||
return NewPendingApprovalError(rid, role)
|
||||
}
|
||||
|
||||
// Slice B.4 (mig 140, t-paliad-305): rule_id column dropped from
|
||||
// paliad.deadlines. sequencing_rule_id holds the same UUID and is the
|
||||
// FK to paliad.sequencing_rules. SELECT-column lists below pull
|
||||
// sequencing_rule_id into the Deadline.RuleID field (db tag adjusted in
|
||||
// internal/models/models.go).
|
||||
const deadlineColumns = `id, project_id, title, description, due_date, original_due_date,
|
||||
warning_date, source, rule_id, rule_code, custom_rule_text, status, completed_at, caldav_uid, caldav_etag,
|
||||
warning_date, source, sequencing_rule_id, rule_code, custom_rule_text, status, completed_at, caldav_uid, caldav_etag,
|
||||
notes, created_by, created_at, updated_at,
|
||||
approval_status, pending_request_id, approved_by, approved_at`
|
||||
|
||||
@@ -272,7 +277,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
|
||||
ar.requester_kind AS requester_kind
|
||||
FROM paliad.deadlines f
|
||||
JOIN paliad.projects p ON p.id = f.project_id
|
||||
LEFT JOIN paliad.deadline_rules_unified r ON r.id = f.rule_id
|
||||
LEFT JOIN paliad.deadline_rules_unified r ON r.id = f.sequencing_rule_id
|
||||
LEFT JOIN paliad.approval_requests ar ON ar.id = f.pending_request_id
|
||||
WHERE ` + strings.Join(conds, " AND ") + `
|
||||
ORDER BY f.due_date ASC, f.created_at DESC`
|
||||
@@ -539,7 +544,11 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
if input.RuleID != nil && input.CustomRuleText != nil {
|
||||
return nil, fmt.Errorf("%w: rule_id and custom_rule_text are mutually exclusive", ErrInvalidInput)
|
||||
}
|
||||
appendSet("rule_id", input.RuleID)
|
||||
// Slice B.4 (t-paliad-305): rule_id column dropped; the FK
|
||||
// back-link now lives on sequencing_rule_id. Same UUID value.
|
||||
// The procedural_event_id mirror is derived in
|
||||
// syncDeadlineDualLinks below after the primary UPDATE lands.
|
||||
appendSet("sequencing_rule_id", input.RuleID)
|
||||
var customText *string
|
||||
if input.CustomRuleText != nil {
|
||||
trimmed := strings.TrimSpace(*input.CustomRuleText)
|
||||
@@ -585,13 +594,13 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("update deadline: %w", err)
|
||||
}
|
||||
// Slice B.2 dual-write (t-paliad-305): if rule_id was in the
|
||||
// patch (auto/custom swap from t-paliad-258), the parallel
|
||||
// procedural_event_id + sequencing_rule_id columns must follow.
|
||||
// Call unconditionally — it's a single UPDATE keyed on
|
||||
// deadlineID and a no-op when rule_id is unchanged.
|
||||
// Slice B.4 (mig 140, t-paliad-305): rule_id column gone;
|
||||
// sequencing_rule_id holds the back-link. When the patch updated
|
||||
// it (auto/custom swap from t-paliad-258), mirror the FK onto
|
||||
// procedural_event_id so the joined view continues to resolve.
|
||||
// Idempotent: no-op when sequencing_rule_id is unchanged.
|
||||
if input.RuleSet {
|
||||
if err := syncDeadlineDualLinks(ctx, tx, deadlineID); err != nil {
|
||||
if err := syncDeadlineProceduralEventID(ctx, tx, deadlineID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,392 +1,50 @@
|
||||
// Slice B.2 dual-write (t-paliad-305 / m/paliad#93) — keep paliad's
|
||||
// new tables (procedural_events / sequencing_rules / legal_sources) in
|
||||
// lock-step with the legacy paliad.deadline_rules table during the
|
||||
// dual-write window. Mig 136 (Slice B.1) created the new tables and
|
||||
// backfilled them once. This file keeps them in sync going forward.
|
||||
// Slice B.4 retirement of B.2 dual-write (t-paliad-305 / m/paliad#93).
|
||||
//
|
||||
// Contract:
|
||||
// Mig 140 dropped paliad.deadline_rules and installed INSTEAD OF
|
||||
// triggers on paliad.deadline_rules_unified that route writes to
|
||||
// procedural_events + sequencing_rules + legal_sources. The legacy
|
||||
// dual-write helper (syncDualWriteFromDeadlineRule) and the drift-check
|
||||
// loop (CheckDualWriteDrift / StartDualWriteDriftCheckLoop) reference
|
||||
// paliad.deadline_rules, which no longer exists — they would crash on
|
||||
// first call if kept.
|
||||
//
|
||||
// - Every RuleEditorService method that mutates paliad.deadline_rules
|
||||
// calls syncDualWriteFromDeadlineRule(ctx, tx, id) inside the same
|
||||
// transaction, AFTER the deadline_rules write, BEFORE tx.Commit.
|
||||
// - The sync is idempotent (INSERT … ON CONFLICT … DO UPDATE) so the
|
||||
// same call works for Create (new row), UpdateDraft (existing row),
|
||||
// CloneAsDraft (new row referencing an old row), Publish (lifecycle
|
||||
// flip), Archive/Restore (lifecycle flip), and the published-peer
|
||||
// archive that Publish performs as a cascade.
|
||||
// - The sync re-derives the new-table state from paliad.deadline_rules
|
||||
// in pure SQL — no struct mapping in Go. The legacy table stays the
|
||||
// source of truth during B.2 (B.3 flips reads, B.4 drops it).
|
||||
// - Read paths still read deadline_rules in B.2. The new tables are a
|
||||
// parallel projection kept consistent for B.3's read cutover; they
|
||||
// are not yet authoritative.
|
||||
// Survivor: syncDeadlineProceduralEventID — keeps paliad.deadlines's
|
||||
// new procedural_event_id column in sync with sequencing_rule_id after
|
||||
// any UPDATE that touched the latter. Still useful as a "derive from
|
||||
// canonical pointer" helper.
|
||||
//
|
||||
// Why a per-row sync instead of a global trigger:
|
||||
//
|
||||
// - The deadline_rules audit trigger (mig 079) reads paliad.audit_reason
|
||||
// to record the rationale on every change. Putting the new-table
|
||||
// write in the same TX preserves that auditability — set_config is
|
||||
// transactional and the new writes share the same reason.
|
||||
// - A Postgres-side AFTER UPDATE trigger on deadline_rules would also
|
||||
// work but it's harder to test in isolation and harder to revert
|
||||
// when B.4 drops the source table. A Go-side sync is reversible
|
||||
// with a code revert; an SQL trigger needs a follow-up migration.
|
||||
//
|
||||
// The drift-check job (CheckDualWriteDrift below) runs daily and
|
||||
// alerts on mismatches. If the sync ever silently misses a row, the
|
||||
// drift check surfaces it inside one day.
|
||||
//
|
||||
// See docs/design-procedural-events-model-2026-05-25.md §5.2 (dual-write
|
||||
// phase) and docs/design-procedural-events-b0-findings-2026-05-26.md §7.
|
||||
// The DualWriteDriftReport struct + HasDrift method are retired with
|
||||
// the loop they served.
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// syncDualWriteFromDeadlineRule re-projects the deadline_rules row with
|
||||
// the given id into legal_sources + procedural_events + sequencing_rules.
|
||||
// Runs three UPSERT statements in the open transaction.
|
||||
// syncDeadlineProceduralEventID mirrors paliad.deadlines.sequencing_rule_id
|
||||
// onto procedural_event_id. Call this within an open transaction AFTER
|
||||
// any UPDATE that mutates paliad.deadlines.sequencing_rule_id (today's
|
||||
// callers: DeadlineService.Update on the RuleSet branch, and the
|
||||
// RuleEditorService orphan-resolve path which sets both columns in one
|
||||
// statement so doesn't need this helper).
|
||||
//
|
||||
// Synthetic-code rule (for rows where deadline_rules.submission_code is
|
||||
// NULL) mirrors mig 136's backfill: 'null.' || first 8 hex chars of the
|
||||
// uuid (dashes stripped). This must stay byte-identical to the mig 136
|
||||
// expression or the lookup join inside the sequencing_rules UPSERT
|
||||
// misses.
|
||||
func syncDualWriteFromDeadlineRule(ctx context.Context, tx *sqlx.Tx, id uuid.UUID) error {
|
||||
// 1. legal_sources — UPSERT the citation (no-op if already present).
|
||||
// jurisdiction is parsed from the first dot-separated segment;
|
||||
// 'other' on empty (paranoid fallback, no live rows hit it).
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO paliad.legal_sources (citation, jurisdiction)
|
||||
SELECT dr.legal_source,
|
||||
COALESCE(NULLIF(split_part(dr.legal_source, '.', 1), ''), 'other')
|
||||
FROM paliad.deadline_rules dr
|
||||
WHERE dr.id = $1 AND dr.legal_source IS NOT NULL
|
||||
ON CONFLICT (citation) DO NOTHING`, id); err != nil {
|
||||
return fmt.Errorf("dual-write legal_sources for rule %s: %w", id, err)
|
||||
}
|
||||
|
||||
// 2. procedural_events — UPSERT keyed by code. The code is the
|
||||
// submission_code if present, else the synthetic 'null.<8hex>'
|
||||
// minted from the deadline_rules row's id (matches mig 136).
|
||||
// legal_source_id is resolved by JOIN on legal_sources.citation
|
||||
// (NULL when the rule has no legal_source).
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO paliad.procedural_events
|
||||
(code, name, name_en, description, event_kind,
|
||||
primary_party_default, legal_source_id, concept_id,
|
||||
lifecycle_state, published_at, is_active)
|
||||
SELECT
|
||||
COALESCE(dr.submission_code,
|
||||
'null.' || substring(replace(dr.id::text, '-', ''), 1, 8)),
|
||||
dr.name, dr.name_en, dr.description, dr.event_type,
|
||||
dr.primary_party, ls.id, dr.concept_id,
|
||||
dr.lifecycle_state, dr.published_at, dr.is_active
|
||||
FROM paliad.deadline_rules dr
|
||||
LEFT JOIN paliad.legal_sources ls ON ls.citation = dr.legal_source
|
||||
WHERE dr.id = $1
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
name_en = EXCLUDED.name_en,
|
||||
description = EXCLUDED.description,
|
||||
event_kind = EXCLUDED.event_kind,
|
||||
primary_party_default = EXCLUDED.primary_party_default,
|
||||
legal_source_id = EXCLUDED.legal_source_id,
|
||||
concept_id = EXCLUDED.concept_id,
|
||||
lifecycle_state = EXCLUDED.lifecycle_state,
|
||||
published_at = EXCLUDED.published_at,
|
||||
is_active = EXCLUDED.is_active,
|
||||
updated_at = now()`, id); err != nil {
|
||||
return fmt.Errorf("dual-write procedural_events for rule %s: %w", id, err)
|
||||
}
|
||||
|
||||
// 3. sequencing_rules — UPSERT keyed by id (1:1 inheritance from
|
||||
// deadline_rules.id). procedural_event_id resolved by JOIN on
|
||||
// the (real or synthetic) code. All hat-3 mechanics columns copy
|
||||
// 1:1 from the deadline_rules row's post-write state.
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO paliad.sequencing_rules
|
||||
(id, procedural_event_id, proceeding_type_id, parent_id, trigger_event_id,
|
||||
duration_value, duration_unit, timing,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt,
|
||||
combine_op, condition_expr, primary_party, sequence_order,
|
||||
is_spawn, spawn_label, spawn_proceeding_type_id,
|
||||
is_bilateral, is_court_set, priority,
|
||||
rule_code, rule_codes, deadline_notes, deadline_notes_en,
|
||||
choices_offered, applies_to_target,
|
||||
lifecycle_state, draft_of, published_at, is_active,
|
||||
created_at, updated_at)
|
||||
SELECT
|
||||
dr.id, pe.id,
|
||||
dr.proceeding_type_id, dr.parent_id, dr.trigger_event_id,
|
||||
dr.duration_value, dr.duration_unit, dr.timing,
|
||||
dr.alt_duration_value, dr.alt_duration_unit, dr.alt_rule_code, dr.anchor_alt,
|
||||
dr.combine_op, dr.condition_expr, dr.primary_party, dr.sequence_order,
|
||||
dr.is_spawn, dr.spawn_label, dr.spawn_proceeding_type_id,
|
||||
dr.is_bilateral, dr.is_court_set, dr.priority,
|
||||
dr.rule_code, dr.rule_codes, dr.deadline_notes, dr.deadline_notes_en,
|
||||
dr.choices_offered, dr.applies_to_target,
|
||||
dr.lifecycle_state, dr.draft_of, dr.published_at, dr.is_active,
|
||||
dr.created_at, dr.updated_at
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.procedural_events pe
|
||||
ON pe.code = COALESCE(dr.submission_code,
|
||||
'null.' || substring(replace(dr.id::text, '-', ''), 1, 8))
|
||||
WHERE dr.id = $1
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
procedural_event_id = EXCLUDED.procedural_event_id,
|
||||
proceeding_type_id = EXCLUDED.proceeding_type_id,
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
trigger_event_id = EXCLUDED.trigger_event_id,
|
||||
duration_value = EXCLUDED.duration_value,
|
||||
duration_unit = EXCLUDED.duration_unit,
|
||||
timing = EXCLUDED.timing,
|
||||
alt_duration_value = EXCLUDED.alt_duration_value,
|
||||
alt_duration_unit = EXCLUDED.alt_duration_unit,
|
||||
alt_rule_code = EXCLUDED.alt_rule_code,
|
||||
anchor_alt = EXCLUDED.anchor_alt,
|
||||
combine_op = EXCLUDED.combine_op,
|
||||
condition_expr = EXCLUDED.condition_expr,
|
||||
primary_party = EXCLUDED.primary_party,
|
||||
sequence_order = EXCLUDED.sequence_order,
|
||||
is_spawn = EXCLUDED.is_spawn,
|
||||
spawn_label = EXCLUDED.spawn_label,
|
||||
spawn_proceeding_type_id = EXCLUDED.spawn_proceeding_type_id,
|
||||
is_bilateral = EXCLUDED.is_bilateral,
|
||||
is_court_set = EXCLUDED.is_court_set,
|
||||
priority = EXCLUDED.priority,
|
||||
rule_code = EXCLUDED.rule_code,
|
||||
rule_codes = EXCLUDED.rule_codes,
|
||||
deadline_notes = EXCLUDED.deadline_notes,
|
||||
deadline_notes_en = EXCLUDED.deadline_notes_en,
|
||||
choices_offered = EXCLUDED.choices_offered,
|
||||
applies_to_target = EXCLUDED.applies_to_target,
|
||||
lifecycle_state = EXCLUDED.lifecycle_state,
|
||||
draft_of = EXCLUDED.draft_of,
|
||||
published_at = EXCLUDED.published_at,
|
||||
is_active = EXCLUDED.is_active,
|
||||
updated_at = now()`, id); err != nil {
|
||||
return fmt.Errorf("dual-write sequencing_rules for rule %s: %w", id, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncDeadlineDualLinks mirrors a deadline's legacy rule_id back-link
|
||||
// onto the new procedural_event_id + sequencing_rule_id columns added
|
||||
// by mig 136. Call this within an open transaction AFTER any UPDATE
|
||||
// that mutates paliad.deadlines.rule_id (mig 122 introduced rule_id
|
||||
// as the deadline→rule FK; today's writers are DeadlineService.Update
|
||||
// and RuleEditorService.ResolveOrphan).
|
||||
//
|
||||
// Idempotent: NULL rule_id collapses both new columns to NULL by virtue
|
||||
// of the subquery returning NULL. Slice B.2 (t-paliad-305).
|
||||
func syncDeadlineDualLinks(ctx context.Context, tx *sqlx.Tx, deadlineID uuid.UUID) error {
|
||||
// Idempotent: NULL sequencing_rule_id collapses procedural_event_id to
|
||||
// NULL via the subquery returning NULL. Slice B.4 (t-paliad-305).
|
||||
func syncDeadlineProceduralEventID(ctx context.Context, tx *sqlx.Tx, deadlineID uuid.UUID) error {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE paliad.deadlines d
|
||||
SET sequencing_rule_id = d.rule_id,
|
||||
procedural_event_id = (
|
||||
SET procedural_event_id = (
|
||||
SELECT sr.procedural_event_id
|
||||
FROM paliad.sequencing_rules sr
|
||||
WHERE sr.id = d.rule_id
|
||||
WHERE sr.id = d.sequencing_rule_id
|
||||
)
|
||||
WHERE d.id = $1`, deadlineID); err != nil {
|
||||
return fmt.Errorf("sync deadline dual-links for %s: %w", deadlineID, err)
|
||||
return fmt.Errorf("sync deadline procedural_event_id for %s: %w", deadlineID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DualWriteDriftReport summarises the comparison between the legacy
|
||||
// paliad.deadline_rules table and the new procedural_events /
|
||||
// sequencing_rules tables that B.2's dual-write is meant to keep in
|
||||
// sync. A zero-drift report (every count delta zero, every join clean)
|
||||
// is the steady state during the dual-write window; any non-zero field
|
||||
// is the signal that a write path either bypassed
|
||||
// syncDualWriteFromDeadlineRule or that an out-of-band mutation
|
||||
// happened (e.g. raw SQL run by an operator).
|
||||
type DualWriteDriftReport struct {
|
||||
// Counts on the legacy and the projected side.
|
||||
DeadlineRules int `json:"deadline_rules"`
|
||||
SequencingRules int `json:"sequencing_rules"`
|
||||
ProceduralEvents int `json:"procedural_events"`
|
||||
LegalSources int `json:"legal_sources"`
|
||||
|
||||
// Expected (from the legacy side) vs observed (on the new side).
|
||||
ExpectedPE int `json:"expected_procedural_events"`
|
||||
ExpectedLegalSources int `json:"expected_legal_sources"`
|
||||
|
||||
// MissingSR — deadline_rules rows with no sequencing_rules row by id.
|
||||
// OrphanedSR — sequencing_rules rows whose id doesn't exist in
|
||||
// deadline_rules anymore (would only happen with a deletion path
|
||||
// that bypasses dual-write).
|
||||
MissingSR int `json:"missing_sequencing_rules"`
|
||||
OrphanedSR int `json:"orphaned_sequencing_rules"`
|
||||
|
||||
// MismatchedLifecycle — rows where deadline_rules.lifecycle_state
|
||||
// disagrees with sequencing_rules.lifecycle_state. Should always be
|
||||
// zero during dual-write.
|
||||
MismatchedLifecycle int `json:"mismatched_lifecycle"`
|
||||
|
||||
// MismatchedActive — same shape, for is_active.
|
||||
MismatchedActive int `json:"mismatched_active"`
|
||||
}
|
||||
|
||||
// HasDrift returns true if any field signals divergence between the
|
||||
// legacy and projected sides. Used by the drift-check ticker to decide
|
||||
// whether to log at WARN (drift) or INFO (clean).
|
||||
func (r DualWriteDriftReport) HasDrift() bool {
|
||||
if r.SequencingRules != r.DeadlineRules {
|
||||
return true
|
||||
}
|
||||
if r.ProceduralEvents != r.ExpectedPE {
|
||||
return true
|
||||
}
|
||||
if r.LegalSources != r.ExpectedLegalSources {
|
||||
return true
|
||||
}
|
||||
if r.MissingSR != 0 || r.OrphanedSR != 0 {
|
||||
return true
|
||||
}
|
||||
if r.MismatchedLifecycle != 0 || r.MismatchedActive != 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CheckDualWriteDrift compares the legacy paliad.deadline_rules table
|
||||
// against the parallel new tables maintained by Slice B.2's dual-write.
|
||||
// Returns a DualWriteDriftReport — caller decides what to do with
|
||||
// non-zero drift (log, page, fail healthcheck, etc.).
|
||||
//
|
||||
// Read-only. Safe to run against prod. Single query per metric so the
|
||||
// pool isn't held for a long time. No locks; tolerates concurrent
|
||||
// writes (counts may shift by one or two during the read, but a
|
||||
// persistent drift > 0 is the alarm signal).
|
||||
func CheckDualWriteDrift(ctx context.Context, conn *sqlx.DB) (*DualWriteDriftReport, error) {
|
||||
var r DualWriteDriftReport
|
||||
|
||||
q := func(label, sql string, dst *int) error {
|
||||
if err := conn.GetContext(ctx, dst, sql); err != nil {
|
||||
return fmt.Errorf("drift-check %s: %w", label, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := q("dr_total", `SELECT COUNT(*) FROM paliad.deadline_rules`, &r.DeadlineRules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := q("sr_total", `SELECT COUNT(*) FROM paliad.sequencing_rules`, &r.SequencingRules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := q("pe_total", `SELECT COUNT(*) FROM paliad.procedural_events`, &r.ProceduralEvents); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := q("ls_total", `SELECT COUNT(*) FROM paliad.legal_sources`, &r.LegalSources); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := q("expected_pe", `
|
||||
SELECT
|
||||
(SELECT COUNT(DISTINCT submission_code) FROM paliad.deadline_rules WHERE submission_code IS NOT NULL)
|
||||
+
|
||||
(SELECT COUNT(*) FROM paliad.deadline_rules WHERE submission_code IS NULL)
|
||||
`, &r.ExpectedPE); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := q("expected_ls",
|
||||
`SELECT COUNT(DISTINCT legal_source) FROM paliad.deadline_rules WHERE legal_source IS NOT NULL`,
|
||||
&r.ExpectedLegalSources); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := q("missing_sr", `
|
||||
SELECT COUNT(*) FROM paliad.deadline_rules dr
|
||||
LEFT JOIN paliad.sequencing_rules sr ON sr.id = dr.id
|
||||
WHERE sr.id IS NULL`, &r.MissingSR); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := q("orphaned_sr", `
|
||||
SELECT COUNT(*) FROM paliad.sequencing_rules sr
|
||||
LEFT JOIN paliad.deadline_rules dr ON dr.id = sr.id
|
||||
WHERE dr.id IS NULL`, &r.OrphanedSR); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := q("mismatched_lifecycle", `
|
||||
SELECT COUNT(*) FROM paliad.deadline_rules dr
|
||||
JOIN paliad.sequencing_rules sr ON sr.id = dr.id
|
||||
WHERE dr.lifecycle_state <> sr.lifecycle_state`, &r.MismatchedLifecycle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := q("mismatched_active", `
|
||||
SELECT COUNT(*) FROM paliad.deadline_rules dr
|
||||
JOIN paliad.sequencing_rules sr ON sr.id = dr.id
|
||||
WHERE dr.is_active <> sr.is_active`, &r.MismatchedActive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// StartDualWriteDriftCheckLoop runs CheckDualWriteDrift on a fixed
|
||||
// interval for the lifetime of ctx. A clean run logs at INFO level;
|
||||
// drift logs at WARN level with the full report payload. The first
|
||||
// check fires after `interval`, not immediately on Start — by the time
|
||||
// the ticker first fires the process has finished booting and the
|
||||
// initial backfill + dual-write writes have settled.
|
||||
//
|
||||
// Slice B.2 (t-paliad-305). interval should be short enough to surface
|
||||
// drift before the next deploy (so a broken dual-write doesn't sit
|
||||
// silent for a week) and long enough to avoid noise (the check holds
|
||||
// no locks but it does run nine SELECT COUNTs).
|
||||
//
|
||||
// Recommended interval: 6h. Override via the caller (cmd/server picks
|
||||
// the runtime value).
|
||||
func StartDualWriteDriftCheckLoop(ctx context.Context, conn *sqlx.DB, interval time.Duration) {
|
||||
if interval <= 0 {
|
||||
interval = 6 * time.Hour
|
||||
}
|
||||
go func() {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
report, err := CheckDualWriteDrift(ctx, conn)
|
||||
if err != nil {
|
||||
log.Printf("dual-write drift-check: error: %v", err)
|
||||
continue
|
||||
}
|
||||
if report.HasDrift() {
|
||||
log.Printf("dual-write drift-check: DRIFT DETECTED — "+
|
||||
"deadline_rules=%d sequencing_rules=%d "+
|
||||
"procedural_events=%d (expected %d) "+
|
||||
"legal_sources=%d (expected %d) "+
|
||||
"missing_sr=%d orphaned_sr=%d "+
|
||||
"mismatched_lifecycle=%d mismatched_active=%d",
|
||||
report.DeadlineRules, report.SequencingRules,
|
||||
report.ProceduralEvents, report.ExpectedPE,
|
||||
report.LegalSources, report.ExpectedLegalSources,
|
||||
report.MissingSR, report.OrphanedSR,
|
||||
report.MismatchedLifecycle, report.MismatchedActive)
|
||||
} else {
|
||||
log.Printf("dual-write drift-check: OK — "+
|
||||
"deadline_rules=%d sequencing_rules=%d "+
|
||||
"procedural_events=%d legal_sources=%d",
|
||||
report.DeadlineRules, report.SequencingRules,
|
||||
report.ProceduralEvents, report.LegalSources)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
@@ -202,14 +202,11 @@ func TestDualWrite_RuleEditorLifecycle(t *testing.T) {
|
||||
t.Errorf("sequencing_rules.lifecycle_state after Archive: got %q, want %q", srLifecycleArchived, "archived")
|
||||
}
|
||||
|
||||
// 5. Drift check should return zero drift right after the dance.
|
||||
report, err := CheckDualWriteDrift(ctx, pool)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckDualWriteDrift: %v", err)
|
||||
}
|
||||
if report.HasDrift() {
|
||||
t.Errorf("CheckDualWriteDrift unexpectedly flagged drift: %+v", report)
|
||||
}
|
||||
// Slice B.4 (mig 140, t-paliad-305): the legacy paliad.deadline_rules
|
||||
// table is gone and so is CheckDualWriteDrift — there's no parallel
|
||||
// side to compare against. The INSTEAD OF triggers on the view
|
||||
// guarantee parity by construction (single TX fan-out from one
|
||||
// SQL write to three target tables).
|
||||
}
|
||||
|
||||
// TestDualWrite_SyntheticCodeForNullSubmission asserts that a rule
|
||||
|
||||
@@ -1805,7 +1805,7 @@ func (s *ProjectionService) parentHasAnchoredActual(ctx context.Context, project
|
||||
err := s.db.GetContext(ctx, &count, `
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT 1 FROM paliad.deadlines
|
||||
WHERE project_id = $1 AND rule_id = $2
|
||||
WHERE project_id = $1 AND sequencing_rule_id = $2
|
||||
AND (completed_at IS NOT NULL
|
||||
OR status = 'completed'
|
||||
OR source = 'anchor')
|
||||
@@ -1843,7 +1843,7 @@ func (s *ProjectionService) upsertAnchorDeadline(ctx context.Context, userID, pr
|
||||
var existingID uuid.UUID
|
||||
err := s.db.GetContext(ctx, &existingID,
|
||||
`SELECT id FROM paliad.deadlines
|
||||
WHERE project_id = $1 AND rule_id = $2
|
||||
WHERE project_id = $1 AND sequencing_rule_id = $2
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1`, projectID, rule.ID)
|
||||
switch {
|
||||
|
||||
@@ -212,20 +212,21 @@ func (s *RuleEditorService) ResolveOrphan(ctx context.Context, orphanID uuid.UUI
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
// Slice B.4 (mig 140, t-paliad-305): paliad.deadlines.rule_id column
|
||||
// dropped. Back-link lives on sequencing_rule_id (same UUIDs as
|
||||
// before — sr.id inherited dr.id at mig 136 backfill).
|
||||
// procedural_event_id is derived from the same sequencing_rules row.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadlines
|
||||
SET rule_id = $1,
|
||||
updated_at = $2
|
||||
WHERE id = $3`,
|
||||
`UPDATE paliad.deadlines d
|
||||
SET sequencing_rule_id = $1,
|
||||
procedural_event_id = (SELECT procedural_event_id
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE id = $1),
|
||||
updated_at = $2
|
||||
WHERE d.id = $3`,
|
||||
ruleID, now, oc.DeadlineID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("set deadline rule_id: %w", err)
|
||||
}
|
||||
// Slice B.2 dual-write (t-paliad-305): mirror the new linkage onto
|
||||
// the parallel deadlines.procedural_event_id + sequencing_rule_id
|
||||
// columns so they don't drift from rule_id.
|
||||
if err := syncDeadlineDualLinks(ctx, tx, oc.DeadlineID); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("set deadline sequencing_rule_id: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadline_rule_backfill_orphans
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
@@ -76,7 +77,13 @@ type RulePatch struct {
|
||||
NameEN *string `json:"name_en,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
PrimaryParty *string `json:"primary_party,omitempty"`
|
||||
// EventType is the legacy JSON key; EventKind is the Slice B.5
|
||||
// canonical name. Decoder accepts either — coalescePatchKeys()
|
||||
// resolves the canonical to the legacy field if only EventKind
|
||||
// was sent. Same uuid wire shape; emit-side wraps via
|
||||
// adminRuleResponse to expose both keys for one slice.
|
||||
EventType *string `json:"event_type,omitempty"`
|
||||
EventKind *string `json:"event_kind,omitempty"`
|
||||
DurationValue *int `json:"duration_value,omitempty"`
|
||||
DurationUnit *string `json:"duration_unit,omitempty"`
|
||||
Timing *string `json:"timing,omitempty"`
|
||||
@@ -101,6 +108,24 @@ type RulePatch struct {
|
||||
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
|
||||
}
|
||||
|
||||
// CoalesceCanonicalKeys folds the Slice B.5 (t-paliad-305) canonical
|
||||
// JSON aliases into the legacy field positions so the rest of the
|
||||
// service can keep using the existing field names. Canonical wins
|
||||
// when both are sent.
|
||||
//
|
||||
// json:"event_kind" → EventType (legacy)
|
||||
//
|
||||
// Called by the handler immediately after json.Decode. New code can
|
||||
// adopt the canonical naming; legacy callers continue to work.
|
||||
func (p *RulePatch) CoalesceCanonicalKeys() {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if p.EventKind != nil {
|
||||
p.EventType = p.EventKind
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRuleInput is the create payload — a full rule row in draft
|
||||
// state. Required fields enforce schema NOT-NULL on insert (name,
|
||||
// name_en, duration_value, duration_unit).
|
||||
@@ -111,9 +136,16 @@ type CreateRuleInput struct {
|
||||
TriggerEventID *int64 `json:"trigger_event_id,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
|
||||
// SubmissionCode is the legacy JSON key; Code is the Slice B.5
|
||||
// canonical name. Decoder accepts either — CoalesceCanonicalKeys()
|
||||
// folds Code → SubmissionCode if only the canonical was sent.
|
||||
SubmissionCode *string `json:"submission_code,omitempty"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
PrimaryParty *string `json:"primary_party,omitempty"`
|
||||
// EventType is the legacy JSON key; EventKind is the Slice B.5
|
||||
// canonical name. Same dual-accept pattern as SubmissionCode/Code.
|
||||
EventType *string `json:"event_type,omitempty"`
|
||||
EventKind *string `json:"event_kind,omitempty"`
|
||||
DurationValue int `json:"duration_value"`
|
||||
DurationUnit string `json:"duration_unit"`
|
||||
Timing *string `json:"timing,omitempty"`
|
||||
@@ -135,6 +167,24 @@ type CreateRuleInput struct {
|
||||
SequenceOrder int `json:"sequence_order"`
|
||||
}
|
||||
|
||||
// CoalesceCanonicalKeys folds the Slice B.5 (t-paliad-305) canonical
|
||||
// JSON aliases into the legacy field positions. Canonical wins when
|
||||
// both are sent. Called by the handler immediately after json.Decode.
|
||||
//
|
||||
// json:"code" → SubmissionCode (legacy)
|
||||
// json:"event_kind" → EventType (legacy)
|
||||
func (in *CreateRuleInput) CoalesceCanonicalKeys() {
|
||||
if in == nil {
|
||||
return
|
||||
}
|
||||
if in.Code != nil {
|
||||
in.SubmissionCode = in.Code
|
||||
}
|
||||
if in.EventKind != nil {
|
||||
in.EventType = in.EventKind
|
||||
}
|
||||
}
|
||||
|
||||
// Create inserts a new rule as lifecycle_state='draft' with
|
||||
// published_at=NULL. The caller's reason is set on the session BEFORE
|
||||
// the INSERT so the mig 079 trigger writes an audit row with the
|
||||
@@ -178,7 +228,7 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
|
||||
// here writes the live shape only — priority + condition_expr
|
||||
// + is_court_set are the new gates.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.deadline_rules
|
||||
`INSERT INTO paliad.deadline_rules_unified
|
||||
(id, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
|
||||
name, name_en, description, primary_party, event_type,
|
||||
duration_value, duration_unit, timing,
|
||||
@@ -209,13 +259,11 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
|
||||
return nil, fmt.Errorf("insert rule: %w", err)
|
||||
}
|
||||
|
||||
// Slice B.2 dual-write (t-paliad-305): project the new row into
|
||||
// legal_sources / procedural_events / sequencing_rules in the same
|
||||
// transaction so the parallel tables stay in lock-step with
|
||||
// deadline_rules through the B.3 read-cutover window.
|
||||
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Slice B.4 (mig 140, t-paliad-305): write routes through the
|
||||
// INSTEAD OF triggers on paliad.deadline_rules_unified, which fan
|
||||
// out into legal_sources + procedural_events + sequencing_rules.
|
||||
// No Go-side mirror call needed — the INSERT above already landed
|
||||
// the parallel rows.
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit create: %w", err)
|
||||
@@ -279,15 +327,13 @@ func (s *RuleEditorService) UpdateDraft(ctx context.Context, id uuid.UUID, patch
|
||||
args = append(args, time.Now().UTC())
|
||||
args = append(args, id)
|
||||
q := fmt.Sprintf(
|
||||
`UPDATE paliad.deadline_rules SET %s WHERE id = $%d AND lifecycle_state = 'draft'`,
|
||||
`UPDATE paliad.deadline_rules_unified SET %s WHERE id = $%d AND lifecycle_state = 'draft'`,
|
||||
strings.Join(sets, ", "), len(args))
|
||||
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("update rule draft: %w", err)
|
||||
}
|
||||
// Slice B.2 dual-write (t-paliad-305).
|
||||
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Slice B.4 (mig 140, t-paliad-305): INSTEAD OF trigger handles the
|
||||
// new-table writes — the UPDATE above is already fan-out.
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit update: %w", err)
|
||||
}
|
||||
@@ -321,7 +367,7 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas
|
||||
|
||||
newID := uuid.New()
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.deadline_rules
|
||||
`INSERT INTO paliad.deadline_rules_unified
|
||||
(id, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
|
||||
name, name_en, description, primary_party, event_type,
|
||||
duration_value, duration_unit, timing,
|
||||
@@ -342,20 +388,16 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas
|
||||
is_active,
|
||||
'draft', $2, NULL,
|
||||
now(), now()
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE id = $2`,
|
||||
newID, id,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("clone rule as draft: %w", err)
|
||||
}
|
||||
// Slice B.2 dual-write (t-paliad-305): new draft gets its own
|
||||
// procedural_events + sequencing_rules row. The synthetic-code
|
||||
// branch fires here when the source rule had NULL submission_code
|
||||
// (the clone inherits the NULL and mints a fresh 'null.<8hex>'
|
||||
// derived from newID).
|
||||
if err := syncDualWriteFromDeadlineRule(ctx, tx, newID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Slice B.4 (mig 140, t-paliad-305): INSTEAD OF INSERT trigger
|
||||
// mints the synthetic 'null.<8hex>' code when submission_code is
|
||||
// NULL (matching mig 136 + the legacy dual-write helper's
|
||||
// expression).
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit clone: %w", err)
|
||||
}
|
||||
@@ -389,7 +431,7 @@ func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason st
|
||||
|
||||
now := time.Now().UTC()
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadline_rules
|
||||
`UPDATE paliad.deadline_rules_unified
|
||||
SET lifecycle_state = 'published',
|
||||
published_at = $1,
|
||||
updated_at = $1
|
||||
@@ -402,7 +444,7 @@ func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason st
|
||||
// Archive the peer this draft was cloned from, if any.
|
||||
if current.DraftOf != nil {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadline_rules
|
||||
`UPDATE paliad.deadline_rules_unified
|
||||
SET lifecycle_state = 'archived',
|
||||
updated_at = $1
|
||||
WHERE id = $2 AND lifecycle_state = 'published'`,
|
||||
@@ -412,17 +454,9 @@ func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason st
|
||||
}
|
||||
}
|
||||
|
||||
// Slice B.2 dual-write (t-paliad-305): sync both sides — the newly
|
||||
// published draft AND the cloned-from peer that just flipped to
|
||||
// archived (if any).
|
||||
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if current.DraftOf != nil {
|
||||
if err := syncDualWriteFromDeadlineRule(ctx, tx, *current.DraftOf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Slice B.4 (mig 140, t-paliad-305): both UPDATEs above route via
|
||||
// the INSTEAD OF UPDATE trigger, which mirrors the lifecycle flip
|
||||
// onto procedural_events + sequencing_rules in the same TX.
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit publish: %w", err)
|
||||
@@ -471,7 +505,7 @@ func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, tar
|
||||
// timestamp helps audit reads ("when was this rule first live?").
|
||||
if target == "published" {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadline_rules
|
||||
`UPDATE paliad.deadline_rules_unified
|
||||
SET lifecycle_state = $1,
|
||||
published_at = COALESCE(published_at, $2),
|
||||
updated_at = $2
|
||||
@@ -482,7 +516,7 @@ func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, tar
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadline_rules
|
||||
`UPDATE paliad.deadline_rules_unified
|
||||
SET lifecycle_state = $1, updated_at = $2
|
||||
WHERE id = $3`,
|
||||
target, now, id,
|
||||
@@ -491,11 +525,8 @@ func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, tar
|
||||
}
|
||||
}
|
||||
|
||||
// Slice B.2 dual-write (t-paliad-305): mirror the lifecycle flip
|
||||
// onto sequencing_rules + procedural_events.
|
||||
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Slice B.4 (mig 140, t-paliad-305): INSTEAD OF UPDATE trigger
|
||||
// mirrors the lifecycle flip onto sr + pe.
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit flip: %w", err)
|
||||
@@ -647,6 +678,42 @@ func (s *RuleEditorService) ListRules(ctx context.Context, f ListRulesFilter) ([
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// LoadProceedingTypeCodes returns an id → code map for every distinct
|
||||
// non-NULL proceeding_type_id present in rows. Single SELECT against
|
||||
// paliad.proceeding_types (firm-wide reference data, no RLS). Used by
|
||||
// /admin/api/procedural-events to enrich the LIST response with a
|
||||
// proceeding_type_code field so the admin UI can disambiguate
|
||||
// same-named rules at a glance (t-paliad-321).
|
||||
func (s *RuleEditorService) LoadProceedingTypeCodes(ctx context.Context, rows []models.DeadlineRule) (map[int]string, error) {
|
||||
seen := map[int]bool{}
|
||||
var ids []int
|
||||
for _, r := range rows {
|
||||
if r.ProceedingTypeID != nil && !seen[*r.ProceedingTypeID] {
|
||||
seen[*r.ProceedingTypeID] = true
|
||||
ids = append(ids, *r.ProceedingTypeID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
type pair struct {
|
||||
ID int `db:"id"`
|
||||
Code string `db:"code"`
|
||||
}
|
||||
var pairs []pair
|
||||
if err := s.db.SelectContext(ctx, &pairs,
|
||||
`SELECT id, code FROM paliad.proceeding_types WHERE id = ANY($1)`,
|
||||
pq.Array(ids),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("load proceeding_type codes: %w", err)
|
||||
}
|
||||
out := make(map[int]string, len(pairs))
|
||||
for _, p := range pairs {
|
||||
out[p.ID] = p.Code
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetByID returns a single rule. Exported so the handler can call it
|
||||
// directly without round-tripping through ListRules.
|
||||
func (s *RuleEditorService) GetByID(ctx context.Context, id uuid.UUID) (*models.DeadlineRule, error) {
|
||||
|
||||
@@ -111,8 +111,15 @@ func (c *SubmissionComposer) Compose(ctx context.Context, opts ComposeOptions) (
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Per-compose hyperlink allocator. Each unique URL gets a fresh
|
||||
// rId outside the base's existing namespace. The post-pass
|
||||
// (patchDocumentXMLRels) writes the matching Relationship rows
|
||||
// before the zip is repacked. Slice D adds inline `[label](url)`
|
||||
// hyperlink support.
|
||||
linkAlloc := newComposerLinkAllocator()
|
||||
|
||||
// Build the rendered-section map: section_key → OOXML span.
|
||||
style := opts.Base.SectionSpec.Stylemap["paragraph"]
|
||||
stylemap := opts.Base.SectionSpec.Stylemap
|
||||
rendered := make(map[string]string, len(sections))
|
||||
keptSections := make([]SubmissionSection, 0, len(sections))
|
||||
for _, sec := range sections {
|
||||
@@ -123,7 +130,7 @@ func (c *SubmissionComposer) Compose(ctx context.Context, opts ComposeOptions) (
|
||||
if strings.EqualFold(opts.Lang, "en") {
|
||||
md = sec.ContentMDEN
|
||||
}
|
||||
rendered[sec.SectionKey] = RenderMarkdownToOOXML(md, style)
|
||||
rendered[sec.SectionKey] = RenderMarkdownToOOXMLWithStyles(md, stylemap, linkAlloc.Alloc)
|
||||
keptSections = append(keptSections, sec)
|
||||
}
|
||||
// Stable order — already sorted ascending by ListForDraft, but
|
||||
@@ -135,6 +142,19 @@ func (c *SubmissionComposer) Compose(ctx context.Context, opts ComposeOptions) (
|
||||
|
||||
assembledBody := spliceSections(documentXML, rendered, keptSections, sections)
|
||||
|
||||
// Slice D hyperlink patch: when the walker emitted hyperlink rIds
|
||||
// for inline `[label](url)` links, the base's
|
||||
// word/_rels/document.xml.rels needs matching <Relationship>
|
||||
// entries so Word can resolve the rIds. Mutates one zip part in
|
||||
// otherParts (or appends if missing).
|
||||
if linkAlloc.HasLinks() {
|
||||
updatedParts, err := patchDocumentXMLRels(otherParts, linkAlloc.Pairs())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
otherParts = updatedParts
|
||||
}
|
||||
|
||||
// Re-pack into a zip with the assembled document.xml. All other
|
||||
// parts (styles, fonts, headers, footers, theme, settings) pass
|
||||
// through bit-for-bit at their original mtime + compression.
|
||||
@@ -467,3 +487,121 @@ func readZipEntry(f *zip.File) ([]byte, error) {
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Slice D — hyperlink wiring
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// composerLinkAllocator hands out fresh rIds for inline hyperlink
|
||||
// targets discovered by the MD walker. Each unique URL gets one rId
|
||||
// (deduped — repeated links to the same URL share one Relationship).
|
||||
// Allocations land outside the base's rId namespace by prefixing with
|
||||
// "rIdComposer" so they can't collide with existing relationships.
|
||||
type composerLinkAllocator struct {
|
||||
next int
|
||||
byURL map[string]string
|
||||
order []string // URLs in allocation order
|
||||
}
|
||||
|
||||
func newComposerLinkAllocator() *composerLinkAllocator {
|
||||
return &composerLinkAllocator{byURL: map[string]string{}}
|
||||
}
|
||||
|
||||
// Alloc returns the rId for url, allocating one on first sight.
|
||||
func (a *composerLinkAllocator) Alloc(url string) string {
|
||||
if rid, ok := a.byURL[url]; ok {
|
||||
return rid
|
||||
}
|
||||
a.next++
|
||||
rid := fmt.Sprintf("rIdComposer%d", a.next)
|
||||
a.byURL[url] = rid
|
||||
a.order = append(a.order, url)
|
||||
return rid
|
||||
}
|
||||
|
||||
// HasLinks reports whether any links were allocated during this compose.
|
||||
func (a *composerLinkAllocator) HasLinks() bool {
|
||||
return len(a.order) > 0
|
||||
}
|
||||
|
||||
// Pairs returns the (rId, URL) pairs in allocation order. The
|
||||
// document.xml.rels patcher consumes this to emit <Relationship>
|
||||
// elements.
|
||||
func (a *composerLinkAllocator) Pairs() [][2]string {
|
||||
pairs := make([][2]string, 0, len(a.order))
|
||||
for _, url := range a.order {
|
||||
pairs = append(pairs, [2]string{a.byURL[url], url})
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// patchDocumentXMLRels mutates the word/_rels/document.xml.rels entry
|
||||
// in `parts` to append the given (rId, URL) pairs as hyperlink
|
||||
// relationships. If the rels part doesn't exist (some bases omit it
|
||||
// when the body has no relationships), this function appends a fresh
|
||||
// part with the minimal Relationships wrapper.
|
||||
//
|
||||
// Idempotent on (rId, URL) pairs already present (e.g. when a base
|
||||
// already references the URL for some other reason).
|
||||
//
|
||||
// Returns the (possibly extended) parts slice — callers must overwrite
|
||||
// their reference because the append in the no-rels-yet case grows the
|
||||
// backing array.
|
||||
func patchDocumentXMLRels(parts []baseZipPart, pairs [][2]string) ([]baseZipPart, error) {
|
||||
const path = "word/_rels/document.xml.rels"
|
||||
const hyperlinkType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
|
||||
|
||||
existingIdx := -1
|
||||
for i := range parts {
|
||||
if parts[i].name == path {
|
||||
existingIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var body string
|
||||
if existingIdx >= 0 {
|
||||
body = string(parts[existingIdx].body)
|
||||
} else {
|
||||
body = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
||||
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
|
||||
}
|
||||
|
||||
var inserts strings.Builder
|
||||
for _, p := range pairs {
|
||||
rid := p[0]
|
||||
url := p[1]
|
||||
if strings.Contains(body, `Id="`+rid+`"`) {
|
||||
continue
|
||||
}
|
||||
inserts.WriteString(`<Relationship Id="`)
|
||||
inserts.WriteString(xmlAttrEscape(rid))
|
||||
inserts.WriteString(`" Type="`)
|
||||
inserts.WriteString(hyperlinkType)
|
||||
inserts.WriteString(`" Target="`)
|
||||
inserts.WriteString(xmlAttrEscape(url))
|
||||
inserts.WriteString(`" TargetMode="External"/>`)
|
||||
}
|
||||
|
||||
if inserts.Len() == 0 {
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
closeIdx := strings.LastIndex(body, "</Relationships>")
|
||||
if closeIdx < 0 {
|
||||
return parts, fmt.Errorf("submission compose: malformed document.xml.rels (no closing tag)")
|
||||
}
|
||||
patched := body[:closeIdx] + inserts.String() + body[closeIdx:]
|
||||
|
||||
if existingIdx >= 0 {
|
||||
parts[existingIdx].body = []byte(patched)
|
||||
return parts, nil
|
||||
}
|
||||
parts = append(parts, baseZipPart{
|
||||
name: path,
|
||||
method: zip.Deflate,
|
||||
modTime: time.Now().Unix(),
|
||||
body: []byte(patched),
|
||||
})
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
@@ -58,27 +58,32 @@ func minimalBaseBytes(t *testing.T, body string) []byte {
|
||||
// extractDocumentXML pulls word/document.xml out of a .docx zip for
|
||||
// assertions.
|
||||
func extractDocumentXML(t *testing.T, data []byte) string {
|
||||
return extractZipEntry(t, data, "word/document.xml")
|
||||
}
|
||||
|
||||
// extractZipEntry pulls any named entry out of a .docx zip.
|
||||
func extractZipEntry(t *testing.T, data []byte, name string) string {
|
||||
t.Helper()
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
t.Fatalf("open zip: %v", err)
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if f.Name != "word/document.xml" {
|
||||
if f.Name != name {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open document.xml: %v", err)
|
||||
t.Fatalf("open %s: %v", name, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
var buf bytes.Buffer
|
||||
if _, err := buf.ReadFrom(rc); err != nil {
|
||||
t.Fatalf("read document.xml: %v", err)
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
t.Fatal("document.xml not found in zip")
|
||||
t.Fatalf("%s not found in zip", name)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -249,6 +254,203 @@ func TestComposer_LangPicksColumn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Slice D — rich-prose end-to-end through the composer.
|
||||
|
||||
func TestComposer_HeadingsAndLists(t *testing.T) {
|
||||
base := composerBase()
|
||||
// Extend the stylemap so the walker has named styles to apply.
|
||||
base.SectionSpec.Stylemap["heading_1"] = "Heading1"
|
||||
base.SectionSpec.Stylemap["list_bullet"] = "ListBullet"
|
||||
base.SectionSpec.Stylemap["list_numbered"] = "ListNumber"
|
||||
base.SectionSpec.Stylemap["blockquote"] = "Quote"
|
||||
|
||||
body := `<w:p><w:r><w:t>{{#section:body}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:body}}</w:t></w:r></w:p>`
|
||||
baseBytes := minimalBaseBytes(t, body)
|
||||
composer := NewSubmissionComposer(NewSubmissionRenderer())
|
||||
|
||||
md := "# Heading line\n\n- bullet a\n- bullet b\n\n1. first\n2. second\n\n> quoted"
|
||||
sections := []SubmissionSection{
|
||||
{ID: uuid.New(), SectionKey: "body", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: md},
|
||||
}
|
||||
out, err := composer.Compose(context.Background(), ComposeOptions{
|
||||
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compose: %v", err)
|
||||
}
|
||||
docXML := extractDocumentXML(t, out)
|
||||
|
||||
for _, want := range []string{
|
||||
`<w:pStyle w:val="Heading1"/>`,
|
||||
`<w:pStyle w:val="ListBullet"/>`,
|
||||
`<w:pStyle w:val="ListNumber"/>`,
|
||||
`<w:pStyle w:val="Quote"/>`,
|
||||
"Heading line",
|
||||
"bullet a",
|
||||
"bullet b",
|
||||
`<w:t xml:space="preserve">1. </w:t>`,
|
||||
`<w:t xml:space="preserve">2. </w:t>`,
|
||||
"first",
|
||||
"second",
|
||||
"quoted",
|
||||
} {
|
||||
if !strings.Contains(docXML, want) {
|
||||
t.Errorf("expected %q in composed body; got: %s", want, docXML)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposer_HyperlinkWiresRels(t *testing.T) {
|
||||
base := composerBase()
|
||||
body := `<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>`
|
||||
baseBytes := minimalBaseBytes(t, body)
|
||||
composer := NewSubmissionComposer(NewSubmissionRenderer())
|
||||
|
||||
sections := []SubmissionSection{
|
||||
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true,
|
||||
ContentMDDE: "See [BGH](https://bgh.bund.de) and [EuGH](https://curia.europa.eu)."},
|
||||
}
|
||||
out, err := composer.Compose(context.Background(), ComposeOptions{
|
||||
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compose: %v", err)
|
||||
}
|
||||
|
||||
// Body: hyperlink elements with composer rIds.
|
||||
docXML := extractDocumentXML(t, out)
|
||||
if !strings.Contains(docXML, `<w:hyperlink r:id="rIdComposer1">`) ||
|
||||
!strings.Contains(docXML, `<w:hyperlink r:id="rIdComposer2">`) {
|
||||
t.Errorf("hyperlink rIds missing in body: %q", docXML)
|
||||
}
|
||||
if !strings.Contains(docXML, "BGH") || !strings.Contains(docXML, "EuGH") {
|
||||
t.Errorf("hyperlink labels missing: %q", docXML)
|
||||
}
|
||||
|
||||
// Rels: the matching <Relationship> rows must be in
|
||||
// word/_rels/document.xml.rels with the URL targets + External mode.
|
||||
rels := extractZipEntry(t, out, "word/_rels/document.xml.rels")
|
||||
for _, want := range []string{
|
||||
`Id="rIdComposer1"`,
|
||||
`Id="rIdComposer2"`,
|
||||
`Target="https://bgh.bund.de"`,
|
||||
`Target="https://curia.europa.eu"`,
|
||||
`TargetMode="External"`,
|
||||
"hyperlink", // the Type URL contains "hyperlink"
|
||||
} {
|
||||
if !strings.Contains(rels, want) {
|
||||
t.Errorf("expected %q in document.xml.rels: %s", want, rels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposer_HyperlinkDedupesByURL(t *testing.T) {
|
||||
base := composerBase()
|
||||
body := `<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>`
|
||||
baseBytes := minimalBaseBytes(t, body)
|
||||
composer := NewSubmissionComposer(NewSubmissionRenderer())
|
||||
|
||||
// Same URL referenced twice — should produce one rId, two
|
||||
// <w:hyperlink> elements both pointing at it.
|
||||
sections := []SubmissionSection{
|
||||
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true,
|
||||
ContentMDDE: "First [BGH](https://bgh.bund.de) and again [Bundesgerichtshof](https://bgh.bund.de)."},
|
||||
}
|
||||
out, _ := composer.Compose(context.Background(), ComposeOptions{
|
||||
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
|
||||
})
|
||||
docXML := extractDocumentXML(t, out)
|
||||
if strings.Count(docXML, `<w:hyperlink r:id="rIdComposer1">`) != 2 {
|
||||
t.Errorf("expected 2 hyperlinks sharing rIdComposer1; got: %s", docXML)
|
||||
}
|
||||
if strings.Contains(docXML, `<w:hyperlink r:id="rIdComposer2">`) {
|
||||
t.Errorf("dedupe failed — second rId allocated for same URL: %s", docXML)
|
||||
}
|
||||
}
|
||||
|
||||
// Slice E — base swap preserves section content; only chrome / styles
|
||||
// change. This is the design's "Markdown is base-agnostic" contract
|
||||
// from Q10 + §5.3 ratification. We compose the SAME section text
|
||||
// against two bases with DIFFERENT stylemaps and verify:
|
||||
// 1. The section text appears in both outputs.
|
||||
// 2. Each base applies its OWN paragraph style (the stylemap diff
|
||||
// is the only visible delta in the document body).
|
||||
|
||||
func TestComposer_BaseSwapPreservesContent(t *testing.T) {
|
||||
body := `<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>`
|
||||
baseBytes := minimalBaseBytes(t, body)
|
||||
|
||||
// Base A: HLC-style stylemap.
|
||||
hlc := &SubmissionBase{
|
||||
ID: uuid.New(), Slug: "hlc-test",
|
||||
SectionSpec: BaseSectionSpec{
|
||||
Stylemap: map[string]string{
|
||||
"paragraph": "HLpat-Body-B0",
|
||||
"heading_1": "HLpat-Heading-H1",
|
||||
},
|
||||
},
|
||||
}
|
||||
// Base B: LG-style stylemap.
|
||||
lg := &SubmissionBase{
|
||||
ID: uuid.New(), Slug: "lg-test",
|
||||
SectionSpec: BaseSectionSpec{
|
||||
Stylemap: map[string]string{
|
||||
"paragraph": "LG-Body",
|
||||
"heading_1": "LG-Heading1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Identical Markdown content rendered against each base.
|
||||
md := "# Heading line\n\nA paragraph of substantive prose."
|
||||
sections := []SubmissionSection{
|
||||
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: md},
|
||||
}
|
||||
|
||||
composer := NewSubmissionComposer(NewSubmissionRenderer())
|
||||
hlcOut, err := composer.Compose(context.Background(), ComposeOptions{
|
||||
Sections: sections, Base: hlc, BaseBytes: baseBytes, Lang: "de",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compose hlc: %v", err)
|
||||
}
|
||||
lgOut, err := composer.Compose(context.Background(), ComposeOptions{
|
||||
Sections: sections, Base: lg, BaseBytes: baseBytes, Lang: "de",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compose lg: %v", err)
|
||||
}
|
||||
|
||||
hlcXML := extractDocumentXML(t, hlcOut)
|
||||
lgXML := extractDocumentXML(t, lgOut)
|
||||
|
||||
// Content survives both ways.
|
||||
for _, want := range []string{"Heading line", "A paragraph of substantive prose."} {
|
||||
if !strings.Contains(hlcXML, want) {
|
||||
t.Errorf("HLC output missing content %q", want)
|
||||
}
|
||||
if !strings.Contains(lgXML, want) {
|
||||
t.Errorf("LG output missing content %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// Stylemap diff actually shows up in the body — HLC's headings
|
||||
// use HLpat-Heading-H1, LG's use LG-Heading1. If the composer
|
||||
// silently passed the wrong stylemap, this would fire.
|
||||
if !strings.Contains(hlcXML, `<w:pStyle w:val="HLpat-Heading-H1"/>`) {
|
||||
t.Errorf("HLC heading style missing: %s", hlcXML)
|
||||
}
|
||||
if !strings.Contains(lgXML, `<w:pStyle w:val="LG-Heading1"/>`) {
|
||||
t.Errorf("LG heading style missing: %s", lgXML)
|
||||
}
|
||||
if strings.Contains(hlcXML, `<w:pStyle w:val="LG-Heading1"/>`) {
|
||||
t.Errorf("HLC output leaked LG style: %s", hlcXML)
|
||||
}
|
||||
if strings.Contains(lgXML, `<w:pStyle w:val="HLpat-Heading-H1"/>`) {
|
||||
t.Errorf("LG output leaked HLC style: %s", lgXML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposer_OrderIndexAscending(t *testing.T) {
|
||||
base := composerBase()
|
||||
// No anchors → both sections append in order_index ASC order
|
||||
|
||||
@@ -27,79 +27,223 @@ package services
|
||||
// - Otherwise → plain text run
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HyperlinkAllocator hands the walker a `rId` for each external URL
|
||||
// it encounters in `[label](url)` inline links. The composer's
|
||||
// post-pass uses these allocations to mutate
|
||||
// `word/_rels/document.xml.rels` so the emitted `<w:hyperlink
|
||||
// r:id="…">` elements resolve correctly. Pass nil to drop links to
|
||||
// plain text (the label survives, the URL doesn't render).
|
||||
//
|
||||
// t-paliad-316 Slice D.
|
||||
type HyperlinkAllocator func(url string) string
|
||||
|
||||
// RenderMarkdownToOOXML renders the given Markdown source into OOXML
|
||||
// paragraph elements (`<w:p>…</w:p>`), suitable for splicing into a
|
||||
// .docx body. Each paragraph carries `<w:pStyle w:val="<paragraphStyle>"/>`
|
||||
// when paragraphStyle is non-empty.
|
||||
//
|
||||
// Slice B shipped paragraphs + bold/italic. Slice D extends to
|
||||
// headings (h1/h2/h3), bullet/numbered lists, blockquote, and inline
|
||||
// hyperlinks via the optional HyperlinkAllocator.
|
||||
//
|
||||
// stylemap supplies the paragraph-style names for each kind:
|
||||
// stylemap["paragraph"] — default body
|
||||
// stylemap["heading_1/2/3"] — heading levels
|
||||
// stylemap["list_bullet"] — bullet list paragraph style
|
||||
// stylemap["list_numbered"] — numbered list paragraph style
|
||||
// stylemap["blockquote"] — blockquote
|
||||
// Missing entries fall back to the "paragraph" style.
|
||||
//
|
||||
// Empty input renders one empty paragraph so the splice site is
|
||||
// well-formed even when the lawyer hasn't typed anything in this
|
||||
// section.
|
||||
func RenderMarkdownToOOXML(md, paragraphStyle string) string {
|
||||
return RenderMarkdownToOOXMLWithStyles(md, map[string]string{"paragraph": paragraphStyle}, nil)
|
||||
}
|
||||
|
||||
// RenderMarkdownToOOXMLWithStyles is the full Slice-D-aware entry
|
||||
// point. Slice B's RenderMarkdownToOOXML is a wrapper for back-compat.
|
||||
func RenderMarkdownToOOXMLWithStyles(md string, stylemap map[string]string, links HyperlinkAllocator) string {
|
||||
defaultStyle := stylemap["paragraph"]
|
||||
if md == "" {
|
||||
return emptyParagraph(paragraphStyle)
|
||||
return emptyParagraph(defaultStyle)
|
||||
}
|
||||
paragraphs := splitMarkdownParagraphs(md)
|
||||
if len(paragraphs) == 0 {
|
||||
return emptyParagraph(paragraphStyle)
|
||||
blocks := splitMarkdownBlocks(md)
|
||||
if len(blocks) == 0 {
|
||||
return emptyParagraph(defaultStyle)
|
||||
}
|
||||
// Numbered-list counter resets on every non-numbered block so
|
||||
// "1. A\n2. B\n\n1. C" renders as 1./2./1. (the lawyer's input
|
||||
// determined the ordinal, the walker just renders).
|
||||
numberedCounter := 0
|
||||
var b strings.Builder
|
||||
for _, para := range paragraphs {
|
||||
b.WriteString(renderParagraph(para, paragraphStyle))
|
||||
for _, blk := range blocks {
|
||||
style := stylemap[blk.styleKey]
|
||||
if style == "" {
|
||||
style = defaultStyle
|
||||
}
|
||||
if blk.styleKey == "list_numbered" {
|
||||
numberedCounter++
|
||||
} else {
|
||||
numberedCounter = 0
|
||||
}
|
||||
b.WriteString(renderBlockParagraph(blk, style, links, numberedCounter))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// splitMarkdownParagraphs splits the source into paragraphs. A
|
||||
// "paragraph" is a maximal run of non-blank lines. N consecutive blank
|
||||
// lines between two paragraphs produce (N-1) empty paragraphs in the
|
||||
// output so the lawyer's intentional vertical spacing survives.
|
||||
// mdBlock is one rendered paragraph: a kind (paragraph / heading_*
|
||||
// / list_bullet / list_numbered / blockquote) and the inline content
|
||||
// text. List markers, heading hashes, blockquote `> ` etc. are
|
||||
// stripped from the text before storage.
|
||||
type mdBlock struct {
|
||||
styleKey string // "paragraph" | "heading_1" | "heading_2" | "heading_3" | "list_bullet" | "list_numbered" | "blockquote"
|
||||
text string
|
||||
}
|
||||
|
||||
// splitMarkdownBlocks parses the source into a sequence of blocks,
|
||||
// detecting heading / list / blockquote prefixes line-by-line. Blank
|
||||
// lines split paragraph runs (same semantics as splitMarkdownParagraphs)
|
||||
// but each line is also tagged with its block kind.
|
||||
//
|
||||
// CRLF line endings normalise to LF before splitting.
|
||||
func splitMarkdownParagraphs(md string) []string {
|
||||
// Lines that look like block markers don't merge with their neighbours
|
||||
// even across blank lines — every list / heading / blockquote line is
|
||||
// its own block in the output. A run of unmarked lines collapses into
|
||||
// one "paragraph" block (so soft line breaks inside a paragraph still
|
||||
// concatenate).
|
||||
//
|
||||
// CRLF normalised to LF before parsing.
|
||||
func splitMarkdownBlocks(md string) []mdBlock {
|
||||
normalised := strings.ReplaceAll(md, "\r\n", "\n")
|
||||
lines := strings.Split(normalised, "\n")
|
||||
var paragraphs []string
|
||||
var current []string
|
||||
var blocks []mdBlock
|
||||
var pendingPara []string
|
||||
blankRun := 0
|
||||
flushParagraph := func() {
|
||||
if len(current) > 0 {
|
||||
paragraphs = append(paragraphs, strings.Join(current, "\n"))
|
||||
current = nil
|
||||
|
||||
flushPara := func() {
|
||||
if len(pendingPara) > 0 {
|
||||
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: strings.Join(pendingPara, "\n")})
|
||||
pendingPara = nil
|
||||
}
|
||||
}
|
||||
for _, line := range lines {
|
||||
|
||||
for _, raw := range lines {
|
||||
line := raw
|
||||
if strings.TrimSpace(line) == "" {
|
||||
if len(current) > 0 {
|
||||
// End of a paragraph; the blank-counting starts now.
|
||||
flushParagraph()
|
||||
if len(pendingPara) > 0 {
|
||||
flushPara()
|
||||
blankRun = 1
|
||||
continue
|
||||
}
|
||||
// Already inside a blank run (or before the first paragraph).
|
||||
blankRun++
|
||||
continue
|
||||
}
|
||||
// Starting a new paragraph — emit (blankRun-1) empty paragraphs
|
||||
// in between if the lawyer used multiple blank lines as
|
||||
// vertical spacing.
|
||||
for i := 1; i < blankRun; i++ {
|
||||
paragraphs = append(paragraphs, "")
|
||||
// Detect heading / list / blockquote markers BEFORE we accumulate
|
||||
// into the paragraph buffer.
|
||||
kind, payload, ok := detectBlockMarker(line)
|
||||
if ok {
|
||||
flushPara()
|
||||
// Emit spacing paragraphs equivalent to (blankRun - 1) extra.
|
||||
for i := 1; i < blankRun; i++ {
|
||||
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: ""})
|
||||
}
|
||||
blankRun = 0
|
||||
blocks = append(blocks, mdBlock{styleKey: kind, text: payload})
|
||||
continue
|
||||
}
|
||||
// Plain paragraph line.
|
||||
if len(pendingPara) == 0 {
|
||||
// Starting a new paragraph after a blank run — emit
|
||||
// (blankRun-1) extra empty paragraphs for vertical spacing.
|
||||
for i := 1; i < blankRun; i++ {
|
||||
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: ""})
|
||||
}
|
||||
}
|
||||
blankRun = 0
|
||||
current = append(current, line)
|
||||
pendingPara = append(pendingPara, line)
|
||||
}
|
||||
flushParagraph()
|
||||
return paragraphs
|
||||
flushPara()
|
||||
return blocks
|
||||
}
|
||||
|
||||
// renderParagraph emits one `<w:p>` element for the given paragraph
|
||||
// text. Inline bold/italic spans become `<w:r>` runs with the
|
||||
// corresponding `<w:rPr>`.
|
||||
func renderParagraph(text, paragraphStyle string) string {
|
||||
// detectBlockMarker classifies a single line. Returns (styleKey,
|
||||
// payload-with-marker-stripped, true) for recognised markers; false
|
||||
// for plain paragraph lines.
|
||||
//
|
||||
// Recognised markers (Slice D):
|
||||
// # Heading → heading_1
|
||||
// ## Heading → heading_2
|
||||
// ### Heading → heading_3
|
||||
// - item / * item → list_bullet
|
||||
// 1. item / 2. item ... → list_numbered (any positive integer)
|
||||
// > quote → blockquote
|
||||
//
|
||||
// Leading whitespace inside the line is tolerated up to 3 spaces (per
|
||||
// CommonMark) so the lawyer's contentEditable indentation doesn't
|
||||
// hide the marker.
|
||||
func detectBlockMarker(line string) (string, string, bool) {
|
||||
trimmed := strings.TrimLeft(line, " ")
|
||||
// Cap to 3 spaces of leading indent — beyond that, treat as a
|
||||
// regular paragraph line (matches CommonMark).
|
||||
if len(line)-len(trimmed) > 3 {
|
||||
return "", "", false
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "### ") {
|
||||
return "heading_3", strings.TrimSpace(trimmed[4:]), true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "## ") {
|
||||
return "heading_2", strings.TrimSpace(trimmed[3:]), true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "# ") {
|
||||
return "heading_1", strings.TrimSpace(trimmed[2:]), true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "> ") {
|
||||
return "blockquote", strings.TrimSpace(trimmed[2:]), true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") {
|
||||
return "list_bullet", strings.TrimSpace(trimmed[2:]), true
|
||||
}
|
||||
// Numbered: "N. " where N is one or more digits.
|
||||
if i := indexOfNumberedMarker(trimmed); i > 0 {
|
||||
return "list_numbered", strings.TrimSpace(trimmed[i:]), true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// indexOfNumberedMarker checks for "N. " or "N) " at the start of the
|
||||
// trimmed line; returns the byte index just past the marker, or -1 if
|
||||
// no marker present.
|
||||
func indexOfNumberedMarker(s string) int {
|
||||
i := 0
|
||||
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
if i == 0 {
|
||||
return -1
|
||||
}
|
||||
if i >= len(s) {
|
||||
return -1
|
||||
}
|
||||
if s[i] != '.' && s[i] != ')' {
|
||||
return -1
|
||||
}
|
||||
if i+1 >= len(s) || s[i+1] != ' ' {
|
||||
return -1
|
||||
}
|
||||
return i + 2
|
||||
}
|
||||
|
||||
// renderBlockParagraph emits one `<w:p>` for a block. List blocks
|
||||
// keep the same paragraph style as a default paragraph (the Slice D
|
||||
// design's contract — list styles come from the base's stylemap and
|
||||
// Word's numbering.xml is honoured by adding a leading bullet/number
|
||||
// prefix in the rendered text). This keeps the composer free of
|
||||
// numbering.xml mutations.
|
||||
func renderBlockParagraph(blk mdBlock, paragraphStyle string, links HyperlinkAllocator, numberedOrdinal int) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<w:p>`)
|
||||
if paragraphStyle != "" {
|
||||
@@ -107,21 +251,124 @@ func renderParagraph(text, paragraphStyle string) string {
|
||||
b.WriteString(xmlAttrEscape(paragraphStyle))
|
||||
b.WriteString(`"/></w:pPr>`)
|
||||
}
|
||||
if text == "" {
|
||||
// Empty paragraph — emit a single empty run so Word renders the
|
||||
// paragraph as a blank line. Without the run, some Word
|
||||
// versions collapse the paragraph entirely.
|
||||
if blk.text == "" {
|
||||
b.WriteString(`<w:r><w:t xml:space="preserve"></w:t></w:r>`)
|
||||
b.WriteString(`</w:p>`)
|
||||
return b.String()
|
||||
}
|
||||
for _, span := range parseInlineSpans(text) {
|
||||
b.WriteString(renderRun(span))
|
||||
text := blk.text
|
||||
// List blocks emit a visible "• " / "N. " prefix run. The
|
||||
// stylemap entry handles paragraph indentation if the base
|
||||
// defines a list paragraph style; otherwise the prefix at least
|
||||
// surfaces the structure in plain Word. Lawyers who want Word's
|
||||
// auto-numbering reapply a list style post-export.
|
||||
switch blk.styleKey {
|
||||
case "list_bullet":
|
||||
b.WriteString(`<w:r><w:t xml:space="preserve">• </w:t></w:r>`)
|
||||
case "list_numbered":
|
||||
ordinal := numberedOrdinal
|
||||
if ordinal <= 0 {
|
||||
ordinal = 1
|
||||
}
|
||||
b.WriteString(`<w:r><w:t xml:space="preserve">`)
|
||||
b.WriteString(fmt.Sprintf("%d. ", ordinal))
|
||||
b.WriteString(`</w:t></w:r>`)
|
||||
}
|
||||
for _, run := range parseInlineRuns(text, links) {
|
||||
b.WriteString(run)
|
||||
}
|
||||
b.WriteString(`</w:p>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// parseInlineRuns extracts inline spans + hyperlink runs and serialises
|
||||
// each to OOXML. Hyperlinks become `<w:hyperlink r:id="RID">…runs…</w:hyperlink>`
|
||||
// where RID comes from the HyperlinkAllocator.
|
||||
func parseInlineRuns(text string, links HyperlinkAllocator) []string {
|
||||
// Phase 1: find all hyperlink spans `[label](url)` and split the
|
||||
// text around them.
|
||||
type segment struct {
|
||||
text string
|
||||
isLink bool
|
||||
url string
|
||||
}
|
||||
var segs []segment
|
||||
rest := text
|
||||
for {
|
||||
idx := strings.Index(rest, "[")
|
||||
if idx < 0 {
|
||||
if rest != "" {
|
||||
segs = append(segs, segment{text: rest})
|
||||
}
|
||||
break
|
||||
}
|
||||
// Find matching closing bracket, then a "(" right after.
|
||||
closeBracket := strings.Index(rest[idx:], "](")
|
||||
if closeBracket < 0 {
|
||||
segs = append(segs, segment{text: rest})
|
||||
break
|
||||
}
|
||||
closeParen := strings.Index(rest[idx+closeBracket:], ")")
|
||||
if closeParen < 0 {
|
||||
segs = append(segs, segment{text: rest})
|
||||
break
|
||||
}
|
||||
// idx = start of "["
|
||||
// idx+closeBracket = position of "]"
|
||||
// idx+closeBracket+1 = position of "("
|
||||
// idx+closeBracket+closeParen = position of ")"
|
||||
label := rest[idx+1 : idx+closeBracket]
|
||||
url := rest[idx+closeBracket+2 : idx+closeBracket+closeParen]
|
||||
if idx > 0 {
|
||||
segs = append(segs, segment{text: rest[:idx]})
|
||||
}
|
||||
segs = append(segs, segment{text: label, isLink: true, url: url})
|
||||
rest = rest[idx+closeBracket+closeParen+1:]
|
||||
}
|
||||
|
||||
var runs []string
|
||||
for _, seg := range segs {
|
||||
if seg.isLink && links != nil {
|
||||
rid := links(seg.url)
|
||||
if rid != "" {
|
||||
var hb strings.Builder
|
||||
hb.WriteString(`<w:hyperlink r:id="`)
|
||||
hb.WriteString(xmlAttrEscape(rid))
|
||||
hb.WriteString(`">`)
|
||||
for _, span := range parseInlineSpans(seg.text) {
|
||||
hb.WriteString(renderRunWithLinkStyle(span))
|
||||
}
|
||||
hb.WriteString(`</w:hyperlink>`)
|
||||
runs = append(runs, hb.String())
|
||||
continue
|
||||
}
|
||||
}
|
||||
for _, span := range parseInlineSpans(seg.text) {
|
||||
runs = append(runs, renderRun(span))
|
||||
}
|
||||
}
|
||||
return runs
|
||||
}
|
||||
|
||||
// renderRunWithLinkStyle emits a hyperlink child run. Same B/I support
|
||||
// as renderRun, but additionally tags the run with the "Hyperlink"
|
||||
// character style (Word's built-in) so the link renders in the
|
||||
// document's hyperlink colour + underline.
|
||||
func renderRunWithLinkStyle(span inlineSpan) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<w:r><w:rPr><w:rStyle w:val="Hyperlink"/>`)
|
||||
if span.Bold {
|
||||
b.WriteString(`<w:b/>`)
|
||||
}
|
||||
if span.Italic {
|
||||
b.WriteString(`<w:i/>`)
|
||||
}
|
||||
b.WriteString(`</w:rPr><w:t xml:space="preserve">`)
|
||||
b.WriteString(xmlTextEscape(span.Text))
|
||||
b.WriteString(`</w:t></w:r>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// inlineSpan is one piece of inline content: a text payload plus
|
||||
// formatting flags. Bold and italic are independent — `***both***`
|
||||
// produces one span with both flags set.
|
||||
|
||||
@@ -144,3 +144,156 @@ func TestParseInlineSpans_UnderscoreBold(t *testing.T) {
|
||||
t.Errorf("expected one bold 'strong' span; got %+v", spans)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Slice D — rich-prose constructs
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func slicedStylemap() map[string]string {
|
||||
return map[string]string{
|
||||
"paragraph": "Body",
|
||||
"heading_1": "H1",
|
||||
"heading_2": "H2",
|
||||
"heading_3": "H3",
|
||||
"list_bullet": "ListBullet",
|
||||
"list_numbered": "ListNumber",
|
||||
"blockquote": "Quote",
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_Heading1(t *testing.T) {
|
||||
out := RenderMarkdownToOOXMLWithStyles("# A heading", slicedStylemap(), nil)
|
||||
if !strings.Contains(out, `<w:pStyle w:val="H1"/>`) {
|
||||
t.Errorf("heading_1 missing H1 style: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "A heading") {
|
||||
t.Errorf("heading text missing: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_Heading2And3(t *testing.T) {
|
||||
out := RenderMarkdownToOOXMLWithStyles("## H2 line\n### H3 line", slicedStylemap(), nil)
|
||||
if !strings.Contains(out, `<w:pStyle w:val="H2"/>`) || !strings.Contains(out, "H2 line") {
|
||||
t.Errorf("h2 not rendered: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, `<w:pStyle w:val="H3"/>`) || !strings.Contains(out, "H3 line") {
|
||||
t.Errorf("h3 not rendered: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_BulletList(t *testing.T) {
|
||||
out := RenderMarkdownToOOXMLWithStyles("- first\n- second\n* third", slicedStylemap(), nil)
|
||||
if !strings.Contains(out, `<w:pStyle w:val="ListBullet"/>`) {
|
||||
t.Errorf("bullet stylemap not applied: %q", out)
|
||||
}
|
||||
if strings.Count(out, "• ") != 3 {
|
||||
t.Errorf("expected 3 bullet prefixes; got %d in %q", strings.Count(out, "• "), out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_NumberedList(t *testing.T) {
|
||||
out := RenderMarkdownToOOXMLWithStyles("1. first\n2. second\n3. third", slicedStylemap(), nil)
|
||||
if !strings.Contains(out, `<w:pStyle w:val="ListNumber"/>`) {
|
||||
t.Errorf("numbered stylemap not applied: %q", out)
|
||||
}
|
||||
for _, want := range []string{"1. ", "2. ", "3. "} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing ordinal prefix %q in %q", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_NumberedListResetsOnNonList(t *testing.T) {
|
||||
// "1. A\n2. B\nplain\n1. C" → 1. A, 2. B, plain para, 1. C
|
||||
out := RenderMarkdownToOOXMLWithStyles("1. A\n2. B\nplain\n1. C", slicedStylemap(), nil)
|
||||
// The plain "plain" line breaks the list, so the next numbered
|
||||
// item restarts at 1.
|
||||
idxA := strings.Index(out, "1. ")
|
||||
if idxA < 0 {
|
||||
t.Fatalf("first 1. missing: %q", out)
|
||||
}
|
||||
idxB := strings.Index(out, "2. ")
|
||||
if idxB < 0 || idxB <= idxA {
|
||||
t.Fatalf("2. not after 1.: idxA=%d idxB=%d", idxA, idxB)
|
||||
}
|
||||
rest := out[idxB+1:]
|
||||
idxC := strings.Index(rest, "1. ")
|
||||
if idxC < 0 {
|
||||
t.Errorf("numbered counter didn't reset on non-list block: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_Blockquote(t *testing.T) {
|
||||
out := RenderMarkdownToOOXMLWithStyles("> the quoted text", slicedStylemap(), nil)
|
||||
if !strings.Contains(out, `<w:pStyle w:val="Quote"/>`) {
|
||||
t.Errorf("blockquote stylemap not applied: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "the quoted text") {
|
||||
t.Errorf("blockquote text missing: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_Hyperlink(t *testing.T) {
|
||||
allocated := map[string]string{}
|
||||
alloc := func(url string) string {
|
||||
rid := "rIdComposer" + url
|
||||
allocated[url] = rid
|
||||
return rid
|
||||
}
|
||||
out := RenderMarkdownToOOXMLWithStyles("See [Bundesgerichtshof](https://bgh.bund.de) for details.", slicedStylemap(), alloc)
|
||||
if _, ok := allocated["https://bgh.bund.de"]; !ok {
|
||||
t.Errorf("allocator never called for URL: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, `<w:hyperlink r:id="rIdComposerhttps://bgh.bund.de">`) {
|
||||
t.Errorf("hyperlink tag missing or wrong rid: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "Bundesgerichtshof") {
|
||||
t.Errorf("link label missing: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, `<w:rStyle w:val="Hyperlink"/>`) {
|
||||
t.Errorf("hyperlink character style missing: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_HyperlinkNilAllocatorFallsBackToPlain(t *testing.T) {
|
||||
out := RenderMarkdownToOOXMLWithStyles("See [BGH](https://bgh.bund.de) here.", slicedStylemap(), nil)
|
||||
// Without an allocator, the label still renders as plain text.
|
||||
if !strings.Contains(out, "BGH") {
|
||||
t.Errorf("label dropped: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "<w:hyperlink") {
|
||||
t.Errorf("hyperlink emitted without allocator: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectBlockMarker(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
kind string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"# A", "heading_1", "A", true},
|
||||
{"## B", "heading_2", "B", true},
|
||||
{"### C", "heading_3", "C", true},
|
||||
{" # indented", "heading_1", "indented", true}, // up to 3 spaces tolerated
|
||||
{" # too-deep", "", "", false}, // 4 spaces → not a heading
|
||||
{"- bullet", "list_bullet", "bullet", true},
|
||||
{"* star", "list_bullet", "star", true},
|
||||
{"1. one", "list_numbered", "one", true},
|
||||
{"42. forty-two", "list_numbered", "forty-two", true},
|
||||
{"1) paren", "list_numbered", "paren", true},
|
||||
{"1.no-space", "", "", false}, // ordinal needs trailing space
|
||||
{"> quote", "blockquote", "quote", true},
|
||||
{"plain", "", "", false},
|
||||
{"#nospace", "", "", false}, // heading needs space after hash
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
kind, payload, ok := detectBlockMarker(tc.in)
|
||||
if ok != tc.ok || kind != tc.kind || payload != tc.want {
|
||||
t.Errorf("detectBlockMarker(%q) = (%q,%q,%v); want (%q,%q,%v)", tc.in, kind, payload, ok, tc.kind, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,130 @@ func (s *SectionService) Update(ctx context.Context, sectionID uuid.UUID, patch
|
||||
return &sec, nil
|
||||
}
|
||||
|
||||
// SectionCreateInput is the payload for adding a new (lawyer-custom)
|
||||
// section to a draft (t-paliad-318 Slice F).
|
||||
type SectionCreateInput struct {
|
||||
DraftID uuid.UUID
|
||||
SectionKey string
|
||||
Kind string
|
||||
LabelDE string
|
||||
LabelEN string
|
||||
ContentMDDE string
|
||||
ContentMDEN string
|
||||
OrderIndex int // 0 = append at end
|
||||
Included bool // defaults to true if not specified at the handler
|
||||
}
|
||||
|
||||
// Create inserts a new section row for the draft. The section_key
|
||||
// must not already exist on this draft (UNIQUE constraint at the DB
|
||||
// catches collisions and surfaces as ErrInvalidInput).
|
||||
//
|
||||
// OrderIndex=0 means "auto-assign at the end" — the service queries
|
||||
// the current max(order_index) and increments. Non-zero values insert
|
||||
// at the requested position; the caller is responsible for any
|
||||
// subsequent Reorder if they intend to push existing rows down.
|
||||
func (s *SectionService) Create(ctx context.Context, in SectionCreateInput) (*SubmissionSection, error) {
|
||||
in.SectionKey = strings.TrimSpace(in.SectionKey)
|
||||
in.LabelDE = strings.TrimSpace(in.LabelDE)
|
||||
in.LabelEN = strings.TrimSpace(in.LabelEN)
|
||||
if in.SectionKey == "" || in.LabelDE == "" || in.LabelEN == "" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
switch in.Kind {
|
||||
case "prose", "requests", "evidence":
|
||||
default:
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
|
||||
if in.OrderIndex == 0 {
|
||||
var maxOrder int
|
||||
err := s.db.GetContext(ctx, &maxOrder,
|
||||
`SELECT COALESCE(MAX(order_index), 0) FROM paliad.submission_sections WHERE draft_id = $1`,
|
||||
in.DraftID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("max order_index: %w", err)
|
||||
}
|
||||
in.OrderIndex = maxOrder + 1
|
||||
}
|
||||
|
||||
var sec SubmissionSection
|
||||
err := s.db.GetContext(ctx, &sec,
|
||||
`INSERT INTO paliad.submission_sections
|
||||
(draft_id, section_key, order_index, kind,
|
||||
label_de, label_en, included,
|
||||
content_md_de, content_md_en)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING `+sectionColumns,
|
||||
in.DraftID, in.SectionKey, in.OrderIndex, in.Kind,
|
||||
in.LabelDE, in.LabelEN, in.Included,
|
||||
in.ContentMDDE, in.ContentMDEN)
|
||||
if err != nil {
|
||||
// UNIQUE (draft_id, section_key) collision → invalid input.
|
||||
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "23505") {
|
||||
return nil, fmt.Errorf("%w: section_key already exists on this draft", ErrInvalidInput)
|
||||
}
|
||||
return nil, fmt.Errorf("create submission section: %w", err)
|
||||
}
|
||||
return &sec, nil
|
||||
}
|
||||
|
||||
// Delete removes one section row by id. Owner-scope is the caller's
|
||||
// responsibility (the handler runs SubmissionDraftService.Get first).
|
||||
func (s *SectionService) Delete(ctx context.Context, sectionID uuid.UUID) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM paliad.submission_sections WHERE id = $1`,
|
||||
sectionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete submission section: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrSubmissionSectionNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reorder updates the order_index of every section row for the draft
|
||||
// according to the supplied ID sequence. Transactional — partial
|
||||
// failures roll back. Any section_id present on the draft but not in
|
||||
// the sequence keeps its previous order_index, then sorts last by
|
||||
// updated_at (so a partial reorder doesn't lose rows the caller
|
||||
// forgot to mention).
|
||||
func (s *SectionService) Reorder(ctx context.Context, draftID uuid.UUID, order []uuid.UUID) ([]SubmissionSection, error) {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reorder tx: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// Each id in order gets order_index 10, 20, 30, ... (gaps so a
|
||||
// future single-row insert doesn't trigger a full reflow). Ids
|
||||
// not present on the draft are silently ignored.
|
||||
for i, sectionID := range order {
|
||||
idx := (i + 1) * 10
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.submission_sections
|
||||
SET order_index = $1
|
||||
WHERE id = $2 AND draft_id = $3`,
|
||||
idx, sectionID, draftID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reorder update: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit reorder: %w", err)
|
||||
}
|
||||
committed = true
|
||||
|
||||
return s.ListForDraft(ctx, draftID)
|
||||
}
|
||||
|
||||
// SeedFromSpec inserts one row per BaseSectionSpec.Default into
|
||||
// submission_sections for the given draft. Runs inside the caller's
|
||||
// transaction (the SubmissionDraftService.Create path wraps the
|
||||
|
||||
152
internal/services/submission_section_slice_f_test.go
Normal file
152
internal/services/submission_section_slice_f_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package services
|
||||
|
||||
// Live-DB tests for Slice F section service additions (Create + Delete
|
||||
// + Reorder). Gated on TEST_DATABASE_URL, mirroring Slice A's pattern.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
func TestSectionService_SliceF(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
bases := NewBaseService(pool)
|
||||
sections := NewSectionService(pool)
|
||||
|
||||
// Seed user + draft so we have a draft_id to attach sections to.
|
||||
userID := uuid.New()
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.submission_sections WHERE draft_id IN (SELECT id FROM paliad.submission_drafts WHERE user_id = $1)`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
email := "slice-f-" + userID.String()[:8] + "@hlc.com"
|
||||
if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, userID, email); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
|
||||
VALUES ($1, $2, 'Slice F User', 'munich', 'standard', 'de')`,
|
||||
userID, email); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
parties := NewPartyService(pool, projects)
|
||||
vars := NewSubmissionVarsService(pool, projects, parties, users)
|
||||
renderer := NewSubmissionRenderer()
|
||||
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
|
||||
drafts.AttachComposer(bases, sections, "HLC")
|
||||
|
||||
d, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("Create draft: %v", err)
|
||||
}
|
||||
initial, err := sections.ListForDraft(ctx, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListForDraft initial: %v", err)
|
||||
}
|
||||
if len(initial) != 10 {
|
||||
t.Fatalf("expected 10 seeded sections; got %d", len(initial))
|
||||
}
|
||||
|
||||
t.Run("Create custom section", func(t *testing.T) {
|
||||
created, err := sections.Create(ctx, SectionCreateInput{
|
||||
DraftID: d.ID,
|
||||
SectionKey: "berufungsantraege",
|
||||
Kind: "requests",
|
||||
LabelDE: "Berufungsanträge",
|
||||
LabelEN: "Appeal requests",
|
||||
Included: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if created.OrderIndex <= 10 {
|
||||
t.Errorf("auto-assigned order_index should be > existing max; got %d", created.OrderIndex)
|
||||
}
|
||||
// Slug collision must surface as ErrInvalidInput.
|
||||
_, err = sections.Create(ctx, SectionCreateInput{
|
||||
DraftID: d.ID, SectionKey: "berufungsantraege",
|
||||
Kind: "prose", LabelDE: "x", LabelEN: "x", Included: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Errorf("expected unique-key collision error; got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Delete section", func(t *testing.T) {
|
||||
// Grab one of the seeded rows to delete.
|
||||
current, _ := sections.ListForDraft(ctx, d.ID)
|
||||
var victimID uuid.UUID
|
||||
for _, s := range current {
|
||||
if s.SectionKey == "exhibits" {
|
||||
victimID = s.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if victimID == uuid.Nil {
|
||||
t.Fatalf("expected exhibits section to exist")
|
||||
}
|
||||
if err := sections.Delete(ctx, victimID); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
// Second delete returns not-found.
|
||||
if err := sections.Delete(ctx, victimID); err == nil {
|
||||
t.Errorf("expected ErrSubmissionSectionNotFound on second delete")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Reorder sections", func(t *testing.T) {
|
||||
current, _ := sections.ListForDraft(ctx, d.ID)
|
||||
if len(current) < 3 {
|
||||
t.Skipf("need at least 3 sections to test reorder; got %d", len(current))
|
||||
}
|
||||
// Reverse the order list.
|
||||
ids := make([]uuid.UUID, 0, len(current))
|
||||
for i := len(current) - 1; i >= 0; i-- {
|
||||
ids = append(ids, current[i].ID)
|
||||
}
|
||||
reordered, err := sections.Reorder(ctx, d.ID, ids)
|
||||
if err != nil {
|
||||
t.Fatalf("Reorder: %v", err)
|
||||
}
|
||||
// Verify the first ID in our list now has the lowest order_index.
|
||||
if reordered[0].ID != ids[0] {
|
||||
t.Errorf("first ID after reorder = %s; want %s", reordered[0].ID, ids[0])
|
||||
}
|
||||
// Order indices should be ascending.
|
||||
prev := 0
|
||||
for _, s := range reordered {
|
||||
if s.OrderIndex <= prev {
|
||||
t.Errorf("non-ascending order_index after reorder: %d (prev=%d) at %s", s.OrderIndex, prev, s.SectionKey)
|
||||
}
|
||||
prev = s.OrderIndex
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -289,12 +289,12 @@ func (s *SubmissionVarsService) nextOpenDeadline(ctx context.Context, projectID,
|
||||
var d models.Deadline
|
||||
err := s.db.GetContext(ctx, &d,
|
||||
`SELECT id, project_id, title, description, due_date, original_due_date,
|
||||
warning_date, source, rule_id, rule_code, status, completed_at,
|
||||
warning_date, source, sequencing_rule_id, rule_code, status, completed_at,
|
||||
caldav_uid, caldav_etag, notes, created_by, created_at, updated_at,
|
||||
approval_status, pending_request_id, approved_by, approved_at
|
||||
FROM paliad.deadlines
|
||||
WHERE project_id = $1
|
||||
AND rule_id = $2
|
||||
AND sequencing_rule_id = $2
|
||||
AND status = 'pending'
|
||||
ORDER BY due_date ASC
|
||||
LIMIT 1`, projectID, ruleID)
|
||||
|
||||
256
scripts/gen-submission-base/main.go
Normal file
256
scripts/gen-submission-base/main.go
Normal file
@@ -0,0 +1,256 @@
|
||||
// Composer Slice E base-template generator (t-paliad-317).
|
||||
//
|
||||
// Produces a minimal Composer-mode .docx whose <w:body> contains the
|
||||
// 10 default section anchors and whose word/styles.xml declares a
|
||||
// named style for each stylemap key the composer references. Each
|
||||
// "preset" (lg-duesseldorf, upc-formal, …) hard-codes the typography
|
||||
// (font, sizes, colour) so the lawyer can swap between them and see
|
||||
// the chrome change while the section content carries through
|
||||
// unchanged (the Q10 base-swap-content-survival contract).
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// go run ./scripts/gen-submission-base -preset lg-duesseldorf -out /tmp/lg-duesseldorf.docx
|
||||
// go run ./scripts/gen-submission-base -preset upc-formal -out /tmp/upc-formal.docx
|
||||
//
|
||||
// Both outputs are byte-reproducible (zip mtimes pinned to a fixed
|
||||
// UTC timestamp so a clean rebuild diff stays at zero bytes).
|
||||
//
|
||||
// Cross-firm: the bases this generator emits are firm-agnostic
|
||||
// (firm = NULL on the catalog row). They contain no HLC branding
|
||||
// content. Per-firm bases continue to use gen-hl-skeleton-template
|
||||
// against the proprietary .dotm source.
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
preset := flag.String("preset", "", "preset: lg-duesseldorf | upc-formal")
|
||||
out := flag.String("out", "", "output .docx path (required)")
|
||||
flag.Parse()
|
||||
|
||||
if *preset == "" || *out == "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: gen-submission-base -preset NAME -out PATH")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
cfg, ok := presets[*preset]
|
||||
if !ok {
|
||||
fmt.Fprintf(os.Stderr, "unknown preset %q (available: ", *preset)
|
||||
first := true
|
||||
for k := range presets {
|
||||
if !first {
|
||||
fmt.Fprint(os.Stderr, ", ")
|
||||
}
|
||||
fmt.Fprint(os.Stderr, k)
|
||||
first = false
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, ")")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
docx, err := buildDocx(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen-submission-base:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(*out, docx, 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen-submission-base: write:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("wrote %s (%d bytes) for preset %s\n", *out, len(docx), *preset)
|
||||
}
|
||||
|
||||
// presetConfig captures everything the generator needs to vary between
|
||||
// bases: typography defaults (font + size + colour) and the style-name
|
||||
// prefix that surfaces in the styles.xml.
|
||||
type presetConfig struct {
|
||||
StylePrefix string // e.g. "LG" / "UPC"
|
||||
DefaultFont string // e.g. "Times New Roman" / "Calibri"
|
||||
BodyHalfPoints int // w:sz value (half-points; 22 = 11pt)
|
||||
Heading1Size int
|
||||
Heading2Size int
|
||||
Heading3Size int
|
||||
Heading1Color string // hex without #
|
||||
Heading2Color string
|
||||
Heading3Color string
|
||||
BlockquoteFont string // separate font for the quote style
|
||||
}
|
||||
|
||||
// presets are the seeded base styles for Slice E. Both are intended
|
||||
// as starting points the firm's admin can refine via the admin editor
|
||||
// in a later slice — this is the floor, not the ceiling.
|
||||
var presets = map[string]presetConfig{
|
||||
"lg-duesseldorf": {
|
||||
StylePrefix: "LG",
|
||||
DefaultFont: "Times New Roman",
|
||||
BodyHalfPoints: 22, // 11pt
|
||||
Heading1Size: 28, // 14pt
|
||||
Heading2Size: 26, // 13pt
|
||||
Heading3Size: 24, // 12pt
|
||||
Heading1Color: "000000",
|
||||
Heading2Color: "000000",
|
||||
Heading3Color: "000000",
|
||||
BlockquoteFont: "Times New Roman",
|
||||
},
|
||||
"upc-formal": {
|
||||
StylePrefix: "UPC",
|
||||
DefaultFont: "Calibri",
|
||||
BodyHalfPoints: 22, // 11pt
|
||||
Heading1Size: 32, // 16pt
|
||||
Heading2Size: 28, // 14pt
|
||||
Heading3Size: 24, // 12pt
|
||||
Heading1Color: "1F3864", // UPC dark blue
|
||||
Heading2Color: "1F3864",
|
||||
Heading3Color: "1F3864",
|
||||
BlockquoteFont: "Cambria",
|
||||
},
|
||||
}
|
||||
|
||||
var fixedTime = time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func buildDocx(cfg presetConfig) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
add := func(name, body string) error {
|
||||
hdr := &zip.FileHeader{Name: name, Method: zip.Deflate, Modified: fixedTime}
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", name, err)
|
||||
}
|
||||
if _, err := w.Write([]byte(body)); err != nil {
|
||||
return fmt.Errorf("write %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := add("[Content_Types].xml", contentTypesXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("_rels/.rels", rootRelsXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/_rels/document.xml.rels", documentRelsXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/styles.xml", buildStylesXML(cfg)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/document.xml", buildDocumentXML()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("finalise zip: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
const contentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
|
||||
</Types>`
|
||||
|
||||
const rootRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
// documentRelsXML — empty relationships envelope. The composer's
|
||||
// hyperlink patch slots fresh <Relationship Type="…/hyperlink"/>
|
||||
// rows in here at compose time.
|
||||
const documentRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
// buildStylesXML emits the stylemap-aligned named styles. Each style
|
||||
// id matches what the catalog row's section_spec.stylemap declares
|
||||
// for the corresponding key (paragraph / heading_1/2/3 / list_*
|
||||
// / blockquote / Hyperlink).
|
||||
//
|
||||
// "Hyperlink" is the built-in Word style id the composer's MD walker
|
||||
// emits on link-child runs (Slice D). Including it here makes the
|
||||
// blue-underline-link rendering land out of the box.
|
||||
func buildStylesXML(cfg presetConfig) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
|
||||
b.WriteString(`<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
|
||||
|
||||
// Document defaults — sets the body font + size for every paragraph
|
||||
// that doesn't override.
|
||||
fmt.Fprintf(&b, `<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="%s" w:hAnsi="%s" w:cs="%s"/><w:sz w:val="%d"/></w:rPr></w:rPrDefault></w:docDefaults>`,
|
||||
cfg.DefaultFont, cfg.DefaultFont, cfg.DefaultFont, cfg.BodyHalfPoints)
|
||||
|
||||
// Normal — Word's default paragraph style; nothing fancy.
|
||||
b.WriteString(`<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>`)
|
||||
|
||||
// Body style — body0 alias for the composer's stylemap.paragraph.
|
||||
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-Body"><w:name w:val="%s body"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:after="120" w:line="276" w:lineRule="auto"/></w:pPr></w:style>`,
|
||||
cfg.StylePrefix, cfg.StylePrefix)
|
||||
|
||||
// Headings — three levels with descending sizes + colours.
|
||||
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-Heading1"><w:name w:val="%s heading 1"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:before="320" w:after="160"/></w:pPr><w:rPr><w:b/><w:sz w:val="%d"/><w:color w:val="%s"/></w:rPr></w:style>`,
|
||||
cfg.StylePrefix, cfg.StylePrefix, cfg.Heading1Size, cfg.Heading1Color)
|
||||
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-Heading2"><w:name w:val="%s heading 2"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:before="240" w:after="120"/></w:pPr><w:rPr><w:b/><w:sz w:val="%d"/><w:color w:val="%s"/></w:rPr></w:style>`,
|
||||
cfg.StylePrefix, cfg.StylePrefix, cfg.Heading2Size, cfg.Heading2Color)
|
||||
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-Heading3"><w:name w:val="%s heading 3"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:before="200" w:after="80"/></w:pPr><w:rPr><w:b/><w:sz w:val="%d"/><w:color w:val="%s"/></w:rPr></w:style>`,
|
||||
cfg.StylePrefix, cfg.StylePrefix, cfg.Heading3Size, cfg.Heading3Color)
|
||||
|
||||
// List paragraph styles — same indent as body but with hanging
|
||||
// indent so the visible "• " / "N. " prefix from the MD walker
|
||||
// aligns cleanly.
|
||||
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-ListBullet"><w:name w:val="%s list bullet"/><w:basedOn w:val="Normal"/><w:pPr><w:ind w:left="360" w:hanging="360"/><w:spacing w:after="60"/></w:pPr></w:style>`,
|
||||
cfg.StylePrefix, cfg.StylePrefix)
|
||||
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-ListNumber"><w:name w:val="%s list number"/><w:basedOn w:val="Normal"/><w:pPr><w:ind w:left="360" w:hanging="360"/><w:spacing w:after="60"/></w:pPr></w:style>`,
|
||||
cfg.StylePrefix, cfg.StylePrefix)
|
||||
|
||||
// Blockquote — italic, indented, optional alternative font.
|
||||
fmt.Fprintf(&b, `<w:style w:type="paragraph" w:styleId="%s-Quote"><w:name w:val="%s quote"/><w:basedOn w:val="Normal"/><w:pPr><w:ind w:left="720"/><w:spacing w:before="120" w:after="120"/></w:pPr><w:rPr><w:i/><w:rFonts w:ascii="%s" w:hAnsi="%s"/></w:rPr></w:style>`,
|
||||
cfg.StylePrefix, cfg.StylePrefix, cfg.BlockquoteFont, cfg.BlockquoteFont)
|
||||
|
||||
// Hyperlink — Word's built-in character-style id matches what the
|
||||
// MD walker emits, so the link runs pick up the colour + underline
|
||||
// automatically.
|
||||
b.WriteString(`<w:style w:type="character" w:styleId="Hyperlink"><w:name w:val="Hyperlink"/><w:rPr><w:color w:val="0563C1"/><w:u w:val="single"/></w:rPr></w:style>`)
|
||||
|
||||
b.WriteString(`</w:styles>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// buildDocumentXML emits the composer-mode body — 10 default section
|
||||
// anchors in the design's §6.1 order, nothing else.
|
||||
func buildDocumentXML() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
|
||||
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
|
||||
b.WriteString(`<w:body>`)
|
||||
for _, key := range []string{
|
||||
"letterhead", "caption", "introduction", "requests",
|
||||
"facts", "legal_argument", "evidence", "exhibits",
|
||||
"closing", "signature",
|
||||
} {
|
||||
anchor(&b, "{{#section:"+key+"}}")
|
||||
anchor(&b, "{{/section:"+key+"}}")
|
||||
}
|
||||
b.WriteString(`</w:body></w:document>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func anchor(b *strings.Builder, text string) {
|
||||
b.WriteString(`<w:p><w:r><w:t xml:space="preserve">`)
|
||||
b.WriteString(text)
|
||||
b.WriteString(`</w:t></w:r></w:p>`)
|
||||
}
|
||||
Reference in New Issue
Block a user