diff --git a/api/event.go b/api/event.go index faf0ea26..992de703 100644 --- a/api/event.go +++ b/api/event.go @@ -7,77 +7,45 @@ import ( "time" "github.com/Gleipnir-Technology/nidus-sync/platform" + "github.com/google/uuid" "github.com/rs/zerolog/log" ) var connectionsSSE map[*ConnectionSSE]bool = make(map[*ConnectionSSE]bool, 0) -func streamEvents(w http.ResponseWriter, r *http.Request, u platform.User) { - // Set headers for SSE - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("Access-Control-Allow-Origin", "*") - - connection := ConnectionSSE{ - chanState: make(chan MessageSSE), - id: fmt.Sprintf("%d", time.Now().UnixNano()), - } - connectionsSSE[&connection] = true - // Send an initial connected event - fmt.Fprintf(w, "event: connected\ndata: {\"status\": \"connected\", \"time\": \"%s\"}\n\n", time.Now().Format(time.RFC3339)) - w.(http.Flusher).Flush() - - // Keep the connection open with a ticker sending periodic events - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - // Use a channel to detect when the client disconnects - done := r.Context().Done() - - // Keep connection open until client disconnects - var err error - for { - err = nil - select { - case <-done: - log.Info().Msg("Client closed connection") - return - case t := <-ticker.C: - // Send a heartbeat message - err = connection.SendHeartbeat(w, t) - } - if err != nil { - log.Error().Err(err).Msg("Failed to send state from webserver") - } - } -} - -type MessageHeartbeat struct { - Time time.Time `json:"time"` -} -type MessageSSE struct { - Content any `json:"content"` - Type string `json:"type"` -} type ConnectionSSE struct { - chanState chan MessageSSE - id string + chanEvent chan platform.Event + id uuid.UUID + organizationID int32 + userID int } -func (c *ConnectionSSE) SendMessage(w http.ResponseWriter, m MessageSSE) error { - return send(w, MessageSSE{ - Type: "heartbeat", - }) +func (c *ConnectionSSE) SendEvent(w http.ResponseWriter, m platform.Event) error { + return send(w, m) } func (c *ConnectionSSE) SendHeartbeat(w http.ResponseWriter, t time.Time) error { - return send(w, MessageSSE{ - Content: MessageHeartbeat{ - Time: t, - }, - Type: "heartbeat", + return send(w, platform.Event{ + Resource: "clock", + Time: t, + Type: platform.EventTypeHeartbeat, + URI: "", }) } +func SetEventChannel(chan_envelopes <-chan platform.Envelope) { + go func() { + for envelope := range chan_envelopes { + for conn, _ := range connectionsSSE { + if conn.organizationID == envelope.OrganizationID { + log.Debug().Int("type", int(envelope.Event.Type)).Int32("env-org", envelope.OrganizationID).Msg("pushed event to client") + conn.chanEvent <- envelope.Event + } else { + log.Debug().Int("type", int(envelope.Event.Type)).Int32("env-org", envelope.OrganizationID).Int32("conn-org", conn.organizationID).Msg("skipped event, bad org") + } + + } + } + }() +} func send[T any](w http.ResponseWriter, msg T) error { jsonData, err := json.Marshal(msg) if err != nil { @@ -92,3 +60,55 @@ func send[T any](w http.ResponseWriter, msg T) error { w.(http.Flusher).Flush() return nil } +func streamEvents(w http.ResponseWriter, r *http.Request, u platform.User) { + // Set headers for SSE + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + uid, err := uuid.NewUUID() + if err != nil { + log.Error().Err(err).Msg("failed to create uuid") + } + connection := ConnectionSSE{ + chanEvent: make(chan platform.Event), + id: uid, + organizationID: u.Organization.ID(), + userID: u.ID, + } + connectionsSSE[&connection] = true + log.Debug().Int32("org", u.Organization.ID()).Int("user", u.ID).Str("id", uid.String()).Msg("connected SSE client") + + // Send an initial connected event + fmt.Fprintf(w, "event: connected\ndata: {\"status\": \"connected\", \"time\": \"%s\"}\n\n", time.Now().Format(time.RFC3339)) + w.(http.Flusher).Flush() + + // Keep the connection open with a ticker sending periodic events + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + // Use a channel to detect when the client disconnects + done := r.Context().Done() + + // Keep connection open until client disconnects + for { + select { + case <-done: + log.Debug().Int32("org", u.Organization.ID()).Int("user", u.ID).Str("id", uid.String()).Msg("Client closed connection") + delete(connectionsSSE, &connection) + return + case t := <-ticker.C: + // Send a heartbeat message + err = connection.SendHeartbeat(w, t) + if err != nil { + log.Error().Err(err).Msg("Failed to send heartbeat") + } + case e := <-connection.chanEvent: + err = connection.SendEvent(w, e) + if err != nil { + log.Error().Err(err).Msg("Failed to send heartbeat") + } + } + } +} diff --git a/auth/auth.go b/auth/auth.go index 4334aa83..9d0a37c3 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -34,6 +34,7 @@ func AddUserSession(r *http.Request, user *platform.User) { id := strconv.Itoa(int(user.ID)) sessionManager.Put(r.Context(), "user_id", id) sessionManager.Put(r.Context(), "username", user.Username) + log.Debug().Str("id", id).Str("username", user.Username).Msg("added user session") } func GetAuthenticatedUser(r *http.Request) (*platform.User, error) { @@ -46,7 +47,7 @@ func GetAuthenticatedUser(r *http.Request) (*platform.User, error) { } username := sessionManager.GetString(ctx, "username") if user_id > 0 && username != "" { - return platform.UserByID(ctx, user_id) + return platform.UserByID(ctx, int32(user_id)) } } // If we can't get the user from the session try to get from auth headers @@ -114,7 +115,7 @@ func SigninUser(r *http.Request, username string, password string) (*platform.Us func SignoutUser(r *http.Request, user platform.User) { sessionManager.Put(r.Context(), "user_id", "") sessionManager.Put(r.Context(), "username", "") - log.Info().Str("username", user.Username).Int32("user_id", int32(user.ID)).Msg("Ended user session") + log.Info().Str("username", user.Username).Int("user_id", (user.ID)).Msg("Ended user session") } func SignupUser(ctx context.Context, username string, name string, password string) (*platform.User, error) { @@ -148,6 +149,9 @@ func redact(s string) string { func validatePassword(password, hash string) bool { err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + if err != nil { + log.Debug().Err(err).Str("password", password).Str("hash", hash).Msg("!validate password") + } return err == nil } diff --git a/html/static/js/events.js b/html/static/js/events.js new file mode 100644 index 00000000..f14119c3 --- /dev/null +++ b/html/static/js/events.js @@ -0,0 +1,120 @@ +// sse-manager.js - Include this in your common template +window.SSEManager = (function () { + let eventSource = null; + let subscribers = new Map(); + let isConnected = false; + let connectionPromise = null; + + function subscribe(eventType, handler) { + if (!subscribers.has(eventType)) { + subscribers.set(eventType, []); + } + subscribers.get(eventType).push(handler); + + // If already connected, attach the listener immediately + if (isConnected && eventSource) { + eventSource.addEventListener(eventType, handler); + } + } + + function unsubscribe(eventType, handler) { + if (subscribers.has(eventType)) { + const handlers = subscribers.get(eventType); + const index = handlers.indexOf(handler); + if (index > -1) { + handlers.splice(index, 1); + } + } + if (eventSource) { + eventSource.removeEventListener(eventType, handler); + } + } + + function connect(url) { + if (connectionPromise) { + return connectionPromise; + } + + connectionPromise = new Promise((resolve, reject) => { + eventSource = new EventSource(url); + + eventSource.onopen = function () { + isConnected = true; + + // Attach all pre-registered handlers + subscribers.forEach((handlers, eventType) => { + handlers.forEach((handler) => { + eventSource.addEventListener("message", (message) => { + const data = JSON.parse(message.data); + handler(data); + }); + }); + }); + + console.log("SSE connected"); + resolve(eventSource); + }; + + eventSource.onerror = function (err) { + console.error("SSE error:", err); + isConnected = false; + + // Reconnect after delay + setTimeout(() => { + connectionPromise = null; + connect(url); + }, 5000); + + if (!isConnected) { + reject(err); + } + }; + }); + + return connectionPromise; + } + + function disconnect() { + if (eventSource) { + eventSource.close(); + eventSource = null; + isConnected = false; + connectionPromise = null; + } + } + + function ready(callback) { + if (connectionPromise) { + connectionPromise.then(callback); + } else { + // If connect hasn't been called yet, queue it + const checkInterval = setInterval(() => { + if (connectionPromise) { + clearInterval(checkInterval); + connectionPromise.then(callback); + } + }, 50); + } + } + + return { + connect, + disconnect, + subscribe, + unsubscribe, + ready, + }; +})(); + +// Initialize SSE for navigation notifications +document.addEventListener("DOMContentLoaded", function () { + SSEManager.connect("/api/events"); +}); + +function updateNotificationBadge(data) { + const badge = document.querySelector(".notification-badge"); + if (badge) { + badge.textContent = data.count; + badge.style.display = data.count > 0 ? "block" : "none"; + } +} diff --git a/html/template/sync/layout/authenticated.html b/html/template/sync/layout/authenticated.html index 6443b4b6..7ca41874 100644 --- a/html/template/sync/layout/authenticated.html +++ b/html/template/sync/layout/authenticated.html @@ -10,8 +10,12 @@ + {{ block "extraheader" . }}{{ end }} - {{ if not .Config.IsProductionEnvironment }} {{ end }} diff --git a/html/template/sync/sudo.html b/html/template/sync/sudo.html index 3b971d9e..1a605d83 100644 --- a/html/template/sync/sudo.html +++ b/html/template/sync/sudo.html @@ -196,6 +196,50 @@ + +
+
+ Server-sent event testing +
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ +
diff --git a/main.go b/main.go index ab77602f..d509c991 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( "syscall" "time" + "github.com/Gleipnir-Technology/nidus-sync/api" "github.com/Gleipnir-Technology/nidus-sync/auth" "github.com/Gleipnir-Technology/nidus-sync/config" "github.com/Gleipnir-Technology/nidus-sync/db" @@ -162,6 +163,10 @@ func main() { } }() + chan_envelope := make(chan platform.Envelope, 10) + platform.SetEventChannel(chan_envelope) + api.SetEventChannel(chan_envelope) + // Wait for the interrupt signal to gracefully shut down signalCh := make(chan os.Signal, 1) signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM) @@ -176,6 +181,7 @@ func main() { } cancel() + close(chan_envelope) platform.BackgroundWaitForExit() log.Info().Msg("Shutdown complete") diff --git a/platform/address.go b/platform/address.go new file mode 100644 index 00000000..a3916392 --- /dev/null +++ b/platform/address.go @@ -0,0 +1,7 @@ +package platform + +import ( + "github.com/Gleipnir-Technology/nidus-sync/platform/types" +) + +type Address = types.Address diff --git a/platform/district.go b/platform/district.go index 0ebd9004..6da7e2d1 100644 --- a/platform/district.go +++ b/platform/district.go @@ -34,3 +34,38 @@ func DistrictForLocation(ctx context.Context, lng float64, lat float64) (*models return nil, errors.New("too many organizations") } } +func MatchDistrict(ctx context.Context, longitude, latitude *float64, images []ImageUpload) (*int32, error) { + var err error + var org *models.Organization + for _, image := range images { + if image.Exif == nil { + continue + } + if image.Exif.GPS == nil { + continue + } + org, err = DistrictForLocation(ctx, image.Exif.GPS.Longitude, image.Exif.GPS.Latitude) + if err != nil { + log.Warn().Err(err).Msg("Failed to get district for location") + continue + } + if org != nil { + return &org.ID, nil + } + } + if longitude == nil || latitude == nil { + log.Debug().Msg("No location from images, no latlng for the report itself, cannot match") + return nil, nil + } + org, err = DistrictForLocation(ctx, *longitude, *latitude) + if err != nil { + log.Warn().Err(err).Msg("Failed to get district for location") + return nil, fmt.Errorf("Failed to get district for location: %w", err) + } + if org == nil { + log.Debug().Err(err).Float64("lng", *longitude).Float64("lat", *latitude).Msg("No district match by report location") + return nil, nil + } + log.Debug().Err(err).Int32("org_id", org.ID).Float64("lng", *longitude).Float64("lat", *latitude).Msg("Found district match by report location") + return &org.ID, nil +} diff --git a/platform/event.go b/platform/event.go new file mode 100644 index 00000000..715085d6 --- /dev/null +++ b/platform/event.go @@ -0,0 +1,27 @@ +package platform + +import ( + "time" + + "github.com/Gleipnir-Technology/nidus-sync/platform/event" +) + +type Envelope = event.Envelope +type Event = event.Event + +const EventTypeHeartbeat = event.EventTypeHeartbeat + +func SetEventChannel(chan_events chan<- Envelope) { + event.SetEventChannel(chan_events) +} +func SudoEvent(org_id int32, content string) { + go event.Send(event.Envelope{ + Event: Event{ + Resource: "sudo", + Time: time.Now(), + Type: event.EventTypeSudo, + URI: content, + }, + OrganizationID: org_id, + }) +} diff --git a/platform/event/event.go b/platform/event/event.go new file mode 100644 index 00000000..91c73593 --- /dev/null +++ b/platform/event/event.go @@ -0,0 +1,68 @@ +package event + +import ( + "github.com/Gleipnir-Technology/nidus-sync/config" + "time" +) + +var chanEvents chan<- Envelope + +type Event struct { + Resource string `json:"resource"` + Time time.Time `json:"time"` + Type EventType `json:"type"` + URI string `json:"uri"` +} +type Envelope struct { + OrganizationID int32 + Event Event +} + +func SetEventChannel(chan_events chan<- Envelope) { + chanEvents = chan_events +} + +type EventType int + +const ( + EventTypeCreated EventType = iota + EventTypeDeleted + EventTypeModified + EventTypeHeartbeat + EventTypeSudo +) + +type ResourceType int + +const ( + TypeRMONuisance = iota + TypeRMOWater +) + +func Created(type_ ResourceType, organization_id int32, uri_id string) { + var resource string + var uri string + switch type_ { + case TypeRMONuisance: + resource = "rmo:nuisance" + uri = config.MakeURLReport("/report/%s", uri_id) + case TypeRMOWater: + resource = "rmo:water" + uri = config.MakeURLReport("/report/%s", uri_id) + default: + + } + go Send(Envelope{ + Event: Event{ + Resource: resource, + Time: time.Now(), + Type: EventTypeCreated, + URI: uri, + }, + OrganizationID: organization_id, + }) +} +func Send(env Envelope) { + chanEvents <- env + +} diff --git a/platform/image.go b/platform/image.go new file mode 100644 index 00000000..c5dcc3b9 --- /dev/null +++ b/platform/image.go @@ -0,0 +1,148 @@ +package platform + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + _ "image/gif" // register GIF format + _ "image/jpeg" // register JPEG format + _ "image/png" // register PNG format + "io" + "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/models" + "github.com/aarondl/opt/omit" + "github.com/aarondl/opt/omitnull" + "github.com/google/uuid" + "github.com/rs/zerolog/log" + "github.com/rwcarlsen/goexif/exif" + "github.com/rwcarlsen/goexif/tiff" + //exif "github.com/rwcarlsen/goexif/exif" + //"github.com/dsoprea/go-exif-extra/format" +) + +type GPS struct { + Latitude float64 + Longitude float64 +} + +type ExifCollection struct { + GPS *GPS + Tags map[string]string +} + +type ImageUpload struct { + Bounds image.Rectangle + ContentType string + Exif *ExifCollection + Format string + + UploadFilesize int + UploadFilename string + UUID uuid.UUID +} + +func (e *ExifCollection) Walk(name exif.FieldName, tag *tiff.Tag) error { + e.Tags[string(name)] = tag.String() + return nil +} +func ImageExtractExif(content_type string, file_bytes []byte) (result *ExifCollection, err error) { + /* + Using "github.com/evanoberholster/imagemeta" + meta, err := imagemeta.Decode(bytes.NewReader(file_bytes)) + if err != nil { + return result, fmt.Errorf("Failed to decode image meta: %w", err) + } + result.GPS = &GPS{ + Latitude: meta.GPS.Latitude(), + Longitude: meta.GPS.Longitude(), + } + return result, err + */ + + e, err := exif.Decode(bytes.NewReader(file_bytes)) + if err != nil { + if err.Error() == "exif: failed to find exif intro marker" { + return nil, nil + } else if errors.Is(err, io.EOF) { + return nil, nil + } + return nil, fmt.Errorf("Failed to decode image meta: %w", err) + } + lat, lng, _ := e.LatLong() + result = &ExifCollection{ + GPS: &GPS{ + Latitude: lat, + Longitude: lng, + }, + Tags: make(map[string]string, 0), + } + err = e.Walk(result) + return result, err +} + +func saveImageUploads(ctx context.Context, tx bob.Tx, uploads []ImageUpload) (models.PublicreportImageSlice, error) { + images := make(models.PublicreportImageSlice, 0) + for _, u := range uploads { + image, err := models.PublicreportImages.Insert(&models.PublicreportImageSetter{ + ContentType: omit.From(u.ContentType), + + Created: omit.From(time.Now()), + //Location: psql.Raw("NULL"), + Location: omitnull.FromPtr[string](nil), + ResolutionX: omit.From(int32(u.Bounds.Max.X)), + ResolutionY: omit.From(int32(u.Bounds.Max.Y)), + StorageUUID: omit.From(u.UUID), + StorageSize: omit.From(int64(u.UploadFilesize)), + UploadedFilename: omit.From(u.UploadFilename), + }).One(ctx, tx) + if err != nil { + return images, fmt.Errorf("Failed to create photo records: %w", err) + } + + // TODO: figure out how to do this via the setter...? + if u.Exif != nil { + if u.Exif.GPS != nil { + _, err = psql.Update( + um.Table("publicreport.image"), + um.SetCol("location").To(fmt.Sprintf("ST_Point(%f, %f, 4326)", u.Exif.GPS.Longitude, u.Exif.GPS.Latitude)), + um.Where(psql.Quote("id").EQ(psql.Arg(image.ID))), + ).Exec(ctx, tx) + } + + exif_setters := make([]*models.PublicreportImageExifSetter, 0) + for k, v := range u.Exif.Tags { + to_save := trimQuotes(v) + exif_setters = append(exif_setters, &models.PublicreportImageExifSetter{ + ImageID: omit.From(image.ID), + Name: omit.From(k), + Value: omit.From(to_save), + }) + } + if len(exif_setters) > 0 { + _, err = models.PublicreportImageExifs.Insert(bob.ToMods(exif_setters...)).Exec(ctx, tx) + if err != nil { + return images, fmt.Errorf("Failed to create photo exif records: %w", err) + } + } + log.Info().Int32("id", image.ID).Int("tags", len(u.Exif.Tags)).Msg("Saved an uploaded file to the database") + } else { + log.Info().Int32("id", image.ID).Int("tags", 0).Msg("Saved an uploaded file without EXIF data") + } + images = append(images, image) + } + return images, nil +} + +// Given a string like "\"foo\"" return "foo". +func trimQuotes(s string) string { + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + return s[1 : len(s)-1] + } + return s +} diff --git a/platform/latlng.go b/platform/latlng.go new file mode 100644 index 00000000..157e65db --- /dev/null +++ b/platform/latlng.go @@ -0,0 +1,57 @@ +package platform + +import ( + "errors" + "fmt" + + "github.com/Gleipnir-Technology/nidus-sync/db/enums" + "github.com/Gleipnir-Technology/nidus-sync/h3utils" + "github.com/rs/zerolog/log" + "github.com/uber/h3-go/v4" +) + +type LatLng struct { + Latitude *float64 + Longitude *float64 + MapZoom float32 + AccuracyValue float64 + AccuracyType enums.PublicreportAccuracytype +} + +func (l LatLng) Resolution() uint { + switch l.AccuracyType { + // These accuracy_type strings come from the Mapbox Geocoding API definition and + // are far from scientific + case enums.PublicreportAccuracytypeRooftop: + return 14 + case enums.PublicreportAccuracytypeParcel: + return 13 + case enums.PublicreportAccuracytypePoint: + return 13 + case enums.PublicreportAccuracytypeInterpolated: + return 12 + case enums.PublicreportAccuracytypeApproximate: + return 11 + case enums.PublicreportAccuracytypeIntersection: + return 10 + // This is a special indicator that we got our location from the browser measurements + case enums.PublicreportAccuracytypeBrowser: + return uint(h3utils.MeterAccuracyToH3Resolution(l.AccuracyValue)) + default: + log.Warn().Str("accuracy-type", string(l.AccuracyType)).Msg("unrecognized accuracy type, this indicates either a weird client or misbehaving web page. Defaulting to resolution 13") + return 13 + } +} +func (l LatLng) H3Cell() (*h3.Cell, error) { + if l.Longitude == nil || l.Latitude == nil { + return nil, errors.New("nil lat/lng") + } + result, err := h3utils.GetCell(*l.Longitude, *l.Latitude, int(l.Resolution())) + return &result, err +} +func (l LatLng) GeometryQuery() (string, error) { + if l.Longitude == nil || l.Latitude == nil { + return "", errors.New("nil lat/lng") + } + return fmt.Sprintf("ST_Point(%f, %f, 4326)", *l.Longitude, *l.Latitude), nil +} diff --git a/platform/nuisance.go b/platform/nuisance.go new file mode 100644 index 00000000..4ee572e0 --- /dev/null +++ b/platform/nuisance.go @@ -0,0 +1,104 @@ +package platform + +import ( + "context" + "fmt" + + "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/models" + "github.com/Gleipnir-Technology/nidus-sync/platform/event" + "github.com/Gleipnir-Technology/nidus-sync/platform/geocode" + "github.com/Gleipnir-Technology/nidus-sync/platform/report" + "github.com/Gleipnir-Technology/nidus-sync/platform/types" + "github.com/aarondl/opt/omit" + "github.com/aarondl/opt/omitnull" + "github.com/rs/zerolog/log" +) + +func NuisanceCreate(ctx context.Context, setter models.PublicreportNuisanceSetter, latlng LatLng, address Address, images []ImageUpload) (public_id string, err error) { + txn, err := db.PGInstance.BobDB.BeginTx(ctx, nil) + if err != nil { + return "", fmt.Errorf("create txn: %w", err) + } + defer txn.Rollback(ctx) + + public_id, err = report.GenerateReportID() + if err != nil { + return "", fmt.Errorf("create public ID: %w", err) + } + setter.PublicID = omit.From(public_id) + + // If we've got an locality value it was set by geocoding so we should save it + var a *models.Address + if address.Locality != "" && latlng.Latitude != nil && latlng.Longitude != nil { + a, err = geocode.EnsureAddress(ctx, txn, address, types.Location{ + Latitude: *latlng.Latitude, + Longitude: *latlng.Longitude, + }) + if err != nil { + return "", fmt.Errorf("Failed to ensure address: %w", err) + } + } + + saved_images, err := saveImageUploads(ctx, txn, images) + if err != nil { + return "", fmt.Errorf("Failed to save image uploads: %w", err) + } + var organization_id *int32 + organization_id, err = MatchDistrict(ctx, latlng.Longitude, latlng.Latitude, images) + if err != nil { + log.Warn().Err(err).Msg("Failed to match district") + } + + if a != nil { + setter.AddressID = omitnull.From(a.ID) + } + if organization_id != nil { + setter.OrganizationID = omitnull.FromPtr(organization_id) + } + nuisance, err := models.PublicreportNuisances.Insert(&setter).One(ctx, txn) + if err != nil { + return "", fmt.Errorf("Failed to create database record: %w", err) + } + if latlng.Latitude != nil && latlng.Longitude != nil { + h3cell, _ := latlng.H3Cell() + geom_query, _ := latlng.GeometryQuery() + _, err = psql.Update( + um.Table("publicreport.nuisance"), + um.SetCol("h3cell").ToArg(h3cell), + um.SetCol("location").To(geom_query), + um.Where(psql.Quote("id").EQ(psql.Arg(nuisance.ID))), + ).Exec(ctx, txn) + if err != nil { + return "", fmt.Errorf("Failed to insert publicreport.nuisance geospatial", err) + } + } + log.Info().Str("public_id", public_id).Int32("id", nuisance.ID).Msg("Created nuisance report") + if len(saved_images) > 0 { + setters := make([]*models.PublicreportNuisanceImageSetter, 0) + for _, image := range saved_images { + setters = append(setters, &models.PublicreportNuisanceImageSetter{ + ImageID: omit.From(int32(image.ID)), + NuisanceID: omit.From(int32(nuisance.ID)), + }) + } + _, err = models.PublicreportNuisanceImages.Insert(bob.ToMods(setters...)).Exec(ctx, txn) + if err != nil { + return "", fmt.Errorf("Failed to save reference to images: %w", err) + } + log.Info().Int("len", len(images)).Msg("saved uploaded images") + } + txn.Commit(ctx) + + if organization_id != nil { + event.Created( + event.TypeRMONuisance, + *organization_id, + nuisance.PublicID, + ) + } + return nuisance.PublicID, nil +} diff --git a/platform/types/address.go b/platform/types/address.go index 12ed7516..c85419a7 100644 --- a/platform/types/address.go +++ b/platform/types/address.go @@ -11,6 +11,7 @@ type Address struct { Locality string `db:"locality" json:"locality"` Number string `db:"number" json:"number"` PostalCode string `db:"postal_code" json:"postal_code"` + Raw string `db:"-" json:"raw"` Region string `db:"region" json:"region"` Street string `db:"street" json:"street"` Unit string `db:"unit" json:"unit"` diff --git a/platform/user.go b/platform/user.go index e49407f5..e79a353a 100644 --- a/platform/user.go +++ b/platform/user.go @@ -35,7 +35,22 @@ type User struct { } func (u User) HasRoot() bool { - return u.model.Role != enums.UserroleRoot + return u.model.Role == enums.UserroleRoot +} +func newUser(org Organization, user *models.User) User { + return User{ + DisplayName: user.DisplayName, + ID: int(user.ID), + Initials: extractInitials(user.DisplayName), + Notifications: []Notification{}, + Organization: org, + PasswordHash: user.PasswordHash, + PasswordHashType: string(user.PasswordHashType), + Role: user.Role.String(), + Username: user.Username, + + model: user, + } } func CreateUser(ctx context.Context, username string, name string, password_hash string) (*User, error) { @@ -60,19 +75,11 @@ func CreateUser(ctx context.Context, username string, name string, password_hash return nil, fmt.Errorf("Failed to create user: %w", err) } log.Info().Int32("id", user.ID).Str("username", user.Username).Msg("Created user") - return &User{ - DisplayName: user.DisplayName, - Initials: extractInitials(user.DisplayName), - Notifications: []Notification{}, - Organization: newOrganization(o), - Role: user.Role.String(), - Username: user.Username, - - model: user, - }, nil + u := newUser(newOrganization(o), user) + return &u, nil } -func UserByID(ctx context.Context, user_id int) (*User, error) { - return getUser(ctx, models.SelectWhere.Users.ID.EQ(int32(user_id))) +func UserByID(ctx context.Context, user_id int32) (*User, error) { + return getUser(ctx, models.SelectWhere.Users.ID.EQ(user_id)) } func UserByUsername(ctx context.Context, username string) (*User, error) { return getUser(ctx, models.SelectWhere.Users.Username.EQ(username)) @@ -84,15 +91,8 @@ func UsersByOrg(ctx context.Context, org Organization) (map[int32]*User, error) } results := make(map[int32]*User, len(users)) for _, user := range users { - results[user.ID] = &User{ - DisplayName: user.DisplayName, - Initials: "", - Notifications: []Notification{}, - Organization: org, - Role: user.Role.String(), - Username: user.Username, - model: user, - } + u := newUser(org, user) + results[user.ID] = &u } return results, nil } @@ -102,6 +102,7 @@ func getUser(ctx context.Context, where mods.Where[*dialect.SelectQuery]) (*User where, ).One(ctx, db.PGInstance.BobDB) if err != nil { + log.Debug().Err(err).Msg("getUser failed") if err.Error() == "No such user" || err.Error() == "sql: no rows in result set" { return nil, &NoUserError{} } else { @@ -112,14 +113,8 @@ func getUser(ctx context.Context, where mods.Where[*dialect.SelectQuery]) (*User } org := newOrganization(user.R.Organization) - return &User{ - DisplayName: user.DisplayName, - Initials: extractInitials(user.DisplayName), - Notifications: []Notification{}, - Organization: org, - Role: user.Role.String(), - Username: user.Username, - }, nil + u := newUser(org, user) + return &u, nil } func extractInitials(name string) string { parts := strings.Fields(name) diff --git a/platform/water.go b/platform/water.go new file mode 100644 index 00000000..d37d07ec --- /dev/null +++ b/platform/water.go @@ -0,0 +1,104 @@ +package platform + +import ( + "context" + "fmt" + + "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/models" + "github.com/Gleipnir-Technology/nidus-sync/platform/event" + "github.com/Gleipnir-Technology/nidus-sync/platform/geocode" + "github.com/Gleipnir-Technology/nidus-sync/platform/report" + "github.com/Gleipnir-Technology/nidus-sync/platform/types" + "github.com/aarondl/opt/omit" + "github.com/aarondl/opt/omitnull" + "github.com/rs/zerolog/log" +) + +func WaterCreate(ctx context.Context, setter models.PublicreportWaterSetter, latlng LatLng, address Address, images []ImageUpload) (public_id string, err error) { + txn, err := db.PGInstance.BobDB.BeginTx(ctx, nil) + if err != nil { + return "", fmt.Errorf("Failed to create transaction: %w", err) + } + defer txn.Rollback(ctx) + public_id, err = report.GenerateReportID() + if err != nil { + return "", fmt.Errorf("Failed to create water report public ID", err) + } + setter.PublicID = omit.From(public_id) + + // If we've got an locality value it was set by geocoding so we should save it + var a *models.Address + if address.Locality != "" && latlng.Latitude != nil && latlng.Longitude != nil { + a, err = geocode.EnsureAddress(ctx, txn, address, types.Location{ + Latitude: *latlng.Latitude, + Longitude: *latlng.Longitude, + }) + if err != nil { + return "", fmt.Errorf("Failed to ensure address: %w", err) + } + } + + saved_images, err := saveImageUploads(ctx, txn, images) + if err != nil { + return "", fmt.Errorf("Failed to save image uploads", err) + } + + var organization_id *int32 + organization_id, err = MatchDistrict(ctx, latlng.Longitude, latlng.Latitude, images) + if err != nil { + log.Warn().Err(err).Msg("Failed to match district") + } + if a != nil { + setter.AddressID = omitnull.From(a.ID) + } + if organization_id != nil { + setter.OrganizationID = omitnull.FromPtr(organization_id) + } + + water, err := models.PublicreportWaters.Insert(&setter).One(ctx, txn) + if err != nil { + return "", fmt.Errorf("Failed to create database record", err) + } + + if latlng.Latitude != nil && latlng.Longitude != nil { + h3cell, _ := latlng.H3Cell() + geom_query, _ := latlng.GeometryQuery() + _, err = psql.Update( + um.Table("publicreport.water"), + um.SetCol("h3cell").ToArg(h3cell), + um.SetCol("location").To(geom_query), + um.Where(psql.Quote("id").EQ(psql.Arg(water.ID))), + ).Exec(ctx, txn) + if err != nil { + return "", fmt.Errorf("Failed to update publicreport.water geospatial", err) + } + } + log.Info().Int32("id", water.ID).Str("public_id", water.PublicID).Msg("Created water report") + setters := make([]*models.PublicreportWaterImageSetter, 0) + for _, image := range saved_images { + setters = append(setters, &models.PublicreportWaterImageSetter{ + ImageID: omit.From(int32(image.ID)), + WaterID: omit.From(int32(water.ID)), + }) + } + if len(setters) > 0 { + _, err = models.PublicreportWaterImages.Insert(bob.ToMods(setters...)).Exec(ctx, txn) + if err != nil { + return "", fmt.Errorf("Failed to save upload relationships", err) + } + } + txn.Commit(ctx) + + if organization_id != nil { + event.Created( + event.TypeRMOWater, + *organization_id, + water.PublicID, + ) + } + return water.PublicID, nil +} diff --git a/rmo/district.go b/rmo/district.go index 9c789623..25ce2cbe 100644 --- a/rmo/district.go +++ b/rmo/district.go @@ -1,8 +1,6 @@ package rmo import ( - "context" - "fmt" "net/http" "github.com/Gleipnir-Technology/bob/dialect/psql/sm" @@ -10,9 +8,7 @@ import ( "github.com/Gleipnir-Technology/nidus-sync/db" "github.com/Gleipnir-Technology/nidus-sync/db/models" "github.com/Gleipnir-Technology/nidus-sync/html" - "github.com/Gleipnir-Technology/nidus-sync/platform" "github.com/go-chi/chi/v5" - "github.com/rs/zerolog/log" ) type ContentDistrict struct { @@ -57,42 +53,6 @@ func getDistrictList(w http.ResponseWriter, r *http.Request) { ) } -func matchDistrict(ctx context.Context, longitude, latitude *float64, images []ImageUpload) (*int32, error) { - var err error - var org *models.Organization - for _, image := range images { - if image.Exif == nil { - continue - } - if image.Exif.GPS == nil { - continue - } - org, err = platform.DistrictForLocation(ctx, image.Exif.GPS.Longitude, image.Exif.GPS.Latitude) - if err != nil { - log.Warn().Err(err).Msg("Failed to get district for location") - continue - } - if org != nil { - return &org.ID, nil - } - } - if longitude == nil || latitude == nil { - log.Debug().Msg("No location from images, no latlng for the report itself, cannot match") - return nil, nil - } - org, err = platform.DistrictForLocation(ctx, *longitude, *latitude) - if err != nil { - log.Warn().Err(err).Msg("Failed to get district for location") - return nil, fmt.Errorf("Failed to get district for location: %w", err) - } - if org == nil { - log.Debug().Err(err).Float64("lng", *longitude).Float64("lat", *latitude).Msg("No district match by report location") - return nil, nil - } - log.Debug().Err(err).Int32("org_id", org.ID).Float64("lng", *longitude).Float64("lat", *latitude).Msg("Found district match by report location") - return &org.ID, nil -} - func newContentDistrict(d *models.Organization) *ContentDistrict { if d == nil { return nil diff --git a/rmo/geospatial.go b/rmo/geospatial.go deleted file mode 100644 index 29b2b968..00000000 --- a/rmo/geospatial.go +++ /dev/null @@ -1,70 +0,0 @@ -package rmo - -import ( - "fmt" - "net/http" - "strconv" - - "github.com/Gleipnir-Technology/nidus-sync/h3utils" - "github.com/rs/zerolog/log" - "github.com/uber/h3-go/v4" -) - -type GeospatialData struct { - Cell h3.Cell - GeometryQuery string - Populated bool -} - -func geospatialFromForm(r *http.Request) (GeospatialData, error) { - lat := r.FormValue("latitude") - lng := r.FormValue("longitude") - accuracy_type := r.FormValue("latlng-accuracy-type") - accuracy_value := r.FormValue("latlng-accuracy-value") - - if lat == "" || lng == "" { - return GeospatialData{Populated: false}, nil - } - latitude, err := strconv.ParseFloat(lat, 64) - if err != nil { - return GeospatialData{Populated: false}, fmt.Errorf("Failed to create parse latitude: %v", err) - } - longitude, err := strconv.ParseFloat(lng, 64) - if err != nil { - return GeospatialData{Populated: false}, fmt.Errorf("Failed to create parse longitude: %v", err) - } - var resolution int - switch accuracy_type { - // These accuracy_type strings come from the Mapbox Geocoding API definition and - // are far from scientific - case "rooftop": - resolution = 14 - case "parcel": - resolution = 13 - case "point": - resolution = 13 - case "interpolated": - resolution = 12 - case "approximate": - resolution = 11 - case "intersection": - resolution = 10 - // This is a special indicator that we got our location from the browser measurements - case "meters": - case "browser": - accuracy_in_meters, err := strconv.ParseFloat(accuracy_value, 64) - if err != nil { - return GeospatialData{Populated: false}, fmt.Errorf("Failed to parse '%s' as an accuracy in meters: %v", accuracy_value, err) - } - resolution = h3utils.MeterAccuracyToH3Resolution(accuracy_in_meters) - default: - log.Warn().Str("accuracy-type", accuracy_type).Msg("unrecognized accuracy type, this indicates either a weird client or misbehaving web page. Defaulting to resolution 13") - resolution = 13 - } - cell, err := h3utils.GetCell(longitude, latitude, resolution) - return GeospatialData{ - Cell: cell, - GeometryQuery: fmt.Sprintf("ST_Point(%f, %f, 4326)", longitude, latitude), - Populated: true, - }, nil -} diff --git a/rmo/image-upload.go b/rmo/image-upload.go index afb5a356..6232bb54 100644 --- a/rmo/image-upload.go +++ b/rmo/image-upload.go @@ -2,9 +2,11 @@ package rmo import ( "bytes" - "context" - "errors" "fmt" + "github.com/Gleipnir-Technology/nidus-sync/platform" + "github.com/Gleipnir-Technology/nidus-sync/platform/file" + "github.com/google/uuid" + "github.com/rs/zerolog/log" "image" _ "image/gif" // register GIF format _ "image/jpeg" // register JPEG format @@ -12,84 +14,9 @@ import ( "io" "mime/multipart" "net/http" - "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/models" - "github.com/Gleipnir-Technology/nidus-sync/platform/file" - "github.com/aarondl/opt/omit" - "github.com/aarondl/opt/omitnull" - "github.com/google/uuid" - "github.com/rs/zerolog/log" - "github.com/rwcarlsen/goexif/exif" - "github.com/rwcarlsen/goexif/tiff" - //exif "github.com/rwcarlsen/goexif/exif" - //"github.com/dsoprea/go-exif-extra/format" ) -type GPS struct { - Latitude float64 - Longitude float64 -} - -type ExifCollection struct { - GPS *GPS - Tags map[string]string -} - -type ImageUpload struct { - Bounds image.Rectangle - ContentType string - Exif *ExifCollection - Format string - - UploadFilesize int - UploadFilename string - UUID uuid.UUID -} - -func (e *ExifCollection) Walk(name exif.FieldName, tag *tiff.Tag) error { - e.Tags[string(name)] = tag.String() - return nil -} -func extractExif(content_type string, file_bytes []byte) (result *ExifCollection, err error) { - /* - Using "github.com/evanoberholster/imagemeta" - meta, err := imagemeta.Decode(bytes.NewReader(file_bytes)) - if err != nil { - return result, fmt.Errorf("Failed to decode image meta: %w", err) - } - result.GPS = &GPS{ - Latitude: meta.GPS.Latitude(), - Longitude: meta.GPS.Longitude(), - } - return result, err - */ - - e, err := exif.Decode(bytes.NewReader(file_bytes)) - if err != nil { - if err.Error() == "exif: failed to find exif intro marker" { - return nil, nil - } else if errors.Is(err, io.EOF) { - return nil, nil - } - return nil, fmt.Errorf("Failed to decode image meta: %w", err) - } - lat, lng, _ := e.LatLong() - result = &ExifCollection{ - GPS: &GPS{ - Latitude: lat, - Longitude: lng, - }, - Tags: make(map[string]string, 0), - } - err = e.Walk(result) - return result, err -} - -func extractImageUpload(headers *multipart.FileHeader) (upload ImageUpload, err error) { +func extractImageUpload(headers *multipart.FileHeader) (upload platform.ImageUpload, err error) { f, err := headers.Open() if err != nil { return upload, fmt.Errorf("Failed to open header: %w", err) @@ -99,7 +26,7 @@ func extractImageUpload(headers *multipart.FileHeader) (upload ImageUpload, err file_bytes, err := io.ReadAll(f) content_type := http.DetectContentType(file_bytes) - exif, err := extractExif(content_type, file_bytes) + exif, err := platform.ImageExtractExif(content_type, file_bytes) if err != nil { return upload, fmt.Errorf("Failed to extract EXIF data: %w", err) } @@ -117,7 +44,7 @@ func extractImageUpload(headers *multipart.FileHeader) (upload ImageUpload, err return upload, fmt.Errorf("Failed to write image file to disk: %w", err) } log.Info().Int("size", len(file_bytes)).Str("uploaded_filename", headers.Filename).Str("content-type", content_type).Str("uuid", u.String()).Msg("Saved an uploaded file to disk") - return ImageUpload{ + return platform.ImageUpload{ Bounds: i.Bounds(), ContentType: content_type, Exif: exif, @@ -128,77 +55,16 @@ func extractImageUpload(headers *multipart.FileHeader) (upload ImageUpload, err }, nil } -func extractImageUploads(r *http.Request) (uploads []ImageUpload, err error) { - uploads = make([]ImageUpload, 0) +func extractImageUploads(r *http.Request) (uploads []platform.ImageUpload, err error) { + uploads = make([]platform.ImageUpload, 0) for _, fheaders := range r.MultipartForm.File { for _, headers := range fheaders { upload, err := extractImageUpload(headers) if err != nil { - return make([]ImageUpload, 0), fmt.Errorf("Failed to extract photo upload: %w", err) + return make([]platform.ImageUpload, 0), fmt.Errorf("Failed to extract photo upload: %w", err) } uploads = append(uploads, upload) } } return uploads, nil } - -func saveImageUploads(ctx context.Context, tx bob.Tx, uploads []ImageUpload) (models.PublicreportImageSlice, error) { - images := make(models.PublicreportImageSlice, 0) - for _, u := range uploads { - image, err := models.PublicreportImages.Insert(&models.PublicreportImageSetter{ - ContentType: omit.From(u.ContentType), - - Created: omit.From(time.Now()), - //Location: psql.Raw("NULL"), - Location: omitnull.FromPtr[string](nil), - ResolutionX: omit.From(int32(u.Bounds.Max.X)), - ResolutionY: omit.From(int32(u.Bounds.Max.Y)), - StorageUUID: omit.From(u.UUID), - StorageSize: omit.From(int64(u.UploadFilesize)), - UploadedFilename: omit.From(u.UploadFilename), - }).One(ctx, tx) - if err != nil { - return images, fmt.Errorf("Failed to create photo records: %w", err) - } - - // TODO: figure out how to do this via the setter...? - if u.Exif != nil { - if u.Exif.GPS != nil { - _, err = psql.Update( - um.Table("publicreport.image"), - um.SetCol("location").To(fmt.Sprintf("ST_Point(%f, %f, 4326)", u.Exif.GPS.Longitude, u.Exif.GPS.Latitude)), - um.Where(psql.Quote("id").EQ(psql.Arg(image.ID))), - ).Exec(ctx, tx) - } - - exif_setters := make([]*models.PublicreportImageExifSetter, 0) - for k, v := range u.Exif.Tags { - to_save := trimQuotes(v) - exif_setters = append(exif_setters, &models.PublicreportImageExifSetter{ - ImageID: omit.From(image.ID), - Name: omit.From(k), - Value: omit.From(to_save), - }) - } - if len(exif_setters) > 0 { - _, err = models.PublicreportImageExifs.Insert(bob.ToMods(exif_setters...)).Exec(ctx, tx) - if err != nil { - return images, fmt.Errorf("Failed to create photo exif records: %w", err) - } - } - log.Info().Int32("id", image.ID).Int("tags", len(u.Exif.Tags)).Msg("Saved an uploaded file to the database") - } else { - log.Info().Int32("id", image.ID).Int("tags", 0).Msg("Saved an uploaded file without EXIF data") - } - images = append(images, image) - } - return images, nil -} - -// Given a string like "\"foo\"" return "foo". -func trimQuotes(s string) string { - if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { - return s[1 : len(s)-1] - } - return s -} diff --git a/rmo/nuisance.go b/rmo/nuisance.go index e6a0132c..ddd6b4fd 100644 --- a/rmo/nuisance.go +++ b/rmo/nuisance.go @@ -6,16 +6,11 @@ import ( "slices" "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/html" - "github.com/Gleipnir-Technology/nidus-sync/platform/geocode" + "github.com/Gleipnir-Technology/nidus-sync/platform" "github.com/Gleipnir-Technology/nidus-sync/platform/report" - "github.com/Gleipnir-Technology/nidus-sync/platform/types" "github.com/aarondl/opt/omit" "github.com/aarondl/opt/omitnull" "github.com/rs/zerolog/log" @@ -130,46 +125,6 @@ func postNuisance(w http.ResponseWriter, r *http.Request) { if slices.Contains(source_locations, "pool-area") { is_location_pool = true } - //log.Debug().Bool("is_location_backyard", is_location_backyard).Bool("is_location_frontyard", is_location_frontyard).Bool("is_location_garden", is_location_garden).Bool("is_location_other", is_location_other).Bool("is_location_pool", is_location_pool).Msg("parsed") - public_id, err := report.GenerateReportID() - if err != nil { - respondError(w, "Failed to create report public ID", err, http.StatusInternalServerError) - return - } - - geospatial, err := geospatialFromForm(r) - if err != nil { - respondError(w, "Failed to handle geospatial data", err, http.StatusInternalServerError) - return - } - - txn, err := db.PGInstance.BobDB.BeginTx(ctx, nil) - if err != nil { - respondError(w, "Failed to create transaction", err, http.StatusInternalServerError) - return - } - defer txn.Rollback(ctx) - - // If we've got an address_country value it was set by geocoding so we should save it - var address *models.Address - if address_country != "" && latlng.Latitude != nil && latlng.Longitude != nil { - address, err = geocode.EnsureAddress(ctx, txn, types.Address{ - Country: address_country, - Locality: address_locality, - Number: address_number, - PostalCode: address_postal_code, - Region: address_region, - Street: address_street, - Unit: "", - }, types.Location{ - Latitude: *latlng.Latitude, - Longitude: *latlng.Longitude, - }) - if err != nil { - respondError(w, "Failed to ensure address: %w", err, http.StatusInternalServerError) - return - } - } uploads, err := extractImageUploads(r) log.Info().Int("len", len(uploads)).Msg("extracted uploads") @@ -177,42 +132,41 @@ func postNuisance(w http.ResponseWriter, r *http.Request) { respondError(w, "Failed to extract image uploads", err, http.StatusInternalServerError) return } - images, err := saveImageUploads(ctx, txn, uploads) - if err != nil { - respondError(w, "Failed to save image uploads", err, http.StatusInternalServerError) - return + address := platform.Address{ + Country: address_country, + Locality: address_locality, + Number: address_number, + PostalCode: address_postal_code, + Raw: address_raw, + Region: address_region, + Street: address_street, + Unit: "", } - var organization_id *int32 - organization_id, err = matchDistrict(ctx, latlng.Longitude, latlng.Latitude, uploads) - if err != nil { - log.Warn().Err(err).Msg("Failed to match district") - } - setter := models.PublicreportNuisanceSetter{ AdditionalInfo: omit.From(additional_info), - //AddressID: omitnull.From(geospatial.Cell.String()), - AddressRaw: omit.From(address_raw), - AddressCountry: omit.From(address_country), - AddressNumber: omit.From(address_number), - AddressLocality: omit.From(address_locality), - AddressPostalCode: omit.From(address_postal_code), - AddressRegion: omit.From(address_region), - AddressStreet: omit.From(address_street), + //AddressID: omitnull.From(latlng.Cell.String()), + AddressRaw: omit.From(address.Raw), + AddressCountry: omit.From(address.Country), + AddressNumber: omit.From(address.Number), + AddressLocality: omit.From(address.Locality), + AddressPostalCode: omit.From(address.PostalCode), + AddressRegion: omit.From(address.Region), + AddressStreet: omit.From(address.Street), Created: omit.From(time.Now()), Duration: omit.From(duration), - //H3cell: omitnull.From(geospatial.Cell.String()), + //H3cell: omitnull.From(latlng.Cell.String()), IsLocationBackyard: omit.From(is_location_backyard), IsLocationFrontyard: omit.From(is_location_frontyard), IsLocationGarden: omit.From(is_location_garden), IsLocationOther: omit.From(is_location_other), IsLocationPool: omit.From(is_location_pool), LatlngAccuracyType: omit.From(latlng.AccuracyType), - LatlngAccuracyValue: omit.From(latlng.AccuracyValue), + LatlngAccuracyValue: omit.From(float32(latlng.AccuracyValue)), //Location: omitnull.From(fmt.Sprintf("ST_GeometryFromText(Point(%s %s))", longitude, latitude)), - Location: omitnull.FromPtr[string](nil), - MapZoom: omit.From(latlng.MapZoom), - OrganizationID: omitnull.FromPtr(organization_id), - PublicID: omit.From(public_id), + Location: omitnull.FromPtr[string](nil), + MapZoom: omit.From(latlng.MapZoom), + //OrganizationID: omitnull.FromPtr(organization_id), + //PublicID: omit.From(public_id), ReporterEmail: omitnull.FromPtr[string](nil), ReporterName: omitnull.FromPtr[string](nil), ReporterPhone: omitnull.FromPtr[string](nil), @@ -226,42 +180,6 @@ func postNuisance(w http.ResponseWriter, r *http.Request) { TodEvening: omit.From(tod_evening), TodNight: omit.From(tod_night), } - if address != nil { - setter.AddressID = omitnull.From(address.ID) - } - nuisance, err := models.PublicreportNuisances.Insert(&setter).One(ctx, txn) - if err != nil { - respondError(w, "Failed to create database record", err, http.StatusInternalServerError) - return - } - if geospatial.Populated { - _, err = psql.Update( - um.Table("publicreport.nuisance"), - um.SetCol("h3cell").ToArg(geospatial.Cell), - um.SetCol("location").To(geospatial.GeometryQuery), - um.Where(psql.Quote("id").EQ(psql.Arg(nuisance.ID))), - ).Exec(ctx, txn) - if err != nil { - respondError(w, "Failed to insert publicreport.nuisance geospatial", err, http.StatusInternalServerError) - return - } - } - log.Info().Str("public_id", public_id).Int32("id", nuisance.ID).Msg("Created nuisance report") - if len(images) > 0 { - setters := make([]*models.PublicreportNuisanceImageSetter, 0) - for _, image := range images { - setters = append(setters, &models.PublicreportNuisanceImageSetter{ - ImageID: omit.From(int32(image.ID)), - NuisanceID: omit.From(int32(nuisance.ID)), - }) - } - _, err = models.PublicreportNuisanceImages.Insert(bob.ToMods(setters...)).Exec(ctx, txn) - if err != nil { - respondError(w, "Failed to save reference to images", err, http.StatusInternalServerError) - return - } - log.Info().Int("len", len(images)).Msg("saved uploads") - } - txn.Commit(ctx) + public_id, err := platform.NuisanceCreate(ctx, setter, latlng, address, uploads) http.Redirect(w, r, fmt.Sprintf("/submit-complete?report=%s", public_id), http.StatusFound) } diff --git a/rmo/report.go b/rmo/report.go index f031d5c6..1dc7c474 100644 --- a/rmo/report.go +++ b/rmo/report.go @@ -11,6 +11,7 @@ import ( "github.com/Gleipnir-Technology/nidus-sync/db" "github.com/Gleipnir-Technology/nidus-sync/db/enums" "github.com/Gleipnir-Technology/nidus-sync/db/sql" + "github.com/Gleipnir-Technology/nidus-sync/platform" //"github.com/go-chi/chi/v5" //"github.com/rs/zerolog/log" ) @@ -67,16 +68,8 @@ func getReportSuggestion(w http.ResponseWriter, r *http.Request) { w.Write(jsonBody) } -type LatLngForm struct { - Latitude *float64 - Longitude *float64 - MapZoom float32 - AccuracyValue float32 - AccuracyType enums.PublicreportAccuracytype -} - -func parseLatLng(r *http.Request) (LatLngForm, error) { - result := LatLngForm{ +func parseLatLng(r *http.Request) (platform.LatLng, error) { + result := platform.LatLng{ AccuracyType: enums.PublicreportAccuracytypeNone, AccuracyValue: 0.0, Latitude: nil, @@ -102,7 +95,7 @@ func parseLatLng(r *http.Request) (LatLngForm, error) { if err != nil { return result, fmt.Errorf("Failed to parse latlng_accuracy_value '%s': %w", latlng_accuracy_value_str, err) } - result.AccuracyValue = float32(t) + result.AccuracyValue = float64(t) } if latitude_str != "" { diff --git a/rmo/water.go b/rmo/water.go index fa9bbe9d..fd2b6ae5 100644 --- a/rmo/water.go +++ b/rmo/water.go @@ -5,17 +5,11 @@ import ( "net/http" "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/html" - "github.com/Gleipnir-Technology/nidus-sync/platform/report" + "github.com/Gleipnir-Technology/nidus-sync/platform" "github.com/aarondl/opt/omit" - "github.com/aarondl/opt/omitnull" - "github.com/rs/zerolog/log" ) type ContentWater struct { @@ -65,7 +59,7 @@ func postWater(w http.ResponseWriter, r *http.Request) { address_country := r.FormValue("address-country") address_locality := r.FormValue("address-locality") address_number := r.FormValue("address-number") - address_postalcode := r.FormValue("address-postalcode") + address_postal_code := r.FormValue("address-postalcode") address_region := r.FormValue("address-region") address_street := r.FormValue("address-street") comments := r.FormValue("comments") @@ -85,42 +79,24 @@ func postWater(w http.ResponseWriter, r *http.Request) { return } - geospatial, err := geospatialFromForm(r) - if err != nil { - respondError(w, "Failed to handle geospatial data", err, http.StatusInternalServerError) - return - } - public_id, err := report.GenerateReportID() - if err != nil { - respondError(w, "Failed to create water report public ID", err, http.StatusInternalServerError) - return - } - ctx := r.Context() - tx, err := db.PGInstance.BobDB.BeginTx(ctx, nil) - if err != nil { - respondError(w, "Failed to create transaction", err, http.StatusInternalServerError) - return - } - defer tx.Rollback(ctx) uploads, err := extractImageUploads(r) if err != nil { respondError(w, "Failed to extract image uploads", err, http.StatusInternalServerError) return } - images, err := saveImageUploads(r.Context(), tx, uploads) - if err != nil { - respondError(w, "Failed to save image uploads", err, http.StatusInternalServerError) - return - } - var organization_id *int32 - organization_id, err = matchDistrict(ctx, latlng.Longitude, latlng.Latitude, uploads) - if err != nil { - log.Warn().Err(err).Msg("Failed to match district") + address := platform.Address{ + Country: address_country, + Locality: address_locality, + Number: address_number, + PostalCode: address_postal_code, + Raw: address_raw, + Region: address_region, + Street: address_street, + Unit: "", } - setter := models.PublicreportWaterSetter{ AccessComments: omit.From(access_comments), AccessDog: omit.From(access_dog), @@ -132,7 +108,7 @@ func postWater(w http.ResponseWriter, r *http.Request) { AddressCountry: omit.From(address_country), AddressLocality: omit.From(address_locality), AddressNumber: omit.From(address_number), - AddressPostalCode: omit.From(address_postalcode), + AddressPostalCode: omit.From(address_postal_code), AddressStreet: omit.From(address_street), AddressRegion: omit.From(address_region), Comments: omit.From(comments), @@ -145,51 +121,22 @@ func postWater(w http.ResponseWriter, r *http.Request) { IsReporterConfidential: omit.From(is_reporter_confidential), IsReporterOwner: omit.From(is_reporter_owner), //Location: add later - MapZoom: omit.From(latlng.MapZoom), - OrganizationID: omitnull.FromPtr(organization_id), - OwnerEmail: omit.From(owner_email), - OwnerName: omit.From(owner_name), - OwnerPhone: omit.From(owner_phone), - PublicID: omit.From(public_id), - ReporterEmail: omit.From(""), - ReporterName: omit.From(""), - ReporterPhone: omit.From(""), - Status: omit.From(enums.PublicreportReportstatustypeReported), + MapZoom: omit.From(latlng.MapZoom), + //OrganizationID: omitnull.FromPtr(organization_id), + OwnerEmail: omit.From(owner_email), + OwnerName: omit.From(owner_name), + OwnerPhone: omit.From(owner_phone), + //PublicID: omit.From(public_id), + ReporterEmail: omit.From(""), + ReporterName: omit.From(""), + ReporterPhone: omit.From(""), + Status: omit.From(enums.PublicreportReportstatustypeReported), } - water, err := models.PublicreportWaters.Insert(&setter).One(ctx, tx) + public_id, err := platform.WaterCreate(ctx, setter, latlng, address, uploads) if err != nil { - respondError(w, "Failed to create database record", err, http.StatusInternalServerError) + respondError(w, "Failed to save new report", err, http.StatusInternalServerError) return } - - if geospatial.Populated { - _, err = psql.Update( - um.Table("publicreport.water"), - um.SetCol("h3cell").ToArg(geospatial.Cell), - um.SetCol("location").To(geospatial.GeometryQuery), - um.Where(psql.Quote("id").EQ(psql.Arg(water.ID))), - ).Exec(ctx, tx) - if err != nil { - respondError(w, "Failed to update publicreport.water geospatial", err, http.StatusInternalServerError) - return - } - } - log.Info().Int32("id", water.ID).Str("public_id", water.PublicID).Msg("Created water report") - setters := make([]*models.PublicreportWaterImageSetter, 0) - for _, image := range images { - setters = append(setters, &models.PublicreportWaterImageSetter{ - ImageID: omit.From(int32(image.ID)), - WaterID: omit.From(int32(water.ID)), - }) - } - if len(setters) > 0 { - _, err = models.PublicreportWaterImages.Insert(bob.ToMods(setters...)).Exec(r.Context(), tx) - if err != nil { - respondError(w, "Failed to save upload relationships", err, http.StatusInternalServerError) - return - } - } - tx.Commit(ctx) http.Redirect(w, r, fmt.Sprintf("/submit-complete?report=%s", public_id), http.StatusFound) } func postWaterDistrict(w http.ResponseWriter, r *http.Request) { diff --git a/sync/routes.go b/sync/routes.go index 4b9445c3..70524b7c 100644 --- a/sync/routes.go +++ b/sync/routes.go @@ -87,6 +87,7 @@ func Router() chi.Router { r.Method("GET", "/sudo", authenticatedHandler(getSudo)) r.Method("POST", "/sudo/email", authenticatedHandlerPost(postSudoEmail)) r.Method("POST", "/sudo/sms", authenticatedHandlerPost(postSudoSMS)) + r.Method("POST", "/sudo/sse", authenticatedHandlerPost(postSudoSSE)) r.Method("GET", "/trap/{globalid}", authenticatedHandler(getTrap)) r.Method("GET", "/text/{destination}", authenticatedHandler(getTextMessages)) r.Method("GET", "/tile/gps", auth.NewEnsureAuth(getTileGPS)) diff --git a/sync/sudo.go b/sync/sudo.go index 3a7a9577..180dc0bc 100644 --- a/sync/sudo.go +++ b/sync/sudo.go @@ -84,3 +84,19 @@ func postSudoSMS(ctx context.Context, r *http.Request, u platform.User, sms Form } return "/sudo", nil } + +type FormSSE struct { + Content string `schema:"content"` + OrganizationID int32 `schema:"organizationID"` +} + +func postSudoSSE(ctx context.Context, r *http.Request, u platform.User, sse FormSSE) (string, *nhttp.ErrorWithStatus) { + if !u.HasRoot() { + return "", &nhttp.ErrorWithStatus{ + Message: "You must have sudo powers to do this", + Status: http.StatusForbidden, + } + } + platform.SudoEvent(sse.OrganizationID, sse.Content) + return "/sudo", nil +}