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

View File

@@ -83,10 +83,58 @@ type PickupBooking struct {
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
// ── Customer-app (doormile_cx) columns ──────────────────────────────────
// All additive and nullable/zero-valued, so every existing row and every
// console-created booking stays valid without a backfill.
// Slotid is the pickup window the customer chose, as the opaque id the app
// sent back ("slot_20260905_t1"). Preferredpickupfrom/to carry the same
// window as real times for the assignment engine; this keeps the customer's
// own choice recorded verbatim, so a slot definition edited later cannot
// silently rewrite what they picked.
Slotid string `json:"slotid" gorm:"column:slotid;size:40"`
// Customerstage is the operational stage the customer app renders, one of
// the nine keys in constants.CxStage*. Distinct from Status, which is the
// booking lifecycle the console and the miler app read: Status stops moving
// at Converted_To_Consignment while the parcels keep going, and the two
// vocabularies are not a renaming of each other. Derived from the same
// writes, stored so a read is one row rather than a replay of the history.
Customerstage string `json:"customerstage" gorm:"column:customerstage;size:24"`
// Customerstatus is active / completed / cancelled. Derived, but sent
// explicitly — making the client infer it from a stage is how two surfaces
// end up disagreeing about whether an order is finished.
Customerstatus string `json:"customerstatus" gorm:"column:customerstatus;size:16"`
// The estimate range the customer was actually shown at booking time, in
// whole rupees. Kept forever, including after settlement: the receipt
// renders amountPaid − estimate min as the weight adjustment, and a price
// dispute needs the number that was on screen, not a re-run of today's
// pricing rules.
Estimateminrupees int `json:"estimateminrupees" gorm:"column:estimateminrupees;default:0"`
Estimatemaxrupees int `json:"estimatemaxrupees" gorm:"column:estimatemaxrupees;default:0"`
// Routekm is the pickup→destination distance the estimate was priced on;
// it drives the route outline and the receipt.
Routekm float64 `json:"routekm" gorm:"column:routekm;default:0"`
// Pickuptitle/Pickupsub are the two-line pickup label the customer picked
// from the place search ("12 Nehru Street" / "Gandhipuram, Coimbatore
// 641012"). Pickupaddress remains the single flat string the rest of the
// system uses; these keep the split the app renders without it having to
// re-parse one back into two.
Pickuptitle string `json:"pickuptitle" gorm:"column:pickuptitle;size:64"`
Pickupsub string `json:"pickupsub" gorm:"column:pickupsub"`
// Cancelreason is free text or one of the app's five presets. Nullable in
// spirit — an empty string means the customer gave no reason, which is
// allowed.
Cancelreason string `json:"cancelreason" gorm:"column:cancelreason;size:120"`
// Relations
Parcels []BookingParcel `json:"parcels" gorm:"foreignKey:Bookingid"`
ServiceOptions []BookingServiceOption `json:"serviceoptions" gorm:"foreignKey:Bookingid"`
Payments []BookingPayment `json:"payments" gorm:"foreignKey:Bookingid"`
Destinations []BookingDestination `json:"destinations" gorm:"foreignKey:Bookingid"`
}
func (PickupBooking) TableName() string {
@@ -94,8 +142,16 @@ func (PickupBooking) TableName() string {
}
type BookingParcel struct {
Bookingparcelid int `json:"bookingparcelid" gorm:"primaryKey;column:bookingparcelid"`
Bookingid int `json:"bookingid" gorm:"column:bookingid"`
Bookingparcelid int `json:"bookingparcelid" gorm:"primaryKey;column:bookingparcelid"`
Bookingid int `json:"bookingid" gorm:"column:bookingid"`
// Bookingdestinationid says which destination this package is going to.
// Null on console-created and pre-existing bookings, which have exactly one
// delivery address and therefore no ambiguity. On a customer-app booking it
// is what lets the miler weigh three packages for Chennai and one for
// Kochi and have each weight settle against the right order — without it,
// a multi-destination pickup has one pile of parcels and no way to say
// which parcel belongs to which tracking number.
Bookingdestinationid *int `json:"bookingdestinationid" gorm:"column:bookingdestinationid;index"`
Itemcategory string `json:"itemcategory" gorm:"column:itemcategory"`
Itemdescription string `json:"itemdescription" gorm:"column:itemdescription"`
Declaredvalue float64 `json:"declaredvalue" gorm:"column:declaredvalue"`

241
models/customer_app.go Normal file
View File

@@ -0,0 +1,241 @@
package models
import "time"
// Schema for the customer app (doormile_cx) surface.
//
// The one structural idea here is that a customer books a PICKUP, not a
// shipment: one booking fans out to 1..N destinations, and each destination
// becomes its own consignment with its own tracking number when the miler
// completes the pickup. A single-destination booking is the same shape, not a
// special case — pickupbookings keeps its flat delivery* columns mirrored from
// destination 0 so the miler app, the hub console and the routing code keep
// reading the row they already read.
// ServiceableState is a state the customer may choose a destination in. The
// picker hides any state with no available district, but the row stays so ops
// can open one without an app release.
type ServiceableState struct {
Statecode string `json:"statecode" gorm:"primaryKey;column:statecode;size:8"`
Statename string `json:"statename" gorm:"column:statename;not null"`
// Transittag is display copy shown under the state name ("Ultra-fast
// transit", "Opening soon"). Capped at 22 characters by the design.
Transittag string `json:"transittag" gorm:"column:transittag;size:22"`
Displayorder int `json:"displayorder" gorm:"column:displayorder;default:0"`
Status string `json:"status" gorm:"column:status;default:Active"` // Active, InActive
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
}
func (ServiceableState) TableName() string { return "serviceablestates" }
// ServiceableDistrict is one district inside a state. Unavailable districts are
// returned too: the picker filters them out but the app shows their names in a
// quiet "coming soon" line, so dropping them would lose real copy.
type ServiceableDistrict struct {
Districtcode string `json:"districtcode" gorm:"primaryKey;column:districtcode;size:16"`
Statecode string `json:"statecode" gorm:"column:statecode;size:8;index;not null"`
Districtname string `json:"districtname" gorm:"column:districtname;not null"`
Available bool `json:"available" gorm:"column:available;default:false"`
// Note says why a district is not available — "Opening soon", "Paused this
// week". Only meaningful when Available is false.
Note string `json:"note" gorm:"column:note;size:60"`
// Hubid is the base that serves this district. Nullable: a district can be
// announced before its hub exists.
Hubid *int `json:"hubid" gorm:"column:hubid"`
// Promise is the delivery promise shown on the destination card,
// "Next-day delivery" / "2-day delivery".
Promise string `json:"promise" gorm:"column:promise;size:40"`
// Centre coordinates price and route a destination before any street
// address exists — only state and district are required at booking time, so
// this is frequently the only geography a destination has.
Centrelatitude float64 `json:"centrelatitude" gorm:"column:centrelatitude"`
Centrelongitude float64 `json:"centrelongitude" gorm:"column:centrelongitude"`
Pincodeprefix string `json:"pincodeprefix" gorm:"column:pincodeprefix;size:6"`
Displayorder int `json:"displayorder" gorm:"column:displayorder;default:0"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
}
func (ServiceableDistrict) TableName() string { return "serviceabledistricts" }
// PickupSlotTemplate is a recurring daily pickup window. The customer-facing
// slot id is minted per date from the template code, so a slot always resolves
// back to a real preferredpickupfrom/to on the booking — a slot ops cannot
// staff is worse than no slot.
type PickupSlotTemplate struct {
Slottemplateid int `json:"slottemplateid" gorm:"primaryKey;column:slottemplateid"`
// Code is the stable part of the slot id: "t1" renders as
// "slot_20260905_t1".
Code string `json:"code" gorm:"column:code;size:16;not null"`
Starthour int `json:"starthour" gorm:"column:starthour;not null"`
Startminute int `json:"startminute" gorm:"column:startminute;default:0"`
Endhour int `json:"endhour" gorm:"column:endhour;not null"`
Endminute int `json:"endminute" gorm:"column:endminute;default:0"`
// Capacity is how many pickups this window absorbs in one zone.
Capacity int `json:"capacity" gorm:"column:capacity;default:20"`
// Applocationid scopes a template to one city. Null means every city.
Applocationid *int `json:"applocationid" gorm:"column:applocationid;index"`
// Tag is the single promotional label the design allows on at most one slot
// ("Fastest pickup"). Caption is the softer line under it.
Tag string `json:"tag" gorm:"column:tag;size:40"`
Caption string `json:"caption" gorm:"column:caption;size:60"`
Displayorder int `json:"displayorder" gorm:"column:displayorder;default:0"`
Status string `json:"status" gorm:"column:status;default:Active"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
}
func (PickupSlotTemplate) TableName() string { return "pickupslottemplates" }
// CustomerBookingLimit caps a pickup. Kept in the database rather than in the
// app so ops can vary it by city or tier without an app release. A row with a
// null applocationid is the global default.
type CustomerBookingLimit struct {
Limitid int `json:"limitid" gorm:"primaryKey;column:limitid"`
Applocationid *int `json:"applocationid" gorm:"column:applocationid;index"`
Maxpackages int `json:"maxpackages" gorm:"column:maxpackages;default:20"`
Maxdestinations int `json:"maxdestinations" gorm:"column:maxdestinations;default:5"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
}
func (CustomerBookingLimit) TableName() string { return "customerbookinglimits" }
// BookingDestination is one place inside a pickup booking, and the row that
// becomes a consignment at pickup completion. This is the table that makes
// "one visit, N orders" expressible: before it a booking carried exactly one
// delivery address in its own columns and there was nowhere to put the second.
type BookingDestination struct {
Bookingdestinationid int `json:"bookingdestinationid" gorm:"primaryKey;column:bookingdestinationid"`
Bookingid int `json:"bookingid" gorm:"column:bookingid;index;not null"`
// Seq is the 0-based position within the booking and is the {index} in
// PATCH /customer/bookings/{ref}/destinations/{index}. Stable for the life
// of the booking — destinations are never reordered, because the customer
// addresses them by position.
Seq int `json:"seq" gorm:"column:seq;not null"`
// Only state and district are required at booking time. Names are stored
// alongside the codes rather than joined at read time: the client renders
// "Chennai, Tamil Nadu" straight from the booking and never looks a code
// up, and a district renamed later must not silently rewrite an address the
// customer already agreed to.
Statecode string `json:"statecode" gorm:"column:statecode;size:8;not null"`
Statename string `json:"statename" gorm:"column:statename;not null"`
Districtcode string `json:"districtcode" gorm:"column:districtcode;size:16;not null"`
Districtname string `json:"districtname" gorm:"column:districtname;not null"`
Packagecount int `json:"packagecount" gorm:"column:packagecount;default:1"`
// Everything below is optional at booking time and may be completed by the
// customer afterwards or by the miler at the door. Empty means "not added
// yet", which the UI states explicitly rather than hiding.
Street string `json:"street" gorm:"column:street"`
Building string `json:"building" gorm:"column:building"`
Landmark string `json:"landmark" gorm:"column:landmark"`
Recipientname string `json:"recipientname" gorm:"column:recipientname"`
Recipientphone string `json:"recipientphone" gorm:"column:recipientphone"`
Instructions string `json:"instructions" gorm:"column:instructions"`
Pinlatitude *float64 `json:"pinlatitude" gorm:"column:pinlatitude"`
Pinlongitude *float64 `json:"pinlongitude" gorm:"column:pinlongitude"`
Pincode string `json:"pincode" gorm:"column:pincode;size:10"`
// Codamount is money the miler collects at this door on the customer's
// behalf. Doormile is the carrier, never the seller — this is the
// customer's own collection, and it is per destination because it is
// collected per delivery.
Codamount float64 `json:"codamount" gorm:"column:codamount;default:0"`
// Set at pickup completion, when this destination becomes an order.
Consignmentid *int `json:"consignmentid" gorm:"column:consignmentid;index"`
Trackingno string `json:"trackingno" gorm:"column:trackingno;size:32;index"`
// Stage is the per-order stage from order_created onward. Empty until the
// order exists; the booking's own stage governs everything before that.
Stage string `json:"stage" gorm:"column:stage;size:24"`
Expecteddeliveryat *time.Time `json:"expecteddeliveryat" gorm:"column:expecteddeliveryat"`
Deliveredat *time.Time `json:"deliveredat" gorm:"column:deliveredat"`
// Verification is what the miler recorded at the door: the weight the price
// actually settled on, and who recorded it. Photos live in
// bookingparcelphotos, one row per package.
Verifiedweightkg *float64 `json:"verifiedweightkg" gorm:"column:verifiedweightkg"`
Verifiedat *time.Time `json:"verifiedat" gorm:"column:verifiedat"`
Verifiedbyuserid *int `json:"verifiedbyuserid" gorm:"column:verifiedbyuserid"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
}
func (BookingDestination) TableName() string { return "bookingdestinations" }
// BookingParcelPhoto is a photograph the miler took of a package at the door.
// It is the evidence half of the receipt — a settled weight without the photo
// is a number the customer has no way to check.
type BookingParcelPhoto struct {
Photoid int `json:"photoid" gorm:"primaryKey;column:photoid"`
Bookingid int `json:"bookingid" gorm:"column:bookingid;index;not null"`
Bookingdestinationid *int `json:"bookingdestinationid" gorm:"column:bookingdestinationid;index"`
// Objectkey is the storage key. The customer is served a short-lived signed
// URL derived from it, never a permanent public link — a parcel photo can
// show the inside of someone's doorway.
Objectkey string `json:"objectkey" gorm:"column:objectkey;not null"`
Capturedbyuserid *int `json:"capturedbyuserid" gorm:"column:capturedbyuserid"`
Capturedat time.Time `json:"capturedat" gorm:"column:capturedat;default:CURRENT_TIMESTAMP"`
}
func (BookingParcelPhoto) TableName() string { return "bookingparcelphotos" }
// BookingStageEvent is the append-only audit log every customer timeline is
// built from: one row per stage actually reached, with the real time it was
// reached and who caused it. Nothing here is synthesised or backfilled — a
// timeline with invented timestamps is worse than a short one, because the
// customer cannot tell which entries are real.
type BookingStageEvent struct {
Stageeventid int `json:"stageeventid" gorm:"primaryKey;column:stageeventid"`
Bookingid int `json:"bookingid" gorm:"column:bookingid;index;not null"`
// Bookingdestinationid is null for the booking-level stages (booked through
// order_created) and set for the per-order ones (in_transit onward), which
// may differ between destinations of the same booking.
Bookingdestinationid *int `json:"bookingdestinationid" gorm:"column:bookingdestinationid;index"`
Stage string `json:"stage" gorm:"column:stage;size:24;not null"`
// Actortype/Actorid answer "who did this" — miler, ops user, the customer,
// or the system. Required by the audit rule, and the only way to explain a
// cancellation to a customer who did not make it.
Actortype string `json:"actortype" gorm:"column:actortype;size:16;not null"`
Actorid *int `json:"actorid" gorm:"column:actorid"`
Source string `json:"source" gorm:"column:source;size:64"`
Remarks string `json:"remarks" gorm:"column:remarks"`
Occurredat time.Time `json:"occurredat" gorm:"column:occurredat;index;not null"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
}
func (BookingStageEvent) TableName() string { return "bookingstageevents" }
// CustomerRefreshToken backs the 60-day session. Stored hashed: a leaked
// database row must not itself be a usable credential. Rotation is recorded via
// Replacedbyid so a replayed old token identifies the chain it came from.
type CustomerRefreshToken struct {
Refreshtokenid int `json:"refreshtokenid" gorm:"primaryKey;column:refreshtokenid"`
Appcustomerid int `json:"appcustomerid" gorm:"column:appcustomerid;index;not null"`
Tokenhash string `json:"-" gorm:"column:tokenhash;size:64;uniqueIndex;not null"`
Expiresat time.Time `json:"expiresat" gorm:"column:expiresat;not null"`
Revokedat *time.Time `json:"revokedat" gorm:"column:revokedat"`
Replacedbyid *int `json:"replacedbyid" gorm:"column:replacedbyid"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
}
func (CustomerRefreshToken) TableName() string { return "customerrefreshtokens" }
// CustomerDevice is a push registration. One row per device token, not per
// customer: a customer with a phone and a tablet must get the notification on
// both, and appcustomers.device_token could only ever hold the last one.
type CustomerDevice struct {
Customerdeviceid int `json:"customerdeviceid" gorm:"primaryKey;column:customerdeviceid"`
Appcustomerid int `json:"appcustomerid" gorm:"column:appcustomerid;index;not null"`
Token string `json:"token" gorm:"column:token;uniqueIndex;not null"`
Platform string `json:"platform" gorm:"column:platform;size:16"`
Appversion string `json:"appversion" gorm:"column:appversion;size:32"`
Lastseenat time.Time `json:"lastseenat" gorm:"column:lastseenat;default:CURRENT_TIMESTAMP"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
}
func (CustomerDevice) TableName() string { return "customerdevices" }

175
models/customer_app_test.go Normal file
View File

@@ -0,0 +1,175 @@
package models
import (
"reflect"
"strings"
"testing"
)
// Table names and column tags are strings the compiler never checks. A typo in
// either produces a binary that builds cleanly and then fails against the
// database at runtime — which, for the customer app, means the first real
// booking. These are cheap tests for a class of bug nothing else catches.
// Every customer-app model maps to the table AutoMigrate creates and the seed
// fills. A mismatch here means seed_customer_app.sql populates one table while
// the code reads another.
func TestCustomerAppTableNames(t *testing.T) {
cases := []struct {
model interface{ TableName() string }
want string
}{
{ServiceableState{}, "serviceablestates"},
{ServiceableDistrict{}, "serviceabledistricts"},
{PickupSlotTemplate{}, "pickupslottemplates"},
{CustomerBookingLimit{}, "customerbookinglimits"},
{BookingDestination{}, "bookingdestinations"},
{BookingParcelPhoto{}, "bookingparcelphotos"},
{BookingStageEvent{}, "bookingstageevents"},
{CustomerRefreshToken{}, "customerrefreshtokens"},
{CustomerDevice{}, "customerdevices"},
}
for _, tc := range cases {
if got := tc.model.TableName(); got != tc.want {
t.Errorf("%T.TableName() = %q, want %q",
tc.model, got, tc.want)
}
}
}
// columnOf reads the gorm column name off a struct field, the way the driver
// does.
func columnOf(t *testing.T, model interface{}, field string) string {
t.Helper()
f, ok := reflect.TypeOf(model).FieldByName(field)
if !ok {
t.Fatalf("%T has no field %s", model, field)
}
for _, part := range strings.Split(f.Tag.Get("gorm"), ";") {
if strings.HasPrefix(part, "column:") {
return strings.TrimPrefix(part, "column:")
}
}
return ""
}
// The columns the fan-out is built on. bookingdestinations.seq in particular is
// addressed by position in PATCH .../destinations/{index} and is what stop
// ordering sorts by — a renamed column there silently reorders a rider's route.
func TestBookingDestinationColumns(t *testing.T) {
want := map[string]string{
"Bookingdestinationid": "bookingdestinationid",
"Bookingid": "bookingid",
"Seq": "seq",
"Statecode": "statecode",
"Districtcode": "districtcode",
"Packagecount": "packagecount",
"Recipientname": "recipientname",
"Recipientphone": "recipientphone",
"Codamount": "codamount",
"Consignmentid": "consignmentid",
"Trackingno": "trackingno",
"Stage": "stage",
"Verifiedweightkg": "verifiedweightkg",
}
for field, col := range want {
if got := columnOf(t, BookingDestination{}, field); got != col {
t.Errorf("BookingDestination.%s column = %q, want %q", field, got, col)
}
}
}
// The columns added to the live pickupbookings table. These land on 512
// existing rows, so the names have to match what migrate.go creates.
func TestPickupBookingCustomerColumns(t *testing.T) {
want := map[string]string{
"Slotid": "slotid",
"Customerstage": "customerstage",
"Customerstatus": "customerstatus",
"Estimateminrupees": "estimateminrupees",
"Estimatemaxrupees": "estimatemaxrupees",
"Routekm": "routekm",
"Pickuptitle": "pickuptitle",
"Pickupsub": "pickupsub",
"Cancelreason": "cancelreason",
}
for field, col := range want {
if got := columnOf(t, PickupBooking{}, field); got != col {
t.Errorf("PickupBooking.%s column = %q, want %q", field, got, col)
}
}
// The link that lets a parcel know which order it belongs to.
if got := columnOf(t, BookingParcel{}, "Bookingdestinationid"); got != "bookingdestinationid" {
t.Errorf("BookingParcel.Bookingdestinationid column = %q, want %q",
got, "bookingdestinationid")
}
}
// A refresh token is a bearer credential valid for sixty days. It must never be
// serialised outward — a JSON tag of anything but "-" would put it in a
// response body.
func TestRefreshTokenHashIsNeverSerialised(t *testing.T) {
f, ok := reflect.TypeOf(CustomerRefreshToken{}).FieldByName("Tokenhash")
if !ok {
t.Fatal("CustomerRefreshToken has no Tokenhash field")
}
if got := f.Tag.Get("json"); got != "-" {
t.Errorf("Tokenhash json tag = %q, want \"-\" — a stored credential must "+
"never leave the server", got)
}
}
// The delivery OTP is the receiver's proof, not the rider's. Returning it in a
// response would hand the rider the code they are supposed to be told at the
// door.
func TestDeliveryOtpIsNeverSerialised(t *testing.T) {
f, ok := reflect.TypeOf(Consignment{}).FieldByName("Deliveryotp")
if !ok {
t.Fatal("Consignment has no Deliveryotp field")
}
if got := f.Tag.Get("json"); got != "-" {
t.Errorf("Deliveryotp json tag = %q, want \"-\"", got)
}
}
// The customer PIN hash predates the OTP flow and is now unused, but the column
// still exists. It must stay unserialised.
func TestCustomerPinHashIsNeverSerialised(t *testing.T) {
f, ok := reflect.TypeOf(AppCustomer{}).FieldByName("Loginpinhash")
if !ok {
t.Fatal("AppCustomer has no Loginpinhash field")
}
if got := f.Tag.Get("json"); got != "-" {
t.Errorf("Loginpinhash json tag = %q, want \"-\"", got)
}
}
// Two columns on pickupbookings that are computed, not stored. A missing
// gorm:"-" would make AutoMigrate create real columns for them and the driver
// would try to write there.
func TestComputedBookingFieldsAreNotColumns(t *testing.T) {
for _, field := range []string{"Consignmentstatus", "Trackingno"} {
f, ok := reflect.TypeOf(PickupBooking{}).FieldByName(field)
if !ok {
t.Fatalf("PickupBooking has no field %s", field)
}
if got := f.Tag.Get("gorm"); got != "-" {
t.Errorf("PickupBooking.%s gorm tag = %q, want \"-\" — it is filled in "+
"by handlers, not stored", field, got)
}
}
}
// The device token is unique across the whole table, not per customer: a shared
// handset must be REASSIGNED rather than duplicated, or one person's parcel
// updates reach another person's phone.
func TestCustomerDeviceTokenIsGloballyUnique(t *testing.T) {
f, _ := reflect.TypeOf(CustomerDevice{}).FieldByName("Token")
if tag := f.Tag.Get("gorm"); !strings.Contains(tag, "uniqueIndex") {
t.Errorf("CustomerDevice.Token gorm tag = %q, want a uniqueIndex — the "+
"upsert on conflict depends on it", tag)
}
}