e-li-nps/cmd/widgetwasm/main.go
2026-01-01 19:32:29 -03:00

249 lines
6.8 KiB
Go

//go:build js && wasm
package main
import (
"regexp"
"strings"
"syscall/js"
)
// IMPORTANTE (.agent): o backend Go continua sendo a autoridade das regras.
// Este WASM existe para concentrar regras de negócio do widget (cliente) em Go,
// mantendo o arquivo JS pequeno e fácil de auditar.
var dataISORe = regexp.MustCompile(`^([0-9]{4})-([0-9]{2})-([0-9]{2})$`)
type cfgWidget struct {
ProdutoNome string
InquilinoCodigo string
InquilinoNome string
UsuarioCodigo string
UsuarioNome string
UsuarioTelefone string
UsuarioEmail string
CooldownHours float64
DataMinimaAbertura string
}
func main() {
js.Global().Set("__eli_nps_wasm_preflight", js.FuncOf(preflight))
js.Global().Set("__eli_nps_wasm_decidir", js.FuncOf(decidir))
js.Global().Set("__eli_nps_wasm_cooldown_ativo", js.FuncOf(cooldownAtivo))
js.Global().Set("__eli_nps_wasm_set_cooldown", js.FuncOf(setCooldown))
js.Global().Set("__eli_nps_wasm_ready", true)
// Mantém o módulo vivo.
select {}
}
func cooldownAtivo(this js.Value, args []js.Value) any {
if len(args) < 1 {
return false
}
chave := strings.TrimSpace(args[0].String())
if chave == "" {
return false
}
// Best-effort: se der erro, tratamos como não ativo.
storage := js.Global().Get("localStorage")
if storage.IsUndefined() || storage.IsNull() {
return false
}
v := storage.Call("getItem", chave)
if v.IsNull() || v.IsUndefined() {
return false
}
parsed := js.Global().Get("JSON").Call("parse", v)
until := parsed.Get("until")
if until.IsUndefined() || until.IsNull() {
return false
}
agora := js.Global().Get("Date").Call("now").Int()
return int64(agora) < int64(until.Int())
}
func setCooldown(this js.Value, args []js.Value) any {
if len(args) < 2 {
return nil
}
chave := strings.TrimSpace(args[0].String())
if chave == "" {
return nil
}
ateMs := int64(args[1].Int())
if ateMs <= 0 {
return nil
}
storage := js.Global().Get("localStorage")
if storage.IsUndefined() || storage.IsNull() {
return nil
}
obj := js.Global().Get("Object").New()
obj.Set("until", ateMs)
json := js.Global().Get("JSON").Call("stringify", obj)
storage.Call("setItem", chave, json)
return nil
}
func preflight(this js.Value, args []js.Value) any {
if len(args) < 1 {
return map[string]any{"ok": false, "motivo": "sem_cfg"}
}
cfg := lerCfg(args[0])
// Bloqueio por data mínima.
if antesDaDataMinima(cfg.DataMinimaAbertura) {
return map[string]any{"ok": false, "motivo": "antes_da_data_minima"}
}
// controle de exibição: produto + inquilino_codigo + usuario_codigo
if cfg.ProdutoNome == "" || cfg.InquilinoCodigo == "" || cfg.UsuarioCodigo == "" {
return map[string]any{"ok": false, "motivo": "contexto_incompleto"}
}
chaveCooldown := chaveCooldown(cfg.ProdutoNome, cfg.InquilinoCodigo, cfg.UsuarioCodigo)
untilMs := calcularCooldownAteMs(cfg.CooldownHours)
// Enviamos exatamente o produto_nome informado.
payload := map[string]any{
"produto_nome": cfg.ProdutoNome,
"inquilino_codigo": cfg.InquilinoCodigo,
"inquilino_nome": cfg.InquilinoNome,
"usuario_codigo": cfg.UsuarioCodigo,
"usuario_nome": cfg.UsuarioNome,
"usuario_telefone": cfg.UsuarioTelefone,
"usuario_email": normalizarEmail(cfg.UsuarioEmail),
}
return map[string]any{
"ok": true,
"chave_cooldown": chaveCooldown,
"cooldown_ate_ms": untilMs,
"payload": payload,
}
}
func decidir(this js.Value, args []js.Value) any {
// Entrada:
// - args[0] = cfg (para cooldownHours)
// - args[1] = resposta JSON do servidor (obj)
if len(args) < 2 {
return map[string]any{"abrir": false, "motivo": "sem_dados"}
}
cfg := lerCfg(args[0])
data := args[1]
untilMs := calcularCooldownAteMs(cfg.CooldownHours)
// Fail-closed.
if data.IsUndefined() || data.IsNull() {
return map[string]any{"abrir": false, "motivo": "resposta_invalida", "aplicar_cooldown": true, "cooldown_ate_ms": untilMs}
}
// Esperado: {pode_abrir:bool, id:string, produto:string}
podeAbrir := data.Get("pode_abrir")
if !podeAbrir.Truthy() {
return map[string]any{"abrir": false, "motivo": "nao_pode_abrir", "aplicar_cooldown": true, "cooldown_ate_ms": untilMs}
}
id := strings.TrimSpace(data.Get("id").String())
produtoRota := strings.TrimSpace(data.Get("produto").String())
if id == "" || produtoRota == "" {
// Não dá para montar URL segura.
return map[string]any{"abrir": false, "motivo": "sem_produto_ou_id", "aplicar_cooldown": true, "cooldown_ate_ms": untilMs}
}
return map[string]any{
"abrir": true,
"id": id,
"produto_rota": produtoRota,
"aplicar_cooldown": true,
"cooldown_ate_ms": untilMs,
}
}
func lerCfg(v js.Value) cfgWidget {
// Leitura defensiva: tudo é best-effort.
getStr := func(k string) string {
if v.Type() != js.TypeObject {
return ""
}
vv := v.Get(k)
if vv.IsUndefined() || vv.IsNull() {
return ""
}
return strings.TrimSpace(vv.String())
}
getNum := func(k string) float64 {
if v.Type() != js.TypeObject {
return 0
}
vv := v.Get(k)
if vv.IsUndefined() || vv.IsNull() {
return 0
}
return vv.Float()
}
return cfgWidget{
ProdutoNome: getStr("produto_nome"),
InquilinoCodigo: getStr("inquilino_codigo"),
InquilinoNome: getStr("inquilino_nome"),
UsuarioCodigo: getStr("usuario_codigo"),
UsuarioNome: getStr("usuario_nome"),
UsuarioTelefone: getStr("usuario_telefone"),
UsuarioEmail: getStr("usuario_email"),
CooldownHours: getNum("cooldownHours"),
DataMinimaAbertura: getStr("data_minima_abertura"),
}
}
func normalizarEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
func chaveCooldown(produto, inquilino, usuarioCodigo string) string {
// Prefixo de storage atualizado para o novo nome do projeto.
return "eli-nps:cooldown:" + produto + ":" + inquilino + ":" + usuarioCodigo
}
func calcularCooldownAteMs(hours float64) int64 {
if hours <= 0 {
hours = 24
}
agora := js.Global().Get("Date").Call("now").Int()
return int64(agora) + int64(hours*3600*1000)
}
func antesDaDataMinima(s string) bool {
// Aceita somente ISO (data) YYYY-MM-DD.
v := strings.TrimSpace(s)
if v == "" {
return false
}
m := dataISORe.FindStringSubmatch(v)
if m == nil {
return false
}
// new Date(ano, mes-1, dia, 0, 0, 0, 0)
ano := m[1]
mes := m[2]
dia := m[3]
// Converte para números via JS (simplifica validação e compatibilidade de timezone).
nAno := js.Global().Get("Number").Invoke(ano).Int()
nMes := js.Global().Get("Number").Invoke(mes).Int()
nDia := js.Global().Get("Number").Invoke(dia).Int()
if nAno <= 0 || nMes < 1 || nMes > 12 || nDia < 1 || nDia > 31 {
return false
}
dataMin := js.Global().Get("Date").New(nAno, nMes-1, nDia, 0, 0, 0, 0)
agora := js.Global().Get("Date").New()
return agora.Call("getTime").Int() < dataMin.Call("getTime").Int()
}