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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user