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
6 changes: 6 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ STRIPE_WEBHOOK_SECRET=whsec_...
# Telegram Bot
TELEGRAM_BOT_TOKEN=your_telegram_bot_token

# Calendar Sync (iCal feed)
# Generate with: openssl rand -hex 32
CALENDAR_SECRET=your_calendar_secret_here_use_openssl_rand_hex_32
# Public base URL used in generated feed links (defaults to FRONTEND_URL)
CALENDAR_FEED_BASE_URL=http://localhost:3000

# Encryption (for API keys)
ENCRYPTION_KEY=your_32_byte_encryption_key

Expand Down
107 changes: 107 additions & 0 deletions backend/CALENDAR_INTEGRATION_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Calendar Integration Guide

## Overview

SYNCRO calendar sync exports subscription renewal dates and reminder schedules as an iCal (`.ics`) feed. Users can subscribe to the feed in Apple Calendar, Google Calendar, or Outlook, or download a one-time `.ics` export from the subscriptions page.

## Architecture

| Layer | Responsibility |
|-------|----------------|
| `backend/src/services/calendar-service.ts` | Token generation, preference storage, iCal event creation/cancellation |
| `backend/src/routes/calendar.ts` | HTTP routes for feed, token, and preferences |
| `client/app/api/calendar/feed/[userId]/[token]/route.ts` | Next.js proxy so feed URLs use the frontend host |
| `client/components/settings/CalendarSettings.tsx` | Settings UI for calendar preferences and export |
| `client/components/pages/subscriptions.tsx` | Quick access to feed URL and `.ics` export |

## Environment

Add to `backend/.env`:

```bash
CALENDAR_SECRET=your_calendar_secret_here_use_openssl_rand_hex_32
CALENDAR_FEED_BASE_URL=http://localhost:3000
```

`CALENDAR_SECRET` is required in production. Generate it with:

```bash
openssl rand -hex 32
```

## Database

Run migration `backend/migrations/023_add_calendar_preferences.sql` to add:

- `user_preferences.calendar_sync_enabled` (default `false`)
- `user_preferences.calendar_export_reminders` (default `true`)

## API Routes

### Public

| Method | Route | Description |
|--------|-------|-------------|
| `GET` | `/api/calendar/feed/:userId/:token.ics` | Returns iCal feed when token is valid and sync is enabled |

### Authenticated

| Method | Route | Description |
|--------|-------|-------------|
| `GET` | `/api/calendar/token` | Returns HMAC token and feed URL |
| `GET` | `/api/calendar/preferences` | Returns calendar sync preferences and feed URL |
| `PATCH` | `/api/calendar/preferences` | Updates `calendar_sync_enabled` and `calendar_export_reminders` |

## Service Behavior

### Event creation

- Active subscriptions with `next_billing_date` produce `STATUS:CONFIRMED` events.
- Pending reminder schedules produce reminder events when `calendar_export_reminders` is enabled.
- Each subscription uses a stable UID: `syncro-sub-{subscriptionId}@syncro.app`.

### Cancellation updates

- Cancelled subscriptions produce `STATUS:CANCELLED` events with the same UID as their renewal event.
- Cancelled reminder schedules produce cancelled reminder events.
- Calendar clients remove or mark events cancelled on the next feed refresh.

### Security

- Feed access is gated by an HMAC token derived from `CALENDAR_SECRET` and `user_id`.
- Token verification uses constant-time comparison.
- Feeds are blocked when `calendar_sync_enabled` is `false`.
- Authenticated routes require standard JWT/API-key auth.

## User Flows

### Enable calendar sync (Settings)

1. Open **Settings → Notifications**.
2. Enable **Calendar feed**.
3. Optionally enable **Include reminder schedules**.
4. Copy the feed URL into your calendar app.

### Export reminders (Subscriptions)

1. Open the **Export** menu on the subscriptions page.
2. Choose **Export reminders (.ics)** for a one-time download.
3. Or use **Sync to Calendar** to copy the subscribed feed URL.

## Testing

Backend tests cover event creation, cancellation updates, token validation, and route behavior:

```bash
cd backend
npm test -- calendar-service.test.ts calendar-api.test.ts
```

## Related Files

- `backend/src/routes/calendar.ts`
- `backend/src/services/calendar-service.ts`
- `backend/tests/calendar-service.test.ts`
- `backend/tests/calendar-api.test.ts`
- `client/lib/api/calendar.ts`
- `client/components/settings/CalendarSettings.tsx`
7 changes: 7 additions & 0 deletions backend/migrations/023_add_calendar_preferences.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Calendar sync preferences for iCal feed export
ALTER TABLE public.user_preferences
ADD COLUMN IF NOT EXISTS calendar_sync_enabled BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS calendar_export_reminders BOOLEAN NOT NULL DEFAULT TRUE;

COMMENT ON COLUMN public.user_preferences.calendar_sync_enabled IS 'Whether the user iCal calendar feed is active';
COMMENT ON COLUMN public.user_preferences.calendar_export_reminders IS 'Whether pending reminder schedules are included in the iCal feed';
6 changes: 6 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ import { adminAuth } from './middleware/admin';
import { createAdminLimiter, RateLimiterFactory } from './middleware/rate-limit-factory';
import { scheduleAutoResume } from './jobs/auto-resume';
import giftCardLedgerRoutes from './routes/gift-card-ledger';
import calendarRouter from './routes/calendar';
import userPreferencesRoutes from './routes/user-preferences';
import reminderSettingsRoutes from './routes/reminder-settings';
import { errorHandler } from './middleware/errorHandler';
import { swaggerSpec } from './swagger';

Expand Down Expand Up @@ -128,6 +131,9 @@ app.use('/api/mfa', mfaRoutes);
app.use('/api/notifications/push', pushNotificationRoutes);
app.use('/api/exchange-rates', createExchangeRatesRouter(exchangeRateService));
app.use('/api/gift-card-ledger', giftCardLedgerRoutes);
app.use('/api/calendar', calendarRouter);
app.use('/api/user-preferences', authenticate, userPreferencesRoutes);
app.use('/api/reminder-settings', authenticate, reminderSettingsRoutes);

app.get('/api/reminders/status', (req, res) => {
const status = schedulerService.getStatus();
Expand Down
133 changes: 84 additions & 49 deletions backend/src/routes/calendar.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
import { Router, Response, Request } from 'express';
import ical from 'ical-generator';
import { supabase } from '../config/database';
import crypto from 'crypto';
import { authenticate, AuthenticatedRequest } from '../middleware/auth';
import { validateRequest } from '../utils/validation';
import { calendarService, verifyCalendarToken } from '../services/calendar-service';
import logger from '../config/logger';
import { z } from 'zod';

const router: Router = Router();
const CALENDAR_SECRET = process.env.CALENDAR_SECRET || 'syncro-calendar-secret-key-123';

/**
* Generate a secure token for a user
*/
export function generateCalendarToken(userId: string): string {
return crypto
.createHmac('sha256', CALENDAR_SECRET)
.update(userId)
.digest('hex')
.substring(0, 16);
const calendarPreferencesSchema = z.object({
calendar_sync_enabled: z.boolean().optional(),
calendar_export_reminders: z.boolean().optional(),
});

function getFeedBaseUrl(req: Request): string {
const configured = process.env.CALENDAR_FEED_BASE_URL || process.env.FRONTEND_URL;
if (configured) {
return configured.replace(/\/$/, '');
}
const host = req.get('host');
const protocol = req.protocol;
return `${protocol}://${host}`;
}

/**
Expand All @@ -27,41 +31,20 @@ router.get('/feed/:userId/:token.ics', async (req: Request, res: Response) => {
const userId = Array.isArray(req.params.userId) ? req.params.userId[0] : req.params.userId;
const token = Array.isArray(req.params.token) ? req.params.token[0] : req.params.token;

// Verify token
const expectedToken = generateCalendarToken(userId);
if (token !== expectedToken) {
if (!verifyCalendarToken(userId, token)) {
return res.status(403).send('Invalid calendar token');
}

// Fetch subscriptions for the user
const { data: subscriptions, error } = await supabase
.from('subscriptions')
.select('*')
.eq('user_id', userId)
.eq('status', 'active');

if (error) {
throw error;
}

const calendar = ical({ name: 'SYNCRO Subscriptions' });

(subscriptions || []).forEach(sub => {
if (sub.next_billing_date) {
calendar.createEvent({
start: new Date(sub.next_billing_date),
allDay: true,
summary: `Subscription Renewal: ${sub.name}`,
description: `Renewal for ${sub.name} - $${sub.price}/${sub.billing_cycle}`,
url: sub.renewal_url || undefined,
});
}
});
const feed = await calendarService.generateFeed(userId);

res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="subscriptions.ics"');
res.send(calendar.toString());
res.setHeader('Cache-Control', 'private, max-age=300');
res.send(feed);
} catch (error) {
if (error instanceof Error && error.message === 'Calendar sync is disabled for this user') {
return res.status(403).send('Calendar sync is disabled');
}
logger.error('Calendar feed error:', error);
res.status(500).send('Internal server error');
}
Expand All @@ -71,14 +54,66 @@ router.get('/feed/:userId/:token.ics', async (req: Request, res: Response) => {
* GET /api/calendar/token
* Get current user's calendar token (requires standard auth)
*/
import { authenticate, AuthenticatedRequest } from '../middleware/auth';
router.get('/token', authenticate, (req: AuthenticatedRequest, res: Response) => {
const token = generateCalendarToken(req.user!.id);
res.json({
success: true,
token,
userId: req.user!.id,
});
router.get('/token', authenticate, async (req: AuthenticatedRequest, res: Response) => {
try {
const tokenResponse = await calendarService.getToken(req.user!.id, getFeedBaseUrl(req));
res.json({
success: true,
token: tokenResponse.token,
userId: tokenResponse.userId,
feedUrl: tokenResponse.feedUrl,
});
} catch (error) {
logger.error('Calendar token error:', error);
res.status(500).json({ success: false, error: 'Failed to generate calendar token' });
}
});

/**
* GET /api/calendar/preferences
* Get calendar sync preferences for the authenticated user
*/
router.get('/preferences', authenticate, async (req: AuthenticatedRequest, res: Response) => {
try {
const preferences = await calendarService.getPreferences(req.user!.id);
const tokenResponse = await calendarService.getToken(req.user!.id, getFeedBaseUrl(req));
res.json({
success: true,
data: {
...preferences,
feedUrl: tokenResponse.feedUrl,
},
});
} catch (error) {
logger.error('Calendar preferences fetch error:', error);
res.status(500).json({ success: false, error: 'Failed to fetch calendar preferences' });
}
});

/**
* PATCH /api/calendar/preferences
* Update calendar sync preferences for the authenticated user
*/
router.patch('/preferences', authenticate, async (req: AuthenticatedRequest, res: Response) => {
try {
const validated = validateRequest(calendarPreferencesSchema, req.body);
const preferences = await calendarService.updatePreferences(req.user!.id, validated);
const tokenResponse = await calendarService.getToken(req.user!.id, getFeedBaseUrl(req));

res.json({
success: true,
data: {
...preferences,
feedUrl: tokenResponse.feedUrl,
},
message: 'Calendar preferences updated successfully',
});
} catch (error) {
logger.error('Calendar preferences update error:', error);
res.status(500).json({ success: false, error: 'Failed to update calendar preferences' });
}
});

export { generateCalendarToken } from '../services/calendar-service';

export default router;
Loading
Loading