Skip to content
Merged
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
40 changes: 40 additions & 0 deletions docs/caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Static Metadata Caching Strategy

To improve application loading speeds and reduce bandwidth, we employ an aggressive edge caching strategy for highly static metadata (majors, semesters, terms, departments).

## The Cache Header

All static edge function responses include the following header:

```
Cache-Control: public, max-age=86400, s-maxage=604800, stale-while-revalidate=86400
```

### Breakdown:

- **`public`**: The response can be cached by any cache (browser, CDN).
- **`max-age=86400`**: The browser will cache the data locally for 1 day (86,400 seconds).
- **`s-maxage=604800`**: The CDN (Cloudflare/Vercel) will cache the data for 7 days.
- **`stale-while-revalidate=86400`**: The CDN will serve stale data while fetching the updated data in the background if the cache is older than 7 days, up to an additional 1 day.
Comment on lines +10 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Browser caching defeats the documented invalidation guarantee.

max-age=86400 allows browsers to reuse metadata for 24 hours without contacting the CDN, while notify_cdn_purge() only purges Cloudflare. After a database change, users can still receive stale metadata despite the trigger.

Use browser revalidation (for example, max-age=0 while retaining s-maxage), versioned URLs, or another client invalidation mechanism.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/caching.md` around lines 10 - 18, Update the Cache-Control guidance in
the caching documentation to prevent browser reuse from bypassing CDN
invalidation, while retaining the documented s-maxage behavior. Use browser
revalidation such as max-age=0, or document an equivalent versioned-URL/client
invalidation mechanism, and revise the breakdown to match the chosen policy.


## Frontend Fetch Wrapper

The frontend `customFetch` utility in `src/utils/fetch.ts` is designed to support this strategy.
For static metadata requests, it ensures:

1. `cache: "default"` is explicitly passed to the underlying `fetch` API.
2. Cache-busting query strings (like `?timestamp=12345`) are NOT appended, allowing the browser to serve from disk/memory cache.

## Database Triggers and Cache Invalidation

Since the data is cached at the CDN for 7 days, any modifications made in the database (e.g., adding a new major) would not reflect immediately.

To solve this, we implemented a Postgres trigger on the static metadata tables.
When a row is inserted, updated, or deleted, it invokes a PL/pgSQL function `notify_cdn_purge()`.
This function makes an HTTP POST request via `pg_net` to the Cloudflare/Vercel CDN Purge API, invalidating the specific endpoint url (e.g. `/api/majors`).

### Relevant Files

- `supabase/migrations/*_cache_invalidation.sql`
- `supabase/migrations/*_triggers.sql`
- `supabase/functions/shared/cache.ts`
59 changes: 59 additions & 0 deletions docs/event-state-machine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Event State Machine Documentation

The `EventWizard` component uses an XState finite state machine (`eventCreationMachine`) to manage its complex multi-step UI flow.

## State Chart

```mermaid
stateDiagram-v2
[*] --> basics
basics --> ticketing : NEXT (isPaid = true)
basics --> location : NEXT (isPaid = false)
ticketing --> location : NEXT
ticketing --> basics : BACK
location --> review : NEXT
location --> ticketing : BACK (isPaid = true)
location --> basics : BACK (isPaid = false)
review --> submitting : SUBMIT
review --> location : BACK
submitting --> success : onDone
submitting --> error : onError
error --> submitting : RETRY
error --> review : BACK
success --> [*]
```

## Context Schema

```ts
export interface EventContext {
formData: {
title: string;
description: string;
category: string;
isPaid: boolean;
price?: number;
currency?: string;
location?: string;
startDate: string;
endDate: string;
tags: string[];
image?: string;
};
validationErrors: Record<string, string>;
currentStep: number;
}
```

## Guards

- `isBasicsValid`: Verifies all required fields in the basics step are filled.
- `isTicketingValid`: Ensures price > 0 and currency is selected for paid events.
- `isLocationValid`: Ensures a location string exists.
- `isPaidEvent` / `isFreeEvent`: Checks `context.formData.isPaid`.
- `canSubmit`: Runs full validation across all fields before allowing submission.

## Persistence

The `useEventWizard` hook automatically saves the `snapshot.value` and `snapshot.context` to `sessionStorage` after every transition (except `success`).
Upon mount, it attempts to load from `sessionStorage` and restores context using the `RESTORE` event.
92 changes: 92 additions & 0 deletions docs/webhooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Outbound Webhooks

Clubs can configure outbound webhooks to receive real-time HTTP POST notifications when events occur in their club on CampusConnect.

## Supported Events

Currently supported events:

- `event.created`
- `event.updated`
- `event.deleted`
- `post.created`
- `club.updated`
- `member.joined`
- `member.left`

## Payload Schema

The webhook payload is a JSON object with the following structure:

```json
{
"event": "event.created",
"timestamp": "2026-07-30T11:30:00Z",
"club": {
"id": "uuid-of-club"
},
"data": {
"id": "uuid-of-event",
"title": "Hackathon",
"location": "Auditorium",
"startsAt": "2026-08-15T09:00:00Z"
}
}
```

## Security & Signatures

To verify that a webhook request was genuinely sent by CampusConnect, we include an HMAC-SHA256 signature in the `X-CampusConnect-Signature` header.

The signature is generated using your Webhook Secret (available in the Club Settings UI).

### Verifying Signatures in Node.js

```javascript
const crypto = require("crypto");

function verifySignature(payloadString, secret, signatureHeader) {
const hash = crypto.createHmac("sha256", secret).update(payloadString).digest("hex");

const expectedSignature = `sha256=${hash}`;

// Use crypto.timingSafeEqual to prevent timing attacks
return crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expectedSignature));
}
```

### Verifying Signatures in Python

```python
import hmac
import hashlib

def verify_signature(payload_string, secret, signature_header):
hash_obj = hmac.new(
secret.encode('utf-8'),
payload_string.encode('utf-8'),
hashlib.sha256
)
expected_signature = f"sha256={hash_obj.hexdigest()}"
return hmac.compare_digest(signature_header, expected_signature)
```

## Retry Policy

If your server responds with a 5xx error or times out, CampusConnect will automatically retry the delivery with exponential backoff:

- **Attempt 1:** Immediate
- **Attempt 2:** +1 minute
- **Attempt 3:** +5 minutes
- **Attempt 4:** +15 minutes
- **Attempt 5:** +1 hour

After 5 failed attempts, the delivery is marked as permanently failed. Client errors (4xx responses) are generally not retried, except for `429 Too Many Requests`.

## Server-Side Request Forgery (SSRF) Protection

For security reasons, webhook URLs must be publicly accessible over HTTPS. We reject URLs pointing to:

- `localhost` or `127.0.0.1`
- Private network ranges (e.g., `10.x.x.x`, `192.168.x.x`)
- AWS Metadata endpoints (`169.254.169.254`)
52 changes: 52 additions & 0 deletions package-lock.json

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

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
"@types/canvas-confetti": "^1.9.0",
"@types/react-easy-crop": "^1.16.0",
"@uiw/react-md-editor": "^4.1.1",
"@xstate/react": "^6.1.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
Expand Down Expand Up @@ -130,6 +131,7 @@
"workbox-precaching": "^7.4.1",
"workbox-routing": "^7.4.1",
"workbox-strategies": "^7.4.1",
"xstate": "^5.32.5",
"y-prosemirror": "^1.3.7",
"y-protocols": "^1.0.7",
"yjs": "^13.6.31",
Expand Down
38 changes: 24 additions & 14 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,10 @@ const Leaderboard = lazy(() =>
import("./components/Leaderboard").then((m) => ({ default: m.Leaderboard })),
);

const LazyEventsIndex = lazy(() => import("./routes/events"));
const LazyEventDetails = lazy(() => import("./routes/events.$eventId"));
const EventsLayout = lazy(() => import("./pages/Events/EventsLayout"));
const LazyEventsIndex = lazy(() => import("./pages/Events/EventsList"));
const LazyEventDetails = lazy(() => import("./pages/Events/EventDetail"));
const EmptyState = lazy(() => import("./pages/Events/EmptyState"));

function PageFallback() {
return (
Expand Down Expand Up @@ -155,21 +157,29 @@ const router = createBrowserRouter(
<Route
path="/events"
element={
<<<<<<< HEAD
<Suspense fallback={<PageFallback />}>
<LazyEventsIndex />
<EventsLayout />
</Suspense>
}
/>

<Route
path="/events/:eventId"
element={
<Suspense fallback={<PageFallback />}>
<LazyEventDetails />
</Suspense>
}
/>

>
<Route
index
element={
<Suspense fallback={<PageFallback />}>
<EmptyState />
</Suspense>
}
/>
<Route
path=":eventId"
element={
<Suspense fallback={<PageFallback />}>
<LazyEventDetails />
</Suspense>
}
/>
</Route>
<Route path="/events/:eventId/dashboard" element={<EventDashboard />} />
{/* Events Map View with clustering */}
<Route path="events/map" element={<EventsMapPage />} />
Expand Down
Loading
Loading