Add notification count to user, populate sidebar via alpine

This commit is contained in:
Eli Ribble 2026-03-13 21:22:34 +00:00
parent 6fb964852f
commit 3e1b56a266
No known key found for this signature in database
11 changed files with 3126 additions and 54 deletions

View file

@ -27,6 +27,7 @@ func AddRoutes(r chi.Router) {
r.Method("GET", "/signal", authenticatedHandlerJSON(listSignal))
r.Method("GET", "/trap-data", auth.NewEnsureAuth(apiTrapData))
r.Method("GET", "/tile/{z}/{y}/{x}", auth.NewEnsureAuth(getTile))
r.Method("GET", "/user", authenticatedHandlerJSON(getUser))
// Unauthenticated endpoints
r.Get("/district", apiGetDistrict)

18
api/user.go Normal file
View file

@ -0,0 +1,18 @@
package api
import (
"context"
"net/http"
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
"github.com/Gleipnir-Technology/nidus-sync/platform"
)
func getUser(ctx context.Context, r *http.Request, user platform.User, query queryParams) (*platform.User, *nhttp.ErrorWithStatus) {
counts, err := platform.NotificationCountsForUser(ctx, user)
if err != nil {
return nil, nhttp.NewError("get notifications: %w", err)
}
user.NotificationCounts = *counts
return &user, nil
}

3018
html/static/js/alpine-3.15.8-min.js vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -46,7 +46,9 @@ window.SSEManager = (function () {
handlers.forEach((handler) => {
eventSource.addEventListener("message", (message) => {
const data = JSON.parse(message.data);
handler(data);
if (eventType == "*" || eventType == data.type) {
handler(data);
}
});
});
});

View file

@ -6,10 +6,6 @@
type="text/javascript"
src="//unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.js"
></script>
<script
defer
src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"
></script>
<script src="/static/js/time-relative.js"></script>
<script src="/static/js/map-multipoint.js"></script>
<script>

View file

@ -1,5 +1,5 @@
{{ define "sync/component/sidebar.html" }}
<div id="sidebar">
<div id="sidebar" x-data="$store.user">
<div class="sidebar-header">
<div class="logo-container">
<img class="logo" src="/static/img/nidus-logo-256-transparent.png" />
@ -41,18 +41,16 @@
>
<div class="menu-icon">{{ template "messaging.svg" }}</div>
<span class="menu-text ms-2">Communication</span>
{{ if gt (len .User.Notifications) 0 }}
<span
x-show="notification_counts.communication > 0"
x-cloak
class="position-absolute translate-middle badge rounded-pill bg-primary"
>
<span
class="position-absolute translate-middle badge rounded-pill bg-primary"
>
{{ if gt (len .User.Notifications) 99 }}
99+
{{ else }}
{{ len .User.Notifications }}
{{ end }}
<span class="visually-hidden">unread notifications</span>
</span>
{{ end }}
x-text="notification_counts.communication > 99 ? '99+' : notification_counts.communication"
></span>
<span class="visually-hidden">unread notifications</span>
</span>
</a>
</li>
<li>

View file

@ -11,15 +11,18 @@
<!-- favicon -->
<link rel="icon" href="/static/favicon-sync.ico" type="image/x-icon" />
<script src="/static/js/events.js"></script>
<script defer src="/static/js/alpine-3.15.8-min.js"></script>
{{ block "extraheader" . }}{{ end }}
<script>
const USER = {{ .User.AsJSON|json }};
SSEManager.subscribe("*", function (e) {
if (e.type == "created") {
console.log("created event", e);
} else {
console.log("other event", e);
if (e.type == "created" && e.resource.startsWith("rmo:")) {
updateUserState();
}
});
document.addEventListener("alpine:init", () => {
Alpine.store("user", USER);
})
function restoreLocalStorage() {
const expanded = localStorage.getItem("sidebar.expanded");
if (expanded == "false") {
@ -47,6 +50,11 @@
}
});
}
async function updateUserState() {
const response = await fetch("/api/user");
const data = await response.json();
Alpine.store("user", data);
}
document.addEventListener("DOMContentLoaded", function () {
var popoverTriggerList = [].slice.call(
document.querySelectorAll('[data-bs-toggle="popover"]'),
@ -67,8 +75,7 @@
restoreLocalStorage();
setTooltipsForSidebar();
});
</script>
{{ if not .Config.IsProductionEnvironment }}
</script> {{ if not .Config.IsProductionEnvironment }}
<script src="/.flogo/injector.js"></script>
{{ end }}
</head>

View file

@ -6,10 +6,6 @@
type="text/javascript"
src="//unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.js"
></script>
<script
defer
src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"
></script>
<script src="https://unpkg.com/@esri/maplibre-arcgis@1.1.0/dist/umd/maplibre-arcgis.min.js"></script>
<script src="/static/js/map-arcgis-tile.js"></script>
<script src="/static/js/map-multipoint.js"></script>

View file

@ -6,10 +6,6 @@
type="text/javascript"
src="//unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.js"
></script>
<script
defer
src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"
></script>
<script src="/static/js/map-proxied-arcgis-tile.js"></script>
<script src="/static/js/map-multipoint.js"></script>
<script src="/static/js/time-relative.js"></script>

View file

@ -27,6 +27,10 @@ type Notification struct {
Time time.Time
Type string
}
type UserNotificationCounts struct {
Communications uint `json:"communication"`
Home uint `json:"home"`
}
// Clear all notifications for a given user with the given path
func ClearOauth(ctx context.Context, user *models.User) {
@ -100,6 +104,26 @@ func NotificationsForUser(ctx context.Context, u User) ([]Notification, error) {
}
return results, nil
}
func NotificationCountsForUser(ctx context.Context, u User) (*UserNotificationCounts, error) {
count_home, err := u.model.UserNotifications(
models.SelectWhere.Notifications.ResolvedAt.IsNull(),
).Count(ctx, db.PGInstance.BobDB)
if err != nil {
return nil, fmt.Errorf("Failed to get home notification count: %w", err)
}
count_nuisance, err := u.Organization.model.Waters().Count(ctx, db.PGInstance.BobDB)
if err != nil {
return nil, fmt.Errorf("Failed to get nuisance notification count: %w", err)
}
count_water, err := u.Organization.model.Waters().Count(ctx, db.PGInstance.BobDB)
if err != nil {
return nil, fmt.Errorf("Failed to get water notification count: %w", err)
}
return &UserNotificationCounts{
Communications: uint(count_nuisance + count_water),
Home: uint(count_home),
}, nil
}
func notificationTypeName(t enums.Notificationtype) string {
switch t {

View file

@ -2,6 +2,7 @@ package platform
import (
"context"
"encoding/json"
"fmt"
"strings"
@ -21,36 +22,51 @@ type NoUserError struct{}
func (e NoUserError) Error() string { return "That user does not exist" }
type User struct {
DisplayName string `json:"display_name"`
ID int `json:"-"`
Initials string `json:"initials"`
Notifications []Notification `json:"-"`
Organization Organization `json:"organization"`
PasswordHash string `json:"-"`
PasswordHashType string `json:"-"`
Role string `json:"role"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
ID int `json:"-"`
Initials string `json:"initials"`
Notifications []Notification `json:"notifications"`
NotificationCounts UserNotificationCounts `json:"notification_counts"`
Organization Organization `json:"organization"`
PasswordHash string `json:"-"`
PasswordHashType string `json:"-"`
Role string `json:"role"`
Username string `json:"username"`
model *models.User
}
func (u User) AsJSON() string {
content, err := json.Marshal(u)
if err != nil {
return fmt.Sprintf("{error: \"%s\"}", err.Error())
}
return string(content)
}
func (u User) HasRoot() bool {
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,
func newUser(ctx context.Context, org Organization, user *models.User) User {
u := User{
DisplayName: user.DisplayName,
ID: int(user.ID),
Initials: extractInitials(user.DisplayName),
Notifications: []Notification{},
NotificationCounts: UserNotificationCounts{},
Organization: org,
PasswordHash: user.PasswordHash,
PasswordHashType: string(user.PasswordHashType),
Role: user.Role.String(),
Username: user.Username,
model: user,
}
counts, err := NotificationCountsForUser(ctx, u)
if err != nil {
log.Error().Err(err).Int32("id", user.ID).Msg("failed to get notification counts for user")
}
u.NotificationCounts = *counts
return u
}
func CreateUser(ctx context.Context, username string, name string, password_hash string) (*User, error) {
@ -75,7 +91,7 @@ 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")
u := newUser(newOrganization(o), user)
u := newUser(ctx, newOrganization(o), user)
return &u, nil
}
func UserByID(ctx context.Context, user_id int32) (*User, error) {
@ -91,7 +107,7 @@ func UsersByOrg(ctx context.Context, org Organization) (map[int32]*User, error)
}
results := make(map[int32]*User, len(users))
for _, user := range users {
u := newUser(org, user)
u := newUser(ctx, org, user)
results[user.ID] = &u
}
return results, nil
@ -113,7 +129,7 @@ func getUser(ctx context.Context, where mods.Where[*dialect.SelectQuery]) (*User
}
org := newOrganization(user.R.Organization)
u := newUser(org, user)
u := newUser(ctx, org, user)
return &u, nil
}
func extractInitials(name string) string {