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>
This commit is contained in:
379
pkg/server/portal.go
Normal file
379
pkg/server/portal.go
Normal file
@@ -0,0 +1,379 @@
|
||||
// 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})
|
||||
}
|
||||
Reference in New Issue
Block a user