personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

support: feedback defaults to anonymous, reveals email when off

- Default #feedback-anonymous to checked; unchecking reveals an email input plus a "we never use your email for anything beyond replying" privacy-policy link (https://solpbc.org/privacy).

- Thread optional user_email through support_feedback → support_create → PortalClient.create_ticket. Route normalizes empty/whitespace email to omitted; anonymous submissions always strip user_email server-side (defense in depth).

- Tests cover the four route boundary cases: anonymous-no-email, identified-with-email, anonymous-drops-smuggled-email, identified-empty-email-omits-kwarg.

+174 -10
+13 -5
apps/support/routes.py
··· 194 194 try: 195 195 from apps.support.tools import support_feedback 196 196 197 - result = support_feedback( 198 - body=body, 199 - product=payload.get("product", "solstone"), 200 - anonymous=payload.get("anonymous", False), 201 - ) 197 + product = payload.get("product", "solstone") 198 + anonymous = bool(payload.get("anonymous")) 199 + feedback_kwargs: dict[str, object] = { 200 + "body": body, 201 + "product": product, 202 + "anonymous": anonymous, 203 + } 204 + if not anonymous: 205 + raw_email = (payload.get("user_email") or "").strip() 206 + if raw_email: 207 + feedback_kwargs["user_email"] = raw_email 208 + 209 + result = support_feedback(**feedback_kwargs) 202 210 return jsonify(result), 201 203 211 except Exception as exc: 204 212 logger.exception("Failed to submit feedback")
+3 -1
apps/support/tools.py
··· 89 89 product: str = "solstone", 90 90 portal_url: str | None = None, 91 91 anonymous: bool = False, 92 - ) -> dict[str, Any]: 92 + user_email: str | None = None, 93 + ) -> dict[str, object]: 93 94 """Submit feedback (lower-friction path). 94 95 95 96 Feedback is a ticket with ``category="feedback"`` and low severity. ··· 100 101 product=product, 101 102 severity="low", 102 103 category="feedback", 104 + user_email=user_email, 103 105 portal_url=portal_url, 104 106 anonymous=anonymous, 105 107 )
+79 -4
apps/support/workspace.html
··· 137 137 margin-bottom: 0.5rem; 138 138 font-size: 0.85rem; 139 139 } 140 + .support-feedback-email-row { 141 + margin-bottom: 0.75rem; 142 + } 143 + .support-feedback-email-row input { 144 + width: 100%; 145 + padding: 0.65rem 0.75rem; 146 + border: 1px solid var(--facet-border, #e5e0db); 147 + border-radius: 6px; 148 + font: inherit; 149 + box-sizing: border-box; 150 + transition: border-color 0.15s, box-shadow 0.15s; 151 + } 152 + .support-feedback-email-row input:hover { border-color: #bbb; } 153 + .support-feedback-email-row input:focus { 154 + outline: none; 155 + border-color: var(--facet-color, #3b82f6); 156 + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); 157 + } 158 + .support-feedback-email-row .support-field-error:not(:empty) { display: block; } 159 + .support-feedback-email-hint { 160 + display: block; 161 + margin-top: 0.35rem; 162 + color: #666; 163 + font-size: 0.8rem; 164 + } 140 165 141 166 /* Help */ 142 167 .support-help-card { ··· 525 550 <textarea id="feedback-text" placeholder="what's on your mind?" required></textarea> 526 551 <div class="support-field-error" id="feedback-error">please write something before sending</div> 527 552 <div class="support-feedback-options"> 528 - <label><input type="checkbox" id="feedback-anonymous"> Submit anonymously</label> 553 + <label><input type="checkbox" id="feedback-anonymous" checked> Submit anonymously</label> 554 + </div> 555 + <div class="support-feedback-email-row" id="feedback-email-row" hidden> 556 + <label for="feedback-email">your email</label> 557 + <input type="email" id="feedback-email" autocomplete="email" required> 558 + <div class="support-field-error" id="feedback-email-error"></div> 559 + <small class="support-feedback-email-hint"> 560 + we never use your email for anything beyond replying — see our 561 + <a href="https://solpbc.org/privacy" target="_blank" rel="noopener">privacy policy</a>. 562 + </small> 529 563 </div> 530 564 <button class="support-btn" id="feedback-submit" type="submit">send feedback</button> 531 565 <span class="support-keyboard-hint">ctrl+enter to send</span> ··· 970 1004 const feedbackForm = document.getElementById('feedback-form'); 971 1005 const feedbackError = document.getElementById('feedback-error'); 972 1006 const feedbackTextEl = document.getElementById('feedback-text'); 1007 + const feedbackAnonymous = document.getElementById('feedback-anonymous'); 1008 + const feedbackEmailRow = document.getElementById('feedback-email-row'); 1009 + const feedbackEmailInput = document.getElementById('feedback-email'); 1010 + const feedbackEmailError = document.getElementById('feedback-email-error'); 1011 + feedbackAnonymous.addEventListener('change', () => { 1012 + if (feedbackAnonymous.checked) { 1013 + feedbackEmailRow.hidden = true; 1014 + feedbackEmailInput.value = ''; 1015 + feedbackEmailError.textContent = ''; 1016 + } else { 1017 + feedbackEmailRow.hidden = false; 1018 + feedbackEmailInput.focus(); 1019 + } 1020 + }); 973 1021 feedbackForm.addEventListener('submit', async e => { 974 1022 e.preventDefault(); 975 1023 const text = feedbackTextEl.value.trim(); 976 1024 if (!text) { 1025 + feedbackError.textContent = 'please write something before sending'; 977 1026 feedbackError.style.display = 'block'; 978 1027 feedbackTextEl.focus(); 979 1028 return; 980 1029 } 981 1030 feedbackError.style.display = 'none'; 982 - const anon = document.getElementById('feedback-anonymous').checked; 1031 + feedbackEmailError.textContent = ''; 1032 + let email = ''; 1033 + if (!feedbackAnonymous.checked) { 1034 + email = feedbackEmailInput.value.trim(); 1035 + if (!email) { 1036 + feedbackEmailError.textContent = 'enter an email or check submit anonymously'; 1037 + feedbackEmailInput.focus(); 1038 + return; 1039 + } 1040 + if (!feedbackEmailInput.checkValidity()) { 1041 + feedbackEmailError.textContent = "that doesn't look like a valid email"; 1042 + feedbackEmailInput.focus(); 1043 + return; 1044 + } 1045 + } 983 1046 const status = document.getElementById('feedback-status'); 984 1047 document.getElementById('feedback-submit').disabled = true; 1048 + const payload = {body: text, anonymous: feedbackAnonymous.checked}; 1049 + if (!feedbackAnonymous.checked) { 1050 + payload.user_email = email; 1051 + } 985 1052 986 1053 try { 987 1054 const resp = await fetch('/app/support/api/feedback', { 988 1055 method: 'POST', 989 1056 headers: {'Content-Type': 'application/json'}, 990 - body: JSON.stringify({body: text, anonymous: anon}) 1057 + body: JSON.stringify(payload) 991 1058 }); 992 1059 if (resp.ok) { 1060 + feedbackTextEl.value = ''; 1061 + feedbackAnonymous.checked = true; 1062 + feedbackEmailRow.hidden = true; 1063 + feedbackEmailInput.value = ''; 1064 + feedbackEmailError.textContent = ''; 1065 + feedbackError.textContent = ''; 993 1066 status.textContent = 'thanks for your feedback!'; 994 1067 status.className = 'support-status-msg success'; 995 - feedbackTextEl.value = ''; 996 1068 } else { 997 1069 throw new Error('Failed'); 998 1070 } ··· 1004 1076 }); 1005 1077 feedbackTextEl.addEventListener('input', () => { 1006 1078 feedbackError.style.display = 'none'; 1079 + }); 1080 + feedbackEmailInput.addEventListener('input', () => { 1081 + feedbackEmailError.textContent = ''; 1007 1082 }); 1008 1083 feedbackTextEl.addEventListener('keydown', e => { 1009 1084 if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
+79
tests/test_app_support.py
··· 59 59 60 60 assert resp.status_code == 500 61 61 assert "error" in resp.get_json() 62 + 63 + 64 + def test_feedback_anonymous_no_email_kwarg(support_client, monkeypatch): 65 + captured: list[dict] = [] 66 + 67 + def recorder(**kwargs): 68 + captured.append(kwargs) 69 + return {"ok": True, "ticket_id": "t1"} 70 + 71 + monkeypatch.setattr("apps.support.routes._enabled", lambda: True) 72 + monkeypatch.setattr("apps.support.tools.support_feedback", recorder) 73 + 74 + resp = support_client.post( 75 + "/app/support/api/feedback", json={"body": "hi", "anonymous": True} 76 + ) 77 + 78 + assert resp.status_code == 201 79 + assert len(captured) == 1 80 + assert "user_email" not in captured[0] 81 + 82 + 83 + def test_feedback_identified_forwards_email(support_client, monkeypatch): 84 + captured: list[dict] = [] 85 + 86 + def recorder(**kwargs): 87 + captured.append(kwargs) 88 + return {"ok": True, "ticket_id": "t1"} 89 + 90 + monkeypatch.setattr("apps.support.routes._enabled", lambda: True) 91 + monkeypatch.setattr("apps.support.tools.support_feedback", recorder) 92 + 93 + resp = support_client.post( 94 + "/app/support/api/feedback", 95 + json={"body": "hi", "anonymous": False, "user_email": "a@b.com"}, 96 + ) 97 + 98 + assert resp.status_code == 201 99 + assert len(captured) == 1 100 + assert captured[0]["user_email"] == "a@b.com" 101 + 102 + 103 + def test_feedback_anonymous_drops_smuggled_email(support_client, monkeypatch): 104 + captured: list[dict] = [] 105 + 106 + def recorder(**kwargs): 107 + captured.append(kwargs) 108 + return {"ok": True, "ticket_id": "t1"} 109 + 110 + monkeypatch.setattr("apps.support.routes._enabled", lambda: True) 111 + monkeypatch.setattr("apps.support.tools.support_feedback", recorder) 112 + 113 + resp = support_client.post( 114 + "/app/support/api/feedback", 115 + json={"body": "hi", "anonymous": True, "user_email": "smug@x.com"}, 116 + ) 117 + 118 + assert resp.status_code == 201 119 + assert len(captured) == 1 120 + assert "user_email" not in captured[0] 121 + 122 + 123 + def test_feedback_identified_empty_email_omits_kwarg(support_client, monkeypatch): 124 + captured: list[dict] = [] 125 + 126 + def recorder(**kwargs): 127 + captured.append(kwargs) 128 + return {"ok": True, "ticket_id": "t1"} 129 + 130 + monkeypatch.setattr("apps.support.routes._enabled", lambda: True) 131 + monkeypatch.setattr("apps.support.tools.support_feedback", recorder) 132 + 133 + resp = support_client.post( 134 + "/app/support/api/feedback", 135 + json={"body": "hi", "anonymous": False, "user_email": " "}, 136 + ) 137 + 138 + assert resp.status_code == 201 139 + assert len(captured) == 1 140 + assert "user_email" not in captured[0]