diff --git a/README.md b/README.md index f268f60e..b89d76cc 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,53 @@ 9. Run `npm install` to install all the node packages. 10. Run `npm start` to run the React App and check if you can see the rendered site at http://localhost:3000/ +## Google Calendar sync (Events page) + +The Events page displays upcoming events from the CSES Google Calendar (`csesucsd@gmail.com`) via +`GET /api/v1/calendar/events`. Backend setup (see `backend/.env.example`): + +1. Requires Node 18+ (the backend uses the built-in `fetch`). +2. In [Google Cloud Console](https://console.cloud.google.com/), create/select a project, enable the + **Google Calendar API**, and create an **API key**. Restrict the key to the Calendar API. +3. In the calendar's settings (as `csesucsd@gmail.com`), enable **"Make available to public"** under + Access permissions and set the dropdown to **"See all event details"** (free/busy mode strips + titles and locations). The API returns 404 for private calendars even with a valid key. +4. Add to `backend/.env`: + - `GOOGLE_CALENDAR_API_KEY=` + - `GOOGLE_CALENDAR_ID` (default `csesucsd@gmail.com`), found under the calendar's + Settings > "Integrate calendar". + +When `GOOGLE_CALENDAR_API_KEY` is unset the endpoint logs a warning and returns `[]`, and the +Events page shows its empty state. Responses are cached in memory for 5 minutes. + +### Sorting an event into a community tab + +Every event lives on the one calendar; its tab comes from a prefix on the event **title**: + +| Title prefix | Tab | +| --- | --- | +| `CSES General ...` | General | +| `CSES Opensource ...` | Open-Source | +| `CSES Innovate ...` | Innovate | +| `CSES Dev ...` | Dev | + +The prefix is stripped before display, so `CSES Opensource Git Workshop` shows as +**Git Workshop** under Open-Source. Matching ignores case and tolerates spelling and separator +variants (`Open-Source`, `open source`, `CSES DEV: ...`). A title with no recognized prefix falls +under **General** with its title left untouched. + +### Labelling an event's type + +Each event card shows a small label under the title ("Social", "Career", "Workshop", ...). Set it +on the Google Calendar event in either of these ways: + +- Add a `Type: Social` line anywhere in the event's **description**, or +- Prefix the event **title** with the type in square brackets: `[Social] Welcome Week Social`. + +Either way the tag is stripped before display, so the card shows a clean title and description. The +label is free-form — any word works, no code change needed. Untagged events fall back to showing +their community (General / Open-Source / Innovate / Dev). + ## Development - Prior to any local development, you should pull the latest code from `main` and work on your separate branch. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 00000000..b485c54f --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,22 @@ +# MongoDB connection string +CONNECTION_URL= + +# Mailchimp API key +MAILCHIMP_API_KEY= + +# Server port +PORT=5000 + +# Google Calendar sync (Events page). +# Create an API key in Google Cloud Console (enable the Google Calendar API and +# restrict the key to it). The calendar must be public ("Make available to +# public" + "See all event details" in its sharing settings). +# When the key is unset, /api/v1/calendar/events returns an empty list. +GOOGLE_CALENDAR_API_KEY= + +# All events come from one public calendar. An event's community tab is taken +# from a prefix on its title -- "CSES General", "CSES Opensource", +# "CSES Innovate", "CSES Dev" -- and the prefix is stripped before display. +# Titles with no recognized prefix fall under General. +# Defaults to csesucsd@gmail.com when unset. +GOOGLE_CALENDAR_ID=csesucsd@gmail.com diff --git a/backend/controllers/calendarController.js b/backend/controllers/calendarController.js new file mode 100644 index 00000000..b62086c2 --- /dev/null +++ b/backend/controllers/calendarController.js @@ -0,0 +1,135 @@ +import asyncHandler from 'express-async-handler'; + +// All events live on one public CSES calendar. +const DEFAULT_CALENDAR_ID = 'csesucsd@gmail.com'; + +// Upcoming events to request. One calendar now carries every community's +// events, so this is the budget across all four tabs, not per tab. +const MAX_RESULTS = 50; + +// In-memory cache so we don't burn Google API quota on every page load. +const CACHE_TTL_MS = 5 * 60 * 1000; +let cache = { data: null, fetchedAt: 0 }; + +// Organizers prefix each event title with its community, e.g. +// "CSES Opensource Workshop". Matching is deliberately loose about spelling +// and separators so a stray hyphen or capital doesn't silently drop an event +// into General. Anything with no recognized prefix falls under General. +const DEFAULT_CATEGORY = 'General'; +const CATEGORY_PREFIXES = [ + { category: 'Open-Source', pattern: /^\s*CSES[\s_-]+open[\s_-]?source\b[\s:_–—-]*/i }, + { category: 'Innovate', pattern: /^\s*CSES[\s_-]+innovate\b[\s:_–—-]*/i }, + { category: 'Dev', pattern: /^\s*CSES[\s_-]+dev\b[\s:_–—-]*/i }, + { category: 'General', pattern: /^\s*CSES[\s_-]+general\b[\s:_–—-]*/i }, +]; + +// The prefix is routing information, not part of the event name, so it is +// stripped from what we display. +const extractCategory = (summary) => { + for (const { category, pattern } of CATEGORY_PREFIXES) { + if (pattern.test(summary)) { + return { category, title: summary.replace(pattern, '').trim() || summary.trim() }; + } + } + + return { category: DEFAULT_CATEGORY, title: summary }; +}; + +// Organizers tag an event's type ("Social", "Career", ...) either with a +// "Type: X" line anywhere in the description or with a "[X]" prefix on the +// title. Both are stripped from what we display. +const TYPE_IN_DESCRIPTION = /^[ \t]*type[ \t]*:[ \t]*(.+?)[ \t]*$/im; +const TYPE_IN_TITLE = /^\s*\[([^\]]+)\]\s*/; + +const extractType = (summary, description) => { + const fromDescription = description.match(TYPE_IN_DESCRIPTION); + if (fromDescription) { + return { + type: fromDescription[1], + title: summary, + description: description.replace(TYPE_IN_DESCRIPTION, '').trim(), + }; + } + + const fromTitle = summary.match(TYPE_IN_TITLE); + if (fromTitle) { + return { + type: fromTitle[1].trim(), + title: summary.replace(TYPE_IN_TITLE, '').trim(), + description, + }; + } + + return { type: '', title: summary, description }; +}; + +const fetchCalendar = async (apiKey, calendarId) => { + const url = new URL( + `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`, + ); + url.search = new URLSearchParams({ + key: apiKey, + timeMin: new Date().toISOString(), + singleEvents: 'true', + orderBy: 'startTime', + maxResults: String(MAX_RESULTS), + }).toString(); + + const response = await fetch(url); + if (!response.ok) { + const body = await response.text(); + console.error(`Google Calendar API error for "${calendarId}" (${response.status}): ${body}`); + return []; + } + + const { items = [] } = await response.json(); + return items + .filter((item) => item.status !== 'cancelled') + .map((item) => { + // Category prefix comes first in the title, so strip it before looking + // for a "[Type]" prefix on what remains. + const { category, title: untagged } = extractCategory(item.summary ?? 'Untitled event'); + const { type, title, description } = extractType(untagged, item.description ?? ''); + + return { + id: item.id, + title, + description, + type, + location: item.location ?? '', + start: item.start?.dateTime ?? item.start?.date, + end: item.end?.dateTime ?? item.end?.date, + allDay: !item.start?.dateTime, + htmlLink: item.htmlLink ?? '', + category, + }; + }); +}; + +// Display list of upcoming events from the CSES Google Calendar. +export const calendarEventList = asyncHandler(async (req, res) => { + // Read env inside the handler: dotenv.config() runs after module imports. + const apiKey = process.env.GOOGLE_CALENDAR_API_KEY; + + if (!apiKey) { + console.warn('GOOGLE_CALENDAR_API_KEY is not set; returning empty calendar event list'); + return res.json([]); + } + + if (cache.data && Date.now() - cache.fetchedAt < CACHE_TTL_MS) { + return res.json(cache.data); + } + + const calendarId = process.env.GOOGLE_CALENDAR_ID || DEFAULT_CALENDAR_ID; + const events = (await fetchCalendar(apiKey, calendarId)).sort( + (a, b) => new Date(a.start) - new Date(b.start), + ); + + cache = { data: events, fetchedAt: Date.now() }; + res.json(events); +}); + +// Export default controller methods +export default { + calendarEventList, +}; diff --git a/backend/database/connect-db.js b/backend/database/connect-db.js index 7e4d420f..07fd6b87 100644 --- a/backend/database/connect-db.js +++ b/backend/database/connect-db.js @@ -6,6 +6,10 @@ const uri = process.env.CONNECTION_URL; // Connect to database const connectDB = async () => { + if (!uri) { + console.warn('CONNECTION_URL is not set; skipping MongoDB connection (event/user routes will fail)'); + return; + } try { await mongoose.connect(uri, { useNewUrlParser: true, diff --git a/backend/index.js b/backend/index.js index 28346f74..a9ed92f1 100644 --- a/backend/index.js +++ b/backend/index.js @@ -9,6 +9,7 @@ import connectMailchimp from './mailchimp/connect-mailchimp.js'; // import routes import eventRoutes from './routes/event.js'; +import calendarRoutes from './routes/calendar.js'; import subscriptionRoutes from './routes/emailSubscription.js'; import userRoutes from './routes/user.js'; @@ -39,6 +40,7 @@ app.get('/', function (_, res) { }); app.use(`${baseApi}`, eventRoutes); +app.use(`${baseApi}/calendar`, calendarRoutes); app.use(`${baseApi}/subscribers`, subscriptionRoutes); app.use(`${baseApi}/users`, userRoutes); diff --git a/backend/mailchimp/connect-mailchimp.js b/backend/mailchimp/connect-mailchimp.js index 00a3315a..d4f0588e 100644 --- a/backend/mailchimp/connect-mailchimp.js +++ b/backend/mailchimp/connect-mailchimp.js @@ -9,8 +9,16 @@ mailchimp.setConfig({ }); async function connectMailchimp() { - const response = await mailchimp.ping.get(); - console.log(response.health_status); // if successful, returns "Everything's Chimpy!" + if (!apikey) { + console.warn('MAILCHIMP_API_KEY is not set; skipping Mailchimp connection (subscriber routes will fail)'); + return; + } + try { + const response = await mailchimp.ping.get(); + console.log(response.health_status); // if successful, returns "Everything's Chimpy!" + } catch (error) { + console.error('Mailchimp connection error:', error); + } } export default connectMailchimp; \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 3587215d..0941a7bd 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -23,6 +23,9 @@ }, "devDependencies": { "eslint": "^8.43.0" + }, + "engines": { + "node": ">=18" } }, "node_modules/@aashutoshrathi/word-wrap": { diff --git a/backend/package.json b/backend/package.json index bdcc2348..baa4ddd6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,7 +25,7 @@ "qrcode": "^1.5.3" }, "engines": { - "node": ">=16" + "node": ">=18" }, "devDependencies": { "eslint": "^8.43.0" diff --git a/backend/routes/calendar.js b/backend/routes/calendar.js new file mode 100644 index 00000000..2caca985 --- /dev/null +++ b/backend/routes/calendar.js @@ -0,0 +1,13 @@ +import express from 'express'; +const router = express.Router(); + +// Require controller modules. +import calendarController from '../controllers/calendarController.js'; + +/// CALENDAR ROUTES /// + +// GET request for upcoming events synced from the CSES Google Calendar. +router.get('/events', calendarController.calendarEventList); + +// Export router. +export default router; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f0a24f0a..6f0ce1af 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -38,7 +38,7 @@ "react-countup": "^6.4.2", "react-dom": "^18.2.0", "react-google-button": "^0.7.2", - "react-router-dom": "^6.30.1", + "react-router-dom": "^6.30.4", "react-scripts": "^5.0.1", "typescript": "^4.9.5", "web-vitals": "^2.1.4" @@ -3732,9 +3732,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", - "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -17781,12 +17781,12 @@ } }, "node_modules/react-router": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", - "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.0" + "@remix-run/router": "1.23.3" }, "engines": { "node": ">=14.0.0" @@ -17796,13 +17796,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", - "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.0", - "react-router": "6.30.1" + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" }, "engines": { "node": ">=14.0.0" @@ -23608,9 +23608,9 @@ "requires": {} }, "@remix-run/router": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", - "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==" + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==" }, "@restart/hooks": { "version": "0.4.11", @@ -32837,20 +32837,20 @@ "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==" }, "react-router": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", - "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", "requires": { - "@remix-run/router": "1.23.0" + "@remix-run/router": "1.23.3" } }, "react-router-dom": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", - "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", "requires": { - "@remix-run/router": "1.23.0", - "react-router": "6.30.1" + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" } }, "react-scripts": { diff --git a/frontend/package.json b/frontend/package.json index 0db63c4d..8acf3704 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,7 +33,7 @@ "react-countup": "^6.4.2", "react-dom": "^18.2.0", "react-google-button": "^0.7.2", - "react-router-dom": "^6.30.1", + "react-router-dom": "^6.30.4", "react-scripts": "^5.0.1", "typescript": "^4.9.5", "web-vitals": "^2.1.4" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4a08aac5..fcfe3309 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,6 +17,7 @@ import Sponsorships from './components/Sponsorships/Sponsorships'; // added my m import OpenSourceCommunity from './components/OpenSourceCommunity/OpenSourceCommunity'; import DevCommunity from './components/Dev/DevCommunity' import InovateCommunity from './components/Inovate/InovateCommunity' +import JoinUs from './components/JoinUs/JoinUs'; function App() { return ( @@ -38,6 +39,7 @@ function App() { } /> } /> } /> + } />