This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Orbitant Calendar Sync is a multi-tenant calendar aggregation service that synchronizes events from multiple sources (Google Calendar, Microsoft Outlook, iCal feeds) and provides:
- Unified iCal feeds per user
- Slack bot integration (
/ajustes,/calendario) - OAuth 2.0 authentication per Slack user
- Automatic periodic synchronization via cron
Key Architecture Concept: Each Slack user authenticates independently with calendar providers. The system stores encrypted OAuth tokens per user and aggregates their calendar sources into a unified feed.
# Start with auto-reload (Node 22 watch mode)
npm run dev
# Start in production
npm start
# Linting
npm run lint
npm run lint:fix
# Run legacy token generation script (deprecated — tokens managed via Slack OAuth flow)
npm run auth# Build and run with Docker Compose
docker-compose up -d
# View logs
docker-compose logs -f
# Stop
docker-compose downThe SQLite database is automatically initialized on startup from src/database/schema.sql. Migrations run automatically in src/config/database.js.
Express Server (src/index.js)
- OAuth callbacks (
/auth/google/callback,/auth/azure/callback) - iCal feed endpoint (
/feed/:token/orbitando.ics) - Health check (
/health) - Graceful shutdown handlers (SIGINT/SIGTERM)
Slack Bot (Socket Mode)
- Commands:
/ajustes(settings),/calendario(view events) - Actions: Source management, OAuth flow initiation
- Runs on Socket Mode (no HTTP webhooks needed)
SyncService (src/services/SyncService.js)
- Singleton coordinating all synchronization
- Methods:
syncAll(),syncSource(id),syncUserSources(slackUserId) - Handles incremental sync with sync tokens and ETags
SyncScheduler (src/jobs/SyncScheduler.js)
- Cron-based automatic sync (default: every 15 minutes)
- Configurable via
SYNC_CRONenvironment variable - Skips during maintenance mode
CalendarAggregator (src/services/CalendarAggregator.js)
- Factory for provider instances
- Caches providers per source (keyed by
${type}-${id}) - Delegates to provider-specific implementations
GoogleCalendarService (src/services/google-calendar.js)
- Google Calendar API wrapper with OAuth token management
- Initializes OAuth2 client from stored tokens per Slack user (
initOAuthFromDB()) - Automatically refreshes expired access tokens and persists new tokens to DB
MicrosoftCalendarService (src/services/microsoft-calendar.js)
- Microsoft Graph API wrapper with MSAL token management
- Initializes Graph client from stored tokens per Slack user (
initOAuthFromDB()) - Delegates token refresh to MSAL via
refreshMicrosoftTokens()
ICalGenerator (src/services/ICalGenerator.js)
- Generates iCalendar (
.ics) output from stored events - Uses ical.js to build VCALENDAR with VEVENT components
- Supports per-user feed generation via
generateForUser(slackUserId)
All calendar sources implement BaseProvider (src/providers/BaseProvider.js):
initialize(): Authenticate and connect to calendar APIfetchEvents(options): Full fetch (returns all events)sync(syncState): Incremental sync (returns{ events, deleted, newSyncState })normalizeEvent(rawEvent): Convert to unified formatsupportsIncrementalSync(): Whether provider can do incremental updates
Provider Implementations:
GoogleCalendarProvider- Uses Google Calendar API v3, supports sync tokensMicrosoftCalendarProvider- Uses Microsoft Graph API, supports delta queriesICalRemoteProvider- Fetches remote.icsURLs, uses ETags for cachingICalLocalProvider- Reads local.icsfiles, uses mtime for change detection
Important: Google and Microsoft providers require OAuth tokens from the oauth_tokens table. These are automatically created during the OAuth callback flow and associated with the Slack user.
All models use better-sqlite3 directly (no ORM).
-
Source (
src/models/Source.js) - Calendar sources (Google, Microsoft, iCal)- User-scoped queries:
findBySlackUserId(),createForUser(),deleteForUser() - Config stored as JSON string in database
- User-scoped queries:
-
Event (
src/models/Event.js) - Calendar events from all sources- Unified schema with
source_idforeign key bulkUpsert()for efficient batch updates- Unique constraint on
(source_id, external_id)
- Unified schema with
-
OAuthToken (
src/models/OAuthToken.js) - Encrypted OAuth tokens per user- Provider field:
'google'or'microsoft' - Tokens encrypted using
src/utils/crypto.js(AES-256-GCM) - Includes refresh tokens and expiry tracking
- Provider field:
-
SyncState (
src/models/SyncState.js) - Sync status per source- Stores sync tokens, ETags, error states
- Used for incremental sync
-
FeedToken (
src/models/FeedToken.js) - Unique tokens for iCal feed URLs- One per Slack user
- Used in
/feed/:token/orbitando.icsendpoint
Google Calendar:
- User clicks "Connect Google" in Slack
- OAuth state includes
{ slackUserId, slackTeamId, responseUrl } - User redirects to Google OAuth consent screen
- Google redirects to
/auth/google/callback - Server exchanges code for tokens, stores in
oauth_tokens - Auto-creates Google Calendar source if not exists
- Triggers immediate sync for user
Microsoft Outlook:
- Same flow but uses
/auth/azure/callbackand Microsoft Graph
Key Functions:
src/slack/actions/oauth.js- Google OAuth helperssrc/slack/actions/microsoft-oauth.js- Microsoft OAuth helpers
All OAuth tokens are encrypted before storage using src/utils/crypto.js:
- Algorithm: AES-256-GCM (authenticated encryption)
- Key derivation: PBKDF2 with SHA-512, 100,000 iterations, unique 64-byte salt per encryption
- IV: 16 random bytes per encryption
- Storage format:
salt:iv:authTag:ciphertext(all hex-encoded, colon-separated) - Master key: 32-byte hex string from
TOKEN_ENCRYPTION_KEYenv var - Methods:
encrypt(plaintext, masterKey),decrypt(encryptedData, masterKey),generateEncryptionKey() - Generate key:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
/ajustes (src/slack/commands/ajustes.js)
- Shows connected accounts, calendar sources, and feed URL
- Buttons to connect Google/Microsoft, manage sources
- Uses Slack Block Kit interactive messages
/calendario (src/slack/commands/calendario.js)
- Displays today's and tomorrow's events
- Fetches from local database (not real-time API calls)
- Supports timezone per user (stored in
oauth_tokens.timezone)
Source Management (src/slack/actions/sources.js)
- Handles source CRUD actions: add, edit, toggle enable/disable, delete
- Filters ICS sources (Google/Microsoft sources managed via OAuth separately)
- Validates user ownership before modifications
Source Modal (src/slack/modals/sourceModal.js)
- Slack modal for adding/editing iCal calendar sources
- Fields: name, type (ical_remote/ical_local), URL, color
- Predefined color palette for calendar visual identification
Calendar providers return diverse formats. Events are normalized to:
{
source_id: number,
external_id: string,
summary: string,
description: string | null,
location: string | null,
start_datetime: string, // ISO 8601
end_datetime: string | null,
all_day: 0 | 1,
status: string,
recurrence: string | null, // JSON
raw_data: object // Original event
}This happens in each provider's normalizeEvent() method. Shared date normalization utilities are in src/utils/eventNormalizer.js.
- Users can set their timezone via Slack modals
- Stored in
oauth_tokens.timezone(defaults to 'UTC') - Used when displaying events in Slack and generating iCal feeds
- Timezone utilities in
src/utils/timezone.js
See .env.example for full list. Critical variables:
TOKEN_ENCRYPTION_KEY(required) - 64-char hex for encrypting OAuth tokensGOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GOOGLE_REDIRECT_URIGOOGLE_SCOPES- Google OAuth scopes (calendar.readonly, userinfo.email)GOOGLE_CALENDAR_ID- Calendar to sync (default:primary)AZURE_CLIENT_ID,AZURE_CLIENT_SECRET,AZURE_TENANT_ID,AZURE_REDIRECT_URIAZURE_SCOPES- Microsoft Graph scopes (Calendars.Read, User.Read, offline_access)SLACK_APP_TOKEN(xapp-...),SLACK_BOT_TOKEN(xoxb-...)SLACK_ADMINS- Comma-separated user IDs with admin privilegesSLACK_CHANNEL_ID- Channel ID for notifications (optional)SYNC_CRON- Cron expression (default:0 */15 * * * *)SYNC_ON_STARTUP- Whether to sync on server start (default:true)MAINTENANCE_MODE- Set totrueto pause sync and gate Slack commandsBASE_URL- Used to generate iCal feed URLsDATABASE_PATH- SQLite database location (default:./data/calendar.db)
To test synchronization without waiting for cron:
- Use
/ajustesin Slack, connect your accounts - Check logs for sync activity
- Or trigger via health endpoint:
curl http://localhost:3000/health - Or manually call
syncService.syncAll()in code
SQLite schema in src/database/schema.sql:
sources- Calendar sources (type: google | microsoft | ical_remote | ical_local)events- Unified events from all sourcessync_state- Sync status per source (tokens, errors, counts)oauth_tokens- Encrypted OAuth credentials per Slack user + providerfeed_tokens- Unique tokens for iCal feed URLs
Migrations handled in src/config/database.js runMigrations().
The system supports maintenance mode to pause syncing:
- Check:
isMaintenanceMode()insrc/utils/maintenance.js - When active, SyncScheduler skips sync jobs
- Slack commands can also be gated (see
src/slack/middleware/maintenanceMiddleware.js)
Many operations are scoped to Slack users:
- Each Slack user has independent OAuth tokens for Google/Microsoft
- Sources are linked to
slack_user_id - Events are queried via source → Slack user relationship
- iCal feeds are user-specific via unique tokens
When implementing features:
- Always validate user ownership of sources before operations
- Use
Source.findByIdAndUser(id, slackUserId)for user-scoped queries - Use
Event.findBySlackUserId(slackUserId)to get a user's events
- ES modules (
import/export,"type": "module"in package.json) - ESLint config in
eslint.config.js - Async/await throughout (no callbacks)
- Singleton pattern for services (SyncService, SyncScheduler)
- Factory pattern for providers (CalendarAggregator)
- Active Record-style models (static methods + instance data)
src/
├── index.js # Express server, routes, startup
├── config/
│ └── database.js # SQLite initialization and migrations
├── database/
│ └── schema.sql # Database schema
├── jobs/
│ └── SyncScheduler.js # Cron-based sync scheduling
├── models/
│ ├── Event.js # Calendar events
│ ├── FeedToken.js # iCal feed tokens per user
│ ├── OAuthToken.js # Encrypted OAuth tokens
│ ├── Source.js # Calendar sources
│ └── SyncState.js # Sync status per source
├── providers/
│ ├── BaseProvider.js # Abstract base class
│ ├── GoogleCalendarProvider.js # Google Calendar API v3
│ ├── MicrosoftCalendarProvider.js # Microsoft Graph API
│ ├── ICalRemoteProvider.js # Remote .ics URL fetching
│ └── ICalLocalProvider.js # Local .ics file reading
├── scripts/
│ └── generate-tokens.js # Legacy token script (deprecated)
├── services/
│ ├── CalendarAggregator.js # Provider factory and cache
│ ├── google-calendar.js # Google Calendar API service
│ ├── microsoft-calendar.js # Microsoft Graph API service
│ ├── ICalGenerator.js # iCal feed generation
│ └── SyncService.js # Sync orchestration (singleton)
├── slack/
│ ├── app.js # Slack Bolt app setup
│ ├── actions/
│ │ ├── oauth.js # Google OAuth flow helpers
│ │ ├── microsoft-oauth.js # Microsoft OAuth flow helpers
│ │ └── sources.js # Source management actions
│ ├── commands/
│ │ ├── ajustes.js # /ajustes command
│ │ └── calendario.js # /calendario command
│ ├── middleware/
│ │ └── maintenanceMiddleware.js # Maintenance mode gating
│ └── modals/
│ └── sourceModal.js # Add/edit iCal source modal
└── utils/
├── crypto.js # AES-256-GCM encryption with PBKDF2
├── eventNormalizer.js # Date/event normalization utilities
├── maintenance.js # Maintenance mode management
└── timezone.js # Timezone utilities
- Consistent
[Tag]prefix logging throughout (e.g.,[GoogleCalendar],[SyncService],[Maintenance]) - Try-catch blocks in all async operations with descriptive error messages
- Sync errors are recorded in
sync_statetable (last_sync_status,last_error) - OAuth token refresh failures are logged and propagated to the sync layer
- Uses Node 22 watch mode in development (
--watch) - Docker image based on
node:22(full Debian image, not Alpine) withlibsqlite3-dev - Requires persistent volume for SQLite database (
./data) - Docker Compose maps port
3030(host) →3000(container) - Slack Socket Mode means no inbound webhooks needed (firewall-friendly)
- OAuth callbacks require public URLs (use ngrok for local dev)
.nvmrcspecifies Node 22 for version management