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 %> + +
No active businesses matched.
+ <% Else %> +| Business | City | Status | Current 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")) %> | +