diff --git a/app/controllers/OrderApiController.asp b/app/controllers/OrderApiController.asp index 9736d0c..efaa0e6 100644 --- a/app/controllers/OrderApiController.asp +++ b/app/controllers/OrderApiController.asp @@ -78,7 +78,7 @@ Class OrderApiController_Class On Error GoTo 0 Set answers = json().data - Dim validationError : validationError = ValidateAnswers(answers) + Dim validationError : validationError = ValidateOrderDetailsAnswers(answers) If Len(validationError) > 0 Then WriteJsonError "400 Bad Request", validationError Exit Sub @@ -109,426 +109,11 @@ Class OrderApiController_Class ' Best-effort: the order is already safely persisted at this point, so an SMTP hiccup ' should not turn a successful submission into a failed one. - SendOrderDetailsEmail order, model + OrderMailer().SendOrderDetailsEmail order, model Response.Write "{""success"":true}" End Sub - '------------------------------------------------------------------------------------------------------------------- - ' Order-confirmation email - '------------------------------------------------------------------------------------------------------------------- - Private Sub SendOrderDetailsEmail(order, model) - On Error Resume Next - - Dim smtpPort : smtpPort = GetAppSetting("SmtpPort") - If Not IsNumeric(smtpPort) Then smtpPort = 25 - - Dim mail : Set mail = CDOEmail() - mail.SMTPServer = GetAppSetting("SmtpServer") - mail.SMTPPort = CInt(smtpPort) - mail.SMTPUsername = GetAppSetting("SmtpUsername") - mail.SMTPPassword = GetAppSetting("SmtpPassword") - mail.SMTPUseSSL = (LCase(GetAppSetting("SmtpUseSSL")) = "true") - mail.From = GetAppSetting("SmtpFromAddress") - mail.Subject = "Your Purple Envelope order details - Jurisdiction " & order("JurisdictionNumber") - mail.IsBodyHTML = True - mail.Body = BuildOrderDetailsEmailBody(order, model) - mail.AddRecipient "To", order("Email") - mail.Send - - Err.Clear - On Error GoTo 0 - End Sub - - Private Function BuildOrderDetailsEmailBody(order, model) - Dim html - html = "" & _ - "
" & _ - "" & _ - "" & _ - "" & _ - "" & _ - "
" & _ - "KCI Purple Envelope Order Confirmation" & _ - "
" & _ - "
Order Received
" & _ - "

Thanks! We've received your order details.

" & _ - "

Jurisdiction number " & H(order("JurisdictionNumber")) & "" & _ - " · submitted " & H(EmailDate(model.SubmittedAt)) & "

" - - html = html & EmailSection("Contact Information", _ - EmailRow("Contact Name", model.ContactName) & _ - EmailRow("Municipality", model.Municipality) & _ - EmailRow("Phone", model.Phone)) - - If IsTrue(model.PurpleWantsQuote) Then - Dim purpleBlueProviderLine - purpleBlueProviderLine = LabelPurpleBlueProvider(model.PurpleBlueProvider) - If purpleBlueProviderLine = "Other" And Len(Trim(model.PurpleBlueProviderOther & "")) > 0 Then - purpleBlueProviderLine = model.PurpleBlueProviderOther - End If - - html = html & EmailSection("Purple Ballot Envelopes", _ - EmailRow("Envelope stock", LabelEnvelopeStock(model.PurpleEnvelopeStock)) & _ - EmailRow("Print option", LabelPrintOption(model.PurplePrintOption)) & _ - EmailRow("Also wants KCI blue envelopes", EmailYesNo(model.PurpleWantsBlueEnvelopes)) & _ - EmailRow("Blue envelope provider", purpleBlueProviderLine) & _ - EmailRow("Print style", LabelPrintStyle(model.PurplePrintStyle)) & _ - EmailRow("Color coding by", LabelColorCodingBy(model.PurpleColorCodingBy)) & _ - EmailRow("Number of precincts", model.PurplePrecinctCount) & _ - EmailRow("Colors requested", model.PurpleColorNames) & _ - EmailRow("Track with TrackMI Ballot", EmailYesNo(model.PurpleTrackMIBallot)) & _ - EmailRow("Extra envelopes requested", model.PurpleExtraEnvelopeQty)) - End If - - If IsTrue(model.BlueWantsQuote) Then - ' City/permit number are always collected for an own-organization permit - a - ' permit lookup can't be done from the nonprofit authorization code alone, so - ' that code is additional information when nonprofit status is Yes, not a - ' replacement for city/permit number. - Dim bluePermitLine - bluePermitLine = "" - If LabelPermitOwnership(model.BluePermitOwnership) = "My organization's permit" Then - bluePermitLine = EmailRow("City of permit holder", model.BluePermitCity) & _ - EmailRow("Permit number", model.BluePermitNumber) - If IsTrue(model.BlueHasNonprofitStatus) Then - bluePermitLine = bluePermitLine & EmailRow("Nonprofit authorization code", model.BlueNonprofitAuthCode) - End If - End If - - html = html & EmailSection("Blue AV Envelopes", _ - EmailRow("Print permit", EmailYesNo(model.BluePrintPermit)) & _ - EmailRow("Permit ownership", LabelPermitOwnership(model.BluePermitOwnership)) & _ - EmailRow("Confirmed nonprofit status with USPS", EmailYesNo(model.BlueHasNonprofitStatus)) & _ - bluePermitLine) - End If - - If IsTrue(model.MailingWantsService) Then - html = html & EmailSection("Ballot Mailing Service", _ - EmailRow("Postage", LabelPostageOption(model.MailingPostageOption)) & _ - EmailRow("Nonprofit status with USPS", LabelNonprofitStatus(model.MailingHasNonprofitStatus)) & _ - EmailRow("Estimated quantity", model.MailingEstimatedQuantity) & _ - EmailRow("Preferred pickup date", EmailDate(model.MailingPickupDate))) - End If - - If NumOrZero(model.SecrecySleevesQty) > 0 Or NumOrZero(model.IVotedStickerRolls) > 0 Or NumOrZero(model.FutureVoterStickerRolls) > 0 Or Len(Trim(model.SpecialRequests & "")) > 0 Then - html = html & EmailSection("Additional Election Items", _ - EmailRow("Secrecy sleeves (quantity)", model.SecrecySleevesQty) & _ - EmailRow("""I Voted"" stickers (rolls of 250)", model.IVotedStickerRolls) & _ - EmailRow("""Future Voter"" stickers (rolls of 500)", model.FutureVoterStickerRolls) & _ - EmailRow("Special requests", model.SpecialRequests)) - End If - - html = html & "

Questions about this order? Just reply to this email.

" & _ - "
" & _ - "Purple Envelope · Addressing & Barcoding" & _ - "
" - - BuildOrderDetailsEmailBody = html - End Function - - Private Function EmailSection(sectionTitle, rowsHtml) - If Len(rowsHtml) = 0 Then - EmailSection = "" - Else - EmailSection = "

" & _ - H(sectionTitle) & "

" & _ - "" & rowsHtml & "
" - End If - End Function - - Private Function EmailRow(label, value) - If Len(Trim(value & "")) = 0 Then - EmailRow = "" - Else - EmailRow = "" & _ - "" & H(label) & "" & _ - "" & H(value) & "" & _ - "" - End If - End Function - - ' Null-safe boolean check for gating whole sections - a section's top-level "do you want - ' this?" question isn't marked required client-side, so it may be missing (Null) rather - ' than explicitly false. - Private Function IsTrue(v) - If IsNull(v) Then - IsTrue = False - Else - IsTrue = CBool(v) - End If - End Function - - Private Function EmailYesNo(v) - If IsNull(v) Then - EmailYesNo = "" - ElseIf CBool(v) Then - EmailYesNo = "Yes" - Else - EmailYesNo = "No" - End If - End Function - - Private Function EmailDate(v) - If IsNull(v) Then - EmailDate = "" - Else - On Error Resume Next - EmailDate = MonthName(Month(v)) & " " & Day(v) & ", " & Year(v) - If Err.Number <> 0 Then EmailDate = "" - Err.Clear - On Error GoTo 0 - End If - End Function - - Private Function NumOrZero(v) - If IsNull(v) Or Not IsNumeric(v) Then - NumOrZero = 0 - Else - NumOrZero = CDbl(v) - End If - End Function - - ' Human-readable labels for the coded choice values written by continue.asp's SurveyJS - ' model - falls back to the raw code for anything unrecognized rather than hiding it. - Private Function LabelPrintOption(code) - Select Case CStr(code & "") - Case "AddressesAndPermit" : LabelPrintOption = "Addresses and state of Michigan permit" - Case "PermitOnly" : LabelPrintOption = "State of Michigan permit only" - Case Else : LabelPrintOption = CStr(code & "") - End Select - End Function - - Private Function LabelPurpleBlueProvider(code) - Select Case CStr(code & "") - Case "ElectionSource" : LabelPurpleBlueProvider = "ElectionSource" - Case "PSI" : LabelPurpleBlueProvider = "PSI" - Case "Spectrum" : LabelPurpleBlueProvider = "Spectrum" - Case "Other" : LabelPurpleBlueProvider = "Other" - Case Else : LabelPurpleBlueProvider = CStr(code & "") - End Select - End Function - - Private Function LabelPermitOwnership(code) - Select Case CStr(code & "") - Case "KCIPermit" : LabelPermitOwnership = "KCI permit" - Case "OwnPermit" : LabelPermitOwnership = "My organization's permit" - Case Else : LabelPermitOwnership = CStr(code & "") - End Select - End Function - - Private Function LabelEnvelopeStock(code) - Select Case CStr(code & "") - Case "KCIStock" : LabelEnvelopeStock = "KCI's stock" - Case "OwnStock" : LabelEnvelopeStock = "My own stock" - Case Else : LabelEnvelopeStock = CStr(code & "") - End Select - End Function - - Private Function LabelPrintStyle(code) - Select Case CStr(code & "") - Case "ColorCoding" : LabelPrintStyle = "Color Coding" - Case "BlackOnly" : LabelPrintStyle = "Black Ink Only" - Case Else : LabelPrintStyle = CStr(code & "") - End Select - End Function - - Private Function LabelColorCodingBy(code) - Select Case CStr(code & "") - Case "Precinct" : LabelColorCodingBy = "Precinct" - Case "Election" : LabelColorCodingBy = "Election" - Case Else : LabelColorCodingBy = CStr(code & "") - End Select - End Function - - Private Function LabelPostageOption(code) - Select Case CStr(code & "") - Case "FirstClass" : LabelPostageOption = "Yes, at First Class Rate ($0.721/pc.)" - Case "NonprofitRate" : LabelPostageOption = "Yes, at Nonprofit Rate ($0.263/pc.)" - Case "PresortStandard" : LabelPostageOption = "Yes, at Presort Standard Rate ($0.473/pc.)" - Case "No" : LabelPostageOption = "No" - Case Else : LabelPostageOption = CStr(code & "") - End Select - End Function - - Private Function LabelNonprofitStatus(code) - Select Case CStr(code & "") - Case "Yes" : LabelNonprofitStatus = "Yes" - Case "No" : LabelNonprofitStatus = "No" - Case "NotSure" : LabelNonprofitStatus = "I'm not sure" - Case Else : LabelNonprofitStatus = CStr(code & "") - End Select - End Function - - '------------------------------------------------------------------------------------------------------------------- - ' Re-validates the submission server-side, mirroring the SurveyJS required/enum rules in - ' continue.asp - the client-side checks are a UX convenience, not something the server can - ' trust. Returns "" if valid, or a "; "-joined list of validation error messages. - '------------------------------------------------------------------------------------------------------------------- - Private Function ValidateAnswers(answers) - Dim errors() : ReDim errors(-1) - - RequireNonEmptyString errors, answers, "ContactName", "Contact name is required." - RequireNonEmptyString errors, answers, "Municipality", "Municipality is required." - RequireNonEmptyString errors, answers, "Phone", "Phone number is required." - - RequireEnum errors, answers, "PurpleEnvelopeStock", Array("KCIStock", "OwnStock") - RequireEnum errors, answers, "PurplePrintOption", Array("AddressesAndPermit", "PermitOnly") - RequireEnum errors, answers, "PurpleBlueProvider", Array("ElectionSource", "PSI", "Spectrum", "Other") - RequireEnum errors, answers, "PurplePrintStyle", Array("ColorCoding", "BlackOnly") - RequireEnum errors, answers, "PurpleColorCodingBy", Array("Precinct", "Election") - RequireEnum errors, answers, "BluePermitOwnership", Array("KCIPermit", "OwnPermit") - RequireEnum errors, answers, "MailingPostageOption", Array("FirstClass", "NonprofitRate", "PresortStandard", "No") - RequireEnum errors, answers, "MailingHasNonprofitStatus", Array("Yes", "No", "NotSure") - - RequireBoolean errors, answers, "PurpleWantsQuote" - RequireBoolean errors, answers, "PurpleWantsBlueEnvelopes" - RequireBoolean errors, answers, "PurpleTrackMIBallot" - RequireBoolean errors, answers, "BlueWantsQuote" - RequireBoolean errors, answers, "BluePrintPermit" - RequireBoolean errors, answers, "BlueHasNonprofitStatus" - RequireBoolean errors, answers, "MailingWantsService" - - RequireNonNegativeInteger errors, answers, "PurplePrecinctCount" - RequireNonNegativeInteger errors, answers, "PurpleExtraEnvelopeQty" - RequireNonNegativeInteger errors, answers, "MailingEstimatedQuantity" - RequireNonNegativeInteger errors, answers, "SecrecySleevesQty" - RequireNonNegativeInteger errors, answers, "IVotedStickerRolls" - RequireNonNegativeInteger errors, answers, "FutureVoterStickerRolls" - - RequireValidDate errors, answers, "MailingPickupDate" - - If UBound(errors) >= 0 Then - ValidateAnswers = Join(errors, "; ") - Else - ValidateAnswers = "" - End If - End Function - - Private Sub AddValidationError(ByRef errors, msg) - ReDim Preserve errors(UBound(errors) + 1) - errors(UBound(errors)) = msg - End Sub - - ' Required on the client (page 1) - re-checked here since the client can't be trusted. - Private Sub RequireNonEmptyString(ByRef errors, answers, key, msg) - Dim v : v = AnswerValue(answers, key) - If IsNull(v) Or Len(Trim(v & "")) = 0 Then AddValidationError errors, msg - End Sub - - ' Only checked when present - these questions are conditionally hidden by visibleIf, so a - ' missing value just means the question wasn't shown, not an invalid submission. - Private Sub RequireEnum(ByRef errors, answers, key, allowedValues) - Dim v : v = AnswerValue(answers, key) - If Not IsNull(v) Then - Dim found : found = False - Dim i - For i = 0 To UBound(allowedValues) - If CStr(v) = allowedValues(i) Then found = True - Next - If Not found Then AddValidationError errors, key & " has an invalid value." - End If - End Sub - - Private Sub RequireBoolean(ByRef errors, answers, key) - Dim v : v = AnswerValue(answers, key) - If Not IsNull(v) Then - If TypeName(v) <> "Boolean" Then AddValidationError errors, key & " must be true or false." - End If - End Sub - - Private Sub RequireNonNegativeInteger(ByRef errors, answers, key) - Dim v : v = AnswerValue(answers, key) - If Not IsNull(v) Then - If Not IsNumeric(v) Then - AddValidationError errors, key & " must be a number." - ElseIf CDbl(v) < 0 Then - AddValidationError errors, key & " cannot be negative." - ElseIf CDbl(v) <> Int(CDbl(v)) Then - AddValidationError errors, key & " must be a whole number." - End If - End If - End Sub - - Private Sub RequireValidDate(ByRef errors, answers, key) - Dim v : v = AnswerValue(answers, key) - If Not IsNull(v) Then - If Len(Trim(v & "")) > 0 And Not IsDate(v) Then - AddValidationError errors, key & " is not a valid date." - End If - End If - End Sub - - '------------------------------------------------------------------------------------------------------------------- - ' Private helpers - '------------------------------------------------------------------------------------------------------------------- - Private Sub MapOrderDetailsAnswers(ByRef model, answers) - ' Contact - model.ContactName = AnswerValue(answers, "ContactName") - model.Municipality = AnswerValue(answers, "Municipality") - model.Phone = AnswerValue(answers, "Phone") - - ' Purple envelope - model.PurpleWantsQuote = AnswerValue(answers, "PurpleWantsQuote") - model.PurpleEnvelopeStock = AnswerValue(answers, "PurpleEnvelopeStock") - model.PurplePrintOption = AnswerValue(answers, "PurplePrintOption") - model.PurpleWantsBlueEnvelopes = AnswerValue(answers, "PurpleWantsBlueEnvelopes") - model.PurpleBlueProvider = AnswerValue(answers, "PurpleBlueProvider") - model.PurpleBlueProviderOther = AnswerValue(answers, "PurpleBlueProviderOther") - model.PurplePrintStyle = AnswerValue(answers, "PurplePrintStyle") - model.PurpleColorCodingBy = AnswerValue(answers, "PurpleColorCodingBy") - model.PurplePrecinctCount = AnswerValue(answers, "PurplePrecinctCount") - model.PurpleColorNames = AnswerValue(answers, "PurpleColorNames") - model.PurpleTrackMIBallot = AnswerValue(answers, "PurpleTrackMIBallot") - model.PurpleExtraEnvelopeQty = AnswerValue(answers, "PurpleExtraEnvelopeQty") - - ' Blue envelope - model.BlueWantsQuote = AnswerValue(answers, "BlueWantsQuote") - model.BluePrintPermit = AnswerValue(answers, "BluePrintPermit") - model.BluePermitOwnership = AnswerValue(answers, "BluePermitOwnership") - model.BlueHasNonprofitStatus = AnswerValue(answers, "BlueHasNonprofitStatus") - model.BlueNonprofitAuthCode = AnswerValue(answers, "BlueNonprofitAuthCode") - model.BluePermitCity = AnswerValue(answers, "BluePermitCity") - model.BluePermitNumber = AnswerValue(answers, "BluePermitNumber") - - ' Mailing service - model.MailingWantsService = AnswerValue(answers, "MailingWantsService") - model.MailingPostageOption = AnswerValue(answers, "MailingPostageOption") - model.MailingHasNonprofitStatus = AnswerValue(answers, "MailingHasNonprofitStatus") - model.MailingEstimatedQuantity = AnswerValue(answers, "MailingEstimatedQuantity") - model.MailingPickupDate = AnswerValue(answers, "MailingPickupDate") - - ' Additional items - model.SecrecySleevesQty = AnswerValue(answers, "SecrecySleevesQty") - model.IVotedStickerRolls = AnswerValue(answers, "IVotedStickerRolls") - model.FutureVoterStickerRolls = AnswerValue(answers, "FutureVoterStickerRolls") - model.SpecialRequests = AnswerValue(answers, "SpecialRequests") - End Sub - - ' Returns the value for key in a parsed aspJSON Dictionary, or Null if the question was - ' skipped (hidden by visibleIf logic and so never included in SurveyJS's result data). - Private Function AnswerValue(answers, key) - If answers.Exists(key) Then - AnswerValue = answers.Item(key) - Else - AnswerValue = Null - End If - End Function - - Private Sub WriteJsonError(statusLine, message) - Response.Status = statusLine - Response.Write "{""success"":false,""error"":" & JsonEncodeString(message) & "}" - End Sub - - Private Function JsonEncodeString(s) - Dim out : out = s - out = Replace(out, "\", "\\") - out = Replace(out, """", "\""") - out = Replace(out, Chr(10), "\n") - out = Replace(out, Chr(13), "\r") - JsonEncodeString = """" & out & """" - End Function - End Class ' Singleton instance diff --git a/app/controllers/RequestOrderController.asp b/app/controllers/RequestOrderController.asp index 91230b3..51dca08 100644 --- a/app/controllers/RequestOrderController.asp +++ b/app/controllers/RequestOrderController.asp @@ -84,52 +84,12 @@ Class RequestOrderController_Class End If Dim token : token = OrdersRepository().CreateOrder(email, jurisdictionNumber) - SendOrderContinuationEmail email, token + OrderMailer().SendContinuationEmail email, token Flash().Success = "Check your email for a link to continue your order." Response.Redirect Routes().AppURL & "request-order" End Sub - '------------------------------------------------------------------------------------------------------------------- - ' Private helpers - '------------------------------------------------------------------------------------------------------------------- - Private Function IsValidEmail(email) - Dim re - Set re = New RegExp - re.Pattern = "^[^\s@]+@[^\s@]+\.[^\s@]+$" - re.IgnoreCase = True - IsValidEmail = re.Test(email) - End Function - - Private Sub AddValidationError(ByRef errorList, msg) - ReDim Preserve errorList(UBound(errorList) + 1) - errorList(UBound(errorList)) = msg - End Sub - - Private Sub SendOrderContinuationEmail(email, token) - Dim continueUrl - continueUrl = Routes().AppURL & "order/continue?token=" & Server.URLEncode(token) - - Dim smtpPort : smtpPort = GetAppSetting("SmtpPort") - If Not IsNumeric(smtpPort) Then smtpPort = 25 - - Dim mail : Set mail = CDOEmail() - mail.SMTPServer = GetAppSetting("SmtpServer") - mail.SMTPPort = CInt(smtpPort) - mail.SMTPUsername = GetAppSetting("SmtpUsername") - mail.SMTPPassword = GetAppSetting("SmtpPassword") - mail.SMTPUseSSL = (LCase(GetAppSetting("SmtpUseSSL")) = "true") - mail.From = GetAppSetting("SmtpFromAddress") - mail.Subject = "Continue your Purple Envelope order" - mail.IsBodyHTML = True - mail.Body = "

Click the link below to continue your order:

" & _ - "

" & H(continueUrl) & "

" & _ - "

This link expires in " & H(GetAppSetting("OrderTokenExpirationHours")) & _ - " hours and can only be used once.

" - mail.AddRecipient "To", email - mail.Send - End Sub - End Class Dim RequestOrderController_Class__Singleton diff --git a/app/controllers/autoload_controllers.asp b/app/controllers/autoload_controllers.asp index edb70b3..255211b 100644 --- a/app/controllers/autoload_controllers.asp +++ b/app/controllers/autoload_controllers.asp @@ -2,6 +2,9 @@ + + + diff --git a/app/models/OrderDetailsAnswersMapper.asp b/app/models/OrderDetailsAnswersMapper.asp new file mode 100644 index 0000000..3fb4860 --- /dev/null +++ b/app/models/OrderDetailsAnswersMapper.asp @@ -0,0 +1,91 @@ +<% +'======================================================================================================================= +' Maps a parsed-JSON answers Dictionary (from the SurveyJS order-details form) onto a +' POBO_OrderDetails instance, and validates it first - the client-side SurveyJS +' required/enum rules in continue.asp are a UX convenience, not something the server can trust. +'======================================================================================================================= + +' Returns "" if valid, or a "; "-joined list of validation error messages. +Function ValidateOrderDetailsAnswers(answers) + Dim errors() : ReDim errors(-1) + + RequireNonEmptyString errors, answers, "ContactName", "Contact name is required." + RequireNonEmptyString errors, answers, "Municipality", "Municipality is required." + RequireNonEmptyString errors, answers, "Phone", "Phone number is required." + + RequireEnum errors, answers, "PurpleEnvelopeStock", Array("KCIStock", "OwnStock") + RequireEnum errors, answers, "PurplePrintOption", Array("AddressesAndPermit", "PermitOnly") + RequireEnum errors, answers, "PurpleBlueProvider", Array("ElectionSource", "PSI", "Spectrum", "Other") + RequireEnum errors, answers, "PurplePrintStyle", Array("ColorCoding", "BlackOnly") + RequireEnum errors, answers, "PurpleColorCodingBy", Array("Precinct", "Election") + RequireEnum errors, answers, "BluePermitOwnership", Array("KCIPermit", "OwnPermit") + RequireEnum errors, answers, "MailingPostageOption", Array("FirstClass", "NonprofitRate", "PresortStandard", "No") + RequireEnum errors, answers, "MailingHasNonprofitStatus", Array("Yes", "No", "NotSure") + + RequireBoolean errors, answers, "PurpleWantsQuote" + RequireBoolean errors, answers, "PurpleWantsBlueEnvelopes" + RequireBoolean errors, answers, "PurpleTrackMIBallot" + RequireBoolean errors, answers, "BlueWantsQuote" + RequireBoolean errors, answers, "BluePrintPermit" + RequireBoolean errors, answers, "BlueHasNonprofitStatus" + RequireBoolean errors, answers, "MailingWantsService" + + RequireNonNegativeInteger errors, answers, "PurplePrecinctCount" + RequireNonNegativeInteger errors, answers, "PurpleExtraEnvelopeQty" + RequireNonNegativeInteger errors, answers, "MailingEstimatedQuantity" + RequireNonNegativeInteger errors, answers, "SecrecySleevesQty" + RequireNonNegativeInteger errors, answers, "IVotedStickerRolls" + RequireNonNegativeInteger errors, answers, "FutureVoterStickerRolls" + + RequireValidDate errors, answers, "MailingPickupDate" + + If UBound(errors) >= 0 Then + ValidateOrderDetailsAnswers = Join(errors, "; ") + Else + ValidateOrderDetailsAnswers = "" + End If +End Function + +Sub MapOrderDetailsAnswers(ByRef model, answers) + ' Contact + model.ContactName = DictValue(answers, "ContactName") + model.Municipality = DictValue(answers, "Municipality") + model.Phone = DictValue(answers, "Phone") + + ' Purple envelope + model.PurpleWantsQuote = DictValue(answers, "PurpleWantsQuote") + model.PurpleEnvelopeStock = DictValue(answers, "PurpleEnvelopeStock") + model.PurplePrintOption = DictValue(answers, "PurplePrintOption") + model.PurpleWantsBlueEnvelopes = DictValue(answers, "PurpleWantsBlueEnvelopes") + model.PurpleBlueProvider = DictValue(answers, "PurpleBlueProvider") + model.PurpleBlueProviderOther = DictValue(answers, "PurpleBlueProviderOther") + model.PurplePrintStyle = DictValue(answers, "PurplePrintStyle") + model.PurpleColorCodingBy = DictValue(answers, "PurpleColorCodingBy") + model.PurplePrecinctCount = DictValue(answers, "PurplePrecinctCount") + model.PurpleColorNames = DictValue(answers, "PurpleColorNames") + model.PurpleTrackMIBallot = DictValue(answers, "PurpleTrackMIBallot") + model.PurpleExtraEnvelopeQty = DictValue(answers, "PurpleExtraEnvelopeQty") + + ' Blue envelope + model.BlueWantsQuote = DictValue(answers, "BlueWantsQuote") + model.BluePrintPermit = DictValue(answers, "BluePrintPermit") + model.BluePermitOwnership = DictValue(answers, "BluePermitOwnership") + model.BlueHasNonprofitStatus = DictValue(answers, "BlueHasNonprofitStatus") + model.BlueNonprofitAuthCode = DictValue(answers, "BlueNonprofitAuthCode") + model.BluePermitCity = DictValue(answers, "BluePermitCity") + model.BluePermitNumber = DictValue(answers, "BluePermitNumber") + + ' Mailing service + model.MailingWantsService = DictValue(answers, "MailingWantsService") + model.MailingPostageOption = DictValue(answers, "MailingPostageOption") + model.MailingHasNonprofitStatus = DictValue(answers, "MailingHasNonprofitStatus") + model.MailingEstimatedQuantity = DictValue(answers, "MailingEstimatedQuantity") + model.MailingPickupDate = DictValue(answers, "MailingPickupDate") + + ' Additional items + model.SecrecySleevesQty = DictValue(answers, "SecrecySleevesQty") + model.IVotedStickerRolls = DictValue(answers, "IVotedStickerRolls") + model.FutureVoterStickerRolls = DictValue(answers, "FutureVoterStickerRolls") + model.SpecialRequests = DictValue(answers, "SpecialRequests") +End Sub +%> diff --git a/app/models/OrderDetailsLabels.asp b/app/models/OrderDetailsLabels.asp new file mode 100644 index 0000000..c33be53 --- /dev/null +++ b/app/models/OrderDetailsLabels.asp @@ -0,0 +1,75 @@ +<% +'======================================================================================================================= +' Human-readable labels for the coded choice values written by continue.asp's SurveyJS +' model - falls back to the raw code for anything unrecognized rather than hiding it. +'======================================================================================================================= + +Function LabelPrintOption(code) + Select Case CStr(code & "") + Case "AddressesAndPermit" : LabelPrintOption = "Addresses and state of Michigan permit" + Case "PermitOnly" : LabelPrintOption = "State of Michigan permit only" + Case Else : LabelPrintOption = CStr(code & "") + End Select +End Function + +Function LabelPurpleBlueProvider(code) + Select Case CStr(code & "") + Case "ElectionSource" : LabelPurpleBlueProvider = "ElectionSource" + Case "PSI" : LabelPurpleBlueProvider = "PSI" + Case "Spectrum" : LabelPurpleBlueProvider = "Spectrum" + Case "Other" : LabelPurpleBlueProvider = "Other" + Case Else : LabelPurpleBlueProvider = CStr(code & "") + End Select +End Function + +Function LabelPermitOwnership(code) + Select Case CStr(code & "") + Case "KCIPermit" : LabelPermitOwnership = "KCI permit" + Case "OwnPermit" : LabelPermitOwnership = "My organization's permit" + Case Else : LabelPermitOwnership = CStr(code & "") + End Select +End Function + +Function LabelEnvelopeStock(code) + Select Case CStr(code & "") + Case "KCIStock" : LabelEnvelopeStock = "KCI's stock" + Case "OwnStock" : LabelEnvelopeStock = "My own stock" + Case Else : LabelEnvelopeStock = CStr(code & "") + End Select +End Function + +Function LabelPrintStyle(code) + Select Case CStr(code & "") + Case "ColorCoding" : LabelPrintStyle = "Color Coding" + Case "BlackOnly" : LabelPrintStyle = "Black Ink Only" + Case Else : LabelPrintStyle = CStr(code & "") + End Select +End Function + +Function LabelColorCodingBy(code) + Select Case CStr(code & "") + Case "Precinct" : LabelColorCodingBy = "Precinct" + Case "Election" : LabelColorCodingBy = "Election" + Case Else : LabelColorCodingBy = CStr(code & "") + End Select +End Function + +Function LabelPostageOption(code) + Select Case CStr(code & "") + Case "FirstClass" : LabelPostageOption = "Yes, at First Class Rate ($0.721/pc.)" + Case "NonprofitRate" : LabelPostageOption = "Yes, at Nonprofit Rate ($0.263/pc.)" + Case "PresortStandard" : LabelPostageOption = "Yes, at Presort Standard Rate ($0.473/pc.)" + Case "No" : LabelPostageOption = "No" + Case Else : LabelPostageOption = CStr(code & "") + End Select +End Function + +Function LabelNonprofitStatus(code) + Select Case CStr(code & "") + Case "Yes" : LabelNonprofitStatus = "Yes" + Case "No" : LabelNonprofitStatus = "No" + Case "NotSure" : LabelNonprofitStatus = "I'm not sure" + Case Else : LabelNonprofitStatus = CStr(code & "") + End Select +End Function +%> diff --git a/app/models/OrderMailer.asp b/app/models/OrderMailer.asp new file mode 100644 index 0000000..9c99c24 --- /dev/null +++ b/app/models/OrderMailer.asp @@ -0,0 +1,214 @@ +<% +'======================================================================================================================= +' Order-related outbound email: the "continue your order" link and the order-details +' confirmation sent after SubmitOrderDetails. +'======================================================================================================================= +Class OrderMailer_Class + + Public Sub SendContinuationEmail(email, token) + Dim continueUrl + continueUrl = Routes().AppURL & "order/continue?token=" & Server.URLEncode(token) + + Dim mail : Set mail = NewConfiguredMail() + mail.Subject = "Continue your Purple Envelope order" + mail.IsBodyHTML = True + mail.Body = "

Click the link below to continue your order:

" & _ + "

" & H(continueUrl) & "

" & _ + "

This link expires in " & H(GetAppSetting("OrderTokenExpirationHours")) & _ + " hours and can only be used once.

" + mail.AddRecipient "To", email + mail.Send + End Sub + + ' Best-effort: the order is already safely persisted by the time a caller reaches this - + ' an SMTP hiccup here should not turn a successful submission into a failed one. + Public Sub SendOrderDetailsEmail(order, model) + On Error Resume Next + + Dim mail : Set mail = NewConfiguredMail() + mail.Subject = "Your Purple Envelope order details - Jurisdiction " & order("JurisdictionNumber") + mail.IsBodyHTML = True + mail.Body = BuildOrderDetailsEmailBody(order, model) + mail.AddRecipient "To", order("Email") + mail.Send + + Err.Clear + On Error GoTo 0 + End Sub + + '------------------------------------------------------------------------------------------------------------------- + ' Private helpers + '------------------------------------------------------------------------------------------------------------------- + Private Function NewConfiguredMail() + Dim smtpPort : smtpPort = GetAppSetting("SmtpPort") + If Not IsNumeric(smtpPort) Then smtpPort = 25 + + Dim mail : Set mail = CDOEmail() + mail.SMTPServer = GetAppSetting("SmtpServer") + mail.SMTPPort = CInt(smtpPort) + mail.SMTPUsername = GetAppSetting("SmtpUsername") + mail.SMTPPassword = GetAppSetting("SmtpPassword") + mail.SMTPUseSSL = (LCase(GetAppSetting("SmtpUseSSL")) = "true") + mail.From = GetAppSetting("SmtpFromAddress") + Set NewConfiguredMail = mail + End Function + + Private Function BuildOrderDetailsEmailBody(order, model) + Dim html + html = "" & _ + "
" & _ + "" & _ + "" & _ + "" & _ + "" & _ + "
" & _ + "KCI Purple Envelope Order Confirmation" & _ + "
" & _ + "
Order Received
" & _ + "

Thanks! We've received your order details.

" & _ + "

Jurisdiction number " & H(order("JurisdictionNumber")) & "" & _ + " · submitted " & H(EmailDate(model.SubmittedAt)) & "

" + + html = html & EmailSection("Contact Information", _ + EmailRow("Contact Name", model.ContactName) & _ + EmailRow("Municipality", model.Municipality) & _ + EmailRow("Phone", model.Phone)) + + If IsTrue(model.PurpleWantsQuote) Then + Dim purpleBlueProviderLine + purpleBlueProviderLine = LabelPurpleBlueProvider(model.PurpleBlueProvider) + If purpleBlueProviderLine = "Other" And Len(Trim(model.PurpleBlueProviderOther & "")) > 0 Then + purpleBlueProviderLine = model.PurpleBlueProviderOther + End If + + html = html & EmailSection("Purple Ballot Envelopes", _ + EmailRow("Envelope stock", LabelEnvelopeStock(model.PurpleEnvelopeStock)) & _ + EmailRow("Print option", LabelPrintOption(model.PurplePrintOption)) & _ + EmailRow("Also wants KCI blue envelopes", EmailYesNo(model.PurpleWantsBlueEnvelopes)) & _ + EmailRow("Blue envelope provider", purpleBlueProviderLine) & _ + EmailRow("Print style", LabelPrintStyle(model.PurplePrintStyle)) & _ + EmailRow("Color coding by", LabelColorCodingBy(model.PurpleColorCodingBy)) & _ + EmailRow("Number of precincts", model.PurplePrecinctCount) & _ + EmailRow("Colors requested", model.PurpleColorNames) & _ + EmailRow("Track with TrackMI Ballot", EmailYesNo(model.PurpleTrackMIBallot)) & _ + EmailRow("Extra envelopes requested", model.PurpleExtraEnvelopeQty)) + End If + + If IsTrue(model.BlueWantsQuote) Then + ' City/permit number are always collected for an own-organization permit - a + ' permit lookup can't be done from the nonprofit authorization code alone, so + ' that code is additional information when nonprofit status is Yes, not a + ' replacement for city/permit number. + Dim bluePermitLine + bluePermitLine = "" + If LabelPermitOwnership(model.BluePermitOwnership) = "My organization's permit" Then + bluePermitLine = EmailRow("City of permit holder", model.BluePermitCity) & _ + EmailRow("Permit number", model.BluePermitNumber) + If IsTrue(model.BlueHasNonprofitStatus) Then + bluePermitLine = bluePermitLine & EmailRow("Nonprofit authorization code", model.BlueNonprofitAuthCode) + End If + End If + + html = html & EmailSection("Blue AV Envelopes", _ + EmailRow("Print permit", EmailYesNo(model.BluePrintPermit)) & _ + EmailRow("Permit ownership", LabelPermitOwnership(model.BluePermitOwnership)) & _ + EmailRow("Confirmed nonprofit status with USPS", EmailYesNo(model.BlueHasNonprofitStatus)) & _ + bluePermitLine) + End If + + If IsTrue(model.MailingWantsService) Then + html = html & EmailSection("Ballot Mailing Service", _ + EmailRow("Postage", LabelPostageOption(model.MailingPostageOption)) & _ + EmailRow("Nonprofit status with USPS", LabelNonprofitStatus(model.MailingHasNonprofitStatus)) & _ + EmailRow("Estimated quantity", model.MailingEstimatedQuantity) & _ + EmailRow("Preferred pickup date", EmailDate(model.MailingPickupDate))) + End If + + If NumOrZero(model.SecrecySleevesQty) > 0 Or NumOrZero(model.IVotedStickerRolls) > 0 Or NumOrZero(model.FutureVoterStickerRolls) > 0 Or Len(Trim(model.SpecialRequests & "")) > 0 Then + html = html & EmailSection("Additional Election Items", _ + EmailRow("Secrecy sleeves (quantity)", model.SecrecySleevesQty) & _ + EmailRow("""I Voted"" stickers (rolls of 250)", model.IVotedStickerRolls) & _ + EmailRow("""Future Voter"" stickers (rolls of 500)", model.FutureVoterStickerRolls) & _ + EmailRow("Special requests", model.SpecialRequests)) + End If + + html = html & "

Questions about this order? Just reply to this email.

" & _ + "
" & _ + "Purple Envelope · Addressing & Barcoding" & _ + "
" + + BuildOrderDetailsEmailBody = html + End Function + + Private Function EmailSection(sectionTitle, rowsHtml) + If Len(rowsHtml) = 0 Then + EmailSection = "" + Else + EmailSection = "

" & _ + H(sectionTitle) & "

" & _ + "" & rowsHtml & "
" + End If + End Function + + Private Function EmailRow(label, value) + If Len(Trim(value & "")) = 0 Then + EmailRow = "" + Else + EmailRow = "" & _ + "" & H(label) & "" & _ + "" & H(value) & "" & _ + "" + End If + End Function + + ' Null-safe boolean check for gating whole sections - a section's top-level "do you want + ' this?" question isn't marked required client-side, so it may be missing (Null) rather + ' than explicitly false. + Private Function IsTrue(v) + If IsNull(v) Then + IsTrue = False + Else + IsTrue = CBool(v) + End If + End Function + + Private Function EmailYesNo(v) + If IsNull(v) Then + EmailYesNo = "" + ElseIf CBool(v) Then + EmailYesNo = "Yes" + Else + EmailYesNo = "No" + End If + End Function + + Private Function EmailDate(v) + If IsNull(v) Then + EmailDate = "" + Else + On Error Resume Next + EmailDate = MonthName(Month(v)) & " " & Day(v) & ", " & Year(v) + If Err.Number <> 0 Then EmailDate = "" + Err.Clear + On Error GoTo 0 + End If + End Function + + Private Function NumOrZero(v) + If IsNull(v) Or Not IsNumeric(v) Then + NumOrZero = 0 + Else + NumOrZero = CDbl(v) + End If + End Function + +End Class + +Dim OrderMailer_Class__Singleton +Function OrderMailer() + If IsEmpty(OrderMailer_Class__Singleton) Then + Set OrderMailer_Class__Singleton = New OrderMailer_Class + End If + Set OrderMailer = OrderMailer_Class__Singleton +End Function +%> diff --git a/core/autoload_core.asp b/core/autoload_core.asp index 6b406b8..1449d1c 100644 --- a/core/autoload_core.asp +++ b/core/autoload_core.asp @@ -16,6 +16,8 @@ + + diff --git a/core/lib.JsonResponse.asp b/core/lib.JsonResponse.asp new file mode 100644 index 0000000..d21c904 --- /dev/null +++ b/core/lib.JsonResponse.asp @@ -0,0 +1,20 @@ +<% +'======================================================================================================================= +' Small helpers for controllers that only ever return JSON (useLayout = False), so each new +' JSON API controller doesn't reimplement error-response formatting and string escaping. +'======================================================================================================================= + +Sub WriteJsonError(statusLine, message) + Response.Status = statusLine + Response.Write "{""success"":false,""error"":" & JsonEncodeString(message) & "}" +End Sub + +Function JsonEncodeString(s) + Dim out : out = s + out = Replace(out, "\", "\\") + out = Replace(out, """", "\""") + out = Replace(out, Chr(10), "\n") + out = Replace(out, Chr(13), "\r") + JsonEncodeString = """" & out & """" +End Function +%> diff --git a/core/lib.Validations.asp b/core/lib.Validations.asp index 62bdeb0..d34c307 100644 --- a/core/lib.Validations.asp +++ b/core/lib.Validations.asp @@ -247,4 +247,85 @@ Class Validator_Class m_errors(ubound(m_errors)) = msg End Sub End Class + + +'----------------------------------------------------------------------------------------------------------------------- +' Dictionary-based answer validation +' For validating a Scripting.Dictionary of loosely-typed values (e.g. a parsed JSON POST body) +' against field rules, accumulating messages into a ByRef errors() array - a different shape +' of validation than the instance/property Validator_Class above, which needs a live object +' instance and an Eval'able property name. +'----------------------------------------------------------------------------------------------------------------------- + +Function IsValidEmail(email) + Dim re + Set re = New RegExp + re.Pattern = "^[^\s@]+@[^\s@]+\.[^\s@]+$" + re.IgnoreCase = True + IsValidEmail = re.Test(email) +End Function + +Sub AddValidationError(ByRef errors, msg) + ReDim Preserve errors(UBound(errors) + 1) + errors(UBound(errors)) = msg +End Sub + +' Returns the value for key in a Scripting.Dictionary, or Null if not present (e.g. a +' SurveyJS question skipped via visibleIf logic and so never included in the result data). +Function DictValue(dict, key) + If dict.Exists(key) Then + DictValue = dict.Item(key) + Else + DictValue = Null + End If +End Function + +' Required - always checked. +Sub RequireNonEmptyString(ByRef errors, dict, key, msg) + Dim v : v = DictValue(dict, key) + If IsNull(v) Or Len(Trim(v & "")) = 0 Then AddValidationError errors, msg +End Sub + +' Only checked when present - callers may have fields that are conditionally hidden +' client-side, so a missing value just means the field wasn't shown, not an invalid submission. +Sub RequireEnum(ByRef errors, dict, key, allowedValues) + Dim v : v = DictValue(dict, key) + If Not IsNull(v) Then + Dim found : found = False + Dim i + For i = 0 To UBound(allowedValues) + If CStr(v) = allowedValues(i) Then found = True + Next + If Not found Then AddValidationError errors, key & " has an invalid value." + End If +End Sub + +Sub RequireBoolean(ByRef errors, dict, key) + Dim v : v = DictValue(dict, key) + If Not IsNull(v) Then + If TypeName(v) <> "Boolean" Then AddValidationError errors, key & " must be true or false." + End If +End Sub + +Sub RequireNonNegativeInteger(ByRef errors, dict, key) + Dim v : v = DictValue(dict, key) + If Not IsNull(v) Then + If Not IsNumeric(v) Then + AddValidationError errors, key & " must be a number." + ElseIf CDbl(v) < 0 Then + AddValidationError errors, key & " cannot be negative." + ElseIf CDbl(v) <> Int(CDbl(v)) Then + AddValidationError errors, key & " must be a whole number." + End If + End If +End Sub + +Sub RequireValidDate(ByRef errors, dict, key) + Dim v : v = DictValue(dict, key) + If Not IsNull(v) Then + If Len(Trim(v & "")) > 0 And Not IsDate(v) Then + AddValidationError errors, key & " is not a valid date." + End If + End If +End Sub %> \ No newline at end of file