diff --git a/.gitignore b/.gitignore
index 97f0076..8adea7c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,4 +8,5 @@ postman/
.worktrees/
docs/superpowers/
.claude/
-!.claude/settings.json
\ No newline at end of file
+!.claude/settings.json
+graphify-out/
diff --git a/README.md b/README.md
index e013dfe..e48689d 100644
--- a/README.md
+++ b/README.md
@@ -54,11 +54,17 @@ For a deeper tour, start at the per-package READMEs linked in the [Repository la
**`Place`**: a physical location with coordinates, category, OSM metadata, and an optional parent (e.g. a shop inside a mall). External references (Wheelmap, future sources) attach as an `external_ids` JSONB map. See [`pkg/models`](./pkg/models).
-**`AccessibilityProfile`**: attached to a place. Contains:
-- `overall_status`: `accessible` | `limited` | `inaccessible` | `unknown`
-- `components`: array of typed features (`entrance`, `restroom`, `parking`, `elevator`, `other`)
+**`AccessibilityProfile`**: attached to a place. Named component fields hold measured facts:
-Each component carries its own `overall_status`, raw property values (widths, heights, booleans), and machine-computed `audit_flags` (e.g. `"narrow width (0.8m required)"`). Child places inherit parent components for any type they don't provide themselves, so a shop inside a mall inherits the mall's parking. See [`internal/a11y`](./internal/a11y).
+| Field | What it captures |
+|---|---|
+| `entrance` | Level access, ramp, door width, door type, intercom |
+| `pathways` | Width, surface, kerb-free, steps |
+| `restroom` | Accessible toilet, grab rails, door width, turning radius |
+| `parking` | Disabled spaces, count, distance to entrance |
+| `elevator` | Cabin dimensions, braille, audio |
+
+Each component carries typed property values and machine-computed `audit_flags` written on every save (e.g. `"entrance_narrow_width"`). `source_reports` stores raw opinions from external sources (e.g. OSM `wheelchair=yes`) verbatim — no server-side judgment is applied. Child places inherit parent components for any slot they don't provide themselves, so a shop inside a mall inherits the mall's parking. See [`internal/a11y`](./internal/a11y).
**`ExternalRef`**: confidence-scored attachment of an external source's ID (Wheelmap etc.) to a Place. Produced by the [identity matcher](./internal/identity).
@@ -92,15 +98,13 @@ flowchart LR
Schema -- no --> R400[400 validation failed]
Schema -- yes --> Struct{Structural
internal/validation}
Struct -- errors --> R400
- Struct -- ok --> Engine[a11y engine]
- Engine --> Conflicts{Hard conflicts?}
- Conflicts -- yes --> R422[422 conflicts]
- Conflicts -- no --> Persist[(persist + audit log)]
+ Struct -- ok --> Engine[a11y engine
computes audit flags]
+ Engine --> Persist[(persist + audit log)]
```
- **Spec validation** at the middleware layer catches type, format, and required-field errors from the OpenAPI spec.
- **Structural validation** ([`internal/validation`](./internal/validation)) catches multi-field business validity: mutually exclusive query params, range checks, UUID format.
-- **The a11y engine** ([`internal/a11y`](./internal/a11y)) computes audit flags from component properties, then detects *hard* conflicts. Submitting `overall_status: accessible` while also submitting `has_step: true, has_ramp: false` returns 422. Threshold flags (narrow width, missing braille) are stored but never block.
+- **The a11y engine** ([`internal/a11y`](./internal/a11y)) computes `audit_flags` from component property values and writes them alongside the record. Flags are informational — no write is ever rejected on accessibility grounds.
The API never decides accessibility. It records facts.
@@ -149,7 +153,7 @@ See [`cmd/ingestion`](./cmd/ingestion) for the full pipeline diagrams.
| [`cmd/api/`](./cmd/api) | REST API binary: routing, auth, OpenAPI strict server wiring |
| [`cmd/ingestion/`](./cmd/ingestion) | Batch ingestion CLI: canonical and external pipelines, batcher, sweep wiring |
| [`pkg/models/`](./pkg/models) | Domain types shared across binaries |
-| [`internal/a11y/`](./internal/a11y) | Accessibility rule engine: audit flag computation, conflict detection, parent inheritance |
+| [`internal/a11y/`](./internal/a11y) | Accessibility rule engine: audit flag computation, parent component inheritance |
| [`internal/api/v1/`](./internal/api/v1) | Code-generated OpenAPI types and strict server interface (do not edit by hand) |
| `internal/audit/` | Append-only write log of "who created/modified what" |
| [`internal/db/`](./internal/db) | GORM connection, migration runner, PostGIS index setup |
diff --git a/api/openapi.yaml b/api/openapi.yaml
index e578483..b934247 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -24,13 +24,13 @@ components:
# ── Enum helpers ──────────────────────────────────────────────────────────
- A11yStatus:
+ DoorType:
type: string
- enum: [accessible, limited, inaccessible, unknown]
+ enum: [automatic, manual, revolving, none]
- A11yComponentType:
+ SurfaceType:
type: string
- enum: [entrance, restroom, parking, elevator, other]
+ enum: [asphalt, paving_stones, cobblestone, gravel, concrete, wood, carpet, tiles]
Category:
type: string
@@ -58,51 +58,118 @@ components:
# x-go-type tells oapi-codegen "don't generate a new struct, use this one".
# The spec schema still drives middleware validation; the Go type is imported.
- EntranceProperties:
- x-go-type: models.EntranceProperties
+ DoorProps:
+ x-go-type: models.DoorProps
x-go-type-import:
name: models
path: github.com/InWheelOrg/inwheel-api/pkg/models
type: object
properties:
+ type:
+ $ref: "#/components/schemas/DoorType"
width:
type: number
format: double
minimum: 0
maximum: 10
- has_ramp:
+
+ EntranceProps:
+ x-go-type: models.EntranceProps
+ x-go-type-import:
+ name: models
+ path: github.com/InWheelOrg/inwheel-api/pkg/models
+ type: object
+ properties:
+ is_level:
type: boolean
- is_automatic:
+ has_fixed_ramp:
type: boolean
- has_step:
+ has_removable_ramp:
type: boolean
- step_height:
+ slope_percent:
type: number
format: double
minimum: 0
- maximum: 1
+ maximum: 100
+ width:
+ type: number
+ format: double
+ minimum: 0
+ maximum: 10
+ door:
+ $ref: "#/components/schemas/DoorProps"
+ has_intercom:
+ type: boolean
+ audit_flags:
+ type: array
+ items:
+ type: string
+ readOnly: true
- RestroomProperties:
- x-go-type: models.RestroomProperties
+ PathwayProps:
+ x-go-type: models.PathwayProps
x-go-type-import:
name: models
path: github.com/InWheelOrg/inwheel-api/pkg/models
type: object
properties:
- wheelchair_accessible:
+ width:
+ type: number
+ format: double
+ minimum: 0
+ maximum: 20
+ surface:
+ $ref: "#/components/schemas/SurfaceType"
+ is_kerbstone_free:
type: boolean
- grab_rails:
+ has_steps:
type: boolean
- changing_table:
+ audit_flags:
+ type: array
+ items:
+ type: string
+ readOnly: true
+
+ RestroomProps:
+ x-go-type: models.RestroomProps
+ x-go-type-import:
+ name: models
+ path: github.com/InWheelOrg/inwheel-api/pkg/models
+ type: object
+ properties:
+ is_accessible:
type: boolean
door_width:
type: number
format: double
minimum: 0
maximum: 10
+ turning_radius:
+ type: number
+ format: double
+ minimum: 0
+ maximum: 10
+ has_grab_rails:
+ type: boolean
+ has_roll_in_shower:
+ type: boolean
+ toilet_seat_height:
+ type: number
+ format: double
+ minimum: 0
+ maximum: 1
+ has_emergency_pull:
+ type: boolean
+ has_changing_table:
+ type: boolean
+ audit_flags:
+ type: array
+ items:
+ type: string
+ readOnly: true
- ParkingProperties:
- x-go-type: models.ParkingProperties
+ ParkingProps:
+ x-go-type: models.ParkingProps
x-go-type-import:
name: models
path: github.com/InWheelOrg/inwheel-api/pkg/models
@@ -114,9 +181,26 @@ components:
type: integer
minimum: 0
maximum: 10000
+ distance_to_entrance:
+ type: number
+ format: double
+ minimum: 0
+ maximum: 10000
+ width:
+ type: number
+ format: double
+ minimum: 0
+ maximum: 20
+ has_dedicated_signage:
+ type: boolean
+ audit_flags:
+ type: array
+ items:
+ type: string
+ readOnly: true
- ElevatorProperties:
- x-go-type: models.ElevatorProperties
+ ElevatorProps:
+ x-go-type: models.ElevatorProps
x-go-type-import:
name: models
path: github.com/InWheelOrg/inwheel-api/pkg/models
@@ -132,43 +216,34 @@ components:
format: double
minimum: 0
maximum: 10
- braille:
+ door_width:
+ type: number
+ format: double
+ minimum: 0
+ maximum: 10
+ has_braille:
type: boolean
- audio:
+ has_audio:
type: boolean
-
- A11yComponent:
- x-go-type: models.A11yComponent
- x-go-type-import:
- name: models
- path: github.com/InWheelOrg/inwheel-api/pkg/models
- type: object
- required: [type]
- properties:
- type:
- $ref: "#/components/schemas/A11yComponentType"
- is_inherited:
- type: boolean
- source_id:
- type: string
- format: uuid
- overall_status:
- $ref: "#/components/schemas/A11yStatus"
audit_flags:
type: array
items:
type: string
- entrance:
- $ref: "#/components/schemas/EntranceProperties"
- restroom:
- $ref: "#/components/schemas/RestroomProperties"
- parking:
- $ref: "#/components/schemas/ParkingProperties"
- elevator:
- $ref: "#/components/schemas/ElevatorProperties"
- metadata:
- type: object
- additionalProperties: true
+ readOnly: true
+
+ SourceReport:
+ type: object
+ required: [source, value, recorded_at]
+ properties:
+ source:
+ type: string
+ example: "osm"
+ value:
+ type: string
+ example: "yes"
+ recorded_at:
+ type: string
+ format: date-time
AccessibilityProfile:
x-go-type: models.AccessibilityProfile
@@ -176,7 +251,6 @@ components:
name: models
path: github.com/InWheelOrg/inwheel-api/pkg/models
type: object
- required: [overall_status]
properties:
id:
type: string
@@ -186,12 +260,20 @@ components:
type: string
format: uuid
readOnly: true
- overall_status:
- $ref: "#/components/schemas/A11yStatus"
- components:
+ source_reports:
type: array
items:
- $ref: "#/components/schemas/A11yComponent"
+ $ref: "#/components/schemas/SourceReport"
+ entrance:
+ $ref: "#/components/schemas/EntranceProps"
+ pathways:
+ $ref: "#/components/schemas/PathwayProps"
+ restroom:
+ $ref: "#/components/schemas/RestroomProps"
+ parking:
+ $ref: "#/components/schemas/ParkingProps"
+ elevator:
+ $ref: "#/components/schemas/ElevatorProps"
updated_at:
type: string
format: date-time
@@ -331,27 +413,6 @@ components:
items:
$ref: "#/components/schemas/FieldError"
- ConflictItem:
- type: object
- required: [component, reason]
- properties:
- component:
- type: string
- reason:
- type: string
-
- ConflictError:
- type: object
- required: [error, conflicts]
- properties:
- error:
- type: string
- example: "accessibility data contains conflicts"
- conflicts:
- type: array
- items:
- $ref: "#/components/schemas/ConflictItem"
-
ErrorResponse:
type: object
required: [error]
@@ -504,12 +565,6 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
- "422":
- description: Accessibility data contains conflicts
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/ConflictError"
/places/{id}:
get:
@@ -553,8 +608,7 @@ paths:
summary: Update or create an accessibility profile
description: |
Upserts the accessibility profile for the given place.
- Audit flags are computed from submitted component properties.
- Returns 422 if any component's status directly contradicts its audit flags.
+ Audit flags are computed server-side from submitted property values.
security:
- ApiKeyAuth: []
parameters:
@@ -595,12 +649,6 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
- "422":
- description: Accessibility data contains conflicts
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/ConflictError"
# ── Auth ─────────────────────────────────────────────────────────────────
diff --git a/cmd/api/auth_integration_test.go b/cmd/api/auth_integration_test.go
index f9b548e..dd5bdbe 100644
--- a/cmd/api/auth_integration_test.go
+++ b/cmd/api/auth_integration_test.go
@@ -246,7 +246,7 @@ func TestHandlePatchAccessibility_WithValidKey(t *testing.T) {
rawKey := registerKey(t, srv, "patcher@example.com")
- profile := models.AccessibilityProfile{OverallStatus: models.StatusAccessible}
+ profile := models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}}
body, _ := json.Marshal(profile)
r := httptest.NewRequest(http.MethodPatch, "/v1/places/"+place.ID+"/accessibility", bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
diff --git a/cmd/api/integration_test.go b/cmd/api/integration_test.go
index caaf609..5d41326 100644
--- a/cmd/api/integration_test.go
+++ b/cmd/api/integration_test.go
@@ -35,7 +35,6 @@ func TestMain(m *testing.M) {
os.Exit(run(m))
}
-// run is extracted from TestMain so defer-based cleanup executes before os.Exit.
func run(m *testing.M) int {
ctx := context.Background()
var cleanup func()
@@ -50,12 +49,13 @@ func run(m *testing.M) int {
return m.Run()
}
-// truncate clears all test data between tests to prevent state bleed.
func truncate(t *testing.T) {
t.Helper()
testDB.Exec("TRUNCATE places, accessibility_profiles, api_keys, write_logs CASCADE")
}
+func boolPtr(b bool) *bool { return &b }
+
func newTestServer(t *testing.T) *Server {
t.Helper()
ctx := t.Context()
@@ -79,7 +79,7 @@ func TestHandlePostPlace_WithAccessibility(t *testing.T) {
Rank: models.RankEstablishment,
Source: "test",
Accessibility: &models.AccessibilityProfile{
- OverallStatus: models.StatusAccessible,
+ Entrance: &models.EntranceProps{IsLevel: boolPtr(true)},
},
})
@@ -129,64 +129,10 @@ func TestHandlePostPlace_WithoutAccessibility(t *testing.T) {
}
}
-func TestHandlePostPlace_HardConflictReturns422(t *testing.T) {
- t.Cleanup(func() { truncate(t) })
-
- // step with no ramp + accessible = hard self-contradiction
- stepHeight := 0.1
- hasStep := true
- hasRamp := false
- body, _ := json.Marshal(models.Place{
- Name: "Conflicting Cafe",
- Lat: 52.5,
- Lng: 13.4,
- Category: models.CategoryCafe,
- Rank: models.RankEstablishment,
- Source: "test",
- Accessibility: &models.AccessibilityProfile{
- OverallStatus: models.StatusAccessible,
- Components: models.A11yComponents{
- {
- Type: models.ComponentEntrance,
- OverallStatus: models.StatusAccessible,
- Entrance: &models.EntranceProperties{
- HasStep: &hasStep,
- StepHeight: &stepHeight,
- HasRamp: &hasRamp,
- },
- },
- },
- },
- })
-
- r := httptest.NewRequest(http.MethodPost, "/v1/places", bytes.NewReader(body))
- r.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- handlerNoAuth(t, newTestServer(t)).ServeHTTP(w, r)
-
- if w.Code != http.StatusUnprocessableEntity {
- t.Fatalf("status = %d, want 422; body: %s", w.Code, w.Body.String())
- }
-
- var resp struct {
- Error string `json:"error"`
- Conflicts []struct {
- Component string `json:"component"`
- Reason string `json:"reason"`
- } `json:"conflicts"`
- }
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("decode 422 response: %v", err)
- }
- if len(resp.Conflicts) == 0 {
- t.Error("expected conflicts in 422 response body")
- }
-}
-
func TestHandlePostPlace_InformationalFlagsAllowed(t *testing.T) {
t.Cleanup(func() { truncate(t) })
- // narrow width is informational only — should not block the write
+ // narrow width sets an audit flag but must not block the write
narrowWidth := 0.75
body, _ := json.Marshal(models.Place{
Name: "Narrow Cafe",
@@ -196,14 +142,7 @@ func TestHandlePostPlace_InformationalFlagsAllowed(t *testing.T) {
Rank: models.RankEstablishment,
Source: "test",
Accessibility: &models.AccessibilityProfile{
- OverallStatus: models.StatusAccessible,
- Components: models.A11yComponents{
- {
- Type: models.ComponentEntrance,
- OverallStatus: models.StatusAccessible,
- Entrance: &models.EntranceProperties{Width: &narrowWidth},
- },
- },
+ Entrance: &models.EntranceProps{Width: &narrowWidth},
},
})
@@ -216,21 +155,19 @@ func TestHandlePostPlace_InformationalFlagsAllowed(t *testing.T) {
t.Fatalf("status = %d, want 201; body: %s", w.Code, w.Body.String())
}
- // Verify the flag was stored
var place models.Place
testDB.Preload("Accessibility").Last(&place)
- if place.Accessibility == nil || len(place.Accessibility.Components) == 0 {
- t.Fatal("expected accessibility with components")
+ if place.Accessibility == nil || place.Accessibility.Entrance == nil {
+ t.Fatal("expected accessibility with entrance component")
}
- flags := place.Accessibility.Components[0].AuditFlags
found := false
- for _, f := range flags {
+ for _, f := range place.Accessibility.Entrance.AuditFlags {
if f == "narrow width (0.8m required)" {
found = true
}
}
if !found {
- t.Errorf("expected narrow width flag to be stored, got flags: %v", flags)
+ t.Errorf("expected narrow width flag to be stored, got flags: %v", place.Accessibility.Entrance.AuditFlags)
}
}
@@ -238,7 +175,7 @@ func TestHandlePatchAccessibility_PlaceNotFound(t *testing.T) {
t.Cleanup(func() { truncate(t) })
const nonExistentID = "00000000-0000-0000-0000-000000000000"
- body, _ := json.Marshal(models.AccessibilityProfile{OverallStatus: models.StatusAccessible})
+ body, _ := json.Marshal(models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}})
r := httptest.NewRequest(http.MethodPatch, "/v1/places/"+nonExistentID+"/accessibility", bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
@@ -258,7 +195,7 @@ func TestHandlePatchAccessibility_CreatePath(t *testing.T) {
testDB.Create(&place)
body, _ := json.Marshal(models.AccessibilityProfile{
- OverallStatus: models.StatusLimited,
+ Entrance: &models.EntranceProps{IsLevel: boolPtr(false)},
})
r := httptest.NewRequest(http.MethodPatch, "/v1/places/"+place.ID+"/accessibility", bytes.NewReader(body))
@@ -277,8 +214,8 @@ func TestHandlePatchAccessibility_CreatePath(t *testing.T) {
if profile.PlaceID != place.ID {
t.Errorf("PlaceID = %s, want %s", profile.PlaceID, place.ID)
}
- if profile.OverallStatus != models.StatusLimited {
- t.Errorf("OverallStatus = %s, want limited", profile.OverallStatus)
+ if profile.Entrance == nil || profile.Entrance.IsLevel == nil || *profile.Entrance.IsLevel {
+ t.Error("expected Entrance.IsLevel=false")
}
}
@@ -293,13 +230,13 @@ func TestHandlePatchAccessibility_UpdatesExistingProfile(t *testing.T) {
Rank: models.RankEstablishment,
Source: "test",
Accessibility: &models.AccessibilityProfile{
- OverallStatus: models.StatusAccessible,
+ Entrance: &models.EntranceProps{IsLevel: boolPtr(true)},
},
}
testDB.Create(&place)
body, _ := json.Marshal(models.AccessibilityProfile{
- OverallStatus: models.StatusLimited,
+ Entrance: &models.EntranceProps{IsLevel: boolPtr(false)},
})
r := httptest.NewRequest(http.MethodPatch, "/v1/places/"+place.ID+"/accessibility", bytes.NewReader(body))
@@ -315,54 +252,8 @@ func TestHandlePatchAccessibility_UpdatesExistingProfile(t *testing.T) {
var profile models.AccessibilityProfile
testDB.Where("place_id = ?", place.ID).First(&profile)
- if profile.OverallStatus != models.StatusLimited {
- t.Errorf("OverallStatus = %s, want limited", profile.OverallStatus)
- }
-}
-
-func TestHandlePatchAccessibility_ConflictReturns422(t *testing.T) {
- t.Cleanup(func() { truncate(t) })
-
- place := models.Place{Name: "Test Place", Lat: 52.5, Lng: 13.4, Category: models.CategoryCafe, Rank: models.RankEstablishment, Source: "test"}
- testDB.Create(&place)
-
- stepHeight := 0.1
- hasStep := true
- hasRamp := false
- body, _ := json.Marshal(models.AccessibilityProfile{
- OverallStatus: models.StatusAccessible,
- Components: models.A11yComponents{
- {
- Type: models.ComponentEntrance,
- OverallStatus: models.StatusAccessible,
- Entrance: &models.EntranceProperties{
- HasStep: &hasStep,
- StepHeight: &stepHeight,
- HasRamp: &hasRamp,
- },
- },
- },
- })
-
- r := httptest.NewRequest(http.MethodPatch, "/v1/places/"+place.ID+"/accessibility", bytes.NewReader(body))
- r.Header.Set("Content-Type", "application/json")
- r.SetPathValue("id", place.ID)
- w := httptest.NewRecorder()
- handlerNoAuth(t, newTestServer(t)).ServeHTTP(w, r)
-
- if w.Code != http.StatusUnprocessableEntity {
- t.Fatalf("status = %d, want 422; body: %s", w.Code, w.Body.String())
- }
-
- var resp struct {
- Error string `json:"error"`
- Conflicts []any `json:"conflicts"`
- }
- if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
- t.Fatalf("decode 422 response: %v", err)
- }
- if len(resp.Conflicts) == 0 {
- t.Error("expected conflicts in 422 response body")
+ if profile.Entrance == nil || profile.Entrance.IsLevel == nil || *profile.Entrance.IsLevel {
+ t.Error("expected Entrance.IsLevel=false after update")
}
}
@@ -377,7 +268,7 @@ func TestHandleGetPlace_ReturnsPlaceWithAccessibility(t *testing.T) {
Rank: models.RankEstablishment,
Source: "test",
Accessibility: &models.AccessibilityProfile{
- OverallStatus: models.StatusAccessible,
+ Entrance: &models.EntranceProps{IsLevel: boolPtr(true)},
},
}
testDB.Create(&place)
@@ -417,7 +308,6 @@ func TestHandleGetPlace_NotFound(t *testing.T) {
func TestHandleGetPlace_InheritsParentComponents(t *testing.T) {
t.Cleanup(func() { truncate(t) })
- hasSpaces := true
parent := models.Place{
Name: "Test Mall",
Lat: 52.5,
@@ -426,10 +316,7 @@ func TestHandleGetPlace_InheritsParentComponents(t *testing.T) {
Rank: models.RankEstablishment,
Source: "test",
Accessibility: &models.AccessibilityProfile{
- OverallStatus: models.StatusAccessible,
- Components: models.A11yComponents{
- {Type: models.ComponentParking, OverallStatus: models.StatusAccessible, Parking: &models.ParkingProperties{HasDisabledSpaces: &hasSpaces}},
- },
+ Parking: &models.ParkingProps{HasDisabledSpaces: boolPtr(true)},
},
}
testDB.Create(&parent)
@@ -461,22 +348,14 @@ func TestHandleGetPlace_InheritsParentComponents(t *testing.T) {
if got.Accessibility == nil {
t.Fatal("expected accessibility profile in response")
}
-
- var inherited *models.A11yComponent
- for i := range got.Accessibility.Components {
- if got.Accessibility.Components[i].Type == models.ComponentParking {
- inherited = &got.Accessibility.Components[i]
- break
- }
- }
- if inherited == nil {
- t.Fatal("expected inherited parking component in effective profile")
+ if got.Accessibility.Parking == nil {
+ t.Fatal("expected inherited parking in effective profile")
}
- if !inherited.IsInherited {
- t.Error("parking component should be marked is_inherited=true")
+ if !got.Accessibility.Parking.IsInherited {
+ t.Error("parking should be marked is_inherited=true")
}
- if inherited.SourceID != parent.ID {
- t.Errorf("source_id = %q, want %q", inherited.SourceID, parent.ID)
+ if got.Accessibility.Parking.SourceID != parent.ID {
+ t.Errorf("source_id = %q, want %q", got.Accessibility.Parking.SourceID, parent.ID)
}
}
@@ -491,10 +370,7 @@ func TestHandleGetPlace_ChildOverridesParentComponent(t *testing.T) {
Rank: models.RankEstablishment,
Source: "test",
Accessibility: &models.AccessibilityProfile{
- OverallStatus: models.StatusAccessible,
- Components: models.A11yComponents{
- {Type: models.ComponentEntrance, OverallStatus: models.StatusAccessible},
- },
+ Entrance: &models.EntranceProps{IsLevel: boolPtr(true)},
},
}
testDB.Create(&parent)
@@ -508,10 +384,7 @@ func TestHandleGetPlace_ChildOverridesParentComponent(t *testing.T) {
Source: "test",
ParentID: &parent.ID,
Accessibility: &models.AccessibilityProfile{
- OverallStatus: models.StatusInaccessible,
- Components: models.A11yComponents{
- {Type: models.ComponentEntrance, OverallStatus: models.StatusInaccessible},
- },
+ Entrance: &models.EntranceProps{IsLevel: boolPtr(false)},
},
}
testDB.Create(&child)
@@ -529,24 +402,14 @@ func TestHandleGetPlace_ChildOverridesParentComponent(t *testing.T) {
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
t.Fatalf("decode response: %v", err)
}
- if got.Accessibility == nil {
- t.Fatal("expected accessibility profile in response")
+ if got.Accessibility == nil || got.Accessibility.Entrance == nil {
+ t.Fatal("expected entrance in response")
}
-
- var entranceCount int
- for _, c := range got.Accessibility.Components {
- if c.Type == models.ComponentEntrance {
- entranceCount++
- if c.IsInherited {
- t.Error("entrance should not be inherited — child owns it")
- }
- if c.OverallStatus != models.StatusInaccessible {
- t.Errorf("entrance status = %q, want inaccessible", c.OverallStatus)
- }
- }
+ if got.Accessibility.Entrance.IsInherited {
+ t.Error("entrance should not be inherited; child owns it")
}
- if entranceCount != 1 {
- t.Errorf("expected exactly 1 entrance component, got %d", entranceCount)
+ if got.Accessibility.Entrance.IsLevel == nil || *got.Accessibility.Entrance.IsLevel {
+ t.Error("expected child's is_level=false to override parent's is_level=true")
}
}
@@ -561,10 +424,7 @@ func TestHandleGetPlace_NoParentReturnsRawData(t *testing.T) {
Rank: models.RankEstablishment,
Source: "test",
Accessibility: &models.AccessibilityProfile{
- OverallStatus: models.StatusAccessible,
- Components: models.A11yComponents{
- {Type: models.ComponentEntrance, OverallStatus: models.StatusAccessible},
- },
+ Entrance: &models.EntranceProps{IsLevel: boolPtr(true)},
},
}
testDB.Create(&place)
@@ -582,14 +442,11 @@ func TestHandleGetPlace_NoParentReturnsRawData(t *testing.T) {
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
t.Fatalf("decode response: %v", err)
}
- if got.Accessibility == nil {
- t.Fatal("expected accessibility profile")
- }
- if len(got.Accessibility.Components) != 1 {
- t.Errorf("expected 1 component, got %d", len(got.Accessibility.Components))
+ if got.Accessibility == nil || got.Accessibility.Entrance == nil {
+ t.Fatal("expected entrance in accessibility profile")
}
- if got.Accessibility.Components[0].IsInherited {
- t.Error("component should not be inherited for place with no parent")
+ if got.Accessibility.Entrance.IsInherited {
+ t.Error("entrance should not be inherited for place with no parent")
}
}
@@ -692,7 +549,7 @@ func TestHandlePatchAccessibility_UserVerifiedDefaultsFalse(t *testing.T) {
place := models.Place{Name: "Test Cafe", Lat: 52.5, Lng: 13.4, Category: models.CategoryCafe, Rank: models.RankEstablishment, Source: "test"}
testDB.Create(&place)
- body, _ := json.Marshal(models.AccessibilityProfile{OverallStatus: models.StatusAccessible})
+ body, _ := json.Marshal(models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}})
r := httptest.NewRequest(http.MethodPatch, "/v1/places/"+place.ID+"/accessibility", bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
diff --git a/cmd/api/main.go b/cmd/api/main.go
index e9c6df7..046b64c 100644
--- a/cmd/api/main.go
+++ b/cmd/api/main.go
@@ -257,9 +257,6 @@ func (s *Server) CreatePlace(ctx context.Context, request apiv1.CreatePlaceReque
}
place.Accessibility.SubmittedAt = &now
s.engine.WithAuditFlags(place.Accessibility)
- if conflicts := s.engine.DetectConflicts(place.Accessibility); len(conflicts) > 0 {
- return apiv1.CreatePlace422JSONResponse(conflictError(conflicts)), nil
- }
}
if err := s.db.Create(&place).Error; err != nil {
@@ -303,9 +300,6 @@ func (s *Server) PatchPlaceAccessibility(ctx context.Context, request apiv1.Patc
keyID := middleware.APIKeyIDFromCtx(ctx)
s.engine.WithAuditFlags(&input)
- if conflicts := s.engine.DetectConflicts(&input); len(conflicts) > 0 {
- return apiv1.PatchPlaceAccessibility422JSONResponse(conflictError(conflicts)), nil
- }
now := time.Now()
if keyID != "" {
@@ -361,14 +355,6 @@ func validationError(errs []validation.FieldError) apiv1.ValidationError {
return apiv1.ValidationError{Error: "validation failed", Fields: fields}
}
-func conflictError(conflicts []a11y.Conflict) apiv1.ConflictError {
- items := make([]apiv1.ConflictItem, len(conflicts))
- for i, c := range conflicts {
- items[i] = apiv1.ConflictItem{Component: string(c.Component), Reason: c.Reason}
- }
- return apiv1.ConflictError{Error: "accessibility data contains conflicts", Conflicts: items}
-}
-
func writeJSON(w http.ResponseWriter, data any, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
diff --git a/cmd/api/places_integration_test.go b/cmd/api/places_integration_test.go
index b0aca62..c770537 100644
--- a/cmd/api/places_integration_test.go
+++ b/cmd/api/places_integration_test.go
@@ -335,7 +335,7 @@ func TestHandleGetPlaces_PreloadsAccessibility(t *testing.T) {
Rank: models.RankEstablishment,
Source: "test",
Accessibility: &models.AccessibilityProfile{
- OverallStatus: models.StatusAccessible,
+ Entrance: &models.EntranceProps{IsLevel: boolPtr(true)},
},
}
testDB.Create(&p)
diff --git a/cmd/ingestion/batcher.go b/cmd/ingestion/batcher.go
index c1de556..726b6ac 100644
--- a/cmd/ingestion/batcher.go
+++ b/cmd/ingestion/batcher.go
@@ -7,7 +7,6 @@ package main
import (
"context"
- "fmt"
"github.com/InWheelOrg/inwheel-api/pkg/models"
)
@@ -15,17 +14,15 @@ import (
// batcher buffers (place, profile) pairs, writes places first via flush, then
// writes profiles using the UUIDs returned by flush. Single-goroutine.
type batcher struct {
- size int
- flush func(context.Context, []models.Place) error
- writeProfile func(context.Context, string, *models.AccessibilityProfile) (bool, error)
- downgradeProfile func(*models.AccessibilityProfile) int
+ size int
+ flush func(context.Context, []models.Place) error
+ writeProfile func(context.Context, string, *models.AccessibilityProfile) (bool, error)
- buffer []models.Place
- pendingProfiles []*models.AccessibilityProfile
- written int
- touchedIDs []string
- profilesWritten int
- profilesDowngraded int
+ buffer []models.Place
+ pendingProfiles []*models.AccessibilityProfile
+ written int
+ touchedIDs []string
+ profilesWritten int
}
func (b *batcher) sink(ctx context.Context, p models.Place, profile *models.AccessibilityProfile) error {
@@ -41,9 +38,6 @@ func (b *batcher) flushNow(ctx context.Context) error {
if len(b.buffer) == 0 {
return nil
}
- if b.writeProfile != nil && b.downgradeProfile == nil {
- return fmt.Errorf("batcher: writeProfile set without downgradeProfile")
- }
if err := b.flush(ctx, b.buffer); err != nil {
return err
}
@@ -57,9 +51,6 @@ func (b *batcher) flushNow(ctx context.Context) error {
if profile == nil || b.writeProfile == nil {
continue
}
- if b.downgradeProfile != nil {
- b.profilesDowngraded += b.downgradeProfile(profile)
- }
ok, err := b.writeProfile(ctx, p.ID, profile)
if err != nil {
return err
diff --git a/cmd/ingestion/batcher_test.go b/cmd/ingestion/batcher_test.go
index 03d73b1..c09c2fd 100644
--- a/cmd/ingestion/batcher_test.go
+++ b/cmd/ingestion/batcher_test.go
@@ -161,13 +161,12 @@ func TestBatcher_WritesProfileWhenAttached(t *testing.T) {
captured = append(captured, capturedProfile{placeID: placeID, profile: p})
return true, nil
},
- downgradeProfile: func(_ *models.AccessibilityProfile) int { return 0 },
}
if err := b.sink(ctx, models.Place{Name: "no-a11y"}, nil); err != nil {
t.Fatalf("sink p1: %v", err)
}
- profile := &models.AccessibilityProfile{OverallStatus: models.StatusAccessible}
+ profile := &models.AccessibilityProfile{}
if err := b.sink(ctx, models.Place{Name: "with-a11y"}, profile); err != nil {
t.Fatalf("sink p2: %v", err)
}
@@ -185,33 +184,6 @@ func TestBatcher_WritesProfileWhenAttached(t *testing.T) {
}
}
-func TestBatcher_DowngradeCounter(t *testing.T) {
- ctx := context.Background()
- b := &batcher{
- size: 10,
- flush: func(_ context.Context, ps []models.Place) error {
- for i := range ps {
- ps[i].ID = fmt.Sprintf("place-%d", i)
- }
- return nil
- },
- writeProfile: func(_ context.Context, _ string, _ *models.AccessibilityProfile) (bool, error) {
- return true, nil
- },
- downgradeProfile: func(_ *models.AccessibilityProfile) int { return 2 },
- }
- profile := &models.AccessibilityProfile{OverallStatus: models.StatusAccessible}
- if err := b.sink(ctx, models.Place{Name: "p"}, profile); err != nil {
- t.Fatalf("sink: %v", err)
- }
- if err := b.flushNow(ctx); err != nil {
- t.Fatalf("flushNow: %v", err)
- }
- if b.profilesDowngraded != 2 {
- t.Errorf("profilesDowngraded = %d, want 2", b.profilesDowngraded)
- }
-}
-
func TestBatcher_PlaceHasNoAccessibilityFieldInFlush(t *testing.T) {
ctx := context.Background()
var batchSeen []models.Place
@@ -228,9 +200,8 @@ func TestBatcher_PlaceHasNoAccessibilityFieldInFlush(t *testing.T) {
writeProfile: func(_ context.Context, _ string, _ *models.AccessibilityProfile) (bool, error) {
return true, nil
},
- downgradeProfile: func(_ *models.AccessibilityProfile) int { return 0 },
}
- profile := &models.AccessibilityProfile{OverallStatus: models.StatusAccessible}
+ profile := &models.AccessibilityProfile{}
if err := b.sink(ctx, models.Place{Name: "with-a11y"}, profile); err != nil {
t.Fatalf("sink: %v", err)
}
@@ -245,30 +216,6 @@ func TestBatcher_PlaceHasNoAccessibilityFieldInFlush(t *testing.T) {
}
}
-func TestBatcher_FlushNow_ErrorWhenWriteProfileWithoutDowngrade(t *testing.T) {
- ctx := context.Background()
- b := &batcher{
- size: 10,
- flush: func(_ context.Context, ps []models.Place) error {
- for i := range ps {
- ps[i].ID = fmt.Sprintf("place-%d", i)
- }
- return nil
- },
- writeProfile: func(_ context.Context, _ string, _ *models.AccessibilityProfile) (bool, error) {
- return true, nil
- },
- }
- profile := &models.AccessibilityProfile{OverallStatus: models.StatusAccessible}
- if err := b.sink(ctx, models.Place{Name: "p"}, profile); err != nil {
- t.Fatalf("sink: %v", err)
- }
- err := b.flushNow(ctx)
- if err == nil {
- t.Fatal("expected error, got nil")
- }
-}
-
type capturedProfile struct {
placeID string
profile *models.AccessibilityProfile
diff --git a/cmd/ingestion/main.go b/cmd/ingestion/main.go
index 1815d31..1429918 100644
--- a/cmd/ingestion/main.go
+++ b/cmd/ingestion/main.go
@@ -92,7 +92,6 @@ func run(ctx context.Context, sourceName, command string, cfg config) error {
return runPipeline(ctx, src, command, gormDB)
}
-// runPipeline routes src to its pipeline based on Kind.
func runPipeline(ctx context.Context, src sources.Source, command string, gormDB *gorm.DB) error {
switch src.Kind() {
case sources.SourceKindCanonical:
@@ -104,8 +103,6 @@ func runPipeline(ctx context.Context, src sources.Source, command string, gormDB
}
}
-// runCanonical drives a canonical source through the batched upsert path,
-// then runs the retry sweep against the IDs of places the batcher touched.
func runCanonical(ctx context.Context, src sources.Source, command string, gormDB *gorm.DB) error {
placesRepo := place.NewRepository(gormDB)
unmatchedRepo := unmatched.NewRepository(gormDB)
@@ -115,11 +112,9 @@ func runCanonical(ctx context.Context, src sources.Source, command string, gormD
size: batchSize,
flush: placesRepo.UpsertBatch,
writeProfile: func(ctx context.Context, placeID string, p *models.AccessibilityProfile) (bool, error) {
+ engine.WithAuditFlags(p)
return placesRepo.UpsertProfileIngestion(ctx, placeID, p)
},
- downgradeProfile: func(p *models.AccessibilityProfile) int {
- return resolveIngestionConflicts(engine, p)
- },
}
if err := dispatchCanonical(ctx, src, command, b.sink); err != nil {
return fmt.Errorf("source %q: %w", src.Name(), err)
@@ -148,7 +143,6 @@ func runCanonical(ctx context.Context, src sources.Source, command string, gormD
"command", command,
"written", b.written,
"profiles_written", b.profilesWritten,
- "profiles_downgraded", b.profilesDowngraded,
"sweep_failed", sweepErr != nil,
}
if sweepErr == nil {
@@ -164,8 +158,6 @@ func runCanonical(ctx context.Context, src sources.Source, command string, gormD
return nil
}
-// runExternal drives an external source through identity.Resolver, attaching
-// matched external refs and queueing the rest.
func runExternal(ctx context.Context, src sources.Source, command string, gormDB *gorm.DB) error {
placesRepo := place.NewRepository(gormDB)
unmatchedRepo := unmatched.NewRepository(gormDB)
@@ -229,34 +221,6 @@ func dispatchExternal(ctx context.Context, src sources.Source, command string, s
}
}
-// resolveIngestionConflicts applies audit flags and demotes conflicting components
-// from accessible to limited.
-func resolveIngestionConflicts(engine *a11y.Engine, profile *models.AccessibilityProfile) int {
- if profile == nil {
- return 0
- }
- engine.WithAuditFlags(profile)
- conflicts := engine.DetectConflicts(profile)
- if len(conflicts) == 0 {
- return 0
- }
- conflicted := make(map[models.A11yComponentType]bool, len(conflicts))
- for _, c := range conflicts {
- conflicted[c.Component] = true
- }
- downgraded := 0
- for i := range profile.Components {
- c := &profile.Components[i]
- if conflicted[c.Type] && c.OverallStatus == models.StatusAccessible {
- c.OverallStatus = models.StatusLimited
- downgraded++
- slog.Info("ingestion downgraded component", "component", c.Type, "flags", c.AuditFlags)
- }
- }
- return downgraded
-}
-
-// resolveCounters tallies external-source outcomes for the run summary.
type resolveCounters struct {
confident int
lowConfidence int
diff --git a/cmd/ingestion/main_integration_test.go b/cmd/ingestion/main_integration_test.go
index 7651476..56060dd 100644
--- a/cmd/ingestion/main_integration_test.go
+++ b/cmd/ingestion/main_integration_test.go
@@ -12,6 +12,7 @@ import (
"fmt"
"math"
"testing"
+ "time"
"github.com/InWheelOrg/inwheel-api/internal/place"
"github.com/InWheelOrg/inwheel-api/internal/sources"
@@ -27,7 +28,7 @@ const fixturePBFPath = "../../testdata/andorra-sample.osm.pbf"
const expectedPOICount = 976
// pinned is a known POI from the Andorra fixture used to verify that the
-// transform → upsert pipeline produces the expected place row.
+// transform -> upsert pipeline produces the expected place row.
type pinned struct {
osmID int64
name string
@@ -71,7 +72,7 @@ var pinnedPOIs = []pinned{
osmID: 323129883,
name: "Telecabina La Massana",
category: models.CategoryTransport,
- rank: models.RankLandmark, // public_transport=station promotes transport to landmark
+ rank: models.RankLandmark,
lat: 42.547295,
lng: 1.513858,
tagSubset: map[string]string{
@@ -184,8 +185,6 @@ func TestRunCanonical_WritesAccessibilityProfiles(t *testing.T) {
}
defer cleanup()
- hasStep := true
- hasRamp := false
src := &fakeCanonicalSource{
emit: []fakeEmit{
{
@@ -199,29 +198,14 @@ func TestRunCanonical_WritesAccessibilityProfiles(t *testing.T) {
},
{
place: models.Place{
- OSMID: 1002, OSMType: models.OSMNode, Name: "Accessible",
+ OSMID: 1002, OSMType: models.OSMNode, Name: "With Source Report",
Lat: 46.4621, Lng: 6.8401, Category: models.CategoryCafe,
Rank: models.RankEstablishment,
Status: models.PlaceStatusActive,
ExternalIDs: models.ExternalIDs{"osm": {ID: "node/1002", Confidence: 1.0}},
},
- profile: &models.AccessibilityProfile{OverallStatus: models.StatusAccessible},
- },
- {
- place: models.Place{
- OSMID: 1003, OSMType: models.OSMNode, Name: "Hard Conflict",
- Lat: 46.4622, Lng: 6.8402, Category: models.CategoryCafe,
- Rank: models.RankEstablishment,
- Status: models.PlaceStatusActive,
- ExternalIDs: models.ExternalIDs{"osm": {ID: "node/1003", Confidence: 1.0}},
- },
profile: &models.AccessibilityProfile{
- OverallStatus: models.StatusAccessible,
- Components: models.A11yComponents{{
- Type: models.ComponentEntrance,
- OverallStatus: models.StatusAccessible,
- Entrance: &models.EntranceProperties{HasStep: &hasStep, HasRamp: &hasRamp},
- }},
+ SourceReports: models.SourceReports{{Source: "osm", Value: "yes", RecordedAt: time.Now()}},
},
},
},
@@ -235,21 +219,11 @@ func TestRunCanonical_WritesAccessibilityProfiles(t *testing.T) {
if err := db.Find(&profiles).Error; err != nil {
t.Fatalf("read profiles: %v", err)
}
- if len(profiles) != 2 {
- t.Fatalf("expected 2 profiles (Plain has none), got %d", len(profiles))
- }
-
- var conflictProfile models.AccessibilityProfile
- if err := db.Joins("JOIN places ON places.id = accessibility_profiles.place_id").
- Where("places.osm_id = ?", 1003).
- First(&conflictProfile).Error; err != nil {
- t.Fatalf("read conflict profile: %v", err)
+ if len(profiles) != 1 {
+ t.Fatalf("expected 1 profile (Plain has none), got %d", len(profiles))
}
- if len(conflictProfile.Components) != 1 {
- t.Fatalf("conflict profile components = %d, want 1", len(conflictProfile.Components))
- }
- if conflictProfile.Components[0].OverallStatus != models.StatusLimited {
- t.Errorf("component status = %q, want limited (downgraded from accessible)", conflictProfile.Components[0].OverallStatus)
+ if len(profiles[0].SourceReports) == 0 || profiles[0].SourceReports[0].Value != "yes" {
+ t.Errorf("expected source report value 'yes', got %v", profiles[0].SourceReports)
}
}
@@ -261,6 +235,7 @@ func TestRunCanonical_DoesNotOverwriteUserVerified(t *testing.T) {
}
defer cleanup()
+ isLevel := false
repo := place.NewRepository(db)
seed := models.Place{
OSMID: 2001, OSMType: models.OSMNode, Name: "Verified",
@@ -273,8 +248,8 @@ func TestRunCanonical_DoesNotOverwriteUserVerified(t *testing.T) {
t.Fatalf("seed place: %v", err)
}
_, err = repo.UpsertProfile(ctx, seed.ID, &models.AccessibilityProfile{
- OverallStatus: models.StatusInaccessible,
- UserVerified: true,
+ Entrance: &models.EntranceProps{IsLevel: &isLevel},
+ UserVerified: true,
})
if err != nil {
t.Fatalf("seed profile: %v", err)
@@ -289,7 +264,9 @@ func TestRunCanonical_DoesNotOverwriteUserVerified(t *testing.T) {
Status: models.PlaceStatusActive,
ExternalIDs: models.ExternalIDs{"osm": {ID: "node/2001", Confidence: 1.0}},
},
- profile: &models.AccessibilityProfile{OverallStatus: models.StatusAccessible},
+ profile: &models.AccessibilityProfile{
+ SourceReports: models.SourceReports{{Source: "osm", Value: "yes", RecordedAt: time.Now()}},
+ },
}},
}
if err := runCanonical(ctx, src, "full-import", db); err != nil {
@@ -300,11 +277,11 @@ func TestRunCanonical_DoesNotOverwriteUserVerified(t *testing.T) {
if err := db.Where("place_id = ?", seed.ID).First(&stored).Error; err != nil {
t.Fatalf("read: %v", err)
}
- if stored.OverallStatus != models.StatusInaccessible {
- t.Errorf("user-verified profile got overwritten: status = %q, want inaccessible", stored.OverallStatus)
+ if stored.Entrance == nil || stored.Entrance.IsLevel == nil || *stored.Entrance.IsLevel {
+ t.Error("user-verified profile got overwritten: Entrance.IsLevel should still be false")
}
if !stored.UserVerified {
- t.Errorf("user_verified flag was cleared")
+ t.Error("user_verified flag was cleared")
}
}
diff --git a/internal/a11y/engine.go b/internal/a11y/engine.go
index a3677b4..2dcdaa3 100644
--- a/internal/a11y/engine.go
+++ b/internal/a11y/engine.go
@@ -3,198 +3,226 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-// Package a11y implements the accessibility rule engine: audit flag computation and conflict detection.
+// Package a11y implements the accessibility rule engine: audit flag computation
+// and parent-component inheritance. Runs synchronously on every profile write.
package a11y
import (
- "fmt"
- "slices"
- "strings"
-
"github.com/InWheelOrg/inwheel-api/pkg/models"
)
-// Audit flag constants for each technical violation detected by WithAuditFlags.
+// Audit flag constants computed from submitted property values.
const (
FlagEntranceNarrowWidth = "narrow width (0.8m required)"
- FlagEntranceContainsStep = "contains step"
- FlagEntranceHighStep = "high step (>0.05m)"
- FlagEntranceStepNoRamp = "step with no ramp"
+ FlagEntranceNoLevelRoute = "no level route (no ramp and not level)"
- FlagRestroomNotAccessible = "not wheelchair accessible"
FlagRestroomNarrowDoor = "narrow door (0.8m required)"
+ FlagRestroomSmallTurning = "small turning radius (1.5m required)"
FlagRestroomNoGrabRails = "missing grab rails"
- FlagElevatorNarrowWidth = "small cabin width (0.8m required)"
- FlagElevatorShallowDep = "small cabin depth (1.1m required)"
- FlagElevatorNoBraille = "missing braille"
- FlagElevatorNoAudio = "missing audio"
+ FlagElevatorNarrowWidth = "small cabin width (0.8m required)"
+ FlagElevatorShallowDepth = "small cabin depth (1.1m required)"
+ FlagElevatorNarrowDoor = "narrow door (0.8m required)"
+ FlagElevatorNoBraille = "missing braille"
+ FlagElevatorNoAudio = "missing audio"
FlagParkingNoDisabledSpaces = "no disabled spaces"
+
+ FlagPathwayNarrowWidth = "narrow pathway (0.9m required)"
)
-// Engine provides logic for accessibility data processing and inheritance.
type Engine struct{}
-// Conflict represents a self-contradiction in submitted data: a component is marked accessible
-// but contains a fact (submitted by the client themselves) that makes that impossible.
-type Conflict struct {
- Component models.A11yComponentType
- Reason string
-}
-
-// hardConflictFlags are flags that represent direct self-contradictions in the submitted data —
-// either the client explicitly stated inaccessibility, or described a physical barrier with no workaround.
-// These are distinct from informational threshold flags (narrow width, missing braille, etc.) which
-// are stored for clients to use but never block a write.
-var hardConflictFlags = map[string]bool{
- FlagEntranceStepNoRamp: true, // has_step=true + has_ramp=false = impassable physical barrier
- FlagRestroomNotAccessible: true, // wheelchair_accessible=false contradicts overall_status=accessible
- FlagParkingNoDisabledSpaces: true, // has_disabled_spaces=false contradicts overall_status=accessible
-}
-
-// DetectConflicts checks each component for hard self-contradictions between its submitted
-// OverallStatus and the facts the client submitted. Must be called after WithAuditFlags.
-// Only hard contradiction flags (not informational threshold flags) can block a write.
-func (e *Engine) DetectConflicts(profile *models.AccessibilityProfile) []Conflict {
+// WithAuditFlags computes AuditFlags on each non-nil component of the profile.
+func (e *Engine) WithAuditFlags(profile *models.AccessibilityProfile) {
if profile == nil {
- return nil
+ return
}
- var conflicts []Conflict
- for _, comp := range profile.Components {
- if comp.OverallStatus != models.StatusAccessible {
- continue
- }
- var hardFlags []string
- for _, f := range comp.AuditFlags {
- if hardConflictFlags[f] {
- hardFlags = append(hardFlags, f)
- }
- }
- if len(hardFlags) > 0 {
- conflicts = append(conflicts, Conflict{
- Component: comp.Type,
- Reason: fmt.Sprintf("status is accessible but: %s", strings.Join(hardFlags, ", ")),
- })
- }
+
+ if profile.Entrance != nil {
+ profile.Entrance.AuditFlags = entranceFlags(profile.Entrance)
+ }
+ if profile.Pathways != nil {
+ profile.Pathways.AuditFlags = pathwayFlags(profile.Pathways)
+ }
+ if profile.Restroom != nil {
+ profile.Restroom.AuditFlags = restroomFlags(profile.Restroom)
+ }
+ if profile.Parking != nil {
+ profile.Parking.AuditFlags = parkingFlags(profile.Parking)
+ }
+ if profile.Elevator != nil {
+ profile.Elevator.AuditFlags = elevatorFlags(profile.Elevator)
}
- return conflicts
}
-// ComputeEffectiveProfile merges accessibility components from a child place and its parent.
-// Child places inherit parent components they don't own (e.g., a shop inherits a mall's parking).
-// For any component taken from the parent, IsInherited is set to true and SourceID is set to parent.ID.
+// ComputeEffectiveProfile merges components from a child place and its parent.
+// Child places inherit parent components they don't own (e.g. a shop inherits a
+// mall's parking). Inherited components have IsInherited=true and SourceID=parent.ID.
+// Neither the child nor parent record is mutated.
func (e *Engine) ComputeEffectiveProfile(child, parent *models.Place) *models.AccessibilityProfile {
if child == nil {
return nil
}
- childCount := 0
+ effective := &models.AccessibilityProfile{}
+
if child.Accessibility != nil {
- childCount = len(child.Accessibility.Components)
+ ca := child.Accessibility
+ effective.ID = ca.ID
+ effective.PlaceID = ca.PlaceID
+ effective.SourceReports = ca.SourceReports
+ effective.UserVerified = ca.UserVerified
+ effective.SubmittedAt = ca.SubmittedAt
+ effective.UpdatedAt = ca.UpdatedAt
+ effective.Entrance = copyEntrance(ca.Entrance)
+ effective.Pathways = copyPathways(ca.Pathways)
+ effective.Restroom = copyRestroom(ca.Restroom)
+ effective.Parking = copyParking(ca.Parking)
+ effective.Elevator = copyElevator(ca.Elevator)
}
- parentCount := 0
- if parent != nil && parent.Accessibility != nil {
- parentCount = len(parent.Accessibility.Components)
+ if parent == nil || parent.Accessibility == nil {
+ return effective
}
- effective := &models.AccessibilityProfile{
- OverallStatus: models.StatusUnknown,
- Components: make([]models.A11yComponent, 0, childCount+parentCount),
+ pa := parent.Accessibility
+
+ if effective.Entrance == nil && pa.Entrance != nil {
+ c := copyEntrance(pa.Entrance)
+ c.IsInherited = true
+ c.SourceID = parent.ID
+ effective.Entrance = c
+ }
+ if effective.Pathways == nil && pa.Pathways != nil {
+ c := copyPathways(pa.Pathways)
+ c.IsInherited = true
+ c.SourceID = parent.ID
+ effective.Pathways = c
+ }
+ if effective.Restroom == nil && pa.Restroom != nil {
+ c := copyRestroom(pa.Restroom)
+ c.IsInherited = true
+ c.SourceID = parent.ID
+ effective.Restroom = c
+ }
+ if effective.Parking == nil && pa.Parking != nil {
+ c := copyParking(pa.Parking)
+ c.IsInherited = true
+ c.SourceID = parent.ID
+ effective.Parking = c
+ }
+ if effective.Elevator == nil && pa.Elevator != nil {
+ c := copyElevator(pa.Elevator)
+ c.IsInherited = true
+ c.SourceID = parent.ID
+ effective.Elevator = c
}
- if child.Accessibility != nil {
- effective.OverallStatus = child.Accessibility.OverallStatus
- effective.Components = append(effective.Components, child.Accessibility.Components...)
+ return effective
+}
+
+func entranceFlags(p *models.EntranceProps) []string {
+ var flags []string
+ if p.Width != nil && *p.Width < 0.8 {
+ flags = append(flags, FlagEntranceNarrowWidth)
+ }
+ // Only flag when IsLevel is explicitly false; nil means unknown, no flag.
+ if p.IsLevel != nil && !*p.IsLevel {
+ hasRamp := (p.HasFixedRamp != nil && *p.HasFixedRamp) || (p.HasRemovableRamp != nil && *p.HasRemovableRamp)
+ if !hasRamp {
+ flags = append(flags, FlagEntranceNoLevelRoute)
+ }
+ }
+ return flags
+}
+
+func pathwayFlags(p *models.PathwayProps) []string {
+ var flags []string
+ if p.Width != nil && *p.Width < 0.9 {
+ flags = append(flags, FlagPathwayNarrowWidth)
}
+ return flags
+}
- if parent == nil || parent.Accessibility == nil {
- return effective
+func restroomFlags(p *models.RestroomProps) []string {
+ var flags []string
+ if p.DoorWidth != nil && *p.DoorWidth < 0.8 {
+ flags = append(flags, FlagRestroomNarrowDoor)
+ }
+ if p.TurningRadius != nil && *p.TurningRadius < 1.5 {
+ flags = append(flags, FlagRestroomSmallTurning)
}
+ if p.HasGrabRails != nil && !*p.HasGrabRails {
+ flags = append(flags, FlagRestroomNoGrabRails)
+ }
+ return flags
+}
- // Iterate through parent components and inherit those the child doesn't have.
- for _, pc := range parent.Accessibility.Components {
- if !hasComponent(child, pc.Type) {
- inherited := pc
- inherited.IsInherited = true
- inherited.SourceID = parent.ID
- effective.Components = append(effective.Components, inherited)
- }
+func parkingFlags(p *models.ParkingProps) []string {
+ var flags []string
+ if p.HasDisabledSpaces != nil && !*p.HasDisabledSpaces {
+ flags = append(flags, FlagParkingNoDisabledSpaces)
}
+ return flags
+}
- return effective
+func elevatorFlags(p *models.ElevatorProps) []string {
+ var flags []string
+ if p.Width != nil && *p.Width < 0.8 {
+ flags = append(flags, FlagElevatorNarrowWidth)
+ }
+ if p.Depth != nil && *p.Depth < 1.1 {
+ flags = append(flags, FlagElevatorShallowDepth)
+ }
+ if p.DoorWidth != nil && *p.DoorWidth < 0.8 {
+ flags = append(flags, FlagElevatorNarrowDoor)
+ }
+ if p.HasBraille != nil && !*p.HasBraille {
+ flags = append(flags, FlagElevatorNoBraille)
+ }
+ if p.HasAudio != nil && !*p.HasAudio {
+ flags = append(flags, FlagElevatorNoAudio)
+ }
+ return flags
}
-// hasComponent checks if a place already has a component of a specific type.
-func hasComponent(place *models.Place, cType models.A11yComponentType) bool {
- if place == nil || place.Accessibility == nil {
- return false
+func copyEntrance(p *models.EntranceProps) *models.EntranceProps {
+ if p == nil {
+ return nil
}
- return slices.ContainsFunc(place.Accessibility.Components, func(c models.A11yComponent) bool {
- return c.Type == cType
- })
+ c := *p
+ return &c
}
-// WithAuditFlags performs a technical validation of each component and populates the AuditFlags field.
-func (e *Engine) WithAuditFlags(profile *models.AccessibilityProfile) {
- if profile == nil {
- return
+func copyPathways(p *models.PathwayProps) *models.PathwayProps {
+ if p == nil {
+ return nil
}
+ c := *p
+ return &c
+}
- for i := range profile.Components {
- comp := &profile.Components[i]
- comp.AuditFlags = nil // Reset
-
- switch comp.Type {
- case models.ComponentEntrance:
- if e := comp.Entrance; e != nil {
- if e.Width != nil && *e.Width < 0.8 {
- comp.AuditFlags = append(comp.AuditFlags, FlagEntranceNarrowWidth)
- }
- if e.HasStep != nil && *e.HasStep {
- comp.AuditFlags = append(comp.AuditFlags, FlagEntranceContainsStep)
- if e.StepHeight != nil && *e.StepHeight > 0.05 {
- comp.AuditFlags = append(comp.AuditFlags, FlagEntranceHighStep)
- }
- if e.HasRamp != nil && !*e.HasRamp {
- comp.AuditFlags = append(comp.AuditFlags, FlagEntranceStepNoRamp)
- }
- }
- }
- case models.ComponentRestroom:
- if r := comp.Restroom; r != nil {
- if r.WheelchairAccessible != nil && !*r.WheelchairAccessible {
- comp.AuditFlags = append(comp.AuditFlags, FlagRestroomNotAccessible)
- }
- if r.DoorWidth != nil && *r.DoorWidth < 0.8 {
- comp.AuditFlags = append(comp.AuditFlags, FlagRestroomNarrowDoor)
- }
- if r.GrabRails != nil && !*r.GrabRails {
- comp.AuditFlags = append(comp.AuditFlags, FlagRestroomNoGrabRails)
- }
- }
- case models.ComponentElevator:
- if el := comp.Elevator; el != nil {
- if el.Width != nil && *el.Width < 0.8 {
- comp.AuditFlags = append(comp.AuditFlags, FlagElevatorNarrowWidth)
- }
- if el.Depth != nil && *el.Depth < 1.1 {
- comp.AuditFlags = append(comp.AuditFlags, FlagElevatorShallowDep)
- }
- if el.Braille != nil && !*el.Braille {
- comp.AuditFlags = append(comp.AuditFlags, FlagElevatorNoBraille)
- }
- if el.Audio != nil && !*el.Audio {
- comp.AuditFlags = append(comp.AuditFlags, FlagElevatorNoAudio)
- }
- }
- case models.ComponentParking:
- if p := comp.Parking; p != nil {
- if p.HasDisabledSpaces != nil && !*p.HasDisabledSpaces {
- comp.AuditFlags = append(comp.AuditFlags, FlagParkingNoDisabledSpaces)
- }
- }
- }
+func copyRestroom(p *models.RestroomProps) *models.RestroomProps {
+ if p == nil {
+ return nil
+ }
+ c := *p
+ return &c
+}
+
+func copyParking(p *models.ParkingProps) *models.ParkingProps {
+ if p == nil {
+ return nil
+ }
+ c := *p
+ return &c
+}
+
+func copyElevator(p *models.ElevatorProps) *models.ElevatorProps {
+ if p == nil {
+ return nil
}
+ c := *p
+ return &c
}
diff --git a/internal/a11y/engine_test.go b/internal/a11y/engine_test.go
index 151c035..31a52dc 100644
--- a/internal/a11y/engine_test.go
+++ b/internal/a11y/engine_test.go
@@ -18,572 +18,264 @@ func floatPtr(v float64) *float64 { return &v }
func TestComputeEffectiveProfile(t *testing.T) {
engine := &Engine{}
- tests := []struct {
- name string
- child *models.Place
- parent *models.Place
- wantStatus models.A11yStatus
- wantCompCount int
- check func(t *testing.T, res *models.AccessibilityProfile, parent *models.Place)
- }{
- {
- name: "nil child returns nil",
- child: nil,
- parent: nil,
- wantStatus: "", // nil expected
- },
- {
- name: "child without accessibility and no parent",
- child: &models.Place{ID: "child-1"},
- parent: nil,
- wantStatus: models.StatusUnknown,
- wantCompCount: 0,
- },
- {
- name: "child inherits from parent",
- parent: &models.Place{
- ID: "parent-1",
- Accessibility: &models.AccessibilityProfile{
- OverallStatus: models.StatusAccessible,
- Components: []models.A11yComponent{
- {
- Type: models.ComponentParking,
- OverallStatus: models.StatusAccessible,
- },
- },
- },
- },
- child: &models.Place{
- ID: "child-1",
- Accessibility: &models.AccessibilityProfile{
- OverallStatus: models.StatusLimited,
- Components: []models.A11yComponent{
- {
- Type: models.ComponentEntrance,
- OverallStatus: models.StatusLimited,
- },
- },
- },
- },
- wantStatus: models.StatusLimited,
- wantCompCount: 2,
- check: func(t *testing.T, res *models.AccessibilityProfile, parent *models.Place) {
- // Check for child's own component
- var entranceFound bool
- for _, c := range res.Components {
- if c.Type == models.ComponentEntrance {
- entranceFound = true
- if c.IsInherited {
- t.Error("Child entrance component should not be marked as inherited")
- }
- }
- }
- if !entranceFound {
- t.Error("Child entrance component missing from effective profile")
- }
+ t.Run("nil child returns nil", func(t *testing.T) {
+ if engine.ComputeEffectiveProfile(nil, nil) != nil {
+ t.Error("expected nil for nil child")
+ }
+ })
- // Check for inherited parent component
- var parkingFound bool
- for _, c := range res.Components {
- if c.Type == models.ComponentParking {
- parkingFound = true
- if !c.IsInherited {
- t.Error("Parent parking component should be marked as inherited")
- }
- if c.SourceID != parent.ID {
- t.Errorf("Expected SourceID %s, got %s", parent.ID, c.SourceID)
- }
- }
- }
- if !parkingFound {
- t.Error("Parent parking component missing from effective profile")
- }
- },
- },
- {
- name: "child component overrides parent component",
- parent: &models.Place{
- ID: "parent-1",
- Accessibility: &models.AccessibilityProfile{
- Components: []models.A11yComponent{
- {
- Type: models.ComponentEntrance,
- OverallStatus: models.StatusAccessible,
- },
- },
- },
- },
- child: &models.Place{
- ID: "child-1",
- Accessibility: &models.AccessibilityProfile{
- OverallStatus: models.StatusUnknown,
- Components: []models.A11yComponent{
- {
- Type: models.ComponentEntrance,
- OverallStatus: models.StatusInaccessible,
- },
- },
- },
+ t.Run("child with no accessibility and no parent", func(t *testing.T) {
+ res := engine.ComputeEffectiveProfile(&models.Place{ID: "c"}, nil)
+ if res == nil {
+ t.Fatal("expected non-nil profile")
+ }
+ if res.Entrance != nil || res.Parking != nil || res.Restroom != nil {
+ t.Error("expected all components nil for place with no accessibility")
+ }
+ })
+
+ t.Run("child inherits parent parking when child has none", func(t *testing.T) {
+ parent := &models.Place{
+ ID: "parent-1",
+ Accessibility: &models.AccessibilityProfile{
+ Parking: &models.ParkingProps{HasDisabledSpaces: boolPtr(true)},
},
- wantStatus: models.StatusUnknown,
- wantCompCount: 1,
- check: func(t *testing.T, res *models.AccessibilityProfile, _ *models.Place) {
- if res.Components[0].OverallStatus != models.StatusInaccessible {
- t.Errorf("Expected child status %s to override parent, got %s", models.StatusInaccessible, res.Components[0].OverallStatus)
- }
- if res.Components[0].IsInherited {
- t.Error("Child component should not be marked as inherited")
- }
+ }
+ child := &models.Place{
+ ID: "child-1",
+ Accessibility: &models.AccessibilityProfile{
+ Entrance: &models.EntranceProps{IsLevel: boolPtr(true)},
},
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- res := engine.ComputeEffectiveProfile(tt.child, tt.parent)
-
- if tt.child == nil {
- if res != nil {
- t.Errorf("Expected nil for nil child, got %v", res)
- }
- return
- }
-
- if res == nil {
- t.Fatal("Expected non-nil profile")
- }
-
- if res.OverallStatus != tt.wantStatus {
- t.Errorf("OverallStatus = %s, want %s", res.OverallStatus, tt.wantStatus)
- }
-
- if len(res.Components) != tt.wantCompCount {
- t.Errorf("len(Components) = %d, want %d", len(res.Components), tt.wantCompCount)
- }
-
- if tt.check != nil {
- tt.check(t, res, tt.parent)
- }
- })
- }
-}
+ }
+ res := engine.ComputeEffectiveProfile(child, parent)
-func TestDetectConflicts(t *testing.T) {
- engine := &Engine{}
+ if res.Entrance == nil || res.Entrance.IsInherited {
+ t.Error("child's own entrance should be present and not inherited")
+ }
+ if res.Parking == nil {
+ t.Fatal("parent parking should be inherited")
+ }
+ if !res.Parking.IsInherited {
+ t.Error("inherited parking should have IsInherited=true")
+ }
+ if res.Parking.SourceID != parent.ID {
+ t.Errorf("inherited parking SourceID = %q, want %q", res.Parking.SourceID, parent.ID)
+ }
+ })
- tests := []struct {
- name string
- components []models.A11yComponent
- wantConflicts int
- }{
- {
- name: "nil profile",
- components: nil,
- wantConflicts: 0,
- },
- {
- name: "no components",
- components: []models.A11yComponent{},
- wantConflicts: 0,
- },
- // Hard contradictions — must block
- {
- name: "entrance: step with no ramp + accessible",
- components: []models.A11yComponent{
- {
- Type: models.ComponentEntrance,
- OverallStatus: models.StatusAccessible,
- AuditFlags: []string{FlagEntranceStepNoRamp},
- },
+ t.Run("child entrance overrides parent entrance", func(t *testing.T) {
+ parent := &models.Place{
+ ID: "parent-1",
+ Accessibility: &models.AccessibilityProfile{
+ Entrance: &models.EntranceProps{IsLevel: boolPtr(true)},
},
- wantConflicts: 1,
- },
- {
- name: "restroom: not wheelchair accessible + accessible",
- components: []models.A11yComponent{
- {
- Type: models.ComponentRestroom,
- OverallStatus: models.StatusAccessible,
- AuditFlags: []string{FlagRestroomNotAccessible},
- },
- },
- wantConflicts: 1,
- },
- {
- name: "parking: no disabled spaces + accessible",
- components: []models.A11yComponent{
- {
- Type: models.ComponentParking,
- OverallStatus: models.StatusAccessible,
- AuditFlags: []string{FlagParkingNoDisabledSpaces},
- },
- },
- wantConflicts: 1,
- },
- // Informational threshold flags — must not block
- {
- name: "entrance: narrow width + accessible — informational only",
- components: []models.A11yComponent{
- {
- Type: models.ComponentEntrance,
- OverallStatus: models.StatusAccessible,
- AuditFlags: []string{FlagEntranceNarrowWidth},
- },
- },
- wantConflicts: 0,
- },
- {
- name: "entrance: high step + accessible — informational only",
- components: []models.A11yComponent{
- {
- Type: models.ComponentEntrance,
- OverallStatus: models.StatusAccessible,
- AuditFlags: []string{FlagEntranceHighStep},
- },
- },
- wantConflicts: 0,
- },
- {
- name: "restroom: narrow door + accessible — informational only",
- components: []models.A11yComponent{
- {
- Type: models.ComponentRestroom,
- OverallStatus: models.StatusAccessible,
- AuditFlags: []string{FlagRestroomNarrowDoor},
- },
- },
- wantConflicts: 0,
- },
- {
- name: "elevator: narrow width + accessible — informational only",
- components: []models.A11yComponent{
- {
- Type: models.ComponentElevator,
- OverallStatus: models.StatusAccessible,
- AuditFlags: []string{FlagElevatorNarrowWidth},
- },
- },
- wantConflicts: 0,
- },
- // Status other than accessible — never a conflict
- {
- name: "step with no ramp + limited — no conflict",
- components: []models.A11yComponent{
- {
- Type: models.ComponentEntrance,
- OverallStatus: models.StatusLimited,
- AuditFlags: []string{FlagEntranceStepNoRamp},
- },
- },
- wantConflicts: 0,
- },
- {
- name: "restroom not accessible + inaccessible — no conflict",
- components: []models.A11yComponent{
- {
- Type: models.ComponentRestroom,
- OverallStatus: models.StatusInaccessible,
- AuditFlags: []string{FlagRestroomNotAccessible},
- },
+ }
+ child := &models.Place{
+ ID: "child-1",
+ Accessibility: &models.AccessibilityProfile{
+ Entrance: &models.EntranceProps{IsLevel: boolPtr(false)},
},
- wantConflicts: 0,
- },
- // Multiple components — only conflicting ones reported
- {
- name: "two components, one conflict",
- components: []models.A11yComponent{
- {
- Type: models.ComponentEntrance,
- OverallStatus: models.StatusAccessible,
- AuditFlags: []string{FlagEntranceNarrowWidth}, // informational only
- },
- {
- Type: models.ComponentRestroom,
- OverallStatus: models.StatusAccessible,
- AuditFlags: []string{FlagRestroomNotAccessible}, // hard conflict
- },
+ }
+ res := engine.ComputeEffectiveProfile(child, parent)
+
+ if res.Entrance == nil {
+ t.Fatal("expected entrance")
+ }
+ if res.Entrance.IsInherited {
+ t.Error("child's own entrance should not be inherited")
+ }
+ if res.Entrance.IsLevel == nil || *res.Entrance.IsLevel {
+ t.Error("expected child's is_level=false to override parent's is_level=true")
+ }
+ })
+
+ t.Run("original child and parent records are not mutated", func(t *testing.T) {
+ parent := &models.Place{
+ ID: "p",
+ Accessibility: &models.AccessibilityProfile{
+ Parking: &models.ParkingProps{HasDisabledSpaces: boolPtr(true)},
},
- wantConflicts: 1,
- },
- }
+ }
+ child := &models.Place{ID: "c", Accessibility: &models.AccessibilityProfile{}}
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- var profile *models.AccessibilityProfile
- if tt.components != nil {
- profile = &models.AccessibilityProfile{Components: tt.components}
- }
- conflicts := engine.DetectConflicts(profile)
- if len(conflicts) != tt.wantConflicts {
- t.Errorf("DetectConflicts() = %d conflicts, want %d: %v", len(conflicts), tt.wantConflicts, conflicts)
- }
- })
- }
+ engine.ComputeEffectiveProfile(child, parent)
+
+ if parent.Accessibility.Parking.IsInherited {
+ t.Error("parent parking should not be mutated")
+ }
+ })
}
func TestWithAuditFlags(t *testing.T) {
engine := &Engine{}
+ t.Run("nil profile does not panic", func(_ *testing.T) {
+ engine.WithAuditFlags(nil)
+ })
+
tests := []struct {
name string
- component models.A11yComponent
- wantFlags []string
+ profile models.AccessibilityProfile
+ wantFlags map[string][]string // component → expected flags
}{
- // --- nil / empty ---
- {
- name: "nil profile does not panic",
- component: models.A11yComponent{}, // tested separately below via nil call
- },
-
// --- entrance ---
{
- name: "entrance: no properties set — no flags",
- component: models.A11yComponent{
- Type: models.ComponentEntrance,
- Entrance: &models.EntranceProperties{},
- },
- wantFlags: nil,
+ name: "entrance: no properties, no flags",
+ profile: models.AccessibilityProfile{Entrance: &models.EntranceProps{}},
+ wantFlags: map[string][]string{"entrance": nil},
},
{
- name: "entrance: width below minimum",
- component: models.A11yComponent{
- Type: models.ComponentEntrance,
- Entrance: &models.EntranceProperties{Width: floatPtr(0.75)},
- },
- wantFlags: []string{FlagEntranceNarrowWidth},
+ name: "entrance: width below 0.8m",
+ profile: models.AccessibilityProfile{Entrance: &models.EntranceProps{Width: floatPtr(0.75)}},
+ wantFlags: map[string][]string{"entrance": {FlagEntranceNarrowWidth}},
},
{
- name: "entrance: width exactly at minimum — no flag",
- component: models.A11yComponent{
- Type: models.ComponentEntrance,
- Entrance: &models.EntranceProperties{Width: floatPtr(0.8)},
- },
- wantFlags: nil,
+ name: "entrance: width exactly 0.8m, no flag",
+ profile: models.AccessibilityProfile{Entrance: &models.EntranceProps{Width: floatPtr(0.8)}},
+ wantFlags: map[string][]string{"entrance": nil},
},
{
- name: "entrance: has step",
- component: models.A11yComponent{
- Type: models.ComponentEntrance,
- Entrance: &models.EntranceProperties{HasStep: boolPtr(true)},
- },
- wantFlags: []string{FlagEntranceContainsStep},
- },
- {
- name: "entrance: step height above threshold",
- component: models.A11yComponent{
- Type: models.ComponentEntrance,
- Entrance: &models.EntranceProperties{
- HasStep: boolPtr(true),
- StepHeight: floatPtr(0.1),
- },
- },
- wantFlags: []string{FlagEntranceContainsStep, FlagEntranceHighStep},
+ name: "entrance: not level and no ramp",
+ profile: models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(false)}},
+ wantFlags: map[string][]string{"entrance": {FlagEntranceNoLevelRoute}},
},
{
- name: "entrance: step height at threshold — no high-step flag",
- component: models.A11yComponent{
- Type: models.ComponentEntrance,
- Entrance: &models.EntranceProperties{
- HasStep: boolPtr(true),
- StepHeight: floatPtr(0.05),
- },
- },
- wantFlags: []string{FlagEntranceContainsStep},
+ name: "entrance: not level but has fixed ramp, no flag",
+ profile: models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(false), HasFixedRamp: boolPtr(true)}},
+ wantFlags: map[string][]string{"entrance": nil},
},
{
- name: "entrance: step with no ramp",
- component: models.A11yComponent{
- Type: models.ComponentEntrance,
- Entrance: &models.EntranceProperties{
- HasStep: boolPtr(true),
- HasRamp: boolPtr(false),
- },
- },
- wantFlags: []string{FlagEntranceContainsStep, FlagEntranceStepNoRamp},
+ name: "entrance: is level, no flag",
+ profile: models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}},
+ wantFlags: map[string][]string{"entrance": nil},
},
+
+ // --- pathways ---
{
- name: "entrance: step with ramp — no ramp flag",
- component: models.A11yComponent{
- Type: models.ComponentEntrance,
- Entrance: &models.EntranceProperties{
- HasStep: boolPtr(true),
- HasRamp: boolPtr(true),
- },
- },
- wantFlags: []string{FlagEntranceContainsStep},
+ name: "pathway: width below 0.9m",
+ profile: models.AccessibilityProfile{Pathways: &models.PathwayProps{Width: floatPtr(0.8)}},
+ wantFlags: map[string][]string{"pathways": {FlagPathwayNarrowWidth}},
},
{
- name: "entrance: nil entrance — no flags",
- component: models.A11yComponent{
- Type: models.ComponentEntrance,
- Entrance: nil,
- },
- wantFlags: nil,
+ name: "pathway: width at 0.9m, no flag",
+ profile: models.AccessibilityProfile{Pathways: &models.PathwayProps{Width: floatPtr(0.9)}},
+ wantFlags: map[string][]string{"pathways": nil},
},
// --- restroom ---
{
- name: "restroom: not wheelchair accessible",
- component: models.A11yComponent{
- Type: models.ComponentRestroom,
- Restroom: &models.RestroomProperties{WheelchairAccessible: boolPtr(false)},
- },
- wantFlags: []string{FlagRestroomNotAccessible},
+ name: "restroom: door width below 0.8m",
+ profile: models.AccessibilityProfile{Restroom: &models.RestroomProps{DoorWidth: floatPtr(0.75)}},
+ wantFlags: map[string][]string{"restroom": {FlagRestroomNarrowDoor}},
},
{
- name: "restroom: door width below minimum",
- component: models.A11yComponent{
- Type: models.ComponentRestroom,
- Restroom: &models.RestroomProperties{DoorWidth: floatPtr(0.7)},
- },
- wantFlags: []string{FlagRestroomNarrowDoor},
+ name: "restroom: turning radius below 1.5m",
+ profile: models.AccessibilityProfile{Restroom: &models.RestroomProps{TurningRadius: floatPtr(1.2)}},
+ wantFlags: map[string][]string{"restroom": {FlagRestroomSmallTurning}},
},
{
- name: "restroom: door width exactly at minimum — no flag",
- component: models.A11yComponent{
- Type: models.ComponentRestroom,
- Restroom: &models.RestroomProperties{DoorWidth: floatPtr(0.8)},
- },
- wantFlags: nil,
+ name: "restroom: no grab rails",
+ profile: models.AccessibilityProfile{Restroom: &models.RestroomProps{HasGrabRails: boolPtr(false)}},
+ wantFlags: map[string][]string{"restroom": {FlagRestroomNoGrabRails}},
},
{
- name: "restroom: missing grab rails",
- component: models.A11yComponent{
- Type: models.ComponentRestroom,
- Restroom: &models.RestroomProperties{GrabRails: boolPtr(false)},
- },
- wantFlags: []string{FlagRestroomNoGrabRails},
- },
- {
- name: "restroom: nil restroom — no flags",
- component: models.A11yComponent{
- Type: models.ComponentRestroom,
- Restroom: nil,
- },
- wantFlags: nil,
+ name: "restroom: has grab rails, no flag",
+ profile: models.AccessibilityProfile{Restroom: &models.RestroomProps{HasGrabRails: boolPtr(true)}},
+ wantFlags: map[string][]string{"restroom": nil},
},
- // --- elevator ---
- {
- name: "elevator: cabin width below minimum",
- component: models.A11yComponent{
- Type: models.ComponentElevator,
- Elevator: &models.ElevatorProperties{Width: floatPtr(0.7)},
- },
- wantFlags: []string{FlagElevatorNarrowWidth},
- },
- {
- name: "elevator: cabin depth below minimum",
- component: models.A11yComponent{
- Type: models.ComponentElevator,
- Elevator: &models.ElevatorProperties{Depth: floatPtr(1.0)},
- },
- wantFlags: []string{FlagElevatorShallowDep},
- },
+ // --- parking ---
{
- name: "elevator: missing braille",
- component: models.A11yComponent{
- Type: models.ComponentElevator,
- Elevator: &models.ElevatorProperties{Braille: boolPtr(false)},
- },
- wantFlags: []string{FlagElevatorNoBraille},
+ name: "parking: no disabled spaces",
+ profile: models.AccessibilityProfile{Parking: &models.ParkingProps{HasDisabledSpaces: boolPtr(false)}},
+ wantFlags: map[string][]string{"parking": {FlagParkingNoDisabledSpaces}},
},
{
- name: "elevator: missing audio",
- component: models.A11yComponent{
- Type: models.ComponentElevator,
- Elevator: &models.ElevatorProperties{Audio: boolPtr(false)},
- },
- wantFlags: []string{FlagElevatorNoAudio},
+ name: "parking: has disabled spaces, no flag",
+ profile: models.AccessibilityProfile{Parking: &models.ParkingProps{HasDisabledSpaces: boolPtr(true)}},
+ wantFlags: map[string][]string{"parking": nil},
},
+
+ // --- elevator ---
{
- name: "elevator: nil elevator — no flags",
- component: models.A11yComponent{
- Type: models.ComponentElevator,
- Elevator: nil,
- },
- wantFlags: nil,
+ name: "elevator: width below 0.8m",
+ profile: models.AccessibilityProfile{Elevator: &models.ElevatorProps{Width: floatPtr(0.7)}},
+ wantFlags: map[string][]string{"elevator": {FlagElevatorNarrowWidth}},
},
-
- // --- parking ---
{
- name: "parking: no disabled spaces",
- component: models.A11yComponent{
- Type: models.ComponentParking,
- Parking: &models.ParkingProperties{HasDisabledSpaces: boolPtr(false)},
- },
- wantFlags: []string{FlagParkingNoDisabledSpaces},
+ name: "elevator: depth below 1.1m",
+ profile: models.AccessibilityProfile{Elevator: &models.ElevatorProps{Depth: floatPtr(1.0)}},
+ wantFlags: map[string][]string{"elevator": {FlagElevatorShallowDepth}},
},
{
- name: "parking: has disabled spaces — no flag",
- component: models.A11yComponent{
- Type: models.ComponentParking,
- Parking: &models.ParkingProperties{HasDisabledSpaces: boolPtr(true)},
- },
- wantFlags: nil,
+ name: "elevator: door width below 0.8m",
+ profile: models.AccessibilityProfile{Elevator: &models.ElevatorProps{DoorWidth: floatPtr(0.75)}},
+ wantFlags: map[string][]string{"elevator": {FlagElevatorNarrowDoor}},
},
{
- name: "parking: nil parking — no flags",
- component: models.A11yComponent{
- Type: models.ComponentParking,
- Parking: nil,
- },
- wantFlags: nil,
+ name: "elevator: no braille",
+ profile: models.AccessibilityProfile{Elevator: &models.ElevatorProps{HasBraille: boolPtr(false)}},
+ wantFlags: map[string][]string{"elevator": {FlagElevatorNoBraille}},
},
-
- // --- other ---
{
- name: "component type other — no flags regardless of data",
- component: models.A11yComponent{
- Type: models.ComponentOther,
- },
- wantFlags: nil,
+ name: "elevator: no audio",
+ profile: models.AccessibilityProfile{Elevator: &models.ElevatorProps{HasAudio: boolPtr(false)}},
+ wantFlags: map[string][]string{"elevator": {FlagElevatorNoAudio}},
},
}
- // Nil profile must not panic.
- t.Run("nil profile does not panic", func(_ *testing.T) {
- engine.WithAuditFlags(nil)
- })
-
- // Existing flags are cleared before re-evaluation.
- t.Run("existing flags are reset", func(t *testing.T) {
- profile := &models.AccessibilityProfile{
- Components: []models.A11yComponent{
- {
- Type: models.ComponentEntrance,
- AuditFlags: []string{"stale flag"},
- Entrance: &models.EntranceProperties{Width: floatPtr(1.0)},
- },
- },
- }
- engine.WithAuditFlags(profile)
- if len(profile.Components[0].AuditFlags) != 0 {
- t.Errorf("expected stale flags to be cleared, got %v", profile.Components[0].AuditFlags)
- }
- })
-
for _, tt := range tests {
- if tt.name == "nil profile does not panic" {
- continue // handled above
- }
t.Run(tt.name, func(t *testing.T) {
- profile := &models.AccessibilityProfile{
- Components: []models.A11yComponent{tt.component},
- }
- engine.WithAuditFlags(profile)
- got := profile.Components[0].AuditFlags
+ p := tt.profile
+ engine.WithAuditFlags(&p)
- if len(tt.wantFlags) == 0 && len(got) == 0 {
- return
- }
- if len(got) != len(tt.wantFlags) {
- t.Errorf("flags = %v, want %v", got, tt.wantFlags)
- return
+ checkFlags := func(component string, got []string, want []string) {
+ if len(want) == 0 && len(got) == 0 {
+ return
+ }
+ if len(got) != len(want) {
+ t.Errorf("%s: flags = %v, want %v", component, got, want)
+ return
+ }
+ for _, wf := range want {
+ if !slices.Contains(got, wf) {
+ t.Errorf("%s: missing flag %q in %v", component, wf, got)
+ }
+ }
}
- for _, wf := range tt.wantFlags {
- if !slices.Contains(got, wf) {
- t.Errorf("missing expected flag %q in %v", wf, got)
+
+ for comp, want := range tt.wantFlags {
+ switch comp {
+ case "entrance":
+ var got []string
+ if p.Entrance != nil {
+ got = p.Entrance.AuditFlags
+ }
+ checkFlags(comp, got, want)
+ case "pathways":
+ var got []string
+ if p.Pathways != nil {
+ got = p.Pathways.AuditFlags
+ }
+ checkFlags(comp, got, want)
+ case "restroom":
+ var got []string
+ if p.Restroom != nil {
+ got = p.Restroom.AuditFlags
+ }
+ checkFlags(comp, got, want)
+ case "parking":
+ var got []string
+ if p.Parking != nil {
+ got = p.Parking.AuditFlags
+ }
+ checkFlags(comp, got, want)
+ case "elevator":
+ var got []string
+ if p.Elevator != nil {
+ got = p.Elevator.AuditFlags
+ }
+ checkFlags(comp, got, want)
}
}
})
diff --git a/internal/api/v1/server.gen.go b/internal/api/v1/server.gen.go
index ca8c0a1..01189b2 100644
--- a/internal/api/v1/server.gen.go
+++ b/internal/api/v1/server.gen.go
@@ -29,93 +29,66 @@ const (
ApiKeyAuthScopes apiKeyAuthContextKey = "ApiKeyAuth.Scopes"
)
-// Defines values for A11yComponentType.
+// Defines values for Category.
const (
- A11yComponentTypeElevator A11yComponentType = "elevator"
- A11yComponentTypeEntrance A11yComponentType = "entrance"
- A11yComponentTypeOther A11yComponentType = "other"
- A11yComponentTypeParking A11yComponentType = "parking"
- A11yComponentTypeRestroom A11yComponentType = "restroom"
+ Airport Category = "airport"
+ Cafe Category = "cafe"
+ Entrance Category = "entrance"
+ Mall Category = "mall"
+ Other Category = "other"
+ Parking Category = "parking"
+ Restaurant Category = "restaurant"
+ Shop Category = "shop"
+ Toilet Category = "toilet"
+ TrainStation Category = "train_station"
)
-// Valid indicates whether the value is a known member of the A11yComponentType enum.
-func (e A11yComponentType) Valid() bool {
+// Valid indicates whether the value is a known member of the Category enum.
+func (e Category) Valid() bool {
switch e {
- case A11yComponentTypeElevator:
+ case Airport:
return true
- case A11yComponentTypeEntrance:
+ case Cafe:
return true
- case A11yComponentTypeOther:
+ case Entrance:
return true
- case A11yComponentTypeParking:
+ case Mall:
return true
- case A11yComponentTypeRestroom:
+ case Other:
return true
- default:
- return false
- }
-}
-
-// Defines values for A11yStatus.
-const (
- Accessible A11yStatus = "accessible"
- Inaccessible A11yStatus = "inaccessible"
- Limited A11yStatus = "limited"
- Unknown A11yStatus = "unknown"
-)
-
-// Valid indicates whether the value is a known member of the A11yStatus enum.
-func (e A11yStatus) Valid() bool {
- switch e {
- case Accessible:
+ case Parking:
+ return true
+ case Restaurant:
return true
- case Inaccessible:
+ case Shop:
return true
- case Limited:
+ case Toilet:
return true
- case Unknown:
+ case TrainStation:
return true
default:
return false
}
}
-// Defines values for Category.
+// Defines values for DoorType.
const (
- CategoryAirport Category = "airport"
- CategoryCafe Category = "cafe"
- CategoryEntrance Category = "entrance"
- CategoryMall Category = "mall"
- CategoryOther Category = "other"
- CategoryParking Category = "parking"
- CategoryRestaurant Category = "restaurant"
- CategoryShop Category = "shop"
- CategoryToilet Category = "toilet"
- CategoryTrainStation Category = "train_station"
+ Automatic DoorType = "automatic"
+ Manual DoorType = "manual"
+ None DoorType = "none"
+ Revolving DoorType = "revolving"
)
-// Valid indicates whether the value is a known member of the Category enum.
-func (e Category) Valid() bool {
+// Valid indicates whether the value is a known member of the DoorType enum.
+func (e DoorType) Valid() bool {
switch e {
- case CategoryAirport:
- return true
- case CategoryCafe:
+ case Automatic:
return true
- case CategoryEntrance:
+ case Manual:
return true
- case CategoryMall:
+ case None:
return true
- case CategoryOther:
- return true
- case CategoryParking:
- return true
- case CategoryRestaurant:
- return true
- case CategoryShop:
- return true
- case CategoryToilet:
- return true
- case CategoryTrainStation:
+ case Revolving:
return true
default:
return false
@@ -164,14 +137,41 @@ func (e PlaceStatus) Valid() bool {
}
}
-// A11yComponent defines model for A11yComponent.
-type A11yComponent = models.A11yComponent
-
-// A11yComponentType defines model for A11yComponentType.
-type A11yComponentType string
+// Defines values for SurfaceType.
+const (
+ Asphalt SurfaceType = "asphalt"
+ Carpet SurfaceType = "carpet"
+ Cobblestone SurfaceType = "cobblestone"
+ Concrete SurfaceType = "concrete"
+ Gravel SurfaceType = "gravel"
+ PavingStones SurfaceType = "paving_stones"
+ Tiles SurfaceType = "tiles"
+ Wood SurfaceType = "wood"
+)
-// A11yStatus defines model for A11yStatus.
-type A11yStatus string
+// Valid indicates whether the value is a known member of the SurfaceType enum.
+func (e SurfaceType) Valid() bool {
+ switch e {
+ case Asphalt:
+ return true
+ case Carpet:
+ return true
+ case Cobblestone:
+ return true
+ case Concrete:
+ return true
+ case Gravel:
+ return true
+ case PavingStones:
+ return true
+ case Tiles:
+ return true
+ case Wood:
+ return true
+ default:
+ return false
+ }
+}
// AccessibilityProfile defines model for AccessibilityProfile.
type AccessibilityProfile = models.AccessibilityProfile
@@ -179,23 +179,17 @@ type AccessibilityProfile = models.AccessibilityProfile
// Category defines model for Category.
type Category string
-// ConflictError defines model for ConflictError.
-type ConflictError struct {
- Conflicts []ConflictItem `json:"conflicts"`
- Error string `json:"error"`
-}
+// DoorProps defines model for DoorProps.
+type DoorProps = models.DoorProps
-// ConflictItem defines model for ConflictItem.
-type ConflictItem struct {
- Component string `json:"component"`
- Reason string `json:"reason"`
-}
+// DoorType defines model for DoorType.
+type DoorType string
-// ElevatorProperties defines model for ElevatorProperties.
-type ElevatorProperties = models.ElevatorProperties
+// ElevatorProps defines model for ElevatorProps.
+type ElevatorProps = models.ElevatorProps
-// EntranceProperties defines model for EntranceProperties.
-type EntranceProperties = models.EntranceProperties
+// EntranceProps defines model for EntranceProps.
+type EntranceProps = models.EntranceProps
// ErrorResponse defines model for ErrorResponse.
type ErrorResponse struct {
@@ -214,8 +208,11 @@ type FieldError struct {
// OSMType defines model for OSMType.
type OSMType string
-// ParkingProperties defines model for ParkingProperties.
-type ParkingProperties = models.ParkingProperties
+// ParkingProps defines model for ParkingProps.
+type ParkingProps = models.ParkingProps
+
+// PathwayProps defines model for PathwayProps.
+type PathwayProps = models.PathwayProps
// Place defines model for Place.
type Place = models.Place
@@ -244,8 +241,18 @@ type RegisterResponse struct {
Email string `json:"email"`
}
-// RestroomProperties defines model for RestroomProperties.
-type RestroomProperties = models.RestroomProperties
+// RestroomProps defines model for RestroomProps.
+type RestroomProps = models.RestroomProps
+
+// SourceReport defines model for SourceReport.
+type SourceReport struct {
+ RecordedAt time.Time `json:"recorded_at"`
+ Source string `json:"source"`
+ Value string `json:"value"`
+}
+
+// SurfaceType defines model for SurfaceType.
+type SurfaceType string
// ValidationError defines model for ValidationError.
type ValidationError struct {
@@ -867,20 +874,6 @@ func (response CreatePlace401JSONResponse) VisitCreatePlaceResponse(w http.Respo
return err
}
-type CreatePlace422JSONResponse ConflictError
-
-func (response CreatePlace422JSONResponse) VisitCreatePlaceResponse(w http.ResponseWriter) error {
-
- var buf bytes.Buffer
- if err := json.NewEncoder(&buf).Encode(response); err != nil {
- return err
- }
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(422)
- _, err := buf.WriteTo(w)
- return err
-}
-
type GetPlaceRequestObject struct {
Id openapi_types.UUID `json:"id"`
}
@@ -996,20 +989,6 @@ func (response PatchPlaceAccessibility404JSONResponse) VisitPatchPlaceAccessibil
return err
}
-type PatchPlaceAccessibility422JSONResponse ConflictError
-
-func (response PatchPlaceAccessibility422JSONResponse) VisitPatchPlaceAccessibilityResponse(w http.ResponseWriter) error {
-
- var buf bytes.Buffer
- if err := json.NewEncoder(&buf).Encode(response); err != nil {
- return err
- }
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(422)
- _, err := buf.WriteTo(w)
- return err
-}
-
// StrictServerInterface represents all server handlers.
type StrictServerInterface interface {
// Revoke the authenticated API key
@@ -1237,52 +1216,54 @@ func (sh *strictHandler) PatchPlaceAccessibility(w http.ResponseWriter, r *http.
// const string: with thousands of chunks the chained `+` fold is several
// times slower for the Go compiler than parsing a slice literal.
var swaggerSpec = []string{
- "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",
- "NDIZJ6PlSbK+Xv8rAAD//w==",
+ "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",
+ "AP//",
}
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
diff --git a/internal/db/migrations/000002_create_accessibility_profiles.up.sql b/internal/db/migrations/000002_create_accessibility_profiles.up.sql
index d72232f..d5c37c3 100644
--- a/internal/db/migrations/000002_create_accessibility_profiles.up.sql
+++ b/internal/db/migrations/000002_create_accessibility_profiles.up.sql
@@ -1,7 +1,11 @@
CREATE TABLE IF NOT EXISTS accessibility_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
place_id UUID NOT NULL UNIQUE REFERENCES places(id),
- overall_status TEXT,
- components JSONB,
+ source_reports JSONB,
+ entrance JSONB,
+ pathways JSONB,
+ restroom JSONB,
+ parking JSONB,
+ elevator JSONB,
updated_at TIMESTAMPTZ
);
diff --git a/internal/place/repository.go b/internal/place/repository.go
index 28c117c..d276bd3 100644
--- a/internal/place/repository.go
+++ b/internal/place/repository.go
@@ -112,8 +112,8 @@ func (r *Repository) AttachExternalRef(
return nil
}
-// UpsertProfile creates or replaces the accessibility profile. Always overwrites — API write path.
-// Returns created=true when a new row was inserted, false when an existing row was updated.
+// UpsertProfile creates or replaces the accessibility profile. Always overwrites.
+// Returns created=true when a new row was inserted, false on update.
func (r *Repository) UpsertProfile(ctx context.Context, placeID string, profile *models.AccessibilityProfile) (created bool, err error) {
if profile == nil {
return false, fmt.Errorf("upsert profile: nil profile")
@@ -138,8 +138,12 @@ func (r *Repository) UpsertProfile(ctx context.Context, placeID string, profile
return tx.Create(profile).Error
}
updates := map[string]any{
- "overall_status": profile.OverallStatus,
- "components": profile.Components,
+ "source_reports": profile.SourceReports,
+ "entrance": profile.Entrance,
+ "pathways": profile.Pathways,
+ "restroom": profile.Restroom,
+ "parking": profile.Parking,
+ "elevator": profile.Elevator,
"updated_at": now,
"submitted_by": profile.SubmittedBy,
"submitted_at": profile.SubmittedAt,
@@ -182,8 +186,12 @@ func (r *Repository) UpsertProfileIngestion(ctx context.Context, placeID string,
return nil
}
updates := map[string]any{
- "overall_status": profile.OverallStatus,
- "components": profile.Components,
+ "source_reports": profile.SourceReports,
+ "entrance": profile.Entrance,
+ "pathways": profile.Pathways,
+ "restroom": profile.Restroom,
+ "parking": profile.Parking,
+ "elevator": profile.Elevator,
"updated_at": now,
"submitted_by": nil,
"submitted_at": nil,
diff --git a/internal/place/repository_integration_test.go b/internal/place/repository_integration_test.go
index 9da2aa5..d0c74a3 100644
--- a/internal/place/repository_integration_test.go
+++ b/internal/place/repository_integration_test.go
@@ -21,6 +21,8 @@ import (
"github.com/InWheelOrg/inwheel-api/pkg/models"
)
+func boolPtr(b bool) *bool { return &b }
+
func TestUpsertBatch_InsertsNewPlaces(t *testing.T) {
ctx := context.Background()
db, cleanup, err := testhelpers.StartPostgres(ctx)
@@ -242,7 +244,7 @@ func TestRepository_UpsertProfile_CreatesWhenAbsent(t *testing.T) {
placeID := mustCreatePlace(ctx, t, gormDB, 1001, "Profile Test Place")
profile := &models.AccessibilityProfile{
- OverallStatus: models.StatusAccessible,
+ Entrance: &models.EntranceProps{IsLevel: boolPtr(true)},
}
created, err := repo.UpsertProfile(ctx, placeID, profile)
if err != nil {
@@ -256,8 +258,8 @@ func TestRepository_UpsertProfile_CreatesWhenAbsent(t *testing.T) {
if err := gormDB.Where("place_id = ?", placeID).First(&got).Error; err != nil {
t.Fatalf("load profile: %v", err)
}
- if got.OverallStatus != models.StatusAccessible {
- t.Errorf("OverallStatus = %q, want %q", got.OverallStatus, models.StatusAccessible)
+ if got.Entrance == nil || got.Entrance.IsLevel == nil || !*got.Entrance.IsLevel {
+ t.Errorf("Entrance.IsLevel = %v, want true", got.Entrance)
}
if got.PlaceID != placeID {
t.Errorf("PlaceID = %q, want %q", got.PlaceID, placeID)
@@ -275,7 +277,7 @@ func TestRepository_UpsertProfile_UpdatesWhenPresent(t *testing.T) {
repo := place.NewRepository(gormDB)
placeID := mustCreatePlace(ctx, t, gormDB, 1002, "Profile Update Place")
- first := &models.AccessibilityProfile{OverallStatus: models.StatusUnknown}
+ first := &models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(false)}}
created, err := repo.UpsertProfile(ctx, placeID, first)
if err != nil {
t.Fatalf("first UpsertProfile: %v", err)
@@ -284,7 +286,7 @@ func TestRepository_UpsertProfile_UpdatesWhenPresent(t *testing.T) {
t.Errorf("created = false, want true on first insert")
}
- second := &models.AccessibilityProfile{OverallStatus: models.StatusLimited}
+ second := &models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}}
created, err = repo.UpsertProfile(ctx, placeID, second)
if err != nil {
t.Fatalf("second UpsertProfile: %v", err)
@@ -297,8 +299,8 @@ func TestRepository_UpsertProfile_UpdatesWhenPresent(t *testing.T) {
if err := gormDB.Where("place_id = ?", placeID).First(&got).Error; err != nil {
t.Fatalf("load profile: %v", err)
}
- if got.OverallStatus != models.StatusLimited {
- t.Errorf("OverallStatus = %q, want %q", got.OverallStatus, models.StatusLimited)
+ if got.Entrance == nil || got.Entrance.IsLevel == nil || !*got.Entrance.IsLevel {
+ t.Errorf("Entrance.IsLevel = %v, want true (updated)", got.Entrance)
}
var count int64
gormDB.Model(&models.AccessibilityProfile{}).Where("place_id = ?", placeID).Count(&count)
@@ -318,13 +320,13 @@ func TestRepository_UpsertProfile_OverwritesUserVerified(t *testing.T) {
repo := place.NewRepository(gormDB)
placeID := mustCreatePlace(ctx, t, gormDB, 1003, "User Verified Overwrite Place")
- if _, err := repo.UpsertProfile(ctx, placeID, &models.AccessibilityProfile{OverallStatus: models.StatusAccessible, UserVerified: true}); err != nil {
+ if _, err := repo.UpsertProfile(ctx, placeID, &models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}, UserVerified: true}); err != nil {
t.Fatalf("seed: %v", err)
}
override := &models.AccessibilityProfile{
- OverallStatus: models.StatusInaccessible,
- UserVerified: false,
+ Entrance: &models.EntranceProps{IsLevel: boolPtr(false)},
+ UserVerified: false,
}
if _, err := repo.UpsertProfile(ctx, placeID, override); err != nil {
t.Fatalf("override UpsertProfile: %v", err)
@@ -334,8 +336,8 @@ func TestRepository_UpsertProfile_OverwritesUserVerified(t *testing.T) {
if err := gormDB.Where("place_id = ?", placeID).First(&got).Error; err != nil {
t.Fatalf("load profile: %v", err)
}
- if got.OverallStatus != models.StatusInaccessible {
- t.Errorf("OverallStatus = %q, want %q", got.OverallStatus, models.StatusInaccessible)
+ if got.Entrance == nil || got.Entrance.IsLevel == nil || *got.Entrance.IsLevel {
+ t.Errorf("Entrance.IsLevel = %v, want false (overwritten)", got.Entrance)
}
}
@@ -348,7 +350,7 @@ func TestRepository_UpsertProfile_PlaceNotFound(t *testing.T) {
defer cleanup()
repo := place.NewRepository(db)
- _, err = repo.UpsertProfile(ctx, "00000000-0000-0000-0000-000000000000", &models.AccessibilityProfile{OverallStatus: models.StatusAccessible})
+ _, err = repo.UpsertProfile(ctx, "00000000-0000-0000-0000-000000000000", &models.AccessibilityProfile{})
if !errors.Is(err, place.ErrPlaceNotFound) {
t.Errorf("err = %v, want ErrPlaceNotFound", err)
}
@@ -364,7 +366,7 @@ func TestRepository_UpsertProfileIngestion_InsertsWhenAbsent(t *testing.T) {
repo := place.NewRepository(db)
placeID := mustCreatePlace(ctx, t, db, 9004, "Café Pascal Ingestion")
- written, err := repo.UpsertProfileIngestion(ctx, placeID, &models.AccessibilityProfile{OverallStatus: models.StatusAccessible})
+ written, err := repo.UpsertProfileIngestion(ctx, placeID, &models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}})
if err != nil {
t.Fatalf("UpsertProfileIngestion: %v", err)
}
@@ -373,8 +375,8 @@ func TestRepository_UpsertProfileIngestion_InsertsWhenAbsent(t *testing.T) {
}
var stored models.AccessibilityProfile
db.Where("place_id = ?", placeID).First(&stored)
- if stored.OverallStatus != models.StatusAccessible {
- t.Errorf("OverallStatus = %q, want accessible", stored.OverallStatus)
+ if stored.Entrance == nil || stored.Entrance.IsLevel == nil || !*stored.Entrance.IsLevel {
+ t.Errorf("Entrance.IsLevel = %v, want true", stored.Entrance)
}
}
@@ -388,10 +390,10 @@ func TestRepository_UpsertProfileIngestion_OverwritesNonVerified(t *testing.T) {
repo := place.NewRepository(db)
placeID := mustCreatePlace(ctx, t, db, 9005, "Café Pascal Ingestion2")
- if _, err := repo.UpsertProfileIngestion(ctx, placeID, &models.AccessibilityProfile{OverallStatus: models.StatusLimited}); err != nil {
+ if _, err := repo.UpsertProfileIngestion(ctx, placeID, &models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(false)}}); err != nil {
t.Fatalf("seed: %v", err)
}
- written, err := repo.UpsertProfileIngestion(ctx, placeID, &models.AccessibilityProfile{OverallStatus: models.StatusAccessible})
+ written, err := repo.UpsertProfileIngestion(ctx, placeID, &models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}})
if err != nil {
t.Fatalf("second: %v", err)
}
@@ -400,8 +402,8 @@ func TestRepository_UpsertProfileIngestion_OverwritesNonVerified(t *testing.T) {
}
var stored models.AccessibilityProfile
db.Where("place_id = ?", placeID).First(&stored)
- if stored.OverallStatus != models.StatusAccessible {
- t.Errorf("OverallStatus = %q, want accessible", stored.OverallStatus)
+ if stored.Entrance == nil || stored.Entrance.IsLevel == nil || !*stored.Entrance.IsLevel {
+ t.Errorf("Entrance.IsLevel = %v, want true (overwritten)", stored.Entrance)
}
}
@@ -415,10 +417,10 @@ func TestRepository_UpsertProfileIngestion_SkipsUserVerified(t *testing.T) {
repo := place.NewRepository(db)
placeID := mustCreatePlace(ctx, t, db, 9006, "Café Pascal Ingestion3")
- if _, err := repo.UpsertProfile(ctx, placeID, &models.AccessibilityProfile{OverallStatus: models.StatusAccessible, UserVerified: true}); err != nil {
+ if _, err := repo.UpsertProfile(ctx, placeID, &models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(true)}, UserVerified: true}); err != nil {
t.Fatalf("seed: %v", err)
}
- written, err := repo.UpsertProfileIngestion(ctx, placeID, &models.AccessibilityProfile{OverallStatus: models.StatusInaccessible})
+ written, err := repo.UpsertProfileIngestion(ctx, placeID, &models.AccessibilityProfile{Entrance: &models.EntranceProps{IsLevel: boolPtr(false)}})
if err != nil {
t.Fatalf("machine: %v", err)
}
@@ -427,8 +429,8 @@ func TestRepository_UpsertProfileIngestion_SkipsUserVerified(t *testing.T) {
}
var stored models.AccessibilityProfile
db.Where("place_id = ?", placeID).First(&stored)
- if stored.OverallStatus != models.StatusAccessible {
- t.Errorf("user-verified row must survive; got %q", stored.OverallStatus)
+ if stored.Entrance == nil || stored.Entrance.IsLevel == nil || !*stored.Entrance.IsLevel {
+ t.Errorf("user-verified row must survive; Entrance.IsLevel = %v, want true", stored.Entrance)
}
}
diff --git a/internal/sources/osm/a11y.go b/internal/sources/osm/a11y.go
index 1d16a51..cf68978 100644
--- a/internal/sources/osm/a11y.go
+++ b/internal/sources/osm/a11y.go
@@ -7,156 +7,117 @@ package osm
import (
"strconv"
+ "time"
"github.com/InWheelOrg/inwheel-api/pkg/models"
)
-// mapTagsToProfile derives an AccessibilityProfile from accessibility tags
-// present on an OSM POI node. Returns nil when no accessibility signal is
-// found. Reads only tags on the POI itself; no graph traversal.
+// mapTagsToProfile derives an AccessibilityProfile from OSM accessibility tags
+// on a POI node. Returns nil when no accessibility signal is found.
+// The OSM wheelchair tag is stored as a SourceReport, not interpreted as truth.
func mapTagsToProfile(tags map[string]string) *models.AccessibilityProfile {
- overall, hasOverall := wheelchairToStatus(tags["wheelchair"])
-
- var components []models.A11yComponent
- if c, ok := mapRestroom(tags); ok {
- components = append(components, c)
- }
- if c, ok := mapParking(tags); ok {
- components = append(components, c)
- }
- if c, ok := mapEntrance(tags); ok {
- components = append(components, c)
- }
- if c, ok := mapElevator(tags); ok {
- components = append(components, c)
+ var reports models.SourceReports
+ if v, ok := tags["wheelchair"]; ok {
+ switch v {
+ case "yes", "designated", "limited", "no":
+ reports = append(reports, models.SourceReport{
+ Source: "osm",
+ Value: v,
+ RecordedAt: time.Now(),
+ })
+ }
}
- if !hasOverall && len(components) == 0 {
+ entrance := mapEntrance(tags)
+ restroom := mapRestroom(tags)
+ parking := mapParking(tags)
+ elevator := mapElevator(tags)
+
+ if len(reports) == 0 && entrance == nil && restroom == nil && parking == nil && elevator == nil {
return nil
}
- if !hasOverall {
- overall = models.StatusUnknown
- }
- return &models.AccessibilityProfile{
- OverallStatus: overall,
- Components: components,
- }
-}
-func wheelchairToStatus(v string) (models.A11yStatus, bool) {
- switch v {
- case "yes", "designated":
- return models.StatusAccessible, true
- case "limited":
- return models.StatusLimited, true
- case "no":
- return models.StatusInaccessible, true
- default:
- return "", false
+ return &models.AccessibilityProfile{
+ SourceReports: reports,
+ Entrance: entrance,
+ Restroom: restroom,
+ Parking: parking,
+ Elevator: elevator,
}
}
-func mapRestroom(tags map[string]string) (models.A11yComponent, bool) {
+func mapRestroom(tags map[string]string) *models.RestroomProps {
v, ok := tags["toilets:wheelchair"]
if !ok {
- return models.A11yComponent{}, false
+ return nil
}
switch v {
case "yes":
- yes := true
- return models.A11yComponent{
- Type: models.ComponentRestroom,
- OverallStatus: models.StatusAccessible,
- Restroom: &models.RestroomProperties{WheelchairAccessible: &yes},
- }, true
+ t := true
+ return &models.RestroomProps{IsAccessible: &t}
case "no":
- no := false
- return models.A11yComponent{
- Type: models.ComponentRestroom,
- OverallStatus: models.StatusInaccessible,
- Restroom: &models.RestroomProperties{WheelchairAccessible: &no},
- }, true
+ f := false
+ return &models.RestroomProps{IsAccessible: &f}
}
- return models.A11yComponent{}, false
+ return nil
}
-func mapParking(tags map[string]string) (models.A11yComponent, bool) {
+func mapParking(tags map[string]string) *models.ParkingProps {
if v, ok := tags["capacity:disabled"]; ok {
n, err := strconv.Atoi(v)
if err != nil {
- return models.A11yComponent{}, false
- }
- if n > 0 {
- yes := true
- return models.A11yComponent{
- Type: models.ComponentParking,
- OverallStatus: models.StatusAccessible,
- Parking: &models.ParkingProperties{
- HasDisabledSpaces: &yes,
- Count: &n,
- },
- }, true
+ return nil
}
- no := false
- return models.A11yComponent{
- Type: models.ComponentParking,
- OverallStatus: models.StatusInaccessible,
- Parking: &models.ParkingProperties{HasDisabledSpaces: &no, Count: &n},
- }, true
+ hasSpaces := n > 0
+ return &models.ParkingProps{HasDisabledSpaces: &hasSpaces, Count: &n}
}
if tags["parking:disabled"] == "no" {
- no := false
- return models.A11yComponent{
- Type: models.ComponentParking,
- OverallStatus: models.StatusInaccessible,
- Parking: &models.ParkingProperties{HasDisabledSpaces: &no},
- }, true
+ f := false
+ return &models.ParkingProps{HasDisabledSpaces: &f}
}
- return models.A11yComponent{}, false
+ return nil
}
-func mapEntrance(tags map[string]string) (models.A11yComponent, bool) {
- props := &models.EntranceProperties{}
- any := false
+func mapEntrance(tags map[string]string) *models.EntranceProps {
+ props := &models.EntranceProps{}
+ found := false
if v := tags["automatic_door"]; v != "" && v != "no" {
- t := true
- props.IsAutomatic = &t
- any = true
+ auto := models.DoorAutomatic
+ if props.Door == nil {
+ props.Door = &models.DoorProps{}
+ }
+ props.Door.Type = auto
+ found = true
}
if stepCountPositive(tags["step_count"]) || stepCountPositive(tags["entrance:step_count"]) {
- t := true
- props.HasStep = &t
- any = true
+ f := false
+ props.IsLevel = &f
+ found = true
}
- // ramp:wheelchair takes precedence over the generic ramp key.
if v, ok := tags["ramp:wheelchair"]; ok {
switch v {
case "yes":
t := true
- props.HasRamp = &t
- any = true
+ props.HasFixedRamp = &t
+ found = true
case "no":
f := false
- props.HasRamp = &f
- any = true
+ props.HasFixedRamp = &f
+ found = true
}
} else if tags["ramp"] == "no" {
f := false
- props.HasRamp = &f
- any = true
+ props.HasFixedRamp = &f
+ found = true
}
- if !any {
- return models.A11yComponent{}, false
+ if !found {
+ return nil
}
- return models.A11yComponent{
- Type: models.ComponentEntrance,
- OverallStatus: models.StatusUnknown,
- Entrance: props,
- }, true
+ return props
}
func stepCountPositive(v string) bool {
@@ -167,13 +128,9 @@ func stepCountPositive(v string) bool {
return err == nil && n > 0
}
-func mapElevator(tags map[string]string) (models.A11yComponent, bool) {
+func mapElevator(tags map[string]string) *models.ElevatorProps {
if tags["elevator"] == "yes" {
- return models.A11yComponent{
- Type: models.ComponentElevator,
- OverallStatus: models.StatusAccessible,
- Elevator: &models.ElevatorProperties{},
- }, true
+ return &models.ElevatorProps{}
}
- return models.A11yComponent{}, false
+ return nil
}
diff --git a/internal/sources/osm/a11y_test.go b/internal/sources/osm/a11y_test.go
index e5af8d9..9b77525 100644
--- a/internal/sources/osm/a11y_test.go
+++ b/internal/sources/osm/a11y_test.go
@@ -11,6 +11,8 @@ import (
"github.com/InWheelOrg/inwheel-api/pkg/models"
)
+func boolPtr(b bool) *bool { return &b }
+
func TestMapTagsToProfile_ReturnsNilWhenNoA11ySignal(t *testing.T) {
got := mapTagsToProfile(map[string]string{
"amenity": "cafe",
@@ -23,13 +25,13 @@ func TestMapTagsToProfile_ReturnsNilWhenNoA11ySignal(t *testing.T) {
func TestMapTagsToProfile_WheelchairValues(t *testing.T) {
cases := []struct {
- tag string
- want models.A11yStatus
+ tag string
+ wantValue string
}{
- {"yes", models.StatusAccessible},
- {"designated", models.StatusAccessible},
- {"limited", models.StatusLimited},
- {"no", models.StatusInaccessible},
+ {"yes", "yes"},
+ {"designated", "designated"},
+ {"limited", "limited"},
+ {"no", "no"},
}
for _, c := range cases {
t.Run(c.tag, func(t *testing.T) {
@@ -37,137 +39,159 @@ func TestMapTagsToProfile_WheelchairValues(t *testing.T) {
if got == nil {
t.Fatalf("expected profile, got nil")
}
- if got.OverallStatus != c.want {
- t.Errorf("OverallStatus = %q, want %q", got.OverallStatus, c.want)
+ if len(got.SourceReports) != 1 {
+ t.Fatalf("expected 1 source report, got %d", len(got.SourceReports))
+ }
+ sr := got.SourceReports[0]
+ if sr.Source != "osm" {
+ t.Errorf("Source = %q, want osm", sr.Source)
+ }
+ if sr.Value != c.wantValue {
+ t.Errorf("Value = %q, want %q", sr.Value, c.wantValue)
}
})
}
}
-func TestMapTagsToProfile_UnknownStatusWhenComponentOnlyTags(t *testing.T) {
- got := mapTagsToProfile(map[string]string{
- "toilets:wheelchair": "yes",
- })
+func TestMapTagsToProfile_UnknownWheelchairTagSkipped(t *testing.T) {
+ got := mapTagsToProfile(map[string]string{"wheelchair": "permissive"})
+ if got != nil {
+ t.Errorf("unknown wheelchair value should not emit a profile, got %+v", got)
+ }
+}
+
+func TestMapTagsToProfile_ComponentOnlyNoSourceReport(t *testing.T) {
+ got := mapTagsToProfile(map[string]string{"toilets:wheelchair": "yes"})
if got == nil {
t.Fatalf("expected profile when toilet tag present")
}
- if got.OverallStatus != models.StatusUnknown {
- t.Errorf("OverallStatus = %q, want unknown", got.OverallStatus)
+ if len(got.SourceReports) != 0 {
+ t.Errorf("expected no source reports, got %v", got.SourceReports)
}
- if len(got.Components) != 1 || got.Components[0].Type != models.ComponentRestroom {
- t.Errorf("Components = %+v, want one restroom", got.Components)
+ if got.Restroom == nil {
+ t.Error("expected Restroom to be set")
}
}
func TestMapTagsToProfile_Restroom(t *testing.T) {
- cases := []struct {
- name string
- val string
- want models.A11yStatus
- access *bool
- }{
- {"yes", "yes", models.StatusAccessible, boolPtr(true)},
- {"no", "no", models.StatusInaccessible, boolPtr(false)},
- }
- for _, c := range cases {
- t.Run(c.name, func(t *testing.T) {
- got := mapTagsToProfile(map[string]string{"toilets:wheelchair": c.val})
- if got == nil {
- t.Fatalf("expected profile")
- }
- r := findComponent(t, got, models.ComponentRestroom)
- if r.OverallStatus != c.want {
- t.Errorf("OverallStatus = %q, want %q", r.OverallStatus, c.want)
- }
- if r.Restroom == nil || r.Restroom.WheelchairAccessible == nil {
- t.Fatalf("Restroom.WheelchairAccessible nil")
- }
- if *r.Restroom.WheelchairAccessible != *c.access {
- t.Errorf("WheelchairAccessible = %v, want %v", *r.Restroom.WheelchairAccessible, *c.access)
- }
- })
- }
+ t.Run("yes sets IsAccessible true", func(t *testing.T) {
+ got := mapTagsToProfile(map[string]string{"toilets:wheelchair": "yes"})
+ if got == nil || got.Restroom == nil {
+ t.Fatal("expected restroom")
+ }
+ if got.Restroom.IsAccessible == nil || !*got.Restroom.IsAccessible {
+ t.Error("IsAccessible should be true")
+ }
+ })
+ t.Run("no sets IsAccessible false", func(t *testing.T) {
+ got := mapTagsToProfile(map[string]string{"toilets:wheelchair": "no"})
+ if got == nil || got.Restroom == nil {
+ t.Fatal("expected restroom")
+ }
+ if got.Restroom.IsAccessible == nil || *got.Restroom.IsAccessible {
+ t.Error("IsAccessible should be false")
+ }
+ })
}
func TestMapTagsToProfile_Parking(t *testing.T) {
t.Run("capacity:disabled positive", func(t *testing.T) {
got := mapTagsToProfile(map[string]string{"capacity:disabled": "3"})
- p := findComponent(t, got, models.ComponentParking)
- if p.OverallStatus != models.StatusAccessible {
- t.Errorf("OverallStatus = %q, want accessible", p.OverallStatus)
+ if got == nil || got.Parking == nil {
+ t.Fatal("expected parking")
}
- if p.Parking == nil || p.Parking.HasDisabledSpaces == nil || !*p.Parking.HasDisabledSpaces {
- t.Errorf("HasDisabledSpaces not set true")
+ if got.Parking.HasDisabledSpaces == nil || !*got.Parking.HasDisabledSpaces {
+ t.Error("HasDisabledSpaces should be true")
}
- if p.Parking.Count == nil || *p.Parking.Count != 3 {
- t.Errorf("Count = %v, want 3", p.Parking.Count)
+ if got.Parking.Count == nil || *got.Parking.Count != 3 {
+ t.Errorf("Count = %v, want 3", got.Parking.Count)
}
})
t.Run("capacity:disabled zero", func(t *testing.T) {
got := mapTagsToProfile(map[string]string{"capacity:disabled": "0"})
- p := findComponent(t, got, models.ComponentParking)
- if p.OverallStatus != models.StatusInaccessible {
- t.Errorf("OverallStatus = %q, want inaccessible", p.OverallStatus)
+ if got == nil || got.Parking == nil {
+ t.Fatal("expected parking")
}
- if p.Parking == nil || p.Parking.HasDisabledSpaces == nil || *p.Parking.HasDisabledSpaces {
- t.Errorf("HasDisabledSpaces should be false")
+ if got.Parking.HasDisabledSpaces == nil || *got.Parking.HasDisabledSpaces {
+ t.Error("HasDisabledSpaces should be false")
}
- if p.Parking.Count == nil || *p.Parking.Count != 0 {
- t.Errorf("Count = %v, want 0 (preserves confirmed-zero from source tag)", p.Parking.Count)
+ if got.Parking.Count == nil || *got.Parking.Count != 0 {
+ t.Errorf("Count = %v, want 0", got.Parking.Count)
+ }
+ })
+ t.Run("parking:disabled=no", func(t *testing.T) {
+ got := mapTagsToProfile(map[string]string{"parking:disabled": "no"})
+ if got == nil || got.Parking == nil {
+ t.Fatal("expected parking")
+ }
+ if got.Parking.HasDisabledSpaces == nil || *got.Parking.HasDisabledSpaces {
+ t.Error("HasDisabledSpaces should be false")
}
})
}
func TestMapTagsToProfile_Entrance(t *testing.T) {
- t.Run("automatic_door=button sets IsAutomatic", func(t *testing.T) {
+ t.Run("automatic_door=button sets door type", func(t *testing.T) {
got := mapTagsToProfile(map[string]string{"automatic_door": "button"})
- e := findComponent(t, got, models.ComponentEntrance)
- if e.Entrance == nil || e.Entrance.IsAutomatic == nil || !*e.Entrance.IsAutomatic {
- t.Errorf("IsAutomatic not set true")
+ if got == nil || got.Entrance == nil {
+ t.Fatal("expected entrance")
+ }
+ if got.Entrance.Door == nil || got.Entrance.Door.Type != models.DoorAutomatic {
+ t.Errorf("Door.Type = %q, want automatic", got.Entrance.Door.Type)
}
})
- t.Run("automatic_door=no does not set IsAutomatic", func(t *testing.T) {
+ t.Run("automatic_door=no does not produce entrance", func(t *testing.T) {
got := mapTagsToProfile(map[string]string{"automatic_door": "no"})
if got != nil {
- t.Errorf("automatic_door=no alone should not emit an entrance component, got %+v", got)
+ t.Errorf("automatic_door=no alone should not emit a profile")
}
})
- t.Run("step_count creates HasStep", func(t *testing.T) {
+ t.Run("step_count sets IsLevel false", func(t *testing.T) {
got := mapTagsToProfile(map[string]string{"step_count": "2"})
- e := findComponent(t, got, models.ComponentEntrance)
- if e.Entrance == nil || e.Entrance.HasStep == nil || !*e.Entrance.HasStep {
- t.Errorf("HasStep not set true")
+ if got == nil || got.Entrance == nil {
+ t.Fatal("expected entrance")
+ }
+ if got.Entrance.IsLevel == nil || *got.Entrance.IsLevel {
+ t.Error("IsLevel should be false")
}
})
t.Run("entrance:step_count is honoured", func(t *testing.T) {
got := mapTagsToProfile(map[string]string{"entrance:step_count": "1"})
- e := findComponent(t, got, models.ComponentEntrance)
- if e.Entrance == nil || e.Entrance.HasStep == nil || !*e.Entrance.HasStep {
- t.Errorf("HasStep not set true via entrance:step_count")
+ if got == nil || got.Entrance == nil {
+ t.Fatal("expected entrance")
+ }
+ if got.Entrance.IsLevel == nil || *got.Entrance.IsLevel {
+ t.Error("IsLevel should be false")
}
})
- t.Run("ramp:wheelchair=yes sets HasRamp true", func(t *testing.T) {
+ t.Run("ramp:wheelchair=yes sets HasFixedRamp true", func(t *testing.T) {
got := mapTagsToProfile(map[string]string{"ramp:wheelchair": "yes"})
- e := findComponent(t, got, models.ComponentEntrance)
- if e.Entrance == nil || e.Entrance.HasRamp == nil || !*e.Entrance.HasRamp {
- t.Errorf("HasRamp not set true")
+ if got == nil || got.Entrance == nil {
+ t.Fatal("expected entrance")
+ }
+ if got.Entrance.HasFixedRamp == nil || !*got.Entrance.HasFixedRamp {
+ t.Error("HasFixedRamp should be true")
}
})
- t.Run("ramp:wheelchair=no sets HasRamp false", func(t *testing.T) {
+ t.Run("ramp:wheelchair=no sets HasFixedRamp false", func(t *testing.T) {
got := mapTagsToProfile(map[string]string{"step_count": "1", "ramp:wheelchair": "no"})
- e := findComponent(t, got, models.ComponentEntrance)
- if e.Entrance == nil || e.Entrance.HasRamp == nil || *e.Entrance.HasRamp {
- t.Errorf("HasRamp should be false, got %v", e.Entrance.HasRamp)
+ if got == nil || got.Entrance == nil {
+ t.Fatal("expected entrance")
+ }
+ if got.Entrance.HasFixedRamp == nil || *got.Entrance.HasFixedRamp {
+ t.Errorf("HasFixedRamp should be false, got %v", got.Entrance.HasFixedRamp)
}
})
t.Run("ramp:wheelchair takes precedence over ramp", func(t *testing.T) {
got := mapTagsToProfile(map[string]string{"ramp": "yes", "ramp:wheelchair": "no", "step_count": "1"})
- e := findComponent(t, got, models.ComponentEntrance)
- if e.Entrance == nil || e.Entrance.HasRamp == nil || *e.Entrance.HasRamp {
- t.Errorf("ramp:wheelchair=no should win, got HasRamp=%v", e.Entrance.HasRamp)
+ if got == nil || got.Entrance == nil {
+ t.Fatal("expected entrance")
+ }
+ if got.Entrance.HasFixedRamp == nil || *got.Entrance.HasFixedRamp {
+ t.Errorf("ramp:wheelchair=no should win, HasFixedRamp=%v", got.Entrance.HasFixedRamp)
}
})
- t.Run("ramp=yes alone returns nil (non-wheelchair-specific ramp is not a signal)", func(t *testing.T) {
+ t.Run("ramp=yes alone returns nil", func(t *testing.T) {
got := mapTagsToProfile(map[string]string{"ramp": "yes"})
if got != nil {
t.Errorf("ramp=yes alone should return nil, got %+v", got)
@@ -177,13 +201,12 @@ func TestMapTagsToProfile_Entrance(t *testing.T) {
func TestMapTagsToProfile_Elevator(t *testing.T) {
got := mapTagsToProfile(map[string]string{"elevator": "yes"})
- e := findComponent(t, got, models.ComponentElevator)
- if e.OverallStatus != models.StatusAccessible {
- t.Errorf("elevator status = %q, want accessible", e.OverallStatus)
+ if got == nil || got.Elevator == nil {
+ t.Fatal("expected elevator")
}
}
-func TestMapTagsToProfile_AggregatesMultipleComponents(t *testing.T) {
+func TestMapTagsToProfile_AggregatesAllComponents(t *testing.T) {
got := mapTagsToProfile(map[string]string{
"wheelchair": "yes",
"toilets:wheelchair": "yes",
@@ -192,41 +215,21 @@ func TestMapTagsToProfile_AggregatesMultipleComponents(t *testing.T) {
"elevator": "yes",
})
if got == nil {
- t.Fatalf("expected profile")
+ t.Fatal("expected profile")
}
- if got.OverallStatus != models.StatusAccessible {
- t.Errorf("OverallStatus = %q, want accessible", got.OverallStatus)
+ if len(got.SourceReports) != 1 || got.SourceReports[0].Value != "yes" {
+ t.Errorf("expected one OSM source report with value 'yes', got %v", got.SourceReports)
}
- want := map[models.A11yComponentType]bool{
- models.ComponentRestroom: false,
- models.ComponentParking: false,
- models.ComponentEntrance: false,
- models.ComponentElevator: false,
+ if got.Restroom == nil {
+ t.Error("expected Restroom")
}
- for _, c := range got.Components {
- if _, ok := want[c.Type]; ok {
- want[c.Type] = true
- }
+ if got.Parking == nil {
+ t.Error("expected Parking")
}
- for k, v := range want {
- if !v {
- t.Errorf("missing %q component", k)
- }
+ if got.Entrance == nil {
+ t.Error("expected Entrance")
}
-}
-
-func findComponent(t *testing.T, p *models.AccessibilityProfile, ct models.A11yComponentType) models.A11yComponent {
- t.Helper()
- if p == nil {
- t.Fatalf("profile is nil")
- }
- for _, c := range p.Components {
- if c.Type == ct {
- return c
- }
+ if got.Elevator == nil {
+ t.Error("expected Elevator")
}
- t.Fatalf("component %q not found, components = %+v", ct, p.Components)
- return models.A11yComponent{}
}
-
-func boolPtr(b bool) *bool { return &b }
diff --git a/internal/sources/osm/transformer_test.go b/internal/sources/osm/transformer_test.go
index 0b168f9..391c344 100644
--- a/internal/sources/osm/transformer_test.go
+++ b/internal/sources/osm/transformer_test.go
@@ -95,13 +95,13 @@ func TestTransformNode_ReturnsProfileWhenA11yTagsPresent(t *testing.T) {
t.Fatalf("TransformNode: %v", err)
}
if place.Accessibility != nil {
- t.Errorf("place.Accessibility should be nil — profile is returned separately")
+ t.Errorf("place.Accessibility should be nil; profile is returned separately")
}
if profile == nil {
t.Fatalf("profile = nil, want non-nil when wheelchair=yes present")
}
- if profile.OverallStatus != models.StatusAccessible {
- t.Errorf("OverallStatus = %q, want accessible", profile.OverallStatus)
+ if len(profile.SourceReports) == 0 || profile.SourceReports[0].Value != "yes" {
+ t.Errorf("expected source report with value 'yes', got %v", profile.SourceReports)
}
}
diff --git a/internal/validation/validation.go b/internal/validation/validation.go
index 7adc4f6..f8662ec 100644
--- a/internal/validation/validation.go
+++ b/internal/validation/validation.go
@@ -11,7 +11,6 @@
package validation
import (
- "encoding/json"
"fmt"
"regexp"
"strings"
@@ -32,8 +31,6 @@ const (
maxTagEntries = 50
maxTagKeyLength = 64
maxTagValueLength = 256
- maxMetadataEntries = 50
- maxMetadataBytes = 4 * 1024
maxParkingCount = 10000
)
@@ -62,12 +59,6 @@ func Place(p *models.Place) []FieldError {
errs = append(errs, validateTags(p.Tags)...)
- if p.Accessibility != nil {
- for _, comp := range p.Accessibility.Components {
- errs = append(errs, validateMetadata(comp.Metadata)...)
- }
- }
-
return errs
}
@@ -138,23 +129,6 @@ func validateTags(tags models.PlaceTags) []FieldError {
return errs
}
-func validateMetadata(md map[string]any) []FieldError {
- if len(md) > maxMetadataEntries {
- return []FieldError{{Field: "metadata", Reason: fmt.Sprintf("must contain ≤ %d entries", maxMetadataEntries)}}
- }
- if len(md) == 0 {
- return nil
- }
- b, err := json.Marshal(md)
- if err != nil {
- return []FieldError{{Field: "metadata", Reason: "must be JSON-serialisable"}}
- }
- if len(b) > maxMetadataBytes {
- return []FieldError{{Field: "metadata", Reason: fmt.Sprintf("serialised size exceeds %d bytes", maxMetadataBytes)}}
- }
- return nil
-}
-
func countNonNilFloats(ptrs ...*float64) int {
n := 0
for _, p := range ptrs {
diff --git a/pkg/models/accessibility.go b/pkg/models/accessibility.go
index c35492d..14262ad 100644
--- a/pkg/models/accessibility.go
+++ b/pkg/models/accessibility.go
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-// Package models defines the domain types shared across InWheel services.
package models
import (
@@ -13,144 +12,155 @@ import (
"time"
)
-// A11yStatus defines the overall accessibility state of a place.
-type A11yStatus string
+// AccessibilityProfile stores factual accessibility data for a place.
+// Component fields hold measured facts; SourceReports carries raw opinions from
+// external sources (e.g. OSM wheelchair=yes). No server-side accessibility
+// judgment is made; clients apply their own logic per user need.
+type AccessibilityProfile struct {
+ ID string `json:"id,omitempty" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
+ PlaceID string `json:"place_id,omitempty" gorm:"uniqueIndex;type:uuid"`
+ SourceReports SourceReports `json:"source_reports,omitempty" gorm:"type:jsonb"`
+ Entrance *EntranceProps `json:"entrance,omitempty" gorm:"type:jsonb"`
+ Pathways *PathwayProps `json:"pathways,omitempty" gorm:"type:jsonb"`
+ Restroom *RestroomProps `json:"restroom,omitempty" gorm:"type:jsonb"`
+ Parking *ParkingProps `json:"parking,omitempty" gorm:"type:jsonb"`
+ Elevator *ElevatorProps `json:"elevator,omitempty" gorm:"type:jsonb"`
+ UserVerified bool `json:"user_verified,omitempty"`
+ SubmittedBy *string `json:"-" gorm:"type:uuid"`
+ SubmittedAt *time.Time `json:"submitted_at,omitempty"`
+ UpdatedAt time.Time `json:"updated_at,omitzero"`
+}
+
+// SourceReport is a raw opinion about accessibility from a named external source.
+// Records what a source said without interpreting it. Clients decide trust level.
+type SourceReport struct {
+ Source string `json:"source"` // e.g. "osm", "wheelmap", "user"
+ Value string `json:"value"` // raw: "yes", "limited", "no"
+ RecordedAt time.Time `json:"recorded_at"`
+}
+
+type SourceReports []SourceReport
+
+func (s *SourceReports) Scan(value interface{}) error { return scanJSONB(s, value) }
+func (s SourceReports) Value() (driver.Value, error) { return marshalJSONB(s) }
+
+type DoorType string
const (
- // StatusAccessible means the place is fully accessible.
- StatusAccessible A11yStatus = "accessible"
- // StatusLimited means the place is partially accessible (e.g., requires assistance).
- StatusLimited A11yStatus = "limited"
- // StatusInaccessible means the place is not accessible.
- StatusInaccessible A11yStatus = "inaccessible"
- // StatusUnknown means accessibility information is not available.
- StatusUnknown A11yStatus = "unknown"
+ DoorAutomatic DoorType = "automatic"
+ DoorManual DoorType = "manual"
+ DoorRevolving DoorType = "revolving"
+ DoorNone DoorType = "none"
)
-// A11yComponentType identifies the kind of accessibility feature.
-type A11yComponentType string
+type SurfaceType string
const (
- ComponentEntrance A11yComponentType = "entrance"
- ComponentRestroom A11yComponentType = "restroom"
- ComponentParking A11yComponentType = "parking"
- ComponentElevator A11yComponentType = "elevator"
- ComponentOther A11yComponentType = "other"
+ SurfaceAsphalt SurfaceType = "asphalt"
+ SurfacePavingStones SurfaceType = "paving_stones"
+ SurfaceCobblestone SurfaceType = "cobblestone"
+ SurfaceGravel SurfaceType = "gravel"
+ SurfaceConcrete SurfaceType = "concrete"
+ SurfaceWood SurfaceType = "wood"
+ SurfaceCarpet SurfaceType = "carpet"
+ SurfaceTiles SurfaceType = "tiles"
)
-// AccessibilityProfile summarizes the accessibility of a place.
-type AccessibilityProfile struct {
- // ID is the unique identifier for the profile.
- ID string `json:"id,omitzero" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
- // PlaceID is the identifier of the related place.
- PlaceID string `json:"place_id,omitzero" gorm:"uniqueIndex;type:uuid"`
- // OverallStatus is the client-submitted accessibility rating, validated against component flags on write.
- OverallStatus A11yStatus `json:"overall_status"`
- // Components are the individual accessibility features (entrance, etc).
- Components A11yComponents `json:"components,omitzero" gorm:"type:jsonb"`
- // SubmittedBy is the ID of the API key that last wrote this record. Internal only.
- SubmittedBy *string `json:"-" gorm:"type:uuid"`
- // SubmittedAt is the timestamp of the last submission.
- SubmittedAt *time.Time `json:"submitted_at,omitzero"`
- // UserVerified indicates that a human user explicitly submitted this accessibility data.
- // When true, the ingestion service will not overwrite this profile with automated data.
- UserVerified bool `json:"user_verified,omitzero"`
- // UpdatedAt is the timestamp when the profile was last updated.
- UpdatedAt time.Time `json:"updated_at,omitzero"`
+type DoorProps struct {
+ Type DoorType `json:"type,omitempty"`
+ Width *float64 `json:"width,omitempty"` // metres, clear opening
}
-// A11yComponent represents a modular accessibility feature.
-type A11yComponent struct {
- // Type is the kind of component.
- Type A11yComponentType `json:"type"`
- // IsInherited is true if the component data is inherited from a parent place.
- IsInherited bool `json:"is_inherited"`
- // SourceID is the ID of the Place that owns this specific data.
- SourceID string `json:"source_id,omitzero"`
- // OverallStatus is the summary rating of this specific component.
- OverallStatus A11yStatus `json:"overall_status"`
- // AuditFlags contains technical violations detected.
- AuditFlags []string `json:"audit_flags,omitzero"`
- // Entrance contains properties for an entrance component.
- Entrance *EntranceProperties `json:"entrance,omitzero"`
- // Restroom contains properties for a restroom component.
- Restroom *RestroomProperties `json:"restroom,omitzero"`
- // Parking contains properties for a parking component.
- Parking *ParkingProperties `json:"parking,omitzero"`
- // Elevator contains properties for an elevator component.
- Elevator *ElevatorProperties `json:"elevator,omitzero"`
- // Metadata contains additional un-modeled tags or source-specific data.
- Metadata map[string]any `json:"metadata,omitzero"`
+// EntranceProps. All dimensions in metres. AuditFlags are computed on write.
+// IsInherited and SourceID are set by ComputeEffectiveProfile at read time, never stored.
+type EntranceProps struct {
+ IsLevel *bool `json:"is_level,omitempty"`
+ HasFixedRamp *bool `json:"has_fixed_ramp,omitempty"`
+ HasRemovableRamp *bool `json:"has_removable_ramp,omitempty"`
+ SlopePercent *float64 `json:"slope_percent,omitempty"`
+ Width *float64 `json:"width,omitempty"`
+ Door *DoorProps `json:"door,omitempty"`
+ HasIntercom *bool `json:"has_intercom,omitempty"`
+ AuditFlags []string `json:"audit_flags,omitempty"`
+ IsInherited bool `json:"is_inherited,omitempty"`
+ SourceID string `json:"source_id,omitempty"`
}
-// A11yComponents is a custom type, so we can implement SQL scanning.
-type A11yComponents []A11yComponent
-
-// EntranceProperties contains technical details about an entrance.
-type EntranceProperties struct {
- // Width is the clear opening width in meters.
- Width *float64 `json:"width,omitzero"`
- // HasRamp indicates if a ramp is present.
- HasRamp *bool `json:"has_ramp,omitzero"`
- // IsAutomatic indicates if the door is automatic.
- IsAutomatic *bool `json:"is_automatic,omitzero"`
- // HasStep indicates if there is a step at the entrance.
- HasStep *bool `json:"has_step,omitzero"`
- // StepHeight is the height of the step in meters.
- StepHeight *float64 `json:"step_height,omitzero"`
+func (p *EntranceProps) Scan(value interface{}) error { return scanJSONB(p, value) }
+func (p EntranceProps) Value() (driver.Value, error) { return marshalJSONB(p) }
+
+type PathwayProps struct {
+ Width *float64 `json:"width,omitempty"` // narrowest passage in metres
+ Surface SurfaceType `json:"surface,omitempty"`
+ IsKerbstoneFree *bool `json:"is_kerbstone_free,omitempty"`
+ HasSteps *bool `json:"has_steps,omitempty"`
+ AuditFlags []string `json:"audit_flags,omitempty"`
+ IsInherited bool `json:"is_inherited,omitempty"`
+ SourceID string `json:"source_id,omitempty"`
}
-// RestroomProperties contains details about a restroom feature.
-type RestroomProperties struct {
- // WheelchairAccessible indicates if the restroom is accessible to wheelchairs.
- WheelchairAccessible *bool `json:"wheelchair_accessible,omitzero"`
- // GrabRails indicates if grab rails are installed.
- GrabRails *bool `json:"grab_rails,omitzero"`
- // ChangingTable indicates if a diaper changing table is available.
- ChangingTable *bool `json:"changing_table,omitzero"`
- // DoorWidth is the width of the restroom door in meters.
- DoorWidth *float64 `json:"door_width,omitzero"`
+func (p *PathwayProps) Scan(value interface{}) error { return scanJSONB(p, value) }
+func (p PathwayProps) Value() (driver.Value, error) { return marshalJSONB(p) }
+
+// RestroomProps. All dimensions in metres.
+type RestroomProps struct {
+ IsAccessible *bool `json:"is_accessible,omitempty"`
+ DoorWidth *float64 `json:"door_width,omitempty"`
+ TurningRadius *float64 `json:"turning_radius,omitempty"`
+ HasGrabRails *bool `json:"has_grab_rails,omitempty"`
+ HasRollInShower *bool `json:"has_roll_in_shower,omitempty"`
+ ToiletSeatHeight *float64 `json:"toilet_seat_height,omitempty"`
+ HasEmergencyPull *bool `json:"has_emergency_pull,omitempty"`
+ HasChangingTable *bool `json:"has_changing_table,omitempty"`
+ AuditFlags []string `json:"audit_flags,omitempty"`
+ IsInherited bool `json:"is_inherited,omitempty"`
+ SourceID string `json:"source_id,omitempty"`
}
-// ParkingProperties contains details about disabled parking.
-type ParkingProperties struct {
- // HasDisabledSpaces indicates if there are dedicated disabled parking spots.
- HasDisabledSpaces *bool `json:"has_disabled_spaces,omitzero"`
- // Count is the number of disabled parking spaces available.
- Count *int `json:"count,omitzero"`
+func (p *RestroomProps) Scan(value interface{}) error { return scanJSONB(p, value) }
+func (p RestroomProps) Value() (driver.Value, error) { return marshalJSONB(p) }
+
+// ParkingProps. DistanceToEntrance and Width in metres.
+type ParkingProps struct {
+ HasDisabledSpaces *bool `json:"has_disabled_spaces,omitempty"`
+ Count *int `json:"count,omitempty"`
+ DistanceToEntrance *float64 `json:"distance_to_entrance,omitempty"`
+ Width *float64 `json:"width,omitempty"`
+ HasDedicatedSignage *bool `json:"has_dedicated_signage,omitempty"`
+ AuditFlags []string `json:"audit_flags,omitempty"`
+ IsInherited bool `json:"is_inherited,omitempty"`
+ SourceID string `json:"source_id,omitempty"`
}
-// ElevatorProperties contains technical details about an elevator.
-type ElevatorProperties struct {
- // Width is the elevator cabin width in meters.
- Width *float64 `json:"width,omitzero"`
- // Depth is the elevator cabin depth in meters.
- Depth *float64 `json:"depth,omitzero"`
- // Braille indicates if there are braille labels on the buttons.
- Braille *bool `json:"braille,omitzero"`
- // Audio indicates if there are audio announcements.
- Audio *bool `json:"audio,omitzero"`
+func (p *ParkingProps) Scan(value interface{}) error { return scanJSONB(p, value) }
+func (p ParkingProps) Value() (driver.Value, error) { return marshalJSONB(p) }
+
+// ElevatorProps. Width, Depth, and DoorWidth in metres.
+type ElevatorProps struct {
+ Width *float64 `json:"width,omitempty"`
+ Depth *float64 `json:"depth,omitempty"`
+ DoorWidth *float64 `json:"door_width,omitempty"`
+ HasBraille *bool `json:"has_braille,omitempty"`
+ HasAudio *bool `json:"has_audio,omitempty"`
+ AuditFlags []string `json:"audit_flags,omitempty"`
+ IsInherited bool `json:"is_inherited,omitempty"`
+ SourceID string `json:"source_id,omitempty"`
}
-// Scan tells the SQL driver how to read the JSONB bytes into the slice.
-func (c *A11yComponents) Scan(value interface{}) error {
+func (p *ElevatorProps) Scan(value interface{}) error { return scanJSONB(p, value) }
+func (p ElevatorProps) Value() (driver.Value, error) { return marshalJSONB(p) }
+
+func scanJSONB(dest any, value interface{}) error {
if value == nil {
- *c = make(A11yComponents, 0)
return nil
}
-
- bytes, ok := value.([]byte)
+ b, ok := value.([]byte)
if !ok {
return errors.New("type assertion to []byte failed")
}
-
- return json.Unmarshal(bytes, c)
+ return json.Unmarshal(b, dest)
}
-// Value tells the SQL driver how to write the slice to the database as JSONB.
-func (c A11yComponents) Value() (driver.Value, error) {
- if c == nil {
- return json.Marshal(make(A11yComponents, 0))
- }
- return json.Marshal(c)
+func marshalJSONB(v any) (driver.Value, error) {
+ return json.Marshal(v)
}