backend requirements onthe xustomer app

This commit is contained in:
2026-09-07 10:55:11 +05:30
parent 35675d8a9b
commit 1b2690b21a
56 changed files with 13342 additions and 1071 deletions

103
utils/epoch.go Normal file
View 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
View 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)
}
}

View File

@@ -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
View 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")
}