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:
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
4
internal/db/migrations/072_projects_our_side.down.sql
Normal file
4
internal/db/migrations/072_projects_our_side.down.sql
Normal 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;
|
||||
42
internal/db/migrations/072_projects_our_side.up.sql
Normal file
42
internal/db/migrations/072_projects_our_side.up.sql
Normal 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.';
|
||||
@@ -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"`
|
||||
|
||||
@@ -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++ {
|
||||
|
||||
Reference in New Issue
Block a user