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);
|
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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user