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 <noreply@anthropic.com>master
| @@ -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 | |||
| %> | |||
| <!--#include file="../views/BulkLog/index.asp" --> | |||
| <% | |||
| 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" | |||
| %> | |||
| <!--#include file="../views/BulkLog/preview.asp" --> | |||
| <% | |||
| 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 | |||
| %> | |||
| @@ -15,5 +15,6 @@ | |||
| <!--#include file="IndustriesController.asp" --> | |||
| <!--#include file="AreasController.asp" --> | |||
| <!--#include file="ImportController.asp" --> | |||
| <!--#include file="BulkLogController.asp" --> | |||
| <!--#include file="ExportController.asp" --> | |||
| <!--#include file="DashboardController.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 | |||
| @@ -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 | |||
| @@ -0,0 +1,85 @@ | |||
| <% Flash().ShowErrorsIfPresent : Flash().ShowSuccessIfPresent %> | |||
| <div class="d-flex justify-content-between align-items-center mb-3"> | |||
| <h1 class="h3 mb-0">Bulk log a contact</h1> | |||
| </div> | |||
| <form method="post" action="/bulk-log/preview" class="card shadow-sm" id="bulkForm"> | |||
| <div class="card-body"> | |||
| <input type="hidden" name="csrf" value="<%= H(csrf) %>" /> | |||
| <div class="mb-3"> | |||
| <label for="emails" class="form-label small mb-1">Email addresses</label> | |||
| <textarea class="form-control" id="emails" name="emails" rows="4" required | |||
| placeholder="jlkj@jkh.com;hjjkh@yoi.com"><%= H(emailsText) %></textarea> | |||
| <div class="form-text">Separate addresses with semicolons, commas, spaces or new lines. Businesses with a matching email are updated; you review the matches before anything is saved.</div> | |||
| </div> | |||
| <div class="mb-3"> | |||
| <div class="btn-group" role="group" aria-label="How"> | |||
| <% Dim mth, oc | |||
| For Each mth In Businesses_Methods() %> | |||
| <input type="radio" class="btn-check" name="method" id="m_<%= mth(0) %>" value="<%= mth(0) %>" autocomplete="off"<% If mth(0) = method Then Response.Write " checked" %> /> | |||
| <label class="btn btn-outline-primary btn-sm" for="m_<%= mth(0) %>"><%= H(mth(1)) %></label> | |||
| <% Next %> | |||
| </div> | |||
| </div> | |||
| <div class="row g-2 mb-2"> | |||
| <div class="col-md-5"> | |||
| <label for="outcome" class="form-label small mb-1">Outcome</label> | |||
| <select class="form-select form-select-sm" id="outcome" name="outcome"> | |||
| <option value="">—</option> | |||
| <% For Each oc In Businesses_Outcomes() | |||
| If oc(0) <> "remove" Then %> | |||
| <option value="<%= oc(0) %>"<% If oc(0) = outcome Then Response.Write " selected" %>><%= H(oc(1)) %></option> | |||
| <% End If | |||
| Next %> | |||
| </select> | |||
| </div> | |||
| <div class="col-6 col-md-3"> | |||
| <label for="contacted_at" class="form-label small mb-1">Date contacted</label> | |||
| <input type="date" class="form-control form-control-sm" id="contacted_at" name="contacted_at" value="<%= H(contactedAt) %>" max="<%= Date_Iso(Date + 1) %>" required /> | |||
| </div> | |||
| <div class="col-6 col-md-4"> | |||
| <label for="follow_up_date" class="form-label small mb-1">Contact again on</label> | |||
| <input type="date" class="form-control form-control-sm" id="follow_up_date" name="follow_up_date" value="<%= H(followUp) %>" /> | |||
| </div> | |||
| </div> | |||
| <div class="mb-2 small"> | |||
| <span class="text-muted">Quick:</span> | |||
| <button type="button" class="btn btn-link btn-sm p-0 me-2" data-days="3">3 days</button> | |||
| <button type="button" class="btn btn-link btn-sm p-0 me-2" data-days="7">1 week</button> | |||
| <button type="button" class="btn btn-link btn-sm p-0 me-2" data-days="14">2 weeks</button> | |||
| <button type="button" class="btn btn-link btn-sm p-0 me-2" data-days="30">1 month</button> | |||
| <button type="button" class="btn btn-link btn-sm p-0 text-secondary" data-days="0">clear</button> | |||
| </div> | |||
| <div class="mb-3"> | |||
| <label for="notes" class="form-label small mb-1">Notes (added to each business's history)</label> | |||
| <textarea class="form-control form-control-sm" id="notes" name="notes" rows="3" maxlength="10000"><%= H(notes) %></textarea> | |||
| </div> | |||
| <button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-search"></i> Find matches</button> | |||
| </div> | |||
| </form> | |||
| <script> | |||
| (function () { | |||
| var contacted = document.getElementById('contacted_at'); | |||
| var followUp = document.getElementById('follow_up_date'); | |||
| function iso(d) { | |||
| return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2); | |||
| } | |||
| document.querySelectorAll('[data-days]').forEach(function (b) { | |||
| b.addEventListener('click', function () { | |||
| var days = parseInt(b.getAttribute('data-days'), 10); | |||
| if (!days) { followUp.value = ''; return; } | |||
| var base = contacted.value ? new Date(contacted.value + 'T00:00:00') : new Date(); | |||
| base.setDate(base.getDate() + days); | |||
| followUp.value = iso(base); | |||
| }); | |||
| }); | |||
| })(); | |||
| </script> | |||
| @@ -0,0 +1,97 @@ | |||
| <% Flash().ShowErrorsIfPresent : Flash().ShowSuccessIfPresent %> | |||
| <div class="d-flex justify-content-between align-items-center mb-3"> | |||
| <h1 class="h3 mb-0">Bulk log: review matches</h1> | |||
| </div> | |||
| <div class="card shadow-sm mb-3"> | |||
| <div class="card-body"> | |||
| <dl class="row mb-0 small"> | |||
| <dt class="col-sm-3">How</dt> | |||
| <dd class="col-sm-9"><%= H(Businesses_Label(Businesses_Methods(), v("method"))) %></dd> | |||
| <dt class="col-sm-3">Outcome</dt> | |||
| <dd class="col-sm-9"><% If Len(v("outcome")) > 0 Then Response.Write H(Businesses_Label(Businesses_Outcomes(), v("outcome"))) Else Response.Write "—" %></dd> | |||
| <dt class="col-sm-3">Date contacted</dt> | |||
| <dd class="col-sm-9"><%= Date_Fmt(Date_Parse(v("contacted_at"))) %></dd> | |||
| <dt class="col-sm-3">Contact again on</dt> | |||
| <dd class="col-sm-9"><% If Len(v("follow_up_date")) > 0 Then Response.Write Date_Fmt(Date_Parse(v("follow_up_date"))) Else Response.Write "— (no follow-up)" %></dd> | |||
| <% If Len(v("notes")) > 0 Then %> | |||
| <dt class="col-sm-3">Notes</dt> | |||
| <dd class="col-sm-9"><%= Html_Nl2Br(v("notes")) %></dd> | |||
| <% End If %> | |||
| </dl> | |||
| </div> | |||
| </div> | |||
| <div class="card shadow-sm mb-3"> | |||
| <div class="card-body"> | |||
| <h2 class="h5 mb-3">Will be updated <span class="text-muted fs-6">(<%= toLog.Count %>)</span></h2> | |||
| <% If toLog.Count = 0 Then %> | |||
| <p class="text-muted mb-0">No active businesses matched.</p> | |||
| <% Else %> | |||
| <div class="table-responsive"> | |||
| <table class="table table-sm align-middle mb-0"> | |||
| <thead class="table-light"><tr><th>Business</th><th>Email</th><th>City</th><th>Status</th><th>Current next contact</th></tr></thead> | |||
| <tbody> | |||
| <% Dim ti, row | |||
| For ti = 0 To toLog.Count - 1 | |||
| Set row = toLog(ti) %> | |||
| <tr> | |||
| <td><a href="/businesses/<%= row("id") %>" class="text-decoration-none"><%= H(row("name")) %></a></td> | |||
| <td><%= H(row("email")) %></td> | |||
| <td><%= H(row("city")) %></td> | |||
| <td><span class="badge <%= Businesses_StatusClass(row("status")) %>"><%= H(Businesses_StatusLabel(row("status"))) %></span></td> | |||
| <td><%= Date_Fmt(row("next_action_date")) %></td> | |||
| </tr> | |||
| <% Next %> | |||
| </tbody> | |||
| </table> | |||
| </div> | |||
| <% End If %> | |||
| </div> | |||
| </div> | |||
| <% If skipped.Count > 0 Then %> | |||
| <div class="alert alert-secondary"> | |||
| <strong>Skipped (removed from the list):</strong> | |||
| <% Dim si | |||
| For si = 0 To skipped.Count - 1 %> | |||
| <div><%= H(skipped(si)("name")) %> <<%= H(skipped(si)("email")) %>></div> | |||
| <% Next %> | |||
| </div> | |||
| <% End If %> | |||
| <% If missing.Count > 0 Or parsed("invalid").Count > 0 Then %> | |||
| <div class="alert alert-warning"> | |||
| <% If missing.Count > 0 Then %> | |||
| <strong>No business has these emails (<%= missing.Count %>):</strong> | |||
| <div class="small"><% Dim mi | |||
| For mi = 0 To missing.Count - 1 | |||
| If mi > 0 Then Response.Write ", " | |||
| Response.Write H(missing(mi)) | |||
| Next %></div> | |||
| <% End If %> | |||
| <% If parsed("invalid").Count > 0 Then %> | |||
| <div class="<% If missing.Count > 0 Then Response.Write "mt-2" %>"><strong>Not valid email addresses (ignored):</strong></div> | |||
| <div class="small"><% Dim ii | |||
| For ii = 0 To parsed("invalid").Count - 1 | |||
| If ii > 0 Then Response.Write ", " | |||
| Response.Write H(parsed("invalid")(ii)) | |||
| Next %></div> | |||
| <% End If %> | |||
| </div> | |||
| <% End If %> | |||
| <form method="post" action="/bulk-log/run" class="d-flex gap-2"> | |||
| <input type="hidden" name="csrf" value="<%= H(csrf) %>" /> | |||
| <input type="hidden" name="emails" value="<%= H(emailsJoined) %>" /> | |||
| <input type="hidden" name="method" value="<%= H(v("method")) %>" /> | |||
| <input type="hidden" name="outcome" value="<%= H(v("outcome")) %>" /> | |||
| <input type="hidden" name="contacted_at" value="<%= H(v("contacted_at")) %>" /> | |||
| <input type="hidden" name="follow_up_date" value="<%= H(v("follow_up_date")) %>" /> | |||
| <input type="hidden" name="notes" value="<%= H(v("notes")) %>" /> | |||
| <% If toLog.Count > 0 Then %> | |||
| <button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-check2"></i> Log contact on <%= toLog.Count %> business<% If toLog.Count <> 1 Then Response.Write "es" %></button> | |||
| <% End If %> | |||
| <a href="/bulk-log" class="btn btn-outline-secondary btn-sm">Back</a> | |||
| </form> | |||
| @@ -65,6 +65,9 @@ If Len(pageTitle) = 0 Then pageTitle = "Classic ASP Starter Template" | |||
| <li class="nav-item"> | |||
| <a class="nav-link" href="/businesses">Businesses</a> | |||
| </li> | |||
| <li class="nav-item"> | |||
| <a class="nav-link" href="/bulk-log">Bulk log</a> | |||
| </li> | |||
| <li class="nav-item"> | |||
| <a class="nav-link" href="/industries">Industries</a> | |||
| </li> | |||
| @@ -22,6 +22,7 @@ Class ControllerRegistry_Class | |||
| RegisterController "areascontroller" | |||
| RegisterController "exportcontroller" | |||
| RegisterController "dashboardcontroller" | |||
| RegisterController "bulklogcontroller" | |||
| End Sub | |||
| Private Sub Class_Terminate() | |||
| @@ -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" | |||
Powered by TurnKey Linux.