470 lines
19 KiB
Go
470 lines
19 KiB
Go
package services
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"nearle/models"
|
|
"nearle/repositories"
|
|
"nearle/utils"
|
|
)
|
|
|
|
type TenantService interface {
|
|
SearchTenant(status, keyword string) ([]models.Tenantinfo, error)
|
|
GetAllTenants(pageno, pagesize, aid int, status, tenanttype, keyword string) ([]models.Tenantinfo, error)
|
|
GetTenantLocations(tid int) ([]models.Tenantlocations, error)
|
|
GetTenantSlot() (models.Tenantslot, error)
|
|
CreateTenantCustomer(req models.CreateTenantCustomerRequest) (*models.Tenantcustomer, error)
|
|
AssignPartner(tenantID, partnerID int) error
|
|
GetCustomerTenants(customerID int, categoryID int, tenantFlag int) (*models.CustomerTenantResponse, error)
|
|
GetTenantPricing(tid, aid int) (models.Tenantpricing, error)
|
|
UpdateLocation(input models.Tenantlocations) error
|
|
CreateLocation(data models.Tenantlocations) error
|
|
DeleteLocation(locationid int, tenantid int) error
|
|
UpdateTenantProfile(tenantID int, fields map[string]any) error
|
|
UpdateOwnProfile(userID, tenantID int, fields map[string]any) error
|
|
GetStaffs(tid int) ([]models.StaffInfo, error)
|
|
// Adds a back-office person and emails their first-password invitation.
|
|
//
|
|
// Same shape as `CreateTenantUser`, and for the same reason: the account is
|
|
// created with no password, and whether the mail left is a separate fact the
|
|
// console has to show. A failure to send is not a failure to hire.
|
|
CreateStaff(user models.User) (InviteOutcome, error)
|
|
AssignStaffToBranch(tenantID, userID, locationID int) error
|
|
UpdateStaff(user models.User) error
|
|
CreateTenantLocation(data models.Tenantlocations) map[string]interface{}
|
|
UpdateTenantLocation(data models.Tenantlocations) map[string]interface{}
|
|
// Onboards a merchant and emails their first-password invitation.
|
|
//
|
|
// The outcome is returned rather than stashed on the service: it belongs to
|
|
// one call, and a field would race between two operators onboarding at the
|
|
// same moment.
|
|
CreateTenantUser(data models.Tenants) (models.UserInfo, InviteOutcome, error)
|
|
// ResendInvite re-issues a first-password link for a tenant that never got
|
|
// one, or whose invitation expired.
|
|
ResendInvite(tenantID int) (InviteOutcome, error)
|
|
// ResendInviteToUser does the same for one named account — a staff member or
|
|
// a branch's own login, neither of which is reachable by tenantid because a
|
|
// business has many of them.
|
|
ResendInviteToUser(userID int) (InviteOutcome, error)
|
|
GetTenantByID(tid int, locationid int, userid int) (models.Tenantinfo, error)
|
|
GetTenantByKeyword(keyword string) ([]models.TenantSearch, error)
|
|
}
|
|
|
|
type tenantService struct {
|
|
repo repositories.TenantRepository
|
|
// May be nil. A deployment with no mail configured still onboards tenants
|
|
// — the merchant is told by whoever set them up — and the outcome says so
|
|
// rather than the creation failing.
|
|
invites InviteService
|
|
}
|
|
|
|
func NewTenantService(repo repositories.TenantRepository, invites InviteService) TenantService {
|
|
return &tenantService{repo: repo, invites: invites}
|
|
}
|
|
|
|
func (s *tenantService) SearchTenant(status, keyword string) ([]models.Tenantinfo, error) {
|
|
return s.repo.SearchTenant(status, keyword)
|
|
}
|
|
|
|
func (s *tenantService) GetAllTenants(pageno, pagesize, aid int, status, tenanttype, keyword string) ([]models.Tenantinfo, error) {
|
|
return s.repo.GetAllTenants(pageno, pagesize, aid, status, tenanttype, keyword)
|
|
}
|
|
|
|
func (s *tenantService) GetTenantLocations(tid int) ([]models.Tenantlocations, error) {
|
|
return s.repo.GetTenantLocations(tid)
|
|
}
|
|
|
|
func (s *tenantService) GetTenantSlot() (models.Tenantslot, error) {
|
|
return s.repo.GetTenantSlot()
|
|
}
|
|
|
|
func (s *tenantService) CreateTenantCustomer(req models.CreateTenantCustomerRequest) (*models.Tenantcustomer, error) {
|
|
tenantCustomer := models.Tenantcustomer{
|
|
TenantID: req.TenantID,
|
|
LocationID: req.LocationID,
|
|
CustomerID: req.CustomerID,
|
|
CustomerLocationID: req.CustomerLocationID,
|
|
Status: req.Status,
|
|
}
|
|
return s.repo.CreateTenantCustomer(tenantCustomer)
|
|
}
|
|
|
|
func (s *tenantService) GetCustomerTenants(customerID int, categoryID int, tenantFlag int) (*models.CustomerTenantResponse, error) {
|
|
details, err := s.repo.GetCustomerTenants(customerID, categoryID, tenantFlag)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &models.CustomerTenantResponse{
|
|
Details: details,
|
|
}, nil
|
|
}
|
|
|
|
func (s *tenantService) GetTenantPricing(tid, aid int) (models.Tenantpricing, error) {
|
|
result, err := s.repo.GetTenantPricing(tid, aid)
|
|
if err != nil {
|
|
return models.Tenantpricing{}, err
|
|
}
|
|
return *result, nil
|
|
}
|
|
|
|
func (s *tenantService) UpdateLocation(input models.Tenantlocations) error {
|
|
return s.repo.UpdateLocation(input)
|
|
}
|
|
|
|
func (s *tenantService) CreateLocation(data models.Tenantlocations) error {
|
|
return s.repo.CreateLocation(data)
|
|
}
|
|
|
|
func (s *tenantService) DeleteLocation(locationid int, tenantid int) error {
|
|
return s.repo.DeleteLocation(locationid, tenantid)
|
|
}
|
|
|
|
func (s *tenantService) GetStaffs(tid int) ([]models.StaffInfo, error) {
|
|
return s.repo.GetStaffs(tid)
|
|
}
|
|
|
|
func (s *tenantService) CreateStaff(user models.User) (InviteOutcome, error) {
|
|
// Same two fields, same reason: without an authname and a console configid
|
|
// the account is created, listed, and refused at the login screen. This path
|
|
// and users/create both make back-office accounts, so both need it.
|
|
ready := PrepareNewAccount(user)
|
|
|
|
userid, err := s.repo.CreateStaff(ready)
|
|
if err != nil {
|
|
return InviteOutcome{}, err
|
|
}
|
|
|
|
// The invitation, for the same reason onboarding sends one: this account is
|
|
// created with no password, and the sign-in screen no longer offers to set
|
|
// one. Without the mail the person is added to the directory, appears in
|
|
// every branch picker, and cannot sign in — and nothing anywhere would say
|
|
// so. That was true for a while, and this is the fix.
|
|
//
|
|
// After the write and outside it, like the tenant's. A person who exists and
|
|
// was not emailed is a resend; a person rolled back by a slow mail relay is
|
|
// somebody the manager was told they had hired.
|
|
return s.inviteAccount(userid, ready.Tenantid, ready.Email, ready.Authname), nil
|
|
}
|
|
|
|
// inviteAccount emails one newly created back-office account.
|
|
//
|
|
// The business name is left to the invite service, which looks it up from the
|
|
// tenantid: a staff row arrives with an id and nothing else about the business,
|
|
// and every caller doing that lookup itself would be the same query written four
|
|
// times.
|
|
func (s *tenantService) inviteAccount(userid, tenantid int, email, authname string) InviteOutcome {
|
|
if s.invites == nil {
|
|
return InviteOutcome{Reason: "invitations are not configured on this server"}
|
|
}
|
|
if userid <= 0 {
|
|
return InviteOutcome{Reason: "the new account could not be read back to invite it"}
|
|
}
|
|
|
|
address := strings.TrimSpace(email)
|
|
if address == "" {
|
|
// The authname IS the email on every back-office account — `users/create`
|
|
// and `createstaff` both copy one to the other — so this is a fallback
|
|
// for a caller that filled in only one of the two, not a second address.
|
|
address = strings.TrimSpace(authname)
|
|
}
|
|
|
|
sent, reason := s.invites.Invite(userid, tenantid, address, "")
|
|
return InviteOutcome{Sent: sent, Reason: reason}
|
|
}
|
|
|
|
func (s *tenantService) UpdateStaff(user models.User) error {
|
|
return s.repo.UpdateStaff(user)
|
|
}
|
|
|
|
func (s *tenantService) CreateTenantLocation(data models.Tenantlocations) map[string]interface{} {
|
|
// A branch needs a location for the same reasons a tenant does — delivery
|
|
// radius is stored on this very row. Same rule: only when the caller sent
|
|
// nothing, and never fatal.
|
|
if strings.TrimSpace(data.Latitude) == "" && strings.TrimSpace(data.Longitude) == "" {
|
|
data.Latitude, data.Longitude = utils.GeocodeAddress(
|
|
data.Address, data.Suburb, data.City, data.State, data.Postcode)
|
|
}
|
|
|
|
created, spawnedUserid, err := s.repo.CreateTenantLocation(data)
|
|
if err != nil {
|
|
return map[string]interface{}{
|
|
"code": http.StatusConflict,
|
|
"message": err.Error(),
|
|
"status": false,
|
|
}
|
|
}
|
|
|
|
// A branch that spawned its own login needs that login invited — it is
|
|
// created with no password, and the invitation is the only way to set one.
|
|
// `spawnedUserid` is 0 when an existing person was named instead, and there
|
|
// is deliberately nothing to send then: they had an account before this
|
|
// branch existed, and re-inviting somebody who may already have a password
|
|
// would be a password reset wearing a branch's clothes.
|
|
invite := InviteOutcome{}
|
|
if spawnedUserid > 0 {
|
|
invite = s.inviteAccount(spawnedUserid, data.Tenantid, data.Email, data.Email)
|
|
}
|
|
|
|
// "details" carries back the DB-assigned locationid so the frontend can
|
|
// build the store's QR code (tenantid+locationid) immediately after
|
|
// onboarding, instead of having to look the new location up separately.
|
|
return map[string]interface{}{
|
|
"code": http.StatusCreated,
|
|
"message": "Tenant Location Successfully Created",
|
|
"status": true,
|
|
"details": created,
|
|
// Beside "details" for the same reason it is on the tenant create: the
|
|
// branch exists either way, and whether its operator was emailed is a
|
|
// separate fact the console has to be able to show.
|
|
"invited": invite.Sent,
|
|
// Omitted when it sent, and when there was nobody to send to — a branch
|
|
// handed to an existing person has no invitation to report, and an
|
|
// apology there would read as a failure.
|
|
"invitereason": invite.Reason,
|
|
// Who to resend to, when it did not go. 0 when no login was spawned.
|
|
//
|
|
// The console cannot work this out: `details` is the tenantlocations row,
|
|
// and the account lives in `app_users`. Without this the only route to a
|
|
// resend is finding the right row in the people list by eye, on a screen
|
|
// that has just told somebody the mail failed.
|
|
"inviteuserid": spawnedUserid,
|
|
}
|
|
}
|
|
|
|
func (s *tenantService) UpdateTenantLocation(data models.Tenantlocations) map[string]interface{} {
|
|
err := s.repo.UpdateTenantLocation(data)
|
|
if err != nil {
|
|
return map[string]interface{}{
|
|
"status": false,
|
|
"code": http.StatusConflict,
|
|
"message": err.Error(),
|
|
}
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"status": true,
|
|
"code": http.StatusAccepted,
|
|
"message": "Tenant Location update successful",
|
|
}
|
|
}
|
|
|
|
func (s *tenantService) CreateTenantUser(data models.Tenants) (models.UserInfo, InviteOutcome, error) {
|
|
// ✅ Check if tenant already exists
|
|
exists := s.repo.CheckTenantByNo(data.Primarycontact)
|
|
if exists != 0 {
|
|
return models.UserInfo{}, InviteOutcome{}, errors.New("Tenant Already Exists")
|
|
}
|
|
|
|
// Coordinates from the address, for the tenant and its primary outlet.
|
|
//
|
|
// `tenants.latitude` and `tenants.longitude` have existed since the schema
|
|
// was written and no caller has ever filled them, so every shop on the
|
|
// platform sits at no location at all. Resolved here rather than in the
|
|
// console because the key stays server-side and because a merchant created
|
|
// by any other client — the mobile app, a script — gets the same treatment.
|
|
//
|
|
// Only when the caller sent nothing: a console that lets an operator drag a
|
|
// pin is stating something a geocoder cannot know, and must win.
|
|
fillTenantCoordinates(&data)
|
|
|
|
// ✅ Create Tenant User
|
|
status, err := s.repo.CreateTenantUser(data)
|
|
if err != nil || !status {
|
|
return models.UserInfo{}, InviteOutcome{}, err
|
|
}
|
|
|
|
// ✅ Get user details by contact number
|
|
result := s.repo.GetUserByNo(data.Primarycontact)
|
|
|
|
// The invitation, sent after everything above is committed and never inside
|
|
// it. `CreateTenantUser` in the repository runs a transaction; this does not
|
|
// join it.
|
|
//
|
|
// A tenant that exists and has not been emailed is recoverable — somebody
|
|
// presses resend. 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.
|
|
//
|
|
// The account being invited is the one the repository just wrote: primary
|
|
// email as the authname, roleid 3, and no password. That empty password is
|
|
// what makes the invitation the only way in, and what `SetInitialPassword`
|
|
// re-checks before it writes.
|
|
return result, s.inviteFor(result, data), nil
|
|
}
|
|
|
|
// InviteOutcome is what the operator is told about the invitation.
|
|
//
|
|
// Its own type rather than a bool, because "not sent" is only useful with the
|
|
// reason attached: somebody who sees a tenant created and no mail sent needs to
|
|
// know whether to correct an address or set a variable.
|
|
//
|
|
// Returned rather than stashed on the service. The first version of this kept
|
|
// it in a field for the controller to read afterwards, which races — the
|
|
// service is one shared instance, and two operators onboarding at the same
|
|
// moment would each read the other's result. A value belonging to one call
|
|
// travels with that call.
|
|
type InviteOutcome struct {
|
|
Sent bool
|
|
Reason string
|
|
}
|
|
|
|
// inviteFor emails the new merchant, and says what happened.
|
|
//
|
|
// Never returns an error: the outcome is for the operator who onboarded them,
|
|
// not something for the caller to fail on.
|
|
func (s *tenantService) inviteFor(user models.UserInfo, data models.Tenants) InviteOutcome {
|
|
if s.invites == nil {
|
|
return InviteOutcome{Reason: "invitations are not configured on this server"}
|
|
}
|
|
if user.Userid <= 0 {
|
|
// The account was written but could not be read back, so there is
|
|
// nobody to address. Worth saying rather than silently not sending.
|
|
return InviteOutcome{Reason: "the new account could not be read back to invite it"}
|
|
}
|
|
|
|
sent, reason := s.invites.Invite(user.Userid, user.Tenantid, data.Primaryemail, data.Tenantname)
|
|
return InviteOutcome{Sent: sent, Reason: reason}
|
|
}
|
|
|
|
func (s *tenantService) GetTenantByID(tid int, locationid int, userid int) (models.Tenantinfo, error) {
|
|
return s.repo.GetTenantByID(tid, locationid, userid)
|
|
}
|
|
|
|
func (s *tenantService) GetTenantByKeyword(keyword string) ([]models.TenantSearch, error) {
|
|
return s.repo.GetTenantByKeyword(keyword)
|
|
}
|
|
|
|
// fillTenantCoordinates geocodes a tenant and its nested primary outlet.
|
|
//
|
|
// Never returns an error and never blocks creation. A geocoder that is down,
|
|
// slow or rate-limited leaves the columns exactly as they are today — empty —
|
|
// and a merchant is still onboarded. The alternative, refusing to create a shop
|
|
// because a third party did not answer, trades a fixable blank field for a
|
|
// person stuck on the phone.
|
|
func fillTenantCoordinates(data *models.Tenants) {
|
|
if strings.TrimSpace(data.Latitude) == "" && strings.TrimSpace(data.Longitude) == "" {
|
|
data.Latitude, data.Longitude = utils.GeocodeAddress(
|
|
data.Address, data.Suburb, data.City, data.State, data.Postcode)
|
|
}
|
|
|
|
outlet := &data.Tenantlocations
|
|
if strings.TrimSpace(outlet.Latitude) != "" || strings.TrimSpace(outlet.Longitude) != "" {
|
|
return
|
|
}
|
|
|
|
// The first outlet is almost always at the address just typed, and the
|
|
// console already copies it across. Reuse the tenant's answer rather than
|
|
// asking twice for the same string — that is a second network round trip
|
|
// inside a request someone is waiting on, for a guaranteed identical result.
|
|
if sameAddress(*data) {
|
|
outlet.Latitude, outlet.Longitude = data.Latitude, data.Longitude
|
|
return
|
|
}
|
|
outlet.Latitude, outlet.Longitude = utils.GeocodeAddress(
|
|
outlet.Address, outlet.Suburb, outlet.City, outlet.State, outlet.Postcode)
|
|
}
|
|
|
|
func sameAddress(data models.Tenants) bool {
|
|
outlet := data.Tenantlocations
|
|
return strings.EqualFold(strings.TrimSpace(outlet.Address), strings.TrimSpace(data.Address)) &&
|
|
strings.EqualFold(strings.TrimSpace(outlet.City), strings.TrimSpace(data.City)) &&
|
|
strings.EqualFold(strings.TrimSpace(outlet.Postcode), strings.TrimSpace(data.Postcode))
|
|
}
|
|
|
|
func (s *tenantService) AssignStaffToBranch(tenantID, userID, locationID int) error {
|
|
return s.repo.AssignStaffToBranch(tenantID, userID, locationID)
|
|
}
|
|
|
|
// UpdateTenantProfile filters the request down to what a merchant owns, then
|
|
// writes it. The allowlist lives in tenantProfile.go with the reasoning.
|
|
func (s *tenantService) UpdateTenantProfile(tenantID int, fields map[string]any) error {
|
|
clean, err := TenantProfileUpdate(fields)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.repo.UpdateTenantProfile(tenantID, clean)
|
|
}
|
|
|
|
// UpdateOwnProfile filters a self-service edit down to identity fields, then
|
|
// writes it scoped to the person's own business. See ownProfile.go.
|
|
func (s *tenantService) UpdateOwnProfile(userID, tenantID int, fields map[string]any) error {
|
|
clean, err := OwnProfileUpdate(fields)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.repo.UpdateOwnProfile(userID, tenantID, clean)
|
|
}
|
|
|
|
// AssignPartner records which delivery partner supplies a merchant's riders.
|
|
//
|
|
// Not part of UpdateTenantProfile: that endpoint is the merchant's own, and
|
|
// which partner they sit under is the platform's call. See the repository.
|
|
func (s *tenantService) AssignPartner(tenantID, partnerID int) error {
|
|
return s.repo.AssignPartner(tenantID, partnerID)
|
|
}
|
|
|
|
// ResendInvite emails a fresh first-password link to a tenant's admin.
|
|
//
|
|
// ── Why it refuses an account that is already set up ────────────────────────
|
|
//
|
|
// `SetInitialPassword` would refuse such a link anyway, so the merchant could
|
|
// come to no harm — but the operator would be told the invitation was sent, the
|
|
// merchant would follow a link that does not work, and nobody would understand
|
|
// why. Refusing here names the real situation: they already have a password,
|
|
// and what they need is help signing in.
|
|
//
|
|
// It also keeps this from quietly becoming a password reset. Nothing on this
|
|
// backend verifies identity well enough to support one, and an endpoint that
|
|
// re-issues a working link for any account is that, whatever it is called.
|
|
func (s *tenantService) ResendInvite(tenantID int) (InviteOutcome, error) {
|
|
target, err := s.repo.PrimaryAdminForTenant(tenantID)
|
|
if err != nil {
|
|
return InviteOutcome{}, err
|
|
}
|
|
return s.resendTo(target, tenantID)
|
|
}
|
|
|
|
// ResendInviteToUser re-invites one named account.
|
|
//
|
|
// The owner is reachable by tenantid because there is exactly one of them. Staff
|
|
// added after onboarding, and the login every branch spawns, are not — a business
|
|
// has many, and all of them are created with no password. So an operator chasing
|
|
// a branch manager who never received their mail names the person.
|
|
//
|
|
// Same refusals as above, for the same reason: this must not become a password
|
|
// reset for anybody whose userid can be found.
|
|
func (s *tenantService) ResendInviteToUser(userID int) (InviteOutcome, error) {
|
|
target, err := s.repo.InviteTargetForUser(userID)
|
|
if err != nil {
|
|
return InviteOutcome{}, err
|
|
}
|
|
return s.resendTo(target, 0)
|
|
}
|
|
|
|
// resendTo is the half the two resends share.
|
|
//
|
|
// `tenantID` is passed in rather than read off the target because the token's
|
|
// claim should carry the tenant the CALLER asked about; a staff resend has no
|
|
// tenant in hand and 0 is honest about that.
|
|
func (s *tenantService) resendTo(target repositories.InviteTarget, tenantID int) (InviteOutcome, error) {
|
|
if target.IsSetUp {
|
|
who := strings.TrimSpace(target.Tenantname)
|
|
if who == "" {
|
|
who = "That account"
|
|
}
|
|
return InviteOutcome{}, fmt.Errorf(
|
|
"%s has already set a password — send them to the sign-in page instead", who)
|
|
}
|
|
if s.invites == nil {
|
|
return InviteOutcome{Reason: "invitations are not configured on this server"}, nil
|
|
}
|
|
|
|
sent, reason := s.invites.Invite(target.Userid, tenantID, target.Email, target.Tenantname)
|
|
return InviteOutcome{Sent: sent, Reason: reason}, nil
|
|
}
|