Render organization logos by 'slug'

This avoids leaking org IDs in the URL, and makes it possible to have a
district-specific root mock that works in both dev and prod.
This commit is contained in:
Eli Ribble 2026-01-24 19:13:55 +00:00
parent 45868e4bde
commit f549243c10
No known key found for this signature in database
10 changed files with 259 additions and 17 deletions

View file

@ -54,21 +54,29 @@ func apiGetDistrict(w http.ResponseWriter, r *http.Request) {
}
func apiGetDistrictLogo(w http.ResponseWriter, r *http.Request) {
id_str := chi.URLParam(r, "id")
org_id, err := strconv.ParseInt(id_str, 10, 32)
slug := chi.URLParam(r, "slug")
ctx := r.Context()
rows, err := models.Organizations.Query(
models.SelectWhere.Organizations.Slug.EQ(slug),
).All(ctx, db.PGInstance.BobDB)
if err != nil {
render.Render(w, r, errRender(fmt.Errorf("%s is not a recognized organization ID: %w", id_str, err)))
http.Error(w, "Failed to query", http.StatusInternalServerError)
return
}
ctx := r.Context()
org, err := models.FindOrganization(ctx, db.PGInstance.BobDB, int32(org_id))
if err != nil {
switch len(rows) {
case 0:
http.Error(w, "Organization not found", http.StatusNotFound)
return
}
if org.LogoUUID.IsNull() {
http.Error(w, "Logo not found", http.StatusNotFound)
case 1:
org := rows[0]
if org.LogoUUID.IsNull() {
http.Error(w, "Logo not found", http.StatusNotFound)
return
}
userfile.ImageFileContentWriteLogo(w, org.LogoUUID.MustGet())
return
default:
http.Error(w, "Too many organizations, this is a programmer error", http.StatusInternalServerError)
return
}
userfile.ImageFileContentWriteLogo(w, org.LogoUUID.MustGet())
}

View file

@ -21,7 +21,7 @@ func AddRoutes(r chi.Router) {
// Unauthenticated endpoints
r.Get("/district", apiGetDistrict)
r.Get("/district/{id}/logo", apiGetDistrictLogo)
r.Get("/district/{slug}/logo", apiGetDistrictLogo)
r.Post("/twilio/message", twilioMessagePost)
r.Post("/twilio/status", twilioStatusPost)
r.Post("/twilio/text", twilioTextPost)

View file

@ -87,6 +87,15 @@ var Organizations = Table[
Generated: false,
AutoIncr: false,
},
Slug: column{
Name: "slug",
DBType: "character varying",
Default: "NULL",
Comment: "",
Nullable: true,
Generated: false,
AutoIncr: false,
},
},
Indexes: organizationIndexes{
OrganizationPkey: index{
@ -182,11 +191,12 @@ type organizationColumns struct {
ImportDistrictGid column
Website column
LogoUUID column
Slug column
}
func (c organizationColumns) AsSlice() []column {
return []column{
c.ID, c.Name, c.ArcgisID, c.ArcgisName, c.FieldseekerURL, c.ImportDistrictGid, c.Website, c.LogoUUID,
c.ID, c.Name, c.ArcgisID, c.ArcgisName, c.FieldseekerURL, c.ImportDistrictGid, c.Website, c.LogoUUID, c.Slug,
}
}

View file

@ -2594,6 +2594,7 @@ func (f *Factory) FromExistingOrganization(m *models.Organization) *Organization
o.ImportDistrictGid = func() null.Val[int32] { return m.ImportDistrictGid }
o.Website = func() null.Val[string] { return m.Website }
o.LogoUUID = func() null.Val[uuid.UUID] { return m.LogoUUID }
o.Slug = func() null.Val[string] { return m.Slug }
ctx := context.Background()
if len(m.R.Containerrelates) > 0 {

View file

@ -45,6 +45,7 @@ type OrganizationTemplate struct {
ImportDistrictGid func() null.Val[int32]
Website func() null.Val[string]
LogoUUID func() null.Val[uuid.UUID]
Slug func() null.Val[string]
r organizationR
f *Factory
@ -745,6 +746,10 @@ func (o OrganizationTemplate) BuildSetter() *models.OrganizationSetter {
val := o.LogoUUID()
m.LogoUUID = omitnull.FromNull(val)
}
if o.Slug != nil {
val := o.Slug()
m.Slug = omitnull.FromNull(val)
}
return m
}
@ -791,6 +796,9 @@ func (o OrganizationTemplate) Build() *models.Organization {
if o.LogoUUID != nil {
m.LogoUUID = o.LogoUUID()
}
if o.Slug != nil {
m.Slug = o.Slug()
}
o.setModelRels(m)
@ -1642,6 +1650,7 @@ func (m organizationMods) RandomizeAllColumns(f *faker.Faker) OrganizationMod {
OrganizationMods.RandomImportDistrictGid(f),
OrganizationMods.RandomWebsite(f),
OrganizationMods.RandomLogoUUID(f),
OrganizationMods.RandomSlug(f),
}
}
@ -2025,6 +2034,59 @@ func (m organizationMods) RandomLogoUUIDNotNull(f *faker.Faker) OrganizationMod
})
}
// Set the model columns to this value
func (m organizationMods) Slug(val null.Val[string]) OrganizationMod {
return OrganizationModFunc(func(_ context.Context, o *OrganizationTemplate) {
o.Slug = func() null.Val[string] { return val }
})
}
// Set the Column from the function
func (m organizationMods) SlugFunc(f func() null.Val[string]) OrganizationMod {
return OrganizationModFunc(func(_ context.Context, o *OrganizationTemplate) {
o.Slug = f
})
}
// Clear any values for the column
func (m organizationMods) UnsetSlug() OrganizationMod {
return OrganizationModFunc(func(_ context.Context, o *OrganizationTemplate) {
o.Slug = nil
})
}
// Generates a random value for the column using the given faker
// if faker is nil, a default faker is used
// The generated value is sometimes null
func (m organizationMods) RandomSlug(f *faker.Faker) OrganizationMod {
return OrganizationModFunc(func(_ context.Context, o *OrganizationTemplate) {
o.Slug = func() null.Val[string] {
if f == nil {
f = &defaultFaker
}
val := random_string(f, "24")
return null.From(val)
}
})
}
// Generates a random value for the column using the given faker
// if faker is nil, a default faker is used
// The generated value is never null
func (m organizationMods) RandomSlugNotNull(f *faker.Faker) OrganizationMod {
return OrganizationModFunc(func(_ context.Context, o *OrganizationTemplate) {
o.Slug = func() null.Val[string] {
if f == nil {
f = &defaultFaker
}
val := random_string(f, "24")
return null.From(val)
}
})
}
func (m organizationMods) WithParentsCascading() OrganizationMod {
return OrganizationModFunc(func(ctx context.Context, o *OrganizationTemplate) {
if isDone, _ := organizationWithParentsCascadingCtx.Value(ctx); isDone {

View file

@ -0,0 +1,4 @@
-- +goose Up
ALTER TABLE organization ADD COLUMN slug VARCHAR(24) UNIQUE;
-- +goose Down
ALTER TABLE organization DROP COLUMN slug;

View file

@ -34,6 +34,7 @@ type Organization struct {
ImportDistrictGid null.Val[int32] `db:"import_district_gid" `
Website null.Val[string] `db:"website" `
LogoUUID null.Val[uuid.UUID] `db:"logo_uuid" `
Slug null.Val[string] `db:"slug" `
R organizationR `db:"-" `
@ -93,7 +94,7 @@ type organizationR struct {
func buildOrganizationColumns(alias string) organizationColumns {
return organizationColumns{
ColumnsExpr: expr.NewColumnsExpr(
"id", "name", "arcgis_id", "arcgis_name", "fieldseeker_url", "import_district_gid", "website", "logo_uuid",
"id", "name", "arcgis_id", "arcgis_name", "fieldseeker_url", "import_district_gid", "website", "logo_uuid", "slug",
).WithParent("organization"),
tableAlias: alias,
ID: psql.Quote(alias, "id"),
@ -104,6 +105,7 @@ func buildOrganizationColumns(alias string) organizationColumns {
ImportDistrictGid: psql.Quote(alias, "import_district_gid"),
Website: psql.Quote(alias, "website"),
LogoUUID: psql.Quote(alias, "logo_uuid"),
Slug: psql.Quote(alias, "slug"),
}
}
@ -118,6 +120,7 @@ type organizationColumns struct {
ImportDistrictGid psql.Expression
Website psql.Expression
LogoUUID psql.Expression
Slug psql.Expression
}
func (c organizationColumns) Alias() string {
@ -140,10 +143,11 @@ type OrganizationSetter struct {
ImportDistrictGid omitnull.Val[int32] `db:"import_district_gid" `
Website omitnull.Val[string] `db:"website" `
LogoUUID omitnull.Val[uuid.UUID] `db:"logo_uuid" `
Slug omitnull.Val[string] `db:"slug" `
}
func (s OrganizationSetter) SetColumns() []string {
vals := make([]string, 0, 8)
vals := make([]string, 0, 9)
if s.ID.IsValue() {
vals = append(vals, "id")
}
@ -168,6 +172,9 @@ func (s OrganizationSetter) SetColumns() []string {
if !s.LogoUUID.IsUnset() {
vals = append(vals, "logo_uuid")
}
if !s.Slug.IsUnset() {
vals = append(vals, "slug")
}
return vals
}
@ -196,6 +203,9 @@ func (s OrganizationSetter) Overwrite(t *Organization) {
if !s.LogoUUID.IsUnset() {
t.LogoUUID = s.LogoUUID.MustGetNull()
}
if !s.Slug.IsUnset() {
t.Slug = s.Slug.MustGetNull()
}
}
func (s *OrganizationSetter) Apply(q *dialect.InsertQuery) {
@ -204,7 +214,7 @@ func (s *OrganizationSetter) Apply(q *dialect.InsertQuery) {
})
q.AppendValues(bob.ExpressionFunc(func(ctx context.Context, w io.StringWriter, d bob.Dialect, start int) ([]any, error) {
vals := make([]bob.Expression, 8)
vals := make([]bob.Expression, 9)
if s.ID.IsValue() {
vals[0] = psql.Arg(s.ID.MustGet())
} else {
@ -253,6 +263,12 @@ func (s *OrganizationSetter) Apply(q *dialect.InsertQuery) {
vals[7] = psql.Raw("DEFAULT")
}
if !s.Slug.IsUnset() {
vals[8] = psql.Arg(s.Slug.MustGetNull())
} else {
vals[8] = psql.Raw("DEFAULT")
}
return bob.ExpressSlice(ctx, w, d, start, vals, "", ", ", "")
}))
}
@ -262,7 +278,7 @@ func (s OrganizationSetter) UpdateMod() bob.Mod[*dialect.UpdateQuery] {
}
func (s OrganizationSetter) Expressions(prefix ...string) []bob.Expression {
exprs := make([]bob.Expression, 0, 8)
exprs := make([]bob.Expression, 0, 9)
if s.ID.IsValue() {
exprs = append(exprs, expr.Join{Sep: " = ", Exprs: []bob.Expression{
@ -320,6 +336,13 @@ func (s OrganizationSetter) Expressions(prefix ...string) []bob.Expression {
}})
}
if !s.Slug.IsUnset() {
exprs = append(exprs, expr.Join{Sep: " = ", Exprs: []bob.Expression{
psql.Quote(append(prefix, "slug")...),
psql.Arg(s.Slug),
}})
}
return exprs
}
@ -3847,6 +3870,7 @@ type organizationWhere[Q psql.Filterable] struct {
ImportDistrictGid psql.WhereNullMod[Q, int32]
Website psql.WhereNullMod[Q, string]
LogoUUID psql.WhereNullMod[Q, uuid.UUID]
Slug psql.WhereNullMod[Q, string]
}
func (organizationWhere[Q]) AliasedAs(alias string) organizationWhere[Q] {
@ -3863,6 +3887,7 @@ func buildOrganizationWhere[Q psql.Filterable](cols organizationColumns) organiz
ImportDistrictGid: psql.WhereNull[Q, int32](cols.ImportDistrictGid),
Website: psql.WhereNull[Q, string](cols.Website),
LogoUUID: psql.WhereNull[Q, uuid.UUID](cols.LogoUUID),
Slug: psql.WhereNull[Q, string](cols.Slug),
}
}

43
public-report/mock.go Normal file
View file

@ -0,0 +1,43 @@
package publicreport
import (
"net/http"
"github.com/Gleipnir-Technology/nidus-sync/config"
"github.com/Gleipnir-Technology/nidus-sync/htmlpage"
"github.com/go-chi/chi/v5"
)
var (
mockRootT = buildTemplate("mock/root", "base")
mockDistrictRootT = buildTemplate("mock/district-root", "base")
)
type ContentDistrict struct {
LogoURL string
Name string
}
type ContentMock struct {
District ContentDistrict
}
func addMockRoutes(r chi.Router) {
r.Get("/", renderMock(mockRootT))
r.Get("/district/{slug}", renderMock(mockDistrictRootT))
}
func renderMock(t *htmlpage.BuiltTemplate) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
htmlpage.RenderOrError(
w,
t,
ContentMock{
District: ContentDistrict{
LogoURL: config.MakeURLNidus("/api/district/%s/logo", slug),
Name: "Delta MCD",
},
},
)
}
}

View file

@ -75,7 +75,7 @@ func getQuickSubmitComplete(w http.ResponseWriter, r *http.Request) {
log.Debug().Int32("org_id", org.ID).Int32("d_gid", d.Gid).Msg("Getting district")
if d != nil {
district = &District{
LogoURL: config.MakeURLNidus("/api/district/%s/logo", strconv.Itoa(int(org_id))),
LogoURL: config.MakeURLNidus("/api/district/%s/logo", org.Slug.GetOr("placeholder")),
Name: d.Agency.GetOr("Unknown"),
}
}

View file

@ -0,0 +1,89 @@
{{template "base.html" .}}
{{define "title"}}Main{{end}}
{{define "extraheader"}}
<style>
.service-card {
transition: transform 0.3s;
height: 100%;
}
.service-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
}
.district-logo {
max-height: 80px;
width: auto;
}
.quick-report-mobile {
background-color: #ff9800;
}
.quick-report-desktop {
background-color: #ffefd5;
border-left: 4px solid #ff9800;
}
</style>
{{end}}
{{define "content"}}
<!-- Main Content -->
<main>
<!-- Introduction Section -->
<section class="py-5 bg-primary text-white">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-10">
<h2 class="text-center mb-4">Report Mosquitoes for {{.District.Name}}</h2>
<img src="{{.District.LogoURL}}" width="256"/>
<p class="lead text-center">This is the reporting page for mosquito problems in your area.</p>
</div>
</div>
</div>
</section>
<!-- Services Section -->
<section class="py-5">
<div class="container">
<h3 class="text-center mb-4">How Can We Help You Today?</h3>
<div class="row g-4">
<div class="col-md-4">
<div class="card service-card h-100">
<div class="card-body text-center">
<div class="mb-3">
{{ template "svg/mosquito" }}
</div>
<h4 class="card-title">Report Mosquito Nuisance</h4>
<p class="card-text">Report areas with high adult mosquito activity causing discomfort or concern.</p>
<a href="/nuisance" class="btn btn-primary mt-3">Report Problem</a>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card service-card h-100">
<div class="card-body text-center">
<div class="mb-3">
{{ template "svg/pond" }}
</div>
<h4 class="card-title">Report Standing Water</h4>
<p class="card-text">Report any water that has been sitting for several days, where mosquitoes can live.</p>
<a href="/pool" class="btn btn-primary mt-3">Report Source</a>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card service-card h-100">
<div class="card-body text-center">
<div class="mb-3">
{{ template "svg/check-report" }}
</div>
<h4 class="card-title">Follow-up or Check Status</h4>
<p class="card-text">Check on a previous request or view current mosquito activity in your area.</p>
<a href="/status" class="btn btn-primary mt-3">Get Status</a>
</div>
</div>
</div>
</div>
</div>
</section>
</main>
{{end}}