feat: add ContactRepository.ResolveCategoryNames, which maps the category titles an imported file names to the caller's category ids in one round trip and creates the missing ones at the end of their list with a palette colour, rejecting a title over 50 characters and refusing more than MaxImportCategoryNames distinct values so a free-text column mapped to Categories by mistake cannot mint a category per row

This commit is contained in:
Matthew Meszaros
2026-08-27 03:44:17 -07:00
parent ae17c8dead
commit eff67f64eb
+96
View File
@@ -47,6 +47,10 @@ type ContactRepository interface {
// block sending.
SetContactESP(ctx context.Context, contactID uuid.UUID, provider string) error
GetByEmailsAndUser(ctx context.Context, userID uuid.UUID, emails []string) (map[string]models.Contact, *errx.Error)
// ResolveCategoryNames maps category titles (as typed in an imported file)
// to the caller's category IDs, creating the ones that don't exist yet.
// Keys of the returned map are the lowercased titles.
ResolveCategoryNames(ctx context.Context, userID uuid.UUID, names []string) (map[string]uuid.UUID, *errx.Error)
Search(ctx context.Context, userID string, category, cursor *string, filters models.SearchContacts, limit int32) (*models.ContactsResult, *errx.Error)
// SearchCounts returns org-wide contact facet totals for the browse
// sidebar (independent of any search filters), mirroring campaigns-overview.
@@ -1935,6 +1939,98 @@ func (r *contactRepository) Delete(ctx context.Context, userID string, orgID uui
// the given list, scoped to a single user. Used by the import path to
// detect collisions before doing the bulk upsert. The map is keyed by
// lowercased email so the caller doesn't have to normalize again.
// MaxImportCategoryNames bounds how many distinct category titles one import
// may introduce. A column of free text mapped to Categories by mistake would
// otherwise mint a category per row.
const MaxImportCategoryNames = 100
func (r *contactRepository) ResolveCategoryNames(ctx context.Context, userID uuid.UUID, names []string) (map[string]uuid.UUID, *errx.Error) {
out := make(map[string]uuid.UUID, len(names))
wanted := make([]string, 0, len(names))
seen := make(map[string]string, len(names)) // lowered -> original casing
for _, raw := range names {
title := strings.TrimSpace(raw)
if title == "" {
continue
}
if len(title) > 50 {
return nil, errx.New(errx.BadRequest,
"category name "+strconv.Quote(title)+" is longer than 50 characters")
}
lower := strings.ToLower(title)
if _, dup := seen[lower]; dup {
continue
}
seen[lower] = title
wanted = append(wanted, lower)
}
if len(wanted) == 0 {
return out, nil
}
if len(wanted) > MaxImportCategoryNames {
return nil, errx.New(errx.BadRequest, fmt.Sprintf(
"the categories column has %d distinct values; at most %d can be created in one import",
len(wanted), MaxImportCategoryNames))
}
rows, err := r.DB.Query(ctx, `
SELECT id, LOWER(title) FROM categories
WHERE user_id = $1 AND LOWER(title) = ANY($2::text[])
`, userID, wanted)
if err != nil {
db.CaptureError(err, "", nil, "ResolveCategoryNames query")
return nil, errx.InternalError()
}
for rows.Next() {
var id uuid.UUID
var lower string
if err := rows.Scan(&id, &lower); err != nil {
rows.Close()
db.CaptureError(err, "", nil, "ResolveCategoryNames scan")
return nil, errx.InternalError()
}
out[lower] = id
}
rows.Close()
if err := rows.Err(); err != nil {
db.CaptureError(err, "", nil, "ResolveCategoryNames rows")
return nil, errx.InternalError()
}
missing := make([]string, 0, len(wanted))
for _, lower := range wanted {
if _, ok := out[lower]; !ok {
missing = append(missing, lower)
}
}
if len(missing) == 0 {
return out, nil
}
// Positions continue after whatever the user already has, so the new
// categories land at the end of their list instead of colliding.
var nextPos int32
if err := r.DB.QueryRow(ctx,
`SELECT COALESCE(MAX(position), -1) + 1 FROM categories WHERE user_id = $1`,
userID).Scan(&nextPos); err != nil {
db.CaptureError(err, "", nil, "ResolveCategoryNames position")
return nil, errx.InternalError()
}
for _, lower := range missing {
id := uuid.New()
if _, err := r.DB.Exec(ctx, `
INSERT INTO categories (id, user_id, title, color, position)
VALUES ($1, $2, $3, $4, $5)
`, id, userID, seen[lower], defaultGroupColor(nextPos), nextPos); err != nil {
db.CaptureError(err, "", nil, "ResolveCategoryNames insert")
return nil, errx.InternalError()
}
out[lower] = id
nextPos++
}
return out, nil
}
func (r *contactRepository) GetByEmailsAndUser(ctx context.Context, userID uuid.UUID, emails []string) (map[string]models.Contact, *errx.Error) {
out := make(map[string]models.Contact, len(emails))
if len(emails) == 0 {