fix(projects): three project-detail page hotfixes

m hit a cluster of three bugs on /projects/{id}/submissions:

1. 500 on /api/projects/{id}/partner-units — DerivationService.AttachedUnit
   scanned derive_unit_roles (text[]) into a plain []string. sqlx returns
   []uint8 for array columns without an adapter. Swap to pq.StringArray
   (same shape as the other array-scanned types in the codebase).

2. 404 on /projects/{id}/submissions — every other project-tab path
   (history, deadlines, team, checklists, …) is registered in handlers.go
   routing all to handleProjectsDetailPage so deep links work, but the
   submissions tab added in t-paliad-230 never got the matching route.
   Result: m navigates to the share-able URL and gets the 404 chrome.
   Add the missing route entry.

3. Create / update project rejected by projekte_client_number_check —
   the CHECK is `client_number IS NULL OR matches '^[0-9]{6}$'`, but the
   form sends empty string "" for an unset field. The Create path passed
   `*input.ClientNumber` raw; the Update path's appendSetSkippable did
   the same. Both now route through a new nullableTrimmed helper that
   coerces empty/whitespace to nil → SQL NULL → constraint accepts.
   matter_number gets the same treatment for symmetry.

Verified the SQL by EXPLAIN against the live DB on the today-filter
hotfix (becf4f0). These three fixes only change Go-side type / nil-
coercion, so no SQL-syntax exposure.
This commit is contained in:
mAi
2026-05-22 15:48:47 +02:00
parent d088de95eb
commit 65308651dd
3 changed files with 36 additions and 9 deletions

View File

@@ -466,6 +466,10 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /projects/{id}/notes", gateOnboarded(handleProjectsDetailPage))
protected.HandleFunc("GET /projects/{id}/checklists", gateOnboarded(handleProjectsDetailPage))
protected.HandleFunc("GET /projects/{id}/team", gateOnboarded(handleProjectsDetailPage))
// t-paliad-230 Schriftsätze tab — same shape as every other tab above.
// Without this route the deep-link 404s; the tab still works via
// in-page click since it just toggles a panel.
protected.HandleFunc("GET /projects/{id}/submissions", gateOnboarded(handleProjectsDetailPage))
// t-paliad-177 — standalone Project Timeline / Chart page (Slice 1).
// Horizontal SVG renderer mounted client-side; reuses the existing
// /api/projects/{id}/timeline JSON endpoint for data.

View File

@@ -42,12 +42,15 @@ func NewDerivationService(db *sqlx.DB, projects *ProjectService, partnerUnit *Pa
// the configured derive_unit_roles. The frontend renders this on the
// /projects/{id}/settings/team Partner Units section.
type AttachedUnit struct {
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
PartnerUnitID uuid.UUID `db:"partner_unit_id" json:"partner_unit_id"`
UnitName string `db:"unit_name" json:"unit_name"`
DeriveUnitRoles []string `db:"derive_unit_roles" json:"derive_unit_roles"`
DeriveGrantsAuthority bool `db:"derive_grants_authority" json:"derive_grants_authority"`
DerivedMemberCount int `db:"derived_member_count" json:"derived_member_count"`
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
PartnerUnitID uuid.UUID `db:"partner_unit_id" json:"partner_unit_id"`
UnitName string `db:"unit_name" json:"unit_name"`
// derive_unit_roles is a Postgres text[]; sqlx returns it as []byte
// without an array adapter, so we use pq.StringArray for the scan
// and convert to []string in JSON via a tiny ergonomics wrapper.
DeriveUnitRoles pq.StringArray `db:"derive_unit_roles" json:"derive_unit_roles"`
DeriveGrantsAuthority bool `db:"derive_grants_authority" json:"derive_grants_authority"`
DerivedMemberCount int `db:"derived_member_count" json:"derived_member_count"`
}
// DerivedMembership is one (unit, role) pair through which a user currently

View File

@@ -924,7 +924,7 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
input.Title, input.Reference, input.Description, status,
userID,
input.Industry, input.Country, input.BillingReference,
input.ClientNumber, input.MatterNumber, input.NetDocumentsURL,
nullableTrimmed(input.ClientNumber), nullableTrimmed(input.MatterNumber), input.NetDocumentsURL,
input.PatentNumber, input.FilingDate, input.GrantDate,
input.Court, input.CaseNumber, input.ProceedingTypeID,
nullableOurSide(input.OurSide),
@@ -1038,10 +1038,13 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
appendSet("billing_reference", *input.BillingReference)
}
if input.ClientNumber != nil {
appendSetSkippable("client_number", *input.ClientNumber)
// Coerce empty string → NULL so the
// projekte_client_number_check ('^[0-9]{6}$' OR NULL) accepts a
// blanked field (m, 2026-05-22 — "I cant add a project").
appendSetSkippable("client_number", nullableTrimmed(input.ClientNumber))
}
if input.MatterNumber != nil {
appendSet("matter_number", *input.MatterNumber)
appendSet("matter_number", nullableTrimmed(input.MatterNumber))
}
if input.NetDocumentsURL != nil {
appendSet("netdocuments_url", *input.NetDocumentsURL)
@@ -2033,6 +2036,23 @@ func nullableInstanceLevel(p *string) any {
return s
}
// nullableTrimmed returns nil for an empty / whitespace value so the SQL
// driver writes NULL, otherwise the trimmed string. Used for nullable
// text columns whose constraints reject the empty string (e.g.
// projekte_client_number_check requires NULL or 6 digits — a blank
// client_number field on the project form would otherwise fail the
// constraint instead of being treated as "not set").
func nullableTrimmed(p *string) any {
if p == nil {
return nil
}
s := strings.TrimSpace(*p)
if s == "" {
return nil
}
return 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