Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 33 additions & 12 deletions api/cmd/fleet-lite-app/import_group_attestations.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/DIMO-Network/fleet-lite-app/internal/gateway"
"github.com/DIMO-Network/fleet-lite-app/internal/service"
"github.com/DIMO-Network/shared/pkg/db"
"github.com/aarondl/sqlboiler/v4/queries/qm"
"github.com/ethereum/go-ethereum/common"
"github.com/google/subcommands"
_ "github.com/lib/pq"
Expand All @@ -36,20 +37,23 @@ type importGroupAttestationsCmd struct {
dryRun bool
warmOnly bool
warmDays int
vinOnly bool
}

func (*importGroupAttestationsCmd) Name() string { return "import-group-attestations" }
func (*importGroupAttestationsCmd) Synopsis() string {
return "pull vehicle group attestations from fetch-api and merge them into the DB (additive, de-duplicated)"
}
func (*importGroupAttestationsCmd) Usage() string {
return `import-group-attestations [-tenant-id ID] [-token-id N] [-warm-only] [-warm-days N] [-dry-run]:
return `import-group-attestations [-tenant-id ID] [-token-id N] [-warm-only] [-warm-days N] [-vin-only] [-dry-run]:
Pulls dimo.document.vehicle.groups attestations per vehicle (latest per
producer) and adds any group membership not already present in
vehicle_fleet_groups. Additive only — never removes; de-duplicated by primary
key. Unknown groups are auto-created. -warm-only limits the run to tenants with
a member login within -warm-days days (default 7). -dry-run logs changes
without writing.
a member login within -warm-days days (default 7). -vin-only skips the group
and registration-document sync and only backfills vehicles.vin from the DIMO
VIN VC, restricted to vehicles with no VIN yet (see docs/VIN_SYNC_PLAN.md).
-dry-run logs changes without writing.
`
}

Expand All @@ -59,6 +63,7 @@ func (p *importGroupAttestationsCmd) SetFlags(f *flag.FlagSet) {
f.BoolVar(&p.dryRun, "dry-run", false, "log what would change without writing")
f.BoolVar(&p.warmOnly, "warm-only", false, "only tenants with a recent member login (see -warm-days)")
f.IntVar(&p.warmDays, "warm-days", 7, "login-recency window (days) that makes a tenant warm")
f.BoolVar(&p.vinOnly, "vin-only", false, "only backfill vehicles.vin from the DIMO VIN VC (vehicles without a VIN)")
}

func (p *importGroupAttestationsCmd) Execute(ctx context.Context, _ *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
Expand All @@ -70,7 +75,8 @@ func (p *importGroupAttestationsCmd) Execute(ctx context.Context, _ *flag.FlagSe
authProvider := gateway.NewDimoAuthProvider(p.logger, &p.settings)
fetchAPI := gateway.NewFetchAPI(p.logger, &p.settings, authProvider)
groupSync := service.NewGroupSyncService(&p.logger, &p.pdb, fetchAPI, authProvider)
plateSync := service.NewLicensePlateSyncService(&p.logger, &p.pdb, fetchAPI, authProvider)
telemetryAPI := service.NewTelemetryAPIService(p.logger, &p.settings, authProvider)
plateSync := service.NewLicensePlateSyncService(&p.logger, &p.pdb, fetchAPI, authProvider, telemetryAPI)

// Resolve tenants to process.
var dbTenants dbmodels.TenantSlice
Expand Down Expand Up @@ -111,23 +117,38 @@ func (p *importGroupAttestationsCmd) Execute(ctx context.Context, _ *flag.FlagSe
continue
}

// Vehicles for this tenant (optionally one token id).
var vehicles dbmodels.VehicleSlice
// Vehicles for this tenant (optionally one token id). The vin-only
// backfill restricts to vehicles with no VIN yet — pull-once: a filled
// vehicle never costs another telemetry query.
mods := []qm.QueryMod{dbmodels.VehicleWhere.TenantID.EQ(dt.ID)}
if p.tokenID != 0 {
vehicles, err = dbmodels.Vehicles(
dbmodels.VehicleWhere.TenantID.EQ(dt.ID),
dbmodels.VehicleWhere.TokenID.EQ(p.tokenID),
).All(ctx, p.pdb.DBS().Reader)
} else {
vehicles, err = dbmodels.Vehicles(dbmodels.VehicleWhere.TenantID.EQ(dt.ID)).All(ctx, p.pdb.DBS().Reader)
mods = append(mods, dbmodels.VehicleWhere.TokenID.EQ(p.tokenID))
}
if p.vinOnly {
mods = append(mods, qm.Where("(vin IS NULL OR vin = '')"))
}
vehicles, err := dbmodels.Vehicles(mods...).All(ctx, p.pdb.DBS().Reader)
if err != nil {
p.logger.Err(err).Str("tenant_id", dt.ID).Msg("list vehicles, skipping tenant")
continue
}

for _, v := range vehicles {
checked++

// vin-only: just the VIN VC read, no group or registration-doc pull.
if p.vinOnly {
pres, perr := plateSync.SyncVINOnly(ctx, *tenant, v.TokenID, p.dryRun)
if perr != nil {
p.logger.Debug().Err(perr).Int64("token_id", v.TokenID).Msg("vin vc sync, skipping")
} else if pres.VINChanged {
vinsChanged++
p.logger.Info().Str("tenant_id", dt.ID).Int64("token_id", v.TokenID).
Str("vin", pres.VIN).Bool("dry_run", p.dryRun).Msg("cached vin from vc")
}
continue
}

res, serr := groupSync.SyncVehicle(ctx, *tenant, v.TokenID, service.SyncOpts{DryRun: p.dryRun})
if serr != nil {
p.logger.Debug().Err(serr).Int64("token_id", v.TokenID).Msg("sync vehicle, skipping")
Expand Down
2 changes: 1 addition & 1 deletion api/internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ func App(
tenantApp.Get("/telemetry/:tokenID/replay", telemetryCtrl.GetTripReplay)

// Glovebox / documents
plateSvc := service.NewLicensePlateSyncService(logger, pdb, fetchAPI, authProvider)
plateSvc := service.NewLicensePlateSyncService(logger, pdb, fetchAPI, authProvider, telemetryAPI)
documentsCtrl := controllers.NewDocumentsController(
logger, settings, vehicleSvc, authProvider, extractAPI, attestSvc, fetchAPI, plateSvc,
)
Expand Down
84 changes: 82 additions & 2 deletions api/internal/service/license_plate_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,11 @@ type LicensePlateSyncService struct {
pdb *db.Store
fetchAPI *gateway.FetchAPI
authProvider *gateway.DimoAuthProvider
telemetry TelemetryAPIService
}

func NewLicensePlateSyncService(logger *zerolog.Logger, pdb *db.Store, fetchAPI *gateway.FetchAPI, authProvider *gateway.DimoAuthProvider) *LicensePlateSyncService {
return &LicensePlateSyncService{logger: logger, pdb: pdb, fetchAPI: fetchAPI, authProvider: authProvider}
func NewLicensePlateSyncService(logger *zerolog.Logger, pdb *db.Store, fetchAPI *gateway.FetchAPI, authProvider *gateway.DimoAuthProvider, telemetry TelemetryAPIService) *LicensePlateSyncService {
return &LicensePlateSyncService{logger: logger, pdb: pdb, fetchAPI: fetchAPI, authProvider: authProvider, telemetry: telemetry}
}

// PlateSyncResult reports what a SyncVehicle call did across the registration
Expand All @@ -64,6 +65,7 @@ type PlateSyncResult struct {
Plate string // the resolved plate (empty when none found)
VINChanged bool // the cached vin was updated
VIN string // the resolved vin (empty when none found)
VINSource string // where a newly-cached vin came from: "registration" | "vc"
}

// SyncVehicle pulls one vehicle's registration attestations and caches the latest
Expand Down Expand Up @@ -109,6 +111,22 @@ func (s *LicensePlateSyncService) SyncVehicle(ctx context.Context, tenant models
updates["vin"] = null.StringFrom(vin)
res.VINChanged = true
res.VIN = vin
res.VINSource = "registration"
}

// VIN VC fallback: most vehicles never get a registration document
// uploaded, but almost all carry a DIMO VIN verifiable credential — read
// it when the VIN is still unknown after the document pass. Pull-once by
// construction: once vehicles.vin is set, res.VIN is non-empty here and
// the vehicle never costs another telemetry query. See
// docs/VIN_SYNC_PLAN.md.
if res.VIN == "" {
if vin, found := s.vinFromVC(tenant, tokenID); found {
updates["vin"] = null.StringFrom(vin)
res.VINChanged = true
res.VIN = vin
res.VINSource = "vc"
}
}

if len(updates) == 0 {
Expand All @@ -131,6 +149,68 @@ func (s *LicensePlateSyncService) SyncVehicle(ctx context.Context, tenant models
return res, nil
}

// vinFromVC wraps TelemetryAPIService.VINFromVC with this service's skip-
// quietly posture: no SACD, no VC, or a transient error all come back as
// found=false and are retried on a future pass (only while vin IS NULL).
func (s *LicensePlateSyncService) vinFromVC(tenant models.Tenant, tokenID int64) (string, bool) {
if s.telemetry == nil {
return "", false
}
vin, found, err := s.telemetry.VINFromVC(tenant, uint64(tokenID))
if err != nil {
s.logger.Debug().Err(err).Str("tenant_id", tenant.ID).Int64("token_id", tokenID).
Msg("vin vc read failed, skipping")
return "", false
}
return vin, found
}

// SyncVINOnly fills vehicles.vin from the DIMO VIN VC for one vehicle,
// skipping the fetch-api registration pull — the lean path for the cron's
// -vin-only backfill. No-op when the vehicle already has a VIN (pull-once).
// Same fill-if-missing and dry-run semantics as SyncVehicle.
func (s *LicensePlateSyncService) SyncVINOnly(ctx context.Context, tenant models.Tenant, tokenID int64, dryRun bool) (PlateSyncResult, error) {
v, err := dbmodels.Vehicles(
dbmodels.VehicleWhere.TenantID.EQ(tenant.ID),
dbmodels.VehicleWhere.TokenID.EQ(tokenID),
).One(ctx, s.pdb.DBS().Reader)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return PlateSyncResult{}, ErrVehicleNotFound
}
return PlateSyncResult{}, fmt.Errorf("load vehicle: %w", err)
}

res := PlateSyncResult{Plate: v.LicensePlate.String, VIN: v.Vin.String}
if v.Vin.String != "" {
return res, nil
}
vin, found := s.vinFromVC(tenant, tokenID)
if !found {
return res, nil
}
res.VINChanged = true
res.VIN = vin
res.VINSource = "vc"

if dryRun {
s.logger.Info().Str("tenant_id", tenant.ID).Int64("token_id", tokenID).
Str("vin", vin).Msg("would cache vin from vc")
return res, nil
}

if _, err := dbmodels.Vehicles(
dbmodels.VehicleWhere.TenantID.EQ(tenant.ID),
dbmodels.VehicleWhere.TokenID.EQ(tokenID),
).UpdateAll(ctx, s.pdb.DBS().Writer, dbmodels.M{
"vin": null.StringFrom(vin),
"updated_at": time.Now(),
}); err != nil {
return PlateSyncResult{}, fmt.Errorf("update vin: %w", err)
}
return res, nil
}

// latestRegistrationField returns the value of the first matching name in
// names from the most-recent (by CE time) registration document that carries
// a non-empty one, and whether such a document was found. Used for both
Expand Down
45 changes: 45 additions & 0 deletions api/internal/service/telemetry_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ type TelemetryAPIService interface {
// JWT exchange fails are returned in NoPermissions. Results are cached
// per tenant for fleetLocationsCacheTTL; force bypasses the cache.
FleetLocations(ctx context.Context, tenant models.Tenant, tokenIDs []uint64, force bool) (FleetLocationsResult, error)
// VINFromVC reads the vehicle's VIN off its latest DIMO VIN verifiable
// credential (`vinVCLatest`). found=false with a nil error means the
// vehicle has no VC (or an empty vin) — not a failure. Requires privilege
// 5 (GetVINCredential), which the vehicle-JWT exchange already requests.
VINFromVC(tenant models.Tenant, tokenID uint64) (vin string, found bool, err error)
}

// SegmentPoint is one end of a detected trip: a GPS fix plus its timestamp.
Expand Down Expand Up @@ -273,6 +278,46 @@ func (t *telemetryAPIService) LatestLocation(tenant models.Tenant, tokenID uint6
}, nil
}

// VINFromVC queries `vinVCLatest(tokenId)` for the VIN carried on the
// vehicle's latest VIN verifiable credential. See the interface doc for the
// found/error contract; a "no VC" GraphQL error (should the API express
// absence that way) surfaces as err and is treated by callers as
// skip-and-retry-later, which is equally safe. Pull-once callers gate on
// vehicles.vin IS NULL so a filled vehicle never costs another query (DCX).
func (t *telemetryAPIService) VINFromVC(tenant models.Tenant, tokenID uint64) (string, bool, error) {
q := fmt.Sprintf("query { vinVCLatest(tokenId: %d) { vin } }", tokenID)

raw, err := t.query(tenant, tokenID, q)
if err != nil {
return "", false, err
}
return parseVINVCResponse(raw)
}

// parseVINVCResponse extracts the VIN from a `vinVCLatest { vin }` response
// body. A null vinVCLatest or blank vin is (found=false, nil) — no VC exists.
func parseVINVCResponse(raw []byte) (string, bool, error) {
var resp struct {
Data struct {
VinVCLatest *struct {
Vin string `json:"vin"`
} `json:"vinVCLatest"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &resp); err != nil {
return "", false, fmt.Errorf("parse vinVCLatest: %w", err)
}
vc := resp.Data.VinVCLatest
if vc == nil {
return "", false, nil
}
vin := strings.TrimSpace(vc.Vin)
if vin == "" {
return "", false, nil
}
return vin, true, nil
}

// TimeSeries queries `signals(tokenId, from, to, interval)` for one signal
// and returns its min/max/avg/last buckets. Interval is a duration string
// the telemetry-api recognizes (e.g. "1d", "1h", "15m").
Expand Down
58 changes: 58 additions & 0 deletions api/internal/service/telemetry_api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package service

import "testing"

func TestParseVINVCResponse(t *testing.T) {
cases := []struct {
name string
raw string
wantVIN string
wantFound bool
wantErr bool
}{
{
name: "vc present",
raw: `{"data":{"vinVCLatest":{"vin":"JTJGARBZ0M5023425"}}}`,
wantVIN: "JTJGARBZ0M5023425",
wantFound: true,
},
{
name: "no vc (null)",
raw: `{"data":{"vinVCLatest":null}}`,
wantFound: false,
},
{
name: "empty data object",
raw: `{"data":{}}`,
wantFound: false,
},
{
name: "blank vin treated as absent",
raw: `{"data":{"vinVCLatest":{"vin":" "}}}`,
wantFound: false,
},
{
name: "vin whitespace trimmed",
raw: `{"data":{"vinVCLatest":{"vin":" 1ABC123 "}}}`,
wantVIN: "1ABC123",
wantFound: true,
},
{
name: "malformed body errors",
raw: `not json`,
wantErr: true,
},
}

for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
vin, found, err := parseVINVCResponse([]byte(c.raw))
if (err != nil) != c.wantErr {
t.Fatalf("parseVINVCResponse err = %v, wantErr %v", err, c.wantErr)
}
if found != c.wantFound || vin != c.wantVIN {
t.Fatalf("parseVINVCResponse = (%q, %v), want (%q, %v)", vin, found, c.wantVIN, c.wantFound)
}
})
}
}
Loading
Loading