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

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
}