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 }