feat(projects): add projects.our_side column + service plumbing (t-paliad-164 slice 1)

m's 2026-05-08 21:42 dogfood feedback on the Determinator perspective
chip: when an Akte is selected, the chip should be locked to the firm's
known side instead of asking the user to re-pick. paliad didn't track
that anywhere — paliad.parties.role records each party's role but no
flag for "this is the side we represent".

Migration 072 adds paliad.projects.our_side text with a CHECK
constraint (claimant | defendant | court | both | NULL). NULL stays the
default so existing rows are neutral and the Determinator falls back to
free-pick. Idempotent (ADD COLUMN IF NOT EXISTS + DO-block guarded
constraint) so a re-run against a partially-applied state is safe —
paliad has been bitten by collision twice this week.

Project model + ProjectService:
- OurSide *string field on models.Project
- CreateProjectInput / UpdateProjectInput accept our_side
- INSERT and partial UPDATE thread the value through; validateOurSide
  rejects unknown enum values with ErrInvalidInput before the DB
  constraint would; nullableOurSide turns "" into NULL so the form's
  "unset" sentinel can clear the column
- Update logs an our_side_changed audit event with "<from> → <to>"
  description (matching status_changed / project_type_changed
  shape); both ends use the literal "none" sentinel for NULL so the
  frontend renderer can map it to projects.field.our_side.none

i18n: event.title.our_side_changed (DE/EN), dashboard.action.short
verb form, projects.field.our_side.{label,hint,unset,claimant,
defendant,court,both,none} for the upcoming Slice 2 select.

Frontend translateEventDescription gets an our_side_changed branch
that runs translateArrowSlugs over the projects.field.our_side.*
prefix so the Verlauf tab renders localized labels.

Slice 2 wires the form, Slice 3 wires the Determinator.
This commit is contained in:
m
2026-05-08 21:52:50 +02:00
parent 936aca5925
commit 188d8ec9ba
6 changed files with 161 additions and 3 deletions

View File

@@ -875,6 +875,7 @@ const translations: Record<Lang, Record<string, string>> = {
"dashboard.action.short.project_reparented": "ordnete Projekt neu zu",
"dashboard.action.short.project_type_changed": "\u00e4nderte Projekt-Typ",
"dashboard.action.short.status_changed": "\u00e4nderte Status",
"dashboard.action.short.our_side_changed": "\u00e4nderte vertretene Seite",
"dashboard.action.short.visibility_changed": "\u00e4nderte Sichtbarkeit",
"dashboard.action.short.collaborators_updated": "aktualisierte Bearbeiter",
"dashboard.action.short.note_created": "f\u00fcgte Notiz hinzu",
@@ -896,6 +897,7 @@ const translations: Record<Lang, Record<string, string>> = {
"event.title.project_reparented": "Projekt umstrukturiert",
"event.title.project_type_changed": "Projekt-Typ ge\u00e4ndert",
"event.title.status_changed": "Status ge\u00e4ndert",
"event.title.our_side_changed": "Vertretene Seite ge\u00e4ndert",
"event.title.note_created": "Notiz hinzugef\u00fcgt",
"event.title.deadline_created": "Frist angelegt",
"event.title.deadline_updated": "Frist ge\u00e4ndert",
@@ -1125,6 +1127,14 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.field.court": "Gericht",
"projects.field.case_number": "Aktenzeichen (Gericht)",
"projects.field.proceeding_type_id": "Verfahrensart",
"projects.field.our_side": "Wir vertreten",
"projects.field.our_side.hint": "Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator. Lässt sich dort jederzeit überschreiben.",
"projects.field.our_side.unset": "Unbekannt / nicht gesetzt",
"projects.field.our_side.claimant": "Klägerseite",
"projects.field.our_side.defendant": "Beklagtenseite",
"projects.field.our_side.court": "Gericht / Tribunal",
"projects.field.our_side.both": "Beide Seiten",
"projects.field.our_side.none": "—",
"projects.field.status": "Status",
"projects.error.title_required": "Titel erforderlich",
"projects.detail.edit.type_change_warning.title": "Diese Felder werden geleert:",
@@ -3002,6 +3012,7 @@ const translations: Record<Lang, Record<string, string>> = {
"dashboard.action.short.project_reparented": "re-parented project",
"dashboard.action.short.project_type_changed": "changed project type",
"dashboard.action.short.status_changed": "changed status",
"dashboard.action.short.our_side_changed": "changed represented side",
"dashboard.action.short.visibility_changed": "changed visibility",
"dashboard.action.short.collaborators_updated": "updated collaborators",
"dashboard.action.short.note_created": "added note",
@@ -3023,6 +3034,7 @@ const translations: Record<Lang, Record<string, string>> = {
"event.title.project_reparented": "Project re-parented",
"event.title.project_type_changed": "Project type changed",
"event.title.status_changed": "Status changed",
"event.title.our_side_changed": "Represented side changed",
"event.title.note_created": "Note added",
"event.title.deadline_created": "Deadline created",
"event.title.deadline_updated": "Deadline updated",
@@ -3250,6 +3262,14 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.field.court": "Court",
"projects.field.case_number": "Case number (court)",
"projects.field.proceeding_type_id": "Proceeding type",
"projects.field.our_side": "We represent",
"projects.field.our_side.hint": "Pre-selects the perspective chip in the Fristenrechner Determinator. Always overridable from there.",
"projects.field.our_side.unset": "Unknown / not set",
"projects.field.our_side.claimant": "Claimant side",
"projects.field.our_side.defendant": "Defendant side",
"projects.field.our_side.court": "Court / tribunal",
"projects.field.our_side.both": "Both sides",
"projects.field.our_side.none": "—",
"projects.field.status": "Status",
"projects.error.title_required": "Title required",
"projects.detail.edit.type_change_warning.title": "These fields will be cleared:",
@@ -4351,6 +4371,12 @@ function translateEventDescription(eventType: string, description: string): stri
// New format: "active → archived". Legacy: "Status active → archived".
return translateArrowSlugs(body.replace(/^Status\s+/, ""), "projects.filter.status.");
}
if (eventType === "our_side_changed") {
// Format: "<from> → <to>", where each side is one of
// claimant / defendant / court / both / "none" (the sentinel for
// NULL the service writes when the column is unset on either end).
return translateArrowSlugs(body, "projects.field.our_side.");
}
if (eventType === "note_created") {
// New format: just the parent slug. Legacy: "Note zu <slug> hinzugefügt".
const m = body.match(/^Note zu (project|deadline|appointment) hinzugef[üu]gt$/i);

View File

@@ -652,6 +652,7 @@ export type I18nKey =
| "dashboard.action.short.fristen_imported"
| "dashboard.action.short.note_created"
| "dashboard.action.short.notiz_created"
| "dashboard.action.short.our_side_changed"
| "dashboard.action.short.partei_added"
| "dashboard.action.short.partei_removed"
| "dashboard.action.short.project_archived"
@@ -1108,6 +1109,7 @@ export type I18nKey =
| "event.title.deadline_updated"
| "event.title.deadlines_imported"
| "event.title.note_created"
| "event.title.our_side_changed"
| "event.title.project_archived"
| "event.title.project_created"
| "event.title.project_reparented"
@@ -1746,6 +1748,14 @@ export type I18nKey =
| "projects.field.matter_number"
| "projects.field.netdocuments_url"
| "projects.field.office"
| "projects.field.our_side"
| "projects.field.our_side.both"
| "projects.field.our_side.claimant"
| "projects.field.our_side.court"
| "projects.field.our_side.defendant"
| "projects.field.our_side.hint"
| "projects.field.our_side.none"
| "projects.field.our_side.unset"
| "projects.field.parent"
| "projects.field.parent.hint"
| "projects.field.parent.placeholder"

View File

@@ -0,0 +1,4 @@
-- Reverse t-paliad-164: drop the our_side column + check constraint.
ALTER TABLE paliad.projects DROP CONSTRAINT IF EXISTS projects_our_side_check;
ALTER TABLE paliad.projects DROP COLUMN IF EXISTS our_side;

View File

@@ -0,0 +1,42 @@
-- t-paliad-164 / m's 2026-05-08 21:42 dogfood feedback: when the user
-- selects an Akte in the Determinator (Slice 3c perspective chip),
-- the chip should already be locked to the firm's known side instead
-- of asking the user to re-pick something the project already knows.
--
-- Add a project-level our_side text column. NULL = unknown / not set
-- (default), so existing projects stay neutral and the Determinator
-- falls back to free-pick. The chip values mirror event_categories.
-- party so the Determinator can predefine the chip without mapping.
--
-- 'court' is allowed for completeness (paliad runs internal projects
-- where the firm represents the court / a tribunal-side stakeholder
-- — rare but real); the Determinator currently only acts on
-- claimant / defendant.
--
-- Idempotent so re-runs against a partially-applied state stay safe
-- (live tracker is at v71; paliad has been bitten by collisions
-- twice this week, see m/paliad#15 commits and dirac's mig 070).
ALTER TABLE paliad.projects
ADD COLUMN IF NOT EXISTS our_side text;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'projects_our_side_check'
AND conrelid = 'paliad.projects'::regclass
) THEN
ALTER TABLE paliad.projects
ADD CONSTRAINT projects_our_side_check
CHECK (our_side IS NULL
OR our_side IN ('claimant', 'defendant', 'court', 'both'));
END IF;
END $$;
COMMENT ON COLUMN paliad.projects.our_side IS
'Which side the firm represents on this project. Used by the '
'Fristenrechner Determinator (Slice 3c) to predefine the '
'perspective chip from the project context. Allowed: claimant, '
'defendant, court, both. NULL = unknown / not set; Determinator '
'falls back to free-pick.';

View File

@@ -156,6 +156,13 @@ type Project struct {
CaseNumber *string `db:"case_number" json:"case_number,omitempty"`
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
// OurSide is which side the firm represents on this project. Used
// by the Fristenrechner Determinator to predefine the perspective
// chip from the project context (t-paliad-164). NULL = unknown /
// not set; Determinator falls back to free-pick. Allowed values:
// claimant, defendant, court, both.
OurSide *string `db:"our_side" json:"our_side,omitempty"`
Metadata json.RawMessage `db:"metadata" json:"metadata"`
AISummary *string `db:"ai_summary" json:"ai_summary,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`

View File

@@ -97,7 +97,7 @@ func (s *ProjectService) DB() *sqlx.DB { return s.db }
const projectColumns = `id, type, parent_id, path, title, reference, description, status,
created_by, industry, country, billing_reference, client_number, matter_number,
netdocuments_url, patent_number, filing_date, grant_date, court, case_number,
proceeding_type_id, metadata, ai_summary, created_at, updated_at`
proceeding_type_id, our_side, metadata, ai_summary, created_at, updated_at`
// CreateProjectInput is the payload for Create.
type CreateProjectInput struct {
@@ -121,6 +121,7 @@ type CreateProjectInput struct {
Court *string `json:"court,omitempty"`
CaseNumber *string `json:"case_number,omitempty"`
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
OurSide *string `json:"our_side,omitempty"`
}
// UpdateProjectInput is the partial-update payload.
@@ -144,6 +145,7 @@ type UpdateProjectInput struct {
Court *string `json:"court,omitempty"`
CaseNumber *string `json:"case_number,omitempty"`
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
OurSide *string `json:"our_side,omitempty"`
}
// ListFilter narrows List results. Zero-value → no filter.
@@ -819,14 +821,19 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
// path is NOT NULL but the trigger populates it; supply a placeholder
// the trigger will overwrite. (BEFORE INSERT trigger rewrites path.)
if input.OurSide != nil {
if err := validateOurSide(*input.OurSide); err != nil {
return nil, err
}
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, parent_id, path, title, reference, description, status,
created_by, industry, country, billing_reference, client_number,
matter_number, netdocuments_url, patent_number, filing_date, grant_date,
court, case_number, proceeding_type_id, metadata, created_at, updated_at)
court, case_number, proceeding_type_id, our_side, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $1::text, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20, '{}'::jsonb, $21, $21)`,
$14, $15, $16, $17, $18, $19, $20, $21, '{}'::jsonb, $22, $22)`,
id, input.Type, input.ParentID,
input.Title, input.Reference, input.Description, status,
userID,
@@ -834,6 +841,7 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
input.ClientNumber, input.MatterNumber, input.NetDocumentsURL,
input.PatentNumber, input.FilingDate, input.GrantDate,
input.Court, input.CaseNumber, input.ProceedingTypeID,
nullableOurSide(input.OurSide),
now,
); err != nil {
return nil, fmt.Errorf("insert project: %w", err)
@@ -967,6 +975,12 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
if input.ProceedingTypeID != nil {
appendSetSkippable("proceeding_type_id", *input.ProceedingTypeID)
}
if input.OurSide != nil {
if err := validateOurSide(*input.OurSide); err != nil {
return nil, err
}
appendSet("our_side", nullableOurSide(input.OurSide))
}
if typeChanged {
for _, col := range typeSpecificColumns(current.Type) {
appendSet(col, nil)
@@ -1012,6 +1026,32 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
return nil, err
}
}
// our_side change: log when the value (or its set/unset state) actually
// flips. Description follows the same value-only "old → new" pattern as
// status_changed; frontend renderer maps the slugs to localized labels
// (claimant / defendant / court / both / "—" for NULL).
if input.OurSide != nil {
nextOS := strings.TrimSpace(*input.OurSide)
prevOS := ""
if current.OurSide != nil {
prevOS = *current.OurSide
}
if nextOS != prevOS {
from := prevOS
if from == "" {
from = "none"
}
to := nextOS
if to == "" {
to = "none"
}
desc := fmt.Sprintf("%s → %s", from, to)
descPtr := &desc
if err := insertProjectEvent(ctx, tx, id, userID, "our_side_changed", "Represented side changed", descPtr); err != nil {
return nil, err
}
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update project: %w", err)
}
@@ -1518,6 +1558,35 @@ func validateProjectStatus(s string) error {
return fmt.Errorf("%w: invalid status %q", ErrInvalidInput, s)
}
// validateOurSide checks the project-level "represented side" enum
// (t-paliad-164). Empty string is the explicit "clear" sentinel —
// callers pass the value as-is from the form payload, and the helper
// accepts it so an Update can null the column. The DB-level CHECK
// constraint enforces the same set; this validation gives a clearer
// error than relying on the constraint to fire.
func validateOurSide(s string) error {
switch strings.TrimSpace(s) {
case "", "claimant", "defendant", "court", "both":
return nil
}
return fmt.Errorf("%w: invalid our_side %q", ErrInvalidInput, s)
}
// nullableOurSide returns nil for an empty / whitespace value so the
// SQL driver writes NULL, otherwise the trimmed string. Mirrors the
// Update payload contract: empty string from the form clears the
// column, a value sets it.
func nullableOurSide(p *string) any {
if p == nil {
return nil
}
v := strings.TrimSpace(*p)
if v == "" {
return nil
}
return v
}
func sortByOrder(xs []models.Project, order map[uuid.UUID]int) {
// Insertion sort — ancestor lists are short (<20).
for i := 1; i < len(xs); i++ {