# Calendar Module — Implementation Plan

**Spec:** docs/specs/2026-05-30-calendar-module.md  ·  **Slug:** calendar-module  ·  **Wave:** 5
**Depends on:** customers-module, foundation-auth-rbac, projects-module, system-communications-notifications, tasks-board-engine

## Goal
Deliver a tenant-scoped calendar that aggregates task due dates, project milestones, manual events, and externally-synced events into Day/Week/Month views. Provide two-way sync with Google Calendar and Microsoft Outlook (OAuth + webhooks + refresh + daily cron), and inbound scheduling-tool integrations (Calendly, Acuity, moCal) that create customer-linked events and optionally spawn tasks/tickets. All OAuth tokens and provider API keys are AES-256-GCM encrypted at rest.

## Architecture
- **DB (`@zync/db`):** three new tables — `calendar_events`, `calendar_connections`, `scheduling_connections` — added to the Drizzle schema, all FKs UUID→UUID against existing `tenants(id)`, `users(id)`, `tasks(id)`, `projects(id)`, `customers(id)`.
- **Read aggregation:** the events list endpoint UNIONs three sources — rows from `calendar_events`, virtual events derived from `tasks` (`due_date IS NOT NULL`, joined into the requested range), and (when the project-milestones table exists downstream) `project_milestones`. Aggregation is tenant-scoped via `tenantQuery`.
- **Sync engine (`@zync/calendar` package):** provider-neutral `CalendarSyncProvider` interface with `GoogleCalendarProvider` and `OutlookCalendarProvider` implementations. Push on local create/update/delete; pull via provider push-notification webhooks; token refresh helper checks `token_expires_at` with a 5-minute skew. Tokens encrypted/decrypted with `encryptCredential`/`decryptCredential` (from `@zync/notifications`, keyed by `INTEGRATION_ENCRYPTION_KEY`).
- **Scheduling inbound:** webhook handlers verify HMAC signatures with `timingSafeEqual`, decrypt provider `api_key`, create a `calendar_events` row (`source='calendly'|'acuity'|'mocal'`), match invitee email against `customer_contacts` to set `customer_id`, then optionally enqueue task/ticket creation and emit a `calendar.booking_created` notification via `createNotification`/`deliverNotification`.
- **API (Hono, `apps/zync-api`):** CRUD routes, connection routes, OAuth start/callback routes, and webhook routes — all under existing middleware (`authMiddleware`, `requirePermission`, `requireModuleEnabled('calendar')`, `rateLimit` with `RATE_LIMITER_WEBHOOK` for inbound webhooks). Serialization reuses the upstream `serializeEvent`/`EventObject` contract where compatible and extends it for calendar fields.
- **UI (`apps/zync-app`, Vite+React):** `/calendar` route with Day/Week/Month views, source filter toggles persisted in `user_preferences`, an event-create Sheet, and an event popover. RTL-correct grid using CSS logical properties only.
- **Cron:** `POST /api/cron/calendar-sync` daily full re-sync.

Upstream tables consumed: `tasks` (id, tenant_id, project_id, title, due_date, assignee_id), `projects` (id, tenant_id, name, customer_id), `customers` (id, tenant_id), `customer_contacts` (id, customer_id, tenant_id, email), `users`, `tenants`, `user_preferences`. Upstream exports consumed: `tenantQuery`, `requirePermission`, `requireModuleEnabled`, `authMiddleware`, `rateLimit`, `RATE_LIMITER_WEBHOOK`, `timingSafeEqual`, `encryptCredential`, `decryptCredential`, `createNotification`, `deliverNotification`, `buildPaginated`, `serializeEvent`, `EventObject`, `cn`, `Sheet`, `Popover`, `Button`, `Select`, `Switch`, `Input`, `Textarea`, `Form`, `FormField`, `Toast`/`toast`, `useDirection`, `EmptyState`, `Spinner`.

## Tech Stack
- **Packages:** new `@zync/calendar` (sync providers + types) in `packages/calendar`; schema additions in `packages/db`; route modules in `apps/zync-api/src/routes/calendar`; React feature in `apps/zync-app/src/features/calendar`.
- **Libraries:** Drizzle ORM; `googleapis`-free thin `fetch` clients (Workers-compatible) for Google Calendar API v3 and Microsoft Graph; Zod for request validation; `date-fns` / `date-fns-tz` for range math and timezone handling; Web Crypto (`crypto.subtle`) HMAC for webhook signature verification.
- **Cloudflare bindings:** `DB` (Hyperdrive→Neon Postgres), `QUEUE` (deferred task/ticket creation + push fan-out), `RATE_LIMITER_WEBHOOK`, `KV` (OAuth state nonce + Google watch channel mapping), secret `INTEGRATION_ENCRYPTION_KEY`, secrets for `GOOGLE_CALENDAR_CLIENT_ID/SECRET` and `OUTLOOK_CLIENT_ID/SECRET`.
- **Runtime:** Cloudflare Workers (Hono API), Turborepo + pnpm.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 5a | 1, 2 | `packages/db/src/schema/calendar.ts`, `packages/db/src/schema/index.ts`, migration SQL, `packages/auth` seed (permissions) | No (schema first) |
| 5b | 3, 4 | `packages/calendar/*` (types, crypto helpers, provider interface) | Task 3 then 4 |
| 5c | 5, 6, 7 | `apps/zync-api/src/routes/calendar/{events,connections}.ts`, validation | 5→6, 7 parallel after 5 |
| 5d | 8, 9, 10 | OAuth providers (Google, Outlook), sync push/pull webhooks | 8,9 parallel; 10 after both |
| 5e | 11, 12 | scheduling webhooks, cron | parallel |
| 5f | 13, 14, 15 | React views, create sheet, filters | 13 then 14,15 parallel |
| 5g | 16 | wiring, module manifest, CSP | No (last) |

## Tasks

### Task 1: Database schema — calendar tables
**Blocks:** 2,3,5,6,8,9,11,12  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/calendar.ts`
- Modify: `packages/db/src/schema/index.ts` (re-export calendar tables)
- Create: `packages/db/migrations/<timestamp>_calendar_module.sql`
**Steps:**
- [ ] Define the three tables in Drizzle (`pgTable`) matching the canonical DDL below; UUID PKs default `gen_random_uuid()`; all FKs UUID→UUID with `ON DELETE CASCADE` on tenant scoping.
- [ ] Add CHECK constraints for every enum (`source`, `sync_status`, `provider`).
- [ ] Add indexes: range query index on events, per-user connection lookup, scheduling unique.
- [ ] Generate the SQL migration and verify it applies against a Neon branch.
**Schema / Interfaces:**
```sql
CREATE TABLE calendar_events (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  created_by UUID NOT NULL REFERENCES users(id),
  title TEXT NOT NULL,
  description TEXT,
  start_at TIMESTAMPTZ NOT NULL,
  end_at TIMESTAMPTZ NOT NULL,
  all_day BOOLEAN NOT NULL DEFAULT false,
  location TEXT,
  source TEXT NOT NULL DEFAULT 'manual'
    CHECK (source IN ('manual','task','project','google','outlook','calendly','acuity','mocal')),
  task_id UUID REFERENCES tasks(id) ON DELETE CASCADE,
  project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
  customer_id UUID REFERENCES customers(id) ON DELETE SET NULL,
  external_id TEXT,
  external_calendar_id TEXT,
  synced_at TIMESTAMPTZ,
  sync_status TEXT NOT NULL DEFAULT 'local'
    CHECK (sync_status IN ('local','synced','pending','error')),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_calendar_events_range ON calendar_events (tenant_id, start_at, end_at);
CREATE INDEX idx_calendar_events_external ON calendar_events (tenant_id, source, external_id);

CREATE TABLE calendar_connections (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  provider TEXT NOT NULL CHECK (provider IN ('google','outlook')),
  external_user_id TEXT NOT NULL,
  access_token BYTEA NOT NULL,
  refresh_token BYTEA NOT NULL,
  token_expires_at TIMESTAMPTZ,
  selected_calendar_id TEXT,
  sync_enabled BOOLEAN NOT NULL DEFAULT true,
  last_synced_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, user_id, provider)
);
CREATE INDEX idx_calendar_connections_user ON calendar_connections (tenant_id, user_id);

CREATE TABLE scheduling_connections (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  provider TEXT NOT NULL CHECK (provider IN ('calendly','acuity','mocal')),
  api_key BYTEA NOT NULL,
  webhook_uri TEXT,
  settings JSONB,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, provider)
);
```
**Acceptance:**
- [ ] `drizzle-kit` generates a migration with no diff drift; migration applies cleanly on a Neon branch.
- [ ] All three tables exist with the exact enum CHECKs and UNIQUE constraints above.

### Task 2: Seed calendar permissions
**Blocks:** 5,6,7,8  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/auth/src/seed-permissions.ts` (the source consumed by `seedPermissions`)
**Steps:**
- [ ] Add permission rows `calendar:read`, `calendar:write`, `calendar:connect` to the permission seed list.
- [ ] Grant `calendar:read`+`calendar:write`+`calendar:connect` to admin/owner/member roles per existing role grant matrix; `calendar:read` to viewer. Scheduling-integration management reuses existing `settings:write`.
- [ ] Register module id `calendar` in the module manifest list if not already a known `ModuleId` (used by `requireModuleEnabled`).
**Acceptance:**
- [ ] Running `seedPermissions` inserts the three permissions idempotently (re-run produces no duplicates).
- [ ] `requirePermission('calendar:write')` resolves these from `role_permissions`.

### Task 3: `@zync/calendar` package — types & crypto helpers
**Blocks:** 4,5,8,9,11,12  ·  **Blocked by:** 1
**Files:**
- Create: `packages/calendar/package.json` (name `@zync/calendar`)
- Create: `packages/calendar/src/types.ts`
- Create: `packages/calendar/src/crypto.ts`
- Create: `packages/calendar/src/index.ts`
**Steps:**
- [ ] Define `CalendarEventObject`, `CalendarConnectionObject`, `SchedulingConnectionObject`, `CalendarSource`, `SyncStatus`, `CalendarProvider` (`'google'|'outlook'`), `SchedulingProvider` (`'calendly'|'acuity'|'mocal'`).
- [ ] Implement `encryptToken`/`decryptToken` thin wrappers that delegate to upstream `encryptCredential`/`decryptCredential` (keyed by `INTEGRATION_ENCRYPTION_KEY`) returning/accepting `Uint8Array` for BYTEA columns. Do not re-implement AES; reuse the upstream helper.
- [ ] Implement `verifyHmacSignature(secret, rawBody, signatureHeader)` using Web Crypto `crypto.subtle` and compare with `timingSafeEqual` (never `===` on tokens — `no-string-equality-for-tokens`).
- [ ] Export all from `index.ts`.
**Schema / Interfaces:**
```ts
export type CalendarSource = 'manual'|'task'|'project'|'google'|'outlook'|'calendly'|'acuity'|'mocal';
export type SyncStatus = 'local'|'synced'|'pending'|'error';
export type CalendarProvider = 'google'|'outlook';
export type SchedulingProvider = 'calendly'|'acuity'|'mocal';

export interface CalendarEventObject {
  id: string; title: string; description: string | null;
  startAt: string; endAt: string; allDay: boolean; location: string | null;
  source: CalendarSource;
  taskId: string | null; projectId: string | null; customerId: string | null;
  externalId: string | null; syncStatus: SyncStatus; syncedAt: string | null;
  readOnly: boolean; // true for external-only events (source google/outlook not authored in Zync)
}
export interface CalendarConnectionObject {
  id: string; provider: CalendarProvider; externalUserId: string;
  selectedCalendarId: string | null; syncEnabled: boolean; lastSyncedAt: string | null;
}
export function verifyHmacSignature(
  secret: string, rawBody: string, signatureHeader: string
): Promise<boolean>;
export function encryptToken(plaintext: string, key: string): Promise<Uint8Array>;
export function decryptToken(ciphertext: Uint8Array, key: string): Promise<string>;
```
**Acceptance:**
- [ ] `verifyHmacSignature` returns false for a tampered body and true for a valid signature; comparison uses `timingSafeEqual`.
- [ ] `decryptToken(encryptToken(x))` round-trips to `x`.

### Task 4: Provider sync interface + range/serialization helpers
**Blocks:** 5,8,9,10  ·  **Blocked by:** 3
**Files:**
- Create: `packages/calendar/src/provider.ts`
- Create: `packages/calendar/src/serialize.ts`
- Create: `packages/calendar/src/range.ts`
**Steps:**
- [ ] Define `CalendarSyncProvider` interface (push/pull/refresh contract) that both Google and Outlook implement.
- [ ] Implement `serializeCalendarEvent(row): CalendarEventObject` computing `readOnly = source in ('google','outlook') && external_id != null` (external-only events read-only in Zync).
- [ ] Implement `taskToCalendarEvent(task)` mapping a `tasks` row with `due_date` to an all-day `CalendarEventObject` (`source='task'`, `taskId` set, `readOnly` false for reschedule).
- [ ] Implement `parseRange(start, end)` validating ISO bounds and capping span (max 92 days) to bound query cost.
**Schema / Interfaces:**
```ts
export interface ExternalEvent {
  externalId: string; externalCalendarId: string;
  title: string; description: string | null;
  startAt: string; endAt: string; allDay: boolean; location: string | null;
}
export interface CalendarSyncProvider {
  readonly provider: CalendarProvider;
  refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; expiresAt: string }>;
  pushEvent(accessToken: string, calendarId: string, ev: ExternalEvent): Promise<{ externalId: string }>;
  updateEvent(accessToken: string, calendarId: string, externalId: string, ev: ExternalEvent): Promise<void>;
  deleteEvent(accessToken: string, calendarId: string, externalId: string): Promise<void>;
  listCalendars(accessToken: string): Promise<{ id: string; name: string }[]>;
  fetchChanges(accessToken: string, calendarId: string, since: string | null): Promise<ExternalEvent[]>;
  registerWatch?(accessToken: string, calendarId: string, callbackUrl: string): Promise<void>;
}
export function serializeCalendarEvent(row: CalendarEventRow): CalendarEventObject;
export function parseRange(start: string, end: string): { start: Date; end: Date };
```
**Acceptance:**
- [ ] `serializeCalendarEvent` sets `readOnly=true` only for external-authored events.
- [ ] `parseRange` rejects inverted or >92-day ranges with a typed error.

### Task 5: Events CRUD API
**Blocks:** 8,13  ·  **Blocked by:** 2,4
**Files:**
- Create: `apps/zync-api/src/routes/calendar/events.ts`
- Create: `apps/zync-api/src/routes/calendar/validation.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount `/api/calendar`)
**Steps:**
- [ ] Implement `GET /api/calendar/events?start=&end=` — `requirePermission('calendar:read')`; aggregate `calendar_events` (via `tenantQuery`) UNION virtual task events (join `tasks` where `due_date` in range) UNION milestone events when the milestones table is present; return `{ events: CalendarEventObject[] }`. Use `buildPaginated` only if a paginated list variant is requested; range query is unpaginated within capped span.
- [ ] Implement `POST /api/calendar/events` — `requirePermission('calendar:write')`, Zod-validated body; insert `calendar_events` with `source='manual'`, `created_by` from session; if the creating user has a `sync_enabled` connection, enqueue a push job (`QUEUE`).
- [ ] Implement `GET /api/calendar/events/:id` (`calendar:read`), `PATCH` (`calendar:write`), `DELETE` (`calendar:write`). On PATCH/DELETE of synced events, enqueue provider update/delete; reject edits to `readOnly` external-only events with 409.
- [ ] All routes go through `authMiddleware` + `requireModuleEnabled('calendar')`; use Zod (`require-zod-validation-in-routes`) and `tenantQuery` (`no-raw-drizzle-from-routes`).
**Schema / Interfaces:**
```ts
export const createCalendarEventSchema = z.object({
  title: z.string().min(1),
  description: z.string().optional(),
  startAt: z.string().datetime(),
  endAt: z.string().datetime(),
  allDay: z.boolean().default(false),
  location: z.string().optional(),
  customerId: z.string().uuid().optional(),
  projectId: z.string().uuid().optional(),
  participantEmails: z.array(z.string().email()).optional(),
}).refine(v => v.endAt >= v.startAt, { message: 'endAt must be >= startAt' });
export const updateCalendarEventSchema = createCalendarEventSchema.partial();
// Routes: GET/POST /api/calendar/events ; GET/PATCH/DELETE /api/calendar/events/:id
```
**Acceptance:**
- [ ] Range query returns manual + task-derived events merged, tenant-scoped, within the capped span.
- [ ] PATCH on a `readOnly` external event returns 409; PATCH on a manual synced event enqueues a provider update.

### Task 6: Connections API + user filter preference
**Blocks:** 13  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/routes/calendar/connections.ts`
- Modify: `apps/zync-api/src/routes/index.ts`
**Steps:**
- [ ] `GET /api/calendar/connections` — `requirePermission('calendar:read')`, list the session user's `calendar_connections` serialized as `CalendarConnectionObject` (never expose token bytes).
- [ ] `DELETE /api/calendar/connections/:id` — `requirePermission('calendar:connect')`, ownership-checked, deletes connection and (best-effort) unregisters provider watch channel.
- [ ] Persist per-user source filter toggles (tasks/projects/manual/external) into `user_preferences` (extend the existing preferences blob/columns via the upstream `updateUserPreferencesSchema` path — do not add a new table).
**Acceptance:**
- [ ] Connection list omits `access_token`/`refresh_token` entirely.
- [ ] Filter toggles persist across reload via `user_preferences`.

### Task 7: Settings — scheduling connection management API
**Blocks:** 11  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/routes/calendar/scheduling-settings.ts`
- Modify: `apps/zync-api/src/routes/index.ts`
**Steps:**
- [ ] `POST /api/calendar/scheduling/:provider` — `requirePermission('settings:write')`; validate provider in (`calendly`,`acuity`,`mocal`); encrypt the supplied API key via `encryptToken`; upsert `scheduling_connections` (UNIQUE tenant_id, provider); register the webhook with the provider (store `webhook_uri`).
- [ ] `POST /api/calendar/scheduling/:provider/test` — decrypt key, call provider ping endpoint, return reachability.
- [ ] `DELETE /api/calendar/scheduling/:provider` — remove connection + unregister webhook.
- [ ] Store the post-booking action (`event` | `event_task` | `event_ticket`) in `scheduling_connections.settings` JSONB.
**Acceptance:**
- [ ] API key is stored only as encrypted BYTEA; test endpoint reports success/failure without leaking the key.

### Task 8: Google Calendar provider + OAuth
**Blocks:** 10  ·  **Blocked by:** 4,5
**Files:**
- Create: `packages/calendar/src/providers/google.ts`
- Create: `apps/zync-api/src/routes/calendar/oauth-google.ts`
- Modify: `apps/zync-api/src/routes/index.ts`
**Steps:**
- [ ] Implement `GoogleCalendarProvider` (`CalendarSyncProvider`) against Calendar API v3 (`events.insert/update/delete/list`, `calendarList.list`, `events.watch`) using `fetch`.
- [ ] `GET /api/auth/google-calendar/start` — `requirePermission('calendar:connect')`; build consent URL with scopes `https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events`, `access_type=offline`, `prompt=consent`; store a random state nonce in `KV` (TTL 600s) bound to the session.
- [ ] `GET /api/auth/google-calendar/callback` — validate state nonce, exchange code, encrypt tokens with `encryptToken`, upsert `calendar_connections` (provider `google`, `external_user_id`=Google `sub`), set `token_expires_at`.
- [ ] Implement `getFreshAccessToken(connection)` — refresh when within 5 minutes of `token_expires_at`, persist new ciphertext + expiry.
**Acceptance:**
- [ ] OAuth round-trip stores encrypted tokens and creates a `google` connection; replayed/forged state is rejected.
- [ ] An access token expiring in <5min triggers a refresh before any Google API call.

### Task 9: Outlook (Microsoft Graph) provider + OAuth
**Blocks:** 10  ·  **Blocked by:** 4,5
**Files:**
- Create: `packages/calendar/src/providers/outlook.ts`
- Create: `apps/zync-api/src/routes/calendar/oauth-outlook.ts`
- Modify: `apps/zync-api/src/routes/index.ts`
**Steps:**
- [ ] Implement `OutlookCalendarProvider` against Microsoft Graph (`/me/events`, `/me/calendars`, subscription create) with scope `Calendars.ReadWrite offline_access`.
- [ ] `GET /api/auth/outlook-calendar/start` and `GET /api/auth/outlook-calendar/callback` mirroring Task 8 (state nonce in `KV`, encrypted token storage, `external_user_id`=Graph `oid`).
- [ ] Implement Graph token refresh and the same 5-minute pre-call refresh guard.
**Acceptance:**
- [ ] OAuth round-trip creates an `outlook` connection with encrypted tokens; refresh guard behaves as in Task 8.

### Task 10: Calendar sync webhooks (Google + Outlook pull)
**Blocks:** —  ·  **Blocked by:** 8,9
**Files:**
- Create: `apps/zync-api/src/routes/calendar/sync-webhooks.ts`
- Modify: `apps/zync-api/src/routes/index.ts`
**Steps:**
- [ ] `POST /api/webhooks/calendar/google/:connId` — `rateLimit(RATE_LIMITER_WEBHOOK, ...)`; validate the Google channel token/`X-Goog-Channel-Id` against the stored `KV` mapping; fetch changed events with the connection's fresh access token; upsert into `calendar_events` (`source='google'`, `external_id`, `external_calendar_id`, `sync_status='synced'`, `synced_at=now()`); mark removed external events deleted.
- [ ] `POST /api/webhooks/calendar/outlook/:connId` — validate Graph `clientState`; handle the subscription validation handshake (echo `validationToken`); fetch changes; upsert as `source='outlook'`.
- [ ] Conflict rule: never overwrite task/project-sourced Zync events from external pulls (Zync is source of truth for those); only upsert external-authored events.
**Acceptance:**
- [ ] A Google change webhook upserts the changed event idempotently keyed by `(tenant_id, source, external_id)`.
- [ ] Outlook subscription validation handshake returns the `validationToken` as plain text with 200.

### Task 11: Scheduling inbound webhooks (Calendly/Acuity/moCal)
**Blocks:** —  ·  **Blocked by:** 3,7
**Files:**
- Create: `apps/zync-api/src/routes/calendar/scheduling-webhooks.ts`
- Modify: `apps/zync-api/src/routes/index.ts`
**Steps:**
- [ ] `POST /api/webhooks/scheduling/calendly` — `rateLimit(RATE_LIMITER_WEBHOOK, ...)`; read raw body; look up `scheduling_connections` for the tenant (derived from `X-Calendly-Webhook-Subscription-Uuid`); decrypt `api_key`; `verifyHmacSignature` over the raw body (reject on mismatch using `timingSafeEqual`).
- [ ] On valid booking: insert `calendar_events` (`source='calendly'`, title, start/end); match invitee email against `customer_contacts` (tenant-scoped) to set `customer_id`; fallback to unlinked event with a note when no match.
- [ ] Per `scheduling_connections.settings` action: enqueue task creation and/or ticket creation via `QUEUE`; emit `calendar.booking_created` and call `createNotification`/`deliverNotification` (in-app + email) to the assignee.
- [ ] `POST /api/webhooks/scheduling/acuity` and `POST /api/webhooks/scheduling/mocal` — same pipeline with provider-specific HMAC header/format (`source='acuity'`/`'mocal'`).
**Schema / Interfaces:**
```ts
// Outbound webhook event emitted on booking:
// calendar.booking_created -> { eventId, customerId, provider, title, startAt }
```
**Acceptance:**
- [ ] Invalid HMAC signature → 401 and no DB write.
- [ ] Valid booking with a matching contact email links `customer_id`; `event_ticket` action enqueues exactly one ticket job.

### Task 12: Sync cron
**Blocks:** —  ·  **Blocked by:** 8,9
**Files:**
- Create: `apps/zync-api/src/routes/calendar/cron-sync.ts`
- Modify: `apps/zync-api/wrangler.toml` (cron trigger), `apps/zync-api/src/routes/index.ts`
**Steps:**
- [ ] `POST /api/cron/calendar-sync` — guarded for cron/internal invocation only; iterate `sync_enabled` `calendar_connections`; for each, fetch provider changes since `last_synced_at`, upsert into `calendar_events`, update `last_synced_at`.
- [ ] Register a daily cron trigger in `wrangler.toml`.
**Acceptance:**
- [ ] Cron run reconciles events missed by webhooks and advances `last_synced_at` per connection.

### Task 13: Calendar views UI (Day/Week/Month) with RTL
**Blocks:** 16  ·  **Blocked by:** 5,6
**Files:**
- Create: `apps/zync-app/src/features/calendar/CalendarPage.tsx`
- Create: `apps/zync-app/src/features/calendar/views/{MonthView,WeekView,DayView}.tsx`
- Create: `apps/zync-app/src/features/calendar/EventPopover.tsx`
- Create: `apps/zync-app/src/features/calendar/useCalendarEvents.ts`
- Modify: `apps/zync-app/src/router.tsx` (route `/calendar`, `requireModuleEnabled('calendar')`)
**Steps:**
- [ ] `useCalendarEvents(range)` — react-query hook calling `GET /api/calendar/events`; key includes range + active source filters.
- [ ] Month view: 7-column grid, week starts Sunday; Day/Week: time-grid with hour rows.
- [ ] Color-code chips by source: task=blue, project=green, manual=purple, external=grey — via design tokens only (`no-hardcoded-colors`, `no-hardcoded-spacing`).
- [ ] Click event → `EventPopover` with summary + "Open" link to the source task/project/detail.
- [ ] **RTL:** set `dir="rtl"` on the grid container so the Sunday column renders rightmost; time gutter uses `inset-inline-start: 0` (never `left`/`right`); event text uses `dir="auto"`; prev/next chevrons mirror via `transform: scaleX(-1)` (or `margin-inline-start`) per spec 81. Use `useDirection`.
- [ ] Respect `prefers-reduced-motion` for view transitions; grid cells expose `role="gridcell"`, day headers `role="columnheader"`, container `role="grid"` with `aria-label`.
**Acceptance:**
- [ ] In RTL, Sunday is the rightmost column and the time gutter sits on the right with no physical `left/right` CSS.
- [ ] Source colors come from tokens; reduced-motion disables animated transitions.

### Task 14: Create-event Sheet
**Blocks:** 16  ·  **Blocked by:** 5,13
**Files:**
- Create: `apps/zync-app/src/features/calendar/EventCreateSheet.tsx`
- Create: `apps/zync-app/src/features/calendar/useCreateEvent.ts`
**Steps:**
- [ ] "+ New event" opens a `Sheet` form: Title (required), Date+time with all-day `Switch`, Description, Location, Customer `Select`, Project `Select`, Participants (team members + customer emails).
- [ ] Validate against `createCalendarEventSchema`; submit via `useCreateEvent` (POST), optimistic insert + `toast` on success.
- [ ] Reuse upstream `Form`/`FormField`/`Input`/`Textarea`/`Select`/`Switch`/`Button`; all text via `@zync/types` translations for i18n/RTL.
**Acceptance:**
- [ ] Creating an event refreshes the calendar without full reload; validation errors surface inline.

### Task 15: Source filter toggles UI
**Blocks:** 16  ·  **Blocked by:** 6,13
**Files:**
- Create: `apps/zync-app/src/features/calendar/SourceFilters.tsx`
- Create: `apps/zync-app/src/features/calendar/useCalendarFilters.ts`
**Steps:**
- [ ] Toggle row for tasks/projects/manual/external; state seeded from and persisted to `user_preferences` (via the connections/preferences endpoint from Task 6).
- [ ] Filters feed into `useCalendarEvents` query key so toggling refetches/filters client-side.
**Acceptance:**
- [ ] Disabling "tasks" hides task-derived chips and the choice persists across reloads.

### Task 16: Wiring, module manifest, CSP
**Blocks:** —  ·  **Blocked by:** 13,14,15
**Files:**
- Modify: `packages/config/src/modules.ts` (or `MODULE_MANIFEST` source) — add `calendar` module definition
- Modify: `apps/zync-api/src/index.ts` (mount all calendar route groups)
- Modify: app CSP config (allow Google/MS OAuth + API origins)
- Modify: `apps/zync-app` nav/sidebar to add the Calendar entry gated by `useModuleEnabled('calendar')`
**Steps:**
- [ ] Add `calendar` to `MODULE_MANIFEST`/`MODULE_BY_ID` with id, title, icon, and required tier; add to `TOGGLEABLE_MODULE_IDS` if user-toggleable.
- [ ] Ensure every route group is mounted and protected by `authMiddleware` + `requireModuleEnabled('calendar')`.
- [ ] Extend CSP `connect-src` to include `https://www.googleapis.com`, `https://oauth2.googleapis.com`, `https://graph.microsoft.com`, `https://login.microsoftonline.com`; keep `frame-ancestors 'none'`. Do not weaken existing CSP directives.
- [ ] Add the sidebar nav entry gated by module-enabled + `calendar:read`.
**Acceptance:**
- [ ] `/calendar` is reachable only when the module is enabled and the user has `calendar:read`; CSP additions are scoped to required origins only.
- [ ] `pnpm build` and `pnpm typecheck` pass across `packages/calendar`, `apps/zync-api`, `apps/zync-app`.
