Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
27548ad
web: the date guard, red first (G1 walk note N15)
futurebuildai Oct 14, 2026
399e982
core: the dealer's locale on the profile, read alone and on the porta…
futurebuildai Oct 14, 2026
fa366ea
web: one date module and the app's own date field, swept (G1 walk not…
futurebuildai Oct 14, 2026
9140852
web: the portal config type carries locale
futurebuildai Oct 14, 2026
80898be
Merge refactor/v1 into refactor/g1-dates (fix round 1)
futurebuildai Oct 14, 2026
bca3214
test(dates): formatDay on a timestamp (red, PR 156 review P1)
futurebuildai Oct 14, 2026
917ba73
web: formatDay takes a business day sent as midnight UTC (PR 156 revi…
futurebuildai Oct 14, 2026
37459e5
test(dealerprofile): a locale the column refuses is a 400 in words (r…
futurebuildai Oct 14, 2026
a8b7673
core: the locale write validates exactly what the column accepts (PR …
futurebuildai Oct 14, 2026
3c857d7
test(gable-date-field): focus follows the day across a month turn (re…
futurebuildai Oct 14, 2026
ff6e185
web: the calendar keeps its focus across a month turn (PR 156 review …
futurebuildai Oct 14, 2026
6e4e6ba
test(dates): the guard catches toLocaleString on a date, toDateString…
futurebuildai Oct 14, 2026
9cf32ee
web: the product tabs' Generated stamps through the module; date-fns …
futurebuildai Oct 14, 2026
1c31930
test(crm): Log Activity posts the picked day at the picked time (red,…
futurebuildai Oct 14, 2026
77faf29
web: Log Activity keeps the time beside the day (PR 156 review P2-4)
futurebuildai Oct 14, 2026
01a3ac7
test(gable-date-field): the popover stays inside the screen (red, PR …
futurebuildai Oct 14, 2026
971e45e
web: the calendar pops fixed at the field, clamped and flipped (PR 15…
futurebuildai Oct 14, 2026
0413b88
test(gable-date-field): the field follows its host surface (red, PR 1…
futurebuildai Oct 14, 2026
82b3c67
web: the date field follows its host surface (PR 156 review P2-6)
futurebuildai Oct 14, 2026
1b34408
test(accounting): the four pages default today in the person's zone (…
futurebuildai Oct 14, 2026
174c7c0
web: the accounting pages default today from the person's zone (PR 15…
futurebuildai Oct 14, 2026
77b901d
test(gable-date-field): the calendar opens in the top layer (red, PR …
futurebuildai Oct 14, 2026
de4d902
web: the calendar opens in the browser's top layer (PR 156 r2 P1)
futurebuildai Oct 14, 2026
bda51a3
test(dates): the guard's input rule at a line's end (red, PR 156 r2 P2)
futurebuildai Oct 14, 2026
9214326
test(dates): the guard's input rule ends at the line's end too (PR 15…
futurebuildai Oct 14, 2026
331c84f
test(gable-date-field): the removal focusout must not lose the focus …
futurebuildai Oct 14, 2026
0864f44
web: the month turn refocus keys off the grid's own handler (PR 156 r…
futurebuildai Oct 14, 2026
0cff4ab
web: the time entry's words, by example (PR 156 r2, the names grep)
futurebuildai Oct 14, 2026
37cdfe8
test(gable-date-field): the popover's default inset and margin are cl…
futurebuildai Oct 15, 2026
924b06b
web: the calendar clears the popover's default inset and margin (PR 1…
futurebuildai Oct 15, 2026
a9eefd3
test(crm): the payload leg drives the day field as a person does
futurebuildai Oct 15, 2026
9881432
Merge refactor/v1 (PR 147 Gable light, PR 157) into refactor/g1-dates
futurebuildai Oct 15, 2026
71dbd82
web: the r4 P3s on the merged tree
futurebuildai Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/api/ROUTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ GET /api/v1/dashboard/summary internal/dashboard h.HandleSummary
GET /api/v1/dashboard/top-customers internal/dashboard h.HandleTopCustomers
GET /api/v1/dealer-profile internal/dealerprofile guarded(g.Read, h.Get)
PUT /api/v1/dealer-profile internal/dealerprofile guarded(g.Write, h.Put)
GET /api/v1/dealer-profile/locale internal/dealerprofile guarded(g.Locale, h.GetLocale)
GET /api/v1/dealer-profile/logo internal/dealerprofile guarded(g.Read, h.GetLogo)
PUT /api/v1/dealer-profile/logo internal/dealerprofile guarded(g.Write, h.PutLogo)
POST /api/v1/delivery/deliveries internal/delivery h.HandleAssignOrder
Expand Down
45 changes: 43 additions & 2 deletions core/api/fragments/dealerprofile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ paths:
tags: [dealer-profile]
summary: Replace the dealer profile
description: >-
Replaces the writable fields (the logo has its own route). Role gated
Replaces the writable fields (the logo has its own route; a locale left
out keeps the stored one). Role gated
admin and owner. The revision precondition applies: If-Match or the
body revision, 428 without either, 409 stale_revision when stale. A
branch bound machine key is refused. The write is audited with the
Expand Down Expand Up @@ -80,6 +81,32 @@ paths:
$ref: "#/components/responses/WirePreconditionRequired"
"500":
$ref: "#/components/responses/WireInternalError"
/api/v1/dealer-profile/locale:
get:
operationId: dealerProfileLocaleGet
tags: [dealer-profile]
summary: Read the dealer's locale alone
description: >-
The locale alone, {"locale": "en-CA"}: every signed in desk person may
call it (the profile read itself is held to the roles that print), so
every page can format its dates for the dealer's locale.
security:
- BearerJWT: []
responses:
"200":
description: The dealer's locale.
content:
application/json:
schema:
$ref: "#/components/schemas/DealerProfileLocale"
"400":
$ref: "#/components/responses/WireBadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"500":
$ref: "#/components/responses/WireInternalError"
/api/v1/dealer-profile/logo:
get:
operationId: dealerProfileLogoGet
Expand Down Expand Up @@ -169,9 +196,16 @@ paths:
$ref: "#/components/responses/WireInternalError"
components:
schemas:
DealerProfileLocale:
type: object
required: [locale]
properties:
locale:
type: string
description: The dealer's locale (a BCP 47 tag).
DealerProfile:
type: object
required: [legal_name, trading_name, phone, email, website, remit_to, logo_url, revision, updated_at]
required: [legal_name, trading_name, phone, email, website, remit_to, locale, logo_url, revision, updated_at]
properties:
legal_name:
type: string
Expand All @@ -186,6 +220,9 @@ components:
remit_to:
type: string
description: The address payments are sent to; invoices and statements print it.
locale:
type: string
description: The dealer's locale (a BCP 47 tag, default en-US); every date the app shows is formatted for it.
logo_url:
type: [string, "null"]
description: /api/v1/dealer-profile/logo when a logo is set, otherwise null.
Expand Down Expand Up @@ -217,6 +254,10 @@ components:
remit_to:
type: string
maxLength: 1000
locale:
type: string
maxLength: 35
description: A BCP 47 tag (en-US, en-CA), refused in words otherwise. Leave it out to keep the stored one.
revision:
type: integer
format: int64
Expand Down
7 changes: 5 additions & 2 deletions core/api/fragments/portal.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ paths:
operationId: portalGetConfig
tags: [portal]
summary: The dealer branding config
description: Public. The one row of white-label branding the login page renders before any session exists.
description: Public. The one row of white-label branding the login page renders before any session exists, with the dealer's locale for the portal's dates.
security: []
responses:
"200":
Expand Down Expand Up @@ -1227,7 +1227,7 @@ components:
format: date-time
PortalConfig:
type: object
required: [id, dealer_name, logo_url, primary_color, support_email, support_phone, created_at, updated_at]
required: [id, dealer_name, logo_url, primary_color, support_email, support_phone, locale, created_at, updated_at]
description: The white-label branding row.
properties:
id:
Expand All @@ -1243,6 +1243,9 @@ components:
type: string
support_phone:
type: string
locale:
type: string
description: The dealer profile's locale (a BCP 47 tag); the portal's pages format their dates for it.
created_at:
type: string
format: date-time
Expand Down
49 changes: 45 additions & 4 deletions core/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3244,7 +3244,7 @@ paths:
tags: [dealer-profile]
summary: Replace the dealer profile
description: >-
Replaces the writable fields (the logo has its own route). Role gated admin and owner. The revision precondition applies: If-Match or the body revision, 428 without either, 409 stale_revision when stale. A branch bound machine key is refused. The write is audited with the before and after in one transaction.
Replaces the writable fields (the logo has its own route; a locale left out keeps the stored one). Role gated admin and owner. The revision precondition applies: If-Match or the body revision, 428 without either, 409 stale_revision when stale. A branch bound machine key is refused. The write is audited with the before and after in one transaction.
security:
- BearerJWT: []
parameters:
Expand Down Expand Up @@ -3279,6 +3279,30 @@ paths:
$ref: "#/components/responses/WirePreconditionRequired"
"500":
$ref: "#/components/responses/WireInternalError"
/api/v1/dealer-profile/locale:
get:
operationId: dealerProfileLocaleGet
tags: [dealer-profile]
summary: Read the dealer's locale alone
description: >-
The locale alone, {"locale": "en-CA"}: every signed in desk person may call it (the profile read itself is held to the roles that print), so every page can format its dates for the dealer's locale.
security:
- BearerJWT: []
responses:
"200":
description: The dealer's locale.
content:
application/json:
schema:
$ref: "#/components/schemas/DealerProfileLocale"
"400":
$ref: "#/components/responses/WireBadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"500":
$ref: "#/components/responses/WireInternalError"
/api/v1/dealer-profile/logo:
get:
operationId: dealerProfileLogoGet
Expand Down Expand Up @@ -9621,7 +9645,7 @@ paths:
operationId: portalGetConfig
tags: [portal]
summary: The dealer branding config
description: Public. The one row of white-label branding the login page renders before any session exists.
description: Public. The one row of white-label branding the login page renders before any session exists, with the dealer's locale for the portal's dates.
security: []
responses:
"200":
Expand Down Expand Up @@ -18295,9 +18319,16 @@ components:
type: integer
format: int64
description: Cents.
DealerProfileLocale:
type: object
required: [locale]
properties:
locale:
type: string
description: The dealer's locale (a BCP 47 tag).
DealerProfile:
type: object
required: [legal_name, trading_name, phone, email, website, remit_to, logo_url, revision, updated_at]
required: [legal_name, trading_name, phone, email, website, remit_to, locale, logo_url, revision, updated_at]
properties:
legal_name:
type: string
Expand All @@ -18312,6 +18343,9 @@ components:
remit_to:
type: string
description: The address payments are sent to; invoices and statements print it.
locale:
type: string
description: The dealer's locale (a BCP 47 tag, default en-US); every date the app shows is formatted for it.
logo_url:
type: [string, "null"]
description: /api/v1/dealer-profile/logo when a logo is set, otherwise null.
Expand Down Expand Up @@ -18343,6 +18377,10 @@ components:
remit_to:
type: string
maxLength: 1000
locale:
type: string
maxLength: 35
description: A BCP 47 tag (en-US, en-CA), refused in words otherwise. Leave it out to keep the stored one.
revision:
type: integer
format: int64
Expand Down Expand Up @@ -23647,7 +23685,7 @@ components:
format: date-time
PortalConfig:
type: object
required: [id, dealer_name, logo_url, primary_color, support_email, support_phone, created_at, updated_at]
required: [id, dealer_name, logo_url, primary_color, support_email, support_phone, locale, created_at, updated_at]
description: The white-label branding row.
properties:
id:
Expand All @@ -23663,6 +23701,9 @@ components:
type: string
support_phone:
type: string
locale:
type: string
description: The dealer profile's locale (a BCP 47 tag); the portal's pages format their dates for it.
created_at:
type: string
format: date-time
Expand Down
2 changes: 1 addition & 1 deletion core/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ require (
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
github.com/xuri/excelize/v2 v2.11.0
golang.org/x/crypto v0.57.0
golang.org/x/text v0.42.0
gopkg.in/yaml.v3 v3.0.1
)

Expand Down Expand Up @@ -51,7 +52,6 @@ require (
golang.org/x/net v0.60.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/time v0.9.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
Expand Down
12 changes: 11 additions & 1 deletion core/internal/app/seed/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ var demoLogoPNG = []byte{

// seedDealerProfile sets the demo's invented dealer profile (migration 115's
// single row) and logo. It writes only a profile that is still blank, so a
// profile an operator has filled in is never overwritten by a re-seed.
// profile an operator has filled in is never overwritten by a re-seed. The
// locale is en-CA (the demo dealer is in British Columbia); a profile already
// seeded by the demo that still sits on the migration's en-US default is
// lifted to en-CA, while any other tag (an operator's own choice) is kept.
func seedDealerProfile(db *sql.DB) {
res, err := db.Exec(`
UPDATE dealer_profile
Expand All @@ -48,6 +51,7 @@ func seedDealerProfile(db *sql.DB) {
email = 'accounts@gablebuildingsupply.example',
website = 'https://gablebuildingsupply.example',
remit_to = 'Gable Building Supply, Accounts Receivable' || E'\n' || 'PO Box 4100, Kelowna BC V1Y 7R7',
locale = 'en-CA',
logo = $1, logo_content_type = 'image/png',
revision = revision + 1, updated_at = NOW()
WHERE id = 1 AND legal_name = '' AND trading_name = '' AND logo IS NULL`, demoLogo())
Expand All @@ -57,5 +61,11 @@ func seedDealerProfile(db *sql.DB) {
}
if n, _ := res.RowsAffected(); n > 0 {
fmt.Println("Seed: Dealer Profile (invented) and logo")
return
}
if _, err := db.Exec(`
UPDATE dealer_profile SET locale = 'en-CA', updated_at = NOW()
WHERE id = 1 AND locale = 'en-US' AND trading_name = 'Gable Building Supply'`); err != nil {
log.Printf("seed: dealer profile locale: %v", err)
}
}
41 changes: 30 additions & 11 deletions core/internal/app/seed/seed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -426,36 +426,40 @@ func TestPosRegisterUpsert_MovesOnlyTheSeedsOwnRow(t *testing.T) {
}

// The demo seed sets an invented dealer profile and logo, once: a profile the
// operator has filled in is not overwritten by a re-seed.
// operator has filled in is not overwritten by a re-seed. The locale is
// en-CA (the demo dealer is in British Columbia): set on the blank write, and
// lifted from the migration's en-US default on a profile the demo already
// seeded; an operator's own tag is kept.
//
// DB-gated: it runs the seeder's actual statement against the single row.
func TestDealerProfileSeed_SetsOnlyABlankProfile(t *testing.T) {
db := testutil.RequireDB(t)
ctx := context.Background()
type snap struct {
legal, trading, phone, email, website, remit string
locale string
logo []byte
ctype *string
revision int64
}
var orig snap
if err := db.Pool.QueryRow(ctx, `SELECT legal_name, trading_name, phone, email, website, remit_to, logo, logo_content_type, revision FROM dealer_profile WHERE id = 1`).
Scan(&orig.legal, &orig.trading, &orig.phone, &orig.email, &orig.website, &orig.remit, &orig.logo, &orig.ctype, &orig.revision); err != nil {
if err := db.Pool.QueryRow(ctx, `SELECT legal_name, trading_name, phone, email, website, remit_to, locale, logo, logo_content_type, revision FROM dealer_profile WHERE id = 1`).
Scan(&orig.legal, &orig.trading, &orig.phone, &orig.email, &orig.website, &orig.remit, &orig.locale, &orig.logo, &orig.ctype, &orig.revision); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = db.Pool.Exec(ctx, `UPDATE dealer_profile SET legal_name=$1, trading_name=$2, phone=$3, email=$4, website=$5, remit_to=$6, logo=$7, logo_content_type=$8, revision=$9 WHERE id = 1`,
orig.legal, orig.trading, orig.phone, orig.email, orig.website, orig.remit, orig.logo, orig.ctype, orig.revision)
_, _ = db.Pool.Exec(ctx, `UPDATE dealer_profile SET legal_name=$1, trading_name=$2, phone=$3, email=$4, website=$5, remit_to=$6, locale=$7, logo=$8, logo_content_type=$9, revision=$10 WHERE id = 1`,
orig.legal, orig.trading, orig.phone, orig.email, orig.website, orig.remit, orig.locale, orig.logo, orig.ctype, orig.revision)
})
blank := func() {
t.Helper()
if _, err := db.Pool.Exec(ctx, `UPDATE dealer_profile SET legal_name='', trading_name='', phone='', email='', website='', remit_to='', logo=NULL, logo_content_type=NULL WHERE id = 1`); err != nil {
if _, err := db.Pool.Exec(ctx, `UPDATE dealer_profile SET legal_name='', trading_name='', phone='', email='', website='', remit_to='', locale='en-US', logo=NULL, logo_content_type=NULL WHERE id = 1`); err != nil {
t.Fatal(err)
}
}
read := func() (legal, remit string, hasLogo bool) {
read := func() (legal, remit, locale string, hasLogo bool) {
t.Helper()
if err := db.Pool.QueryRow(ctx, `SELECT legal_name, remit_to, logo IS NOT NULL FROM dealer_profile WHERE id = 1`).Scan(&legal, &remit, &hasLogo); err != nil {
if err := db.Pool.QueryRow(ctx, `SELECT legal_name, remit_to, locale, logo IS NOT NULL FROM dealer_profile WHERE id = 1`).Scan(&legal, &remit, &locale, &hasLogo); err != nil {
t.Fatal(err)
}
return
Expand All @@ -472,16 +476,31 @@ func TestDealerProfileSeed_SetsOnlyABlankProfile(t *testing.T) {

blank()
seedDealerProfile(sqlDB)
legal, remit, hasLogo := read()
legal, remit, locale, hasLogo := read()
if legal != "Gable Building Supply Ltd" || !strings.Contains(remit, "Accounts Receivable") || !hasLogo {
t.Fatalf("a blank profile was not seeded: %q %q logo=%v", legal, remit, hasLogo)
}
if _, err := db.Pool.Exec(ctx, `UPDATE dealer_profile SET legal_name = 'Edited By The Operator Ltd' WHERE id = 1`); err != nil {
if locale != "en-CA" {
t.Errorf("the seeded locale = %q, want en-CA", locale)
}
if _, err := db.Pool.Exec(ctx, `UPDATE dealer_profile SET legal_name = 'Edited By The Operator Ltd', locale = 'fr-CA' WHERE id = 1`); err != nil {
t.Fatal(err)
}
seedDealerProfile(sqlDB)
if legal, _, _ := read(); legal != "Edited By The Operator Ltd" {
if legal, _, locale, _ := read(); legal != "Edited By The Operator Ltd" {
t.Errorf("a re-seed overwrote the operator's profile: %q", legal)
} else if locale != "fr-CA" {
t.Errorf("a re-seed overwrote the operator's locale: %q", locale)
}

// A profile the demo already seeded that still sits on the migration's
// en-US default is lifted to en-CA.
if _, err := db.Pool.Exec(ctx, `UPDATE dealer_profile SET legal_name='Gable Building Supply Ltd', trading_name='Gable Building Supply', locale='en-US' WHERE id = 1`); err != nil {
t.Fatal(err)
}
seedDealerProfile(sqlDB)
if _, _, locale, _ := read(); locale != "en-CA" {
t.Errorf("the demo default was not lifted to en-CA: %q", locale)
}
}

Expand Down
9 changes: 6 additions & 3 deletions core/internal/app/serve/wire_branch_wall.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,10 +355,13 @@ func (w *branchWall) emailDeliveries(mux *http.ServeMux, h *emaildelivery.Handle
// dealerProfile mounts the dealer profile: every role that prints reads it
// and its logo, and only admin and owner write them. The profile is one
// dealer wide row, so it runs behind the role guard alone and a branch bound
// key is refused the writes by the route allowlist gate.
// key is refused the writes by the route allowlist gate. The locale alone is
// the one read every signed in desk person may call (a cashier, the yard and
// a driver format their dates for it too).
func (w *branchWall) dealerProfile(mux *http.ServeMux, h *dealerprofile.Handler) {
h.RegisterRoutes(mux, dealerprofile.Guards{
Read: middleware.RequireRole("admin", "owner", "sales", "finance", "purchasing"),
Write: middleware.RequireRole("admin", "owner"),
Read: middleware.RequireRole("admin", "owner", "sales", "finance", "purchasing"),
Write: middleware.RequireRole("admin", "owner"),
Locale: middleware.RequireRole("admin", "owner", "sales", "finance", "purchasing", "warehouse", "cashier", "driver"),
})
}
Loading
Loading