From f549243c10fdc8067666ab750935be98591204f3 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Sat, 24 Jan 2026 19:13:55 +0000 Subject: [PATCH] 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. --- api/district.go | 28 +++--- api/routes.go | 2 +- db/dbinfo/organization.bob.go | 12 ++- db/factory/bobfactory_main.bob.go | 1 + db/factory/organization.bob.go | 62 +++++++++++++ db/migrations/00040_organization_slug.sql | 4 + db/models/organization.bob.go | 33 ++++++- public-report/mock.go | 43 +++++++++ public-report/quick.go | 2 +- .../template/mock/district-root.html | 89 +++++++++++++++++++ 10 files changed, 259 insertions(+), 17 deletions(-) create mode 100644 db/migrations/00040_organization_slug.sql create mode 100644 public-report/mock.go create mode 100644 public-report/template/mock/district-root.html diff --git a/api/district.go b/api/district.go index 360e473b..85499cbb 100644 --- a/api/district.go +++ b/api/district.go @@ -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()) } diff --git a/api/routes.go b/api/routes.go index 81458890..80726f40 100644 --- a/api/routes.go +++ b/api/routes.go @@ -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) diff --git a/db/dbinfo/organization.bob.go b/db/dbinfo/organization.bob.go index 5013dc15..23be6de2 100644 --- a/db/dbinfo/organization.bob.go +++ b/db/dbinfo/organization.bob.go @@ -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, } } diff --git a/db/factory/bobfactory_main.bob.go b/db/factory/bobfactory_main.bob.go index 2f2ea083..00bfe2bc 100644 --- a/db/factory/bobfactory_main.bob.go +++ b/db/factory/bobfactory_main.bob.go @@ -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 { diff --git a/db/factory/organization.bob.go b/db/factory/organization.bob.go index a30832a3..e92ea5a7 100644 --- a/db/factory/organization.bob.go +++ b/db/factory/organization.bob.go @@ -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 { diff --git a/db/migrations/00040_organization_slug.sql b/db/migrations/00040_organization_slug.sql new file mode 100644 index 00000000..3b57452c --- /dev/null +++ b/db/migrations/00040_organization_slug.sql @@ -0,0 +1,4 @@ +-- +goose Up +ALTER TABLE organization ADD COLUMN slug VARCHAR(24) UNIQUE; +-- +goose Down +ALTER TABLE organization DROP COLUMN slug; diff --git a/db/models/organization.bob.go b/db/models/organization.bob.go index af84e294..8876432c 100644 --- a/db/models/organization.bob.go +++ b/db/models/organization.bob.go @@ -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), } } diff --git a/public-report/mock.go b/public-report/mock.go new file mode 100644 index 00000000..fb4d0017 --- /dev/null +++ b/public-report/mock.go @@ -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", + }, + }, + ) + } +} diff --git a/public-report/quick.go b/public-report/quick.go index c8d94c32..cfb27a0b 100644 --- a/public-report/quick.go +++ b/public-report/quick.go @@ -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"), } } diff --git a/public-report/template/mock/district-root.html b/public-report/template/mock/district-root.html new file mode 100644 index 00000000..bfc8bdca --- /dev/null +++ b/public-report/template/mock/district-root.html @@ -0,0 +1,89 @@ +{{template "base.html" .}} + +{{define "title"}}Main{{end}} +{{define "extraheader"}} + +{{end}} +{{define "content"}} + +
+ +
+
+
+
+

Report Mosquitoes for {{.District.Name}}

+ +

This is the reporting page for mosquito problems in your area.

+
+
+
+
+ + +
+
+

How Can We Help You Today?

+
+
+
+
+
+ {{ template "svg/mosquito" }} +
+

Report Mosquito Nuisance

+

Report areas with high adult mosquito activity causing discomfort or concern.

+ Report Problem +
+
+
+
+
+
+
+ {{ template "svg/pond" }} +
+

Report Standing Water

+

Report any water that has been sitting for several days, where mosquitoes can live.

+ Report Source +
+
+
+
+
+
+
+ {{ template "svg/check-report" }} +
+

Follow-up or Check Status

+

Check on a previous request or view current mosquito activity in your area.

+ Get Status +
+
+
+
+
+
+
+ +{{end}}