From aa34144d55dbe8a49e10cf6dca169c427165547e Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Mon, 26 Oct 2026 10:45:28 -0400 Subject: [PATCH] Add bulk log: record a contact on businesses by pasted email addresses New /bulk-log page: paste emails (semicolon/comma/newline separated), choose method, outcome, contact date and a follow-up date, review the matches, then log the contact on every matching non-removed business. Reuses BusinessRepository.LogInteraction so status and next-contact date update the same way as the single-business form. Registers bulklogcontroller in the core controller whitelist. Co-Authored-By: Claude Sonnet 5.5 --- app/controllers/BulkLogController.asp | 198 +++++++++++++++++++++++ app/controllers/autoload_controllers.asp | 1 + app/lib/lib.BusinessHelpers.asp | 25 +++ app/repositories/BusinessRepository.asp | 20 +++ app/views/BulkLog/index.asp | 85 ++++++++++ app/views/BulkLog/preview.asp | 97 +++++++++++ app/views/shared/header.asp | 3 + core/lib.ControllerRegistry.asp | 1 + public/Default.asp | 5 + 9 files changed, 435 insertions(+) create mode 100644 app/controllers/BulkLogController.asp create mode 100644 app/views/BulkLog/index.asp create mode 100644 app/views/BulkLog/preview.asp diff --git a/app/controllers/BulkLogController.asp b/app/controllers/BulkLogController.asp new file mode 100644 index 0000000..779f9d0 --- /dev/null +++ b/app/controllers/BulkLogController.asp @@ -0,0 +1,198 @@ +<% +Class BulkLogController_Class + Private m_useLayout + Private m_title + + Private Sub Class_Initialize() + m_useLayout = True + m_title = "Bulk log" + End Sub + + Public Property Get useLayout + useLayout = m_useLayout + End Property + + Public Property Let useLayout(v) + m_useLayout = v + End Property + + Public Property Get Title + Title = m_title + End Property + + Public Property Let Title(v) + m_title = v + End Property + + '------------------------------------------------------------------------------------------------------------------- + ' GET /bulk-log : paste emails + dates + '------------------------------------------------------------------------------------------------------------------- + Public Sub index() + Dim csrf, fv + csrf = Csrf_Token("bulklog") + Set fv = Nothing + RenderForm csrf, fv + End Sub + + ' Shared by index and a failed preview: fv (unsaved values) is Nothing for a blank form. + Private Sub RenderForm(csrf, fv) + Dim today, emailsText, method, outcome, contactedAt, followUp, notes + today = Date_Iso(Date) + emailsText = "" : method = "email" : outcome = "" : contactedAt = today : followUp = "" : notes = "" + If Not fv Is Nothing Then + emailsText = fv("emails") : method = fv("method") : outcome = fv("outcome") + contactedAt = fv("contacted_at") : followUp = fv("follow_up_date") : notes = fv("notes") + End If + %> + + <% + End Sub + + '------------------------------------------------------------------------------------------------------------------- + ' POST /bulk-log/preview : show who matched before anything is written + '------------------------------------------------------------------------------------------------------------------- + Public Sub preview() + Dim v, parsed, matches, i, found, k, missing, skipped, toLog, csrf, emailsJoined + Set v = ReadForm() + If Not v.Exists("emails") Then Exit Sub ' ReadForm already redirected + + Set parsed = BulkLog_ParseEmails(v("emails")) + Set matches = BusinessRepository().FindByEmails(parsed("valid")) + + Set found = CreateObject("Scripting.Dictionary") + Set toLog = CreateObject("Scripting.Dictionary") + Set skipped = CreateObject("Scripting.Dictionary") + For i = 0 To matches.Count - 1 + found(LCase(matches(i)("email") & "")) = 1 + If matches(i)("status") = "removed" Then + skipped.Add skipped.Count, matches(i) + Else + toLog.Add toLog.Count, matches(i) + End If + Next + Set missing = CreateObject("Scripting.Dictionary") + For Each k In parsed("valid").Keys + If Not found.Exists(k) Then missing.Add missing.Count, k + Next + + emailsJoined = Join(parsed("valid").Keys, ";") + csrf = Csrf_Token("bulklog") + m_title = "Bulk log: review" + %> + + <% + End Sub + + '------------------------------------------------------------------------------------------------------------------- + ' POST /bulk-log/run : log the contact on every matching, non-removed business + '------------------------------------------------------------------------------------------------------------------- + Public Sub run() + Dim v, parsed, matches, i, n, contactedAt, followUp + Set v = ReadForm() + If Not v.Exists("emails") Then Exit Sub + + contactedAt = Date_Parse(v("contacted_at")) + If IsNull(contactedAt) Then contactedAt = Date + followUp = Date_Parse(v("follow_up_date")) + + Set parsed = BulkLog_ParseEmails(v("emails")) + Set matches = BusinessRepository().FindByEmails(parsed("valid")) + + n = 0 + For i = 0 To matches.Count - 1 + If matches(i)("status") <> "removed" Then + BusinessRepository().LogInteraction CLng(matches(i)("id")), v("method"), v("outcome"), v("notes"), _ + contactedAt, followUp, False, Null + n = n + 1 + End If + Next + + If n = 0 Then + Flash().AddError "No businesses were updated." + Response.Redirect "/bulk-log" + End If + If IsNull(followUp) Then + Flash().Success = "Logged a contact on " & n & " business" & Iif_(n = 1, "", "es") & "." + Else + Flash().Success = "Logged a contact on " & n & " business" & Iif_(n = 1, "", "es") & ". Follow-up set for " & Date_Fmt(followUp) & "." + End If + Response.Redirect "/bulk-log" + End Sub + + '------------------------------------------------------------------------------------------------------------------- + ' Shared checks + '------------------------------------------------------------------------------------------------------------------- + + ' Validates the CSRF token and the form fields; returns them as a Dictionary (empty after a redirect on error). + Private Function ReadForm() + Dim v, d, contactedAt, followUp, method, outcome + Set v = CreateObject("Scripting.Dictionary") + Set ReadForm = v + + If Not Csrf_Valid("bulklog", F("csrf")) Then + Flash().AddError "Invalid form state. Please try again." + Response.Redirect "/bulk-log" + End If + + method = F("method") + outcome = F("outcome") + If Not Businesses_IsKey(Businesses_Methods(), method) Then + Flash().AddError "Choose how you contacted them." + Response.Redirect "/bulk-log" + End If + ' "Remove from list" is deliberately not offered in bulk. + If outcome = "remove" Or Not Businesses_IsKey(Businesses_Outcomes(), outcome) Then outcome = "" + + If Len(F("contacted_at")) > 0 Then + contactedAt = Date_Parse(F("contacted_at")) + If IsNull(contactedAt) Then + Flash().AddError "The contact date isn't a valid date." + Response.Redirect "/bulk-log" + End If + If contactedAt > Date + 1 Then + Flash().AddError "The contact date can't be in the future." + Response.Redirect "/bulk-log" + End If + End If + + If Len(F("follow_up_date")) > 0 Then + followUp = Date_Parse(F("follow_up_date")) + If IsNull(followUp) Then + Flash().AddError "The follow-up date isn't a valid date." + Response.Redirect "/bulk-log" + End If + End If + + Set d = BulkLog_ParseEmails(F("emails")) + If d("valid").Count = 0 Then + Flash().AddError "Enter at least one valid email address." + Response.Redirect "/bulk-log" + End If + If d("valid").Count > 500 Then + Flash().AddError "That's more than 500 addresses; split them into smaller batches." + Response.Redirect "/bulk-log" + End If + + v("emails") = Left(F("emails"), 60000) + v("method") = method + v("outcome") = outcome + v("contacted_at") = F("contacted_at") + v("follow_up_date") = F("follow_up_date") + v("notes") = Left(F("notes"), 10000) + Set ReadForm = v + End Function + + Private Function F(name) + F = CStr(Request.Form(name)) + End Function + +End Class + +Dim BulkLogController_Class__Singleton +Function BulkLogController() + If IsEmpty(BulkLogController_Class__Singleton) Then + Set BulkLogController_Class__Singleton = New BulkLogController_Class + End If + Set BulkLogController = BulkLogController_Class__Singleton +End Function +%> diff --git a/app/controllers/autoload_controllers.asp b/app/controllers/autoload_controllers.asp index 5a3f906..d9bf470 100644 --- a/app/controllers/autoload_controllers.asp +++ b/app/controllers/autoload_controllers.asp @@ -15,5 +15,6 @@ + diff --git a/app/lib/lib.BusinessHelpers.asp b/app/lib/lib.BusinessHelpers.asp index 7d53b43..65c871b 100644 --- a/app/lib/lib.BusinessHelpers.asp +++ b/app/lib/lib.BusinessHelpers.asp @@ -58,6 +58,31 @@ Function Businesses_StatusClass(s) End Select End Function +' Splits pasted text like "a@x.com;b@y.com" (also commas, spaces, new lines) into addresses. +' Returns Dictionary("valid" -> Dictionary(lowercased email -> 1), "invalid" -> Dictionary(index -> text)). +Function BulkLog_ParseEmails(text) + Dim re, parts, p, valid, invalid, result + Set valid = CreateObject("Scripting.Dictionary") + Set invalid = CreateObject("Scripting.Dictionary") + Set re = New RegExp + re.Pattern = "^[^@\s;,]+@[^@\s;,]+\.[^@\s;,]+$" + parts = Split(Replace(Replace(Replace(Replace(text & "", vbCrLf, ";"), vbLf, ";"), ",", ";"), vbTab, ";"), ";") + For Each p In parts + p = LCase(Trim(Replace(p, " ", ""))) + If Len(p) > 0 And Len(p) <= 255 Then + If re.Test(p) Then + valid(p) = 1 + Else + invalid.Add invalid.Count, Left(p, 80) + End If + End If + Next + Set result = CreateObject("Scripting.Dictionary") + Set result("valid") = valid + Set result("invalid") = invalid + Set BulkLog_ParseEmails = result +End Function + ' Query string for the list: current filters with optional page / sort overrides. Function Businesses_QS(f, page, sort, dir) Dim k, s diff --git a/app/repositories/BusinessRepository.asp b/app/repositories/BusinessRepository.asp index 90314e4..8059054 100644 --- a/app/repositories/BusinessRepository.asp +++ b/app/repositories/BusinessRepository.asp @@ -172,6 +172,26 @@ Class BusinessRepository_Class End If End Function + ' Businesses whose email equals one of the keys of `emails` (a Dictionary of lowercased addresses). + ' Returns a Dictionary of row Dictionaries (id, name, contact_name, email, city, state, status, next_action_date). + Public Function FindByEmails(emails) + Dim list, rs, k, d, i + Set list = CreateObject("Scripting.Dictionary") + For Each k In emails.Keys + Set rs = Db_Query("SELECT id, name, contact_name, email, city, state, status, next_action_date FROM businesses WHERE email = ? ORDER BY name, id", Array(Db_PS(k))) + Do While Not rs.EOF + Set d = CreateObject("Scripting.Dictionary") + For i = 0 To rs.Fields.Count - 1 + d(rs.Fields(i).Name) = rs.Fields(i).Value + Next + list.Add list.Count, d + rs.MoveNext + Loop + rs.Close + Next + Set FindByEmails = list + End Function + ' Returns the industry id for a name, creating the industry when it does not exist. Null for a blank name. Public Function FindOrCreateIndustry(name) Dim rs diff --git a/app/views/BulkLog/index.asp b/app/views/BulkLog/index.asp new file mode 100644 index 0000000..1b3adb1 --- /dev/null +++ b/app/views/BulkLog/index.asp @@ -0,0 +1,85 @@ +<% Flash().ShowErrorsIfPresent : Flash().ShowSuccessIfPresent %> + +
+

Bulk log a contact

+
+ +
+
+ + +
+ + +
Separate addresses with semicolons, commas, spaces or new lines. Businesses with a matching email are updated; you review the matches before anything is saved.
+
+ +
+
+ <% Dim mth, oc + For Each mth In Businesses_Methods() %> + /> + + <% Next %> +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ Quick: + + + + + +
+ +
+ + +
+ + +
+
+ + diff --git a/app/views/BulkLog/preview.asp b/app/views/BulkLog/preview.asp new file mode 100644 index 0000000..68c0f21 --- /dev/null +++ b/app/views/BulkLog/preview.asp @@ -0,0 +1,97 @@ +<% Flash().ShowErrorsIfPresent : Flash().ShowSuccessIfPresent %> + +
+

Bulk log: review matches

+
+ +
+
+
+
How
+
<%= H(Businesses_Label(Businesses_Methods(), v("method"))) %>
+
Outcome
+
<% If Len(v("outcome")) > 0 Then Response.Write H(Businesses_Label(Businesses_Outcomes(), v("outcome"))) Else Response.Write "—" %>
+
Date contacted
+
<%= Date_Fmt(Date_Parse(v("contacted_at"))) %>
+
Contact again on
+
<% If Len(v("follow_up_date")) > 0 Then Response.Write Date_Fmt(Date_Parse(v("follow_up_date"))) Else Response.Write "— (no follow-up)" %>
+ <% If Len(v("notes")) > 0 Then %> +
Notes
+
<%= Html_Nl2Br(v("notes")) %>
+ <% End If %> +
+
+
+ +
+
+

Will be updated (<%= toLog.Count %>)

+ <% If toLog.Count = 0 Then %> +

No active businesses matched.

+ <% Else %> +
+ + + + <% Dim ti, row + For ti = 0 To toLog.Count - 1 + Set row = toLog(ti) %> + + + + + + + + <% Next %> + +
BusinessEmailCityStatusCurrent next contact
" class="text-decoration-none"><%= H(row("name")) %><%= H(row("email")) %><%= H(row("city")) %>"><%= H(Businesses_StatusLabel(row("status"))) %><%= Date_Fmt(row("next_action_date")) %>
+
+ <% End If %> +
+
+ +<% If skipped.Count > 0 Then %> +
+ Skipped (removed from the list): + <% Dim si + For si = 0 To skipped.Count - 1 %> +
<%= H(skipped(si)("name")) %> <<%= H(skipped(si)("email")) %>>
+ <% Next %> +
+<% End If %> + +<% If missing.Count > 0 Or parsed("invalid").Count > 0 Then %> +
+ <% If missing.Count > 0 Then %> + No business has these emails (<%= missing.Count %>): +
<% Dim mi + For mi = 0 To missing.Count - 1 + If mi > 0 Then Response.Write ", " + Response.Write H(missing(mi)) + Next %>
+ <% End If %> + <% If parsed("invalid").Count > 0 Then %> +
">Not valid email addresses (ignored):
+
<% Dim ii + For ii = 0 To parsed("invalid").Count - 1 + If ii > 0 Then Response.Write ", " + Response.Write H(parsed("invalid")(ii)) + Next %>
+ <% End If %> +
+<% End If %> + +
+ + + " /> + " /> + " /> + " /> + " /> + <% If toLog.Count > 0 Then %> + + <% End If %> + Back +
diff --git a/app/views/shared/header.asp b/app/views/shared/header.asp index 8c1cd76..bf14720 100644 --- a/app/views/shared/header.asp +++ b/app/views/shared/header.asp @@ -65,6 +65,9 @@ If Len(pageTitle) = 0 Then pageTitle = "Classic ASP Starter Template" + diff --git a/core/lib.ControllerRegistry.asp b/core/lib.ControllerRegistry.asp index 14c665f..91e708e 100644 --- a/core/lib.ControllerRegistry.asp +++ b/core/lib.ControllerRegistry.asp @@ -22,6 +22,7 @@ Class ControllerRegistry_Class RegisterController "areascontroller" RegisterController "exportcontroller" RegisterController "dashboardcontroller" + RegisterController "bulklogcontroller" End Sub Private Sub Class_Terminate() diff --git a/public/Default.asp b/public/Default.asp index f86d6ac..b7ceffc 100644 --- a/public/Default.asp +++ b/public/Default.asp @@ -21,6 +21,11 @@ router.AddRoute "POST", "/businesses/:id/remove", "BusinessesController", "Remove" router.AddRoute "POST", "/businesses/:id/restore", "BusinessesController", "Restore" + ' Bulk log a contact by pasted email addresses + router.AddRoute "GET", "/bulk-log", "BulkLogController", "Index" + router.AddRoute "POST", "/bulk-log/preview", "BulkLogController", "Preview" + router.AddRoute "POST", "/bulk-log/run", "BulkLogController", "Run" + ' Industries router.AddRoute "GET", "/industries", "IndustriesController", "Index" router.AddRoute "POST", "/industries/create", "IndustriesController", "Create"