diff --git a/backend/src/routes/timesheets.js b/backend/src/routes/timesheets.js index 65e0b12..4483384 100644 --- a/backend/src/routes/timesheets.js +++ b/backend/src/routes/timesheets.js @@ -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 */ diff --git a/frontend/src/pages/Timesheet.jsx b/frontend/src/pages/Timesheet.jsx index 6eb11eb..146c984 100644 --- a/frontend/src/pages/Timesheet.jsx +++ b/frontend/src/pages/Timesheet.jsx @@ -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'} ) : ( + <>
- + {!emailConfirm ? ( + + ) : ( +
+

+ Send to Office@CoastalContractingFL.com? + {emailStatus && ( + + {emailStatus.count}/{emailStatus.max} sends used this week + {emailStatus.cooldownMs > 0 ? ` · available in ${Math.ceil(emailStatus.cooldownMs / 60000)}m` : ''} + + )} +

+ {emailStatus && !emailStatus.allowed ? ( +

+ {emailStatus.count >= emailStatus.max + ? `Max ${emailStatus.max} sends reached for this timesheet` + : `Please wait ${Math.ceil(emailStatus.cooldownMs / 60000)} min before sending again`} +

+ ) : ( +
+ + +
+ )} +
+ )}
+ {emailResult && ( +

+ {emailResult.msg} +

+ )} + )} {timesheet?.status === 'rejected' && timesheet?.notes && (