feat: smart email-to-office with rate limiting (3/week, 30min cooldown)

This commit is contained in:
BizzleBot
2026-03-04 22:22:54 +00:00
parent 2f60d7d980
commit 7433ec8d9e
2 changed files with 100 additions and 11 deletions
+18
View File
@@ -14,6 +14,24 @@ const router = express.Router();
router.use(authenticate); router.use(authenticate);
// ─────────────── Email Rate Limiter ───────────────
const emailRateLimit = new Map(); // timesheetId -> { count, lastSentAt }
const MAX_EMAILS = 3;
const COOLDOWN_MS = 30 * 60 * 1000; // 30 min
function checkEmailLimit(id) {
const now = Date.now();
const r = emailRateLimit.get(id) || { count: 0, lastSentAt: 0 };
const cooldownMs = Math.max(0, r.lastSentAt + COOLDOWN_MS - now);
return { allowed: cooldownMs === 0 && r.count < MAX_EMAILS, cooldownMs, count: r.count };
}
function recordEmailSent(id) {
const r = emailRateLimit.get(id) || { count: 0, lastSentAt: 0 };
emailRateLimit.set(id, { count: r.count + 1, lastSentAt: Date.now() });
}
/** /**
* Compute Monday of the week for a given date * Compute Monday of the week for a given date
*/ */
+78 -7
View File
@@ -51,6 +51,10 @@ export default function Timesheet() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [pdfLoading, setPdfLoading] = useState(false); const [pdfLoading, setPdfLoading] = useState(false);
const [copying, setCopying] = useState(false); const [copying, setCopying] = useState(false);
const [emailSending, setEmailSending] = useState(false);
const [emailStatus, setEmailStatus] = useState(null);
const [emailConfirm, setEmailConfirm] = useState(false);
const [emailResult, setEmailResult] = useState(null);
const [overtime, setOvertime] = useState(null); const [overtime, setOvertime] = useState(null);
const saveTimer = useRef(null); const saveTimer = useRef(null);
const pendingChanges = useRef({}); const pendingChanges = useRef({});
@@ -235,6 +239,31 @@ export default function Timesheet() {
} }
} }
async function loadEmailStatus() {
if (!timesheetId) return;
try {
const res = await api.get(`/timesheets/${timesheetId}/email-status`);
setEmailStatus(res.data);
} catch {}
}
async function handleSendEmail() {
if (!timesheetId) return;
setEmailSending(true);
setEmailResult(null);
try {
const res = await api.post(`/timesheets/${timesheetId}/email`);
setEmailStatus(s => ({ ...s, count: res.data.count, allowed: res.data.remaining > 0, cooldownMs: 30 * 60 * 1000 }));
setEmailResult({ ok: true, msg: `Sent! (${res.data.count}/${res.data.max} sends used for this week)` });
setEmailConfirm(false);
} catch (err) {
setEmailResult({ ok: false, msg: err.response?.data?.error || 'Failed to send email' });
setEmailConfirm(false);
} finally {
setEmailSending(false);
}
}
async function handleDownloadPdf() { async function handleDownloadPdf() {
if (!timesheetId) return; if (!timesheetId) return;
setPdfLoading(true); setPdfLoading(true);
@@ -336,6 +365,7 @@ export default function Timesheet() {
{submitting ? 'Submitting...' : 'Submit Timesheet'} {submitting ? 'Submitting...' : 'Submit Timesheet'}
</button> </button>
) : ( ) : (
<>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={handleDownloadPdf} onClick={handleDownloadPdf}
@@ -345,19 +375,60 @@ export default function Timesheet() {
{pdfLoading ? <Loader2 size={18} className="animate-spin" /> : <Download size={18} />} {pdfLoading ? <Loader2 size={18} className="animate-spin" /> : <Download size={18} />}
Download PDF Download PDF
</button> </button>
{!emailConfirm ? (
<button <button
onClick={() => { onClick={() => { loadEmailStatus(); setEmailConfirm(true); setEmailResult(null); }}
if (timesheetId) { disabled={emailSending}
window.open(`mailto:?subject=Timesheet - ${user.name} - Week of ${weekParam}&body=Please find my timesheet attached.`); className="flex-1 py-3.5 rounded-xl bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-semibold flex items-center justify-center gap-2 hover:bg-emerald-100 dark:hover:bg-emerald-500/20 transition-all"
}
}}
className="flex-1 py-3.5 rounded-xl bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-400 font-semibold flex items-center justify-center gap-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"
> >
<Mail size={18} /> <Mail size={18} />
Email Send to Office
</button>
) : (
<div className="flex-1 rounded-xl border border-emerald-200 dark:border-emerald-500/30 bg-emerald-50 dark:bg-emerald-500/5 p-3 space-y-2">
<p className="text-xs text-emerald-700 dark:text-emerald-400 font-medium text-center">
Send to Office@CoastalContractingFL.com?
{emailStatus && (
<span className="block text-emerald-600/70 dark:text-emerald-500/70 mt-0.5">
{emailStatus.count}/{emailStatus.max} sends used this week
{emailStatus.cooldownMs > 0 ? ` · available in ${Math.ceil(emailStatus.cooldownMs / 60000)}m` : ''}
</span>
)}
</p>
{emailStatus && !emailStatus.allowed ? (
<p className="text-xs text-red-500 text-center">
{emailStatus.count >= emailStatus.max
? `Max ${emailStatus.max} sends reached for this timesheet`
: `Please wait ${Math.ceil(emailStatus.cooldownMs / 60000)} min before sending again`}
</p>
) : (
<div className="flex gap-2">
<button
onClick={handleSendEmail}
disabled={emailSending}
className="flex-1 py-1.5 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white text-xs font-semibold disabled:opacity-50 flex items-center justify-center gap-1"
>
{emailSending ? <Loader2 size={12} className="animate-spin" /> : <Mail size={12} />}
{emailSending ? 'Sending...' : 'Yes, Send'}
</button>
<button
onClick={() => { setEmailConfirm(false); setEmailResult(null); }}
className="flex-1 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-xs font-semibold"
>
Cancel
</button> </button>
</div> </div>
)} )}
</div>
)}
</div>
{emailResult && (
<p className={`text-xs text-center font-medium ${emailResult.ok ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500'}`}>
{emailResult.msg}
</p>
)}
</>
)}
{timesheet?.status === 'rejected' && timesheet?.notes && ( {timesheet?.status === 'rejected' && timesheet?.notes && (
<div className="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-xl px-4 py-3"> <div className="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-xl px-4 py-3">