backend requirements onthe xustomer app
This commit is contained in:
103
utils/epoch.go
Normal file
103
utils/epoch.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Timestamps on the customer surface.
|
||||
//
|
||||
// The requirement is epoch milliseconds, UTC, integer — a real instant, not a
|
||||
// wall clock. That is not what this database hands back. The DSN sets
|
||||
// TimeZone=Asia/Kolkata and the timestamp columns hold IST wall-clock digits
|
||||
// (see DBNow), so a stored value read into a time.Time carries the right
|
||||
// digits with the wrong (or no) zone. Calling UnixMilli on it directly is off
|
||||
// by 5h30m — the same class of defect as sending a naive local string with a Z
|
||||
// on it, which has already produced "yesterday's work shown as today" on the
|
||||
// miler app. So every timestamp leaving /customer/* goes through here.
|
||||
//
|
||||
// Reinterpreting the wall clock in IST is correct for both shapes this
|
||||
// database produces: a value tagged UTC that actually holds IST digits, and a
|
||||
// value correctly tagged +05:30. Both name the same instant afterwards.
|
||||
|
||||
// istLocation is Asia/Kolkata, with an exact fixed-offset fallback for
|
||||
// containers shipped without tzdata. IST observes no DST, so +05:30 is not an
|
||||
// approximation.
|
||||
var istLocation = func() *time.Location {
|
||||
if loc, err := time.LoadLocation("Asia/Kolkata"); err == nil {
|
||||
return loc
|
||||
}
|
||||
return time.FixedZone("IST", 5*3600+30*60)
|
||||
}()
|
||||
|
||||
// IST reinterprets a database timestamp's wall clock as Indian Standard Time,
|
||||
// yielding the instant it actually names.
|
||||
func IST(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), istLocation)
|
||||
}
|
||||
|
||||
// EpochMillis converts a database timestamp to UTC epoch milliseconds.
|
||||
func EpochMillis(t time.Time) int64 {
|
||||
return IST(t).UnixMilli()
|
||||
}
|
||||
|
||||
// EpochMillisPtr is EpochMillis over a nullable column. A nil timestamp stays
|
||||
// null in JSON rather than becoming the epoch, which the client would render
|
||||
// as 1 Jan 1970.
|
||||
func EpochMillisPtr(t *time.Time) *int64 {
|
||||
if t == nil || t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
ms := EpochMillis(*t)
|
||||
return &ms
|
||||
}
|
||||
|
||||
// ISTNow is the current moment in IST, for formatting and for comparing
|
||||
// against a value already put through IST().
|
||||
func ISTNow() time.Time {
|
||||
return time.Now().In(istLocation)
|
||||
}
|
||||
|
||||
// ISTLocation exposes the zone for callers building their own times.
|
||||
func ISTLocation() *time.Location { return istLocation }
|
||||
|
||||
// FormatISTDate renders a date the way the customer app shows it: "Thu, 12 Sep".
|
||||
// Display strings are formatted server-side, in IST, so the client never has to
|
||||
// know the operating timezone.
|
||||
func FormatISTDate(t time.Time) string {
|
||||
return IST(t).Format("Mon, 2 Jan")
|
||||
}
|
||||
|
||||
// FormatISTDay renders a date relative to today where that reads better —
|
||||
// "Today", "Tomorrow", otherwise "Mon, 8 Sep". Used for pickup slot days.
|
||||
func FormatISTDay(t time.Time) string {
|
||||
d := IST(t)
|
||||
today := ISTNow()
|
||||
y1, m1, d1 := d.Date()
|
||||
y2, m2, d2 := today.Date()
|
||||
switch {
|
||||
case y1 == y2 && m1 == m2 && d1 == d2:
|
||||
return "Today"
|
||||
case y1 == y2 && m1 == m2 && d1 == d2+1:
|
||||
return "Tomorrow"
|
||||
default:
|
||||
tomorrow := today.AddDate(0, 0, 1)
|
||||
y3, m3, d3 := tomorrow.Date()
|
||||
if y1 == y3 && m1 == m3 && d1 == d3 {
|
||||
return "Tomorrow"
|
||||
}
|
||||
return d.Format("Mon, 2 Jan")
|
||||
}
|
||||
}
|
||||
|
||||
// FormatISTWindow renders a pickup window as the design writes it:
|
||||
// "2:00 – 4:00 PM" — en dash, spaced, and the meridiem stated once when both
|
||||
// ends share it.
|
||||
func FormatISTWindow(from, to time.Time) string {
|
||||
f, t := IST(from), IST(to)
|
||||
fMer, tMer := f.Format("PM"), t.Format("PM")
|
||||
if fMer == tMer {
|
||||
return fmt.Sprintf("%s – %s", f.Format("3:04"), t.Format("3:04 PM"))
|
||||
}
|
||||
return fmt.Sprintf("%s – %s", f.Format("3:04 PM"), t.Format("3:04 PM"))
|
||||
}
|
||||
116
utils/epoch_test.go
Normal file
116
utils/epoch_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The customer contract asks for epoch milliseconds, UTC, integer. This
|
||||
// database stores IST wall-clock digits (see DBNow), so the conversion is the
|
||||
// one place a 5h30m error can enter every timestamp the app renders — which is
|
||||
// exactly the defect class §3.3 of the contract warns about after it produced
|
||||
// "yesterday's work shown as today" on the miler app.
|
||||
|
||||
func TestEpochMillisTreatsStoredWallClockAsIST(t *testing.T) {
|
||||
// A row written as 2026-09-05 14:30:00 by this database means 2:30pm in
|
||||
// Chennai, whatever zone the time.Time happens to carry.
|
||||
stored := time.Date(2026, 9, 5, 14, 30, 0, 0, time.UTC)
|
||||
|
||||
got := EpochMillis(stored)
|
||||
want := time.Date(2026, 9, 5, 9, 0, 0, 0, time.UTC).UnixMilli() // 14:30 IST == 09:00 UTC
|
||||
|
||||
if got != want {
|
||||
t.Errorf("EpochMillis(2026-09-05 14:30 stored) = %d, want %d (a %d ms error)",
|
||||
got, want, got-want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpochMillisAgreesForBothTaggings(t *testing.T) {
|
||||
// The driver can hand back the same instant either tagged UTC with IST
|
||||
// digits, or correctly tagged +05:30. Both must name one moment, or a
|
||||
// column type change silently shifts every timestamp on the tracking screen.
|
||||
ist := ISTLocation()
|
||||
taggedUTC := time.Date(2026, 9, 5, 14, 30, 0, 0, time.UTC)
|
||||
taggedIST := time.Date(2026, 9, 5, 14, 30, 0, 0, ist)
|
||||
|
||||
if EpochMillis(taggedUTC) != EpochMillis(taggedIST) {
|
||||
t.Errorf("EpochMillis disagrees on tagging: utc-tagged=%d ist-tagged=%d",
|
||||
EpochMillis(taggedUTC), EpochMillis(taggedIST))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpochMillisPtrKeepsNullNull(t *testing.T) {
|
||||
// A nil timestamp must stay null in JSON. Coercing it to 0 renders as
|
||||
// 1 Jan 1970 on the timeline, which reads as a real event.
|
||||
if got := EpochMillisPtr(nil); got != nil {
|
||||
t.Errorf("EpochMillisPtr(nil) = %v, want nil", got)
|
||||
}
|
||||
|
||||
var zero time.Time
|
||||
if got := EpochMillisPtr(&zero); got != nil {
|
||||
t.Errorf("EpochMillisPtr(zero time) = %v, want nil", got)
|
||||
}
|
||||
|
||||
stored := time.Date(2026, 9, 5, 14, 30, 0, 0, time.UTC)
|
||||
got := EpochMillisPtr(&stored)
|
||||
if got == nil || *got != EpochMillis(stored) {
|
||||
t.Errorf("EpochMillisPtr(%v) = %v, want %d", stored, got, EpochMillis(stored))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatISTWindowMatchesTheDesign(t *testing.T) {
|
||||
// "2:00 – 4:00 PM" — en dash, spaced, meridiem stated once when both ends
|
||||
// share it. The client renders this string verbatim.
|
||||
cases := []struct {
|
||||
name string
|
||||
fromH, fromM int
|
||||
toH, toM int
|
||||
want string
|
||||
}{
|
||||
{"afternoon window", 14, 0, 16, 0, "2:00 – 4:00 PM"},
|
||||
{"morning window", 8, 0, 10, 0, "8:00 – 10:00 AM"},
|
||||
{"straddles noon", 10, 0, 14, 0, "10:00 AM – 2:00 PM"},
|
||||
{"half hour bounds", 18, 30, 20, 30, "6:30 – 8:30 PM"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
from := time.Date(2026, 9, 5, tc.fromH, tc.fromM, 0, 0, time.UTC)
|
||||
to := time.Date(2026, 9, 5, tc.toH, tc.toM, 0, 0, time.UTC)
|
||||
if got := FormatISTWindow(from, to); got != tc.want {
|
||||
t.Errorf("FormatISTWindow = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatISTDayUsesRelativeWordsOnlyWhenTrue(t *testing.T) {
|
||||
now := ISTNow()
|
||||
|
||||
if got := FormatISTDay(now); got != "Today" {
|
||||
t.Errorf("FormatISTDay(now) = %q, want \"Today\"", got)
|
||||
}
|
||||
if got := FormatISTDay(now.AddDate(0, 0, 1)); got != "Tomorrow" {
|
||||
t.Errorf("FormatISTDay(tomorrow) = %q, want \"Tomorrow\"", got)
|
||||
}
|
||||
|
||||
// Three days out has no relative word — it falls back to the dated form,
|
||||
// which must not read as "Today".
|
||||
got := FormatISTDay(now.AddDate(0, 0, 3))
|
||||
if got == "Today" || got == "Tomorrow" {
|
||||
t.Errorf("FormatISTDay(+3 days) = %q, want a dated label", got)
|
||||
}
|
||||
}
|
||||
|
||||
// FormatISTDay crossing a month boundary is the case a naive day+1 comparison
|
||||
// gets wrong: 30 September plus one day is 1 October, not 31 September.
|
||||
func TestFormatISTDayAcrossMonthEnd(t *testing.T) {
|
||||
now := ISTNow()
|
||||
tomorrow := now.AddDate(0, 0, 1)
|
||||
if now.Month() == tomorrow.Month() {
|
||||
t.Skip("not a month boundary today; the AddDate path is exercised by the general case")
|
||||
}
|
||||
if got := FormatISTDay(tomorrow); got != "Tomorrow" {
|
||||
t.Errorf("FormatISTDay across a month end = %q, want \"Tomorrow\"", got)
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,18 @@ type Claims struct {
|
||||
}
|
||||
|
||||
func GenerateToken(userID int, email string, roleID int, tenantID int, configID int, secret string) (string, error) {
|
||||
expirationTime := time.Now().Add(24 * time.Hour)
|
||||
return GenerateTokenWithTTL(userID, email, roleID, tenantID, configID, secret, 24*time.Hour)
|
||||
}
|
||||
|
||||
// GenerateTokenWithTTL is GenerateToken with an explicit lifetime.
|
||||
//
|
||||
// The customer app pairs a short access token with a long-lived refresh token,
|
||||
// so a stolen access token expires in an hour rather than a day, while the
|
||||
// customer still stays signed in for months. The miler and console surfaces
|
||||
// keep the 24-hour default: they have no refresh endpoint, and shortening their
|
||||
// token would sign a rider out mid-shift.
|
||||
func GenerateTokenWithTTL(userID int, email string, roleID int, tenantID int, configID int, secret string, ttl time.Duration) (string, error) {
|
||||
expirationTime := time.Now().Add(ttl)
|
||||
claims := &Claims{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
|
||||
97
utils/response_cx.go
Normal file
97
utils/response_cx.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package utils
|
||||
|
||||
import "github.com/gofiber/fiber/v2"
|
||||
|
||||
// Customer-app (doormile_cx) response envelope.
|
||||
//
|
||||
// Deliberately a separate helper set from OK/List/Fail rather than a reuse of
|
||||
// them. The customer contract fixes three things the miler/console contract
|
||||
// does not: `message` is always present on success (empty string, never
|
||||
// omitted), the machine-readable code is nested under `error.code` rather than
|
||||
// sitting at the top level, and `total`/`nextCursor` ride the list envelope.
|
||||
// Serving one endpoint in one shape and its neighbour in another is exactly
|
||||
// what cost the miler client a release on /miler/verify-pin — so the customer
|
||||
// surface gets its own helpers and every /customer/* response goes through
|
||||
// them, auth included.
|
||||
|
||||
// CxErr* are the error codes in the customer contract. The client branches on
|
||||
// these; `message` is customer-safe English shown verbatim in the UI.
|
||||
const (
|
||||
CxErrInvalid = "invalid"
|
||||
CxErrInvalidName = "invalid_name"
|
||||
CxErrInvalidOtp = "invalid_otp"
|
||||
CxErrUnauthorized = "unauthorized"
|
||||
CxErrForbidden = "forbidden"
|
||||
CxErrNotFound = "not_found"
|
||||
CxErrConflict = "conflict"
|
||||
CxErrUnserviceable = "unserviceable"
|
||||
CxErrRateLimited = "rate_limited"
|
||||
CxErrServer = "server_error"
|
||||
)
|
||||
|
||||
// CxOK is the success envelope: {success, data, message}.
|
||||
func CxOK(c *fiber.Ctx, data interface{}) error {
|
||||
return c.JSON(fiber.Map{"success": true, "data": data, "message": ""})
|
||||
}
|
||||
|
||||
// CxCreated is CxOK with a 201.
|
||||
func CxCreated(c *fiber.Ctx, data interface{}) error {
|
||||
return c.Status(fiber.StatusCreated).
|
||||
JSON(fiber.Map{"success": true, "data": data, "message": ""})
|
||||
}
|
||||
|
||||
// CxList is the list envelope. data is always an array — never null, because
|
||||
// the client types it as a list and a null throws in the parser. nextCursor is
|
||||
// null when the page is the last one.
|
||||
func CxList(c *fiber.Ctx, data interface{}, total int, nextCursor *string) error {
|
||||
body := fiber.Map{
|
||||
"success": true,
|
||||
"data": data,
|
||||
"total": total,
|
||||
"nextCursor": nil,
|
||||
"message": "",
|
||||
}
|
||||
if nextCursor != nil && *nextCursor != "" {
|
||||
body["nextCursor"] = *nextCursor
|
||||
}
|
||||
return c.JSON(body)
|
||||
}
|
||||
|
||||
// CxFail is the error envelope: {success:false, message, error:{code}}. The
|
||||
// app funnels every failure into one retryable error state and renders
|
||||
// `message` verbatim, so callers must pass customer-safe English here — never
|
||||
// an enum key, a driver error or a wrapped stack.
|
||||
func CxFail(c *fiber.Ctx, status int, code, msg string) error {
|
||||
return c.Status(status).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"message": msg,
|
||||
"error": fiber.Map{"code": code},
|
||||
})
|
||||
}
|
||||
|
||||
// Shorthands for the statuses the contract pins to a specific code.
|
||||
func CxBadRequest(c *fiber.Ctx, msg string) error {
|
||||
return CxFail(c, fiber.StatusBadRequest, CxErrInvalid, msg)
|
||||
}
|
||||
|
||||
func CxUnauthorized(c *fiber.Ctx, msg string) error {
|
||||
return CxFail(c, fiber.StatusUnauthorized, CxErrUnauthorized, msg)
|
||||
}
|
||||
|
||||
func CxForbidden(c *fiber.Ctx, msg string) error {
|
||||
return CxFail(c, fiber.StatusForbidden, CxErrForbidden, msg)
|
||||
}
|
||||
|
||||
func CxNotFound(c *fiber.Ctx, msg string) error {
|
||||
return CxFail(c, fiber.StatusNotFound, CxErrNotFound, msg)
|
||||
}
|
||||
|
||||
func CxConflict(c *fiber.Ctx, msg string) error {
|
||||
return CxFail(c, fiber.StatusConflict, CxErrConflict, msg)
|
||||
}
|
||||
|
||||
// CxInternal never leaks the underlying error to the customer. Log the real
|
||||
// one; the app shows this.
|
||||
func CxInternal(c *fiber.Ctx) error {
|
||||
return CxFail(c, fiber.StatusInternalServerError, CxErrServer, "Something went wrong")
|
||||
}
|
||||
Reference in New Issue
Block a user