Files
goodie/pkg/server/portal.go
Marc 66383adf06 feat: Portal, Email Inbound, Discuss + module improvements
- Portal: /my/* routes, signup, password reset, portal user support
- Email Inbound: IMAP polling (go-imap/v2), thread matching
- Discuss: mail.channel, long-polling bus, DM, unread count
- Cron: ir.cron runner (goroutine scheduler)
- Bank Import, CSV/Excel Import
- Automation (ir.actions.server)
- Fetchmail service
- HR Payroll model
- Various fixes across account, sale, stock, purchase, crm, hr, project

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:41:57 +02:00

380 lines
11 KiB
Go

// 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})
}