Merge remote-tracking branch 'origin/main'

This commit is contained in:
Suriya
2026-08-03 17:48:21 +05:30
9 changed files with 326 additions and 98 deletions

View File

@@ -390,10 +390,13 @@ func (ctl *OrderController) UploadOfflineSales(c *fiber.Ctx) error {
})
}
if input.Tenantid <= 0 || input.Locationid <= 0 {
// locationid is optional: 0 means the bills carry their own branch, which
// is how one workbook covers every outlet a merchant runs. Supplying it
// pins the upload to that branch and rejects anything else in the file.
if input.Tenantid <= 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "tenantid and locationid are required",
"message": "tenantid is required",
"status": false,
})
}

View File

@@ -310,19 +310,21 @@ func (ctl *ProductController) GetLocationProducts(c *fiber.Ctx) error {
}
// GetSaleTemplate serves the data the web app turns into the offline-sales
// spreadsheet. Both tenantid and locationid are required rather than defaulted:
// a template generated against the wrong outlet would carry productids the
// import then rejects, which is a confusing failure a missing parameter should
// not be able to cause.
// spreadsheet.
//
// locationid is optional and defaults to 0, meaning every branch the tenant
// runs — one workbook for the whole business, with each row carrying the branch
// its stock belongs to. A store user passes their own locationid to get just
// theirs. tenantid is required: without it there is no scope at all.
func (ctl *ProductController) GetSaleTemplate(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
locationID, _ := strconv.Atoi(c.Query("locationid"))
locationID, _ := strconv.Atoi(c.Query("locationid", "0"))
if tenantID <= 0 || locationID <= 0 {
if tenantID <= 0 {
return c.JSON(fiber.Map{
"status": false,
"code": http.StatusBadRequest,
"message": "tenantid and locationid are required",
"message": "tenantid is required",
})
}

View File

@@ -460,9 +460,18 @@ type OfflineSaleItem struct {
}
// OfflineSaleBill is one counter bill — the rows of a spreadsheet grouped by
// their billno. Billno is what makes a re-upload of the same file safe: it is
// recorded on the order and refused if it is already present for this outlet.
// their branch and bill number.
//
// Locationid is the branch the bill was rung up at, taken from the spreadsheet
// row rather than from a store the operator picked in the UI. One workbook can
// therefore carry sales for every branch a merchant runs, and each bill's stock
// comes out of its own outlet. Bills are grouped per branch, so the same bill
// number at two outlets is two separate sales, not a duplicate.
//
// Billno is what makes a re-upload of the same file safe: it is recorded on the
// order and refused if already present for that branch.
type OfflineSaleBill struct {
Locationid int `json:"locationid"`
Billno string `json:"billno"`
Saledate string `json:"saledate"`
Paymentmode string `json:"paymentmode"`
@@ -472,9 +481,16 @@ type OfflineSaleBill struct {
Items []OfflineSaleItem `json:"items"`
}
// OfflineSalesUpload is the request body. Locationid is the outlet the sales
// belong to and is authorised server-side against Tenantid — a store user
// editing the spreadsheet cannot post sales into another branch.
// OfflineSalesUpload is the request body.
//
// Locationid here is a scope constraint, not the destination. Left at 0 the
// bills go to whichever branch each one names, which is what a multi-branch
// owner uploads. Set to a branch it pins the whole upload to that outlet and
// any bill naming a different one is refused — that is how a store user is
// held to their own store no matter what the spreadsheet says.
//
// Every branch referenced is checked against Tenantid regardless, so no upload
// can reach an outlet the merchant does not own.
type OfflineSalesUpload struct {
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
@@ -492,7 +508,11 @@ const (
// OfflineSaleResult reports one bill's fate. Bills are independent, so a file
// with one bad bill still imports the rest and names exactly what it skipped.
// The branch is echoed back because a single upload spans several, and "bill 7
// failed" is not actionable without knowing which store it belonged to.
type OfflineSaleResult struct {
Locationid int `json:"locationid"`
Locationname string `json:"locationname"`
Billno string `json:"billno"`
Status string `json:"status"`
Orderid string `json:"orderid"`

View File

@@ -327,19 +327,27 @@ type ProductLocationRef struct {
Productid int
}
// SaleTemplateRow is one line of the downloadable offline-sales spreadsheet:
// a product actually stocked at one outlet, with the numbers the person at the
// till needs to see before they type a sold quantity against it.
// SaleTemplateRow is one line of the downloadable offline-sales spreadsheet: a
// product stocked at one branch, with the numbers the person at the till needs
// to see before typing a sold quantity against it.
//
// Tenantid and Locationid ride on every row because one workbook covers every
// branch a merchant runs. The row's own Locationid decides which branch's stock
// its sale comes out of — a tenant-level import would be wrong, since the same
// product is held separately at each outlet.
//
// Productid is the only field that identifies the product. It cannot be
// productsku: across the live catalogue 6,245 products share just 93 distinct
// sku values (one tenant has 463 products all carrying sku "1"), and 154 are
// blank, so a sku is not a key. Productname is nearly unique per tenant but
// not reliably ("rice" appears 6 times for one tenant), so it travels as a
// blank, so a sku is not a key. Productname is nearly unique per tenant but not
// reliably ("rice" appears 6 times for one tenant), so it travels as a
// human-readable confirmation only and is never matched on. That is why the
// spreadsheet has to be generated from this endpoint rather than typed from
// scratch — the productid column is filled in for the user.
// scratch — productid and locationid are filled in for the user.
type SaleTemplateRow struct {
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
Locationname string `json:"locationname"`
Productid int `json:"productid"`
Productname string `json:"productname"`
Productunit string `json:"productunit"`
@@ -350,14 +358,23 @@ type SaleTemplateRow struct {
Taxpercent float64 `json:"taxpercent"`
}
// SaleTemplate is the payload the web app turns into an .xlsx workbook. The
// tenant/location identity travels with it so the generated file records which
// outlet it belongs to, and the upload can be checked against the file it came
// from instead of trusting a hand-typed location.
// SaleTemplateLocation is one branch covered by the workbook, so the sheet can
// list what it spans and the UI can summarise it without walking every row.
type SaleTemplateLocation struct {
Locationid int `json:"locationid"`
Locationname string `json:"locationname"`
Productcount int `json:"productcount"`
}
// SaleTemplate is the payload the web app turns into an .xlsx workbook.
//
// Locationid is 0 when the template spans every branch of the tenant, which is
// the normal case for an owner or admin. A store user gets a template for their
// own branch only, and it is then the single entry in Locations.
type SaleTemplate struct {
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
Locationname string `json:"locationname"`
Locations []SaleTemplateLocation `json:"locations"`
Products []SaleTemplateRow `json:"products"`
}

View File

@@ -188,8 +188,19 @@ func (r *customerRepository) GetTenantCustomers(tid, lid, pageno, pagesize int,
var args []interface{}
searchLike := "%" + keyword + "%"
// DISTINCT ON collapses to one row per customer BEFORE the LIMIT is applied.
// Without it the store-scoped branch below paginated the joined
// customerlocations rows — one per saved address — so `pagesize` bought a
// page of addresses, not of customers. Live example: locationid 1185 returned
// 12 rows that were only 2 people, 11 of them one customer's addresses. A
// store with a page size of 20 therefore listed roughly three customers and
// gave no hint that the rest existed.
//
// The ORDER BY must lead with the DISTINCT ON expression, so customerid sorts
// first; the trailing keys only decide WHICH address represents a customer,
// preferring the one flagged primary.
if lid != 0 {
q1 = `SELECT a.customerid,a.firstname,a.lastname,a.contactno,a.email,
q1 = `SELECT DISTINCT ON (a.customerid) a.customerid,a.firstname,a.lastname,a.contactno,a.email,
b.locationid as deliverylocationid,b.address,b.suburb,b.city,b.state,b.landmark,b.doorno,b.postcode,
b.latitude,b.longitude,a.applocationid,c.locationid as tenantlocationid,a.status
FROM customers a
@@ -204,11 +215,15 @@ func (r *customerRepository) GetTenantCustomers(tid, lid, pageno, pagesize int,
args = append(args, searchLike, searchLike, searchLike)
}
q1 += ` ORDER BY a.customerid DESC LIMIT ? OFFSET ?`
q1 += ` ORDER BY a.customerid DESC, b.primaryaddress DESC NULLS LAST, b.locationid ASC
LIMIT ? OFFSET ?`
args = append(args, pagesize, offset)
} else {
q1 = `SELECT a.customerid,a.firstname,a.lastname,a.contactno,a.email,
// A customer linked to several outlets of the same tenant has one
// tenantcustomers row per outlet, so this branch double-counted them
// against the LIMIT too.
q1 = `SELECT DISTINCT ON (a.customerid) a.customerid,a.firstname,a.lastname,a.contactno,a.email,
a.address,a.suburb,a.city,a.state,a.landmark,a.doorno,a.postcode,
a.latitude,a.longitude,a.applocationid,c.locationid as tenantlocationid,a.status
FROM customers a
@@ -223,12 +238,10 @@ func (r *customerRepository) GetTenantCustomers(tid, lid, pageno, pagesize int,
args = append(args, searchLike, searchLike, searchLike)
}
q1 += ` ORDER BY a.customerid DESC LIMIT ? OFFSET ?`
q1 += ` ORDER BY a.customerid DESC, c.locationid ASC LIMIT ? OFFSET ?`
args = append(args, pagesize, offset)
}
print(q1)
r.db.Raw(q1, args...).Find(&data)
return data
}

View File

@@ -1,11 +1,13 @@
package repositories
import (
"errors"
"fmt"
"log"
"nearle/models"
"strconv"
"strings"
"time"
"github.com/jinzhu/copier"
"gorm.io/gorm"
@@ -147,18 +149,75 @@ func (r *deliveriesRepository) UpdateDelivery(data models.UpdateDeliveryStatus)
var ord models.Updateorderstatus
var cloc models.Customerlocations
if data.Deliveryid == 0 {
return errors.New("deliveryid is required")
}
tx := r.db.Begin()
if tx.Error != nil {
return tx.Error
}
if err := tx.Table("deliveries").Where("deliveryid = ?", data.Deliveryid).Updates(&data).Error; err != nil {
tx.Rollback()
return err
}
// The parent order is resolved from the delivery row rather than taken from
// the request. Every status branch below writes the order with
// "WHERE orderheaderid = ?", and a client that omits orderheaderid made that
// "WHERE orderheaderid = 0", matching nothing. GORM reports no error for an
// update that affects no rows, so the handler still answered 201 Success
// while the order silently kept its old status — 635 deliveries are marked
// delivered against an order still reading pending because of this.
//
// deliveryid is the one field every caller must send (it is how the row
// above is found), so deriving the link from it makes the sync independent
// of how complete the client's payload is.
orderHeaderID := data.Orderheaderid
if orderHeaderID == 0 {
if err := tx.Table("deliveries").
Select("orderheaderid").
Where("deliveryid = ?", data.Deliveryid).
Scan(&orderHeaderID).Error; err != nil {
tx.Rollback()
return err
}
}
if orderHeaderID == 0 {
tx.Rollback()
return fmt.Errorf("delivery %d has no order attached", data.Deliveryid)
}
// syncOrder applies the status to the parent order and fails loudly if the
// row is not there, instead of reporting success for a write that landed
// nowhere.
syncOrder := func() error {
res := tx.Table("orders").Where("orderheaderid = ?", orderHeaderID).Updates(&ord)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return fmt.Errorf("order %d not found for delivery %d", orderHeaderID, data.Deliveryid)
}
return nil
}
// The lifecycle timestamp mirrored onto the order. Clients frequently send
// the status without one, and because Updates() skips zero-valued struct
// fields the order's own column was left blank while its status moved on.
stamp := func(supplied string) string {
if strings.TrimSpace(supplied) != "" {
return supplied
}
return time.Now().Format("2006-01-02 15:04:05")
}
switch data.Orderstatus {
case "pending":
ord.Orderstatus = data.Orderstatus
ord.Pending = data.Assigntime
if err := tx.Table("orders").Where("orderheaderid = ?", data.Orderheaderid).Updates(&ord).Error; err != nil {
ord.Pending = stamp(data.Assigntime)
if err := syncOrder(); err != nil {
tx.Rollback()
return err
}
@@ -181,8 +240,8 @@ func (r *deliveriesRepository) UpdateDelivery(data models.UpdateDeliveryStatus)
case "delivered":
ord.Orderstatus = data.Orderstatus
ord.Delivered = data.Deliverytime
if err := tx.Table("orders").Where("orderheaderid = ?", data.Orderheaderid).Updates(&ord).Error; err != nil {
ord.Delivered = stamp(data.Deliverytime)
if err := syncOrder(); err != nil {
tx.Rollback()
return err
}
@@ -204,8 +263,8 @@ func (r *deliveriesRepository) UpdateDelivery(data models.UpdateDeliveryStatus)
case "cancelled":
ord.Orderstatus = data.Orderstatus
ord.Cancelled = data.Canceltime
if err := tx.Table("orders").Where("orderheaderid = ?", data.Orderheaderid).Updates(&ord).Error; err != nil {
ord.Cancelled = stamp(data.Canceltime)
if err := syncOrder(); err != nil {
tx.Rollback()
return err
}

View File

@@ -1785,29 +1785,52 @@ func (r *orderRepository) resolveOfflineCustomer(tx *gorm.DB, ctx *offlineLocati
// instead of a race: two uploads of the same file arriving together would
// otherwise both read "not yet imported" and both commit.
func (r *orderRepository) UploadOfflineSales(input models.OfflineSalesUpload) (*models.OfflineSalesUploadResponse, error) {
if input.Tenantid <= 0 {
return nil, errors.New("tenantid is required")
}
if len(input.Bills) == 0 {
return nil, errors.New("no sales rows found in the upload")
}
ctx, err := r.resolveOfflineLocationContext(input.Tenantid, input.Locationid)
if err != nil {
// When the caller pins the upload to one branch, that branch is resolved
// (and authorised) up front so an outlet the merchant does not own fails
// the whole request rather than each bill in turn.
if input.Locationid > 0 {
if _, err := r.resolveOfflineLocationContext(input.Tenantid, input.Locationid); err != nil {
return nil, err
}
}
products, err := r.loadOfflineProducts(input.Tenantid, input.Locationid)
// Branch context and catalogue are resolved once per branch and reused. A
// workbook covering six outlets would otherwise re-run both queries for
// every bill in it.
contexts := make(map[int]*offlineLocationContext)
catalogues := make(map[int]map[int]offlineProduct)
resolve := func(locationID int) (*offlineLocationContext, map[int]offlineProduct, error) {
if ctx, ok := contexts[locationID]; ok {
return ctx, catalogues[locationID], nil
}
ctx, err := r.resolveOfflineLocationContext(input.Tenantid, locationID)
if err != nil {
return nil, err
return nil, nil, err
}
products, err := r.loadOfflineProducts(input.Tenantid, locationID)
if err != nil {
return nil, nil, err
}
if len(products) == 0 {
return nil, fmt.Errorf("outlet '%s' has no products stocked against it", ctx.Locationname)
return nil, nil, fmt.Errorf("outlet '%s' has no products stocked against it", ctx.Locationname)
}
contexts[locationID] = ctx
catalogues[locationID] = products
return ctx, products, nil
}
resp := &models.OfflineSalesUploadResponse{Results: make([]models.OfflineSaleResult, 0, len(input.Bills))}
for _, bill := range input.Bills {
result := r.importOfflineBill(ctx, products, input.Userid, bill)
record := func(result models.OfflineSaleResult) {
resp.Results = append(resp.Results, result)
switch result.Status {
case models.OfflineSaleImported:
resp.Imported++
@@ -1819,6 +1842,49 @@ func (r *orderRepository) UploadOfflineSales(input models.OfflineSalesUpload) (*
}
}
for _, bill := range input.Bills {
billLocation := bill.Locationid
if billLocation <= 0 {
billLocation = input.Locationid
}
if billLocation <= 0 {
record(models.OfflineSaleResult{
Billno: strings.TrimSpace(bill.Billno),
Status: models.OfflineSaleFailed,
Message: "no locationid on these rows — the sheet must say which branch the sale belongs to",
})
continue
}
// A pinned upload refuses bills for anywhere else. This is what keeps a
// store user inside their own branch: editing the locationid column in
// the spreadsheet changes nothing, because the pin is set from their
// session and not from the file.
if input.Locationid > 0 && billLocation != input.Locationid {
record(models.OfflineSaleResult{
Locationid: billLocation,
Billno: strings.TrimSpace(bill.Billno),
Status: models.OfflineSaleFailed,
Message: fmt.Sprintf("this upload is limited to outlet %d, but these rows are for outlet %d", input.Locationid, billLocation),
})
continue
}
ctx, products, err := resolve(billLocation)
if err != nil {
record(models.OfflineSaleResult{
Locationid: billLocation,
Billno: strings.TrimSpace(bill.Billno),
Status: models.OfflineSaleFailed,
Message: err.Error(),
})
continue
}
record(r.importOfflineBill(ctx, products, input.Userid, bill))
}
return resp, nil
}
@@ -1836,6 +1902,8 @@ func (r *orderRepository) importOfflineBill(
fail := func(format string, args ...any) models.OfflineSaleResult {
return models.OfflineSaleResult{
Locationid: ctx.Locationid,
Locationname: ctx.Locationname,
Billno: billLabel,
Status: models.OfflineSaleFailed,
Message: fmt.Sprintf(format, args...),
@@ -1968,9 +2036,11 @@ func (r *orderRepository) importOfflineBill(
if already > 0 {
tx.Rollback()
return models.OfflineSaleResult{
Locationid: ctx.Locationid,
Locationname: ctx.Locationname,
Billno: billLabel,
Status: models.OfflineSaleDuplicate,
Message: fmt.Sprintf("bill %s was already imported for this outlet; stock was not deducted again", billLabel),
Message: fmt.Sprintf("bill %s was already imported for %s; stock was not deducted again", billLabel, ctx.Locationname),
}
}
@@ -2021,13 +2091,15 @@ func (r *orderRepository) importOfflineBill(
}
return models.OfflineSaleResult{
Locationid: ctx.Locationid,
Locationname: ctx.Locationname,
Billno: billLabel,
Status: models.OfflineSaleImported,
Orderid: created.Orderid,
Orderheaderid: created.Orderheaderid,
Itemcount: len(items),
Amount: orderAmount,
Message: fmt.Sprintf("imported as order %s", created.Orderid),
Message: fmt.Sprintf("imported as order %s at %s", created.Orderid, ctx.Locationname),
}
}

View File

@@ -503,41 +503,60 @@ func (r *productRepository) GetLocationProducts(tenantID, locationID, subcategor
return data, nil
}
// GetSaleTemplate lists every product stocked at one outlet, with its live
// ledger balance, so the web app can generate a pre-filled offline-sales
// spreadsheet. It deliberately returns the whole catalogue for the outlet
// unpaged — a spreadsheet the user is meant to fill in and hand back is only
// useful if it contains every product they could have sold.
// GetSaleTemplate lists products stocked at a tenant's branches, with each
// one's live ledger balance, so the web app can generate a pre-filled
// offline-sales spreadsheet.
//
// locationID = 0 means "every branch this tenant runs", which is the normal
// case: a merchant with several outlets gets ONE workbook covering all of them,
// with tenantid and locationid stamped on every row. The row's own locationid
// is what later decides which branch a sale is deducted from, so the operator
// never has to pick a store or juggle a file per outlet. Passing a specific
// locationID narrows it to that branch, which is what a store user gets.
//
// It deliberately returns the whole catalogue unpaged — a spreadsheet meant to
// be filled in and handed back is only useful if it contains every product that
// could have been sold.
//
// The balance is the same SUM(in) - SUM(out) expression CreateOrder validates
// against, so the "currentstock" the user reads in the sheet is exactly the
// number the import will later check their quantity against. LOWER() covers
// the mixed-case stocktype values in production ('out', 'IN', 'in').
// against, so the "currentstock" read in the sheet is exactly the number the
// import will check the typed quantity against. LOWER() covers the mixed-case
// stocktype values in production ('out', 'IN', 'in').
//
// A location that does not belong to the tenant yields no template rather than
// another tenant's catalogue: the caller treats that as "not your outlet".
// The INNER JOIN on tenantlocations is load-bearing: it confines the result to
// branches the tenant actually owns, so a template can never disclose another
// merchant's catalogue even if a stray productlocations row pointed at one.
func (r *productRepository) GetSaleTemplate(tenantID, locationID int) (*models.SaleTemplate, error) {
if tenantID <= 0 || locationID <= 0 {
return nil, errors.New("tenantid and locationid are required")
if tenantID <= 0 {
return nil, errors.New("tenantid is required")
}
if locationID < 0 {
locationID = 0
}
var loc struct {
Locationname string
}
// Only checked when the caller narrowed to one branch. Without it a
// mistyped locationid would silently yield an empty template rather than
// saying the outlet is not theirs.
if locationID > 0 {
var locationName string
err := r.db.Raw(
`SELECT locationname FROM tenantlocations WHERE tenantid = ? AND locationid = ?`,
`SELECT COALESCE(locationname, '') FROM tenantlocations WHERE tenantid = ? AND locationid = ?`,
tenantID, locationID,
).Scan(&loc).Error
).Scan(&locationName).Error
if err != nil {
return nil, err
}
if strings.TrimSpace(loc.Locationname) == "" {
if strings.TrimSpace(locationName) == "" {
return nil, fmt.Errorf("location %d does not belong to tenant %d", locationID, tenantID)
}
}
rows := make([]models.SaleTemplateRow, 0)
query := `
SELECT a.productid,
SELECT a.tenantid,
b.locationid,
COALESCE(tl.locationname, '') AS locationname,
a.productid,
a.productname,
COALESCE(a.productunit, '') AS productunit,
COALESCE(a.unitvalue, '') AS unitvalue,
@@ -548,22 +567,40 @@ func (r *productRepository) GetSaleTemplate(tenantID, locationID int) (*models.S
COALESCE(a.taxpercent, 0) AS taxpercent
FROM products a
INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid
INNER JOIN tenantlocations tl ON tl.locationid = b.locationid AND tl.tenantid = a.tenantid
LEFT JOIN productstocks c
ON a.productid = c.productid AND b.locationid = c.locationid AND a.tenantid = c.tenantid
LEFT JOIN productcategories d ON a.categoryid = d.categoryid
WHERE a.approve = 1 AND a.tenantid = ? AND b.locationid = ?
GROUP BY a.productid, a.productname, a.productunit, a.unitvalue, d.categoryname,
b.price, a.retailprice, a.taxpercent
ORDER BY a.productname ASC`
WHERE a.approve = 1 AND a.tenantid = ? AND (? = 0 OR b.locationid = ?)
GROUP BY a.tenantid, b.locationid, tl.locationname, a.productid, a.productname,
a.productunit, a.unitvalue, d.categoryname, b.price, a.retailprice, a.taxpercent
ORDER BY tl.locationname ASC, a.productname ASC`
if err := r.db.Raw(query, tenantID, locationID).Scan(&rows).Error; err != nil {
if err := r.db.Raw(query, tenantID, locationID, locationID).Scan(&rows).Error; err != nil {
return nil, err
}
// Summarised from the rows themselves rather than queried separately, so
// the branch list can never disagree with what the sheet actually contains.
locations := make([]models.SaleTemplateLocation, 0)
seen := make(map[int]int)
for _, row := range rows {
if idx, ok := seen[row.Locationid]; ok {
locations[idx].Productcount++
continue
}
seen[row.Locationid] = len(locations)
locations = append(locations, models.SaleTemplateLocation{
Locationid: row.Locationid,
Locationname: strings.TrimSpace(row.Locationname),
Productcount: 1,
})
}
return &models.SaleTemplate{
Tenantid: tenantID,
Locationid: locationID,
Locationname: strings.TrimSpace(loc.Locationname),
Locations: locations,
Products: rows,
}, nil
}

View File

@@ -11,7 +11,12 @@ func RegisterUtilsRoutes(api fiber.Router, f *facade.Facade) {
utils := api.Group("/v1/web/utils")
utils.Get("/getapptypes", f.UtilsController.GetAppTypes)
// utils.Post("/notifyuser", f.UtilsController.NotifyUser)
// Commented out since the initial commit, which meant every rider push the
// admin console has ever sent returned 404 — riders were assigned deliveries
// and never told. The handler, the FcmNotification model, the Firebase
// service account and the Dockerfile COPY that puts it in the image were all
// already in place; only the route was missing.
utils.Post("/notifyuser", f.UtilsController.NotifyUser)
utils.Get("/getsubcategories", f.UtilsController.GetSubcategories)
utils.Get("/getapplocations", f.UtilsController.GetApplocations)
utils.Get("/getappcategories", f.UtilsController.GetAppCategory)