diff --git a/backend/.env.example b/backend/.env.example index 18ac732d..2aa41195 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/CALENDAR_INTEGRATION_GUIDE.md b/backend/CALENDAR_INTEGRATION_GUIDE.md new file mode 100644 index 00000000..a8e4c819 --- /dev/null +++ b/backend/CALENDAR_INTEGRATION_GUIDE.md @@ -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` diff --git a/backend/migrations/023_add_calendar_preferences.sql b/backend/migrations/023_add_calendar_preferences.sql new file mode 100644 index 00000000..dc9e15e1 --- /dev/null +++ b/backend/migrations/023_add_calendar_preferences.sql @@ -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'; diff --git a/backend/src/index.ts b/backend/src/index.ts index fb2257ef..26117003 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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'; @@ -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(); diff --git a/backend/src/routes/calendar.ts b/backend/src/routes/calendar.ts index 54fc670b..55016b8b 100644 --- a/backend/src/routes/calendar.ts +++ b/backend/src/routes/calendar.ts @@ -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}`; } /** @@ -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'); } @@ -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; diff --git a/backend/src/services/calendar-service.ts b/backend/src/services/calendar-service.ts new file mode 100644 index 00000000..cd2cced2 --- /dev/null +++ b/backend/src/services/calendar-service.ts @@ -0,0 +1,215 @@ +import crypto from 'crypto'; +import ical, { ICalCalendar } from 'ical-generator'; +import { supabase } from '../config/database'; +import logger from '../config/logger'; +import { userPreferenceService } from './user-preference-service'; +import type { Subscription } from '../types/reminder'; + +const CALENDAR_SECRET = process.env.CALENDAR_SECRET; + +export interface CalendarPreferences { + calendar_sync_enabled: boolean; + calendar_export_reminders: boolean; +} + +export interface CalendarTokenResponse { + token: string; + userId: string; + feedUrl: string; +} + +export interface CalendarPreferencesUpdate { + calendar_sync_enabled?: boolean; + calendar_export_reminders?: boolean; +} + +interface ReminderScheduleRow { + id: string; + subscription_id: string; + reminder_date: string; + reminder_type: string; + days_before: number; + status: string; + subscriptions?: Pick; +} + +function getCalendarSecret(): string { + if (!CALENDAR_SECRET) { + if (process.env.NODE_ENV === 'production') { + throw new Error('CALENDAR_SECRET environment variable is required in production'); + } + return 'syncro-calendar-secret-dev-only'; + } + return CALENDAR_SECRET; +} + +export function generateCalendarToken(userId: string): string { + return crypto + .createHmac('sha256', getCalendarSecret()) + .update(userId) + .digest('hex') + .substring(0, 32); +} + +export function verifyCalendarToken(userId: string, token: string): boolean { + const expected = generateCalendarToken(userId); + if (expected.length !== token.length) { + return false; + } + return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(token)); +} + +export function getSubscriptionEventUid(subscriptionId: string): string { + return `syncro-sub-${subscriptionId}@syncro.app`; +} + +export function getReminderEventUid(reminderId: string): string { + return `syncro-reminder-${reminderId}@syncro.app`; +} + +export function buildRenewalEvent(subscription: Subscription) { + return { + id: getSubscriptionEventUid(subscription.id), + start: new Date(subscription.next_billing_date!), + allDay: true, + summary: `Subscription Renewal: ${subscription.name}`, + description: `Renewal for ${subscription.name} - $${subscription.price}/${subscription.billing_cycle}`, + url: subscription.renewal_url || undefined, + status: 'CONFIRMED' as const, + }; +} + +export function buildCancelledRenewalEvent(subscription: Subscription) { + const eventDate = subscription.next_billing_date || subscription.updated_at; + return { + id: getSubscriptionEventUid(subscription.id), + start: new Date(eventDate), + allDay: true, + summary: `Subscription Renewal: ${subscription.name}`, + description: `Cancelled subscription: ${subscription.name}`, + status: 'CANCELLED' as const, + }; +} + +export function buildReminderEvent(reminder: ReminderScheduleRow) { + const subscriptionName = reminder.subscriptions?.name || 'Subscription'; + return { + id: getReminderEventUid(reminder.id), + start: new Date(reminder.reminder_date), + allDay: true, + summary: `Renewal Reminder: ${subscriptionName}`, + description: `${reminder.days_before} day reminder for ${subscriptionName}`, + status: reminder.status === 'cancelled' ? ('CANCELLED' as const) : ('CONFIRMED' as const), + }; +} + +export function buildCalendarFeed( + activeSubscriptions: Subscription[], + cancelledSubscriptions: Subscription[] = [], + reminders: ReminderScheduleRow[] = [], +): ICalCalendar { + const calendar = ical({ name: 'SYNCRO Subscriptions' }); + + activeSubscriptions.forEach((subscription) => { + if (subscription.next_billing_date) { + calendar.createEvent(buildRenewalEvent(subscription)); + } + }); + + cancelledSubscriptions.forEach((subscription) => { + calendar.createEvent(buildCancelledRenewalEvent(subscription)); + }); + + reminders.forEach((reminder) => { + calendar.createEvent(buildReminderEvent(reminder)); + }); + + return calendar; +} + +export class CalendarService { + async getPreferences(userId: string): Promise { + const preferences = await userPreferenceService.getPreferences(userId); + return { + calendar_sync_enabled: preferences.calendar_sync_enabled ?? false, + calendar_export_reminders: preferences.calendar_export_reminders ?? true, + }; + } + + async updatePreferences( + userId: string, + updates: CalendarPreferencesUpdate, + ): Promise { + const updated = await userPreferenceService.updatePreferences(userId, updates); + return { + calendar_sync_enabled: updated.calendar_sync_enabled ?? false, + calendar_export_reminders: updated.calendar_export_reminders ?? true, + }; + } + + async getToken(userId: string, feedBaseUrl: string): Promise { + const token = generateCalendarToken(userId); + const normalizedBase = feedBaseUrl.replace(/\/$/, ''); + return { + token, + userId, + feedUrl: `${normalizedBase}/api/calendar/feed/${userId}/${token}.ics`, + }; + } + + async generateFeed(userId: string): Promise { + const preferences = await this.getPreferences(userId); + + if (!preferences.calendar_sync_enabled) { + throw new Error('Calendar sync is disabled for this user'); + } + + const activeSubscriptions = await this.fetchSubscriptions(userId, 'active'); + const cancelledSubscriptions = await this.fetchSubscriptions(userId, 'cancelled'); + const reminders = preferences.calendar_export_reminders + ? await this.fetchReminderSchedules(userId) + : []; + + return buildCalendarFeed(activeSubscriptions, cancelledSubscriptions, reminders).toString(); + } + + private async fetchSubscriptions(userId: string, status: string): Promise { + let query = supabase + .from('subscriptions') + .select('*') + .eq('user_id', userId) + .eq('status', status); + + if (status === 'cancelled') { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - 90); + query = query.gte('updated_at', cutoff.toISOString()); + } + + const { data, error } = await query; + + if (error) { + logger.error(`Failed to fetch ${status} subscriptions for calendar feed`, error); + throw error; + } + + return (data || []) as Subscription[]; + } + + private async fetchReminderSchedules(userId: string): Promise { + const { data, error } = await supabase + .from('reminder_schedules') + .select('id, subscription_id, reminder_date, reminder_type, days_before, status, subscriptions(name, price, billing_cycle, status)') + .eq('user_id', userId) + .in('status', ['pending', 'cancelled']); + + if (error) { + logger.error('Failed to fetch reminder schedules for calendar feed', error); + throw error; + } + + return (data || []) as ReminderScheduleRow[]; + } +} + +export const calendarService = new CalendarService(); diff --git a/backend/src/services/user-preference-service.ts b/backend/src/services/user-preference-service.ts index 39db470d..d5cff7fa 100644 --- a/backend/src/services/user-preference-service.ts +++ b/backend/src/services/user-preference-service.ts @@ -21,6 +21,8 @@ export class UserPreferenceService { quiet_hours_end: '08:00', quiet_hours_timezone: 'UTC', critical_alerts_only: true, + calendar_sync_enabled: false, + calendar_export_reminders: true, }; /** diff --git a/backend/src/types/reminder.ts b/backend/src/types/reminder.ts index 529c8ba8..492c186a 100644 --- a/backend/src/types/reminder.ts +++ b/backend/src/types/reminder.ts @@ -105,6 +105,8 @@ export interface UserPreferences { quiet_hours_end: string; // HH:MM format quiet_hours_timezone: string; // IANA timezone identifier critical_alerts_only: boolean; + calendar_sync_enabled: boolean; + calendar_export_reminders: boolean; updated_at: string; } diff --git a/backend/tests/calendar-api.test.ts b/backend/tests/calendar-api.test.ts new file mode 100644 index 00000000..c190717b --- /dev/null +++ b/backend/tests/calendar-api.test.ts @@ -0,0 +1,147 @@ +import request from 'supertest'; +import express from 'express'; +import calendarRouter from '../src/routes/calendar'; +import { calendarService, verifyCalendarToken } from '../src/services/calendar-service'; + +jest.mock('../src/config/logger', () => ({ + default: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, + __esModule: true, +})); + +jest.mock('../src/services/calendar-service', () => { + const actual = jest.requireActual('../src/services/calendar-service'); + return { + ...actual, + calendarService: { + generateFeed: jest.fn(), + getToken: jest.fn(), + getPreferences: jest.fn(), + updatePreferences: jest.fn(), + }, + verifyCalendarToken: jest.fn(), + }; +}); + +jest.mock('../src/middleware/auth', () => ({ + authenticate: (req: any, _res: any, next: any) => { + req.user = { id: '00000000-0000-0000-0000-000000000003', role: 'owner' }; + next(); + }, +})); + +const app = express(); +app.use(express.json()); +app.use('/api/calendar', calendarRouter); + +describe('Calendar API', () => { + const userId = '00000000-0000-0000-0000-000000000003'; + const token = 'valid-calendar-token-value-123456'; + + beforeEach(() => { + jest.clearAllMocks(); + process.env.CALENDAR_SECRET = 'test-calendar-secret-key'; + process.env.FRONTEND_URL = 'http://localhost:3000'; + }); + + describe('GET /api/calendar/feed/:userId/:token.ics', () => { + it('returns iCal content for a valid token', async () => { + (verifyCalendarToken as jest.Mock).mockReturnValue(true); + (calendarService.generateFeed as jest.Mock).mockResolvedValue('BEGIN:VCALENDAR\nEND:VCALENDAR'); + + const response = await request(app).get(`/api/calendar/feed/${userId}/${token}.ics`); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain('text/calendar'); + expect(response.text).toContain('BEGIN:VCALENDAR'); + expect(calendarService.generateFeed).toHaveBeenCalledWith(userId); + }); + + it('returns 403 for an invalid token', async () => { + (verifyCalendarToken as jest.Mock).mockReturnValue(false); + + const response = await request(app).get(`/api/calendar/feed/${userId}/bad-token.ics`); + + expect(response.status).toBe(403); + expect(response.text).toBe('Invalid calendar token'); + }); + + it('returns 403 when calendar sync is disabled', async () => { + (verifyCalendarToken as jest.Mock).mockReturnValue(true); + (calendarService.generateFeed as jest.Mock).mockRejectedValue( + new Error('Calendar sync is disabled for this user'), + ); + + const response = await request(app).get(`/api/calendar/feed/${userId}/${token}.ics`); + + expect(response.status).toBe(403); + expect(response.text).toBe('Calendar sync is disabled'); + }); + }); + + describe('GET /api/calendar/token', () => { + it('returns a feed token for authenticated users', async () => { + (calendarService.getToken as jest.Mock).mockResolvedValue({ + token: 'abc123', + userId, + feedUrl: `http://localhost:3000/api/calendar/feed/${userId}/abc123.ics`, + }); + + const response = await request(app).get('/api/calendar/token'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.token).toBe('abc123'); + expect(response.body.feedUrl).toContain('.ics'); + }); + }); + + describe('GET /api/calendar/preferences', () => { + it('returns calendar preferences and feed URL', async () => { + (calendarService.getPreferences as jest.Mock).mockResolvedValue({ + calendar_sync_enabled: true, + calendar_export_reminders: true, + }); + (calendarService.getToken as jest.Mock).mockResolvedValue({ + token: 'abc123', + userId, + feedUrl: `http://localhost:3000/api/calendar/feed/${userId}/abc123.ics`, + }); + + const response = await request(app).get('/api/calendar/preferences'); + + expect(response.status).toBe(200); + expect(response.body.data.calendar_sync_enabled).toBe(true); + expect(response.body.data.feedUrl).toContain('.ics'); + }); + }); + + describe('PATCH /api/calendar/preferences', () => { + it('updates calendar preferences', async () => { + (calendarService.updatePreferences as jest.Mock).mockResolvedValue({ + calendar_sync_enabled: true, + calendar_export_reminders: false, + }); + (calendarService.getToken as jest.Mock).mockResolvedValue({ + token: 'abc123', + userId, + feedUrl: `http://localhost:3000/api/calendar/feed/${userId}/abc123.ics`, + }); + + const response = await request(app) + .patch('/api/calendar/preferences') + .send({ calendar_sync_enabled: true, calendar_export_reminders: false }); + + expect(response.status).toBe(200); + expect(response.body.data.calendar_export_reminders).toBe(false); + expect(calendarService.updatePreferences).toHaveBeenCalledWith(userId, { + calendar_sync_enabled: true, + calendar_export_reminders: false, + }); + }); + }); +}); diff --git a/backend/tests/calendar-service.test.ts b/backend/tests/calendar-service.test.ts new file mode 100644 index 00000000..e86b7c66 --- /dev/null +++ b/backend/tests/calendar-service.test.ts @@ -0,0 +1,280 @@ +import { + buildCalendarFeed, + buildCancelledRenewalEvent, + buildRenewalEvent, + buildReminderEvent, + generateCalendarToken, + verifyCalendarToken, + getSubscriptionEventUid, + calendarService, +} from '../src/services/calendar-service'; +import { supabase } from '../src/config/database'; +import { userPreferenceService } from '../src/services/user-preference-service'; +import type { Subscription } from '../src/types/reminder'; + +jest.mock('../src/config/logger', () => ({ + default: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, + __esModule: true, +})); + +jest.mock('../src/config/database', () => ({ + supabase: { + from: jest.fn(), + }, +})); + +jest.mock('../src/services/user-preference-service', () => ({ + userPreferenceService: { + getPreferences: jest.fn(), + updatePreferences: jest.fn(), + }, +})); + +describe('CalendarService helpers', () => { + const userId = '00000000-0000-0000-0000-000000000001'; + + beforeEach(() => { + process.env.CALENDAR_SECRET = 'test-calendar-secret-key'; + jest.clearAllMocks(); + }); + + it('generates deterministic tokens for the same user', () => { + const tokenA = generateCalendarToken(userId); + const tokenB = generateCalendarToken(userId); + expect(tokenA).toBe(tokenB); + expect(tokenA).toHaveLength(32); + }); + + it('verifies valid tokens and rejects invalid tokens', () => { + const token = generateCalendarToken(userId); + expect(verifyCalendarToken(userId, token)).toBe(true); + expect(verifyCalendarToken(userId, 'invalid-token-value-here-123456')).toBe(false); + }); + + it('creates renewal events for active subscriptions', () => { + const subscription: Subscription = { + id: 'sub-1', + user_id: userId, + email_account_id: null, + merchant_id: null, + name: 'Netflix', + provider: 'Netflix', + category: 'entertainment', + price: 15.99, + billing_cycle: 'monthly', + status: 'active', + next_billing_date: '2026-06-01T00:00:00.000Z', + logo_url: null, + website_url: null, + renewal_url: 'https://netflix.com/account', + notes: null, + tags: [], + expired_at: null, + active_until: null, + is_trial: false, + trial_ends_at: null, + trial_converts_to_price: null, + credit_card_required: false, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + }; + + const event = buildRenewalEvent(subscription); + expect(event.id).toBe(getSubscriptionEventUid('sub-1')); + expect(event.summary).toContain('Netflix'); + expect(event.status).toBe('CONFIRMED'); + }); + + it('creates cancelled events with stable UIDs for subscription updates', () => { + const subscription: Subscription = { + id: 'sub-2', + user_id: userId, + email_account_id: null, + merchant_id: null, + name: 'Spotify', + provider: 'Spotify', + category: 'entertainment', + price: 9.99, + billing_cycle: 'monthly', + status: 'cancelled', + next_billing_date: '2026-06-15T00:00:00.000Z', + logo_url: null, + website_url: null, + renewal_url: null, + notes: null, + tags: [], + expired_at: null, + active_until: null, + is_trial: false, + trial_ends_at: null, + trial_converts_to_price: null, + credit_card_required: false, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-05-01T00:00:00.000Z', + }; + + const event = buildCancelledRenewalEvent(subscription); + expect(event.id).toBe(getSubscriptionEventUid('sub-2')); + expect(event.status).toBe('CANCELLED'); + }); + + it('builds an iCal feed with active, cancelled, and reminder events', () => { + const activeSub = { + id: 'sub-active', + name: 'Adobe', + price: 54.99, + billing_cycle: 'monthly', + next_billing_date: '2026-07-01T00:00:00.000Z', + renewal_url: null, + } as Subscription; + + const cancelledSub = { + id: 'sub-cancelled', + name: 'Dropbox', + next_billing_date: '2026-07-10T00:00:00.000Z', + updated_at: '2026-05-20T00:00:00.000Z', + } as Subscription; + + const feed = buildCalendarFeed( + [activeSub], + [cancelledSub], + [{ + id: 'rem-1', + subscription_id: 'sub-active', + reminder_date: '2026-06-24T00:00:00.000Z', + reminder_type: 'renewal', + days_before: 7, + status: 'pending', + subscriptions: { name: 'Adobe', price: 54.99, billing_cycle: 'monthly', status: 'active' }, + }], + ).toString(); + + expect(feed).toContain('BEGIN:VCALENDAR'); + expect(feed).toContain('Subscription Renewal: Adobe'); + expect(feed).toContain('Subscription Renewal: Dropbox'); + expect(feed).toContain('Renewal Reminder: Adobe'); + expect(feed).toContain('STATUS:CANCELLED'); + expect(feed).toContain(getSubscriptionEventUid('sub-active')); + expect(feed).toContain(getSubscriptionEventUid('sub-cancelled')); + }); + + it('marks cancelled reminder schedules as cancelled events', () => { + const event = buildReminderEvent({ + id: 'rem-cancelled', + subscription_id: 'sub-1', + reminder_date: '2026-06-01T00:00:00.000Z', + reminder_type: 'renewal', + days_before: 3, + status: 'cancelled', + subscriptions: { name: 'Hulu', price: 7.99, billing_cycle: 'monthly', status: 'cancelled' }, + }); + + expect(event.status).toBe('CANCELLED'); + }); +}); + +describe('CalendarService', () => { + const userId = '00000000-0000-0000-0000-000000000002'; + + beforeEach(() => { + process.env.CALENDAR_SECRET = 'test-calendar-secret-key'; + jest.clearAllMocks(); + }); + + it('returns calendar preferences with defaults', async () => { + (userPreferenceService.getPreferences as jest.Mock).mockResolvedValue({ + user_id: userId, + calendar_sync_enabled: false, + calendar_export_reminders: true, + }); + + const preferences = await calendarService.getPreferences(userId); + expect(preferences.calendar_sync_enabled).toBe(false); + expect(preferences.calendar_export_reminders).toBe(true); + }); + + it('generates feed URLs using the configured base URL', async () => { + const tokenResponse = await calendarService.getToken(userId, 'https://app.syncro.test'); + expect(tokenResponse.feedUrl).toContain('https://app.syncro.test/api/calendar/feed/'); + expect(tokenResponse.feedUrl).toContain(`${userId}/`); + expect(tokenResponse.feedUrl).toContain('.ics'); + }); + + it('throws when calendar sync is disabled', async () => { + (userPreferenceService.getPreferences as jest.Mock).mockResolvedValue({ + user_id: userId, + calendar_sync_enabled: false, + calendar_export_reminders: true, + }); + + await expect(calendarService.generateFeed(userId)).rejects.toThrow( + 'Calendar sync is disabled for this user', + ); + }); + + it('generates a feed with active and cancelled subscription events', async () => { + (userPreferenceService.getPreferences as jest.Mock).mockResolvedValue({ + user_id: userId, + calendar_sync_enabled: true, + calendar_export_reminders: false, + }); + + const mockFrom = jest.fn((table: string) => { + if (table === 'subscriptions') { + const chain: any = { + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + gte: jest.fn().mockReturnThis(), + then: undefined, + }; + + chain.eq = jest.fn().mockImplementation((_field: string, value: string) => { + if (value === 'active') { + return Promise.resolve({ + data: [{ + id: 'sub-active', + name: 'Notion', + price: 10, + billing_cycle: 'monthly', + next_billing_date: '2026-08-01T00:00:00.000Z', + status: 'active', + }], + error: null, + }); + } + return chain; + }); + + chain.gte = jest.fn().mockResolvedValue({ + data: [{ + id: 'sub-cancelled', + name: 'Canva', + next_billing_date: '2026-08-05T00:00:00.000Z', + updated_at: '2026-05-25T00:00:00.000Z', + status: 'cancelled', + }], + error: null, + }); + + return chain; + } + return { + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + in: jest.fn().mockResolvedValue({ data: [], error: null }), + }; + }); + + (supabase.from as jest.Mock).mockImplementation(mockFrom); + + const feed = await calendarService.generateFeed(userId); + expect(feed).toContain('Subscription Renewal: Notion'); + expect(feed).toContain('Subscription Renewal: Canva'); + expect(feed).toContain('STATUS:CANCELLED'); + }); +}); diff --git a/client/BACKEND_DOCUMENTATION.md b/client/BACKEND_DOCUMENTATION.md index 67a9730d..31581933 100644 --- a/client/BACKEND_DOCUMENTATION.md +++ b/client/BACKEND_DOCUMENTATION.md @@ -380,9 +380,11 @@ MICROSOFT_REDIRECT_URI=https://yourdomain.com/api/integrations/outlook/auth ### Priority 3: Nice to Have (Enhancement) #### 3.1 Calendar Integration -**Task**: Sync renewals to Google Calendar +**Status**: ✅ Implemented (iCal feed export) -**See**: `INTEGRATIONS.md` for implementation guide +**Task**: Export renewals and reminder schedules to calendar apps via iCal feed + +**See**: `../backend/CALENDAR_INTEGRATION_GUIDE.md` for route, service, and UI behavior #### 3.2 Slack Notifications **Task**: Send team notifications to Slack @@ -683,8 +685,11 @@ POST /api/integrations/stripe/checkout # Create checkout POST /api/integrations/stripe/webhook # Webhook handler GET /api/integrations/stripe/portal # Customer portal -# Calendar -POST /api/integrations/calendar/sync # Sync to calendar +# Calendar (iCal feed) +GET /api/calendar/feed/:userId/:token.ics # Public iCal feed (token-gated) +GET /api/calendar/token # Authenticated feed token +GET /api/calendar/preferences # Calendar sync preferences +PATCH /api/calendar/preferences # Update calendar sync preferences # Slack POST /api/integrations/slack/notify # Send notification @@ -1103,10 +1108,12 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... STRIPE_WEBHOOK_SECRET=whsec_... \`\`\` -### 4. Google Calendar (Renewal Sync) -**Status**: ❌ Not implemented -**Priority**: Low -**See**: `INTEGRATIONS.md` Section 6 +### 4. Calendar Sync (iCal Feed Export) +**Status**: ✅ Implemented +**Priority**: P1 +**See**: `../backend/CALENDAR_INTEGRATION_GUIDE.md` + +Provides a token-gated iCal feed for subscription renewals and reminder schedules. Google Calendar OAuth push sync remains a future enhancement in `INTEGRATIONS.md` Section 6. ### 5. Slack (Team Notifications) **Status**: ❌ Not implemented diff --git a/client/README.md b/client/README.md index 31505ef8..c9fc02e8 100644 --- a/client/README.md +++ b/client/README.md @@ -132,7 +132,7 @@ Open [http://localhost:3000](http://localhost:3000) ### Integrations - Gmail email scanning (UI ready, integration pending) - Outlook email scanning (UI ready, integration pending) -- Calendar sync (planned) +- Calendar sync (iCal feed export — see `backend/CALENDAR_INTEGRATION_GUIDE.md`) - Slack notifications (planned) ### Team Management diff --git a/client/app/api/calendar/feed/[userId]/[token]/route.ts b/client/app/api/calendar/feed/[userId]/[token]/route.ts new file mode 100644 index 00000000..25a7598f --- /dev/null +++ b/client/app/api/calendar/feed/[userId]/[token]/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server' + +const API_BASE = process.env.NEXT_PUBLIC_API_BASE || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001' + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ userId: string; token: string }> }, +) { + const { userId, token } = await params + const normalizedToken = token.endsWith('.ics') ? token.slice(0, -4) : token + const backendUrl = `${API_BASE}/api/calendar/feed/${userId}/${normalizedToken}.ics` + + try { + const response = await fetch(backendUrl, { + headers: { Accept: 'text/calendar' }, + cache: 'no-store', + }) + + const body = await response.text() + + return new NextResponse(body, { + status: response.status, + headers: { + 'Content-Type': 'text/calendar; charset=utf-8', + 'Content-Disposition': 'attachment; filename="subscriptions.ics"', + 'Cache-Control': 'private, max-age=300', + }, + }) + } catch { + return new NextResponse('Failed to fetch calendar feed', { status: 502 }) + } +} diff --git a/client/app/settings/notifications/page.tsx b/client/app/settings/notifications/page.tsx index 5b6d034f..a80c8305 100644 --- a/client/app/settings/notifications/page.tsx +++ b/client/app/settings/notifications/page.tsx @@ -1,6 +1,7 @@ import { redirect } from "next/navigation" import { createClient } from "@/lib/supabase/server" import QuietHoursSettings from "@/components/settings/QuietHoursSettings" +import CalendarSettings from "@/components/settings/CalendarSettings" import ReminderSettings from "@/components/settings/ReminderSettings" import Link from "next/link" @@ -34,7 +35,7 @@ export default async function NotificationSettingsPage() {

Notification Settings

- Manage your notification preferences and quiet hours to control when you receive alerts. + Manage your notification preferences, calendar sync, and quiet hours to control when you receive alerts.

@@ -65,6 +66,11 @@ export default async function NotificationSettingsPage() { + {/* Calendar Sync */} +
+ +
+ {/* Quiet Hours Settings */} diff --git a/client/components/pages/subscriptions.tsx b/client/components/pages/subscriptions.tsx index 03fb9691..dea53e5e 100644 --- a/client/components/pages/subscriptions.tsx +++ b/client/components/pages/subscriptions.tsx @@ -15,6 +15,7 @@ import { fetchAllCancellationGuides, type CancellationGuide } from "@/lib/supaba import { StatusBadge, normalizeStatus } from "@/components/ui/status-badge" import { AdvancedFilterBar, type FilterState, EMPTY_FILTERS, hasActiveFilters } from "@/components/ui/advanced-filter-bar" import { KeyboardHelpModal } from "@/components/modals/keyboard-help-modal" +import { fetchCalendarToken as getCalendarToken, downloadCalendarExport, getCalendarFeedUrl, updateCalendarPreferences } from "@/lib/api/calendar" interface SubscriptionsPageProps { subscriptions?: any[] @@ -119,8 +120,9 @@ export default function SubscriptionsPage({ }, []) const [calendarToken, setCalendarToken] = useState(null) - const [calendarUserId, setCalendarUserId] = useState(null) + const [calendarFeedUrl, setCalendarFeedUrl] = useState(null) const [showCalendarModal, setShowCalendarModal] = useState(false) + const [exportingCalendar, setExportingCalendar] = useState(false) const [copied, setCopied] = useState(false) const emailAccountsList = ["all", ...new Set((subscriptions || []).map((s: any) => s.email).filter(Boolean))] @@ -137,18 +139,29 @@ export default function SubscriptionsPage({ const fetchCalendarToken = async () => { try { - const response = await fetch("/api/calendar/token") - const data = await response.json() - if (data.success) { - setCalendarToken(data.token) - setCalendarUserId(data.userId) - setShowCalendarModal(true) - } + await updateCalendarPreferences({ calendar_sync_enabled: true }) + const data = await getCalendarToken() + setCalendarToken(data.token) + setCalendarFeedUrl(data.feedUrl || getCalendarFeedUrl(data.userId, data.token)) + setShowCalendarModal(true) } catch (error) { console.error("Failed to fetch calendar token", error) } } + const handleExportCalendar = async () => { + setExportingCalendar(true) + try { + await updateCalendarPreferences({ calendar_sync_enabled: true }) + await downloadCalendarExport() + setShowExportMenu(false) + } catch (error) { + console.error("Failed to export calendar reminders", error) + } finally { + setExportingCalendar(false) + } + } + const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text) setCopied(true) @@ -360,6 +373,22 @@ export default function SubscriptionsPage({
+

+ Calendar +

+ + +
+

PDF

@@ -645,7 +674,7 @@ export default function SubscriptionsPage({ darkMode={darkMode} /> )} - {showCalendarModal && calendarToken && ( + {showCalendarModal && calendarToken && calendarFeedUrl && (
+
+ + + Add this URL as a subscribed calendar in your calendar app. Renewals update automatically when + subscriptions change. + + +
+ )} + +
+ + +
+ + + ) +} diff --git a/client/lib/api/calendar.ts b/client/lib/api/calendar.ts new file mode 100644 index 00000000..4c3d73b0 --- /dev/null +++ b/client/lib/api/calendar.ts @@ -0,0 +1,64 @@ +import { apiGet, apiPatch } from '@/lib/api' + +export interface CalendarPreferences { + calendar_sync_enabled: boolean + calendar_export_reminders: boolean + feedUrl: string +} + +export interface CalendarPreferencesUpdate { + calendar_sync_enabled?: boolean + calendar_export_reminders?: boolean +} + +export interface CalendarTokenResponse { + token: string + userId: string + feedUrl: string +} + +export async function fetchCalendarPreferences(): Promise { + const res = await apiGet('/api/calendar/preferences') + return res.data as CalendarPreferences +} + +export async function updateCalendarPreferences( + input: CalendarPreferencesUpdate, +): Promise { + const res = await apiPatch('/api/calendar/preferences', input) + return res.data as CalendarPreferences +} + +export async function fetchCalendarToken(): Promise { + const res = await apiGet('/api/calendar/token') + return { + token: res.token, + userId: res.userId, + feedUrl: res.feedUrl, + } +} + +export function getCalendarFeedUrl(userId: string, token: string): string { + if (typeof window === 'undefined') { + return `/api/calendar/feed/${userId}/${token}.ics` + } + return `${window.location.protocol}//${window.location.host}/api/calendar/feed/${userId}/${token}.ics` +} + +export async function downloadCalendarExport(): Promise { + const tokenResponse = await fetchCalendarToken() + const feedUrl = getCalendarFeedUrl(tokenResponse.userId, tokenResponse.token) + const response = await fetch(feedUrl) + if (!response.ok) { + throw new Error('Failed to download calendar export') + } + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = 'syncro-reminders.ics' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} diff --git a/supabase/migrations/20260527000000_add_calendar_preferences.sql b/supabase/migrations/20260527000000_add_calendar_preferences.sql new file mode 100644 index 00000000..dc9e15e1 --- /dev/null +++ b/supabase/migrations/20260527000000_add_calendar_preferences.sql @@ -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';