Files
backend_fiesta/services/inviteService.go
2026-09-29 16:53:21 +05:30

152 lines
5.6 KiB
Go

package services
import (
"fmt"
"log"
"strings"
"time"
"nearle/config"
"nearle/utils"
)
// The invitation a newly onboarded merchant receives.
//
// ── Why onboarding does not fail when this does ─────────────────────────────
//
// `Invite` never returns an error to the onboarding path. A tenant that exists
// and has not been emailed is recoverable — somebody presses resend — while a
// tenant rolled back because a mail relay was slow is a business that was
// onboarded, told it was onboarded, and is not in the system. The first is a
// task; the second is a phone call nobody can explain.
//
// So a failure is logged loudly and reported as `false`, and the caller decides
// what to tell the operator. The platform console shows "invitation not sent"
// beside the tenant, which is the state somebody can act on.
type InviteService interface {
// Invite emails a first-password link. Reports whether it was sent, and
// why not when it was not — a sentence for the operator, not an error.
//
// `businessName` may be empty: the name is then looked up from `tenantid`,
// because most callers hold an account and a tenantid and nothing else about
// the business. A caller that already has the name — onboarding, which was
// handed it in the form — passes it and saves the query.
Invite(userid, tenantid int, email, businessName string) (bool, string)
}
// TenantNamer reads a business's name for the invitation's first line.
//
// A one-method interface rather than the whole tenant repository, because that
// is all this needs and because it keeps `inviteService` testable without a
// database. `repositories.TenantRepository` satisfies it.
type TenantNamer interface {
TenantNameByID(tenantID int) (string, error)
}
type inviteService struct {
mailer utils.Mailer
cfg config.MailConfig
// May be nil. The invitation then says "your business", which is worse copy
// and a working link — never a reason not to send.
names TenantNamer
}
func NewInviteService(mailer utils.Mailer, cfg config.MailConfig, names TenantNamer) InviteService {
return &inviteService{mailer: mailer, cfg: cfg, names: names}
}
func (s *inviteService) Invite(userid, tenantid int, email, businessName string) (bool, string) {
address := strings.TrimSpace(email)
if address == "" {
return false, "no email address on the account"
}
if s.mailer == nil {
// Not a fault. A deployment with no mail configured still onboards; the
// reason names the variable so it is fixable rather than mysterious.
return false, s.cfg.Why()
}
token, _, err := utils.MintInviteToken(
utils.InviteClaims{Userid: userid, Tenantid: tenantid}, time.Now())
if err != nil {
// Only happens with no signing secret, which is already fatal at boot
// in production — but an invitation with no token would be a link that
// cannot work, and sending it would be worse than not sending.
log.Printf("invite: could not sign an invitation for user %d: %v", userid, err)
return false, "this server cannot sign an invitation"
}
subject, body := inviteMessage(s.businessName(tenantid, businessName), s.cfg.InviteLink(token))
if err := s.mailer.Send(address, subject, body); err != nil {
log.Printf("invite: could not email user %d at %s: %v", userid, address, err)
return false, err.Error()
}
log.Printf("invite: sent to user %d for tenant %d", userid, tenantid)
return true, ""
}
// businessName is the name for the mail's first line.
//
// Looked up only when the caller did not have one. A failure is logged and
// swallowed: the alternative is refusing to send somebody their only way into
// their account because a name could not be read, and "your business has been
// set up on Nearle" is a perfectly usable sentence.
func (s *inviteService) businessName(tenantid int, given string) string {
if name := strings.TrimSpace(given); name != "" {
return name
}
if s.names == nil || tenantid <= 0 {
return ""
}
name, err := s.names.TenantNameByID(tenantid)
if err != nil {
log.Printf("invite: could not read tenant %d's name: %v", tenantid, err)
return ""
}
return strings.TrimSpace(name)
}
// inviteMessage is what the merchant reads.
//
// ── Why it says so little ───────────────────────────────────────────────────
//
// This is the first thing a new merchant receives from us and the only way into
// their account, so it has one job: make the link obvious and make it credible.
// Every extra paragraph is somewhere for the link to hide, and a mail full of
// features reads like marketing — which is the thing people delete.
//
// It states who it is for and what it does, gives the link on its own line, and
// says how long it lasts. The expiry is there because an invitation found three
// weeks later needs to explain itself rather than look broken.
//
// Plain text, not HTML. A password link that arrives as an image-heavy template
// is the shape of a phishing mail, and plain text renders identically
// everywhere.
func inviteMessage(businessName, link string) (subject, body string) {
name := strings.TrimSpace(businessName)
if name == "" {
name = "your business"
}
subject = "Set your Nearle password"
body = fmt.Sprintf(`%s has been set up on Nearle.
To finish, choose a password for your account:
%s
This link is for you alone and works once. It expires in 7 days — if it has,
ask whoever set you up to send another.
If you were not expecting this, you can ignore it. Nothing happens until
somebody uses the link.
— Nearle
`, name, link)
return subject, body
}