diff --git a/internal/repository/pg_contact.go b/internal/repository/pg_contact.go index 12de55a4..89cc5834 100644 --- a/internal/repository/pg_contact.go +++ b/internal/repository/pg_contact.go @@ -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 {