mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-19 08:01:16 +00:00
8d351d1f0f
- gofmt-align ContactEngagement fields and drop trailing blank line in contact/export.go - remove no-op self-assignment ac.CustomFields = ac.CustomFields flagged by govet
388 lines
11 KiB
Go
388 lines
11 KiB
Go
package contact
|
|
|
|
import (
|
|
"context"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/xuri/excelize/v2"
|
|
)
|
|
|
|
// Export streams contacts in the requested format directly to w. The
|
|
// caller is responsible for setting response headers based on the
|
|
// returned content type + filename.
|
|
func (s *contactService) Export(
|
|
ctx context.Context,
|
|
userID string,
|
|
req *models.ContactExportRequest,
|
|
w io.Writer,
|
|
) (string, string, int, *errx.Error) {
|
|
if req == nil {
|
|
return "", "", 0, errx.New(errx.BadRequest, "missing export request body")
|
|
}
|
|
|
|
format, xerr := normalizeExportFormat(req.Format)
|
|
if xerr != nil {
|
|
return "", "", 0, xerr
|
|
}
|
|
scope, xerr := normalizeExportScope(req.Scope)
|
|
if xerr != nil {
|
|
return "", "", 0, xerr
|
|
}
|
|
|
|
// Resolve the column set up front so we can render the header row
|
|
// (CSV/XLSX) before walking contacts.
|
|
fields := req.Fields
|
|
if len(fields) == 0 {
|
|
fields = models.DefaultExportFields
|
|
}
|
|
if err := validateFieldList(fields); err != nil {
|
|
return "", "", 0, err
|
|
}
|
|
|
|
var (
|
|
searchFilters *models.SearchContacts
|
|
contactIDs []string
|
|
)
|
|
switch scope {
|
|
case models.ContactExportScopeAll:
|
|
// nothing to do; ExportAll handles "no filters" as "everyone".
|
|
case models.ContactExportScopeFiltered:
|
|
if req.Filters == nil {
|
|
return "", "", 0, errx.New(errx.BadRequest, "filters required when scope=filtered")
|
|
}
|
|
searchFilters = req.Filters
|
|
case models.ContactExportScopeSelected:
|
|
if len(req.ContactIDs) == 0 {
|
|
return "", "", 0, errx.New(errx.BadRequest, "contact_ids required when scope=selected")
|
|
}
|
|
contactIDs = req.ContactIDs
|
|
}
|
|
|
|
rows, xerr := s.contactRepository.ExportAll(ctx, userID, searchFilters, contactIDs, models.MaxContactExportRows)
|
|
if xerr != nil {
|
|
return "", "", 0, xerr
|
|
}
|
|
|
|
filename := sanitizeFilename(req.Filename)
|
|
if filename == "" {
|
|
filename = "contacts-" + time.Now().UTC().Format("2006-01-02")
|
|
}
|
|
|
|
switch format {
|
|
case models.ContactExportFormatCSV:
|
|
if err := writeCSV(w, rows, fields); err != nil {
|
|
return "", "", 0, errx.InternalError()
|
|
}
|
|
return filename + ".csv", "text/csv; charset=utf-8", len(rows), nil
|
|
case models.ContactExportFormatJSON:
|
|
if err := writeJSON(w, rows, fields); err != nil {
|
|
return "", "", 0, errx.InternalError()
|
|
}
|
|
return filename + ".json", "application/json", len(rows), nil
|
|
case models.ContactExportFormatXLSX:
|
|
if err := writeXLSX(w, rows, fields); err != nil {
|
|
return "", "", 0, errx.InternalError()
|
|
}
|
|
return filename + ".xlsx",
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
len(rows), nil
|
|
}
|
|
return "", "", 0, errx.New(errx.BadRequest, "unsupported export format")
|
|
}
|
|
|
|
func normalizeExportFormat(f models.ContactExportFormat) (models.ContactExportFormat, *errx.Error) {
|
|
switch strings.ToLower(string(f)) {
|
|
case "", "csv":
|
|
return models.ContactExportFormatCSV, nil
|
|
case "xlsx":
|
|
return models.ContactExportFormatXLSX, nil
|
|
case "json":
|
|
return models.ContactExportFormatJSON, nil
|
|
}
|
|
return "", errx.New(errx.BadRequest, "unsupported format: "+string(f))
|
|
}
|
|
|
|
func normalizeExportScope(s models.ContactExportScope) (models.ContactExportScope, *errx.Error) {
|
|
switch strings.ToLower(string(s)) {
|
|
case "", "all":
|
|
return models.ContactExportScopeAll, nil
|
|
case "filtered":
|
|
return models.ContactExportScopeFiltered, nil
|
|
case "selected":
|
|
return models.ContactExportScopeSelected, nil
|
|
}
|
|
return "", errx.New(errx.BadRequest, "unsupported scope: "+string(s))
|
|
}
|
|
|
|
// validateFieldList catches typo'd field identifiers early so the user
|
|
// sees an explicit error instead of a column full of blanks.
|
|
func validateFieldList(fields []string) *errx.Error {
|
|
for _, f := range fields {
|
|
if strings.HasPrefix(f, "custom:") {
|
|
key := strings.TrimPrefix(f, "custom:")
|
|
if key == "" {
|
|
return errx.New(errx.BadRequest, "custom: prefix without a key")
|
|
}
|
|
continue
|
|
}
|
|
switch f {
|
|
case models.ContactExportFieldID,
|
|
models.ContactExportFieldEmail,
|
|
models.ContactExportFieldFirstName,
|
|
models.ContactExportFieldLastName,
|
|
models.ContactExportFieldCompany,
|
|
models.ContactExportFieldPhone,
|
|
models.ContactExportFieldSubscribed,
|
|
models.ContactExportFieldCategories,
|
|
models.ContactExportFieldCampaigns,
|
|
models.ContactExportFieldCreatedAt,
|
|
models.ContactExportFieldUpdatedAt:
|
|
default:
|
|
return errx.New(errx.BadRequest, "unknown export field: "+f)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// fieldHeader is the user-visible column label. We keep the export
|
|
// header human-readable (capitalised, no underscores) since the file
|
|
// is meant to be opened in Excel/Sheets and stared at.
|
|
func fieldHeader(f string) string {
|
|
if strings.HasPrefix(f, "custom:") {
|
|
return strings.TrimPrefix(f, "custom:")
|
|
}
|
|
switch f {
|
|
case models.ContactExportFieldID:
|
|
return "ID"
|
|
case models.ContactExportFieldEmail:
|
|
return "Email"
|
|
case models.ContactExportFieldFirstName:
|
|
return "First Name"
|
|
case models.ContactExportFieldLastName:
|
|
return "Last Name"
|
|
case models.ContactExportFieldCompany:
|
|
return "Company"
|
|
case models.ContactExportFieldPhone:
|
|
return "Phone"
|
|
case models.ContactExportFieldSubscribed:
|
|
return "Subscribed"
|
|
case models.ContactExportFieldCategories:
|
|
return "Categories"
|
|
case models.ContactExportFieldCampaigns:
|
|
return "Campaigns"
|
|
case models.ContactExportFieldCreatedAt:
|
|
return "Created At"
|
|
case models.ContactExportFieldUpdatedAt:
|
|
return "Updated At"
|
|
}
|
|
return f
|
|
}
|
|
|
|
// fieldValue is the cell text for a given contact + field. Concrete
|
|
// types are stringified consistently so the same exported file produces
|
|
// the same hash if exported twice with no data changes (helps QA).
|
|
func fieldValue(c *models.Contact, f string) string {
|
|
if strings.HasPrefix(f, "custom:") {
|
|
key := strings.TrimPrefix(f, "custom:")
|
|
if c.CustomFields != nil {
|
|
return c.CustomFields[key]
|
|
}
|
|
return ""
|
|
}
|
|
switch f {
|
|
case models.ContactExportFieldID:
|
|
return c.ID.String()
|
|
case models.ContactExportFieldEmail:
|
|
return c.Email
|
|
case models.ContactExportFieldFirstName:
|
|
return c.FirstName
|
|
case models.ContactExportFieldLastName:
|
|
return c.LastName
|
|
case models.ContactExportFieldCompany:
|
|
return c.Company
|
|
case models.ContactExportFieldPhone:
|
|
return c.Phone
|
|
case models.ContactExportFieldSubscribed:
|
|
return strconv.FormatBool(c.Subscribed)
|
|
case models.ContactExportFieldCategories:
|
|
titles := make([]string, len(c.Categories))
|
|
for i, cat := range c.Categories {
|
|
titles[i] = cat.Title
|
|
}
|
|
return strings.Join(titles, ", ")
|
|
case models.ContactExportFieldCampaigns:
|
|
names := make([]string, len(c.Campaigns))
|
|
for i, cam := range c.Campaigns {
|
|
names[i] = cam.Name
|
|
}
|
|
return strings.Join(names, ", ")
|
|
case models.ContactExportFieldCreatedAt:
|
|
if c.CreatedAt.IsZero() {
|
|
return ""
|
|
}
|
|
return c.CreatedAt.UTC().Format(time.RFC3339)
|
|
case models.ContactExportFieldUpdatedAt:
|
|
if c.UpdatedAt.IsZero() {
|
|
return ""
|
|
}
|
|
return c.UpdatedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// writeCSV uses encoding/csv from the stdlib. The leading UTF-8 BOM is
|
|
// what Excel-on-Windows looks for to render non-ASCII characters
|
|
// correctly when opening a CSV directly — without it, names like "Söre"
|
|
// become "Söre". Annoying but real.
|
|
func writeCSV(w io.Writer, rows []models.Contact, fields []string) error {
|
|
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
|
return err
|
|
}
|
|
cw := csv.NewWriter(w)
|
|
headers := make([]string, len(fields))
|
|
for i, f := range fields {
|
|
headers[i] = fieldHeader(f)
|
|
}
|
|
if err := cw.Write(headers); err != nil {
|
|
return err
|
|
}
|
|
rec := make([]string, len(fields))
|
|
for i := range rows {
|
|
c := &rows[i]
|
|
for j, f := range fields {
|
|
rec[j] = fieldValue(c, f)
|
|
}
|
|
if err := cw.Write(rec); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
cw.Flush()
|
|
return cw.Error()
|
|
}
|
|
|
|
// writeJSON emits a flat array of objects keyed by field identifier so
|
|
// the file round-trips through the import endpoint unmodified.
|
|
func writeJSON(w io.Writer, rows []models.Contact, fields []string) error {
|
|
enc := json.NewEncoder(w)
|
|
enc.SetIndent("", " ")
|
|
out := make([]map[string]any, len(rows))
|
|
for i := range rows {
|
|
c := &rows[i]
|
|
obj := make(map[string]any, len(fields))
|
|
for _, f := range fields {
|
|
if f == models.ContactExportFieldSubscribed {
|
|
obj[f] = c.Subscribed
|
|
} else if f == models.ContactExportFieldCategories {
|
|
obj[f] = c.Categories
|
|
} else if f == models.ContactExportFieldCampaigns {
|
|
obj[f] = c.Campaigns
|
|
} else {
|
|
obj[f] = fieldValue(c, f)
|
|
}
|
|
}
|
|
out[i] = obj
|
|
}
|
|
return enc.Encode(out)
|
|
}
|
|
|
|
// writeXLSX writes a single-sheet xlsx file using excelize. We freeze
|
|
// the header row + bold it so the file feels native to anyone who's
|
|
// ever opened an Excel template.
|
|
func writeXLSX(w io.Writer, rows []models.Contact, fields []string) error {
|
|
f := excelize.NewFile()
|
|
defer f.Close()
|
|
|
|
sheet := "Contacts"
|
|
idx, err := f.NewSheet(sheet)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f.SetActiveSheet(idx)
|
|
if err := f.DeleteSheet("Sheet1"); err != nil {
|
|
// Best-effort: this is the default sheet excelize creates;
|
|
// if it's already gone we don't care.
|
|
var notFoundErr *excelize.ErrSheetNotExist
|
|
if !errors.As(err, ¬FoundErr) {
|
|
return err
|
|
}
|
|
}
|
|
|
|
headerStyle, _ := f.NewStyle(&excelize.Style{
|
|
Font: &excelize.Font{Bold: true},
|
|
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"#F1F5F9"}},
|
|
})
|
|
|
|
for i, fld := range fields {
|
|
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
|
if err := f.SetCellValue(sheet, cell, fieldHeader(fld)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if headerStyle != 0 && len(fields) > 0 {
|
|
end, _ := excelize.CoordinatesToCellName(len(fields), 1)
|
|
_ = f.SetCellStyle(sheet, "A1", end, headerStyle)
|
|
}
|
|
if err := f.SetPanes(sheet, &excelize.Panes{
|
|
Freeze: true,
|
|
Split: false,
|
|
XSplit: 0,
|
|
YSplit: 1,
|
|
TopLeftCell: "A2",
|
|
ActivePane: "bottomLeft",
|
|
}); err != nil {
|
|
// Freeze pane is decorative; don't fail the whole export over it.
|
|
_ = err
|
|
}
|
|
|
|
for r := 0; r < len(rows); r++ {
|
|
c := &rows[r]
|
|
for i, fld := range fields {
|
|
cell, _ := excelize.CoordinatesToCellName(i+1, r+2)
|
|
var v any
|
|
if fld == models.ContactExportFieldSubscribed {
|
|
v = c.Subscribed
|
|
} else {
|
|
v = fieldValue(c, fld)
|
|
}
|
|
if err := f.SetCellValue(sheet, cell, v); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reasonable column widths so the file looks usable on first open.
|
|
if len(fields) > 0 {
|
|
startCol, _ := excelize.ColumnNumberToName(1)
|
|
endCol, _ := excelize.ColumnNumberToName(len(fields))
|
|
_ = f.SetColWidth(sheet, startCol, endCol, 20)
|
|
}
|
|
|
|
return f.Write(w)
|
|
}
|
|
|
|
var filenameSafe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
|
|
|
// sanitizeFilename strips path separators and weird unicode so the
|
|
// Content-Disposition header is well-formed without quoting tricks.
|
|
func sanitizeFilename(name string) string {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return ""
|
|
}
|
|
name = filenameSafe.ReplaceAllString(name, "_")
|
|
name = strings.Trim(name, "._-")
|
|
if len(name) > 80 {
|
|
name = name[:80]
|
|
}
|
|
return name
|
|
}
|