From e4259cd87e2fff9652e100e056c4bacf15e72f92 Mon Sep 17 00:00:00 2001 From: Olivier Guntenaar Date: Wed, 1 Oct 2025 11:26:36 +0200 Subject: [PATCH 01/37] feat(api-v2): add GET /v2/slots/all-of-day to aggregate daily slots --- .../controllers/slots.controller.ts | 32 ++++++++ .../services/slots.service.spec.ts | 17 +++- .../services/slots.service.ts | 77 ++++++++++++++++++- docs/api-reference/v2/openapi.json | 61 +++++++++++++++ 4 files changed, 185 insertions(+), 2 deletions(-) diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/slots.controller.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/slots.controller.ts index ae160e1dc86..a7d3dc21718 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/slots.controller.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/slots.controller.ts @@ -32,6 +32,7 @@ import { import { plainToClass } from "class-transformer"; import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { SlotFormat } from "@calcom/platform-enums"; import { GetSlotsInput_2024_09_04, GetSlotsInputPipe, @@ -251,6 +252,37 @@ export class SlotsController_2024_09_04 { }; } + @Get("/all-of-day") + @ApiOperation({ + summary: "Get all available slots for all event types on a given day", + description: + "Aggregates available slots across all non-archived, non-hidden event types for the specified date.", + }) + @ApiQuery({ name: "date", required: true, example: "2050-09-05" }) + @ApiQuery({ + name: "timeZone", + required: false, + description: "Time zone for slot formatting. Defaults to UTC.", + example: "Europe/London", + }) + @ApiQuery({ + name: "format", + required: false, + description: "Format of slot times in response. Use 'range' to get start and end times.", + example: "range", + }) + async getAllSlotsForDay( + @Query("date") date: string, + @Query("timeZone") timeZone?: string, + @Query("format") format?: SlotFormat + ): Promise> { + const data = await this.slotsService.getAllSlotsForDay({ date, timeZone, format }); + return { + status: SUCCESS_STATUS, + data, + }; + } + @Post("/reservations") @UseGuards(OptionalApiAuthGuard) @ApiOperation({ diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.spec.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.spec.ts index 2ffc759c9b9..4d997183a0c 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.spec.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.spec.ts @@ -2,6 +2,7 @@ import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_20 import { AvailableSlotsService } from "@/lib/services/available-slots.service"; import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; import { MembershipsService } from "@/modules/memberships/services/memberships.service"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { SlotsInputService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots-input.service"; import { SlotsOutputService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots-output.service"; import { SlotsRepository_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.repository"; @@ -21,6 +22,16 @@ describe("SlotsService_2024_09_04", () => { const module: TestingModule = await Test.createTestingModule({ providers: [ SlotsService_2024_09_04, + { + provide: PrismaReadService, + useValue: { + prisma: { + eventType: { + findMany: jest.fn().mockResolvedValue([]), + }, + }, + }, + }, { provide: SlotsInputService_2024_09_04, useValue: { @@ -146,7 +157,11 @@ describe("SlotsService_2024_09_04", () => { ...inputQuery, }); - const { start: _1, end: _2, type: _3, ...queryWithoutStartEndAndType } = sharedTestData.baseInputQuery; + const queryWithoutStartEndAndType = Object.fromEntries( + Object.entries(sharedTestData.baseInputQuery).filter( + ([key]) => !["start", "end", "type"].includes(key) + ) + ); expect(availableSlotsService.getAvailableSlots).toHaveBeenCalledWith({ input: { diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts index 6e34882a19e..a1a514464b4 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts @@ -2,6 +2,7 @@ import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_20 import { AvailableSlotsService } from "@/lib/services/available-slots.service"; import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; import { MembershipsService } from "@/modules/memberships/services/memberships.service"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { TimeSlots } from "@/modules/slots/slots-2024-04-15/services/slots-output.service"; import { SlotsInputService_2024_09_04, @@ -49,7 +50,8 @@ export class SlotsService_2024_09_04 { private readonly membershipsService: MembershipsService, private readonly membershipsRepository: MembershipsRepository, private readonly teamsRepository: TeamsRepository, - private readonly availableSlotsService: AvailableSlotsService + private readonly availableSlotsService: AvailableSlotsService, + private readonly dbRead: PrismaReadService ) {} private async fetchAndFormatSlots(queryTransformed: InternalSlotsQuery, format?: SlotFormat) { @@ -90,6 +92,79 @@ export class SlotsService_2024_09_04 { return this.fetchAndFormatSlots(queryTransformed, query.format); } + async getAllSlotsForDay(input: { date: string; timeZone?: string; format?: SlotFormat }) { + const { date, timeZone, format } = input; + const start = DateTime.fromISO(date, { zone: "utc" }).startOf("day"); + const end = DateTime.fromISO(date, { zone: "utc" }).endOf("day"); + + if (!start.isValid || !end.isValid) { + throw new BadRequestException("Invalid date. Expected ISO 8601 like 2050-09-05"); + } + + const eventTypes = await this.dbRead.prisma.eventType.findMany({ + where: { + // Skip hidden event types to avoid noise + hidden: { equals: false }, + // Only individual event types (owned by a user, not a team) + userId: { not: null }, + teamId: null, + }, + select: { + id: true, + slug: true, + userId: true, + teamId: true, + }, + }); + + const startIso = start.toISO(); + const endIso = end.toISO(); + + const results = await Promise.all( + eventTypes.map(async (et) => { + const internalQuery: InternalGetSlotsQuery = { + isTeamEvent: !!et.teamId, + startTime: startIso!, + endTime: endIso!, + duration: undefined, + eventTypeId: et.id, + eventTypeSlug: et.slug, + usernameList: [], + timeZone, + orgSlug: null, + rescheduleUid: null, + }; + + try { + const formatted = await this.fetchAndFormatSlots(internalQuery, format); + // The formatter returns a map keyed by dates in the requested TZ. + // Keep only the requested date key if present. + const formattedMap = formatted as Record; + const onlyRequested = formattedMap && formattedMap[date] ? { [date]: formattedMap[date] } : {}; + + return { + eventTypeId: et.id, + eventTypeSlug: et.slug, + ownerUserId: et.userId ?? null, + ownerTeamId: et.teamId ?? null, + slotsByDate: onlyRequested, + }; + } catch { + // Swallow per-event type errors to not fail the whole aggregation + return { + eventTypeId: et.id, + eventTypeSlug: et.slug, + ownerUserId: et.userId ?? null, + ownerTeamId: et.teamId ?? null, + slotsByDate: {}, + }; + } + }) + ); + + return results; + } + async reserveSlot(input: ReserveSlotInput_2024_09_04, authUserId?: number) { if (input.reservationDuration && !authUserId) { throw new UnauthorizedException( diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index c9d4b6a5848..972536f3180 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -11867,6 +11867,67 @@ "tags": ["Slots"] } }, + "/v2/slots/all-of-day": { + "get": { + "operationId": "SlotsController_2024_09_04_getAllSlotsForDay", + "summary": "Get all available slots for all event types on a given day", + "description": "Aggregates available slots across all non-archived, non-hidden event types for the specified date.", + "parameters": [ + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to 2024-09-04", + "required": true, + "schema": { + "type": "string", + "default": "2024-09-04" + } + }, + { + "name": "date", + "required": true, + "in": "query", + "example": "2050-09-05", + "schema": { + "type": "string" + } + }, + { + "name": "timeZone", + "required": false, + "in": "query", + "description": "Time zone for slot formatting. Defaults to UTC.", + "example": "Europe/London", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "required": false, + "in": "query", + "description": "Format of slot times in response. Use 'range' to get start and end times.", + "example": "range", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": ["Slots"] + } + }, "/v2/slots/reservations": { "post": { "operationId": "SlotsController_2024_09_04_reserveSlot", From 79a5adfdd878fbfcd8bfdb2075578e95d346094c Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 7 Oct 2025 19:55:08 +0200 Subject: [PATCH 02/37] first test of docker compose for apiv2 --- .dockerignore | 58 ++++ .gitignore | 1 + apps/api/v2/docker-compose.local.yml | 104 +++++++ apps/api/v2/docker-compose.production.yml | 133 ++++++++ apps/api/v2/env.production.example | 31 ++ .../v2/nginx/conf.d/api-v2-cloudflare.conf | 60 ++++ .../conf.d/api-v2-http-only.conf.disabled | 43 +++ apps/api/v2/nginx/conf.d/api-v2.conf | 80 +++++ apps/api/v2/nginx/nginx.conf | 41 +++ apps/api/v2/setup.sh | 158 ++++++++++ package.json | 1 + scripts/create-oauth-client.js | 204 ++++++++++++ scripts/create-oauth-client.ts | 291 ++++++++++++++++++ 13 files changed, 1205 insertions(+) create mode 100644 .dockerignore create mode 100644 apps/api/v2/docker-compose.local.yml create mode 100644 apps/api/v2/docker-compose.production.yml create mode 100644 apps/api/v2/env.production.example create mode 100644 apps/api/v2/nginx/conf.d/api-v2-cloudflare.conf create mode 100644 apps/api/v2/nginx/conf.d/api-v2-http-only.conf.disabled create mode 100644 apps/api/v2/nginx/conf.d/api-v2.conf create mode 100644 apps/api/v2/nginx/nginx.conf create mode 100755 apps/api/v2/setup.sh create mode 100755 scripts/create-oauth-client.js create mode 100755 scripts/create-oauth-client.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000000..5dc24f4fc26 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,58 @@ +coverage +build +dist +node_modules +*.log +.git +.agents +.husky +.snaplet +.turbo + + +# Test results and artifacts +test-results/ +**/playwright/videos +**/playwright/screenshots +**/playwright/artifacts +**/playwright/results +**/playwright/reports + +# Checks and testing infrastructure +__checks__/ +tests/performance/ +playwright/ + +docs/ +*.md + +# IDE and editor files +.vscode/ +.idea/ +.history/ +*.code-workspace +.DS_Store +.claude + +# CI/CD and deployment configs +.github/ +.changeset/ +checkly.config.ts +.vercel/ +vercel.json +Procfile + +# Build artifacts +.next/ +out/ +*.tsbuildinfo +lint-results/ + +# Yarn cache and non-essential yarn files +.yarn/cache/ +.yarn/install-state.gz +.pnp.* + +# Example and development apps +example-apps/ +apps/ui-playground/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8574f3d252c..712959d3103 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,4 @@ packages/**/.yarn/ci-cache/ # AI .claude +.env\ prod \ No newline at end of file diff --git a/apps/api/v2/docker-compose.local.yml b/apps/api/v2/docker-compose.local.yml new file mode 100644 index 00000000000..87f6d4d617e --- /dev/null +++ b/apps/api/v2/docker-compose.local.yml @@ -0,0 +1,104 @@ +version: '3.8' + +# Local Development Docker Compose +# Simpler setup for testing on your Mac/local machine + +services: + # PostgreSQL Database + postgres: + image: postgres:15-alpine + container_name: calcom-api-v2-postgres-local + restart: unless-stopped + environment: + POSTGRES_USER: calcom + POSTGRES_PASSWORD: calcom_local_password + POSTGRES_DB: calcom + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" # Exposed for local debugging + healthcheck: + test: ["CMD-SHELL", "pg_isready -U calcom"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - calcom-network + + # Redis Cache & Queue + redis: + image: redis:7-alpine + container_name: calcom-api-v2-redis-local + restart: unless-stopped + command: redis-server --appendonly yes --requirepass redis_local_password + volumes: + - redis_data:/data + ports: + - "6379:6379" # Exposed for local debugging + healthcheck: + test: ["CMD", "redis-cli", "--raw", "incr", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - calcom-network + + # Cal.com API v2 + api-v2: + build: + context: ../../.. + dockerfile: apps/api/v2/Dockerfile + args: + DATABASE_URL: postgresql://calcom:calcom_local_password@postgres:5432/calcom + DATABASE_DIRECT_URL: postgresql://calcom:calcom_local_password@postgres:5432/calcom + container_name: calcom-api-v2-local + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + NODE_ENV: production + API_PORT: 5555 + + # Database + DATABASE_URL: postgresql://calcom:calcom_local_password@postgres:5432/calcom + DATABASE_DIRECT_URL: postgresql://calcom:calcom_local_password@postgres:5432/calcom + DATABASE_READ_URL: postgresql://calcom:calcom_local_password@postgres:5432/calcom + DATABASE_WRITE_URL: postgresql://calcom:calcom_local_password@postgres:5432/calcom + + # Redis + REDIS_URL: redis://:redis_local_password@redis:6379 + + # Auth + NEXTAUTH_SECRET: local_testing_secret_change_in_production_12345 + + # API Configuration + API_URL: http://localhost + API_KEY_PREFIX: cal_ + + # Web App URL + WEB_APP_URL: http://localhost:3000 + + ports: + - "5555:80" # Direct access to API + networks: + - calcom-network + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + +volumes: + postgres_data: + driver: local + redis_data: + driver: local + +networks: + calcom-network: + driver: bridge + diff --git a/apps/api/v2/docker-compose.production.yml b/apps/api/v2/docker-compose.production.yml new file mode 100644 index 00000000000..0bf53e9c303 --- /dev/null +++ b/apps/api/v2/docker-compose.production.yml @@ -0,0 +1,133 @@ +version: '3.8' + +services: + # PostgreSQL Database + postgres: + image: postgres:15-alpine + container_name: calcom-api-v2-postgres + restart: unless-stopped + environment: + POSTGRES_USER: calcom + POSTGRES_PASSWORD: ${DB_PASSWORD:-calcom_secure_password_change_me} + POSTGRES_DB: calcom + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "127.0.0.1:5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U calcom"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - calcom-network + + # Redis Cache & Queue + redis: + image: redis:7-alpine + container_name: calcom-api-v2-redis + restart: unless-stopped + command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-redis_secure_password_change_me} + volumes: + - redis_data:/data + ports: + - "127.0.0.1:6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "--raw", "incr", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - calcom-network + + # Cal.com API v2 + api-v2: + build: + context: ../../.. # Build from repo root to include monorepo + dockerfile: apps/api/v2/Dockerfile + args: + DATABASE_URL: postgresql://calcom:${DB_PASSWORD:-calcom_secure_password_change_me}@postgres:5432/calcom + DATABASE_DIRECT_URL: postgresql://calcom:${DB_PASSWORD:-calcom_secure_password_change_me}@postgres:5432/calcom + container_name: calcom-api-v2 + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + NODE_ENV: production + API_PORT: 5555 + + # Database + DATABASE_URL: postgresql://calcom:${DB_PASSWORD:-calcom_secure_password_change_me}@postgres:5432/calcom + DATABASE_DIRECT_URL: postgresql://calcom:${DB_PASSWORD:-calcom_secure_password_change_me}@postgres:5432/calcom + DATABASE_READ_URL: postgresql://calcom:${DB_PASSWORD:-calcom_secure_password_change_me}@postgres:5432/calcom + DATABASE_WRITE_URL: postgresql://calcom:${DB_PASSWORD:-calcom_secure_password_change_me}@postgres:5432/calcom + + # Redis + REDIS_URL: redis://:${REDIS_PASSWORD:-redis_secure_password_change_me}@redis:6379 + + # Auth + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-generate_a_secure_random_32_char_string} + + # API Configuration + API_URL: ${API_URL:-https://api.yourdomain.com} + API_KEY_PREFIX: ${API_KEY_PREFIX:-cal_} + + # License (optional for self-hosted) + CALCOM_LICENSE_KEY: ${CALCOM_LICENSE_KEY:-} + + # Web App URL + WEB_APP_URL: ${WEB_APP_URL:-https://app.cal.com} + + # Stripe (optional) + STRIPE_API_KEY: ${STRIPE_API_KEY:-} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + + # Sentry (optional) + NEXT_PUBLIC_SENTRY_DSN: ${SENTRY_DSN:-} + + ports: + - "127.0.0.1:5555:80" + networks: + - calcom-network + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # Nginx Reverse Proxy (Cloudflare handles SSL) + nginx: + image: nginx:alpine + container_name: calcom-api-v2-nginx + restart: unless-stopped + depends_on: + - api-v2 + ports: + - "80:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/conf.d:/etc/nginx/conf.d:ro + networks: + - calcom-network + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"] + interval: 30s + timeout: 10s + retries: 3 + + # Note: No Certbot needed - Cloudflare handles SSL/TLS termination + +volumes: + postgres_data: + driver: local + redis_data: + driver: local + +networks: + calcom-network: + driver: bridge + diff --git a/apps/api/v2/env.production.example b/apps/api/v2/env.production.example new file mode 100644 index 00000000000..4a5f0b7c71d --- /dev/null +++ b/apps/api/v2/env.production.example @@ -0,0 +1,31 @@ +# Database Configuration +DB_PASSWORD=your_secure_postgres_password_here + +# Redis Configuration +REDIS_PASSWORD=your_secure_redis_password_here + +# NextAuth Secret (generate with: openssl rand -base64 32) +NEXTAUTH_SECRET=your_32_character_secret_here + +# API Configuration +API_URL=https://api.yourdomain.com +API_KEY_PREFIX=cal_ + +# Web App URL (if you have the main Cal.com app) +WEB_APP_URL=https://app.yourdomain.com + +# Cal.com License Key (optional for self-hosted) +CALCOM_LICENSE_KEY= + +# Stripe (optional, only if using billing features) +STRIPE_API_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_TEAM_MONTHLY_PRICE_ID= + +# Sentry (optional, for error tracking) +SENTRY_DSN= + +# Domain for SSL certificate +DOMAIN=api.collegecontactcalendar.com +EMAIL=admin@collegecontactcalendar.com + diff --git a/apps/api/v2/nginx/conf.d/api-v2-cloudflare.conf b/apps/api/v2/nginx/conf.d/api-v2-cloudflare.conf new file mode 100644 index 00000000000..960b11f12cb --- /dev/null +++ b/apps/api/v2/nginx/conf.d/api-v2-cloudflare.conf @@ -0,0 +1,60 @@ +# Cloudflare SSL Configuration +# Cloudflare handles SSL/TLS termination, this server only serves HTTP + +server { + listen 80; + server_name _; + + # Security Headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # Note: HSTS handled by Cloudflare + # Note: SSL handled by Cloudflare + + # Rate limiting + limit_req zone=api_limit burst=20 nodelay; + + # Proxy to API v2 + location / { + proxy_pass http://api-v2:80; + proxy_http_version 1.1; + + # Proxy Headers - Important for Cloudflare + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + # Cloudflare Real IP (optional - if you want to see actual visitor IPs) + # Requires: set_real_ip_from directives in nginx.conf + + # WebSocket support (if needed) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Timeouts + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # Buffering + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + proxy_busy_buffers_size 8k; + } + + # Health check endpoint (no rate limit) + location /health { + limit_req off; + proxy_pass http://api-v2:80/health; + proxy_http_version 1.1; + proxy_set_header Host $host; + access_log off; + } +} + diff --git a/apps/api/v2/nginx/conf.d/api-v2-http-only.conf.disabled b/apps/api/v2/nginx/conf.d/api-v2-http-only.conf.disabled new file mode 100644 index 00000000000..83d764a7b74 --- /dev/null +++ b/apps/api/v2/nginx/conf.d/api-v2-http-only.conf.disabled @@ -0,0 +1,43 @@ +# HTTP Only Configuration (for testing before SSL setup) +# To use this: +# 1. Rename this file to remove .disabled extension +# 2. Disable/remove the api-v2.conf file +# 3. Restart nginx + +server { + listen 80; + server_name _; + + # Security Headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # Rate limiting + limit_req zone=api_limit burst=20 nodelay; + + # Proxy to API v2 + location / { + proxy_pass http://api-v2:80; + proxy_http_version 1.1; + + # Proxy Headers + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Timeouts + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # Health check endpoint + location /health { + limit_req off; + proxy_pass http://api-v2:80/health; + access_log off; + } +} + diff --git a/apps/api/v2/nginx/conf.d/api-v2.conf b/apps/api/v2/nginx/conf.d/api-v2.conf new file mode 100644 index 00000000000..0e82a213c4a --- /dev/null +++ b/apps/api/v2/nginx/conf.d/api-v2.conf @@ -0,0 +1,80 @@ +# HTTP - Redirect to HTTPS +server { + listen 80; + server_name _; + + # Let's Encrypt validation + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # Redirect all other traffic to HTTPS + location / { + return 301 https://$host$request_uri; + } +} + +# HTTPS - Main API v2 Server +server { + listen 443 ssl http2; + server_name _; + + # SSL Configuration (update paths after obtaining certificates) + ssl_certificate /etc/letsencrypt/live/api.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/api.yourdomain.com/privkey.pem; + + # SSL Security Settings + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384'; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # Security Headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + # Rate limiting + limit_req zone=api_limit burst=20 nodelay; + + # Proxy to API v2 + location / { + proxy_pass http://api-v2:80; + proxy_http_version 1.1; + + # Proxy Headers + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + # WebSocket support (if needed) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Timeouts + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # Buffering + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + proxy_busy_buffers_size 8k; + } + + # Health check endpoint (no rate limit) + location /health { + limit_req off; + proxy_pass http://api-v2:80/health; + proxy_http_version 1.1; + proxy_set_header Host $host; + access_log off; + } +} + diff --git a/apps/api/v2/nginx/nginx.conf b/apps/api/v2/nginx/nginx.conf new file mode 100644 index 00000000000..4a80f074e83 --- /dev/null +++ b/apps/api/v2/nginx/nginx.conf @@ -0,0 +1,41 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 20M; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml; + + # Rate limiting + limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; + limit_req_status 429; + + # Include additional configurations + include /etc/nginx/conf.d/*.conf; +} + diff --git a/apps/api/v2/setup.sh b/apps/api/v2/setup.sh new file mode 100755 index 00000000000..34817d56119 --- /dev/null +++ b/apps/api/v2/setup.sh @@ -0,0 +1,158 @@ +#!/bin/bash +# Cal.com API v2 - Quick Setup Script for EC2 + +set -e + +echo "๐Ÿš€ Cal.com API v2 - EC2 Setup Script" +echo "======================================" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Check if running as root +if [[ $EUID -eq 0 ]]; then + echo -e "${RED}โŒ This script should NOT be run as root${NC}" + exit 1 +fi + +# Function to check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Step 1: Install Docker if not installed +echo -e "\n${YELLOW}๐Ÿ“ฆ Checking Docker installation...${NC}" +if ! command_exists docker; then + echo "Installing Docker..." + curl -fsSL https://get.docker.com -o get-docker.sh + sudo sh get-docker.sh + sudo usermod -aG docker $USER + rm get-docker.sh + echo -e "${GREEN}โœ… Docker installed${NC}" +else + echo -e "${GREEN}โœ… Docker already installed${NC}" +fi + +# Step 2: Install Docker Compose if not installed +echo -e "\n${YELLOW}๐Ÿ“ฆ Checking Docker Compose installation...${NC}" +if ! docker compose version >/dev/null 2>&1; then + echo "Installing Docker Compose plugin..." + sudo dnf install -y docker-compose-plugin + echo -e "${GREEN}โœ… Docker Compose installed${NC}" +else + echo -e "${GREEN}โœ… Docker Compose already installed${NC}" +fi + +# Step 3: Generate environment file if it doesn't exist +echo -e "\n${YELLOW}๐Ÿ”ง Configuring environment...${NC}" +if [ ! -f .env.production ]; then + echo "Creating .env.production from example..." + cp env.production.example .env.production + + # Generate secure passwords + DB_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-32) + REDIS_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-32) + NEXTAUTH_SECRET=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-32) + + # Replace in file (cross-platform compatible) + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS + sed -i '' "s/your_secure_postgres_password_here/$DB_PASSWORD/" .env.production + sed -i '' "s/your_secure_redis_password_here/$REDIS_PASSWORD/" .env.production + sed -i '' "s/your_32_character_secret_here/$NEXTAUTH_SECRET/" .env.production + else + # Linux + sed -i "s/your_secure_postgres_password_here/$DB_PASSWORD/" .env.production + sed -i "s/your_secure_redis_password_here/$REDIS_PASSWORD/" .env.production + sed -i "s/your_32_character_secret_here/$NEXTAUTH_SECRET/" .env.production + fi + + echo -e "${GREEN}โœ… Generated secure passwords${NC}" + echo -e "${YELLOW}โš ๏ธ Please edit .env.production and update:${NC}" + echo " - API_URL (your domain)" + echo " - DOMAIN (for SSL certificate)" + echo " - EMAIL (for SSL certificate)" + + read -p "Press enter to open .env.production in nano editor..." + nano .env.production +else + echo -e "${GREEN}โœ… .env.production already exists${NC}" +fi + +# Step 4: Setup Cloudflare-friendly configuration +echo -e "\n${YELLOW}๐ŸŒ Setting up Cloudflare SSL configuration...${NC}" +echo "Cloudflare will handle SSL/TLS termination" +echo "This server will listen on HTTP (port 80) only" + +# Enable Cloudflare configuration +mv nginx/conf.d/api-v2.conf nginx/conf.d/api-v2.conf.disabled 2>/dev/null || true +mv nginx/conf.d/api-v2-http-only.conf nginx/conf.d/api-v2-http-only.conf.disabled 2>/dev/null || true +mv nginx/conf.d/api-v2-cloudflare.conf.disabled nginx/conf.d/api-v2-cloudflare.conf 2>/dev/null || true + +echo -e "${GREEN}โœ… Cloudflare configuration active${NC}" +echo "" +echo -e "${YELLOW}๐Ÿ“ Cloudflare Setup Reminder:${NC}" +echo "1. In Cloudflare Dashboard โ†’ SSL/TLS:" +echo " - Set mode to 'Flexible' (Cloudflare to origin via HTTP)" +echo " - Or 'Full' if you add a self-signed cert later" +echo "2. Add your domain DNS record:" +echo " - Type: A" +echo " - Name: api" +echo " - Content: Your EC2 Elastic IP" +echo " - Proxy status: Proxied (orange cloud)" +echo "" + +# Step 5: Build and start services +echo -e "\n${YELLOW}๐Ÿ—๏ธ Building and starting services...${NC}" +echo "This may take several minutes..." + +docker compose -f docker-compose.production.yml up -d --build + +# Step 6: Wait for services to be healthy +echo -e "\n${YELLOW}โณ Waiting for services to be ready...${NC}" +sleep 10 + +# Check service status +echo -e "\n${YELLOW}๐Ÿ“Š Service Status:${NC}" +docker compose -f docker-compose.production.yml ps + +# Step 7: Test the deployment +echo -e "\n${YELLOW}๐Ÿงช Testing deployment...${NC}" + +# Get the IP address +IP=$(curl -s ifconfig.me) +DOMAIN=$(grep "^DOMAIN=" .env.production | cut -d'=' -f2) + +echo "Testing health endpoint via IP: http://$IP/health" +sleep 5 + +if curl -f -s "http://$IP/health" >/dev/null; then + echo -e "${GREEN}โœ… API v2 is running!${NC}" + echo -e "\n${GREEN}๐ŸŽ‰ Deployment successful!${NC}" + echo -e "\nDirect Access (for testing):" + echo " Health: http://$IP/health" + echo " API: http://$IP/api/v2" + echo " Docs: http://$IP/docs" + echo -e "\nCloudflare Access (after DNS configured):" + echo " Health: https://$DOMAIN/health" + echo " API: https://$DOMAIN/api/v2" + echo " Docs: https://$DOMAIN/docs" +else + echo -e "${YELLOW}โš ๏ธ Health check failed. Checking logs...${NC}" + docker compose -f docker-compose.production.yml logs --tail=50 api-v2 + echo -e "\n${YELLOW}Run 'docker compose -f docker-compose.production.yml logs -f' to see full logs${NC}" +fi + +# Step 8: Show next steps +echo -e "\n${YELLOW}๐Ÿ“ Next Steps:${NC}" +echo "1. View logs: docker compose -f docker-compose.production.yml logs -f" +echo "2. Check status: docker compose -f docker-compose.production.yml ps" +echo "3. Restart: docker compose -f docker-compose.production.yml restart" +echo "4. Stop: docker compose -f docker-compose.production.yml down" +echo "5. Read full documentation: cat DEPLOYMENT.md" + +echo -e "\n${GREEN}Setup complete!${NC}" + diff --git a/package.json b/package.json index 2a159067971..0306754937c 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "i-dx": "infisical run -- turbo run dx", "i-gen-web-example-env": "infisical secrets generate-example-env --tags=web > .env.example", "i-gen-app-store-example-env": "infisical secrets generate-example-env --tags=appstore > .env.appStore.example", + "create-oauth-client": "node scripts/create-oauth-client.js", "embed-tests-quick": "turbo run embed-tests-quick", "embed-tests": "turbo run embed-tests", "env-check:app-store": "dotenv-checker --schema .env.appStore.example --env .env.appStore", diff --git a/scripts/create-oauth-client.js b/scripts/create-oauth-client.js new file mode 100755 index 00000000000..1026ace7d1e --- /dev/null +++ b/scripts/create-oauth-client.js @@ -0,0 +1,204 @@ +#!/usr/bin/env node + +/** + * Simple OAuth Client Creator for Cal.com + * + * This is a simplified JavaScript version that can be run directly with Node.js + * without requiring TypeScript compilation. + * + * Usage: + * node scripts/create-oauth-client.js [permissions] + * + * Examples: + * node scripts/create-oauth-client.js 123 "My App" "http://localhost:3000/callback" + * node scripts/create-oauth-client.js 123 "My App" "http://localhost:3000/callback" "*" + * node scripts/create-oauth-client.js 123 "My App" "https://example.com/callback" "BOOKING_READ,BOOKING_WRITE" + */ + +const { sign } = require("jsonwebtoken"); +const { PrismaClient } = require("@calcom/prisma"); + +const PERMISSION_MAP = { + BOOKING_READ: 1, + BOOKING_WRITE: 2, + EVENT_TYPE_READ: 4, + EVENT_TYPE_WRITE: 8, + SCHEDULE_READ: 16, + SCHEDULE_WRITE: 32, + USER_READ: 64, + USER_WRITE: 128, + CALENDAR_READ: 256, + CALENDAR_WRITE: 512, +}; + +function transformPermissions(permissions) { + if (permissions.includes("*")) { + return Object.values(PERMISSION_MAP).reduce((acc, val) => acc | val, 0); + } + + const values = permissions.map((p) => { + if (!(p in PERMISSION_MAP)) { + throw new Error(`Invalid permission: ${p}. Valid permissions: ${Object.keys(PERMISSION_MAP).join(", ")}, *`); + } + return PERMISSION_MAP[p]; + }); + + return values.reduce((acc, val) => acc | val, 0); +} + +function generateClientSecret(data) { + const secret = process.env.CALENDSO_ENCRYPTION_KEY || process.env.NEXTAUTH_SECRET || "secret"; + return sign(data, secret); +} + +async function createOAuthClient(organizationId, name, redirectUri, permissions = ["*"]) { + const prisma = new PrismaClient(); + + try { + // Verify organization exists and has platform billing + const organization = await prisma.team.findUnique({ + where: { id: parseInt(organizationId) }, + include: { platformBilling: true } + }); + + if (!organization) { + throw new Error(`Organization with ID ${organizationId} not found`); + } + + // Check if organization has platform billing (or create it for dev/testing) + if (!organization.platformBilling) { + console.log("โš ๏ธ Organization doesn't have platform billing. Creating platform billing record..."); + await prisma.platformBilling.create({ + data: { + id: organization.id, + plan: "SCALE", + customerId: `cus_${Date.now()}`, + subscriptionId: `sub_${Date.now()}`, + }, + }); + } + + // Transform permissions to integer + const permissionsInt = transformPermissions(permissions); + + // Create the OAuth client data for JWT + const clientData = { + name: name, + permissions: permissionsInt, + redirectUris: [redirectUri], + bookingRedirectUri: "", + bookingCancelRedirectUri: "", + bookingRescheduleRedirectUri: "", + areEmailsEnabled: true, + iat: Math.floor(Date.now() / 1000), + }; + + // Generate client secret (JWT) + const clientSecret = generateClientSecret(clientData); + + // Create OAuth client in database + const oauthClient = await prisma.platformOAuthClient.create({ + data: { + name: name, + secret: clientSecret, + permissions: permissionsInt, + redirectUris: [redirectUri], + organizationId: parseInt(organizationId), + areEmailsEnabled: true, + }, + }); + + console.log("โœ… OAuth Client created successfully!"); + console.log(`๐Ÿ“‹ Client ID: ${oauthClient.id}`); + console.log(`๐Ÿ”‘ Client Secret: ${oauthClient.secret}`); + console.log(`๐Ÿข Organization ID: ${oauthClient.organizationId}`); + console.log(`๐Ÿ“ Name: ${oauthClient.name}`); + console.log(`๐Ÿ” Permissions: ${permissions.join(", ")}`); + console.log(`๐Ÿ”— Redirect URI: ${oauthClient.redirectUris.join(", ")}`); + console.log(""); + console.log("๐Ÿ”‘ OAuth Client Credentials Authentication:"); + console.log(` Headers:`); + console.log(` x-cal-client-id: ${oauthClient.id}`); + console.log(` x-cal-secret-key: ${oauthClient.secret}`); + console.log(""); + console.log("โš ๏ธ IMPORTANT: Store these credentials securely. The client secret cannot be retrieved again!"); + + return { + clientId: oauthClient.id, + clientSecret: oauthClient.secret, + }; + + } finally { + await prisma.$disconnect(); + } +} + +// CLI interface +async function main() { + const args = process.argv.slice(2); + + if (args.length === 0 || args.includes("--help") || args.includes("-h")) { + console.log(` +Simple OAuth Client Creator for Cal.com + +Usage: + node scripts/create-oauth-client.js [permissions] + +Arguments: + orgId Organization/Team ID (required) + name OAuth client name (required) + redirectUri Redirect URI (required) + permissions Permissions (comma-separated or "*" for all, default: "*") + +Available Permissions: + BOOKING_READ, BOOKING_WRITE, EVENT_TYPE_READ, EVENT_TYPE_WRITE, + SCHEDULE_READ, SCHEDULE_WRITE, USER_READ, USER_WRITE, + CALENDAR_READ, CALENDAR_WRITE, * (all permissions) + +Examples: + # Create OAuth client with all permissions + node scripts/create-oauth-client.js 123 "My App" "http://localhost:3000/callback" + + # Create OAuth client with specific permissions + node scripts/create-oauth-client.js 123 "Booking App" "https://example.com/callback" "BOOKING_READ,BOOKING_WRITE" +`); + process.exit(0); + } + + const orgId = args[0]; + const name = args[1]; + const redirectUri = args[2]; + const permissions = args[3] ? args[3].split(",").map(p => p.trim()) : ["*"]; + + if (!orgId) { + console.error("โŒ Error: orgId is required"); + console.error("Use --help for usage information"); + process.exit(1); + } + + if (!name) { + console.error("โŒ Error: name is required"); + console.error("Use --help for usage information"); + process.exit(1); + } + + if (!redirectUri) { + console.error("โŒ Error: redirectUri is required"); + console.error("Use --help for usage information"); + process.exit(1); + } + + try { + await createOAuthClient(orgId, name, redirectUri, permissions); + } catch (error) { + console.error("โŒ Error:", error.message); + process.exit(1); + } +} + +// Run the script if called directly +if (require.main === module) { + main(); +} + +module.exports = { createOAuthClient }; diff --git a/scripts/create-oauth-client.ts b/scripts/create-oauth-client.ts new file mode 100755 index 00000000000..ccfee773bc6 --- /dev/null +++ b/scripts/create-oauth-client.ts @@ -0,0 +1,291 @@ +#!/usr/bin/env node + +/** + * OAuth Client Generator for Cal.com Platform API + * + * This script allows you to create OAuth clients for the Cal.com Platform API (v2) + * without needing to host the frontend or use API keys. + * + * Usage: + * npx tsx scripts/create-oauth-client.ts --orgId --name [options] + * + * Options: + * --orgId, -o Organization/Team ID (required) + * --name, -n OAuth client name (required) + * --redirectUri, -r Redirect URI (can be specified multiple times) + * --permissions, -p Permissions (comma-separated or "*" for all) + * --areEmailsEnabled Enable emails (default: true) + * --help, -h Show this help message + */ + +import { createHash } from "crypto"; +import { PrismaClient } from "@calcom/prisma"; +import { sign } from "jsonwebtoken"; + +const PERMISSION_MAP = { + BOOKING_READ: 1, + BOOKING_WRITE: 2, + EVENT_TYPE_READ: 4, + EVENT_TYPE_WRITE: 8, + SCHEDULE_READ: 16, + SCHEDULE_WRITE: 32, + USER_READ: 64, + USER_WRITE: 128, + CALENDAR_READ: 256, + CALENDAR_WRITE: 512, +} as const; + +interface CreateOAuthClientOptions { + organizationId: number; + name: string; + redirectUris: string[]; + permissions: string[]; + areEmailsEnabled?: boolean; + bookingRedirectUri?: string; + bookingCancelRedirectUri?: string; + bookingRescheduleRedirectUri?: string; +} + +function transformPermissions(permissions: string[]): number { + if (permissions.includes("*")) { + return Object.values(PERMISSION_MAP).reduce((acc, val) => acc | val, 0); + } + + const values = permissions.map((p) => { + const key = p as keyof typeof PERMISSION_MAP; + if (!(key in PERMISSION_MAP)) { + throw new Error(`Invalid permission: ${p}. Valid permissions: ${Object.keys(PERMISSION_MAP).join(", ")}, *`); + } + return PERMISSION_MAP[key]; + }); + + return values.reduce((acc, val) => acc | val, 0); +} + +function generateClientSecret(data: any): string { + const secret = process.env.CALENDSO_ENCRYPTION_KEY || process.env.NEXTAUTH_SECRET || "secret"; + return sign(data, secret); +} + +async function createOAuthClient(options: CreateOAuthClientOptions): Promise<{ clientId: string; clientSecret: string }> { + const prisma = new PrismaClient(); + + try { + // Verify organization exists and has platform billing + const organization = await prisma.team.findUnique({ + where: { id: options.organizationId }, + include: { platformBilling: true } + }); + + if (!organization) { + throw new Error(`Organization with ID ${options.organizationId} not found`); + } + + // Check if organization has platform billing (or create it for dev/testing) + if (!organization.platformBilling) { + console.log("โš ๏ธ Organization doesn't have platform billing. Creating platform billing record..."); + await prisma.platformBilling.create({ + data: { + id: organization.id, + plan: "SCALE", + customerId: `cus_${Date.now()}`, + subscriptionId: `sub_${Date.now()}`, + }, + }); + } + + // Transform permissions to integer + const permissionsInt = transformPermissions(options.permissions); + + // Create the OAuth client data for JWT + const clientData = { + name: options.name, + permissions: permissionsInt, + redirectUris: options.redirectUris, + bookingRedirectUri: options.bookingRedirectUri || "", + bookingCancelRedirectUri: options.bookingCancelRedirectUri || "", + bookingRescheduleRedirectUri: options.bookingRescheduleRedirectUri || "", + areEmailsEnabled: options.areEmailsEnabled ?? true, + iat: Math.floor(Date.now() / 1000), + }; + + // Generate client secret (JWT) + const clientSecret = generateClientSecret(clientData); + + // Create OAuth client in database + const oauthClient = await prisma.platformOAuthClient.create({ + data: { + name: options.name, + secret: clientSecret, + permissions: permissionsInt, + redirectUris: options.redirectUris, + organizationId: options.organizationId, + areEmailsEnabled: options.areEmailsEnabled ?? true, + bookingRedirectUri: options.bookingRedirectUri, + bookingCancelRedirectUri: options.bookingCancelRedirectUri, + bookingRescheduleRedirectUri: options.bookingRescheduleRedirectUri, + }, + }); + + console.log("โœ… OAuth Client created successfully!"); + console.log(`๐Ÿ“‹ Client ID: ${oauthClient.id}`); + console.log(`๐Ÿ”‘ Client Secret: ${oauthClient.secret}`); + console.log(`๐Ÿข Organization ID: ${oauthClient.organizationId}`); + console.log(`๐Ÿ“ Name: ${oauthClient.name}`); + console.log(`๐Ÿ” Permissions: ${options.permissions.join(", ")}`); + console.log(`๐Ÿ”— Redirect URIs: ${oauthClient.redirectUris.join(", ")}`); + console.log(""); + console.log("๐Ÿ”‘ OAuth Client Credentials Authentication:"); + console.log(` Headers:`); + console.log(` x-cal-client-id: ${oauthClient.id}`); + console.log(` x-cal-secret-key: ${oauthClient.secret}`); + console.log(""); + console.log("โš ๏ธ IMPORTANT: Store these credentials securely. The client secret cannot be retrieved again!"); + + return { + clientId: oauthClient.id, + clientSecret: oauthClient.secret, + }; + + } finally { + await prisma.$disconnect(); + } +} + +// CLI interface +async function main() { + const args = process.argv.slice(2); + + if (args.includes("--help") || args.includes("-h")) { + console.log(` +OAuth Client Generator for Cal.com Platform API + +Usage: + npx tsx scripts/create-oauth-client.ts --orgId --name [options] + +Options: + --orgId, -o Organization/Team ID (required) + --name, -n OAuth client name (required) + --redirectUri, -r Redirect URI (can be specified multiple times) + --permissions, -p Permissions (comma-separated or "*" for all) + --areEmailsEnabled Enable emails (default: true) + --bookingRedirectUri Booking redirect URI (optional) + --help, -h Show this help message + +Available Permissions: + BOOKING_READ, BOOKING_WRITE, EVENT_TYPE_READ, EVENT_TYPE_WRITE, + SCHEDULE_READ, SCHEDULE_WRITE, USER_READ, USER_WRITE, + CALENDAR_READ, CALENDAR_WRITE, * (all permissions) + +Examples: + # Create OAuth client with all permissions + npx tsx scripts/create-oauth-client.ts --orgId 123 --name "My App" --redirectUri "http://localhost:3000/callback" --permissions "*" + + # Create OAuth client with specific permissions + npx tsx scripts/create-oauth-client.ts --orgId 123 --name "Booking App" --redirectUri "https://example.com/callback" --permissions "BOOKING_READ,BOOKING_WRITE" + + # Create OAuth client with multiple redirect URIs + npx tsx scripts/create-oauth-client.ts --orgId 123 --name "Multi-env App" --redirectUri "http://localhost:3000/callback" --redirectUri "https://example.com/callback" --permissions "*" +`); + process.exit(0); + } + + // Parse command line arguments + let organizationId: number | undefined; + let name: string | undefined; + const redirectUris: string[] = []; + let permissions: string[] = ["*"]; + let areEmailsEnabled = true; + let bookingRedirectUri: string | undefined; + let bookingCancelRedirectUri: string | undefined; + let bookingRescheduleRedirectUri: string | undefined; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const nextArg = args[i + 1]; + + switch (arg) { + case "--orgId": + case "-o": + organizationId = parseInt(nextArg); + if (isNaN(organizationId)) { + console.error("โŒ Error: --orgId must be a valid number"); + process.exit(1); + } + i++; + break; + case "--name": + case "-n": + name = nextArg; + i++; + break; + case "--redirectUri": + case "-r": + redirectUris.push(nextArg); + i++; + break; + case "--permissions": + case "-p": + permissions = nextArg.split(",").map(p => p.trim()); + i++; + break; + case "--areEmailsEnabled": + areEmailsEnabled = nextArg === "true" || nextArg === "1"; + i++; + break; + case "--bookingRedirectUri": + bookingRedirectUri = nextArg; + i++; + break; + case "--bookingCancelRedirectUri": + bookingCancelRedirectUri = nextArg; + i++; + break; + case "--bookingRescheduleRedirectUri": + bookingRescheduleRedirectUri = nextArg; + i++; + break; + } + } + + if (!organizationId) { + console.error("โŒ Error: --orgId is required"); + console.error("Use --help for usage information"); + process.exit(1); + } + + if (!name) { + console.error("โŒ Error: --name is required"); + console.error("Use --help for usage information"); + process.exit(1); + } + + if (redirectUris.length === 0) { + console.error("โŒ Error: At least one --redirectUri is required"); + console.error("Use --help for usage information"); + process.exit(1); + } + + try { + await createOAuthClient({ + organizationId, + name, + redirectUris, + permissions, + areEmailsEnabled, + bookingRedirectUri, + bookingCancelRedirectUri, + bookingRescheduleRedirectUri, + }); + } catch (error) { + console.error("โŒ Error:", error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + +// Run the script if called directly +if (require.main === module) { + main(); +} + +export { createOAuthClient }; From 935c70f1984d277f72eab8c74146b32e78eaf8bb Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 7 Oct 2025 20:32:00 +0200 Subject: [PATCH 03/37] fix setup script from amazon linux --- apps/api/v2/setup.sh | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/apps/api/v2/setup.sh b/apps/api/v2/setup.sh index 34817d56119..f6fc403f382 100755 --- a/apps/api/v2/setup.sh +++ b/apps/api/v2/setup.sh @@ -27,10 +27,22 @@ command_exists() { echo -e "\n${YELLOW}๐Ÿ“ฆ Checking Docker installation...${NC}" if ! command_exists docker; then echo "Installing Docker..." - curl -fsSL https://get.docker.com -o get-docker.sh - sudo sh get-docker.sh - sudo usermod -aG docker $USER - rm get-docker.sh + + # Check if Amazon Linux + if [ -f /etc/os-release ] && grep -q "Amazon Linux" /etc/os-release; then + echo "Detected Amazon Linux - using dnf installation" + sudo dnf install -y docker + sudo systemctl start docker + sudo systemctl enable docker + sudo usermod -aG docker $USER + else + # Use Docker's install script for other distros + curl -fsSL https://get.docker.com -o get-docker.sh + sudo sh get-docker.sh + sudo usermod -aG docker $USER + rm get-docker.sh + fi + echo -e "${GREEN}โœ… Docker installed${NC}" else echo -e "${GREEN}โœ… Docker already installed${NC}" @@ -40,7 +52,18 @@ fi echo -e "\n${YELLOW}๐Ÿ“ฆ Checking Docker Compose installation...${NC}" if ! docker compose version >/dev/null 2>&1; then echo "Installing Docker Compose plugin..." - sudo dnf install -y docker-compose-plugin + + # Check package manager + if command_exists dnf; then + sudo dnf install -y docker-compose-plugin + elif command_exists apt; then + sudo apt update + sudo apt install -y docker-compose-plugin + else + echo -e "${RED}โŒ Unsupported package manager${NC}" + exit 1 + fi + echo -e "${GREEN}โœ… Docker Compose installed${NC}" else echo -e "${GREEN}โœ… Docker Compose already installed${NC}" From 2bd526e4cbca5a86b97bd11f6467684fc241e9ac Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 7 Oct 2025 20:36:59 +0200 Subject: [PATCH 04/37] added docker compose to setup script --- apps/api/v2/setup.sh | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/api/v2/setup.sh b/apps/api/v2/setup.sh index f6fc403f382..0944c3429e1 100755 --- a/apps/api/v2/setup.sh +++ b/apps/api/v2/setup.sh @@ -51,24 +51,36 @@ fi # Step 2: Install Docker Compose if not installed echo -e "\n${YELLOW}๐Ÿ“ฆ Checking Docker Compose installation...${NC}" if ! docker compose version >/dev/null 2>&1; then - echo "Installing Docker Compose plugin..." + echo "Installing Docker Compose..." - # Check package manager - if command_exists dnf; then - sudo dnf install -y docker-compose-plugin + # Amazon Linux 2023 includes compose with docker package + if [ -f /etc/os-release ] && grep -q "Amazon Linux" /etc/os-release; then + echo "Amazon Linux detected - Docker Compose should be included with Docker" + echo "If 'docker compose' still doesn't work, installing docker-buildx..." + sudo dnf install -y docker-buildx 2>/dev/null || true elif command_exists apt; then sudo apt update sudo apt install -y docker-compose-plugin else - echo -e "${RED}โŒ Unsupported package manager${NC}" - exit 1 + # Manual install for other systems + DOCKER_CONFIG=${DOCKER_CONFIG:-$HOME/.docker} + mkdir -p $DOCKER_CONFIG/cli-plugins + curl -SL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64 -o $DOCKER_CONFIG/cli-plugins/docker-compose + chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose fi - echo -e "${GREEN}โœ… Docker Compose installed${NC}" + echo -e "${GREEN}โœ… Docker Compose setup complete${NC}" else echo -e "${GREEN}โœ… Docker Compose already installed${NC}" fi +# Ensure docker service is running +if ! sudo systemctl is-active --quiet docker; then + echo "Starting Docker service..." + sudo systemctl start docker + sudo systemctl enable docker +fi + # Step 3: Generate environment file if it doesn't exist echo -e "\n${YELLOW}๐Ÿ”ง Configuring environment...${NC}" if [ ! -f .env.production ]; then From 31742c020948e34eefe0b05a174d6fdde067b122 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 7 Oct 2025 20:46:23 +0200 Subject: [PATCH 05/37] fix the .env.example --- apps/api/v2/.env.example | 8 +++++--- .../v2/nginx/conf.d/{api-v2.conf => api-v2.conf.disabled} | 0 apps/api/v2/setup.sh | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) rename apps/api/v2/nginx/conf.d/{api-v2.conf => api-v2.conf.disabled} (100%) diff --git a/apps/api/v2/.env.example b/apps/api/v2/.env.example index 79dc011fc19..5504fd547bb 100644 --- a/apps/api/v2/.env.example +++ b/apps/api/v2/.env.example @@ -1,4 +1,4 @@ -NODE_ENV= +NODE_ENV=production API_PORT= API_URL= DATABASE_READ_URL= @@ -22,7 +22,7 @@ STRIPE_PRICE_ID_ENTERPRISE_OVERAGE= STRIPE_API_KEY= STRIPE_WEBHOOK_SECRET= -WEB_APP_URL=http://localhost:3000/ +WEB_APP_URL=https://app.collegecontactcalendar.com CALCOM_LICENSE_KEY= API_KEY_PREFIX=cal_ GET_LICENSE_KEY_URL="https://console.cal.com/api/license" @@ -41,4 +41,6 @@ LOGGER_BRIDGE_LOG_LEVEL="1" # 1: Rewrite /api/v2 to /v2 # 0: Don't rewrite -REWRITE_API_V2_PREFIX="1" \ No newline at end of file +REWRITE_API_V2_PREFIX="1" + +API_URL=https://api.collegecontactcalendar.com \ No newline at end of file diff --git a/apps/api/v2/nginx/conf.d/api-v2.conf b/apps/api/v2/nginx/conf.d/api-v2.conf.disabled similarity index 100% rename from apps/api/v2/nginx/conf.d/api-v2.conf rename to apps/api/v2/nginx/conf.d/api-v2.conf.disabled diff --git a/apps/api/v2/setup.sh b/apps/api/v2/setup.sh index 0944c3429e1..3d6ab5d8760 100755 --- a/apps/api/v2/setup.sh +++ b/apps/api/v2/setup.sh @@ -85,7 +85,7 @@ fi echo -e "\n${YELLOW}๐Ÿ”ง Configuring environment...${NC}" if [ ! -f .env.production ]; then echo "Creating .env.production from example..." - cp env.production.example .env.production + cp .env.example .env.production # Generate secure passwords DB_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-32) From 4f25f2accf116b2ff23b12d8f48f1e134a9d2520 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 8 Oct 2025 15:48:23 +0200 Subject: [PATCH 06/37] expose database on port 5432 --- apps/api/v2/.dockerignore | 3 ++- apps/api/v2/docker-compose.production.yml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/api/v2/.dockerignore b/apps/api/v2/.dockerignore index 569ce539708..0a4a8606143 100644 --- a/apps/api/v2/.dockerignore +++ b/apps/api/v2/.dockerignore @@ -1,2 +1,3 @@ **/node_modules -**/dist \ No newline at end of file +**/dist +.env* \ No newline at end of file diff --git a/apps/api/v2/docker-compose.production.yml b/apps/api/v2/docker-compose.production.yml index 0bf53e9c303..2440dd22d89 100644 --- a/apps/api/v2/docker-compose.production.yml +++ b/apps/api/v2/docker-compose.production.yml @@ -13,7 +13,7 @@ services: volumes: - postgres_data:/var/lib/postgresql/data ports: - - "127.0.0.1:5432:5432" + - "0.0.0.0:5432:5432" # Exposed on all interfaces, controlled by Security Group healthcheck: test: ["CMD-SHELL", "pg_isready -U calcom"] interval: 10s From f49315f92cf84eef83c1d24f689d4603ad170035 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 9 Oct 2025 10:14:53 +0200 Subject: [PATCH 07/37] Retry with version attribute --- apps/api/v2/docker-compose.production.yml | 4 ++++ apps/api/v2/nginx/conf.d/api-v2-cloudflare.conf | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/api/v2/docker-compose.production.yml b/apps/api/v2/docker-compose.production.yml index 2440dd22d89..a87c3383a23 100644 --- a/apps/api/v2/docker-compose.production.yml +++ b/apps/api/v2/docker-compose.production.yml @@ -88,6 +88,10 @@ services: # Sentry (optional) NEXT_PUBLIC_SENTRY_DSN: ${SENTRY_DSN:-} + # Web Push VAPID Keys (required) + NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} + VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} + ports: - "127.0.0.1:5555:80" networks: diff --git a/apps/api/v2/nginx/conf.d/api-v2-cloudflare.conf b/apps/api/v2/nginx/conf.d/api-v2-cloudflare.conf index 960b11f12cb..94f4d8c9c89 100644 --- a/apps/api/v2/nginx/conf.d/api-v2-cloudflare.conf +++ b/apps/api/v2/nginx/conf.d/api-v2-cloudflare.conf @@ -50,7 +50,6 @@ server { # Health check endpoint (no rate limit) location /health { - limit_req off; proxy_pass http://api-v2:80/health; proxy_http_version 1.1; proxy_set_header Host $host; From afa0b3f82928fc8959950d66eb7eb4bfd2f93544 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 9 Oct 2025 12:31:03 +0200 Subject: [PATCH 08/37] prebuilt image --- apps/api/v2/docker-compose.production.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/api/v2/docker-compose.production.yml b/apps/api/v2/docker-compose.production.yml index a87c3383a23..5610163b7f7 100644 --- a/apps/api/v2/docker-compose.production.yml +++ b/apps/api/v2/docker-compose.production.yml @@ -42,12 +42,13 @@ services: # Cal.com API v2 api-v2: - build: - context: ../../.. # Build from repo root to include monorepo - dockerfile: apps/api/v2/Dockerfile - args: - DATABASE_URL: postgresql://calcom:${DB_PASSWORD:-calcom_secure_password_change_me}@postgres:5432/calcom - DATABASE_DIRECT_URL: postgresql://calcom:${DB_PASSWORD:-calcom_secure_password_change_me}@postgres:5432/calcom + image: calcom-api-v2:latest # Use pre-built image + # build: + # context: ../../.. # Build from repo root to include monorepo + # dockerfile: apps/api/v2/Dockerfile + # args: + # DATABASE_URL: postgresql://calcom:${DB_PASSWORD:-calcom_secure_password_change_me}@postgres:5432/calcom + # DATABASE_DIRECT_URL: postgresql://calcom:${DB_PASSWORD:-calcom_secure_password_change_me}@postgres:5432/calcom container_name: calcom-api-v2 restart: unless-stopped depends_on: From 0d0df1836b958ad5f333e8c21b28963d4ce1d618 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 9 Oct 2025 14:00:25 +0200 Subject: [PATCH 09/37] change the api port to 80 --- apps/api/v2/docker-compose.production.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/v2/docker-compose.production.yml b/apps/api/v2/docker-compose.production.yml index 5610163b7f7..4b7cf1d8a4a 100644 --- a/apps/api/v2/docker-compose.production.yml +++ b/apps/api/v2/docker-compose.production.yml @@ -58,7 +58,7 @@ services: condition: service_healthy environment: NODE_ENV: production - API_PORT: 5555 + API_PORT: 80 # Database DATABASE_URL: postgresql://calcom:${DB_PASSWORD:-calcom_secure_password_change_me}@postgres:5432/calcom From 117b86b95d600944ad9ee3a9b7738d9d5107c189 Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 13 Oct 2025 12:26:50 +0200 Subject: [PATCH 10/37] updated seed file to only add a platform user --- scripts/seed.ts | 1449 ++++++++++++++++++++++++----------------------- 1 file changed, 725 insertions(+), 724 deletions(-) diff --git a/scripts/seed.ts b/scripts/seed.ts index cb52433c21e..54f4e5ec945 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -167,7 +167,7 @@ async function createPlatformAndSetupUser({ const membershipRole = MembershipRole.OWNER; - if (!!team) { + if (team) { await associateUserAndOrg({ teamId: team.id, userId: platformUser.id, @@ -608,358 +608,358 @@ async function createOrganizationAndAddMembersAndTeams({ } async function main() { - await createUserAndEventType({ - user: { - email: "delete-me@example.com", - password: "delete-me", - username: "delete-me", - name: "delete-me", - }, - }); - - await createUserAndEventType({ - user: { - email: "onboarding@example.com", - password: "onboarding", - username: "onboarding", - name: "onboarding", - completedOnboarding: false, - }, - }); - - await createUserAndEventType({ - user: { - email: "free-first-hidden@example.com", - password: "free-first-hidden", - username: "free-first-hidden", - name: "Free First Hidden Example", - }, - eventTypes: [ - { - title: "30min", - slug: "30min", - length: 30, - hidden: true, - }, - { - title: "60min", - slug: "60min", - length: 30, - }, - ], - }); - - await createUserAndEventType({ - user: { - email: "pro@example.com", - name: "Pro Example", - password: "pro", - username: "pro", - theme: "light", - }, - eventTypes: [ - { - title: "30min", - slug: "30min", - length: 30, - _bookings: [ - { - uid: uuid(), - title: "30min", - startTime: dayjs().add(1, "day").toDate(), - endTime: dayjs().add(1, "day").add(30, "minutes").toDate(), - }, - { - uid: uuid(), - title: "30min", - startTime: dayjs().add(2, "day").toDate(), - endTime: dayjs().add(2, "day").add(30, "minutes").toDate(), - status: BookingStatus.PENDING, - }, - { - // hardcode UID so that we can easily test rescheduling in embed - uid: "qm3kwt3aTnVD7vmP9tiT2f", - title: "30min Seeded Booking", - startTime: dayjs().add(3, "day").toDate(), - endTime: dayjs().add(3, "day").add(30, "minutes").toDate(), - status: BookingStatus.PENDING, - }, - ], - }, - { - title: "60min", - slug: "60min", - length: 60, - }, - { - title: "Multiple duration", - slug: "multiple-duration", - length: 75, - metadata: { - multipleDuration: [30, 75, 90], - }, - }, - { - title: "paid", - slug: "paid", - length: 60, - price: 100, - }, - { - title: "In person meeting", - slug: "in-person", - length: 60, - locations: [{ type: "inPerson", address: "London" }], - }, - { - title: "Zoom Event", - slug: "zoom", - length: 60, - locations: [{ type: zoomMeta.appData?.location?.type }], - }, - { - title: "Daily Event", - slug: "daily", - length: 60, - locations: [{ type: dailyMeta.appData?.location?.type }], - }, - { - title: "Google Meet", - slug: "google-meet", - length: 60, - locations: [{ type: googleMeetMeta.appData?.location?.type }], - }, - { - title: "Yoga class", - slug: "yoga-class", - length: 30, - recurringEvent: { freq: 2, count: 12, interval: 1 }, - _bookings: [ - { - uid: uuid(), - title: "Yoga class", - recurringEventId: Buffer.from("yoga-class").toString("base64"), - startTime: dayjs().add(1, "day").toDate(), - endTime: dayjs().add(1, "day").add(30, "minutes").toDate(), - status: BookingStatus.ACCEPTED, - }, - { - uid: uuid(), - title: "Yoga class", - recurringEventId: Buffer.from("yoga-class").toString("base64"), - startTime: dayjs().add(1, "day").add(1, "week").toDate(), - endTime: dayjs().add(1, "day").add(1, "week").add(30, "minutes").toDate(), - status: BookingStatus.ACCEPTED, - }, - { - uid: uuid(), - title: "Yoga class", - recurringEventId: Buffer.from("yoga-class").toString("base64"), - startTime: dayjs().add(1, "day").add(2, "week").toDate(), - endTime: dayjs().add(1, "day").add(2, "week").add(30, "minutes").toDate(), - status: BookingStatus.ACCEPTED, - }, - { - uid: uuid(), - title: "Yoga class", - recurringEventId: Buffer.from("yoga-class").toString("base64"), - startTime: dayjs().add(1, "day").add(3, "week").toDate(), - endTime: dayjs().add(1, "day").add(3, "week").add(30, "minutes").toDate(), - status: BookingStatus.ACCEPTED, - }, - { - uid: uuid(), - title: "Yoga class", - recurringEventId: Buffer.from("yoga-class").toString("base64"), - startTime: dayjs().add(1, "day").add(4, "week").toDate(), - endTime: dayjs().add(1, "day").add(4, "week").add(30, "minutes").toDate(), - status: BookingStatus.ACCEPTED, - }, - { - uid: uuid(), - title: "Yoga class", - recurringEventId: Buffer.from("yoga-class").toString("base64"), - startTime: dayjs().add(1, "day").add(5, "week").toDate(), - endTime: dayjs().add(1, "day").add(5, "week").add(30, "minutes").toDate(), - status: BookingStatus.ACCEPTED, - }, - { - uid: uuid(), - title: "Seeded Yoga class", - description: "seeded", - recurringEventId: Buffer.from("seeded-yoga-class").toString("base64"), - startTime: dayjs().subtract(4, "day").toDate(), - endTime: dayjs().subtract(4, "day").add(30, "minutes").toDate(), - status: BookingStatus.ACCEPTED, - }, - { - uid: uuid(), - title: "Seeded Yoga class", - description: "seeded", - recurringEventId: Buffer.from("seeded-yoga-class").toString("base64"), - startTime: dayjs().subtract(4, "day").add(1, "week").toDate(), - endTime: dayjs().subtract(4, "day").add(1, "week").add(30, "minutes").toDate(), - status: BookingStatus.ACCEPTED, - }, - { - uid: uuid(), - title: "Seeded Yoga class", - description: "seeded", - recurringEventId: Buffer.from("seeded-yoga-class").toString("base64"), - startTime: dayjs().subtract(4, "day").add(2, "week").toDate(), - endTime: dayjs().subtract(4, "day").add(2, "week").add(30, "minutes").toDate(), - status: BookingStatus.ACCEPTED, - }, - { - uid: uuid(), - title: "Seeded Yoga class", - description: "seeded", - recurringEventId: Buffer.from("seeded-yoga-class").toString("base64"), - startTime: dayjs().subtract(4, "day").add(3, "week").toDate(), - endTime: dayjs().subtract(4, "day").add(3, "week").add(30, "minutes").toDate(), - status: BookingStatus.ACCEPTED, - }, - ], - }, - { - title: "Tennis class", - slug: "tennis-class", - length: 60, - recurringEvent: { freq: 2, count: 10, interval: 2 }, - requiresConfirmation: true, - _bookings: [ - { - uid: uuid(), - title: "Tennis class", - recurringEventId: Buffer.from("tennis-class").toString("base64"), - startTime: dayjs().add(2, "day").toDate(), - endTime: dayjs().add(2, "day").add(60, "minutes").toDate(), - status: BookingStatus.PENDING, - }, - { - uid: uuid(), - title: "Tennis class", - recurringEventId: Buffer.from("tennis-class").toString("base64"), - startTime: dayjs().add(2, "day").add(2, "week").toDate(), - endTime: dayjs().add(2, "day").add(2, "week").add(60, "minutes").toDate(), - status: BookingStatus.PENDING, - }, - { - uid: uuid(), - title: "Tennis class", - recurringEventId: Buffer.from("tennis-class").toString("base64"), - startTime: dayjs().add(2, "day").add(4, "week").toDate(), - endTime: dayjs().add(2, "day").add(4, "week").add(60, "minutes").toDate(), - status: BookingStatus.PENDING, - }, - { - uid: uuid(), - title: "Tennis class", - recurringEventId: Buffer.from("tennis-class").toString("base64"), - startTime: dayjs().add(2, "day").add(8, "week").toDate(), - endTime: dayjs().add(2, "day").add(8, "week").add(60, "minutes").toDate(), - status: BookingStatus.PENDING, - }, - { - uid: uuid(), - title: "Tennis class", - recurringEventId: Buffer.from("tennis-class").toString("base64"), - startTime: dayjs().add(2, "day").add(10, "week").toDate(), - endTime: dayjs().add(2, "day").add(10, "week").add(60, "minutes").toDate(), - status: BookingStatus.PENDING, - }, - ], - }, - ], - }); - - await createUserAndEventType({ - user: { - email: "trial@example.com", - password: "trial", - username: "trial", - name: "Trial Example", - }, - eventTypes: [ - { - title: "30min", - slug: "30min", - length: 30, - }, - { - title: "60min", - slug: "60min", - length: 60, - }, - ], - }); - - await createUserAndEventType({ - user: { - email: "free@example.com", - password: "free", - username: "free", - name: "Free Example", - }, - eventTypes: [ - { - title: "30min", - slug: "30min", - length: 30, - }, - { - title: "60min", - slug: "60min", - length: 30, - }, - ], - }); - - await createUserAndEventType({ - user: { - email: "usa@example.com", - password: "usa", - username: "usa", - name: "USA Timezone Example", - timeZone: "America/Phoenix", - }, - eventTypes: [ - { - title: "30min", - slug: "30min", - length: 30, - }, - ], - }); - - const freeUserTeam = await createUserAndEventType({ - user: { - email: "teamfree@example.com", - password: "teamfree", - username: "teamfree", - name: "Team Free Example", - }, - }); - - const proUserTeam = await createUserAndEventType({ - user: { - email: "teampro@example.com", - password: "teampro", - username: "teampro", - name: "Team Pro Example", - }, - }); - - await createUserAndEventType({ - user: { - email: "admin@example.com", - /** To comply with admin password requirements */ - password: "ADMINadmin2022!", - username: "admin", - name: "Admin Example", - role: "ADMIN", - }, - }); + // await createUserAndEventType({ + // user: { + // email: "delete-me@example.com", + // password: "delete-me", + // username: "delete-me", + // name: "delete-me", + // }, + // }); + + // await createUserAndEventType({ + // user: { + // email: "onboarding@example.com", + // password: "onboarding", + // username: "onboarding", + // name: "onboarding", + // completedOnboarding: false, + // }, + // }); + + // await createUserAndEventType({ + // user: { + // email: "free-first-hidden@example.com", + // password: "free-first-hidden", + // username: "free-first-hidden", + // name: "Free First Hidden Example", + // }, + // eventTypes: [ + // { + // title: "30min", + // slug: "30min", + // length: 30, + // hidden: true, + // }, + // { + // title: "60min", + // slug: "60min", + // length: 30, + // }, + // ], + // }); + + // await createUserAndEventType({ + // user: { + // email: "pro@example.com", + // name: "Pro Example", + // password: "pro", + // username: "pro", + // theme: "light", + // }, + // eventTypes: [ + // { + // title: "30min", + // slug: "30min", + // length: 30, + // _bookings: [ + // { + // uid: uuid(), + // title: "30min", + // startTime: dayjs().add(1, "day").toDate(), + // endTime: dayjs().add(1, "day").add(30, "minutes").toDate(), + // }, + // { + // uid: uuid(), + // title: "30min", + // startTime: dayjs().add(2, "day").toDate(), + // endTime: dayjs().add(2, "day").add(30, "minutes").toDate(), + // status: BookingStatus.PENDING, + // }, + // { + // // hardcode UID so that we can easily test rescheduling in embed + // uid: "qm3kwt3aTnVD7vmP9tiT2f", + // title: "30min Seeded Booking", + // startTime: dayjs().add(3, "day").toDate(), + // endTime: dayjs().add(3, "day").add(30, "minutes").toDate(), + // status: BookingStatus.PENDING, + // }, + // ], + // }, + // { + // title: "60min", + // slug: "60min", + // length: 60, + // }, + // { + // title: "Multiple duration", + // slug: "multiple-duration", + // length: 75, + // metadata: { + // multipleDuration: [30, 75, 90], + // }, + // }, + // { + // title: "paid", + // slug: "paid", + // length: 60, + // price: 100, + // }, + // { + // title: "In person meeting", + // slug: "in-person", + // length: 60, + // locations: [{ type: "inPerson", address: "London" }], + // }, + // { + // title: "Zoom Event", + // slug: "zoom", + // length: 60, + // locations: [{ type: zoomMeta.appData?.location?.type }], + // }, + // { + // title: "Daily Event", + // slug: "daily", + // length: 60, + // locations: [{ type: dailyMeta.appData?.location?.type }], + // }, + // { + // title: "Google Meet", + // slug: "google-meet", + // length: 60, + // locations: [{ type: googleMeetMeta.appData?.location?.type }], + // }, + // { + // title: "Yoga class", + // slug: "yoga-class", + // length: 30, + // recurringEvent: { freq: 2, count: 12, interval: 1 }, + // _bookings: [ + // { + // uid: uuid(), + // title: "Yoga class", + // recurringEventId: Buffer.from("yoga-class").toString("base64"), + // startTime: dayjs().add(1, "day").toDate(), + // endTime: dayjs().add(1, "day").add(30, "minutes").toDate(), + // status: BookingStatus.ACCEPTED, + // }, + // { + // uid: uuid(), + // title: "Yoga class", + // recurringEventId: Buffer.from("yoga-class").toString("base64"), + // startTime: dayjs().add(1, "day").add(1, "week").toDate(), + // endTime: dayjs().add(1, "day").add(1, "week").add(30, "minutes").toDate(), + // status: BookingStatus.ACCEPTED, + // }, + // { + // uid: uuid(), + // title: "Yoga class", + // recurringEventId: Buffer.from("yoga-class").toString("base64"), + // startTime: dayjs().add(1, "day").add(2, "week").toDate(), + // endTime: dayjs().add(1, "day").add(2, "week").add(30, "minutes").toDate(), + // status: BookingStatus.ACCEPTED, + // }, + // { + // uid: uuid(), + // title: "Yoga class", + // recurringEventId: Buffer.from("yoga-class").toString("base64"), + // startTime: dayjs().add(1, "day").add(3, "week").toDate(), + // endTime: dayjs().add(1, "day").add(3, "week").add(30, "minutes").toDate(), + // status: BookingStatus.ACCEPTED, + // }, + // { + // uid: uuid(), + // title: "Yoga class", + // recurringEventId: Buffer.from("yoga-class").toString("base64"), + // startTime: dayjs().add(1, "day").add(4, "week").toDate(), + // endTime: dayjs().add(1, "day").add(4, "week").add(30, "minutes").toDate(), + // status: BookingStatus.ACCEPTED, + // }, + // { + // uid: uuid(), + // title: "Yoga class", + // recurringEventId: Buffer.from("yoga-class").toString("base64"), + // startTime: dayjs().add(1, "day").add(5, "week").toDate(), + // endTime: dayjs().add(1, "day").add(5, "week").add(30, "minutes").toDate(), + // status: BookingStatus.ACCEPTED, + // }, + // { + // uid: uuid(), + // title: "Seeded Yoga class", + // description: "seeded", + // recurringEventId: Buffer.from("seeded-yoga-class").toString("base64"), + // startTime: dayjs().subtract(4, "day").toDate(), + // endTime: dayjs().subtract(4, "day").add(30, "minutes").toDate(), + // status: BookingStatus.ACCEPTED, + // }, + // { + // uid: uuid(), + // title: "Seeded Yoga class", + // description: "seeded", + // recurringEventId: Buffer.from("seeded-yoga-class").toString("base64"), + // startTime: dayjs().subtract(4, "day").add(1, "week").toDate(), + // endTime: dayjs().subtract(4, "day").add(1, "week").add(30, "minutes").toDate(), + // status: BookingStatus.ACCEPTED, + // }, + // { + // uid: uuid(), + // title: "Seeded Yoga class", + // description: "seeded", + // recurringEventId: Buffer.from("seeded-yoga-class").toString("base64"), + // startTime: dayjs().subtract(4, "day").add(2, "week").toDate(), + // endTime: dayjs().subtract(4, "day").add(2, "week").add(30, "minutes").toDate(), + // status: BookingStatus.ACCEPTED, + // }, + // { + // uid: uuid(), + // title: "Seeded Yoga class", + // description: "seeded", + // recurringEventId: Buffer.from("seeded-yoga-class").toString("base64"), + // startTime: dayjs().subtract(4, "day").add(3, "week").toDate(), + // endTime: dayjs().subtract(4, "day").add(3, "week").add(30, "minutes").toDate(), + // status: BookingStatus.ACCEPTED, + // }, + // ], + // }, + // { + // title: "Tennis class", + // slug: "tennis-class", + // length: 60, + // recurringEvent: { freq: 2, count: 10, interval: 2 }, + // requiresConfirmation: true, + // _bookings: [ + // { + // uid: uuid(), + // title: "Tennis class", + // recurringEventId: Buffer.from("tennis-class").toString("base64"), + // startTime: dayjs().add(2, "day").toDate(), + // endTime: dayjs().add(2, "day").add(60, "minutes").toDate(), + // status: BookingStatus.PENDING, + // }, + // { + // uid: uuid(), + // title: "Tennis class", + // recurringEventId: Buffer.from("tennis-class").toString("base64"), + // startTime: dayjs().add(2, "day").add(2, "week").toDate(), + // endTime: dayjs().add(2, "day").add(2, "week").add(60, "minutes").toDate(), + // status: BookingStatus.PENDING, + // }, + // { + // uid: uuid(), + // title: "Tennis class", + // recurringEventId: Buffer.from("tennis-class").toString("base64"), + // startTime: dayjs().add(2, "day").add(4, "week").toDate(), + // endTime: dayjs().add(2, "day").add(4, "week").add(60, "minutes").toDate(), + // status: BookingStatus.PENDING, + // }, + // { + // uid: uuid(), + // title: "Tennis class", + // recurringEventId: Buffer.from("tennis-class").toString("base64"), + // startTime: dayjs().add(2, "day").add(8, "week").toDate(), + // endTime: dayjs().add(2, "day").add(8, "week").add(60, "minutes").toDate(), + // status: BookingStatus.PENDING, + // }, + // { + // uid: uuid(), + // title: "Tennis class", + // recurringEventId: Buffer.from("tennis-class").toString("base64"), + // startTime: dayjs().add(2, "day").add(10, "week").toDate(), + // endTime: dayjs().add(2, "day").add(10, "week").add(60, "minutes").toDate(), + // status: BookingStatus.PENDING, + // }, + // ], + // }, + // ], + // }); + + // await createUserAndEventType({ + // user: { + // email: "trial@example.com", + // password: "trial", + // username: "trial", + // name: "Trial Example", + // }, + // eventTypes: [ + // { + // title: "30min", + // slug: "30min", + // length: 30, + // }, + // { + // title: "60min", + // slug: "60min", + // length: 60, + // }, + // ], + // }); + + // await createUserAndEventType({ + // user: { + // email: "free@example.com", + // password: "free", + // username: "free", + // name: "Free Example", + // }, + // eventTypes: [ + // { + // title: "30min", + // slug: "30min", + // length: 30, + // }, + // { + // title: "60min", + // slug: "60min", + // length: 30, + // }, + // ], + // }); + + // await createUserAndEventType({ + // user: { + // email: "usa@example.com", + // password: "usa", + // username: "usa", + // name: "USA Timezone Example", + // timeZone: "America/Phoenix", + // }, + // eventTypes: [ + // { + // title: "30min", + // slug: "30min", + // length: 30, + // }, + // ], + // }); + + // const freeUserTeam = await createUserAndEventType({ + // user: { + // email: "teamfree@example.com", + // password: "teamfree", + // username: "teamfree", + // name: "Team Free Example", + // }, + // }); + + // const proUserTeam = await createUserAndEventType({ + // user: { + // email: "teampro@example.com", + // password: "teampro", + // username: "teampro", + // name: "Team Pro Example", + // }, + // }); + + // await createUserAndEventType({ + // user: { + // email: "admin@example.com", + // /** To comply with admin password requirements */ + // password: "ADMINadmin2022!", + // username: "admin", + // name: "Admin Example", + // role: "ADMIN", + // }, + // }); await createPlatformAndSetupUser({ teamInput: { @@ -988,387 +988,388 @@ async function main() { createdAt: new Date(), }, user: { - email: "platform@example.com", + email: "platform@collegecontactcalendar.com", /** To comply with admin password requirements */ - password: "PLATFORMadmin2024!", + password: "PLATFORMLeah1602!", username: "platform", name: "Platform Admin", role: "USER", + }, }); - const pro2UserTeam = await createUserAndEventType({ - user: { - email: "teampro2@example.com", - password: "teampro2", - username: "teampro2", - name: "Team Pro Example 2", - }, - }); - - const pro3UserTeam = await createUserAndEventType({ - user: { - email: "teampro3@example.com", - password: "teampro3", - username: "teampro3", - name: "Team Pro Example 3", - }, - }); - - const pro4UserTeam = await createUserAndEventType({ - user: { - email: "teampro4@example.com", - password: "teampro4", - username: "teampro4", - name: "Team Pro Example 4", - }, - }); - - if (!!(process.env.E2E_TEST_CALCOM_QA_EMAIL && process.env.E2E_TEST_CALCOM_QA_PASSWORD)) { - await createUserAndEventType({ - user: { - email: process.env.E2E_TEST_CALCOM_QA_EMAIL || "qa@example.com", - password: process.env.E2E_TEST_CALCOM_QA_PASSWORD || "qa", - username: "qa", - name: "QA Example", - }, - eventTypes: [ - { - title: "15min", - slug: "15min", - length: 15, - }, - ], - credentials: [ - !!process.env.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS - ? { - type: "google_calendar", - key: JSON.parse(process.env.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS) as Prisma.JsonObject, - appId: "google-calendar", - } - : null, - ], - }); - } - - await createTeamAndAddUsers( - { - name: "Seeded Team", - slug: "seeded-team", - eventTypes: { - createMany: { - data: [ - { - title: "Collective Seeded Team Event", - slug: "collective-seeded-team-event", - length: 15, - schedulingType: "COLLECTIVE", - }, - { - title: "Round Robin Seeded Team Event", - slug: "round-robin-seeded-team-event", - length: 15, - schedulingType: "ROUND_ROBIN", - }, - ], - }, - }, - createdAt: new Date(), - }, - [ - { - id: proUserTeam.id, - username: proUserTeam.name || "Unknown", - }, - { - id: freeUserTeam.id, - username: freeUserTeam.name || "Unknown", - }, - { - id: pro2UserTeam.id, - username: pro2UserTeam.name || "Unknown", - role: "MEMBER", - }, - { - id: pro3UserTeam.id, - username: pro3UserTeam.name || "Unknown", - }, - { - id: pro4UserTeam.id, - username: pro4UserTeam.name || "Unknown", - }, - ] - ); - - await createTeamAndAddUsers( - { - name: "Seeded Team (Marketing)", - slug: "seeded-team-marketing", - eventTypes: { - createMany: { - data: [ - { - title: "Collective Seeded Team Event", - slug: "collective-seeded-team-event", - length: 15, - schedulingType: "COLLECTIVE", - }, - { - title: "Round Robin Seeded Team Event", - slug: "round-robin-seeded-team-event", - length: 15, - schedulingType: "ROUND_ROBIN", - }, - ], - }, - }, - createdAt: new Date(), - }, - [ - { - id: proUserTeam.id, - username: proUserTeam.name || "Unknown", - }, - { - id: freeUserTeam.id, - username: freeUserTeam.name || "Unknown", - }, - { - id: pro2UserTeam.id, - username: pro2UserTeam.name || "Unknown", - role: "MEMBER", - }, - { - id: pro3UserTeam.id, - username: pro3UserTeam.name || "Unknown", - }, - { - id: pro4UserTeam.id, - username: pro4UserTeam.name || "Unknown", - }, - ] - ); - - await createTeamAndAddUsers( - { - name: "Seeded Team (Design)", - slug: "seeded-team-design", - eventTypes: { - createMany: { - data: [ - { - title: "Collective Seeded Team Event", - slug: "collective-seeded-team-event", - length: 15, - schedulingType: "COLLECTIVE", - }, - { - title: "Round Robin Seeded Team Event", - slug: "round-robin-seeded-team-event", - length: 15, - schedulingType: "ROUND_ROBIN", - }, - ], - }, - }, - createdAt: new Date(), - }, - [ - { - id: proUserTeam.id, - username: proUserTeam.name || "Unknown", - }, - { - id: freeUserTeam.id, - username: freeUserTeam.name || "Unknown", - }, - { - id: pro2UserTeam.id, - username: pro2UserTeam.name || "Unknown", - role: "MEMBER", - }, - { - id: pro3UserTeam.id, - username: pro3UserTeam.name || "Unknown", - }, - { - id: pro4UserTeam.id, - username: pro4UserTeam.name || "Unknown", - }, - ] - ); - - await createOrganizationAndAddMembersAndTeams({ - org: { - orgData: { - name: "Acme Inc", - slug: "acme", - isOrganization: true, - organizationSettings: { - isOrganizationVerified: true, - orgAutoAcceptEmail: "acme.com", - isAdminAPIEnabled: true, - isAdminReviewed: true, - }, - }, - members: [ - { - memberData: { - email: "owner1-acme@example.com", - password: { - create: { - hash: "owner1-acme", - }, - }, - username: "owner1-acme", - name: "Owner 1", - }, - orgMembership: { - role: "OWNER", - accepted: true, - }, - orgProfile: { - username: "owner1", - }, - inTeams: [ - { - slug: "team1", - role: "ADMIN", - }, - ], - }, - ...Array.from({ length: 10 }, (_, i) => ({ - memberData: { - email: `member${i}-acme@example.com`, - password: { - create: { - hash: `member${i}-acme`, - }, - }, - username: `member${i}-acme`, - name: `Member ${i}`, - }, - orgMembership: { - role: MembershipRole.MEMBER, - accepted: true, - }, - orgProfile: { - username: `member${i}`, - }, - inTeams: - i % 2 === 0 - ? [ - { - slug: "team1", - role: MembershipRole.MEMBER, - }, - ] - : [], - })), - ], - }, - teams: [ - { - teamData: { - name: "Team 1", - slug: "team1", - }, - nonOrgMembers: [ - { - email: "non-acme-member-1@example.com", - password: { - create: { - hash: "non-acme-member-1", - }, - }, - username: "non-acme-member-1", - name: "NonAcme Member1", - }, - ], - }, - ], - usersOutsideOrg: [ - { - name: "Jane Doe", - email: "jane@acme.com", - username: "jane-outside-org", - }, - ], - }); - - await createOrganizationAndAddMembersAndTeams({ - org: { - orgData: { - name: "Dunder Mifflin", - slug: "dunder-mifflin", - isOrganization: true, - organizationSettings: { - isOrganizationVerified: true, - orgAutoAcceptEmail: "dunder-mifflin.com", - isAdminReviewed: true, - }, - }, - members: [ - { - memberData: { - email: "owner1-dunder@example.com", - password: { - create: { - hash: "owner1-dunder", - }, - }, - username: "owner1-dunder", - name: "Owner 1", - }, - orgMembership: { - role: "OWNER", - accepted: true, - }, - orgProfile: { - username: "owner1", - }, - inTeams: [ - { - slug: "team1", - role: "ADMIN", - }, - ], - }, - ], - }, - teams: [ - { - teamData: { - name: "Team 1", - slug: "team1", - }, - nonOrgMembers: [ - { - email: "non-dunder-member-1@example.com", - password: { - create: { - hash: "non-dunder-member-1", - }, - }, - username: "non-dunder-member-1", - name: "NonDunder Member1", - }, - ], - }, - ], - usersOutsideOrg: [ - { - name: "John Doe", - email: "john@dunder-mifflin.com", - username: "john-outside-org", - }, - ], - }); + // const pro2UserTeam = await createUserAndEventType({ + // user: { + // email: "teampro2@example.com", + // password: "teampro2", + // username: "teampro2", + // name: "Team Pro Example 2", + // }, + // }); + + // const pro3UserTeam = await createUserAndEventType({ + // user: { + // email: "teampro3@example.com", + // password: "teampro3", + // username: "teampro3", + // name: "Team Pro Example 3", + // }, + // }); + + // const pro4UserTeam = await createUserAndEventType({ + // user: { + // email: "teampro4@example.com", + // password: "teampro4", + // username: "teampro4", + // name: "Team Pro Example 4", + // }, + // }); + + // if (process.env.E2E_TEST_CALCOM_QA_EMAIL && process.env.E2E_TEST_CALCOM_QA_PASSWORD) { + // await createUserAndEventType({ + // user: { + // email: process.env.E2E_TEST_CALCOM_QA_EMAIL || "qa@example.com", + // password: process.env.E2E_TEST_CALCOM_QA_PASSWORD || "qa", + // username: "qa", + // name: "QA Example", + // }, + // eventTypes: [ + // { + // title: "15min", + // slug: "15min", + // length: 15, + // }, + // ], + // credentials: [ + // process.env.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS + // ? { + // type: "google_calendar", + // key: JSON.parse(process.env.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS) as Prisma.JsonObject, + // appId: "google-calendar", + // } + // : null, + // ], + // }); + // } + + // await createTeamAndAddUsers( + // { + // name: "Seeded Team", + // slug: "seeded-team", + // eventTypes: { + // createMany: { + // data: [ + // { + // title: "Collective Seeded Team Event", + // slug: "collective-seeded-team-event", + // length: 15, + // schedulingType: "COLLECTIVE", + // }, + // { + // title: "Round Robin Seeded Team Event", + // slug: "round-robin-seeded-team-event", + // length: 15, + // schedulingType: "ROUND_ROBIN", + // }, + // ], + // }, + // }, + // createdAt: new Date(), + // }, + // [ + // { + // id: proUserTeam.id, + // username: proUserTeam.name || "Unknown", + // }, + // { + // id: freeUserTeam.id, + // username: freeUserTeam.name || "Unknown", + // }, + // { + // id: pro2UserTeam.id, + // username: pro2UserTeam.name || "Unknown", + // role: "MEMBER", + // }, + // { + // id: pro3UserTeam.id, + // username: pro3UserTeam.name || "Unknown", + // }, + // { + // id: pro4UserTeam.id, + // username: pro4UserTeam.name || "Unknown", + // }, + // ] + // ); + + // await createTeamAndAddUsers( + // { + // name: "Seeded Team (Marketing)", + // slug: "seeded-team-marketing", + // eventTypes: { + // createMany: { + // data: [ + // { + // title: "Collective Seeded Team Event", + // slug: "collective-seeded-team-event", + // length: 15, + // schedulingType: "COLLECTIVE", + // }, + // { + // title: "Round Robin Seeded Team Event", + // slug: "round-robin-seeded-team-event", + // length: 15, + // schedulingType: "ROUND_ROBIN", + // }, + // ], + // }, + // }, + // createdAt: new Date(), + // }, + // [ + // { + // id: proUserTeam.id, + // username: proUserTeam.name || "Unknown", + // }, + // { + // id: freeUserTeam.id, + // username: freeUserTeam.name || "Unknown", + // }, + // { + // id: pro2UserTeam.id, + // username: pro2UserTeam.name || "Unknown", + // role: "MEMBER", + // }, + // { + // id: pro3UserTeam.id, + // username: pro3UserTeam.name || "Unknown", + // }, + // { + // id: pro4UserTeam.id, + // username: pro4UserTeam.name || "Unknown", + // }, + // ] + // ); + + // await createTeamAndAddUsers( + // { + // name: "Seeded Team (Design)", + // slug: "seeded-team-design", + // eventTypes: { + // createMany: { + // data: [ + // { + // title: "Collective Seeded Team Event", + // slug: "collective-seeded-team-event", + // length: 15, + // schedulingType: "COLLECTIVE", + // }, + // { + // title: "Round Robin Seeded Team Event", + // slug: "round-robin-seeded-team-event", + // length: 15, + // schedulingType: "ROUND_ROBIN", + // }, + // ], + // }, + // }, + // createdAt: new Date(), + // }, + // [ + // { + // id: proUserTeam.id, + // username: proUserTeam.name || "Unknown", + // }, + // { + // id: freeUserTeam.id, + // username: freeUserTeam.name || "Unknown", + // }, + // { + // id: pro2UserTeam.id, + // username: pro2UserTeam.name || "Unknown", + // role: "MEMBER", + // }, + // { + // id: pro3UserTeam.id, + // username: pro3UserTeam.name || "Unknown", + // }, + // { + // id: pro4UserTeam.id, + // username: pro4UserTeam.name || "Unknown", + // }, + // ] + // ); + + // await createOrganizationAndAddMembersAndTeams({ + // org: { + // orgData: { + // name: "Acme Inc", + // slug: "acme", + // isOrganization: true, + // organizationSettings: { + // isOrganizationVerified: true, + // orgAutoAcceptEmail: "acme.com", + // isAdminAPIEnabled: true, + // isAdminReviewed: true, + // }, + // }, + // members: [ + // { + // memberData: { + // email: "owner1-acme@example.com", + // password: { + // create: { + // hash: "owner1-acme", + // }, + // }, + // username: "owner1-acme", + // name: "Owner 1", + // }, + // orgMembership: { + // role: "OWNER", + // accepted: true, + // }, + // orgProfile: { + // username: "owner1", + // }, + // inTeams: [ + // { + // slug: "team1", + // role: "ADMIN", + // }, + // ], + // }, + // ...Array.from({ length: 10 }, (_, i) => ({ + // memberData: { + // email: `member${i}-acme@example.com`, + // password: { + // create: { + // hash: `member${i}-acme`, + // }, + // }, + // username: `member${i}-acme`, + // name: `Member ${i}`, + // }, + // orgMembership: { + // role: MembershipRole.MEMBER, + // accepted: true, + // }, + // orgProfile: { + // username: `member${i}`, + // }, + // inTeams: + // i % 2 === 0 + // ? [ + // { + // slug: "team1", + // role: MembershipRole.MEMBER, + // }, + // ] + // : [], + // })), + // ], + // }, + // teams: [ + // { + // teamData: { + // name: "Team 1", + // slug: "team1", + // }, + // nonOrgMembers: [ + // { + // email: "non-acme-member-1@example.com", + // password: { + // create: { + // hash: "non-acme-member-1", + // }, + // }, + // username: "non-acme-member-1", + // name: "NonAcme Member1", + // }, + // ], + // }, + // ], + // usersOutsideOrg: [ + // { + // name: "Jane Doe", + // email: "jane@acme.com", + // username: "jane-outside-org", + // }, + // ], + // }); + + // await createOrganizationAndAddMembersAndTeams({ + // org: { + // orgData: { + // name: "Dunder Mifflin", + // slug: "dunder-mifflin", + // isOrganization: true, + // organizationSettings: { + // isOrganizationVerified: true, + // orgAutoAcceptEmail: "dunder-mifflin.com", + // isAdminReviewed: true, + // }, + // }, + // members: [ + // { + // memberData: { + // email: "owner1-dunder@example.com", + // password: { + // create: { + // hash: "owner1-dunder", + // }, + // }, + // username: "owner1-dunder", + // name: "Owner 1", + // }, + // orgMembership: { + // role: "OWNER", + // accepted: true, + // }, + // orgProfile: { + // username: "owner1", + // }, + // inTeams: [ + // { + // slug: "team1", + // role: "ADMIN", + // }, + // ], + // }, + // ], + // }, + // teams: [ + // { + // teamData: { + // name: "Team 1", + // slug: "team1", + // }, + // nonOrgMembers: [ + // { + // email: "non-dunder-member-1@example.com", + // password: { + // create: { + // hash: "non-dunder-member-1", + // }, + // }, + // username: "non-dunder-member-1", + // name: "NonDunder Member1", + // }, + // ], + // }, + // ], + // usersOutsideOrg: [ + // { + // name: "John Doe", + // email: "john@dunder-mifflin.com", + // username: "john-outside-org", + // }, + // ], + // }); } main() - .then(() => mainAppStore()) - .then(() => mainHugeEventTypesSeed()) + // .then(() => mainAppStore()) + // .then(() => mainHugeEventTypesSeed()) .catch((e) => { console.error(e); process.exit(1); From 9c2d7f95e99f98f4c160cd50ca6caf052d41e8a4 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 15 Oct 2025 18:56:32 +0200 Subject: [PATCH 11/37] fix the port api v2 --- apps/api/v2/docker-compose.local.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/v2/docker-compose.local.yml b/apps/api/v2/docker-compose.local.yml index 87f6d4d617e..4c01538cbb9 100644 --- a/apps/api/v2/docker-compose.local.yml +++ b/apps/api/v2/docker-compose.local.yml @@ -60,7 +60,7 @@ services: condition: service_healthy environment: NODE_ENV: production - API_PORT: 5555 + API_PORT: 80 # Database DATABASE_URL: postgresql://calcom:calcom_local_password@postgres:5432/calcom From 09d073f8385fe647621717a8451c760c5c81c453 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 24 Oct 2025 10:50:32 +0200 Subject: [PATCH 12/37] update environment configuration: add VAPID_PUBLIC_KEY to .env.example, remove env.production.example, and adjust setup script to create .env file --- .env.example | 1 + apps/api/v2/env.production.example | 31 ------------------------------ apps/api/v2/setup.sh | 28 +++++++++++++-------------- apps/web/next.config.js | 11 ++++++++--- eslint.config.mjs | 22 ++++++++++++++++++++- scripts/seed.ts | 4 ++-- 6 files changed, 46 insertions(+), 51 deletions(-) delete mode 100644 apps/api/v2/env.production.example diff --git a/.env.example b/.env.example index e156f609a44..0d3d51e2916 100644 --- a/.env.example +++ b/.env.example @@ -416,6 +416,7 @@ BLACKLISTED_GUEST_EMAILS= # Used to allow browser push notifications # You can use: 'npx web-push generate-vapid-keys' to generate these keys NEXT_PUBLIC_VAPID_PUBLIC_KEY= +VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= # Mintlify chat api diff --git a/apps/api/v2/env.production.example b/apps/api/v2/env.production.example deleted file mode 100644 index 4a5f0b7c71d..00000000000 --- a/apps/api/v2/env.production.example +++ /dev/null @@ -1,31 +0,0 @@ -# Database Configuration -DB_PASSWORD=your_secure_postgres_password_here - -# Redis Configuration -REDIS_PASSWORD=your_secure_redis_password_here - -# NextAuth Secret (generate with: openssl rand -base64 32) -NEXTAUTH_SECRET=your_32_character_secret_here - -# API Configuration -API_URL=https://api.yourdomain.com -API_KEY_PREFIX=cal_ - -# Web App URL (if you have the main Cal.com app) -WEB_APP_URL=https://app.yourdomain.com - -# Cal.com License Key (optional for self-hosted) -CALCOM_LICENSE_KEY= - -# Stripe (optional, only if using billing features) -STRIPE_API_KEY= -STRIPE_WEBHOOK_SECRET= -STRIPE_TEAM_MONTHLY_PRICE_ID= - -# Sentry (optional, for error tracking) -SENTRY_DSN= - -# Domain for SSL certificate -DOMAIN=api.collegecontactcalendar.com -EMAIL=admin@collegecontactcalendar.com - diff --git a/apps/api/v2/setup.sh b/apps/api/v2/setup.sh index 3d6ab5d8760..6d05021c022 100755 --- a/apps/api/v2/setup.sh +++ b/apps/api/v2/setup.sh @@ -83,9 +83,9 @@ fi # Step 3: Generate environment file if it doesn't exist echo -e "\n${YELLOW}๐Ÿ”ง Configuring environment...${NC}" -if [ ! -f .env.production ]; then - echo "Creating .env.production from example..." - cp .env.example .env.production +if [ ! -f .env ]; then + echo "Creating .env from example..." + cp .env.example .env # Generate secure passwords DB_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-32) @@ -95,26 +95,26 @@ if [ ! -f .env.production ]; then # Replace in file (cross-platform compatible) if [[ "$OSTYPE" == "darwin"* ]]; then # macOS - sed -i '' "s/your_secure_postgres_password_here/$DB_PASSWORD/" .env.production - sed -i '' "s/your_secure_redis_password_here/$REDIS_PASSWORD/" .env.production - sed -i '' "s/your_32_character_secret_here/$NEXTAUTH_SECRET/" .env.production + sed -i '' "s/your_secure_postgres_password_here/$DB_PASSWORD/" .env + sed -i '' "s/your_secure_redis_password_here/$REDIS_PASSWORD/" .env + sed -i '' "s/your_32_character_secret_here/$NEXTAUTH_SECRET/" .env else # Linux - sed -i "s/your_secure_postgres_password_here/$DB_PASSWORD/" .env.production - sed -i "s/your_secure_redis_password_here/$REDIS_PASSWORD/" .env.production - sed -i "s/your_32_character_secret_here/$NEXTAUTH_SECRET/" .env.production + sed -i "s/your_secure_postgres_password_here/$DB_PASSWORD/" .env + sed -i "s/your_secure_redis_password_here/$REDIS_PASSWORD/" .env + sed -i "s/your_32_character_secret_here/$NEXTAUTH_SECRET/" .env fi echo -e "${GREEN}โœ… Generated secure passwords${NC}" - echo -e "${YELLOW}โš ๏ธ Please edit .env.production and update:${NC}" + echo -e "${YELLOW}โš ๏ธ Please edit .env and update:${NC}" echo " - API_URL (your domain)" echo " - DOMAIN (for SSL certificate)" echo " - EMAIL (for SSL certificate)" - read -p "Press enter to open .env.production in nano editor..." - nano .env.production + read -p "Press enter to open .env in nano editor..." + nano .env else - echo -e "${GREEN}โœ… .env.production already exists${NC}" + echo -e "${GREEN}โœ… .env already exists${NC}" fi # Step 4: Setup Cloudflare-friendly configuration @@ -159,7 +159,7 @@ echo -e "\n${YELLOW}๐Ÿงช Testing deployment...${NC}" # Get the IP address IP=$(curl -s ifconfig.me) -DOMAIN=$(grep "^DOMAIN=" .env.production | cut -d'=' -f2) +DOMAIN=$(grep "^DOMAIN=" .env | cut -d'=' -f2) echo "Testing health endpoint via IP: http://$IP/health" sleep 5 diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 029b42e38eb..7ff227b001e 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -1,3 +1,6 @@ +/* eslint-env node */ +/* eslint-disable no-useless-escape */ + require("dotenv").config({ path: "../../.env" }); const englishTranslation = require("./public/static/locales/en/common.json"); const { withAxiom } = require("next-axiom"); @@ -400,11 +403,12 @@ const nextConfig = (phase) => { }, */ ]; - if (Boolean(process.env.NEXT_PUBLIC_API_V2_URL)) { - afterFiles.push({ + if (process.env.NEXT_PUBLIC_API_V2_URL) { + const rewrite = { source: "/api/v2/:path*", destination: `${process.env.NEXT_PUBLIC_API_V2_URL}/:path*`, - }); + }; + afterFiles.push(rewrite); } return { @@ -625,6 +629,7 @@ const nextConfig = (phase) => { type: "query", key: "callbackUrl", // prettier-ignore + value: "^(?!https?:\/\/).*$", }, ], diff --git a/eslint.config.mjs b/eslint.config.mjs index af72d61cc0e..4e290343a3e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,3 +1,23 @@ import { config } from "@calcom/eslint-config/base"; -export default config; +export default [ + ...config, + { + files: ["**/*.config.js", "**/next.config.js"], + languageOptions: { + sourceType: "commonjs", + globals: { + __dirname: "readonly", + __filename: "readonly", + exports: "writable", + module: "writable", + require: "readonly", + process: "readonly", + }, + }, + rules: { + "@typescript-eslint/no-require-imports": "off", + "@typescript-eslint/no-var-requires": "off", + }, + }, +]; \ No newline at end of file diff --git a/scripts/seed.ts b/scripts/seed.ts index 54f4e5ec945..9faf72faa49 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -990,11 +990,11 @@ async function main() { user: { email: "platform@collegecontactcalendar.com", /** To comply with admin password requirements */ - password: "PLATFORMLeah1602!", + password: "PLATFORMadmin2024!", // Changed it :) username: "platform", name: "Platform Admin", role: "USER", - + }, }); From d4b598c32523f79579bbb3985fbbee9b783dba7c Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 24 Oct 2025 10:57:07 +0200 Subject: [PATCH 13/37] added docs --- docs/AWS-EC2-SETUP.md | 686 ++++++++++++++++++++++++++++++++++++++++++ docs/DEPLOYMENT.md | 316 +++++++++++++++++++ docs/README.EC2.md | 258 ++++++++++++++++ 3 files changed, 1260 insertions(+) create mode 100644 docs/AWS-EC2-SETUP.md create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/README.EC2.md diff --git a/docs/AWS-EC2-SETUP.md b/docs/AWS-EC2-SETUP.md new file mode 100644 index 00000000000..51a52ee7b3f --- /dev/null +++ b/docs/AWS-EC2-SETUP.md @@ -0,0 +1,686 @@ +# ๐Ÿš€ Complete AWS EC2 Setup Guide for Cal.com API v2 + +**Step-by-step guide to launch an EC2 instance, configure DNS, and deploy Cal.com API v2 with HTTPS.** + +Domain: `api.collegecontactcalendar.com` +Email: `admin@collegecontactcalendar.com` + +--- + +## ๐Ÿ“‹ Prerequisites + +- AWS Account with billing enabled +- Domain name `collegecontactcalendar.com` (registered anywhere - GoDaddy, Namecheap, Route53, etc.) +- Access to domain's DNS settings +- SSH client installed on your computer + +--- + +## Part 1: Launch EC2 Instance + +### Step 1: Login to AWS Console + +1. Go to [AWS Console](https://console.aws.amazon.com) +2. Login with your credentials +3. Select a region (e.g., `us-east-1` - N. Virginia) + - **Important:** Remember this region, you'll need it later + +### Step 2: Launch EC2 Instance + +1. Navigate to **EC2 Dashboard** + - Search for "EC2" in the top search bar + - Click "EC2" under Services + +2. Click **"Launch Instance"** button + +3. **Configure Instance:** + + **Name and tags:** + - Name: `cal-api-v2-production` + + **Application and OS Images (Amazon Machine Image):** + - Select: **Amazon Linux 2023 AMI** + - Architecture: **64-bit (x86)** + + **Instance type:** + - Select: **t3.medium** (recommended minimum) + - 2 vCPU, 4 GB RAM + - Upgrade to `t3.large` for better performance + + **Key pair (login):** + - Click "Create new key pair" + - Key pair name: `cal-api-v2-key` + - Key pair type: **RSA** + - Private key file format: **`.pem`** + - Click "Create key pair" + - **IMPORTANT:** Save this file! You can't download it again + - Move it to a safe location: + ```bash + # On Mac/Linux + mv ~/Downloads/cal-api-v2-key.pem ~/.ssh/ + chmod 400 ~/.ssh/cal-api-v2-key.pem + ``` + + **Network settings:** + - Click "Edit" + - Auto-assign public IP: **Enable** + - Firewall (security groups): **Create security group** + - Security group name: `cal-api-v2-sg` + - Description: `Security group for Cal.com API v2` + + **Security group rules (AWS manages the firewall):** + - โœ… **SSH (port 22)** - Source: **My IP** (restricts SSH to your current IP for security) + - Click "Add security group rule" + - Type: **HTTP** + - Port: **80** + - Source: **Anywhere (0.0.0.0/0, ::/0)** - Needed for Let's Encrypt SSL validation + - Click "Add security group rule" + - Type: **HTTPS** + - Port: **443** + - Source: **Anywhere (0.0.0.0/0, ::/0)** - Public API access + + > **Note:** We use AWS Security Groups instead of OS-level firewalls (like UFW) as this is the AWS best practice. Security Groups are stateful and managed at the network level. + + **Configure storage:** + - Size: **50 GB** (minimum) + - Volume type: **gp3** (General Purpose SSD) + - Delete on termination: **Checked** โœ… + + **Advanced details:** + - Leave as defaults + +4. **Review Summary** on the right panel + - Verify: Amazon Linux 2023, t3.medium, 50GB storage + +5. Click **"Launch instance"** + +6. Wait for instance to start (Status: Running) + +### Step 3: Allocate Elastic IP (Static IP) + +**Why?** By default, EC2 instances get a new IP every time they restart. We need a permanent IP for our DNS. + +1. In EC2 Dashboard, click **"Elastic IPs"** (left sidebar under "Network & Security") + +2. Click **"Allocate Elastic IP address"** + - Region: Make sure it matches your EC2 region + - Click "Allocate" + +3. **Associate Elastic IP with your instance:** + - Select the newly allocated IP + - Click **"Actions"** โ†’ **"Associate Elastic IP address"** + - Instance: Select `cal-api-v2-production` + - Click "Associate" + +4. **Copy your Elastic IP address** - you'll need it for DNS + - Example: `54.123.45.67` + +--- + +## Part 2: Configure DNS (Point Domain to EC2) + +### Option A: Using AWS Route 53 + +If your domain is registered with AWS Route 53: + +1. Go to **Route 53** in AWS Console + +2. Click **"Hosted zones"** + +3. Click on **`collegecontactcalendar.com`** + +4. Click **"Create record"** + +5. Configure record: + - Record name: `api` + - Record type: **A - Routes traffic to an IPv4 address** + - Value: **Your Elastic IP** (e.g., `54.123.45.67`) + - TTL: `300` (5 minutes) + - Routing policy: **Simple routing** + - Click "Create records" + +### Option B: Using External DNS Provider (GoDaddy, Namecheap, Cloudflare, etc.) + +1. **Login to your DNS provider** where you registered `collegecontactcalendar.com` + +2. **Find DNS Management/DNS Settings** + - GoDaddy: Domain Settings โ†’ DNS + - Namecheap: Domain List โ†’ Manage โ†’ Advanced DNS + - Cloudflare: DNS โ†’ Records + +3. **Create A Record:** + - Type: **A** + - Name/Host: `api` + - Value/Points to: **Your Elastic IP** (e.g., `54.123.45.67`) + - TTL: `300` or `Auto` + - Click "Save" or "Add Record" + +4. **Verify DNS Propagation** (can take 5-60 minutes): + ```bash + # On your local computer + dig api.collegecontactcalendar.com + + # Should show your Elastic IP in the ANSWER section + # Example output: + # api.collegecontactcalendar.com. 300 IN A 54.123.45.67 + ``` + + Or use online tools: + - https://dnschecker.org + - Enter: `api.collegecontactcalendar.com` + +--- + +## Part 3: Connect to EC2 Instance + +### Mac/Linux: + +```bash +# Navigate to where you saved the key +cd ~/.ssh + +# Connect to EC2 (Amazon Linux uses 'ec2-user' instead of 'ubuntu') +ssh -i cal-api-v2-key.pem ec2-user@3.138.150.165 + +# If you get "connection refused", wait a minute - instance might still be starting +``` + +## Part 4: Prepare EC2 Instance + +Once connected via SSH: + +```bash +# Update system packages (Amazon Linux uses dnf) +sudo dnf update -y + +# Install basic tools +sudo dnf install -y git curl wget nano htop + +# Note: We DO NOT configure a firewall (UFW/iptables) on AWS EC2 +# AWS Security Groups handle firewall rules at the network level (best practice) +# This is more secure and manageable than OS-level firewalls + +# Set timezone (optional) +sudo timedatectl set-timezone America/New_York # Change to your timezone + +# Increase file limits (recommended for production) +echo "* soft nofile 65536" | sudo tee -a /etc/security/limits.conf +echo "* hard nofile 65536" | sudo tee -a /etc/security/limits.conf + +# Amazon Linux 2023 already has optimized swap, but you can add more if needed +# Check current swap +free -h + +# Add additional swap if needed (optional for t3.medium) +sudo dd if=/dev/zero of=/swapfile bs=1M count=4096 +sudo chmod 600 /swapfile +sudo mkswap /swapfile +sudo swapon /swapfile +echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab + +# Verify swap +free -h +``` + +--- + +## Part 5: Clone Repository and Configure + +```bash +# Clone your Cal.com repository +git clone https://github.com/yourcollegecontact/cal.com.git calcom +cd calcom/apps/api/v2 + +# Or use the official repository: +# git clone https://github.com/calcom/cal.com.git calcom +# cd calcom/apps/api/v2 + +# Copy environment file +cp env.production.example .env + +# Edit environment file +nano .env +``` + +### Configure .env: + +The file should already have your domain, but verify: + +```bash +# Database Configuration +DB_PASSWORD=your_secure_postgres_password_here # CHANGE THIS! + +# Redis Configuration +REDIS_PASSWORD=your_secure_redis_password_here # CHANGE THIS! + +# NextAuth Secret (generate with: openssl rand -base64 32) +NEXTAUTH_SECRET=your_32_character_secret_here # CHANGE THIS! + +# API Configuration +API_URL=https://api.collegecontactcalendar.com +API_KEY_PREFIX=cal_ + +# Web App URL (if you have the main Cal.com app) +WEB_APP_URL=https://app.collegecontactcalendar.com + +# Cal.com License Key (optional for self-hosted) +CALCOM_LICENSE_KEY= + +# Stripe (optional, only if using billing features) +STRIPE_API_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_TEAM_MONTHLY_PRICE_ID= + +# Sentry (optional, for error tracking) +SENTRY_DSN= + +# Domain for SSL certificate +DOMAIN=api.collegecontactcalendar.com +EMAIL=admin@collegecontactcalendar.com +``` + +**Generate secure passwords:** +```bash +# Generate database password +openssl rand -base64 32 + +# Generate Redis password +openssl rand -base64 32 + +# Generate NextAuth secret +openssl rand -base64 32 +``` + +Copy each generated password into the appropriate field in `.env`. + +**Save and exit:** +- Press `Ctrl + X` +- Press `Y` to confirm +- Press `Enter` to save + +--- + +## Part 6: Deploy Cal.com API v2 + +### Option 1: Automated Setup (Recommended) + +```bash +# Make setup script executable +chmod +x setup.sh + +# Run automated setup +./setup.sh +``` + +The script will: +1. โœ… Install Docker & Docker Compose +2. โœ… Verify `.env` configuration +3. โœ… Ask about HTTP or HTTPS deployment +4. โœ… Obtain SSL certificate from Let's Encrypt +5. โœ… Build and start all services +6. โœ… Test the deployment + +**Follow the prompts:** +- When asked about deployment mode, choose: `2` (HTTPS with Let's Encrypt) +- Confirm domain: `api.collegecontactcalendar.com` +- Confirm email: `admin@collegecontactcalendar.com` + +### Option 2: Manual Setup + +```bash +# Install Docker +curl -fsSL https://get.docker.com -o get-docker.sh +sudo sh get-docker.sh +sudo usermod -aG docker $USER +newgrp docker + +# Install Docker Compose +sudo apt install docker-compose-plugin -y + +# Verify installation +docker --version +docker compose version + +# Start services temporarily for SSL +docker compose -f docker-compose.production.yml up -d nginx + +# Wait for nginx to start +sleep 10 + +# Obtain SSL certificate +docker compose -f docker-compose.production.yml run --rm certbot certonly \ + --webroot \ + --webroot-path=/var/www/certbot \ + -d api.collegecontactcalendar.com \ + --email admin@collegecontactcalendar.com \ + --agree-tos \ + --no-eff-email + +# Update nginx config with domain +sed -i 's/api.yourdomain.com/api.collegecontactcalendar.com/g' nginx/conf.d/api-v2.conf + +# Enable HTTPS configuration +mv nginx/conf.d/api-v2-http-only.conf nginx/conf.d/api-v2-http-only.conf.disabled 2>/dev/null || true +mv nginx/conf.d/api-v2.conf.disabled nginx/conf.d/api-v2.conf 2>/dev/null || true + +# Stop nginx +docker compose -f docker-compose.production.yml down + +# Build and start all services +docker compose -f docker-compose.production.yml up -d --build +``` + +--- + +## Part 7: Verify Deployment + +### Check Service Status + +```bash +# View all running containers +docker compose -f docker-compose.production.yml ps + +# Should show: +# - calcom-api-v2-postgres (healthy) +# - calcom-api-v2-redis (healthy) +# - calcom-api-v2 (healthy) +# - calcom-api-v2-nginx (healthy) +# - calcom-api-v2-certbot (running) +``` + +### View Logs + +```bash +# View all logs +docker compose -f docker-compose.production.yml logs -f + +# View specific service logs +docker compose -f docker-compose.production.yml logs -f api-v2 +docker compose -f docker-compose.production.yml logs -f nginx +``` + +Press `Ctrl + C` to stop viewing logs. + +### Test Endpoints + +**From your EC2 instance:** +```bash +# Test health endpoint (local) +curl http://localhost/health + +# Test HTTPS (via domain) +curl https://api.collegecontactcalendar.com/health + +# Should return: {"status":"ok"} +``` + +**From your local computer:** +```bash +# Test HTTP redirect +curl -I http://api.collegecontactcalendar.com +# Should return: 301 Moved Permanently (redirects to HTTPS) + +# Test HTTPS +curl https://api.collegecontactcalendar.com/health +# Should return: {"status":"ok"} + +# Test API docs +curl https://api.collegecontactcalendar.com/docs +# Should return HTML for API documentation +``` + +**In your browser:** +1. Visit: https://api.collegecontactcalendar.com/health + - Should show: `{"status":"ok"}` + - Browser should show ๐Ÿ”’ (secure HTTPS) + +2. Visit: https://api.collegecontactcalendar.com/docs + - Should show: API documentation page + +--- + +## Part 8: Ongoing Maintenance + +### Update Application + +```bash +# SSH to EC2 +ssh -i ~/.ssh/cal-api-v2-key.pem ec2-user@3.138.150.165 + +# Navigate to project +cd calcom/apps/api/v2 + +# Pull latest changes +git pull + +# Rebuild and restart +docker compose -f docker-compose.production.yml up -d --build +``` + +### Backup Database + +```bash +# Create backup +docker compose -f docker-compose.production.yml exec postgres \ + pg_dump -U calcom calcom > backup-$(date +%Y%m%d-%H%M%S).sql + +# Download backup to local computer (from your local terminal) +scp -i ~/.ssh/cal-api-v2-key.pem \ + ec2-user@3.138.150.165:~/calcom/apps/api/v2/backup-*.sql \ + ~/backups/ +``` + +### Monitor Logs + +```bash +# Real-time logs +docker compose -f docker-compose.production.yml logs -f + +# Last 100 lines +docker compose -f docker-compose.production.yml logs --tail=100 + +# Specific service +docker compose -f docker-compose.production.yml logs -f api-v2 +``` + +### Restart Services + +```bash +# Restart all services +docker compose -f docker-compose.production.yml restart + +# Restart specific service +docker compose -f docker-compose.production.yml restart api-v2 +``` + +### Check SSL Certificate Renewal + +```bash +# Test renewal (dry run) +docker compose -f docker-compose.production.yml run --rm certbot renew --dry-run + +# Force renewal (if needed) +docker compose -f docker-compose.production.yml run --rm certbot renew --force-renewal + +# Reload nginx to use new certificate +docker compose -f docker-compose.production.yml exec nginx nginx -s reload +``` + +Certificates auto-renew every 12 hours via the certbot container. + +--- + +## ๐ŸŽฏ Quick Reference + +### Important URLs + +- **API Health:** https://api.collegecontactcalendar.com/health +- **API Docs:** https://api.collegecontactcalendar.com/docs +- **API Base:** https://api.collegecontactcalendar.com/api/v2 + +### SSH Connection + +```bash +ssh -i ~/.ssh/cal-api-v2-key.pem ec2-user@3.138.150.165 +``` + +### Common Commands + +```bash +# View status +docker compose -f docker-compose.production.yml ps + +# View logs +docker compose -f docker-compose.production.yml logs -f + +# Restart +docker compose -f docker-compose.production.yml restart + +# Stop all +docker compose -f docker-compose.production.yml down + +# Start all +docker compose -f docker-compose.production.yml up -d +``` + +### Environment File Location + +``` +/home/ec2-user/calcom/apps/api/v2/.env +``` + +--- + +## ๐Ÿ†˜ Troubleshooting + +### Issue: Can't SSH to EC2 + +**Check:** +1. Security group allows SSH (port 22) from your IP +2. Using correct username: `ec2-user` (not ubuntu) +3. Using correct Elastic IP address: `3.138.150.165` +4. Key file has correct permissions: `chmod 400 cal-api-v2-key.pem` +5. Instance is in "Running" state in AWS Console + +**Correct SSH command:** +```bash +ssh -i ~/.ssh/cal-api-v2-key.pem ec2-user@3.138.150.165 +``` + +### Issue: Domain doesn't resolve + +**Check:** +1. DNS A record points to Elastic IP: `dig api.collegecontactcalendar.com` +2. Wait up to 1 hour for DNS propagation (usually 5-15 minutes) +3. Verify DNS resolves to your Elastic IP (3.138.150.165): + ```bash + dig api.collegecontactcalendar.com +short + # Should return: 3.138.150.165 + ``` +4. Clear your DNS cache if needed: + ```bash + # Mac + sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder + + # Windows + ipconfig /flushdns + ``` + +### Issue: SSL certificate failed + +**Check:** +1. DNS is fully propagated: `dig api.collegecontactcalendar.com` +2. Port 80 is open in security group +3. Nginx is running: `docker compose -f docker-compose.production.yml ps` +4. Check certbot logs: `docker compose -f docker-compose.production.yml logs certbot` + +**Try again:** +```bash +docker compose -f docker-compose.production.yml run --rm certbot certonly \ + --webroot --webroot-path=/var/www/certbot \ + -d api.collegecontactcalendar.com \ + --email admin@collegecontactcalendar.com \ + --agree-tos --no-eff-email +``` + +### Issue: Services won't start + +**Check logs:** +```bash +docker compose -f docker-compose.production.yml logs +``` + +**Common fixes:** +```bash +# Rebuild everything +docker compose -f docker-compose.production.yml down +docker compose -f docker-compose.production.yml up -d --build + +# Check disk space +df -h + +# Clean up Docker +docker system prune -a +``` + +### Issue: Out of memory + +```bash +# Check memory +free -h + +# Add more swap +sudo fallocate -l 8G /swapfile +sudo chmod 600 /swapfile +sudo mkswap /swapfile +sudo swapon /swapfile + +# Or upgrade instance type to t3.large +``` + +--- + +## ๐Ÿ’ฐ Cost Summary + +**Monthly AWS Costs:** +- EC2 t3.medium (on-demand): ~$30.37 +- 50GB EBS gp3 storage: ~$4.00 +- Elastic IP (while instance running): $0.00 +- Data transfer (estimated): ~$5.00 +- **Total: ~$40/month** + +**To reduce costs:** +- Use Reserved Instance (1 year): ~$18/month +- Use Spot Instance: ~$9/month (but can be terminated) + +--- + +## โœ… Security Checklist + +- [x] SSH key-based authentication (password auth disabled by default) +- [x] AWS Security Groups configured (network-level firewall - AWS best practice) + - [x] Port 22 (SSH) - Restricted to your IP + - [x] Port 80 (HTTP) - Open for SSL validation + - [x] Port 443 (HTTPS) - Open for API access +- [x] No OS-level firewall needed (Security Groups handle it) +- [x] HTTPS enabled with valid SSL certificate +- [x] All services use strong passwords +- [x] Database & Redis not exposed to internet (only localhost) +- [x] Automatic security updates (Amazon Linux default) + +--- + +## ๐Ÿ“š Additional Resources + +- [AWS EC2 Documentation](https://docs.aws.amazon.com/ec2/) +- [Let's Encrypt Documentation](https://letsencrypt.org/docs/) +- [Docker Documentation](https://docs.docker.com/) +- [Cal.com Documentation](https://cal.com/docs) + +--- + +**OS:** Amazon Linux 2023 +**Domain:** `api.collegecontactcalendar.com` +**Elastic IP:** `3.138.150.165` +**Status:** Production Ready โœ… + diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 00000000000..461c0275d0c --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,316 @@ +# Cal.com API v2 - Self-Contained EC2 Deployment Guide + +This guide helps you deploy Cal.com API v2 on a single EC2 instance with PostgreSQL, Redis, and Nginx with SSL. + +## ๐Ÿ“‹ Prerequisites + +- AWS EC2 instance (t3.medium or larger recommended) +- Ubuntu 22.04 LTS or similar +- Domain name pointed to your EC2 instance +- Docker and Docker Compose installed + +## ๐Ÿš€ Quick Start + +### 1. Install Docker & Docker Compose on EC2 + +```bash +# Update system +sudo apt update && sudo apt upgrade -y + +# Install Docker +curl -fsSL https://get.docker.com -o get-docker.sh +sudo sh get-docker.sh + +# Add user to docker group +sudo usermod -aG docker $USER +newgrp docker + +# Install Docker Compose +sudo apt install docker-compose-plugin -y + +# Verify installation +docker --version +docker compose version +``` + +### 2. Clone Your Repository + +```bash +git clone calcom +cd calcom/apps/api/v2 +``` + +### 3. Configure Environment Variables + +```bash +# Copy example env file +cp .env.example .env + +# Generate secure passwords +export DB_PASSWORD=$(openssl rand -base64 32) +export REDIS_PASSWORD=$(openssl rand -base64 32) +export NEXTAUTH_SECRET=$(openssl rand -base64 32) + +# Update .env with your values +nano .env +``` + +**Important variables to set:** +- `DB_PASSWORD` - PostgreSQL password +- `REDIS_PASSWORD` - Redis password +- `NEXTAUTH_SECRET` - Auth secret (32+ characters) +- `API_URL` - Your domain (e.g., https://api.yourdomain.com) +- `DOMAIN` - Your domain without https +- `EMAIL` - Your email for SSL certificates + +### 4. Initial Deployment (HTTP Only - Testing) + +Start with HTTP to test everything works: + +```bash +# Use HTTP-only nginx config for initial testing +mv nginx/conf.d/api-v2.conf nginx/conf.d/api-v2.conf.disabled +mv nginx/conf.d/api-v2-http-only.conf.disabled nginx/conf.d/api-v2-http-only.conf + +# Build and start services +docker compose -f docker-compose.production.yml --env-file .env up -d --build + +# Check logs +docker compose -f docker-compose.production.yml logs -f +``` + +Test the API: +```bash +curl http://your-ec2-ip/health +# Should return: {"status":"ok"} +``` + +### 5. Setup SSL with Let's Encrypt + +Once HTTP works, setup SSL: + +```bash +# Stop services +docker compose -f docker-compose.production.yml down + +# Get SSL certificate +docker compose -f docker-compose.production.yml run --rm certbot certonly \ + --webroot \ + --webroot-path=/var/www/certbot \ + -d api.yourdomain.com \ + --email admin@yourdomain.com \ + --agree-tos \ + --no-eff-email + +# Update nginx config with your domain +sed -i 's/api.yourdomain.com/your-actual-domain.com/g' nginx/conf.d/api-v2.conf + +# Switch to HTTPS config +mv nginx/conf.d/api-v2-http-only.conf nginx/conf.d/api-v2-http-only.conf.disabled +mv nginx/conf.d/api-v2.conf.disabled nginx/conf.d/api-v2.conf + +# Restart with HTTPS +docker compose -f docker-compose.production.yml up -d +``` + +### 6. Verify Deployment + +```bash +# Check all services are running +docker compose -f docker-compose.production.yml ps + +# Test HTTPS endpoint +curl https://api.yourdomain.com/health + +# Check API docs +curl https://api.yourdomain.com/docs +``` + +## ๐Ÿ”ง Management Commands + +### View Logs +```bash +# All services +docker compose -f docker-compose.production.yml logs -f + +# Specific service +docker compose -f docker-compose.production.yml logs -f api-v2 +docker compose -f docker-compose.production.yml logs -f postgres +docker compose -f docker-compose.production.yml logs -f redis +``` + +### Restart Services +```bash +# Restart all +docker compose -f docker-compose.production.yml restart + +# Restart specific service +docker compose -f docker-compose.production.yml restart api-v2 +``` + +### Update Deployment +```bash +# Pull latest code +git pull + +# Rebuild and restart +docker compose -f docker-compose.production.yml up -d --build + +# Or rebuild specific service +docker compose -f docker-compose.production.yml up -d --build api-v2 +``` + +### Database Management +```bash +# Access PostgreSQL +docker compose -f docker-compose.production.yml exec postgres psql -U calcom -d calcom + +# Backup database +docker compose -f docker-compose.production.yml exec postgres pg_dump -U calcom calcom > backup.sql + +# Restore database +cat backup.sql | docker compose -f docker-compose.production.yml exec -T postgres psql -U calcom calcom +``` + +### Redis Management +```bash +# Access Redis CLI +docker compose -f docker-compose.production.yml exec redis redis-cli -a your_redis_password + +# Monitor Redis +docker compose -f docker-compose.production.yml exec redis redis-cli -a your_redis_password monitor + +# Flush Redis cache +docker compose -f docker-compose.production.yml exec redis redis-cli -a your_redis_password FLUSHALL +``` + +## ๐Ÿ”’ Security Checklist + +- [ ] Change all default passwords in `.env` +- [ ] Setup SSL certificates with Let's Encrypt +- [ ] Configure firewall (UFW): + ```bash + sudo ufw allow 22/tcp # SSH + sudo ufw allow 80/tcp # HTTP + sudo ufw allow 443/tcp # HTTPS + sudo ufw enable + ``` +- [ ] Setup automatic security updates: + ```bash + sudo apt install unattended-upgrades + sudo dpkg-reconfigure --priority=low unattended-upgrades + ``` +- [ ] Enable Docker logging limits (add to `/etc/docker/daemon.json`): + ```json + { + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + } + } + ``` + +## ๐Ÿ“Š Monitoring + +### Health Checks +```bash +# API v2 health +curl https://api.yourdomain.com/health + +# Docker health status +docker compose -f docker-compose.production.yml ps +``` + +### Resource Usage +```bash +# Docker stats +docker stats + +# Disk usage +docker system df + +# Clean up unused resources +docker system prune -a +``` + +## ๐Ÿ†˜ Troubleshooting + +### API v2 won't start +```bash +# Check logs +docker compose -f docker-compose.production.yml logs api-v2 + +# Check if database is ready +docker compose -f docker-compose.production.yml exec postgres pg_isready -U calcom + +# Restart API v2 +docker compose -f docker-compose.production.yml restart api-v2 +``` + +### SSL Certificate Issues +```bash +# Test certificate renewal +docker compose -f docker-compose.production.yml run --rm certbot renew --dry-run + +# Force certificate renewal +docker compose -f docker-compose.production.yml run --rm certbot renew --force-renewal +``` + +### Database Connection Issues +```bash +# Check database logs +docker compose -f docker-compose.production.yml logs postgres + +# Verify connection string +docker compose -f docker-compose.production.yml exec api-v2 env | grep DATABASE_URL +``` + +### High Memory Usage +```bash +# Check memory usage +docker stats + +# Restart services to free memory +docker compose -f docker-compose.production.yml restart + +# Add swap if needed (4GB example) +sudo fallocate -l 4G /swapfile +sudo chmod 600 /swapfile +sudo mkswap /swapfile +sudo swapon /swapfile +echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab +``` + +## ๐Ÿ”„ Automatic SSL Renewal + +The certbot container automatically renews certificates. To verify: + +```bash +# Check certbot logs +docker compose -f docker-compose.production.yml logs certbot + +# Manual renewal test +docker compose -f docker-compose.production.yml run --rm certbot renew --dry-run +``` + +## ๐Ÿ“ˆ Scaling Considerations + +This single-instance setup is good for: +- Development/staging environments +- Small to medium production loads (<100 req/s) +- Organizations with <1000 users + +For larger deployments, consider: +- Separate database server (RDS/managed PostgreSQL) +- Redis cluster or managed Redis (ElastiCache) +- Multiple API v2 instances behind a load balancer +- CDN for static assets + +## ๐Ÿ”— Useful Links + +- [Cal.com Documentation](https://cal.com/docs) +- [API v2 Documentation](https://api.cal.com/docs) +- [Docker Documentation](https://docs.docker.com) +- [Let's Encrypt Documentation](https://letsencrypt.org/docs/) + diff --git a/docs/README.EC2.md b/docs/README.EC2.md new file mode 100644 index 00000000000..32bf1c450b0 --- /dev/null +++ b/docs/README.EC2.md @@ -0,0 +1,258 @@ +# ๐Ÿš€ Cal.com API v2 - Single EC2 Instance Deployment + +**Complete, self-contained deployment with PostgreSQL, Redis, and Nginx/SSL on one EC2 instance.** + +## โœจ What's Included + +- ๐Ÿ˜ **PostgreSQL 15** - Database +- ๐Ÿ”ด **Redis 7** - Cache & Queue +- ๐ŸŒ **Nginx** - Reverse Proxy with SSL +- ๐Ÿ”’ **Let's Encrypt** - Free SSL Certificates +- ๐Ÿ“ฆ **Cal.com API v2** - The actual API + +## โšก Quick Start (2 Commands!) + +```bash +# 1. Run automated setup +./setup.sh + +# 2. That's it! The script handles everything: +# - Docker installation +# - Password generation +# - SSL setup (optional) +# - Service deployment +``` + +## ๐Ÿ“‹ Manual Setup (If You Prefer) + +### 1. Prerequisites + +```bash +# Install Docker +curl -fsSL https://get.docker.com -o get-docker.sh +sudo sh get-docker.sh +sudo usermod -aG docker $USER + +# Install Docker Compose +sudo apt install docker-compose-plugin -y +``` + +### 2. Configuration + +```bash +# Copy and edit environment file +cp env.production.example .env + +# Generate secure passwords +echo "DB_PASSWORD=$(openssl rand -base64 32)" >> .env +echo "REDIS_PASSWORD=$(openssl rand -base64 32)" >> .env +echo "NEXTAUTH_SECRET=$(openssl rand -base64 32)" >> .env + +# Edit with your domain and settings +nano .env +``` + +### 3. Deploy + +**Option A: HTTP Only (Testing)** +```bash +# Use HTTP-only nginx config +mv nginx/conf.d/api-v2.conf nginx/conf.d/api-v2.conf.disabled +mv nginx/conf.d/api-v2-http-only.conf.disabled nginx/conf.d/api-v2-http-only.conf + +# Start services +docker compose -f docker-compose.production.yml up -d --build +``` + +**Option B: HTTPS with Let's Encrypt (Production)** +```bash +# Get SSL certificate +docker compose -f docker-compose.production.yml run --rm certbot certonly \ + --webroot --webroot-path=/var/www/certbot \ + -d api.yourdomain.com \ + --email admin@yourdomain.com \ + --agree-tos + +# Update nginx config with your domain +sed -i 's/api.yourdomain.com/your-domain.com/g' nginx/conf.d/api-v2.conf + +# Enable HTTPS config +mv nginx/conf.d/api-v2-http-only.conf nginx/conf.d/api-v2-http-only.conf.disabled +mv nginx/conf.d/api-v2.conf.disabled nginx/conf.d/api-v2.conf + +# Start services +docker compose -f docker-compose.production.yml up -d --build +``` + +## ๐Ÿงช Test Your Deployment + +```bash +# Health check +curl https://api.yourdomain.com/health +# Expected: {"status":"ok"} + +# View API docs +open https://api.yourdomain.com/docs + +# Check all services +docker compose -f docker-compose.production.yml ps +``` + +## ๐Ÿ“Š Daily Operations + +### View Logs +```bash +# All services +docker compose -f docker-compose.production.yml logs -f + +# Specific service +docker compose -f docker-compose.production.yml logs -f api-v2 +``` + +### Restart Services +```bash +docker compose -f docker-compose.production.yml restart +``` + +### Update Code +```bash +git pull +docker compose -f docker-compose.production.yml up -d --build +``` + +### Backup Database +```bash +docker compose -f docker-compose.production.yml exec postgres \ + pg_dump -U calcom calcom > backup-$(date +%Y%m%d).sql +``` + +### Restore Database +```bash +cat backup.sql | docker compose -f docker-compose.production.yml exec -T postgres \ + psql -U calcom calcom +``` + +## ๐Ÿ”ง Common Issues + +### Port Already in Use +```bash +# Check what's using port 80/443 +sudo lsof -i :80 +sudo lsof -i :443 + +# Stop conflicting service +sudo systemctl stop apache2 # or nginx, etc. +``` + +### SSL Certificate Failed +```bash +# Ensure DNS points to your EC2 IP +dig api.yourdomain.com + +# Check port 80 is accessible +curl http://api.yourdomain.com/.well-known/acme-challenge/test + +# Try certificate again +docker compose -f docker-compose.production.yml run --rm certbot renew --force-renewal +``` + +### API Won't Start +```bash +# Check database is ready +docker compose -f docker-compose.production.yml exec postgres pg_isready + +# Check environment variables +docker compose -f docker-compose.production.yml exec api-v2 env | grep DATABASE + +# View detailed logs +docker compose -f docker-compose.production.yml logs api-v2 --tail=100 +``` + +### Out of Disk Space +```bash +# Check disk usage +df -h +docker system df + +# Clean up +docker system prune -a --volumes +``` + +## ๐Ÿ”’ Security Checklist + +- [ ] Changed all passwords in `.env` +- [ ] SSL certificate installed and working +- [ ] Firewall configured (UFW): + ```bash + sudo ufw allow 22/tcp # SSH + sudo ufw allow 80/tcp # HTTP + sudo ufw allow 443/tcp # HTTPS + sudo ufw enable + ``` +- [ ] Automatic security updates enabled +- [ ] SSH key authentication configured (disable password auth) +- [ ] Database not exposed to public internet (only localhost) +- [ ] Redis password protected + +## ๐Ÿ’ฐ Cost Estimate (AWS) + +**Minimum Setup:** +- EC2 t3.medium: ~$30/month +- 30GB EBS storage: ~$3/month +- Data transfer: ~$5/month +- **Total: ~$38/month** + +**Recommended Production:** +- EC2 t3.large: ~$60/month +- 100GB EBS storage: ~$10/month +- Data transfer: ~$10/month +- **Total: ~$80/month** + +## ๐Ÿ“ˆ Performance & Limits + +This single-instance setup can handle: +- โœ… **~100-200 requests/second** +- โœ… **~1,000-5,000 concurrent users** +- โœ… **~100GB database size** +- โœ… **Development, staging, small production** + +**Need more?** See `DEPLOYMENT.md` for scaling options. + +## ๐Ÿ†˜ Getting Help + +1. Check logs: `docker compose -f docker-compose.production.yml logs -f` +2. View service status: `docker compose -f docker-compose.production.yml ps` +3. Read detailed guide: `cat DEPLOYMENT.md` +4. Cal.com Discord: https://cal.com/slack +5. GitHub Issues: https://github.com/calcom/cal.com/issues + +## ๐Ÿ“š File Structure + +``` +apps/api/v2/ +โ”œโ”€โ”€ docker-compose.production.yml # Main deployment config +โ”œโ”€โ”€ Dockerfile # Existing Dockerfile +โ”œโ”€โ”€ env.production.example # Environment template +โ”œโ”€โ”€ setup.sh # Automated setup script +โ”œโ”€โ”€ DEPLOYMENT.md # Detailed deployment guide +โ”œโ”€โ”€ README.EC2.md # This file +โ””โ”€โ”€ nginx/ + โ”œโ”€โ”€ nginx.conf # Main nginx config + โ””โ”€โ”€ conf.d/ + โ”œโ”€โ”€ api-v2.conf # HTTPS config + โ””โ”€โ”€ api-v2-http-only.conf # HTTP-only config +``` + +## ๐ŸŽฏ Next Steps + +1. โœ… Deploy using `./setup.sh` +2. ๐Ÿ“Š Test all endpoints +3. ๐Ÿ”’ Verify SSL is working +4. ๐Ÿ“ˆ Monitor logs and performance +5. ๐Ÿ”„ Setup backups (automated) +6. ๐Ÿšจ Configure monitoring/alerts + +--- + +**Made with โค๏ธ for easy Cal.com API v2 deployment** + From 685f649cb85adadc30be75ff0fc41a11241d0739 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 24 Oct 2025 11:02:01 +0200 Subject: [PATCH 14/37] added docs --- docs/API_SLOTS_ALL_OF_DAY.md | 88 ++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/API_SLOTS_ALL_OF_DAY.md diff --git a/docs/API_SLOTS_ALL_OF_DAY.md b/docs/API_SLOTS_ALL_OF_DAY.md new file mode 100644 index 00000000000..6e077744787 --- /dev/null +++ b/docs/API_SLOTS_ALL_OF_DAY.md @@ -0,0 +1,88 @@ +# GET /v2/slots/all-of-day + +## Overview +Aggregates available time slots across all managed users' event types for a single date. + +## Endpoint +``` +GET /v2/slots/all-of-day +``` + +## Headers +- `cal-api-version`: `2024-09-04` (required) +- `Authorization`: Bearer token or API key (if using authenticated requests) + +## Query Parameters + +| Parameter | Required | Type | Description | Example | +|-----------|----------|------|-------------|---------| +| `date` | Yes | string | ISO 8601 date | `2050-09-05` | +| `timeZone` | No | string | Time zone for formatting (defaults to UTC) | `Europe/London` | +| `format` | No | string | Use `range` for start/end times, omit for start only | `range` | + +## Response Structure + +Returns an array of objects, one per event type: + +```json +{ + "status": "success", + "data": [ + { + "eventTypeId": 123, + "eventTypeSlug": "30min", + "ownerUserId": 456, + "ownerTeamId": null, + "slotsByDate": { + "2050-09-05": [ + { "start": "2050-09-05T09:00:00.000+02:00" }, + { "start": "2050-09-05T10:00:00.000+02:00" } + ] + } + } + ] +} +``` + +With `format=range`: + +```json +{ + "status": "success", + "data": [ + { + "eventTypeId": 123, + "eventTypeSlug": "30min", + "ownerUserId": 456, + "ownerTeamId": null, + "slotsByDate": { + "2050-09-05": [ + { + "start": "2050-09-05T09:00:00.000+02:00", + "end": "2050-09-05T09:30:00.000+02:00" + }, + { + "start": "2050-09-05T10:00:00.000+02:00", + "end": "2050-09-05T10:30:00.000+02:00" + } + ] + } + } + ] +} +``` + +## Behavior + +- Only returns individual event types (non-team, non-hidden) +- Errors for individual event types are silently handled (returns empty `slotsByDate`) +- Date is validated as ISO 8601 format + +## Example Request + +```bash +curl -X GET "https://api.cal.com/v2/slots/all-of-day?date=2050-09-05&timeZone=Europe/London&format=range" \ + -H "cal-api-version: 2024-09-04" \ + -H "Authorization: Bearer YOUR_API_KEY" +``` + From 8dc57d0e110bd6b37a174d14d32dccd01e7401bb Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 24 Oct 2025 15:18:21 +0200 Subject: [PATCH 15/37] Add endpoint to fetch available slots for specific users on a given day - Implemented `GET /v2/slots/by-users` in `SlotsController` to aggregate available slots for specified user IDs. - Added validation for required parameters: `date`, `timeZone`, and `userIds`. - Enhanced `SlotsService` to handle fetching and formatting slots based on user IDs. - Created detailed documentation for the new endpoint, including query parameters and response structure. --- .../controllers/slots.controller.ts | 77 ++++++++ .../services/slots.service.ts | 114 +++++++++++ docs/API_SLOTS_BY_USERS.md | 183 ++++++++++++++++++ docs/api-reference/v2/openapi.json | 105 ++++++++++ packages/embeds/embed-core/src/embed.ts | 1 + 5 files changed, 480 insertions(+) create mode 100644 docs/API_SLOTS_BY_USERS.md diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/slots.controller.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/slots.controller.ts index a7d3dc21718..586b0dcdb08 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/slots.controller.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/slots.controller.ts @@ -283,6 +283,83 @@ export class SlotsController_2024_09_04 { }; } + @Get("/by-users") + @ApiOperation({ + summary: "Get available slots for specific users on a given day", + description: + "Aggregates available slots for the specified user IDs. Only includes non-hidden event types owned by the provided users.", + }) + @ApiQuery({ + name: "date", + required: true, + description: "ISO 8601 date format (YYYY-MM-DD)", + example: "2050-09-05", + }) + @ApiQuery({ + name: "timeZone", + required: true, + description: "IANA timezone string for slot formatting", + example: "Europe/London", + }) + @ApiQuery({ + name: "userIds", + required: true, + description: "Comma-separated list of Cal.com user IDs (max 50). Example: '1,10,11,12'", + example: "1,10,11", + }) + @ApiQuery({ + name: "format", + required: false, + description: "Format of slot times in response. Use 'range' to get start and end times.", + example: "range", + }) + @DocsResponse({ + status: 200, + description: "Available slots for specified users", + schema: { + type: "object", + example: { + status: "success", + data: [ + { + eventTypeId: 2, + eventTypeSlug: "15min", + ownerUserId: 1, + ownerTeamId: null, + slotsByDate: { + "2050-09-05": [ + { start: "2050-09-05T08:00:00.000+01:00", end: "2050-09-05T08:15:00.000+01:00" }, + ], + }, + }, + ], + }, + }, + }) + @DocsResponse({ + status: 400, + description: "Bad Request - Invalid parameters", + schema: { + type: "object", + example: { + statusCode: 400, + message: "Invalid userIds format. Must be comma-separated positive integers.", + }, + }, + }) + async getSlotsByUsers( + @Query("date") date: string, + @Query("timeZone") timeZone: string, + @Query("userIds") userIds: string, + @Query("format") format?: SlotFormat + ): Promise> { + const data = await this.slotsService.getSlotsByUsers({ date, timeZone, userIds, format }); + return { + status: SUCCESS_STATUS, + data, + }; + } + @Post("/reservations") @UseGuards(OptionalApiAuthGuard) @ApiOperation({ diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts index a1a514464b4..26586e4d54d 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts @@ -165,6 +165,120 @@ export class SlotsService_2024_09_04 { return results; } + async getSlotsByUsers(input: { date: string; timeZone: string; userIds: string; format?: SlotFormat }) { + const { date, timeZone, userIds, format } = input; + + // Validate required parameters + if (!date) { + throw new BadRequestException("Missing required parameter: date"); + } + if (!timeZone) { + throw new BadRequestException("Missing required parameter: timeZone"); + } + if (!userIds) { + throw new BadRequestException("Missing required parameter: userIds"); + } + + // Validate and parse date + const start = DateTime.fromISO(date, { zone: "utc" }).startOf("day"); + const end = DateTime.fromISO(date, { zone: "utc" }).endOf("day"); + + if (!start.isValid || !end.isValid) { + throw new BadRequestException("Invalid date format. Expected ISO 8601 like 2050-09-05"); + } + + // Parse and validate userIds + const userIdArray = userIds + .split(",") + .map((id) => id.trim()) + .filter((id) => id.length > 0); + + if (userIdArray.length === 0) { + throw new BadRequestException("userIds cannot be empty"); + } + + if (userIdArray.length > 50) { + throw new BadRequestException("Maximum 50 user IDs allowed"); + } + + const parsedUserIds: number[] = []; + for (const idStr of userIdArray) { + const parsed = parseInt(idStr, 10); + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parseFloat(idStr))) { + throw new BadRequestException( + `Invalid userIds format. Must be comma-separated positive integers. Invalid value: '${idStr}'` + ); + } + parsedUserIds.push(parsed); + } + + // Fetch event types for specified users only + const eventTypes = await this.dbRead.prisma.eventType.findMany({ + where: { + hidden: { equals: false }, + userId: { in: parsedUserIds }, + teamId: null, + }, + select: { + id: true, + slug: true, + userId: true, + teamId: true, + }, + }); + + // Early return if no event types found + if (eventTypes.length === 0) { + return []; + } + + const startIso = start.toISO(); + const endIso = end.toISO(); + + // Fetch slots for each event type in parallel + const results = await Promise.all( + eventTypes.map(async (et) => { + const internalQuery: InternalGetSlotsQuery = { + isTeamEvent: !!et.teamId, + startTime: startIso!, + endTime: endIso!, + duration: undefined, + eventTypeId: et.id, + eventTypeSlug: et.slug, + usernameList: [], + timeZone, + orgSlug: null, + rescheduleUid: null, + }; + + try { + const formatted = await this.fetchAndFormatSlots(internalQuery, format); + const formattedMap = formatted as Record; + const onlyRequested = formattedMap && formattedMap[date] ? { [date]: formattedMap[date] } : {}; + + return { + eventTypeId: et.id, + eventTypeSlug: et.slug, + ownerUserId: et.userId ?? null, + ownerTeamId: et.teamId ?? null, + slotsByDate: onlyRequested, + }; + } catch { + // Swallow per-event type errors to not fail the whole aggregation + return { + eventTypeId: et.id, + eventTypeSlug: et.slug, + ownerUserId: et.userId ?? null, + ownerTeamId: et.teamId ?? null, + slotsByDate: {}, + }; + } + }) + ); + + return results; + } + async reserveSlot(input: ReserveSlotInput_2024_09_04, authUserId?: number) { if (input.reservationDuration && !authUserId) { throw new UnauthorizedException( diff --git a/docs/API_SLOTS_BY_USERS.md b/docs/API_SLOTS_BY_USERS.md new file mode 100644 index 00000000000..92406e428a0 --- /dev/null +++ b/docs/API_SLOTS_BY_USERS.md @@ -0,0 +1,183 @@ +# GET /v2/slots/by-users + +## Overview +Fetches available time slots for a filtered subset of users on a specific date. Returns slots only for the specified user IDs, improving performance compared to fetching all users. + +## Endpoint +``` +GET /v2/slots/by-users +``` + +## Headers +- `cal-api-version`: `2024-09-04` (required) + +## Query Parameters + +| Parameter | Required | Type | Description | Example | +|-----------|----------|------|-------------|---------| +| `date` | Yes | string | ISO 8601 date (YYYY-MM-DD) | `2050-09-05` | +| `timeZone` | Yes | string | IANA timezone string | `Europe/London` | +| `userIds` | Yes | string | Comma-separated Cal.com user IDs (max 50) | `1,10,11,12` | +| `format` | No | string | Use `range` for start/end times, omit for start only | `range` | + +## Response Structure + +Returns an array of objects, one per event type owned by the specified users: + +```json +{ + "status": "success", + "data": [ + { + "eventTypeId": 2, + "eventTypeSlug": "15min", + "ownerUserId": 1, + "ownerTeamId": null, + "slotsByDate": { + "2050-09-05": [ + { "start": "2050-09-05T08:00:00.000+01:00" }, + { "start": "2050-09-05T08:15:00.000+01:00" } + ] + } + } + ] +} +``` + +With `format=range`: + +```json +{ + "status": "success", + "data": [ + { + "eventTypeId": 2, + "eventTypeSlug": "15min", + "ownerUserId": 1, + "ownerTeamId": null, + "slotsByDate": { + "2050-09-05": [ + { + "start": "2050-09-05T08:00:00.000+01:00", + "end": "2050-09-05T08:15:00.000+01:00" + }, + { + "start": "2050-09-05T08:15:00.000+01:00", + "end": "2050-09-05T08:30:00.000+01:00" + } + ] + } + } + ] +} +``` + +## Behavior + +- Only returns individual event types (non-team, non-hidden) +- Filters to only event types owned by users in the `userIds` list +- Non-existent user IDs are gracefully ignored (no error thrown) +- Returns empty array if no event types found for specified users +- Date is validated as ISO 8601 format +- Maximum 50 user IDs per request + +## Error Responses + +### 400 Bad Request - Missing Parameters +```json +{ + "statusCode": 400, + "message": "Missing required parameter: date" +} +``` + +### 400 Bad Request - Invalid Date Format +```json +{ + "statusCode": 400, + "message": "Invalid date format. Expected ISO 8601 like 2050-09-05" +} +``` + +### 400 Bad Request - Invalid userIds Format +```json +{ + "statusCode": 400, + "message": "Invalid userIds format. Must be comma-separated positive integers. Invalid value: 'abc'" +} +``` + +### 400 Bad Request - Empty userIds +```json +{ + "statusCode": 400, + "message": "userIds cannot be empty" +} +``` + +### 400 Bad Request - Too Many User IDs +```json +{ + "statusCode": 400, + "message": "Maximum 50 user IDs allowed" +} +``` + +## Example Requests + +### Basic Request (Single User) +```bash +curl -X GET "https://api.cal.com/v2/slots/by-users?date=2050-09-05&timeZone=America/New_York&userIds=1" \ + -H "cal-api-version: 2024-09-04" +``` + +### Multiple Users with Range Format +```bash +curl -X GET "https://api.cal.com/v2/slots/by-users?date=2050-09-05&timeZone=Europe/London&format=range&userIds=1,10,11,12" \ + -H "cal-api-version: 2024-09-04" +``` + +### Different Timezone +```bash +curl -X GET "https://api.cal.com/v2/slots/by-users?date=2050-09-05&timeZone=Asia/Tokyo&userIds=5,8,15" \ + -H "cal-api-version: 2024-09-04" +``` + +## Validation Rules + +1. **date**: Must be valid ISO 8601 format (YYYY-MM-DD) +2. **timeZone**: Must be valid IANA timezone string +3. **userIds**: + - Must be comma-separated + - Each ID must be a positive integer + - Maximum 50 IDs per request + - No whitespace issues (automatically trimmed) +4. **format**: Optional, accepts "range" or omit for timestamp format + +## Performance Considerations + +- Much faster than `/v2/slots/all-of-day` when querying subset of users +- Slots for each event type are fetched in parallel +- Individual event type errors are silently handled (won't fail entire request) +- Recommended to query 5-15 users at a time for optimal performance + +## Use Cases + +1. **Filtered Mentor Lists**: Get slots for mentors filtered by college, major, or favorites +2. **Team Scheduling**: Get availability for specific team members +3. **Departmental Booking**: Query specific department users +4. **Performance Optimization**: Avoid fetching slots for all users when only subset needed + +## Comparison with /v2/slots/all-of-day + +| Feature | /v2/slots/all-of-day | /v2/slots/by-users | +|---------|---------------------|-------------------| +| User Filter | All users | Specified user IDs only | +| timeZone | Optional (defaults to UTC) | Required | +| Performance | Slower with many users | Faster for subset | +| Use Case | Get overview of all availability | Get specific users' availability | + +## Authentication + +No authentication required - this is a public endpoint. + diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 972536f3180..c06e88766e4 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -11928,6 +11928,111 @@ "tags": ["Slots"] } }, + "/v2/slots/by-users": { + "get": { + "operationId": "SlotsController_2024_09_04_getSlotsByUsers", + "summary": "Get available slots for specific users on a given day", + "description": "Aggregates available slots for the specified user IDs. Only includes non-hidden event types owned by the provided users.", + "parameters": [ + { + "name": "cal-api-version", + "in": "header", + "description": "Must be set to 2024-09-04", + "required": true, + "schema": { + "type": "string", + "default": "2024-09-04" + } + }, + { + "name": "date", + "required": true, + "in": "query", + "description": "ISO 8601 date format (YYYY-MM-DD)", + "example": "2050-09-05", + "schema": { + "type": "string" + } + }, + { + "name": "timeZone", + "required": true, + "in": "query", + "description": "IANA timezone string for slot formatting", + "example": "Europe/London", + "schema": { + "type": "string" + } + }, + { + "name": "userIds", + "required": true, + "in": "query", + "description": "Comma-separated list of Cal.com user IDs (max 50). Example: '1,10,11,12'", + "example": "1,10,11", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "required": false, + "in": "query", + "description": "Format of slot times in response. Use 'range' to get start and end times.", + "example": "range", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Available slots for specified users", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "status": "success", + "data": [ + { + "eventTypeId": 2, + "eventTypeSlug": "15min", + "ownerUserId": 1, + "ownerTeamId": null, + "slotsByDate": { + "2050-09-05": [ + { + "start": "2050-09-05T08:00:00.000+01:00", + "end": "2050-09-05T08:15:00.000+01:00" + } + ] + } + } + ] + } + } + } + } + }, + "400": { + "description": "Bad Request - Invalid parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "statusCode": 400, + "message": "Invalid userIds format. Must be comma-separated positive integers." + } + } + } + } + } + }, + "tags": ["Slots"] + } + }, "/v2/slots/reservations": { "post": { "operationId": "SlotsController_2024_09_04_reserveSlot", diff --git a/packages/embeds/embed-core/src/embed.ts b/packages/embeds/embed-core/src/embed.ts index d8056f37154..2a7c9f02579 100644 --- a/packages/embeds/embed-core/src/embed.ts +++ b/packages/embeds/embed-core/src/embed.ts @@ -85,6 +85,7 @@ if (!globalCal || !globalCal.q) { initializeGlobalCalProps(); +// @ts-ignore document.head.appendChild(document.createElement("style")).innerHTML = css; // eslint-disable-next-line @typescript-eslint/ban-types From b48d765af0433dd4b561a6359518fb76ec3a2734 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 24 Oct 2025 15:25:30 +0200 Subject: [PATCH 16/37] revert web app url --- apps/api/v2/.env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/v2/.env.example b/apps/api/v2/.env.example index 5504fd547bb..740fc0bf078 100644 --- a/apps/api/v2/.env.example +++ b/apps/api/v2/.env.example @@ -22,7 +22,7 @@ STRIPE_PRICE_ID_ENTERPRISE_OVERAGE= STRIPE_API_KEY= STRIPE_WEBHOOK_SECRET= -WEB_APP_URL=https://app.collegecontactcalendar.com +WEB_APP_URL=http://localhost:3000/ CALCOM_LICENSE_KEY= API_KEY_PREFIX=cal_ GET_LICENSE_KEY_URL="https://console.cal.com/api/license" From 87be5aa80d620a89e66ca6823281596a0770efca Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 24 Oct 2025 15:28:16 +0200 Subject: [PATCH 17/37] revert scripts --- package.json | 1 - scripts/create-oauth-client.js | 204 ----------------------- scripts/create-oauth-client.ts | 291 --------------------------------- 3 files changed, 496 deletions(-) delete mode 100755 scripts/create-oauth-client.js delete mode 100755 scripts/create-oauth-client.ts diff --git a/package.json b/package.json index 0306754937c..2a159067971 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,6 @@ "i-dx": "infisical run -- turbo run dx", "i-gen-web-example-env": "infisical secrets generate-example-env --tags=web > .env.example", "i-gen-app-store-example-env": "infisical secrets generate-example-env --tags=appstore > .env.appStore.example", - "create-oauth-client": "node scripts/create-oauth-client.js", "embed-tests-quick": "turbo run embed-tests-quick", "embed-tests": "turbo run embed-tests", "env-check:app-store": "dotenv-checker --schema .env.appStore.example --env .env.appStore", diff --git a/scripts/create-oauth-client.js b/scripts/create-oauth-client.js deleted file mode 100755 index 1026ace7d1e..00000000000 --- a/scripts/create-oauth-client.js +++ /dev/null @@ -1,204 +0,0 @@ -#!/usr/bin/env node - -/** - * Simple OAuth Client Creator for Cal.com - * - * This is a simplified JavaScript version that can be run directly with Node.js - * without requiring TypeScript compilation. - * - * Usage: - * node scripts/create-oauth-client.js [permissions] - * - * Examples: - * node scripts/create-oauth-client.js 123 "My App" "http://localhost:3000/callback" - * node scripts/create-oauth-client.js 123 "My App" "http://localhost:3000/callback" "*" - * node scripts/create-oauth-client.js 123 "My App" "https://example.com/callback" "BOOKING_READ,BOOKING_WRITE" - */ - -const { sign } = require("jsonwebtoken"); -const { PrismaClient } = require("@calcom/prisma"); - -const PERMISSION_MAP = { - BOOKING_READ: 1, - BOOKING_WRITE: 2, - EVENT_TYPE_READ: 4, - EVENT_TYPE_WRITE: 8, - SCHEDULE_READ: 16, - SCHEDULE_WRITE: 32, - USER_READ: 64, - USER_WRITE: 128, - CALENDAR_READ: 256, - CALENDAR_WRITE: 512, -}; - -function transformPermissions(permissions) { - if (permissions.includes("*")) { - return Object.values(PERMISSION_MAP).reduce((acc, val) => acc | val, 0); - } - - const values = permissions.map((p) => { - if (!(p in PERMISSION_MAP)) { - throw new Error(`Invalid permission: ${p}. Valid permissions: ${Object.keys(PERMISSION_MAP).join(", ")}, *`); - } - return PERMISSION_MAP[p]; - }); - - return values.reduce((acc, val) => acc | val, 0); -} - -function generateClientSecret(data) { - const secret = process.env.CALENDSO_ENCRYPTION_KEY || process.env.NEXTAUTH_SECRET || "secret"; - return sign(data, secret); -} - -async function createOAuthClient(organizationId, name, redirectUri, permissions = ["*"]) { - const prisma = new PrismaClient(); - - try { - // Verify organization exists and has platform billing - const organization = await prisma.team.findUnique({ - where: { id: parseInt(organizationId) }, - include: { platformBilling: true } - }); - - if (!organization) { - throw new Error(`Organization with ID ${organizationId} not found`); - } - - // Check if organization has platform billing (or create it for dev/testing) - if (!organization.platformBilling) { - console.log("โš ๏ธ Organization doesn't have platform billing. Creating platform billing record..."); - await prisma.platformBilling.create({ - data: { - id: organization.id, - plan: "SCALE", - customerId: `cus_${Date.now()}`, - subscriptionId: `sub_${Date.now()}`, - }, - }); - } - - // Transform permissions to integer - const permissionsInt = transformPermissions(permissions); - - // Create the OAuth client data for JWT - const clientData = { - name: name, - permissions: permissionsInt, - redirectUris: [redirectUri], - bookingRedirectUri: "", - bookingCancelRedirectUri: "", - bookingRescheduleRedirectUri: "", - areEmailsEnabled: true, - iat: Math.floor(Date.now() / 1000), - }; - - // Generate client secret (JWT) - const clientSecret = generateClientSecret(clientData); - - // Create OAuth client in database - const oauthClient = await prisma.platformOAuthClient.create({ - data: { - name: name, - secret: clientSecret, - permissions: permissionsInt, - redirectUris: [redirectUri], - organizationId: parseInt(organizationId), - areEmailsEnabled: true, - }, - }); - - console.log("โœ… OAuth Client created successfully!"); - console.log(`๐Ÿ“‹ Client ID: ${oauthClient.id}`); - console.log(`๐Ÿ”‘ Client Secret: ${oauthClient.secret}`); - console.log(`๐Ÿข Organization ID: ${oauthClient.organizationId}`); - console.log(`๐Ÿ“ Name: ${oauthClient.name}`); - console.log(`๐Ÿ” Permissions: ${permissions.join(", ")}`); - console.log(`๐Ÿ”— Redirect URI: ${oauthClient.redirectUris.join(", ")}`); - console.log(""); - console.log("๐Ÿ”‘ OAuth Client Credentials Authentication:"); - console.log(` Headers:`); - console.log(` x-cal-client-id: ${oauthClient.id}`); - console.log(` x-cal-secret-key: ${oauthClient.secret}`); - console.log(""); - console.log("โš ๏ธ IMPORTANT: Store these credentials securely. The client secret cannot be retrieved again!"); - - return { - clientId: oauthClient.id, - clientSecret: oauthClient.secret, - }; - - } finally { - await prisma.$disconnect(); - } -} - -// CLI interface -async function main() { - const args = process.argv.slice(2); - - if (args.length === 0 || args.includes("--help") || args.includes("-h")) { - console.log(` -Simple OAuth Client Creator for Cal.com - -Usage: - node scripts/create-oauth-client.js [permissions] - -Arguments: - orgId Organization/Team ID (required) - name OAuth client name (required) - redirectUri Redirect URI (required) - permissions Permissions (comma-separated or "*" for all, default: "*") - -Available Permissions: - BOOKING_READ, BOOKING_WRITE, EVENT_TYPE_READ, EVENT_TYPE_WRITE, - SCHEDULE_READ, SCHEDULE_WRITE, USER_READ, USER_WRITE, - CALENDAR_READ, CALENDAR_WRITE, * (all permissions) - -Examples: - # Create OAuth client with all permissions - node scripts/create-oauth-client.js 123 "My App" "http://localhost:3000/callback" - - # Create OAuth client with specific permissions - node scripts/create-oauth-client.js 123 "Booking App" "https://example.com/callback" "BOOKING_READ,BOOKING_WRITE" -`); - process.exit(0); - } - - const orgId = args[0]; - const name = args[1]; - const redirectUri = args[2]; - const permissions = args[3] ? args[3].split(",").map(p => p.trim()) : ["*"]; - - if (!orgId) { - console.error("โŒ Error: orgId is required"); - console.error("Use --help for usage information"); - process.exit(1); - } - - if (!name) { - console.error("โŒ Error: name is required"); - console.error("Use --help for usage information"); - process.exit(1); - } - - if (!redirectUri) { - console.error("โŒ Error: redirectUri is required"); - console.error("Use --help for usage information"); - process.exit(1); - } - - try { - await createOAuthClient(orgId, name, redirectUri, permissions); - } catch (error) { - console.error("โŒ Error:", error.message); - process.exit(1); - } -} - -// Run the script if called directly -if (require.main === module) { - main(); -} - -module.exports = { createOAuthClient }; diff --git a/scripts/create-oauth-client.ts b/scripts/create-oauth-client.ts deleted file mode 100755 index ccfee773bc6..00000000000 --- a/scripts/create-oauth-client.ts +++ /dev/null @@ -1,291 +0,0 @@ -#!/usr/bin/env node - -/** - * OAuth Client Generator for Cal.com Platform API - * - * This script allows you to create OAuth clients for the Cal.com Platform API (v2) - * without needing to host the frontend or use API keys. - * - * Usage: - * npx tsx scripts/create-oauth-client.ts --orgId --name [options] - * - * Options: - * --orgId, -o Organization/Team ID (required) - * --name, -n OAuth client name (required) - * --redirectUri, -r Redirect URI (can be specified multiple times) - * --permissions, -p Permissions (comma-separated or "*" for all) - * --areEmailsEnabled Enable emails (default: true) - * --help, -h Show this help message - */ - -import { createHash } from "crypto"; -import { PrismaClient } from "@calcom/prisma"; -import { sign } from "jsonwebtoken"; - -const PERMISSION_MAP = { - BOOKING_READ: 1, - BOOKING_WRITE: 2, - EVENT_TYPE_READ: 4, - EVENT_TYPE_WRITE: 8, - SCHEDULE_READ: 16, - SCHEDULE_WRITE: 32, - USER_READ: 64, - USER_WRITE: 128, - CALENDAR_READ: 256, - CALENDAR_WRITE: 512, -} as const; - -interface CreateOAuthClientOptions { - organizationId: number; - name: string; - redirectUris: string[]; - permissions: string[]; - areEmailsEnabled?: boolean; - bookingRedirectUri?: string; - bookingCancelRedirectUri?: string; - bookingRescheduleRedirectUri?: string; -} - -function transformPermissions(permissions: string[]): number { - if (permissions.includes("*")) { - return Object.values(PERMISSION_MAP).reduce((acc, val) => acc | val, 0); - } - - const values = permissions.map((p) => { - const key = p as keyof typeof PERMISSION_MAP; - if (!(key in PERMISSION_MAP)) { - throw new Error(`Invalid permission: ${p}. Valid permissions: ${Object.keys(PERMISSION_MAP).join(", ")}, *`); - } - return PERMISSION_MAP[key]; - }); - - return values.reduce((acc, val) => acc | val, 0); -} - -function generateClientSecret(data: any): string { - const secret = process.env.CALENDSO_ENCRYPTION_KEY || process.env.NEXTAUTH_SECRET || "secret"; - return sign(data, secret); -} - -async function createOAuthClient(options: CreateOAuthClientOptions): Promise<{ clientId: string; clientSecret: string }> { - const prisma = new PrismaClient(); - - try { - // Verify organization exists and has platform billing - const organization = await prisma.team.findUnique({ - where: { id: options.organizationId }, - include: { platformBilling: true } - }); - - if (!organization) { - throw new Error(`Organization with ID ${options.organizationId} not found`); - } - - // Check if organization has platform billing (or create it for dev/testing) - if (!organization.platformBilling) { - console.log("โš ๏ธ Organization doesn't have platform billing. Creating platform billing record..."); - await prisma.platformBilling.create({ - data: { - id: organization.id, - plan: "SCALE", - customerId: `cus_${Date.now()}`, - subscriptionId: `sub_${Date.now()}`, - }, - }); - } - - // Transform permissions to integer - const permissionsInt = transformPermissions(options.permissions); - - // Create the OAuth client data for JWT - const clientData = { - name: options.name, - permissions: permissionsInt, - redirectUris: options.redirectUris, - bookingRedirectUri: options.bookingRedirectUri || "", - bookingCancelRedirectUri: options.bookingCancelRedirectUri || "", - bookingRescheduleRedirectUri: options.bookingRescheduleRedirectUri || "", - areEmailsEnabled: options.areEmailsEnabled ?? true, - iat: Math.floor(Date.now() / 1000), - }; - - // Generate client secret (JWT) - const clientSecret = generateClientSecret(clientData); - - // Create OAuth client in database - const oauthClient = await prisma.platformOAuthClient.create({ - data: { - name: options.name, - secret: clientSecret, - permissions: permissionsInt, - redirectUris: options.redirectUris, - organizationId: options.organizationId, - areEmailsEnabled: options.areEmailsEnabled ?? true, - bookingRedirectUri: options.bookingRedirectUri, - bookingCancelRedirectUri: options.bookingCancelRedirectUri, - bookingRescheduleRedirectUri: options.bookingRescheduleRedirectUri, - }, - }); - - console.log("โœ… OAuth Client created successfully!"); - console.log(`๐Ÿ“‹ Client ID: ${oauthClient.id}`); - console.log(`๐Ÿ”‘ Client Secret: ${oauthClient.secret}`); - console.log(`๐Ÿข Organization ID: ${oauthClient.organizationId}`); - console.log(`๐Ÿ“ Name: ${oauthClient.name}`); - console.log(`๐Ÿ” Permissions: ${options.permissions.join(", ")}`); - console.log(`๐Ÿ”— Redirect URIs: ${oauthClient.redirectUris.join(", ")}`); - console.log(""); - console.log("๐Ÿ”‘ OAuth Client Credentials Authentication:"); - console.log(` Headers:`); - console.log(` x-cal-client-id: ${oauthClient.id}`); - console.log(` x-cal-secret-key: ${oauthClient.secret}`); - console.log(""); - console.log("โš ๏ธ IMPORTANT: Store these credentials securely. The client secret cannot be retrieved again!"); - - return { - clientId: oauthClient.id, - clientSecret: oauthClient.secret, - }; - - } finally { - await prisma.$disconnect(); - } -} - -// CLI interface -async function main() { - const args = process.argv.slice(2); - - if (args.includes("--help") || args.includes("-h")) { - console.log(` -OAuth Client Generator for Cal.com Platform API - -Usage: - npx tsx scripts/create-oauth-client.ts --orgId --name [options] - -Options: - --orgId, -o Organization/Team ID (required) - --name, -n OAuth client name (required) - --redirectUri, -r Redirect URI (can be specified multiple times) - --permissions, -p Permissions (comma-separated or "*" for all) - --areEmailsEnabled Enable emails (default: true) - --bookingRedirectUri Booking redirect URI (optional) - --help, -h Show this help message - -Available Permissions: - BOOKING_READ, BOOKING_WRITE, EVENT_TYPE_READ, EVENT_TYPE_WRITE, - SCHEDULE_READ, SCHEDULE_WRITE, USER_READ, USER_WRITE, - CALENDAR_READ, CALENDAR_WRITE, * (all permissions) - -Examples: - # Create OAuth client with all permissions - npx tsx scripts/create-oauth-client.ts --orgId 123 --name "My App" --redirectUri "http://localhost:3000/callback" --permissions "*" - - # Create OAuth client with specific permissions - npx tsx scripts/create-oauth-client.ts --orgId 123 --name "Booking App" --redirectUri "https://example.com/callback" --permissions "BOOKING_READ,BOOKING_WRITE" - - # Create OAuth client with multiple redirect URIs - npx tsx scripts/create-oauth-client.ts --orgId 123 --name "Multi-env App" --redirectUri "http://localhost:3000/callback" --redirectUri "https://example.com/callback" --permissions "*" -`); - process.exit(0); - } - - // Parse command line arguments - let organizationId: number | undefined; - let name: string | undefined; - const redirectUris: string[] = []; - let permissions: string[] = ["*"]; - let areEmailsEnabled = true; - let bookingRedirectUri: string | undefined; - let bookingCancelRedirectUri: string | undefined; - let bookingRescheduleRedirectUri: string | undefined; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - const nextArg = args[i + 1]; - - switch (arg) { - case "--orgId": - case "-o": - organizationId = parseInt(nextArg); - if (isNaN(organizationId)) { - console.error("โŒ Error: --orgId must be a valid number"); - process.exit(1); - } - i++; - break; - case "--name": - case "-n": - name = nextArg; - i++; - break; - case "--redirectUri": - case "-r": - redirectUris.push(nextArg); - i++; - break; - case "--permissions": - case "-p": - permissions = nextArg.split(",").map(p => p.trim()); - i++; - break; - case "--areEmailsEnabled": - areEmailsEnabled = nextArg === "true" || nextArg === "1"; - i++; - break; - case "--bookingRedirectUri": - bookingRedirectUri = nextArg; - i++; - break; - case "--bookingCancelRedirectUri": - bookingCancelRedirectUri = nextArg; - i++; - break; - case "--bookingRescheduleRedirectUri": - bookingRescheduleRedirectUri = nextArg; - i++; - break; - } - } - - if (!organizationId) { - console.error("โŒ Error: --orgId is required"); - console.error("Use --help for usage information"); - process.exit(1); - } - - if (!name) { - console.error("โŒ Error: --name is required"); - console.error("Use --help for usage information"); - process.exit(1); - } - - if (redirectUris.length === 0) { - console.error("โŒ Error: At least one --redirectUri is required"); - console.error("Use --help for usage information"); - process.exit(1); - } - - try { - await createOAuthClient({ - organizationId, - name, - redirectUris, - permissions, - areEmailsEnabled, - bookingRedirectUri, - bookingCancelRedirectUri, - bookingRescheduleRedirectUri, - }); - } catch (error) { - console.error("โŒ Error:", error instanceof Error ? error.message : String(error)); - process.exit(1); - } -} - -// Run the script if called directly -if (require.main === module) { - main(); -} - -export { createOAuthClient }; From bf9f0d2ce6b109ebce58ae51c794f2d66f018696 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 24 Oct 2025 22:38:00 +0200 Subject: [PATCH 18/37] Update Docker image reference for API v2 in production configuration --- apps/api/v2/docker-compose.production.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/v2/docker-compose.production.yml b/apps/api/v2/docker-compose.production.yml index 4b7cf1d8a4a..1459ba42836 100644 --- a/apps/api/v2/docker-compose.production.yml +++ b/apps/api/v2/docker-compose.production.yml @@ -42,7 +42,7 @@ services: # Cal.com API v2 api-v2: - image: calcom-api-v2:latest # Use pre-built image + image: 194266086878.dkr.ecr.us-east-2.amazonaws.com/collegecontact/calcom-api-v2:latest # build: # context: ../../.. # Build from repo root to include monorepo # dockerfile: apps/api/v2/Dockerfile From f7597dbedd54cbe898d496effebe250dbafac309 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 24 Oct 2025 22:39:28 +0200 Subject: [PATCH 19/37] add docs --- docs/DEPLOYMENT_API_V2.md | 245 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 docs/DEPLOYMENT_API_V2.md diff --git a/docs/DEPLOYMENT_API_V2.md b/docs/DEPLOYMENT_API_V2.md new file mode 100644 index 00000000000..01d1f6752d9 --- /dev/null +++ b/docs/DEPLOYMENT_API_V2.md @@ -0,0 +1,245 @@ +# Cal.com API v2 Deployment Guide + +## Prerequisites + +- AWS CLI configured with ECR access +- Docker with buildx support +- Access to EC2 instance running the API + +## Building and Deploying a New Version + +### Step 1: Build and Push to ECR + +From the repository root: + +```bash +# Authenticate with ECR +aws ecr get-login-password --region us-east-2 | docker login --username AWS --password-stdin 194266086878.dkr.ecr.us-east-2.amazonaws.com + +# Build and push directly to ECR (with --push flag) +docker buildx build -f apps/api/v2/Dockerfile \ + --platform linux/amd64 \ + --build-arg DATABASE_URL="postgresql://calcom:placeholder@postgres:5432/calcom" \ + --build-arg DATABASE_DIRECT_URL="postgresql://calcom:placeholder@postgres:5432/calcom" \ + -t 194266086878.dkr.ecr.us-east-2.amazonaws.com/collegecontact/calcom-api-v2:latest \ + --push \ + . +``` + +**If `--push` fails**, build locally then push: + +```bash +# Build without --push +docker buildx build -f apps/api/v2/Dockerfile \ + --platform linux/amd64 \ + --build-arg DATABASE_URL="postgresql://calcom:placeholder@postgres:5432/calcom" \ + --build-arg DATABASE_DIRECT_URL="postgresql://calcom:placeholder@postgres:5432/calcom" \ + -t 194266086878.dkr.ecr.us-east-2.amazonaws.com/collegecontact/calcom-api-v2:latest \ + --load \ + . + +# Push manually +docker push 194266086878.dkr.ecr.us-east-2.amazonaws.com/collegecontact/calcom-api-v2:latest +``` + +### Step 2: Deploy on EC2 Instance + +SSH into your EC2 instance and run: + +```bash +# Navigate to the deployment directory +cd /path/to/cal.com/apps/api/v2 + +# Authenticate with ECR +aws ecr get-login-password --region us-east-2 | docker login --username AWS --password-stdin 194266086878.dkr.ecr.us-east-2.amazonaws.com + +# Pull the latest image +docker-compose -f docker-compose.production.yml pull api-v2 + +# Restart the service with the new image +docker-compose -f docker-compose.production.yml up -d api-v2 + +# Verify deployment +docker-compose -f docker-compose.production.yml ps +docker-compose -f docker-compose.production.yml logs -f api-v2 +``` + +### Step 3: Verify Health + +```bash +# Check health endpoint +curl http://localhost/health + +# Monitor logs for errors +docker-compose -f docker-compose.production.yml logs -f api-v2 +``` + +## Rollback + +If something goes wrong, rollback to a previous version: + +```bash +# Pull and deploy a specific version +docker pull 194266086878.dkr.ecr.us-east-2.amazonaws.com/collegecontact/calcom-api-v2:previous-tag +docker tag 194266086878.dkr.ecr.us-east-2.amazonaws.com/collegecontact/calcom-api-v2:previous-tag \ + 194266086878.dkr.ecr.us-east-2.amazonaws.com/collegecontact/calcom-api-v2:latest +docker-compose -f docker-compose.production.yml up -d api-v2 +``` + +## Deployment Checklist + +- [ ] Build completes without errors +- [ ] Image pushed to ECR successfully +- [ ] EC2 instance can pull from ECR +- [ ] All environment variables configured in `.env` +- [ ] Database migrations run (if needed) +- [ ] Health check passes +- [ ] API responds to test requests +- [ ] Logs show no critical errors + +## Troubleshooting + +### Build Fails + +- Ensure you're in the repository root +- Check Docker has enough memory (4GB+ recommended) +- Verify all dependencies are available + +### ECR Authentication Fails + +```bash +# Re-authenticate +aws ecr get-login-password --region us-east-2 | docker login --username AWS --password-stdin 194266086878.dkr.ecr.us-east-2.amazonaws.com +``` + +### Container Won't Start + +```bash +# Check logs +docker-compose -f docker-compose.production.yml logs api-v2 + +# Check environment variables +docker-compose -f docker-compose.production.yml config + +# Verify database connectivity +docker-compose -f docker-compose.production.yml exec api-v2 sh +# Inside container: +nc -zv postgres 5432 +``` + +## Next Steps: Automation + +### Option 1: GitHub Actions CI/CD + +Create `.github/workflows/deploy-api-v2.yml`: + +```yaml +name: Deploy API v2 + +on: + push: + branches: + - main + paths: + - 'apps/api/v2/**' + - 'packages/**' + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-2 + + - name: Login to Amazon ECR + uses: aws-actions/amazon-ecr-login@v1 + + - name: Build and push + run: | + docker buildx build -f apps/api/v2/Dockerfile \ + --platform linux/amd64 \ + --build-arg DATABASE_URL="postgresql://calcom:placeholder@postgres:5432/calcom" \ + --build-arg DATABASE_DIRECT_URL="postgresql://calcom:placeholder@postgres:5432/calcom" \ + -t 194266086878.dkr.ecr.us-east-2.amazonaws.com/collegecontact/calcom-api-v2:latest \ + -t 194266086878.dkr.ecr.us-east-2.amazonaws.com/collegecontact/calcom-api-v2:${{ github.sha }} \ + --push \ + . + + - name: Deploy to EC2 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + cd /path/to/cal.com/apps/api/v2 + aws ecr get-login-password --region us-east-2 | docker login --username AWS --password-stdin 194266086878.dkr.ecr.us-east-2.amazonaws.com + docker-compose -f docker-compose.production.yml pull api-v2 + docker-compose -f docker-compose.production.yml up -d api-v2 +``` + +### Option 2: Watchtower (Auto-Update on New Images) + +Add Watchtower to `docker-compose.production.yml`: + +```yaml + watchtower: + image: containrrr/watchtower + container_name: calcom-api-v2-watchtower + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ~/.docker/config.json:/config.json:ro + environment: + - WATCHTOWER_CLEANUP=true + - WATCHTOWER_POLL_INTERVAL=300 # Check every 5 minutes + - WATCHTOWER_INCLUDE_STOPPED=true + - WATCHTOWER_REVIVE_STOPPED=false + command: calcom-api-v2 + networks: + - calcom-network +``` + +Then on EC2, ensure Docker is authenticated with ECR and Watchtower will automatically pull and restart when a new `latest` image is pushed. + +### Option 3: AWS ECS/Fargate + +Migrate to AWS ECS for managed container orchestration with built-in auto-deployment on new ECR images. + +### Option 4: Simple Cron Job + +On EC2, add to crontab: + +```bash +# Edit crontab +crontab -e + +# Add (checks every 5 minutes) +*/5 * * * * cd /path/to/cal.com/apps/api/v2 && docker-compose -f docker-compose.production.yml pull -q api-v2 && docker-compose -f docker-compose.production.yml up -d api-v2 >> /var/log/api-deploy.log 2>&1 +``` + +## Recommended: Versioned Tags + +Instead of only using `latest`, use semantic versioning: + +```bash +# Build with version tag +VERSION="v2.1.0" +docker buildx build -f apps/api/v2/Dockerfile \ + --platform linux/amd64 \ + --build-arg DATABASE_URL="postgresql://calcom:placeholder@postgres:5432/calcom" \ + --build-arg DATABASE_DIRECT_URL="postgresql://calcom:placeholder@postgres:5432/calcom" \ + -t 194266086878.dkr.ecr.us-east-2.amazonaws.com/collegecontact/calcom-api-v2:latest \ + -t 194266086878.dkr.ecr.us-east-2.amazonaws.com/collegecontact/calcom-api-v2:${VERSION} \ + --push \ + . +``` + +This allows easy rollbacks to specific versions. + From b5d5e57df571eadc60bdfc0c1aa25ed4344ff8b5 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 24 Oct 2025 23:09:06 +0200 Subject: [PATCH 20/37] update docs --- docs/DEPLOYMENT_API_V2.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/DEPLOYMENT_API_V2.md b/docs/DEPLOYMENT_API_V2.md index 01d1f6752d9..898e93c4361 100644 --- a/docs/DEPLOYMENT_API_V2.md +++ b/docs/DEPLOYMENT_API_V2.md @@ -47,21 +47,21 @@ docker push 194266086878.dkr.ecr.us-east-2.amazonaws.com/collegecontact/calcom-a SSH into your EC2 instance and run: ```bash -# Navigate to the deployment directory -cd /path/to/cal.com/apps/api/v2 +# Navigate to the repository root +cd /path/to/cal.com # Authenticate with ECR aws ecr get-login-password --region us-east-2 | docker login --username AWS --password-stdin 194266086878.dkr.ecr.us-east-2.amazonaws.com # Pull the latest image -docker-compose -f docker-compose.production.yml pull api-v2 +docker-compose -f apps/api/v2/docker-compose.production.yml pull api-v2 # Restart the service with the new image -docker-compose -f docker-compose.production.yml up -d api-v2 +docker-compose -f apps/api/v2/docker-compose.production.yml up -d api-v2 # Verify deployment -docker-compose -f docker-compose.production.yml ps -docker-compose -f docker-compose.production.yml logs -f api-v2 +docker-compose -f apps/api/v2/docker-compose.production.yml ps +docker-compose -f apps/api/v2/docker-compose.production.yml logs -f api-v2 ``` ### Step 3: Verify Health @@ -83,7 +83,7 @@ If something goes wrong, rollback to a previous version: docker pull 194266086878.dkr.ecr.us-east-2.amazonaws.com/collegecontact/calcom-api-v2:previous-tag docker tag 194266086878.dkr.ecr.us-east-2.amazonaws.com/collegecontact/calcom-api-v2:previous-tag \ 194266086878.dkr.ecr.us-east-2.amazonaws.com/collegecontact/calcom-api-v2:latest -docker-compose -f docker-compose.production.yml up -d api-v2 +docker-compose -f apps/api/v2/docker-compose.production.yml up -d api-v2 ``` ## Deployment Checklist @@ -116,13 +116,13 @@ aws ecr get-login-password --region us-east-2 | docker login --username AWS --pa ```bash # Check logs -docker-compose -f docker-compose.production.yml logs api-v2 +docker-compose -f apps/api/v2/docker-compose.production.yml logs api-v2 # Check environment variables -docker-compose -f docker-compose.production.yml config +docker-compose -f apps/api/v2/docker-compose.production.yml config # Verify database connectivity -docker-compose -f docker-compose.production.yml exec api-v2 sh +docker-compose -f apps/api/v2/docker-compose.production.yml exec api-v2 sh # Inside container: nc -zv postgres 5432 ``` @@ -178,10 +178,10 @@ jobs: username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} script: | - cd /path/to/cal.com/apps/api/v2 + cd /path/to/cal.com aws ecr get-login-password --region us-east-2 | docker login --username AWS --password-stdin 194266086878.dkr.ecr.us-east-2.amazonaws.com - docker-compose -f docker-compose.production.yml pull api-v2 - docker-compose -f docker-compose.production.yml up -d api-v2 + docker-compose -f apps/api/v2/docker-compose.production.yml pull api-v2 + docker-compose -f apps/api/v2/docker-compose.production.yml up -d api-v2 ``` ### Option 2: Watchtower (Auto-Update on New Images) @@ -221,7 +221,7 @@ On EC2, add to crontab: crontab -e # Add (checks every 5 minutes) -*/5 * * * * cd /path/to/cal.com/apps/api/v2 && docker-compose -f docker-compose.production.yml pull -q api-v2 && docker-compose -f docker-compose.production.yml up -d api-v2 >> /var/log/api-deploy.log 2>&1 +*/5 * * * * cd /path/to/cal.com && docker-compose -f apps/api/v2/docker-compose.production.yml pull -q api-v2 && docker-compose -f apps/api/v2/docker-compose.production.yml up -d api-v2 >> /var/log/api-deploy.log 2>&1 ``` ## Recommended: Versioned Tags From f6bb47b50c7e320c6019e0c4e8e55c24e6485cc7 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 28 Oct 2025 13:44:36 +0100 Subject: [PATCH 21/37] updated docker compose --- apps/api/v2/docker-compose.production.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/api/v2/docker-compose.production.yml b/apps/api/v2/docker-compose.production.yml index 1459ba42836..516857aaddc 100644 --- a/apps/api/v2/docker-compose.production.yml +++ b/apps/api/v2/docker-compose.production.yml @@ -71,7 +71,8 @@ services: # Auth NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-generate_a_secure_random_32_char_string} - + JWT_SECRET: ${JWT_SECRET:-${NEXTAUTH_SECRET}} + # API Configuration API_URL: ${API_URL:-https://api.yourdomain.com} API_KEY_PREFIX: ${API_KEY_PREFIX:-cal_} @@ -80,8 +81,13 @@ services: CALCOM_LICENSE_KEY: ${CALCOM_LICENSE_KEY:-} # Web App URL - WEB_APP_URL: ${WEB_APP_URL:-https://app.cal.com} - + WEB_APP_URL: ${WEB_APP_URL:-http://api-internal} + NEXT_PUBLIC_WEBAPP_URL: ${NEXT_PUBLIC_WEBAPP_URL:-http://api-internal} + + # RESEND + EMAIL_FROM: ${EMAIL_FROM:-noreply@collegecontactcalendar.com} + RESEND_API_KEY: ${RESEND_API_KEY:-} + # Stripe (optional) STRIPE_API_KEY: ${STRIPE_API_KEY:-} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} From 5442ad12e5153325dd7d738d4c952a05a43b1e84 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 28 Oct 2025 13:46:28 +0100 Subject: [PATCH 22/37] update docs --- docs/DEPLOYMENT_API_V2.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/DEPLOYMENT_API_V2.md b/docs/DEPLOYMENT_API_V2.md index 898e93c4361..fae61ef5415 100644 --- a/docs/DEPLOYMENT_API_V2.md +++ b/docs/DEPLOYMENT_API_V2.md @@ -1,5 +1,10 @@ # Cal.com API v2 Deployment Guide +# Force restart docker container +```sh +docker-compose -f apps/api/v2/docker-compose.production.yml up -d --force-recreate --no-deps api-v2 +``` + ## Prerequisites - AWS CLI configured with ECR access From 5fce0a0ac63b37e94be77dc5111b325292d61c52 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 28 Oct 2025 14:03:03 +0100 Subject: [PATCH 23/37] update docker compose --- apps/api/v2/docker-compose.production.yml | 6 +++--- docs/{DEPLOYMENT_API_V2.md => API-V2.md} | 16 +++++++++++----- 2 files changed, 14 insertions(+), 8 deletions(-) rename docs/{DEPLOYMENT_API_V2.md => API-V2.md} (98%) diff --git a/apps/api/v2/docker-compose.production.yml b/apps/api/v2/docker-compose.production.yml index 516857aaddc..1d430b2b6f0 100644 --- a/apps/api/v2/docker-compose.production.yml +++ b/apps/api/v2/docker-compose.production.yml @@ -74,15 +74,15 @@ services: JWT_SECRET: ${JWT_SECRET:-${NEXTAUTH_SECRET}} # API Configuration - API_URL: ${API_URL:-https://api.yourdomain.com} + API_URL: ${API_URL:-https://api.collegecontactcalendar.com} API_KEY_PREFIX: ${API_KEY_PREFIX:-cal_} # License (optional for self-hosted) CALCOM_LICENSE_KEY: ${CALCOM_LICENSE_KEY:-} # Web App URL - WEB_APP_URL: ${WEB_APP_URL:-http://api-internal} - NEXT_PUBLIC_WEBAPP_URL: ${NEXT_PUBLIC_WEBAPP_URL:-http://api-internal} + WEB_APP_URL: ${WEB_APP_URL:-https://api.collegecontactcalendar.com} + NEXT_PUBLIC_WEBAPP_URL: ${NEXT_PUBLIC_WEBAPP_URL:-https://api.collegecontactcalendar.com} # RESEND EMAIL_FROM: ${EMAIL_FROM:-noreply@collegecontactcalendar.com} diff --git a/docs/DEPLOYMENT_API_V2.md b/docs/API-V2.md similarity index 98% rename from docs/DEPLOYMENT_API_V2.md rename to docs/API-V2.md index fae61ef5415..a179cbe7735 100644 --- a/docs/DEPLOYMENT_API_V2.md +++ b/docs/API-V2.md @@ -1,10 +1,5 @@ # Cal.com API v2 Deployment Guide -# Force restart docker container -```sh -docker-compose -f apps/api/v2/docker-compose.production.yml up -d --force-recreate --no-deps api-v2 -``` - ## Prerequisites - AWS CLI configured with ECR access @@ -79,6 +74,17 @@ curl http://localhost/health docker-compose -f docker-compose.production.yml logs -f api-v2 ``` +## Check API LOGS + +```sh +docker logs --tail 200 --timestamps calcom-api-v2 +``` + +## Force restart docker container +```sh +docker-compose -f apps/api/v2/docker-compose.production.yml up -d --force-recreate --no-deps api-v2 +``` + ## Rollback If something goes wrong, rollback to a previous version: From 6d9476a3340c26f9b0811b78fae745bc6895c19d Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 28 Oct 2025 14:27:21 +0100 Subject: [PATCH 24/37] update docker compose --- apps/api/v2/docker-compose.production.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/v2/docker-compose.production.yml b/apps/api/v2/docker-compose.production.yml index 1d430b2b6f0..931251eae69 100644 --- a/apps/api/v2/docker-compose.production.yml +++ b/apps/api/v2/docker-compose.production.yml @@ -85,8 +85,8 @@ services: NEXT_PUBLIC_WEBAPP_URL: ${NEXT_PUBLIC_WEBAPP_URL:-https://api.collegecontactcalendar.com} # RESEND - EMAIL_FROM: ${EMAIL_FROM:-noreply@collegecontactcalendar.com} - RESEND_API_KEY: ${RESEND_API_KEY:-} + EMAIL_FROM: noreply@collegecontactcalendar.com + RESEND_API_KEY: ${RESEND_API_KEY} # Stripe (optional) STRIPE_API_KEY: ${STRIPE_API_KEY:-} From 838edb53b6b80b0dbef7354fdc9fbbd472565766 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 29 Oct 2025 13:34:42 +0100 Subject: [PATCH 25/37] updated docker compose required env variables --- apps/api/v2/docker-compose.production.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/api/v2/docker-compose.production.yml b/apps/api/v2/docker-compose.production.yml index 931251eae69..7311c0a7c11 100644 --- a/apps/api/v2/docker-compose.production.yml +++ b/apps/api/v2/docker-compose.production.yml @@ -85,9 +85,14 @@ services: NEXT_PUBLIC_WEBAPP_URL: ${NEXT_PUBLIC_WEBAPP_URL:-https://api.collegecontactcalendar.com} # RESEND - EMAIL_FROM: noreply@collegecontactcalendar.com - RESEND_API_KEY: ${RESEND_API_KEY} - + EMAIL_FROM: ${EMAIL_FROM} + EMAIL_SERVER_HOST: ${EMAIL_SERVER_HOST} + EMAIL_SERVER_PORT: ${EMAIL_SERVER_PORT} + EMAIL_SERVER_USER: ${EMAIL_SERVER_USER} + EMAIL_SERVER_PASSWORD: ${EMAIL_SERVER_PASSWORD} + # Will override smtp server settings + RESEND_API_KEY: ${RESEND_API_KEY:-} + # Stripe (optional) STRIPE_API_KEY: ${STRIPE_API_KEY:-} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} From 6af2a96afa09eb9eb4aa34e67e7649d6102c0592 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 29 Oct 2025 14:12:36 +0100 Subject: [PATCH 26/37] updated docker compose prod --- apps/api/v2/docker-compose.production.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/api/v2/docker-compose.production.yml b/apps/api/v2/docker-compose.production.yml index 7311c0a7c11..fbd563a47ff 100644 --- a/apps/api/v2/docker-compose.production.yml +++ b/apps/api/v2/docker-compose.production.yml @@ -84,6 +84,8 @@ services: WEB_APP_URL: ${WEB_APP_URL:-https://api.collegecontactcalendar.com} NEXT_PUBLIC_WEBAPP_URL: ${NEXT_PUBLIC_WEBAPP_URL:-https://api.collegecontactcalendar.com} + LOG_LEVEL: ${LOG_LEVEL:-info} + # RESEND EMAIL_FROM: ${EMAIL_FROM} EMAIL_SERVER_HOST: ${EMAIL_SERVER_HOST} From 9723dacdb34b7ae98ef98c71e3f7d23a8625c681 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 29 Oct 2025 14:25:55 +0100 Subject: [PATCH 27/37] update docker-compose --- apps/api/v2/docker-compose.production.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/api/v2/docker-compose.production.yml b/apps/api/v2/docker-compose.production.yml index fbd563a47ff..9702dd2f69d 100644 --- a/apps/api/v2/docker-compose.production.yml +++ b/apps/api/v2/docker-compose.production.yml @@ -85,7 +85,7 @@ services: NEXT_PUBLIC_WEBAPP_URL: ${NEXT_PUBLIC_WEBAPP_URL:-https://api.collegecontactcalendar.com} LOG_LEVEL: ${LOG_LEVEL:-info} - + # RESEND EMAIL_FROM: ${EMAIL_FROM} EMAIL_SERVER_HOST: ${EMAIL_SERVER_HOST} @@ -105,7 +105,11 @@ services: # Web Push VAPID Keys (required) NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} - + + # Logging + AXIOM_TOKEN: ${AXIOM_TOKEN} + AXIOM_DATASET: ${AXIOM_DATASET} + ports: - "127.0.0.1:5555:80" networks: From 2fd256376966c7339bd119b0273d5a335893ca66 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 29 Oct 2025 19:10:54 +0100 Subject: [PATCH 28/37] remove event location --- apps/api/v2/docker-compose.production.yml | 3 +- .../features/bookings/lib/EventManager.ts | 36 +++++++++---------- .../features/bookings/lib/handleNewBooking.ts | 2 +- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/apps/api/v2/docker-compose.production.yml b/apps/api/v2/docker-compose.production.yml index 9702dd2f69d..eefbad03175 100644 --- a/apps/api/v2/docker-compose.production.yml +++ b/apps/api/v2/docker-compose.production.yml @@ -100,7 +100,8 @@ services: STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} # Sentry (optional) - NEXT_PUBLIC_SENTRY_DSN: ${SENTRY_DSN:-} + NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-} + SENTRY_DSN: ${SENTRY_DSN} # Web Push VAPID Keys (required) NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} diff --git a/packages/features/bookings/lib/EventManager.ts b/packages/features/bookings/lib/EventManager.ts index c4e6e9662be..5967e2b8a3e 100644 --- a/packages/features/bookings/lib/EventManager.ts +++ b/packages/features/bookings/lib/EventManager.ts @@ -1,4 +1,4 @@ -// eslint-disable-next-line no-restricted-imports + import { cloneDeep, merge } from "lodash"; import { v5 as uuidv5 } from "uuid"; import type { z } from "zod"; @@ -288,23 +288,23 @@ export default class EventManager { const evt = processLocation(event); // Fallback to cal video if no location is set - if (!evt.location) { - // See if cal video is enabled & has keys - const calVideo = await prisma.app.findUnique({ - where: { - slug: "daily-video", - }, - select: { - keys: true, - enabled: true, - }, - }); - - const calVideoKeys = calVideoKeysSchema.safeParse(calVideo?.keys); - - if (calVideo?.enabled && calVideoKeys.success) evt["location"] = "integrations:daily"; - log.warn("Falling back to cal video as no location is set"); - } + // if (!evt.location) { + // // See if cal video is enabled & has keys + // const calVideo = await prisma.app.findUnique({ + // where: { + // slug: "daily-video", + // }, + // select: { + // keys: true, + // enabled: true, + // }, + // }); + + // const calVideoKeys = calVideoKeysSchema.safeParse(calVideo?.keys); + + // if (calVideo?.enabled && calVideoKeys.success) evt["location"] = "integrations:daily"; + // log.warn("Falling back to cal video as no location is set"); + // } const [mainHostDestinationCalendar] = (evt.destinationCalendar as [undefined | NonNullable[number]]) ?? []; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 74f08daa95b..39b99835cc8 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1118,7 +1118,7 @@ async function handler( } else if (organizationDefaultLocation) { locationBodyString = organizationDefaultLocation; } else { - locationBodyString = "integrations:daily"; + locationBodyString = ""; // Removed } } From be163521361ac27a0c4828a364fe0069b30cb292 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 29 Oct 2025 19:24:45 +0100 Subject: [PATCH 29/37] godverdomme kut --- apps/api/v2/src/instrument.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/v2/src/instrument.ts b/apps/api/v2/src/instrument.ts index 28ac453e169..284d5589b79 100644 --- a/apps/api/v2/src/instrument.ts +++ b/apps/api/v2/src/instrument.ts @@ -8,8 +8,8 @@ if (process.env.SENTRY_DSN) { dsn: getEnv("SENTRY_DSN"), integrations: [nodeProfilingIntegration(), Sentry.prismaIntegration()], // Performance Monitoring - tracesSampleRate: getEnv("SENTRY_TRACES_SAMPLE_RATE") ?? 1.0, // Capture 100% of the transactions + tracesSampleRate: 1.0, // Capture 100% of the transactions // Set sampling rate for profiling - this is relative to tracesSampleRate - profilesSampleRate: getEnv("SENTRY_PROFILES_SAMPLE_RATE") ?? 1.0, + profilesSampleRate: 1.0, }); } From f3a33e4e04cd6323386df29a0a2eac445f63a160 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 29 Oct 2025 19:36:19 +0100 Subject: [PATCH 30/37] geen ene kut verfiiene --- apps/api/v2/docker-compose.production.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/api/v2/docker-compose.production.yml b/apps/api/v2/docker-compose.production.yml index eefbad03175..d9615cefefd 100644 --- a/apps/api/v2/docker-compose.production.yml +++ b/apps/api/v2/docker-compose.production.yml @@ -102,6 +102,8 @@ services: # Sentry (optional) NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-} SENTRY_DSN: ${SENTRY_DSN} + SENTRY_TRACES_SAMPLE_RATE: ${SENTRY_TRACES_SAMPLE_RATE:-1.0} + SENTRY_PROFILES_SAMPLE_RATE: ${SENTRY_PROFILES_SAMPLE_RATE:-1.0} # Web Push VAPID Keys (required) NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} From 5de44332d853b5a8a5a2768e70a9d6878ad6c895 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 29 Oct 2025 19:46:22 +0100 Subject: [PATCH 31/37] add daily api key --- apps/api/v2/docker-compose.production.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/api/v2/docker-compose.production.yml b/apps/api/v2/docker-compose.production.yml index d9615cefefd..09b56a2a18e 100644 --- a/apps/api/v2/docker-compose.production.yml +++ b/apps/api/v2/docker-compose.production.yml @@ -113,6 +113,9 @@ services: AXIOM_TOKEN: ${AXIOM_TOKEN} AXIOM_DATASET: ${AXIOM_DATASET} + DAILY_API_KEY: ${DAILY_API_KEY} + DAILY_SCALE_PLAN: ${DAILY_SCALE_PLAN:-false} + ports: - "127.0.0.1:5555:80" networks: From 49436be8bb318ec82d2575a2ac6812eb6498ec26 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 29 Oct 2025 19:57:35 +0100 Subject: [PATCH 32/37] revert location stuff --- .../features/bookings/lib/EventManager.ts | 34 +++++++++---------- .../features/bookings/lib/handleNewBooking.ts | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/features/bookings/lib/EventManager.ts b/packages/features/bookings/lib/EventManager.ts index 5967e2b8a3e..9e44a8bf8f2 100644 --- a/packages/features/bookings/lib/EventManager.ts +++ b/packages/features/bookings/lib/EventManager.ts @@ -288,23 +288,23 @@ export default class EventManager { const evt = processLocation(event); // Fallback to cal video if no location is set - // if (!evt.location) { - // // See if cal video is enabled & has keys - // const calVideo = await prisma.app.findUnique({ - // where: { - // slug: "daily-video", - // }, - // select: { - // keys: true, - // enabled: true, - // }, - // }); - - // const calVideoKeys = calVideoKeysSchema.safeParse(calVideo?.keys); - - // if (calVideo?.enabled && calVideoKeys.success) evt["location"] = "integrations:daily"; - // log.warn("Falling back to cal video as no location is set"); - // } + if (!evt.location) { + // See if cal video is enabled & has keys + const calVideo = await prisma.app.findUnique({ + where: { + slug: "daily-video", + }, + select: { + keys: true, + enabled: true, + }, + }); + + const calVideoKeys = calVideoKeysSchema.safeParse(calVideo?.keys); + + if (calVideo?.enabled && calVideoKeys.success) evt["location"] = "integrations:daily"; + log.warn("Falling back to cal video as no location is set"); + } const [mainHostDestinationCalendar] = (evt.destinationCalendar as [undefined | NonNullable[number]]) ?? []; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 39b99835cc8..74f08daa95b 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1118,7 +1118,7 @@ async function handler( } else if (organizationDefaultLocation) { locationBodyString = organizationDefaultLocation; } else { - locationBodyString = ""; // Removed + locationBodyString = "integrations:daily"; } } From 237076077c1e5932968b97efb69815777f484e92 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 29 Oct 2025 20:07:27 +0100 Subject: [PATCH 33/37] seed the daily app kut --- scripts/seed-app-store.ts | 256 +++++++++++++++++++------------------- 1 file changed, 128 insertions(+), 128 deletions(-) diff --git a/scripts/seed-app-store.ts b/scripts/seed-app-store.ts index 85df186b5ec..503eb52f215 100644 --- a/scripts/seed-app-store.ts +++ b/scripts/seed-app-store.ts @@ -243,57 +243,57 @@ async function createApp( export default async function main() { // Calendar apps - await createApp("apple-calendar", "applecalendar", ["calendar"], "apple_calendar"); - if ( - process.env.BASECAMP3_CLIENT_ID && - process.env.BASECAMP3_CLIENT_SECRET && - process.env.BASECAMP3_USER_AGENT - ) { - await createApp("basecamp3", "basecamp3", ["other"], "basecamp3_other", { - client_id: process.env.BASECAMP3_CLIENT_ID, - client_secret: process.env.BASECAMP3_CLIENT_SECRET, - user_agent: process.env.BASECAMP3_USER_AGENT, - }); - } - await createApp("caldav-calendar", "caldavcalendar", ["calendar"], "caldav_calendar"); - try { - const { client_secret, client_id, redirect_uris } = JSON.parse( - process.env.GOOGLE_API_CREDENTIALS || "" - ).web; - await createApp("google-calendar", "googlecalendar", ["calendar"], "google_calendar", { - client_id, - client_secret, - redirect_uris, - }); - await createApp("google-meet", "googlevideo", ["conferencing"], "google_video", { - client_id, - client_secret, - redirect_uris, - }); - } catch (e) { - if (e instanceof Error) console.error("Error adding google credentials to DB:", e.message); - } - if (process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET) { - await createApp("office365-calendar", "office365calendar", ["calendar"], "office365_calendar", { - client_id: process.env.MS_GRAPH_CLIENT_ID, - client_secret: process.env.MS_GRAPH_CLIENT_SECRET, - }); - await createApp("msteams", "office365video", ["conferencing"], "office365_video", { - client_id: process.env.MS_GRAPH_CLIENT_ID, - client_secret: process.env.MS_GRAPH_CLIENT_SECRET, - }); - } - if ( - process.env.LARK_OPEN_APP_ID && - process.env.LARK_OPEN_APP_SECRET && - process.env.LARK_OPEN_VERIFICATION_TOKEN - ) { - await createApp("lark-calendar", "larkcalendar", ["calendar"], "lark_calendar", { - app_id: process.env.LARK_OPEN_APP_ID, - app_secret: process.env.LARK_OPEN_APP_SECRET, - open_verification_token: process.env.LARK_OPEN_VERIFICATION_TOKEN, - }); - } + // await createApp("apple-calendar", "applecalendar", ["calendar"], "apple_calendar"); + // if ( + // process.env.BASECAMP3_CLIENT_ID && + // process.env.BASECAMP3_CLIENT_SECRET && + // process.env.BASECAMP3_USER_AGENT + // ) { + // await createApp("basecamp3", "basecamp3", ["other"], "basecamp3_other", { + // client_id: process.env.BASECAMP3_CLIENT_ID, + // client_secret: process.env.BASECAMP3_CLIENT_SECRET, + // user_agent: process.env.BASECAMP3_USER_AGENT, + // }); + // } + // await createApp("caldav-calendar", "caldavcalendar", ["calendar"], "caldav_calendar"); + // try { + // const { client_secret, client_id, redirect_uris } = JSON.parse( + // process.env.GOOGLE_API_CREDENTIALS || "" + // ).web; + // await createApp("google-calendar", "googlecalendar", ["calendar"], "google_calendar", { + // client_id, + // client_secret, + // redirect_uris, + // }); + // await createApp("google-meet", "googlevideo", ["conferencing"], "google_video", { + // client_id, + // client_secret, + // redirect_uris, + // }); + // } catch (e) { + // if (e instanceof Error) console.error("Error adding google credentials to DB:", e.message); + // } + // if (process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET) { + // await createApp("office365-calendar", "office365calendar", ["calendar"], "office365_calendar", { + // client_id: process.env.MS_GRAPH_CLIENT_ID, + // client_secret: process.env.MS_GRAPH_CLIENT_SECRET, + // }); + // await createApp("msteams", "office365video", ["conferencing"], "office365_video", { + // client_id: process.env.MS_GRAPH_CLIENT_ID, + // client_secret: process.env.MS_GRAPH_CLIENT_SECRET, + // }); + // } + // if ( + // process.env.LARK_OPEN_APP_ID && + // process.env.LARK_OPEN_APP_SECRET && + // process.env.LARK_OPEN_VERIFICATION_TOKEN + // ) { + // await createApp("lark-calendar", "larkcalendar", ["calendar"], "lark_calendar", { + // app_id: process.env.LARK_OPEN_APP_ID, + // app_secret: process.env.LARK_OPEN_APP_SECRET, + // open_verification_token: process.env.LARK_OPEN_VERIFICATION_TOKEN, + // }); + // } // Video apps if (process.env.DAILY_API_KEY) { await createApp("daily-video", "dailyvideo", ["conferencing"], "daily_video", { @@ -301,96 +301,96 @@ export default async function main() { scale_plan: process.env.DAILY_SCALE_PLAN, }); } - if (process.env.TANDEM_CLIENT_ID && process.env.TANDEM_CLIENT_SECRET) { - await createApp("tandem", "tandemvideo", ["conferencing"], "tandem_video", { - client_id: process.env.TANDEM_CLIENT_ID as string, - client_secret: process.env.TANDEM_CLIENT_SECRET as string, - base_url: (process.env.TANDEM_BASE_URL as string) || "https://tandem.chat", - }); - } + // if (process.env.TANDEM_CLIENT_ID && process.env.TANDEM_CLIENT_SECRET) { + // await createApp("tandem", "tandemvideo", ["conferencing"], "tandem_video", { + // client_id: process.env.TANDEM_CLIENT_ID as string, + // client_secret: process.env.TANDEM_CLIENT_SECRET as string, + // base_url: (process.env.TANDEM_BASE_URL as string) || "https://tandem.chat", + // }); + // } if (process.env.ZOOM_CLIENT_ID && process.env.ZOOM_CLIENT_SECRET) { await createApp("zoom", "zoomvideo", ["conferencing"], "zoom_video", { client_id: process.env.ZOOM_CLIENT_ID, client_secret: process.env.ZOOM_CLIENT_SECRET, }); } - await createApp("jitsi", "jitsivideo", ["conferencing"], "jitsi_video"); + // await createApp("jitsi", "jitsivideo", ["conferencing"], "jitsi_video"); // Other apps - if (process.env.HUBSPOT_CLIENT_ID && process.env.HUBSPOT_CLIENT_SECRET) { - await createApp("hubspot", "hubspot", ["crm"], "hubspot_other_calendar", { - client_id: process.env.HUBSPOT_CLIENT_ID, - client_secret: process.env.HUBSPOT_CLIENT_SECRET, - }); - } - if (process.env.SALESFORCE_CONSUMER_KEY && process.env.SALESFORCE_CONSUMER_SECRET) { - await createApp("salesforce", "salesforce", ["crm"], "salesforce_other_calendar", { - consumer_key: process.env.SALESFORCE_CONSUMER_KEY, - consumer_secret: process.env.SALESFORCE_CONSUMER_SECRET, - }); - } - if (process.env.ZOHOCRM_CLIENT_ID && process.env.ZOHOCRM_CLIENT_SECRET) { - await createApp("zohocrm", "zohocrm", ["crm"], "zohocrm_other_calendar", { - client_id: process.env.ZOHOCRM_CLIENT_ID, - client_secret: process.env.ZOHOCRM_CLIENT_SECRET, - }); - } + // if (process.env.HUBSPOT_CLIENT_ID && process.env.HUBSPOT_CLIENT_SECRET) { + // await createApp("hubspot", "hubspot", ["crm"], "hubspot_other_calendar", { + // client_id: process.env.HUBSPOT_CLIENT_ID, + // client_secret: process.env.HUBSPOT_CLIENT_SECRET, + // }); + // } + // if (process.env.SALESFORCE_CONSUMER_KEY && process.env.SALESFORCE_CONSUMER_SECRET) { + // await createApp("salesforce", "salesforce", ["crm"], "salesforce_other_calendar", { + // consumer_key: process.env.SALESFORCE_CONSUMER_KEY, + // consumer_secret: process.env.SALESFORCE_CONSUMER_SECRET, + // }); + // } + // if (process.env.ZOHOCRM_CLIENT_ID && process.env.ZOHOCRM_CLIENT_SECRET) { + // await createApp("zohocrm", "zohocrm", ["crm"], "zohocrm_other_calendar", { + // client_id: process.env.ZOHOCRM_CLIENT_ID, + // client_secret: process.env.ZOHOCRM_CLIENT_SECRET, + // }); + // } - await createApp("wipe-my-cal", "wipemycalother", ["automation"], "wipemycal_other"); - if (process.env.GIPHY_API_KEY) { - await createApp("giphy", "giphy", ["other"], "giphy_other", { - api_key: process.env.GIPHY_API_KEY, - }); - } + // await createApp("wipe-my-cal", "wipemycalother", ["automation"], "wipemycal_other"); + // if (process.env.GIPHY_API_KEY) { + // await createApp("giphy", "giphy", ["other"], "giphy_other", { + // api_key: process.env.GIPHY_API_KEY, + // }); + // } - if (process.env.VITAL_API_KEY && process.env.VITAL_WEBHOOK_SECRET) { - await createApp("vital-automation", "vital", ["automation"], "vital_other", { - mode: process.env.VITAL_DEVELOPMENT_MODE || "sandbox", - region: process.env.VITAL_REGION || "us", - api_key: process.env.VITAL_API_KEY, - webhook_secret: process.env.VITAL_WEBHOOK_SECRET, - }); - } + // if (process.env.VITAL_API_KEY && process.env.VITAL_WEBHOOK_SECRET) { + // await createApp("vital-automation", "vital", ["automation"], "vital_other", { + // mode: process.env.VITAL_DEVELOPMENT_MODE || "sandbox", + // region: process.env.VITAL_REGION || "us", + // api_key: process.env.VITAL_API_KEY, + // webhook_secret: process.env.VITAL_WEBHOOK_SECRET, + // }); + // } - if (process.env.ZAPIER_INVITE_LINK) { - await createApp("zapier", "zapier", ["automation"], "zapier_automation", { - invite_link: process.env.ZAPIER_INVITE_LINK, - }); - } - await createApp("make", "make", ["automation"], "make_automation", { - invite_link: "https://make.com/en/hq/app-invitation/6cb2772b61966508dd8f414ba3b44510", - }); + // if (process.env.ZAPIER_INVITE_LINK) { + // await createApp("zapier", "zapier", ["automation"], "zapier_automation", { + // invite_link: process.env.ZAPIER_INVITE_LINK, + // }); + // } + // await createApp("make", "make", ["automation"], "make_automation", { + // invite_link: "https://make.com/en/hq/app-invitation/6cb2772b61966508dd8f414ba3b44510", + // }); - if (process.env.HUDDLE01_API_TOKEN) { - await createApp("huddle01", "huddle01video", ["conferencing"], "huddle01_video", { - apiKey: process.env.HUDDLE01_API_TOKEN, - }); - } + // if (process.env.HUDDLE01_API_TOKEN) { + // await createApp("huddle01", "huddle01video", ["conferencing"], "huddle01_video", { + // apiKey: process.env.HUDDLE01_API_TOKEN, + // }); + // } - // Payment apps - if ( - process.env.STRIPE_CLIENT_ID && - process.env.STRIPE_PRIVATE_KEY && - process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && - process.env.STRIPE_WEBHOOK_SECRET && - process.env.PAYMENT_FEE_FIXED && - process.env.PAYMENT_FEE_PERCENTAGE - ) { - await createApp("stripe", "stripepayment", ["payment"], "stripe_payment", { - client_id: process.env.STRIPE_CLIENT_ID, - client_secret: process.env.STRIPE_PRIVATE_KEY, - payment_fee_fixed: Number(process.env.PAYMENT_FEE_FIXED), - payment_fee_percentage: Number(process.env.PAYMENT_FEE_PERCENTAGE), - public_key: process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY, - webhook_secret: process.env.STRIPE_WEBHOOK_SECRET, - }); - } + // // Payment apps + // if ( + // process.env.STRIPE_CLIENT_ID && + // process.env.STRIPE_PRIVATE_KEY && + // process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && + // process.env.STRIPE_WEBHOOK_SECRET && + // process.env.PAYMENT_FEE_FIXED && + // process.env.PAYMENT_FEE_PERCENTAGE + // ) { + // await createApp("stripe", "stripepayment", ["payment"], "stripe_payment", { + // client_id: process.env.STRIPE_CLIENT_ID, + // client_secret: process.env.STRIPE_PRIVATE_KEY, + // payment_fee_fixed: Number(process.env.PAYMENT_FEE_FIXED), + // payment_fee_percentage: Number(process.env.PAYMENT_FEE_PERCENTAGE), + // public_key: process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY, + // webhook_secret: process.env.STRIPE_WEBHOOK_SECRET, + // }); + // } - if (process.env.CLOSECOM_CLIENT_ID && process.env.CLOSECOM_CLIENT_SECRET) { - await createApp("closecom", "closecom", ["crm"], "closecom_crm", { - client_id: process.env.CLOSECOM_CLIENT_ID, - client_secret: process.env.CLOSECOM_CLIENT_SECRET, - }); - } + // if (process.env.CLOSECOM_CLIENT_ID && process.env.CLOSECOM_CLIENT_SECRET) { + // await createApp("closecom", "closecom", ["crm"], "closecom_crm", { + // client_id: process.env.CLOSECOM_CLIENT_ID, + // client_secret: process.env.CLOSECOM_CLIENT_SECRET, + // }); + // } for (const [, app] of Object.entries(appStoreMetadata)) { if (app.isTemplate && process.argv[2] !== "seed-templates") { From 28f17928277cd41482199ee06078e2061d041344 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 30 Oct 2025 10:01:21 +0100 Subject: [PATCH 34/37] update redis config --- apps/api/v2/.env.example | 6 +++++- apps/api/v2/docker-compose.production.yml | 6 ++++-- apps/api/v2/src/app.module.ts | 4 +++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/api/v2/.env.example b/apps/api/v2/.env.example index 740fc0bf078..c57326d7377 100644 --- a/apps/api/v2/.env.example +++ b/apps/api/v2/.env.example @@ -43,4 +43,8 @@ LOGGER_BRIDGE_LOG_LEVEL="1" # 0: Don't rewrite REWRITE_API_V2_PREFIX="1" -API_URL=https://api.collegecontactcalendar.com \ No newline at end of file +API_URL=https://api.collegecontactcalendar.com + +REDIS_URL= +REDIS_PASSWORD= +REDIS_TSL=true \ No newline at end of file diff --git a/apps/api/v2/docker-compose.production.yml b/apps/api/v2/docker-compose.production.yml index 09b56a2a18e..87ca9410bb1 100644 --- a/apps/api/v2/docker-compose.production.yml +++ b/apps/api/v2/docker-compose.production.yml @@ -33,7 +33,7 @@ services: ports: - "127.0.0.1:6379:6379" healthcheck: - test: ["CMD", "redis-cli", "--raw", "incr", "ping"] + test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "${REDIS_PASSWORD:-redis_secure_password_change_me}", "ping"] interval: 10s timeout: 5s retries: 5 @@ -68,7 +68,9 @@ services: # Redis REDIS_URL: redis://:${REDIS_PASSWORD:-redis_secure_password_change_me}@redis:6379 - + REDIS_PASSWORD: ${REDIS_PASSWORD:-redis_secure_password_change_me} + REDIS_TLS: ${REDIS_TLS:-false} + # Auth NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-generate_a_secure_random_32_char_string} JWT_SECRET: ${JWT_SECRET:-${NEXTAUTH_SECRET}} diff --git a/apps/api/v2/src/app.module.ts b/apps/api/v2/src/app.module.ts index 6ec489bcba8..ae57c1c8667 100644 --- a/apps/api/v2/src/app.module.ts +++ b/apps/api/v2/src/app.module.ts @@ -34,7 +34,9 @@ import { AppController } from "./app.controller"; RedisModule, BullModule.forRoot({ - redis: `${process.env.REDIS_URL}${process.env.NODE_ENV === "production" ? "?tls=true" : ""}`, + redis: `${process.env.REDIS_URL}${ + process.env.NODE_ENV === "production" && process.env.REDIS_TLS == "true" ? "?tls=true" : "" + }`, }), ThrottlerModule.forRootAsync({ imports: [RedisModule], From 1ddcf2d6508a7389cb03c711b80e8bd4541403b6 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 6 Nov 2025 10:51:16 -0600 Subject: [PATCH 35/37] update turbo config to include redis --- turbo.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/turbo.json b/turbo.json index 33ad46ed11f..4feaadae230 100644 --- a/turbo.json +++ b/turbo.json @@ -209,6 +209,8 @@ "TWILIO_WHATSAPP_COMPLETED_CONTENT_SID", "UPSTASH_REDIS_REST_TOKEN", "UPSTASH_REDIS_REST_URL", + "REDIS_URL", + "REDIS_TLS", "UNKEY_ROOT_KEY", "USERNAME_BLACKLIST_URL", "VERCEL_ENV", From 8edbb9069185063f3032a08a11d0ec4d5c6363a3 Mon Sep 17 00:00:00 2001 From: Thomas Date: Sun, 18 Jan 2026 13:34:58 +0200 Subject: [PATCH 36/37] add migration script - remove unused endpoint all-of-day --- .../controllers/slots.controller.ts | 31 - .../services/slots.service.ts | 77 +-- docs/api-reference/v2/openapi.json | 61 -- scripts/calcom_migration.py | 568 ++++++++++++++++++ 4 files changed, 570 insertions(+), 167 deletions(-) create mode 100644 scripts/calcom_migration.py diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/slots.controller.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/slots.controller.ts index 586b0dcdb08..ba00445044c 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/slots.controller.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/slots.controller.ts @@ -252,37 +252,6 @@ export class SlotsController_2024_09_04 { }; } - @Get("/all-of-day") - @ApiOperation({ - summary: "Get all available slots for all event types on a given day", - description: - "Aggregates available slots across all non-archived, non-hidden event types for the specified date.", - }) - @ApiQuery({ name: "date", required: true, example: "2050-09-05" }) - @ApiQuery({ - name: "timeZone", - required: false, - description: "Time zone for slot formatting. Defaults to UTC.", - example: "Europe/London", - }) - @ApiQuery({ - name: "format", - required: false, - description: "Format of slot times in response. Use 'range' to get start and end times.", - example: "range", - }) - async getAllSlotsForDay( - @Query("date") date: string, - @Query("timeZone") timeZone?: string, - @Query("format") format?: SlotFormat - ): Promise> { - const data = await this.slotsService.getAllSlotsForDay({ date, timeZone, format }); - return { - status: SUCCESS_STATUS, - data, - }; - } - @Get("/by-users") @ApiOperation({ summary: "Get available slots for specific users on a given day", diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts index 26586e4d54d..d9b6d11dafc 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts @@ -92,79 +92,6 @@ export class SlotsService_2024_09_04 { return this.fetchAndFormatSlots(queryTransformed, query.format); } - async getAllSlotsForDay(input: { date: string; timeZone?: string; format?: SlotFormat }) { - const { date, timeZone, format } = input; - const start = DateTime.fromISO(date, { zone: "utc" }).startOf("day"); - const end = DateTime.fromISO(date, { zone: "utc" }).endOf("day"); - - if (!start.isValid || !end.isValid) { - throw new BadRequestException("Invalid date. Expected ISO 8601 like 2050-09-05"); - } - - const eventTypes = await this.dbRead.prisma.eventType.findMany({ - where: { - // Skip hidden event types to avoid noise - hidden: { equals: false }, - // Only individual event types (owned by a user, not a team) - userId: { not: null }, - teamId: null, - }, - select: { - id: true, - slug: true, - userId: true, - teamId: true, - }, - }); - - const startIso = start.toISO(); - const endIso = end.toISO(); - - const results = await Promise.all( - eventTypes.map(async (et) => { - const internalQuery: InternalGetSlotsQuery = { - isTeamEvent: !!et.teamId, - startTime: startIso!, - endTime: endIso!, - duration: undefined, - eventTypeId: et.id, - eventTypeSlug: et.slug, - usernameList: [], - timeZone, - orgSlug: null, - rescheduleUid: null, - }; - - try { - const formatted = await this.fetchAndFormatSlots(internalQuery, format); - // The formatter returns a map keyed by dates in the requested TZ. - // Keep only the requested date key if present. - const formattedMap = formatted as Record; - const onlyRequested = formattedMap && formattedMap[date] ? { [date]: formattedMap[date] } : {}; - - return { - eventTypeId: et.id, - eventTypeSlug: et.slug, - ownerUserId: et.userId ?? null, - ownerTeamId: et.teamId ?? null, - slotsByDate: onlyRequested, - }; - } catch { - // Swallow per-event type errors to not fail the whole aggregation - return { - eventTypeId: et.id, - eventTypeSlug: et.slug, - ownerUserId: et.userId ?? null, - ownerTeamId: et.teamId ?? null, - slotsByDate: {}, - }; - } - }) - ); - - return results; - } - async getSlotsByUsers(input: { date: string; timeZone: string; userIds: string; format?: SlotFormat }) { const { date, timeZone, userIds, format } = input; @@ -180,8 +107,8 @@ export class SlotsService_2024_09_04 { } // Validate and parse date - const start = DateTime.fromISO(date, { zone: "utc" }).startOf("day"); - const end = DateTime.fromISO(date, { zone: "utc" }).endOf("day"); + const start = DateTime.fromISO(date, { zone: timeZone }).startOf("day"); + const end = DateTime.fromISO(date, { zone: timeZone }).endOf("day"); if (!start.isValid || !end.isValid) { throw new BadRequestException("Invalid date format. Expected ISO 8601 like 2050-09-05"); diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index c06e88766e4..30efd52b029 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -11867,67 +11867,6 @@ "tags": ["Slots"] } }, - "/v2/slots/all-of-day": { - "get": { - "operationId": "SlotsController_2024_09_04_getAllSlotsForDay", - "summary": "Get all available slots for all event types on a given day", - "description": "Aggregates available slots across all non-archived, non-hidden event types for the specified date.", - "parameters": [ - { - "name": "cal-api-version", - "in": "header", - "description": "Must be set to 2024-09-04", - "required": true, - "schema": { - "type": "string", - "default": "2024-09-04" - } - }, - { - "name": "date", - "required": true, - "in": "query", - "example": "2050-09-05", - "schema": { - "type": "string" - } - }, - { - "name": "timeZone", - "required": false, - "in": "query", - "description": "Time zone for slot formatting. Defaults to UTC.", - "example": "Europe/London", - "schema": { - "type": "string" - } - }, - { - "name": "format", - "required": false, - "in": "query", - "description": "Format of slot times in response. Use 'range' to get start and end times.", - "example": "range", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - } - }, - "tags": ["Slots"] - } - }, "/v2/slots/by-users": { "get": { "operationId": "SlotsController_2024_09_04_getSlotsByUsers", diff --git a/scripts/calcom_migration.py b/scripts/calcom_migration.py new file mode 100644 index 00000000000..ce78d0564cf --- /dev/null +++ b/scripts/calcom_migration.py @@ -0,0 +1,568 @@ +#!/usr/bin/env python3 +""" +Cal.com Migration Script + +Migrates mentor data from old Cal.com instance to new self-hosted instance. + +Usage: + python3 scripts/calcom_migration.py +""" + +import os +import sys +from dataclasses import dataclass +from typing import Optional, List, Dict, Any +import logging + +import requests +from dotenv import load_dotenv +import pymysql +from pymysql.cursors import DictCursor + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(message)s' +) +logger = logging.getLogger(__name__) + + +@dataclass +class MentorData: + """Represents mentor data from database.""" + mentor_user_id: int + cal_id: Optional[str] + email: str + username: Optional[str] + event_id: Optional[int] + event_slug: Optional[str] + schedule_id: Optional[int] + + +@dataclass +class CalComUser: + """Represents user data from Cal.com.""" + id: int + email: str + name: Optional[str] + username: Optional[str] + time_zone: str + + +@dataclass +class MigrationResult: + """Tracks migration results.""" + successful: int = 0 + failed: int = 0 + errors: List[Dict[str, str]] = None + + def __post_init__(self): + if self.errors is None: + self.errors = [] + + +class CalComAPI: + """Handles Cal.com API interactions.""" + + def __init__(self, base_url: str, client_id: str, client_secret: str): + self.base_url = base_url + self.client_id = client_id + self.client_secret = client_secret + self.session = requests.Session() + self.session.headers.update({ + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }) + + def _get_headers(self, access_token: Optional[str] = None) -> Dict[str, str]: + """Get headers with optional access token.""" + headers = {'x-cal-secret-key': self.client_secret} + if access_token: + headers['Authorization'] = f'Bearer {access_token}' + return headers + + def get_user(self, user_id: str) -> Dict[str, Any]: + """Fetch user details from Cal.com.""" + url = f'{self.base_url}/oauth-clients/{self.client_id}/users/{user_id}' + response = self.session.get(url, headers=self._get_headers()) + response.raise_for_status() + return response.json()['data'] + + def delete_user(self, user_id: int) -> bool: + """Delete a managed user from Cal.com.""" + try: + url = f'{self.base_url}/oauth-clients/{self.client_id}/users/{user_id}' + response = self.session.delete(url, headers=self._get_headers()) + response.raise_for_status() + return True + except requests.RequestException as e: + logger.warning(f' โš ๏ธ Could not delete user {user_id}: {e}') + return False + + def list_managed_users(self) -> List[Dict[str, Any]]: + """List all managed users for this OAuth client.""" + try: + url = f'{self.base_url}/oauth-clients/{self.client_id}/users' + response = self.session.get(url, headers=self._get_headers()) + response.raise_for_status() + return response.json()['data'] + except requests.RequestException as e: + logger.warning(f' โš ๏ธ Could not list managed users: {e}') + return [] + + def get_event_type(self, event_id: int) -> Optional[Dict[str, Any]]: + """Fetch event type details.""" + try: + url = f'{self.base_url}/event-types/{event_id}' + response = self.session.get(url, headers=self._get_headers()) + response.raise_for_status() + return response.json()['data'] + except requests.RequestException as e: + logger.warning(f' โš ๏ธ Could not fetch event type: {e}') + return None + + def get_schedule(self, schedule_id: int) -> Optional[Dict[str, Any]]: + """Fetch schedule/availability data.""" + try: + url = f'{self.base_url}/schedules/{schedule_id}' + response = self.session.get(url, headers=self._get_headers()) + response.raise_for_status() + return response.json()['data'] + except requests.RequestException as e: + logger.warning(f' โš ๏ธ Could not fetch schedule: {e}') + return None + + def get_user_by_email(self, email: str) -> Optional[Dict[str, Any]]: + """Try to find user by email in Cal.com.""" + try: + # Cal.com API doesn't have direct email lookup, so we'll catch 409 instead + return None + except Exception: + return None + + def create_user(self, email: str, name: str, time_zone: str = 'America/New_York') -> Dict[str, Any]: + """Create new user in Cal.com, or return existing user data if already exists.""" + url = f'{self.base_url}/oauth-clients/{self.client_id}/users' + payload = { + 'email': email, + 'name': name, + 'timeZone': time_zone + } + try: + response = self.session.post(url, json=payload, headers=self._get_headers()) + response.raise_for_status() + return response.json()['data'] + except requests.exceptions.HTTPError as e: + if e.response.status_code == 409: + # User already exists, we need to handle this + raise Exception(f'User {email} already exists in Cal.com. Cannot retrieve existing user credentials via API.') + raise + + def create_event_type( + self, + access_token: str, + slug: str, + title: str, + length: int = 60, + description: str = '', + locations: Optional[List[Dict]] = None, + slot_interval: int = 60 + ) -> Dict[str, Any]: + """Create event type.""" + url = f'{self.base_url}/event-types' + payload = { + 'length': length, + 'slug': slug, + 'title': title, + 'description': description, + 'locations': locations or [{'type': 'link'}], + 'slotInterval': slot_interval, + 'hidden': False, # Make it visible/public + 'requiresConfirmation': False, # Don't require manual approval + 'disableGuests': True, # Typically you don't want guests + } + response = self.session.post(url, json=payload, headers=self._get_headers(access_token)) + response.raise_for_status() + return response.json()['data'] + + def create_schedule( + self, + access_token: str, + name: str, + time_zone: str = 'America/New_York', + is_default: bool = True, + availability: Optional[List[Dict]] = None + ) -> Dict[str, Any]: + """Create schedule.""" + url = f'{self.base_url}/schedules' + payload = { + 'name': name, + 'timeZone': time_zone, + 'isDefault': is_default, + 'availability': availability or [] + } + response = self.session.post(url, json=payload, headers=self._get_headers(access_token)) + response.raise_for_status() + return response.json()['data'] + + +class DatabaseManager: + """Handles database operations.""" + + def __init__(self): + self.connection = pymysql.connect( + host=os.getenv('DB_HOST_CLUSTER', 'localhost'), + user=os.getenv('DB_USERNAME'), + password=os.getenv('DB_PASSWORD'), + database=os.getenv('DATABASE'), + cursorclass=DictCursor + ) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.connection.close() + + def get_mentors(self, skip_migrated: bool = True) -> List[MentorData]: + """Fetch all mentors with Cal.com data.""" + query = """ + SELECT + mentor_user_id, + cal_id, + email, + username, + event_id, + event_slug, + schedule_id + FROM mentor_cal_dot_com + """ + if skip_migrated: + query += " WHERE new_cal_id IS NULL OR new_cal_id = ''" + # query += " WHERE new_event_id IS NULL OR new_event_id = ''" + + with self.connection.cursor() as cursor: + cursor.execute(query) + results = cursor.fetchall() + return [MentorData(**row) for row in results] + + def update_mentor( + self, + mentor_user_id: int, + access_token: str, + refresh_token: str, + cal_id: int, + email: str, + username: str, + event_id: Optional[int], + event_slug: Optional[str], + schedule_id: Optional[int] + ) -> None: + """Update mentor with new Cal.com credentials in new columns.""" + query = """ + UPDATE mentor_cal_dot_com + SET new_access_token = %s, + new_refresh_token = %s, + new_cal_id = %s, + new_email = %s, + new_username = %s, + new_event_id = %s, + new_event_slug = %s, + new_schedule_id = %s + WHERE mentor_user_id = %s + """ + with self.connection.cursor() as cursor: + cursor.execute( + query, + (access_token, refresh_token, cal_id, email, username, + event_id, event_slug, schedule_id, mentor_user_id) + ) + self.connection.commit() + + def clear_migration_data(self) -> None: + """Clear new_* columns to allow re-migration.""" + query = """ + UPDATE mentor_cal_dot_com + SET new_access_token = NULL, + new_refresh_token = NULL, + new_cal_id = NULL, + new_email = NULL, + new_username = NULL, + new_event_id = NULL, + new_event_slug = NULL, + new_schedule_id = NULL + """ + with self.connection.cursor() as cursor: + cursor.execute(query) + self.connection.commit() + logger.info('โœ… Cleared migration data from database') + + +class CalComMigration: + """Main migration orchestrator.""" + + def __init__( + self, + old_api: CalComAPI, + new_api: CalComAPI, + db_manager: DatabaseManager, + dry_run: bool = True + ): + self.old_api = old_api + self.new_api = new_api + self.db_manager = db_manager + self.dry_run = dry_run + self.results = MigrationResult() + + def migrate_mentor(self, mentor: MentorData) -> None: + """Migrate a single mentor.""" + logger.info(f'\n--- Processing {mentor.email} ---') + + try: + # Step 1: Fetch user details from old Cal.com + logger.info(' ๐Ÿ“ฅ Fetching user details from cal.com...') + old_user = self.old_api.get_user(mentor.cal_id) + logger.info(f' โœ… Got user data: {old_user["email"]}') + + # Step 2: Use standard event type settings - 60 min, on the hour only + event_type_data = { + 'slug': 'one-meeting', + 'title': '1 Hour Mentor Meeting', + 'length': 60, + 'description': 'Meet with mentor for 1 hour', + 'locations': [{'type': 'link'}], + 'slotInterval': 60 # Only allow bookings on the hour (12-1, not 12:30-1:30) + } + logger.info(' โ„น๏ธ Using standard event type: 60min, hourly slots only') + + # Step 3: Use standard schedule settings + schedule_data = { + 'name': 'Availability', + 'timeZone': 'America/Chicago', + 'isDefault': True, + 'availability': [ + { + 'days': [1, 2, 3, 4, 5,6,7], # Monday=1 ... Friday=5 + 'startTime': '09:00', + 'endTime': '17:00' + } + ] + } + logger.info(' โ„น๏ธ Using standard schedule configuration') + + if self.dry_run: + logger.info(' ๐Ÿ” DRY RUN - Skipping creation steps') + self.results.successful += 1 + return + + # Step 4: Create user in new Cal.com + logger.info(' ๐Ÿ“ค Creating user in self-hosted Cal.com...') + new_user_response = self.new_api.create_user( + email=old_user['email'], + name=old_user.get('name') or old_user.get('username', 'User'), + time_zone=old_user.get('timeZone', 'America/New_York') + ) + + new_user = new_user_response['user'] + access_token = new_user_response['accessToken'] + refresh_token = new_user_response['refreshToken'] + logger.info(f' โœ… Created user: {new_user["email"]} (ID: {new_user["id"]})') + + # Step 5: Create event type + new_event_id = None + new_event_slug = None + if event_type_data: + logger.info(' ๐Ÿ“ค Creating event type...') + new_event = self.new_api.create_event_type( + access_token=access_token, + slug=event_type_data.get('slug', 'one-meeting'), + title=event_type_data.get('title', '1 Hour Mentor Meeting'), + length=event_type_data.get('length', 60), + description=event_type_data.get('description', 'Meet with mentor for 1 hour'), + locations=event_type_data.get('locations'), + slot_interval=60 # Only allow bookings on the hour + ) + new_event_id = new_event['id'] + new_event_slug = new_event['slug'] + logger.info(f' โœ… Created event type: {new_event_slug} (ID: {new_event_id})') + + # Step 6: Create schedule + new_schedule_id = None + if schedule_data: + logger.info(' ๐Ÿ“ค Creating schedule...') + new_schedule = self.new_api.create_schedule( + access_token=access_token, + name=schedule_data.get('name', 'Availability'), + time_zone=schedule_data.get('timeZone', 'America/New_York'), + is_default=schedule_data.get('isDefault', True), + availability=schedule_data.get('availability', []) + ) + new_schedule_id = new_schedule['id'] + logger.info(f' โœ… Created schedule (ID: {new_schedule_id})') + + # Step 7: Update database + logger.info(' ๐Ÿ’พ Updating database...') + self.db_manager.update_mentor( + mentor_user_id=mentor.mentor_user_id, + access_token=access_token, + refresh_token=refresh_token, + cal_id=new_user['id'], + email=new_user['email'], + username=new_user.get('username', ''), + event_id=new_event_id, + event_slug=new_event_slug, + schedule_id=new_schedule_id + ) + + logger.info(f' โœ… Database updated for {mentor.email}') + self.results.successful += 1 + + except Exception as e: + logger.error(f' โŒ Failed for {mentor.email}: {e}') + self.results.errors.append({ + 'mentor': mentor.email, + 'error': str(e) + }) + self.results.failed += 1 + + def run(self) -> MigrationResult: + """Execute the migration.""" + logger.info('๐Ÿš€ Starting Cal.com migration...\n') + + mentors = self.db_manager.get_mentors() + logger.info(f'๐Ÿ“Š Found {len(mentors)} mentors to migrate\n') + + if self.dry_run: + logger.info('โš ๏ธ DRY RUN MODE - No changes will be made\n') + + for mentor in mentors: + self.migrate_mentor(mentor) + + self._print_summary(len(mentors)) + return self.results + + def _print_summary(self, total: int) -> None: + """Print migration summary.""" + logger.info('\n\n========================================') + logger.info('๐Ÿ“Š MIGRATION SUMMARY') + logger.info('========================================') + logger.info(f'โœ… Successful: {self.results.successful}') + logger.info(f'โŒ Failed: {self.results.failed}') + logger.info(f'๐Ÿ“ˆ Total: {total}') + + if self.results.errors: + logger.info('\nโŒ Errors:') + for error in self.results.errors: + logger.info(f' - {error["mentor"]}: {error["error"]}') + + logger.info('\nโœ… Migration complete!\n') + + +def cleanup_new_calcom_users(new_api: CalComAPI, emails_to_delete: List[str]) -> None: + """Delete users from new Cal.com instance to allow re-migration.""" + logger.info('๐Ÿงน Cleaning up existing users in new Cal.com...\n') + + # Get all managed users + managed_users = new_api.list_managed_users() + + if not managed_users: + logger.info('No managed users found.') + return + + deleted_count = 0 + for user in managed_users: + user_email = user.get('email', '') + user_id = user.get('id') + + # Check if this user's email matches any of our mentors (with or without the client ID suffix) + should_delete = False + for email in emails_to_delete: + base_email = email.split('@')[0] # Get part before @ + if base_email in user_email: + should_delete = True + break + + if should_delete and user_id: + logger.info(f' ๐Ÿ—‘๏ธ Deleting user: {user_email} (ID: {user_id})') + if new_api.delete_user(user_id): + deleted_count += 1 + logger.info(f' โœ… Deleted') + else: + logger.info(f' โŒ Failed to delete') + + logger.info(f'\nโœ… Deleted {deleted_count} users\n') + +def main(): + """Main entry point.""" + # Load environment variables + load_dotenv() + + # Validate environment variables + required_vars = [ + 'CAL_CLIENT_ID', + 'CAL_CLIENT_SECRETE', + 'NEW_CAL_CLIENT_ID', + 'NEW_CAL_CLIENT_SECRETE', + 'DB_USERNAME', + 'DB_PASSWORD', + 'DATABASE', + "DB_HOST_CLUSTER" + ] + + missing_vars = [var for var in required_vars if not os.getenv(var)] + if missing_vars: + logger.error(f'โŒ Missing required environment variables: {", ".join(missing_vars)}') + sys.exit(1) + + # Display configuration (without secrets) + logger.info('Configuration:') + logger.info(f' OLD Cal Client ID: {os.getenv("CAL_CLIENT_ID")}') + logger.info(f' NEW Cal Client ID: {os.getenv("NEW_CAL_CLIENT_ID")}') + logger.info(f' Database: {os.getenv("DB_USERNAME")}') + logger.info('') + + # Initialize APIs + old_api = CalComAPI( + base_url='https://api.cal.com/v2', + client_id=os.getenv('CAL_CLIENT_ID'), + client_secret=os.getenv('CAL_CLIENT_SECRETE') + ) + + new_api = CalComAPI( + base_url='https://api.collegecontactcalendar.com/v2', + client_id=os.getenv('NEW_CAL_CLIENT_ID'), + client_secret=os.getenv('NEW_CAL_CLIENT_SECRETE') + ) + + # Run migration + try: + with DatabaseManager() as db_manager: + # Get list of mentor emails for cleanup + mentors = db_manager.get_mentors(skip_migrated=False) + mentor_emails = [m.email for m in mentors] + + # Cleanup existing users in new Cal.com before migration + cleanup_new_calcom_users(new_api, mentor_emails) + + # After cleanup_new_calcom_users, also clear DB: + db_manager.clear_migration_data() + + migration = CalComMigration( + old_api=old_api, + new_api=new_api, + db_manager=db_manager, + dry_run=False # Set to False to actually run migration + ) + results = migration.run() + + # Exit with error code if there were failures + sys.exit(0 if results.failed == 0 else 1) + + except Exception as e: + logger.error(f'๐Ÿ’ฅ Migration failed: {e}') + sys.exit(1) + + +if __name__ == '__main__': + main() + From 7c2e564d48130eee80250fed51e17447464c72b1 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 23 Jan 2026 17:04:01 +0200 Subject: [PATCH 37/37] update doc --- docs/API-V2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/API-V2.md b/docs/API-V2.md index a179cbe7735..fa5604c7d42 100644 --- a/docs/API-V2.md +++ b/docs/API-V2.md @@ -17,6 +17,7 @@ From the repository root: aws ecr get-login-password --region us-east-2 | docker login --username AWS --password-stdin 194266086878.dkr.ecr.us-east-2.amazonaws.com # Build and push directly to ECR (with --push flag) +# placeholder database gets overwritten later by .env on the production VM docker buildx build -f apps/api/v2/Dockerfile \ --platform linux/amd64 \ --build-arg DATABASE_URL="postgresql://calcom:placeholder@postgres:5432/calcom" \