Wire up events for creating new public reports

This involved moving a lot of stuff to the platform layer since I don't
want event interfaces leaking out.

Also this includes a fix to the user authentication which I had
previously broken by making a platform-layer user object independent of
the database layer.
This commit is contained in:
Eli Ribble 2026-03-13 17:33:39 +00:00
parent 9a5cc4cf97
commit e8d865d0ab
No known key found for this signature in database
24 changed files with 915 additions and 541 deletions

View file

@ -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{
return send(w, platform.Event{
Resource: "clock",
Time: t,
},
Type: "heartbeat",
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")
}
}
}
}

View file

@ -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
}

120
html/static/js/events.js Normal file
View file

@ -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";
}
}

View file

@ -10,8 +10,12 @@
<link rel="stylesheet" href="/static/vendor/css/bootstrap-icons.min.css" />
<!-- favicon -->
<link rel="icon" href="/static/favicon-sync.ico" type="image/x-icon" />
<script src="/static/js/events.js"></script>
{{ block "extraheader" . }}{{ end }}
<script>
SSEManager.subscribe("*", function (e) {
console.log("event", e);
});
function restoreLocalStorage() {
const expanded = localStorage.getItem("sidebar.expanded");
if (expanded == "false") {
@ -60,7 +64,6 @@
setTooltipsForSidebar();
});
</script>
<script src="/static/js/events.js"></script>
{{ if not .Config.IsProductionEnvironment }}
<script src="/.flogo/injector.js"></script>
{{ end }}

View file

@ -196,6 +196,50 @@
</div>
</div>
<!-- SSE Testing -->
<div class="card mb-5">
<div class="card-header bg-danger text-white">
<i class="bi bi-bell"></i> Server-sent event testing
</div>
<div class="card-body">
<form action="/sudo/sse" method="POST">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="organizationID" class="form-label"
>Organization ID</label
>
<input
class="form-control"
id="organization-id"
name="organizationID"
placeholder="Organization ID"
type="text"
value="{{ .Organization.ID }}"
/>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="content" class="form-label">Content</label>
<input
class="form-control"
id="content"
name="content"
placeholder="Message content"
type="text"
/>
</div>
</div>
</div>
<button type="submit" class="btn btn-danger">
Send SSE<i class="bi bi-send"></i>
</button>
</form>
</div>
</div>
<hr class="my-5" />
<!-- Push Notification Testing -->
<div class="card mb-5">
<div class="card-header bg-danger text-white">

View file

@ -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")

7
platform/address.go Normal file
View file

@ -0,0 +1,7 @@
package platform
import (
"github.com/Gleipnir-Technology/nidus-sync/platform/types"
)
type Address = types.Address

View file

@ -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
}

27
platform/event.go Normal file
View file

@ -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,
})
}

68
platform/event/event.go Normal file
View file

@ -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
}

148
platform/image.go Normal file
View file

@ -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
}

57
platform/latlng.go Normal file
View file

@ -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
}

104
platform/nuisance.go Normal file
View file

@ -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
}

View file

@ -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"`

View file

@ -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)

104
platform/water.go Normal file
View file

@ -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
}

View file

@ -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

View file

@ -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
}

View file

@ -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
}

View file

@ -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),
//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)
}

View file

@ -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 != "" {

View file

@ -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),
@ -146,50 +122,21 @@ func postWater(w http.ResponseWriter, r *http.Request) {
IsReporterOwner: omit.From(is_reporter_owner),
//Location: add later
MapZoom: omit.From(latlng.MapZoom),
OrganizationID: omitnull.FromPtr(organization_id),
//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),
//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) {

View file

@ -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))

View file

@ -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
}