Files
backend_fiesta/services/tenantService.go
2026-10-09 16:27:51 +05:30

545 lines
22 KiB
Go

package services
import (
"time"
"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)
SetStoreOpen(tenantID, locationID int, isOpen bool, closedUntil string) error
StoreClosedReason(tenantID, locationID int) string
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
}
/*
Whether each branch is actually taking orders.
Worked out here rather than in SQL so there is ONE implementation of the
rule — order creation calls the same function before accepting an order,
and a shop the app showed as open while the server refused it would be a
shopper staring at an error nothing on screen explains.
`now` is read once for the whole list: a request that straddled midnight
could otherwise return two branches on different days.
*/
now := time.Now()
for i := range details {
details[i].Isaccepting = models.StoreIsOpen(
details[i].Locationstatus, details[i].Isopen, details[i].Closeduntil, now,
)
details[i].Closedreason = models.StoreClosedReason(
details[i].Locationstatus, details[i].Isopen, details[i].Closeduntil, now,
)
}
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
}
/*
Open or close a branch for the day.
Refuses a reopen date in the past outright. "Closed until yesterday" is a shop
that is open, so storing it would mean the console showing closed while the
app showed open — the two disagreeing about the same branch is worse than the
edit failing.
*/
func (s *tenantService) SetStoreOpen(tenantID, locationID int, isOpen bool, closedUntil string) error {
if tenantID <= 0 || locationID <= 0 {
return fmt.Errorf("tenantid and locationid are both required")
}
trimmed := strings.TrimSpace(closedUntil)
if !isOpen && trimmed != "" {
reopen, err := time.Parse("2006-01-02", trimmed)
if err != nil {
return fmt.Errorf("the reopening date must be written as YYYY-MM-DD")
}
today := time.Now().Truncate(24 * time.Hour)
if reopen.Truncate(24 * time.Hour).Before(today) {
return fmt.Errorf("that reopening date has already passed")
}
}
return s.repo.SetStoreOpen(tenantID, locationID, isOpen, trimmed)
}
/*
Why this branch cannot take an order, in words fit to show a shopper.
Empty when it can. Called by order creation, and it leans on the same
models.StoreIsOpen the customer-facing list uses — so a shop the app showed as
open is never refused here, and one it showed as closed is never quietly
accepted.
Any failure reading the branch returns "" — open. A database hiccup must not
stop a shop trading.
*/
func (s *tenantService) StoreClosedReason(tenantID, locationID int) string {
if tenantID <= 0 || locationID <= 0 {
return ""
}
status, isOpen, closedUntil, err := s.repo.StoreOpenState(tenantID, locationID)
if err != nil {
return ""
}
return models.StoreClosedReason(status, isOpen, closedUntil, time.Now())
}