📚 ${prayer.text}
+Here are all your active prayers and praises:
+"Pray without ceasing"+ — 1 Thessalonians 5:17 +
From 6b0713612d76e426d9d6f5723b427483f83e2473 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:20:21 -0400 Subject: [PATCH] Implement scheduled email reminders - Add cron service to Docker container for automated email sending - Create /api/send-scheduled-emails endpoint for cron job calls - Modify prayer submission to store user reminder preferences instead of sending immediate emails - Update Firestore rules to allow server-side API access for scheduled emails - Add userId to prayers for proper user association - Cron runs every 5 minutes to check and send scheduled reminders --- Dockerfile | 20 +- firestore.rules | 10 + public/sw.js | 2 +- src/app/api/send-scheduled-emails/route.ts | 353 +++++++++++++++++++++ src/app/page.tsx | 48 +-- 5 files changed, 398 insertions(+), 35 deletions(-) create mode 100644 src/app/api/send-scheduled-emails/route.ts diff --git a/Dockerfile b/Dockerfile index 4084622..774e833 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,12 @@ ENV NEXT_TELEMETRY_DISABLED=1 RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs +# Install cron and curl for scheduled tasks +RUN apk add --no-cache dcron curl + +# Create cron log directory +RUN mkdir -p /var/log/cron && chown nextjs:nodejs /var/log/cron + COPY --from=builder /app/public ./public # Set the correct permission for prerender cache @@ -52,6 +58,18 @@ ENV PORT=3000 # set hostname to localhost ENV HOSTNAME="0.0.0.0" +# Create cron jobs for scheduled emails +RUN echo "*/5 * * * * curl -s http://localhost:3000/api/send-scheduled-emails >> /var/log/cron/scheduled-emails.log 2>&1" > /etc/crontabs/nextjs + +# Make sure cron files have correct permissions +RUN chown nextjs:nodejs /etc/crontabs/nextjs + +# Create startup script +RUN echo '#!/bin/sh' > /app/start.sh && \ + echo 'crond -f -l 8 &' >> /app/start.sh && \ + echo 'exec "$@"' >> /app/start.sh && \ + chmod +x /app/start.sh + # server.js is created by next build from the standalone output # https://nextjs.org/docs/pages/api-reference/next-config-js/output -CMD ["node", "server.js"] \ No newline at end of file +CMD ["/app/start.sh", "node", "server.js"] \ No newline at end of file diff --git a/firestore.rules b/firestore.rules index 0bd1910..2d79492 100644 --- a/firestore.rules +++ b/firestore.rules @@ -11,6 +11,16 @@ service cloud.firestore { allow read, write: if request.auth != null; } + // Allow server-side API access for scheduled emails (no auth required for GET) + match /prayers { + allow read: if true; + } + + // Allow server-side API access for scheduled emails + match /users { + allow read: if true; + } + // Allow authenticated users to read/write config (for admin settings) match /config/{configId} { allow read, write: if request.auth != null; diff --git a/public/sw.js b/public/sw.js index 9ff8e98..6554c0a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1 +1 @@ -if(!self.define){let e,s={};const a=(a,n)=>(a=new URL(a+".js",n).href,s[a]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=a,e.onload=s,document.head.appendChild(e)}else e=a,importScripts(a),s()}).then(()=>{let e=s[a];if(!e)throw new Error(`Module ${a} didn’t register its module`);return e}));self.define=(n,t)=>{const i=e||("document"in self?document.currentScript.src:"")||location.href;if(s[i])return;let c={};const r=e=>a(e,i),o={module:{uri:i},exports:c,require:r};s[i]=Promise.all(n.map(e=>o[e]||r(e))).then(e=>(t(...e),c))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/static/84QbTf9EqwkRW6ZuK_UKV/_buildManifest.js",revision:"009de90521464c9665092678c4f82fe9"},{url:"/_next/static/84QbTf9EqwkRW6ZuK_UKV/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/chunks/139.678015000317a168.js",revision:"678015000317a168"},{url:"/_next/static/chunks/240-754372c10c8766c9.js",revision:"754372c10c8766c9"},{url:"/_next/static/chunks/4bd1b696-b7529f9b306ab05d.js",revision:"b7529f9b306ab05d"},{url:"/_next/static/chunks/512-8d2b9de6fda25175.js",revision:"8d2b9de6fda25175"},{url:"/_next/static/chunks/646.30443a55a43a3bfb.js",revision:"30443a55a43a3bfb"},{url:"/_next/static/chunks/7508b87c-1ab0c72b2a9e57da.js",revision:"1ab0c72b2a9e57da"},{url:"/_next/static/chunks/app/_global-error/page-7a99a1a9afe03223.js",revision:"7a99a1a9afe03223"},{url:"/_next/static/chunks/app/_not-found/page-1123cfe416c748d0.js",revision:"1123cfe416c748d0"},{url:"/_next/static/chunks/app/api/send-email/route-7a99a1a9afe03223.js",revision:"7a99a1a9afe03223"},{url:"/_next/static/chunks/app/layout-dc9d7c3da9cf59ef.js",revision:"dc9d7c3da9cf59ef"},{url:"/_next/static/chunks/app/page-5ebd182a478eec74.js",revision:"5ebd182a478eec74"},{url:"/_next/static/chunks/dd8162e8-b0853216adf21a86.js",revision:"b0853216adf21a86"},{url:"/_next/static/chunks/framework-292291387d6b2e39.js",revision:"292291387d6b2e39"},{url:"/_next/static/chunks/main-7ddb690de3906442.js",revision:"7ddb690de3906442"},{url:"/_next/static/chunks/main-app-4f19d69ed4295336.js",revision:"4f19d69ed4295336"},{url:"/_next/static/chunks/next/dist/client/components/builtin/app-error-7a99a1a9afe03223.js",revision:"7a99a1a9afe03223"},{url:"/_next/static/chunks/next/dist/client/components/builtin/forbidden-7a99a1a9afe03223.js",revision:"7a99a1a9afe03223"},{url:"/_next/static/chunks/next/dist/client/components/builtin/global-error-bcba0732671665d2.js",revision:"bcba0732671665d2"},{url:"/_next/static/chunks/next/dist/client/components/builtin/not-found-7a99a1a9afe03223.js",revision:"7a99a1a9afe03223"},{url:"/_next/static/chunks/next/dist/client/components/builtin/unauthorized-7a99a1a9afe03223.js",revision:"7a99a1a9afe03223"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-167d4419e1b22999.js",revision:"167d4419e1b22999"},{url:"/_next/static/css/8a0dbbd40cc7c3db.css",revision:"8a0dbbd40cc7c3db"},{url:"/_next/static/media/4cf2300e9c8272f7-s.p.woff2",revision:"18bae71b1e1b2bb25321090a3b563103"},{url:"/_next/static/media/747892c23ea88013-s.woff2",revision:"a0761690ccf4441ace5cec893b82d4ab"},{url:"/_next/static/media/8d697b304b401681-s.woff2",revision:"cc728f6c0adb04da0dfcb0fc436a8ae5"},{url:"/_next/static/media/93f479601ee12b01-s.p.woff2",revision:"da83d5f06d825c5ae65b7cca706cb312"},{url:"/_next/static/media/9610d9e46709d722-s.woff2",revision:"7b7c0ef93df188a852344fc272fc096b"},{url:"/_next/static/media/ba015fad6dcf6784-s.woff2",revision:"8ea4f719af3312a055caf09f34c89a77"},{url:"/file.svg",revision:"d09f95206c3fa0bb9bd9fefabfd0ea71"},{url:"/globe.svg",revision:"2aaafa6a49b6563925fe440891e32717"},{url:"/manifest.json",revision:"785fbc5941efd3caa27ade80c67bb5ef"},{url:"/next.svg",revision:"8e061864f388b47f33a1c3780831193e"},{url:"/vercel.svg",revision:"c0af2f507b369b085b35ef4bbe3bcf1e"},{url:"/window.svg",revision:"a2760511c65806022ad20adf74370ff3"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:a,state:n})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")},new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>!(self.origin===e.origin),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")}); +if(!self.define){let e,s={};const n=(n,a)=>(n=new URL(n+".js",a).href,s[n]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=s,document.head.appendChild(e)}else e=n,importScripts(n),s()}).then(()=>{let e=s[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(a,t)=>{const i=e||("document"in self?document.currentScript.src:"")||location.href;if(s[i])return;let c={};const r=e=>n(e,i),o={module:{uri:i},exports:c,require:r};s[i]=Promise.all(a.map(e=>o[e]||r(e))).then(e=>(t(...e),c))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/static/chunks/139.678015000317a168.js",revision:"678015000317a168"},{url:"/_next/static/chunks/240-754372c10c8766c9.js",revision:"754372c10c8766c9"},{url:"/_next/static/chunks/4bd1b696-b7529f9b306ab05d.js",revision:"b7529f9b306ab05d"},{url:"/_next/static/chunks/512-8d2b9de6fda25175.js",revision:"8d2b9de6fda25175"},{url:"/_next/static/chunks/646.30443a55a43a3bfb.js",revision:"30443a55a43a3bfb"},{url:"/_next/static/chunks/7508b87c-1ab0c72b2a9e57da.js",revision:"1ab0c72b2a9e57da"},{url:"/_next/static/chunks/app/_global-error/page-7ff1880bba064291.js",revision:"7ff1880bba064291"},{url:"/_next/static/chunks/app/_not-found/page-1123cfe416c748d0.js",revision:"1123cfe416c748d0"},{url:"/_next/static/chunks/app/api/send-email/route-7ff1880bba064291.js",revision:"7ff1880bba064291"},{url:"/_next/static/chunks/app/api/send-scheduled-emails/route-7ff1880bba064291.js",revision:"7ff1880bba064291"},{url:"/_next/static/chunks/app/layout-dc9d7c3da9cf59ef.js",revision:"dc9d7c3da9cf59ef"},{url:"/_next/static/chunks/app/page-99ceb58c98b8f8c5.js",revision:"99ceb58c98b8f8c5"},{url:"/_next/static/chunks/dd8162e8-b0853216adf21a86.js",revision:"b0853216adf21a86"},{url:"/_next/static/chunks/framework-292291387d6b2e39.js",revision:"292291387d6b2e39"},{url:"/_next/static/chunks/main-7ddb690de3906442.js",revision:"7ddb690de3906442"},{url:"/_next/static/chunks/main-app-4f19d69ed4295336.js",revision:"4f19d69ed4295336"},{url:"/_next/static/chunks/next/dist/client/components/builtin/app-error-7ff1880bba064291.js",revision:"7ff1880bba064291"},{url:"/_next/static/chunks/next/dist/client/components/builtin/forbidden-7ff1880bba064291.js",revision:"7ff1880bba064291"},{url:"/_next/static/chunks/next/dist/client/components/builtin/global-error-bcba0732671665d2.js",revision:"bcba0732671665d2"},{url:"/_next/static/chunks/next/dist/client/components/builtin/not-found-7ff1880bba064291.js",revision:"7ff1880bba064291"},{url:"/_next/static/chunks/next/dist/client/components/builtin/unauthorized-7ff1880bba064291.js",revision:"7ff1880bba064291"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-167d4419e1b22999.js",revision:"167d4419e1b22999"},{url:"/_next/static/cnkXhILcfrDgcR4Vc6_X1/_buildManifest.js",revision:"a91cb247fd17096d2ba17603cfdf5fe7"},{url:"/_next/static/cnkXhILcfrDgcR4Vc6_X1/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/css/40e47386a48391ef.css",revision:"40e47386a48391ef"},{url:"/_next/static/media/4cf2300e9c8272f7-s.p.woff2",revision:"18bae71b1e1b2bb25321090a3b563103"},{url:"/_next/static/media/747892c23ea88013-s.woff2",revision:"a0761690ccf4441ace5cec893b82d4ab"},{url:"/_next/static/media/8d697b304b401681-s.woff2",revision:"cc728f6c0adb04da0dfcb0fc436a8ae5"},{url:"/_next/static/media/93f479601ee12b01-s.p.woff2",revision:"da83d5f06d825c5ae65b7cca706cb312"},{url:"/_next/static/media/9610d9e46709d722-s.woff2",revision:"7b7c0ef93df188a852344fc272fc096b"},{url:"/_next/static/media/ba015fad6dcf6784-s.woff2",revision:"8ea4f719af3312a055caf09f34c89a77"},{url:"/file.svg",revision:"d09f95206c3fa0bb9bd9fefabfd0ea71"},{url:"/globe.svg",revision:"2aaafa6a49b6563925fe440891e32717"},{url:"/manifest.json",revision:"785fbc5941efd3caa27ade80c67bb5ef"},{url:"/next.svg",revision:"8e061864f388b47f33a1c3780831193e"},{url:"/vercel.svg",revision:"c0af2f507b369b085b35ef4bbe3bcf1e"},{url:"/window.svg",revision:"a2760511c65806022ad20adf74370ff3"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:n,state:a})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")},new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>!(self.origin===e.origin),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")}); diff --git a/src/app/api/send-scheduled-emails/route.ts b/src/app/api/send-scheduled-emails/route.ts new file mode 100644 index 0000000..2cce321 --- /dev/null +++ b/src/app/api/send-scheduled-emails/route.ts @@ -0,0 +1,353 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { doc, getDoc, collection, getDocs, updateDoc } from 'firebase/firestore'; +import { db } from '@/lib/firebase'; + +const nodemailer = require('nodemailer'); + +function generatePrayerEmailHTML(message: string, subject: string) { + // Parse the message to extract prayer details + const lines = message.split('\n'); + let currentPrayer = null; + let prayers = []; + let summarySection = false; + + for (const line of lines) { + if (line.includes('New prayer added') || line.includes('New praise added')) { + currentPrayer = { type: '', date: '', text: '', journal: '', prayFor: '' }; + } else if (line.startsWith('Date: ')) { + if (currentPrayer) currentPrayer.date = line.replace('Date: ', ''); + } else if (line.startsWith('Praying for: ')) { + if (currentPrayer) currentPrayer.prayFor = line.replace('Praying for: ', ''); + } else if (line.startsWith('Journal: ')) { + if (currentPrayer) currentPrayer.journal = line.replace('Journal: ', ''); + } else if (line.includes('--- Active Prayers Summary ---')) { + summarySection = true; + prayers.push({ type: 'summary-header', text: 'Active Prayers Summary' }); + } else if (summarySection && line.includes(' - ')) { + // Parse summary lines like "2025-01-15 - PRAYER: prayer text" + const match = line.match(/(\d{4}-\d{2}-\d{2}) - (PRAYER|PRAISE): (.+)/); + if (match) { + prayers.push({ + type: 'summary-item', + date: match[1], + prayerType: match[2].toLowerCase(), + text: match[3] + }); + } + } else if (currentPrayer && !currentPrayer.text && line.trim()) { + currentPrayer.text = line; + prayers.push(currentPrayer); + currentPrayer = null; + } + } + + const html = ` + + +
+ + +Here are all your active prayers and praises:
+"Pray without ceasing"+ — 1 Thessalonians 5:17 +