152 lines
5.6 KiB
Go
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
|
|
}
|