// Package server — Portal controllers for external (customer/supplier) access. // Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal package server import ( "context" "encoding/json" "fmt" "log" "net/http" "time" ) // registerPortalRoutes registers all /my/* portal endpoints. func (s *Server) registerPortalRoutes() { s.mux.HandleFunc("/my", s.handlePortalHome) s.mux.HandleFunc("/my/", s.handlePortalDispatch) s.mux.HandleFunc("/my/home", s.handlePortalHome) s.mux.HandleFunc("/my/invoices", s.handlePortalInvoices) s.mux.HandleFunc("/my/orders", s.handlePortalOrders) s.mux.HandleFunc("/my/pickings", s.handlePortalPickings) s.mux.HandleFunc("/my/account", s.handlePortalAccount) } // handlePortalDispatch routes /my/* sub-paths to the correct handler. func (s *Server) handlePortalDispatch(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/my/home": s.handlePortalHome(w, r) case "/my/invoices": s.handlePortalInvoices(w, r) case "/my/orders": s.handlePortalOrders(w, r) case "/my/pickings": s.handlePortalPickings(w, r) case "/my/account": s.handlePortalAccount(w, r) default: s.handlePortalHome(w, r) } } // portalPartnerID resolves the partner_id of the currently logged-in portal user. // Returns (partnerID, error). If session is missing, writes an error response and returns 0. func (s *Server) portalPartnerID(w http.ResponseWriter, r *http.Request) (int64, bool) { sess := GetSession(r) if sess == nil { writePortalError(w, http.StatusUnauthorized, "Not authenticated") return 0, false } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() var partnerID int64 err := s.pool.QueryRow(ctx, `SELECT partner_id FROM res_users WHERE id = $1 AND active = true`, sess.UID).Scan(&partnerID) if err != nil { log.Printf("portal: cannot resolve partner_id for uid=%d: %v", sess.UID, err) writePortalError(w, http.StatusForbidden, "User not found") return 0, false } return partnerID, true } // handlePortalHome returns the portal dashboard with document counts. // Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal.home() func (s *Server) handlePortalHome(w http.ResponseWriter, r *http.Request) { partnerID, ok := s.portalPartnerID(w, r) if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() var invoiceCount, orderCount, pickingCount int64 // Count invoices (account.move with move_type in ('out_invoice','out_refund')) err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM account_move WHERE partner_id = $1 AND move_type IN ('out_invoice','out_refund') AND state = 'posted'`, partnerID).Scan(&invoiceCount) if err != nil { log.Printf("portal: invoice count error: %v", err) } // Count sale orders (confirmed or done) err = s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM sale_order WHERE partner_id = $1 AND state IN ('sale','done')`, partnerID).Scan(&orderCount) if err != nil { log.Printf("portal: order count error: %v", err) } // Count pickings (stock.picking) err = s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM stock_picking WHERE partner_id = $1 AND state != 'cancel'`, partnerID).Scan(&pickingCount) if err != nil { log.Printf("portal: picking count error: %v", err) } writePortalJSON(w, map[string]interface{}{ "counters": map[string]int64{ "invoice_count": invoiceCount, "order_count": orderCount, "picking_count": pickingCount, }, }) } // handlePortalInvoices lists invoices for the current portal user. // Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal.portal_my_invoices() func (s *Server) handlePortalInvoices(w http.ResponseWriter, r *http.Request) { partnerID, ok := s.portalPartnerID(w, r) if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() rows, err := s.pool.Query(ctx, `SELECT m.id, m.name, m.move_type, m.state, m.date, m.amount_total::float8, m.amount_residual::float8, m.payment_state, COALESCE(m.ref, '') FROM account_move m WHERE m.partner_id = $1 AND m.move_type IN ('out_invoice','out_refund') AND m.state = 'posted' ORDER BY m.date DESC LIMIT 80`, partnerID) if err != nil { log.Printf("portal: invoice query error: %v", err) writePortalError(w, http.StatusInternalServerError, "Failed to load invoices") return } defer rows.Close() var invoices []map[string]interface{} for rows.Next() { var id int64 var name, moveType, state, paymentState, ref string var date time.Time var amountTotal, amountResidual float64 if err := rows.Scan(&id, &name, &moveType, &state, &date, &amountTotal, &amountResidual, &paymentState, &ref); err != nil { log.Printf("portal: invoice scan error: %v", err) continue } invoices = append(invoices, map[string]interface{}{ "id": id, "name": name, "move_type": moveType, "state": state, "date": date.Format("2006-01-02"), "amount_total": amountTotal, "amount_residual": amountResidual, "payment_state": paymentState, "ref": ref, }) } if invoices == nil { invoices = []map[string]interface{}{} } writePortalJSON(w, map[string]interface{}{"invoices": invoices}) } // handlePortalOrders lists sale orders for the current portal user. // Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal.portal_my_orders() func (s *Server) handlePortalOrders(w http.ResponseWriter, r *http.Request) { partnerID, ok := s.portalPartnerID(w, r) if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() rows, err := s.pool.Query(ctx, `SELECT so.id, so.name, so.state, so.date_order, so.amount_total::float8, COALESCE(so.invoice_status, ''), COALESCE(so.delivery_status, '') FROM sale_order so WHERE so.partner_id = $1 AND so.state IN ('sale','done') ORDER BY so.date_order DESC LIMIT 80`, partnerID) if err != nil { log.Printf("portal: order query error: %v", err) writePortalError(w, http.StatusInternalServerError, "Failed to load orders") return } defer rows.Close() var orders []map[string]interface{} for rows.Next() { var id int64 var name, state, invoiceStatus, deliveryStatus string var dateOrder time.Time var amountTotal float64 if err := rows.Scan(&id, &name, &state, &dateOrder, &amountTotal, &invoiceStatus, &deliveryStatus); err != nil { log.Printf("portal: order scan error: %v", err) continue } orders = append(orders, map[string]interface{}{ "id": id, "name": name, "state": state, "date_order": dateOrder.Format("2006-01-02 15:04:05"), "amount_total": amountTotal, "invoice_status": invoiceStatus, "delivery_status": deliveryStatus, }) } if orders == nil { orders = []map[string]interface{}{} } writePortalJSON(w, map[string]interface{}{"orders": orders}) } // handlePortalPickings lists stock pickings for the current portal user. // Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal.portal_my_pickings() func (s *Server) handlePortalPickings(w http.ResponseWriter, r *http.Request) { partnerID, ok := s.portalPartnerID(w, r) if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() rows, err := s.pool.Query(ctx, `SELECT sp.id, sp.name, sp.state, sp.scheduled_date, COALESCE(sp.origin, ''), COALESCE(spt.name, '') AS picking_type_name FROM stock_picking sp LEFT JOIN stock_picking_type spt ON spt.id = sp.picking_type_id WHERE sp.partner_id = $1 AND sp.state != 'cancel' ORDER BY sp.scheduled_date DESC LIMIT 80`, partnerID) if err != nil { log.Printf("portal: picking query error: %v", err) writePortalError(w, http.StatusInternalServerError, "Failed to load pickings") return } defer rows.Close() var pickings []map[string]interface{} for rows.Next() { var id int64 var name, state, origin, pickingTypeName string var scheduledDate time.Time if err := rows.Scan(&id, &name, &state, &scheduledDate, &origin, &pickingTypeName); err != nil { log.Printf("portal: picking scan error: %v", err) continue } pickings = append(pickings, map[string]interface{}{ "id": id, "name": name, "state": state, "scheduled_date": scheduledDate.Format("2006-01-02 15:04:05"), "origin": origin, "picking_type_name": pickingTypeName, }) } if pickings == nil { pickings = []map[string]interface{}{} } writePortalJSON(w, map[string]interface{}{"pickings": pickings}) } // handlePortalAccount returns/updates the portal user's profile. // GET: returns user profile. POST: updates name/email/phone/street/city/zip. // Mirrors: odoo/addons/portal/controllers/portal.py CustomerPortal.account() func (s *Server) handlePortalAccount(w http.ResponseWriter, r *http.Request) { partnerID, ok := s.portalPartnerID(w, r) if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() if r.Method == http.MethodPost { // Update profile var body struct { Name *string `json:"name"` Email *string `json:"email"` Phone *string `json:"phone"` Street *string `json:"street"` City *string `json:"city"` Zip *string `json:"zip"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writePortalError(w, http.StatusBadRequest, "Invalid JSON") return } // Build SET clause dynamically with parameterized placeholders sets := make([]string, 0, 6) args := make([]interface{}, 0, 7) idx := 1 addField := func(col string, val *string) { if val != nil { sets = append(sets, fmt.Sprintf("%s = $%d", col, idx)) args = append(args, *val) idx++ } } addField("name", body.Name) addField("email", body.Email) addField("phone", body.Phone) addField("street", body.Street) addField("city", body.City) addField("zip", body.Zip) if len(sets) > 0 { args = append(args, partnerID) query := "UPDATE res_partner SET " for j, set := range sets { if j > 0 { query += ", " } query += set } query += fmt.Sprintf(" WHERE id = $%d", idx) if _, err := s.pool.Exec(ctx, query, args...); err != nil { log.Printf("portal: account update error: %v", err) writePortalError(w, http.StatusInternalServerError, "Update failed") return } } writePortalJSON(w, map[string]interface{}{"success": true}) return } // GET — return profile var name, email, phone, street, city, zip string err := s.pool.QueryRow(ctx, `SELECT COALESCE(name,''), COALESCE(email,''), COALESCE(phone,''), COALESCE(street,''), COALESCE(city,''), COALESCE(zip,'') FROM res_partner WHERE id = $1`, partnerID).Scan( &name, &email, &phone, &street, &city, &zip) if err != nil { log.Printf("portal: account read error: %v", err) writePortalError(w, http.StatusInternalServerError, "Failed to load profile") return } writePortalJSON(w, map[string]interface{}{ "name": name, "email": email, "phone": phone, "street": street, "city": city, "zip": zip, }) } // --- Helpers --- func writePortalJSON(w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") json.NewEncoder(w).Encode(data) } func writePortalError(w http.ResponseWriter, status int, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(map[string]string{"error": message}) }