updates on the ai and agent and all thse things awith onboarding
This commit is contained in:
64
controllers/aiInsightsController.go
Normal file
64
controllers/aiInsightsController.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"doormile/db"
|
||||
"doormile/internal/ai/telemetry"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// GetAIInsights — GET /admin/ai/insights?days=7
|
||||
//
|
||||
// What AI_engine's agents did over the window: runs and failures per agent
|
||||
// (aiagentruns, from telemetry.task), decisions by type and outcome
|
||||
// (agent_decisions), and each agent's latest heartbeat (Redis). `receiving`
|
||||
// says whether this backend is subscribed to the telemetry at all, so an
|
||||
// empty page can tell "not connected" from "nothing happened".
|
||||
func GetAIInsights(c *fiber.Ctx) error {
|
||||
days := telemetry.ClampDays(c.QueryInt("days", 7))
|
||||
// A real instant: aiagentruns.receivedat and agent_decisions.created_at are
|
||||
// both timestamptz (see telemetry.NewRecorder on why not utils.DBNow).
|
||||
since := time.Now().AddDate(0, 0, -days)
|
||||
|
||||
runs, err := telemetry.RunStats(db.DB, since)
|
||||
if err != nil {
|
||||
utils.Error("ai insights: runs", "error", err.Error())
|
||||
return utils.Internal(c, "failed to read agent runs")
|
||||
}
|
||||
decisions, err := telemetry.DecisionCounts(db.DB, since)
|
||||
if err != nil {
|
||||
utils.Error("ai insights: decisions", "error", err.Error())
|
||||
return utils.Internal(c, "failed to read agent decisions")
|
||||
}
|
||||
|
||||
var engineAgents []string
|
||||
if err := db.DB.Model(&models.AIAgent{}).Where("runtime = ?", "engine").Order("sortorder").Pluck("agentid", &engineAgents).Error; err != nil {
|
||||
utils.Error("ai insights: agents", "error", err.Error())
|
||||
}
|
||||
|
||||
return utils.OK(c, telemetry.Insights{
|
||||
Days: days,
|
||||
Since: since,
|
||||
Receiving: telemetry.Receiving.Load(),
|
||||
Runs: telemetry.SummariseRuns(runs),
|
||||
Decisions: telemetry.SummariseDecisions(decisions),
|
||||
Live: telemetry.LiveStates(db.Rdb, engineAgents),
|
||||
})
|
||||
}
|
||||
|
||||
// GetAIDecisions — GET /admin/ai/decisions?type=&before=&limit=
|
||||
//
|
||||
// Recent agent decisions, newest first, keyset-paged by id. Reasoning is
|
||||
// trimmed and the context column is left out (it can hold rider data).
|
||||
func GetAIDecisions(c *fiber.Ctx) error {
|
||||
rows, err := telemetry.RecentDecisions(db.DB, c.Query("type"), uint64(c.QueryInt("before", 0)), c.QueryInt("limit", 25))
|
||||
if err != nil {
|
||||
utils.Error("ai insights: recent decisions", "error", err.Error())
|
||||
return utils.Internal(c, "failed to read agent decisions")
|
||||
}
|
||||
return utils.List(c, rows, int64(len(rows)))
|
||||
}
|
||||
127
controllers/aiPlaygroundController.go
Normal file
127
controllers/aiPlaygroundController.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"doormile/db"
|
||||
"doormile/internal/ai/playground"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// POST /admin/ai/playground/run — Agent Studio's Test tab. Runs one prompt
|
||||
// through the configured model with a registry skill's tools; see internal/ai/playground for
|
||||
// what executes and what only becomes a proposal. Staff only, roleid 1 only,
|
||||
// and rate-limited per user because every run is a paid API call.
|
||||
|
||||
// PlaygroundModel is the model client the playground uses (an OpenAI-compatible
|
||||
// provider such as Groq, see main.go). Nil until PLAYGROUND_LLM_API_KEY is set; the endpoint then answers 503 and the console keeps the
|
||||
// Test tab labelled as unavailable.
|
||||
var PlaygroundModel playground.Model
|
||||
|
||||
const (
|
||||
playgroundRunTimeout = 120 * time.Second
|
||||
playgroundRunsPerWin = 10
|
||||
playgroundWindow = 10 * time.Minute
|
||||
)
|
||||
|
||||
type playgroundLimiter struct {
|
||||
mu sync.Mutex
|
||||
runs map[string][]time.Time
|
||||
}
|
||||
|
||||
var playgroundRuns = &playgroundLimiter{runs: map[string][]time.Time{}}
|
||||
|
||||
// allow records a run for key and reports whether it is within the limit.
|
||||
func (l *playgroundLimiter) allow(key string, now time.Time) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
kept := l.runs[key][:0]
|
||||
for _, t := range l.runs[key] {
|
||||
if now.Sub(t) < playgroundWindow {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) >= playgroundRunsPerWin {
|
||||
l.runs[key] = kept
|
||||
return false
|
||||
}
|
||||
l.runs[key] = append(kept, now)
|
||||
return true
|
||||
}
|
||||
|
||||
// RunAIPlayground — POST /admin/ai/playground/run {agentid, skillid?, prompt}
|
||||
func RunAIPlayground(c *fiber.Ctx) error {
|
||||
if PlaygroundModel == nil {
|
||||
return utils.Fail(c, fiber.StatusServiceUnavailable, "PLAYGROUND_NOT_CONFIGURED",
|
||||
"The Test playground has no model configured on this server.")
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Agentid string `json:"agentid"`
|
||||
Skillid string `json:"skillid"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
body.Prompt = strings.TrimSpace(body.Prompt)
|
||||
if body.Agentid == "" || body.Prompt == "" {
|
||||
return utils.BadRequest(c, "agentid and prompt are required")
|
||||
}
|
||||
if utf8.RuneCountInString(body.Prompt) > playground.MaxPromptChars {
|
||||
return utils.BadRequest(c, "prompt is too long (at most 2000 characters)")
|
||||
}
|
||||
|
||||
actor := actorOf(c)
|
||||
key := actor.Email
|
||||
if key == "" {
|
||||
key = "user:" + strconv.Itoa(actor.UserID)
|
||||
}
|
||||
if !playgroundRuns.allow(key, time.Now()) {
|
||||
return utils.Fail(c, fiber.StatusTooManyRequests, "PLAYGROUND_RATE_LIMITED",
|
||||
"Playground limit reached: 10 runs per 10 minutes. Try again shortly.")
|
||||
}
|
||||
|
||||
snap, ok := loadRegistry(c)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
plan, err := playground.Prepare(snap, body.Agentid, body.Skillid)
|
||||
if errors.Is(err, playground.ErrNotFound) {
|
||||
return utils.NotFound(c, err.Error())
|
||||
}
|
||||
if err != nil {
|
||||
return utils.BadRequest(c, err.Error())
|
||||
}
|
||||
|
||||
// An OpenAI-compatible provider serves its own configured model, not the
|
||||
// agent's registry pin (a Claude id AI_engine uses); report the real one.
|
||||
if named, ok := PlaygroundModel.(interface{ ModelName() string }); ok {
|
||||
plan.Model = named.ModelName()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), playgroundRunTimeout)
|
||||
defer cancel()
|
||||
trace, err := playground.Run(ctx, PlaygroundModel, plan, body.Prompt, playground.Executors(db.DB, db.Rdb))
|
||||
utils.Info("ai playground run", "email", actor.Email, "agent", plan.AgentID, "skill", plan.SkillID,
|
||||
"model", plan.Model, "turns", trace.Turns, "ms", trace.Ms, "failed", err != nil)
|
||||
if err != nil {
|
||||
utils.Error("ai playground: model call failed", "error", err.Error())
|
||||
var pe *playground.ProviderError
|
||||
if errors.As(err, &pe) && pe.Status == fiber.StatusTooManyRequests {
|
||||
return utils.Fail(c, fiber.StatusTooManyRequests, "PLAYGROUND_PROVIDER_RATE_LIMITED",
|
||||
"The model provider's rate limit was reached (common on free plans). Wait a minute and try again.")
|
||||
}
|
||||
return utils.Fail(c, fiber.StatusBadGateway, "PLAYGROUND_MODEL_FAILED",
|
||||
"The model request failed; nothing was changed. Try again.")
|
||||
}
|
||||
return utils.OK(c, trace)
|
||||
}
|
||||
215
controllers/aiRegistryController.go
Normal file
215
controllers/aiRegistryController.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"doormile/db"
|
||||
"doormile/internal/ai/registry"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// The AI agent registry: /admin/ai/* for the console's Agent Studio and
|
||||
// /internal/ai/registry for AI_engine. Every route here sits behind
|
||||
// DoormileStaffOnly (admin) or InternalKeyAuth (internal); writes additionally
|
||||
// require roleid 1. Logic lives in internal/ai/registry — these handlers only
|
||||
// translate HTTP.
|
||||
|
||||
// registryError maps a registry error to a response. Validation messages are
|
||||
// written for operators and returned as-is; anything else is logged, not leaked.
|
||||
func registryError(c *fiber.Ctx, err error, what string) error {
|
||||
var v *registry.ValidationError
|
||||
switch {
|
||||
case errors.As(err, &v):
|
||||
return utils.BadRequest(c, v.Msg)
|
||||
case errors.Is(err, registry.ErrNotFound):
|
||||
return utils.NotFound(c, what+" not found")
|
||||
default:
|
||||
utils.Error("ai registry: "+what, "error", err.Error())
|
||||
return utils.Internal(c, "failed to update the agent registry")
|
||||
}
|
||||
}
|
||||
|
||||
// actorOf is the caller as the registry audit records it: the user id and the
|
||||
// email from the token (set by AuthMiddleware).
|
||||
func actorOf(c *fiber.Ctx) registry.Actor {
|
||||
userID, _ := c.Locals("userid").(int)
|
||||
email, _ := c.Locals("email").(string)
|
||||
return registry.Actor{UserID: userID, Email: email}
|
||||
}
|
||||
|
||||
func loadRegistry(c *fiber.Ctx) (*registry.Snapshot, bool) {
|
||||
snap, err := registry.Load(db.DB)
|
||||
if err != nil {
|
||||
utils.Error("ai registry: load", "error", err.Error())
|
||||
_ = utils.Internal(c, "failed to read the agent registry")
|
||||
return nil, false
|
||||
}
|
||||
return snap, true
|
||||
}
|
||||
|
||||
// GetAIAgents — GET /admin/ai/agents
|
||||
func GetAIAgents(c *fiber.Ctx) error {
|
||||
snap, ok := loadRegistry(c)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return utils.List(c, snap.Agents, int64(len(snap.Agents)))
|
||||
}
|
||||
|
||||
// GetAIAgent — GET /admin/ai/agents/:id, the agent with its skills and tools.
|
||||
func GetAIAgent(c *fiber.Ctx) error {
|
||||
snap, ok := loadRegistry(c)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
id := c.Params("id")
|
||||
for _, a := range snap.Agents {
|
||||
if a.Agentid != id {
|
||||
continue
|
||||
}
|
||||
skills := []registry.SkillView{}
|
||||
used := map[string]bool{}
|
||||
for _, s := range snap.Skills {
|
||||
if s.Agentid == id {
|
||||
skills = append(skills, s)
|
||||
for _, t := range s.Tools {
|
||||
used[t] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
tools := []registry.ToolView{}
|
||||
for _, t := range snap.Tools {
|
||||
if used[t.Toolname] {
|
||||
tools = append(tools, t)
|
||||
}
|
||||
}
|
||||
return utils.OK(c, fiber.Map{"agent": a, "skills": skills, "tools": tools})
|
||||
}
|
||||
return utils.NotFound(c, "agent not found")
|
||||
}
|
||||
|
||||
// GetAISkills — GET /admin/ai/skills[?agent=]
|
||||
func GetAISkills(c *fiber.Ctx) error {
|
||||
snap, ok := loadRegistry(c)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
agent := c.Query("agent")
|
||||
out := []registry.SkillView{}
|
||||
for _, s := range snap.Skills {
|
||||
if agent == "" || s.Agentid == agent {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return utils.List(c, out, int64(len(out)))
|
||||
}
|
||||
|
||||
// GetAITools — GET /admin/ai/tools[?kind=]
|
||||
func GetAITools(c *fiber.Ctx) error {
|
||||
snap, ok := loadRegistry(c)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
kind := c.Query("kind")
|
||||
out := []registry.ToolView{}
|
||||
for _, t := range snap.Tools {
|
||||
if kind == "" || t.Kind == kind {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return utils.List(c, out, int64(len(out)))
|
||||
}
|
||||
|
||||
// PatchAISkill — PATCH /admin/ai/skills/:id {enabled?, thresholds?}
|
||||
func PatchAISkill(c *fiber.Ctx) error {
|
||||
var p registry.SkillPatch
|
||||
if err := c.BodyParser(&p); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
if err := registry.UpdateSkill(db.DB, c.Params("id"), p, actorOf(c)); err != nil {
|
||||
return registryError(c, err, "skill")
|
||||
}
|
||||
snap, ok := loadRegistry(c)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, s := range snap.Skills {
|
||||
if s.Skillid == c.Params("id") {
|
||||
return utils.OK(c, s)
|
||||
}
|
||||
}
|
||||
return utils.NotFound(c, "skill not found")
|
||||
}
|
||||
|
||||
// CreateAISkill — POST /admin/ai/skills
|
||||
func CreateAISkill(c *fiber.Ctx) error {
|
||||
var n registry.NewSkill
|
||||
if err := c.BodyParser(&n); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
id, err := registry.CreateSkill(db.DB, n, actorOf(c))
|
||||
if err != nil {
|
||||
return registryError(c, err, "skill")
|
||||
}
|
||||
snap, ok := loadRegistry(c)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, s := range snap.Skills {
|
||||
if s.Skillid == id {
|
||||
return utils.Created(c, s)
|
||||
}
|
||||
}
|
||||
return utils.Internal(c, "skill was created but could not be read back")
|
||||
}
|
||||
|
||||
// PatchAIAgent — PATCH /admin/ai/agents/:id {autonomous?, model?, confirm?}
|
||||
func PatchAIAgent(c *fiber.Ctx) error {
|
||||
var p registry.AgentPatch
|
||||
if err := c.BodyParser(&p); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
if err := registry.UpdateAgent(db.DB, c.Params("id"), p, actorOf(c)); err != nil {
|
||||
return registryError(c, err, "agent")
|
||||
}
|
||||
snap, ok := loadRegistry(c)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, a := range snap.Agents {
|
||||
if a.Agentid == c.Params("id") {
|
||||
return utils.OK(c, a)
|
||||
}
|
||||
}
|
||||
return utils.NotFound(c, "agent not found")
|
||||
}
|
||||
|
||||
// GetAIRegistryAudit — GET /admin/ai/audit[?limit=]
|
||||
func GetAIRegistryAudit(c *fiber.Ctx) error {
|
||||
rows, err := registry.ListAudit(db.DB, c.QueryInt("limit", 100))
|
||||
if err != nil {
|
||||
utils.Error("ai registry: audit", "error", err.Error())
|
||||
return utils.Internal(c, "failed to read the registry audit")
|
||||
}
|
||||
return utils.List(c, rows, int64(len(rows)))
|
||||
}
|
||||
|
||||
// GetInternalAIRegistry — GET /internal/ai/registry, for AI_engine.
|
||||
//
|
||||
// Sends an ETag and honours If-None-Match, so the engine can poll every few
|
||||
// seconds and receive a 304 with no body until something actually changes.
|
||||
func GetInternalAIRegistry(c *fiber.Ctx) error {
|
||||
snap, ok := loadRegistry(c)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
tag := registry.ETag(snap)
|
||||
c.Set(fiber.HeaderETag, tag)
|
||||
c.Set(fiber.HeaderCacheControl, "no-cache")
|
||||
if c.Get(fiber.HeaderIfNoneMatch) == tag {
|
||||
return c.SendStatus(fiber.StatusNotModified)
|
||||
}
|
||||
return utils.OK(c, snap)
|
||||
}
|
||||
566
controllers/clientOnboardingController.go
Normal file
566
controllers/clientOnboardingController.go
Normal file
@@ -0,0 +1,566 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"doormile/db"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Client onboarding: one call creates everything a new client needs to sign in
|
||||
// to the console, in one transaction —
|
||||
//
|
||||
// tenants the client company (the tenant every booking is scoped to)
|
||||
// doormile_auth the console login LoginAdmin checks: email, bcrypt hash,
|
||||
// role "manager", tenantid = the new tenant
|
||||
// appusers the user row LoginAdmin reads the userid and name from,
|
||||
// roleid 3, tenantid = the new tenant
|
||||
//
|
||||
// A client needs all three: an appusers row alone cannot log in (LoginAdmin
|
||||
// authenticates against doormile_auth), and a doormile_auth row without a
|
||||
// tenantid would be Doormile STAFF — unscoped, seeing every client's data.
|
||||
//
|
||||
// The client login gets role "manager" (roleid 3), not "admin": nothing a
|
||||
// client does needs roleid 1, and roleid 1 is what gates the agent-registry
|
||||
// writes. Their data scope comes from the tenantid in the token.
|
||||
//
|
||||
// Routes sit behind ClientOnboardingOwnerOnly (see routes.go).
|
||||
|
||||
const clientLoginRole = "manager"
|
||||
const clientLoginRoleID = 3
|
||||
|
||||
var indianMobile = regexp.MustCompile(`^[6-9]\d{9}$`)
|
||||
|
||||
type onboardClientRequest struct {
|
||||
Companyname string `json:"companyname"`
|
||||
Contactname string `json:"contactname"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Password string `json:"password"`
|
||||
Applocationid int `json:"applocationid"`
|
||||
Requiredeliveryotp bool `json:"requiredeliveryotp"`
|
||||
}
|
||||
|
||||
// normalisePhone strips spaces, dashes and a +91/91/0 prefix.
|
||||
func normalisePhone(p string) string {
|
||||
p = strings.NewReplacer(" ", "", "-", "", "(", "", ")", "").Replace(strings.TrimSpace(p))
|
||||
p = strings.TrimPrefix(p, "+91")
|
||||
if len(p) == 12 && strings.HasPrefix(p, "91") {
|
||||
p = p[2:]
|
||||
}
|
||||
if len(p) == 11 && strings.HasPrefix(p, "0") {
|
||||
p = p[1:]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// validate normalises the request in place and returns an operator-readable
|
||||
// message for the first problem, or "".
|
||||
func (r *onboardClientRequest) validate() string {
|
||||
r.Companyname = strings.Join(strings.Fields(r.Companyname), " ")
|
||||
r.Contactname = strings.Join(strings.Fields(r.Contactname), " ")
|
||||
r.Email = strings.ToLower(strings.TrimSpace(r.Email))
|
||||
r.Phone = normalisePhone(r.Phone)
|
||||
|
||||
switch n := utf8.RuneCountInString(r.Companyname); {
|
||||
case n < 2:
|
||||
return "company name is required"
|
||||
case n > 120:
|
||||
return "company name is too long (at most 120 characters)"
|
||||
}
|
||||
if utf8.RuneCountInString(r.Contactname) < 2 || utf8.RuneCountInString(r.Contactname) > 80 {
|
||||
return "contact person's name is required (at most 80 characters)"
|
||||
}
|
||||
if addr, err := mail.ParseAddress(r.Email); err != nil || addr.Address != r.Email || !strings.Contains(r.Email[strings.LastIndex(r.Email, "@"):], ".") {
|
||||
return "enter a valid email address"
|
||||
}
|
||||
if !indianMobile.MatchString(r.Phone) {
|
||||
return "enter a valid 10-digit mobile number"
|
||||
}
|
||||
switch n := utf8.RuneCountInString(r.Password); {
|
||||
case n < 8:
|
||||
return "password must be at least 8 characters"
|
||||
case n > 72: // bcrypt ignores everything past 72 bytes
|
||||
return "password is too long (at most 72 characters)"
|
||||
}
|
||||
if strings.EqualFold(r.Password, r.Email) || strings.EqualFold(r.Password, r.Phone) {
|
||||
return "password must not be the email or the phone number"
|
||||
}
|
||||
if r.Applocationid <= 0 {
|
||||
return "choose the client's operating city"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// errOnboardingConflict carries a 409 message out of the transaction.
|
||||
type errOnboardingConflict struct{ msg string }
|
||||
|
||||
func (e errOnboardingConflict) Error() string { return e.msg }
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "23505") || strings.Contains(strings.ToLower(s), "duplicate key")
|
||||
}
|
||||
|
||||
// onboardingOwnerStillValid re-reads the caller's doormile_auth row: still an
|
||||
// admin, still Doormile staff. The middleware checked the token; this checks
|
||||
// the account behind it has not been removed or demoted since it was issued.
|
||||
func onboardingOwnerStillValid(email string) bool {
|
||||
var n int64
|
||||
db.DB.Model(&models.DoormileAuth{}).
|
||||
Where("LOWER(email) = ? AND role = ? AND tenantid IS NULL", strings.ToLower(email), "admin").
|
||||
Count(&n)
|
||||
return n == 1
|
||||
}
|
||||
|
||||
// OnboardClient — POST /admin/clients/onboard
|
||||
func OnboardClient(c *fiber.Ctx) error {
|
||||
actor := actorOf(c)
|
||||
if !onboardingOwnerStillValid(actor.Email) {
|
||||
return utils.Forbidden(c, "client onboarding is restricted to the designated onboarding account")
|
||||
}
|
||||
|
||||
req := new(onboardClientRequest)
|
||||
if err := c.BodyParser(req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
if msg := req.validate(); msg != "" {
|
||||
return utils.BadRequest(c, msg)
|
||||
}
|
||||
|
||||
hash, err := utils.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
return utils.Internal(c, "failed to process the password")
|
||||
}
|
||||
|
||||
var tenant models.Tenant
|
||||
var user models.AppUser
|
||||
var auth models.DoormileAuth
|
||||
|
||||
err = db.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var city models.AppLocation
|
||||
if err := tx.Where("applocationid = ?", req.Applocationid).First(&city).Error; err != nil {
|
||||
return errOnboardingConflict{"that operating city does not exist"}
|
||||
}
|
||||
|
||||
var n int64
|
||||
tx.Model(&models.Tenant{}).Where("LOWER(tenantname) = LOWER(?)", req.Companyname).Count(&n)
|
||||
if n > 0 {
|
||||
return errOnboardingConflict{"a client with this company name already exists"}
|
||||
}
|
||||
tx.Model(&models.DoormileAuth{}).Where("LOWER(email) = ?", req.Email).Count(&n)
|
||||
if n > 0 {
|
||||
return errOnboardingConflict{"this email already has a console login"}
|
||||
}
|
||||
tx.Model(&models.AppUser{}).Where("LOWER(email) = ?", req.Email).Count(&n)
|
||||
if n > 0 {
|
||||
return errOnboardingConflict{"this email is already used by another user"}
|
||||
}
|
||||
|
||||
tenant = models.Tenant{
|
||||
Tenantname: req.Companyname,
|
||||
Primaryemail: req.Email,
|
||||
Primarycontact: req.Phone,
|
||||
Status: "Active",
|
||||
Requiredeliveryotp: req.Requiredeliveryotp,
|
||||
}
|
||||
if err := tx.Create(&tenant).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tenantID := tenant.Tenantid
|
||||
auth = models.DoormileAuth{Email: req.Email, PasswordHash: hash, Role: clientLoginRole, Tenantid: &tenantID}
|
||||
if err := tx.Create(&auth).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user = models.AppUser{
|
||||
Authname: req.Contactname,
|
||||
Email: req.Email,
|
||||
Contactno: req.Phone,
|
||||
Password: hash,
|
||||
Roleid: clientLoginRoleID,
|
||||
Tenantid: tenantID,
|
||||
Applocationid: req.Applocationid,
|
||||
Status: "Active",
|
||||
}
|
||||
return tx.Create(&user).Error
|
||||
})
|
||||
|
||||
var conflict errOnboardingConflict
|
||||
switch {
|
||||
case errors.As(err, &conflict):
|
||||
if conflict.msg == "that operating city does not exist" {
|
||||
return utils.BadRequest(c, conflict.msg)
|
||||
}
|
||||
return utils.Conflict(c, conflict.msg)
|
||||
case err != nil && isUniqueViolation(err):
|
||||
// Lost a race with a concurrent onboarding of the same email.
|
||||
return utils.Conflict(c, "this email already has a console login")
|
||||
case err != nil:
|
||||
utils.Error("client onboarding failed", "error", err.Error(), "by", actor.Email)
|
||||
return utils.Internal(c, "failed to onboard the client; nothing was created")
|
||||
}
|
||||
|
||||
utils.Info("client onboarded", "by", actor.Email, "tenantid", tenant.Tenantid, "login", auth.Email, "userid", user.Userid)
|
||||
|
||||
return utils.Created(c, fiber.Map{
|
||||
"tenant": fiber.Map{
|
||||
"tenantid": tenant.Tenantid,
|
||||
"tenantname": tenant.Tenantname,
|
||||
"primaryemail": tenant.Primaryemail,
|
||||
"primarycontact": tenant.Primarycontact,
|
||||
"status": tenant.Status,
|
||||
"requiredeliveryotp": tenant.Requiredeliveryotp,
|
||||
},
|
||||
"login": fiber.Map{
|
||||
"email": auth.Email,
|
||||
"role": auth.Role,
|
||||
"userid": user.Userid,
|
||||
"name": user.Authname,
|
||||
"tenantid": tenant.Tenantid,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type onboardedClient struct {
|
||||
Authid uint64 `json:"authid"`
|
||||
Tenantid int `json:"tenantid"`
|
||||
Tenantname string `json:"tenantname"`
|
||||
Primaryemail string `json:"primaryemail"`
|
||||
Primarycontact string `json:"primarycontact"`
|
||||
Status string `json:"status"`
|
||||
Requiredeliveryotp bool `json:"requiredeliveryotp"`
|
||||
Contactname string `json:"contactname"`
|
||||
Loginemail string `json:"loginemail"`
|
||||
Loginrole string `json:"loginrole"`
|
||||
Logincreatedat *time.Time `json:"logincreatedat"`
|
||||
}
|
||||
|
||||
// realTime drops the zero/placeholder timestamps some older logins carry (they
|
||||
// render as "1 Jan 0001"), so the console shows "—" instead of a fake date.
|
||||
func realTime(t *time.Time) *time.Time {
|
||||
if t == nil || t.Year() < 2000 {
|
||||
return nil
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// GetOnboardedClients — GET /admin/clients/onboarded: the clients that have a
|
||||
// console login, newest first. One row per login. Never returns a password hash.
|
||||
func GetOnboardedClients(c *fiber.Ctx) error {
|
||||
if !onboardingOwnerStillValid(actorOf(c).Email) {
|
||||
return utils.Forbidden(c, "client onboarding is restricted to the designated onboarding account")
|
||||
}
|
||||
var rows []onboardedClientRow
|
||||
err := db.DB.Table("doormile_auth AS a").
|
||||
Select(`a.id AS authid, t.tenantid, t.tenantname, t.primaryemail, t.primarycontact, t.status,
|
||||
t.requiredeliveryotp, COALESCE(u.authname, '') AS contactname,
|
||||
a.email AS loginemail, a.role AS loginrole,
|
||||
a.created_at AS authcreatedat, t.createdat AS tenantcreatedat`).
|
||||
Joins("JOIN tenants t ON t.tenantid = a.tenantid").
|
||||
Joins("LEFT JOIN appusers u ON LOWER(u.email) = LOWER(a.email) AND u.tenantid = a.tenantid").
|
||||
Where("a.tenantid IS NOT NULL").
|
||||
Order("a.id DESC").
|
||||
Limit(200).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
utils.Error("list onboarded clients", "error", err.Error())
|
||||
return utils.Internal(c, "failed to list clients")
|
||||
}
|
||||
out := make([]onboardedClient, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, r.toClient())
|
||||
}
|
||||
return utils.List(c, out, int64(len(out)))
|
||||
}
|
||||
|
||||
// onboardedClientRow is what the list query scans into. It is deliberately
|
||||
// FLAT with every column named: GORM silently skips an embedded struct of an
|
||||
// unexported type, which once left every field but the dates empty (and every
|
||||
// authid 0). TestOnboardedClientRowMapsEveryColumn guards this.
|
||||
type onboardedClientRow struct {
|
||||
Authid uint64 `gorm:"column:authid"`
|
||||
Tenantid int `gorm:"column:tenantid"`
|
||||
Tenantname string `gorm:"column:tenantname"`
|
||||
Primaryemail string `gorm:"column:primaryemail"`
|
||||
Primarycontact string `gorm:"column:primarycontact"`
|
||||
Status string `gorm:"column:status"`
|
||||
Requiredeliveryotp bool `gorm:"column:requiredeliveryotp"`
|
||||
Contactname string `gorm:"column:contactname"`
|
||||
Loginemail string `gorm:"column:loginemail"`
|
||||
Loginrole string `gorm:"column:loginrole"`
|
||||
Authcreatedat *time.Time `gorm:"column:authcreatedat"`
|
||||
Tenantcreatedat *time.Time `gorm:"column:tenantcreatedat"`
|
||||
}
|
||||
|
||||
func (r onboardedClientRow) toClient() onboardedClient {
|
||||
created := realTime(r.Authcreatedat) // timestamptz: already the right instant
|
||||
if created == nil {
|
||||
// tenants.createdat is a legacy timestamp WITHOUT zone holding IST
|
||||
// digits; read as UTC it shows 5h30m late. utils.IST puts it right.
|
||||
if t := realTime(r.Tenantcreatedat); t != nil {
|
||||
ist := utils.IST(*t)
|
||||
created = &ist
|
||||
}
|
||||
}
|
||||
return onboardedClient{
|
||||
Authid: r.Authid, Tenantid: r.Tenantid, Tenantname: r.Tenantname,
|
||||
Primaryemail: r.Primaryemail, Primarycontact: r.Primarycontact, Status: r.Status,
|
||||
Requiredeliveryotp: r.Requiredeliveryotp, Contactname: r.Contactname,
|
||||
Loginemail: r.Loginemail, Loginrole: r.Loginrole, Logincreatedat: created,
|
||||
}
|
||||
}
|
||||
|
||||
// loadClientLogin finds a CLIENT login by doormile_auth id. A Doormile staff
|
||||
// login (tenantid NULL) is reported as not found: these routes never touch one.
|
||||
func loadClientLogin(authID string) (*models.DoormileAuth, error) {
|
||||
var auth models.DoormileAuth
|
||||
if err := db.DB.Where("id = ? AND tenantid IS NOT NULL", authID).First(&auth).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &auth, nil
|
||||
}
|
||||
|
||||
type updateClientRequest struct {
|
||||
Companyname *string `json:"companyname"`
|
||||
Contactname *string `json:"contactname"`
|
||||
Email *string `json:"email"`
|
||||
Phone *string `json:"phone"`
|
||||
Status *string `json:"status"`
|
||||
Requiredeliveryotp *bool `json:"requiredeliveryotp"`
|
||||
Password *string `json:"password"` // optional reset; empty = unchanged
|
||||
}
|
||||
|
||||
var clientStatuses = map[string]string{"active": "Active", "pending": "Pending", "inactive": "Inactive"}
|
||||
|
||||
// UpdateOnboardedClient — PUT /admin/clients/:id (id = the login's authid).
|
||||
// Edits the client company (tenants) and that login (doormile_auth + appusers)
|
||||
// in one transaction. Only fields sent are changed.
|
||||
func UpdateOnboardedClient(c *fiber.Ctx) error {
|
||||
actor := actorOf(c)
|
||||
if !onboardingOwnerStillValid(actor.Email) {
|
||||
return utils.Forbidden(c, "client onboarding is restricted to the designated onboarding account")
|
||||
}
|
||||
auth, err := loadClientLogin(c.Params("id"))
|
||||
if err != nil {
|
||||
return utils.NotFound(c, "client login not found")
|
||||
}
|
||||
req := new(updateClientRequest)
|
||||
if err := c.BodyParser(req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
|
||||
// Validate by reusing the onboarding rules on a filled-in copy.
|
||||
var tenant models.Tenant
|
||||
if err := db.DB.First(&tenant, *auth.Tenantid).Error; err != nil {
|
||||
return utils.NotFound(c, "client not found")
|
||||
}
|
||||
check := onboardClientRequest{
|
||||
Companyname: tenant.Tenantname, Contactname: "xx", Email: auth.Email,
|
||||
Phone: tenant.Primarycontact, Password: "unchanged-ok", Applocationid: 1,
|
||||
}
|
||||
if req.Companyname != nil {
|
||||
check.Companyname = *req.Companyname
|
||||
}
|
||||
if req.Contactname != nil {
|
||||
check.Contactname = *req.Contactname
|
||||
}
|
||||
if req.Email != nil {
|
||||
check.Email = *req.Email
|
||||
} else {
|
||||
check.Email = "unchanged@doormile.example" // as with the phone: only a changed email is validated
|
||||
}
|
||||
if req.Phone != nil {
|
||||
check.Phone = *req.Phone
|
||||
} else {
|
||||
// An older client may carry a phone that fails today's rule; only a
|
||||
// phone the caller is actually changing is validated.
|
||||
check.Phone = "9000000000"
|
||||
}
|
||||
newPassword := ""
|
||||
if req.Password != nil && *req.Password != "" {
|
||||
newPassword = *req.Password
|
||||
check.Password = newPassword
|
||||
}
|
||||
if msg := check.validate(); msg != "" {
|
||||
return utils.BadRequest(c, msg)
|
||||
}
|
||||
status := tenant.Status
|
||||
if req.Status != nil {
|
||||
s, ok := clientStatuses[strings.ToLower(strings.TrimSpace(*req.Status))]
|
||||
if !ok {
|
||||
return utils.BadRequest(c, "status must be Active, Pending or Inactive")
|
||||
}
|
||||
status = s
|
||||
}
|
||||
|
||||
var hash string
|
||||
if newPassword != "" {
|
||||
if hash, err = utils.HashPassword(newPassword); err != nil {
|
||||
return utils.Internal(c, "failed to process the password")
|
||||
}
|
||||
}
|
||||
|
||||
oldEmail := auth.Email
|
||||
err = db.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var n int64
|
||||
if req.Companyname != nil && !strings.EqualFold(check.Companyname, tenant.Tenantname) {
|
||||
tx.Model(&models.Tenant{}).Where("LOWER(tenantname) = LOWER(?) AND tenantid <> ?", check.Companyname, tenant.Tenantid).Count(&n)
|
||||
if n > 0 {
|
||||
return errOnboardingConflict{"a client with this company name already exists"}
|
||||
}
|
||||
}
|
||||
emailChanged := req.Email != nil && check.Email != strings.ToLower(oldEmail)
|
||||
if emailChanged {
|
||||
tx.Model(&models.DoormileAuth{}).Where("LOWER(email) = ? AND id <> ?", check.Email, auth.ID).Count(&n)
|
||||
if n > 0 {
|
||||
return errOnboardingConflict{"this email already has a console login"}
|
||||
}
|
||||
tx.Model(&models.AppUser{}).Where("LOWER(email) = ? AND LOWER(email) <> LOWER(?)", check.Email, oldEmail).Count(&n)
|
||||
if n > 0 {
|
||||
return errOnboardingConflict{"this email is already used by another user"}
|
||||
}
|
||||
}
|
||||
|
||||
tenantUpdates := map[string]any{"status": status, "updatedat": gorm.Expr("CURRENT_TIMESTAMP")}
|
||||
if req.Companyname != nil {
|
||||
tenantUpdates["tenantname"] = check.Companyname
|
||||
}
|
||||
if req.Phone != nil {
|
||||
tenantUpdates["primarycontact"] = check.Phone
|
||||
}
|
||||
if emailChanged && strings.EqualFold(tenant.Primaryemail, oldEmail) {
|
||||
tenantUpdates["primaryemail"] = check.Email
|
||||
}
|
||||
if req.Requiredeliveryotp != nil {
|
||||
tenantUpdates["requiredeliveryotp"] = *req.Requiredeliveryotp
|
||||
}
|
||||
if err := tx.Model(&models.Tenant{}).Where("tenantid = ?", tenant.Tenantid).Updates(tenantUpdates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authUpdates := map[string]any{"updated_at": time.Now()}
|
||||
if emailChanged {
|
||||
authUpdates["email"] = check.Email
|
||||
}
|
||||
if hash != "" {
|
||||
authUpdates["password_hash"] = hash
|
||||
}
|
||||
if err := tx.Model(&models.DoormileAuth{}).Where("id = ?", auth.ID).Updates(authUpdates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userUpdates := map[string]any{}
|
||||
if emailChanged {
|
||||
userUpdates["email"] = check.Email
|
||||
}
|
||||
if req.Contactname != nil {
|
||||
userUpdates["authname"] = check.Contactname
|
||||
}
|
||||
if req.Phone != nil {
|
||||
userUpdates["contactno"] = check.Phone
|
||||
}
|
||||
if hash != "" {
|
||||
userUpdates["password"] = hash
|
||||
}
|
||||
if len(userUpdates) > 0 {
|
||||
userUpdates["updatedat"] = gorm.Expr("CURRENT_TIMESTAMP")
|
||||
if err := tx.Model(&models.AppUser{}).
|
||||
Where("LOWER(email) = LOWER(?) AND tenantid = ?", oldEmail, tenant.Tenantid).
|
||||
Updates(userUpdates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
var conflict errOnboardingConflict
|
||||
switch {
|
||||
case errors.As(err, &conflict):
|
||||
return utils.Conflict(c, conflict.msg)
|
||||
case err != nil && isUniqueViolation(err):
|
||||
return utils.Conflict(c, "this email already has a console login")
|
||||
case err != nil:
|
||||
utils.Error("client update failed", "error", err.Error(), "by", actor.Email)
|
||||
return utils.Internal(c, "failed to update the client; nothing was changed")
|
||||
}
|
||||
|
||||
utils.Info("client updated", "by", actor.Email, "tenantid", tenant.Tenantid, "authid", auth.ID,
|
||||
"password_reset", hash != "", "email_changed", req.Email != nil && check.Email != strings.ToLower(oldEmail))
|
||||
return utils.OK(c, fiber.Map{"authid": auth.ID, "tenantid": tenant.Tenantid, "status": status, "password_reset": hash != ""})
|
||||
}
|
||||
|
||||
// DeleteOnboardedClient — DELETE /admin/clients/:id (id = the login's authid).
|
||||
//
|
||||
// Removes the CONSOLE LOGIN, not the company's history: the doormile_auth row
|
||||
// and the matching appusers row are deleted, so the client can no longer sign
|
||||
// in, and the client is marked Inactive when this was its last login. The
|
||||
// tenants row and every booking, consignment and price attached to it stay —
|
||||
// deleting them would break past orders and reports.
|
||||
//
|
||||
// A token already issued keeps working until it expires (JWTs are stateless);
|
||||
// the login cannot be used to sign in again.
|
||||
func DeleteOnboardedClient(c *fiber.Ctx) error {
|
||||
actor := actorOf(c)
|
||||
if !onboardingOwnerStillValid(actor.Email) {
|
||||
return utils.Forbidden(c, "client onboarding is restricted to the designated onboarding account")
|
||||
}
|
||||
auth, err := loadClientLogin(c.Params("id"))
|
||||
if err != nil {
|
||||
return utils.NotFound(c, "client login not found")
|
||||
}
|
||||
tenantID := *auth.Tenantid
|
||||
deactivated := false
|
||||
|
||||
err = db.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("id = ?", auth.ID).Delete(&models.DoormileAuth{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("LOWER(email) = LOWER(?) AND tenantid = ?", auth.Email, tenantID).
|
||||
Delete(&models.AppUser{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var remaining int64
|
||||
tx.Model(&models.DoormileAuth{}).Where("tenantid = ?", tenantID).Count(&remaining)
|
||||
if remaining == 0 {
|
||||
deactivated = true
|
||||
return tx.Model(&models.Tenant{}).Where("tenantid = ?", tenantID).
|
||||
Updates(map[string]any{"status": "Inactive", "updatedat": gorm.Expr("CURRENT_TIMESTAMP")}).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
utils.Error("client login delete failed", "error", err.Error(), "by", actor.Email)
|
||||
return utils.Internal(c, "failed to remove the client login; nothing was changed")
|
||||
}
|
||||
|
||||
utils.Info("client login removed", "by", actor.Email, "tenantid", tenantID, "login", auth.Email, "client_deactivated", deactivated)
|
||||
return utils.OK(c, fiber.Map{"authid": auth.ID, "tenantid": tenantID, "login_removed": true, "client_deactivated": deactivated})
|
||||
}
|
||||
|
||||
// GetOnboardingCities — GET /admin/clients/cities: the operating cities a new
|
||||
// client can be placed in, straight from applocations (the table OnboardClient
|
||||
// validates against). The console's usual city picker derives cities from
|
||||
// hubs, which would hide a city that has no hub yet.
|
||||
func GetOnboardingCities(c *fiber.Ctx) error {
|
||||
var cities []models.AppLocation
|
||||
if err := db.DB.Where("status IS NULL OR status = '' OR LOWER(status) = 'active'").
|
||||
Order("applocationid").Find(&cities).Error; err != nil {
|
||||
utils.Error("list onboarding cities", "error", err.Error())
|
||||
return utils.Internal(c, "failed to list cities")
|
||||
}
|
||||
if cities == nil {
|
||||
cities = []models.AppLocation{}
|
||||
}
|
||||
return utils.List(c, cities, int64(len(cities)))
|
||||
}
|
||||
106
controllers/clientOnboarding_test.go
Normal file
106
controllers/clientOnboarding_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func validOnboarding() onboardClientRequest {
|
||||
return onboardClientRequest{
|
||||
Companyname: " Acme Foods ",
|
||||
Contactname: "Priya Raman",
|
||||
Email: " Ops@Acme.Example ",
|
||||
Phone: "+91 98765-43210",
|
||||
Password: "s3cure-pass",
|
||||
Applocationid: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnboardingValidateNormalises(t *testing.T) {
|
||||
r := validOnboarding()
|
||||
if msg := r.validate(); msg != "" {
|
||||
t.Fatalf("valid request refused: %s", msg)
|
||||
}
|
||||
if r.Companyname != "Acme Foods" || r.Contactname != "Priya Raman" || r.Email != "ops@acme.example" || r.Phone != "9876543210" {
|
||||
t.Fatalf("not normalised: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnboardingValidateRefuses(t *testing.T) {
|
||||
cases := map[string]func(*onboardClientRequest){
|
||||
"company name is required": func(r *onboardClientRequest) { r.Companyname = " " },
|
||||
"company name is too long": func(r *onboardClientRequest) { r.Companyname = strings.Repeat("a", 121) },
|
||||
"contact person's name": func(r *onboardClientRequest) { r.Contactname = "" },
|
||||
"valid email": func(r *onboardClientRequest) { r.Email = "not-an-email" },
|
||||
"valid email ": func(r *onboardClientRequest) { r.Email = "Ops <ops@acme.example>" },
|
||||
"valid email ": func(r *onboardClientRequest) { r.Email = "ops@localhost" },
|
||||
"10-digit mobile": func(r *onboardClientRequest) { r.Phone = "12345" },
|
||||
"10-digit mobile ": func(r *onboardClientRequest) { r.Phone = "5876543210" }, // must start 6-9
|
||||
"at least 8 characters": func(r *onboardClientRequest) { r.Password = "short" },
|
||||
"at most 72 characters": func(r *onboardClientRequest) { r.Password = strings.Repeat("x", 73) },
|
||||
"must not be the email": func(r *onboardClientRequest) { r.Password = "OPS@acme.example" },
|
||||
"must not be the email or the ": func(r *onboardClientRequest) { r.Password = "9876543210" },
|
||||
"operating city": func(r *onboardClientRequest) { r.Applocationid = 0 },
|
||||
}
|
||||
for want, mutate := range cases {
|
||||
r := validOnboarding()
|
||||
mutate(&r)
|
||||
if msg := r.validate(); !strings.Contains(msg, strings.TrimSpace(want)) {
|
||||
t.Errorf("%q: got %q", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalisePhone(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"9876543210": "9876543210",
|
||||
"+919876543210": "9876543210",
|
||||
"919876543210": "9876543210",
|
||||
"09876543210": "9876543210",
|
||||
" 98765 43210 ": "9876543210",
|
||||
"(987) 654-3210": "9876543210",
|
||||
} {
|
||||
if got := normalisePhone(in); got != want {
|
||||
t.Errorf("normalisePhone(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The list query selects these column aliases; every one must land in a field.
|
||||
// GORM maps silently — a field it cannot see stays empty with no error — so
|
||||
// this parses the scan struct exactly as GORM does and checks each alias.
|
||||
func TestOnboardedClientRowMapsEveryColumn(t *testing.T) {
|
||||
s, err := schema.Parse(&onboardedClientRow{}, &sync.Map{}, schema.NamingStrategy{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, col := range []string{
|
||||
"authid", "tenantid", "tenantname", "primaryemail", "primarycontact", "status",
|
||||
"requiredeliveryotp", "contactname", "loginemail", "loginrole", "authcreatedat", "tenantcreatedat",
|
||||
} {
|
||||
if s.LookUpField(col) == nil {
|
||||
t.Errorf("column %q selected by the list query maps to no field", col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnboardedClientRowToClient(t *testing.T) {
|
||||
zero := time.Time{}
|
||||
// As the driver hands back a timestamp-without-zone column: IST digits tagged UTC.
|
||||
tenant := time.Date(2026, 6, 24, 16, 14, 0, 0, time.UTC)
|
||||
c := onboardedClientRow{Authid: 7, Tenantname: "Acme", Loginemail: "a@b.co", Authcreatedat: &zero, Tenantcreatedat: &tenant}.toClient()
|
||||
if c.Authid != 7 || c.Tenantname != "Acme" || c.Loginemail != "a@b.co" {
|
||||
t.Fatalf("fields lost: %+v", c)
|
||||
}
|
||||
// 16:14 IST, i.e. 10:44 UTC — not 16:14 UTC (which would show as 21:44 in India).
|
||||
if c.Logincreatedat == nil || !c.Logincreatedat.Equal(time.Date(2026, 6, 24, 10, 44, 0, 0, time.UTC)) {
|
||||
t.Fatalf("a zero login date must fall back to the tenant's, read as IST: %v", c.Logincreatedat)
|
||||
}
|
||||
if (onboardedClientRow{}).toClient().Logincreatedat != nil {
|
||||
t.Fatal("no real date must give null, not year 1")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user