package controllers import ( "fmt" "log" "nearle/middleware" "nearle/models" "nearle/services" "net/http" "strconv" "strings" "github.com/gofiber/fiber/v2" ) type TenantController struct { tenantService services.TenantService } func NewTenantController(tenantService services.TenantService) *TenantController { return &TenantController{tenantService: tenantService} } func (ctl *TenantController) SearchTenant(c *fiber.Ctx) error { status := c.Query("status") searchStr := c.Query("keyword") data, err := ctl.tenantService.SearchTenant(status, searchStr) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "code": http.StatusInternalServerError, "message": fmt.Sprintf("Error searching tenants: %v", err), "status": false, }) } return c.JSON(fiber.Map{ "code": http.StatusOK, "message": "Success", "status": true, "details": data, }) } func (ctl *TenantController) GetAllTenants(c *fiber.Ctx) error { pageno, _ := strconv.Atoi(c.Query("pageno")) pagesize, _ := strconv.Atoi(c.Query("pagesize")) // Paging is defaulted, not required. // // The repository builds LIMIT/OFFSET from these directly, so a caller that // omitted either — or sent pageno=0 — got an empty result reported as // `code 200, status true, message "Success"`. "There are no tenants on the // platform" and "you forgot a query parameter" are very different answers // and this endpoint gave the first for the second. // // Defaulted rather than rejected with a 400: every existing caller that // works today keeps working, and a platform list with no paging asked for // has an obvious right answer — the first page. if pageno < 1 { pageno = 1 } if pagesize < 1 { pagesize = 50 } status := c.Query("status") aid, _ := strconv.Atoi(c.Query("applocationid")) tenanttype := c.Query("tenanttype") keyword := c.Query("keyword") details, err := ctl.tenantService.GetAllTenants(pageno, pagesize, aid, status, tenanttype, keyword) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "code": http.StatusInternalServerError, "message": fmt.Sprintf("Error getting all tenants: %v", err), "status": false, }) } return c.JSON(fiber.Map{ "code": http.StatusOK, "message": "Success", "status": true, "details": details, }) } func (ctl *TenantController) GetTenantLocations(c *fiber.Ctx) error { tidStr := c.Query("tenantid") if tidStr == "" { tidStr = c.Query("tenantId") } if tidStr == "" { tidStr = c.Query("tenantID") } if tidStr == "" { tidStr = c.Query("tenant_id") } tid, _ := strconv.Atoi(strings.TrimSpace(tidStr)) data, err := ctl.tenantService.GetTenantLocations(tid) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "code": http.StatusInternalServerError, "message": fmt.Sprintf("Error getting tenant locations: %v", err), "status": false, }) } return c.JSON(fiber.Map{ "code": http.StatusOK, "message": "Success", "status": true, "details": data, }) } func (ctl *TenantController) GetTenantSlot(c *fiber.Ctx) error { data, err := ctl.tenantService.GetTenantSlot() if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "code": http.StatusInternalServerError, "message": fmt.Sprintf("Error getting tenant slots: %v", err), "status": false, }) } return c.JSON(fiber.Map{ "code": http.StatusOK, "message": "Success", "status": true, "details": data, }) } func (ctl *TenantController) CreateTenantCustomer(c *fiber.Ctx) error { var req models.CreateTenantCustomerRequest if err := c.BodyParser(&req); err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": 400, "status": false, "message": "Invalid request body", "data": fiber.Map{}, }) } tenantCustomer, err := ctl.tenantService.CreateTenantCustomer(req) if err != nil { if strings.Contains(err.Error(), "already exists for this location") { return c.JSON(fiber.Map{ "code": 409, "status": false, "message": "Customer already assigned to this location", "data": fiber.Map{}, }) } log.Println("Error inserting tenant customer:", err) return c.JSON(fiber.Map{ "code": 500, "status": false, "message": "Failed to create tenant customer", "data": fiber.Map{}, }) } return c.Status(http.StatusOK).JSON(fiber.Map{ "code": 200, "status": true, "message": "Tenant customer created successfully", "data": tenantCustomer, }) } func (ctl *TenantController) GetCustomerTenants(c *fiber.Ctx) error { customerID, err := strconv.Atoi(c.Query("customerid")) if err != nil || customerID == 0 { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": 400, "message": "Invalid customerid", "status": false, "details": []interface{}{}, }) } categoryID, _ := strconv.Atoi(c.Query("categoryid")) tenantFlag, _ := strconv.Atoi(c.Query("tenant")) // 0 = all tenants, 1 = tenants with orders data, err := ctl.tenantService.GetCustomerTenants(customerID, categoryID, tenantFlag) if err != nil { return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ "code": 500, "message": err.Error(), "status": false, "details": []interface{}{}, }) } return c.Status(http.StatusOK).JSON(fiber.Map{ "code": http.StatusOK, "message": "Success", "status": true, "details": data.Details, }) } func (ctl *TenantController) GetTenantPricing(c *fiber.Ctx) error { tid, _ := strconv.Atoi(c.Query("tenantid")) aid, _ := strconv.Atoi(c.Query("applocationid")) data, err := ctl.tenantService.GetTenantPricing(tid, aid) if err != nil { return c.JSON(fiber.Map{ "code": http.StatusInternalServerError, "message": err.Error(), "status": false, }) } return c.JSON(fiber.Map{ "code": http.StatusOK, "message": "Success", "status": true, "details": data, }) } func (ctl *TenantController) UpdateLocation(c *fiber.Ctx) error { var data models.Tenantlocations if err := c.BodyParser(&data); err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "status": false, "code": http.StatusBadRequest, "message": "Invalid request body", }) } if err := ctl.tenantService.UpdateLocation(data); err != nil { return c.JSON(fiber.Map{ "status": false, "code": http.StatusConflict, "message": err.Error(), }) } return c.JSON(fiber.Map{ "status": true, "code": http.StatusAccepted, "message": "Location update successful", }) } func (ctl *TenantController) CreateLocation(c *fiber.Ctx) error { var data models.Tenantlocations if err := c.BodyParser(&data); err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "message": "Invalid request body", "status": false, }) } err := ctl.tenantService.CreateLocation(data) if err != nil { return c.Status(http.StatusConflict).JSON(fiber.Map{ "code": http.StatusConflict, "message": err.Error(), "status": false, }) } return c.Status(http.StatusCreated).JSON(fiber.Map{ "code": http.StatusCreated, "message": "Location Successfully Created", "status": true, }) } func (ctl *TenantController) DeleteLocation(c *fiber.Ctx) error { locationid, err := strconv.Atoi(c.Query("locationid")) if err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "message": "Invalid location ID", "status": false, }) } tenantid, err := strconv.Atoi(c.Query("tenantid")) if err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "message": "Invalid tenant ID", "status": false, }) } if err := ctl.tenantService.DeleteLocation(locationid, tenantid); err != nil { return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ "code": http.StatusInternalServerError, "message": err.Error(), "status": false, }) } return c.JSON(fiber.Map{ "code": http.StatusOK, "message": "Location Successfully Deleted", "status": true, }) } func (ctl *TenantController) GetStaffs(c *fiber.Ctx) error { tid, _ := strconv.Atoi(c.Query("tenantid")) data, err := ctl.tenantService.GetStaffs(tid) if err != nil { return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ "code": http.StatusInternalServerError, "message": err.Error(), "status": false, }) } return c.JSON(fiber.Map{ "code": http.StatusOK, "message": "Success", "status": true, "details": data, }) } func (ctl *TenantController) CreateStaff(c *fiber.Ctx) error { var data models.User if err := c.BodyParser(&data); err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "message": "Invalid request body", "status": false, }) } invite, err := ctl.tenantService.CreateStaff(data) if err != nil { // A rejected PIN, a missing name, a role nobody set — these are things // the person filling in the form can fix, so they come back as 400 with // the reason. This answered 500 with a body claiming 409, which told a // console nothing it could act on and told the operator less. return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "message": err.Error(), "status": false, }) } // The person was hired either way. Whether they were emailed their // first-password link is reported beside that rather than folded into // `status`: this account is created with no password and the link is the only // way in, so an operator who is not told cannot know they have added somebody // who cannot sign in. return c.JSON(fiber.Map{ "code": http.StatusCreated, "message": "Staff created successfully", "status": true, "invited": invite.Sent, "invitereason": invite.Reason, }) } func (ctl *TenantController) UpdateStaff(c *fiber.Ctx) error { var data models.User if err := c.BodyParser(&data); err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "status": false, "code": http.StatusBadRequest, "message": "Invalid request body", }) } if err := ctl.tenantService.UpdateStaff(data); err != nil { return c.Status(http.StatusConflict).JSON(fiber.Map{ "status": false, "code": http.StatusConflict, "message": err.Error(), }) } return c.JSON(fiber.Map{ "status": true, "code": http.StatusAccepted, "message": "Staff updated successfully", }) } func (ctl *TenantController) CreateTenantLocation(c *fiber.Ctx) error { var data models.Tenantlocations if err := c.BodyParser(&data); err != nil { return c.Status(fiber.StatusOK).JSON(fiber.Map{ "code": 400, "message": "Invalid request body", "status": false, }) } resp := ctl.tenantService.CreateTenantLocation(data) return c.Status(fiber.StatusOK).JSON(resp) } func (ctl *TenantController) UpdateTenantLocation(c *fiber.Ctx) error { var data models.Tenantlocations // Parse JSON body if err := c.BodyParser(&data); err != nil { return c.Status(fiber.StatusOK).JSON(fiber.Map{ "status": false, "code": 400, "message": "Invalid request body", }) } // Call service layer resp := ctl.tenantService.UpdateTenantLocation(data) // Always return HTTP 200 (as per your API pattern) return c.Status(fiber.StatusOK).JSON(resp) } func (ctl *TenantController) CreateTenantUser(c *fiber.Ctx) error { var data models.Tenants if err := c.BodyParser(&data); err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": 400, "status": false, "message": "Invalid request body", }) } result, invite, err := ctl.tenantService.CreateTenantUser(data) if err != nil { if err.Error() == "Tenant Already Exists" { return c.Status(http.StatusConflict).JSON(fiber.Map{ "code": 409, "status": false, "message": err.Error(), }) } return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ "code": 500, "status": false, "message": err.Error(), }) } // The tenant was created either way. The invitation is reported beside it // rather than folded into `status`, because a merchant who exists and has // not been emailed is a task for the operator — resend, or correct the // address — and not a failed onboarding to be retried. // // `invited: false` with a reason is the state the platform console shows on // the tenant, so it never has to guess whether the email went. return c.Status(http.StatusCreated).JSON(fiber.Map{ "code": 201, "status": true, "message": "Successfully Created", "details": result, "invited": invite.Sent, // Omitted when it sent, so a successful onboarding carries no apology. "invitereason": invite.Reason, }) } func (ctl *TenantController) GetTenantInfo(c *fiber.Ctx) error { log.Printf("[DEBUG] GetTenantInfo OriginalURL: %s, Headers: %v", c.OriginalURL(), c.GetReqHeaders()) // Parse tenant ID tidStr := c.Query("tenantid") if tidStr == "" { tidStr = c.Query("tenantId") } if tidStr == "" { tidStr = c.Query("tenantID") } if tidStr == "" { tidStr = c.Query("tenant_id") } if tidStr == "" { tidStr = c.Get("tenantid") } if tidStr == "" { tidStr = c.Get("tenantId") } if tidStr == "" { tidStr = c.Get("tenantID") } if tidStr == "" { tidStr = c.Get("tenant_id") } tid, _ := strconv.Atoi(strings.TrimSpace(tidStr)) // Parse location ID lidStr := c.Query("locationid") if lidStr == "" { lidStr = c.Query("locationId") } if lidStr == "" { lidStr = c.Query("locationID") } if lidStr == "" { lidStr = c.Query("location_id") } if lidStr == "" { lidStr = c.Get("locationid") } if lidStr == "" { lidStr = c.Get("locationId") } if lidStr == "" { lidStr = c.Get("locationID") } if lidStr == "" { lidStr = c.Get("location_id") } locationid, _ := strconv.Atoi(strings.TrimSpace(lidStr)) // Parse user ID uidStr := c.Query("userid") if uidStr == "" { uidStr = c.Query("userId") } if uidStr == "" { uidStr = c.Query("userID") } if uidStr == "" { uidStr = c.Query("user_id") } if uidStr == "" { uidStr = c.Query("appuserid") } if uidStr == "" { uidStr = c.Query("appuserId") } if uidStr == "" { uidStr = c.Query("appuserID") } if uidStr == "" { uidStr = c.Query("appuser_id") } if uidStr == "" { uidStr = c.Get("userid") } if uidStr == "" { uidStr = c.Get("userId") } if uidStr == "" { uidStr = c.Get("userID") } if uidStr == "" { uidStr = c.Get("user_id") } if uidStr == "" { uidStr = c.Get("appuserid") } if uidStr == "" { uidStr = c.Get("appuserId") } if uidStr == "" { uidStr = c.Get("appuserID") } if uidStr == "" { uidStr = c.Get("appuser_id") } userid, _ := strconv.Atoi(strings.TrimSpace(uidStr)) data, err := ctl.tenantService.GetTenantByID(tid, locationid, userid) if err != nil { return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ "code": http.StatusInternalServerError, "message": "Error fetching tenant info", "status": false, }) } return c.JSON(fiber.Map{ "code": http.StatusOK, "message": "Success", "status": true, "details": data, }) } func (ctl *TenantController) GetTenantByKeyword(c *fiber.Ctx) error { keyword := c.Query("keyword") data, err := ctl.tenantService.GetTenantByKeyword(keyword) if err != nil { return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ "code": 500, "message": "Error searching tenants by keyword", "status": false, }) } return c.JSON(fiber.Map{ "code": http.StatusOK, "message": "Success", "status": true, "details": data, }) } // AssignStaff moves one of a merchant's people to a branch, or takes them off. // // `locationid` 0 unassigns, and is a real instruction rather than a missing // value — somebody can leave a shop before the next one opens, and the console // needs a way to say that which is not "delete the account". // // The tenant comes from the request and every check is scoped to it in the // query, so a userid belonging to another business matches nothing and the call // fails rather than moving a stranger's staff. func (ctl *TenantController) AssignStaff(c *fiber.Ctx) error { var input struct { Tenantid int `json:"tenantid"` Userid int `json:"userid"` Locationid int `json:"locationid"` Unassign bool `json:"unassign"` } if err := c.BodyParser(&input); err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "status": false, "message": "Invalid input", }) } if input.Tenantid <= 0 || input.Userid <= 0 { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "status": false, "message": "tenantid and userid are required", }) } // The rule lives in services.ResolveAssignment so it can be tested without // a request: a zero locationid must never be read as "unassign", because a // dropped field looks exactly like one. location, err := services.ResolveAssignment(input.Locationid, input.Unassign) if err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "status": false, "message": err.Error(), }) } if err := ctl.tenantService.AssignStaffToBranch(input.Tenantid, input.Userid, location); err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "status": false, "message": err.Error(), }) } return c.JSON(fiber.Map{"code": 200, "status": true, "message": "Success"}) } // UpdateTenantProfile lets a merchant change their own business record. // // The body is read as a free-form map rather than into `models.Tenants`, // deliberately. Binding to the struct would make every column on the table a // candidate for writing and leave "which of these may a merchant set?" answered // by whichever fields a form happened to send. The allowlist in // services.TenantProfileUpdate answers it in one place instead, and everything // absent from a request is left alone rather than blanked. func (ctl *TenantController) UpdateTenantProfile(c *fiber.Ctx) error { fields := map[string]any{} if err := c.BodyParser(&fields); err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "status": false, "message": "Invalid input", }) } // The row to write is named by `tenantid`, and it is the one value in the // body that is never a value to write. tenantID := 0 switch id := fields["tenantid"].(type) { case float64: tenantID = int(id) case int: tenantID = id } if tenantID <= 0 { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "status": false, "message": "tenantid is required", }) } if err := ctl.tenantService.UpdateTenantProfile(tenantID, fields); err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "status": false, "message": err.Error(), }) } return c.JSON(fiber.Map{"code": 200, "status": true, "message": "Success"}) } // UpdateOwnProfile lets somebody change their own name, mobile or email. // // Read as a map rather than into `models.User` for the same reason as the shop // profile: `app_users` keeps identity next to authorisation, so binding to the // struct would make `roleid`, `locationid`, `status`, `password` and `pin` // candidates for writing. The allowlist in services.OwnProfileUpdate answers // "what may a person change about themselves?" in one place. // // Scoped by userid AND tenantid — the existing `users/update` checks only the // userid, which is why the store user's account page has been read-only rather // than editable. func (ctl *TenantController) UpdateOwnProfile(c *fiber.Ctx) error { fields := map[string]any{} if err := c.BodyParser(&fields); err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "status": false, "message": "Invalid input", }) } readID := func(key string) int { switch id := fields[key].(type) { case float64: return int(id) case int: return id } return 0 } userID, tenantID := readID("userid"), readID("tenantid") if userID <= 0 || tenantID <= 0 { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "status": false, "message": "userid and tenantid are both required", }) } if err := ctl.tenantService.UpdateOwnProfile(userID, tenantID, fields); err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "status": false, "message": err.Error(), }) } return c.JSON(fiber.Map{"code": 200, "status": true, "message": "Success"}) } // AssignPartner puts a merchant under a delivery partner, or takes them out of // one. // // `partnerid` 0 is a real instruction here — it means "this merchant uses their // own riders" — so it is read as sent rather than treated as absent. Everywhere // else in the tenant API a zero means "not supplied"; this is the exception and // it is the reason the route exists separately. func (ctl *TenantController) AssignPartner(c *fiber.Ctx) error { var body struct { Tenantid int `json:"tenantid"` Partnerid *int `json:"partnerid"` } if err := c.BodyParser(&body); err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "status": false, "message": "Invalid request body", }) } if tid, _ := strconv.Atoi(c.Query("tenantid")); tid != 0 { body.Tenantid = tid } if body.Tenantid <= 0 { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "status": false, "message": "tenantid is required", }) } // A pointer, so "no partner" and "field omitted" are different requests. // Sent as a plain int, an omitted field would read as 0 and quietly unassign // a merchant's partner. if body.Partnerid == nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "status": false, "message": "partnerid is required — send 0 to take the partner away", }) } if err := ctl.tenantService.AssignPartner(body.Tenantid, *body.Partnerid); err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "status": false, "message": err.Error(), }) } return c.JSON(fiber.Map{ "code": http.StatusOK, "status": true, "message": "Successfully Updated", }) } // ResendInvite re-issues a merchant's first-password link. // // ── Why this is platform staff only ───────────────────────────────────────── // // It mints a credential. `middleware.WebAuth` already pins a merchant's session // to their own tenant, so a shop could at most re-invite itself — but the // account it would be inviting is the one signing in to ask, which can only // happen if that account already has a password, and the service refuses that // case outright. // // So the only caller this is for is Nearle's own staff, chasing a merchant who // never received the mail. Saying so explicitly is better than relying on two // other checks to make the wrong case impossible. func (ctl *TenantController) ResendInvite(c *fiber.Ctx) error { claims, ok := middleware.WebClaimsFrom(c) if !ok || !claims.IsPlatformAccount() { return c.Status(http.StatusForbidden).JSON(fiber.Map{ "code": http.StatusForbidden, "status": false, "message": "Only Nearle staff can resend an invitation.", }) } // Either a tenant — meaning its owner, the one account onboarding created — // or one named person. Staff added later and the login every branch spawns // are created with no password too, and a business has many of them, so // "the tenant's invitation" cannot reach them. var req struct { Tenantid int `json:"tenantid"` Userid int `json:"userid"` } if err := c.BodyParser(&req); err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "status": false, "message": "Invalid request body", }) } if req.Tenantid <= 0 && req.Userid <= 0 { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "status": false, "message": "Send a tenantid to re-invite the owner, or a userid to re-invite one person.", }) } // `userid` wins when both arrive. It is the more specific of the two, and a // caller that sent a person's id meant that person — silently emailing the // owner instead would be the wrong mailbox with no sign anything was off. var ( outcome services.InviteOutcome err error ) if req.Userid > 0 { outcome, err = ctl.tenantService.ResendInviteToUser(req.Userid) } else { outcome, err = ctl.tenantService.ResendInvite(req.Tenantid) } if err != nil { // 409, not 500. Every failure here is a business fact the operator can // act on — no such tenant, an address that matches no login, a merchant // already set up — rather than a fault in the server. return c.Status(http.StatusConflict).JSON(fiber.Map{ "code": http.StatusConflict, "status": false, "message": err.Error(), }) } if !outcome.Sent { // The tenant is fine and the mail did not go. Reported as a failure // because the operator pressed a button expecting an email to leave, // and the reason names what to fix. return c.Status(http.StatusConflict).JSON(fiber.Map{ "code": http.StatusConflict, "status": false, "message": outcome.Reason, }) } return c.JSON(fiber.Map{ "code": http.StatusOK, "status": true, "message": "Invitation sent.", }) }