package repository import ( "aggios-app/backend/internal/domain" "database/sql" "fmt" "log" "github.com/lib/pq" ) type CRMRepository struct { db *sql.DB } func NewCRMRepository(db *sql.DB) *CRMRepository { return &CRMRepository{db: db} } // ==================== CUSTOMERS ==================== func (r *CRMRepository) CreateCustomer(customer *domain.CRMCustomer) error { query := ` INSERT INTO crm_customers ( id, tenant_id, name, email, phone, company, position, address, city, state, zip_code, country, notes, tags, is_active, created_by, logo_url ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING created_at, updated_at ` // Handle optional created_by field (NULL for public registrations) var createdBy interface{} if customer.CreatedBy != "" { createdBy = customer.CreatedBy } else { createdBy = nil } return r.db.QueryRow( query, customer.ID, customer.TenantID, customer.Name, customer.Email, customer.Phone, customer.Company, customer.Position, customer.Address, customer.City, customer.State, customer.ZipCode, customer.Country, customer.Notes, pq.Array(customer.Tags), customer.IsActive, createdBy, customer.LogoURL, ).Scan(&customer.CreatedAt, &customer.UpdatedAt) } func (r *CRMRepository) GetCustomersByTenant(tenantID string) ([]domain.CRMCustomer, error) { query := ` SELECT id, tenant_id, name, email, phone, company, position, address, city, state, zip_code, country, notes, tags, is_active, COALESCE(created_by::text, '') AS created_by, created_at, updated_at, COALESCE(logo_url, '') as logo_url FROM crm_customers WHERE tenant_id = $1 AND is_active = true ORDER BY created_at DESC ` rows, err := r.db.Query(query, tenantID) if err != nil { return nil, err } defer rows.Close() var customers []domain.CRMCustomer for rows.Next() { var c domain.CRMCustomer err := rows.Scan( &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, &c.LogoURL, ) if err != nil { return nil, err } customers = append(customers, c) } return customers, nil } func (r *CRMRepository) GetCustomerByID(id string, tenantID string) (*domain.CRMCustomer, error) { query := ` SELECT id, tenant_id, name, email, phone, company, position, address, city, state, zip_code, country, notes, tags, is_active, COALESCE(created_by::text, '') AS created_by, created_at, updated_at, COALESCE(logo_url, '') as logo_url FROM crm_customers WHERE id = $1 AND tenant_id = $2 ` var c domain.CRMCustomer err := r.db.QueryRow(query, id, tenantID).Scan( &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, &c.LogoURL, ) if err != nil { return nil, err } return &c, nil } func (r *CRMRepository) UpdateCustomer(customer *domain.CRMCustomer) error { query := ` UPDATE crm_customers SET name = $1, email = $2, phone = $3, company = $4, position = $5, address = $6, city = $7, state = $8, zip_code = $9, country = $10, notes = $11, tags = $12, is_active = $13, logo_url = $14 WHERE id = $15 AND tenant_id = $16 ` result, err := r.db.Exec( query, customer.Name, customer.Email, customer.Phone, customer.Company, customer.Position, customer.Address, customer.City, customer.State, customer.ZipCode, customer.Country, customer.Notes, pq.Array(customer.Tags), customer.IsActive, customer.LogoURL, customer.ID, customer.TenantID, ) if err != nil { return err } rows, err := result.RowsAffected() if err != nil { return err } if rows == 0 { return fmt.Errorf("customer not found") } return nil } func (r *CRMRepository) DeleteCustomer(id string, tenantID string) error { query := `DELETE FROM crm_customers WHERE id = $1 AND tenant_id = $2` result, err := r.db.Exec(query, id, tenantID) if err != nil { return err } rows, err := result.RowsAffected() if err != nil { return err } if rows == 0 { return fmt.Errorf("customer not found") } return nil } // ==================== LISTS ==================== func (r *CRMRepository) CreateList(list *domain.CRMList) error { query := ` INSERT INTO crm_lists (id, tenant_id, customer_id, funnel_id, name, description, color, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING created_at, updated_at ` return r.db.QueryRow( query, list.ID, list.TenantID, list.CustomerID, list.FunnelID, list.Name, list.Description, list.Color, list.CreatedBy, ).Scan(&list.CreatedAt, &list.UpdatedAt) } func (r *CRMRepository) GetListsByTenant(tenantID string) ([]domain.CRMListWithCustomers, error) { query := ` SELECT l.id, l.tenant_id, l.customer_id, l.funnel_id, l.name, l.description, l.color, l.created_by, l.created_at, l.updated_at, COALESCE(c.name, '') as customer_name, (SELECT COUNT(*) FROM crm_customer_lists cl WHERE cl.list_id = l.id) as customer_count, (SELECT COUNT(*) FROM crm_lead_lists ll WHERE ll.list_id = l.id) as lead_count FROM crm_lists l LEFT JOIN crm_customers c ON l.customer_id = c.id WHERE l.tenant_id = $1 ORDER BY l.created_at DESC ` rows, err := r.db.Query(query, tenantID) if err != nil { return nil, err } defer rows.Close() var lists []domain.CRMListWithCustomers for rows.Next() { var l domain.CRMListWithCustomers err := rows.Scan( &l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, &l.CustomerName, &l.CustomerCount, &l.LeadCount, ) if err != nil { return nil, err } lists = append(lists, l) } return lists, nil } func (r *CRMRepository) GetListByID(id string, tenantID string) (*domain.CRMList, error) { query := ` SELECT id, tenant_id, customer_id, funnel_id, name, description, color, created_by, created_at, updated_at FROM crm_lists WHERE id = $1 AND tenant_id = $2 ` var l domain.CRMList err := r.db.QueryRow(query, id, tenantID).Scan( &l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, ) if err != nil { return nil, err } return &l, nil } func (r *CRMRepository) UpdateList(list *domain.CRMList) error { query := ` UPDATE crm_lists SET name = $1, description = $2, color = $3, customer_id = $4, funnel_id = $5 WHERE id = $6 AND tenant_id = $7 ` result, err := r.db.Exec(query, list.Name, list.Description, list.Color, list.CustomerID, list.FunnelID, list.ID, list.TenantID) if err != nil { return err } rows, err := result.RowsAffected() if err != nil { return err } if rows == 0 { return fmt.Errorf("list not found") } return nil } func (r *CRMRepository) DeleteList(id string, tenantID string) error { query := `DELETE FROM crm_lists WHERE id = $1 AND tenant_id = $2` result, err := r.db.Exec(query, id, tenantID) if err != nil { return err } rows, err := result.RowsAffected() if err != nil { return err } if rows == 0 { return fmt.Errorf("list not found") } return nil } // ==================== CUSTOMER <-> LIST ==================== func (r *CRMRepository) AddCustomerToList(customerID, listID, addedBy string) error { query := ` INSERT INTO crm_customer_lists (customer_id, list_id, added_by) VALUES ($1, $2, $3) ON CONFLICT (customer_id, list_id) DO NOTHING ` _, err := r.db.Exec(query, customerID, listID, addedBy) return err } func (r *CRMRepository) RemoveCustomerFromList(customerID, listID string) error { query := `DELETE FROM crm_customer_lists WHERE customer_id = $1 AND list_id = $2` _, err := r.db.Exec(query, customerID, listID) return err } func (r *CRMRepository) GetCustomerLists(customerID string) ([]domain.CRMList, error) { query := ` SELECT l.id, l.tenant_id, l.name, l.description, l.color, l.created_by, l.created_at, l.updated_at FROM crm_lists l INNER JOIN crm_customer_lists cl ON l.id = cl.list_id WHERE cl.customer_id = $1 ORDER BY l.name ` rows, err := r.db.Query(query, customerID) if err != nil { return nil, err } defer rows.Close() var lists []domain.CRMList for rows.Next() { var l domain.CRMList err := rows.Scan( &l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, ) if err != nil { return nil, err } lists = append(lists, l) } return lists, nil } func (r *CRMRepository) GetListCustomers(listID string, tenantID string) ([]domain.CRMCustomer, error) { query := ` SELECT c.id, c.tenant_id, c.name, c.email, c.phone, c.company, c.position, c.address, c.city, c.state, c.zip_code, c.country, c.notes, c.tags, c.is_active, c.created_by, c.created_at, c.updated_at, COALESCE(c.logo_url, '') as logo_url FROM crm_customers c INNER JOIN crm_customer_lists cl ON c.id = cl.customer_id WHERE cl.list_id = $1 AND c.tenant_id = $2 AND c.is_active = true ORDER BY c.name ` rows, err := r.db.Query(query, listID, tenantID) if err != nil { return nil, err } defer rows.Close() var customers []domain.CRMCustomer for rows.Next() { var c domain.CRMCustomer err := rows.Scan( &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, &c.LogoURL, ) if err != nil { return nil, err } customers = append(customers, c) } return customers, nil } // ==================== LEADS ==================== func (r *CRMRepository) CreateLead(lead *domain.CRMLead) error { query := ` INSERT INTO crm_leads ( id, tenant_id, customer_id, funnel_id, stage_id, name, email, phone, source, source_meta, status, notes, tags, is_active, created_by ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING created_at, updated_at ` return r.db.QueryRow( query, lead.ID, lead.TenantID, lead.CustomerID, lead.FunnelID, lead.StageID, lead.Name, lead.Email, lead.Phone, lead.Source, lead.SourceMeta, lead.Status, lead.Notes, pq.Array(lead.Tags), lead.IsActive, lead.CreatedBy, ).Scan(&lead.CreatedAt, &lead.UpdatedAt) } func (r *CRMRepository) AddLeadToList(leadID, listID, addedBy string) error { query := ` INSERT INTO crm_lead_lists (lead_id, list_id, added_by) VALUES ($1, $2, $3) ON CONFLICT (lead_id, list_id) DO NOTHING ` _, err := r.db.Exec(query, leadID, listID, addedBy) return err } func (r *CRMRepository) BulkAddLeadsToList(leadIDs []string, listID string, addedBy string) error { tx, err := r.db.Begin() if err != nil { return err } defer tx.Rollback() stmt, err := tx.Prepare(pq.CopyIn("crm_lead_lists", "lead_id", "list_id", "added_by")) if err != nil { return err } defer stmt.Close() for _, leadID := range leadIDs { _, err = stmt.Exec(leadID, listID, addedBy) if err != nil { return err } } _, err = stmt.Exec() if err != nil { return err } return tx.Commit() } func (r *CRMRepository) BulkCreateLeads(leads []domain.CRMLead) error { tx, err := r.db.Begin() if err != nil { return err } defer tx.Rollback() query := ` INSERT INTO crm_leads ( id, tenant_id, customer_id, funnel_id, stage_id, name, email, phone, source, source_meta, status, notes, tags, is_active, created_by ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 ) ON CONFLICT (tenant_id, email) DO UPDATE SET customer_id = COALESCE(EXCLUDED.customer_id, crm_leads.customer_id), funnel_id = COALESCE(EXCLUDED.funnel_id, crm_leads.funnel_id), stage_id = COALESCE(EXCLUDED.stage_id, crm_leads.stage_id), name = COALESCE(EXCLUDED.name, crm_leads.name), phone = COALESCE(EXCLUDED.phone, crm_leads.phone), source = EXCLUDED.source, source_meta = EXCLUDED.source_meta, status = EXCLUDED.status, notes = COALESCE(EXCLUDED.notes, crm_leads.notes), tags = EXCLUDED.tags, updated_at = CURRENT_TIMESTAMP RETURNING id ` stmt, err := tx.Prepare(query) if err != nil { return err } defer stmt.Close() for i := range leads { var returnedID string err = stmt.QueryRow( leads[i].ID, leads[i].TenantID, leads[i].CustomerID, leads[i].FunnelID, leads[i].StageID, leads[i].Name, leads[i].Email, leads[i].Phone, leads[i].Source, string(leads[i].SourceMeta), leads[i].Status, leads[i].Notes, pq.Array(leads[i].Tags), leads[i].IsActive, leads[i].CreatedBy, ).Scan(&returnedID) if err != nil { return err } // Atualiza o ID do lead com o ID retornado (pode ser diferente em caso de conflito) leads[i].ID = returnedID } return tx.Commit() } func (r *CRMRepository) GetLeadsByTenant(tenantID string) ([]domain.CRMLead, error) { query := ` SELECT id, tenant_id, customer_id, funnel_id, stage_id, name, email, phone, source, source_meta, status, COALESCE(notes, ''), tags, is_active, created_by, created_at, updated_at FROM crm_leads WHERE tenant_id = $1 AND is_active = true ORDER BY created_at DESC ` rows, err := r.db.Query(query, tenantID) if err != nil { return nil, err } defer rows.Close() var leads []domain.CRMLead for rows.Next() { var l domain.CRMLead err := rows.Scan( &l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.StageID, &l.Name, &l.Email, &l.Phone, &l.Source, &l.SourceMeta, &l.Status, &l.Notes, pq.Array(&l.Tags), &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, ) if err != nil { log.Printf("Error scanning lead: %v", err) continue } leads = append(leads, l) } return leads, nil } func (r *CRMRepository) GetLeadsWithListsByTenant(tenantID string) ([]domain.CRMLeadWithLists, error) { leads, err := r.GetLeadsByTenant(tenantID) if err != nil { return nil, err } var leadsWithLists []domain.CRMLeadWithLists for _, l := range leads { lists, err := r.GetListsByLeadID(l.ID) if err != nil { lists = []domain.CRMList{} } leadsWithLists = append(leadsWithLists, domain.CRMLeadWithLists{ CRMLead: l, Lists: lists, }) } return leadsWithLists, nil } func (r *CRMRepository) GetListsByLeadID(leadID string) ([]domain.CRMList, error) { query := ` SELECT l.id, l.tenant_id, l.customer_id, l.name, l.description, l.color, l.created_by, l.created_at, l.updated_at FROM crm_lists l JOIN crm_lead_lists cll ON l.id = cll.list_id WHERE cll.lead_id = $1 ` rows, err := r.db.Query(query, leadID) if err != nil { return nil, err } defer rows.Close() var lists []domain.CRMList for rows.Next() { var l domain.CRMList err := rows.Scan( &l.ID, &l.TenantID, &l.CustomerID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, ) if err != nil { return nil, err } lists = append(lists, l) } return lists, nil } func (r *CRMRepository) GetLeadByID(id string, tenantID string) (*domain.CRMLead, error) { query := ` SELECT id, tenant_id, customer_id, funnel_id, stage_id, name, email, phone, source, source_meta, status, COALESCE(notes, ''), tags, is_active, created_by, created_at, updated_at FROM crm_leads WHERE id = $1 AND tenant_id = $2 ` var l domain.CRMLead err := r.db.QueryRow(query, id, tenantID).Scan( &l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.StageID, &l.Name, &l.Email, &l.Phone, &l.Source, &l.SourceMeta, &l.Status, &l.Notes, pq.Array(&l.Tags), &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, ) if err != nil { return nil, err } return &l, nil } func (r *CRMRepository) UpdateLead(lead *domain.CRMLead) error { query := ` UPDATE crm_leads SET customer_id = $1, funnel_id = $2, stage_id = $3, name = $4, email = $5, phone = $6, source = $7, source_meta = $8, status = $9, notes = $10, tags = $11, is_active = $12 WHERE id = $13 AND tenant_id = $14 ` result, err := r.db.Exec( query, lead.CustomerID, lead.FunnelID, lead.StageID, lead.Name, lead.Email, lead.Phone, lead.Source, lead.SourceMeta, lead.Status, lead.Notes, pq.Array(lead.Tags), lead.IsActive, lead.ID, lead.TenantID, ) if err != nil { return err } rows, err := result.RowsAffected() if err != nil { return err } if rows == 0 { return fmt.Errorf("lead not found") } return nil } func (r *CRMRepository) DeleteLead(id string, tenantID string) error { query := `DELETE FROM crm_leads WHERE id = $1 AND tenant_id = $2` result, err := r.db.Exec(query, id, tenantID) if err != nil { return err } rows, err := result.RowsAffected() if err != nil { return err } if rows == 0 { return fmt.Errorf("lead not found") } return nil } func (r *CRMRepository) GetLeadByEmailOrPhone(tenantID, email, phone string) (*domain.CRMLead, error) { query := ` SELECT id, tenant_id, customer_id, name, email, phone, source, source_meta, status, COALESCE(notes, ''), tags, is_active, created_by, created_at, updated_at FROM crm_leads WHERE tenant_id = $1 AND ( (email IS NOT NULL AND $2 <> '' AND LOWER(email) = LOWER($2)) OR (phone IS NOT NULL AND $3 <> '' AND phone = $3) ) ORDER BY created_at DESC LIMIT 1 ` var l domain.CRMLead err := r.db.QueryRow(query, tenantID, email, phone).Scan( &l.ID, &l.TenantID, &l.CustomerID, &l.Name, &l.Email, &l.Phone, &l.Source, &l.SourceMeta, &l.Status, &l.Notes, pq.Array(&l.Tags), &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, ) if err != nil { return nil, err } return &l, nil } func (r *CRMRepository) RemoveLeadFromList(leadID, listID string) error { query := `DELETE FROM crm_lead_lists WHERE lead_id = $1 AND list_id = $2` _, err := r.db.Exec(query, leadID, listID) return err } func (r *CRMRepository) GetLeadLists(leadID string) ([]domain.CRMList, error) { query := ` SELECT l.id, l.tenant_id, l.name, COALESCE(l.description, ''), l.color, l.created_by, l.created_at, l.updated_at FROM crm_lists l INNER JOIN crm_lead_lists ll ON l.id = ll.list_id WHERE ll.lead_id = $1 ORDER BY l.name ` rows, err := r.db.Query(query, leadID) if err != nil { return nil, err } defer rows.Close() var lists []domain.CRMList for rows.Next() { var l domain.CRMList err := rows.Scan( &l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, ) if err != nil { return nil, err } lists = append(lists, l) } return lists, nil } func (r *CRMRepository) GetListByName(tenantID, name string) (*domain.CRMList, error) { query := ` SELECT id, tenant_id, name, description, color, created_by, created_at, updated_at FROM crm_lists WHERE tenant_id = $1 AND LOWER(name) = LOWER($2) LIMIT 1 ` var l domain.CRMList err := r.db.QueryRow(query, tenantID, name).Scan( &l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, ) if err != nil { return nil, err } return &l, nil } // CreateShareToken cria um novo token de compartilhamento func (r *CRMRepository) CreateShareToken(token *domain.CRMShareToken) error { query := ` INSERT INTO crm_share_tokens (id, tenant_id, customer_id, token, expires_at, created_by, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7) ` _, err := r.db.Exec(query, token.ID, token.TenantID, token.CustomerID, token.Token, token.ExpiresAt, token.CreatedBy, token.CreatedAt) return err } // GetShareTokenByToken busca um token de compartilhamento pelo token func (r *CRMRepository) GetShareTokenByToken(token string) (*domain.CRMShareToken, error) { query := ` SELECT id, tenant_id, customer_id, token, expires_at, created_by, created_at FROM crm_share_tokens WHERE token = $1 ` var st domain.CRMShareToken err := r.db.QueryRow(query, token).Scan( &st.ID, &st.TenantID, &st.CustomerID, &st.Token, &st.ExpiresAt, &st.CreatedBy, &st.CreatedAt, ) if err != nil { return nil, err } return &st, nil } // GetLeadsByCustomerID retorna todos os leads de um cliente específico func (r *CRMRepository) GetLeadsByCustomerID(customerID string) ([]domain.CRMLead, error) { query := ` SELECT id, tenant_id, customer_id, name, email, phone, source, source_meta, status, notes, tags, is_active, created_by, created_at, updated_at FROM crm_leads WHERE customer_id = $1 AND is_active = true ORDER BY created_at DESC ` rows, err := r.db.Query(query, customerID) if err != nil { return nil, err } defer rows.Close() var leads []domain.CRMLead for rows.Next() { var l domain.CRMLead err := rows.Scan( &l.ID, &l.TenantID, &l.CustomerID, &l.Name, &l.Email, &l.Phone, &l.Source, &l.SourceMeta, &l.Status, &l.Notes, &l.Tags, &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, ) if err != nil { return nil, err } leads = append(leads, l) } if leads == nil { leads = []domain.CRMLead{} } return leads, nil } // GetListsByCustomerID retorna todas as listas que possuem leads de um cliente específico func (r *CRMRepository) GetListsByCustomerID(customerID string) ([]domain.CRMList, error) { query := ` SELECT DISTINCT l.id, l.tenant_id, l.name, l.description, l.color, l.created_by, l.created_at, l.updated_at FROM crm_lists l INNER JOIN crm_lead_lists ll ON l.id = ll.list_id INNER JOIN crm_leads le ON ll.lead_id = le.id WHERE le.customer_id = $1 ORDER BY l.name ` rows, err := r.db.Query(query, customerID) if err != nil { return nil, err } defer rows.Close() var lists []domain.CRMList for rows.Next() { var l domain.CRMList err := rows.Scan( &l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, ) if err != nil { return nil, err } lists = append(lists, l) } if lists == nil { lists = []domain.CRMList{} } return lists, nil } // GetCustomerByEmail busca um cliente pelo email func (r *CRMRepository) GetCustomerByEmail(email string) (*domain.CRMCustomer, error) { query := ` SELECT id, tenant_id, name, email, COALESCE(phone, '') as phone, COALESCE(company, '') as company, COALESCE(position, '') as position, COALESCE(address, '') as address, COALESCE(city, '') as city, COALESCE(state, '') as state, COALESCE(zip_code, '') as zip_code, COALESCE(country, '') as country, COALESCE(notes, '{}') as notes, COALESCE(tags, '{}') as tags, is_active, created_by, created_at, updated_at, COALESCE(password_hash, '') as password_hash, has_portal_access, portal_last_login, portal_created_at FROM crm_customers WHERE email = $1 AND is_active = true ` var c domain.CRMCustomer var createdBy sql.NullString err := r.db.QueryRow(query, email).Scan( &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), &c.IsActive, &createdBy, &c.CreatedAt, &c.UpdatedAt, &c.PasswordHash, &c.HasPortalAccess, &c.PortalLastLogin, &c.PortalCreatedAt, ) if err != nil { return nil, err } if createdBy.Valid { c.CreatedBy = createdBy.String } return &c, nil } // UpdateCustomerLastLogin atualiza o último login do cliente no portal func (r *CRMRepository) UpdateCustomerLastLogin(customerID string) error { query := `UPDATE crm_customers SET portal_last_login = NOW() WHERE id = $1` _, err := r.db.Exec(query, customerID) return err } // SetCustomerPortalAccess define o acesso ao portal e senha para um cliente func (r *CRMRepository) SetCustomerPortalAccess(customerID, passwordHash string, hasAccess bool) error { query := ` UPDATE crm_customers SET password_hash = $1, has_portal_access = $2, portal_created_at = CASE WHEN portal_created_at IS NULL THEN NOW() ELSE portal_created_at END WHERE id = $3 ` _, err := r.db.Exec(query, passwordHash, hasAccess, customerID) return err } // UpdateCustomerPassword atualiza apenas a senha do cliente func (r *CRMRepository) UpdateCustomerPassword(customerID, passwordHash string) error { query := ` UPDATE crm_customers SET password_hash = $1 WHERE id = $2 ` _, err := r.db.Exec(query, passwordHash, customerID) return err } // UpdateCustomerLogo atualiza apenas o logo do cliente func (r *CRMRepository) UpdateCustomerLogo(customerID, tenantID, logoURL string) error { query := ` UPDATE crm_customers SET logo_url = $1 WHERE id = $2 AND tenant_id = $3 ` _, err := r.db.Exec(query, logoURL, customerID, tenantID) return err } // GetCustomerByEmailAndTenant checks if a customer with the given email exists for the tenant func (r *CRMRepository) GetCustomerByEmailAndTenant(email string, tenantID string) (*domain.CRMCustomer, error) { query := ` SELECT id, tenant_id, name, email, phone, company, position, address, city, state, zip_code, country, notes, tags, is_active, created_by, created_at, updated_at FROM crm_customers WHERE LOWER(email) = LOWER($1) AND tenant_id = $2 LIMIT 1 ` var c domain.CRMCustomer err := r.db.QueryRow(query, email, tenantID).Scan( &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, ) if err == sql.ErrNoRows { return nil, nil // Not found is not an error } if err != nil { return nil, err } return &c, nil } // TenantExists checks if a tenant with the given ID exists func (r *CRMRepository) TenantExists(tenantID string) (bool, error) { query := `SELECT EXISTS(SELECT 1 FROM tenants WHERE id = $1 AND is_active = true)` var exists bool err := r.db.QueryRow(query, tenantID).Scan(&exists) return exists, err } // EnableCustomerPortalAccess habilita o acesso ao portal para um cliente (usado na aprovação) func (r *CRMRepository) EnableCustomerPortalAccess(customerID string) error { query := ` UPDATE crm_customers SET has_portal_access = true, portal_created_at = COALESCE(portal_created_at, NOW()) WHERE id = $1 ` _, err := r.db.Exec(query, customerID) return err } // GetCustomerByCPF checks if a customer with the given CPF exists for the tenant func (r *CRMRepository) GetCustomerByCPF(cpf string, tenantID string) (*domain.CRMCustomer, error) { query := ` SELECT id, tenant_id, name, email, phone, company, position, address, city, state, zip_code, country, notes, tags, is_active, created_by, created_at, updated_at FROM crm_customers WHERE tenant_id = $1 AND notes LIKE '%"cpf":"' || $2 || '"%' LIMIT 1 ` var c domain.CRMCustomer err := r.db.QueryRow(query, tenantID, cpf).Scan( &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return &c, nil } // GetCustomerByCNPJ checks if a customer with the given CNPJ exists for the tenant func (r *CRMRepository) GetCustomerByCNPJ(cnpj string, tenantID string) (*domain.CRMCustomer, error) { query := ` SELECT id, tenant_id, name, email, phone, company, position, address, city, state, zip_code, country, notes, tags, is_active, created_by, created_at, updated_at FROM crm_customers WHERE tenant_id = $1 AND notes LIKE '%"cnpj":"' || $2 || '"%' LIMIT 1 ` var c domain.CRMCustomer err := r.db.QueryRow(query, tenantID, cnpj).Scan( &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return &c, nil } func (r *CRMRepository) GetLeadsByListID(listID string) ([]domain.CRMLead, error) { query := ` SELECT l.id, l.tenant_id, l.customer_id, l.funnel_id, l.stage_id, l.name, l.email, l.phone, l.source, l.source_meta, l.status, COALESCE(l.notes, ''), l.tags, l.is_active, COALESCE(l.created_by::text, '') as created_by, l.created_at, l.updated_at FROM crm_leads l INNER JOIN crm_lead_lists ll ON l.id = ll.lead_id WHERE ll.list_id = $1 ORDER BY l.created_at DESC ` rows, err := r.db.Query(query, listID) if err != nil { return nil, err } defer rows.Close() var leads []domain.CRMLead for rows.Next() { var l domain.CRMLead var sourceMeta []byte err := rows.Scan( &l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.StageID, &l.Name, &l.Email, &l.Phone, &l.Source, &sourceMeta, &l.Status, &l.Notes, pq.Array(&l.Tags), &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, ) if err != nil { log.Printf("Error scanning lead from list: %v", err) continue } if sourceMeta != nil { l.SourceMeta = sourceMeta } leads = append(leads, l) } return leads, nil } // ==================== FUNNELS & STAGES ==================== func (r *CRMRepository) CreateFunnel(funnel *domain.CRMFunnel) error { query := ` INSERT INTO crm_funnels (id, tenant_id, name, description, is_default) VALUES ($1, $2, $3, $4, $5) RETURNING created_at, updated_at ` return r.db.QueryRow(query, funnel.ID, funnel.TenantID, funnel.Name, funnel.Description, funnel.IsDefault). Scan(&funnel.CreatedAt, &funnel.UpdatedAt) } func (r *CRMRepository) GetFunnelsByTenant(tenantID string) ([]domain.CRMFunnel, error) { query := `SELECT id, tenant_id, name, COALESCE(description, ''), is_default, created_at, updated_at FROM crm_funnels WHERE tenant_id = $1 ORDER BY created_at ASC` rows, err := r.db.Query(query, tenantID) if err != nil { return nil, err } defer rows.Close() var funnels []domain.CRMFunnel for rows.Next() { var f domain.CRMFunnel if err := rows.Scan(&f.ID, &f.TenantID, &f.Name, &f.Description, &f.IsDefault, &f.CreatedAt, &f.UpdatedAt); err != nil { return nil, err } funnels = append(funnels, f) } return funnels, nil } func (r *CRMRepository) GetFunnelByID(id, tenantID string) (*domain.CRMFunnel, error) { query := `SELECT id, tenant_id, name, COALESCE(description, ''), is_default, created_at, updated_at FROM crm_funnels WHERE id = $1 AND tenant_id = $2` var f domain.CRMFunnel err := r.db.QueryRow(query, id, tenantID).Scan(&f.ID, &f.TenantID, &f.Name, &f.Description, &f.IsDefault, &f.CreatedAt, &f.UpdatedAt) if err != nil { return nil, err } return &f, nil } func (r *CRMRepository) UpdateFunnel(funnel *domain.CRMFunnel) error { query := `UPDATE crm_funnels SET name = $1, description = $2, is_default = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $4 AND tenant_id = $5` _, err := r.db.Exec(query, funnel.Name, funnel.Description, funnel.IsDefault, funnel.ID, funnel.TenantID) return err } func (r *CRMRepository) DeleteFunnel(id, tenantID string) error { query := `DELETE FROM crm_funnels WHERE id = $1 AND tenant_id = $2` _, err := r.db.Exec(query, id, tenantID) return err } func (r *CRMRepository) CreateFunnelStage(stage *domain.CRMFunnelStage) error { query := ` INSERT INTO crm_funnel_stages (id, funnel_id, name, description, color, order_index) VALUES ($1, $2, $3, $4, $5, $6) RETURNING created_at, updated_at ` return r.db.QueryRow(query, stage.ID, stage.FunnelID, stage.Name, stage.Description, stage.Color, stage.OrderIndex). Scan(&stage.CreatedAt, &stage.UpdatedAt) } func (r *CRMRepository) GetStagesByFunnelID(funnelID string) ([]domain.CRMFunnelStage, error) { query := `SELECT id, funnel_id, name, COALESCE(description, ''), color, order_index, created_at, updated_at FROM crm_funnel_stages WHERE funnel_id = $1 ORDER BY order_index ASC` rows, err := r.db.Query(query, funnelID) if err != nil { return nil, err } defer rows.Close() var stages []domain.CRMFunnelStage for rows.Next() { var s domain.CRMFunnelStage if err := rows.Scan(&s.ID, &s.FunnelID, &s.Name, &s.Description, &s.Color, &s.OrderIndex, &s.CreatedAt, &s.UpdatedAt); err != nil { return nil, err } stages = append(stages, s) } return stages, nil } func (r *CRMRepository) UpdateFunnelStage(stage *domain.CRMFunnelStage) error { query := `UPDATE crm_funnel_stages SET name = $1, description = $2, color = $3, order_index = $4, updated_at = CURRENT_TIMESTAMP WHERE id = $5` _, err := r.db.Exec(query, stage.Name, stage.Description, stage.Color, stage.OrderIndex, stage.ID) return err } func (r *CRMRepository) DeleteFunnelStage(id string) error { query := `DELETE FROM crm_funnel_stages WHERE id = $1` _, err := r.db.Exec(query, id) return err } func (r *CRMRepository) UpdateLeadStage(leadID, tenantID, funnelID, stageID string) error { query := `UPDATE crm_leads SET funnel_id = $1, stage_id = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3 AND tenant_id = $4` _, err := r.db.Exec(query, funnelID, stageID, leadID, tenantID) return err } func (r *CRMRepository) EnsureDefaultFunnel(tenantID string) (string, error) { // Check if tenant already has a funnel var funnelID string query := `SELECT id FROM crm_funnels WHERE tenant_id = $1 LIMIT 1` err := r.db.QueryRow(query, tenantID).Scan(&funnelID) if err == nil { return funnelID, nil } // If not, create default using the function we defined in migration query = `SELECT create_default_crm_funnel($1)` err = r.db.QueryRow(query, tenantID).Scan(&funnelID) return funnelID, err }