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:
parent
9a5cc4cf97
commit
e8d865d0ab
24 changed files with 915 additions and 541 deletions
136
api/event.go
136
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{
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
120
html/static/js/events.js
Normal 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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
6
main.go
6
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")
|
||||
|
|
|
|||
7
platform/address.go
Normal file
7
platform/address.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package platform
|
||||
|
||||
import (
|
||||
"github.com/Gleipnir-Technology/nidus-sync/platform/types"
|
||||
)
|
||||
|
||||
type Address = types.Address
|
||||
|
|
@ -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
27
platform/event.go
Normal 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
68
platform/event/event.go
Normal 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
148
platform/image.go
Normal 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
57
platform/latlng.go
Normal 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
104
platform/nuisance.go
Normal 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
|
||||
}
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
104
platform/water.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
128
rmo/nuisance.go
128
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),
|
||||
//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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
85
rmo/water.go
85
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),
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
16
sync/sudo.go
16
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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue