Переглянути джерело

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 <noreply@anthropic.com>
master
Daniel Covington 2 тижднів тому
джерело
коміт
aa34144d55
9 змінених файлів з 435 додано та 0 видалено
  1. +198
    -0
      app/controllers/BulkLogController.asp
  2. +1
    -0
      app/controllers/autoload_controllers.asp
  3. +25
    -0
      app/lib/lib.BusinessHelpers.asp
  4. +20
    -0
      app/repositories/BusinessRepository.asp
  5. +85
    -0
      app/views/BulkLog/index.asp
  6. +97
    -0
      app/views/BulkLog/preview.asp
  7. +3
    -0
      app/views/shared/header.asp
  8. +1
    -0
      core/lib.ControllerRegistry.asp
  9. +5
    -0
      public/Default.asp

+ 198
- 0
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
%>
<!--#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
%>

+ 1
- 0
app/controllers/autoload_controllers.asp Переглянути файл

@@ -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" -->

+ 25
- 0
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


+ 20
- 0
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


+ 85
- 0
app/views/BulkLog/index.asp Переглянути файл

@@ -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="">&mdash;</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>

+ 97
- 0
app/views/BulkLog/preview.asp Переглянути файл

@@ -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 "&mdash;" %></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 "&mdash; (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")) %> &lt;<%= H(skipped(si)("email")) %>&gt;</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>

+ 3
- 0
app/views/shared/header.asp Переглянути файл

@@ -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>


+ 1
- 0
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()


+ 5
- 0
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"


Завантаження…
Відмінити
Зберегти

Powered by TurnKey Linux.