Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,40 @@
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 (`cses@ucsd.edu`) 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 each calendar's settings (as `cses@ucsd.edu`), 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=<your key>`
- One calendar ID per community tab: `GOOGLE_CALENDAR_ID_GENERAL`, `GOOGLE_CALENDAR_ID_OPEN_SOURCE`,
`GOOGLE_CALENDAR_ID_INNOVATE`, `GOOGLE_CALENDAR_ID_DEV`. Events are categorized by which
calendar they're on. Each ID is under that calendar's Settings > "Integrate calendar".
- If none of those are set, `GOOGLE_CALENDAR_ID` (default `cses@ucsd.edu`) is used as a single
calendar whose events all show under the General tab.

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.

### 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.
Expand Down
27 changes: 27 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# 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). Each 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=

# One calendar per community tab; events are categorized by which calendar
# they're on. Find each ID under the calendar's Settings > "Integrate calendar"
# (secondary calendars look like c_xxxx@group.calendar.google.com).
GOOGLE_CALENDAR_ID_GENERAL=
GOOGLE_CALENDAR_ID_OPEN_SOURCE=
GOOGLE_CALENDAR_ID_INNOVATE=
GOOGLE_CALENDAR_ID_DEV=

# Fallback: used only when none of the per-community IDs above are set;
# all its events show under the General tab.
GOOGLE_CALENDAR_ID=cses@ucsd.edu
126 changes: 126 additions & 0 deletions backend/controllers/calendarController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import asyncHandler from 'express-async-handler';

// One Google Calendar per community; each event's category comes from the
// calendar it lives on. Calendars with no configured ID are skipped.
const CALENDAR_CATEGORIES = [
{ category: 'General', envKey: 'GOOGLE_CALENDAR_ID_GENERAL' },
{ category: 'Open-Source', envKey: 'GOOGLE_CALENDAR_ID_OPEN_SOURCE' },
{ category: 'Innovate', envKey: 'GOOGLE_CALENDAR_ID_INNOVATE' },
{ category: 'Dev', envKey: 'GOOGLE_CALENDAR_ID_DEV' },
];

// 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 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, category) => {
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: '25',
}).toString();

const response = await fetch(url);
if (!response.ok) {
const body = await response.text();
console.error(`Google Calendar API error for "${category}" (${response.status}): ${body}`);
return [];
}

const { items = [] } = await response.json();
return items
.filter((item) => item.status !== 'cancelled')
.map((item) => {
const { type, title, description } = extractType(
item.summary ?? 'Untitled event',
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 Calendars.
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([]);
}

const calendars = CALENDAR_CATEGORIES.filter(({ envKey }) => process.env[envKey]).map(
({ category, envKey }) => ({ category, calendarId: process.env[envKey] }),
);

// Fallback: a single calendar (all events shown as General) when no
// per-community calendars are configured.
if (calendars.length === 0) {
calendars.push({
category: 'General',
calendarId: process.env.GOOGLE_CALENDAR_ID || 'cses@ucsd.edu',
});
}

if (cache.data && Date.now() - cache.fetchedAt < CACHE_TTL_MS) {
return res.json(cache.data);
}

const results = await Promise.all(
calendars.map(({ category, calendarId }) => fetchCalendar(apiKey, calendarId, category)),
);
const events = results.flat().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,
};
4 changes: 4 additions & 0 deletions backend/database/connect-db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions backend/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);

Expand Down
12 changes: 10 additions & 2 deletions backend/mailchimp/connect-mailchimp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
3 changes: 3 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"qrcode": "^1.5.3"
},
"engines": {
"node": ">=16"
"node": ">=18"
},
"devDependencies": {
"eslint": "^8.43.0"
Expand Down
13 changes: 13 additions & 0 deletions backend/routes/calendar.js
Original file line number Diff line number Diff line change
@@ -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;
12 changes: 12 additions & 0 deletions frontend/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,18 @@ export const eventListAPI = () => {
});
};

export const calendarEventsAPI = () => {
return new Promise((resolve, reject) => {
API.get('/calendar/events')
.then((response) => {
resolve(response.data);
})
.catch((error) => {
reject(error);
});
});
};

export const eventCreateAPI = (newEvent) => {
return new Promise((resolve, reject) => {
API.post('/event/create', newEvent)
Expand Down
Loading