@fayz-ai/plugin-agenda 0.1.3 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/index.cjs +412 -201
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +254 -48
- package/dist/index.js.map +1 -1
- package/dist/integrations/google-calendar/GoogleCalendarSettings.d.ts +3 -0
- package/dist/integrations/google-calendar/GoogleCalendarSettings.d.ts.map +1 -0
- package/dist/integrations/google-calendar/connector.d.ts +3 -0
- package/dist/integrations/google-calendar/connector.d.ts.map +1 -0
- package/dist/integrations/google-calendar/connectorDef.d.ts +3 -0
- package/dist/integrations/google-calendar/connectorDef.d.ts.map +1 -0
- package/dist/integrations/google-calendar/data/supabase.d.ts +16 -0
- package/dist/integrations/google-calendar/data/supabase.d.ts.map +1 -0
- package/dist/integrations/google-calendar/index.d.ts +8 -0
- package/dist/integrations/google-calendar/index.d.ts.map +1 -0
- package/dist/integrations/google-calendar/integration.test.d.ts +2 -0
- package/dist/integrations/google-calendar/integration.test.d.ts.map +1 -0
- package/dist/integrations/google-calendar/mapping.d.ts +25 -0
- package/dist/integrations/google-calendar/mapping.d.ts.map +1 -0
- package/dist/integrations/google-calendar/types.d.ts +36 -0
- package/dist/integrations/google-calendar/types.d.ts.map +1 -0
- package/package.json +10 -7
- package/src/index.ts +12 -2
- package/src/integrations/google-calendar/GoogleCalendarSettings.tsx +136 -0
- package/src/integrations/google-calendar/connector.ts +20 -0
- package/src/integrations/google-calendar/connectorDef.tsx +94 -0
- package/src/integrations/google-calendar/data/supabase.ts +74 -0
- package/src/integrations/google-calendar/index.ts +36 -0
- package/src/integrations/google-calendar/integration.test.ts +74 -0
- package/src/integrations/google-calendar/mapping.ts +43 -0
- package/src/integrations/google-calendar/migrations/001_google_calendar.sql +94 -0
- package/src/integrations/google-calendar/types.ts +31 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Integration contract + capability test for the Google Calendar connector.
|
|
2
|
+
//
|
|
3
|
+
// TDD anchor: the connector contract, manifest integrity, and the pure
|
|
4
|
+
// booking↔event mapping are all proven HERE — before/without the deployed edge
|
|
5
|
+
// function. The data-plane behaviors that need a live Google account are written
|
|
6
|
+
// as it.todo specs so the intended contract is documented and pending, not
|
|
7
|
+
// silently missing. See PLUGIN_PATTERNS.md → integration capability.
|
|
8
|
+
import { describe, it, expect } from 'vitest'
|
|
9
|
+
import { assertConnectorContract } from '@fayz-ai/core/testing'
|
|
10
|
+
import { googleCalendarConnector } from './connector'
|
|
11
|
+
import { bookingToEvent, eventToBookingPatch, bookingSummary } from './mapping'
|
|
12
|
+
import type { CalendarBooking } from '../../types'
|
|
13
|
+
|
|
14
|
+
function booking(overrides: Partial<CalendarBooking> = {}): CalendarBooking {
|
|
15
|
+
return {
|
|
16
|
+
id: 'b1', tenantId: 't1', kind: 'appointment',
|
|
17
|
+
startsAt: '2026-06-18T13:00:00-03:00', endsAt: '2026-06-18T14:00:00-03:00',
|
|
18
|
+
status: 'scheduled', notes: null, orderId: 'b1', locationId: null, metadata: {},
|
|
19
|
+
clientId: null, clientName: 'Sarah', clientPhone: null, clientEmail: 'sarah@x.com', clientAvatarUrl: null,
|
|
20
|
+
professionalId: null, professionalName: 'Ana', professionalAvatarUrl: null,
|
|
21
|
+
locationName: 'HQ', orderTotal: null, orderStatus: null,
|
|
22
|
+
services: [{ id: 's1', serviceId: 'svc1', name: 'Haircut', durationMinutes: 60, price: 100, assigneeId: null }] as any,
|
|
23
|
+
totalDurationMinutes: 60, createdAt: '', updatedAt: '',
|
|
24
|
+
...overrides,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('google-calendar · connector contract (integrity)', () => {
|
|
29
|
+
it('declares a valid connector descriptor', () => {
|
|
30
|
+
expect(() => assertConnectorContract(googleCalendarConnector)).not.toThrow()
|
|
31
|
+
expect(googleCalendarConnector.capabilities[0].direction).toBe('bidirectional')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('google-calendar · booking↔event mapping (pure, TDD)', () => {
|
|
36
|
+
it('builds an event title from services + client', () => {
|
|
37
|
+
expect(bookingSummary(booking())).toBe('Haircut — Sarah')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('maps a booking to a Google event input with start/end', () => {
|
|
41
|
+
const ev = bookingToEvent(booking())
|
|
42
|
+
expect(ev).not.toBeNull()
|
|
43
|
+
expect(ev!.summary).toBe('Haircut — Sarah')
|
|
44
|
+
expect(ev!.start.dateTime).toBe('2026-06-18T13:00:00-03:00')
|
|
45
|
+
expect(ev!.end.dateTime).toBe('2026-06-18T14:00:00-03:00')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('refuses to map a booking with no end time', () => {
|
|
49
|
+
expect(bookingToEvent(booking({ endsAt: null }))).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('maps an inbound reschedule to a booking patch', () => {
|
|
53
|
+
const patch = eventToBookingPatch({
|
|
54
|
+
id: 'e1', start: { dateTime: '2026-06-18T15:00:00-03:00' }, end: { dateTime: '2026-06-18T16:00:00-03:00' },
|
|
55
|
+
})
|
|
56
|
+
expect(patch.startsAt).toBe('2026-06-18T15:00:00-03:00')
|
|
57
|
+
expect(patch.endsAt).toBe('2026-06-18T16:00:00-03:00')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('maps an inbound cancellation to a cancelled status', () => {
|
|
61
|
+
const patch = eventToBookingPatch({ id: 'e1', status: 'cancelled', start: {}, end: {} })
|
|
62
|
+
expect(patch.status).toBe('cancelled')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// Data-plane behaviors — require a live Google account + the deployed
|
|
67
|
+
// google-calendar-sync edge function. Documented as the pending contract (TDD).
|
|
68
|
+
describe('google-calendar · sync data plane (pending — needs edge function)', () => {
|
|
69
|
+
it.todo('push_event: creating a booking creates a Google event and stores its id on bookings.metadata')
|
|
70
|
+
it.todo('push_event: updating a booking patches the linked Google event')
|
|
71
|
+
it.todo('push_event: cancelling a booking deletes the Google event')
|
|
72
|
+
it.todo('pull_events: a Google reschedule updates the linked booking; a cancel cancels it')
|
|
73
|
+
it.todo('pull_events: advances and persists the incremental syncToken')
|
|
74
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Booking ↔ Google Calendar event mapping (pure helpers).
|
|
2
|
+
//
|
|
3
|
+
// Shared shape used both by the SDK (docs/tests) and mirrored by the edge
|
|
4
|
+
// function. Keeping it pure makes the field decisions reviewable in one place.
|
|
5
|
+
import type { CalendarBooking } from '../../types'
|
|
6
|
+
import type { GCalEvent } from './types'
|
|
7
|
+
|
|
8
|
+
export interface GCalEventInput {
|
|
9
|
+
summary: string
|
|
10
|
+
description?: string
|
|
11
|
+
start: { dateTime: string; timeZone?: string }
|
|
12
|
+
end: { dateTime: string; timeZone?: string }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Build the event title from the booking's services + client. */
|
|
16
|
+
export function bookingSummary(b: Pick<CalendarBooking, 'services' | 'clientName'>): string {
|
|
17
|
+
const services = (b.services ?? []).map((s) => s.name).filter(Boolean).join(', ')
|
|
18
|
+
const who = b.clientName ?? ''
|
|
19
|
+
if (services && who) return `${services} — ${who}`
|
|
20
|
+
return services || who || 'Appointment'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Map a Fayz booking to a Google Calendar event input. Returns null when the booking has no end. */
|
|
24
|
+
export function bookingToEvent(b: CalendarBooking, timeZone?: string): GCalEventInput | null {
|
|
25
|
+
if (!b.startsAt || !b.endsAt) return null
|
|
26
|
+
const notes = [b.notes, b.professionalName ? `Pro: ${b.professionalName}` : '', b.locationName ?? '']
|
|
27
|
+
.filter(Boolean).join('\n')
|
|
28
|
+
return {
|
|
29
|
+
summary: bookingSummary(b),
|
|
30
|
+
description: notes || undefined,
|
|
31
|
+
start: { dateTime: b.startsAt, timeZone },
|
|
32
|
+
end: { dateTime: b.endsAt, timeZone },
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Map an inbound Google event change to a partial booking update (reschedule/cancel). */
|
|
37
|
+
export function eventToBookingPatch(ev: GCalEvent): { startsAt?: string; endsAt?: string; status?: 'cancelled' } {
|
|
38
|
+
if (ev.status === 'cancelled') return { status: 'cancelled' }
|
|
39
|
+
return {
|
|
40
|
+
startsAt: ev.start?.dateTime,
|
|
41
|
+
endsAt: ev.end?.dateTime,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- Google Calendar integration (bidirectional) — connection + audit + triggers
|
|
3
|
+
-- ---------------------------------------------------------------------------
|
|
4
|
+
-- Outbound (Fayz booking → Google event) fires from a trigger on saas_core.bookings
|
|
5
|
+
-- via pg_net to the google-calendar-sync edge function (push_event). Inbound
|
|
6
|
+
-- (Google → booking) runs on a pg_cron schedule (pull_events) and/or a Google
|
|
7
|
+
-- watch-channel webhook. The booking↔event link is stored on
|
|
8
|
+
-- saas_core.bookings.metadata.googleCalendarEventId (no schema change there).
|
|
9
|
+
|
|
10
|
+
CREATE TABLE IF NOT EXISTS public.calendar_integrations (
|
|
11
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
12
|
+
tenant_id uuid NOT NULL REFERENCES saas_core.tenants(id) ON DELETE CASCADE,
|
|
13
|
+
provider text NOT NULL DEFAULT 'google',
|
|
14
|
+
oauth_refresh_token text,
|
|
15
|
+
oauth_access_token text,
|
|
16
|
+
token_expires_at timestamptz,
|
|
17
|
+
calendar_id text NOT NULL DEFAULT 'primary',
|
|
18
|
+
-- Incremental pull cursor + Google watch channel bookkeeping.
|
|
19
|
+
sync_token text,
|
|
20
|
+
channel_id text,
|
|
21
|
+
channel_resource_id text,
|
|
22
|
+
channel_expires_at timestamptz,
|
|
23
|
+
active boolean NOT NULL DEFAULT true,
|
|
24
|
+
last_sync_at timestamptz,
|
|
25
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
26
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
27
|
+
UNIQUE (tenant_id, provider)
|
|
28
|
+
);
|
|
29
|
+
ALTER TABLE public.calendar_integrations ENABLE ROW LEVEL SECURITY;
|
|
30
|
+
CREATE INDEX IF NOT EXISTS idx_calendar_integrations_tenant ON public.calendar_integrations(tenant_id);
|
|
31
|
+
|
|
32
|
+
CREATE TABLE IF NOT EXISTS public.calendar_sync_log (
|
|
33
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
34
|
+
tenant_id uuid NOT NULL REFERENCES saas_core.tenants(id) ON DELETE CASCADE,
|
|
35
|
+
direction text NOT NULL, -- inbound | outbound
|
|
36
|
+
trigger text, -- on-write | scheduled | webhook | manual
|
|
37
|
+
status text NOT NULL DEFAULT 'success', -- success | partial | error
|
|
38
|
+
fetched integer NOT NULL DEFAULT 0,
|
|
39
|
+
written integer NOT NULL DEFAULT 0,
|
|
40
|
+
error text,
|
|
41
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
42
|
+
);
|
|
43
|
+
ALTER TABLE public.calendar_sync_log ENABLE ROW LEVEL SECURITY;
|
|
44
|
+
|
|
45
|
+
-- RLS: tenant isolation.
|
|
46
|
+
DO $$
|
|
47
|
+
DECLARE t text;
|
|
48
|
+
BEGIN
|
|
49
|
+
FOR t IN SELECT unnest(ARRAY['calendar_integrations','calendar_sync_log'])
|
|
50
|
+
LOOP
|
|
51
|
+
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);
|
|
52
|
+
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_rls') THEN
|
|
53
|
+
EXECUTE format('CREATE POLICY %I ON public.%I FOR ALL TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids())) WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_rls', t);
|
|
54
|
+
END IF;
|
|
55
|
+
END LOOP;
|
|
56
|
+
END $$;
|
|
57
|
+
|
|
58
|
+
-- ---------------------------------------------------------------------------
|
|
59
|
+
-- Outbound trigger: booking change → edge function (push_event).
|
|
60
|
+
-- Requires pg_net + a configured function URL. Set once per project:
|
|
61
|
+
-- ALTER DATABASE postgres SET app.gcal_sync_url = 'https://<ref>.functions.supabase.co/google-calendar-sync';
|
|
62
|
+
-- ALTER DATABASE postgres SET app.gcal_service_key = '<service-role-key>';
|
|
63
|
+
-- Safe to skip in environments without pg_net — outbound also works via the
|
|
64
|
+
-- client-side withGoogleCalendarSync() decorator.
|
|
65
|
+
-- ---------------------------------------------------------------------------
|
|
66
|
+
CREATE OR REPLACE FUNCTION public.fn_gcal_push_booking() RETURNS trigger
|
|
67
|
+
LANGUAGE plpgsql SECURITY DEFINER AS $$
|
|
68
|
+
DECLARE
|
|
69
|
+
url text := current_setting('app.gcal_sync_url', true);
|
|
70
|
+
key text := current_setting('app.gcal_service_key', true);
|
|
71
|
+
rec record := COALESCE(NEW, OLD);
|
|
72
|
+
op text := lower(TG_OP);
|
|
73
|
+
BEGIN
|
|
74
|
+
IF url IS NULL OR url = '' THEN RETURN COALESCE(NEW, OLD); END IF;
|
|
75
|
+
PERFORM net.http_post(
|
|
76
|
+
url := url,
|
|
77
|
+
headers := jsonb_build_object('Content-Type', 'application/json', 'Authorization', 'Bearer ' || COALESCE(key, '')),
|
|
78
|
+
body := jsonb_build_object('action', 'push_event', 'op', op, 'bookingId', rec.id, 'tenantId', rec.tenant_id)
|
|
79
|
+
);
|
|
80
|
+
RETURN COALESCE(NEW, OLD);
|
|
81
|
+
END $$;
|
|
82
|
+
|
|
83
|
+
DROP TRIGGER IF EXISTS trg_gcal_push ON saas_core.bookings;
|
|
84
|
+
CREATE TRIGGER trg_gcal_push
|
|
85
|
+
AFTER INSERT OR UPDATE OR DELETE ON saas_core.bookings
|
|
86
|
+
FOR EACH ROW EXECUTE FUNCTION public.fn_gcal_push_booking();
|
|
87
|
+
|
|
88
|
+
-- Inbound schedule (optional): pull external changes every 10 minutes.
|
|
89
|
+
-- SELECT cron.schedule('gcal-pull', '*/10 * * * *', $$
|
|
90
|
+
-- SELECT net.http_post(
|
|
91
|
+
-- url := current_setting('app.gcal_sync_url'),
|
|
92
|
+
-- headers := jsonb_build_object('Content-Type','application/json','Authorization','Bearer '||current_setting('app.gcal_service_key')),
|
|
93
|
+
-- body := jsonb_build_object('action','pull_events'));
|
|
94
|
+
-- $$);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Google Calendar integration — shared types.
|
|
2
|
+
|
|
3
|
+
export interface CalendarIntegration {
|
|
4
|
+
id: string
|
|
5
|
+
provider: string
|
|
6
|
+
calendarId: string
|
|
7
|
+
active: boolean
|
|
8
|
+
connected: boolean
|
|
9
|
+
lastSyncAt?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CalendarSyncLogEntry {
|
|
13
|
+
id: string
|
|
14
|
+
direction: string
|
|
15
|
+
trigger?: string
|
|
16
|
+
status: string
|
|
17
|
+
fetched: number
|
|
18
|
+
written: number
|
|
19
|
+
error?: string
|
|
20
|
+
createdAt: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Minimal Google Calendar event shape we read/write (subset of the API). */
|
|
24
|
+
export interface GCalEvent {
|
|
25
|
+
id: string
|
|
26
|
+
summary?: string
|
|
27
|
+
description?: string
|
|
28
|
+
start: { dateTime?: string; date?: string; timeZone?: string }
|
|
29
|
+
end: { dateTime?: string; date?: string; timeZone?: string }
|
|
30
|
+
status?: string
|
|
31
|
+
}
|