feat: hub console backend — complete API surface

This commit is contained in:
2026-07-04 16:14:12 +05:30
parent c8adaf7815
commit 707323b68c
10 changed files with 1302 additions and 42 deletions

View File

@@ -111,6 +111,79 @@ func tryAssign(bookingID int) (bool, error) {
return true, nil
}
// AutoAssignResult carries enough detail for a synchronous caller (the hub
// console's manual "auto-assign" trigger) to report what happened, since that
// caller can't rely on the fire-and-forget logging AssignCRMMiler normally uses.
type AutoAssignResult struct {
Assigned bool
Escalated bool
MilerUserID int
MilerName string
DistanceKm float64
Reasoning string
SearchedRadiusKm float64
CandidatesFound int
}
// TryAssignOnce performs a single assignment attempt (GEOSEARCH → AI decision
// → commit) with no retry loop, for a booking that wasn't auto-assigned at
// creation time. AssignCRMMiler/AssignCustomerMiler retry over ~10 minutes,
// which is too slow for a hub staff member waiting on a synchronous response;
// this wraps the same single-attempt core (tryAssign) used internally by both.
func TryAssignOnce(bookingID int) (AutoAssignResult, error) {
var booking models.PickupBooking
if err := db.DB.Preload("Parcels").Preload("ServiceOptions").First(&booking, bookingID).Error; err != nil {
return AutoAssignResult{}, fmt.Errorf("load booking: %w", err)
}
if booking.Status == constants.BookingCancelled || booking.Assignedmileruserid != nil {
return AutoAssignResult{Assigned: true}, nil
}
if booking.Pickuplatitude == 0 || booking.Pickuplongitude == 0 {
return AutoAssignResult{}, fmt.Errorf("booking %d has no pickup coordinates", bookingID)
}
nearby, err := queryNearbyMilers(booking.Pickuplatitude, booking.Pickuplongitude)
if err != nil {
return AutoAssignResult{Escalated: true, Reasoning: "miler location search unavailable", SearchedRadiusKm: geoRadiusKm}, nil
}
if len(nearby) == 0 {
return AutoAssignResult{Escalated: true, Reasoning: "no milers within search radius", SearchedRadiusKm: geoRadiusKm}, nil
}
candidate, agentDecisionID, found := selectMilerWithAI(&booking, nearby)
if !found {
return AutoAssignResult{
Escalated: true,
Reasoning: "no eligible miler after evaluation",
SearchedRadiusKm: geoRadiusKm,
CandidatesFound: len(nearby),
}, nil
}
if err := commitAssignment(&booking, candidate, agentDecisionID); err != nil {
return AutoAssignResult{}, fmt.Errorf("commit: %w", err)
}
reasoning := ""
if agentDecisionID != nil {
var ad models.AgentDecision
if db.DB.Where("id = ?", *agentDecisionID).First(&ad).Error == nil {
reasoning = ad.Reasoning
}
}
return AutoAssignResult{
Assigned: true,
MilerUserID: candidate.profile.Userid,
MilerName: candidate.profile.Displayname,
DistanceKm: candidate.distanceKm,
Reasoning: reasoning,
CandidatesFound: len(nearby),
}, nil
}
// queryNearbyMilers runs GEOSEARCH on milers:locations and returns up to geoMaxCount
// milers within geoRadiusKm km, sorted nearest-first, with distances populated.
func queryNearbyMilers(lat, lon float64) ([]redis.GeoLocation, error) {

70
internal/mail/mail.go Normal file
View File

@@ -0,0 +1,70 @@
package mail
import (
"crypto/tls"
"fmt"
"net/smtp"
"doormile/config"
)
func SendOTPEmail(cfg *config.Config, toEmail, code string) error {
subject := "Your Doormile verification code"
body := fmt.Sprintf(
"Your Doormile verification code is %s.\r\n\r\nIt expires in 5 minutes. If you didn't request this, you can ignore this email.",
code,
)
msg := []byte(fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=\"utf-8\"\r\n\r\n%s\r\n",
cfg.SMTPFrom, toEmail, subject, body,
))
auth := smtp.PlainAuth("", cfg.SMTPUser, cfg.SMTPPassword, cfg.SMTPHost)
// Port 465 is implicit TLS (encrypted from the first byte); everything else
// (587, 25) goes through smtp.SendMail's opportunistic STARTTLS upgrade.
if cfg.SMTPPort == "465" {
return sendImplicitTLS(cfg, auth, toEmail, msg)
}
addr := fmt.Sprintf("%s:%s", cfg.SMTPHost, cfg.SMTPPort)
return smtp.SendMail(addr, auth, cfg.SMTPFrom, []string{toEmail}, msg)
}
func sendImplicitTLS(cfg *config.Config, auth smtp.Auth, toEmail string, msg []byte) error {
addr := fmt.Sprintf("%s:%s", cfg.SMTPHost, cfg.SMTPPort)
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: cfg.SMTPHost})
if err != nil {
return fmt.Errorf("tls dial failed: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, cfg.SMTPHost)
if err != nil {
return fmt.Errorf("smtp client init failed: %w", err)
}
defer client.Close()
if err := client.Auth(auth); err != nil {
return fmt.Errorf("smtp auth failed: %w", err)
}
if err := client.Mail(cfg.SMTPFrom); err != nil {
return err
}
if err := client.Rcpt(toEmail); err != nil {
return err
}
w, err := client.Data()
if err != nil {
return err
}
if _, err := w.Write(msg); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return client.Quit()
}