2026-03-02 18:49:02 +00:00
package csv
import (
"context"
"encoding/csv"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/Gleipnir-Technology/bob"
"github.com/Gleipnir-Technology/bob/dialect/psql"
"github.com/Gleipnir-Technology/bob/dialect/psql/um"
"github.com/Gleipnir-Technology/nidus-sync/db"
"github.com/Gleipnir-Technology/nidus-sync/db/enums"
"github.com/Gleipnir-Technology/nidus-sync/db/models"
"github.com/Gleipnir-Technology/nidus-sync/h3utils"
2026-03-12 23:49:16 +00:00
"github.com/Gleipnir-Technology/nidus-sync/platform/file"
2026-03-02 18:49:02 +00:00
"github.com/Gleipnir-Technology/nidus-sync/platform/geom"
2026-03-14 01:14:30 +00:00
"github.com/Gleipnir-Technology/nidus-sync/platform/types"
2026-03-02 18:49:02 +00:00
"github.com/aarondl/opt/omit"
"github.com/aarondl/opt/omitnull"
"github.com/rs/zerolog/log"
)
type Enum interface {
~ int | ~ int8 | ~ int16 | ~ int32 | ~ int64 | ~ string
}
type headerFlyoverEnum int
const (
2026-03-04 14:52:34 +00:00
headerFlyoverAddressLocality headerFlyoverEnum = iota
headerFlyoverAddressNumber
headerFlyoverAddressPostalCode
headerFlyoverAddressRegion
headerFlyoverAddressStreet
headerFlyoverComment
2026-03-02 18:49:02 +00:00
headerFlyoverLatitude
headerFlyoverLongitude
headerFlyoverNone
)
func ( e headerFlyoverEnum ) String ( ) string {
switch e {
2026-03-04 14:52:34 +00:00
case headerFlyoverAddressLocality :
return "City"
case headerFlyoverAddressNumber :
return "HouseNo"
case headerFlyoverAddressPostalCode :
return "ZIP"
case headerFlyoverAddressRegion :
return "State"
case headerFlyoverAddressStreet :
return "Street"
2026-03-02 18:49:02 +00:00
case headerFlyoverComment :
return "Comment"
case headerFlyoverLatitude :
return "TargetLat"
case headerFlyoverLongitude :
return "TargetLon"
default :
return "bad programmer"
}
}
var parseCSVFlyover = makeParseCSV (
makeParseHeaders ( map [ string ] headerFlyoverEnum {
"comment" : headerFlyoverComment ,
2026-03-04 14:52:34 +00:00
"houseno" : headerFlyoverAddressNumber ,
"state" : headerFlyoverAddressRegion ,
"street" : headerFlyoverAddressStreet ,
"city" : headerFlyoverAddressLocality ,
2026-03-02 18:49:02 +00:00
"targetlat" : headerFlyoverLatitude ,
"targetlon" : headerFlyoverLongitude ,
2026-03-04 14:52:34 +00:00
"zip" : headerFlyoverAddressPostalCode ,
2026-03-02 18:49:02 +00:00
"*" : headerFlyoverNone ,
} ) ,
insertFlyover ,
)
type insertModelFunc [ ModelType any , HeaderType Enum ] = func ( context . Context , bob . Tx , * models . FileuploadFile , * models . FileuploadCSV , int32 , [ ] HeaderType , [ ] string , [ ] string ) ( ModelType , error )
2026-03-12 23:49:16 +00:00
type parseCSVFunc [ ModelType any ] = func ( ctx context . Context , txn bob . Tx , f * models . FileuploadFile , c * models . FileuploadCSV ) ( [ ] ModelType , error )
2026-03-02 18:49:02 +00:00
func makeParseCSV [ ModelType any , HeaderType Enum ] ( parseHeader parseHeaderFunc [ HeaderType ] , insertModel insertModelFunc [ ModelType , HeaderType ] ) parseCSVFunc [ ModelType ] {
2026-03-12 23:49:16 +00:00
return func ( ctx context . Context , txn bob . Tx , f * models . FileuploadFile , c * models . FileuploadCSV ) ( [ ] ModelType , error ) {
2026-03-02 18:49:02 +00:00
rows := make ( [ ] ModelType , 0 )
2026-03-12 23:49:16 +00:00
r , err := file . NewFileReader ( file . CollectionCSV , f . FileUUID )
2026-03-02 18:49:02 +00:00
if err != nil {
2026-03-12 23:49:16 +00:00
return rows , fmt . Errorf ( "Failed to get filereader for %d: %w" , f . ID , err )
2026-03-02 18:49:02 +00:00
}
reader := csv . NewReader ( r )
h , err := reader . Read ( )
if err != nil {
2026-03-12 23:49:16 +00:00
return rows , fmt . Errorf ( "Failed to read header of CSV for file %d: %w" , f . ID , err )
2026-03-02 18:49:02 +00:00
}
header_types , header_names := parseHeader ( h )
/ *
TODO : Add support for missing headersi
missing_headers := missingRequiredHeaders ( header_types )
for _ , mh := range missing_headers {
errorMissingHeader ( ctx , txn , c , mh )
file . Update ( ctx , txn , & models . FileuploadFileSetter {
Status : omit . From ( enums . FileuploadFilestatustypeError ) ,
} )
return pools , nil
}
* /
// Start at 2 because the header is line 1, not line 0
line_number := int32 ( 2 )
for {
row , err := reader . Read ( )
if err != nil {
if err == io . EOF {
return rows , nil
}
2026-03-12 23:49:16 +00:00
return rows , fmt . Errorf ( "Failed to read all CSV records for file %d: %w" , f . ID , err )
2026-03-02 18:49:02 +00:00
}
2026-03-12 23:49:16 +00:00
m , err := insertModel ( ctx , txn , f , c , line_number , header_types , header_names , row )
2026-03-04 14:52:34 +00:00
if err != nil {
return rows , fmt . Errorf ( "insert models: %w" , err )
}
2026-03-02 18:49:02 +00:00
rows = append ( rows , m )
line_number = line_number + 1
}
}
}
2026-03-04 14:52:34 +00:00
func insertFlyover ( ctx context . Context , txn bob . Tx , file * models . FileuploadFile , c * models . FileuploadCSV , line_number int32 , header_types [ ] headerFlyoverEnum , header_names [ ] string , row [ ] string ) ( * models . FileuploadPool , error ) {
/ *
setter := models . FileuploadFlyoverAerialServiceSetter {
Committed : omit . From ( false ) ,
Condition : omit . From ( enums . FileuploadPoolconditiontypeUnknown ) ,
Created : omit . From ( time . Now ( ) ) ,
CreatorID : omit . From ( file . CreatorID ) ,
CSVFile : omit . From ( file . ID ) ,
Deleted : omitnull . FromPtr [ time . Time ] ( nil ) ,
Geom : omitnull . FromPtr [ string ] ( nil ) ,
H3cell : omitnull . FromPtr [ string ] ( nil ) ,
// ID - generated
OrganizationID : omit . From ( file . OrganizationID ) ,
}
* /
setter := models . FileuploadPoolSetter {
// required fields
//AddressLocality: omit.From(),
2026-03-19 15:31:04 +00:00
//AddressNumber: omit.From(),
2026-03-04 14:52:34 +00:00
//AddressPostalCode: omit.From(),
//AddressRegion: omit.From(),
//AddressStreet: omit.From(),
2026-03-02 18:49:02 +00:00
Committed : omit . From ( false ) ,
2026-03-05 01:22:21 +00:00
Condition : omit . From ( enums . PoolconditiontypeUnknown ) ,
2026-03-02 18:49:02 +00:00
Created : omit . From ( time . Now ( ) ) ,
CreatorID : omit . From ( file . CreatorID ) ,
CSVFile : omit . From ( file . ID ) ,
Deleted : omitnull . FromPtr [ time . Time ] ( nil ) ,
Geom : omitnull . FromPtr [ string ] ( nil ) ,
H3cell : omitnull . FromPtr [ string ] ( nil ) ,
// ID - generated
2026-03-19 15:31:04 +00:00
IsInDistrict : omit . From ( false ) ,
// Calculated after we gather the address data
//IsNew: omit.From(true),
2026-03-04 14:52:34 +00:00
LineNumber : omit . From ( line_number ) ,
Notes : omit . From ( "" ) ,
PropertyOwnerName : omit . From ( "" ) ,
PropertyOwnerPhoneE164 : omitnull . FromPtr [ string ] ( nil ) ,
ResidentOwned : omitnull . FromPtr [ bool ] ( nil ) ,
ResidentPhoneE164 : omitnull . FromPtr [ string ] ( nil ) ,
//Tags: convertToPGData(tags),
2026-03-02 18:49:02 +00:00
}
var lat , lng float64
var err error
for i , value := range row {
if value == "" {
continue
}
header_type := header_types [ i ]
switch header_type {
2026-03-04 14:52:34 +00:00
case headerFlyoverAddressLocality :
setter . AddressLocality = omit . From ( value )
case headerFlyoverAddressNumber :
setter . AddressNumber = omit . From ( value )
case headerFlyoverAddressPostalCode :
setter . AddressPostalCode = omit . From ( value )
case headerFlyoverAddressRegion :
setter . AddressRegion = omit . From ( value )
case headerFlyoverAddressStreet :
setter . AddressStreet = omit . From ( value )
2026-03-02 18:49:02 +00:00
case headerFlyoverComment :
condition , err := parsePoolCondition ( value )
if err == nil {
setter . Condition = omit . From ( condition )
} else {
addError ( ctx , txn , c , int32 ( line_number ) , int32 ( i ) , fmt . Sprintf ( "'%s' is not a pool condition that we recognize. It should be one of %s" , value , poolConditionValidValues ( ) ) )
continue
}
case headerFlyoverLatitude :
lat , err = strconv . ParseFloat ( value , 10 )
if err != nil {
addError ( ctx , txn , c , int32 ( line_number ) , int32 ( i ) , fmt . Sprintf ( "'%s' is not decimal value" , value ) )
continue
}
case headerFlyoverLongitude :
lng , err = strconv . ParseFloat ( value , 10 )
if err != nil {
addError ( ctx , txn , c , int32 ( line_number ) , int32 ( i ) , fmt . Sprintf ( "'%s' is not decimal value" , value ) )
continue
}
}
}
2026-03-04 14:52:34 +00:00
setter . Tags = omit . From ( db . ConvertToPGData ( map [ string ] string { } ) )
2026-03-19 15:31:04 +00:00
is_existing , err := hasExistingPool ( ctx , txn , & setter )
if err != nil {
return nil , fmt . Errorf ( "has existing pool: %w" , err )
}
setter . IsNew = omit . From ( ! is_existing )
2026-03-04 14:52:34 +00:00
flyover , err := models . FileuploadPools . Insert ( & setter ) . One ( ctx , txn )
2026-03-02 18:49:02 +00:00
if err != nil {
return nil , fmt . Errorf ( "Failed to create flyover: %w" , err )
}
cell , err := h3utils . GetCell ( lng , lat , 15 )
if err != nil {
return nil , fmt . Errorf ( "failed to convert lat %f lng %f to h3 cell" , lng , lat )
}
2026-03-14 01:14:30 +00:00
geom_query := geom . PostgisPointQuery ( types . Location {
Latitude : lat ,
Longitude : lng ,
} )
2026-03-02 18:49:02 +00:00
_ , err = psql . Update (
2026-03-04 20:59:57 +00:00
um . TableAs ( "fileupload.pool" , "pool" ) ,
2026-03-02 18:49:02 +00:00
um . SetCol ( "h3cell" ) . ToArg ( cell ) ,
um . SetCol ( "geom" ) . To ( geom_query ) ,
2026-03-19 03:25:36 +00:00
um . SetCol ( "is_in_district" ) . To (
psql . F ( "COALESCE" ,
psql . F ( "ST_Contains" , "org.service_area_geometry" , geom_query ) ,
psql . Quote ( "org" , "is_catchall" ) ,
) ,
) ,
2026-03-04 20:59:57 +00:00
um . From ( "fileupload.csv" ) . As ( "csv" ) ,
2026-03-18 15:36:20 +00:00
um . InnerJoin ( "fileupload.file" ) . As ( "file" ) . OnEQ ( psql . Quote ( "csv" , "file_id" ) , psql . Quote ( "file" , "id" ) ) ,
um . InnerJoin ( "organization" ) . As ( "org" ) . OnEQ ( psql . Quote ( "file" , "organization_id" ) , psql . Quote ( "org" , "id" ) ) ,
2026-03-11 22:54:22 +00:00
um . Where ( psql . Quote ( "pool" , "id" ) . EQ ( psql . Arg ( flyover . ID ) ) ) ,
2026-03-02 18:49:02 +00:00
) . Exec ( ctx , txn )
if err != nil {
return nil , fmt . Errorf ( "failed to update flyover geometry: %w" , err )
}
return flyover , nil
}
2026-03-19 15:31:04 +00:00
func hasExistingPool ( ctx context . Context , txn bob . Executor , setter * models . FileuploadPoolSetter ) ( bool , error ) {
exists , err := models . Addresses . Query (
models . SelectWhere . Addresses . Locality . EQ ( setter . AddressLocality . GetOr ( "" ) ) ,
models . SelectWhere . Addresses . Number . EQ ( setter . AddressNumber . GetOr ( "" ) ) ,
models . SelectWhere . Addresses . PostalCode . EQ ( setter . AddressPostalCode . GetOr ( "" ) ) ,
//models.SelectWhere.Addresses.Region.EQ(setter.AddressRegion.GetOr("")),
models . SelectWhere . Addresses . Street . EQ ( setter . AddressStreet . GetOr ( "" ) ) ,
) . Exists ( ctx , txn )
if err != nil {
return false , fmt . Errorf ( "query address: %w" , err )
}
log . Debug ( ) .
Str ( "number" , setter . AddressNumber . GetOr ( "" ) ) .
Str ( "postal_code" , setter . AddressPostalCode . GetOr ( "" ) ) .
Str ( "region" , setter . AddressRegion . GetOr ( "" ) ) .
Str ( "street" , setter . AddressStreet . GetOr ( "" ) ) .
Str ( "locality" , setter . AddressLocality . GetOr ( "" ) ) .
Bool ( "exists" , exists ) . Msg ( "checking pool exists" )
return exists , nil
}
2026-03-04 14:52:34 +00:00
func insertPoollistRow ( ctx context . Context , txn bob . Tx , file * models . FileuploadFile , c * models . FileuploadCSV , line_number int32 , header_types [ ] headerFlyoverEnum , header_names [ ] string , row [ ] string ) ( * models . FileuploadPool , error ) {
2026-03-02 18:49:02 +00:00
tags := make ( map [ string ] string , 0 )
// Start with a setter with default values, comment out the required fields to ensure they're set
setter := models . FileuploadPoolSetter {
// AddressCity: omit.From(),
// AddressPostalCode: omit.From(),
// AddressStreet: omit.From(),
Committed : omit . From ( false ) ,
2026-03-05 01:22:21 +00:00
Condition : omit . From ( enums . PoolconditiontypeUnknown ) ,
2026-03-02 18:49:02 +00:00
Created : omit . From ( time . Now ( ) ) ,
CreatorID : omit . From ( file . CreatorID ) ,
CSVFile : omit . From ( file . ID ) ,
Deleted : omitnull . FromPtr [ time . Time ] ( nil ) ,
Geom : omitnull . FromPtr [ string ] ( nil ) ,
H3cell : omitnull . FromPtr [ string ] ( nil ) ,
// ID - generated
IsInDistrict : omit . From ( false ) ,
IsNew : omit . From ( true ) ,
LineNumber : omit . From ( line_number ) ,
Notes : omit . From ( "" ) ,
PropertyOwnerName : omit . From ( "" ) ,
PropertyOwnerPhoneE164 : omitnull . FromPtr [ string ] ( nil ) ,
ResidentOwned : omitnull . FromPtr [ bool ] ( nil ) ,
ResidentPhoneE164 : omitnull . FromPtr [ string ] ( nil ) ,
// Can't set this via a Setter
// Tags: convertToPGData(tags),
}
for i , value := range row {
if value == "" {
continue
}
header_type := header_types [ i ]
switch header_type {
2026-03-04 14:52:34 +00:00
case headerFlyoverAddressLocality :
setter . AddressLocality = omit . From ( value )
case headerFlyoverAddressPostalCode :
2026-03-02 18:49:02 +00:00
setter . AddressPostalCode = omit . From ( value )
2026-03-04 14:52:34 +00:00
case headerFlyoverAddressStreet :
2026-03-02 18:49:02 +00:00
setter . AddressStreet = omit . From ( value )
2026-03-04 14:52:34 +00:00
case headerFlyoverComment :
2026-03-02 18:49:02 +00:00
condition , err := parsePoolCondition ( value )
if err == nil {
setter . Condition = omit . From ( condition )
} else {
addError ( ctx , txn , c , int32 ( line_number ) , int32 ( i ) , fmt . Sprintf ( "'%s' is not a pool condition that we recognize. It should be one of %s" , value , poolConditionValidValues ( ) ) )
continue
}
}
}
setter . Tags = omit . From ( db . ConvertToPGData ( tags ) )
return models . FileuploadPools . Insert ( & setter ) . One ( ctx , txn )
}
type parseHeaderFunc [ EnumType any ] = func ( row [ ] string ) ( [ ] EnumType , [ ] string )
func makeParseHeaders [ EnumType any ] ( headerToType map [ string ] EnumType ) parseHeaderFunc [ EnumType ] {
return func ( row [ ] string ) ( [ ] EnumType , [ ] string ) {
result_enums := make ( [ ] EnumType , len ( row ) )
result_names := make ( [ ] string , len ( row ) )
for i , h := range row {
ht := strings . TrimSpace ( h )
hl := strings . ToLower ( ht )
log . Debug ( ) . Str ( "header" , hl ) . Msg ( "Saw CSV header" )
var type_ EnumType
type_ , ok := headerToType [ hl ]
if ! ok {
// See if there is a '*' entry which should match anything
all_type , ok2 := headerToType [ "*" ]
if ! ok2 {
log . Error ( ) . Str ( "name" , hl ) . Msg ( "No header type matches column. You should add a '*' to the makeParseHeaders call" )
continue
} else {
type_ = all_type
}
}
result_enums [ i ] = type_
result_names [ i ] = hl
}
return result_enums , result_names
}
}
2026-03-04 14:52:34 +00:00
func processCSVFlyover ( ctx context . Context , txn bob . Tx , file * models . FileuploadFile , c * models . FileuploadCSV , rows [ ] * models . FileuploadPool ) error {
2026-03-02 18:49:02 +00:00
return nil
}
var poolConditionAliases = map [ string ] string {
"covered" : "unknown" ,
"dark bottom" : "unknown" ,
"no data" : "unknown" ,
"empty" : "dry" ,
"green" : "green" ,
"murky pool" : "murky" ,
"putting green" : "false pool" ,
"questionable" : "unknown" ,
}
2026-03-05 01:22:21 +00:00
func parsePoolCondition ( c string ) ( enums . Poolconditiontype , error ) {
var condition enums . Poolconditiontype
2026-03-02 18:49:02 +00:00
col_l := strings . ToLower ( c )
col_translated , ok := poolConditionAliases [ col_l ]
if ok {
col_l = col_translated
}
err := condition . Scan ( col_l )
return condition , err
}