Various new modules for mailer

This commit is contained in:
Eli Ribble 2026-02-28 23:17:30 +00:00
parent 985a2ab186
commit 558412cfb4
No known key found for this signature in database
6 changed files with 627 additions and 0 deletions

58
api/compliance.go Normal file
View file

@ -0,0 +1,58 @@
package api
import (
"bytes"
"fmt"
"io"
"net/http"
"github.com/Gleipnir-Technology/nidus-sync/db"
"github.com/Gleipnir-Technology/nidus-sync/db/models"
"github.com/Gleipnir-Technology/nidus-sync/platform"
"github.com/Gleipnir-Technology/nidus-sync/platform/imagetile"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"
)
func getComplianceRequestImagePool(w http.ResponseWriter, r *http.Request) {
code := chi.URLParam(r, "public_id")
if code == "" {
http.Error(w, "empty public_id", http.StatusBadRequest)
return
}
ctx := r.Context()
comp, err := models.ComplianceReportRequests.Query(
models.Preload.ComplianceReportRequest.Site(),
models.SelectWhere.ComplianceReportRequests.PublicID.EQ(code),
).One(ctx, db.PGInstance.BobDB)
if err != nil {
http.Error(w, "no comp", http.StatusInternalServerError)
return
}
site := comp.R.Site
org, err := models.FindOrganization(ctx, db.PGInstance.BobDB, site.OrganizationID)
if err != nil {
http.Error(w, "no org", http.StatusInternalServerError)
return
}
envelope, err := platform.ParcelEnvelope(ctx, site.ParcelID)
if err != nil {
log.Error().Err(err).Msg("parcel envelop failure")
http.Error(w, "parcel env", http.StatusInternalServerError)
return
}
log.Info().Int("len", len(*envelope)).Msg("got envelope")
level := uint(12)
ring := (*envelope)[0]
p := ring[0]
img, err := imagetile.ImageAtPoint(ctx, org, level, p[0], p[1])
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(img)))
_, err = io.Copy(w, bytes.NewBuffer(img))
if err != nil {
http.Error(w, "failed copy", http.StatusInternalServerError)
return
}
}

View file

@ -0,0 +1,401 @@
{{ template "sync/layout/base.html" . }}
{{ define "title" }}Mailer{{ end }}
{{ define "extraheader" }}
<style>
@page {
size: Letter;
margin: 0;
}
html,
body {
height: 100%;
}
body {
margin: 0;
padding: 0;
background: #fff;
}
.page {
width: 8.5in;
height: 11in;
position: relative;
overflow: hidden;
background: #ffffff;
font-family: Arial, Helvetica, sans-serif;
color: #2b2b2b;
page-break-after: always;
}
:root {
--peach: #fbe2d6;
--left-blue: #f4f9fd;
--right-cyan: #caeefb;
--action-strip: #cfdfe6;
}
.hdr-peach {
position: absolute;
left: 4.015in;
top: 0.24in;
width: 4.22in;
height: 2.35in;
background: var(--peach);
box-sizing: border-box;
padding: 0.18in 0.22in 0.14in 0.22in;
display: flex;
flex-direction: column;
}
.hdr-top {
display: flex;
gap: 0.18in;
align-items: flex-start;
}
.left-panel {
position: absolute;
left: 0.255in;
top: 2.78in;
width: 4.995in;
height: 7.95in;
background: var(--left-blue);
border-radius: 0.28in;
padding: 0.38in 0.42in 0.35in 0.42in;
box-sizing: border-box;
}
.right-panel {
position: absolute;
left: 5.45in;
top: 2.78in;
width: 2.795in;
height: 7.95in;
background: var(--right-cyan);
border-radius: 0.12in;
padding: 0.28in 0.3in;
box-sizing: border-box;
}
.hdr-text {
width: 1.95in;
font-size: 10.5pt;
line-height: 1.15;
}
/* Spanish scaling adjustments */
.page[lang="es"] .hdr-text {
font-size: 9.8pt;
line-height: 1.1;
}
.page[lang="es"] .lp-body {
font-size: 10.4pt;
line-height: 1.28;
}
.page[lang="es"] .lp-action {
font-size: 10.4pt;
line-height: 1.28;
padding: 0.13in 0.16in;
}
.page[lang="es"] .lp-greeting {
font-size: 11pt;
}
.page[lang="es"] .rp-steps {
font-size: 10.4pt;
line-height: 1.28;
}
.page[lang="es"] .rp-tips {
font-size: 10.4pt;
line-height: 1.28;
}
.hdr-docid {
margin-top: auto;
text-align: center;
font-size: 9.5pt;
padding-top: 0.08in;
}
.lp-greeting {
font-size: 11.5pt;
margin: 0 0 0.14in 0;
}
.lp-body {
font-size: 11pt;
line-height: 1.38;
margin: 0;
}
.lp-action {
margin: 0.18in 0 0.16in 0;
background: var(--action-strip);
border-radius: 0.22in;
padding: 0.15in 0.18in;
font-size: 11pt;
line-height: 1.38;
}
.lp-action b {
display: block;
margin-bottom: 0.04in;
}
.lp-signoff {
margin-top: 0.2in;
font-size: 11pt;
line-height: 1.35;
}
.lp-signoff b {
font-weight: 700;
}
.rp-title {
font-size: 20pt;
font-weight: 800;
margin: 0 0 0.16in 0;
}
.rp-steps {
font-size: 11pt;
line-height: 1.35;
margin: 0 0 0.18in 0;
font-weight: 600;
}
.rp-steps em {
font-style: italic;
font-weight: 700;
display: block;
margin-bottom: 0.06in;
}
.rp-steps ol {
margin: 0.06in 0 0.06in 1.05em;
}
.rp-tips-title {
font-size: 12pt;
font-weight: 800;
margin: 0.1in 0 0.06in 0;
}
.rp-tips {
margin: 0 0 0.12in 1em;
padding: 0;
font-size: 11pt;
line-height: 1.35;
font-weight: 700;
}
.rp-foot {
margin-top: 0.06in;
font-size: 11pt;
line-height: 1.35;
font-weight: 700;
}
.hdr-photo-img {
width: 1.77in;
height: 1.83in;
object-fit: cover;
display: block;
}
.rp-qr-img {
width: 2.205in;
height: 2.205in;
object-fit: contain;
display: block;
margin: 0 auto 0.18in auto;
}
.lp-logo-img {
position: absolute;
right: 0.42in;
bottom: 0.32in;
width: 0.95in;
height: 0.95in;
object-fit: contain;
display: block;
}
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
</style>
{{ end }}
{{ define "content" }}
<!-- ENGLISH PAGE -->
<div class="page">
<div class="hdr-peach">
<div class="hdr-top">
<div class="hdr-text">
Aerial review indicates<br />
possible standing water at<br />
this address.<br /><br />
Upload a photo to confirm<br />
conditions and close this<br />
case.<br /><br />
Not your property? Please<br />
let us know.
</div>
<img class="hdr-photo-img" src="{{ .PoolImageURL }}" alt="Pool photo" />
</div>
<div class="hdr-docid">id {{ .DocumentID }}</div>
</div>
<div class="left-panel">
<p class="lp-greeting">Dear Resident,</p>
<p class="lp-body">
The {{ .Organization.Name }} is contacting you because recent aerial
imagery indicates that your swimming pool may be holding standing water.
When water sits without circulation or treatment, mosquitoes can develop
quickly and affect the surrounding neighborhood.
</p>
<div class="lp-action">
<b>Action requested</b>
Please scan the QR code on this letter and upload a current photo of
your pool. The image should clearly show the deep end and overall water
condition. We request a photo whether the water appears clear, cloudy,
or green so we can accurately assess the situation and close the matter
if no issue exists.
</div>
<p class="lp-body">
If your pool has started to look more like a pond than a pool, that is
more common than most people realize. Standing water creates ideal
conditions for mosquito production. If treatment is needed, we can
coordinate access while longer-term maintenance or repairs are
addressed.
<br /><br />
Our objective is to prevent mosquito production and protect your
neighborhood. A quick photo response through the QR code is the fastest
way to resolve this.
<br /><br />
If you are unable to use the QR code, please visit {{ .ReportURL }} or
contact our office for assistance.
</p>
<div class="lp-signoff">
Sincerely,<br />
<b>{{ .Organization.Name }}</b><br />
{{ .Organization.OfficeAddressStreet.GetOr "" }} -
{{ .Organization.OfficeAddressCity.GetOr "" }},
{{ .Organization.OfficeAddressState.GetOr "" }}
{{ .Organization.OfficeAddressPostalCode.GetOr "" }}<br />
Phone:
{{ .Organization.OfficePhone.GetOr "" }}
</div>
<img class="lp-logo-img" src="{{ .LogoURL }}" alt="District logo" />
</div>
<div class="right-panel">
<div class="rp-title">Scan. Snap. Done.</div>
<img class="rp-qr-img" src="{{ .QRCodeURL }}" alt="QR code" />
<div class="rp-steps">
<em>Takes less than 60 seconds.</em>
<ol>
<li>Scan the QR code</li>
<li>Upload one pool<br />photo</li>
<li>Case closed if clear</li>
</ol>
If treatment is needed, we<br />coordinate next steps.
</div>
<div class="rp-tips-title">Clear photo tips</div>
<ul class="rp-tips">
<li>Show the full pool</li>
<li>Deep end visible</li>
<li>Take in daylight</li>
<li>Avoid glare</li>
</ul>
<div class="rp-foot">Clear photos allow faster<br />review.</div>
</div>
</div>
<!-- SPANISH PAGE -->
<div class="page" lang="es">
<div class="hdr-peach">
<div class="hdr-top">
<div class="hdr-text">
La revisión aérea indica<br />
posible agua estancada en<br />
esta dirección.<br /><br />
Suba una foto para confirmar<br />
las condiciones y cerrar este<br />
caso.<br /><br />
¿No es su propiedad? Por favor<br />
infórmenos.
</div>
<img
class="hdr-photo-img"
src="{{ .PoolImageURL }}"
alt="Foto de la piscina"
/>
</div>
<div class="hdr-docid">id {{ .DocumentID }}</div>
</div>
<div class="left-panel">
<p class="lp-greeting">Estimado Residente,</p>
<p class="lp-body">
El Distrito de Control de Mosquitos y Vectores del Delta se comunica con
usted porque imágenes aéreas recientes indican que su piscina puede
estar reteniendo agua estancada. Cuando el agua permanece sin
circulación o tratamiento, los mosquitos pueden desarrollarse
rápidamente y afectar al vecindario.
</p>
<div class="lp-action">
<b>Acción requerida</b>
Por favor escanee el código QR en esta carta y suba una foto actual de
su piscina. La imagen debe mostrar claramente la parte profunda y la
condición general del agua. Solicitamos una foto ya sea que el agua esté
clara, turbia o verde para poder evaluar la situación y cerrar el caso
si no existe ningún problema.
</div>
<p class="lp-body">
Si su piscina ha comenzado a parecer más un estanque que una piscina, es
más común de lo que muchos creen. El agua estancada crea condiciones
ideales para la producción de mosquitos. Si se necesita tratamiento,
podemos coordinar el acceso mientras se realizan reparaciones o
mantenimiento a largo plazo.
<br /><br />
Nuestro objetivo es prevenir la producción de mosquitos y proteger su
vecindario. Una respuesta rápida con una foto a través del código QR es
la manera más rápida de resolver esto.
<br /><br />
Si no puede usar el código QR, visite {{ .ReportURL }} o comuníquese con
nuestra oficina para recibir ayuda.
</p>
<div class="lp-signoff">
Atentamente,<br />
<b>{{ .Organization.Name }}</b><br />
{{ .Organization.OfficeAddressStreet.GetOr "" }} -
{{ .Organization.OfficeAddressCity.GetOr "" }},
{{ .Organization.OfficeAddressState.GetOr "" }}
{{ .Organization.OfficeAddressPostalCode.GetOr "" }}<br />
Teléfono:
{{ .Organization.OfficePhone.GetOr "" }}
</div>
<img
class="lp-logo-img"
src="{{ .LogoURL }}"
alt="Logotipo del distrito"
/>
</div>
<div class="right-panel">
<div class="rp-title">Escanee. Tome foto. Listo.</div>
<img class="rp-qr-img" src="{{ .QRCodeURL }}" alt="Código QR" />
<div class="rp-steps">
<em>Toma menos de 60 segundos.</em>
<ol>
<li>Escanee el código QR</li>
<li>Suba una foto de la<br />piscina</li>
<li>Caso cerrado si está claro</li>
</ol>
Si se necesita tratamiento,<br />coordinamos los siguientes pasos.
</div>
<div class="rp-tips-title">Consejos para la foto</div>
<ul class="rp-tips">
<li>Muestre toda la piscina</li>
<li>Parte profunda visible</li>
<li>Tome la foto de día</li>
<li>Evite reflejos</li>
</ul>
<div class="rp-foot">
Fotos claras permiten una<br />revisión más rápida.
</div>
</div>
</div>
{{ end }}

12
rmo/mailer.go Normal file
View file

@ -0,0 +1,12 @@
package rmo
import (
"net/http"
//"github.com/Gleipnir-Technology/nidus-sync/config"
)
type contentMailer struct{}
func getMailer(w http.ResponseWriter, r *http.Request) {
}

60
sync/mailer.go Normal file
View file

@ -0,0 +1,60 @@
package sync
import (
"net/http"
//"strconv"
"github.com/Gleipnir-Technology/nidus-sync/config"
"github.com/Gleipnir-Technology/nidus-sync/db"
"github.com/Gleipnir-Technology/nidus-sync/db/models"
"github.com/Gleipnir-Technology/nidus-sync/html"
//"github.com/google/uuid"
"github.com/go-chi/chi/v5"
)
type contentMailer struct {
Config contentConfig
DocumentID string
LogoURL string
Organization *models.Organization
PoolImageURL string
QRCodeURL string
ReportURL string
}
// func getMailer(ctx context.Context, r *http.Request, org *models.Organization, user *models.User) (*response[contentMailer], *errorWithStatus) {
func getMailer(w http.ResponseWriter, r *http.Request) {
}
func getMailerPreview(w http.ResponseWriter, r *http.Request) {
code := chi.URLParam(r, "code")
if code == "" {
http.Error(w, "empty code", http.StatusBadRequest)
return
}
ctx := r.Context()
comp, err := models.ComplianceReportRequests.Query(
models.Preload.ComplianceReportRequest.Site(),
models.SelectWhere.ComplianceReportRequests.PublicID.EQ(code),
).One(ctx, db.PGInstance.BobDB)
if err != nil {
http.Error(w, "no comp", http.StatusInternalServerError)
return
}
site := comp.R.Site
org, err := models.FindOrganization(ctx, db.PGInstance.BobDB, site.OrganizationID)
if err != nil {
http.Error(w, "no comp", http.StatusInternalServerError)
return
}
html.RenderOrError(w, "sync/mailer.html", contentMailer{
Config: newContentConfig(),
DocumentID: "00000000-0000-0000-0000-000000000000",
LogoURL: config.MakeURLNidus("/api/district/%s/logo", org.Slug.GetOr("unset")),
Organization: org,
PoolImageURL: config.MakeURLNidus("/api/compliance-request/image/pool/%s", code),
QRCodeURL: config.MakeURLNidus("/qr-code/mailer/%s", code),
ReportURL: config.MakeURLReport("/mailer/%s", code),
})
}

14
sync/parcel.go Normal file
View file

@ -0,0 +1,14 @@
package sync
import (
"context"
"net/http"
"github.com/Gleipnir-Technology/nidus-sync/db/models"
)
type contentParcel struct{}
func getParcel(ctx context.Context, r *http.Request, org *models.Organization, user *models.User) (*response[contentParcel], *errorWithStatus) {
return newResponse("sync/parcel.html", contentParcel{}), nil
}

82
sync/qr.go Normal file
View file

@ -0,0 +1,82 @@
package sync
import (
"github.com/skip2/go-qrcode"
"net/http"
"strconv"
"github.com/Gleipnir-Technology/nidus-sync/config"
"github.com/go-chi/chi/v5"
)
func getQRCodeMailer(w http.ResponseWriter, r *http.Request) {
code := chi.URLParam(r, "code")
if code == "" {
respondError(w, "There should always be a id", nil, http.StatusBadRequest)
}
content := config.MakeURLReport("/mailer/%s", code)
writeQRCode(w, r, content)
}
func getQRCodeReport(w http.ResponseWriter, r *http.Request) {
code := chi.URLParam(r, "code")
if code == "" {
respondError(w, "There should always be a code", nil, http.StatusBadRequest)
}
content := config.MakeURLNidus("/report/%s", code)
writeQRCode(w, r, content)
}
func writeQRCode(w http.ResponseWriter, r *http.Request, content string) {
// Get optional size parameter (default to 256)
size := 256
if sizeStr := r.URL.Query().Get("size"); sizeStr != "" {
var err error
size, err = strconv.Atoi(sizeStr)
if err != nil {
http.Error(w, "Invalid 'size' parameter, must be an integer", http.StatusBadRequest)
return
}
}
// Get optional error correction level (default to Medium)
level := qrcode.Medium
if levelStr := r.URL.Query().Get("level"); levelStr != "" {
switch levelStr {
case "L", "l":
level = qrcode.Low
case "M", "m":
level = qrcode.Medium
case "Q", "q":
level = qrcode.High
case "H", "h":
level = qrcode.Highest
default:
respondError(w, "Invalid 'level' parameter, must be L, M, Q, or H", nil, http.StatusBadRequest)
return
}
}
// Generate the QR code
var qr *qrcode.QRCode
var err error
qr, err = qrcode.New(content, level)
if err != nil {
respondError(w, "Error generating QR code", err, http.StatusInternalServerError)
return
}
// Set the appropriate content type
w.Header().Set("Content-Type", "image/png")
// Generate PNG and write directly to the response writer
png, err := qr.PNG(size)
if err != nil {
respondError(w, "Error encoding QR code to PNG", err, http.StatusInternalServerError)
return
}
_, err = w.Write(png)
if err != nil {
respondError(w, "Error writing response", err, http.StatusInternalServerError)
}
}