Compare commits
60 Commits
mai/cronus
...
mai/darwin
| Author | SHA1 | Date | |
|---|---|---|---|
| 733d21c930 | |||
| d190fbe0a4 | |||
| e0a82d9f9e | |||
| d326f9aa4a | |||
| 026ad2d5ee | |||
| 13a65a6d6e | |||
| bd7896ef68 | |||
| 946f373651 | |||
| 94310ba498 | |||
| 5834e3dc66 | |||
| 677849784c | |||
| b27d402156 | |||
| 14290294b4 | |||
| 6b970da774 | |||
| 9359e99a6b | |||
| 2c0efc396c | |||
| 5c6a0095e3 | |||
| 6e0961cc30 | |||
| ee98db94fa | |||
| 987db27831 | |||
| 1129baba7a | |||
| c20e935a4b | |||
| f963b0df34 | |||
| 6cd340300b | |||
| 557f9a4cce | |||
| 3af71e772b | |||
| e2969fc358 | |||
| 85d0cedd22 | |||
| 0e1691f00e | |||
| 05ad43aa46 | |||
| 43de8f9c7b | |||
| 635457474a | |||
| 235e68496b | |||
| 8125caf49a | |||
| 937ff13470 | |||
| b97f170c1d | |||
| 935ea23038 | |||
| f8e5be5f7a | |||
| ee0a9ea6cb | |||
| da464813b7 | |||
| 6d24fb8931 | |||
| 446c46e5c5 | |||
| d1aa0f72c0 | |||
| 94f2831f3f | |||
| 83be122b19 | |||
| df592f9fc4 | |||
| b6c2df95cc | |||
| 367627af0d | |||
| 7d7b20651d | |||
| 8f1a287549 | |||
| 38ebccc907 | |||
| 3b601f156b | |||
| cd5f752a0e | |||
| 2377f08bd7 | |||
| 1d704f6e04 | |||
| a75731a902 | |||
| 727e01c6c9 | |||
| 5cff38ff3c | |||
| 3097df3918 | |||
| 46b58dcf41 |
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)
|
||||
}
|
||||
@@ -159,6 +159,21 @@ func main() {
|
||||
submissionVarsSvc := services.NewSubmissionVarsService(pool, projectSvc, partySvc, users)
|
||||
submissionRenderer := services.NewSubmissionRenderer()
|
||||
submissionDraftSvc := services.NewSubmissionDraftService(pool, projectSvc, submissionVarsSvc, submissionRenderer)
|
||||
// t-paliad-313 Composer Slice A — base catalog + section seeding.
|
||||
// AttachComposer wires both into the draft service so Create
|
||||
// seeds base_id + submission_sections rows on new drafts. v1
|
||||
// fallback path stays active for pre-Composer drafts (base_id
|
||||
// NULL, no section rows).
|
||||
submissionBaseSvc := services.NewBaseService(pool)
|
||||
submissionSectionSvc := services.NewSectionService(pool)
|
||||
submissionDraftSvc.AttachComposer(submissionBaseSvc, submissionSectionSvc, branding.Name)
|
||||
// t-paliad-313 Slice B — render-pipeline assembler. Reuses the
|
||||
// existing SubmissionRenderer for the final placeholder pass so
|
||||
// the {{rule.X}} alias contract stays preserved inside the
|
||||
// composed body.
|
||||
submissionComposerSvc := services.NewSubmissionComposer(submissionRenderer)
|
||||
// t-paliad-315 Slice C — building-block library.
|
||||
submissionBuildingBlockSvc := services.NewBuildingBlockService(pool, branding.Name)
|
||||
// t-paliad-225 Slice A — user-authored checklist templates.
|
||||
// Slice B adds checklist_shares grants + admin promotion.
|
||||
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
|
||||
@@ -170,7 +185,11 @@ func main() {
|
||||
Team: teamSvc,
|
||||
PartnerUnit: partnerUnitSvc,
|
||||
Party: partySvc,
|
||||
SubmissionDraft: submissionDraftSvc,
|
||||
SubmissionDraft: submissionDraftSvc,
|
||||
SubmissionBase: submissionBaseSvc,
|
||||
SubmissionSection: submissionSectionSvc,
|
||||
SubmissionComposer: submissionComposerSvc,
|
||||
SubmissionBuildingBlock: submissionBuildingBlockSvc,
|
||||
Deadline: deadlineSvc,
|
||||
Appointment: appointmentSvc,
|
||||
CalDAV: caldavSvc,
|
||||
@@ -221,6 +240,8 @@ func main() {
|
||||
Export: services.NewExportService(pool, branding.Name),
|
||||
// t-paliad-265 / m/paliad#96 — per-event-card optional choices.
|
||||
EventChoice: services.NewEventChoiceService(pool, projectSvc, users),
|
||||
// Slice D (m/paliad#124 §5, mig 145) — named scenario compositions.
|
||||
Scenario: services.NewScenarioService(pool, projectSvc, rules),
|
||||
}
|
||||
|
||||
// t-paliad-246 Slice A — Backup Mode runner. Wired only when
|
||||
@@ -337,6 +358,11 @@ func main() {
|
||||
log.Printf("CalDAV start: %v", err)
|
||||
}
|
||||
reminderSvc.Start(bgCtx)
|
||||
// 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")
|
||||
|
||||
1018
docs/design-submission-generator-v2-2026-05-26.md
Normal file
1018
docs/design-submission-generator-v2-2026-05-26.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@ import { renderAdminTeam } from "./src/admin-team";
|
||||
import { renderAdminAuditLog } from "./src/admin-audit-log";
|
||||
import { renderAdminPartnerUnits } from "./src/admin-partner-units";
|
||||
import { renderAdminEmailTemplates } from "./src/admin-email-templates";
|
||||
import { renderAdminSubmissionBuildingBlocks } from "./src/admin-submission-building-blocks";
|
||||
import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit";
|
||||
import { renderAdminEventTypes } from "./src/admin-event-types";
|
||||
import { renderAdminApprovalPolicies } from "./src/admin-approval-policies";
|
||||
@@ -278,6 +279,7 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/admin-partner-units.ts"),
|
||||
join(import.meta.dir, "src/client/admin-email-templates.ts"),
|
||||
join(import.meta.dir, "src/client/admin-email-templates-edit.ts"),
|
||||
join(import.meta.dir, "src/client/admin-submission-building-blocks.ts"),
|
||||
join(import.meta.dir, "src/client/admin-event-types.ts"),
|
||||
join(import.meta.dir, "src/client/admin-approval-policies.ts"),
|
||||
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
|
||||
@@ -409,6 +411,7 @@ async function build() {
|
||||
await Bun.write(join(DIST, "admin-partner-units.html"), renderAdminPartnerUnits());
|
||||
await Bun.write(join(DIST, "admin-email-templates.html"), renderAdminEmailTemplates());
|
||||
await Bun.write(join(DIST, "admin-email-templates-edit.html"), renderAdminEmailTemplatesEdit());
|
||||
await Bun.write(join(DIST, "admin-submission-building-blocks.html"), renderAdminSubmissionBuildingBlocks());
|
||||
await Bun.write(join(DIST, "admin-event-types.html"), renderAdminEventTypes());
|
||||
await Bun.write(join(DIST, "admin-approval-policies.html"), renderAdminApprovalPolicies());
|
||||
await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts());
|
||||
|
||||
@@ -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,7 +101,7 @@ 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.rules.col.legal_citation">Rechtsgrundlage</th>
|
||||
<th data-i18n="admin.rules.col.name">Name</th>
|
||||
<th data-i18n="admin.rules.col.proceeding">Verfahrenstyp</th>
|
||||
|
||||
77
frontend/src/admin-submission-building-blocks.tsx
Normal file
77
frontend/src/admin-submission-building-blocks.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /admin/submission-building-blocks — Composer building-blocks library
|
||||
// editor (t-paliad-315 Slice C). Three-pane layout: list on the left,
|
||||
// edit form in the middle, version log on the right. Hydrated by
|
||||
// client/admin-submission-building-blocks.ts from
|
||||
// GET /api/admin/submission-building-blocks.
|
||||
|
||||
export function renderAdminSubmissionBuildingBlocks(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<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.building_blocks.title">Bausteine — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/submission-building-blocks" />
|
||||
<BottomNav currentPath="/admin/submission-building-blocks" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.building_blocks.heading">Bausteine</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.building_blocks.subtitle">
|
||||
Wiederverwendbare Textbausteine für Composer-Abschnitte.
|
||||
</p>
|
||||
</div>
|
||||
<div className="tool-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
id="admin-bb-new-btn"
|
||||
className="btn-primary btn-cta-lime"
|
||||
data-i18n="admin.building_blocks.action.new">
|
||||
+ Neuer Baustein
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="admin-bb-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<div className="admin-bb-layout">
|
||||
<aside className="admin-bb-list" id="admin-bb-list">
|
||||
<div className="admin-bb-loading" data-i18n="admin.building_blocks.loading">Lädt…</div>
|
||||
</aside>
|
||||
|
||||
<section className="admin-bb-editor" id="admin-bb-editor">
|
||||
<p className="admin-bb-empty" data-i18n="admin.building_blocks.editor.empty">
|
||||
Wählen Sie einen Baustein aus der Liste — oder erstellen Sie einen neuen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<aside className="admin-bb-versions" id="admin-bb-versions" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-submission-building-blocks.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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,10 +1,10 @@
|
||||
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.
|
||||
|
||||
@@ -145,7 +145,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> {
|
||||
@@ -248,7 +248,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 +392,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 +416,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;
|
||||
}
|
||||
|
||||
|
||||
429
frontend/src/client/admin-submission-building-blocks.ts
Normal file
429
frontend/src/client/admin-submission-building-blocks.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
import { initI18n, t, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
function isEN(): boolean { return getLang() === "en"; }
|
||||
|
||||
// /admin/submission-building-blocks — Composer building-blocks admin
|
||||
// editor (t-paliad-315 Slice C). Three-pane layout: list → editor →
|
||||
// version log. CRUD via /api/admin/submission-building-blocks/*.
|
||||
//
|
||||
// Per Q2 ratification (m, 2026-05-26): building blocks are plain text
|
||||
// paste sources. The editor here is curator-only — no per-section
|
||||
// lineage to surface, no "where is this block used" view.
|
||||
|
||||
interface BuildingBlockJSON {
|
||||
id: string;
|
||||
slug: string;
|
||||
firm?: string | null;
|
||||
section_key: string;
|
||||
proceeding_family?: string | null;
|
||||
title_de: string;
|
||||
title_en: string;
|
||||
description_de?: string | null;
|
||||
description_en?: string | null;
|
||||
content_md_de: string;
|
||||
content_md_en: string;
|
||||
author_id?: string | null;
|
||||
visibility: string;
|
||||
is_published: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface VersionJSON {
|
||||
id: string;
|
||||
building_block_id: string;
|
||||
content_md_de: string;
|
||||
content_md_en: string;
|
||||
title_de: string;
|
||||
title_en: string;
|
||||
edited_by?: string | null;
|
||||
note?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const VISIBILITIES = ["private", "team", "firm", "global"];
|
||||
|
||||
// Section keys must match what the Composer base spec declares for
|
||||
// each section (see internal/db/migrations/146_submission_bases.up.sql).
|
||||
const SECTION_KEYS = [
|
||||
"letterhead", "caption", "introduction", "requests",
|
||||
"facts", "legal_argument", "evidence", "exhibits",
|
||||
"closing", "signature",
|
||||
];
|
||||
|
||||
const state = {
|
||||
blocks: [] as BuildingBlockJSON[],
|
||||
selectedID: null as string | null,
|
||||
versions: [] as VersionJSON[],
|
||||
dirty: false,
|
||||
};
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
await loadList();
|
||||
document.getElementById("admin-bb-new-btn")?.addEventListener("click", onNew);
|
||||
}
|
||||
|
||||
async function loadList(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch("/api/admin/submission-building-blocks", { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
feedback(`HTTP ${res.status}`, true);
|
||||
return;
|
||||
}
|
||||
const body = await res.json() as { blocks?: BuildingBlockJSON[] };
|
||||
state.blocks = body.blocks ?? [];
|
||||
paintList();
|
||||
} catch (err) {
|
||||
feedback(String(err), true);
|
||||
}
|
||||
}
|
||||
|
||||
function paintList(): void {
|
||||
const host = document.getElementById("admin-bb-list");
|
||||
if (!host) return;
|
||||
host.innerHTML = "";
|
||||
if (state.blocks.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "admin-bb-empty";
|
||||
empty.textContent = isEN() ? "No blocks yet." : "Noch keine Bausteine.";
|
||||
host.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (const b of state.blocks) {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "admin-bb-list-row";
|
||||
if (b.id === state.selectedID) row.classList.add("admin-bb-list-row--active");
|
||||
const title = isEN() ? b.title_en : b.title_de;
|
||||
row.innerHTML = `
|
||||
<span class="admin-bb-list-title">${escapeHTML(title || b.slug)}</span>
|
||||
<span class="admin-bb-list-meta">
|
||||
<span class="admin-bb-list-section">${escapeHTML(b.section_key)}</span>
|
||||
<span class="admin-bb-list-vis admin-bb-list-vis--${escapeHTML(b.visibility)}">${escapeHTML(b.visibility)}</span>
|
||||
${b.is_published ? "" : `<span class="admin-bb-list-draft">${isEN() ? "draft" : "Entwurf"}</span>`}
|
||||
</span>`;
|
||||
row.addEventListener("click", () => onSelect(b.id));
|
||||
host.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSelect(id: string): Promise<void> {
|
||||
state.selectedID = id;
|
||||
state.dirty = false;
|
||||
paintList();
|
||||
const b = state.blocks.find(x => x.id === id);
|
||||
if (!b) return;
|
||||
paintEditor(b);
|
||||
await loadVersions(id);
|
||||
}
|
||||
|
||||
function onNew(): void {
|
||||
state.selectedID = null;
|
||||
state.versions = [];
|
||||
state.dirty = false;
|
||||
paintList();
|
||||
paintEditor(null);
|
||||
paintVersions();
|
||||
}
|
||||
|
||||
function paintEditor(b: BuildingBlockJSON | null): void {
|
||||
const host = document.getElementById("admin-bb-editor");
|
||||
if (!host) return;
|
||||
const isNew = b === null;
|
||||
const data = b ?? {
|
||||
id: "",
|
||||
slug: "",
|
||||
firm: "",
|
||||
section_key: "requests",
|
||||
proceeding_family: "",
|
||||
title_de: "",
|
||||
title_en: "",
|
||||
description_de: "",
|
||||
description_en: "",
|
||||
content_md_de: "",
|
||||
content_md_en: "",
|
||||
visibility: "firm",
|
||||
is_published: false,
|
||||
} as Partial<BuildingBlockJSON>;
|
||||
|
||||
host.innerHTML = "";
|
||||
const form = document.createElement("form");
|
||||
form.className = "admin-bb-form";
|
||||
form.addEventListener("submit", (e) => { e.preventDefault(); onSave(isNew); });
|
||||
|
||||
form.appendChild(textField("slug", isEN() ? "Slug" : "Slug", data.slug ?? "", true));
|
||||
form.appendChild(textField("firm", "Firm", data.firm ?? "", false, isEN() ? "leer = firmenagnostisch" : "leer = firmenagnostisch"));
|
||||
form.appendChild(selectField("section_key", isEN() ? "Section key" : "Abschnitts-Slug", data.section_key ?? "requests", SECTION_KEYS, false));
|
||||
form.appendChild(textField("proceeding_family", isEN() ? "Proceeding family" : "Verfahrensfamilie", data.proceeding_family ?? "", false, "z. B. de.inf.lg"));
|
||||
form.appendChild(textField("title_de", "Titel (DE)", data.title_de ?? "", true));
|
||||
form.appendChild(textField("title_en", "Title (EN)", data.title_en ?? "", true));
|
||||
form.appendChild(textareaField("description_de", "Beschreibung (DE)", data.description_de ?? "", 2));
|
||||
form.appendChild(textareaField("description_en", "Description (EN)", data.description_en ?? "", 2));
|
||||
form.appendChild(textareaField("content_md_de", isEN() ? "Content (DE Markdown)" : "Inhalt (DE Markdown)", data.content_md_de ?? "", 10));
|
||||
form.appendChild(textareaField("content_md_en", isEN() ? "Content (EN Markdown)" : "Inhalt (EN Markdown)", data.content_md_en ?? "", 10));
|
||||
form.appendChild(selectField("visibility", isEN() ? "Visibility" : "Sichtbarkeit", data.visibility ?? "firm", VISIBILITIES, false));
|
||||
form.appendChild(checkboxField("is_published", isEN() ? "Published" : "Veröffentlicht", Boolean(data.is_published)));
|
||||
|
||||
if (!isNew) {
|
||||
form.appendChild(textField("note", isEN() ? "Save note (optional)" : "Speicher-Notiz (optional)", "", false));
|
||||
}
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "admin-bb-form-actions";
|
||||
|
||||
const save = document.createElement("button");
|
||||
save.type = "submit";
|
||||
save.className = "btn-primary btn-cta-lime";
|
||||
save.textContent = isEN() ? "Save" : "Speichern";
|
||||
actions.appendChild(save);
|
||||
|
||||
if (!isNew) {
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "btn-link-danger";
|
||||
del.textContent = isEN() ? "Delete" : "Löschen";
|
||||
del.addEventListener("click", () => onDelete());
|
||||
actions.appendChild(del);
|
||||
}
|
||||
form.appendChild(actions);
|
||||
host.appendChild(form);
|
||||
}
|
||||
|
||||
function textField(name: string, label: string, value: string, required: boolean, hint?: string): HTMLElement {
|
||||
const wrap = document.createElement("label");
|
||||
wrap.className = "admin-bb-form-row";
|
||||
const lab = document.createElement("span");
|
||||
lab.textContent = label + (required ? " *" : "");
|
||||
wrap.appendChild(lab);
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.name = name;
|
||||
input.className = "entity-form-input";
|
||||
input.value = value;
|
||||
if (required) input.required = true;
|
||||
wrap.appendChild(input);
|
||||
if (hint) {
|
||||
const h = document.createElement("small");
|
||||
h.className = "admin-bb-form-hint";
|
||||
h.textContent = hint;
|
||||
wrap.appendChild(h);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function textareaField(name: string, label: string, value: string, rows: number): HTMLElement {
|
||||
const wrap = document.createElement("label");
|
||||
wrap.className = "admin-bb-form-row";
|
||||
const lab = document.createElement("span");
|
||||
lab.textContent = label;
|
||||
wrap.appendChild(lab);
|
||||
const ta = document.createElement("textarea");
|
||||
ta.name = name;
|
||||
ta.className = "entity-form-input";
|
||||
ta.rows = rows;
|
||||
ta.value = value;
|
||||
wrap.appendChild(ta);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function selectField(name: string, label: string, value: string, options: string[], required: boolean): HTMLElement {
|
||||
const wrap = document.createElement("label");
|
||||
wrap.className = "admin-bb-form-row";
|
||||
const lab = document.createElement("span");
|
||||
lab.textContent = label + (required ? " *" : "");
|
||||
wrap.appendChild(lab);
|
||||
const sel = document.createElement("select");
|
||||
sel.name = name;
|
||||
sel.className = "entity-form-input";
|
||||
for (const opt of options) {
|
||||
const o = document.createElement("option");
|
||||
o.value = opt;
|
||||
o.textContent = opt;
|
||||
if (opt === value) o.selected = true;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
wrap.appendChild(sel);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function checkboxField(name: string, label: string, value: boolean): HTMLElement {
|
||||
const wrap = document.createElement("label");
|
||||
wrap.className = "admin-bb-form-row admin-bb-form-row--checkbox";
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.name = name;
|
||||
input.checked = value;
|
||||
wrap.appendChild(input);
|
||||
const lab = document.createElement("span");
|
||||
lab.textContent = label;
|
||||
wrap.appendChild(lab);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function onSave(isNew: boolean): Promise<void> {
|
||||
const form = document.querySelector(".admin-bb-form") as HTMLFormElement | null;
|
||||
if (!form) return;
|
||||
const data = new FormData(form);
|
||||
const payload: Record<string, unknown> = {};
|
||||
for (const key of ["slug", "section_key", "title_de", "title_en", "content_md_de", "content_md_en", "visibility"]) {
|
||||
const v = data.get(key);
|
||||
if (v !== null) payload[key] = String(v);
|
||||
}
|
||||
for (const key of ["firm", "proceeding_family", "description_de", "description_en"]) {
|
||||
const v = data.get(key);
|
||||
if (v !== null) {
|
||||
const s = String(v).trim();
|
||||
payload[key] = s === "" ? null : s;
|
||||
}
|
||||
}
|
||||
payload.is_published = (data.get("is_published") === "on");
|
||||
if (!isNew) {
|
||||
const note = data.get("note");
|
||||
if (note) payload.note = String(note);
|
||||
}
|
||||
try {
|
||||
const url = isNew
|
||||
? "/api/admin/submission-building-blocks"
|
||||
: `/api/admin/submission-building-blocks/${state.selectedID}`;
|
||||
const method = isNew ? "POST" : "PATCH";
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({} as { error?: string }));
|
||||
feedback(body.error ?? `HTTP ${res.status}`, true);
|
||||
return;
|
||||
}
|
||||
const saved = await res.json() as BuildingBlockJSON;
|
||||
feedback(isEN() ? "Saved." : "Gespeichert.", false);
|
||||
await loadList();
|
||||
state.selectedID = saved.id;
|
||||
paintList();
|
||||
paintEditor(saved);
|
||||
await loadVersions(saved.id);
|
||||
} catch (err) {
|
||||
feedback(String(err), true);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(): Promise<void> {
|
||||
if (!state.selectedID) return;
|
||||
const sure = confirm(isEN() ? "Delete this block?" : "Diesen Baustein löschen?");
|
||||
if (!sure) return;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/submission-building-blocks/${state.selectedID}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
feedback(`HTTP ${res.status}`, true);
|
||||
return;
|
||||
}
|
||||
feedback(isEN() ? "Deleted." : "Gelöscht.", false);
|
||||
state.selectedID = null;
|
||||
await loadList();
|
||||
paintEditor(null);
|
||||
state.versions = [];
|
||||
paintVersions();
|
||||
} catch (err) {
|
||||
feedback(String(err), true);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVersions(blockID: string): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/submission-building-blocks/${blockID}/versions`, { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
state.versions = [];
|
||||
paintVersions();
|
||||
return;
|
||||
}
|
||||
const body = await res.json() as { versions?: VersionJSON[] };
|
||||
state.versions = body.versions ?? [];
|
||||
paintVersions();
|
||||
} catch {
|
||||
state.versions = [];
|
||||
paintVersions();
|
||||
}
|
||||
}
|
||||
|
||||
function paintVersions(): void {
|
||||
const host = document.getElementById("admin-bb-versions");
|
||||
if (!host) return;
|
||||
host.innerHTML = "";
|
||||
if (state.versions.length === 0) return;
|
||||
const h = document.createElement("h3");
|
||||
h.textContent = isEN() ? "History" : "Verlauf";
|
||||
host.appendChild(h);
|
||||
for (const v of state.versions) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "admin-bb-version-row";
|
||||
const date = new Date(v.created_at).toLocaleString();
|
||||
row.innerHTML = `
|
||||
<div class="admin-bb-version-meta">${escapeHTML(date)} — ${escapeHTML(v.note ?? "")}</div>`;
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "btn-small btn-secondary";
|
||||
btn.textContent = isEN() ? "Restore" : "Wiederherstellen";
|
||||
btn.addEventListener("click", () => onRestore(v.id));
|
||||
row.appendChild(btn);
|
||||
host.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function onRestore(versionID: string): Promise<void> {
|
||||
if (!state.selectedID) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/submission-building-blocks/${state.selectedID}/restore/${versionID}`,
|
||||
{ method: "POST", credentials: "include" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
feedback(`HTTP ${res.status}`, true);
|
||||
return;
|
||||
}
|
||||
const restored = await res.json() as BuildingBlockJSON;
|
||||
feedback(isEN() ? "Restored." : "Wiederhergestellt.", false);
|
||||
paintEditor(restored);
|
||||
await loadVersions(restored.id);
|
||||
await loadList();
|
||||
} catch (err) {
|
||||
feedback(String(err), true);
|
||||
}
|
||||
}
|
||||
|
||||
function feedback(msg: string, isError: boolean): void {
|
||||
const host = document.getElementById("admin-bb-feedback");
|
||||
if (!host) return;
|
||||
host.style.display = "";
|
||||
host.className = "form-msg " + (isError ? "form-msg--error" : "form-msg--ok");
|
||||
host.textContent = msg;
|
||||
if (!isError) {
|
||||
setTimeout(() => { host.style.display = "none"; }, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHTML(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Silence unused-import warning when t() isn't called directly — i18n
|
||||
// is initialised so data-i18n attrs render on first paint.
|
||||
void t;
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", boot);
|
||||
} else {
|
||||
void boot();
|
||||
}
|
||||
@@ -79,7 +79,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"changelog.tag.fix": "Fix",
|
||||
|
||||
// Footer
|
||||
"footer.text": "\u00a9 2026 Paliad \u2014 ein Werkzeug von",
|
||||
"footer.text": "\u00a9 2026 Paliad \u2014 by",
|
||||
|
||||
// Landing page
|
||||
"index.title": `Paliad \u2014 Patent Litigation f\u00fcr ${FIRM}`,
|
||||
@@ -312,6 +312,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.view.timeline": "Zeitstrahl",
|
||||
"deadlines.view.columns": "Spalten",
|
||||
"deadlines.notes.show": "Hinweise anzeigen",
|
||||
"deadlines.durations.show": "Dauern anzeigen",
|
||||
"deadlines.col.ours": "Unsere Seite",
|
||||
"deadlines.col.court": "Gericht",
|
||||
"deadlines.col.opponent": "Gegnerseite",
|
||||
@@ -1519,6 +1520,18 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"submissions.draft.language.de": "DE",
|
||||
"submissions.draft.language.en": "EN",
|
||||
"submissions.draft.language.fallback_notice": "Fallback: universelles Skelett (keine sprachspezifische Vorlage).",
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice A — base picker + section list.
|
||||
"submissions.draft.base.label": "Vorlagenbasis",
|
||||
"submissions.draft.base.hint": "Steuert Schriftarten, Briefkopf und Abschnitts-Defaults.",
|
||||
"submissions.draft.sections.title": "Abschnitte",
|
||||
"submissions.draft.sections.hint": "Inhalt pro Abschnitt — Autosave nach 500 ms. Letztes Layout in Word.",
|
||||
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks admin.
|
||||
"admin.building_blocks.title": "Bausteine — Paliad",
|
||||
"admin.building_blocks.heading": "Bausteine",
|
||||
"admin.building_blocks.subtitle": "Wiederverwendbare Textbausteine für Composer-Abschnitte.",
|
||||
"admin.building_blocks.loading": "Lädt…",
|
||||
"admin.building_blocks.action.new": "+ Neuer Baustein",
|
||||
"admin.building_blocks.editor.empty": "Wählen Sie einen Baustein aus der Liste — oder erstellen Sie einen neuen.",
|
||||
// t-paliad-240 — global Schriftsätze drafts index page.
|
||||
"submissions.index.title": "Schriftsätze — Paliad",
|
||||
"submissions.index.heading": "Schriftsätze",
|
||||
@@ -2892,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.",
|
||||
@@ -3173,7 +3187,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"changelog.tag.fix": "Fix",
|
||||
|
||||
// Footer
|
||||
"footer.text": "\u00a9 2026 Paliad \u2014 a tool by",
|
||||
"footer.text": "\u00a9 2026 Paliad \u2014 by",
|
||||
|
||||
// Landing page
|
||||
"index.title": `Paliad \u2014 Patent Litigation for ${FIRM}`,
|
||||
@@ -3406,6 +3420,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.view.timeline": "Timeline",
|
||||
"deadlines.view.columns": "Columns",
|
||||
"deadlines.notes.show": "Show details",
|
||||
"deadlines.durations.show": "Show durations",
|
||||
"deadlines.col.ours": "Client Side",
|
||||
"deadlines.col.court": "Court",
|
||||
"deadlines.col.opponent": "Opponent Side",
|
||||
@@ -4594,6 +4609,18 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"submissions.draft.import.button": "Import from project",
|
||||
"submissions.draft.parties.title": "Parties",
|
||||
"submissions.draft.parties.hint": "Pick the parties mentioned in this submission, or add more per side.",
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice A — base picker + section list.
|
||||
"submissions.draft.base.label": "Template base",
|
||||
"submissions.draft.base.hint": "Drives fonts, letterhead, and section defaults.",
|
||||
"submissions.draft.sections.title": "Sections",
|
||||
"submissions.draft.sections.hint": "Edit per section — autosaves after 500ms. Final layout in Word.",
|
||||
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks admin.
|
||||
"admin.building_blocks.title": "Building blocks — Paliad",
|
||||
"admin.building_blocks.heading": "Building blocks",
|
||||
"admin.building_blocks.subtitle": "Reusable text snippets for Composer sections.",
|
||||
"admin.building_blocks.loading": "Loading…",
|
||||
"admin.building_blocks.action.new": "+ New block",
|
||||
"admin.building_blocks.editor.empty": "Pick a block from the list — or create a new one.",
|
||||
// t-paliad-240 — global submissions drafts index page.
|
||||
"submissions.index.title": "Submissions — Paliad",
|
||||
"submissions.index.heading": "Submissions",
|
||||
|
||||
@@ -28,10 +28,47 @@ interface SubmissionDraftJSON {
|
||||
last_exported_at?: string | null;
|
||||
last_exported_sha?: string | null;
|
||||
last_imported_at?: string | null;
|
||||
// t-paliad-313 Composer Slice A — base reference + Composer-side
|
||||
// metadata. base_id is null on pre-Composer drafts (the v1 render
|
||||
// path stays the fallback). composer_meta carries the seed-time
|
||||
// section order in later slices.
|
||||
base_id?: string | null;
|
||||
composer_meta?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// t-paliad-313 Composer Slice A — per-draft section row, surfaced
|
||||
// read-only in the editor body. Slice B adds inline edit + PATCH.
|
||||
interface SubmissionSectionJSON {
|
||||
id: string;
|
||||
section_key: string;
|
||||
order_index: number;
|
||||
kind: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
included: boolean;
|
||||
content_md_de: string;
|
||||
content_md_en: string;
|
||||
}
|
||||
|
||||
// t-paliad-313 Composer Slice A — base catalog row, surfaced in the
|
||||
// sidebar picker dropdown.
|
||||
interface SubmissionBaseRow {
|
||||
id: string;
|
||||
slug: string;
|
||||
firm?: string | null;
|
||||
proceeding_family?: string | null;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
description_de?: string | null;
|
||||
description_en?: string | null;
|
||||
gitea_path: string;
|
||||
is_default_for: string[];
|
||||
is_active: boolean;
|
||||
section_count: number;
|
||||
}
|
||||
|
||||
interface AvailablePartyJSON {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -64,6 +101,9 @@ interface SubmissionDraftView {
|
||||
// language has no per-firm language-matched template.
|
||||
template_tier?: string;
|
||||
language_fallback?: boolean;
|
||||
// t-paliad-313 Composer Slice A — per-draft section stack. Empty
|
||||
// for pre-Composer drafts where no rows have been seeded.
|
||||
sections: SubmissionSectionJSON[];
|
||||
}
|
||||
|
||||
interface SubmissionDraftListResponse {
|
||||
@@ -328,6 +368,11 @@ interface State {
|
||||
addPartyMode: "manual" | "search";
|
||||
addPartySearchHits: PartySearchHit[];
|
||||
addPartyBusy: boolean;
|
||||
// t-paliad-313 Composer Slice A — base catalog fetched once on boot.
|
||||
// Picker hidden until populated; empty array (after the fetch
|
||||
// completes) keeps the picker hidden permanently for this load.
|
||||
bases: SubmissionBaseRow[];
|
||||
basesLoaded: boolean;
|
||||
}
|
||||
|
||||
type PartySide = "claimant" | "defendant" | "other";
|
||||
@@ -354,6 +399,8 @@ const state: State = {
|
||||
addPartyMode: "manual",
|
||||
addPartySearchHits: [],
|
||||
addPartyBusy: false,
|
||||
bases: [],
|
||||
basesLoaded: false,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -371,6 +418,14 @@ async function boot(): Promise<void> {
|
||||
}
|
||||
state.parsed = parsed;
|
||||
|
||||
// t-paliad-313 Composer Slice A — kick the base catalog fetch in
|
||||
// parallel with the view load. The picker hydrates when both land;
|
||||
// either failing leaves the picker hidden but the editor functional.
|
||||
loadBases().catch(err => {
|
||||
console.warn("submission-draft: base catalog fetch failed", err);
|
||||
state.basesLoaded = true;
|
||||
});
|
||||
|
||||
try {
|
||||
if (parsed.mode === "global") {
|
||||
// Global path: we have a draft_id, fetch by id alone. Drafts
|
||||
@@ -523,11 +578,13 @@ function paint(): void {
|
||||
paintNoProjectBanner();
|
||||
paintSwitcher();
|
||||
paintNameRow();
|
||||
paintBasePicker();
|
||||
paintImportRow();
|
||||
paintPartyPicker();
|
||||
paintLanguageRow();
|
||||
paintLanguageFallback();
|
||||
paintVariables();
|
||||
paintSectionList();
|
||||
paintPreview();
|
||||
}
|
||||
|
||||
@@ -1143,6 +1200,869 @@ function paintPreview(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// t-paliad-313 Composer Slice A — base picker + section list
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadBases(): Promise<void> {
|
||||
const res = await fetch("/api/submission-bases", { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
throw new Error("base list HTTP " + res.status);
|
||||
}
|
||||
const body = await res.json() as { bases?: SubmissionBaseRow[] };
|
||||
state.bases = body.bases ?? [];
|
||||
state.basesLoaded = true;
|
||||
// If the view has already painted, re-paint the picker so it
|
||||
// hydrates as soon as the catalog lands. paint() is idempotent.
|
||||
if (state.view) paintBasePicker();
|
||||
}
|
||||
|
||||
function paintBasePicker(): void {
|
||||
const row = document.getElementById("submission-draft-base-row") as HTMLDivElement | null;
|
||||
const sel = document.getElementById("submission-draft-base") as HTMLSelectElement | null;
|
||||
if (!row || !sel || !state.view) return;
|
||||
|
||||
// Hide the picker until the catalog has loaded AND the catalog has
|
||||
// at least one entry. A failed fetch (basesLoaded=true, bases empty)
|
||||
// keeps the picker hidden indefinitely so the editor stays usable.
|
||||
if (!state.basesLoaded || state.bases.length === 0) {
|
||||
row.style.display = "none";
|
||||
return;
|
||||
}
|
||||
row.style.display = "";
|
||||
|
||||
// Rebuild the <option> list each paint so language toggles + base
|
||||
// catalog updates flow through.
|
||||
sel.innerHTML = "";
|
||||
const currentBaseID = state.view.draft.base_id ?? "";
|
||||
|
||||
// "Keine Vorlagenbasis" only listed when the draft is currently in
|
||||
// that state (pre-Composer / cleared). Avoids tempting the lawyer
|
||||
// to clear after they've already picked one.
|
||||
if (!currentBaseID) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "";
|
||||
opt.textContent = isEN() ? "— no base —" : "— keine Vorlagenbasis —";
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
for (const b of state.bases) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = b.id;
|
||||
opt.textContent = isEN() ? b.label_en : b.label_de;
|
||||
if (b.id === currentBaseID) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
|
||||
// Wire change handler once per paint. Removing then re-adding
|
||||
// keeps the binding consistent across repaints (e.g. after
|
||||
// language toggle re-renders the labels).
|
||||
sel.onchange = () => { onBaseChange(sel.value); };
|
||||
}
|
||||
|
||||
async function onBaseChange(newBaseID: string): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const payload: Record<string, unknown> = {
|
||||
// Empty string in the picker maps to null = clear.
|
||||
base_id: newBaseID === "" ? null : newBaseID,
|
||||
};
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/submission-drafts/${state.view.draft.id}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.warn("base swap PATCH failed", res.status);
|
||||
return;
|
||||
}
|
||||
const view = await res.json() as SubmissionDraftView;
|
||||
state.view = view;
|
||||
paint();
|
||||
} catch (err) {
|
||||
console.warn("base swap PATCH error", err);
|
||||
}
|
||||
}
|
||||
|
||||
// sectionAutosaveTimers — one debounce timer per section id so two
|
||||
// sections autosaving simultaneously don't trample each other. Reset
|
||||
// on each keystroke; 500ms after the last keystroke the patch fires.
|
||||
const sectionAutosaveTimers: Record<string, number> = {};
|
||||
const SECTION_AUTOSAVE_MS = 500;
|
||||
|
||||
function paintSectionList(): void {
|
||||
const wrap = document.getElementById("submission-draft-sections-wrap");
|
||||
const list = document.getElementById("submission-draft-sections-list") as HTMLOListElement | null;
|
||||
if (!wrap || !list || !state.view) return;
|
||||
|
||||
const sections = state.view.sections ?? [];
|
||||
if (sections.length === 0) {
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
|
||||
// Don't blow away the editor if a section is currently focused —
|
||||
// would steal cursor + selection mid-type. The patch round-trip
|
||||
// returns the updated row, but paintSectionList only re-renders
|
||||
// when the focused section isn't being edited (or the new render
|
||||
// is being driven by something other than the active editor itself).
|
||||
const activeID = activeSectionEditorID();
|
||||
|
||||
list.innerHTML = "";
|
||||
const lang = state.view.draft.language || state.view.lang || "de";
|
||||
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 {
|
||||
const li = document.createElement("li");
|
||||
li.className = "submission-draft-section";
|
||||
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;
|
||||
head.appendChild(title);
|
||||
|
||||
const kind = document.createElement("span");
|
||||
kind.className = "submission-draft-section-kind";
|
||||
kind.textContent = sec.kind;
|
||||
head.appendChild(kind);
|
||||
|
||||
if (!sec.included) {
|
||||
const muted = document.createElement("span");
|
||||
muted.className = "submission-draft-section-excluded-badge";
|
||||
muted.textContent = isEN() ? "excluded" : "ausgeblendet";
|
||||
head.appendChild(muted);
|
||||
}
|
||||
|
||||
// Per-section "Aufnehmen" / "Ausblenden" toggle in the head — flips
|
||||
// `included` via PATCH and re-paints.
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = "btn-small btn-secondary submission-draft-section-toggle";
|
||||
toggle.textContent = sec.included
|
||||
? (isEN() ? "Hide" : "Ausblenden")
|
||||
: (isEN() ? "Include" : "Aufnehmen");
|
||||
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 — 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).
|
||||
const bbBtn = document.createElement("button");
|
||||
bbBtn.type = "button";
|
||||
bbBtn.className = "btn-small btn-secondary submission-draft-section-bb-btn";
|
||||
bbBtn.textContent = isEN() ? "+ Block" : "+ Baustein";
|
||||
bbBtn.title = isEN() ? "Insert a saved building block" : "Baustein einfügen";
|
||||
bbBtn.addEventListener("click", () => openBlockPicker(sec));
|
||||
toolbar.appendChild(bbBtn);
|
||||
li.appendChild(toolbar);
|
||||
|
||||
const md = (lang === "en" ? sec.content_md_en : sec.content_md_de) || "";
|
||||
const editor = document.createElement("div");
|
||||
editor.className = "submission-draft-section-editor";
|
||||
editor.contentEditable = "true";
|
||||
editor.spellcheck = true;
|
||||
editor.dataset.sectionId = sec.id;
|
||||
editor.dataset.lang = lang;
|
||||
editor.dataset.placeholder = isEN()
|
||||
? "Write section content…"
|
||||
: "Abschnittstext eingeben…";
|
||||
// Paint the Markdown as plain text on first render — the editor's
|
||||
// source of truth is Markdown, the DOM is the view. Lawyer types,
|
||||
// we serialise back to MD on autosave.
|
||||
editor.textContent = md;
|
||||
|
||||
editor.addEventListener("input", () => onSectionInput(editor));
|
||||
editor.addEventListener("focus", () => {
|
||||
li.classList.add("submission-draft-section--editing");
|
||||
});
|
||||
editor.addEventListener("blur", () => {
|
||||
li.classList.remove("submission-draft-section--editing");
|
||||
// Force-flush any pending autosave so we don't leave unsynced
|
||||
// edits hanging when the lawyer tabs out.
|
||||
flushSectionAutosave(sec.id);
|
||||
});
|
||||
|
||||
li.appendChild(editor);
|
||||
|
||||
if (isActive) {
|
||||
// The repaint happened while this section was focused — restore
|
||||
// focus to it. Cursor placement at the end is a fair default
|
||||
// (typing mid-content during a repaint is rare; the autosave path
|
||||
// typically doesn't repaint at all).
|
||||
queueMicrotask(() => {
|
||||
const fresh = document.querySelector(`.submission-draft-section-editor[data-section-id="${cssEscape(sec.id)}"]`) as HTMLDivElement | null;
|
||||
if (fresh) {
|
||||
fresh.focus();
|
||||
placeCaretAtEnd(fresh);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
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";
|
||||
btn.textContent = label;
|
||||
btn.title = title;
|
||||
// Mousedown rather than click so the editor doesn't lose focus
|
||||
// mid-command — execCommand requires the editor to be the active
|
||||
// selection target.
|
||||
btn.addEventListener("mousedown", (ev) => {
|
||||
ev.preventDefault();
|
||||
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")) {
|
||||
onSectionInput(editor as HTMLDivElement);
|
||||
}
|
||||
});
|
||||
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;
|
||||
return active.dataset.sectionId ?? null;
|
||||
}
|
||||
|
||||
function placeCaretAtEnd(el: HTMLElement): void {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
range.collapse(false);
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return;
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
function onSectionInput(editor: HTMLDivElement): void {
|
||||
const id = editor.dataset.sectionId;
|
||||
if (!id) return;
|
||||
if (sectionAutosaveTimers[id]) clearTimeout(sectionAutosaveTimers[id]);
|
||||
sectionAutosaveTimers[id] = window.setTimeout(() => {
|
||||
sectionAutosaveTimers[id] = 0;
|
||||
flushSectionAutosave(id);
|
||||
}, SECTION_AUTOSAVE_MS);
|
||||
}
|
||||
|
||||
function flushSectionAutosave(sectionID: string): void {
|
||||
if (sectionAutosaveTimers[sectionID]) {
|
||||
clearTimeout(sectionAutosaveTimers[sectionID]);
|
||||
sectionAutosaveTimers[sectionID] = 0;
|
||||
}
|
||||
const editor = document.querySelector(`.submission-draft-section-editor[data-section-id="${cssEscape(sectionID)}"]`) as HTMLDivElement | null;
|
||||
if (!editor || !state.view) return;
|
||||
const lang = editor.dataset.lang || state.view.draft.language || "de";
|
||||
const md = domToMarkdown(editor);
|
||||
void patchSection(sectionID, lang === "en" ? { content_md_en: md } : { content_md_de: md });
|
||||
}
|
||||
|
||||
// domToMarkdown serialises a contentEditable's DOM tree back to
|
||||
// Markdown. Walks the tree: <b>/<strong> emit `**…**`, <i>/<em> emit
|
||||
// `*…*`, <br> emits a newline, block-level elements emit a blank line
|
||||
// between siblings. Slice B handles only B/I + paragraphs/line breaks
|
||||
// — Slice D's rich toolbar extends this to headings + lists + quote.
|
||||
function domToMarkdown(root: HTMLElement): string {
|
||||
return serializeNode(root).trim();
|
||||
}
|
||||
|
||||
function serializeNode(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.textContent ?? "";
|
||||
}
|
||||
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":
|
||||
return inner ? `**${inner}**` : "";
|
||||
case "i":
|
||||
case "em":
|
||||
return inner ? `*${inner}*` : "";
|
||||
case "br":
|
||||
return "\n";
|
||||
case "div":
|
||||
case "p":
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BuildingBlockPickJSON {
|
||||
id: string;
|
||||
slug: string;
|
||||
section_key: string;
|
||||
proceeding_family?: string | null;
|
||||
title_de: string;
|
||||
title_en: string;
|
||||
description_de?: string | null;
|
||||
description_en?: string | null;
|
||||
content_md_de: string;
|
||||
content_md_en: string;
|
||||
visibility: string;
|
||||
}
|
||||
|
||||
let blockPickerSearchTimer: number | null = null;
|
||||
|
||||
function openBlockPicker(sec: SubmissionSectionJSON): void {
|
||||
// Remove any prior picker.
|
||||
document.getElementById("submission-bb-picker")?.remove();
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = "submission-bb-picker";
|
||||
overlay.className = "submission-bb-picker-overlay";
|
||||
overlay.addEventListener("click", (ev) => {
|
||||
if (ev.target === overlay) overlay.remove();
|
||||
});
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "submission-bb-picker";
|
||||
|
||||
const head = document.createElement("header");
|
||||
head.className = "submission-bb-picker-head";
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = isEN() ? "Insert building block" : "Baustein einfügen";
|
||||
head.appendChild(title);
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "btn-small btn-secondary";
|
||||
close.textContent = isEN() ? "Close" : "Schließen";
|
||||
close.addEventListener("click", () => overlay.remove());
|
||||
head.appendChild(close);
|
||||
modal.appendChild(head);
|
||||
|
||||
const search = document.createElement("input");
|
||||
search.type = "search";
|
||||
search.placeholder = isEN() ? "Search blocks…" : "Bausteine suchen…";
|
||||
search.className = "entity-form-input submission-bb-picker-search";
|
||||
modal.appendChild(search);
|
||||
|
||||
const sectionInfo = document.createElement("p");
|
||||
sectionInfo.className = "submission-bb-picker-sectioninfo";
|
||||
sectionInfo.textContent = (isEN() ? "Section: " : "Abschnitt: ") + sec.section_key;
|
||||
modal.appendChild(sectionInfo);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "submission-bb-picker-list";
|
||||
list.textContent = isEN() ? "Loading…" : "Lädt…";
|
||||
modal.appendChild(list);
|
||||
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const fetchBlocks = async (q: string) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("section_key", sec.section_key);
|
||||
if (q) params.set("q", q);
|
||||
try {
|
||||
const res = await fetch(`/api/submission-building-blocks?${params.toString()}`, { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
list.textContent = `HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
const body = await res.json() as { blocks?: BuildingBlockPickJSON[] };
|
||||
paintPickerList(list, body.blocks ?? [], sec, overlay);
|
||||
} catch (err) {
|
||||
list.textContent = String(err);
|
||||
}
|
||||
};
|
||||
|
||||
search.addEventListener("input", () => {
|
||||
if (blockPickerSearchTimer) clearTimeout(blockPickerSearchTimer);
|
||||
blockPickerSearchTimer = window.setTimeout(() => {
|
||||
void fetchBlocks(search.value.trim());
|
||||
}, 200);
|
||||
});
|
||||
|
||||
void fetchBlocks("");
|
||||
setTimeout(() => search.focus(), 0);
|
||||
}
|
||||
|
||||
function paintPickerList(host: HTMLElement, blocks: BuildingBlockPickJSON[], sec: SubmissionSectionJSON, overlay: HTMLElement): void {
|
||||
host.innerHTML = "";
|
||||
if (blocks.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "submission-bb-picker-empty";
|
||||
empty.textContent = isEN() ? "No blocks match." : "Keine passenden Bausteine.";
|
||||
host.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
const lang = state.view?.draft.language || "de";
|
||||
for (const b of blocks) {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "submission-bb-picker-row";
|
||||
const title = (lang === "en" ? b.title_en : b.title_de) || b.slug;
|
||||
const desc = (lang === "en" ? b.description_en : b.description_de) || "";
|
||||
const preview = ((lang === "en" ? b.content_md_en : b.content_md_de) || "").slice(0, 200);
|
||||
row.innerHTML = `
|
||||
<div class="submission-bb-picker-row-head">
|
||||
<strong>${escapeHTML(title)}</strong>
|
||||
<span class="submission-bb-picker-vis submission-bb-picker-vis--${escapeHTML(b.visibility)}">${escapeHTML(b.visibility)}</span>
|
||||
</div>
|
||||
${desc ? `<div class="submission-bb-picker-row-desc">${escapeHTML(desc)}</div>` : ""}
|
||||
<pre class="submission-bb-picker-row-preview">${escapeHTML(preview)}${preview.length === 200 ? "…" : ""}</pre>`;
|
||||
row.addEventListener("click", () => {
|
||||
void insertBlockIntoSection(b.id, sec.id, overlay);
|
||||
});
|
||||
host.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function insertBlockIntoSection(blockID: string, sectionID: string, overlay: HTMLElement): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/submission-building-blocks/${blockID}/insert-into/${sectionID}`,
|
||||
{ method: "POST", credentials: "include" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.warn("insert-into PATCH failed", res.status);
|
||||
return;
|
||||
}
|
||||
const updated = await res.json() as SubmissionSectionJSON;
|
||||
if (state.view && state.view.sections) {
|
||||
const idx = state.view.sections.findIndex(s => s.id === sectionID);
|
||||
if (idx >= 0) state.view.sections[idx] = updated;
|
||||
}
|
||||
paintSectionList();
|
||||
overlay.remove();
|
||||
} catch (err) {
|
||||
console.warn("insert block error", err);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHTML(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
async function patchSection(sectionID: string, payload: Record<string, unknown>): Promise<void> {
|
||||
try {
|
||||
const draftID = state.view?.draft.id;
|
||||
if (!draftID) return;
|
||||
const res = await fetch(
|
||||
`/api/submission-drafts/${draftID}/sections/${sectionID}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.warn("section PATCH failed", res.status, sectionID);
|
||||
return;
|
||||
}
|
||||
const updated = await res.json() as SubmissionSectionJSON;
|
||||
// Splice the updated row into state.view.sections. Don't re-paint
|
||||
// unless we need to (avoid focus stealing during active typing).
|
||||
if (state.view && state.view.sections) {
|
||||
const idx = state.view.sections.findIndex(s => s.id === sectionID);
|
||||
if (idx >= 0) state.view.sections[idx] = updated;
|
||||
}
|
||||
// Only repaint when the change has visible UI knock-on (toggle,
|
||||
// label, order). content_md_* changes don't need a repaint —
|
||||
// the editor already shows the lawyer's keystrokes.
|
||||
if ("included" in payload || "label_de" in payload || "label_en" in payload || "order_index" in payload) {
|
||||
paintSectionList();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("section PATCH error", err);
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-261 (B) — click a substituted variable in the preview to
|
||||
// jump to the matching sidebar input. Re-wires on every paintPreview
|
||||
// since the preview HTML is replaced wholesale. The server side wraps
|
||||
|
||||
@@ -12,7 +12,6 @@ import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
type DeadlineResponse,
|
||||
type Side,
|
||||
calculateDeadlines,
|
||||
escHtml,
|
||||
formatDate,
|
||||
@@ -24,10 +23,27 @@ import {
|
||||
import {
|
||||
attachEventCardChoices,
|
||||
reseedChips,
|
||||
currentChoices,
|
||||
type EventChoice,
|
||||
type ChoiceKind,
|
||||
} from "./views/event-card-choices";
|
||||
import {
|
||||
APPEAL_TARGETS,
|
||||
SCENARIO_KEYS,
|
||||
type AppealTarget,
|
||||
type Side,
|
||||
type StorageLike,
|
||||
applyFiltersToSearch,
|
||||
makeMemoryStorage,
|
||||
parseAppealTargetFromSearch,
|
||||
parseProceedingFromSearch,
|
||||
parseSideFromSearch,
|
||||
parseTriggerDateFromSearch,
|
||||
readBoolFlag,
|
||||
readCourtId,
|
||||
readEventChoices,
|
||||
writeBoolFlag,
|
||||
writeCourtId,
|
||||
writeEventChoices,
|
||||
} from "./views/verfahrensablauf-state";
|
||||
|
||||
let selectedType = "";
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
@@ -61,8 +77,14 @@ let sidePrefilledFromProject = false;
|
||||
// chosen side's column). For first-instance proceedings (Inf, Rev,
|
||||
// …) the side picker still narrows columns but doesn't collapse
|
||||
// the "both" rows.
|
||||
//
|
||||
// upc.apl.unified is NOT in this set since t-paliad-307: appeal
|
||||
// timelines route via per-rule appealRole (engine-stamped under
|
||||
// appeal_target) instead of the page-level appellant axis collapse.
|
||||
// Adding upc.apl.unified here would short-circuit the appealAware
|
||||
// path and re-introduce the dead side selector on upc.apl.unified
|
||||
// (m/paliad#136 Bug 1).
|
||||
const APPELLANT_AXIS_PROCEEDINGS = new Set([
|
||||
"upc.apl.unified",
|
||||
"de.inf.olg",
|
||||
"de.inf.bgh",
|
||||
"de.null.bgh",
|
||||
@@ -113,21 +135,13 @@ const ROLE_LABELS: Record<string, RoleLabels> = {
|
||||
// Proceedings that surface the appeal-target chip group. Currently
|
||||
// only the unified upc.apl proceeding; future variants (e.g. de.apl)
|
||||
// can opt in by adding the code here.
|
||||
//
|
||||
// APPEAL_TARGETS itself lives in ./views/verfahrensablauf-state so the
|
||||
// pure URL parser and this page share the same canonical list.
|
||||
const APPEAL_TARGET_PROCEEDINGS = new Set([
|
||||
"upc.apl.unified",
|
||||
]);
|
||||
|
||||
// Five canonical appeal-target slugs (lp.AppealTargets — keep ordered
|
||||
// in sync with pkg/litigationplanner/types.go AppealTargets).
|
||||
const APPEAL_TARGETS = [
|
||||
"endentscheidung",
|
||||
"kostenentscheidung",
|
||||
"anordnung",
|
||||
"schadensbemessung",
|
||||
"bucheinsicht",
|
||||
] as const;
|
||||
type AppealTarget = (typeof APPEAL_TARGETS)[number] | "";
|
||||
|
||||
function hasAppealTarget(proceedingType: string): boolean {
|
||||
return APPEAL_TARGET_PROCEEDINGS.has(proceedingType);
|
||||
}
|
||||
@@ -136,16 +150,35 @@ function hasAppellantAxis(proceedingType: string): boolean {
|
||||
return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
|
||||
}
|
||||
|
||||
function readSideFromURL(): Side {
|
||||
const raw = new URLSearchParams(window.location.search).get("side");
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
// Scenario storage — real localStorage in the browser, in-memory
|
||||
// fallback when localStorage throws (private mode, disabled storage,
|
||||
// etc.). All scenario writes go through this single handle so a
|
||||
// failure mode is isolated to one try/catch path.
|
||||
const scenarioStorage: StorageLike = makeScenarioStorage();
|
||||
|
||||
function makeScenarioStorage(): StorageLike {
|
||||
try {
|
||||
const probe = "__paliad_va_probe__";
|
||||
window.localStorage.setItem(probe, "1");
|
||||
window.localStorage.removeItem(probe);
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return makeMemoryStorage();
|
||||
}
|
||||
}
|
||||
|
||||
function writeSideToURL(s: Side) {
|
||||
// URL writers — all four chip params route through this single helper
|
||||
// so the canonical query-string shape (no empty values, no trailing
|
||||
// `?`) is enforced in one place.
|
||||
function applyURLFilters(filters: {
|
||||
proceeding?: string;
|
||||
side?: Side;
|
||||
target?: AppealTarget;
|
||||
triggerDate?: string;
|
||||
}): void {
|
||||
const url = new URL(window.location.href);
|
||||
if (s === null) url.searchParams.delete("side");
|
||||
else url.searchParams.set("side", s);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
const nextSearch = applyFiltersToSearch(url.search, filters);
|
||||
window.history.replaceState(null, "", url.pathname + nextSearch + url.hash);
|
||||
}
|
||||
|
||||
// t-paliad-301 / m/paliad#132: applies ROLE_LABELS to the side-row
|
||||
@@ -175,26 +208,6 @@ function applyRoleLabels(proceedingType: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Slice B1 — appeal-target URL state. Empty string = no target picked
|
||||
// (the row is hidden because the proceeding isn't an appeal). Any
|
||||
// other value must be one of APPEAL_TARGETS; unknown values are
|
||||
// rejected by readAppealTargetFromURL so a stale link can't break
|
||||
// the engine filter.
|
||||
function readAppealTargetFromURL(): AppealTarget {
|
||||
const raw = new URLSearchParams(window.location.search).get("target") || "";
|
||||
if ((APPEAL_TARGETS as readonly string[]).includes(raw)) {
|
||||
return raw as AppealTarget;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function writeAppealTargetToURL(t: AppealTarget) {
|
||||
const url = new URL(window.location.href);
|
||||
if (t === "") url.searchParams.delete("target");
|
||||
else url.searchParams.set("target", t);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
// Default target on first picker entry into upc.apl. m: Endentscheidung
|
||||
// is the most-common appeal target; the chip group also defaults
|
||||
// "Endentscheidung" checked in verfahrensablauf.tsx. Keep these two in
|
||||
@@ -211,54 +224,18 @@ const anchorOverrides = new Map<string, string>();
|
||||
function clearAnchorOverrides() { anchorOverrides.clear(); }
|
||||
|
||||
// Per-event-card choices (t-paliad-265). Unbound on this page (no
|
||||
// project context), so persistence is URL-only via `?event_choices=`.
|
||||
// Format: comma-separated `submission_code:kind=value` tuples. Same
|
||||
// idiom as `?side=` + `?appellant=`.
|
||||
let perCardChoices: EventChoice[] = [];
|
||||
// project context). Persistence moved from URL → localStorage under
|
||||
// SCENARIO_KEYS.eventChoices (t-paliad-308 / m/paliad#137) — these
|
||||
// are per-user scenario tweaks, not the timeline kind, so a shared
|
||||
// link should NOT leak them into the recipient's view.
|
||||
let perCardChoices: EventChoice[] = readEventChoices(scenarioStorage);
|
||||
|
||||
function readChoicesFromURL(): EventChoice[] {
|
||||
const raw = new URLSearchParams(window.location.search).get("event_choices");
|
||||
if (!raw) return [];
|
||||
const out: EventChoice[] = [];
|
||||
for (const tuple of raw.split(",")) {
|
||||
const m = tuple.match(/^([^:]+):([^=]+)=(.+)$/);
|
||||
if (!m) continue;
|
||||
const kind = m[2] as ChoiceKind;
|
||||
if (kind !== "appellant" && kind !== "include_ccr" && kind !== "skip") continue;
|
||||
out.push({ submission_code: m[1], choice_kind: kind, choice_value: m[3] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function writeChoicesToURL(choices: EventChoice[]) {
|
||||
const url = new URL(window.location.href);
|
||||
if (choices.length === 0) {
|
||||
url.searchParams.delete("event_choices");
|
||||
} else {
|
||||
const enc = choices.map((c) => `${c.submission_code}:${c.choice_kind}=${c.choice_value}`).join(",");
|
||||
url.searchParams.set("event_choices", enc);
|
||||
}
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
// Show-hidden toggle state (t-paliad-290 / m/paliad#122). When ON, the
|
||||
// Show-hidden toggle (t-paliad-290 / m/paliad#122). When ON, the
|
||||
// calculator re-surfaces cards whose submission_code is in the active
|
||||
// skipRules set; they render faded with a "Wieder einblenden" chip.
|
||||
// URL-driven via ?show_hidden=1 so a shared link or reload preserves
|
||||
// the visibility. Default OFF — m's not asking to see hidden by
|
||||
// default, just to be able to.
|
||||
function readShowHiddenFromURL(): boolean {
|
||||
return new URLSearchParams(window.location.search).get("show_hidden") === "1";
|
||||
}
|
||||
|
||||
function writeShowHiddenToURL(on: boolean) {
|
||||
const url = new URL(window.location.href);
|
||||
if (on) url.searchParams.set("show_hidden", "1");
|
||||
else url.searchParams.delete("show_hidden");
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
let showHidden = readShowHiddenFromURL();
|
||||
// Persistence moved from URL → localStorage (t-paliad-308) — it's a
|
||||
// per-user UX preference, not scenario state worth sharing in a link.
|
||||
let showHidden = readBoolFlag(scenarioStorage, SCENARIO_KEYS.showHidden);
|
||||
|
||||
type ProcedureView = "timeline" | "columns";
|
||||
let procedureView: ProcedureView = "columns";
|
||||
@@ -276,6 +253,21 @@ function writeNotesPref(on: boolean): void {
|
||||
}
|
||||
let showNotes = readNotesPref();
|
||||
|
||||
// Durations toggle (m/paliad#133, t-paliad-302) — when off (default),
|
||||
// the per-rule duration label ("2 Mo. nach") only shows on hover via
|
||||
// the date span's `title` attribute. When on, the label renders inline
|
||||
// in the timeline meta row of every event card. Persisted in
|
||||
// localStorage under its own key so the preference is independent of
|
||||
// "Hinweise anzeigen".
|
||||
const DURATIONS_PREF_KEY = "paliad.verfahrensablauf.durations-show";
|
||||
function readDurationsPref(): boolean {
|
||||
try { return localStorage.getItem(DURATIONS_PREF_KEY) === "1"; } catch { return false; }
|
||||
}
|
||||
function writeDurationsPref(on: boolean): void {
|
||||
try { localStorage.setItem(DURATIONS_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
|
||||
}
|
||||
let showDurations = readDurationsPref();
|
||||
|
||||
// Jurisdiction display prefix for the proceeding-summary chip + the
|
||||
// trigger-event placeholder. Same forum slugs the .proceeding-group
|
||||
// `data-forum` attribute carries in verfahrensablauf.tsx /
|
||||
@@ -482,6 +474,7 @@ function renderResults(data: DeadlineResponse) {
|
||||
? renderColumnsBody(data, {
|
||||
editable: true,
|
||||
showNotes,
|
||||
showDurations,
|
||||
side: currentSide,
|
||||
// t-paliad-301: the appellant axis collapses into the single
|
||||
// side picker. For role-swap proceedings, currentSide IS the
|
||||
@@ -489,8 +482,14 @@ function renderResults(data: DeadlineResponse) {
|
||||
// in the picked side's column). For non-role-swap proceedings,
|
||||
// the appellant axis is irrelevant — pass null.
|
||||
appellant: hasAppellantAxis(selectedType) ? currentSide : null,
|
||||
// Appeal-target proceedings get per-rule appealRole routing
|
||||
// instead of the page-level appellant collapse, so the side
|
||||
// selector actually splits Berufungskläger vs Berufungs-
|
||||
// beklagter filings across columns. (t-paliad-307 /
|
||||
// m/paliad#136 Bug 1)
|
||||
appealAware: hasAppealTarget(selectedType),
|
||||
})
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes, showDurations });
|
||||
|
||||
container.innerHTML = headerHtml + noteHtml + bodyHtml;
|
||||
if (printBtn) printBtn.style.display = "block";
|
||||
@@ -540,7 +539,7 @@ function syncInfAmendEnabled() {
|
||||
if (!ccr.checked) infAmend.checked = false;
|
||||
}
|
||||
|
||||
function selectProceeding(btn: HTMLButtonElement) {
|
||||
function selectProceeding(btn: HTMLButtonElement, opts: { writeURL?: boolean } = {}) {
|
||||
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
const nextType = btn.dataset.code || "";
|
||||
@@ -550,20 +549,76 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
if (selectedType !== nextType) clearAnchorOverrides();
|
||||
selectedType = nextType;
|
||||
|
||||
// Persist the picked proceeding to ?proceeding= so a refresh / shared
|
||||
// link reproduces the same tile. writeURL=false on the load-time
|
||||
// hydration path so we don't churn history.replaceState when the
|
||||
// URL already carries the canonical value.
|
||||
if (opts.writeURL !== false) {
|
||||
applyURLFilters({ proceeding: selectedType });
|
||||
}
|
||||
|
||||
// Trigger-event label fires from the calc response (root rule).
|
||||
// Until step 3 renders, fall back to an em-dash placeholder.
|
||||
lastResponse = null;
|
||||
syncTriggerEventLabel();
|
||||
|
||||
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
|
||||
syncFlagRows();
|
||||
syncAppealTargetRowVisibility();
|
||||
applyRoleLabels(selectedType);
|
||||
// Restore flags from localStorage BEFORE the initial calc so the
|
||||
// first /api/tools/fristenrechner POST already carries the user's
|
||||
// stored flag state. Court_id is async (populateCourtPicker fetches
|
||||
// courts from the API) so it restores via the .then() below + a
|
||||
// follow-up recalc when the picker is ready.
|
||||
restoreFlagsForProceeding();
|
||||
|
||||
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
|
||||
|
||||
showStep(2);
|
||||
scheduleCalc(0);
|
||||
|
||||
void populateCourtPicker("court-picker-row", "court-picker", selectedType).then(() => {
|
||||
if (restoreCourtForProceeding()) scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
|
||||
// restoreFlagsForProceeding seeds the proceeding-specific flag
|
||||
// checkboxes from localStorage. Mirrors syncFlagRows in scope — only
|
||||
// flags currently visible for the active proceeding are meaningful
|
||||
// (the hidden checkboxes still write to localStorage if toggled, but
|
||||
// that's impossible because they're not in the DOM as visible
|
||||
// controls). syncInfAmendEnabled enforces the upc.inf.cfi inf-amend
|
||||
// gating after the restore.
|
||||
function restoreFlagsForProceeding(): void {
|
||||
const flagPairs: Array<[string, string]> = [
|
||||
["ccr-flag", SCENARIO_KEYS.ccr],
|
||||
["inf-amend-flag", SCENARIO_KEYS.infAmend],
|
||||
["rev-amend-flag", SCENARIO_KEYS.revAmend],
|
||||
["rev-cci-flag", SCENARIO_KEYS.revCci],
|
||||
];
|
||||
for (const [domId, storageKey] of flagPairs) {
|
||||
const cb = document.getElementById(domId) as HTMLInputElement | null;
|
||||
if (!cb) continue;
|
||||
cb.checked = readBoolFlag(scenarioStorage, storageKey);
|
||||
}
|
||||
syncInfAmendEnabled();
|
||||
}
|
||||
|
||||
// restoreCourtForProceeding tries to apply the localStorage court_id
|
||||
// to the picker after populateCourtPicker resolves. Returns true iff
|
||||
// a value actually changed (so the caller can schedule a follow-up
|
||||
// calc). Skips silently when the picker is hidden, the stored ID isn't
|
||||
// in the options list (court rotated since last visit), or the picker
|
||||
// already happens to be on the stored value.
|
||||
function restoreCourtForProceeding(): boolean {
|
||||
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
|
||||
const storedCourtId = readCourtId(scenarioStorage);
|
||||
if (!courtPicker || !storedCourtId) return false;
|
||||
const has = Array.from(courtPicker.options).some((o) => o.value === storedCourtId);
|
||||
if (!has) return false;
|
||||
if (courtPicker.value === storedCourtId) return false;
|
||||
courtPicker.value = storedCourtId;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Slice B1 (m/paliad#124 §18.1) — Berufung unification.
|
||||
@@ -578,7 +633,7 @@ function syncAppealTargetRowVisibility() {
|
||||
row.style.display = visible ? "" : "none";
|
||||
if (!visible && currentAppealTarget !== "") {
|
||||
currentAppealTarget = "";
|
||||
writeAppealTargetToURL("");
|
||||
applyURLFilters({ target: "" });
|
||||
syncRadioGroup("appeal-target", "endentscheidung");
|
||||
}
|
||||
}
|
||||
@@ -692,11 +747,11 @@ function showSideRadioCluster() {
|
||||
// already chosen and we never overwrite. When we do prefill, write the
|
||||
// derived side to the URL so reload + back/forward round-trip cleanly.
|
||||
function applySidePrefill(os: ProjectOurSide["our_side"] | undefined) {
|
||||
if (readSideFromURL() !== null) return;
|
||||
if (parseSideFromSearch(window.location.search) !== null) return;
|
||||
const next = ourSideToSide(os);
|
||||
if (next === null) return;
|
||||
currentSide = next;
|
||||
writeSideToURL(next);
|
||||
applyURLFilters({ side: next });
|
||||
syncRadioGroup("side", next);
|
||||
sidePrefilledFromProject = true;
|
||||
renderSideChip(next);
|
||||
@@ -765,8 +820,8 @@ function initViewToggle() {
|
||||
// /api/tools/fristenrechner round-trip — perspective is a pure
|
||||
// projection of the last response, no backend involved.
|
||||
function initPerspectiveControls() {
|
||||
currentSide = readSideFromURL();
|
||||
currentAppealTarget = readAppealTargetFromURL();
|
||||
currentSide = parseSideFromSearch(window.location.search);
|
||||
currentAppealTarget = parseAppealTargetFromSearch(window.location.search);
|
||||
syncRadioGroup("side", currentSide ?? "");
|
||||
syncRadioGroup("appeal-target", currentAppealTarget || "endentscheidung");
|
||||
syncSideHintVisibility();
|
||||
@@ -776,7 +831,7 @@ function initPerspectiveControls() {
|
||||
if (!input.checked) return;
|
||||
const v = input.value;
|
||||
currentSide = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeSideToURL(currentSide);
|
||||
applyURLFilters({ side: currentSide });
|
||||
syncSideHintVisibility();
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
@@ -794,7 +849,7 @@ function initPerspectiveControls() {
|
||||
} else {
|
||||
currentAppealTarget = "";
|
||||
}
|
||||
writeAppealTargetToURL(currentAppealTarget);
|
||||
applyURLFilters({ target: currentAppealTarget });
|
||||
scheduleCalc(0);
|
||||
});
|
||||
});
|
||||
@@ -816,28 +871,57 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
|
||||
if (dateInput) {
|
||||
dateInput.addEventListener("change", () => scheduleCalc());
|
||||
dateInput.addEventListener("input", () => scheduleCalc());
|
||||
// Hydrate trigger_date from URL on first paint so a refresh /
|
||||
// shared link reproduces the same dated timeline. URL wins over
|
||||
// the verfahrensablauf.tsx today-default that the <input> renders
|
||||
// with. parseTriggerDateFromSearch validates the shape so a
|
||||
// malformed link silently falls back to the today-default.
|
||||
const urlDate = parseTriggerDateFromSearch(window.location.search);
|
||||
if (urlDate) dateInput.value = urlDate;
|
||||
const persistDate = () => {
|
||||
applyURLFilters({ triggerDate: dateInput.value });
|
||||
};
|
||||
dateInput.addEventListener("change", () => { persistDate(); scheduleCalc(); });
|
||||
dateInput.addEventListener("input", () => { persistDate(); scheduleCalc(); });
|
||||
dateInput.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") scheduleCalc(0);
|
||||
if ((e as KeyboardEvent).key === "Enter") { persistDate(); scheduleCalc(0); }
|
||||
});
|
||||
}
|
||||
|
||||
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
|
||||
if (courtPicker) courtPicker.addEventListener("change", () => scheduleCalc(0));
|
||||
if (courtPicker) courtPicker.addEventListener("change", () => {
|
||||
writeCourtId(scenarioStorage, courtPicker.value);
|
||||
scheduleCalc(0);
|
||||
});
|
||||
|
||||
// Flag-checkbox listeners — each flip triggers a fresh calc so the
|
||||
// timeline re-projects with the new gating. ccr-flag additionally
|
||||
// enables/disables the nested inf-amend row.
|
||||
// enables/disables the nested inf-amend row. Each flip also writes
|
||||
// through to localStorage so the choice survives a reload (URL stays
|
||||
// clean; flags are scenario state, not filter chips — t-paliad-308).
|
||||
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
|
||||
if (ccrFlag) ccrFlag.addEventListener("change", () => {
|
||||
writeBoolFlag(scenarioStorage, SCENARIO_KEYS.ccr, ccrFlag.checked);
|
||||
syncInfAmendEnabled();
|
||||
// Disabling ccr also unchecks inf-amend (see syncInfAmendEnabled).
|
||||
// Mirror that into storage so the next reload doesn't repopulate a
|
||||
// disabled checkbox as checked.
|
||||
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
|
||||
if (infAmend) writeBoolFlag(scenarioStorage, SCENARIO_KEYS.infAmend, infAmend.checked);
|
||||
scheduleCalc(0);
|
||||
});
|
||||
(["inf-amend-flag", "rev-amend-flag", "rev-cci-flag"]).forEach((id) => {
|
||||
const flagStorageKeys: Record<string, string> = {
|
||||
"inf-amend-flag": SCENARIO_KEYS.infAmend,
|
||||
"rev-amend-flag": SCENARIO_KEYS.revAmend,
|
||||
"rev-cci-flag": SCENARIO_KEYS.revCci,
|
||||
};
|
||||
for (const [id, storageKey] of Object.entries(flagStorageKeys)) {
|
||||
const cb = document.getElementById(id) as HTMLInputElement | null;
|
||||
if (cb) cb.addEventListener("change", () => scheduleCalc(0));
|
||||
});
|
||||
if (cb) cb.addEventListener("change", () => {
|
||||
writeBoolFlag(scenarioStorage, storageKey, cb.checked);
|
||||
scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
|
||||
|
||||
@@ -868,16 +952,30 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
}
|
||||
|
||||
// t-paliad-290 — show-hidden toggle. Hydrate from URL, wire change
|
||||
// to URL + recalc (the backend reshapes the response — we can't just
|
||||
// re-render lastResponse since the hidden rows aren't in it when the
|
||||
// toggle was OFF).
|
||||
// Durations toggle (m/paliad#133, t-paliad-302) — sibling of the
|
||||
// notes toggle. Hover-only labels (default) become inline labels when
|
||||
// the user opts in.
|
||||
const durationsShowCb = document.getElementById("verfahrensablauf-durations-show") as HTMLInputElement | null;
|
||||
if (durationsShowCb) {
|
||||
durationsShowCb.checked = showDurations;
|
||||
durationsShowCb.addEventListener("change", () => {
|
||||
showDurations = durationsShowCb.checked;
|
||||
writeDurationsPref(showDurations);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
}
|
||||
|
||||
// t-paliad-290 — show-hidden toggle. Hydrated from localStorage at
|
||||
// module load (showHidden); each flip writes back to localStorage
|
||||
// and triggers a recalc (the backend reshapes the response — we
|
||||
// can't just re-render lastResponse since the hidden rows aren't
|
||||
// in it when the toggle was OFF).
|
||||
const showHiddenCb = document.getElementById("show-hidden-toggle") as HTMLInputElement | null;
|
||||
if (showHiddenCb) {
|
||||
showHiddenCb.checked = showHidden;
|
||||
showHiddenCb.addEventListener("change", () => {
|
||||
showHidden = showHiddenCb.checked;
|
||||
writeShowHiddenToURL(showHidden);
|
||||
writeBoolFlag(scenarioStorage, SCENARIO_KEYS.showHidden, showHidden);
|
||||
scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
@@ -885,11 +983,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initViewToggle();
|
||||
initPerspectiveControls();
|
||||
|
||||
// t-paliad-265 — per-event-card choices. Unbound surface, so commits
|
||||
// mutate the in-memory list + URL, then trigger a recalc. The
|
||||
// popover module owns the popover lifecycle; this page owns the
|
||||
// recalc + URL plumbing.
|
||||
perCardChoices = readChoicesFromURL();
|
||||
// t-paliad-265 — per-event-card choices. Unbound surface; persistence
|
||||
// is localStorage-only (t-paliad-308) so a shared link doesn't carry
|
||||
// the recipient's per-card tweaks. The popover module owns the
|
||||
// popover lifecycle; this page owns the recalc + storage plumbing.
|
||||
const timelineEl = document.getElementById("timeline-container");
|
||||
if (timelineEl) {
|
||||
attachEventCardChoices({
|
||||
@@ -900,14 +997,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
(c) => !(c.submission_code === choice.submission_code && c.choice_kind === choice.choice_kind),
|
||||
);
|
||||
perCardChoices.push(choice);
|
||||
writeChoicesToURL(perCardChoices);
|
||||
writeEventChoices(scenarioStorage, perCardChoices);
|
||||
scheduleCalc(0);
|
||||
},
|
||||
remove: (submissionCode, kind) => {
|
||||
perCardChoices = perCardChoices.filter(
|
||||
(c) => !(c.submission_code === submissionCode && c.choice_kind === kind),
|
||||
);
|
||||
writeChoicesToURL(perCardChoices);
|
||||
writeEventChoices(scenarioStorage, perCardChoices);
|
||||
scheduleCalc(0);
|
||||
},
|
||||
});
|
||||
@@ -943,8 +1040,31 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
syncTriggerEventLabel();
|
||||
});
|
||||
|
||||
// Pre-select the first proceeding tile so users see a timeline
|
||||
// immediately on landing — matches /tools/fristenrechner behaviour.
|
||||
const firstBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn");
|
||||
if (firstBtn) selectProceeding(firstBtn);
|
||||
// Pre-select the proceeding tile. URL wins: if ?proceeding= is set
|
||||
// and points at a known tile, that tile is selected without rewriting
|
||||
// the URL. Otherwise fall back to the first tile so users see a
|
||||
// timeline immediately on landing — matches /tools/fristenrechner
|
||||
// behaviour. The auto-pick does NOT write the URL so the default
|
||||
// landing stays clean (`?proceeding=` only appears once the user
|
||||
// makes an explicit choice). (t-paliad-308 / m/paliad#137)
|
||||
const urlProceeding = parseProceedingFromSearch(window.location.search);
|
||||
let initialBtn: HTMLButtonElement | null = null;
|
||||
let urlHit = false;
|
||||
if (urlProceeding) {
|
||||
initialBtn = document.querySelector<HTMLButtonElement>(
|
||||
`.proceeding-btn[data-code="${urlProceeding.replace(/"/g, '\\"')}"]`,
|
||||
);
|
||||
urlHit = initialBtn !== null;
|
||||
}
|
||||
if (!initialBtn) {
|
||||
initialBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn");
|
||||
}
|
||||
if (initialBtn) {
|
||||
// writeURL=false when the URL either already carries this code
|
||||
// (no churn) or has no proceeding (auto-default → don't pollute
|
||||
// the clean URL). Only an unknown / stale ?proceeding= triggers
|
||||
// a rewrite so the URL converges on the resolved tile.
|
||||
const writeURL = urlProceeding !== "" && !urlHit;
|
||||
selectProceeding(initialBtn, { writeURL });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
type DeadlineResponse,
|
||||
bucketDeadlinesIntoColumns,
|
||||
deadlineCardHtml,
|
||||
formatDurationLabel,
|
||||
renderColumnsBody,
|
||||
stripLeadingDurationFromNotes,
|
||||
} from "./verfahrensablauf-core";
|
||||
|
||||
// Regression tests for the editable→click-to-edit wiring on timeline date
|
||||
@@ -327,6 +329,29 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("side=defendant collapses 'both' rules into ours (no mirror) — m/paliad#135", () => {
|
||||
// When the user has committed to a perspective via `?side=`, the
|
||||
// mirror is visual noise: the same card renders twice on one row,
|
||||
// once in 'Unsere Seite' and once in 'Gegnerseite'. The card's
|
||||
// '↔ beide Seiten' indicator already conveys the both-parties
|
||||
// semantic, so collapsing into ours is sufficient.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Antrag auf Simultanübersetzung", "2026-04-27")],
|
||||
{ side: "defendant" },
|
||||
);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Antrag auf Simultanübersetzung"]);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("side=claimant collapses 'both' rules into ours (no mirror) — m/paliad#135", () => {
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Antrag auf Simultanübersetzung", "2026-04-27")],
|
||||
{ side: "claimant" },
|
||||
);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Antrag auf Simultanübersetzung"]);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rows align across columns by dueDate so same-day events stay on one grid row", () => {
|
||||
const sameDate = "2026-07-23";
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
@@ -464,3 +489,287 @@ describe("renderColumnsBody — side-aware column header labels (m/paliad#127)",
|
||||
expect(html).not.toContain(">Reaktiv<");
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-307 / m/paliad#136 Bug 1 — appeal-aware column routing.
|
||||
// All appeal rules carry party='both' (either side could be the
|
||||
// appellant). With appealAware=true + dl.appealRole set, the bucketer
|
||||
// routes by (filer matches user) instead of collapsing every 'both'
|
||||
// row into the user's column. Without a side picked, the bucketer
|
||||
// keeps the legacy mirror so every appeal rule is visible.
|
||||
describe("bucketDeadlinesIntoColumns — appeal-aware routing (t-paliad-307)", () => {
|
||||
const appeal = (
|
||||
name: string,
|
||||
role: "appellant" | "appellee",
|
||||
due: string,
|
||||
): CalculatedDeadline => ({
|
||||
code: name,
|
||||
name,
|
||||
nameEN: name,
|
||||
party: "both",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: due,
|
||||
originalDate: due,
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
appealRole: role,
|
||||
});
|
||||
|
||||
const notice = appeal("Berufungseinlegung", "appellant", "2026-07-26");
|
||||
const grounds = appeal("Berufungsbegründung", "appellant", "2026-09-26");
|
||||
const response = appeal("Berufungserwiderung", "appellee", "2026-12-26");
|
||||
|
||||
test("appealAware + side=claimant: appellant rules → ours, appellee rules → opponent", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([notice, grounds, response], {
|
||||
side: "claimant",
|
||||
appealAware: true,
|
||||
});
|
||||
const byKey = new Map(rows.map((r) => [r.key, r]));
|
||||
expect(byKey.get(notice.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
|
||||
expect(byKey.get(notice.dueDate)?.opponent).toHaveLength(0);
|
||||
expect(byKey.get(response.dueDate)?.ours).toHaveLength(0);
|
||||
expect(byKey.get(response.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
|
||||
});
|
||||
|
||||
test("appealAware + side=defendant: appellant rules → opponent, appellee rules → ours", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([notice, response], {
|
||||
side: "defendant",
|
||||
appealAware: true,
|
||||
});
|
||||
const byKey = new Map(rows.map((r) => [r.key, r]));
|
||||
expect(byKey.get(notice.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
|
||||
expect(byKey.get(notice.dueDate)?.ours).toHaveLength(0);
|
||||
expect(byKey.get(response.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
|
||||
expect(byKey.get(response.dueDate)?.opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("appealAware + side=null: mirror to both columns (every rule visible)", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([notice, response], {
|
||||
side: null,
|
||||
appealAware: true,
|
||||
});
|
||||
const byKey = new Map(rows.map((r) => [r.key, r]));
|
||||
expect(byKey.get(notice.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
|
||||
expect(byKey.get(notice.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
|
||||
expect(byKey.get(response.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
|
||||
expect(byKey.get(response.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
|
||||
});
|
||||
|
||||
test("appealAware off: appealRole is ignored and legacy bucketing applies", () => {
|
||||
// Regression guard: a stale frontend that drops `appealAware: true`
|
||||
// must not silently route via appealRole — the side selector
|
||||
// would visibly change behaviour without a UI control to opt in.
|
||||
const rows = bucketDeadlinesIntoColumns([notice, response], { side: "defendant" });
|
||||
// Legacy "side without appellant" collapse → both rows into ours.
|
||||
const allOurs = rows.flatMap((r) => r.ours.map((d) => d.name));
|
||||
expect(allOurs).toEqual(["Berufungseinlegung", "Berufungserwiderung"]);
|
||||
rows.forEach((r) => expect(r.opponent).toHaveLength(0));
|
||||
});
|
||||
|
||||
test("appealAware respects court party — court rows always route to court column", () => {
|
||||
const decision: CalculatedDeadline = {
|
||||
...notice,
|
||||
name: "Entscheidung",
|
||||
party: "court",
|
||||
appealRole: "", // court events deliberately stay empty
|
||||
dueDate: "",
|
||||
};
|
||||
const rows = bucketDeadlinesIntoColumns([decision], { side: "claimant", appealAware: true });
|
||||
expect(rows[0].court.map((d) => d.name)).toEqual(["Entscheidung"]);
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("appealAware + rule without appealRole falls back to legacy bucketing", () => {
|
||||
// A future appeal rule we forgot to map: appealRole='' falls
|
||||
// through the appealAware branch and lands in the legacy
|
||||
// side-collapse path → ours.
|
||||
const unmapped: CalculatedDeadline = { ...notice, appealRole: "" };
|
||||
const rows = bucketDeadlinesIntoColumns([unmapped], { side: "claimant", appealAware: true });
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-307 / m/paliad#136 Bug 3 — duration label appends the
|
||||
// parent rule name (or the proceeding's trigger event label for
|
||||
// root rules) so the chip reads "4 Monate nach Endentscheidung"
|
||||
// instead of the dangling "4 Monate nach".
|
||||
describe("formatDurationLabel — appends parent name (t-paliad-307)", () => {
|
||||
const dl = (overrides: Partial<CalculatedDeadline> = {}): CalculatedDeadline => ({
|
||||
code: "x",
|
||||
name: "x",
|
||||
nameEN: "x",
|
||||
party: "both",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: "",
|
||||
originalDate: "",
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
durationValue: 4,
|
||||
durationUnit: "months",
|
||||
timing: "after",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("with parent label: appends to head", () => {
|
||||
expect(formatDurationLabel(dl(), "Endentscheidung (R.118)"))
|
||||
.toBe("4 Monate nach Endentscheidung (R.118)");
|
||||
});
|
||||
|
||||
test("without parent label: bare head — caller decides whether to render", () => {
|
||||
expect(formatDurationLabel(dl())).toBe("4 Monate nach");
|
||||
});
|
||||
|
||||
test("without timing: parent is not appended (degenerate phrasing)", () => {
|
||||
// No timing == we can't form "4 Monate <timing> <parent>" cleanly,
|
||||
// so the bare "4 Monate" head stays. Pinned to catch a future
|
||||
// edit that would emit "4 Monate Endentscheidung" without a
|
||||
// preposition.
|
||||
expect(formatDurationLabel(dl({ timing: "" }), "Endentscheidung")).toBe("4 Monate");
|
||||
});
|
||||
|
||||
test("singular value: switches to .one unit key", () => {
|
||||
expect(formatDurationLabel(dl({ durationValue: 1 }), "X")).toBe("1 Monat nach X");
|
||||
});
|
||||
|
||||
test("zero / missing duration: empty string", () => {
|
||||
expect(formatDurationLabel(dl({ durationValue: 0 }), "X")).toBe("");
|
||||
expect(formatDurationLabel(dl({ durationValue: 0, durationUnit: "" }), "X")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deadlineCardHtml — duration tooltip reads parent name (t-paliad-307)", () => {
|
||||
test("root rule with non-zero duration uses opts.triggerEventLabel as parent fallback", () => {
|
||||
// upc.apl.merits.notice has no parent_id but a 2-month duration
|
||||
// off the trigger event (the appealed decision). The duration
|
||||
// tooltip must read the appeal-target label, not just "2 Monate
|
||||
// nach".
|
||||
const dl: CalculatedDeadline = {
|
||||
code: "upc.apl.merits.notice",
|
||||
name: "Berufungseinlegung",
|
||||
nameEN: "Notice of Appeal",
|
||||
party: "both",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: "2026-07-26",
|
||||
originalDate: "2026-07-26",
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
durationValue: 2,
|
||||
durationUnit: "months",
|
||||
timing: "after",
|
||||
};
|
||||
const html = deadlineCardHtml(dl, {
|
||||
showParty: false,
|
||||
editable: true,
|
||||
triggerEventLabel: "Endentscheidung (R.118)",
|
||||
});
|
||||
expect(html).toContain("title=\"2 Monate nach Endentscheidung (R.118)\"");
|
||||
});
|
||||
|
||||
test("non-root rule prefers parent rule name over triggerEventLabel", () => {
|
||||
// merits.response chains off merits.grounds; the duration label
|
||||
// should read "3 Monate nach Berufungsbegründung", not the
|
||||
// appeal-target fallback.
|
||||
const dl: CalculatedDeadline = {
|
||||
code: "upc.apl.merits.response",
|
||||
name: "Berufungserwiderung",
|
||||
nameEN: "Response to Appeal",
|
||||
party: "both",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: "2026-12-26",
|
||||
originalDate: "2026-12-26",
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
durationValue: 3,
|
||||
durationUnit: "months",
|
||||
timing: "after",
|
||||
parentRuleCode: "upc.apl.merits.grounds",
|
||||
parentRuleName: "Berufungsbegründung",
|
||||
parentRuleNameEN: "Statement of Grounds",
|
||||
};
|
||||
const html = deadlineCardHtml(dl, {
|
||||
showParty: false,
|
||||
editable: true,
|
||||
triggerEventLabel: "Endentscheidung (R.118)",
|
||||
});
|
||||
expect(html).toContain("title=\"3 Monate nach Berufungsbegründung\"");
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-307 / m/paliad#136 Bug 4 — leading "Frist N <unit> …"
|
||||
// substring is stripped before deadline_notes renders so the new
|
||||
// duration affordance and the legacy free-text don't duplicate.
|
||||
describe("stripLeadingDurationFromNotes — render-side dedup (t-paliad-307)", () => {
|
||||
test("DE: strips 'Frist 1 Monat VOR …. ' and keeps the rest", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag auf Simultanübersetzung.",
|
||||
"de",
|
||||
);
|
||||
expect(out).toBe("Antrag auf Simultanübersetzung.");
|
||||
});
|
||||
|
||||
test("DE: strips 'Frist 15 Tage ab …' when the whole notes is the duration prose", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"Frist 15 Tage ab Zustellung der Kostenentscheidung",
|
||||
"de",
|
||||
);
|
||||
expect(out).toBe("");
|
||||
});
|
||||
|
||||
test("DE: strips 'Frist beträgt 2 Monate ab …. ' (Wiedereinsetzung variant)", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"Frist beträgt 2 Monate ab Wegfall des Hindernisses (§ 123(2) PatG). Spätestens 1 Jahr.",
|
||||
"de",
|
||||
);
|
||||
expect(out).toBe("Spätestens 1 Jahr.");
|
||||
});
|
||||
|
||||
test("DE: composite 'Frist N … ODER M …' is preserved (option b follow-up)", () => {
|
||||
const composite =
|
||||
"Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung der einstweiligen Maßnahme.";
|
||||
expect(stripLeadingDurationFromNotes(composite, "de")).toBe(composite);
|
||||
});
|
||||
|
||||
test("DE: 'Frist vom Gericht' (no number) is preserved", () => {
|
||||
const out = stripLeadingDurationFromNotes("Frist vom Gericht bestimmt", "de");
|
||||
expect(out).toBe("Frist vom Gericht bestimmt");
|
||||
});
|
||||
|
||||
test("EN: strips '1 month BEFORE …. ' and keeps the rest", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"1 month BEFORE the oral hearing (R.109.1). Request for simultaneous interpretation.",
|
||||
"en",
|
||||
);
|
||||
expect(out).toBe("Request for simultaneous interpretation.");
|
||||
});
|
||||
|
||||
test("EN: strips '15-day period from …'", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"15-day period from service of the cost decision",
|
||||
"en",
|
||||
);
|
||||
expect(out).toBe("");
|
||||
});
|
||||
|
||||
test("EN: strips 'Period is N <unit> from …'", () => {
|
||||
const out = stripLeadingDurationFromNotes(
|
||||
"Period is 2 months from removal of the obstacle (Rule 136(1) EPC). Latest 12 months.",
|
||||
"en",
|
||||
);
|
||||
expect(out).toBe("Latest 12 months.");
|
||||
});
|
||||
|
||||
test("EN: empty / non-matching notes pass through unchanged", () => {
|
||||
expect(stripLeadingDurationFromNotes("", "en")).toBe("");
|
||||
expect(stripLeadingDurationFromNotes("Time limit set by the court", "en"))
|
||||
.toBe("Time limit set by the court");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,6 +95,111 @@ export interface CalculatedDeadline {
|
||||
parentRuleCode?: string;
|
||||
parentRuleName?: string;
|
||||
parentRuleNameEN?: string;
|
||||
// durationValue / durationUnit / timing surface the rule's arithmetic
|
||||
// so the timeline card can show "2 Mo. nach" on hover (and inline when
|
||||
// the "Dauern anzeigen" toggle is on). Zero-duration rules (root
|
||||
// event, court-set) carry durationValue=0 and the renderer suppresses
|
||||
// the affordance — those don't have an explainable interval.
|
||||
// (m/paliad#133, t-paliad-302)
|
||||
durationValue?: number;
|
||||
durationUnit?: string;
|
||||
timing?: string;
|
||||
// appealRole carries the rule's appeal-filer identity when the
|
||||
// server computed the timeline under an appeal_target filter:
|
||||
// "appellant" (Berufungskläger files this rule), "appellee"
|
||||
// (Berufungsbeklagter files this rule), or empty for court events
|
||||
// and non-appeal timelines. The column bucketer reads this in
|
||||
// preference to primary_party='both' so a user-perspective `?side=`
|
||||
// pick can split appeal filings into the user's column vs the
|
||||
// opponent's, instead of routing every "both" rule into the
|
||||
// user's column. (t-paliad-307 / m/paliad#136 Bug 1)
|
||||
appealRole?: "appellant" | "appellee" | "";
|
||||
// isTriggerEvent marks the synthetic row the engine prepends to the
|
||||
// timeline when computing an appeal: a court-set decision dated to
|
||||
// the trigger date with the per-appeal-target label
|
||||
// (Endentscheidung / Kostenentscheidung / Anordnung / …). The row
|
||||
// carries no real rule_id — it's a UI marker so the timeline reads
|
||||
// decision → appeal filings → next decision. (t-paliad-307 /
|
||||
// m/paliad#136 Bug 2)
|
||||
isTriggerEvent?: boolean;
|
||||
}
|
||||
|
||||
// stripLeadingDurationFromNotes drops the leading
|
||||
// "Frist N <unit> <preposition> <subject>." (DE) /
|
||||
// "N <unit> <preposition> <subject>." (EN) prefix from a rule's
|
||||
// deadline_notes so it doesn't duplicate the new duration affordance
|
||||
// added in m/paliad#133 (t-paliad-307 Bug 4).
|
||||
//
|
||||
// The duration affordance now renders the same prose as a badge on
|
||||
// the card ("4 Monate nach Endentscheidung (R.118)"); a free-text
|
||||
// notes string that opens with the same prose reads as a verbatim
|
||||
// duplicate. Only the leading-prefix shape is stripped — anything
|
||||
// after the first sentence is preserved (the editorial commentary
|
||||
// the lawyers actually want to read).
|
||||
//
|
||||
// Conservative: composite-duration prefaces with "ODER" /
|
||||
// "whichever is the longer" don't match and stay untouched — those
|
||||
// are the follow-up editorial cleanup (option b in the issue brief).
|
||||
//
|
||||
// Examples:
|
||||
// "Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag …"
|
||||
// → "Antrag …"
|
||||
// "Frist 15 Tage ab Zustellung der Kostenentscheidung"
|
||||
// → ""
|
||||
// "Frist beträgt 2 Monate ab Wegfall des Hindernisses (§ 123(2) PatG). Spätestens …"
|
||||
// → "Spätestens …"
|
||||
// "1-month period from service of the main decision"
|
||||
// → ""
|
||||
// "1 month BEFORE the oral hearing (R.109.1). Request for …"
|
||||
// → "Request for …"
|
||||
// "Period is 2 months from removal of the obstacle (Rule 136(1) EPC). Latest …"
|
||||
// → "Latest …"
|
||||
// "Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung …"
|
||||
// → unchanged (composite — option b follow-up)
|
||||
export function stripLeadingDurationFromNotes(notes: string, lang: "de" | "en"): string {
|
||||
if (!notes) return notes;
|
||||
// Terminator `(?:\.\s+|$)` matches the FIRST sentence boundary
|
||||
// (period followed by whitespace) OR end of input. Embedded dots
|
||||
// inside parenthesised citations (R.109.1, § 123(2), Rule 136(1))
|
||||
// are skipped because the char right after them isn't whitespace.
|
||||
// `[^]*?` is the JS-portable form of `.*?` with the dotAll flag —
|
||||
// any character including newlines, non-greedy.
|
||||
const re = lang === "en"
|
||||
? /^(?:Period\s+is\s+)?\d+(?:[-\s]\S+)?\s+(?:\S+\s+)?(?:before|from|after|since)\b[^]*?(?:\.\s+|$)/i
|
||||
: /^Frist\s+(?:beträgt\s+)?\d+\s+\S+\s+(?:VOR|vor|nach|ab|seit)\b[^]*?(?:\.\s+|$)/;
|
||||
return notes.replace(re, "");
|
||||
}
|
||||
|
||||
// formatDurationLabel renders the per-rule duration label for the
|
||||
// Verfahrensablauf card affordance: "2 Monate nach Endentscheidung",
|
||||
// "1 Monat vor Mündlicher Verhandlung", …
|
||||
// (m/paliad#133, t-paliad-302; parent-name append: t-paliad-307 /
|
||||
// m/paliad#136 Bug 3).
|
||||
//
|
||||
// Returns empty string for rules without a usable duration so the
|
||||
// caller can skip the tooltip / inline span entirely. Pluralisation
|
||||
// key naming mirrors the Fristenrechner event-mode renderer
|
||||
// (deadlines.event.unit.<unit>.{one,many}) — the unit and timing
|
||||
// translations already exist for /tools/fristenrechner's
|
||||
// "Was kommt nach…" mode and are reused here as the single source
|
||||
// of truth.
|
||||
//
|
||||
// `parentLabel` is the rule's anchor name (parent rule's name when
|
||||
// the rule has a parent_id; otherwise the proceeding's
|
||||
// triggerEventLabel from the wire). Empty falls back to bare
|
||||
// "<n> <unit> <timing>" — bare phrasing is the pre-fix shape and
|
||||
// remains the default for fixtures / tests that omit a parent.
|
||||
export function formatDurationLabel(dl: CalculatedDeadline, parentLabel: string = ""): string {
|
||||
const value = dl.durationValue ?? 0;
|
||||
const unit = dl.durationUnit || "";
|
||||
if (value <= 0 || !unit) return "";
|
||||
const unitKey = `deadlines.event.unit.${unit}` + (value === 1 ? ".one" : ".many");
|
||||
const unitStr = tDyn(unitKey);
|
||||
const timing = dl.timing || "";
|
||||
const timingStr = timing ? tDyn(`deadlines.event.timing.${timing}`) : "";
|
||||
const head = timingStr ? `${value} ${unitStr} ${timingStr}` : `${value} ${unitStr}`;
|
||||
if (!timingStr || !parentLabel) return head;
|
||||
return `${head} ${parentLabel}`;
|
||||
}
|
||||
|
||||
// priorityRendering returns the per-priority UX hints the save-modal
|
||||
@@ -321,15 +426,56 @@ export interface CardOpts {
|
||||
// Page shells expose a toggle ("Hinweise anzeigen") that flips this and
|
||||
// re-renders. Default false — notes are noisy on long timelines.
|
||||
showNotes?: boolean;
|
||||
// showDurations controls per-rule duration rendering on event cards
|
||||
// (m/paliad#133, t-paliad-302):
|
||||
// true → inline `<span class="timeline-duration">2 Mo. nach</span>`
|
||||
// next to the date.
|
||||
// false → hover-only tooltip on the date span (browser-native
|
||||
// `title` attribute). Cards without a usable
|
||||
// `durationValue > 0` get neither — court-set and trigger-
|
||||
// event cards have no explainable interval.
|
||||
// /tools/verfahrensablauf exposes a toggle ("Dauern anzeigen") that
|
||||
// flips this and re-renders; persisted via the localStorage key
|
||||
// `paliad.verfahrensablauf.durations-show`. Default false.
|
||||
showDurations?: boolean;
|
||||
// triggerEventLabel: per-language label of the proceeding's anchor
|
||||
// event ("Endentscheidung (R.118)" for an Endentscheidung appeal;
|
||||
// "Klageerhebung" for upc.inf.cfi; …). Used by formatDurationLabel
|
||||
// as the parent-name fallback when a rule is a root rule (no
|
||||
// parent_id) but carries a non-zero duration — e.g. the
|
||||
// Berufungseinlegung 2 months after Endentscheidung. Pages pass the
|
||||
// already-language-resolved string. (t-paliad-307 / m/paliad#136
|
||||
// Bug 3)
|
||||
triggerEventLabel?: string;
|
||||
}
|
||||
|
||||
export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
|
||||
const wantsEditable = !!opts.editable;
|
||||
const editable = wantsEditable && !dl.isRootEvent && dl.code !== "";
|
||||
const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : "";
|
||||
// Parent name for the duration label (t-paliad-307 / m/paliad#136
|
||||
// Bug 3): use the rule's parent if set, else fall back to the
|
||||
// proceeding's trigger event label (e.g. "Endentscheidung (R.118)"
|
||||
// for an Endentscheidung appeal; "Klageerhebung" for upc.inf.cfi).
|
||||
// Empty for rules whose anchor isn't surface-able — the duration
|
||||
// label degrades to the bare "<n> <unit> <timing>" form in that case.
|
||||
const parentLabelForDuration = (getLang() === "en"
|
||||
? (dl.parentRuleNameEN || dl.parentRuleName)
|
||||
: (dl.parentRuleName || dl.parentRuleNameEN)) || opts.triggerEventLabel || "";
|
||||
// Duration affordance (m/paliad#133, t-paliad-302). Computed once so
|
||||
// both the date-span tooltip and the inline meta-row span pull from
|
||||
// the same string. Empty for rules without a usable duration.
|
||||
const durationLabel = formatDurationLabel(dl, parentLabelForDuration);
|
||||
// Hover affordance on the date span: prefer the duration tooltip when
|
||||
// we have one, else fall back to the edit-hint when the cell is
|
||||
// click-to-edit. The edit affordance still works either way — the
|
||||
// title is purely advisory.
|
||||
const dateTitle = durationLabel
|
||||
? durationLabel
|
||||
: (editable ? t("deadlines.date.edit.hint") : "");
|
||||
const editAttrs = editable
|
||||
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0" title="${escAttr(t("deadlines.date.edit.hint"))}"`
|
||||
: "";
|
||||
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0"${dateTitle ? ` title="${escAttr(dateTitle)}"` : ""}`
|
||||
: (dateTitle ? ` title="${escAttr(dateTitle)}"` : "");
|
||||
// Conditional rows (t-paliad-289) replace the date column with an
|
||||
// "abhängig von <parent>" chip. The chip remains click-to-edit so
|
||||
// the user can pin a real date once known (e.g. once the oral
|
||||
@@ -425,7 +571,14 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
ruleRef = `<span class="timeline-rule">${escHtml(dl.ruleRef)}</span>`;
|
||||
}
|
||||
|
||||
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
|
||||
const rawNoteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
|
||||
// Strip the leading-duration prefix so the new duration affordance
|
||||
// doesn't duplicate what the lawyer wrote verbatim into deadline_notes
|
||||
// for those legacy rule rows that still carry it.
|
||||
// (t-paliad-307 / m/paliad#136 Bug 4)
|
||||
const noteText = rawNoteText
|
||||
? stripLeadingDurationFromNotes(rawNoteText, getLang() === "en" ? "en" : "de")
|
||||
: rawNoteText;
|
||||
const showNotes = opts.showNotes === true;
|
||||
const notesBlock = noteText && showNotes
|
||||
? `<div class="timeline-notes">${noteText}</div>`
|
||||
@@ -434,9 +587,19 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
? `<span class="timeline-note-hint" tabindex="0" role="note" aria-label="${escAttr(noteText)}" title="${escAttr(noteText)}">ⓘ</span>`
|
||||
: "";
|
||||
|
||||
const meta = (opts.showParty || ruleRef || noteHint)
|
||||
// Inline duration affordance (m/paliad#133, t-paliad-302). Only
|
||||
// emitted when the "Dauern anzeigen" toggle is on AND the rule has a
|
||||
// usable duration; the default-off hover-tooltip path is wired
|
||||
// separately on the date span itself.
|
||||
const showDurations = opts.showDurations === true;
|
||||
const durationInline = showDurations && durationLabel
|
||||
? `<span class="timeline-duration">${escHtml(durationLabel)}</span>`
|
||||
: "";
|
||||
|
||||
const meta = (opts.showParty || ruleRef || noteHint || durationInline)
|
||||
? `<div class="timeline-meta">
|
||||
${opts.showParty ? partyBadge(dl.party) : ""}
|
||||
${durationInline}
|
||||
${ruleRef}
|
||||
${noteHint}
|
||||
</div>`
|
||||
@@ -545,7 +708,32 @@ export function wireDateEditClicks(
|
||||
});
|
||||
}
|
||||
|
||||
// pickTriggerEventLabel returns the per-language trigger event label
|
||||
// from a DeadlineResponse, used as the parent-fallback for root-rule
|
||||
// duration labels. Mirrors the precedence the page-level
|
||||
// triggerEventLabelFor uses (curated server label > proceedingName
|
||||
// fallback). Distinct from the page helper in that it stays language-
|
||||
// scoped to the current getLang() — root-rule duration labels render
|
||||
// in the user's current language. (t-paliad-307 / m/paliad#136 Bug 3)
|
||||
export function pickTriggerEventLabel(data: DeadlineResponse): string {
|
||||
const lang = getLang();
|
||||
const curated = lang === "en"
|
||||
? (data.triggerEventLabelEN || data.triggerEventLabel || "")
|
||||
: (data.triggerEventLabel || data.triggerEventLabelEN || "");
|
||||
if (curated) return curated;
|
||||
return lang === "en"
|
||||
? (data.proceedingNameEN || data.proceedingName || "")
|
||||
: (data.proceedingName || data.proceedingNameEN || "");
|
||||
}
|
||||
|
||||
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
|
||||
// Resolve the trigger event label once so the duration affordance on
|
||||
// root rules (no parent) can read it as the anchor fallback. Caller-
|
||||
// provided value wins (lets the page override for sub-track flows).
|
||||
const cardOpts: CardOpts = {
|
||||
...opts,
|
||||
triggerEventLabel: opts.triggerEventLabel ?? pickTriggerEventLabel(data),
|
||||
};
|
||||
let html = '<div class="timeline">';
|
||||
for (const dl of data.deadlines) {
|
||||
const itemClasses = [
|
||||
@@ -567,7 +755,7 @@ export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { sh
|
||||
<div class="timeline-line"></div>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
${deadlineCardHtml(dl, opts)}
|
||||
${deadlineCardHtml(dl, cardOpts)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -614,6 +802,9 @@ type ColumnPosition = "ours" | "opponent";
|
||||
export interface ColumnsBodyOpts {
|
||||
editable?: boolean;
|
||||
showNotes?: boolean;
|
||||
// Forwarded to deadlineCardHtml — see CardOpts.showDurations.
|
||||
// (m/paliad#133, t-paliad-302)
|
||||
showDurations?: boolean;
|
||||
// side: which side the user is on. Drives column placement;
|
||||
// does NOT filter rows. Default null = claimant-on-the-left
|
||||
// (i.e. "ours = claimant", legacy default).
|
||||
@@ -623,6 +814,15 @@ export interface ColumnsBodyOpts {
|
||||
// (no mirror). Default null = mirror "both" into both cells
|
||||
// (legacy behaviour). Independent of `side`.
|
||||
appellant?: Side;
|
||||
// appealAware: forwarded to bucketDeadlinesIntoColumns when the
|
||||
// page is rendering an appeal_target-filtered timeline. Routes
|
||||
// each rule to its filer-perspective column via dl.appealRole
|
||||
// instead of the legacy primary_party='both' collapse.
|
||||
// (t-paliad-307 / m/paliad#136 Bug 1)
|
||||
appealAware?: boolean;
|
||||
// triggerEventLabel: forwarded to deadlineCardHtml — see CardOpts.
|
||||
// (t-paliad-307 / m/paliad#136 Bug 3)
|
||||
triggerEventLabel?: string;
|
||||
}
|
||||
|
||||
// ColumnsRow is the per-due-date bucket the renderer consumes. Public
|
||||
@@ -638,6 +838,15 @@ export interface ColumnsRow {
|
||||
export interface BucketingOpts {
|
||||
side?: Side;
|
||||
appellant?: Side;
|
||||
// appealAware: when true, rules carrying a `dl.appealRole` of
|
||||
// "appellant" / "appellee" route via the appeal role + user side
|
||||
// axis instead of the legacy primary_party='both' collapse. With
|
||||
// `side=null` the bucketer keeps the mirror semantic (both columns
|
||||
// render every appeal rule); with `side` set, "appellant" rules
|
||||
// land in the user's column when the user IS the appellant, in
|
||||
// the opponent's column otherwise — mirror for "appellee" rules.
|
||||
// (t-paliad-307 / m/paliad#136 Bug 1)
|
||||
appealAware?: boolean;
|
||||
}
|
||||
|
||||
// bucketDeadlinesIntoColumns is the pure routing primitive that
|
||||
@@ -672,6 +881,8 @@ export function bucketDeadlinesIntoColumns(
|
||||
return r;
|
||||
};
|
||||
|
||||
const appealAware = opts.appealAware === true;
|
||||
|
||||
deadlines.forEach((dl, idx) => {
|
||||
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
|
||||
const row = ensureRow(key);
|
||||
@@ -694,11 +905,41 @@ export function bucketDeadlinesIntoColumns(
|
||||
if (dl.appellantContext === "claimant" || dl.appellantContext === "defendant") {
|
||||
const perCardCol = dl.appellantContext === "claimant" ? claimantColumn : defendantColumn;
|
||||
row[perCardCol].push(dl);
|
||||
} else if (
|
||||
appealAware &&
|
||||
(dl.appealRole === "appellant" || dl.appealRole === "appellee")
|
||||
) {
|
||||
// Appeal-aware routing (t-paliad-307 / m/paliad#136 Bug 1).
|
||||
// With no side picked, mirror to both columns so every rule
|
||||
// is visible regardless of which side the user is on. With
|
||||
// a side picked, route by (filer matches user) → ours
|
||||
// column, else opponent column. side=claimant maps the
|
||||
// user to "appellant" (Berufungskläger); side=defendant
|
||||
// maps the user to "appellee" (Berufungsbeklagter).
|
||||
if (userSide === null) {
|
||||
row.ours.push(dl);
|
||||
row.opponent.push(dl);
|
||||
} else {
|
||||
const userIsAppellant = userSide === "claimant";
|
||||
const filerIsAppellant = dl.appealRole === "appellant";
|
||||
row[filerIsAppellant === userIsAppellant ? "ours" : "opponent"].push(dl);
|
||||
}
|
||||
} else if (appellantColumn !== null) {
|
||||
// Role-swap collapse: appellant initiated → both → one row
|
||||
// in appellant's column. Mirror suppressed.
|
||||
row[appellantColumn].push(dl);
|
||||
} else if (userSide !== null) {
|
||||
// Side picked but no appellant axis (first-instance Inf, Rev,
|
||||
// …): the user has committed to a perspective, so the mirror
|
||||
// is visual noise — the same card appears twice on the same
|
||||
// row, once in "Unsere Seite" and once in "Gegnerseite".
|
||||
// Collapse into ours; the "↔ beide Seiten" indicator on the
|
||||
// card already conveys that the rule applies to both parties.
|
||||
// (m/paliad#135 / t-paliad-304)
|
||||
row.ours.push(dl);
|
||||
} else {
|
||||
// No perspective picked → keep the legacy mirror so neither
|
||||
// axis is privileged. Pinned by the "default (no opts)" test.
|
||||
row.ours.push(dl);
|
||||
row.opponent.push(dl);
|
||||
}
|
||||
@@ -721,15 +962,31 @@ export function bucketDeadlinesIntoColumns(
|
||||
|
||||
export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts = {}): string {
|
||||
const userSide: Side = opts.side ?? null;
|
||||
const rows = bucketDeadlinesIntoColumns(data.deadlines, { side: userSide, appellant: opts.appellant });
|
||||
const rows = bucketDeadlinesIntoColumns(data.deadlines, {
|
||||
side: userSide,
|
||||
appellant: opts.appellant,
|
||||
appealAware: opts.appealAware,
|
||||
});
|
||||
const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant";
|
||||
|
||||
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
|
||||
const cardOpts: CardOpts = {
|
||||
showParty: false,
|
||||
editable: opts.editable,
|
||||
showNotes: opts.showNotes,
|
||||
showDurations: opts.showDurations,
|
||||
triggerEventLabel: opts.triggerEventLabel ?? pickTriggerEventLabel(data),
|
||||
};
|
||||
|
||||
// Collapsed "both" rows lose their mirror tag — there's no longer
|
||||
// a sibling row to mirror to, so the "↔ beide Seiten" hint would
|
||||
// be misleading. Keep it for the legacy mirror path.
|
||||
const showMirrorTag = !appellantPinned;
|
||||
// be misleading. Both collapse paths suppress it:
|
||||
// - appellantPinned: role-swap collapse into appellant's column
|
||||
// - userSide !== null without appellantPinned: perspective-locked
|
||||
// collapse into ours (m/paliad#135 / t-paliad-304).
|
||||
// Legacy mirror path (no side, no appellant) keeps the tag — both
|
||||
// sibling rows still render so the tag has a visual referent.
|
||||
const sideCollapse = userSide !== null;
|
||||
const showMirrorTag = !appellantPinned && !sideCollapse;
|
||||
|
||||
const renderCell = (items: CalculatedDeadline[]): string => {
|
||||
if (items.length === 0) {
|
||||
|
||||
309
frontend/src/client/views/verfahrensablauf-state.test.ts
Normal file
309
frontend/src/client/views/verfahrensablauf-state.test.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
// Unit tests for the /tools/verfahrensablauf URL + scenario-localStorage
|
||||
// state contract (t-paliad-308 / m/paliad#137). Run with `bun test`.
|
||||
//
|
||||
// The contract:
|
||||
// 1. URL params (proceeding, side, target, trigger_date) define which
|
||||
// timeline kind the user is looking at — paste-able, shareable,
|
||||
// refresh-resistant.
|
||||
// 2. localStorage (paliad.verfahrensablauf.scenario.*) holds the
|
||||
// per-user scenario tweaks (event_choices, court_id, flags,
|
||||
// show_hidden) — these never leak into a shared link.
|
||||
// 3. On hydrate, URL wins. localStorage fills the rest.
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
APPEAL_TARGETS,
|
||||
SCENARIO_KEYS,
|
||||
SCENARIO_PREFIX,
|
||||
URL_KEYS,
|
||||
applyFiltersToSearch,
|
||||
hydrate,
|
||||
makeMemoryStorage,
|
||||
parseAppealTargetFromSearch,
|
||||
parseProceedingFromSearch,
|
||||
parseSideFromSearch,
|
||||
parseTriggerDateFromSearch,
|
||||
readBoolFlag,
|
||||
readCourtId,
|
||||
readEventChoices,
|
||||
readScenario,
|
||||
writeBoolFlag,
|
||||
writeCourtId,
|
||||
writeEventChoices,
|
||||
} from "./verfahrensablauf-state";
|
||||
|
||||
describe("URL parsers — filter chips", () => {
|
||||
test("parseProceedingFromSearch returns empty string when absent", () => {
|
||||
expect(parseProceedingFromSearch("")).toBe("");
|
||||
expect(parseProceedingFromSearch("?side=claimant")).toBe("");
|
||||
});
|
||||
|
||||
test("parseProceedingFromSearch echoes the raw value", () => {
|
||||
expect(parseProceedingFromSearch("?proceeding=upc.inf.cfi")).toBe("upc.inf.cfi");
|
||||
expect(parseProceedingFromSearch("?proceeding=upc.apl.unified&side=claimant")).toBe("upc.apl.unified");
|
||||
});
|
||||
|
||||
test("parseSideFromSearch validates the enum", () => {
|
||||
expect(parseSideFromSearch("?side=claimant")).toBe("claimant");
|
||||
expect(parseSideFromSearch("?side=defendant")).toBe("defendant");
|
||||
expect(parseSideFromSearch("?side=neither")).toBe(null);
|
||||
expect(parseSideFromSearch("")).toBe(null);
|
||||
});
|
||||
|
||||
test("parseAppealTargetFromSearch only accepts canonical slugs", () => {
|
||||
for (const t of APPEAL_TARGETS) {
|
||||
expect(parseAppealTargetFromSearch(`?target=${t}`)).toBe(t);
|
||||
}
|
||||
expect(parseAppealTargetFromSearch("?target=unknown")).toBe("");
|
||||
expect(parseAppealTargetFromSearch("")).toBe("");
|
||||
});
|
||||
|
||||
test("parseTriggerDateFromSearch validates the ISO-date shape", () => {
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-05-26")).toBe("2026-05-26");
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2024-02-29")).toBe("2024-02-29"); // leap year
|
||||
});
|
||||
|
||||
test("parseTriggerDateFromSearch rejects malformed and impossible dates", () => {
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-02-30")).toBe(""); // Feb 30
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-13-01")).toBe(""); // month 13
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=tomorrow")).toBe("");
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-5-26")).toBe(""); // 1-digit month
|
||||
expect(parseTriggerDateFromSearch("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL encoder — applyFiltersToSearch", () => {
|
||||
test("empty filters preserve the existing query string", () => {
|
||||
expect(applyFiltersToSearch("?other=keep", {})).toBe("?other=keep");
|
||||
});
|
||||
|
||||
test("setting a filter writes the canonical key", () => {
|
||||
expect(applyFiltersToSearch("", { proceeding: "upc.inf.cfi" })).toBe("?proceeding=upc.inf.cfi");
|
||||
expect(applyFiltersToSearch("", { side: "claimant" })).toBe("?side=claimant");
|
||||
expect(applyFiltersToSearch("", { target: "endentscheidung" })).toBe("?target=endentscheidung");
|
||||
expect(applyFiltersToSearch("", { triggerDate: "2026-05-26" })).toBe("?trigger_date=2026-05-26");
|
||||
});
|
||||
|
||||
test("setting null / empty / undefined deletes the key", () => {
|
||||
expect(applyFiltersToSearch("?side=claimant", { side: null })).toBe("");
|
||||
expect(applyFiltersToSearch("?proceeding=upc.inf.cfi", { proceeding: "" })).toBe("");
|
||||
expect(applyFiltersToSearch("?target=endentscheidung", { target: "" })).toBe("");
|
||||
expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "" })).toBe("");
|
||||
});
|
||||
|
||||
test("invalid trigger_date is deleted (never written as-is)", () => {
|
||||
expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "bogus" })).toBe("");
|
||||
});
|
||||
|
||||
test("setting all four filters together emits all four keys", () => {
|
||||
const out = applyFiltersToSearch("", {
|
||||
proceeding: "upc.apl.unified",
|
||||
side: "defendant",
|
||||
target: "endentscheidung",
|
||||
triggerDate: "2026-05-26",
|
||||
});
|
||||
expect(out).toContain("proceeding=upc.apl.unified");
|
||||
expect(out).toContain("side=defendant");
|
||||
expect(out).toContain("target=endentscheidung");
|
||||
expect(out).toContain("trigger_date=2026-05-26");
|
||||
});
|
||||
|
||||
test("other params (project, view) are preserved", () => {
|
||||
const out = applyFiltersToSearch("?project=abc&view=timeline", { side: "claimant" });
|
||||
expect(out).toContain("project=abc");
|
||||
expect(out).toContain("view=timeline");
|
||||
expect(out).toContain("side=claimant");
|
||||
});
|
||||
|
||||
test("absent keys in the filter object don't touch existing URL values", () => {
|
||||
// Only updating side — proceeding should be untouched.
|
||||
const out = applyFiltersToSearch("?proceeding=upc.inf.cfi&side=defendant", { side: "claimant" });
|
||||
expect(out).toContain("proceeding=upc.inf.cfi");
|
||||
expect(out).toContain("side=claimant");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL round-trip — encode then parse yields the same value", () => {
|
||||
test("proceeding", () => {
|
||||
const enc = applyFiltersToSearch("", { proceeding: "upc.inf.cfi" });
|
||||
expect(parseProceedingFromSearch(enc)).toBe("upc.inf.cfi");
|
||||
});
|
||||
|
||||
test("side", () => {
|
||||
const enc = applyFiltersToSearch("", { side: "defendant" });
|
||||
expect(parseSideFromSearch(enc)).toBe("defendant");
|
||||
});
|
||||
|
||||
test("target", () => {
|
||||
const enc = applyFiltersToSearch("", { target: "kostenentscheidung" });
|
||||
expect(parseAppealTargetFromSearch(enc)).toBe("kostenentscheidung");
|
||||
});
|
||||
|
||||
test("trigger_date", () => {
|
||||
const enc = applyFiltersToSearch("", { triggerDate: "2026-05-26" });
|
||||
expect(parseTriggerDateFromSearch(enc)).toBe("2026-05-26");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scenario localStorage helpers", () => {
|
||||
test("SCENARIO_PREFIX is paliad.verfahrensablauf.scenario and all keys live under it", () => {
|
||||
expect(SCENARIO_PREFIX).toBe("paliad.verfahrensablauf.scenario");
|
||||
for (const key of Object.values(SCENARIO_KEYS)) {
|
||||
expect(key.startsWith(SCENARIO_PREFIX + ".")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("readEventChoices returns [] on empty storage", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readEventChoices(s)).toEqual([]);
|
||||
});
|
||||
|
||||
test("writeEventChoices + readEventChoices round-trip", () => {
|
||||
const s = makeMemoryStorage();
|
||||
const choices = [
|
||||
{ submission_code: "upc.inf.cfi.r12", choice_kind: "appellant" as const, choice_value: "claimant" },
|
||||
{ submission_code: "upc.inf.cfi.r30", choice_kind: "include_ccr" as const, choice_value: "1" },
|
||||
];
|
||||
writeEventChoices(s, choices);
|
||||
expect(readEventChoices(s)).toEqual(choices);
|
||||
});
|
||||
|
||||
test("writeEventChoices([]) clears the key (removeItem semantic, not empty string)", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeEventChoices(s, [{ submission_code: "r1", choice_kind: "skip", choice_value: "1" }]);
|
||||
expect(s.getItem(SCENARIO_KEYS.eventChoices)).not.toBe(null);
|
||||
writeEventChoices(s, []);
|
||||
expect(s.getItem(SCENARIO_KEYS.eventChoices)).toBe(null);
|
||||
});
|
||||
|
||||
test("readEventChoices ignores unknown choice_kind values", () => {
|
||||
const s = makeMemoryStorage();
|
||||
s.setItem(SCENARIO_KEYS.eventChoices, "r1:appellant=claimant,r2:bogus=x,r3:skip=1");
|
||||
expect(readEventChoices(s)).toEqual([
|
||||
{ submission_code: "r1", choice_kind: "appellant", choice_value: "claimant" },
|
||||
{ submission_code: "r3", choice_kind: "skip", choice_value: "1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("readCourtId returns '' on empty storage, echoes stored value otherwise", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readCourtId(s)).toBe("");
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
expect(readCourtId(s)).toBe("UPC-LD-MUC");
|
||||
});
|
||||
|
||||
test("writeCourtId('') removes the key", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
expect(s.getItem(SCENARIO_KEYS.courtId)).toBe("UPC-LD-MUC");
|
||||
writeCourtId(s, "");
|
||||
expect(s.getItem(SCENARIO_KEYS.courtId)).toBe(null);
|
||||
});
|
||||
|
||||
test("readBoolFlag / writeBoolFlag round-trip with removeItem on false", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false);
|
||||
writeBoolFlag(s, SCENARIO_KEYS.ccr, true);
|
||||
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(true);
|
||||
expect(s.getItem(SCENARIO_KEYS.ccr)).toBe("1");
|
||||
writeBoolFlag(s, SCENARIO_KEYS.ccr, false);
|
||||
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false);
|
||||
expect(s.getItem(SCENARIO_KEYS.ccr)).toBe(null);
|
||||
});
|
||||
|
||||
test("readScenario returns all fields defaulted on empty storage", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readScenario(s)).toEqual({
|
||||
eventChoices: [],
|
||||
courtId: "",
|
||||
ccr: false,
|
||||
infAmend: false,
|
||||
revAmend: false,
|
||||
revCci: false,
|
||||
showHidden: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hydration order — URL wins, localStorage fills the rest", () => {
|
||||
test("URL fills filter chips, localStorage fills scenario state", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
writeBoolFlag(s, SCENARIO_KEYS.showHidden, true);
|
||||
writeBoolFlag(s, SCENARIO_KEYS.ccr, true);
|
||||
const out = hydrate(
|
||||
"?proceeding=upc.inf.cfi&side=defendant&target=endentscheidung&trigger_date=2026-05-26",
|
||||
s,
|
||||
);
|
||||
// URL-sourced
|
||||
expect(out.proceeding).toBe("upc.inf.cfi");
|
||||
expect(out.side).toBe("defendant");
|
||||
expect(out.target).toBe("endentscheidung");
|
||||
expect(out.triggerDate).toBe("2026-05-26");
|
||||
// localStorage-sourced
|
||||
expect(out.courtId).toBe("UPC-LD-MUC");
|
||||
expect(out.showHidden).toBe(true);
|
||||
expect(out.ccr).toBe(true);
|
||||
});
|
||||
|
||||
test("absent URL → all filter fields are empty/null, localStorage still hydrates scenario", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
const out = hydrate("", s);
|
||||
expect(out.proceeding).toBe("");
|
||||
expect(out.side).toBe(null);
|
||||
expect(out.target).toBe("");
|
||||
expect(out.triggerDate).toBe("");
|
||||
expect(out.courtId).toBe("UPC-LD-MUC");
|
||||
});
|
||||
|
||||
test("absent localStorage → URL still fills filter chips, scenario defaults", () => {
|
||||
const s = makeMemoryStorage();
|
||||
const out = hydrate(
|
||||
"?proceeding=upc.apl.unified&side=claimant&target=anordnung&trigger_date=2026-07-01",
|
||||
s,
|
||||
);
|
||||
expect(out.proceeding).toBe("upc.apl.unified");
|
||||
expect(out.side).toBe("claimant");
|
||||
expect(out.target).toBe("anordnung");
|
||||
expect(out.triggerDate).toBe("2026-07-01");
|
||||
expect(out.courtId).toBe("");
|
||||
expect(out.eventChoices).toEqual([]);
|
||||
expect(out.showHidden).toBe(false);
|
||||
});
|
||||
|
||||
test("a shared link doesn't leak the recipient's scenario state in", () => {
|
||||
// Two storages: m's (loaded with court + flags) and a recipient's
|
||||
// (empty). The same URL should reproduce filter chips identically
|
||||
// but leave each user's scenario state untouched.
|
||||
const mStorage = makeMemoryStorage();
|
||||
writeCourtId(mStorage, "UPC-LD-MUC");
|
||||
writeBoolFlag(mStorage, SCENARIO_KEYS.ccr, true);
|
||||
const recipientStorage = makeMemoryStorage();
|
||||
|
||||
const sharedURL = "?proceeding=upc.inf.cfi&side=defendant&trigger_date=2026-05-26";
|
||||
|
||||
const mView = hydrate(sharedURL, mStorage);
|
||||
const recipientView = hydrate(sharedURL, recipientStorage);
|
||||
|
||||
// Filter chips identical
|
||||
expect(mView.proceeding).toBe(recipientView.proceeding);
|
||||
expect(mView.side).toBe(recipientView.side);
|
||||
expect(mView.triggerDate).toBe(recipientView.triggerDate);
|
||||
|
||||
// Scenario state diverges — recipient sees defaults
|
||||
expect(mView.courtId).toBe("UPC-LD-MUC");
|
||||
expect(recipientView.courtId).toBe("");
|
||||
expect(mView.ccr).toBe(true);
|
||||
expect(recipientView.ccr).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL key constants match the documented contract", () => {
|
||||
test("URL_KEYS uses the spec'd snake_case names", () => {
|
||||
expect(URL_KEYS.proceeding).toBe("proceeding");
|
||||
expect(URL_KEYS.side).toBe("side");
|
||||
expect(URL_KEYS.target).toBe("target");
|
||||
expect(URL_KEYS.triggerDate).toBe("trigger_date");
|
||||
});
|
||||
});
|
||||
263
frontend/src/client/views/verfahrensablauf-state.ts
Normal file
263
frontend/src/client/views/verfahrensablauf-state.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
// /tools/verfahrensablauf URL + scenario-localStorage state contract
|
||||
// (t-paliad-308 / m/paliad#137). Splits the page's persisted state into
|
||||
// two namespaces:
|
||||
//
|
||||
// URL params (filter chips — the timeline kind the user is looking
|
||||
// at; paste-able, shareable, refresh-resistant):
|
||||
// proceeding, side, target, trigger_date
|
||||
//
|
||||
// localStorage `paliad.verfahrensablauf.scenario.*` (per-user
|
||||
// scenario inputs — the noisy parts that don't belong in a URL):
|
||||
// event_choices, court_id, ccr, inf_amend, rev_amend, rev_cci,
|
||||
// show_hidden
|
||||
//
|
||||
// Hydration order: URL wins. On page load, URL fills the filter chips;
|
||||
// localStorage fills the rest. Filter-chip changes write to URL only.
|
||||
// Scenario changes write to localStorage only. A shared link from a
|
||||
// colleague reproduces the timeline kind (proceeding + side + target +
|
||||
// trigger_date) but never leaks the recipient's court / flag /
|
||||
// event_choices state in.
|
||||
//
|
||||
// All helpers in this module are pure: they take a search string (or a
|
||||
// StorageLike) and return values, no DOM. The wiring in
|
||||
// ../verfahrensablauf.ts mounts them onto window.location +
|
||||
// window.localStorage at runtime.
|
||||
|
||||
import type { EventChoice, ChoiceKind } from "./event-card-choices";
|
||||
|
||||
// ----- URL params (filter chips) ----------------------------------
|
||||
|
||||
export type Side = "claimant" | "defendant" | null;
|
||||
|
||||
export const APPEAL_TARGETS = [
|
||||
"endentscheidung",
|
||||
"kostenentscheidung",
|
||||
"anordnung",
|
||||
"schadensbemessung",
|
||||
"bucheinsicht",
|
||||
] as const;
|
||||
export type AppealTarget = (typeof APPEAL_TARGETS)[number] | "";
|
||||
|
||||
export const URL_KEYS = {
|
||||
proceeding: "proceeding",
|
||||
side: "side",
|
||||
target: "target",
|
||||
triggerDate: "trigger_date",
|
||||
} as const;
|
||||
|
||||
// parseProceedingFromSearch extracts the proceeding code. Returns ""
|
||||
// if absent. No validation against the proceeding registry — that's
|
||||
// the caller's job (an unknown code from a stale link should leave
|
||||
// the first-tile auto-select fallback running).
|
||||
export function parseProceedingFromSearch(search: string): string {
|
||||
const v = new URLSearchParams(search).get(URL_KEYS.proceeding);
|
||||
return v ?? "";
|
||||
}
|
||||
|
||||
export function parseSideFromSearch(search: string): Side {
|
||||
const raw = new URLSearchParams(search).get(URL_KEYS.side);
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
export function parseAppealTargetFromSearch(search: string): AppealTarget {
|
||||
const raw = new URLSearchParams(search).get(URL_KEYS.target) || "";
|
||||
if ((APPEAL_TARGETS as readonly string[]).includes(raw)) {
|
||||
return raw as AppealTarget;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// parseTriggerDateFromSearch validates the ISO-date shape so a
|
||||
// malformed link can't poison the date input. Accepts "YYYY-MM-DD"
|
||||
// only. Round-tripped against Date to reject 2026-02-30 etc.
|
||||
export function parseTriggerDateFromSearch(search: string): string {
|
||||
const raw = new URLSearchParams(search).get(URL_KEYS.triggerDate) || "";
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return "";
|
||||
const d = new Date(raw + "T00:00:00Z");
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
if (d.toISOString().slice(0, 10) !== raw) return "";
|
||||
return raw;
|
||||
}
|
||||
|
||||
// applyFiltersToSearch produces the canonical query string for the
|
||||
// four URL-owned params. Other params (e.g. ?view=, ?project=) are
|
||||
// preserved verbatim. Empty values are deleted, never written as
|
||||
// empty string, so the URL stays clean on the default.
|
||||
export function applyFiltersToSearch(
|
||||
search: string,
|
||||
filters: { proceeding?: string; side?: Side; target?: AppealTarget; triggerDate?: string },
|
||||
): string {
|
||||
const params = new URLSearchParams(search);
|
||||
if ("proceeding" in filters) {
|
||||
if (filters.proceeding && filters.proceeding !== "") {
|
||||
params.set(URL_KEYS.proceeding, filters.proceeding);
|
||||
} else {
|
||||
params.delete(URL_KEYS.proceeding);
|
||||
}
|
||||
}
|
||||
if ("side" in filters) {
|
||||
if (filters.side === "claimant" || filters.side === "defendant") {
|
||||
params.set(URL_KEYS.side, filters.side);
|
||||
} else {
|
||||
params.delete(URL_KEYS.side);
|
||||
}
|
||||
}
|
||||
if ("target" in filters) {
|
||||
if (filters.target && filters.target !== "") {
|
||||
params.set(URL_KEYS.target, filters.target);
|
||||
} else {
|
||||
params.delete(URL_KEYS.target);
|
||||
}
|
||||
}
|
||||
if ("triggerDate" in filters) {
|
||||
if (filters.triggerDate && /^\d{4}-\d{2}-\d{2}$/.test(filters.triggerDate)) {
|
||||
params.set(URL_KEYS.triggerDate, filters.triggerDate);
|
||||
} else {
|
||||
params.delete(URL_KEYS.triggerDate);
|
||||
}
|
||||
}
|
||||
const s = params.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
// ----- localStorage (scenario state) ------------------------------
|
||||
|
||||
export const SCENARIO_PREFIX = "paliad.verfahrensablauf.scenario";
|
||||
export const SCENARIO_KEYS = {
|
||||
eventChoices: `${SCENARIO_PREFIX}.event_choices`,
|
||||
courtId: `${SCENARIO_PREFIX}.court_id`,
|
||||
ccr: `${SCENARIO_PREFIX}.ccr`,
|
||||
infAmend: `${SCENARIO_PREFIX}.inf_amend`,
|
||||
revAmend: `${SCENARIO_PREFIX}.rev_amend`,
|
||||
revCci: `${SCENARIO_PREFIX}.rev_cci`,
|
||||
showHidden: `${SCENARIO_PREFIX}.show_hidden`,
|
||||
} as const;
|
||||
|
||||
// StorageLike is the tiny subset of the Web Storage API the scenario
|
||||
// helpers actually use. Lets the tests pass a Map-backed fake without
|
||||
// pulling in a full localStorage polyfill.
|
||||
export interface StorageLike {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
// readEventChoices is forgiving: malformed tuples or unknown
|
||||
// choice_kinds are dropped silently. Same shape as the legacy URL
|
||||
// codec (comma-separated `submission_code:kind=value`).
|
||||
export function readEventChoices(storage: StorageLike): EventChoice[] {
|
||||
const raw = storage.getItem(SCENARIO_KEYS.eventChoices);
|
||||
if (!raw) return [];
|
||||
const out: EventChoice[] = [];
|
||||
for (const tuple of raw.split(",")) {
|
||||
const m = tuple.match(/^([^:]+):([^=]+)=(.+)$/);
|
||||
if (!m) continue;
|
||||
const kind = m[2] as ChoiceKind;
|
||||
if (kind !== "appellant" && kind !== "include_ccr" && kind !== "skip") continue;
|
||||
out.push({ submission_code: m[1], choice_kind: kind, choice_value: m[3] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function writeEventChoices(storage: StorageLike, choices: EventChoice[]): void {
|
||||
if (choices.length === 0) {
|
||||
storage.removeItem(SCENARIO_KEYS.eventChoices);
|
||||
return;
|
||||
}
|
||||
const enc = choices
|
||||
.map((c) => `${c.submission_code}:${c.choice_kind}=${c.choice_value}`)
|
||||
.join(",");
|
||||
storage.setItem(SCENARIO_KEYS.eventChoices, enc);
|
||||
}
|
||||
|
||||
// readCourtId / writeCourtId — empty string == no court picked. The
|
||||
// "" value is stored as a removed key, not an empty string entry, so
|
||||
// reading it back yields null rather than "".
|
||||
export function readCourtId(storage: StorageLike): string {
|
||||
return storage.getItem(SCENARIO_KEYS.courtId) ?? "";
|
||||
}
|
||||
|
||||
export function writeCourtId(storage: StorageLike, courtId: string): void {
|
||||
if (courtId === "") {
|
||||
storage.removeItem(SCENARIO_KEYS.courtId);
|
||||
return;
|
||||
}
|
||||
storage.setItem(SCENARIO_KEYS.courtId, courtId);
|
||||
}
|
||||
|
||||
// Boolean flags — "1" / "0" string encoding, removeItem on default
|
||||
// (false for flags, also false for show_hidden) so the storage stays
|
||||
// uncluttered on a fresh page.
|
||||
export function readBoolFlag(storage: StorageLike, key: string): boolean {
|
||||
return storage.getItem(key) === "1";
|
||||
}
|
||||
|
||||
export function writeBoolFlag(storage: StorageLike, key: string, on: boolean): void {
|
||||
if (on) storage.setItem(key, "1");
|
||||
else storage.removeItem(key);
|
||||
}
|
||||
|
||||
// Read all scenario state in one call — convenience for the page's
|
||||
// load-time hydration. Caller decides whether to apply each field
|
||||
// (e.g. court_id is proceeding-specific; the page may discard the
|
||||
// stored value if the active proceeding doesn't expose a court row).
|
||||
export interface ScenarioState {
|
||||
eventChoices: EventChoice[];
|
||||
courtId: string;
|
||||
ccr: boolean;
|
||||
infAmend: boolean;
|
||||
revAmend: boolean;
|
||||
revCci: boolean;
|
||||
showHidden: boolean;
|
||||
}
|
||||
|
||||
export function readScenario(storage: StorageLike): ScenarioState {
|
||||
return {
|
||||
eventChoices: readEventChoices(storage),
|
||||
courtId: readCourtId(storage),
|
||||
ccr: readBoolFlag(storage, SCENARIO_KEYS.ccr),
|
||||
infAmend: readBoolFlag(storage, SCENARIO_KEYS.infAmend),
|
||||
revAmend: readBoolFlag(storage, SCENARIO_KEYS.revAmend),
|
||||
revCci: readBoolFlag(storage, SCENARIO_KEYS.revCci),
|
||||
showHidden: readBoolFlag(storage, SCENARIO_KEYS.showHidden),
|
||||
};
|
||||
}
|
||||
|
||||
// ----- URL → localStorage hydration order -------------------------
|
||||
|
||||
// The page's load-time contract: read URL filters, then read
|
||||
// scenario state from localStorage. URL wins on conflict — but the
|
||||
// only field that can conflict is none of them today (URL owns
|
||||
// proceeding/side/target/trigger_date; localStorage owns the rest).
|
||||
// The order matters for one edge case: if a future field migrates
|
||||
// from URL → localStorage with overlap, the URL value MUST be honored.
|
||||
|
||||
export interface HydratedState extends ScenarioState {
|
||||
proceeding: string;
|
||||
side: Side;
|
||||
target: AppealTarget;
|
||||
triggerDate: string;
|
||||
}
|
||||
|
||||
export function hydrate(search: string, storage: StorageLike): HydratedState {
|
||||
const scenario = readScenario(storage);
|
||||
return {
|
||||
proceeding: parseProceedingFromSearch(search),
|
||||
side: parseSideFromSearch(search),
|
||||
target: parseAppealTargetFromSearch(search),
|
||||
triggerDate: parseTriggerDateFromSearch(search),
|
||||
...scenario,
|
||||
};
|
||||
}
|
||||
|
||||
// makeMemoryStorage — tiny StorageLike for tests / SSR fallback.
|
||||
// Not used by the runtime page (which mounts real localStorage), but
|
||||
// kept here so test files have one well-known import.
|
||||
export function makeMemoryStorage(): StorageLike {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => (store.has(k) ? store.get(k)! : null),
|
||||
setItem: (k, v) => { store.set(k, v); },
|
||||
removeItem: (k) => { store.delete(k); },
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export function Footer(): string {
|
||||
<footer className="footer">
|
||||
<div className="container">
|
||||
<p>
|
||||
<span data-i18n="footer.text">{"© 2026 Paliad — ein Werkzeug von"}</span>{" "}
|
||||
<span data-i18n="footer.text">{"© 2026 Paliad — by"}</span>{" "}
|
||||
<a href="https://flexsiebels.de" target="_blank" rel="noopener">flexsiebels.de</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -125,6 +125,12 @@ export type I18nKey =
|
||||
| "admin.broadcasts.loading"
|
||||
| "admin.broadcasts.subtitle"
|
||||
| "admin.broadcasts.title"
|
||||
| "admin.building_blocks.action.new"
|
||||
| "admin.building_blocks.editor.empty"
|
||||
| "admin.building_blocks.heading"
|
||||
| "admin.building_blocks.loading"
|
||||
| "admin.building_blocks.subtitle"
|
||||
| "admin.building_blocks.title"
|
||||
| "admin.card.approval_policies.desc"
|
||||
| "admin.card.approval_policies.title"
|
||||
| "admin.card.audit.desc"
|
||||
@@ -1264,6 +1270,7 @@ export type I18nKey =
|
||||
| "deadlines.dpma.appeal.bgh"
|
||||
| "deadlines.dpma.appeal.bpatg"
|
||||
| "deadlines.dpma.opp.dpma"
|
||||
| "deadlines.durations.show"
|
||||
| "deadlines.empty.filtered"
|
||||
| "deadlines.empty.hint"
|
||||
| "deadlines.empty.title"
|
||||
@@ -2614,6 +2621,8 @@ export type I18nKey =
|
||||
| "submissions.draft.action.export"
|
||||
| "submissions.draft.action.new"
|
||||
| "submissions.draft.back"
|
||||
| "submissions.draft.base.hint"
|
||||
| "submissions.draft.base.label"
|
||||
| "submissions.draft.import.button"
|
||||
| "submissions.draft.language"
|
||||
| "submissions.draft.language.de"
|
||||
@@ -2626,6 +2635,8 @@ export type I18nKey =
|
||||
| "submissions.draft.parties.title"
|
||||
| "submissions.draft.preview.hint"
|
||||
| "submissions.draft.preview.title"
|
||||
| "submissions.draft.sections.hint"
|
||||
| "submissions.draft.sections.title"
|
||||
| "submissions.draft.switcher.label"
|
||||
| "submissions.draft.title"
|
||||
| "submissions.index.action.new"
|
||||
|
||||
@@ -3750,6 +3750,16 @@ input[type="range"]::-moz-range-thumb {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Per-rule duration label rendered inline in the meta row when
|
||||
"Dauern anzeigen" is on (m/paliad#133, t-paliad-302). Matches the
|
||||
sibling .timeline-rule weight so the meta line reads as one band of
|
||||
secondary metadata; non-mono so the value reads as prose ("2 Mo. nach")
|
||||
rather than a code reference. */
|
||||
.timeline-duration {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.timeline-adjusted {
|
||||
font-size: 0.78rem;
|
||||
color: var(--status-amber-fg-2);
|
||||
@@ -6114,6 +6124,479 @@ dialog.modal::backdrop {
|
||||
/* t-paliad-276 — DE/EN language toggle on the draft editor. Same look
|
||||
as the rest of the sidebar mini-controls; muted label + inline radios
|
||||
so it doesn't compete with the editor's primary inputs. */
|
||||
/* t-paliad-313 (m/paliad#141) Composer Slice A — base picker + section list. */
|
||||
.submission-draft-base-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.submission-draft-base-row label {
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.submission-draft-base-row select {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-elev-1);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.submission-draft-base-hint {
|
||||
margin: 0;
|
||||
font-size: 0.8em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-sections-wrap {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-elev-1);
|
||||
}
|
||||
|
||||
.submission-draft-sections-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.submission-draft-sections-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
.submission-draft-sections-hint {
|
||||
font-size: 0.8em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-sections-list {
|
||||
list-style: decimal inside;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.submission-draft-section {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: var(--color-bg-elev-2, var(--color-bg));
|
||||
}
|
||||
|
||||
.submission-draft-section--excluded {
|
||||
opacity: 0.55;
|
||||
background: var(--color-bg-subtle, transparent);
|
||||
}
|
||||
|
||||
.submission-draft-section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.submission-draft-section-title {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
font-size: 0.95em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.submission-draft-section-kind {
|
||||
font-size: 0.75em;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-subtle, transparent);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.submission-draft-section-excluded-badge {
|
||||
font-size: 0.75em;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.submission-draft-section-body {
|
||||
margin: 0.5rem 0 0 0;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 0.88em;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* t-paliad-313 Slice B — inline editor per section. */
|
||||
.submission-draft-section-toggle {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.submission-draft-section-toolbar {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin: 0.4rem 0 0.3rem 0;
|
||||
}
|
||||
|
||||
.submission-draft-section-toolbar-btn {
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
background: var(--color-bg-elev-1);
|
||||
font-weight: 600;
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.submission-draft-section-toolbar-btn:hover {
|
||||
background: var(--color-bg-subtle, var(--color-bg-elev-2));
|
||||
}
|
||||
|
||||
.submission-draft-section-editor {
|
||||
min-height: 3rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
background: var(--color-bg-elev-1);
|
||||
font-family: inherit;
|
||||
font-size: 0.92em;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.submission-draft-section-editor:focus {
|
||||
border-color: var(--color-accent-fg, var(--color-text));
|
||||
box-shadow: 0 0 0 2px var(--color-bg-lime-tint, transparent);
|
||||
}
|
||||
|
||||
.submission-draft-section-editor:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--color-text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.submission-draft-section--editing {
|
||||
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;
|
||||
}
|
||||
|
||||
.submission-bb-picker-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.submission-bb-picker {
|
||||
background: var(--color-bg, white);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
width: min(720px, 92vw);
|
||||
max-height: 86vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.25);
|
||||
}
|
||||
|
||||
.submission-bb-picker-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.submission-bb-picker-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.submission-bb-picker-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.submission-bb-picker-sectioninfo {
|
||||
margin: 0;
|
||||
font-size: 0.85em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-bb-picker-list {
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.submission-bb-picker-row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-elev-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.submission-bb-picker-row:hover {
|
||||
background: var(--color-bg-lime-tint, var(--color-bg-elev-2));
|
||||
}
|
||||
|
||||
.submission-bb-picker-row-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.submission-bb-picker-row-desc {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.85em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-bb-picker-row-preview {
|
||||
margin: 0.25rem 0 0 0;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 0.8em;
|
||||
color: var(--color-text-muted);
|
||||
white-space: pre-wrap;
|
||||
max-height: 4em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.submission-bb-picker-vis {
|
||||
font-size: 0.7em;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
background: var(--color-bg-subtle, transparent);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.submission-bb-picker-vis--private { background: #fde2e2; color: #8a2a2a; }
|
||||
.submission-bb-picker-vis--team { background: #fff4d6; color: #7a5d12; }
|
||||
.submission-bb-picker-vis--firm { background: #def5e2; color: #266e34; }
|
||||
.submission-bb-picker-vis--global { background: #dce8fb; color: #1f437a; }
|
||||
|
||||
.submission-bb-picker-empty {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* t-paliad-315 Slice C — /admin/submission-building-blocks editor */
|
||||
.admin-bb-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 280px) 1fr minmax(180px, 240px);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-bb-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.admin-bb-list-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
text-align: left;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-elev-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-bb-list-row--active {
|
||||
background: var(--color-bg-lime-tint, var(--color-bg-elev-2));
|
||||
border-color: var(--color-accent-fg, var(--color-text));
|
||||
}
|
||||
|
||||
.admin-bb-list-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.admin-bb-list-meta {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.7em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-bb-list-section {
|
||||
background: var(--color-bg-subtle, transparent);
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.admin-bb-list-vis {
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.admin-bb-list-vis--private { background: #fde2e2; color: #8a2a2a; }
|
||||
.admin-bb-list-vis--team { background: #fff4d6; color: #7a5d12; }
|
||||
.admin-bb-list-vis--firm { background: #def5e2; color: #266e34; }
|
||||
.admin-bb-list-vis--global { background: #dce8fb; color: #1f437a; }
|
||||
|
||||
.admin-bb-list-draft {
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-bb-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.admin-bb-form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.admin-bb-form-row--checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.admin-bb-form-row > span {
|
||||
font-size: 0.85em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-bb-form-hint {
|
||||
font-size: 0.75em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-bb-form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-bb-versions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.admin-bb-version-row {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
font-size: 0.78em;
|
||||
}
|
||||
|
||||
.admin-bb-version-meta {
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.admin-bb-empty {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-language-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -6220,7 +6703,7 @@ dialog.modal::backdrop {
|
||||
align-items: baseline;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface-alt, #fafafa);
|
||||
background: var(--color-surface-2);
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -6378,7 +6861,7 @@ dialog.modal::backdrop {
|
||||
}
|
||||
|
||||
.submissions-new-chip:hover {
|
||||
background: var(--color-surface-alt, #f4f4f4);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.submissions-new-chip--active {
|
||||
@@ -6416,7 +6899,7 @@ dialog.modal::backdrop {
|
||||
}
|
||||
|
||||
.submissions-new-project-item:hover {
|
||||
background: var(--color-surface-alt, #f4f4f4);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.submissions-new-project-title {
|
||||
@@ -6431,7 +6914,7 @@ dialog.modal::backdrop {
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0 0 1.25rem;
|
||||
background: var(--color-surface-alt, #f7f7f0);
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 4px solid var(--color-accent, #c6f41c);
|
||||
border-radius: 6px;
|
||||
@@ -6454,7 +6937,7 @@ dialog.modal::backdrop {
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0.6rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background: var(--color-surface-alt, #f7f7f0);
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
@@ -6582,7 +7065,7 @@ dialog.modal::backdrop {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-surface-alt, #f7f7f0);
|
||||
background: var(--color-bg-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
@@ -6705,7 +7188,7 @@ dialog.modal::backdrop {
|
||||
margin-left: 0.3rem;
|
||||
padding: 0 0.4em;
|
||||
border-radius: 3px;
|
||||
background: var(--color-surface-alt, #f7f7f0);
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@@ -7912,7 +8395,7 @@ dialog.modal::backdrop {
|
||||
.collab-invite-hint {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--color-surface-alt, var(--color-bg-lime-tint));
|
||||
background: var(--color-bg-lime-tint);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.85rem;
|
||||
@@ -16572,7 +17055,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-surface-alt, #f4f4f4);
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
@@ -16626,7 +17109,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-alt, #f4f4f4);
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
@@ -16648,7 +17131,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
}
|
||||
|
||||
.smart-timeline-kind-chip--projected {
|
||||
background: var(--color-surface-alt, #f4f4f4);
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -16715,7 +17198,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
|
||||
.smart-timeline-add-choice:hover:not(:disabled) {
|
||||
border-color: var(--color-accent-fg);
|
||||
background: var(--color-surface-alt, #fafafa);
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
|
||||
.smart-timeline-add-choice--primary {
|
||||
|
||||
@@ -109,6 +109,27 @@ export function renderSubmissionDraft(): string {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-313 (m/paliad#141) Composer Slice A —
|
||||
base picker. Hydrated by client/submission-draft.ts
|
||||
once /api/submission-bases returns. Disabled
|
||||
for pre-Composer drafts (base_id NULL); switching
|
||||
autosaves the draft. */}
|
||||
<div
|
||||
className="submission-draft-base-row"
|
||||
id="submission-draft-base-row"
|
||||
style="display:none">
|
||||
<label htmlFor="submission-draft-base" data-i18n="submissions.draft.base.label">
|
||||
Vorlagenbasis
|
||||
</label>
|
||||
<select id="submission-draft-base" />
|
||||
<p
|
||||
className="submission-draft-base-hint"
|
||||
id="submission-draft-base-hint"
|
||||
data-i18n="submissions.draft.base.hint">
|
||||
Steuert Schriftarten, Briefkopf und Abschnitts-Defaults.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-276 — output language toggle (DE/EN).
|
||||
Hydrated by client/submission-draft.ts; switching
|
||||
autosaves the draft and re-renders the preview. */}
|
||||
@@ -202,6 +223,29 @@ export function renderSubmissionDraft(): string {
|
||||
<div className="submission-draft-variables" id="submission-draft-variables" />
|
||||
</aside>
|
||||
|
||||
{/* t-paliad-313 (m/paliad#141) Composer Slice A —
|
||||
read-only section list. Painted from
|
||||
view.sections. Empty/hidden for pre-Composer
|
||||
drafts where no rows have been seeded. Slice B
|
||||
turns these into in-place editable prose blocks. */}
|
||||
<section
|
||||
className="submission-draft-sections-wrap"
|
||||
id="submission-draft-sections-wrap"
|
||||
style="display:none">
|
||||
<header className="submission-draft-sections-header">
|
||||
<h2 data-i18n="submissions.draft.sections.title">Abschnitte</h2>
|
||||
<span
|
||||
className="submission-draft-sections-hint"
|
||||
data-i18n="submissions.draft.sections.hint">
|
||||
Inhalt pro Abschnitt — Autosave nach 500 ms. Letztes Layout in Word.
|
||||
</span>
|
||||
</header>
|
||||
<ol
|
||||
className="submission-draft-sections-list"
|
||||
id="submission-draft-sections-list"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Preview pane — read-only HTML render of the merged
|
||||
document body. Re-renders on autosave round-trip. */}
|
||||
<section className="submission-draft-preview-wrap">
|
||||
|
||||
@@ -341,6 +341,13 @@ export function renderVerfahrensablauf(): string {
|
||||
<input type="checkbox" id="fristen-notes-show" />
|
||||
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
|
||||
</label>
|
||||
{/* Durations toggle (m/paliad#133, t-paliad-302).
|
||||
Default off — hover-tooltips on date spans are
|
||||
the always-on path. */}
|
||||
<label className="fristen-notes-option">
|
||||
<input type="checkbox" id="verfahrensablauf-durations-show" />
|
||||
<span data-i18n="deadlines.durations.show">Dauern anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="timeline-container">
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
-- 138_appeal_target_backfill_merits_order DOWN — t-paliad-303, m/paliad#134
|
||||
--
|
||||
-- Removes 'schadensbemessung' from the merits-track rules and
|
||||
-- 'bucheinsicht' from the order-track rules, restoring the pre-137
|
||||
-- shape (endentscheidung-only / anordnung-only / kostenentscheidung-only).
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 0. Audit reason (required by mig 079 trigger for any UPDATE on
|
||||
-- paliad.deadline_rules).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 138 DOWN: t-paliad-303 — strip Schadensbemessung/Bucheinsicht from applies_to_target per m/paliad#134',
|
||||
true);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. Strip new targets via array_remove.
|
||||
--
|
||||
-- WHERE clauses pinned to upc.apl.unified to avoid touching unrelated
|
||||
-- rules that might have been added later under other proceeding types.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
-- 1a. Remove schadensbemessung from merits-track rows.
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = array_remove(dr.applies_to_target, 'schadensbemessung')
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'schadensbemessung' = ANY(dr.applies_to_target);
|
||||
|
||||
-- 1b. Remove bucheinsicht from order-track rows.
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = array_remove(dr.applies_to_target, 'bucheinsicht')
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'bucheinsicht' = ANY(dr.applies_to_target);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. Sanity check — no row may carry the new targets after the down.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
schad_left int;
|
||||
buch_left int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO schad_left
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'schadensbemessung' = ANY(dr.applies_to_target);
|
||||
SELECT COUNT(*) INTO buch_left
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'bucheinsicht' = ANY(dr.applies_to_target);
|
||||
|
||||
IF schad_left > 0 THEN
|
||||
RAISE EXCEPTION '[mig 138 DOWN] FAILED — % rows still carry schadensbemessung', schad_left;
|
||||
END IF;
|
||||
IF buch_left > 0 THEN
|
||||
RAISE EXCEPTION '[mig 138 DOWN] FAILED — % rows still carry bucheinsicht', buch_left;
|
||||
END IF;
|
||||
RAISE NOTICE '[mig 138 DOWN] stripped schadensbemessung + bucheinsicht from upc.apl.unified rules';
|
||||
END $$;
|
||||
@@ -0,0 +1,232 @@
|
||||
-- 138_appeal_target_backfill_merits_order — t-paliad-303, m/paliad#134
|
||||
--
|
||||
-- Slice B1 (mig 134) introduced the unified upc.apl.unified proceeding type
|
||||
-- with 5 appeal_target enum values: endentscheidung, kostenentscheidung,
|
||||
-- anordnung, schadensbemessung, bucheinsicht. The first three each carry
|
||||
-- rules; schadensbemessung and bucheinsicht returned an empty timeline
|
||||
-- because no rules referenced them yet.
|
||||
--
|
||||
-- m's 2026-05-26 decision (#134): extend applies_to_target on the existing
|
||||
-- rules — Schadensbemessung := merits track (R.224 anchored on R.118
|
||||
-- substantive decisions), Bucheinsicht := order track (R.220.2 +
|
||||
-- R.224.2.b + R.235.2 + R.237 + R.238.2 etc.). Legal premise verified
|
||||
-- against the 16 live rules — every endentscheidung rule is a generic
|
||||
-- R.224 merits step, every anordnung rule is a generic R.220/224/235/237/
|
||||
-- 238 order step. No rule carries content specific to a particular kind
|
||||
-- of underlying decision/order. Audit on the comment trail of #134.
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 0. Audit reason (required by mig 079 trigger for any UPDATE on
|
||||
-- paliad.deadline_rules — both UPDATEs below trigger it).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 138: t-paliad-303 — extend applies_to_target for Schadensbemessung (merits) + Bucheinsicht (order) per m/paliad#134',
|
||||
true);
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. Audit-first DO block.
|
||||
--
|
||||
-- Resolve upc.apl.unified, count the rows we are about to touch, and
|
||||
-- RAISE EXCEPTION if anything looks wrong (proceeding type missing,
|
||||
-- merits/order rule counts off, or a rule already carries the new
|
||||
-- target — which would mean an earlier partial run).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
rec record;
|
||||
upc_apl_id int;
|
||||
merits_count int;
|
||||
order_count int;
|
||||
schad_already int;
|
||||
buch_already int;
|
||||
BEGIN
|
||||
SELECT id INTO upc_apl_id
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code = 'upc.apl.unified';
|
||||
IF upc_apl_id IS NULL THEN
|
||||
RAISE EXCEPTION '[mig 138] upc.apl.unified proceeding_type not found — mig 134 must run first';
|
||||
END IF;
|
||||
RAISE NOTICE '[mig 138] upc.apl.unified proceeding_type_id = %', upc_apl_id;
|
||||
|
||||
SELECT COUNT(*) INTO merits_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE proceeding_type_id = upc_apl_id
|
||||
AND is_active = true
|
||||
AND 'endentscheidung' = ANY(applies_to_target);
|
||||
SELECT COUNT(*) INTO order_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE proceeding_type_id = upc_apl_id
|
||||
AND is_active = true
|
||||
AND 'anordnung' = ANY(applies_to_target);
|
||||
|
||||
RAISE NOTICE '[mig 138] live counts: endentscheidung=% anordnung=%', merits_count, order_count;
|
||||
IF merits_count <> 7 THEN
|
||||
RAISE EXCEPTION '[mig 138] expected 7 endentscheidung rules under upc.apl.unified, got %', merits_count;
|
||||
END IF;
|
||||
IF order_count <> 7 THEN
|
||||
RAISE EXCEPTION '[mig 138] expected 7 anordnung rules under upc.apl.unified, got %', order_count;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO schad_already
|
||||
FROM paliad.deadline_rules
|
||||
WHERE proceeding_type_id = upc_apl_id
|
||||
AND is_active = true
|
||||
AND 'schadensbemessung' = ANY(applies_to_target);
|
||||
SELECT COUNT(*) INTO buch_already
|
||||
FROM paliad.deadline_rules
|
||||
WHERE proceeding_type_id = upc_apl_id
|
||||
AND is_active = true
|
||||
AND 'bucheinsicht' = ANY(applies_to_target);
|
||||
IF schad_already > 0 THEN
|
||||
RAISE EXCEPTION '[mig 138] % rules already carry schadensbemessung — partial run?', schad_already;
|
||||
END IF;
|
||||
IF buch_already > 0 THEN
|
||||
RAISE EXCEPTION '[mig 138] % rules already carry bucheinsicht — partial run?', buch_already;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '[mig 138] rules to extend with schadensbemessung (merits track):';
|
||||
FOR rec IN
|
||||
SELECT dr.id, dr.rule_code, dr.legal_source, dr.name, dr.applies_to_target
|
||||
FROM paliad.deadline_rules dr
|
||||
WHERE dr.proceeding_type_id = upc_apl_id
|
||||
AND dr.is_active = true
|
||||
AND 'endentscheidung' = ANY(dr.applies_to_target)
|
||||
ORDER BY dr.sequence_order, dr.rule_code NULLS LAST
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 138] merits % % % pre=% → post=%',
|
||||
COALESCE(rec.rule_code, '(no-code)'),
|
||||
COALESCE(rec.legal_source, '(no-source)'),
|
||||
rec.name,
|
||||
rec.applies_to_target,
|
||||
rec.applies_to_target || 'schadensbemessung'::text;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE '[mig 138] rules to extend with bucheinsicht (order track):';
|
||||
FOR rec IN
|
||||
SELECT dr.id, dr.rule_code, dr.legal_source, dr.name, dr.applies_to_target
|
||||
FROM paliad.deadline_rules dr
|
||||
WHERE dr.proceeding_type_id = upc_apl_id
|
||||
AND dr.is_active = true
|
||||
AND 'anordnung' = ANY(dr.applies_to_target)
|
||||
ORDER BY dr.sequence_order, dr.rule_code NULLS LAST
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 138] order % % % pre=% → post=%',
|
||||
COALESCE(rec.rule_code, '(no-code)'),
|
||||
COALESCE(rec.legal_source, '(no-source)'),
|
||||
rec.name,
|
||||
rec.applies_to_target,
|
||||
rec.applies_to_target || 'bucheinsicht'::text;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. Extend applies_to_target.
|
||||
--
|
||||
-- Narrow WHERE clauses key off upc.apl.unified + existing target +
|
||||
-- absence of new target, so the UPDATEs are idempotent in spirit
|
||||
-- (the audit block above already RAISE EXCEPTIONed if any row
|
||||
-- already had the new value).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
-- 2a. Schadensbemessung := merits track (7 rules expected).
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = applies_to_target || 'schadensbemessung'::text
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'endentscheidung' = ANY(dr.applies_to_target)
|
||||
AND NOT ('schadensbemessung' = ANY(dr.applies_to_target));
|
||||
|
||||
-- 2b. Bucheinsicht := order track (7 rules expected).
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET applies_to_target = applies_to_target || 'bucheinsicht'::text
|
||||
FROM paliad.proceeding_types pt
|
||||
WHERE pt.id = dr.proceeding_type_id
|
||||
AND pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'anordnung' = ANY(dr.applies_to_target)
|
||||
AND NOT ('bucheinsicht' = ANY(dr.applies_to_target));
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. Post-migration sanity check.
|
||||
--
|
||||
-- Hard-fail on any divergence: the two new targets must each cover
|
||||
-- 7 rules, the original three targets must be unchanged in count,
|
||||
-- and no rule has lost its prior target.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
schad_post int;
|
||||
buch_post int;
|
||||
end_post int;
|
||||
anord_post int;
|
||||
cost_post int;
|
||||
target_distribution record;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO schad_post
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'schadensbemessung' = ANY(dr.applies_to_target);
|
||||
SELECT COUNT(*) INTO buch_post
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'bucheinsicht' = ANY(dr.applies_to_target);
|
||||
SELECT COUNT(*) INTO end_post
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'endentscheidung' = ANY(dr.applies_to_target);
|
||||
SELECT COUNT(*) INTO anord_post
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'anordnung' = ANY(dr.applies_to_target);
|
||||
SELECT COUNT(*) INTO cost_post
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified'
|
||||
AND dr.is_active = true
|
||||
AND 'kostenentscheidung' = ANY(dr.applies_to_target);
|
||||
|
||||
RAISE NOTICE '[mig 138] post: schadensbemessung=% bucheinsicht=% endentscheidung=% anordnung=% kostenentscheidung=%',
|
||||
schad_post, buch_post, end_post, anord_post, cost_post;
|
||||
|
||||
IF schad_post <> 7 THEN
|
||||
RAISE EXCEPTION '[mig 138] FAILED — expected 7 schadensbemessung rules, got %', schad_post;
|
||||
END IF;
|
||||
IF buch_post <> 7 THEN
|
||||
RAISE EXCEPTION '[mig 138] FAILED — expected 7 bucheinsicht rules, got %', buch_post;
|
||||
END IF;
|
||||
IF end_post <> 7 THEN
|
||||
RAISE EXCEPTION '[mig 138] FAILED — endentscheidung count drifted: expected 7, got %', end_post;
|
||||
END IF;
|
||||
IF anord_post <> 7 THEN
|
||||
RAISE EXCEPTION '[mig 138] FAILED — anordnung count drifted: expected 7, got %', anord_post;
|
||||
END IF;
|
||||
IF cost_post <> 2 THEN
|
||||
RAISE EXCEPTION '[mig 138] FAILED — kostenentscheidung count drifted: expected 2, got %', cost_post;
|
||||
END IF;
|
||||
|
||||
FOR target_distribution IN
|
||||
SELECT unnest(applies_to_target) AS target, COUNT(*) AS n
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE pt.code = 'upc.apl.unified' AND dr.is_active = true
|
||||
GROUP BY unnest(applies_to_target)
|
||||
ORDER BY 1
|
||||
LOOP
|
||||
RAISE NOTICE '[mig 138] post: applies_to_target=% count=%',
|
||||
target_distribution.target, target_distribution.n;
|
||||
END LOOP;
|
||||
END $$;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 139_deadline_rules_unified_view (down) — Slice B.3, t-paliad-305
|
||||
--
|
||||
-- Drops the view. The underlying paliad.sequencing_rules /
|
||||
-- procedural_events / legal_sources tables are untouched (they own the
|
||||
-- data — the view is just a projection).
|
||||
|
||||
DROP VIEW IF EXISTS paliad.deadline_rules_unified;
|
||||
122
internal/db/migrations/139_deadline_rules_unified_view.up.sql
Normal file
122
internal/db/migrations/139_deadline_rules_unified_view.up.sql
Normal file
@@ -0,0 +1,122 @@
|
||||
-- 139_deadline_rules_unified_view — Slice B.3 read cutover (t-paliad-305 / m/paliad#93)
|
||||
--
|
||||
-- Creates paliad.deadline_rules_unified — a Postgres VIEW that
|
||||
-- re-projects paliad.sequencing_rules + paliad.procedural_events +
|
||||
-- paliad.legal_sources back into the legacy paliad.deadline_rules
|
||||
-- column shape.
|
||||
--
|
||||
-- Why a view instead of rewriting every SELECT in Go:
|
||||
--
|
||||
-- - 19 read sites across 11 service files reference
|
||||
-- paliad.deadline_rules. Rewriting each by hand multiplies the
|
||||
-- opportunity for off-by-one bugs in the JOIN.
|
||||
-- - The view has the same column names + types as the legacy table,
|
||||
-- so the change in Go is a 1-token substitution per query
|
||||
-- (FROM paliad.deadline_rules → FROM paliad.deadline_rules_unified)
|
||||
-- with no struct or scanner changes.
|
||||
-- - When B.4 drops paliad.deadline_rules, this view stays — it
|
||||
-- becomes the canonical legacy-shape reader for any code that
|
||||
-- hasn't been migrated to direct sr/pe/ls reads.
|
||||
--
|
||||
-- Column mapping (per design §4.2):
|
||||
-- - id, proceeding_type_id, parent_id, primary_party, duration_*,
|
||||
-- timing, sequence_order, is_spawn/court_set/bilateral, priority,
|
||||
-- rule_code, rule_codes, deadline_notes(_en), condition_expr,
|
||||
-- choices_offered, applies_to_target, trigger_event_id,
|
||||
-- spawn_proceeding_type_id, anchor_alt, alt_duration_*,
|
||||
-- alt_rule_code, combine_op, lifecycle_state, draft_of,
|
||||
-- published_at, is_active, created_at, updated_at, spawn_label
|
||||
-- → from paliad.sequencing_rules
|
||||
-- - submission_code → procedural_events.code
|
||||
-- - name, name_en, description→ procedural_events
|
||||
-- - event_type → procedural_events.event_kind (renamed)
|
||||
-- - concept_id → procedural_events
|
||||
-- - legal_source → legal_sources.citation (via legal_source_id FK)
|
||||
--
|
||||
-- The view is READ-ONLY by default. Writes still go to the underlying
|
||||
-- tables — RuleEditorService is refactored in the same slice to write
|
||||
-- directly to sr/pe/ls. paliad.deadline_rules is FROZEN from B.3 onward
|
||||
-- (no new writes); the dual-write helper from B.2 is decommissioned.
|
||||
|
||||
-- The CHECK constraint on sequencing_rules.primary_party doesn't exist
|
||||
-- yet (mig 135 only constrained deadline_rules.primary_party). The view
|
||||
-- inherits whatever value sr.primary_party carries; mig 136's backfill
|
||||
-- set sr.primary_party = dr.primary_party so the canonical four-value
|
||||
-- vocab is already in place. A later slice can add the same CHECK to
|
||||
-- sequencing_rules itself.
|
||||
|
||||
CREATE OR REPLACE VIEW paliad.deadline_rules_unified AS
|
||||
SELECT
|
||||
sr.id,
|
||||
sr.proceeding_type_id,
|
||||
sr.parent_id,
|
||||
pe.code AS submission_code,
|
||||
pe.name,
|
||||
pe.name_en,
|
||||
pe.description,
|
||||
sr.primary_party,
|
||||
pe.event_kind AS event_type,
|
||||
sr.duration_value,
|
||||
sr.duration_unit,
|
||||
sr.timing,
|
||||
sr.alt_duration_value,
|
||||
sr.alt_duration_unit,
|
||||
sr.alt_rule_code,
|
||||
sr.anchor_alt,
|
||||
sr.combine_op,
|
||||
sr.rule_code,
|
||||
sr.deadline_notes,
|
||||
sr.deadline_notes_en,
|
||||
sr.sequence_order,
|
||||
sr.is_spawn,
|
||||
sr.spawn_label,
|
||||
sr.spawn_proceeding_type_id,
|
||||
sr.is_bilateral,
|
||||
sr.is_court_set,
|
||||
sr.priority,
|
||||
sr.condition_expr,
|
||||
pe.concept_id,
|
||||
ls.citation AS legal_source,
|
||||
sr.trigger_event_id,
|
||||
sr.rule_codes,
|
||||
sr.choices_offered,
|
||||
sr.applies_to_target,
|
||||
sr.lifecycle_state,
|
||||
sr.draft_of,
|
||||
sr.published_at,
|
||||
sr.is_active,
|
||||
sr.created_at,
|
||||
sr.updated_at
|
||||
FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
LEFT JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id;
|
||||
|
||||
COMMENT ON VIEW paliad.deadline_rules_unified IS
|
||||
'Slice B.3 (mig 139, t-paliad-305): legacy-shape projection over '
|
||||
'sequencing_rules + procedural_events + legal_sources. Read-only — '
|
||||
'writes go directly to the three underlying tables via '
|
||||
'RuleEditorService. Survives B.4 destructive drop of '
|
||||
'paliad.deadline_rules; the view will then be the only '
|
||||
'legacy-shape reader.';
|
||||
|
||||
-- Post-apply integrity check: confirm the view's row count matches the
|
||||
-- live sequencing_rules row count. A mismatch would indicate either a
|
||||
-- mid-deploy race (rare) or a JOIN issue (the LEFT JOIN to legal_sources
|
||||
-- never drops rows, the INNER JOIN to procedural_events drops sr rows
|
||||
-- whose procedural_event_id is NULL — but that column is NOT NULL on
|
||||
-- the table so it can't happen). Belt-and-braces.
|
||||
DO $$
|
||||
DECLARE
|
||||
v_view_count int;
|
||||
v_sr_count int;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_view_count FROM paliad.deadline_rules_unified;
|
||||
SELECT COUNT(*) INTO v_sr_count FROM paliad.sequencing_rules;
|
||||
IF v_view_count <> v_sr_count THEN
|
||||
RAISE EXCEPTION '[mig 139] FAILED POST: view row count % does not match sequencing_rules row count %. '
|
||||
'Possible cause: a sequencing_rules row references a procedural_event_id that does not exist (NOT NULL FK should prevent this).',
|
||||
v_view_count, v_sr_count;
|
||||
END IF;
|
||||
RAISE NOTICE '[mig 139] view OK — deadline_rules_unified rows = % (= sequencing_rules)',
|
||||
v_view_count;
|
||||
END $$;
|
||||
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;
|
||||
13
internal/db/migrations/145_scenarios.down.sql
Normal file
13
internal/db/migrations/145_scenarios.down.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- 145_scenarios — DOWN
|
||||
--
|
||||
-- Reverses mig 145. Drops the FK on paliad.projects, the table, the
|
||||
-- trigger function, and the RLS policies (CASCADE on table drop kills
|
||||
-- policies). Any data in paliad.scenarios is lost on down.
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
DROP COLUMN IF EXISTS active_scenario_id;
|
||||
|
||||
DROP TRIGGER IF EXISTS scenarios_touch_updated_at_trg ON paliad.scenarios;
|
||||
DROP FUNCTION IF EXISTS paliad.scenarios_touch_updated_at();
|
||||
|
||||
DROP TABLE IF EXISTS paliad.scenarios CASCADE;
|
||||
170
internal/db/migrations/145_scenarios.up.sql
Normal file
170
internal/db/migrations/145_scenarios.up.sql
Normal file
@@ -0,0 +1,170 @@
|
||||
-- 145_scenarios — Slice D, m/paliad#124 §5 (revised)
|
||||
--
|
||||
-- Creates paliad.scenarios + paliad.projects.active_scenario_id FK.
|
||||
-- A scenario is a named composition of existing proceedings + flags
|
||||
-- + per-card choices + anchor dates the user can switch between for
|
||||
-- a project (project_id NOT NULL) OR save as an abstract template on
|
||||
-- /tools/verfahrensablauf (project_id IS NULL).
|
||||
--
|
||||
-- m's 2026-05-26 picks (AskUserQuestion round, doc commit 6e58595):
|
||||
-- Q1: composition shape → primary+spawned (v1); multi-proceeding
|
||||
-- peer compose is the v2 goal. spec.jsonb
|
||||
-- architected for N entries from day 1.
|
||||
-- Q2: scope → per-project + abstract.
|
||||
-- Q3: trigger dates → per-anchor overrides over one base date.
|
||||
-- Q4: storage → NEW paliad.scenarios table with jsonb
|
||||
-- spec (NOT a project_event_choices column
|
||||
-- extension).
|
||||
--
|
||||
-- "users should not add their own rules" (m, t-paliad-301) — scenarios
|
||||
-- compose existing rules, never author new ones. spec.proceedings[*].code
|
||||
-- must resolve to an existing active paliad.proceeding_types row;
|
||||
-- spec.proceedings[*].anchor_overrides keys must resolve to existing
|
||||
-- submission_codes. Validation happens at the application layer
|
||||
-- (ScenarioService.validateSpec) — not in DB CHECK constraints (too
|
||||
-- expensive to express in pure SQL).
|
||||
--
|
||||
-- Migration number: 145. Coordination check 2026-05-26 17:38: curie's
|
||||
-- B.2-B.6 migrations land in the 139-143 range. 144 reserved as buffer.
|
||||
-- 145 is the next safe claim.
|
||||
--
|
||||
-- ADDITIVE ONLY: CREATE TABLE, ALTER ADD COLUMN, indexes, RLS policies.
|
||||
-- Down drops everything. No backfill (zero existing scenarios on day 1).
|
||||
--
|
||||
-- See docs/design-litigation-planner-2026-05-26.md §5 + §18.4 for the
|
||||
-- design.
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 1. The scenarios table
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE TABLE paliad.scenarios (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- project_id NULL = abstract scenario (saved Verfahrensablauf
|
||||
-- template, no Akte). project_id NOT NULL = scenario attached to
|
||||
-- a real Akte.
|
||||
project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text NULL,
|
||||
-- spec carries the full composition. Shape documented in the
|
||||
-- design doc §5; the application validates structure before write.
|
||||
spec jsonb NOT NULL,
|
||||
created_by uuid NULL REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- Within a single project, scenario names are unique. Abstract
|
||||
-- scenarios are unique per (created_by, name) so two users can
|
||||
-- each keep a "with_ccr" template without colliding. NULLS NOT
|
||||
-- DISTINCT means a single user can have one "name" per
|
||||
-- (project_id, created_by) tuple, where NULL project_id +
|
||||
-- NULL created_by is a single global namespace (used only by
|
||||
-- seed / system scenarios — none today).
|
||||
CONSTRAINT scenarios_unique_per_scope
|
||||
UNIQUE NULLS NOT DISTINCT (project_id, created_by, name),
|
||||
|
||||
-- Non-empty name.
|
||||
CONSTRAINT scenarios_name_nonempty CHECK (char_length(name) > 0),
|
||||
|
||||
-- Non-empty spec — at least an object. The application checks
|
||||
-- structure (version, proceedings[], base_trigger_date format).
|
||||
CONSTRAINT scenarios_spec_object CHECK (jsonb_typeof(spec) = 'object')
|
||||
);
|
||||
|
||||
CREATE INDEX scenarios_project_id_idx
|
||||
ON paliad.scenarios(project_id) WHERE project_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX scenarios_abstract_user_idx
|
||||
ON paliad.scenarios(created_by) WHERE project_id IS NULL;
|
||||
|
||||
COMMENT ON TABLE paliad.scenarios IS
|
||||
'Named compositions of existing proceedings + flags + per-card '
|
||||
'choices + anchor dates. project_id NULL = abstract template; '
|
||||
'project_id NOT NULL = attached to an Akte. Design: '
|
||||
'docs/design-litigation-planner-2026-05-26.md §5. (Slice D)';
|
||||
|
||||
COMMENT ON COLUMN paliad.scenarios.spec IS
|
||||
'jsonb composition spec. Shape: {version: int, base_trigger_date: '
|
||||
'ISO date, proceedings: [{code, role, flags[], per_card_choices, '
|
||||
'anchor_overrides, skip_rules[]}, ...]}. Validated at write-time '
|
||||
'by ScenarioService.validateSpec.';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 2. paliad.projects.active_scenario_id FK
|
||||
--
|
||||
-- NULL = use today's ad-hoc per-card choice state from
|
||||
-- paliad.project_event_choices (pre-scenario behaviour preserved).
|
||||
-- Non-NULL = the project's current SmartTimeline / Akte-Fristenrechner
|
||||
-- render reads from this scenario's spec instead.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN active_scenario_id uuid NULL
|
||||
REFERENCES paliad.scenarios(id) ON DELETE SET NULL;
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.active_scenario_id IS
|
||||
'FK to paliad.scenarios. NULL = read choices from '
|
||||
'paliad.project_event_choices (legacy). Non-NULL = read from the '
|
||||
'pointed scenario.spec.';
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 3. RLS — mirror paliad.project_event_choices's pattern (mig 129).
|
||||
--
|
||||
-- Project-scoped scenarios (project_id NOT NULL) inherit team visibility
|
||||
-- via paliad.can_see_project. Abstract scenarios (project_id IS NULL)
|
||||
-- are private to created_by — only the author can read / write them.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
ALTER TABLE paliad.scenarios ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Project-scoped: team visibility.
|
||||
DROP POLICY IF EXISTS scenarios_project_select ON paliad.scenarios;
|
||||
CREATE POLICY scenarios_project_select ON paliad.scenarios
|
||||
FOR SELECT
|
||||
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS scenarios_project_mutate ON paliad.scenarios;
|
||||
CREATE POLICY scenarios_project_mutate ON paliad.scenarios
|
||||
FOR ALL
|
||||
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
WITH CHECK (project_id IS NOT NULL AND paliad.can_see_project(project_id));
|
||||
|
||||
-- Abstract: owner-only.
|
||||
DROP POLICY IF EXISTS scenarios_abstract_select ON paliad.scenarios;
|
||||
CREATE POLICY scenarios_abstract_select ON paliad.scenarios
|
||||
FOR SELECT
|
||||
USING (project_id IS NULL AND created_by = auth.uid());
|
||||
|
||||
DROP POLICY IF EXISTS scenarios_abstract_mutate ON paliad.scenarios;
|
||||
CREATE POLICY scenarios_abstract_mutate ON paliad.scenarios
|
||||
FOR ALL
|
||||
USING (project_id IS NULL AND created_by = auth.uid())
|
||||
WITH CHECK (project_id IS NULL AND created_by = auth.uid());
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 4. updated_at trigger (mirrors other paliad tables that carry
|
||||
-- updated_at — keep it in lockstep with row mutations).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.scenarios_touch_updated_at()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER scenarios_touch_updated_at_trg
|
||||
BEFORE UPDATE ON paliad.scenarios
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION paliad.scenarios_touch_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- 5. Informational NOTICE — schema-only migration, zero rows added.
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE '[mig 145] paliad.scenarios created (0 rows; awaits API usage)';
|
||||
RAISE NOTICE '[mig 145] paliad.projects.active_scenario_id added (all rows NULL initially)';
|
||||
END $$;
|
||||
3
internal/db/migrations/146_submission_bases.down.sql
Normal file
3
internal/db/migrations/146_submission_bases.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- t-paliad-313: revert submission_bases catalog.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.submission_bases;
|
||||
173
internal/db/migrations/146_submission_bases.up.sql
Normal file
173
internal/db/migrations/146_submission_bases.up.sql
Normal file
@@ -0,0 +1,173 @@
|
||||
-- t-paliad-313 (m/paliad#141): Composer Slice A — submission base catalog.
|
||||
--
|
||||
-- paliad.submission_bases is a thin pointer table — each row maps a
|
||||
-- short, stable slug ("hlc-letterhead", "neutral", …) onto a Gitea path
|
||||
-- that holds the actual .docx body, plus a JSON section-spec describing
|
||||
-- the base's default section set, stylemap, and per-section seed
|
||||
-- Markdown. The .docx in Gitea stays the source of truth for the
|
||||
-- chrome, fonts, paragraph styles, and (in later slices) the
|
||||
-- {{#section:KEY}} anchors. The DB row carries the listable metadata
|
||||
-- the picker needs.
|
||||
--
|
||||
-- Visibility: every authenticated user SELECTs (the catalog is shared
|
||||
-- firm-wide). Mutations are admin-only and enforced in Go at the
|
||||
-- handler layer — RLS only gates reads.
|
||||
--
|
||||
-- Slice A seeds two rows:
|
||||
-- 1. hlc-letterhead — points at the existing HLC firm skeleton
|
||||
-- (_firm-skeleton.docx with HL Patents Style typography).
|
||||
-- 2. neutral — points at the universal _skeleton.docx.
|
||||
-- Specialist bases (lg-duesseldorf, upc-formal) land in Slice E with
|
||||
-- their own .docx authoring task.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.submission_bases (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug text NOT NULL UNIQUE,
|
||||
firm text,
|
||||
proceeding_family text,
|
||||
label_de text NOT NULL,
|
||||
label_en text NOT NULL,
|
||||
description_de text,
|
||||
description_en text,
|
||||
gitea_path text NOT NULL,
|
||||
section_spec jsonb NOT NULL,
|
||||
is_default_for text[] NOT NULL DEFAULT '{}'::text[],
|
||||
is_active bool NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS submission_bases_firm_family_idx
|
||||
ON paliad.submission_bases (firm, proceeding_family) WHERE is_active;
|
||||
|
||||
ALTER TABLE paliad.submission_bases ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS submission_bases_select ON paliad.submission_bases;
|
||||
CREATE POLICY submission_bases_select
|
||||
ON paliad.submission_bases FOR SELECT TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- INSERT / UPDATE / DELETE intentionally absent — admin-only mutations
|
||||
-- happen via the handler layer with explicit role checks. No RLS path
|
||||
-- for mutations means RLS denies them by default.
|
||||
|
||||
DROP TRIGGER IF EXISTS submission_bases_set_updated_at ON paliad.submission_bases;
|
||||
CREATE TRIGGER submission_bases_set_updated_at
|
||||
BEFORE UPDATE ON paliad.submission_bases
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
|
||||
|
||||
COMMENT ON TABLE paliad.submission_bases IS
|
||||
't-paliad-313: Composer base catalog. One row per base template (HLC letterhead, neutral, …) pointing at a .docx in Gitea + a JSON section spec.';
|
||||
|
||||
-- Seed: HLC letterhead + neutral skeleton. The section_spec carries the
|
||||
-- 10 default sections (letterhead, caption, introduction, requests,
|
||||
-- facts, legal_argument, evidence, exhibits, closing, signature) with
|
||||
-- their kinds, default order, and bilingual labels. seed_md_de /
|
||||
-- seed_md_en are populated for the bag-driven sections (letterhead,
|
||||
-- caption, signature); the remaining sections seed empty.
|
||||
--
|
||||
-- exhibits.included=false by default (lawyer opts in when an attachment
|
||||
-- list applies). Every other section ships included=true.
|
||||
|
||||
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
|
||||
('hlc-letterhead', 'HLC', NULL,
|
||||
'HLC-Briefkopf', 'HLC letterhead',
|
||||
'Mit HL Patents Style — Firmen-Header, Schriftarten, Absatzformaten.',
|
||||
'With HL Patents Style — firm header, fonts, paragraph styles.',
|
||||
'6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx',
|
||||
jsonb_build_object(
|
||||
'version', 1,
|
||||
'stylemap', jsonb_build_object(
|
||||
'paragraph', 'HLpat-Body-B0',
|
||||
'heading_1', 'HLpat-Heading-H1',
|
||||
'heading_2', 'HLpat-Heading-H2',
|
||||
'heading_3', 'HLpat-Heading-H3',
|
||||
'list_bullet', 'HLpat-Body-B0',
|
||||
'list_numbered', 'HLpat-Body-B0',
|
||||
'blockquote', 'HLpat-Body-B1'
|
||||
),
|
||||
'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}}, {{user.office}}',
|
||||
'seed_md_en', E'Submission by {{firm.name}}\n\n{{user.display_name}}, {{user.office}}'),
|
||||
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}}**\nvertreten durch {{parties.claimant.0.representative}}\n\n— Klägerin —\n\ngegen\n\n**{{parties.defendant.0.name}}**\nvertreten durch {{parties.defendant.0.representative}}\n\n— Beklagte —\n\nAktenzeichen: {{project.case_number}}\n{{project.court}}',
|
||||
'seed_md_en', E'In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n\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}}\n{{user.office}}',
|
||||
'seed_md_en', E'{{user.display_name}}\n{{user.office}}')
|
||||
)
|
||||
),
|
||||
'{}'::text[]
|
||||
),
|
||||
('neutral', NULL, NULL,
|
||||
'Neutraler Schriftsatz', 'Neutral skeleton',
|
||||
'Universelle Vorlage ohne firmenspezifisches Branding.',
|
||||
'Universal template with no firm-specific branding.',
|
||||
'6 - material/Templates/Word/Paliad/HLC/_skeleton.docx',
|
||||
jsonb_build_object(
|
||||
'version', 1,
|
||||
'stylemap', jsonb_build_object(
|
||||
'paragraph', 'Normal',
|
||||
'heading_1', 'Heading 1',
|
||||
'heading_2', 'Heading 2',
|
||||
'heading_3', 'Heading 3',
|
||||
'list_bullet', 'Normal',
|
||||
'list_numbered', 'Normal',
|
||||
'blockquote', '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}}',
|
||||
'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}}'),
|
||||
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,5 @@
|
||||
-- t-paliad-313: revert Composer columns on submission_drafts.
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
DROP COLUMN IF EXISTS composer_meta,
|
||||
DROP COLUMN IF EXISTS base_id;
|
||||
31
internal/db/migrations/147_submission_drafts_composer.up.sql
Normal file
31
internal/db/migrations/147_submission_drafts_composer.up.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- t-paliad-313 (m/paliad#141): Composer Slice A — point submission_drafts at a base.
|
||||
--
|
||||
-- Two purely-additive columns on paliad.submission_drafts:
|
||||
--
|
||||
-- base_id uuid — FK to paliad.submission_bases. NULL on existing
|
||||
-- drafts (Slice A explicitly does NOT auto-upgrade pre-Composer
|
||||
-- rows — that's Slice C). NEW drafts created post-Composer get
|
||||
-- base_id seeded by SubmissionDraftService.Create from the firm
|
||||
-- default for the proceeding family. ON DELETE SET NULL keeps a
|
||||
-- draft renderable via the v1 fallback chain even if its base is
|
||||
-- removed; the lawyer picks a new base via the sidebar.
|
||||
--
|
||||
-- composer_meta jsonb — Composer-specific metadata. For Slice A this
|
||||
-- carries the seed-time section order so the editor paints without
|
||||
-- a join. Future slices may add hidden_sections, active_locale,
|
||||
-- etc.
|
||||
--
|
||||
-- No data backfill, no auto-upgrade — pre-Composer drafts keep base_id
|
||||
-- NULL and render via the existing v1 path. The Go side has the
|
||||
-- corresponding gate (base_id IS NULL OR no submission_sections rows →
|
||||
-- v1 path).
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ADD COLUMN IF NOT EXISTS base_id uuid REFERENCES paliad.submission_bases(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS composer_meta jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
COMMENT ON COLUMN paliad.submission_drafts.base_id IS
|
||||
't-paliad-313: Composer base reference. NULL = pre-Composer draft, renders via v1 fallback chain. ON DELETE SET NULL.';
|
||||
|
||||
COMMENT ON COLUMN paliad.submission_drafts.composer_meta IS
|
||||
't-paliad-313: Composer-side metadata (section_order, hidden_sections, …). jsonb, default {}.';
|
||||
3
internal/db/migrations/148_submission_sections.down.sql
Normal file
3
internal/db/migrations/148_submission_sections.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- t-paliad-313: revert submission_sections table.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.submission_sections;
|
||||
116
internal/db/migrations/148_submission_sections.up.sql
Normal file
116
internal/db/migrations/148_submission_sections.up.sql
Normal file
@@ -0,0 +1,116 @@
|
||||
-- t-paliad-313 (m/paliad#141): Composer Slice A — per-draft section rows.
|
||||
--
|
||||
-- paliad.submission_sections holds one row per (draft, section_key) for
|
||||
-- Composer-mode drafts. Slice A seeds rows on draft create from the
|
||||
-- base's section_spec.defaults; the editor renders them read-only. Slice
|
||||
-- B turns them editable, Slice F adds reorder/hide/add-custom.
|
||||
--
|
||||
-- kind values per the design (Q10 ratification — no *_auto kind):
|
||||
-- 'prose' — free Markdown content (default).
|
||||
-- 'requests' — Anträge-style content (editor may add auto-numbering
|
||||
-- later; Slice A treats identical to 'prose').
|
||||
-- 'evidence' — Beweisangebote (editor may prefix lines with
|
||||
-- 'Beweis: '; Slice A treats identical to 'prose').
|
||||
--
|
||||
-- Visibility flows through draft_id → submission_drafts → can_see_project
|
||||
-- + owner-scoped. RLS policies mirror the four-policy shape on
|
||||
-- submission_drafts so seeding from the Go service stays inside the
|
||||
-- same RLS envelope.
|
||||
--
|
||||
-- content_md_de + content_md_en both NOT NULL DEFAULT '' so neither
|
||||
-- side blocks the bilingual-by-construction render path. Empty content
|
||||
-- renders as the missing-content marker per the editor's contract.
|
||||
--
|
||||
-- Per the brief (head's instruction msg #2392) Slice A does NOT auto-
|
||||
-- upgrade the 11 pre-Composer drafts — those remain base_id=NULL with
|
||||
-- no section rows. The v1 fallback render path stays compiled in to
|
||||
-- keep them working.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.submission_sections (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
draft_id uuid NOT NULL REFERENCES paliad.submission_drafts(id) ON DELETE CASCADE,
|
||||
section_key text NOT NULL,
|
||||
order_index int NOT NULL,
|
||||
kind text NOT NULL,
|
||||
label_de text NOT NULL,
|
||||
label_en text NOT NULL,
|
||||
included bool NOT NULL DEFAULT true,
|
||||
content_md_de text NOT NULL DEFAULT '',
|
||||
content_md_en text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT submission_sections_kind_check
|
||||
CHECK (kind IN ('prose', 'requests', 'evidence')),
|
||||
CONSTRAINT submission_sections_unique_per_draft
|
||||
UNIQUE (draft_id, section_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS submission_sections_draft_idx
|
||||
ON paliad.submission_sections (draft_id, order_index);
|
||||
|
||||
ALTER TABLE paliad.submission_sections ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS submission_sections_select ON paliad.submission_sections;
|
||||
CREATE POLICY submission_sections_select
|
||||
ON paliad.submission_sections FOR SELECT TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.submission_drafts d
|
||||
WHERE d.id = paliad.submission_sections.draft_id
|
||||
AND d.user_id = auth.uid()
|
||||
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_sections_insert ON paliad.submission_sections;
|
||||
CREATE POLICY submission_sections_insert
|
||||
ON paliad.submission_sections FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.submission_drafts d
|
||||
WHERE d.id = paliad.submission_sections.draft_id
|
||||
AND d.user_id = auth.uid()
|
||||
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_sections_update ON paliad.submission_sections;
|
||||
CREATE POLICY submission_sections_update
|
||||
ON paliad.submission_sections FOR UPDATE TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.submission_drafts d
|
||||
WHERE d.id = paliad.submission_sections.draft_id
|
||||
AND d.user_id = auth.uid()
|
||||
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.submission_drafts d
|
||||
WHERE d.id = paliad.submission_sections.draft_id
|
||||
AND d.user_id = auth.uid()
|
||||
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_sections_delete ON paliad.submission_sections;
|
||||
CREATE POLICY submission_sections_delete
|
||||
ON paliad.submission_sections FOR DELETE TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.submission_drafts d
|
||||
WHERE d.id = paliad.submission_sections.draft_id
|
||||
AND d.user_id = auth.uid()
|
||||
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
|
||||
)
|
||||
);
|
||||
|
||||
DROP TRIGGER IF EXISTS submission_sections_set_updated_at ON paliad.submission_sections;
|
||||
CREATE TRIGGER submission_sections_set_updated_at
|
||||
BEFORE UPDATE ON paliad.submission_sections
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
|
||||
|
||||
COMMENT ON TABLE paliad.submission_sections IS
|
||||
't-paliad-313: per-draft Composer section rows. Slice A: seeded on draft create from base.section_spec.defaults, rendered read-only. Slice B: editable. RLS mirrors submission_drafts (owner-scoped + can_see_project).';
|
||||
@@ -0,0 +1,4 @@
|
||||
-- t-paliad-315: revert building blocks library.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.submission_building_block_admin_versions;
|
||||
DROP TABLE IF EXISTS paliad.submission_building_blocks;
|
||||
118
internal/db/migrations/149_submission_building_blocks.up.sql
Normal file
118
internal/db/migrations/149_submission_building_blocks.up.sql
Normal file
@@ -0,0 +1,118 @@
|
||||
-- t-paliad-315 (m/paliad#141): Composer Slice C — building blocks library.
|
||||
--
|
||||
-- Per the design at docs/design-submission-generator-v2-2026-05-26.md §4.4
|
||||
-- and the Q2 / Q9 ratifications:
|
||||
--
|
||||
-- Q2 (m, 2026-05-26): building blocks are plain text paste sources.
|
||||
-- No building_block_id reference is stored on submission_sections —
|
||||
-- insertion is a one-way copy of content_md_<lang> into the section.
|
||||
-- This table records the library; submission_sections doesn't know
|
||||
-- where its content came from.
|
||||
--
|
||||
-- Q9 (m, 2026-05-26): four visibility tiers — private / team / firm
|
||||
-- / global. Picker filtering and RLS SELECT predicate both honour
|
||||
-- the tier. Tier upgrades (private → team/firm/global) go through
|
||||
-- admin moderation in later slices; Slice C starts with admin-only
|
||||
-- mutations (no user-initiated rows yet).
|
||||
--
|
||||
-- The _admin_versions companion table mirrors the email-templates
|
||||
-- retention=20 audit history. It is INTERNAL to the admin editor —
|
||||
-- not referenced from submission_sections, not exposed to the lawyer.
|
||||
-- It exists so accidental delete + accidental overwrite are
|
||||
-- recoverable.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.submission_building_blocks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug text NOT NULL,
|
||||
firm text, -- e.g. 'HLC', NULL = cross-firm
|
||||
section_key text NOT NULL, -- which section kind this block fits
|
||||
proceeding_family text, -- 'de.inf.lg', NULL = any family
|
||||
title_de text NOT NULL,
|
||||
title_en text NOT NULL,
|
||||
description_de text,
|
||||
description_en text,
|
||||
content_md_de text NOT NULL DEFAULT '',
|
||||
content_md_en text NOT NULL DEFAULT '',
|
||||
author_id uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
visibility text NOT NULL, -- 'private' | 'team' | 'firm' | 'global'
|
||||
is_published bool NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
|
||||
CONSTRAINT submission_building_blocks_visibility_check
|
||||
CHECK (visibility IN ('private', 'team', 'firm', 'global')),
|
||||
CONSTRAINT submission_building_blocks_unique_slug_per_firm
|
||||
UNIQUE (slug, firm)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS submission_building_blocks_section_visibility_idx
|
||||
ON paliad.submission_building_blocks (section_key, visibility, firm, proceeding_family)
|
||||
WHERE deleted_at IS NULL AND is_published;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS submission_building_blocks_author_idx
|
||||
ON paliad.submission_building_blocks (author_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
ALTER TABLE paliad.submission_building_blocks ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- SELECT policy: coarse-grained RLS that admits every non-deleted
|
||||
-- block to any authenticated user. The Go-side BuildingBlockService
|
||||
-- applies the fine-grained tier predicate (private / team / firm /
|
||||
-- global) using branding.Name + team-membership joins. This split
|
||||
-- keeps the SQL simple and lets the tier semantics evolve in code
|
||||
-- without RLS migrations.
|
||||
--
|
||||
-- The exception below is 'private': only the author sees their own
|
||||
-- private rows. That's the hard line where a tier upgrade is
|
||||
-- substantive enough to warrant DB-level enforcement.
|
||||
DROP POLICY IF EXISTS submission_building_blocks_select ON paliad.submission_building_blocks;
|
||||
CREATE POLICY submission_building_blocks_select
|
||||
ON paliad.submission_building_blocks FOR SELECT TO authenticated
|
||||
USING (
|
||||
deleted_at IS NULL
|
||||
AND (
|
||||
visibility <> 'private'
|
||||
OR author_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- INSERT / UPDATE / DELETE intentionally absent — admin mutations
|
||||
-- happen at the Go handler layer with explicit adminGate. RLS without
|
||||
-- mutation policies denies them by default.
|
||||
|
||||
DROP TRIGGER IF EXISTS submission_building_blocks_set_updated_at ON paliad.submission_building_blocks;
|
||||
CREATE TRIGGER submission_building_blocks_set_updated_at
|
||||
BEFORE UPDATE ON paliad.submission_building_blocks
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
|
||||
|
||||
COMMENT ON TABLE paliad.submission_building_blocks IS
|
||||
't-paliad-315: Composer building-block library. Plain text paste sources for section content (no lineage tracked on sections per Q2 ratification). 4-tier visibility per Q9.';
|
||||
|
||||
|
||||
-- _admin_versions: append-only history per block. Admin-side only;
|
||||
-- not referenced from submission_sections. Retention 20 per block,
|
||||
-- GCed in the same transaction as the Save (mirrors email-templates).
|
||||
CREATE TABLE IF NOT EXISTS paliad.submission_building_block_admin_versions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
building_block_id uuid NOT NULL REFERENCES paliad.submission_building_blocks(id) ON DELETE CASCADE,
|
||||
content_md_de text NOT NULL,
|
||||
content_md_en text NOT NULL,
|
||||
title_de text NOT NULL,
|
||||
title_en text NOT NULL,
|
||||
edited_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS submission_building_block_admin_versions_block_idx
|
||||
ON paliad.submission_building_block_admin_versions (building_block_id, created_at DESC);
|
||||
|
||||
ALTER TABLE paliad.submission_building_block_admin_versions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Admin-only audit; the handler layer gates this via adminGate and
|
||||
-- writes via SECURITY DEFINER paths or admin-role SQL. No RLS SELECT
|
||||
-- policy exists, so non-admin users get an empty result set.
|
||||
|
||||
COMMENT ON TABLE paliad.submission_building_block_admin_versions IS
|
||||
't-paliad-315: append-only history per building block. Admin-side only; retention 20 rows per block, GCed at Save time.';
|
||||
@@ -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;
|
||||
@@ -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,60 @@ 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.
|
||||
type adminRuleResponse struct {
|
||||
*models.DeadlineRule
|
||||
Code *string `json:"code,omitempty"`
|
||||
EventKind *string `json:"event_kind,omitempty"`
|
||||
}
|
||||
|
||||
// wrapRuleResponse builds the dual-emit wrapper from a service result.
|
||||
// Same values, two keys per concept — no semantic change.
|
||||
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.
|
||||
func wrapRuleListResponse(rows []models.DeadlineRule) []adminRuleResponse {
|
||||
out := make([]adminRuleResponse, len(rows))
|
||||
for i := range rows {
|
||||
out[i] = wrapRuleResponse(&rows[i])
|
||||
}
|
||||
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 +128,8 @@ func handleAdminListRules(w http.ResponseWriter, r *http.Request) {
|
||||
writeRuleEditorError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
adminRuleDeprecationHeaders(w)
|
||||
writeJSON(w, http.StatusOK, wrapRuleListResponse(rows))
|
||||
}
|
||||
|
||||
// GET /admin/api/rules/{id}
|
||||
@@ -91,7 +147,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 +165,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 +194,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 +224,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 +247,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 +270,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 +293,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 +486,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -111,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
|
||||
@@ -402,6 +430,37 @@ func fetchFirmSkeletonBytes(ctx context.Context) ([]byte, string, error) {
|
||||
return fetchSubmissionTemplateSlug(ctx, firmSkeletonSubmissionSlug)
|
||||
}
|
||||
|
||||
// composerBaseSlugMap routes a Composer base.slug to the existing
|
||||
// fileRegistry slug whose Gitea object backs it (t-paliad-313 Slice B).
|
||||
// Slice A seeded two bases that already share .docx files with the v1
|
||||
// fallback chain — no new Gitea uploads needed for those. Future bases
|
||||
// (e.g. lg-duesseldorf, upc-formal in Slice E) register their own
|
||||
// fileRegistry entries via the same shape and add a row here.
|
||||
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,
|
||||
// pulled from the shared Gitea proxy cache. ErrComposerBaseNotProxied
|
||||
// when the slug has no registered fileRegistry entry — a base authored
|
||||
// without a file-registry mapping (rare; admin oversight) renders as
|
||||
// "Vorlagenbasis nicht erreichbar" upstream of this call.
|
||||
var ErrComposerBaseNotProxied = errors.New("composer base: Gitea slug not registered")
|
||||
|
||||
func fetchComposerBaseBytes(ctx context.Context, base *services.SubmissionBase) ([]byte, string, error) {
|
||||
if base == nil {
|
||||
return nil, "", fmt.Errorf("composer base: nil base")
|
||||
}
|
||||
slug, ok := composerBaseSlugMap[base.Slug]
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("%w: base slug %q", ErrComposerBaseNotProxied, base.Slug)
|
||||
}
|
||||
return fetchSubmissionTemplateSlug(ctx, slug)
|
||||
}
|
||||
|
||||
// fetchSubmissionTemplateSlug is the shared cache-aware fetcher used by
|
||||
// the firm-skeleton and universal-skeleton accessors. Factored out so
|
||||
// the two paths can't drift apart on caching semantics.
|
||||
|
||||
@@ -116,10 +116,27 @@ type Services struct {
|
||||
// t-paliad-238 — dedicated Submissions/Schriftsätze editor.
|
||||
SubmissionDraft *services.SubmissionDraftService
|
||||
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice A + B — base catalog,
|
||||
// per-draft section rows, render-pipeline assembler. All three
|
||||
// nil in DATABASE_URL-less deploys (the Composer surfaces return
|
||||
// 503 / hide the picker).
|
||||
SubmissionBase *services.BaseService
|
||||
SubmissionSection *services.SectionService
|
||||
SubmissionComposer *services.SubmissionComposer
|
||||
|
||||
// t-paliad-315 Composer Slice C — building-block library + admin
|
||||
// editor. Per Q2: paste sources only, no lineage on sections.
|
||||
SubmissionBuildingBlock *services.BuildingBlockService
|
||||
|
||||
// t-paliad-265 / m/paliad#96 — per-event-card optional choices on
|
||||
// the Verfahrensablauf timeline.
|
||||
EventChoice *services.EventChoiceService
|
||||
|
||||
// Slice D (m/paliad#124 §5, mig 145) — named scenario compositions
|
||||
// per project or as abstract templates. Nil when DATABASE_URL is
|
||||
// unset; the /api/scenarios routes return 503 in that case.
|
||||
Scenario *services.ScenarioService
|
||||
|
||||
// Paliadin is wired when DATABASE_URL is set. The concrete backend
|
||||
// is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
|
||||
// (remote → mRiver via SSH) or local tmux availability. Stays nil
|
||||
@@ -182,8 +199,13 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
projection: svc.Projection,
|
||||
export: svc.Export,
|
||||
backup: svc.Backup,
|
||||
submissionDraft: svc.SubmissionDraft,
|
||||
eventChoice: svc.EventChoice,
|
||||
submissionDraft: svc.SubmissionDraft,
|
||||
submissionBase: svc.SubmissionBase,
|
||||
submissionSection: svc.SubmissionSection,
|
||||
submissionComposer: svc.SubmissionComposer,
|
||||
submissionBuildingBlock: svc.SubmissionBuildingBlock,
|
||||
eventChoice: svc.EventChoice,
|
||||
scenario: svc.Scenario,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,6 +424,23 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("PATCH /api/submission-drafts/{draft_id}", handleGlobalPatchSubmissionDraft)
|
||||
protected.HandleFunc("DELETE /api/submission-drafts/{draft_id}", handleGlobalDeleteSubmissionDraft)
|
||||
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/export", handleGlobalExportSubmissionDraft)
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice A — base catalog for
|
||||
// the sidebar picker. Wide-open SELECT (any authenticated user);
|
||||
// admin mutations are not exposed yet (Slice C).
|
||||
protected.HandleFunc("GET /api/submission-bases", handleListSubmissionBases)
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice B — per-section PATCH
|
||||
// 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)
|
||||
protected.HandleFunc("POST /api/submission-building-blocks/{block_id}/insert-into/{section_id}", handleInsertBlockIntoSection)
|
||||
// t-paliad-277 / m/paliad#109 — refresh project-derived variables on
|
||||
// the draft. Strips overrides for project.* / parties.* / deadline.*
|
||||
// / procedural_event.* / rule.* prefixes and bumps last_imported_at.
|
||||
@@ -446,6 +485,15 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("PUT /api/projects/{id}/event-choices", handlePutProjectEventChoice)
|
||||
protected.HandleFunc("DELETE /api/projects/{id}/event-choices/{submission_code}/{choice_kind}", handleDeleteProjectEventChoice)
|
||||
|
||||
// Slice D (m/paliad#124 §5, mig 145) — named scenario compositions
|
||||
// per project or as abstract templates on /tools/verfahrensablauf.
|
||||
protected.HandleFunc("GET /api/scenarios", handleScenariosList)
|
||||
protected.HandleFunc("GET /api/scenarios/{id}", handleScenarioGet)
|
||||
protected.HandleFunc("POST /api/scenarios", handleScenarioCreate)
|
||||
protected.HandleFunc("PATCH /api/scenarios/{id}", handleScenarioPatch)
|
||||
protected.HandleFunc("DELETE /api/scenarios/{id}", handleScenarioDelete)
|
||||
protected.HandleFunc("PUT /api/projects/{id}/active-scenario", handleSetActiveScenario)
|
||||
|
||||
// Partner units (structural partner-led units; legacy "Dezernate").
|
||||
protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits)
|
||||
protected.HandleFunc("POST /api/partner-units", handleCreatePartnerUnit)
|
||||
@@ -657,6 +705,16 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("DELETE /api/admin/firm-dashboard-default", adminGate(users, handleDeleteFirmDashboardDefault))
|
||||
protected.HandleFunc("POST /api/me/dashboard-layout/promote", adminGate(users, handlePromoteDashboardLayoutToFirmDefault))
|
||||
|
||||
// t-paliad-315 (m/paliad#141) Composer Slice C — admin building blocks editor.
|
||||
protected.HandleFunc("GET /admin/submission-building-blocks", adminGate(users, gateOnboarded(handleAdminBuildingBlocksPage)))
|
||||
protected.HandleFunc("GET /api/admin/submission-building-blocks", adminGate(users, handleAdminListBuildingBlocks))
|
||||
protected.HandleFunc("POST /api/admin/submission-building-blocks", adminGate(users, handleAdminCreateBuildingBlock))
|
||||
protected.HandleFunc("GET /api/admin/submission-building-blocks/{block_id}", adminGate(users, handleAdminGetBuildingBlock))
|
||||
protected.HandleFunc("PATCH /api/admin/submission-building-blocks/{block_id}", adminGate(users, handleAdminUpdateBuildingBlock))
|
||||
protected.HandleFunc("DELETE /api/admin/submission-building-blocks/{block_id}", adminGate(users, handleAdminDeleteBuildingBlock))
|
||||
protected.HandleFunc("GET /api/admin/submission-building-blocks/{block_id}/versions", adminGate(users, handleAdminListBuildingBlockVersions))
|
||||
protected.HandleFunc("POST /api/admin/submission-building-blocks/{block_id}/restore/{version_id}", adminGate(users, handleAdminRestoreBuildingBlockVersion))
|
||||
|
||||
protected.HandleFunc("GET /api/admin/email-templates", adminGate(users, handleAdminListEmailTemplates))
|
||||
protected.HandleFunc("GET /api/admin/email-templates/{key}/variables", adminGate(users, handleAdminEmailTemplateVariables))
|
||||
protected.HandleFunc("GET /api/admin/email-templates/{key}/{lang}", adminGate(users, handleAdminGetEmailTemplate))
|
||||
@@ -669,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))
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -360,6 +361,8 @@ func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turn
|
||||
convID = ev.ConversationID
|
||||
case services.StreamError:
|
||||
errorEmitted = true
|
||||
log.Printf("paliadin: stream error turn=%s code=%s retryable=%v message=%q",
|
||||
turnID, ev.Code, ev.Retryable, ev.Message)
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
Data: map[string]any{
|
||||
@@ -372,6 +375,8 @@ func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turn
|
||||
case <-silenceTicker.C:
|
||||
elapsed := time.Since(lastEventAt)
|
||||
if elapsed >= silenceTimeout {
|
||||
log.Printf("paliadin: silence timeout turn=%s elapsed=%s (silenceTimeout=%s)",
|
||||
turnID, elapsed, silenceTimeout)
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
Data: map[string]any{
|
||||
@@ -419,6 +424,8 @@ func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turn
|
||||
}
|
||||
}
|
||||
if res.err != nil {
|
||||
log.Printf("paliadin: backend returned error turn=%s err=%v errorEmittedAlready=%v",
|
||||
turnID, res.err, errorEmitted)
|
||||
if !errorEmitted {
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
@@ -432,6 +439,8 @@ func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turn
|
||||
}
|
||||
result := res.result
|
||||
if result == nil {
|
||||
log.Printf("paliadin: backend returned nil result without error turn=%s errorEmittedAlready=%v",
|
||||
turnID, errorEmitted)
|
||||
// Shouldn't happen — backend contract returns either err
|
||||
// or a result. Defensive bail.
|
||||
if !errorEmitted {
|
||||
|
||||
@@ -69,8 +69,19 @@ type dbServices struct {
|
||||
// t-paliad-238 — submission draft editor.
|
||||
submissionDraft *services.SubmissionDraftService
|
||||
|
||||
// t-paliad-313 — Composer base catalog + per-draft sections +
|
||||
// (Slice B) the render pipeline assembling base + sections into a
|
||||
// final .docx + (Slice C) building-block library.
|
||||
submissionBase *services.BaseService
|
||||
submissionSection *services.SectionService
|
||||
submissionComposer *services.SubmissionComposer
|
||||
submissionBuildingBlock *services.BuildingBlockService
|
||||
|
||||
// t-paliad-265 — per-event-card optional choices.
|
||||
eventChoice *services.EventChoiceService
|
||||
|
||||
// Slice D — named scenario compositions (m/paliad#124 §5).
|
||||
scenario *services.ScenarioService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
|
||||
216
internal/handlers/scenarios.go
Normal file
216
internal/handlers/scenarios.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// Slice D (m/paliad#124 §5, mig 145) — REST endpoints for paliad.scenarios.
|
||||
//
|
||||
// Routes (registered in handlers.go):
|
||||
//
|
||||
// GET /api/scenarios?project=<id> — list project's scenarios
|
||||
// GET /api/scenarios?abstract=true — list caller's abstract scenarios
|
||||
// GET /api/scenarios/{id} — fetch one
|
||||
// POST /api/scenarios — create
|
||||
// PATCH /api/scenarios/{id} — partial update
|
||||
// PUT /api/projects/{id}/active-scenario — set/clear active scenario
|
||||
// DELETE /api/scenarios/{id} — remove
|
||||
//
|
||||
// All endpoints require auth; visibility is enforced by
|
||||
// ScenarioService.requireProjectVisible / requireVisible.
|
||||
|
||||
func requireScenarioService(w http.ResponseWriter) bool {
|
||||
if dbSvc == nil || dbSvc.scenario == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "Szenarien sind vorübergehend nicht verfügbar (keine Datenbank).",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// scenarioErrorToStatus maps service errors to HTTP statuses. Mirrors
|
||||
// the patterns in projects.go and event_choices.go.
|
||||
func scenarioErrorToStatus(err error) (int, string) {
|
||||
switch {
|
||||
case errors.Is(err, lp.ErrUnknownScenario), errors.Is(err, services.ErrScenarioNotVisible):
|
||||
return http.StatusNotFound, "Szenario nicht gefunden"
|
||||
case errors.Is(err, services.ErrInvalidInput), errors.Is(err, lp.ErrInvalidScenario), errors.Is(err, lp.ErrScenarioNoPrimary):
|
||||
return http.StatusBadRequest, err.Error()
|
||||
}
|
||||
return http.StatusInternalServerError, err.Error()
|
||||
}
|
||||
|
||||
// handleScenariosList — GET /api/scenarios?project=<uuid> OR ?abstract=true.
|
||||
func handleScenariosList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
abstract := r.URL.Query().Get("abstract") == "true"
|
||||
projectStr := r.URL.Query().Get("project")
|
||||
switch {
|
||||
case abstract:
|
||||
out, err := dbSvc.scenario.ListAbstractForUser(r.Context(), uid)
|
||||
if err != nil {
|
||||
status, msg := scenarioErrorToStatus(err)
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
case projectStr != "":
|
||||
pid, err := uuid.Parse(projectStr)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige project ID"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenario.ListForProject(r.Context(), uid, pid)
|
||||
if err != nil {
|
||||
status, msg := scenarioErrorToStatus(err)
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
default:
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "?project=<uuid> oder ?abstract=true erforderlich",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// handleScenarioGet — GET /api/scenarios/{id}.
|
||||
func handleScenarioGet(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenario.Get(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
status, msg := scenarioErrorToStatus(err)
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleScenarioCreate — POST /api/scenarios.
|
||||
func handleScenarioCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input services.CreateScenarioInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenario.Create(r.Context(), uid, input)
|
||||
if err != nil {
|
||||
status, msg := scenarioErrorToStatus(err)
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// handleScenarioPatch — PATCH /api/scenarios/{id}.
|
||||
func handleScenarioPatch(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
|
||||
return
|
||||
}
|
||||
var input services.PatchScenarioInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenario.Patch(r.Context(), uid, id, input)
|
||||
if err != nil {
|
||||
status, msg := scenarioErrorToStatus(err)
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleScenarioDelete — DELETE /api/scenarios/{id}.
|
||||
func handleScenarioDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.scenario.Delete(r.Context(), uid, id); err != nil {
|
||||
status, msg := scenarioErrorToStatus(err)
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleSetActiveScenario — PUT /api/projects/{id}/active-scenario.
|
||||
// Body: {"scenario_id": "<uuid>"} or {"scenario_id": null} to clear.
|
||||
func handleSetActiveScenario(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pid, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige project ID"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ScenarioID *uuid.UUID `json:"scenario_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.scenario.SetActive(r.Context(), uid, pid, body.ScenarioID); err != nil {
|
||||
status, msg := scenarioErrorToStatus(err)
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
96
internal/handlers/submission_bases.go
Normal file
96
internal/handlers/submission_bases.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package handlers
|
||||
|
||||
// Submission base catalog handler — Composer Slice A (t-paliad-313,
|
||||
// m/paliad#141, design doc docs/design-submission-generator-v2-2026-05-26.md
|
||||
// §5.1 / Slice A acceptance).
|
||||
//
|
||||
// Endpoint: GET /api/submission-bases → list of active bases visible
|
||||
// to the requesting firm. The sidebar picker on the draft editor reads
|
||||
// this once on page load and caches in-memory; the response shape is
|
||||
// stable across the picker's lifetime.
|
||||
//
|
||||
// Visibility: the catalog is shared firm-wide (per the design + mig
|
||||
// 146's wide-open RLS SELECT policy). The handler still requires
|
||||
// authentication; anonymous users 401.
|
||||
//
|
||||
// Filtering: the response includes the firm's own bases AND the
|
||||
// firm-agnostic ones (firm IS NULL). The Go service-side filter passes
|
||||
// branding.Name as the firm hint; cross-firm cases (e.g. a future
|
||||
// non-HLC deployment) get their own filtered slice naturally.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// submissionBaseRow is the on-the-wire shape returned by the list
|
||||
// endpoint. Mirrors services.SubmissionBase but drops the raw bytes
|
||||
// and exposes the parsed section spec inline so the picker can show a
|
||||
// preview of the default section count without an extra round-trip.
|
||||
type submissionBaseRow struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Firm *string `json:"firm,omitempty"`
|
||||
ProceedingFamily *string `json:"proceeding_family,omitempty"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
DescriptionDE *string `json:"description_de,omitempty"`
|
||||
DescriptionEN *string `json:"description_en,omitempty"`
|
||||
GiteaPath string `json:"gitea_path"`
|
||||
IsDefaultFor []string `json:"is_default_for"`
|
||||
IsActive bool `json:"is_active"`
|
||||
SectionCount int `json:"section_count"`
|
||||
}
|
||||
|
||||
type submissionBaseListResponse struct {
|
||||
Bases []submissionBaseRow `json:"bases"`
|
||||
}
|
||||
|
||||
// handleListSubmissionBases backs GET /api/submission-bases.
|
||||
func handleListSubmissionBases(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionBase == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "submission bases not configured",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := dbSvc.submissionBase.List(r.Context(), branding.Name)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]submissionBaseRow, 0, len(rows))
|
||||
for i := range rows {
|
||||
out = append(out, baseRowFromService(&rows[i]))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, submissionBaseListResponse{Bases: out})
|
||||
}
|
||||
|
||||
// baseRowFromService projects a services.SubmissionBase into the
|
||||
// on-the-wire row shape.
|
||||
func baseRowFromService(b *services.SubmissionBase) submissionBaseRow {
|
||||
return submissionBaseRow{
|
||||
ID: b.ID.String(),
|
||||
Slug: b.Slug,
|
||||
Firm: b.Firm,
|
||||
ProceedingFamily: b.ProceedingFamily,
|
||||
LabelDE: b.LabelDE,
|
||||
LabelEN: b.LabelEN,
|
||||
DescriptionDE: b.DescriptionDE,
|
||||
DescriptionEN: b.DescriptionEN,
|
||||
GiteaPath: b.GiteaPath,
|
||||
IsDefaultFor: b.IsDefaultFor,
|
||||
IsActive: b.IsActive,
|
||||
SectionCount: len(b.SectionSpec.Defaults),
|
||||
}
|
||||
}
|
||||
482
internal/handlers/submission_building_blocks.go
Normal file
482
internal/handlers/submission_building_blocks.go
Normal file
@@ -0,0 +1,482 @@
|
||||
package handlers
|
||||
|
||||
// Composer building-block handlers — t-paliad-315 Slice C.
|
||||
//
|
||||
// Two surfaces:
|
||||
//
|
||||
// 1. Lawyer-facing picker (any authenticated user):
|
||||
// GET /api/submission-building-blocks?section_key=…&proceeding_family=…&q=…
|
||||
// POST /api/submission-building-blocks/{block_id}/insert-into/{section_id}
|
||||
//
|
||||
// The picker list is visibility-tier-filtered (private/team/firm/
|
||||
// global) at the service layer. Insert is the paste mechanic
|
||||
// ratified by Q2 (m, 2026-05-26): plain text copy of
|
||||
// content_md_<lang> into submission_sections.content_md_<lang>.
|
||||
// No lineage stamped on the section.
|
||||
//
|
||||
// 2. Admin editor (adminGate via auth.RequireAdminFunc):
|
||||
// GET /api/admin/submission-building-blocks
|
||||
// POST /api/admin/submission-building-blocks
|
||||
// GET /api/admin/submission-building-blocks/{block_id}
|
||||
// PATCH /api/admin/submission-building-blocks/{block_id}
|
||||
// DELETE /api/admin/submission-building-blocks/{block_id}
|
||||
// GET /api/admin/submission-building-blocks/{block_id}/versions
|
||||
// POST /api/admin/submission-building-blocks/{block_id}/restore/{version_id}
|
||||
//
|
||||
// Plus the page route /admin/submission-building-blocks (list +
|
||||
// edit shell, hydrated client-side).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// blockJSON is the on-the-wire shape for both the picker and admin
|
||||
// surfaces.
|
||||
type buildingBlockJSON struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Firm *string `json:"firm,omitempty"`
|
||||
SectionKey string `json:"section_key"`
|
||||
ProceedingFamily *string `json:"proceeding_family,omitempty"`
|
||||
TitleDE string `json:"title_de"`
|
||||
TitleEN string `json:"title_en"`
|
||||
DescriptionDE *string `json:"description_de,omitempty"`
|
||||
DescriptionEN *string `json:"description_en,omitempty"`
|
||||
ContentMDDE string `json:"content_md_de"`
|
||||
ContentMDEN string `json:"content_md_en"`
|
||||
AuthorID *uuid.UUID `json:"author_id,omitempty"`
|
||||
Visibility string `json:"visibility"`
|
||||
IsPublished bool `json:"is_published"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type buildingBlockListResponse struct {
|
||||
Blocks []buildingBlockJSON `json:"blocks"`
|
||||
}
|
||||
|
||||
// blockJSONFromService projects services.BuildingBlock into the wire shape.
|
||||
func blockJSONFromService(b *services.BuildingBlock) buildingBlockJSON {
|
||||
return buildingBlockJSON{
|
||||
ID: b.ID,
|
||||
Slug: b.Slug,
|
||||
Firm: b.Firm,
|
||||
SectionKey: b.SectionKey,
|
||||
ProceedingFamily: b.ProceedingFamily,
|
||||
TitleDE: b.TitleDE,
|
||||
TitleEN: b.TitleEN,
|
||||
DescriptionDE: b.DescriptionDE,
|
||||
DescriptionEN: b.DescriptionEN,
|
||||
ContentMDDE: b.ContentMDDE,
|
||||
ContentMDEN: b.ContentMDEN,
|
||||
AuthorID: b.AuthorID,
|
||||
Visibility: b.Visibility,
|
||||
IsPublished: b.IsPublished,
|
||||
CreatedAt: b.CreatedAt,
|
||||
UpdatedAt: b.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Lawyer-facing picker
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func handleListBuildingBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionBuildingBlock == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
filter := services.BlockListFilter{
|
||||
SectionKey: strings.TrimSpace(q.Get("section_key")),
|
||||
ProceedingFamily: strings.TrimSpace(q.Get("proceeding_family")),
|
||||
Search: strings.TrimSpace(q.Get("q")),
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows, err := dbSvc.submissionBuildingBlock.ListVisible(ctx, uid, filter)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]buildingBlockJSON, 0, len(rows))
|
||||
for i := range rows {
|
||||
out = append(out, blockJSONFromService(&rows[i]))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, buildingBlockListResponse{Blocks: out})
|
||||
}
|
||||
|
||||
func handleInsertBlockIntoSection(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionBuildingBlock == nil || dbSvc.submissionSection == nil || dbSvc.submissionDraft == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
||||
return
|
||||
}
|
||||
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sectionID, ok := parseUUIDPath(w, r, "section_id", "section id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Visibility on the section: section.draft_id must point to a
|
||||
// draft the caller owns. Composer Slice B's same owner gate.
|
||||
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 _, err := dbSvc.submissionDraft.Get(ctx, uid, sec.DraftID); err != nil {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := dbSvc.submissionBuildingBlock.InsertIntoSection(ctx, uid, blockID, sectionID, dbSvc.submissionSection)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrBuildingBlockNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block not found"})
|
||||
return
|
||||
}
|
||||
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.StatusOK, sectionJSONFromService(updated))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Admin editor
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func handleAdminListBuildingBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionBuildingBlock == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
rows, err := dbSvc.submissionBuildingBlock.ListAllForAdmin(ctx)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]buildingBlockJSON, 0, len(rows))
|
||||
for i := range rows {
|
||||
out = append(out, blockJSONFromService(&rows[i]))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, buildingBlockListResponse{Blocks: out})
|
||||
}
|
||||
|
||||
func handleAdminGetBuildingBlock(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionBuildingBlock == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
||||
return
|
||||
}
|
||||
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
b, err := dbSvc.submissionBuildingBlock.GetForAdmin(ctx, blockID)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrBuildingBlockNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, blockJSONFromService(b))
|
||||
}
|
||||
|
||||
type buildingBlockCreateInput struct {
|
||||
Slug string `json:"slug"`
|
||||
Firm *string `json:"firm,omitempty"`
|
||||
SectionKey string `json:"section_key"`
|
||||
ProceedingFamily *string `json:"proceeding_family,omitempty"`
|
||||
TitleDE string `json:"title_de"`
|
||||
TitleEN string `json:"title_en"`
|
||||
DescriptionDE *string `json:"description_de,omitempty"`
|
||||
DescriptionEN *string `json:"description_en,omitempty"`
|
||||
ContentMDDE string `json:"content_md_de"`
|
||||
ContentMDEN string `json:"content_md_en"`
|
||||
Visibility string `json:"visibility"`
|
||||
IsPublished bool `json:"is_published"`
|
||||
}
|
||||
|
||||
func handleAdminCreateBuildingBlock(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionBuildingBlock == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
||||
return
|
||||
}
|
||||
var in buildingBlockCreateInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
b, err := dbSvc.submissionBuildingBlock.Create(ctx, uid, services.CreateInput{
|
||||
Slug: in.Slug,
|
||||
Firm: in.Firm,
|
||||
SectionKey: in.SectionKey,
|
||||
ProceedingFamily: in.ProceedingFamily,
|
||||
TitleDE: in.TitleDE,
|
||||
TitleEN: in.TitleEN,
|
||||
DescriptionDE: in.DescriptionDE,
|
||||
DescriptionEN: in.DescriptionEN,
|
||||
ContentMDDE: in.ContentMDDE,
|
||||
ContentMDEN: in.ContentMDEN,
|
||||
Visibility: in.Visibility,
|
||||
IsPublished: in.IsPublished,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrInvalidInput) || errors.Is(err, services.ErrBuildingBlockInvalidVisibility) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, blockJSONFromService(b))
|
||||
}
|
||||
|
||||
type buildingBlockUpdateInput struct {
|
||||
Slug *string `json:"slug,omitempty"`
|
||||
Firm *string `json:"firm,omitempty"`
|
||||
FirmSet bool `json:"-"`
|
||||
SectionKey *string `json:"section_key,omitempty"`
|
||||
ProceedingFamily *string `json:"proceeding_family,omitempty"`
|
||||
ProceedingFamilySet bool `json:"-"`
|
||||
TitleDE *string `json:"title_de,omitempty"`
|
||||
TitleEN *string `json:"title_en,omitempty"`
|
||||
DescriptionDE *string `json:"description_de,omitempty"`
|
||||
DescriptionDESet bool `json:"-"`
|
||||
DescriptionEN *string `json:"description_en,omitempty"`
|
||||
DescriptionENSet bool `json:"-"`
|
||||
ContentMDDE *string `json:"content_md_de,omitempty"`
|
||||
ContentMDEN *string `json:"content_md_en,omitempty"`
|
||||
Visibility *string `json:"visibility,omitempty"`
|
||||
IsPublished *bool `json:"is_published,omitempty"`
|
||||
Note *string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
func (u *buildingBlockUpdateInput) UnmarshalJSON(data []byte) error {
|
||||
type alias buildingBlockUpdateInput
|
||||
var a alias
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
return err
|
||||
}
|
||||
*u = buildingBlockUpdateInput(a)
|
||||
raw := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
_, u.FirmSet = raw["firm"]
|
||||
_, u.ProceedingFamilySet = raw["proceeding_family"]
|
||||
_, u.DescriptionDESet = raw["description_de"]
|
||||
_, u.DescriptionENSet = raw["description_en"]
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleAdminUpdateBuildingBlock(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionBuildingBlock == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
||||
return
|
||||
}
|
||||
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in buildingBlockUpdateInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
patch := services.UpdatePatch{
|
||||
Slug: in.Slug,
|
||||
SectionKey: in.SectionKey,
|
||||
TitleDE: in.TitleDE,
|
||||
TitleEN: in.TitleEN,
|
||||
ContentMDDE: in.ContentMDDE,
|
||||
ContentMDEN: in.ContentMDEN,
|
||||
Visibility: in.Visibility,
|
||||
IsPublished: in.IsPublished,
|
||||
Note: in.Note,
|
||||
}
|
||||
if in.FirmSet {
|
||||
patch.Firm = &in.Firm
|
||||
}
|
||||
if in.ProceedingFamilySet {
|
||||
patch.ProceedingFamily = &in.ProceedingFamily
|
||||
}
|
||||
if in.DescriptionDESet {
|
||||
patch.DescriptionDE = &in.DescriptionDE
|
||||
}
|
||||
if in.DescriptionENSet {
|
||||
patch.DescriptionEN = &in.DescriptionEN
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
b, err := dbSvc.submissionBuildingBlock.Update(ctx, uid, blockID, patch)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrBuildingBlockNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block not found"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrBuildingBlockInvalidVisibility) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, blockJSONFromService(b))
|
||||
}
|
||||
|
||||
func handleAdminDeleteBuildingBlock(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionBuildingBlock == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
||||
return
|
||||
}
|
||||
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := dbSvc.submissionBuildingBlock.SoftDelete(ctx, uid, blockID); err != nil {
|
||||
if errors.Is(err, services.ErrBuildingBlockNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func handleAdminListBuildingBlockVersions(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionBuildingBlock == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
||||
return
|
||||
}
|
||||
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
rows, err := dbSvc.submissionBuildingBlock.ListVersions(ctx, blockID)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"versions": rows})
|
||||
}
|
||||
|
||||
func handleAdminRestoreBuildingBlockVersion(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionBuildingBlock == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
|
||||
return
|
||||
}
|
||||
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
versionID, ok := parseUUIDPath(w, r, "version_id", "version id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
b, err := dbSvc.submissionBuildingBlock.RestoreVersion(ctx, uid, blockID, versionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrBuildingBlockNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block or version not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, blockJSONFromService(b))
|
||||
}
|
||||
|
||||
// handleAdminBuildingBlocksPage serves the admin editor shell. The
|
||||
// client bundle hydrates the list + edit UI.
|
||||
func handleAdminBuildingBlocksPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin-submission-building-blocks.html")
|
||||
}
|
||||
@@ -83,6 +83,11 @@ type submissionDraftView struct {
|
||||
// so the frontend can render the multi-select picker in one round-
|
||||
// trip. Empty when the draft has no project attached.
|
||||
AvailableParties []submissionDraftPartyJSON `json:"available_parties"`
|
||||
// Sections is the per-draft section stack (t-paliad-313 Slice A).
|
||||
// Slice A renders these read-only; the lawyer sees what the
|
||||
// Composer seeded but can't yet edit prose. nil for pre-Composer
|
||||
// drafts (base_id NULL, no submission_sections rows).
|
||||
Sections []submissionSectionJSON `json:"sections"`
|
||||
}
|
||||
|
||||
// submissionDraftPartyJSON is the minimal party row the editor sidebar
|
||||
@@ -106,8 +111,30 @@ type submissionDraftJSON struct {
|
||||
LastExportedAt *time.Time `json:"last_exported_at,omitempty"`
|
||||
LastExportedSHA *string `json:"last_exported_sha,omitempty"`
|
||||
LastImportedAt *time.Time `json:"last_imported_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// BaseID — Composer base reference (t-paliad-313). NULL on
|
||||
// pre-Composer drafts; the editor sidebar surfaces this in the
|
||||
// base picker. PATCH accepts {"base_id": "<uuid>"} or
|
||||
// {"base_id": null} to set or clear.
|
||||
BaseID *uuid.UUID `json:"base_id"`
|
||||
ComposerMeta map[string]any `json:"composer_meta"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// submissionSectionJSON is the on-the-wire row for each per-draft
|
||||
// section. Slice A renders these read-only — the lawyer sees the
|
||||
// section stack but doesn't yet edit prose. Slice B makes content_md_*
|
||||
// editable + adds the PATCH endpoint.
|
||||
type submissionSectionJSON struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
SectionKey string `json:"section_key"`
|
||||
OrderIndex int `json:"order_index"`
|
||||
Kind string `json:"kind"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
Included bool `json:"included"`
|
||||
ContentMDDE string `json:"content_md_de"`
|
||||
ContentMDEN string `json:"content_md_en"`
|
||||
}
|
||||
|
||||
type submissionRuleSummary struct {
|
||||
@@ -132,6 +159,41 @@ type submissionDraftPatchInput struct {
|
||||
Variables *services.PlaceholderMap `json:"variables,omitempty"`
|
||||
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
// BaseID accepts three states per the JSON contract:
|
||||
// field absent → no change (json:"-")
|
||||
// {"base_id": "<uuid>"} → set to picked base
|
||||
// {"base_id": null} → clear (return to v1 fallback)
|
||||
// We model this with a **uuid.UUID inside a custom UnmarshalJSON
|
||||
// in case extends; for now the simpler `*uuid.UUID` + presence
|
||||
// flag covers Slice A's set-base flow. Clearing is exposed but
|
||||
// rarely used (the editor always picks a base; clearing is for
|
||||
// admin-recovery flows).
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
BaseIDSet bool `json:"-"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON on submissionDraftPatchInput sets BaseIDSet=true if
|
||||
// the "base_id" key appears in the payload (regardless of whether
|
||||
// the value is null or a uuid string). Lets the handler distinguish
|
||||
// "field absent" (no change) from "field set to null" (clear).
|
||||
func (p *submissionDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
// Phase 1: decode into a raw map to detect key presence.
|
||||
raw := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
// Phase 2: decode the typed fields. Use an alias to skip this
|
||||
// custom UnmarshalJSON during the re-parse.
|
||||
type alias submissionDraftPatchInput
|
||||
var a alias
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
return err
|
||||
}
|
||||
*p = submissionDraftPatchInput(a)
|
||||
if _, ok := raw["base_id"]; ok {
|
||||
p.BaseIDSet = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -372,6 +434,9 @@ func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
SelectedParties: input.SelectedParties,
|
||||
Language: input.Language,
|
||||
}
|
||||
if input.BaseIDSet {
|
||||
patch.BaseID = &input.BaseID
|
||||
}
|
||||
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
|
||||
if err != nil {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
@@ -501,16 +566,10 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
docx, resolved, tplSHA, composerUsed, err := exportSubmissionDraft(ctx, d)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
||||
return
|
||||
}
|
||||
docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: export render (draft=%s): %v", draftID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
|
||||
log.Printf("submission_drafts: export (draft=%s): %v", draftID, err)
|
||||
writeSubmissionExportError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -523,7 +582,7 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
if err := dbSvc.submissionDraft.MarkExported(bgCtx, d.ID, tplSHA); err != nil {
|
||||
log.Printf("submission_drafts: mark exported (draft=%s): %v", draftID, err)
|
||||
}
|
||||
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA); err != nil {
|
||||
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA, composerUsed); err != nil {
|
||||
log.Printf("submission_drafts: audit insert failed (draft=%s): %v", draftID, err)
|
||||
}
|
||||
if err := writeSubmissionDraftProjectEvent(bgCtx, d, resolved, filename); err != nil {
|
||||
@@ -538,6 +597,82 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// exportSubmissionDraft is the shared render entry point used by both
|
||||
// the project-scoped and global export handlers (t-paliad-313 Slice B).
|
||||
// Branches on draft.BaseID: if set AND the base + bytes resolve, the
|
||||
// Composer pipeline assembles the document; otherwise the v1
|
||||
// template-only path stays the fallback. composerUsed = true means the
|
||||
// metadata jsonb on the audit row carries "composer": true so admins
|
||||
// can tell the two paths apart in the feed.
|
||||
//
|
||||
// Returns (bytes, resolved-bag, templateSHA, composerUsed, err).
|
||||
func exportSubmissionDraft(ctx context.Context, d *services.SubmissionDraft) ([]byte, *services.SubmissionVarsResult, string, bool, error) {
|
||||
if d.BaseID != nil && dbSvc.submissionBase != nil && dbSvc.submissionSection != nil && dbSvc.submissionComposer != nil {
|
||||
base, err := dbSvc.submissionBase.GetByID(ctx, *d.BaseID)
|
||||
switch {
|
||||
case err == nil:
|
||||
baseBytes, baseSHA, err := fetchComposerBaseBytes(ctx, base)
|
||||
if err == nil {
|
||||
sections, err := dbSvc.submissionSection.ListForDraft(ctx, d.ID)
|
||||
if err != nil {
|
||||
return nil, nil, "", false, fmt.Errorf("list sections: %w", err)
|
||||
}
|
||||
bag, resolved, err := dbSvc.submissionDraft.BuildRenderBag(ctx, d)
|
||||
if err != nil {
|
||||
return nil, nil, "", false, err
|
||||
}
|
||||
docx, err := dbSvc.submissionComposer.Compose(ctx, services.ComposeOptions{
|
||||
Sections: sections,
|
||||
Base: base,
|
||||
BaseBytes: baseBytes,
|
||||
Lang: resolved.Lang,
|
||||
Vars: bag,
|
||||
Missing: services.DefaultMissingMarker(resolved.Lang),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, "", false, fmt.Errorf("composer: %w", err)
|
||||
}
|
||||
return docx, resolved, baseSHA, true, nil
|
||||
}
|
||||
log.Printf("submission_drafts: composer base bytes fetch failed (draft=%s base=%s): %v — falling back to v1 path", d.ID, base.Slug, err)
|
||||
case errors.Is(err, services.ErrBaseNotFound):
|
||||
log.Printf("submission_drafts: composer base missing (draft=%s base_id=%s) — falling back to v1 path", d.ID, *d.BaseID)
|
||||
default:
|
||||
return nil, nil, "", false, fmt.Errorf("composer base lookup: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// v1 fallback: template-only render via resolveSubmissionTemplate +
|
||||
// SubmissionDraftService.Export. Unchanged behaviour for
|
||||
// pre-Composer drafts.
|
||||
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
if err != nil {
|
||||
return nil, nil, "", false, fmt.Errorf("template upstream: %w", err)
|
||||
}
|
||||
docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
|
||||
if err != nil {
|
||||
return nil, nil, "", false, fmt.Errorf("render: %w", err)
|
||||
}
|
||||
return docx, resolved, tplSHA, false, nil
|
||||
}
|
||||
|
||||
// writeSubmissionExportError maps a render-time error to an HTTP
|
||||
// response. The shape mirrors what the handlers used to inline.
|
||||
func writeSubmissionExportError(w http.ResponseWriter, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "template upstream"):
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
||||
case strings.Contains(msg, "composer:") || strings.Contains(msg, "render:") || strings.Contains(msg, "list sections"):
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
|
||||
default:
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
|
||||
}
|
||||
}
|
||||
|
||||
// handleSubmissionDraftPage serves dist/submission-draft.html for the
|
||||
// dedicated draft editor at /projects/{id}/submissions/{code}/draft
|
||||
// (and …/draft/{draft_id}). Project visibility is enforced server-side
|
||||
@@ -713,6 +848,11 @@ type globalDraftPatchInput struct {
|
||||
// SelectedParties: present-but-empty array resets to "all parties",
|
||||
// present non-empty array restricts to subset, absent = no change.
|
||||
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
||||
// BaseID + baseIDProvided mirror the ProjectID pattern — present
|
||||
// (regardless of value) means "set"; absent means "no change". Set
|
||||
// by UnmarshalJSON. t-paliad-313 Composer Slice A.
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
baseIDProvided bool
|
||||
}
|
||||
|
||||
func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
@@ -722,6 +862,7 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
Language *string `json:"language,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
}
|
||||
var a alias
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
@@ -732,12 +873,15 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
g.Language = a.Language
|
||||
g.ProjectID = a.ProjectID
|
||||
g.SelectedParties = a.SelectedParties
|
||||
// Detect whether "project_id" was present in the JSON object.
|
||||
g.BaseID = a.BaseID
|
||||
// Detect whether "project_id" / "base_id" were present in the JSON
|
||||
// object.
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
_, g.projectIDProvided = raw["project_id"]
|
||||
_, g.baseIDProvided = raw["base_id"]
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -778,6 +922,10 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
pid := in.ProjectID // may be nil → detach
|
||||
patch.ProjectID = &pid
|
||||
}
|
||||
if in.baseIDProvided {
|
||||
bid := in.BaseID // may be nil → clear
|
||||
patch.BaseID = &bid
|
||||
}
|
||||
|
||||
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
|
||||
if err != nil {
|
||||
@@ -890,16 +1038,10 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
docx, resolved, tplSHA, composerUsed, err := exportSubmissionDraft(ctx, d)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
||||
return
|
||||
}
|
||||
docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: export render (draft=%s): %v", draftID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
|
||||
log.Printf("submission_drafts: export (draft=%s): %v", draftID, err)
|
||||
writeSubmissionExportError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -910,7 +1052,7 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
if err := dbSvc.submissionDraft.MarkExported(bgCtx, d.ID, tplSHA); err != nil {
|
||||
log.Printf("submission_drafts: mark exported (draft=%s): %v", draftID, err)
|
||||
}
|
||||
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA); err != nil {
|
||||
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA, composerUsed); err != nil {
|
||||
log.Printf("submission_drafts: audit insert failed (draft=%s): %v", draftID, err)
|
||||
}
|
||||
if err := writeSubmissionDraftProjectEvent(bgCtx, d, resolved, filename); err != nil {
|
||||
@@ -952,6 +1094,30 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
|
||||
Lang: lang,
|
||||
HasTemplate: true,
|
||||
AvailableParties: []submissionDraftPartyJSON{},
|
||||
Sections: []submissionSectionJSON{},
|
||||
}
|
||||
|
||||
// Composer Slice A — surface seeded sections (read-only). Empty
|
||||
// when the draft has no base + no section rows (pre-Composer
|
||||
// drafts that haven't been auto-upgraded — that's Slice C).
|
||||
if dbSvc.submissionSection != nil {
|
||||
secs, err := dbSvc.submissionSection.ListForDraft(ctx, d.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, sec := range secs {
|
||||
view.Sections = append(view.Sections, submissionSectionJSON{
|
||||
ID: sec.ID,
|
||||
SectionKey: sec.SectionKey,
|
||||
OrderIndex: sec.OrderIndex,
|
||||
Kind: sec.Kind,
|
||||
LabelDE: sec.LabelDE,
|
||||
LabelEN: sec.LabelEN,
|
||||
Included: sec.Included,
|
||||
ContentMDDE: sec.ContentMDDE,
|
||||
ContentMDEN: sec.ContentMDEN,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
merged, resolved, err := dbSvc.submissionDraft.BuildRenderBag(ctx, d)
|
||||
@@ -1135,6 +1301,10 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
|
||||
if lang == "" {
|
||||
lang = "de"
|
||||
}
|
||||
meta := d.ComposerMeta
|
||||
if meta == nil {
|
||||
meta = map[string]any{}
|
||||
}
|
||||
return submissionDraftJSON{
|
||||
ID: d.ID,
|
||||
ProjectID: d.ProjectID,
|
||||
@@ -1147,6 +1317,8 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
|
||||
LastExportedAt: d.LastExportedAt,
|
||||
LastExportedSHA: d.LastExportedSHA,
|
||||
LastImportedAt: d.LastImportedAt,
|
||||
BaseID: d.BaseID,
|
||||
ComposerMeta: meta,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
}
|
||||
@@ -1160,7 +1332,7 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
|
||||
// 'user' with scope_root = draft.user_id; the audit feed therefore
|
||||
// surfaces these exports on the user's row rather than against a
|
||||
// (non-existent) project.
|
||||
func writeSubmissionDraftAuditRow(ctx context.Context, user *models.User, d *services.SubmissionDraft, filename, templateSHA string) error {
|
||||
func writeSubmissionDraftAuditRow(ctx context.Context, user *models.User, d *services.SubmissionDraft, filename, templateSHA string, composerUsed bool) error {
|
||||
meta := map[string]any{
|
||||
"submission_code": d.SubmissionCode,
|
||||
"draft_id": d.ID.String(),
|
||||
@@ -1168,6 +1340,15 @@ func writeSubmissionDraftAuditRow(ctx context.Context, user *models.User, d *ser
|
||||
"filename": filename,
|
||||
"template_sha": templateSHA,
|
||||
}
|
||||
// t-paliad-313 Slice B — composer flag in metadata so admins can
|
||||
// tell the two render paths apart in the audit feed without
|
||||
// adding a new event_type.
|
||||
if composerUsed {
|
||||
meta["composer"] = true
|
||||
if d.BaseID != nil {
|
||||
meta["base_id"] = d.BaseID.String()
|
||||
}
|
||||
}
|
||||
body, _ := json.Marshal(meta)
|
||||
var (
|
||||
actorID any
|
||||
|
||||
332
internal/handlers/submission_sections.go
Normal file
332
internal/handlers/submission_sections.go
Normal file
@@ -0,0 +1,332 @@
|
||||
package handlers
|
||||
|
||||
// Submission section handlers — Composer Slice B (t-paliad-313). Backs
|
||||
// the inline editor on /projects/{id}/submissions/{code}/draft/{draft_id}
|
||||
// where the lawyer types prose into each section.
|
||||
//
|
||||
// Endpoint:
|
||||
//
|
||||
// PATCH /api/submission-drafts/{draft_id}/sections/{section_id}
|
||||
//
|
||||
// Body shape (all fields optional — absent = no change):
|
||||
//
|
||||
// {
|
||||
// "content_md_de": "...",
|
||||
// "content_md_en": "...",
|
||||
// "included": true|false,
|
||||
// "label_de": "...",
|
||||
// "label_en": "...",
|
||||
// "order_index": 3
|
||||
// }
|
||||
//
|
||||
// Visibility: ownership of the draft is checked via
|
||||
// SubmissionDraftService.Get (404 on no-access), then the section is
|
||||
// fetched + verified to belong to that draft. The DB-side RLS policy
|
||||
// (mig 148) enforces the same gate independently.
|
||||
//
|
||||
// Returns 200 + the refreshed section row on success.
|
||||
//
|
||||
// This is global-scoped (no /projects/{id}/ prefix) because the
|
||||
// section's owning draft already carries the project_id; routing on
|
||||
// section_id alone keeps the URL shape stable across project-scoped
|
||||
// and project-less drafts.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// submissionSectionPatchInput is the JSON shape accepted by PATCH.
|
||||
type submissionSectionPatchInput struct {
|
||||
ContentMDDE *string `json:"content_md_de,omitempty"`
|
||||
ContentMDEN *string `json:"content_md_en,omitempty"`
|
||||
Included *bool `json:"included,omitempty"`
|
||||
LabelDE *string `json:"label_de,omitempty"`
|
||||
LabelEN *string `json:"label_en,omitempty"`
|
||||
OrderIndex *int `json:"order_index,omitempty"`
|
||||
}
|
||||
|
||||
// submissionSectionPatchTimeout caps the round-trip.
|
||||
const submissionSectionPatchTimeout = 10 * time.Second
|
||||
|
||||
func handlePatchSubmissionSection(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()
|
||||
|
||||
// Owner-scope on the draft (RLS mirror; this gives us the typed
|
||||
// 404 + the path for the "section belongs to a different draft"
|
||||
// case below).
|
||||
draft, err := dbSvc.submissionDraft.Get(ctx, uid, draftID)
|
||||
if err != nil {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
existing, 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 existing.DraftID != draft.ID {
|
||||
// Section exists but doesn't belong to this draft — surface as
|
||||
// 404 to keep the "no fishing for foreign drafts" property.
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var input submissionSectionPatchInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
|
||||
patch := services.SectionPatch{
|
||||
ContentMDDE: input.ContentMDDE,
|
||||
ContentMDEN: input.ContentMDEN,
|
||||
Included: input.Included,
|
||||
LabelDE: input.LabelDE,
|
||||
LabelEN: input.LabelEN,
|
||||
OrderIndex: input.OrderIndex,
|
||||
}
|
||||
updated, err := dbSvc.submissionSection.Update(ctx, sectionID, patch)
|
||||
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
|
||||
}
|
||||
|
||||
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[].
|
||||
func sectionJSONFromService(sec *services.SubmissionSection) submissionSectionJSON {
|
||||
return submissionSectionJSON{
|
||||
ID: sec.ID,
|
||||
SectionKey: sec.SectionKey,
|
||||
OrderIndex: sec.OrderIndex,
|
||||
Kind: sec.Kind,
|
||||
LabelDE: sec.LabelDE,
|
||||
LabelEN: sec.LabelEN,
|
||||
Included: sec.Included,
|
||||
ContentMDDE: sec.ContentMDDE,
|
||||
ContentMDEN: sec.ContentMDEN,
|
||||
}
|
||||
}
|
||||
@@ -200,7 +200,7 @@ func loadSubmissionCatalog(ctx context.Context, projectProceedingTypeID *int) ([
|
||||
pt.code AS proceeding_code,
|
||||
pt.name AS proceeding_name,
|
||||
pt.name_en AS proceeding_name_en
|
||||
FROM paliad.deadline_rules dr
|
||||
FROM paliad.deadline_rules_unified dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE dr.is_active = true
|
||||
AND dr.lifecycle_state = 'published'
|
||||
@@ -208,7 +208,7 @@ func loadSubmissionCatalog(ctx context.Context, projectProceedingTypeID *int) ([
|
||||
AND dr.submission_code IS NOT NULL
|
||||
AND dr.submission_code <> ''
|
||||
AND pt.is_active = true
|
||||
ORDER BY pt.code ASC, dr.submission_code ASC`)
|
||||
ORDER BY pt.code ASC, dr.sequence_order ASC, dr.submission_code ASC`)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -220,6 +220,14 @@ func (s *AichatPaliadinService) RunTurnStream(ctx context.Context, req TurnReque
|
||||
}
|
||||
|
||||
if streamErr != nil {
|
||||
// Aichat persona without streaming support — graceful fallback to
|
||||
// the one-shot /chat/turn endpoint. Same body shape; we adapt the
|
||||
// non-streaming response into a single StreamChunk so the caller
|
||||
// sees identical event ordering.
|
||||
if strings.Contains(streamErr.Error(), "unsupported_streaming") {
|
||||
log.Printf("paliadin: persona %q lacks streaming support — falling back to one-shot turn %s", s.cfg.Persona, turnID)
|
||||
return s.fallbackOneShotFromStream(ctx, turnID, body, events, startedAt, session)
|
||||
}
|
||||
// Don't overwrite an existing error_code we may have set above.
|
||||
_ = s.markTurnError(ctx, turnID, classifyAichatError(streamErr))
|
||||
return nil, streamErr
|
||||
@@ -255,6 +263,80 @@ func (s *AichatPaliadinService) RunTurnStream(ctx context.Context, req TurnReque
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fallbackOneShotFromStream runs the same `body` against aichat's
|
||||
// non-streaming /chat/turn endpoint and adapts the response into the
|
||||
// StreamingPaliadin contract — a single StreamChunk + StreamMeta +
|
||||
// StreamConversation, followed by `events` being closed by the
|
||||
// outer RunTurnStream's defer. Used when the configured persona doesn't
|
||||
// support streaming (aichat returns HTTP 400 unsupported_streaming).
|
||||
//
|
||||
// Identical persistence shape as the one-shot RunTurn: completeTurn +
|
||||
// markPrimed/clearPrimed. No new turn row (already inserted by
|
||||
// RunTurnStream). No primer rebuild (already in body).
|
||||
func (s *AichatPaliadinService) fallbackOneShotFromStream(
|
||||
ctx context.Context,
|
||||
turnID uuid.UUID,
|
||||
body aichatTurnRequest,
|
||||
events chan<- StreamEvent,
|
||||
startedAt time.Time,
|
||||
session string,
|
||||
) (*TurnResult, error) {
|
||||
var resp aichatTurnResponse
|
||||
if err := s.callHTTP(ctx, http.MethodPost, "/chat/turn", body, &resp); err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, classifyAichatError(err))
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamError,
|
||||
Code: classifyAichatError(err),
|
||||
Message: err.Error(),
|
||||
})
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.PaneSpawned {
|
||||
s.clearPrimed(session)
|
||||
} else {
|
||||
s.markPrimed(session)
|
||||
}
|
||||
|
||||
cleanBody := resp.Response
|
||||
tokens := approxTokenCount(cleanBody)
|
||||
chipCount := countChips(cleanBody)
|
||||
finished := time.Now().UTC()
|
||||
durationMS := int(finished.Sub(startedAt) / time.Millisecond)
|
||||
|
||||
tmeta := trailerMeta{
|
||||
UsedTools: resp.Meta.UsedTools,
|
||||
ClassifierTag: resp.Meta.ClassifierTag,
|
||||
RowsSeen: coerceAichatRowsSeen(resp.Meta.RowsSeen),
|
||||
}
|
||||
|
||||
// Emit the response as a single chunk so the frontend renders it.
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamChunk,
|
||||
Content: cleanBody,
|
||||
})
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamMeta,
|
||||
UsedTools: tmeta.UsedTools,
|
||||
ClassifierTag: tmeta.ClassifierTag,
|
||||
RowsSeen: tmeta.RowsSeen,
|
||||
})
|
||||
|
||||
if err := s.completeTurn(ctx, turnID, finished, durationMS, cleanBody, tokens, tmeta, chipCount); err != nil {
|
||||
log.Printf("paliadin: complete turn %s (fallback one-shot): %v", turnID, err)
|
||||
}
|
||||
|
||||
return &TurnResult{
|
||||
TurnID: turnID,
|
||||
Response: cleanBody,
|
||||
UsedTools: tmeta.UsedTools,
|
||||
RowsSeen: tmeta.RowsSeen,
|
||||
ChipCount: chipCount,
|
||||
ClassifierTag: tmeta.ClassifierTag,
|
||||
DurationMS: durationMS,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// streamFrame is one decoded SSE event.
|
||||
type streamFrame struct {
|
||||
event string // "" → default (data:) event
|
||||
|
||||
99
internal/services/backup_service_live_test.go
Normal file
99
internal/services/backup_service_live_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// TestResolveOrgSheets_LiveSchemaSnapshot probes the live paliad schema
|
||||
// the way the backup runner does at the start of every run, then asserts
|
||||
// that every spec the registry declares either keeps all its ORDER BY
|
||||
// columns or — if any are missing — composes a fallback SELECT that the
|
||||
// DB can still execute. Catches the m/paliad#140 class of bug
|
||||
// (hardcoded ORDER BY against a renamed column) before deploy.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset. Read-only: opens a
|
||||
// REPEATABLE READ tx, never writes.
|
||||
func TestResolveOrgSheets_LiveSchemaSnapshot(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
specs := orgSheetSpecs()
|
||||
sheets, err := resolveOrgSheets(ctx, pool, specs)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveOrgSheets: %v", err)
|
||||
}
|
||||
if len(sheets) != len(specs) {
|
||||
t.Fatalf("resolved %d sheets, want %d", len(sheets), len(specs))
|
||||
}
|
||||
|
||||
// Each resolved SELECT must run cleanly against the live schema.
|
||||
// We LIMIT 1 inside a sub-SELECT so we don't materialise the full
|
||||
// table (some are large) but still exercise the ORDER BY clause.
|
||||
for _, sq := range sheets {
|
||||
wrapped := `SELECT * FROM (` + sq.SQL + `) _wrap LIMIT 1`
|
||||
if _, err := pool.QueryxContext(ctx, wrapped, sq.Args...); err != nil {
|
||||
t.Errorf("sheet %q SQL failed: %v\nSQL: %s", sq.SheetName, err, sq.SQL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteOrg_LiveSmoke runs the full ExportService.WriteOrg pipeline
|
||||
// against a real DB: schema probe, REPEATABLE READ tx, every sheet
|
||||
// query, xlsx + json + per-sheet CSV assembly, outer zip framing.
|
||||
// Discards the bytes — this is a "does it crash" smoke, the bug class
|
||||
// it catches is exactly the one from m/paliad#140 (hardcoded ORDER BY
|
||||
// against a missing column).
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset.
|
||||
func TestWriteOrg_LiveSmoke(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
svc := NewExportService(pool, "test-firm")
|
||||
var buf bytes.Buffer
|
||||
meta, err := svc.WriteOrg(context.Background(), &buf, ExportSpec{
|
||||
ActorID: uuid.New(),
|
||||
ActorEmail: "backup-smoke@test.local",
|
||||
ActorLabel: "Backup Smoke",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteOrg: %v", err)
|
||||
}
|
||||
if buf.Len() == 0 {
|
||||
t.Fatalf("WriteOrg wrote no bytes")
|
||||
}
|
||||
// Spot-check meta fills.
|
||||
if meta.Scope != ExportScopeOrg {
|
||||
t.Errorf("meta.Scope = %q, want %q", meta.Scope, ExportScopeOrg)
|
||||
}
|
||||
if len(meta.RowCounts) != len(orgSheetSpecs()) {
|
||||
t.Errorf("meta.RowCounts has %d entries, want %d (one per sheet)", len(meta.RowCounts), len(orgSheetSpecs()))
|
||||
}
|
||||
// The bytes are a zip; the first 4 bytes are PK\x03\x04 for a non-empty zip.
|
||||
if buf.Len() >= 4 && !strings.HasPrefix(buf.String()[:4], "PK\x03\x04") {
|
||||
t.Errorf("bundle bytes don't look like a zip (first bytes: %x)", buf.Bytes()[:4])
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,10 @@ package services
|
||||
// it would live in backup_service_live_test.go under TEST_DATABASE_URL.
|
||||
// This file covers the bits that don't need a database:
|
||||
//
|
||||
// - orgSheetQueries registry shape: no duplicates, no excluded
|
||||
// - orgSheetSpecs registry shape: no duplicates, no excluded
|
||||
// paliadin sheets, predictable prefix split between entity and ref.
|
||||
// - composeOrgSheetSQL drift-resistance: missing ORDER BY cols drop,
|
||||
// SQL override path bypasses the builder, all-missing → no clause.
|
||||
// - LocalDiskStore Put / Get / Delete round-trip, key validation,
|
||||
// URI traversal rejection.
|
||||
|
||||
@@ -22,60 +24,216 @@ import (
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// orgSheetQueries registry
|
||||
// orgSheetSpecs registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestOrgSheetQueries_NoDuplicates(t *testing.T) {
|
||||
func TestOrgSheetSpecs_NoDuplicates(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, sq := range orgSheetQueries() {
|
||||
if seen[sq.SheetName] {
|
||||
t.Fatalf("duplicate sheet name in orgSheetQueries: %q", sq.SheetName)
|
||||
for _, sp := range orgSheetSpecs() {
|
||||
if seen[sp.SheetName] {
|
||||
t.Fatalf("duplicate sheet name in orgSheetSpecs: %q", sp.SheetName)
|
||||
}
|
||||
seen[sq.SheetName] = true
|
||||
seen[sp.SheetName] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgSheetQueries_ExcludesPaliadinTables(t *testing.T) {
|
||||
func TestOrgSheetSpecs_ExcludesPaliadinTables(t *testing.T) {
|
||||
// m's t-paliad-214 Q5 decision + this design's §11 Q3 default:
|
||||
// paliadin_turns and paliadin_aichat_conversation must be ABSENT
|
||||
// from the registry (structural exclusion, not just column-drop).
|
||||
for _, sq := range orgSheetQueries() {
|
||||
name := sq.SheetName
|
||||
for _, sp := range orgSheetSpecs() {
|
||||
name := sp.SheetName
|
||||
if strings.Contains(name, "paliadin") {
|
||||
t.Fatalf("orgSheetQueries leaked paliadin sheet: %q (m's Q3 mandates structural exclusion)", name)
|
||||
t.Fatalf("orgSheetSpecs leaked paliadin sheet: %q (m's Q3 mandates structural exclusion)", name)
|
||||
}
|
||||
// Belt-and-braces: SQL bodies should not reference the tables
|
||||
// either (no UNION joins, no subqueries pulling them in).
|
||||
if strings.Contains(sq.SQL, "paliadin_turns") || strings.Contains(sq.SQL, "paliadin_aichat_conversation") {
|
||||
t.Fatalf("orgSheetQueries[%q] SQL references a paliadin table: %s", name, sq.SQL)
|
||||
if strings.Contains(sp.Table, "paliadin") {
|
||||
t.Fatalf("orgSheetSpecs[%q].Table references a paliadin table: %s", name, sp.Table)
|
||||
}
|
||||
// Belt-and-braces: SQL override bodies (the few sheets that
|
||||
// bypass the Table+OrderBy builder) also can't pull paliadin
|
||||
// tables in through UNION/subquery.
|
||||
if strings.Contains(sp.SQL, "paliadin_turns") || strings.Contains(sp.SQL, "paliadin_aichat_conversation") {
|
||||
t.Fatalf("orgSheetSpecs[%q] SQL references a paliadin table: %s", name, sp.SQL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgSheetQueries_RefSheetsPrefixed(t *testing.T) {
|
||||
func TestOrgSheetSpecs_RefSheetsPrefixed(t *testing.T) {
|
||||
// Every sheet whose data is read-only reference material is
|
||||
// expected to use the `ref__` prefix. The writer's downstream
|
||||
// consumers rely on this convention to group reference data
|
||||
// visually in the workbook.
|
||||
for _, sq := range orgSheetQueries() {
|
||||
if !strings.HasPrefix(sq.SheetName, "ref__") {
|
||||
for _, sp := range orgSheetSpecs() {
|
||||
if !strings.HasPrefix(sp.SheetName, "ref__") {
|
||||
continue
|
||||
}
|
||||
// Reference sheets shouldn't carry per-row WHERE clauses (they
|
||||
// dump the whole reference table for portability).
|
||||
if strings.Contains(strings.ToUpper(sq.SQL), "WHERE") {
|
||||
t.Fatalf("orgSheetQueries[%q] is ref__ but has a WHERE clause; reference sheets dump the whole table", sq.SheetName)
|
||||
// dump the whole reference table for portability). Only
|
||||
// applies to the SQL-override path; the Table+OrderBy builder
|
||||
// never emits a WHERE.
|
||||
if sp.SQL != "" && strings.Contains(strings.ToUpper(sp.SQL), "WHERE") {
|
||||
t.Fatalf("orgSheetSpecs[%q] is ref__ but has a WHERE clause; reference sheets dump the whole table", sp.SheetName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgSheetQueries_OrderByForDeterminism(t *testing.T) {
|
||||
// Every sheet must specify an ORDER BY so the byte-deterministic
|
||||
// contract from t-paliad-214 §3 holds across runs.
|
||||
for _, sq := range orgSheetQueries() {
|
||||
if !strings.Contains(strings.ToUpper(sq.SQL), "ORDER BY") {
|
||||
t.Fatalf("orgSheetQueries[%q] missing ORDER BY (determinism contract): %s", sq.SheetName, sq.SQL)
|
||||
func TestOrgSheetSpecs_OrderByForDeterminism(t *testing.T) {
|
||||
// Every sheet must declare a stable sort: either OrderBy on the
|
||||
// Table+OrderBy path, or ORDER BY in the SQL override. Keeps the
|
||||
// byte-deterministic contract from t-paliad-214 §3 across runs.
|
||||
//
|
||||
// (Drift removes ORDER BY columns at runtime, but only ones that
|
||||
// no longer exist in the schema — the spec-level declaration is
|
||||
// still required so we know what *should* be ordered.)
|
||||
for _, sp := range orgSheetSpecs() {
|
||||
if sp.SQL != "" {
|
||||
if !strings.Contains(strings.ToUpper(sp.SQL), "ORDER BY") {
|
||||
t.Fatalf("orgSheetSpecs[%q] SQL override missing ORDER BY (determinism contract): %s", sp.SheetName, sp.SQL)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(sp.OrderBy) == 0 {
|
||||
t.Fatalf("orgSheetSpecs[%q] has no OrderBy and no SQL override (determinism contract)", sp.SheetName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// composeOrgSheetSQL — drift-resistant SQL builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestComposeOrgSheetSQL_AllColumnsPresent(t *testing.T) {
|
||||
spec := orgSheetSpec{
|
||||
SheetName: "appointments",
|
||||
Table: "paliad.appointments",
|
||||
OrderBy: []string{"id"},
|
||||
}
|
||||
cols := map[string]map[string]struct{}{
|
||||
"appointments": {"id": {}, "project_id": {}},
|
||||
}
|
||||
got, dropped := composeOrgSheetSQL(spec, cols)
|
||||
want := "SELECT * FROM paliad.appointments ORDER BY id"
|
||||
if got != want {
|
||||
t.Fatalf("got SQL %q, want %q", got, want)
|
||||
}
|
||||
if len(dropped) != 0 {
|
||||
t.Fatalf("expected no dropped columns, got %v", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeOrgSheetSQL_DropsMissingOrderByColumn(t *testing.T) {
|
||||
// The original bug from m/paliad#138 reproduced in unit form:
|
||||
// orderBy references a column the table doesn't have.
|
||||
spec := orgSheetSpec{
|
||||
SheetName: "appointment_caldav_targets",
|
||||
Table: "paliad.appointment_caldav_targets",
|
||||
OrderBy: []string{"appointment_id", "calendar_binding_id"}, // wrong: real col is binding_id
|
||||
}
|
||||
cols := map[string]map[string]struct{}{
|
||||
"appointment_caldav_targets": {
|
||||
"appointment_id": {},
|
||||
"binding_id": {},
|
||||
},
|
||||
}
|
||||
got, dropped := composeOrgSheetSQL(spec, cols)
|
||||
want := "SELECT * FROM paliad.appointment_caldav_targets ORDER BY appointment_id"
|
||||
if got != want {
|
||||
t.Fatalf("got SQL %q, want %q", got, want)
|
||||
}
|
||||
if len(dropped) != 1 || dropped[0] != "calendar_binding_id" {
|
||||
t.Fatalf("expected dropped=[calendar_binding_id], got %v", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeOrgSheetSQL_AllOrderByMissing_NoClause(t *testing.T) {
|
||||
// If every declared ORDER BY column is gone, the builder still
|
||||
// produces a runnable SELECT — without ORDER BY. The export
|
||||
// succeeds; the order across runs is no longer deterministic for
|
||||
// this sheet until the spec is updated. WARN log alerts the
|
||||
// operator (verified in TestResolveOrgSheets_LogsWarnings).
|
||||
spec := orgSheetSpec{
|
||||
SheetName: "ghost",
|
||||
Table: "paliad.ghost",
|
||||
OrderBy: []string{"missing_a", "missing_b"},
|
||||
}
|
||||
cols := map[string]map[string]struct{}{
|
||||
"ghost": {"unrelated": {}},
|
||||
}
|
||||
got, dropped := composeOrgSheetSQL(spec, cols)
|
||||
want := "SELECT * FROM paliad.ghost"
|
||||
if got != want {
|
||||
t.Fatalf("got SQL %q, want %q", got, want)
|
||||
}
|
||||
if len(dropped) != 2 {
|
||||
t.Fatalf("expected 2 dropped columns, got %v", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeOrgSheetSQL_SQLOverride_BypassesBuilder(t *testing.T) {
|
||||
// When a sheet declares SQL, the builder MUST NOT touch it — even
|
||||
// if the column knowledge would suggest a change. Custom
|
||||
// projections (documents drops ai_extracted) and special-case
|
||||
// joins both rely on this.
|
||||
spec := orgSheetSpec{
|
||||
SheetName: "documents",
|
||||
Table: "paliad.documents", // should be ignored
|
||||
OrderBy: []string{"id"}, // should be ignored
|
||||
SQL: "SELECT id, title FROM paliad.documents ORDER BY id",
|
||||
}
|
||||
cols := map[string]map[string]struct{}{
|
||||
"documents": {}, // empty → would drop everything if builder ran
|
||||
}
|
||||
got, dropped := composeOrgSheetSQL(spec, cols)
|
||||
if got != spec.SQL {
|
||||
t.Fatalf("SQL override mutated: got %q, want %q", got, spec.SQL)
|
||||
}
|
||||
if len(dropped) != 0 {
|
||||
t.Fatalf("override path should never report drops; got %v", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeOrgSheetSQL_UnknownTable_DropsAllOrderBy(t *testing.T) {
|
||||
// A table missing entirely from the schema snapshot is treated as
|
||||
// "no columns known" — every ORDER BY column gets dropped, but
|
||||
// the SELECT still emits (so a stale registry doesn't crash the
|
||||
// backup; the operator gets WARNs to fix it).
|
||||
spec := orgSheetSpec{
|
||||
SheetName: "renamed_table",
|
||||
Table: "paliad.renamed_table",
|
||||
OrderBy: []string{"id"},
|
||||
}
|
||||
got, dropped := composeOrgSheetSQL(spec, map[string]map[string]struct{}{})
|
||||
want := "SELECT * FROM paliad.renamed_table"
|
||||
if got != want {
|
||||
t.Fatalf("got SQL %q, want %q", got, want)
|
||||
}
|
||||
if len(dropped) != 1 || dropped[0] != "id" {
|
||||
t.Fatalf("expected dropped=[id], got %v", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeOrgSheetSQL_PreservesOrderByOrder(t *testing.T) {
|
||||
// Multi-column OrderBy must keep its declared order, with kept
|
||||
// columns concatenated in the same sequence. Determinism contract
|
||||
// from t-paliad-214 §3 depends on this.
|
||||
spec := orgSheetSpec{
|
||||
SheetName: "partner_unit_members",
|
||||
Table: "paliad.partner_unit_members",
|
||||
OrderBy: []string{"partner_unit_id", "missing_middle", "user_id"},
|
||||
}
|
||||
cols := map[string]map[string]struct{}{
|
||||
"partner_unit_members": {
|
||||
"partner_unit_id": {},
|
||||
"user_id": {},
|
||||
},
|
||||
}
|
||||
got, dropped := composeOrgSheetSQL(spec, cols)
|
||||
want := "SELECT * FROM paliad.partner_unit_members ORDER BY partner_unit_id, user_id"
|
||||
if got != want {
|
||||
t.Fatalf("got SQL %q, want %q", got, want)
|
||||
}
|
||||
if len(dropped) != 1 || dropped[0] != "missing_middle" {
|
||||
t.Fatalf("expected dropped=[missing_middle], got %v", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -55,13 +67,13 @@ func (s *DeadlineRuleService) List(ctx context.Context, proceedingTypeID *int) (
|
||||
if proceedingTypeID != nil {
|
||||
err = s.db.SelectContext(ctx, &rules,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE proceeding_type_id = $1 AND is_active = true
|
||||
ORDER BY sequence_order`, *proceedingTypeID)
|
||||
} else {
|
||||
err = s.db.SelectContext(ctx, &rules,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE is_active = true
|
||||
ORDER BY proceeding_type_id, sequence_order`)
|
||||
}
|
||||
@@ -100,7 +112,7 @@ func (s *DeadlineRuleService) hydrateConceptDefaultEventTypes(ctx context.Contex
|
||||
}
|
||||
query, args, err := sqlx.In(
|
||||
`SELECT dr.id AS rule_id, j.event_type_id
|
||||
FROM paliad.deadline_rules dr
|
||||
FROM paliad.deadline_rules_unified dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
JOIN paliad.deadline_concept_event_types j
|
||||
ON j.concept_id = dr.concept_id
|
||||
@@ -152,7 +164,7 @@ func (s *DeadlineRuleService) GetRuleTree(ctx context.Context, proceedingTypeCod
|
||||
var rules []models.DeadlineRule
|
||||
if err := s.db.SelectContext(ctx, &rules,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE proceeding_type_id = $1 AND is_active = true
|
||||
ORDER BY sequence_order`, pt.ID); err != nil {
|
||||
return nil, fmt.Errorf("list rules for %q: %w", proceedingTypeCode, err)
|
||||
@@ -175,10 +187,10 @@ func (s *DeadlineRuleService) GetFullTimeline(ctx context.Context, proceedingTyp
|
||||
var rules []models.DeadlineRule
|
||||
err := s.db.SelectContext(ctx, &rules, `
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT * FROM paliad.deadline_rules
|
||||
SELECT * FROM paliad.deadline_rules_unified
|
||||
WHERE proceeding_type_id = $1 AND parent_id IS NULL AND is_active = true
|
||||
UNION ALL
|
||||
SELECT dr.* FROM paliad.deadline_rules dr
|
||||
SELECT dr.* FROM paliad.deadline_rules_unified dr
|
||||
JOIN tree t ON dr.parent_id = t.id
|
||||
WHERE dr.is_active = true
|
||||
)
|
||||
@@ -196,7 +208,7 @@ func (s *DeadlineRuleService) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]
|
||||
}
|
||||
query, args, err := sqlx.In(
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE id IN (?) AND is_active = true
|
||||
ORDER BY sequence_order`, ids)
|
||||
if err != nil {
|
||||
@@ -264,7 +276,7 @@ func (s *DeadlineRuleService) ListByTriggerEvent(ctx context.Context, triggerEve
|
||||
var rules []models.DeadlineRule
|
||||
if err := s.db.SelectContext(ctx, &rules,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE trigger_event_id = $1
|
||||
AND is_active = true
|
||||
ORDER BY sequence_order`, triggerEventID); err != nil {
|
||||
@@ -292,7 +304,7 @@ func (s *DeadlineRuleService) ListByProceedingTypeIDs(ctx context.Context, ids [
|
||||
}
|
||||
query, args, err := sqlx.In(
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE proceeding_type_id IN (?)
|
||||
AND is_active = true
|
||||
ORDER BY proceeding_type_id, sequence_order`, ids)
|
||||
@@ -327,7 +339,7 @@ func (s *DeadlineRuleService) ListByConcept(ctx context.Context, conceptID uuid.
|
||||
var rules []models.DeadlineRule
|
||||
if err := s.db.SelectContext(ctx, &rules,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE concept_id = $1
|
||||
AND is_active = true
|
||||
ORDER BY proceeding_type_id NULLS LAST, sequence_order`, conceptID); err != nil {
|
||||
|
||||
@@ -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 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,6 +594,16 @@ 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.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 := syncDeadlineProceduralEventID(ctx, tx, deadlineID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if input.EventTypeIDs != nil && s.eventTypes != nil {
|
||||
|
||||
50
internal/services/dual_write.go
Normal file
50
internal/services/dual_write.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Slice B.4 retirement of B.2 dual-write (t-paliad-305 / m/paliad#93).
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// The DualWriteDriftReport struct + HasDrift method are retired with
|
||||
// the loop they served.
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// 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).
|
||||
//
|
||||
// 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 procedural_event_id = (
|
||||
SELECT sr.procedural_event_id
|
||||
FROM paliad.sequencing_rules sr
|
||||
WHERE sr.id = d.sequencing_rule_id
|
||||
)
|
||||
WHERE d.id = $1`, deadlineID); err != nil {
|
||||
return fmt.Errorf("sync deadline procedural_event_id for %s: %w", deadlineID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
297
internal/services/dual_write_test.go
Normal file
297
internal/services/dual_write_test.go
Normal file
@@ -0,0 +1,297 @@
|
||||
// Slice B.2 dual-write tests (t-paliad-305 / m/paliad#93).
|
||||
//
|
||||
// Asserts the parallel projection — paliad.procedural_events +
|
||||
// paliad.sequencing_rules + paliad.legal_sources — stays in lock-step
|
||||
// with paliad.deadline_rules through the full RuleEditorService
|
||||
// lifecycle. Skipped when TEST_DATABASE_URL is unset.
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
// TestDualWrite_RuleEditorLifecycle walks Create → UpdateDraft →
|
||||
// CloneAsDraft → Publish → Archive → Restore on RuleEditorService and
|
||||
// after each operation asserts that paliad.sequencing_rules has the
|
||||
// 1:1 mirror, paliad.procedural_events carries the projected identity,
|
||||
// and paliad.legal_sources carries the citation.
|
||||
func TestDualWrite_RuleEditorLifecycle(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()
|
||||
rules := NewDeadlineRuleService(pool)
|
||||
svc := NewRuleEditorService(pool, rules)
|
||||
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx,
|
||||
`SELECT set_config('paliad.audit_reason', 'slice b.2 test cleanup', true)`)
|
||||
// Order matters: sequencing_rules → procedural_events → legal_sources
|
||||
// (FK direction). deadline_rules cleanup last because mig 079 audit
|
||||
// trigger captures the DELETE.
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.sequencing_rules WHERE id IN (
|
||||
SELECT id FROM paliad.deadline_rules WHERE name LIKE 'SLICEB2_TEST_%'
|
||||
)`)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.procedural_events
|
||||
WHERE code LIKE 'sliceb2.%' OR code LIKE 'null.sliceb2%'`)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.legal_sources
|
||||
WHERE citation LIKE 'SLICEB2.%'`)
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM paliad.deadline_rules WHERE name LIKE 'SLICEB2_TEST_%'`)
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM paliad.proceeding_types WHERE code = 'SLICEB2_TEST_PT'`)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
var ptID int
|
||||
if err := pool.GetContext(ctx, &ptID, `
|
||||
INSERT INTO paliad.proceeding_types (code, name, name_en, category, jurisdiction, is_active)
|
||||
VALUES ('SLICEB2_TEST_PT', 'Slice B.2 Test PT', 'Slice B.2 Test PT', 'fristenrechner', 'UPC', true)
|
||||
RETURNING id`); err != nil {
|
||||
t.Fatalf("seed proceeding_type: %v", err)
|
||||
}
|
||||
|
||||
subCode := "sliceb2.create"
|
||||
legalSrc := "SLICEB2.PatG.1"
|
||||
|
||||
// 1. Create — assert the parallel rows land.
|
||||
created, err := svc.Create(ctx, CreateRuleInput{
|
||||
Name: "SLICEB2_TEST_create",
|
||||
NameEN: "SLICEB2_TEST_create_EN",
|
||||
ProceedingTypeID: &ptID,
|
||||
SubmissionCode: &subCode,
|
||||
LegalSource: &legalSrc,
|
||||
DurationValue: 30,
|
||||
DurationUnit: "days",
|
||||
Priority: "mandatory",
|
||||
}, "B.2 dual-write create test")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
// legal_sources should now carry SLICEB2.PatG.1
|
||||
var lsCount int
|
||||
if err := pool.GetContext(ctx, &lsCount,
|
||||
`SELECT COUNT(*) FROM paliad.legal_sources WHERE citation = $1`, legalSrc); err != nil {
|
||||
t.Fatalf("query legal_sources: %v", err)
|
||||
}
|
||||
if lsCount != 1 {
|
||||
t.Errorf("legal_sources after Create: got %d, want 1 for citation %q", lsCount, legalSrc)
|
||||
}
|
||||
|
||||
// procedural_events should carry the submission_code
|
||||
var peName, peLifecycle string
|
||||
if err := pool.GetContext(ctx, &peName,
|
||||
`SELECT name FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
|
||||
t.Fatalf("query procedural_events name: %v", err)
|
||||
}
|
||||
if peName != "SLICEB2_TEST_create" {
|
||||
t.Errorf("procedural_events.name after Create: got %q, want %q", peName, "SLICEB2_TEST_create")
|
||||
}
|
||||
if err := pool.GetContext(ctx, &peLifecycle,
|
||||
`SELECT lifecycle_state FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
|
||||
t.Fatalf("query procedural_events lifecycle: %v", err)
|
||||
}
|
||||
if peLifecycle != "draft" {
|
||||
t.Errorf("procedural_events.lifecycle_state after Create: got %q, want %q", peLifecycle, "draft")
|
||||
}
|
||||
|
||||
// sequencing_rules should have id = created.id and link to PE
|
||||
var srCount, srMatchPE int
|
||||
if err := pool.GetContext(ctx, &srCount,
|
||||
`SELECT COUNT(*) FROM paliad.sequencing_rules WHERE id = $1`, created.ID); err != nil {
|
||||
t.Fatalf("query sequencing_rules count: %v", err)
|
||||
}
|
||||
if srCount != 1 {
|
||||
t.Errorf("sequencing_rules row after Create: got %d, want 1 for id %s", srCount, created.ID)
|
||||
}
|
||||
if err := pool.GetContext(ctx, &srMatchPE, `
|
||||
SELECT COUNT(*) FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE sr.id = $1 AND pe.code = $2`, created.ID, subCode); err != nil {
|
||||
t.Fatalf("query sr→pe join: %v", err)
|
||||
}
|
||||
if srMatchPE != 1 {
|
||||
t.Errorf("sequencing_rules.procedural_event_id after Create: got %d join hits, want 1", srMatchPE)
|
||||
}
|
||||
|
||||
// 2. UpdateDraft — change name + legal_source. Assert propagation.
|
||||
newName := "SLICEB2_TEST_updated"
|
||||
newLegal := "SLICEB2.ZPO.2"
|
||||
_, err = svc.UpdateDraft(ctx, created.ID, RulePatch{
|
||||
Name: &newName,
|
||||
LegalSource: &newLegal,
|
||||
}, "B.2 dual-write update test")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateDraft: %v", err)
|
||||
}
|
||||
|
||||
var afterName string
|
||||
if err := pool.GetContext(ctx, &afterName,
|
||||
`SELECT name FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
|
||||
t.Fatalf("query pe.name post-update: %v", err)
|
||||
}
|
||||
if afterName != newName {
|
||||
t.Errorf("procedural_events.name after UpdateDraft: got %q, want %q", afterName, newName)
|
||||
}
|
||||
|
||||
// New citation must appear in legal_sources, and procedural_events.legal_source_id
|
||||
// must point at it (idempotent UPSERT — the old SLICEB2.PatG.1 row stays).
|
||||
var pePointsAtNewLegal int
|
||||
if err := pool.GetContext(ctx, &pePointsAtNewLegal, `
|
||||
SELECT COUNT(*) FROM paliad.procedural_events pe
|
||||
JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id
|
||||
WHERE pe.code = $1 AND ls.citation = $2`, subCode, newLegal); err != nil {
|
||||
t.Fatalf("query pe→ls join: %v", err)
|
||||
}
|
||||
if pePointsAtNewLegal != 1 {
|
||||
t.Errorf("procedural_events.legal_source_id after UpdateDraft: got %d hits, want 1", pePointsAtNewLegal)
|
||||
}
|
||||
|
||||
// 3. Publish — flip to published. Assert lifecycle mirror.
|
||||
_, err = svc.Publish(ctx, created.ID, "B.2 dual-write publish test")
|
||||
if err != nil {
|
||||
t.Fatalf("Publish: %v", err)
|
||||
}
|
||||
var srLifecycle, peLifecycleAfterPub string
|
||||
if err := pool.GetContext(ctx, &srLifecycle,
|
||||
`SELECT lifecycle_state FROM paliad.sequencing_rules WHERE id = $1`, created.ID); err != nil {
|
||||
t.Fatalf("query sr.lifecycle: %v", err)
|
||||
}
|
||||
if srLifecycle != "published" {
|
||||
t.Errorf("sequencing_rules.lifecycle_state after Publish: got %q, want %q", srLifecycle, "published")
|
||||
}
|
||||
if err := pool.GetContext(ctx, &peLifecycleAfterPub,
|
||||
`SELECT lifecycle_state FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
|
||||
t.Fatalf("query pe.lifecycle post-publish: %v", err)
|
||||
}
|
||||
if peLifecycleAfterPub != "published" {
|
||||
t.Errorf("procedural_events.lifecycle_state after Publish: got %q, want %q", peLifecycleAfterPub, "published")
|
||||
}
|
||||
|
||||
// 4. Archive — flip to archived. Assert mirror.
|
||||
_, err = svc.Archive(ctx, created.ID, "B.2 dual-write archive test")
|
||||
if err != nil {
|
||||
t.Fatalf("Archive: %v", err)
|
||||
}
|
||||
var srLifecycleArchived string
|
||||
if err := pool.GetContext(ctx, &srLifecycleArchived,
|
||||
`SELECT lifecycle_state FROM paliad.sequencing_rules WHERE id = $1`, created.ID); err != nil {
|
||||
t.Fatalf("query sr.lifecycle post-archive: %v", err)
|
||||
}
|
||||
if srLifecycleArchived != "archived" {
|
||||
t.Errorf("sequencing_rules.lifecycle_state after Archive: got %q, want %q", srLifecycleArchived, "archived")
|
||||
}
|
||||
|
||||
// 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
|
||||
// created with submission_code=NULL gets a synthetic 'null.<8hex>'
|
||||
// procedural_events row matching mig 136's mint expression — so a new
|
||||
// draft without a code participates in the dual-write contract without
|
||||
// colliding with any code-bearing rule.
|
||||
func TestDualWrite_SyntheticCodeForNullSubmission(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()
|
||||
rules := NewDeadlineRuleService(pool)
|
||||
svc := NewRuleEditorService(pool, rules)
|
||||
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `SELECT set_config('paliad.audit_reason', 'slice b.2 null-code cleanup', true)`)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.sequencing_rules WHERE id IN (
|
||||
SELECT id FROM paliad.deadline_rules WHERE name = 'SLICEB2_TEST_nullcode'
|
||||
)`)
|
||||
// Synthetic PE rows are keyed off the rule's uuid; delete by name reference.
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.procedural_events
|
||||
WHERE code IN (
|
||||
SELECT 'null.' || substring(replace(id::text, '-', ''), 1, 8)
|
||||
FROM paliad.deadline_rules WHERE name = 'SLICEB2_TEST_nullcode'
|
||||
)`)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.deadline_rules WHERE name = 'SLICEB2_TEST_nullcode'`)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.proceeding_types WHERE code = 'SLICEB2_NC_PT'`)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
var ptID int
|
||||
if err := pool.GetContext(ctx, &ptID, `
|
||||
INSERT INTO paliad.proceeding_types (code, name, name_en, category, jurisdiction, is_active)
|
||||
VALUES ('SLICEB2_NC_PT', 'NC PT', 'NC PT', 'fristenrechner', 'UPC', true)
|
||||
RETURNING id`); err != nil {
|
||||
t.Fatalf("seed proceeding_type: %v", err)
|
||||
}
|
||||
|
||||
created, err := svc.Create(ctx, CreateRuleInput{
|
||||
Name: "SLICEB2_TEST_nullcode",
|
||||
NameEN: "SLICEB2_TEST_nullcode_EN",
|
||||
ProceedingTypeID: &ptID,
|
||||
// SubmissionCode intentionally NIL → tests the synthetic-code branch.
|
||||
DurationValue: 5,
|
||||
DurationUnit: "days",
|
||||
Priority: "mandatory",
|
||||
}, "B.2 dual-write null-code test")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
// Compute the expected synthetic code in the same way mig 136 / the
|
||||
// dual-write helper do — keep the expression in lock-step with the
|
||||
// SQL via this Go-side mirror.
|
||||
var expectedCode string
|
||||
if err := pool.GetContext(ctx, &expectedCode,
|
||||
`SELECT 'null.' || substring(replace(id::text, '-', ''), 1, 8)
|
||||
FROM paliad.deadline_rules WHERE id = $1`, created.ID); err != nil {
|
||||
t.Fatalf("compute expected synthetic code: %v", err)
|
||||
}
|
||||
|
||||
var actualCode string
|
||||
if err := pool.GetContext(ctx, &actualCode, `
|
||||
SELECT pe.code
|
||||
FROM paliad.procedural_events pe
|
||||
JOIN paliad.sequencing_rules sr ON sr.procedural_event_id = pe.id
|
||||
WHERE sr.id = $1`, created.ID); err != nil {
|
||||
t.Fatalf("query procedural_events via sequencing_rules: %v", err)
|
||||
}
|
||||
if actualCode != expectedCode {
|
||||
t.Errorf("synthetic code mismatch: got %q, want %q", actualCode, expectedCode)
|
||||
}
|
||||
if len(actualCode) != len("null.")+8 {
|
||||
t.Errorf("synthetic code length: got %d, want 13 (null.+8hex)", len(actualCode))
|
||||
}
|
||||
}
|
||||
@@ -168,7 +168,7 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
|
||||
COALESCE(timing, 'after') AS timing,
|
||||
deadline_notes, deadline_notes_en, alt_duration_value, alt_duration_unit,
|
||||
combine_op, rule_codes
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE trigger_event_id = $1 AND is_active = true
|
||||
ORDER BY sequence_order`, triggerEventID)
|
||||
if err != nil {
|
||||
|
||||
@@ -46,6 +46,7 @@ import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -297,7 +298,10 @@ func (s *ExportService) WriteOrg(ctx context.Context, w io.Writer, spec ExportSp
|
||||
// is just bookkeeping that releases the snapshot.
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
sheets := orgSheetQueries()
|
||||
sheets, err := resolveOrgSheets(ctx, tx, orgSheetSpecs())
|
||||
if err != nil {
|
||||
return meta, err
|
||||
}
|
||||
if err := s.writeBundle(ctx, tx, w, sheets, &meta); err != nil {
|
||||
return meta, err
|
||||
}
|
||||
@@ -1138,7 +1142,7 @@ func personalSheetQueries(actorID uuid.UUID) []sheetQuery {
|
||||
},
|
||||
{
|
||||
SheetName: "ref__deadline_rules",
|
||||
SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`,
|
||||
SQL: `SELECT * FROM paliad.deadline_rules_unified ORDER BY id`,
|
||||
},
|
||||
{
|
||||
SheetName: "ref__deadline_concepts",
|
||||
@@ -1518,7 +1522,7 @@ SELECT 'partner_unit_default'::text AS source,
|
||||
{SheetName: "ref__proceeding_types", SQL: `SELECT * FROM paliad.proceeding_types ORDER BY id`},
|
||||
{SheetName: "ref__event_types", SQL: `SELECT * FROM paliad.event_types ORDER BY id`},
|
||||
{SheetName: "ref__event_categories", SQL: `SELECT * FROM paliad.event_categories ORDER BY id`},
|
||||
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`},
|
||||
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules_unified ORDER BY id`},
|
||||
{SheetName: "ref__deadline_concepts", SQL: `SELECT * FROM paliad.deadline_concepts ORDER BY id`},
|
||||
{SheetName: "ref__courts", SQL: `SELECT * FROM paliad.courts ORDER BY id`},
|
||||
{SheetName: "ref__countries", SQL: `SELECT * FROM paliad.countries ORDER BY code`},
|
||||
@@ -1560,73 +1564,249 @@ SELECT 'partner_unit_default'::text AS source,
|
||||
// secret|token|password|api_key|private_key on every sheet as a
|
||||
// belt-and-braces filter. user_caldav_config.password_encrypted is
|
||||
// explicitly named in DropColumns too.
|
||||
func orgSheetQueries() []sheetQuery {
|
||||
return []sheetQuery{
|
||||
//
|
||||
// Drift-resistance (m/paliad#140): each spec declares its desired
|
||||
// ORDER BY columns as a list. At backup time the exporter probes
|
||||
// information_schema.columns for the live schema; any ORDER BY column
|
||||
// that no longer exists is dropped (logged WARN). This way a column
|
||||
// rename or removal never breaks a backup — the worst case is a sheet
|
||||
// that loses sort stability until the spec is updated. A sheet whose
|
||||
// ORDER BY columns are all gone still exports, just in pg's natural
|
||||
// (unspecified) order.
|
||||
//
|
||||
// Custom column projections (e.g. documents drops ai_extracted) live
|
||||
// in the SQL override field; if set, it bypasses the Table+OrderBy
|
||||
// builder entirely. Use it sparingly — every override re-introduces
|
||||
// drift risk for that sheet.
|
||||
|
||||
// orgSheetSpec declares one org-scope sheet for the drift-resistant
|
||||
// builder. Either set SQL (free-form override) or set Table+OrderBy
|
||||
// (let the builder compose `SELECT * FROM <Table> ORDER BY <existing>`).
|
||||
type orgSheetSpec struct {
|
||||
// SheetName lands in the workbook sheet and the JSON top-level key.
|
||||
SheetName string
|
||||
// Table is schema-qualified (e.g. "paliad.appointments"). Used only
|
||||
// when SQL is empty. The schema/table form must be valid SQL
|
||||
// identifiers — the builder splits on the dot, no quoting.
|
||||
Table string
|
||||
// OrderBy is the *desired* sort columns. Missing columns are
|
||||
// dropped silently-with-a-WARN at build time; remaining columns
|
||||
// keep their declared order. Empty/all-missing → no ORDER BY (still
|
||||
// deterministic-within-a-snapshot under the REPEATABLE READ tx, but
|
||||
// the order across runs may differ).
|
||||
OrderBy []string
|
||||
// SQL is an explicit override; if non-empty, Table+OrderBy are
|
||||
// ignored entirely. Use only when the projection cannot be
|
||||
// expressed as SELECT * (e.g. documents drops the ai_extracted
|
||||
// jsonb column).
|
||||
SQL string
|
||||
// Args are positional arguments. Only meaningful with SQL override;
|
||||
// the Table+OrderBy path takes no args.
|
||||
Args []any
|
||||
// DropColumns is an explicit list of column names to drop from the
|
||||
// result regardless of the PII deny-regex.
|
||||
DropColumns []string
|
||||
}
|
||||
|
||||
func orgSheetSpecs() []orgSheetSpec {
|
||||
return []orgSheetSpec{
|
||||
// --- entity sheets (alphabetical) ---
|
||||
{SheetName: "appointment_caldav_targets", SQL: `SELECT * FROM paliad.appointment_caldav_targets ORDER BY appointment_id, calendar_binding_id`},
|
||||
{SheetName: "appointments", SQL: `SELECT * FROM paliad.appointments ORDER BY id`},
|
||||
{SheetName: "approval_policies", SQL: `SELECT * FROM paliad.approval_policies ORDER BY id`},
|
||||
{SheetName: "approval_requests", SQL: `SELECT * FROM paliad.approval_requests ORDER BY id`},
|
||||
{SheetName: "appointment_caldav_targets", Table: "paliad.appointment_caldav_targets", OrderBy: []string{"appointment_id", "binding_id"}},
|
||||
{SheetName: "appointments", Table: "paliad.appointments", OrderBy: []string{"id"}},
|
||||
{SheetName: "approval_policies", Table: "paliad.approval_policies", OrderBy: []string{"id"}},
|
||||
{SheetName: "approval_requests", Table: "paliad.approval_requests", OrderBy: []string{"id"}},
|
||||
// backups is self-reflexive — including it makes "what backups
|
||||
// have we taken" recoverable from any prior backup. Tiny table.
|
||||
{SheetName: "backups", SQL: `SELECT * FROM paliad.backups ORDER BY started_at, id`},
|
||||
{SheetName: "caldav_sync_log", SQL: `SELECT * FROM paliad.caldav_sync_log ORDER BY occurred_at, id`},
|
||||
{SheetName: "checklist_instances", SQL: `SELECT * FROM paliad.checklist_instances ORDER BY id`},
|
||||
{SheetName: "checklist_shares", SQL: `SELECT * FROM paliad.checklist_shares ORDER BY id`},
|
||||
{SheetName: "checklists", SQL: `SELECT * FROM paliad.checklists ORDER BY id`},
|
||||
{SheetName: "deadline_rule_audit", SQL: `SELECT * FROM paliad.deadline_rule_audit ORDER BY changed_at, id`},
|
||||
{SheetName: "deadlines", SQL: `SELECT * FROM paliad.deadlines ORDER BY id`},
|
||||
{SheetName: "backups", Table: "paliad.backups", OrderBy: []string{"started_at", "id"}},
|
||||
{SheetName: "caldav_sync_log", Table: "paliad.caldav_sync_log", OrderBy: []string{"occurred_at", "id"}},
|
||||
{SheetName: "checklist_instances", Table: "paliad.checklist_instances", OrderBy: []string{"id"}},
|
||||
{SheetName: "checklist_shares", Table: "paliad.checklist_shares", OrderBy: []string{"id"}},
|
||||
{SheetName: "checklists", Table: "paliad.checklists", OrderBy: []string{"id"}},
|
||||
{SheetName: "deadline_rule_audit", Table: "paliad.deadline_rule_audit", OrderBy: []string{"changed_at", "id"}},
|
||||
{SheetName: "deadlines", Table: "paliad.deadlines", OrderBy: []string{"id"}},
|
||||
// documents: ai_extracted jsonb dropped (verbose AI prompts;
|
||||
// matches the personal/project precedent). Binaries are not in
|
||||
// the export — only metadata.
|
||||
// the export — only metadata. Uses SQL override because the
|
||||
// projection isn't SELECT *.
|
||||
{
|
||||
SheetName: "documents",
|
||||
SQL: `SELECT id, project_id, title, doc_type, file_path, file_size, mime_type, uploaded_by, created_at, updated_at
|
||||
FROM paliad.documents
|
||||
ORDER BY id`,
|
||||
},
|
||||
{SheetName: "email_broadcasts", SQL: `SELECT * FROM paliad.email_broadcasts ORDER BY id`},
|
||||
{SheetName: "email_template_versions", SQL: `SELECT * FROM paliad.email_template_versions ORDER BY id`},
|
||||
{SheetName: "email_templates", SQL: `SELECT * FROM paliad.email_templates ORDER BY id`},
|
||||
{SheetName: "firm_dashboard_default", SQL: `SELECT * FROM paliad.firm_dashboard_default ORDER BY id`},
|
||||
{SheetName: "invitations", SQL: `SELECT * FROM paliad.invitations ORDER BY sent_at, id`},
|
||||
{SheetName: "notes", SQL: `SELECT * FROM paliad.notes ORDER BY id`},
|
||||
{SheetName: "parties", SQL: `SELECT * FROM paliad.parties ORDER BY id`},
|
||||
{SheetName: "partner_unit_events", SQL: `SELECT * FROM paliad.partner_unit_events ORDER BY id`},
|
||||
{SheetName: "partner_unit_members", SQL: `SELECT * FROM paliad.partner_unit_members ORDER BY partner_unit_id, user_id`},
|
||||
{SheetName: "partner_units", SQL: `SELECT * FROM paliad.partner_units ORDER BY id`},
|
||||
{SheetName: "policy_audit_log", SQL: `SELECT * FROM paliad.policy_audit_log ORDER BY changed_at, id`},
|
||||
{SheetName: "project_events", SQL: `SELECT * FROM paliad.project_events ORDER BY id`},
|
||||
{SheetName: "project_partner_units", SQL: `SELECT * FROM paliad.project_partner_units ORDER BY project_id, partner_unit_id`},
|
||||
{SheetName: "project_teams", SQL: `SELECT * FROM paliad.project_teams ORDER BY project_id, user_id`},
|
||||
{SheetName: "projects", SQL: `SELECT * FROM paliad.projects ORDER BY id`},
|
||||
{SheetName: "reminder_log", SQL: `SELECT * FROM paliad.reminder_log ORDER BY sent_at, id`},
|
||||
{SheetName: "submission_drafts", SQL: `SELECT * FROM paliad.submission_drafts ORDER BY id`},
|
||||
{SheetName: "system_audit_log", SQL: `SELECT * FROM paliad.system_audit_log ORDER BY created_at, id`},
|
||||
{SheetName: "email_broadcasts", Table: "paliad.email_broadcasts", OrderBy: []string{"id"}},
|
||||
{SheetName: "email_template_versions", Table: "paliad.email_template_versions", OrderBy: []string{"id"}},
|
||||
{SheetName: "email_templates", Table: "paliad.email_templates", OrderBy: []string{"key", "lang"}},
|
||||
{SheetName: "firm_dashboard_default", Table: "paliad.firm_dashboard_default", OrderBy: []string{"id"}},
|
||||
{SheetName: "invitations", Table: "paliad.invitations", OrderBy: []string{"sent_at", "id"}},
|
||||
{SheetName: "notes", Table: "paliad.notes", OrderBy: []string{"id"}},
|
||||
{SheetName: "parties", Table: "paliad.parties", OrderBy: []string{"id"}},
|
||||
{SheetName: "partner_unit_events", Table: "paliad.partner_unit_events", OrderBy: []string{"id"}},
|
||||
{SheetName: "partner_unit_members", Table: "paliad.partner_unit_members", OrderBy: []string{"partner_unit_id", "user_id"}},
|
||||
{SheetName: "partner_units", Table: "paliad.partner_units", OrderBy: []string{"id"}},
|
||||
{SheetName: "policy_audit_log", Table: "paliad.policy_audit_log", OrderBy: []string{"created_at", "id"}},
|
||||
{SheetName: "project_events", Table: "paliad.project_events", OrderBy: []string{"id"}},
|
||||
{SheetName: "project_partner_units", Table: "paliad.project_partner_units", OrderBy: []string{"project_id", "partner_unit_id"}},
|
||||
{SheetName: "project_teams", Table: "paliad.project_teams", OrderBy: []string{"project_id", "user_id"}},
|
||||
{SheetName: "projects", Table: "paliad.projects", OrderBy: []string{"id"}},
|
||||
{SheetName: "reminder_log", Table: "paliad.reminder_log", OrderBy: []string{"sent_at", "id"}},
|
||||
{SheetName: "submission_drafts", Table: "paliad.submission_drafts", OrderBy: []string{"id"}},
|
||||
{SheetName: "system_audit_log", Table: "paliad.system_audit_log", OrderBy: []string{"created_at", "id"}},
|
||||
{
|
||||
SheetName: "user_caldav_config",
|
||||
SQL: `SELECT * FROM paliad.user_caldav_config ORDER BY user_id`,
|
||||
Table: "paliad.user_caldav_config",
|
||||
OrderBy: []string{"user_id"},
|
||||
DropColumns: []string{"password_encrypted"}, // belt-and-braces; piiColumnDenyRegex also catches it
|
||||
},
|
||||
{SheetName: "user_calendar_bindings", SQL: `SELECT * FROM paliad.user_calendar_bindings ORDER BY user_id, calendar_path`},
|
||||
{SheetName: "user_card_layouts", SQL: `SELECT * FROM paliad.user_card_layouts ORDER BY id`},
|
||||
{SheetName: "user_dashboard_layouts", SQL: `SELECT * FROM paliad.user_dashboard_layouts ORDER BY user_id`},
|
||||
{SheetName: "user_pinned_projects", SQL: `SELECT * FROM paliad.user_pinned_projects ORDER BY user_id, project_id`},
|
||||
{SheetName: "user_views", SQL: `SELECT * FROM paliad.user_views ORDER BY id`},
|
||||
{SheetName: "users", SQL: `SELECT * FROM paliad.users ORDER BY id`},
|
||||
{SheetName: "user_calendar_bindings", Table: "paliad.user_calendar_bindings", OrderBy: []string{"user_id", "calendar_path"}},
|
||||
{SheetName: "user_card_layouts", Table: "paliad.user_card_layouts", OrderBy: []string{"id"}},
|
||||
{SheetName: "user_dashboard_layouts", Table: "paliad.user_dashboard_layouts", OrderBy: []string{"user_id"}},
|
||||
{SheetName: "user_pinned_projects", Table: "paliad.user_pinned_projects", OrderBy: []string{"user_id", "project_id"}},
|
||||
{SheetName: "user_views", Table: "paliad.user_views", OrderBy: []string{"id"}},
|
||||
{SheetName: "users", Table: "paliad.users", OrderBy: []string{"id"}},
|
||||
|
||||
// --- reference data (alphabetical, prefixed ref__) ---
|
||||
{SheetName: "ref__countries", SQL: `SELECT * FROM paliad.countries ORDER BY code`},
|
||||
{SheetName: "ref__courts", SQL: `SELECT * FROM paliad.courts ORDER BY id`},
|
||||
{SheetName: "ref__deadline_concept_event_types", SQL: `SELECT * FROM paliad.deadline_concept_event_types ORDER BY concept_id, event_type_id`},
|
||||
{SheetName: "ref__deadline_concepts", SQL: `SELECT * FROM paliad.deadline_concepts ORDER BY id`},
|
||||
{SheetName: "ref__deadline_event_types", SQL: `SELECT * FROM paliad.deadline_event_types ORDER BY rule_id, event_type_id`},
|
||||
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`},
|
||||
{SheetName: "ref__event_categories", SQL: `SELECT * FROM paliad.event_categories ORDER BY id`},
|
||||
{SheetName: "ref__event_category_concepts", SQL: `SELECT * FROM paliad.event_category_concepts ORDER BY category_id, concept_id`},
|
||||
{SheetName: "ref__event_types", SQL: `SELECT * FROM paliad.event_types ORDER BY id`},
|
||||
{SheetName: "ref__holidays", SQL: `SELECT * FROM paliad.holidays ORDER BY date, country`},
|
||||
{SheetName: "ref__proceeding_types", SQL: `SELECT * FROM paliad.proceeding_types ORDER BY id`},
|
||||
{SheetName: "ref__trigger_events", SQL: `SELECT * FROM paliad.trigger_events ORDER BY id`},
|
||||
{SheetName: "ref__countries", Table: "paliad.countries", OrderBy: []string{"code"}},
|
||||
{SheetName: "ref__courts", Table: "paliad.courts", OrderBy: []string{"id"}},
|
||||
{SheetName: "ref__deadline_concept_event_types", Table: "paliad.deadline_concept_event_types", OrderBy: []string{"concept_id", "event_type_id"}},
|
||||
{SheetName: "ref__deadline_concepts", Table: "paliad.deadline_concepts", OrderBy: []string{"id"}},
|
||||
{SheetName: "ref__deadline_event_types", Table: "paliad.deadline_event_types", OrderBy: []string{"deadline_id", "event_type_id"}},
|
||||
{SheetName: "ref__deadline_rules", Table: "paliad.deadline_rules_unified", OrderBy: []string{"id"}},
|
||||
{SheetName: "ref__event_categories", Table: "paliad.event_categories", OrderBy: []string{"id"}},
|
||||
{SheetName: "ref__event_category_concepts", Table: "paliad.event_category_concepts", OrderBy: []string{"event_category_id", "concept_id"}},
|
||||
{SheetName: "ref__event_types", Table: "paliad.event_types", OrderBy: []string{"id"}},
|
||||
{SheetName: "ref__holidays", Table: "paliad.holidays", OrderBy: []string{"date", "country"}},
|
||||
{SheetName: "ref__proceeding_types", Table: "paliad.proceeding_types", OrderBy: []string{"id"}},
|
||||
{SheetName: "ref__trigger_events", Table: "paliad.trigger_events", OrderBy: []string{"id"}},
|
||||
}
|
||||
}
|
||||
|
||||
// composeOrgSheetSQL turns one orgSheetSpec into the final SQL string,
|
||||
// using a per-table column set (typically loaded once per backup run
|
||||
// from information_schema.columns). Returns the SQL and the list of
|
||||
// ORDER BY columns that were dropped because they don't exist in the
|
||||
// live schema.
|
||||
//
|
||||
// Pure function — no DB access — so the missing-column behaviour is
|
||||
// unit-testable without a fixture database.
|
||||
//
|
||||
// Rules:
|
||||
// - If spec.SQL is non-empty, return it unchanged (override path).
|
||||
// - Otherwise build `SELECT * FROM <Table> [ORDER BY <kept-cols>]`.
|
||||
// - Columns are kept in their declared order; missing ones recorded
|
||||
// in `dropped` and omitted from ORDER BY.
|
||||
// - If no ORDER BY columns survive, the ORDER BY clause is omitted.
|
||||
//
|
||||
// knownCols maps unqualified table names (e.g. "appointments") to the
|
||||
// set of columns they have. A table missing from knownCols is treated
|
||||
// as "no columns known" — every declared ORDER BY column gets dropped.
|
||||
func composeOrgSheetSQL(spec orgSheetSpec, knownCols map[string]map[string]struct{}) (sqlText string, dropped []string) {
|
||||
if spec.SQL != "" {
|
||||
return spec.SQL, nil
|
||||
}
|
||||
unqualified := spec.Table
|
||||
if i := strings.IndexByte(unqualified, '.'); i >= 0 {
|
||||
unqualified = unqualified[i+1:]
|
||||
}
|
||||
cols := knownCols[unqualified]
|
||||
kept := make([]string, 0, len(spec.OrderBy))
|
||||
for _, c := range spec.OrderBy {
|
||||
if _, ok := cols[c]; ok {
|
||||
kept = append(kept, c)
|
||||
} else {
|
||||
dropped = append(dropped, c)
|
||||
}
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("SELECT * FROM ")
|
||||
b.WriteString(spec.Table)
|
||||
if len(kept) > 0 {
|
||||
b.WriteString(" ORDER BY ")
|
||||
b.WriteString(strings.Join(kept, ", "))
|
||||
}
|
||||
return b.String(), dropped
|
||||
}
|
||||
|
||||
// loadOrgSheetColumns probes information_schema.columns once for every
|
||||
// table referenced by Table+OrderBy specs. Returns a lookup
|
||||
// {table_name → {column_name → {}}} restricted to the paliad schema.
|
||||
//
|
||||
// The queryer is whatever runs the backup's read snapshot — typically
|
||||
// the REPEATABLE READ tx opened in WriteOrg, so the schema snapshot
|
||||
// matches the row snapshot.
|
||||
func loadOrgSheetColumns(ctx context.Context, queryer sqlx.QueryerContext, specs []orgSheetSpec) (map[string]map[string]struct{}, error) {
|
||||
tableSet := map[string]struct{}{}
|
||||
for _, sp := range specs {
|
||||
if sp.Table == "" {
|
||||
continue // SQL-override sheets carry their own column refs
|
||||
}
|
||||
t := sp.Table
|
||||
if i := strings.IndexByte(t, '.'); i >= 0 {
|
||||
t = t[i+1:]
|
||||
}
|
||||
tableSet[t] = struct{}{}
|
||||
}
|
||||
if len(tableSet) == 0 {
|
||||
return map[string]map[string]struct{}{}, nil
|
||||
}
|
||||
tables := make([]string, 0, len(tableSet))
|
||||
for t := range tableSet {
|
||||
tables = append(tables, t)
|
||||
}
|
||||
rows, err := queryer.QueryxContext(ctx, `
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = ANY($1)
|
||||
`, tables)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("probe paliad columns: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]map[string]struct{}, len(tableSet))
|
||||
for rows.Next() {
|
||||
var table, column string
|
||||
if err := rows.Scan(&table, &column); err != nil {
|
||||
return nil, fmt.Errorf("scan paliad columns: %w", err)
|
||||
}
|
||||
set, ok := out[table]
|
||||
if !ok {
|
||||
set = map[string]struct{}{}
|
||||
out[table] = set
|
||||
}
|
||||
set[column] = struct{}{}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate paliad columns: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveOrgSheets materialises an org-scope spec list into the
|
||||
// concrete []sheetQuery that writeBundle expects. Composes each
|
||||
// spec's SQL via composeOrgSheetSQL using a schema snapshot loaded
|
||||
// from the same queryer. Logs WARN per dropped ORDER BY column.
|
||||
func resolveOrgSheets(ctx context.Context, queryer sqlx.QueryerContext, specs []orgSheetSpec) ([]sheetQuery, error) {
|
||||
knownCols, err := loadOrgSheetColumns(ctx, queryer, specs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]sheetQuery, 0, len(specs))
|
||||
for _, sp := range specs {
|
||||
sqlText, dropped := composeOrgSheetSQL(sp, knownCols)
|
||||
for _, c := range dropped {
|
||||
slog.Warn("backup: ORDER BY column dropped (not in schema)",
|
||||
"sheet", sp.SheetName,
|
||||
"table", sp.Table,
|
||||
"column", c,
|
||||
)
|
||||
}
|
||||
out = append(out, sheetQuery{
|
||||
SheetName: sp.SheetName,
|
||||
SQL: sqlText,
|
||||
Args: sp.Args,
|
||||
DropColumns: sp.DropColumns,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ func (c *paliadCatalog) LoadRuleByID(ctx context.Context, ruleID string) (*model
|
||||
var rule models.DeadlineRule
|
||||
err := c.rules.db.GetContext(ctx, &rule,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE id = $1 AND is_active = true`, ruleID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, lp.ErrUnknownRule
|
||||
@@ -200,7 +200,7 @@ func (c *paliadCatalog) LoadRuleByCode(ctx context.Context, proceedingCode, subm
|
||||
var rule models.DeadlineRule
|
||||
err = c.rules.db.GetContext(ctx, &rule,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
|
||||
pt.ID, submissionCode)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -311,7 +311,7 @@ func (c *paliadCatalog) LookupEvents(ctx context.Context, axes lp.EventLookupAxe
|
||||
pt.trigger_event_label_de AS pt_trigger_event_label_de,
|
||||
pt.trigger_event_label_en AS pt_trigger_event_label_en,
|
||||
pt.appeal_target AS pt_appeal_target
|
||||
FROM paliad.deadline_rules dr
|
||||
FROM paliad.deadline_rules_unified dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE ` + strings.Join(where, "\n AND ") + `
|
||||
ORDER BY dr.proceeding_type_id, dr.sequence_order`
|
||||
@@ -516,6 +516,61 @@ func computeDepths(
|
||||
return depths
|
||||
}
|
||||
|
||||
// LoadScenarios lists scenarios visible to the caller (Slice D,
|
||||
// m/paliad#124 §5, mig 145). RLS on paliad.scenarios enforces:
|
||||
// project-scoped rows require paliad.can_see_project(project_id);
|
||||
// abstract rows require created_by = auth.uid(). The filter narrows
|
||||
// the SELECT (project_id-bound, abstract-for-user, or all).
|
||||
func (c *paliadCatalog) LoadScenarios(ctx context.Context, filter lp.ScenarioFilter) ([]lp.Scenario, error) {
|
||||
where := []string{}
|
||||
args := []any{}
|
||||
add := func(clause string, val any) {
|
||||
args = append(args, val)
|
||||
where = append(where, fmt.Sprintf(clause, len(args)))
|
||||
}
|
||||
if filter.ProjectID != nil {
|
||||
add("project_id = $%d", *filter.ProjectID)
|
||||
}
|
||||
if filter.AbstractForUser != nil {
|
||||
where = append(where, "project_id IS NULL")
|
||||
add("created_by = $%d", *filter.AbstractForUser)
|
||||
}
|
||||
query := `SELECT id, project_id, name, description, spec,
|
||||
created_by, created_at, updated_at
|
||||
FROM paliad.scenarios`
|
||||
if len(where) > 0 {
|
||||
query += " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
query += " ORDER BY created_at DESC"
|
||||
|
||||
var rows []lp.Scenario
|
||||
if err := c.rules.db.SelectContext(ctx, &rows, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("load scenarios: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// MatchScenario returns the scenario with the given id, or
|
||||
// lp.ErrUnknownScenario if not visible / not found. RLS gates
|
||||
// visibility; a not-found result could mean "doesn't exist" OR
|
||||
// "exists but you can't see it" — either way the caller treats it
|
||||
// as unknown.
|
||||
func (c *paliadCatalog) MatchScenario(ctx context.Context, id uuid.UUID) (*lp.Scenario, error) {
|
||||
var s lp.Scenario
|
||||
err := c.rules.db.GetContext(ctx, &s,
|
||||
`SELECT id, project_id, name, description, spec,
|
||||
created_by, created_at, updated_at
|
||||
FROM paliad.scenarios
|
||||
WHERE id = $1`, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, lp.ErrUnknownScenario
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("match scenario %q: %w", id, err)
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// _ proves paliadCatalog satisfies lp.Catalog at compile time.
|
||||
var _ lp.Catalog = (*paliadCatalog)(nil)
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ func TestLookupEvents(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appeal_target=schadensbemessung returns empty (no rules seeded yet)", func(t *testing.T) {
|
||||
t.Run("appeal_target=schadensbemessung returns upc.apl merits rules (mig 138 backfill)", func(t *testing.T) {
|
||||
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "UPC",
|
||||
AppealTarget: lp.AppealTargetSchadensbemessung,
|
||||
@@ -152,8 +152,68 @@ func TestLookupEvents(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Errorf("schadensbemessung should be empty until rules seeded; got %d rows", len(matches))
|
||||
// mig 138 (t-paliad-303, m/paliad#134) extends the 7 merits-track
|
||||
// rules under upc.apl.unified with applies_to_target ⊇ {schadensbemessung}
|
||||
// because R.224 is uniform across substantive R.118 decisions.
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected upc.apl schadensbemessung rules after mig 138 backfill")
|
||||
}
|
||||
for _, m := range matches {
|
||||
if m.DepthFromAnchor != 1 {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, t := range m.Rule.AppliesToTarget {
|
||||
if t == lp.AppealTargetSchadensbemessung {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("anchor row %s missing schadensbemessung target: %v",
|
||||
m.Rule.Name, m.Rule.AppliesToTarget)
|
||||
}
|
||||
if m.ProceedingType.Code != "upc.apl.unified" {
|
||||
t.Errorf("anchor row %s came from %s, want upc.apl.unified",
|
||||
m.Rule.Name, m.ProceedingType.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appeal_target=bucheinsicht returns upc.apl order rules (mig 138 backfill)", func(t *testing.T) {
|
||||
matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{
|
||||
Jurisdiction: "UPC",
|
||||
AppealTarget: lp.AppealTargetBucheinsicht,
|
||||
}, lp.EventLookupDepthAllFollowing)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
// mig 138 (t-paliad-303, m/paliad#134) extends the 7 order-track
|
||||
// rules under upc.apl.unified with applies_to_target ⊇ {bucheinsicht}
|
||||
// because R.220.2 / R.224.2.b / R.235.2 / R.237 / R.238.2 are
|
||||
// uniform across the orders they appeal.
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected upc.apl bucheinsicht rules after mig 138 backfill")
|
||||
}
|
||||
for _, m := range matches {
|
||||
if m.DepthFromAnchor != 1 {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, t := range m.Rule.AppliesToTarget {
|
||||
if t == lp.AppealTargetBucheinsicht {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("anchor row %s missing bucheinsicht target: %v",
|
||||
m.Rule.Name, m.Rule.AppliesToTarget)
|
||||
}
|
||||
if m.ProceedingType.Code != "upc.apl.unified" {
|
||||
t.Errorf("anchor row %s came from %s, want upc.apl.unified",
|
||||
m.Rule.Name, m.ProceedingType.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1767,7 +1767,7 @@ func (s *ProjectionService) lookupRuleBySubmissionCode(ctx context.Context, ptID
|
||||
var rule models.DeadlineRule
|
||||
err := s.db.GetContext(ctx, &rule,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
|
||||
ptID, code)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -1784,7 +1784,7 @@ func (s *ProjectionService) lookupRuleByID(ctx context.Context, id uuid.UUID) (*
|
||||
var rule models.DeadlineRule
|
||||
err := s.db.GetContext(ctx, &rule,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup rule by id: %w", err)
|
||||
@@ -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 {
|
||||
|
||||
@@ -117,7 +117,7 @@ func (s *RuleEditorService) ListOrphans(ctx context.Context) ([]Orphan, error) {
|
||||
}
|
||||
if err := s.db.SelectContext(ctx, &cs, `
|
||||
SELECT id, rule_code, name, name_en
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE id = ANY($1::uuid[])`, pq.Array(uuidStrs)); err != nil {
|
||||
return nil, fmt.Errorf("list orphan candidate rules: %w", err)
|
||||
}
|
||||
@@ -212,14 +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)
|
||||
return fmt.Errorf("set deadline sequencing_rule_id: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadline_rule_backfill_orphans
|
||||
|
||||
@@ -76,7 +76,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 +107,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 +135,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 +166,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 +227,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,6 +258,12 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
|
||||
return nil, fmt.Errorf("insert rule: %w", 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)
|
||||
}
|
||||
@@ -271,11 +326,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.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)
|
||||
}
|
||||
@@ -309,7 +366,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,
|
||||
@@ -330,12 +387,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.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)
|
||||
}
|
||||
@@ -369,7 +430,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
|
||||
@@ -382,7 +443,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'`,
|
||||
@@ -392,6 +453,10 @@ func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason st
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -439,7 +504,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
|
||||
@@ -450,7 +515,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,
|
||||
@@ -459,6 +524,9 @@ func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, tar
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -598,7 +666,7 @@ func (s *RuleEditorService) ListRules(ctx context.Context, f ListRulesFilter) ([
|
||||
where = "WHERE " + strings.Join(conds, " AND ")
|
||||
}
|
||||
query := `SELECT ` + ruleColumns + `
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
` + where + `
|
||||
ORDER BY proceeding_type_id NULLS LAST, sequence_order
|
||||
LIMIT ` + addArg(f.Limit) + ` OFFSET ` + addArg(f.Offset)
|
||||
@@ -618,7 +686,7 @@ func (s *RuleEditorService) GetByID(ctx context.Context, id uuid.UUID) (*models.
|
||||
func (s *RuleEditorService) getByID(ctx context.Context, id uuid.UUID) (*models.DeadlineRule, error) {
|
||||
var r models.DeadlineRule
|
||||
err := s.db.GetContext(ctx, &r,
|
||||
`SELECT `+ruleColumns+` FROM paliad.deadline_rules WHERE id = $1`, id)
|
||||
`SELECT `+ruleColumns+` FROM paliad.deadline_rules_unified WHERE id = $1`, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrRuleNotFound
|
||||
}
|
||||
@@ -677,7 +745,7 @@ func (s *RuleEditorService) validateSpawnNoCycle(ctx context.Context, ruleID *uu
|
||||
visited[current] = true
|
||||
var nexts []sql.NullInt64
|
||||
q := `SELECT DISTINCT spawn_proceeding_type_id::bigint
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE proceeding_type_id = $1
|
||||
AND is_spawn = true
|
||||
AND spawn_proceeding_type_id IS NOT NULL
|
||||
|
||||
347
internal/services/scenario_service.go
Normal file
347
internal/services/scenario_service.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||
)
|
||||
|
||||
// ScenarioService reads + writes paliad.scenarios — named compositions
|
||||
// of existing proceedings + flags + per-card choices + anchor dates,
|
||||
// switchable per project or saved as abstract templates on
|
||||
// /tools/verfahrensablauf. Slice D, m/paliad#124 §5, mig 145.
|
||||
//
|
||||
// Visibility:
|
||||
// - Project-scoped scenarios (project_id NOT NULL): require
|
||||
// can_see_project on the bound project (mirrors
|
||||
// EventChoiceService.requireProjectVisible).
|
||||
// - Abstract scenarios (project_id IS NULL): owner-only. Only
|
||||
// created_by can read / mutate.
|
||||
//
|
||||
// The service applies these checks in application code; paliad.scenarios
|
||||
// also has RLS policies (mig 145) as defense-in-depth for callers that
|
||||
// connect through Supabase Auth's auth.uid() session.
|
||||
type ScenarioService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
rules *DeadlineRuleService
|
||||
}
|
||||
|
||||
// NewScenarioService wires the service to its dependencies.
|
||||
func NewScenarioService(db *sqlx.DB, projects *ProjectService, rules *DeadlineRuleService) *ScenarioService {
|
||||
return &ScenarioService{db: db, projects: projects, rules: rules}
|
||||
}
|
||||
|
||||
// Sentinel errors. Mirrors EventChoiceService + the lp package errors
|
||||
// so handlers can map cleanly to HTTP statuses.
|
||||
var (
|
||||
ErrScenarioNotVisible = errors.New("scenario not visible to caller")
|
||||
)
|
||||
|
||||
// CreateScenarioInput is the payload for POST /api/scenarios. project_id
|
||||
// nil = abstract scenario (saved Verfahrensablauf template).
|
||||
type CreateScenarioInput struct {
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Spec json.RawMessage `json:"spec"`
|
||||
}
|
||||
|
||||
// Create inserts a new scenario after validating the spec.
|
||||
func (s *ScenarioService) Create(ctx context.Context, userID uuid.UUID, input CreateScenarioInput) (*lp.Scenario, error) {
|
||||
if input.Name == "" {
|
||||
return nil, fmt.Errorf("%w: name required", ErrInvalidInput)
|
||||
}
|
||||
if err := s.validateSpec(ctx, input.Spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.ProjectID != nil {
|
||||
if err := s.requireProjectVisible(ctx, userID, *input.ProjectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var out lp.Scenario
|
||||
err := s.db.GetContext(ctx, &out,
|
||||
`INSERT INTO paliad.scenarios (project_id, name, description, spec, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, project_id, name, description, spec, created_by,
|
||||
created_at, updated_at`,
|
||||
input.ProjectID, input.Name, input.Description,
|
||||
[]byte(input.Spec), userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create scenario: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// Get returns one scenario by id after a visibility check.
|
||||
func (s *ScenarioService) Get(ctx context.Context, userID, scenarioID uuid.UUID) (*lp.Scenario, error) {
|
||||
var sc lp.Scenario
|
||||
err := s.db.GetContext(ctx, &sc,
|
||||
`SELECT id, project_id, name, description, spec, created_by,
|
||||
created_at, updated_at
|
||||
FROM paliad.scenarios
|
||||
WHERE id = $1`, scenarioID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, lp.ErrUnknownScenario
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get scenario: %w", err)
|
||||
}
|
||||
if err := s.requireVisible(ctx, userID, &sc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sc, nil
|
||||
}
|
||||
|
||||
// ListForProject returns scenarios attached to one project, ordered by
|
||||
// created_at desc.
|
||||
func (s *ScenarioService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]lp.Scenario, error) {
|
||||
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []lp.Scenario{}
|
||||
err := s.db.SelectContext(ctx, &out,
|
||||
`SELECT id, project_id, name, description, spec, created_by,
|
||||
created_at, updated_at
|
||||
FROM paliad.scenarios
|
||||
WHERE project_id = $1
|
||||
ORDER BY created_at DESC`, projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list scenarios for project: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListAbstractForUser returns the calling user's abstract scenarios.
|
||||
func (s *ScenarioService) ListAbstractForUser(ctx context.Context, userID uuid.UUID) ([]lp.Scenario, error) {
|
||||
out := []lp.Scenario{}
|
||||
err := s.db.SelectContext(ctx, &out,
|
||||
`SELECT id, project_id, name, description, spec, created_by,
|
||||
created_at, updated_at
|
||||
FROM paliad.scenarios
|
||||
WHERE project_id IS NULL AND created_by = $1
|
||||
ORDER BY created_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list abstract scenarios: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PatchScenarioInput is the payload for PATCH /api/scenarios/{id}. Any
|
||||
// field nil means "don't change". Spec replacement re-runs validation.
|
||||
type PatchScenarioInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Spec json.RawMessage `json:"spec,omitempty"`
|
||||
}
|
||||
|
||||
// Patch updates one or more scenario fields. Visibility check fires
|
||||
// first (the caller must already see the scenario to mutate it).
|
||||
func (s *ScenarioService) Patch(ctx context.Context, userID, scenarioID uuid.UUID, input PatchScenarioInput) (*lp.Scenario, error) {
|
||||
current, err := s.Get(ctx, userID, scenarioID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(input.Spec) > 0 {
|
||||
if err := s.validateSpec(ctx, input.Spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
args := []any{}
|
||||
add := func(clause string, val any) {
|
||||
args = append(args, val)
|
||||
sets = append(sets, fmt.Sprintf(clause, len(args)))
|
||||
}
|
||||
if input.Name != nil {
|
||||
add("name = $%d", *input.Name)
|
||||
}
|
||||
if input.Description != nil {
|
||||
add("description = $%d", *input.Description)
|
||||
}
|
||||
if len(input.Spec) > 0 {
|
||||
add("spec = $%d", []byte(input.Spec))
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
return current, nil
|
||||
}
|
||||
args = append(args, scenarioID)
|
||||
query := fmt.Sprintf(`UPDATE paliad.scenarios SET %s
|
||||
WHERE id = $%d
|
||||
RETURNING id, project_id, name, description, spec, created_by,
|
||||
created_at, updated_at`, joinSets(sets), len(args))
|
||||
var out lp.Scenario
|
||||
if err := s.db.GetContext(ctx, &out, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("patch scenario: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// SetActive points a project at one of its scenarios. Pass nil to
|
||||
// clear (revert to ad-hoc per-card choice state).
|
||||
func (s *ScenarioService) SetActive(ctx context.Context, userID, projectID uuid.UUID, scenarioID *uuid.UUID) error {
|
||||
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
|
||||
return err
|
||||
}
|
||||
if scenarioID != nil {
|
||||
// Ensure scenario exists + belongs to this project. A scenario
|
||||
// from a different project (or an abstract one) can't be the
|
||||
// active scenario on this project.
|
||||
sc, err := s.Get(ctx, userID, *scenarioID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sc.ProjectID == nil || *sc.ProjectID != projectID {
|
||||
return fmt.Errorf("%w: scenario %s is not attached to project %s",
|
||||
ErrInvalidInput, *scenarioID, projectID)
|
||||
}
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE paliad.projects SET active_scenario_id = $1 WHERE id = $2`,
|
||||
scenarioID, projectID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set active scenario: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a scenario. Project's active_scenario_id is cleared
|
||||
// automatically via the FK's ON DELETE SET NULL.
|
||||
func (s *ScenarioService) Delete(ctx context.Context, userID, scenarioID uuid.UUID) error {
|
||||
// Visibility check via Get — also resolves the existence question.
|
||||
if _, err := s.Get(ctx, userID, scenarioID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM paliad.scenarios WHERE id = $1`, scenarioID); err != nil {
|
||||
return fmt.Errorf("delete scenario: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireVisible enforces the per-row visibility rule:
|
||||
// - project_id NOT NULL → caller must see the project
|
||||
// - project_id IS NULL → caller must be the row's created_by
|
||||
func (s *ScenarioService) requireVisible(ctx context.Context, userID uuid.UUID, sc *lp.Scenario) error {
|
||||
if sc.ProjectID != nil {
|
||||
return s.requireProjectVisible(ctx, userID, *sc.ProjectID)
|
||||
}
|
||||
if sc.CreatedBy == nil || *sc.CreatedBy != userID {
|
||||
return ErrScenarioNotVisible
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireProjectVisible mirrors EventChoiceService.requireProjectVisible
|
||||
// (visibility via can_see_project). Cheap re-implementation — keeps the
|
||||
// call-graph small + avoids a cross-service dep.
|
||||
func (s *ScenarioService) requireProjectVisible(ctx context.Context, userID, projectID uuid.UUID) error {
|
||||
var visible bool
|
||||
err := s.db.GetContext(ctx, &visible,
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = $1 AND u.global_role = 'global_admin'
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM paliad.projects p
|
||||
JOIN paliad.project_teams pt ON pt.project_id = ANY(
|
||||
string_to_array(p.path, '.')::uuid[]
|
||||
)
|
||||
WHERE p.id = $2 AND pt.user_id = $1
|
||||
)`, userID, projectID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check project visibility: %w", err)
|
||||
}
|
||||
if !visible {
|
||||
return ErrScenarioNotVisible
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSpec checks the jsonb spec is well-formed, has the right
|
||||
// version, and that every referenced proceeding code + submission code
|
||||
// resolves to an active row in the live catalog. Surfaces friendly
|
||||
// errors wrapping ErrInvalidInput so the handler can map to a 400.
|
||||
func (s *ScenarioService) validateSpec(ctx context.Context, raw json.RawMessage) error {
|
||||
if len(raw) == 0 {
|
||||
return fmt.Errorf("%w: spec is required", ErrInvalidInput)
|
||||
}
|
||||
parsed, err := lp.ParseSpec(lp.NullableJSON(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInvalidInput, err)
|
||||
}
|
||||
if _, err := parsed.PrimaryProceeding(); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInvalidInput, err)
|
||||
}
|
||||
if parsed.BaseTriggerDate != "" {
|
||||
if _, err := time.Parse("2006-01-02", parsed.BaseTriggerDate); err != nil {
|
||||
return fmt.Errorf("%w: base_trigger_date %q is not YYYY-MM-DD", ErrInvalidInput, parsed.BaseTriggerDate)
|
||||
}
|
||||
}
|
||||
for i, p := range parsed.Proceedings {
|
||||
if p.Code == "" {
|
||||
return fmt.Errorf("%w: proceedings[%d].code is empty", ErrInvalidInput, i)
|
||||
}
|
||||
if p.Role != lp.ScenarioRolePrimary && p.Role != lp.ScenarioRolePeer {
|
||||
return fmt.Errorf("%w: proceedings[%d].role=%q must be 'primary' or 'peer'",
|
||||
ErrInvalidInput, i, p.Role)
|
||||
}
|
||||
if p.AppealTarget != "" && !lp.IsValidAppealTarget(p.AppealTarget) {
|
||||
return fmt.Errorf("%w: proceedings[%d].appeal_target=%q not in %v",
|
||||
ErrInvalidInput, i, p.AppealTarget, lp.AppealTargets)
|
||||
}
|
||||
if p.TriggerDateOverride != "" {
|
||||
if _, err := time.Parse("2006-01-02", p.TriggerDateOverride); err != nil {
|
||||
return fmt.Errorf("%w: proceedings[%d].trigger_date_override %q is not YYYY-MM-DD",
|
||||
ErrInvalidInput, i, p.TriggerDateOverride)
|
||||
}
|
||||
}
|
||||
for code, dateStr := range p.AnchorOverrides {
|
||||
if _, err := time.Parse("2006-01-02", dateStr); err != nil {
|
||||
return fmt.Errorf("%w: proceedings[%d].anchor_overrides[%q]=%q is not YYYY-MM-DD",
|
||||
ErrInvalidInput, i, code, dateStr)
|
||||
}
|
||||
}
|
||||
// Resolve code against active proceedings.
|
||||
var exists bool
|
||||
if err := s.db.GetContext(ctx, &exists,
|
||||
`SELECT EXISTS(SELECT 1 FROM paliad.proceeding_types
|
||||
WHERE code = $1 AND is_active = true)`,
|
||||
p.Code); err != nil {
|
||||
return fmt.Errorf("validate spec proceedings[%d]: %w", i, err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("%w: proceedings[%d].code=%q is not an active proceeding_type",
|
||||
ErrInvalidInput, i, p.Code)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// joinSets joins SET clauses with ", ". Tiny utility, kept here to
|
||||
// avoid cross-package strings.Join indirection.
|
||||
func joinSets(sets []string) string {
|
||||
out := ""
|
||||
for i, s := range sets {
|
||||
if i > 0 {
|
||||
out += ", "
|
||||
}
|
||||
out += s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Suppress unused-import diagnostic when models isn't referenced
|
||||
// (kept for future shape-evolution; canonical scenario row lives in lp).
|
||||
var _ = models.NullableJSON(nil)
|
||||
274
internal/services/submission_base_service.go
Normal file
274
internal/services/submission_base_service.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package services
|
||||
|
||||
// Submission base catalog service — Composer Slice A (t-paliad-313,
|
||||
// design doc docs/design-submission-generator-v2-2026-05-26.md §4.2 +
|
||||
// §5.1).
|
||||
//
|
||||
// Each row in paliad.submission_bases maps a stable slug onto a Gitea
|
||||
// path (the .docx body) plus a JSON section spec that drives the
|
||||
// editor's default section seeding. Slice A surfaces this catalog via
|
||||
// a sidebar picker and uses GetDefaultForCode to pre-fill base_id on
|
||||
// new drafts.
|
||||
//
|
||||
// Read-only — admin mutations land in Slice C's /admin/submission-bases
|
||||
// editor. Visibility is wide-open SELECT (the catalog is shared
|
||||
// firm-wide); RLS denies mutations by default.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// SubmissionBase mirrors a row in paliad.submission_bases.
|
||||
type SubmissionBase struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Slug string `db:"slug" json:"slug"`
|
||||
Firm *string `db:"firm" json:"firm,omitempty"`
|
||||
ProceedingFamily *string `db:"proceeding_family" json:"proceeding_family,omitempty"`
|
||||
LabelDE string `db:"label_de" json:"label_de"`
|
||||
LabelEN string `db:"label_en" json:"label_en"`
|
||||
DescriptionDE *string `db:"description_de" json:"description_de,omitempty"`
|
||||
DescriptionEN *string `db:"description_en" json:"description_en,omitempty"`
|
||||
GiteaPath string `db:"gitea_path" json:"gitea_path"`
|
||||
SectionSpecRaw []byte `db:"section_spec" json:"-"`
|
||||
IsDefaultForRaw pq.StringArray `db:"is_default_for" json:"-"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
|
||||
// SectionSpec is the parsed section spec; populated on read by the
|
||||
// service so callers don't have to unmarshal manually.
|
||||
SectionSpec BaseSectionSpec `json:"section_spec"`
|
||||
|
||||
// IsDefaultFor is the parsed string-slice form of the
|
||||
// is_default_for column.
|
||||
IsDefaultFor []string `json:"is_default_for"`
|
||||
}
|
||||
|
||||
// BaseSectionSpec is the parsed shape of submission_bases.section_spec.
|
||||
// Slice A consumes Defaults to seed submission_sections rows on draft
|
||||
// create; later slices consume Stylemap (Slice B's MD→OOXML walker) and
|
||||
// Version (forward compat).
|
||||
type BaseSectionSpec struct {
|
||||
Version int `json:"version"`
|
||||
Stylemap map[string]string `json:"stylemap"`
|
||||
Defaults []BaseSectionSpecDefault `json:"defaults"`
|
||||
}
|
||||
|
||||
// BaseSectionSpecDefault declares one default section per base. SeedMD*
|
||||
// is the Markdown copied into submission_sections.content_md_* on draft
|
||||
// create. Empty seed = blank prose section.
|
||||
type BaseSectionSpecDefault struct {
|
||||
SectionKey string `json:"section_key"`
|
||||
Kind string `json:"kind"`
|
||||
OrderIndex int `json:"order_index"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
Included bool `json:"included"`
|
||||
SeedMDDE string `json:"seed_md_de"`
|
||||
SeedMDEN string `json:"seed_md_en"`
|
||||
}
|
||||
|
||||
// BaseService reads the catalog. No mutations in Slice A.
|
||||
type BaseService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewBaseService wires the service.
|
||||
func NewBaseService(db *sqlx.DB) *BaseService {
|
||||
return &BaseService{db: db}
|
||||
}
|
||||
|
||||
// ErrBaseNotFound is the sentinel for "no base with that id/slug".
|
||||
var ErrBaseNotFound = errors.New("submission base: not found")
|
||||
|
||||
const baseColumns = `id, slug, firm, proceeding_family, label_de, label_en,
|
||||
description_de, description_en, gitea_path,
|
||||
section_spec, is_default_for, is_active`
|
||||
|
||||
// List returns every active base ordered by firm-then-label.
|
||||
// firmFilter (when non-empty) restricts to rows where firm matches OR
|
||||
// firm IS NULL — the picker shows the firm's own bases plus the
|
||||
// firm-agnostic ones.
|
||||
func (s *BaseService) List(ctx context.Context, firmFilter string) ([]SubmissionBase, error) {
|
||||
var rows []SubmissionBase
|
||||
var err error
|
||||
if firmFilter == "" {
|
||||
err = s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+baseColumns+`
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active
|
||||
ORDER BY COALESCE(firm, ''), label_de`)
|
||||
} else {
|
||||
err = s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+baseColumns+`
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active AND (firm = $1 OR firm IS NULL)
|
||||
ORDER BY (firm IS NULL), label_de`,
|
||||
firmFilter)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list submission bases: %w", err)
|
||||
}
|
||||
for i := range rows {
|
||||
if err := rows[i].decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// GetByID fetches one base by uuid.
|
||||
func (s *BaseService) GetByID(ctx context.Context, id uuid.UUID) (*SubmissionBase, error) {
|
||||
var b SubmissionBase
|
||||
err := s.db.GetContext(ctx, &b,
|
||||
`SELECT `+baseColumns+`
|
||||
FROM paliad.submission_bases
|
||||
WHERE id = $1 AND is_active`,
|
||||
id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrBaseNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get submission base by id: %w", err)
|
||||
}
|
||||
if err := b.decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// GetBySlug fetches one base by stable slug ("hlc-letterhead", …).
|
||||
func (s *BaseService) GetBySlug(ctx context.Context, slug string) (*SubmissionBase, error) {
|
||||
var b SubmissionBase
|
||||
err := s.db.GetContext(ctx, &b,
|
||||
`SELECT `+baseColumns+`
|
||||
FROM paliad.submission_bases
|
||||
WHERE slug = $1 AND is_active`,
|
||||
slug)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrBaseNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get submission base by slug: %w", err)
|
||||
}
|
||||
if err := b.decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// GetDefaultForCode picks the base SubmissionDraftService.Create should
|
||||
// seed for a new draft, given the requesting firm and the draft's
|
||||
// submission_code. Priority:
|
||||
//
|
||||
// 1. firm-matched base whose is_default_for[] contains the exact code.
|
||||
// 2. firm-matched base whose proceeding_family matches the code's
|
||||
// family (first three dot-segments, e.g. "de.inf.lg" from
|
||||
// "de.inf.lg.erwidg").
|
||||
// 3. firm-matched base with NULL proceeding_family (firm-agnostic
|
||||
// fallback within the firm).
|
||||
// 4. firm-NULL (cross-firm) base by family match.
|
||||
// 5. firm-NULL base with NULL family — the universal neutral fallback.
|
||||
// 6. first active row (deterministic ordering on (firm IS NULL,
|
||||
// label_de)).
|
||||
//
|
||||
// Returns ErrBaseNotFound if the table is empty.
|
||||
func (s *BaseService) GetDefaultForCode(ctx context.Context, firm, submissionCode string) (*SubmissionBase, error) {
|
||||
family := familyOfCode(submissionCode)
|
||||
|
||||
tryQueries := []struct {
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{
|
||||
`SELECT ` + baseColumns + `
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active AND firm = $1 AND $2 = ANY(is_default_for)
|
||||
ORDER BY label_de LIMIT 1`,
|
||||
[]any{firm, submissionCode},
|
||||
},
|
||||
{
|
||||
`SELECT ` + baseColumns + `
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active AND firm = $1 AND proceeding_family = $2
|
||||
ORDER BY label_de LIMIT 1`,
|
||||
[]any{firm, family},
|
||||
},
|
||||
{
|
||||
`SELECT ` + baseColumns + `
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active AND firm = $1 AND proceeding_family IS NULL
|
||||
ORDER BY label_de LIMIT 1`,
|
||||
[]any{firm},
|
||||
},
|
||||
{
|
||||
`SELECT ` + baseColumns + `
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active AND firm IS NULL AND proceeding_family = $1
|
||||
ORDER BY label_de LIMIT 1`,
|
||||
[]any{family},
|
||||
},
|
||||
{
|
||||
`SELECT ` + baseColumns + `
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active AND firm IS NULL AND proceeding_family IS NULL
|
||||
ORDER BY label_de LIMIT 1`,
|
||||
[]any{},
|
||||
},
|
||||
{
|
||||
`SELECT ` + baseColumns + `
|
||||
FROM paliad.submission_bases
|
||||
WHERE is_active
|
||||
ORDER BY (firm IS NULL), label_de LIMIT 1`,
|
||||
[]any{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, q := range tryQueries {
|
||||
var b SubmissionBase
|
||||
err := s.db.GetContext(ctx, &b, q.sql, q.args...)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get default base: %w", err)
|
||||
}
|
||||
if err := b.decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
return nil, ErrBaseNotFound
|
||||
}
|
||||
|
||||
// familyOfCode returns the first three dot-segments of a submission_code.
|
||||
// "de.inf.lg.erwidg" → "de.inf.lg". Codes with fewer than three segments
|
||||
// pass through unchanged (none in the corpus today, but safe).
|
||||
func familyOfCode(code string) string {
|
||||
parts := strings.SplitN(code, ".", 4)
|
||||
if len(parts) <= 3 {
|
||||
return code
|
||||
}
|
||||
return strings.Join(parts[:3], ".")
|
||||
}
|
||||
|
||||
// decode fills the parsed views from the raw scan fields.
|
||||
func (b *SubmissionBase) decode() error {
|
||||
if len(b.SectionSpecRaw) > 0 {
|
||||
if err := json.Unmarshal(b.SectionSpecRaw, &b.SectionSpec); err != nil {
|
||||
return fmt.Errorf("decode submission base section_spec: %w", err)
|
||||
}
|
||||
}
|
||||
b.IsDefaultFor = []string(b.IsDefaultForRaw)
|
||||
if b.IsDefaultFor == nil {
|
||||
b.IsDefaultFor = []string{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
99
internal/services/submission_base_service_test.go
Normal file
99
internal/services/submission_base_service_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package services
|
||||
|
||||
// Unit tests for Composer base helpers — pure functions, no DB
|
||||
// dependency (t-paliad-313 Slice A).
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFamilyOfCode(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
// canonical four-segment codes → first three segments
|
||||
{"de.inf.lg.erwidg", "de.inf.lg"},
|
||||
{"de.inf.lg.klage", "de.inf.lg"},
|
||||
{"de.inf.olg.berufung", "de.inf.olg"},
|
||||
{"upc.inf.cfi.soc", "upc.inf.cfi"},
|
||||
{"upc.inf.cfi.sod", "upc.inf.cfi"},
|
||||
{"upc.apl.cost.leave_app", "upc.apl.cost"},
|
||||
{"epa.opp.opd.einspruch", "epa.opp.opd"},
|
||||
// five-segment codes (rarely used in the corpus today) → still
|
||||
// truncate to three
|
||||
{"upc.inf.cfi.appeal_spawn.followup", "upc.inf.cfi"},
|
||||
// shorter codes pass through unchanged
|
||||
{"de.inf.lg", "de.inf.lg"},
|
||||
{"de.inf", "de.inf"},
|
||||
{"de", "de"},
|
||||
// empty stays empty
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
if got := familyOfCode(tc.in); got != tc.want {
|
||||
t.Errorf("familyOfCode(%q) = %q; want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSectionSpec_DecodeShape(t *testing.T) {
|
||||
// The default seed in mig 146 emits a JSON document the service
|
||||
// must decode round-trip; this golden pins the exact field shape
|
||||
// the editor expects.
|
||||
raw := []byte(`{
|
||||
"version": 1,
|
||||
"stylemap": {
|
||||
"paragraph": "HLpat-Body-B0",
|
||||
"heading_1": "HLpat-Heading-H1",
|
||||
"heading_2": "HLpat-Heading-H2",
|
||||
"heading_3": "HLpat-Heading-H3",
|
||||
"list_bullet": "HLpat-Body-B0",
|
||||
"list_numbered": "HLpat-Body-B0",
|
||||
"blockquote": "HLpat-Body-B1"
|
||||
},
|
||||
"defaults": [
|
||||
{"section_key":"letterhead","kind":"prose","order_index":1,"label_de":"Briefkopf","label_en":"Letterhead","included":true,"seed_md_de":"hi","seed_md_en":"hi"},
|
||||
{"section_key":"requests","kind":"requests","order_index":4,"label_de":"Anträge","label_en":"Requests","included":true,"seed_md_de":"","seed_md_en":""}
|
||||
]
|
||||
}`)
|
||||
b := SubmissionBase{SectionSpecRaw: raw}
|
||||
if err := b.decode(); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if b.SectionSpec.Version != 1 {
|
||||
t.Errorf("Version = %d; want 1", b.SectionSpec.Version)
|
||||
}
|
||||
if got := b.SectionSpec.Stylemap["heading_1"]; got != "HLpat-Heading-H1" {
|
||||
t.Errorf("Stylemap[heading_1] = %q; want HLpat-Heading-H1", got)
|
||||
}
|
||||
if len(b.SectionSpec.Defaults) != 2 {
|
||||
t.Fatalf("Defaults len = %d; want 2", len(b.SectionSpec.Defaults))
|
||||
}
|
||||
first := b.SectionSpec.Defaults[0]
|
||||
if first.SectionKey != "letterhead" || first.Kind != "prose" || first.OrderIndex != 1 {
|
||||
t.Errorf("Defaults[0] = %+v; want letterhead/prose/1", first)
|
||||
}
|
||||
if first.SeedMDDE != "hi" || first.SeedMDEN != "hi" {
|
||||
t.Errorf("Defaults[0] seed_md_* = %q/%q; want hi/hi", first.SeedMDDE, first.SeedMDEN)
|
||||
}
|
||||
second := b.SectionSpec.Defaults[1]
|
||||
if second.SectionKey != "requests" || second.Kind != "requests" || second.OrderIndex != 4 {
|
||||
t.Errorf("Defaults[1] = %+v; want requests/requests/4", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSectionSpec_EmptyDecode(t *testing.T) {
|
||||
// A bare row (SectionSpecRaw == nil) decodes cleanly into the
|
||||
// zero value — no panic, no garbage.
|
||||
b := SubmissionBase{}
|
||||
if err := b.decode(); err != nil {
|
||||
t.Fatalf("decode empty: %v", err)
|
||||
}
|
||||
if b.SectionSpec.Version != 0 || len(b.SectionSpec.Defaults) != 0 {
|
||||
t.Errorf("expected zero SectionSpec on empty raw; got %+v", b.SectionSpec)
|
||||
}
|
||||
if b.IsDefaultFor == nil {
|
||||
t.Errorf("IsDefaultFor must be non-nil (empty slice) after decode; got nil")
|
||||
}
|
||||
}
|
||||
629
internal/services/submission_building_block_service.go
Normal file
629
internal/services/submission_building_block_service.go
Normal file
@@ -0,0 +1,629 @@
|
||||
package services
|
||||
|
||||
// Composer building-block library service — t-paliad-315 Slice C
|
||||
// (design doc docs/design-submission-generator-v2-2026-05-26.md §8 +
|
||||
// §4.4).
|
||||
//
|
||||
// Per the Q2 ratification (m, 2026-05-26): building blocks are plain
|
||||
// text paste sources. The library row is the source; the lawyer's
|
||||
// section row is the destination. After paste, the section row has
|
||||
// no link back to the library — the prose belongs to the section.
|
||||
//
|
||||
// Per the Q9 ratification: four visibility tiers — private / team /
|
||||
// firm / global. The DB-side RLS policy (mig 149) handles the
|
||||
// "private rows only the author sees" coarse gate. This service
|
||||
// applies the fine-grained tier predicate at query time, so the
|
||||
// picker on the section editor only shows blocks the caller actually
|
||||
// has reach to.
|
||||
//
|
||||
// Admin mutations are gated at the handler layer (adminGate). The
|
||||
// service exposes Create + Update + SoftDelete + RestoreVersion which
|
||||
// all assume the caller has already passed the admin check.
|
||||
// Append-only audit history (_admin_versions) is retained at 20 rows
|
||||
// per block, GCed in the same transaction as each Save.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// BuildingBlock mirrors a row in paliad.submission_building_blocks.
|
||||
type BuildingBlock struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Slug string `db:"slug" json:"slug"`
|
||||
Firm *string `db:"firm" json:"firm,omitempty"`
|
||||
SectionKey string `db:"section_key" json:"section_key"`
|
||||
ProceedingFamily *string `db:"proceeding_family" json:"proceeding_family,omitempty"`
|
||||
TitleDE string `db:"title_de" json:"title_de"`
|
||||
TitleEN string `db:"title_en" json:"title_en"`
|
||||
DescriptionDE *string `db:"description_de" json:"description_de,omitempty"`
|
||||
DescriptionEN *string `db:"description_en" json:"description_en,omitempty"`
|
||||
ContentMDDE string `db:"content_md_de" json:"content_md_de"`
|
||||
ContentMDEN string `db:"content_md_en" json:"content_md_en"`
|
||||
AuthorID *uuid.UUID `db:"author_id" json:"author_id,omitempty"`
|
||||
Visibility string `db:"visibility" json:"visibility"`
|
||||
IsPublished bool `db:"is_published" json:"is_published"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
DeletedAt *time.Time `db:"deleted_at" json:"-"`
|
||||
}
|
||||
|
||||
// BuildingBlockVersion is one row from the admin-only audit history.
|
||||
type BuildingBlockVersion struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
BuildingBlockID uuid.UUID `db:"building_block_id" json:"building_block_id"`
|
||||
ContentMDDE string `db:"content_md_de" json:"content_md_de"`
|
||||
ContentMDEN string `db:"content_md_en" json:"content_md_en"`
|
||||
TitleDE string `db:"title_de" json:"title_de"`
|
||||
TitleEN string `db:"title_en" json:"title_en"`
|
||||
EditedBy *uuid.UUID `db:"edited_by" json:"edited_by,omitempty"`
|
||||
Note *string `db:"note" json:"note,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// BuildingBlockService handles the library + admin audit history.
|
||||
type BuildingBlockService struct {
|
||||
db *sqlx.DB
|
||||
firm string
|
||||
}
|
||||
|
||||
// NewBuildingBlockService wires the service. firm is branding.Name —
|
||||
// captured at construction time and used to apply the firm-tier
|
||||
// filter on List/Get calls.
|
||||
func NewBuildingBlockService(db *sqlx.DB, firm string) *BuildingBlockService {
|
||||
return &BuildingBlockService{db: db, firm: firm}
|
||||
}
|
||||
|
||||
const (
|
||||
// VisPrivate / Team / Firm / Global — the 4 tiers per Q9.
|
||||
VisPrivate = "private"
|
||||
VisTeam = "team"
|
||||
VisFirm = "firm"
|
||||
VisGlobal = "global"
|
||||
|
||||
// Retention horizon for the admin audit history per block.
|
||||
buildingBlockVersionRetention = 20
|
||||
)
|
||||
|
||||
// ErrBuildingBlockNotFound is the sentinel for "no block with that id
|
||||
// visible to this user". Maps to 404 at the handler layer.
|
||||
var ErrBuildingBlockNotFound = errors.New("submission building block: not found")
|
||||
|
||||
// ErrBuildingBlockInvalidVisibility is the sentinel for a Create /
|
||||
// Update with an unknown tier value.
|
||||
var ErrBuildingBlockInvalidVisibility = errors.New("submission building block: invalid visibility")
|
||||
|
||||
const buildingBlockColumns = `id, slug, firm, section_key, proceeding_family,
|
||||
title_de, title_en, description_de, description_en,
|
||||
content_md_de, content_md_en,
|
||||
author_id, visibility, is_published,
|
||||
created_at, updated_at, deleted_at`
|
||||
|
||||
// BlockListFilter narrows the picker query. All fields optional. Returns
|
||||
// only published, non-deleted rows the caller has tier reach to.
|
||||
type BlockListFilter struct {
|
||||
// SectionKey filters to blocks bound to one section (the picker
|
||||
// uses this to restrict "facts" blocks to facts sections, etc.).
|
||||
// Empty string = no filter.
|
||||
SectionKey string
|
||||
// ProceedingFamily filters to blocks tagged for one family OR
|
||||
// untagged (proceeding_family IS NULL = "any family"). Empty
|
||||
// string = no filter.
|
||||
ProceedingFamily string
|
||||
// Search free-text query against title + description + content.
|
||||
// Empty string = no filter.
|
||||
Search string
|
||||
// Limit caps the result count (0 = default 50).
|
||||
Limit int
|
||||
}
|
||||
|
||||
// ListVisible returns blocks the caller can see, after the tier
|
||||
// predicate is applied. Ordered by updated_at DESC. The DB-side
|
||||
// SELECT policy already drops soft-deleted rows + private-other-author
|
||||
// rows; this query additionally honours the picker filter + the
|
||||
// is_published gate + the firm + team predicates.
|
||||
func (s *BuildingBlockService) ListVisible(ctx context.Context, userID uuid.UUID, filter BlockListFilter) ([]BuildingBlock, error) {
|
||||
limit := filter.Limit
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
q := `SELECT ` + buildingBlockColumns + `
|
||||
FROM paliad.submission_building_blocks
|
||||
WHERE deleted_at IS NULL
|
||||
AND is_published = true
|
||||
AND (
|
||||
visibility = 'global'
|
||||
OR visibility = 'private' AND author_id = $1
|
||||
OR visibility = 'firm' AND (firm IS NULL OR firm = $2)
|
||||
OR visibility = 'team' AND EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt1
|
||||
JOIN paliad.project_teams pt2 ON pt1.project_id = pt2.project_id
|
||||
WHERE pt1.user_id = author_id AND pt2.user_id = $1
|
||||
)
|
||||
)`
|
||||
args := []any{userID, s.firm}
|
||||
idx := 3
|
||||
|
||||
if filter.SectionKey != "" {
|
||||
q += fmt.Sprintf(" AND section_key = $%d", idx)
|
||||
args = append(args, filter.SectionKey)
|
||||
idx++
|
||||
}
|
||||
if filter.ProceedingFamily != "" {
|
||||
q += fmt.Sprintf(" AND (proceeding_family IS NULL OR proceeding_family = $%d)", idx)
|
||||
args = append(args, filter.ProceedingFamily)
|
||||
idx++
|
||||
}
|
||||
if filter.Search != "" {
|
||||
pattern := "%" + strings.ToLower(filter.Search) + "%"
|
||||
q += fmt.Sprintf(" AND (LOWER(title_de) LIKE $%d OR LOWER(title_en) LIKE $%d OR LOWER(COALESCE(description_de,'')) LIKE $%d OR LOWER(COALESCE(description_en,'')) LIKE $%d OR LOWER(content_md_de) LIKE $%d OR LOWER(content_md_en) LIKE $%d)",
|
||||
idx, idx, idx, idx, idx, idx)
|
||||
args = append(args, pattern)
|
||||
idx++
|
||||
}
|
||||
q += fmt.Sprintf(" ORDER BY updated_at DESC LIMIT $%d", idx)
|
||||
args = append(args, limit)
|
||||
|
||||
var rows []BuildingBlock
|
||||
err := s.db.SelectContext(ctx, &rows, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list building blocks: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ListAllForAdmin returns every non-deleted row regardless of tier.
|
||||
// Handler-side adminGate is the access gate.
|
||||
func (s *BuildingBlockService) ListAllForAdmin(ctx context.Context) ([]BuildingBlock, error) {
|
||||
var rows []BuildingBlock
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+buildingBlockColumns+`
|
||||
FROM paliad.submission_building_blocks
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY updated_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("admin list building blocks: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// GetVisible fetches a block by id, applying the same tier predicate
|
||||
// as ListVisible. ErrBuildingBlockNotFound when the row exists but
|
||||
// the caller has no tier reach (handler maps to 404).
|
||||
func (s *BuildingBlockService) GetVisible(ctx context.Context, userID, blockID uuid.UUID) (*BuildingBlock, error) {
|
||||
var b BuildingBlock
|
||||
err := s.db.GetContext(ctx, &b,
|
||||
`SELECT `+buildingBlockColumns+`
|
||||
FROM paliad.submission_building_blocks
|
||||
WHERE id = $1
|
||||
AND deleted_at IS NULL
|
||||
AND is_published = true
|
||||
AND (
|
||||
visibility = 'global'
|
||||
OR visibility = 'private' AND author_id = $2
|
||||
OR visibility = 'firm' AND (firm IS NULL OR firm = $3)
|
||||
OR visibility = 'team' AND EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt1
|
||||
JOIN paliad.project_teams pt2 ON pt1.project_id = pt2.project_id
|
||||
WHERE pt1.user_id = author_id AND pt2.user_id = $2
|
||||
)
|
||||
)`,
|
||||
blockID, userID, s.firm)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrBuildingBlockNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get building block: %w", err)
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// GetForAdmin fetches a block by id with no tier filter. adminGate at
|
||||
// the handler is the access gate.
|
||||
func (s *BuildingBlockService) GetForAdmin(ctx context.Context, blockID uuid.UUID) (*BuildingBlock, error) {
|
||||
var b BuildingBlock
|
||||
err := s.db.GetContext(ctx, &b,
|
||||
`SELECT `+buildingBlockColumns+`
|
||||
FROM paliad.submission_building_blocks
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
blockID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrBuildingBlockNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("admin get building block: %w", err)
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// CreateInput carries the fields needed to insert a new block. Admin
|
||||
// path only (Slice C); user-authored private blocks are a later
|
||||
// feature.
|
||||
type CreateInput struct {
|
||||
Slug string
|
||||
Firm *string
|
||||
SectionKey string
|
||||
ProceedingFamily *string
|
||||
TitleDE string
|
||||
TitleEN string
|
||||
DescriptionDE *string
|
||||
DescriptionEN *string
|
||||
ContentMDDE string
|
||||
ContentMDEN string
|
||||
Visibility string
|
||||
IsPublished bool
|
||||
}
|
||||
|
||||
// Create inserts a new block and seeds the first audit-history row.
|
||||
// editorID is the admin's uuid; recorded in _admin_versions.edited_by.
|
||||
func (s *BuildingBlockService) Create(ctx context.Context, editorID uuid.UUID, in CreateInput) (*BuildingBlock, error) {
|
||||
if !validVisibility(in.Visibility) {
|
||||
return nil, ErrBuildingBlockInvalidVisibility
|
||||
}
|
||||
in.Slug = strings.TrimSpace(in.Slug)
|
||||
in.SectionKey = strings.TrimSpace(in.SectionKey)
|
||||
in.TitleDE = strings.TrimSpace(in.TitleDE)
|
||||
in.TitleEN = strings.TrimSpace(in.TitleEN)
|
||||
if in.Slug == "" || in.SectionKey == "" || in.TitleDE == "" || in.TitleEN == "" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create building block tx: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
var b BuildingBlock
|
||||
err = tx.GetContext(ctx, &b,
|
||||
`INSERT INTO paliad.submission_building_blocks
|
||||
(slug, firm, section_key, proceeding_family,
|
||||
title_de, title_en, description_de, description_en,
|
||||
content_md_de, content_md_en, author_id, visibility, is_published)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
RETURNING `+buildingBlockColumns,
|
||||
in.Slug, in.Firm, in.SectionKey, in.ProceedingFamily,
|
||||
in.TitleDE, in.TitleEN, in.DescriptionDE, in.DescriptionEN,
|
||||
in.ContentMDDE, in.ContentMDEN, editorID, in.Visibility, in.IsPublished)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert building block: %w", err)
|
||||
}
|
||||
if err := s.appendVersionTx(ctx, tx, b.ID, editorID, &b, "create"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit create building block: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// UpdatePatch carries the optional fields for an Update call.
|
||||
type UpdatePatch struct {
|
||||
Slug *string
|
||||
Firm **string // **string for "set to null" semantics
|
||||
SectionKey *string
|
||||
ProceedingFamily **string
|
||||
TitleDE *string
|
||||
TitleEN *string
|
||||
DescriptionDE **string
|
||||
DescriptionEN **string
|
||||
ContentMDDE *string
|
||||
ContentMDEN *string
|
||||
Visibility *string
|
||||
IsPublished *bool
|
||||
Note *string // free-form note that lands in _admin_versions
|
||||
}
|
||||
|
||||
// Update applies a patch. Appends an audit-history row; GCs to the
|
||||
// retention=20 horizon in the same tx so old versions don't pile up.
|
||||
func (s *BuildingBlockService) Update(ctx context.Context, editorID, blockID uuid.UUID, patch UpdatePatch) (*BuildingBlock, error) {
|
||||
if patch.Visibility != nil && !validVisibility(*patch.Visibility) {
|
||||
return nil, ErrBuildingBlockInvalidVisibility
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update building block tx: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
setParts := []string{}
|
||||
args := []any{}
|
||||
idx := 1
|
||||
|
||||
addText := func(col string, p *string) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
setParts = append(setParts, fmt.Sprintf("%s = $%d", col, idx))
|
||||
args = append(args, *p)
|
||||
idx++
|
||||
}
|
||||
addBool := func(col string, p *bool) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
setParts = append(setParts, fmt.Sprintf("%s = $%d", col, idx))
|
||||
args = append(args, *p)
|
||||
idx++
|
||||
}
|
||||
addNullable := func(col string, p **string) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
setParts = append(setParts, fmt.Sprintf("%s = $%d", col, idx))
|
||||
args = append(args, *p)
|
||||
idx++
|
||||
}
|
||||
|
||||
addText("slug", patch.Slug)
|
||||
addNullable("firm", patch.Firm)
|
||||
addText("section_key", patch.SectionKey)
|
||||
addNullable("proceeding_family", patch.ProceedingFamily)
|
||||
addText("title_de", patch.TitleDE)
|
||||
addText("title_en", patch.TitleEN)
|
||||
addNullable("description_de", patch.DescriptionDE)
|
||||
addNullable("description_en", patch.DescriptionEN)
|
||||
addText("content_md_de", patch.ContentMDDE)
|
||||
addText("content_md_en", patch.ContentMDEN)
|
||||
addText("visibility", patch.Visibility)
|
||||
addBool("is_published", patch.IsPublished)
|
||||
|
||||
if len(setParts) == 0 {
|
||||
// No-op patch — still append a version with the user's note if
|
||||
// supplied. Otherwise just return current row.
|
||||
current, err := s.GetForAdmin(ctx, blockID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if patch.Note != nil && strings.TrimSpace(*patch.Note) != "" {
|
||||
if err := s.appendVersionTx(ctx, tx, blockID, editorID, current, *patch.Note); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit no-op update building block: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return current, nil
|
||||
}
|
||||
|
||||
args = append(args, blockID)
|
||||
q := fmt.Sprintf(
|
||||
`UPDATE paliad.submission_building_blocks
|
||||
SET %s
|
||||
WHERE id = $%d AND deleted_at IS NULL
|
||||
RETURNING `+buildingBlockColumns,
|
||||
strings.Join(setParts, ", "), idx,
|
||||
)
|
||||
var b BuildingBlock
|
||||
err = tx.GetContext(ctx, &b, q, args...)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrBuildingBlockNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update building block: %w", err)
|
||||
}
|
||||
|
||||
note := ""
|
||||
if patch.Note != nil {
|
||||
note = *patch.Note
|
||||
}
|
||||
if note == "" {
|
||||
note = "update"
|
||||
}
|
||||
if err := s.appendVersionTx(ctx, tx, blockID, editorID, &b, note); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit update building block: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// SoftDelete marks a block deleted. RLS hides deleted rows; the
|
||||
// admin can still see them via GetForAdmin if the row is referenced
|
||||
// by audit history.
|
||||
func (s *BuildingBlockService) SoftDelete(ctx context.Context, editorID, blockID uuid.UUID) error {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("soft delete tx: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
var b BuildingBlock
|
||||
err = tx.GetContext(ctx, &b,
|
||||
`UPDATE paliad.submission_building_blocks
|
||||
SET deleted_at = now()
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING `+buildingBlockColumns,
|
||||
blockID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrBuildingBlockNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("soft delete: %w", err)
|
||||
}
|
||||
if err := s.appendVersionTx(ctx, tx, blockID, editorID, &b, "delete"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit soft delete: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListVersions returns the audit history for a block (most recent
|
||||
// first), capped at retention. Admin path only.
|
||||
func (s *BuildingBlockService) ListVersions(ctx context.Context, blockID uuid.UUID) ([]BuildingBlockVersion, error) {
|
||||
var rows []BuildingBlockVersion
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT id, building_block_id, content_md_de, content_md_en,
|
||||
title_de, title_en, edited_by, note, created_at
|
||||
FROM paliad.submission_building_block_admin_versions
|
||||
WHERE building_block_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`,
|
||||
blockID, buildingBlockVersionRetention)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list building block versions: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// RestoreVersion overwrites the block's current content + titles with
|
||||
// the named version's snapshot. Appends a new audit row noting the
|
||||
// restore. Admin path only.
|
||||
func (s *BuildingBlockService) RestoreVersion(ctx context.Context, editorID, blockID, versionID uuid.UUID) (*BuildingBlock, error) {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("restore version tx: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
var v BuildingBlockVersion
|
||||
err = tx.GetContext(ctx, &v,
|
||||
`SELECT id, building_block_id, content_md_de, content_md_en,
|
||||
title_de, title_en, edited_by, note, created_at
|
||||
FROM paliad.submission_building_block_admin_versions
|
||||
WHERE id = $1 AND building_block_id = $2`,
|
||||
versionID, blockID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrBuildingBlockNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch version: %w", err)
|
||||
}
|
||||
|
||||
var b BuildingBlock
|
||||
err = tx.GetContext(ctx, &b,
|
||||
`UPDATE paliad.submission_building_blocks
|
||||
SET content_md_de = $1, content_md_en = $2,
|
||||
title_de = $3, title_en = $4
|
||||
WHERE id = $5 AND deleted_at IS NULL
|
||||
RETURNING `+buildingBlockColumns,
|
||||
v.ContentMDDE, v.ContentMDEN, v.TitleDE, v.TitleEN, blockID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrBuildingBlockNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("restore update: %w", err)
|
||||
}
|
||||
|
||||
note := fmt.Sprintf("restore from %s", versionID.String())
|
||||
if err := s.appendVersionTx(ctx, tx, blockID, editorID, &b, note); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit restore: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// appendVersionTx inserts an audit row + GCs to the retention horizon.
|
||||
// Runs inside the caller's transaction so a failure rolls back the
|
||||
// associated Create / Update / Delete / Restore.
|
||||
func (s *BuildingBlockService) appendVersionTx(ctx context.Context, tx *sqlx.Tx, blockID, editorID uuid.UUID, b *BuildingBlock, note string) error {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.submission_building_block_admin_versions
|
||||
(building_block_id, content_md_de, content_md_en,
|
||||
title_de, title_en, edited_by, note)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
blockID, b.ContentMDDE, b.ContentMDEN, b.TitleDE, b.TitleEN, editorID, note)
|
||||
if err != nil {
|
||||
return fmt.Errorf("append version: %w", err)
|
||||
}
|
||||
// GC: keep only the most recent N versions per block.
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.submission_building_block_admin_versions
|
||||
WHERE id IN (
|
||||
SELECT id FROM paliad.submission_building_block_admin_versions
|
||||
WHERE building_block_id = $1
|
||||
ORDER BY created_at DESC
|
||||
OFFSET $2
|
||||
)`,
|
||||
blockID, buildingBlockVersionRetention)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gc version history: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InsertIntoSection clones a block's content_md_<lang> into the named
|
||||
// section by appending at the end (with a paragraph break separator).
|
||||
// Per Q2: no lineage stamped on the section. The returned
|
||||
// SubmissionSection carries the updated content.
|
||||
//
|
||||
// The handler enforces draft ownership before calling this; the
|
||||
// service does the visibility check on the block itself and the
|
||||
// SectionService.Get + Update sequence inside one transaction so an
|
||||
// in-flight failure rolls back cleanly.
|
||||
func (s *BuildingBlockService) InsertIntoSection(ctx context.Context, userID, blockID, sectionID uuid.UUID, sections *SectionService) (*SubmissionSection, error) {
|
||||
block, err := s.GetVisible(ctx, userID, blockID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sec, err := sections.Get(ctx, sectionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Determine which lang column to splice into based on the section
|
||||
// row's existing content + the block's content. We splice both
|
||||
// lang columns so the section is bilingually current — the
|
||||
// lawyer's draft language picker still drives which one renders.
|
||||
newDE := appendBlockContent(sec.ContentMDDE, block.ContentMDDE)
|
||||
newEN := appendBlockContent(sec.ContentMDEN, block.ContentMDEN)
|
||||
|
||||
patch := SectionPatch{ContentMDDE: &newDE, ContentMDEN: &newEN}
|
||||
return sections.Update(ctx, sectionID, patch)
|
||||
}
|
||||
|
||||
func appendBlockContent(existing, addition string) string {
|
||||
if strings.TrimSpace(existing) == "" {
|
||||
return addition
|
||||
}
|
||||
if strings.TrimSpace(addition) == "" {
|
||||
return existing
|
||||
}
|
||||
return strings.TrimRight(existing, "\n") + "\n\n" + addition
|
||||
}
|
||||
|
||||
func validVisibility(v string) bool {
|
||||
switch v {
|
||||
case VisPrivate, VisTeam, VisFirm, VisGlobal:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
60
internal/services/submission_building_block_service_test.go
Normal file
60
internal/services/submission_building_block_service_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package services
|
||||
|
||||
// Unit tests for BuildingBlockService helpers — pure functions, no DB
|
||||
// dependency (t-paliad-315 Slice C).
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidVisibility(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
valid bool
|
||||
}{
|
||||
{"private", true},
|
||||
{"team", true},
|
||||
{"firm", true},
|
||||
{"global", true},
|
||||
{"PRIVATE", false}, // case-sensitive
|
||||
{"", false},
|
||||
{"public", false},
|
||||
{"all", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
if got := validVisibility(tc.in); got != tc.valid {
|
||||
t.Errorf("validVisibility(%q) = %v; want %v", tc.in, got, tc.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendBlockContent(t *testing.T) {
|
||||
cases := []struct {
|
||||
existing string
|
||||
addition string
|
||||
want string
|
||||
}{
|
||||
{"", "hello", "hello"},
|
||||
{"existing", "", "existing"},
|
||||
{"", "", ""},
|
||||
{"existing", "addition", "existing\n\naddition"},
|
||||
{"existing\n", "addition", "existing\n\naddition"},
|
||||
{"existing\n\n\n", "addition", "existing\n\naddition"},
|
||||
{" ", "addition", "addition"}, // whitespace-only existing counts as empty
|
||||
{"existing", " ", "existing"}, // whitespace-only addition counts as empty
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := appendBlockContent(tc.existing, tc.addition); got != tc.want {
|
||||
t.Errorf("appendBlockContent(%q,%q) = %q; want %q", tc.existing, tc.addition, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildingBlockVisibilityConstants(t *testing.T) {
|
||||
// Pin the constants so a typo somewhere doesn't silently flip a
|
||||
// tier name. The DB CHECK constraint and the RLS predicate both
|
||||
// hard-code these literals.
|
||||
if VisPrivate != "private" || VisTeam != "team" || VisFirm != "firm" || VisGlobal != "global" {
|
||||
t.Errorf("visibility constants drifted: %q/%q/%q/%q", VisPrivate, VisTeam, VisFirm, VisGlobal)
|
||||
}
|
||||
}
|
||||
607
internal/services/submission_compose.go
Normal file
607
internal/services/submission_compose.go
Normal file
@@ -0,0 +1,607 @@
|
||||
package services
|
||||
|
||||
// Composer render pipeline — t-paliad-313 Slice B (design doc §9.1 +
|
||||
// §9.2). Assembles a base .docx and a draft's section rows into a
|
||||
// merged .docx ready for export.
|
||||
//
|
||||
// Pipeline (high-level):
|
||||
//
|
||||
// 1. ConvertDotmToDocx pre-pass on the base bytes (idempotent on .docx).
|
||||
// 2. Locate `word/document.xml` inside the zip; pull the body XML.
|
||||
// 3. For each section in the draft (order_index ASC, included=true):
|
||||
// render content_md_<lang> → OOXML via RenderMarkdownToOOXML using
|
||||
// base.section_spec.stylemap.paragraph.
|
||||
// 4. Splice the rendered OOXML into the base body. Two splice modes:
|
||||
// - Anchor mode: when the body carries `{{#section:KEY}}` /
|
||||
// `{{/section:KEY}}` marker pairs, replace the slot's content
|
||||
// (including the anchor paragraphs themselves) with the rendered
|
||||
// section.
|
||||
// - Append mode: when no anchor pair is found for a section, the
|
||||
// rendered OOXML appends at the end of the body, just before any
|
||||
// `<w:sectPr>` element. Sections with `included=false` are
|
||||
// dropped silently.
|
||||
// 5. Strip any leftover unmatched anchor paragraphs.
|
||||
// 6. Re-pack the document.xml into the zip, leaving every other part
|
||||
// untouched.
|
||||
// 7. Run the v1 SubmissionRenderer placeholder pass over the assembly
|
||||
// so `{{path}}` placeholders inside section content (and inside
|
||||
// the base's untouched chrome) get substituted by the merged bag.
|
||||
// Cross-run merge in pass 2 handles autocorrect-fragmented
|
||||
// placeholders the same as v1.
|
||||
//
|
||||
// Result: a fully-merged .docx. No new third-party Go dep — reuses
|
||||
// archive/zip + the existing SubmissionRenderer.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SubmissionComposer assembles base + sections into a final .docx.
|
||||
// Stateless; safe for concurrent use.
|
||||
type SubmissionComposer struct {
|
||||
renderer *SubmissionRenderer
|
||||
}
|
||||
|
||||
// NewSubmissionComposer wires the composer. The renderer is required —
|
||||
// a nil renderer is a programmer error and the composer panics at
|
||||
// construction.
|
||||
func NewSubmissionComposer(renderer *SubmissionRenderer) *SubmissionComposer {
|
||||
if renderer == nil {
|
||||
panic("submission composer: renderer required")
|
||||
}
|
||||
return &SubmissionComposer{renderer: renderer}
|
||||
}
|
||||
|
||||
// ComposeOptions carries the per-call composition inputs.
|
||||
type ComposeOptions struct {
|
||||
// Sections are the draft's section rows in display order. The
|
||||
// composer renders included sections; excluded rows are dropped.
|
||||
// Caller is responsible for visibility — by the time the composer
|
||||
// runs, the section rows have already been gated through
|
||||
// SubmissionDraftService.Get + can_see_project.
|
||||
Sections []SubmissionSection
|
||||
|
||||
// Base supplies the document chrome (.docx body host) plus the
|
||||
// stylemap for the MD walker. Must not be nil.
|
||||
Base *SubmissionBase
|
||||
|
||||
// BaseBytes is the raw .docx bytes for the base. Typically fetched
|
||||
// from Gitea via the existing template cache.
|
||||
BaseBytes []byte
|
||||
|
||||
// Lang ('de' or 'en') selects which content_md_* column the
|
||||
// composer reads per section. Defaults to 'de' if empty.
|
||||
Lang string
|
||||
|
||||
// Vars is the merged placeholder bag the v1 renderer pass
|
||||
// substitutes after the composer assembly. Passed straight through
|
||||
// to SubmissionRenderer.Render.
|
||||
Vars PlaceholderMap
|
||||
|
||||
// Missing translates an unbound placeholder key into the marker
|
||||
// the lawyer sees in Word. Passed straight to the renderer.
|
||||
Missing MissingPlaceholderFn
|
||||
}
|
||||
|
||||
// Compose runs the full pipeline and returns the merged .docx bytes.
|
||||
func (c *SubmissionComposer) Compose(ctx context.Context, opts ComposeOptions) ([]byte, error) {
|
||||
if opts.Base == nil {
|
||||
return nil, fmt.Errorf("submission compose: base required")
|
||||
}
|
||||
_ = ctx // reserved for cancellation propagation in later slices
|
||||
sections := opts.Sections
|
||||
|
||||
// Pre-pass: strip macros so the base reads as a plain .docx zip.
|
||||
cleanBytes, err := ConvertDotmToDocx(opts.BaseBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission compose: convert base: %w", err)
|
||||
}
|
||||
|
||||
// Locate + extract word/document.xml so we can splice in-place.
|
||||
documentXML, otherParts, err := splitBaseZip(cleanBytes)
|
||||
if err != nil {
|
||||
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.
|
||||
stylemap := opts.Base.SectionSpec.Stylemap
|
||||
rendered := make(map[string]string, len(sections))
|
||||
keptSections := make([]SubmissionSection, 0, len(sections))
|
||||
for _, sec := range sections {
|
||||
if !sec.Included {
|
||||
continue
|
||||
}
|
||||
md := sec.ContentMDDE
|
||||
if strings.EqualFold(opts.Lang, "en") {
|
||||
md = sec.ContentMDEN
|
||||
}
|
||||
rendered[sec.SectionKey] = RenderMarkdownToOOXMLWithStyles(md, stylemap, linkAlloc.Alloc)
|
||||
keptSections = append(keptSections, sec)
|
||||
}
|
||||
// Stable order — already sorted ascending by ListForDraft, but
|
||||
// belt-and-braces in case the caller swaps the ordering policy
|
||||
// later.
|
||||
sort.SliceStable(keptSections, func(i, j int) bool {
|
||||
return keptSections[i].OrderIndex < keptSections[j].OrderIndex
|
||||
})
|
||||
|
||||
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.
|
||||
repacked, err := repackBaseZip(otherParts, assembledBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Final pass: substitute placeholders against the merged bag. The
|
||||
// existing renderer handles cross-run fragmentation, the `{{rule.X}}`
|
||||
// alias contract, and the missing-marker emission. Reusing it
|
||||
// guarantees v1's placeholder grammar stays intact inside section
|
||||
// content + base chrome.
|
||||
merged, err := c.renderer.Render(repacked, opts.Vars, opts.Missing)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission compose: placeholder pass: %w", err)
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Section splicing
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Anchor markers as they appear inside a <w:t> text node. We don't
|
||||
// need a full XML parse — finding the marker text inside the body is
|
||||
// sufficient because:
|
||||
// - {{ and }} are never legitimate document content (placeholders
|
||||
// follow the same convention everywhere else in paliad).
|
||||
// - The anchor key grammar [A-Za-z0-9_]+ rules out any HTML/XML
|
||||
// special characters.
|
||||
// - Each anchor lives in exactly one <w:t>...<w:t>, which lives in
|
||||
// exactly one <w:r>...</w:r>, which lives in exactly one
|
||||
// <w:p>...</w:p>. We expand from the marker outward to find the
|
||||
// enclosing <w:p> span and drop the entire paragraph as part of
|
||||
// the splice.
|
||||
//
|
||||
// RE2 has no lookahead, so the "find enclosing <w:p>" logic is
|
||||
// implemented as manual byte-index search around the marker hit
|
||||
// (anchorParagraphSpan below) rather than a single regex pattern.
|
||||
|
||||
const (
|
||||
anchorOpenPrefix = "{{#section:"
|
||||
anchorClosePrefix = "{{/section:"
|
||||
anchorSuffix = "}}"
|
||||
)
|
||||
|
||||
// anchorKeyRegex validates that the captured anchor key is a clean
|
||||
// identifier. Keys that include other characters (which can't actually
|
||||
// appear in our authored .docx) are treated as no match.
|
||||
var anchorKeyRegex = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
|
||||
|
||||
// anchorPair records the byte span of one matched anchor pair inside
|
||||
// the body — from the start of the opening anchor's <w:p> element
|
||||
// through the end of the closing anchor's </w:p>.
|
||||
type anchorPair struct {
|
||||
key string
|
||||
openStart int // start of <w:p> for the opening anchor
|
||||
closeEnd int // index just past </w:p> for the closing anchor
|
||||
}
|
||||
|
||||
// findAllAnchorPairs scans the body for matched open/close anchor
|
||||
// pairs. Unbalanced markers (open without close, or vice versa) are
|
||||
// dropped from the result. Returns pairs in body-order; each pair's
|
||||
// span is non-overlapping.
|
||||
func findAllAnchorPairs(body string) []anchorPair {
|
||||
type marker struct {
|
||||
key string
|
||||
paraStart int
|
||||
paraEnd int
|
||||
isOpen bool
|
||||
}
|
||||
var markers []marker
|
||||
|
||||
collect := func(prefix string, isOpen bool) {
|
||||
offset := 0
|
||||
for {
|
||||
idx := strings.Index(body[offset:], prefix)
|
||||
if idx < 0 {
|
||||
return
|
||||
}
|
||||
start := offset + idx
|
||||
suffixIdx := strings.Index(body[start+len(prefix):], anchorSuffix)
|
||||
if suffixIdx < 0 {
|
||||
return
|
||||
}
|
||||
key := body[start+len(prefix) : start+len(prefix)+suffixIdx]
|
||||
if !anchorKeyRegex.MatchString(key) {
|
||||
offset = start + len(prefix)
|
||||
continue
|
||||
}
|
||||
markerEnd := start + len(prefix) + suffixIdx + len(anchorSuffix)
|
||||
pStart, pEnd, ok := paragraphSpanAround(body, start, markerEnd)
|
||||
if !ok {
|
||||
offset = markerEnd
|
||||
continue
|
||||
}
|
||||
markers = append(markers, marker{key: key, paraStart: pStart, paraEnd: pEnd, isOpen: isOpen})
|
||||
offset = pEnd
|
||||
}
|
||||
}
|
||||
collect(anchorOpenPrefix, true)
|
||||
collect(anchorClosePrefix, false)
|
||||
|
||||
// Walk markers in body-order, matching each open with the next
|
||||
// close that carries the same key.
|
||||
sort.SliceStable(markers, func(i, j int) bool {
|
||||
return markers[i].paraStart < markers[j].paraStart
|
||||
})
|
||||
var pairs []anchorPair
|
||||
openStack := map[string]marker{}
|
||||
for _, m := range markers {
|
||||
if m.isOpen {
|
||||
openStack[m.key] = m
|
||||
continue
|
||||
}
|
||||
o, ok := openStack[m.key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, anchorPair{
|
||||
key: m.key,
|
||||
openStart: o.paraStart,
|
||||
closeEnd: m.paraEnd,
|
||||
})
|
||||
delete(openStack, m.key)
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// paragraphSpanAround returns the byte span of the smallest `<w:p>...</w:p>`
|
||||
// element that fully contains the byte range [markerStart, markerEnd).
|
||||
// Returns false when the byte range doesn't sit inside a single
|
||||
// paragraph (which would mean the marker survived a cross-paragraph
|
||||
// edit — defensive guard, shouldn't happen in well-formed input).
|
||||
func paragraphSpanAround(body string, markerStart, markerEnd int) (int, int, bool) {
|
||||
// Walk backwards to find the nearest unclosed <w:p ... > opening.
|
||||
// Since <w:p> doesn't nest, the nearest <w:p before markerStart is
|
||||
// the enclosing paragraph's opening tag.
|
||||
pStart := -1
|
||||
cursor := markerStart
|
||||
for cursor > 0 {
|
||||
idx := strings.LastIndex(body[:cursor], "<w:p")
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
// Confirm this is a paragraph open, not a different
|
||||
// w:p-prefixed tag (e.g. <w:pPr>).
|
||||
if idx+4 <= len(body) {
|
||||
after := body[idx+4]
|
||||
if after == ' ' || after == '>' || after == '/' {
|
||||
// <w:p ...> or <w:p>; not <w:pPr>.
|
||||
close := strings.Index(body[idx:], ">")
|
||||
if close < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
pStart = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
cursor = idx
|
||||
}
|
||||
if pStart < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
// Walk forward to find the matching </w:p>. <w:p> doesn't nest so
|
||||
// the next </w:p> after the marker is the close.
|
||||
pEndIdx := strings.Index(body[markerEnd:], "</w:p>")
|
||||
if pEndIdx < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
pEnd := markerEnd + pEndIdx + len("</w:p>")
|
||||
return pStart, pEnd, true
|
||||
}
|
||||
|
||||
// spliceSections replaces anchor slots with rendered sections and
|
||||
// appends any unanchored sections before sectPr. Returns the assembled
|
||||
// document.xml body.
|
||||
func spliceSections(documentXML []byte, rendered map[string]string, kept []SubmissionSection, all []SubmissionSection) []byte {
|
||||
body := string(documentXML)
|
||||
pairs := findAllAnchorPairs(body)
|
||||
|
||||
// Build a lookup of kept section keys for quick membership tests.
|
||||
keptByKey := map[string]int{}
|
||||
for i, sec := range kept {
|
||||
keptByKey[sec.SectionKey] = i
|
||||
}
|
||||
allByKey := map[string]int{}
|
||||
for i, sec := range all {
|
||||
allByKey[sec.SectionKey] = i
|
||||
}
|
||||
|
||||
matchedKeys := map[string]bool{}
|
||||
|
||||
// Walk pairs in REVERSE body-order so slice mutations don't shift
|
||||
// later offsets.
|
||||
sort.SliceStable(pairs, func(i, j int) bool {
|
||||
return pairs[i].openStart > pairs[j].openStart
|
||||
})
|
||||
for _, p := range pairs {
|
||||
replacement := ""
|
||||
if idx, ok := keptByKey[p.key]; ok {
|
||||
replacement = rendered[p.key]
|
||||
matchedKeys[p.key] = true
|
||||
_ = idx
|
||||
} else if _, isOnDraft := allByKey[p.key]; isOnDraft {
|
||||
// Anchor matches an excluded section on the draft — drop
|
||||
// the entire slot.
|
||||
replacement = ""
|
||||
} else {
|
||||
// Anchor doesn't match any section on this draft — drop
|
||||
// to leave the base's chrome unbroken.
|
||||
replacement = ""
|
||||
}
|
||||
body = body[:p.openStart] + replacement + body[p.closeEnd:]
|
||||
}
|
||||
|
||||
// Append unanchored sections before sectPr in order_index ASC.
|
||||
var unanchored strings.Builder
|
||||
for _, sec := range kept {
|
||||
if matchedKeys[sec.SectionKey] {
|
||||
continue
|
||||
}
|
||||
unanchored.WriteString(rendered[sec.SectionKey])
|
||||
}
|
||||
if unanchored.Len() > 0 {
|
||||
body = appendBeforeSectPr(body, unanchored.String())
|
||||
}
|
||||
|
||||
return []byte(body)
|
||||
}
|
||||
|
||||
// appendBeforeSectPr inserts content immediately before the first
|
||||
// `<w:sectPr` element in the body, or at the end of the body if there
|
||||
// is none. Word documents conventionally close the body with a sectPr
|
||||
// describing page setup; we want to land sections before that element
|
||||
// so they show up on the actual pages.
|
||||
var sectPrRegex = regexp.MustCompile(`<w:sectPr\b`)
|
||||
|
||||
func appendBeforeSectPr(body, content string) string {
|
||||
loc := sectPrRegex.FindStringIndex(body)
|
||||
if loc == nil {
|
||||
// No sectPr → append before `</w:body>` if present, else at
|
||||
// the very end.
|
||||
idx := strings.LastIndex(body, "</w:body>")
|
||||
if idx < 0 {
|
||||
return body + content
|
||||
}
|
||||
return body[:idx] + content + body[idx:]
|
||||
}
|
||||
return body[:loc[0]] + content + body[loc[0]:]
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Zip plumbing
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// baseZipPart captures one zip entry we kept aside while extracting
|
||||
// document.xml.
|
||||
type baseZipPart struct {
|
||||
name string
|
||||
method uint16
|
||||
modTime int64 // wall seconds; converted back to time.Time on repack
|
||||
body []byte
|
||||
}
|
||||
|
||||
// splitBaseZip extracts document.xml and returns it alongside every
|
||||
// other zip entry, ready for repacking.
|
||||
func splitBaseZip(cleanBytes []byte) ([]byte, []baseZipPart, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(cleanBytes), int64(len(cleanBytes)))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("submission compose: open base zip: %w", err)
|
||||
}
|
||||
var documentXML []byte
|
||||
parts := make([]baseZipPart, 0, len(zr.File))
|
||||
for _, f := range zr.File {
|
||||
body, err := readZipEntry(f)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("submission compose: read %s: %w", f.Name, err)
|
||||
}
|
||||
if f.Name == "word/document.xml" {
|
||||
documentXML = body
|
||||
parts = append(parts, baseZipPart{name: f.Name, method: f.Method, modTime: f.Modified.Unix(), body: nil})
|
||||
continue
|
||||
}
|
||||
parts = append(parts, baseZipPart{name: f.Name, method: f.Method, modTime: f.Modified.Unix(), body: body})
|
||||
}
|
||||
if documentXML == nil {
|
||||
return nil, nil, fmt.Errorf("submission compose: base zip missing word/document.xml")
|
||||
}
|
||||
return documentXML, parts, nil
|
||||
}
|
||||
|
||||
// repackBaseZip rebuilds the zip, swapping document.xml for the
|
||||
// assembled body and leaving every other part untouched.
|
||||
func repackBaseZip(parts []baseZipPart, assembledBody []byte) ([]byte, error) {
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
for _, p := range parts {
|
||||
hdr := &zip.FileHeader{
|
||||
Name: p.name,
|
||||
Method: p.method,
|
||||
}
|
||||
if p.modTime > 0 {
|
||||
hdr.Modified = time.Unix(p.modTime, 0)
|
||||
}
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission compose: write header %s: %w", p.name, err)
|
||||
}
|
||||
body := p.body
|
||||
if p.name == "word/document.xml" {
|
||||
body = assembledBody
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
return nil, fmt.Errorf("submission compose: write body %s: %w", p.name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("submission compose: finalise zip: %w", err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
func readZipEntry(f *zip.File) ([]byte, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
478
internal/services/submission_compose_test.go
Normal file
478
internal/services/submission_compose_test.go
Normal file
@@ -0,0 +1,478 @@
|
||||
package services
|
||||
|
||||
// Unit tests for SubmissionComposer's pure splice logic — no DB
|
||||
// dependency. The end-to-end Compose path is exercised by the live
|
||||
// integration test in submission_section_service_live_test.go (Slice
|
||||
// A) once anchors land in the seeded .docx; this file covers the
|
||||
// anchor-splicing primitives and the section rendering glue.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// minimalBaseBytes builds a tiny .docx zip with one document.xml body
|
||||
// for the composer tests. The body content is provided by the caller
|
||||
// so different splice scenarios can be exercised in-process.
|
||||
func minimalBaseBytes(t *testing.T, body string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
parts := map[string]string{
|
||||
"[Content_Types].xml": `<?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"/>
|
||||
</Types>`,
|
||||
"_rels/.rels": `<?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>`,
|
||||
"word/document.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>` + body + `</w:body>
|
||||
</w:document>`,
|
||||
}
|
||||
for name, contents := range parts {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("zip create %s: %v", name, err)
|
||||
}
|
||||
if _, err := w.Write([]byte(contents)); err != nil {
|
||||
t.Fatalf("zip write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("zip close: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// 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 != name {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", name, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
var buf bytes.Buffer
|
||||
if _, err := buf.ReadFrom(rc); err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
t.Fatalf("%s not found in zip", name)
|
||||
return ""
|
||||
}
|
||||
|
||||
// composerBase returns a SubmissionBase wired with the neutral
|
||||
// stylemap for composer tests.
|
||||
func composerBase() *SubmissionBase {
|
||||
return &SubmissionBase{
|
||||
ID: uuid.New(),
|
||||
Slug: "test-base",
|
||||
SectionSpec: BaseSectionSpec{
|
||||
Version: 1,
|
||||
Stylemap: map[string]string{
|
||||
"paragraph": "Normal",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposer_AppendMode_NoAnchors(t *testing.T) {
|
||||
// Base has no anchors → composer appends sections before sectPr.
|
||||
base := composerBase()
|
||||
body := `<w:p><w:r><w:t>Static chrome</w:t></w:r></w:p><w:sectPr/>`
|
||||
baseBytes := minimalBaseBytes(t, body)
|
||||
composer := NewSubmissionComposer(NewSubmissionRenderer())
|
||||
|
||||
sections := []SubmissionSection{
|
||||
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "Section text"},
|
||||
}
|
||||
out, err := composer.Compose(context.Background(), ComposeOptions{
|
||||
Sections: sections,
|
||||
Base: base,
|
||||
BaseBytes: baseBytes,
|
||||
Lang: "de",
|
||||
Vars: PlaceholderMap{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compose: %v", err)
|
||||
}
|
||||
docXML := extractDocumentXML(t, out)
|
||||
if !strings.Contains(docXML, "Static chrome") {
|
||||
t.Errorf("base chrome dropped: %q", docXML)
|
||||
}
|
||||
if !strings.Contains(docXML, "Section text") {
|
||||
t.Errorf("section content missing: %q", docXML)
|
||||
}
|
||||
// Section must land before sectPr (rule of thumb: it's an end-of-body element).
|
||||
staticIdx := strings.Index(docXML, "Section text")
|
||||
sectPrIdx := strings.Index(docXML, "<w:sectPr")
|
||||
if staticIdx < 0 || sectPrIdx < 0 || staticIdx > sectPrIdx {
|
||||
t.Errorf("section landed after sectPr: section=%d sectPr=%d", staticIdx, sectPrIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposer_AnchorMode_SpliceContent(t *testing.T) {
|
||||
base := composerBase()
|
||||
body := `<w:p><w:r><w:t>Header</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>(seed)</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>Footer</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: "Real prose"},
|
||||
}
|
||||
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)
|
||||
if !strings.Contains(docXML, "Header") || !strings.Contains(docXML, "Footer") {
|
||||
t.Errorf("base chrome dropped: %q", docXML)
|
||||
}
|
||||
if !strings.Contains(docXML, "Real prose") {
|
||||
t.Errorf("section content missing: %q", docXML)
|
||||
}
|
||||
// Anchor paragraphs themselves must be gone.
|
||||
if strings.Contains(docXML, "{{#section:facts}}") || strings.Contains(docXML, "{{/section:facts}}") {
|
||||
t.Errorf("anchor markers survived: %q", docXML)
|
||||
}
|
||||
// Seed content between anchors must be gone (replaced by the
|
||||
// composed section).
|
||||
if strings.Contains(docXML, "(seed)") {
|
||||
t.Errorf("anchor-spanned seed survived: %q", docXML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposer_ExcludedSection_DropsAnchorPair(t *testing.T) {
|
||||
base := composerBase()
|
||||
body := `<w:p><w:r><w:t>Header</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>{{#section:exhibits}}</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>(default)</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>{{/section:exhibits}}</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>Footer</w:t></w:r></w:p>`
|
||||
baseBytes := minimalBaseBytes(t, body)
|
||||
composer := NewSubmissionComposer(NewSubmissionRenderer())
|
||||
|
||||
sections := []SubmissionSection{
|
||||
{ID: uuid.New(), SectionKey: "exhibits", OrderIndex: 8, Kind: "prose", Included: false, ContentMDDE: "ignored"},
|
||||
}
|
||||
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)
|
||||
if strings.Contains(docXML, "{{#section:exhibits}}") || strings.Contains(docXML, "{{/section:exhibits}}") {
|
||||
t.Errorf("anchors for excluded section survived: %q", docXML)
|
||||
}
|
||||
if strings.Contains(docXML, "ignored") {
|
||||
t.Errorf("excluded section content rendered: %q", docXML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposer_PlaceholdersResolve(t *testing.T) {
|
||||
base := composerBase()
|
||||
body := `<w:p><w:r><w:t>{{#section:greeting}}</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>{{/section:greeting}}</w:t></w:r></w:p>`
|
||||
baseBytes := minimalBaseBytes(t, body)
|
||||
composer := NewSubmissionComposer(NewSubmissionRenderer())
|
||||
|
||||
sections := []SubmissionSection{
|
||||
{ID: uuid.New(), SectionKey: "greeting", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "Hallo {{user.name}}"},
|
||||
}
|
||||
out, err := composer.Compose(context.Background(), ComposeOptions{
|
||||
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
|
||||
Vars: PlaceholderMap{"user.name": "Maria Schmidt"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compose: %v", err)
|
||||
}
|
||||
docXML := extractDocumentXML(t, out)
|
||||
if !strings.Contains(docXML, "Hallo") || !strings.Contains(docXML, "Maria Schmidt") {
|
||||
t.Errorf("placeholder not substituted: %q", docXML)
|
||||
}
|
||||
if strings.Contains(docXML, "{{user.name}}") {
|
||||
t.Errorf("placeholder survived: %q", docXML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposer_LangPicksColumn(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: "deutscher text", ContentMDEN: "english text"},
|
||||
}
|
||||
deOut, _ := composer.Compose(context.Background(), ComposeOptions{
|
||||
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
|
||||
})
|
||||
enOut, _ := composer.Compose(context.Background(), ComposeOptions{
|
||||
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "en",
|
||||
})
|
||||
deXML := extractDocumentXML(t, deOut)
|
||||
enXML := extractDocumentXML(t, enOut)
|
||||
if !strings.Contains(deXML, "deutscher text") || strings.Contains(deXML, "english text") {
|
||||
t.Errorf("DE pick failed: %q", deXML)
|
||||
}
|
||||
if !strings.Contains(enXML, "english text") || strings.Contains(enXML, "deutscher text") {
|
||||
t.Errorf("EN pick failed: %q", enXML)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// before sectPr.
|
||||
body := `<w:sectPr/>`
|
||||
baseBytes := minimalBaseBytes(t, body)
|
||||
composer := NewSubmissionComposer(NewSubmissionRenderer())
|
||||
|
||||
sections := []SubmissionSection{
|
||||
{ID: uuid.New(), SectionKey: "second", OrderIndex: 2, Kind: "prose", Included: true, ContentMDDE: "ZWEITER"},
|
||||
{ID: uuid.New(), SectionKey: "first", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "ERSTER"},
|
||||
}
|
||||
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)
|
||||
firstIdx := strings.Index(docXML, "ERSTER")
|
||||
secondIdx := strings.Index(docXML, "ZWEITER")
|
||||
if firstIdx < 0 || secondIdx < 0 || firstIdx > secondIdx {
|
||||
t.Errorf("order_index ASC not honoured: ERSTER=%d ZWEITER=%d", firstIdx, secondIdx)
|
||||
}
|
||||
}
|
||||
@@ -58,8 +58,17 @@ type SubmissionDraft struct {
|
||||
LastExportedAt *time.Time `db:"last_exported_at" json:"last_exported_at,omitempty"`
|
||||
LastExportedSHA *string `db:"last_exported_sha" json:"last_exported_sha,omitempty"`
|
||||
LastImportedAt *time.Time `db:"last_imported_at" json:"last_imported_at,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
// BaseID is the Composer base reference (t-paliad-313). NULL on
|
||||
// pre-Composer drafts — the v1 render path stays the fallback.
|
||||
// ON DELETE SET NULL keeps a draft renderable if its base is
|
||||
// removed; the lawyer picks a new one via the sidebar.
|
||||
BaseID *uuid.UUID `db:"base_id" json:"base_id,omitempty"`
|
||||
// ComposerMetaRaw / ComposerMeta — Composer-side metadata jsonb.
|
||||
// Slice A: empty default. Future slices populate section_order,
|
||||
// hidden_sections, etc.
|
||||
ComposerMetaRaw []byte `db:"composer_meta" json:"-"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// Variables is the decoded overrides map; populated on read by the
|
||||
// service so callers don't have to unmarshal manually.
|
||||
@@ -70,15 +79,36 @@ type SubmissionDraft struct {
|
||||
// the backward-compat "include every party" behaviour; a non-empty
|
||||
// slice restricts the variable bag to the listed paliad.parties rows.
|
||||
SelectedParties []uuid.UUID `json:"selected_parties"`
|
||||
|
||||
// ComposerMeta is the parsed Composer-side metadata (t-paliad-313).
|
||||
// Slice A: typically empty. Populated on read by decodeComposerMeta().
|
||||
ComposerMeta map[string]any `json:"composer_meta"`
|
||||
}
|
||||
|
||||
// SubmissionDraftService handles CRUD on submission_drafts and exposes
|
||||
// the render/preview/export entry points the handler layer calls.
|
||||
//
|
||||
// The Composer wiring (t-paliad-313, Slice A): bases + sections are
|
||||
// optional — when nil the service stays back-compat with the v1 shape
|
||||
// (drafts created without a base_id, no section rows). When wired, new
|
||||
// drafts created via Create get base_id seeded from the firm default
|
||||
// and submission_sections rows inserted from the base's section spec.
|
||||
type SubmissionDraftService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
vars *SubmissionVarsService
|
||||
renderer *SubmissionRenderer
|
||||
|
||||
// bases + sections are optional Composer wiring (t-paliad-313).
|
||||
// Nil means "stay back-compat with the v1 shape" — new drafts
|
||||
// keep base_id NULL and no submission_sections rows get seeded.
|
||||
bases *BaseService
|
||||
sections *SectionService
|
||||
|
||||
// firmName captures branding.Name at construction time. Used to
|
||||
// resolve the firm-default base in Create. Empty string is
|
||||
// allowed (treated as "no firm filter" at base-lookup time).
|
||||
firmName string
|
||||
}
|
||||
|
||||
// NewSubmissionDraftService wires the service.
|
||||
@@ -91,6 +121,19 @@ func NewSubmissionDraftService(db *sqlx.DB, projects *ProjectService, vars *Subm
|
||||
}
|
||||
}
|
||||
|
||||
// AttachComposer wires the Composer-side services. Called by
|
||||
// cmd/server/main.go after constructing the base + section services.
|
||||
// firm is branding.Name (typically "HLC"); empty string disables the
|
||||
// firm filter at default-base lookup.
|
||||
//
|
||||
// Calling AttachComposer is purely additive — drafts created before the
|
||||
// call (or with bases==nil) keep the v1 behaviour. Idempotent.
|
||||
func (s *SubmissionDraftService) AttachComposer(bases *BaseService, sections *SectionService, firm string) {
|
||||
s.bases = bases
|
||||
s.sections = sections
|
||||
s.firmName = firm
|
||||
}
|
||||
|
||||
// DraftPatch carries optional fields for Update. nil pointer = "no
|
||||
// change"; non-nil = "set to this". Variables is replace-semantics —
|
||||
// the lawyer's sidebar sends the full map every save.
|
||||
@@ -117,6 +160,16 @@ type DraftPatch struct {
|
||||
// Language sets the output language. Valid values: "de", "en".
|
||||
// Anything else returns ErrInvalidInput. t-paliad-276.
|
||||
Language *string
|
||||
|
||||
// BaseID swaps the Composer base. Two-level pointer mirrors the
|
||||
// ProjectID shape so callers can encode the three operations:
|
||||
// nil → no change
|
||||
// *p == nil → clear (set base_id NULL, return to v1 fallback)
|
||||
// **p → set to the picked base
|
||||
// Slice A: lawyer flips this from the sidebar picker. Section
|
||||
// content is unaffected — the base swap is render-side only.
|
||||
// t-paliad-313.
|
||||
BaseID **uuid.UUID
|
||||
}
|
||||
|
||||
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
|
||||
@@ -133,6 +186,7 @@ const draftColumns = `id, project_id, submission_code, user_id, name, language,
|
||||
variables, selected_parties,
|
||||
last_exported_at, last_exported_sha,
|
||||
last_imported_at,
|
||||
base_id, composer_meta,
|
||||
created_at, updated_at`
|
||||
|
||||
// List returns every draft for (project, submission_code, user)
|
||||
@@ -185,6 +239,7 @@ func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid
|
||||
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name, d.language,
|
||||
d.variables, d.selected_parties,
|
||||
d.last_exported_at, d.last_exported_sha, d.last_imported_at,
|
||||
d.base_id, d.composer_meta,
|
||||
d.created_at, d.updated_at,
|
||||
p.title AS project_title,
|
||||
p.reference AS project_reference
|
||||
@@ -279,6 +334,14 @@ func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, proje
|
||||
// A nil projectID creates a project-less draft (t-paliad-243); the
|
||||
// visibility check is skipped — the caller is the owner and the row is
|
||||
// private to them.
|
||||
//
|
||||
// Composer wiring (t-paliad-313, Slice A): when AttachComposer has
|
||||
// been called and a base resolves for the submission_code, the INSERT
|
||||
// runs in a transaction alongside SectionService.SeedFromSpec so the
|
||||
// new draft and its seeded sections land atomically. If the base
|
||||
// lookup fails (catalog empty, no firm match, etc.) the draft still
|
||||
// creates with base_id=NULL — Composer is additive, the v1 fallback
|
||||
// path remains valid.
|
||||
func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error) {
|
||||
if projectID != nil {
|
||||
if _, err := s.projects.GetByID(ctx, userID, *projectID); err != nil {
|
||||
@@ -294,16 +357,61 @@ func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, p
|
||||
// Anything other than "en" normalizes to "de" — matches the DB CHECK
|
||||
// constraint and the project's primary-language default.
|
||||
draftLang := normalizeDraftLanguage(lang)
|
||||
|
||||
// Resolve the Composer base for this draft. nil result keeps the
|
||||
// draft v1-shaped (base_id NULL, no sections rows).
|
||||
var baseToSeed *SubmissionBase
|
||||
if s.bases != nil {
|
||||
base, err := s.bases.GetDefaultForCode(ctx, s.firmName, submissionCode)
|
||||
switch {
|
||||
case err == nil:
|
||||
baseToSeed = base
|
||||
case errors.Is(err, ErrBaseNotFound):
|
||||
// Catalog empty / no match — fall through to v1 shape.
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin create submission draft tx: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
var baseID *uuid.UUID
|
||||
if baseToSeed != nil {
|
||||
id := baseToSeed.ID
|
||||
baseID = &id
|
||||
}
|
||||
|
||||
var d SubmissionDraft
|
||||
err = s.db.GetContext(ctx, &d,
|
||||
err = tx.GetContext(ctx, &d,
|
||||
`INSERT INTO paliad.submission_drafts
|
||||
(project_id, submission_code, user_id, name, language)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
(project_id, submission_code, user_id, name, language, base_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING `+draftColumns,
|
||||
projectID, submissionCode, userID, name, draftLang)
|
||||
projectID, submissionCode, userID, name, draftLang, baseID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create submission draft: %w", err)
|
||||
}
|
||||
|
||||
if baseToSeed != nil && s.sections != nil {
|
||||
if err := s.sections.SeedFromSpec(ctx, tx, d.ID, baseToSeed.SectionSpec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit create submission draft tx: %w", err)
|
||||
}
|
||||
committed = true
|
||||
|
||||
if err := d.decode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -446,6 +554,18 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
|
||||
idx++
|
||||
}
|
||||
|
||||
if patch.BaseID != nil {
|
||||
newBID := *patch.BaseID // *uuid.UUID — nil means clear
|
||||
if newBID != nil && s.bases != nil {
|
||||
// Validate the picked base exists + is active.
|
||||
if _, err := s.bases.GetByID(ctx, *newBID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
setParts = append(setParts, fmt.Sprintf("base_id = $%d", idx))
|
||||
args = append(args, newBID)
|
||||
idx++
|
||||
}
|
||||
|
||||
if len(setParts) == 0 {
|
||||
return existing, nil
|
||||
@@ -682,14 +802,32 @@ func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, us
|
||||
return out, resolved, nil
|
||||
}
|
||||
|
||||
// decode fills the parsed views (Variables, SelectedParties) from the
|
||||
// raw scan fields. Called by every fetch path so the caller sees both
|
||||
// populated together.
|
||||
// decode fills the parsed views (Variables, SelectedParties,
|
||||
// ComposerMeta) from the raw scan fields. Called by every fetch path
|
||||
// so the caller sees them populated together.
|
||||
func (d *SubmissionDraft) decode() error {
|
||||
if err := d.decodeVariables(); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.decodeSelectedParties()
|
||||
if err := d.decodeSelectedParties(); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.decodeComposerMeta()
|
||||
}
|
||||
|
||||
// decodeComposerMeta turns the raw composer_meta jsonb into a
|
||||
// map[string]any. NULL or empty payload yields an empty map.
|
||||
func (d *SubmissionDraft) decodeComposerMeta() error {
|
||||
if len(d.ComposerMetaRaw) == 0 {
|
||||
d.ComposerMeta = map[string]any{}
|
||||
return nil
|
||||
}
|
||||
out := map[string]any{}
|
||||
if err := json.Unmarshal(d.ComposerMetaRaw, &out); err != nil {
|
||||
return fmt.Errorf("decode submission draft composer_meta: %w", err)
|
||||
}
|
||||
d.ComposerMeta = out
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeVariables turns the raw jsonb bytes into the PlaceholderMap.
|
||||
|
||||
486
internal/services/submission_md.go
Normal file
486
internal/services/submission_md.go
Normal file
@@ -0,0 +1,486 @@
|
||||
package services
|
||||
|
||||
// Markdown → OOXML walker for Composer section content (t-paliad-313
|
||||
// Slice B, design doc §9.2).
|
||||
//
|
||||
// Scope per the head's Slice B brief: paragraphs + inline bold/italic
|
||||
// only. Headings, lists, blockquote, links land in Slice D's rich-prose
|
||||
// pass. This walker is intentionally minimal — every Markdown construct
|
||||
// it doesn't recognise is rendered as a plain paragraph so the lawyer's
|
||||
// prose round-trips losslessly even when they hit Markdown the walker
|
||||
// doesn't yet understand.
|
||||
//
|
||||
// The output uses the base's stylemap.paragraph entry for the
|
||||
// <w:pStyle> on each paragraph so the styling matches the base's
|
||||
// typography (HLpat-Body-B0 on the HLC base, Normal on the neutral
|
||||
// base, etc.).
|
||||
//
|
||||
// Placeholders ({{path.dot.notation}}) are preserved verbatim — they
|
||||
// pass through the walker untouched and get substituted by the v1
|
||||
// SubmissionRenderer's placeholder pass after the composer assembly.
|
||||
//
|
||||
// Grammar supported:
|
||||
//
|
||||
// - Blank line → paragraph break
|
||||
// - `**bold**` → <w:r><w:rPr><w:b/></w:rPr><w:t>…</w:t></w:r>
|
||||
// - `*italic*` or `_italic_` → <w:r><w:rPr><w:i/></w:rPr>…</w:r>
|
||||
// - 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(defaultStyle)
|
||||
}
|
||||
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 _, 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()
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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 blocks []mdBlock
|
||||
var pendingPara []string
|
||||
blankRun := 0
|
||||
|
||||
flushPara := func() {
|
||||
if len(pendingPara) > 0 {
|
||||
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: strings.Join(pendingPara, "\n")})
|
||||
pendingPara = nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, raw := range lines {
|
||||
line := raw
|
||||
if strings.TrimSpace(line) == "" {
|
||||
if len(pendingPara) > 0 {
|
||||
flushPara()
|
||||
blankRun = 1
|
||||
continue
|
||||
}
|
||||
blankRun++
|
||||
continue
|
||||
}
|
||||
// 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
|
||||
pendingPara = append(pendingPara, line)
|
||||
}
|
||||
flushPara()
|
||||
return blocks
|
||||
}
|
||||
|
||||
// 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 != "" {
|
||||
b.WriteString(`<w:pPr><w:pStyle w:val="`)
|
||||
b.WriteString(xmlAttrEscape(paragraphStyle))
|
||||
b.WriteString(`"/></w:pPr>`)
|
||||
}
|
||||
if blk.text == "" {
|
||||
b.WriteString(`<w:r><w:t xml:space="preserve"></w:t></w:r>`)
|
||||
b.WriteString(`</w:p>`)
|
||||
return b.String()
|
||||
}
|
||||
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.
|
||||
type inlineSpan struct {
|
||||
Text string
|
||||
Bold bool
|
||||
Italic bool
|
||||
}
|
||||
|
||||
// parseInlineSpans tokenises Markdown inline formatting into runs of
|
||||
// (text, bold, italic). The grammar is intentionally narrow:
|
||||
//
|
||||
// - `**…**` → bold
|
||||
// - `__…__` → bold (Markdown alternate)
|
||||
// - `*…*` → italic
|
||||
// - `_…_` → italic (Markdown alternate)
|
||||
// - Anything else flows through as plain text.
|
||||
//
|
||||
// Unbalanced delimiters fall through as literal characters — the
|
||||
// walker never errors on malformed Markdown. Nested formatting (e.g.
|
||||
// `**bold *bold-italic* bold**`) toggles flags as it walks.
|
||||
func parseInlineSpans(text string) []inlineSpan {
|
||||
var out []inlineSpan
|
||||
var cur strings.Builder
|
||||
bold := false
|
||||
italic := false
|
||||
flush := func() {
|
||||
if cur.Len() == 0 {
|
||||
return
|
||||
}
|
||||
out = append(out, inlineSpan{Text: cur.String(), Bold: bold, Italic: italic})
|
||||
cur.Reset()
|
||||
}
|
||||
i := 0
|
||||
n := len(text)
|
||||
for i < n {
|
||||
// Bold delimiters first (longer match wins over italic).
|
||||
if i+1 < n && (text[i:i+2] == "**" || text[i:i+2] == "__") {
|
||||
flush()
|
||||
bold = !bold
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if text[i] == '*' || text[i] == '_' {
|
||||
flush()
|
||||
italic = !italic
|
||||
i++
|
||||
continue
|
||||
}
|
||||
cur.WriteByte(text[i])
|
||||
i++
|
||||
}
|
||||
flush()
|
||||
if len(out) == 0 {
|
||||
out = append(out, inlineSpan{Text: ""})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderRun emits one `<w:r>` element for an inline span. Empty text
|
||||
// spans render as empty runs (Word accepts them; they're harmless).
|
||||
func renderRun(span inlineSpan) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<w:r>`)
|
||||
if span.Bold || span.Italic {
|
||||
b.WriteString(`<w:rPr>`)
|
||||
if span.Bold {
|
||||
b.WriteString(`<w:b/>`)
|
||||
}
|
||||
if span.Italic {
|
||||
b.WriteString(`<w:i/>`)
|
||||
}
|
||||
b.WriteString(`</w:rPr>`)
|
||||
}
|
||||
b.WriteString(`<w:t xml:space="preserve">`)
|
||||
b.WriteString(xmlTextEscape(span.Text))
|
||||
b.WriteString(`</w:t></w:r>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// emptyParagraph returns one empty `<w:p>` with the given style. Used
|
||||
// when a section's content_md is empty so the splice site stays
|
||||
// well-formed.
|
||||
func emptyParagraph(paragraphStyle string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<w:p>`)
|
||||
if paragraphStyle != "" {
|
||||
b.WriteString(`<w:pPr><w:pStyle w:val="`)
|
||||
b.WriteString(xmlAttrEscape(paragraphStyle))
|
||||
b.WriteString(`"/></w:pPr>`)
|
||||
}
|
||||
b.WriteString(`<w:r><w:t xml:space="preserve"></w:t></w:r></w:p>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// xmlTextEscape escapes the five XML-significant characters for safe
|
||||
// insertion into <w:t> content. & first to avoid double-encoding.
|
||||
func xmlTextEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
// Quotes and apostrophes are legal inside element text content;
|
||||
// no need to escape them here.
|
||||
return s
|
||||
}
|
||||
|
||||
// xmlAttrEscape escapes for safe insertion into an attribute value
|
||||
// (e.g. `<w:pStyle w:val="…"/>`).
|
||||
func xmlAttrEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
return s
|
||||
}
|
||||
299
internal/services/submission_md_test.go
Normal file
299
internal/services/submission_md_test.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package services
|
||||
|
||||
// Unit tests for the Composer's Markdown → OOXML walker (t-paliad-313
|
||||
// Slice B). Pure function; no DB dependency.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderMarkdownToOOXML_EmptyInput(t *testing.T) {
|
||||
out := RenderMarkdownToOOXML("", "Normal")
|
||||
if !strings.Contains(out, `<w:p>`) {
|
||||
t.Errorf("empty input must still emit one <w:p>; got %q", out)
|
||||
}
|
||||
if !strings.Contains(out, `<w:pStyle w:val="Normal"/>`) {
|
||||
t.Errorf("empty input must carry the paragraph style; got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_SingleParagraph(t *testing.T) {
|
||||
out := RenderMarkdownToOOXML("Hello world", "HLpat-Body-B0")
|
||||
if !strings.Contains(out, `<w:pStyle w:val="HLpat-Body-B0"/>`) {
|
||||
t.Errorf("paragraph missing stylemap entry: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "Hello world") {
|
||||
t.Errorf("paragraph text missing: %q", out)
|
||||
}
|
||||
// Exactly one <w:p>.
|
||||
if got := strings.Count(out, "<w:p>"); got != 1 {
|
||||
t.Errorf("expected 1 <w:p>; got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_TwoParagraphs(t *testing.T) {
|
||||
out := RenderMarkdownToOOXML("first\n\nsecond", "Normal")
|
||||
if got := strings.Count(out, "<w:p>"); got != 2 {
|
||||
t.Errorf("expected 2 <w:p>; got %d, out=%q", got, out)
|
||||
}
|
||||
if !strings.Contains(out, "first") || !strings.Contains(out, "second") {
|
||||
t.Errorf("paragraph text missing: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_BoldInline(t *testing.T) {
|
||||
out := RenderMarkdownToOOXML("hello **bold** world", "")
|
||||
if !strings.Contains(out, `<w:rPr><w:b/></w:rPr>`) {
|
||||
t.Errorf("bold rPr missing: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, ">bold<") {
|
||||
t.Errorf("bold text payload missing: %q", out)
|
||||
}
|
||||
// The surrounding "hello " and " world" pieces are separate runs;
|
||||
// the bold rPr should appear exactly once in this output.
|
||||
if got := strings.Count(out, "<w:b/>"); got != 1 {
|
||||
t.Errorf("expected exactly one <w:b/> tag; got %d in %q", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_ItalicInline(t *testing.T) {
|
||||
out := RenderMarkdownToOOXML("see *italic* here", "")
|
||||
if !strings.Contains(out, `<w:rPr><w:i/></w:rPr>`) {
|
||||
t.Errorf("italic rPr missing: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, ">italic<") {
|
||||
t.Errorf("italic text payload missing: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_BoldItalicCombo(t *testing.T) {
|
||||
// Nested: ***both*** → entering both flags. The walker toggles each
|
||||
// delimiter independently, so the resulting run carries both <w:b/>
|
||||
// and <w:i/>.
|
||||
out := RenderMarkdownToOOXML("***both***", "")
|
||||
if !strings.Contains(out, `<w:b/>`) || !strings.Contains(out, `<w:i/>`) {
|
||||
t.Errorf("expected both <w:b/> and <w:i/>; got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_PlaceholdersPassThrough(t *testing.T) {
|
||||
// Placeholders are sacred — the walker must preserve them verbatim
|
||||
// so the v1 placeholder pass can substitute them later.
|
||||
out := RenderMarkdownToOOXML("Sehr geehrter {{parties.claimant.0.name}}", "Normal")
|
||||
if !strings.Contains(out, "{{parties.claimant.0.name}}") {
|
||||
t.Errorf("placeholder corrupted: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_XMLEscape(t *testing.T) {
|
||||
out := RenderMarkdownToOOXML("a & b < c > d", "")
|
||||
if strings.Contains(out, " & ") {
|
||||
t.Errorf("unescaped & survived: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "&") || !strings.Contains(out, "<") || !strings.Contains(out, ">") {
|
||||
t.Errorf("expected escaped entities; got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_BlankLinesPreserveSpacing(t *testing.T) {
|
||||
// Two blank lines between paragraphs → one empty paragraph in
|
||||
// between, preserving the lawyer's intentional whitespace.
|
||||
out := RenderMarkdownToOOXML("first\n\n\nsecond", "Normal")
|
||||
if got := strings.Count(out, "<w:p>"); got != 3 {
|
||||
t.Errorf("expected 3 <w:p> (first + blank + second); got %d in %q", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_CRLFNormalisation(t *testing.T) {
|
||||
out := RenderMarkdownToOOXML("first\r\n\r\nsecond", "")
|
||||
if got := strings.Count(out, "<w:p>"); got != 2 {
|
||||
t.Errorf("CRLF input should produce 2 paragraphs; got %d in %q", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInlineSpans_Plain(t *testing.T) {
|
||||
spans := parseInlineSpans("hello world")
|
||||
if len(spans) != 1 || spans[0].Bold || spans[0].Italic || spans[0].Text != "hello world" {
|
||||
t.Errorf("expected single plain span; got %+v", spans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInlineSpans_UnderscoreItalic(t *testing.T) {
|
||||
spans := parseInlineSpans("_emph_")
|
||||
var italicHits int
|
||||
for _, s := range spans {
|
||||
if s.Italic && s.Text == "emph" {
|
||||
italicHits++
|
||||
}
|
||||
}
|
||||
if italicHits != 1 {
|
||||
t.Errorf("expected one italic 'emph' span; got %+v", spans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInlineSpans_UnderscoreBold(t *testing.T) {
|
||||
spans := parseInlineSpans("__strong__")
|
||||
var boldHits int
|
||||
for _, s := range spans {
|
||||
if s.Bold && s.Text == "strong" {
|
||||
boldHits++
|
||||
}
|
||||
}
|
||||
if boldHits != 1 {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
337
internal/services/submission_section_service.go
Normal file
337
internal/services/submission_section_service.go
Normal file
@@ -0,0 +1,337 @@
|
||||
package services
|
||||
|
||||
// Submission section service — Composer Slice A (t-paliad-313, design
|
||||
// doc docs/design-submission-generator-v2-2026-05-26.md §4.3 + §6).
|
||||
//
|
||||
// Each row in paliad.submission_sections is one ordered, named block
|
||||
// inside a Composer draft. Slice A seeds rows on draft create from the
|
||||
// base's section_spec.defaults and exposes them read-only for the
|
||||
// editor's section-list pane. Slice B turns them editable, Slice F
|
||||
// adds reorder/hide/add-custom.
|
||||
//
|
||||
// Visibility flows through draft_id → submission_drafts → owner-scoped
|
||||
// + can_see_project (RLS in mig 148 mirrors the four-policy shape on
|
||||
// submission_drafts). Service calls go through SubmissionDraftService
|
||||
// for the visibility gate before touching this table.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// SubmissionSection mirrors a row in paliad.submission_sections.
|
||||
type SubmissionSection struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
DraftID uuid.UUID `db:"draft_id" json:"draft_id"`
|
||||
SectionKey string `db:"section_key" json:"section_key"`
|
||||
OrderIndex int `db:"order_index" json:"order_index"`
|
||||
Kind string `db:"kind" json:"kind"`
|
||||
LabelDE string `db:"label_de" json:"label_de"`
|
||||
LabelEN string `db:"label_en" json:"label_en"`
|
||||
Included bool `db:"included" json:"included"`
|
||||
ContentMDDE string `db:"content_md_de" json:"content_md_de"`
|
||||
ContentMDEN string `db:"content_md_en" json:"content_md_en"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// SectionService handles per-draft section rows. Slice A: read + seed
|
||||
// only. Editable mutations land in Slice B's brief.
|
||||
type SectionService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewSectionService wires the service.
|
||||
func NewSectionService(db *sqlx.DB) *SectionService {
|
||||
return &SectionService{db: db}
|
||||
}
|
||||
|
||||
// ErrSubmissionSectionNotFound is the sentinel for "no section with
|
||||
// that id visible to this user".
|
||||
var ErrSubmissionSectionNotFound = errors.New("submission section: not found")
|
||||
|
||||
const sectionColumns = `id, draft_id, section_key, order_index, kind,
|
||||
label_de, label_en, included,
|
||||
content_md_de, content_md_en,
|
||||
created_at, updated_at`
|
||||
|
||||
// ListForDraft returns every section row for a draft, ordered by
|
||||
// order_index ASC. Caller is responsible for the visibility gate
|
||||
// (SubmissionDraftService.Get returns ErrSubmissionDraftNotFound for
|
||||
// un-visible drafts, which the handler maps to 404). RLS in mig 148
|
||||
// additionally enforces owner-scope at the DB layer.
|
||||
func (s *SectionService) ListForDraft(ctx context.Context, draftID uuid.UUID) ([]SubmissionSection, error) {
|
||||
var rows []SubmissionSection
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+sectionColumns+`
|
||||
FROM paliad.submission_sections
|
||||
WHERE draft_id = $1
|
||||
ORDER BY order_index ASC`,
|
||||
draftID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list submission sections: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Get returns one section by id. Visibility gate is the caller's
|
||||
// responsibility — Slice A handlers wrap this with a SubmissionDraftService.Get
|
||||
// to enforce owner+can_see_project before exposing the section.
|
||||
func (s *SectionService) Get(ctx context.Context, sectionID uuid.UUID) (*SubmissionSection, error) {
|
||||
var sec SubmissionSection
|
||||
err := s.db.GetContext(ctx, &sec,
|
||||
`SELECT `+sectionColumns+`
|
||||
FROM paliad.submission_sections
|
||||
WHERE id = $1`,
|
||||
sectionID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrSubmissionSectionNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get submission section: %w", err)
|
||||
}
|
||||
return &sec, nil
|
||||
}
|
||||
|
||||
// SectionPatch carries optional fields for an Update call. nil pointer
|
||||
// = "no change"; non-nil = "set to this".
|
||||
type SectionPatch struct {
|
||||
ContentMDDE *string
|
||||
ContentMDEN *string
|
||||
Included *bool
|
||||
LabelDE *string
|
||||
LabelEN *string
|
||||
OrderIndex *int
|
||||
}
|
||||
|
||||
// Update applies a patch to one section row. Visibility is the caller's
|
||||
// responsibility — handlers wrap with SubmissionDraftService.Get for
|
||||
// owner-scoped checks. The DB-level RLS policy mirrors that check.
|
||||
//
|
||||
// Returns the refreshed row. ErrSubmissionSectionNotFound when the
|
||||
// section doesn't exist or the calling owner can't see it (RLS
|
||||
// filters at the SELECT step).
|
||||
func (s *SectionService) Update(ctx context.Context, sectionID uuid.UUID, patch SectionPatch) (*SubmissionSection, error) {
|
||||
setParts := []string{}
|
||||
args := []any{}
|
||||
idx := 1
|
||||
|
||||
if patch.ContentMDDE != nil {
|
||||
setParts = append(setParts, fmt.Sprintf("content_md_de = $%d", idx))
|
||||
args = append(args, *patch.ContentMDDE)
|
||||
idx++
|
||||
}
|
||||
if patch.ContentMDEN != nil {
|
||||
setParts = append(setParts, fmt.Sprintf("content_md_en = $%d", idx))
|
||||
args = append(args, *patch.ContentMDEN)
|
||||
idx++
|
||||
}
|
||||
if patch.Included != nil {
|
||||
setParts = append(setParts, fmt.Sprintf("included = $%d", idx))
|
||||
args = append(args, *patch.Included)
|
||||
idx++
|
||||
}
|
||||
if patch.LabelDE != nil {
|
||||
setParts = append(setParts, fmt.Sprintf("label_de = $%d", idx))
|
||||
args = append(args, *patch.LabelDE)
|
||||
idx++
|
||||
}
|
||||
if patch.LabelEN != nil {
|
||||
setParts = append(setParts, fmt.Sprintf("label_en = $%d", idx))
|
||||
args = append(args, *patch.LabelEN)
|
||||
idx++
|
||||
}
|
||||
if patch.OrderIndex != nil {
|
||||
setParts = append(setParts, fmt.Sprintf("order_index = $%d", idx))
|
||||
args = append(args, *patch.OrderIndex)
|
||||
idx++
|
||||
}
|
||||
|
||||
if len(setParts) == 0 {
|
||||
return s.Get(ctx, sectionID)
|
||||
}
|
||||
|
||||
args = append(args, sectionID)
|
||||
q := fmt.Sprintf(
|
||||
`UPDATE paliad.submission_sections
|
||||
SET %s
|
||||
WHERE id = $%d
|
||||
RETURNING `+sectionColumns,
|
||||
strings.Join(setParts, ", "), idx,
|
||||
)
|
||||
|
||||
var sec SubmissionSection
|
||||
err := s.db.GetContext(ctx, &sec, q, args...)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrSubmissionSectionNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update submission section: %w", err)
|
||||
}
|
||||
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
|
||||
// draft INSERT + section seed in one tx so a failed seed rolls back
|
||||
// the draft too).
|
||||
//
|
||||
// Idempotent at the row level — UNIQUE (draft_id, section_key) returns
|
||||
// an error if the seed runs twice for the same draft, which is the
|
||||
// desired safety net (we never want to silently double-seed).
|
||||
//
|
||||
// Per the Q10 ratification: every kind is one of prose | requests |
|
||||
// evidence — there is no *_auto kind. Caption/letterhead/signature
|
||||
// sections are regular prose rows seeded with bag-driven Markdown.
|
||||
func (s *SectionService) SeedFromSpec(ctx context.Context, tx *sqlx.Tx, draftID uuid.UUID, spec BaseSectionSpec) error {
|
||||
if len(spec.Defaults) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, d := range spec.Defaults {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`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)`,
|
||||
draftID, d.SectionKey, d.OrderIndex, d.Kind,
|
||||
d.LabelDE, d.LabelEN, d.Included,
|
||||
d.SeedMDDE, d.SeedMDEN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seed submission section %s: %w", d.SectionKey, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
178
internal/services/submission_section_service_live_test.go
Normal file
178
internal/services/submission_section_service_live_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package services
|
||||
|
||||
// Live-DB integration tests for the Composer seeding flow (t-paliad-313
|
||||
// Slice A). Skipped when TEST_DATABASE_URL is unset, mirroring the
|
||||
// other live-DB tests (see cansee_test.go for the bootstrap pattern).
|
||||
//
|
||||
// Covers:
|
||||
// 1. Mig 146 seeded the catalog: hlc-letterhead + neutral both
|
||||
// resolve via GetBySlug and carry 10 section defaults each.
|
||||
// 2. BaseService.GetDefaultForCode picks the firm-matched base for a
|
||||
// canonical submission_code (e.g. de.inf.lg.erwidg) — Slice A
|
||||
// contract that drives new-draft seeding.
|
||||
// 3. SubmissionDraftService.Create on a fresh draft seeds base_id +
|
||||
// 10 submission_sections rows in one transaction, with order_index
|
||||
// ascending and bilingual labels populated.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
func TestComposerSeedFlow(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)
|
||||
|
||||
t.Run("seed catalog: hlc-letterhead has 10 default sections", func(t *testing.T) {
|
||||
b, err := bases.GetBySlug(ctx, "hlc-letterhead")
|
||||
if err != nil {
|
||||
t.Fatalf("GetBySlug(hlc-letterhead): %v", err)
|
||||
}
|
||||
if got := len(b.SectionSpec.Defaults); got != 10 {
|
||||
t.Errorf("len(Defaults) = %d; want 10", got)
|
||||
}
|
||||
if b.SectionSpec.Stylemap["heading_1"] != "HLpat-Heading-H1" {
|
||||
t.Errorf("Stylemap[heading_1] = %q; want HLpat-Heading-H1", b.SectionSpec.Stylemap["heading_1"])
|
||||
}
|
||||
// Verify the section order is strictly ascending.
|
||||
prev := 0
|
||||
for _, d := range b.SectionSpec.Defaults {
|
||||
if d.OrderIndex <= prev {
|
||||
t.Errorf("non-ascending order_index: %d (prev=%d) at %s", d.OrderIndex, prev, d.SectionKey)
|
||||
}
|
||||
prev = d.OrderIndex
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("seed catalog: neutral exists with universal stylemap", func(t *testing.T) {
|
||||
b, err := bases.GetBySlug(ctx, "neutral")
|
||||
if err != nil {
|
||||
t.Fatalf("GetBySlug(neutral): %v", err)
|
||||
}
|
||||
if b.SectionSpec.Stylemap["heading_1"] != "Heading 1" {
|
||||
t.Errorf("neutral Stylemap[heading_1] = %q; want \"Heading 1\"", b.SectionSpec.Stylemap["heading_1"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetDefaultForCode firm match", func(t *testing.T) {
|
||||
// HLC + de.inf.lg.erwidg → hlc-letterhead (firm-matched).
|
||||
b, err := bases.GetDefaultForCode(ctx, "HLC", "de.inf.lg.erwidg")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDefaultForCode HLC: %v", err)
|
||||
}
|
||||
if b.Slug != "hlc-letterhead" {
|
||||
t.Errorf("Slug = %q; want hlc-letterhead", b.Slug)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetDefaultForCode falls back to neutral when no firm hint", func(t *testing.T) {
|
||||
b, err := bases.GetDefaultForCode(ctx, "", "de.inf.lg.erwidg")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDefaultForCode no-firm: %v", err)
|
||||
}
|
||||
// Without a firm hint, the fallback chain skips firm-matched
|
||||
// queries and lands on the firm-NULL neutral base.
|
||||
if b.Slug != "neutral" {
|
||||
t.Errorf("Slug = %q; want neutral (firm-NULL fallback)", b.Slug)
|
||||
}
|
||||
})
|
||||
|
||||
// Section seeding via SubmissionDraftService.Create — exercises the
|
||||
// transactional INSERT path. Requires a real auth.users + paliad.users
|
||||
// row because submission_drafts.user_id is FK-constrained.
|
||||
t.Run("SubmissionDraftService.Create seeds 10 section rows", func(t *testing.T) {
|
||||
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 := "composer-seed-" + 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, 'Composer Seed', '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)
|
||||
sections := NewSectionService(pool)
|
||||
drafts.AttachComposer(bases, sections, "HLC")
|
||||
|
||||
d, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if d.BaseID == nil {
|
||||
t.Fatalf("BaseID = nil; want seeded base reference")
|
||||
}
|
||||
// hlc-letterhead is the firm default for HLC.
|
||||
base, _ := bases.GetByID(ctx, *d.BaseID)
|
||||
if base == nil || base.Slug != "hlc-letterhead" {
|
||||
t.Errorf("seeded base slug = %v; want hlc-letterhead", base)
|
||||
}
|
||||
|
||||
secs, err := sections.ListForDraft(ctx, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListForDraft: %v", err)
|
||||
}
|
||||
if len(secs) != 10 {
|
||||
t.Errorf("section count = %d; want 10", len(secs))
|
||||
}
|
||||
// Verify section_key set + bilingual labels populated.
|
||||
wantKeys := map[string]bool{
|
||||
"letterhead": false, "caption": false, "introduction": false,
|
||||
"requests": false, "facts": false, "legal_argument": false,
|
||||
"evidence": false, "exhibits": false, "closing": false, "signature": false,
|
||||
}
|
||||
prev := 0
|
||||
for _, sec := range secs {
|
||||
wantKeys[sec.SectionKey] = true
|
||||
if sec.OrderIndex <= prev {
|
||||
t.Errorf("non-ascending order_index: %d (prev=%d) at %s", sec.OrderIndex, prev, sec.SectionKey)
|
||||
}
|
||||
prev = sec.OrderIndex
|
||||
if sec.LabelDE == "" || sec.LabelEN == "" {
|
||||
t.Errorf("section %s missing bilingual label: de=%q en=%q", sec.SectionKey, sec.LabelDE, sec.LabelEN)
|
||||
}
|
||||
}
|
||||
for k, seen := range wantKeys {
|
||||
if !seen {
|
||||
t.Errorf("missing seeded section_key: %s", k)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -243,7 +243,7 @@ func (s *SubmissionVarsService) loadPublishedRule(ctx context.Context, submissio
|
||||
var rule models.DeadlineRule
|
||||
err := s.db.GetContext(ctx, &rule,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
FROM paliad.deadline_rules_unified
|
||||
WHERE submission_code = $1
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true
|
||||
@@ -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)
|
||||
|
||||
58
pkg/litigationplanner/appeal_role.go
Normal file
58
pkg/litigationplanner/appeal_role.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package litigationplanner
|
||||
|
||||
// AppealRole* are the canonical filer-role slugs used by the unified
|
||||
// upc.apl Berufung proceeding (t-paliad-307 / m/paliad#136 Bug 1).
|
||||
//
|
||||
// Every appeal filing rule carries primary_party='both' in the catalog
|
||||
// (either party could be the appellant, depending on which side lost
|
||||
// downstream), so the static primary_party column can't drive
|
||||
// column-bucketing under a user-perspective `?side=` pick. The
|
||||
// per-rule appeal role fills that gap: "appellant" rules are filed by
|
||||
// the Berufungskläger (the party who lost in the lower instance and
|
||||
// is now appealing); "appellee" rules are filed by the
|
||||
// Berufungsbeklagter (the party defending the lower-instance
|
||||
// decision). The mapping is rule-semantic, not data-driven — we know
|
||||
// from R.224/235 which submission belongs to which side.
|
||||
const (
|
||||
AppealRoleAppellant = "appellant"
|
||||
AppealRoleAppellee = "appellee"
|
||||
)
|
||||
|
||||
// AppealFilerRole returns the appeal-filer role for a submission code
|
||||
// in the unified upc.apl proceeding. Empty string for codes whose role
|
||||
// is not statically known (court-issued events, unmapped codes, or
|
||||
// non-appeal proceedings).
|
||||
//
|
||||
// The engine stamps TimelineEntry.AppealRole with this value when
|
||||
// CalcOptions.AppealTarget is set so the frontend column-bucketer can
|
||||
// route each "both"-party rule into the correct user-perspective
|
||||
// column (Berufungskläger vs Berufungsbeklagter) once the user picks
|
||||
// a side.
|
||||
//
|
||||
// Adding a new appeal rule? Add its submission_code to the matching
|
||||
// branch below. Court-issued events (cost.decision, order.order,
|
||||
// merits.oral, merits.decision) deliberately stay empty — they route
|
||||
// to the court column on primary_party='court'.
|
||||
func AppealFilerRole(submissionCode string) string {
|
||||
switch submissionCode {
|
||||
// Appellant filings — Berufungskläger initiates the appeal +
|
||||
// replies to the cross-appeal.
|
||||
case "upc.apl.merits.notice",
|
||||
"upc.apl.merits.grounds",
|
||||
"upc.apl.merits.cross_a_reply",
|
||||
"upc.apl.cost.leave_app",
|
||||
"upc.apl.order.with_leave",
|
||||
"upc.apl.order.grounds_orders",
|
||||
"upc.apl.order.discretion",
|
||||
"upc.apl.order.cross_reply":
|
||||
return AppealRoleAppellant
|
||||
// Appellee filings — Berufungsbeklagter responds to the appeal +
|
||||
// files the cross-appeal.
|
||||
case "upc.apl.merits.response",
|
||||
"upc.apl.merits.cross_a",
|
||||
"upc.apl.order.response_orders",
|
||||
"upc.apl.order.cross":
|
||||
return AppealRoleAppellee
|
||||
}
|
||||
return ""
|
||||
}
|
||||
192
pkg/litigationplanner/appeal_role_test.go
Normal file
192
pkg/litigationplanner/appeal_role_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestAppealFilerRole pins the rule-semantic mapping that drives
|
||||
// column-bucketing on the unified upc.apl Berufung timeline
|
||||
// (t-paliad-307 / m/paliad#136 Bug 1). Every appeal filing rule has
|
||||
// primary_party='both' in the catalog so the bucketer can't decide
|
||||
// between Berufungskläger and Berufungsbeklagter columns from
|
||||
// primary_party alone — the appeal role fills that gap.
|
||||
func TestAppealFilerRole(t *testing.T) {
|
||||
cases := []struct {
|
||||
code string
|
||||
want string
|
||||
}{
|
||||
// Appellant filings (Berufungskläger initiates / replies to cross).
|
||||
{"upc.apl.merits.notice", AppealRoleAppellant},
|
||||
{"upc.apl.merits.grounds", AppealRoleAppellant},
|
||||
{"upc.apl.merits.cross_a_reply", AppealRoleAppellant},
|
||||
{"upc.apl.cost.leave_app", AppealRoleAppellant},
|
||||
{"upc.apl.order.with_leave", AppealRoleAppellant},
|
||||
{"upc.apl.order.grounds_orders", AppealRoleAppellant},
|
||||
{"upc.apl.order.discretion", AppealRoleAppellant},
|
||||
{"upc.apl.order.cross_reply", AppealRoleAppellant},
|
||||
// Appellee filings (Berufungsbeklagter responds + cross-appeals).
|
||||
{"upc.apl.merits.response", AppealRoleAppellee},
|
||||
{"upc.apl.merits.cross_a", AppealRoleAppellee},
|
||||
{"upc.apl.order.response_orders", AppealRoleAppellee},
|
||||
{"upc.apl.order.cross", AppealRoleAppellee},
|
||||
// Court-issued events stay empty — they route on party='court'.
|
||||
{"upc.apl.merits.decision", ""},
|
||||
{"upc.apl.merits.oral", ""},
|
||||
{"upc.apl.cost.decision", ""},
|
||||
{"upc.apl.order.order", ""},
|
||||
// Unmapped codes are empty (defensive — never silently picks a
|
||||
// side for a new appeal rule we forgot to map).
|
||||
{"upc.inf.cfi.soc", ""},
|
||||
{"", ""},
|
||||
{"foo.bar", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := AppealFilerRole(c.code); got != c.want {
|
||||
t.Errorf("AppealFilerRole(%q) = %q, want %q", c.code, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculate_AppealSyntheticTriggerRow exercises the synthetic root
|
||||
// row the engine prepends when CalcOptions.AppealTarget is set
|
||||
// (t-paliad-307 / m/paliad#136 Bug 2). The row carries the
|
||||
// per-appeal-target label, the trigger date as DueDate, IsRootEvent=
|
||||
// IsTriggerEvent=true, and party=court. Without the appeal_target
|
||||
// filter, no synthetic row is emitted (regression guard).
|
||||
func TestCalculate_AppealSyntheticTriggerRow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
jurisdiction := "UPC"
|
||||
procID := 1
|
||||
pt := ProceedingType{
|
||||
ID: procID,
|
||||
Code: "upc.apl.unified",
|
||||
Name: "Berufung",
|
||||
NameEN: "Appeal",
|
||||
Jurisdiction: &jurisdiction,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
mkID := func() uuid.UUID {
|
||||
id, _ := uuid.NewRandom()
|
||||
return id
|
||||
}
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &procID
|
||||
|
||||
noticeCode := "upc.apl.merits.notice"
|
||||
groundsCode := "upc.apl.merits.grounds"
|
||||
|
||||
rules := []Rule{
|
||||
{
|
||||
ID: mkID(),
|
||||
ProceedingTypeID: procIDPtr,
|
||||
SubmissionCode: ¬iceCode,
|
||||
Name: "Berufungseinlegung",
|
||||
NameEN: "Notice of Appeal",
|
||||
PrimaryParty: str(PrimaryPartyBoth),
|
||||
DurationValue: 2,
|
||||
DurationUnit: "months",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 0,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
AppliesToTarget: []string{AppealTargetEndentscheidung, AppealTargetSchadensbemessung},
|
||||
},
|
||||
{
|
||||
ID: mkID(),
|
||||
ProceedingTypeID: procIDPtr,
|
||||
SubmissionCode: &groundsCode,
|
||||
Name: "Berufungsbegründung",
|
||||
NameEN: "Statement of Grounds",
|
||||
PrimaryParty: str(PrimaryPartyBoth),
|
||||
DurationValue: 4,
|
||||
DurationUnit: "months",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 1,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
AppliesToTarget: []string{AppealTargetEndentscheidung, AppealTargetSchadensbemessung},
|
||||
},
|
||||
}
|
||||
|
||||
cat := &stubCatalog{pt: pt, rules: rules}
|
||||
|
||||
t.Run("with appeal_target — synthetic row prepended + appeal_role stamped", func(t *testing.T) {
|
||||
opts := CalcOptions{AppealTarget: AppealTargetEndentscheidung}
|
||||
timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
if len(timeline.Deadlines) < 3 {
|
||||
t.Fatalf("expected synthetic row + 2 rules, got %d rows", len(timeline.Deadlines))
|
||||
}
|
||||
// Synthetic row first.
|
||||
first := timeline.Deadlines[0]
|
||||
if !first.IsTriggerEvent {
|
||||
t.Errorf("first row IsTriggerEvent=%v, want true", first.IsTriggerEvent)
|
||||
}
|
||||
if !first.IsRootEvent {
|
||||
t.Errorf("first row IsRootEvent=%v, want true", first.IsRootEvent)
|
||||
}
|
||||
if first.Name != "Endentscheidung (R.118)" {
|
||||
t.Errorf("first row Name=%q, want %q", first.Name, "Endentscheidung (R.118)")
|
||||
}
|
||||
if first.NameEN != "Final decision (R.118)" {
|
||||
t.Errorf("first row NameEN=%q, want %q", first.NameEN, "Final decision (R.118)")
|
||||
}
|
||||
if first.DueDate != "2026-05-26" {
|
||||
t.Errorf("first row DueDate=%q, want 2026-05-26", first.DueDate)
|
||||
}
|
||||
if first.Party != PrimaryPartyCourt {
|
||||
t.Errorf("first row Party=%q, want court", first.Party)
|
||||
}
|
||||
// Real rules should carry AppealRole.
|
||||
byCode := map[string]TimelineEntry{}
|
||||
for _, d := range timeline.Deadlines {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
if got := byCode[noticeCode].AppealRole; got != AppealRoleAppellant {
|
||||
t.Errorf("notice AppealRole=%q, want appellant", got)
|
||||
}
|
||||
if got := byCode[groundsCode].AppealRole; got != AppealRoleAppellant {
|
||||
t.Errorf("grounds AppealRole=%q, want appellant", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without appeal_target — no synthetic row, no appeal_role", func(t *testing.T) {
|
||||
opts := CalcOptions{}
|
||||
timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
for _, d := range timeline.Deadlines {
|
||||
if d.IsTriggerEvent {
|
||||
t.Errorf("unexpected synthetic trigger row when appeal_target is unset: %+v", d)
|
||||
}
|
||||
if d.AppealRole != "" {
|
||||
t.Errorf("unexpected AppealRole=%q when appeal_target is unset (rule %q)", d.AppealRole, d.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown appeal_target — short-circuits to no-op", func(t *testing.T) {
|
||||
opts := CalcOptions{AppealTarget: "bogus"}
|
||||
timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
// IsValidAppealTarget("bogus") = false, so the engine skips
|
||||
// both the rule filter AND the synthetic trigger emission.
|
||||
for _, d := range timeline.Deadlines {
|
||||
if d.IsTriggerEvent {
|
||||
t.Errorf("unexpected synthetic trigger row for unknown target: %+v", d)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
328
pkg/litigationplanner/before_court_set_anchor_test.go
Normal file
328
pkg/litigationplanner/before_court_set_anchor_test.go
Normal file
@@ -0,0 +1,328 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Regression test for t-paliad-304 / m/paliad#135.
|
||||
//
|
||||
// Reproduces the R.109.1 / R.109.4 anchor bug on upc.inf.cfi:
|
||||
// - Trigger (Klageerhebung): parent_id=nil, duration=0, !IsCourtSet, sequence_order=0
|
||||
// - Translation request: parent_id=oral, duration=1mo before, sequence_order=45
|
||||
// - Interpreter cost: parent_id=oral, duration=2w before, sequence_order=46
|
||||
// - Oral hearing: parent_id=nil, duration=0, IsCourtSet, sequence_order=50
|
||||
//
|
||||
// The "before" children are listed BEFORE the oral hearing in sequence
|
||||
// order (because chronologically they happen before it). The engine walks
|
||||
// rules in sequence_order, so when it processes the translation/
|
||||
// interpreter rows, the oral hearing has not yet been processed →
|
||||
// courtSet[oral.ID] is not yet set → parentIsCourtSet is false → the
|
||||
// engine falls back to the trigger date as the base. Result: the timing=
|
||||
// 'before' arithmetic produces 27.04.2026 (1mo before SoC) instead of
|
||||
// the conditional-no-date treatment that a court-set parent should
|
||||
// trigger.
|
||||
//
|
||||
// Expected post-fix: translation_request + interpreter_cost render as
|
||||
// IsConditional (no concrete date) because their parent's date is
|
||||
// court-set and the proceeding does not yet have an explicit override.
|
||||
|
||||
// stubCatalog implements lp.Catalog backed by an in-memory rule slice.
|
||||
// Only LoadProceeding is needed for the engine path under test; the
|
||||
// other interface methods return errors so an unintended call surfaces
|
||||
// immediately.
|
||||
type stubCatalog struct {
|
||||
pt ProceedingType
|
||||
rules []Rule
|
||||
}
|
||||
|
||||
func (s *stubCatalog) LoadProceeding(_ context.Context, code string, _ ProjectHint) (*ProceedingType, []Rule, error) {
|
||||
if code != s.pt.Code {
|
||||
return nil, nil, ErrUnknownProceedingType
|
||||
}
|
||||
rules := make([]Rule, len(s.rules))
|
||||
copy(rules, s.rules)
|
||||
pt := s.pt
|
||||
return &pt, rules, nil
|
||||
}
|
||||
func (s *stubCatalog) LoadProceedingByID(_ context.Context, _ int) (*ProceedingType, error) {
|
||||
return nil, errors.New("stubCatalog.LoadProceedingByID: not implemented")
|
||||
}
|
||||
func (s *stubCatalog) LoadRuleByID(_ context.Context, _ string) (*Rule, error) {
|
||||
return nil, errors.New("stubCatalog.LoadRuleByID: not implemented")
|
||||
}
|
||||
func (s *stubCatalog) LoadRuleByCode(_ context.Context, _, _ string) (*Rule, *ProceedingType, error) {
|
||||
return nil, nil, errors.New("stubCatalog.LoadRuleByCode: not implemented")
|
||||
}
|
||||
func (s *stubCatalog) LoadRulesByTriggerEvent(_ context.Context, _ int64) ([]Rule, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubCatalog) LoadTriggerEventsByIDs(_ context.Context, _ []int64) (map[int64]TriggerEvent, error) {
|
||||
return map[int64]TriggerEvent{}, nil
|
||||
}
|
||||
func (s *stubCatalog) LookupEvents(_ context.Context, _ EventLookupAxes, _ EventLookupDepth) ([]EventMatch, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubCatalog) LoadScenarios(_ context.Context, _ ScenarioFilter) ([]Scenario, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubCatalog) MatchScenario(_ context.Context, _ uuid.UUID) (*Scenario, error) {
|
||||
return nil, ErrUnknownScenario
|
||||
}
|
||||
|
||||
// noOpHolidays never adjusts dates — the test fixture doesn't care about
|
||||
// weekends or holidays, only about which base date the engine resolves.
|
||||
type noOpHolidays struct{}
|
||||
|
||||
func (noOpHolidays) IsNonWorkingDay(_ time.Time, _, _ string) bool { return false }
|
||||
func (noOpHolidays) AdjustForNonWorkingDays(d time.Time, _, _ string) (time.Time, time.Time, bool) {
|
||||
return d, d, false
|
||||
}
|
||||
func (noOpHolidays) AdjustForNonWorkingDaysBackward(d time.Time, _, _ string) (time.Time, time.Time, bool) {
|
||||
return d, d, false
|
||||
}
|
||||
func (noOpHolidays) AdjustForNonWorkingDaysWithReason(d time.Time, _, _ string) (time.Time, time.Time, bool, *AdjustmentReason) {
|
||||
return d, d, false, nil
|
||||
}
|
||||
|
||||
type fixedCourts struct{}
|
||||
|
||||
func (fixedCourts) CountryRegime(_, _, _ string) (string, string, error) {
|
||||
return CountryDE, RegimeUPC, nil
|
||||
}
|
||||
|
||||
func TestCalculate_BeforeChildOfCourtSetParent_OutOfOrderSequence(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// proceeding metadata
|
||||
jurisdiction := "UPC"
|
||||
procID := 1
|
||||
pt := ProceedingType{
|
||||
ID: procID,
|
||||
Code: "upc.inf.cfi",
|
||||
Name: "Verletzungsverfahren",
|
||||
NameEN: "Infringement",
|
||||
Jurisdiction: &jurisdiction,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
mkID := func() uuid.UUID {
|
||||
id, _ := uuid.NewRandom()
|
||||
return id
|
||||
}
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &procID
|
||||
|
||||
socID := mkID()
|
||||
oralID := mkID()
|
||||
transID := mkID()
|
||||
interpID := mkID()
|
||||
|
||||
socCode := "upc.inf.cfi.soc"
|
||||
oralCode := "upc.inf.cfi.oral"
|
||||
transCode := "upc.inf.cfi.translation_request"
|
||||
interpCode := "upc.inf.cfi.interpreter_cost"
|
||||
|
||||
rules := []Rule{
|
||||
{
|
||||
ID: socID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: nil,
|
||||
SubmissionCode: &socCode,
|
||||
Name: "Klageerhebung",
|
||||
NameEN: "Statement of Claim",
|
||||
PrimaryParty: str("claimant"),
|
||||
DurationValue: 0,
|
||||
DurationUnit: "months",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 0,
|
||||
IsCourtSet: false,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
},
|
||||
// Translation request: sequence_order BEFORE the oral hearing.
|
||||
// Reproduces the real corpus ordering (DB rows 45 < 50).
|
||||
{
|
||||
ID: transID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: &oralID,
|
||||
SubmissionCode: &transCode,
|
||||
Name: "Antrag auf Simultanübersetzung",
|
||||
NameEN: "Translation request",
|
||||
PrimaryParty: str("both"),
|
||||
DurationValue: 1,
|
||||
DurationUnit: "months",
|
||||
Timing: str("before"),
|
||||
SequenceOrder: 45,
|
||||
IsCourtSet: false,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "optional",
|
||||
},
|
||||
// Interpreter cost notice: sequence_order BEFORE the oral hearing.
|
||||
{
|
||||
ID: interpID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: &oralID,
|
||||
SubmissionCode: &interpCode,
|
||||
Name: "Mitteilung Dolmetscherkosten",
|
||||
NameEN: "Interpreter cost notice",
|
||||
PrimaryParty: str("court"),
|
||||
DurationValue: 2,
|
||||
DurationUnit: "weeks",
|
||||
Timing: str("before"),
|
||||
SequenceOrder: 46,
|
||||
IsCourtSet: false,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
},
|
||||
// Oral hearing: court-set, no calculable date. Listed AFTER its
|
||||
// "before"-timed children in sequence_order.
|
||||
{
|
||||
ID: oralID,
|
||||
ProceedingTypeID: procIDPtr,
|
||||
ParentID: nil,
|
||||
SubmissionCode: &oralCode,
|
||||
Name: "Mündliche Verhandlung",
|
||||
NameEN: "Oral hearing",
|
||||
PrimaryParty: str("court"),
|
||||
DurationValue: 0,
|
||||
DurationUnit: "months",
|
||||
Timing: str("after"),
|
||||
SequenceOrder: 50,
|
||||
IsCourtSet: true,
|
||||
IsActive: true,
|
||||
LifecycleState: "published",
|
||||
Priority: "mandatory",
|
||||
},
|
||||
}
|
||||
|
||||
cat := &stubCatalog{pt: pt, rules: rules}
|
||||
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
|
||||
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
|
||||
for _, d := range timeline.Deadlines {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
|
||||
// The trigger event itself is unambiguous.
|
||||
if got := byCode[socCode]; got.DueDate != "2026-05-26" || !got.IsRootEvent {
|
||||
t.Errorf("SoC: DueDate=%q IsRootEvent=%v, want 2026-05-26 + IsRootEvent=true", got.DueDate, got.IsRootEvent)
|
||||
}
|
||||
|
||||
// Oral hearing must surface as IsCourtSet (no date).
|
||||
oral := byCode[oralCode]
|
||||
if oral.DueDate != "" || !oral.IsCourtSet {
|
||||
t.Errorf("oral: DueDate=%q IsCourtSet=%v, want empty + IsCourtSet=true", oral.DueDate, oral.IsCourtSet)
|
||||
}
|
||||
|
||||
// The two "before" children of the court-set oral hearing MUST surface
|
||||
// as conditional rows (no date, no fabricated arithmetic off the
|
||||
// trigger date). The buggy behaviour produces 2026-04-27 and 2026-05-12.
|
||||
trans := byCode[transCode]
|
||||
if trans.DueDate != "" {
|
||||
t.Errorf("translation_request: DueDate=%q, want empty (parent oral is court-set, no anchor known yet)", trans.DueDate)
|
||||
}
|
||||
if !trans.IsConditional && !trans.IsCourtSet {
|
||||
t.Errorf("translation_request: IsConditional=%v IsCourtSet=%v, want at least one true", trans.IsConditional, trans.IsCourtSet)
|
||||
}
|
||||
|
||||
interp := byCode[interpCode]
|
||||
if interp.DueDate != "" {
|
||||
t.Errorf("interpreter_cost: DueDate=%q, want empty (parent oral is court-set, no anchor known yet)", interp.DueDate)
|
||||
}
|
||||
if !interp.IsConditional && !interp.IsCourtSet {
|
||||
t.Errorf("interpreter_cost: IsConditional=%v IsCourtSet=%v, want at least one true", interp.IsConditional, interp.IsCourtSet)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculate_BeforeChildOfCourtSetParent_WithOverride pins the
|
||||
// override semantics: when the user supplies an anchor override for
|
||||
// the court-set parent, the "before" children should compute against
|
||||
// that override date instead of remaining conditional.
|
||||
func TestCalculate_BeforeChildOfCourtSetParent_WithOverride(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
jurisdiction := "UPC"
|
||||
procID := 1
|
||||
pt := ProceedingType{
|
||||
ID: procID,
|
||||
Code: "upc.inf.cfi",
|
||||
Name: "Verletzungsverfahren",
|
||||
Jurisdiction: &jurisdiction,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
mkID := func() uuid.UUID {
|
||||
id, _ := uuid.NewRandom()
|
||||
return id
|
||||
}
|
||||
str := func(s string) *string { return &s }
|
||||
procIDPtr := &procID
|
||||
|
||||
socID := mkID()
|
||||
oralID := mkID()
|
||||
transID := mkID()
|
||||
|
||||
socCode := "upc.inf.cfi.soc"
|
||||
oralCode := "upc.inf.cfi.oral"
|
||||
transCode := "upc.inf.cfi.translation_request"
|
||||
|
||||
rules := []Rule{
|
||||
{
|
||||
ID: socID, ProceedingTypeID: procIDPtr, ParentID: nil,
|
||||
SubmissionCode: &socCode, Name: "Klageerhebung", NameEN: "SoC",
|
||||
PrimaryParty: str("claimant"), DurationValue: 0, DurationUnit: "months", Timing: str("after"),
|
||||
SequenceOrder: 0, IsActive: true, LifecycleState: "published", Priority: "mandatory",
|
||||
},
|
||||
{
|
||||
ID: transID, ProceedingTypeID: procIDPtr, ParentID: &oralID,
|
||||
SubmissionCode: &transCode, Name: "Antrag auf Simultanübersetzung", NameEN: "Translation request",
|
||||
PrimaryParty: str("both"), DurationValue: 1, DurationUnit: "months", Timing: str("before"),
|
||||
SequenceOrder: 45, IsActive: true, LifecycleState: "published", Priority: "optional",
|
||||
},
|
||||
{
|
||||
ID: oralID, ProceedingTypeID: procIDPtr, ParentID: nil,
|
||||
SubmissionCode: &oralCode, Name: "Mündliche Verhandlung", NameEN: "Oral hearing",
|
||||
PrimaryParty: str("court"), DurationValue: 0, DurationUnit: "months", Timing: str("after"),
|
||||
SequenceOrder: 50, IsCourtSet: true, IsActive: true, LifecycleState: "published", Priority: "mandatory",
|
||||
},
|
||||
}
|
||||
|
||||
cat := &stubCatalog{pt: pt, rules: rules}
|
||||
|
||||
// User pins the oral hearing to 2026-10-15.
|
||||
opts := CalcOptions{
|
||||
AnchorOverrides: map[string]string{
|
||||
oralCode: "2026-10-15",
|
||||
},
|
||||
}
|
||||
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Calculate: %v", err)
|
||||
}
|
||||
|
||||
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
|
||||
for _, d := range timeline.Deadlines {
|
||||
byCode[d.Code] = d
|
||||
}
|
||||
|
||||
if got := byCode[oralCode].DueDate; got != "2026-10-15" {
|
||||
t.Errorf("oral: DueDate=%q, want 2026-10-15 (user override)", got)
|
||||
}
|
||||
|
||||
// 1 month before 2026-10-15 = 2026-09-15
|
||||
if got := byCode[transCode].DueDate; got != "2026-09-15" {
|
||||
t.Errorf("translation_request: DueDate=%q, want 2026-09-15 (1 month before oral override)", got)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package litigationplanner
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Catalog supplies proceeding-type metadata + rules for the calculator.
|
||||
//
|
||||
@@ -59,4 +63,17 @@ type Catalog interface {
|
||||
// (proceeding_type_id, sequence_order) so the frontend can render
|
||||
// without re-sorting.
|
||||
LookupEvents(ctx context.Context, axes EventLookupAxes, depth EventLookupDepth) ([]EventMatch, error)
|
||||
|
||||
// LoadScenarios lists scenarios visible to the caller, narrowed by
|
||||
// the filter (Slice D, m/paliad#124 §5). Returns an empty slice
|
||||
// (NOT an error) when no scenarios match. paliad-side impl applies
|
||||
// RLS (paliad.can_see_project for project-scoped, created_by for
|
||||
// abstract); snapshot-backed catalogs return an empty list.
|
||||
LoadScenarios(ctx context.Context, filter ScenarioFilter) ([]Scenario, error)
|
||||
|
||||
// MatchScenario returns the scenario with the given id, or
|
||||
// ErrUnknownScenario if not found / not visible. The engine adapter
|
||||
// (CalculateFromScenario) calls this to fetch a scenario by id and
|
||||
// then unpacks its spec via ParseSpec.
|
||||
MatchScenario(ctx context.Context, id uuid.UUID) (*Scenario, error)
|
||||
}
|
||||
|
||||
@@ -292,6 +292,20 @@ func (c *SnapshotCatalog) LookupEvents(_ context.Context, axes lp.EventLookupAxe
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LoadScenarios returns an empty slice. The snapshot catalog has no
|
||||
// scenarios — youpc.org (the consumer today) doesn't carry a project /
|
||||
// user model. Future snapshot variants could ship demo scenarios, but
|
||||
// v1 returns nothing.
|
||||
func (c *SnapshotCatalog) LoadScenarios(_ context.Context, _ lp.ScenarioFilter) ([]lp.Scenario, error) {
|
||||
return []lp.Scenario{}, nil
|
||||
}
|
||||
|
||||
// MatchScenario always returns ErrUnknownScenario — the snapshot has
|
||||
// no scenarios to match against.
|
||||
func (c *SnapshotCatalog) MatchScenario(_ context.Context, _ uuid.UUID) (*lp.Scenario, error) {
|
||||
return nil, lp.ErrUnknownScenario
|
||||
}
|
||||
|
||||
// Compile-time assertion that SnapshotCatalog satisfies lp.Catalog.
|
||||
var _ lp.Catalog = (*SnapshotCatalog)(nil)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -183,10 +184,27 @@ func Calculate(
|
||||
return nil, fmt.Errorf("load trigger events for conditional labels: %w", err)
|
||||
}
|
||||
|
||||
// Walk the rule list in sequence_order (already sorted by the
|
||||
// catalog query) and compute each entry, keeping a code→date map so
|
||||
// RelativeTo / parent_id references resolve to the adjusted
|
||||
// predecessor date.
|
||||
// Walk the rule list in TOPOLOGICAL order (parents before children),
|
||||
// not the raw sequence_order order from the catalog. The catalog
|
||||
// returns rules sorted by sequence_order, which is the chronological/
|
||||
// display order. That order is parent-first for the common
|
||||
// timing='after' case but parent-LAST for timing='before' children
|
||||
// (e.g. upc.inf.cfi.translation_request at seq=45 vs its parent
|
||||
// upc.inf.cfi.oral at seq=50 — m/paliad#135). Without topological
|
||||
// ordering the parent-state checks below (courtSet[parent] /
|
||||
// computed[parent_code]) read stale empty maps when a child appears
|
||||
// before its parent, and the engine falls back to the trigger date
|
||||
// → fabricates dates before the SoC.
|
||||
//
|
||||
// Original sequence_order is restored at the end of the walk so the
|
||||
// wire shape and the timeline view's render order stay identical to
|
||||
// the legacy behaviour modulo the bug fix.
|
||||
sequenceIndex := make(map[uuid.UUID]int, len(rules))
|
||||
for i, r := range rules {
|
||||
sequenceIndex[r.ID] = i
|
||||
}
|
||||
walkRules := topoSortByParentDepth(rules)
|
||||
|
||||
computed := make(map[string]time.Time, len(rules))
|
||||
courtSet := make(map[uuid.UUID]bool, len(rules))
|
||||
deadlines := make([]TimelineEntry, 0, len(rules))
|
||||
@@ -197,7 +215,7 @@ func Calculate(
|
||||
hiddenCount := 0
|
||||
appellantContext := make(map[uuid.UUID]string, len(rules))
|
||||
|
||||
for _, r := range rules {
|
||||
for _, r := range walkRules {
|
||||
// Phase-3 unified gate: evaluate condition_expr (jsonb).
|
||||
// Suppression semantic preserved: when the gate fires false
|
||||
// AND no alt_* values exist, the rule is dropped from the
|
||||
@@ -249,6 +267,10 @@ func Calculate(
|
||||
appellantContext[r.ID] = ctxVal
|
||||
}
|
||||
|
||||
ruleTiming := ""
|
||||
if r.Timing != nil {
|
||||
ruleTiming = *r.Timing
|
||||
}
|
||||
d := TimelineEntry{
|
||||
RuleID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
@@ -258,6 +280,9 @@ func Calculate(
|
||||
AppellantContext: ctxVal,
|
||||
ChoicesOffered: json.RawMessage(r.ChoicesOffered),
|
||||
IsHidden: isHidden,
|
||||
DurationValue: r.DurationValue,
|
||||
DurationUnit: r.DurationUnit,
|
||||
Timing: ruleTiming,
|
||||
}
|
||||
if r.SubmissionCode != nil {
|
||||
d.Code = *r.SubmissionCode
|
||||
@@ -547,6 +572,35 @@ func Calculate(
|
||||
deadlines = append(deadlines, d)
|
||||
}
|
||||
|
||||
// Stamp AppealRole on every entry when an appeal-target filter is
|
||||
// active so the frontend column-bucketer can route primary_party=
|
||||
// 'both' rules into the user-perspective columns
|
||||
// (Berufungskläger vs Berufungsbeklagter). Court events stay empty
|
||||
// — they route on Party='court' regardless. (t-paliad-307 /
|
||||
// m/paliad#136 Bug 1)
|
||||
if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) {
|
||||
for i := range deadlines {
|
||||
if deadlines[i].Code == "" {
|
||||
continue
|
||||
}
|
||||
deadlines[i].AppealRole = AppealFilerRole(deadlines[i].Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore sequence_order on the output slice. The compute walk
|
||||
// re-ordered rules topologically (parent-first) so the parent-state
|
||||
// checks resolved correctly; the wire shape and the linear timeline
|
||||
// view both rely on sequence_order being the surface render order.
|
||||
// (m/paliad#135)
|
||||
sort.SliceStable(deadlines, func(i, j int) bool {
|
||||
a, errA := uuid.Parse(deadlines[i].RuleID)
|
||||
b, errB := uuid.Parse(deadlines[j].RuleID)
|
||||
if errA != nil || errB != nil {
|
||||
return false
|
||||
}
|
||||
return sequenceIndex[a] < sequenceIndex[b]
|
||||
})
|
||||
|
||||
// t-paliad-296: within consecutive runs of rules sharing the same
|
||||
// trigger group (parent_id + trigger_event_id), reorder by duration
|
||||
// ascending so optional events following the same anchor render in
|
||||
@@ -555,6 +609,31 @@ func Calculate(
|
||||
// same-group rows. Court-set / conditional rows sort LAST.
|
||||
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
||||
|
||||
// Synthetic trigger-event row for appeal timelines (t-paliad-307 /
|
||||
// m/paliad#136 Bug 2). The decision being appealed (Endentscheidung
|
||||
// R.118, Kostenentscheidung, Anordnung, …) isn't a rule in the
|
||||
// upc.apl catalog — it's the anchor the user picked. Lawyers expect
|
||||
// it to surface as the first row of the timeline so the chain reads
|
||||
// decision → appeal filings → next decision. Emitted only when an
|
||||
// appeal_target is in play and the helper returns a non-empty label.
|
||||
if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) {
|
||||
nameDE := TriggerEventLabelForAppealTarget(opts.AppealTarget, "de")
|
||||
nameEN := TriggerEventLabelForAppealTarget(opts.AppealTarget, "en")
|
||||
if nameDE != "" || nameEN != "" {
|
||||
trig := TimelineEntry{
|
||||
Name: nameDE,
|
||||
NameEN: nameEN,
|
||||
Party: PrimaryPartyCourt,
|
||||
Priority: "informational",
|
||||
DueDate: triggerDateStr,
|
||||
OriginalDate: triggerDateStr,
|
||||
IsRootEvent: true,
|
||||
IsTriggerEvent: true,
|
||||
}
|
||||
deadlines = append([]TimelineEntry{trig}, deadlines...)
|
||||
}
|
||||
}
|
||||
|
||||
resp := &Timeline{
|
||||
ProceedingType: pickedProceeding.Code,
|
||||
ProceedingName: pickedProceeding.Name,
|
||||
@@ -671,6 +750,9 @@ func calculateByTriggerEvent(
|
||||
OriginalDate: original.Format("2006-01-02"),
|
||||
WasAdjusted: wasAdj,
|
||||
AdjustmentReason: reason,
|
||||
DurationValue: r.DurationValue,
|
||||
DurationUnit: r.DurationUnit,
|
||||
Timing: timing,
|
||||
}
|
||||
if r.SubmissionCode != nil {
|
||||
d.Code = *r.SubmissionCode
|
||||
@@ -940,3 +1022,60 @@ func AllFlagsSet(required []string, set map[string]struct{}) bool {
|
||||
func WireFlagsFromPriority(priority string) (isMandatory, isOptional bool) {
|
||||
return wireFlagsFromPriority(priority)
|
||||
}
|
||||
|
||||
// topoSortByParentDepth returns a copy of `rules` ordered so every rule
|
||||
// appears after its parent_id ancestor. Ties (rules at the same depth)
|
||||
// preserve their input order — which the catalog returns in
|
||||
// sequence_order. Used by Calculate to ensure the parent-state checks
|
||||
// (courtSet[parent], computed[parent_code]) see populated entries even
|
||||
// when sequence_order lists a "before"-timed child BEFORE its parent
|
||||
// (e.g. upc.inf.cfi.translation_request at seq=45 with parent
|
||||
// upc.inf.cfi.oral at seq=50 — m/paliad#135).
|
||||
//
|
||||
// Rules whose parent_id is missing from the rule slice (cross-tree
|
||||
// references that the per-proceeding filter dropped) are treated as
|
||||
// depth 0 — they walk in their original sequence position.
|
||||
//
|
||||
// The algorithm is depth-via-memoised-recursion. Cycle protection: a
|
||||
// rule chain that revisits a node is broken at depth 0; production
|
||||
// data shouldn't contain cycles, but a corrupted catalog mustn't hang
|
||||
// the calculator.
|
||||
func topoSortByParentDepth(rules []Rule) []Rule {
|
||||
byID := make(map[uuid.UUID]Rule, len(rules))
|
||||
inSlice := make(map[uuid.UUID]bool, len(rules))
|
||||
for _, r := range rules {
|
||||
byID[r.ID] = r
|
||||
inSlice[r.ID] = true
|
||||
}
|
||||
|
||||
depth := make(map[uuid.UUID]int, len(rules))
|
||||
var resolve func(id uuid.UUID, seen map[uuid.UUID]bool) int
|
||||
resolve = func(id uuid.UUID, seen map[uuid.UUID]bool) int {
|
||||
if d, ok := depth[id]; ok {
|
||||
return d
|
||||
}
|
||||
if seen[id] {
|
||||
depth[id] = 0
|
||||
return 0
|
||||
}
|
||||
seen[id] = true
|
||||
r, ok := byID[id]
|
||||
if !ok || r.ParentID == nil || !inSlice[*r.ParentID] {
|
||||
depth[id] = 0
|
||||
return 0
|
||||
}
|
||||
d := resolve(*r.ParentID, seen) + 1
|
||||
depth[id] = d
|
||||
return d
|
||||
}
|
||||
for _, r := range rules {
|
||||
resolve(r.ID, map[uuid.UUID]bool{})
|
||||
}
|
||||
|
||||
out := make([]Rule, len(rules))
|
||||
copy(out, rules)
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
return depth[out[i].ID] < depth[out[j].ID]
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
215
pkg/litigationplanner/scenarios.go
Normal file
215
pkg/litigationplanner/scenarios.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Slice D scenarios — m/paliad#124 §5 (revised), mig 145.
|
||||
//
|
||||
// A Scenario is a named composition of existing proceedings + flags +
|
||||
// per-card choices + anchor dates. v1 ships with one primary proceeding
|
||||
// per scenario; the spec.proceedings[] array is architected to absorb
|
||||
// multi-peer compose (v2) without a schema migration.
|
||||
//
|
||||
// "users should not add their own rules" (m, t-paliad-301) — the spec
|
||||
// references existing rules by submission_code; it never creates new
|
||||
// ones. ValidateSpec checks every code/submission resolves against the
|
||||
// current catalog before a save is accepted.
|
||||
|
||||
// Scenario is one row of paliad.scenarios. Wire shape doubles as the
|
||||
// API request/response payload for /api/scenarios.
|
||||
type Scenario struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
// Spec carries the jsonb composition. Stored raw so we can ship
|
||||
// shape evolutions without schema churn; ParseSpec gives the
|
||||
// structured view.
|
||||
Spec NullableJSON `db:"spec" json:"spec"`
|
||||
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// ScenarioSpec is the parsed view of Scenario.Spec. v1 = version 1.
|
||||
// Future shape changes bump the version; ParseSpec rejects unknown
|
||||
// versions so an old client doesn't silently misread a future-shape
|
||||
// scenario.
|
||||
type ScenarioSpec struct {
|
||||
Version int `json:"version"`
|
||||
BaseTriggerDate string `json:"base_trigger_date"`
|
||||
Proceedings []ScenarioProceeding `json:"proceedings"`
|
||||
}
|
||||
|
||||
// ScenarioProceeding is one entry under spec.proceedings[]. v1 honours
|
||||
// exactly one with role="primary" (additional entries with role="peer"
|
||||
// are reserved for v2 multi-proceeding compose and silently ignored
|
||||
// by the engine today).
|
||||
type ScenarioProceeding struct {
|
||||
Code string `json:"code"`
|
||||
Role string `json:"role"` // "primary" | "peer" (v2)
|
||||
TriggerDateOverride string `json:"trigger_date_override,omitempty"`
|
||||
Flags []string `json:"flags,omitempty"`
|
||||
PerCardChoices map[string]ScenarioCardChoice `json:"per_card_choices,omitempty"`
|
||||
AnchorOverrides map[string]string `json:"anchor_overrides,omitempty"`
|
||||
SkipRules []string `json:"skip_rules,omitempty"`
|
||||
AppealTarget string `json:"appeal_target,omitempty"`
|
||||
}
|
||||
|
||||
// ScenarioCardChoice is one entry under
|
||||
// spec.proceedings[*].per_card_choices. Mirrors the t-paliad-265 choice
|
||||
// kinds; not every kind is populated on every card.
|
||||
type ScenarioCardChoice struct {
|
||||
Appellant string `json:"appellant,omitempty"`
|
||||
IncludeCCR *bool `json:"include_ccr,omitempty"`
|
||||
Skip *bool `json:"skip,omitempty"`
|
||||
}
|
||||
|
||||
// Spec version constant.
|
||||
const ScenarioSpecVersion = 1
|
||||
|
||||
// Sentinel errors for scenarios.
|
||||
var (
|
||||
ErrUnknownScenario = errors.New("unknown scenario")
|
||||
ErrInvalidScenario = errors.New("invalid scenario spec")
|
||||
ErrScenarioNoPrimary = errors.New("scenario spec has no proceeding with role='primary'")
|
||||
)
|
||||
|
||||
// ScenarioRole* are the canonical role slugs for ScenarioProceeding.Role.
|
||||
const (
|
||||
ScenarioRolePrimary = "primary"
|
||||
ScenarioRolePeer = "peer"
|
||||
)
|
||||
|
||||
// ParseSpec decodes Scenario.Spec into a structured ScenarioSpec. Used
|
||||
// by the engine adapter + the rule-editor preview. Surfaces a friendly
|
||||
// error wrapping ErrInvalidScenario on malformed JSON / unknown version
|
||||
// so the handler can map to a 400.
|
||||
func ParseSpec(raw NullableJSON) (*ScenarioSpec, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("%w: spec is empty", ErrInvalidScenario)
|
||||
}
|
||||
var s ScenarioSpec
|
||||
if err := json.Unmarshal([]byte(raw), &s); err != nil {
|
||||
return nil, fmt.Errorf("%w: decode spec: %v", ErrInvalidScenario, err)
|
||||
}
|
||||
if s.Version != ScenarioSpecVersion {
|
||||
return nil, fmt.Errorf("%w: spec.version=%d, want %d",
|
||||
ErrInvalidScenario, s.Version, ScenarioSpecVersion)
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// PrimaryProceeding returns the entry from spec.proceedings[] with
|
||||
// role="primary". Returns ErrScenarioNoPrimary if absent — every spec
|
||||
// must carry exactly one primary entry. (Multiple primaries are also
|
||||
// rejected: the engine consumes one.)
|
||||
func (s *ScenarioSpec) PrimaryProceeding() (*ScenarioProceeding, error) {
|
||||
var primary *ScenarioProceeding
|
||||
for i := range s.Proceedings {
|
||||
if s.Proceedings[i].Role == ScenarioRolePrimary {
|
||||
if primary != nil {
|
||||
return nil, fmt.Errorf("%w: multiple proceedings with role='primary'", ErrInvalidScenario)
|
||||
}
|
||||
primary = &s.Proceedings[i]
|
||||
}
|
||||
}
|
||||
if primary == nil {
|
||||
return nil, ErrScenarioNoPrimary
|
||||
}
|
||||
return primary, nil
|
||||
}
|
||||
|
||||
// CalcOptionsFromSpec builds a CalcOptions from the scenario's primary
|
||||
// entry. The caller still needs the proceeding code + the trigger date,
|
||||
// both returned alongside.
|
||||
//
|
||||
// v1: only the primary entry is honoured. v2 will iterate over peer
|
||||
// entries; the multi-peer merge lives in the paliad-side
|
||||
// ProjectionService (one Calculate call per entry, merged + sorted by
|
||||
// date).
|
||||
func (s *ScenarioSpec) CalcOptionsFromSpec() (proceedingCode, triggerDate string, opts CalcOptions, err error) {
|
||||
primary, err := s.PrimaryProceeding()
|
||||
if err != nil {
|
||||
return "", "", CalcOptions{}, err
|
||||
}
|
||||
td := s.BaseTriggerDate
|
||||
if primary.TriggerDateOverride != "" {
|
||||
td = primary.TriggerDateOverride
|
||||
}
|
||||
if td == "" {
|
||||
return "", "", CalcOptions{}, fmt.Errorf("%w: no base_trigger_date and no per-proceeding override", ErrInvalidScenario)
|
||||
}
|
||||
|
||||
perCardAppellant := make(map[string]string, len(primary.PerCardChoices))
|
||||
skipRules := make(map[string]struct{}, len(primary.SkipRules))
|
||||
includeCCRFor := make(map[string]struct{}, len(primary.PerCardChoices))
|
||||
for code, choice := range primary.PerCardChoices {
|
||||
if choice.Appellant != "" {
|
||||
perCardAppellant[code] = choice.Appellant
|
||||
}
|
||||
if choice.IncludeCCR != nil && *choice.IncludeCCR {
|
||||
includeCCRFor[code] = struct{}{}
|
||||
}
|
||||
if choice.Skip != nil && *choice.Skip {
|
||||
skipRules[code] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, code := range primary.SkipRules {
|
||||
skipRules[code] = struct{}{}
|
||||
}
|
||||
|
||||
return primary.Code, td, CalcOptions{
|
||||
Flags: primary.Flags,
|
||||
AnchorOverrides: primary.AnchorOverrides,
|
||||
AppealTarget: primary.AppealTarget,
|
||||
PerCardAppellant: perCardAppellant,
|
||||
SkipRules: skipRules,
|
||||
IncludeCCRFor: includeCCRFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ScenarioFilter narrows Catalog.LoadScenarios. All fields optional:
|
||||
//
|
||||
// - ProjectID non-nil: only scenarios attached to that project
|
||||
// (project_id = filter.ProjectID).
|
||||
// - AbstractForUser non-nil: only abstract scenarios (project_id IS
|
||||
// NULL) created by that user.
|
||||
// - Both nil: list every scenario the caller can see (RLS-gated).
|
||||
type ScenarioFilter struct {
|
||||
ProjectID *uuid.UUID
|
||||
AbstractForUser *uuid.UUID
|
||||
}
|
||||
|
||||
// CalculateFromScenario is the high-level engine entry for scenario-
|
||||
// driven rendering. Unpacks the spec, builds CalcOptions, and delegates
|
||||
// to Calculate.
|
||||
//
|
||||
// v1: surfaces only the primary proceeding's timeline. v2 multi-peer
|
||||
// expansion lives on the paliad-side ProjectionService (per-entry
|
||||
// Calculate + client-side merge); the package doesn't own that
|
||||
// orchestration.
|
||||
func CalculateFromScenario(
|
||||
ctx context.Context,
|
||||
scenario *Scenario,
|
||||
catalog Catalog,
|
||||
holidays HolidayCalendar,
|
||||
courts CourtRegistry,
|
||||
) (*Timeline, error) {
|
||||
spec, err := ParseSpec(scenario.Spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code, triggerDate, opts, err := spec.CalcOptionsFromSpec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Calculate(ctx, code, triggerDate, opts, catalog, holidays, courts)
|
||||
}
|
||||
207
pkg/litigationplanner/scenarios_test.go
Normal file
207
pkg/litigationplanner/scenarios_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package litigationplanner
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestParseSpec_Roundtrip pins the spec-decoder contract: well-formed
|
||||
// jsonb with version=1 parses; unknown versions and malformed JSON
|
||||
// surface ErrInvalidScenario.
|
||||
func TestParseSpec_Roundtrip(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
spec string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"v1 primary-only",
|
||||
`{"version":1,"base_trigger_date":"2026-05-26","proceedings":[{"code":"upc.inf.cfi","role":"primary"}]}`,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"v1 with full primary entry",
|
||||
`{"version":1,"base_trigger_date":"2026-05-26","proceedings":[
|
||||
{"code":"upc.inf.cfi","role":"primary","flags":["with_ccr"],
|
||||
"anchor_overrides":{"inf.reply":"2026-08-15"},
|
||||
"skip_rules":["inf.r30_amend"]}
|
||||
]}`,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"v2 spec rejected — unknown version",
|
||||
`{"version":2,"proceedings":[]}`,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"empty spec",
|
||||
``,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"malformed json",
|
||||
`{"version":1,"proceedings":[}`,
|
||||
true,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, err := ParseSpec(NullableJSON(c.spec))
|
||||
if c.wantErr && err == nil {
|
||||
t.Errorf("ParseSpec(%s): want error, got nil", c.spec)
|
||||
}
|
||||
if !c.wantErr && err != nil {
|
||||
t.Errorf("ParseSpec(%s): unexpected error %v", c.spec, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestScenarioSpec_PrimaryProceeding pins the "exactly one primary"
|
||||
// invariant: zero → ErrScenarioNoPrimary; multiple → ErrInvalidScenario.
|
||||
func TestScenarioSpec_PrimaryProceeding(t *testing.T) {
|
||||
t.Run("zero primary → ErrScenarioNoPrimary", func(t *testing.T) {
|
||||
s := &ScenarioSpec{
|
||||
Version: 1,
|
||||
Proceedings: []ScenarioProceeding{
|
||||
{Code: "upc.inf.cfi", Role: ScenarioRolePeer},
|
||||
},
|
||||
}
|
||||
_, err := s.PrimaryProceeding()
|
||||
if err != ErrScenarioNoPrimary {
|
||||
t.Errorf("want ErrScenarioNoPrimary, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("two primaries rejected", func(t *testing.T) {
|
||||
s := &ScenarioSpec{
|
||||
Version: 1,
|
||||
Proceedings: []ScenarioProceeding{
|
||||
{Code: "upc.inf.cfi", Role: ScenarioRolePrimary},
|
||||
{Code: "upc.rev.cfi", Role: ScenarioRolePrimary},
|
||||
},
|
||||
}
|
||||
_, err := s.PrimaryProceeding()
|
||||
if err == nil || !strings.Contains(err.Error(), "multiple proceedings with role='primary'") {
|
||||
t.Errorf("want multi-primary error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single primary picked", func(t *testing.T) {
|
||||
s := &ScenarioSpec{
|
||||
Version: 1,
|
||||
Proceedings: []ScenarioProceeding{
|
||||
{Code: "upc.inf.cfi", Role: ScenarioRolePeer},
|
||||
{Code: "upc.rev.cfi", Role: ScenarioRolePrimary, Flags: []string{"with_amend"}},
|
||||
},
|
||||
}
|
||||
p, err := s.PrimaryProceeding()
|
||||
if err != nil {
|
||||
t.Fatalf("PrimaryProceeding: %v", err)
|
||||
}
|
||||
if p.Code != "upc.rev.cfi" {
|
||||
t.Errorf("primary code = %q, want upc.rev.cfi", p.Code)
|
||||
}
|
||||
if len(p.Flags) != 1 || p.Flags[0] != "with_amend" {
|
||||
t.Errorf("primary.Flags = %v, want [with_amend]", p.Flags)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestScenarioSpec_CalcOptionsFromSpec covers the unpack from spec
|
||||
// jsonb into the CalcOptions the engine consumes. Pins:
|
||||
// - base_trigger_date used when no per-proceeding override
|
||||
// - trigger_date_override wins when set
|
||||
// - flags + anchor_overrides + appeal_target passed through verbatim
|
||||
// - per_card_choices unpacked into PerCardAppellant / SkipRules /
|
||||
// IncludeCCRFor maps
|
||||
func TestScenarioSpec_CalcOptionsFromSpec(t *testing.T) {
|
||||
includeTrue := true
|
||||
skipTrue := true
|
||||
s := &ScenarioSpec{
|
||||
Version: 1,
|
||||
BaseTriggerDate: "2026-05-26",
|
||||
Proceedings: []ScenarioProceeding{{
|
||||
Code: "upc.inf.cfi",
|
||||
Role: ScenarioRolePrimary,
|
||||
Flags: []string{"with_ccr"},
|
||||
AnchorOverrides: map[string]string{"inf.reply": "2026-08-15"},
|
||||
AppealTarget: "endentscheidung",
|
||||
SkipRules: []string{"explicit_skip_code"},
|
||||
PerCardChoices: map[string]ScenarioCardChoice{
|
||||
"inf.r30_amend": {Appellant: "claimant"},
|
||||
"inf.rejoin": {IncludeCCR: &includeTrue},
|
||||
"inf.amend_other": {Skip: &skipTrue},
|
||||
},
|
||||
}},
|
||||
}
|
||||
code, td, opts, err := s.CalcOptionsFromSpec()
|
||||
if err != nil {
|
||||
t.Fatalf("CalcOptionsFromSpec: %v", err)
|
||||
}
|
||||
if code != "upc.inf.cfi" {
|
||||
t.Errorf("code = %q, want upc.inf.cfi", code)
|
||||
}
|
||||
if td != "2026-05-26" {
|
||||
t.Errorf("triggerDate = %q, want 2026-05-26", td)
|
||||
}
|
||||
if len(opts.Flags) != 1 || opts.Flags[0] != "with_ccr" {
|
||||
t.Errorf("opts.Flags = %v, want [with_ccr]", opts.Flags)
|
||||
}
|
||||
if opts.AppealTarget != "endentscheidung" {
|
||||
t.Errorf("opts.AppealTarget = %q, want endentscheidung", opts.AppealTarget)
|
||||
}
|
||||
if got := opts.AnchorOverrides["inf.reply"]; got != "2026-08-15" {
|
||||
t.Errorf("opts.AnchorOverrides[inf.reply] = %q, want 2026-08-15", got)
|
||||
}
|
||||
if got := opts.PerCardAppellant["inf.r30_amend"]; got != "claimant" {
|
||||
t.Errorf("opts.PerCardAppellant[inf.r30_amend] = %q, want claimant", got)
|
||||
}
|
||||
if _, ok := opts.IncludeCCRFor["inf.rejoin"]; !ok {
|
||||
t.Error("opts.IncludeCCRFor missing inf.rejoin")
|
||||
}
|
||||
if _, ok := opts.SkipRules["inf.amend_other"]; !ok {
|
||||
t.Error("opts.SkipRules missing inf.amend_other (from per_card_choices.skip)")
|
||||
}
|
||||
if _, ok := opts.SkipRules["explicit_skip_code"]; !ok {
|
||||
t.Error("opts.SkipRules missing explicit_skip_code (from skip_rules[])")
|
||||
}
|
||||
}
|
||||
|
||||
// TestScenarioSpec_TriggerDateOverride pins the per-proceeding override
|
||||
// path (v2-ready — primary entry honours trigger_date_override too).
|
||||
func TestScenarioSpec_TriggerDateOverride(t *testing.T) {
|
||||
s := &ScenarioSpec{
|
||||
Version: 1,
|
||||
BaseTriggerDate: "2026-05-26",
|
||||
Proceedings: []ScenarioProceeding{{
|
||||
Code: "upc.inf.cfi",
|
||||
Role: ScenarioRolePrimary,
|
||||
TriggerDateOverride: "2026-12-01",
|
||||
}},
|
||||
}
|
||||
_, td, _, err := s.CalcOptionsFromSpec()
|
||||
if err != nil {
|
||||
t.Fatalf("CalcOptionsFromSpec: %v", err)
|
||||
}
|
||||
if td != "2026-12-01" {
|
||||
t.Errorf("triggerDate = %q, want override 2026-12-01", td)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScenarioSpec_NoBaseTrigger pins the safety check that a spec
|
||||
// without base_trigger_date AND without per-proceeding override
|
||||
// surfaces ErrInvalidScenario (the engine can't render without a date).
|
||||
func TestScenarioSpec_NoBaseTrigger(t *testing.T) {
|
||||
s := &ScenarioSpec{
|
||||
Version: 1,
|
||||
Proceedings: []ScenarioProceeding{{
|
||||
Code: "upc.inf.cfi",
|
||||
Role: ScenarioRolePrimary,
|
||||
}},
|
||||
}
|
||||
_, _, _, err := s.CalcOptionsFromSpec()
|
||||
if err == nil {
|
||||
t.Fatal("want ErrInvalidScenario, got nil")
|
||||
}
|
||||
}
|
||||
@@ -430,6 +430,33 @@ type TimelineEntry struct {
|
||||
ChoicesOffered json.RawMessage `json:"choicesOffered,omitempty"`
|
||||
AppellantContext string `json:"appellantContext,omitempty"`
|
||||
IsHidden bool `json:"isHidden,omitempty"`
|
||||
// DurationValue / DurationUnit / Timing surface the rule's
|
||||
// arithmetic so /tools/verfahrensablauf can show "2 Mo. nach" on
|
||||
// each event card (m/paliad#133, t-paliad-302). Source values from
|
||||
// the Rule row (not the post-alt-swap arithmetic) — the tooltip
|
||||
// reads as a property of the rule, not a recap of which branch
|
||||
// fired. Zero-duration rules (root event, court-set) emit
|
||||
// DurationValue=0 and the frontend suppresses the affordance.
|
||||
// Timing is "before" | "after" — empty when r.Timing is NULL.
|
||||
DurationValue int `json:"durationValue,omitempty"`
|
||||
DurationUnit string `json:"durationUnit,omitempty"`
|
||||
Timing string `json:"timing,omitempty"`
|
||||
|
||||
// AppealRole carries the rule's appeal-filer role (t-paliad-307 /
|
||||
// m/paliad#136 Bug 1) when the timeline was computed under an
|
||||
// appeal_target filter. One of AppealRoleAppellant /
|
||||
// AppealRoleAppellee, or empty for court events / non-appeal
|
||||
// timelines. The frontend column-bucketer reads this to route
|
||||
// primary_party='both' rules to Berufungskläger vs
|
||||
// Berufungsbeklagter columns once the user picks a side.
|
||||
AppealRole string `json:"appealRole,omitempty"`
|
||||
|
||||
// IsTriggerEvent marks the synthetic root row that represents the
|
||||
// decision being appealed (t-paliad-307 / m/paliad#136 Bug 2).
|
||||
// Distinct from IsRootEvent in that the row carries no real rule
|
||||
// id — it's a UI marker dated to the trigger date with the
|
||||
// per-appeal-target label from TriggerEventLabelForAppealTarget.
|
||||
IsTriggerEvent bool `json:"isTriggerEvent,omitempty"`
|
||||
}
|
||||
|
||||
// RuleCalculation is the single-rule calc response that backs the
|
||||
|
||||
@@ -39,9 +39,17 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// anchorsOnly switches the body emitter from the legacy variable-bag
|
||||
// banner template to the Composer Slice B anchor-only body. Toggled
|
||||
// via the -anchors flag; default true so the Slice B regen produces
|
||||
// the composer-ready file.
|
||||
var anchorsOnly = true
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", "_skeleton.docx", "output .docx path")
|
||||
anchors := flag.Bool("anchors", true, "emit Composer-mode body with section anchors only (t-paliad-313 Slice B); false = legacy variable-bag banner body")
|
||||
flag.Parse()
|
||||
anchorsOnly = *anchors
|
||||
|
||||
docx, err := buildDocx()
|
||||
if err != nil {
|
||||
@@ -156,6 +164,45 @@ const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
// DEMO/SKELETON banner makes it obvious this is a starter template and
|
||||
// not approved firm content.
|
||||
func buildDocumentXML() string {
|
||||
if anchorsOnly {
|
||||
return buildAnchoredDocumentXML()
|
||||
}
|
||||
return buildLegacyDocumentXML()
|
||||
}
|
||||
|
||||
// buildAnchoredDocumentXML emits the Composer-mode body: just section
|
||||
// anchors. The composer pipeline (services/submission_compose.go)
|
||||
// replaces each {{#section:KEY}}...{{/section:KEY}} paragraph pair
|
||||
// with the rendered section content from submission_sections.
|
||||
// Pre-Composer drafts continue to use the legacy body (run with
|
||||
// -anchors=false).
|
||||
//
|
||||
// Order matches the default section spec in mig 146:
|
||||
// letterhead, caption, introduction, requests, facts,
|
||||
// legal_argument, evidence, exhibits, closing, signature.
|
||||
func buildAnchoredDocumentXML() 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>`)
|
||||
|
||||
anchorPair := func(key string) {
|
||||
plain(&b, "{{#section:"+key+"}}")
|
||||
plain(&b, "{{/section:"+key+"}}")
|
||||
}
|
||||
for _, key := range []string{
|
||||
"letterhead", "caption", "introduction", "requests",
|
||||
"facts", "legal_argument", "evidence", "exhibits",
|
||||
"closing", "signature",
|
||||
} {
|
||||
anchorPair(key)
|
||||
}
|
||||
|
||||
b.WriteString(`</w:body></w:document>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func buildLegacyDocumentXML() 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">`)
|
||||
|
||||
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