feat: smart email-to-office with rate limiting (3/week, 30min cooldown)
This commit is contained in:
@@ -14,6 +14,24 @@ const router = express.Router();
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -51,6 +51,10 @@ export default function Timesheet() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [pdfLoading, setPdfLoading] = 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 saveTimer = useRef(null);
|
||||
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() {
|
||||
if (!timesheetId) return;
|
||||
setPdfLoading(true);
|
||||
@@ -336,6 +365,7 @@ export default function Timesheet() {
|
||||
{submitting ? 'Submitting...' : 'Submit Timesheet'}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleDownloadPdf}
|
||||
@@ -345,19 +375,60 @@ export default function Timesheet() {
|
||||
{pdfLoading ? <Loader2 size={18} className="animate-spin" /> : <Download size={18} />}
|
||||
Download PDF
|
||||
</button>
|
||||
{!emailConfirm ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (timesheetId) {
|
||||
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-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"
|
||||
onClick={() => { loadEmailStatus(); setEmailConfirm(true); setEmailResult(null); }}
|
||||
disabled={emailSending}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</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 && (
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user