@startsimpli/integrations 0.1.0

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.
@@ -0,0 +1,40 @@
1
+ import * as React from 'react';
2
+ import type { AccountStatus } from '../types/index';
3
+
4
+ const _STYLE: Record<AccountStatus, React.CSSProperties> = {
5
+ connected: { background: '#e6f7ec', color: '#13642e', borderColor: '#a4dcb6' },
6
+ disconnected: { background: '#f3f3f3', color: '#555', borderColor: '#ccc' },
7
+ needs_reauth: { background: '#fff4e5', color: '#7a4a00', borderColor: '#f5c98a' },
8
+ };
9
+
10
+ const _LABEL: Record<AccountStatus, string> = {
11
+ connected: 'Connected',
12
+ disconnected: 'Not connected',
13
+ needs_reauth: 'Needs reconnect',
14
+ };
15
+
16
+ export interface AccountStatusBadgeProps {
17
+ status: AccountStatus;
18
+ className?: string;
19
+ }
20
+
21
+ export function AccountStatusBadge({ status, className }: AccountStatusBadgeProps) {
22
+ return (
23
+ <span
24
+ data-testid="account-status-badge"
25
+ data-status={status}
26
+ className={className}
27
+ style={{
28
+ display: 'inline-block',
29
+ padding: '2px 8px',
30
+ borderRadius: 9999,
31
+ border: '1px solid',
32
+ fontSize: 12,
33
+ fontWeight: 500,
34
+ ..._STYLE[status],
35
+ }}
36
+ >
37
+ {_LABEL[status]}
38
+ </span>
39
+ );
40
+ }
@@ -0,0 +1,83 @@
1
+ import * as React from 'react';
2
+ import { connectProvider } from '../api/index';
3
+ import type { ConnectedAccount, OAuthProvider } from '../types/index';
4
+ import { AccountStatusBadge } from './AccountStatusBadge';
5
+
6
+ const _PROVIDER_LABEL: Record<OAuthProvider, string> = {
7
+ google: 'Google (Gmail + Calendar)',
8
+ microsoft: 'Microsoft (Outlook + Calendar)',
9
+ };
10
+
11
+ export interface ConnectAccountCardProps {
12
+ provider: OAuthProvider;
13
+ account?: ConnectedAccount;
14
+ onConnect?: () => void;
15
+ onDisconnect?: (id: string) => void;
16
+ redirectUri?: string;
17
+ }
18
+
19
+ export function ConnectAccountCard({
20
+ provider,
21
+ account,
22
+ onConnect,
23
+ onDisconnect,
24
+ redirectUri,
25
+ }: ConnectAccountCardProps) {
26
+ const status =
27
+ !account
28
+ ? 'disconnected'
29
+ : account.needs_reauth || !account.is_active
30
+ ? 'needs_reauth'
31
+ : 'connected';
32
+
33
+ const handleConnect = async () => {
34
+ const resp = await connectProvider(provider, redirectUri);
35
+ if (typeof window !== 'undefined' && resp.authorization_url) {
36
+ window.location.assign(resp.authorization_url);
37
+ }
38
+ onConnect?.();
39
+ };
40
+
41
+ const handleDisconnect = () => {
42
+ if (account && onDisconnect) onDisconnect(account.id);
43
+ };
44
+
45
+ return (
46
+ <div
47
+ data-testid="connect-account-card"
48
+ data-provider={provider}
49
+ data-status={status}
50
+ style={{
51
+ border: '1px solid #ddd',
52
+ borderRadius: 8,
53
+ padding: 16,
54
+ display: 'flex',
55
+ flexDirection: 'column',
56
+ gap: 8,
57
+ }}
58
+ >
59
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
60
+ <strong>{_PROVIDER_LABEL[provider]}</strong>
61
+ <AccountStatusBadge status={status} />
62
+ </div>
63
+ {account && account.email ? (
64
+ <div style={{ color: '#555', fontSize: 13 }}>{account.email}</div>
65
+ ) : null}
66
+ <div>
67
+ {status === 'connected' ? (
68
+ <button type="button" onClick={handleDisconnect} data-action="disconnect">
69
+ Disconnect
70
+ </button>
71
+ ) : status === 'needs_reauth' ? (
72
+ <button type="button" onClick={handleConnect} data-action="reconnect">
73
+ Reconnect
74
+ </button>
75
+ ) : (
76
+ <button type="button" onClick={handleConnect} data-action="connect">
77
+ Connect {provider === 'microsoft' ? 'Outlook' : 'Google'}
78
+ </button>
79
+ )}
80
+ </div>
81
+ </div>
82
+ );
83
+ }
@@ -0,0 +1,76 @@
1
+ import * as React from 'react';
2
+ import type { EmailMessage } from '../types/index';
3
+
4
+ export interface EmailListProps {
5
+ messages: EmailMessage[];
6
+ isLoading?: boolean;
7
+ error?: unknown;
8
+ emptyMessage?: string;
9
+ className?: string;
10
+ }
11
+
12
+ export function EmailList({
13
+ messages,
14
+ isLoading,
15
+ error,
16
+ emptyMessage = 'No messages.',
17
+ className,
18
+ }: EmailListProps) {
19
+ if (isLoading) {
20
+ return (
21
+ <div data-testid="email-list-loading" className={className}>
22
+ Loading messages…
23
+ </div>
24
+ );
25
+ }
26
+ if (error) {
27
+ return (
28
+ <div data-testid="email-list-error" className={className} style={{ color: '#a00' }}>
29
+ Failed to load messages. {(error as Error).message}
30
+ </div>
31
+ );
32
+ }
33
+ if (!messages.length) {
34
+ return (
35
+ <div data-testid="email-list-empty" className={className} style={{ color: '#888' }}>
36
+ {emptyMessage}
37
+ </div>
38
+ );
39
+ }
40
+ return (
41
+ <ul
42
+ data-testid="email-list"
43
+ className={className}
44
+ style={{ listStyle: 'none', margin: 0, padding: 0 }}
45
+ >
46
+ {messages.map((m) => (
47
+ <li
48
+ key={m.id}
49
+ data-testid="email-list-item"
50
+ data-message-id={m.id}
51
+ data-outbound={m.is_outbound}
52
+ style={{
53
+ padding: '10px 0',
54
+ borderBottom: '1px solid #eee',
55
+ display: 'flex',
56
+ flexDirection: 'column',
57
+ gap: 2,
58
+ }}
59
+ >
60
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
61
+ <strong>{m.is_outbound ? `To: ${m.recipients.join(', ')}` : `From: ${m.sender}`}</strong>
62
+ {m.received_at || m.sent_at ? (
63
+ <span style={{ color: '#888', fontSize: 12 }}>
64
+ {new Date(m.received_at || m.sent_at!).toLocaleString()}
65
+ </span>
66
+ ) : null}
67
+ </div>
68
+ <div>{m.subject}</div>
69
+ {m.body_preview ? (
70
+ <div style={{ color: '#888', fontSize: 13 }}>{m.body_preview}</div>
71
+ ) : null}
72
+ </li>
73
+ ))}
74
+ </ul>
75
+ );
76
+ }
@@ -0,0 +1,52 @@
1
+ import * as React from 'react';
2
+ import type { CalendarEvent } from '../types/index';
3
+ import { EventListItem } from './EventListItem';
4
+
5
+ export interface EventListProps {
6
+ events: CalendarEvent[];
7
+ isLoading?: boolean;
8
+ error?: unknown;
9
+ emptyMessage?: string;
10
+ className?: string;
11
+ }
12
+
13
+ export function EventList({
14
+ events,
15
+ isLoading,
16
+ error,
17
+ emptyMessage = 'No events in this window.',
18
+ className,
19
+ }: EventListProps) {
20
+ if (isLoading) {
21
+ return (
22
+ <div data-testid="event-list-loading" className={className}>
23
+ Loading events…
24
+ </div>
25
+ );
26
+ }
27
+ if (error) {
28
+ return (
29
+ <div data-testid="event-list-error" className={className} style={{ color: '#a00' }}>
30
+ Failed to load events. {(error as Error).message}
31
+ </div>
32
+ );
33
+ }
34
+ if (!events.length) {
35
+ return (
36
+ <div data-testid="event-list-empty" className={className} style={{ color: '#888' }}>
37
+ {emptyMessage}
38
+ </div>
39
+ );
40
+ }
41
+ return (
42
+ <ul
43
+ data-testid="event-list"
44
+ className={className}
45
+ style={{ listStyle: 'none', margin: 0, padding: 0 }}
46
+ >
47
+ {events.map((event) => (
48
+ <EventListItem key={event.id} event={event} />
49
+ ))}
50
+ </ul>
51
+ );
52
+ }
@@ -0,0 +1,70 @@
1
+ import * as React from 'react';
2
+ import type { CalendarEvent } from '../types/index';
3
+
4
+ export interface EventListItemProps {
5
+ event: CalendarEvent;
6
+ className?: string;
7
+ }
8
+
9
+ function _formatRange(startIso: string, endIso: string): string {
10
+ try {
11
+ const start = new Date(startIso);
12
+ const end = new Date(endIso);
13
+ const sameDay = start.toDateString() === end.toDateString();
14
+ const dateOpts: Intl.DateTimeFormatOptions = {
15
+ month: 'short',
16
+ day: 'numeric',
17
+ hour: 'numeric',
18
+ minute: 'numeric',
19
+ };
20
+ if (sameDay) {
21
+ const startStr = start.toLocaleString(undefined, dateOpts);
22
+ const endStr = end.toLocaleTimeString(undefined, {
23
+ hour: 'numeric',
24
+ minute: 'numeric',
25
+ });
26
+ return `${startStr} – ${endStr}`;
27
+ }
28
+ return `${start.toLocaleString(undefined, dateOpts)} – ${end.toLocaleString(
29
+ undefined,
30
+ dateOpts,
31
+ )}`;
32
+ } catch {
33
+ return `${startIso} – ${endIso}`;
34
+ }
35
+ }
36
+
37
+ export function EventListItem({ event, className }: EventListItemProps) {
38
+ const isCancelled = event.status === 'cancelled';
39
+ const titleStyle: React.CSSProperties = isCancelled
40
+ ? { textDecoration: 'line-through', color: '#999' }
41
+ : {};
42
+ return (
43
+ <li
44
+ data-testid="event-list-item"
45
+ data-event-id={event.id}
46
+ data-status={event.status}
47
+ className={className}
48
+ style={{
49
+ padding: '12px 0',
50
+ borderBottom: '1px solid #eee',
51
+ display: 'flex',
52
+ flexDirection: 'column',
53
+ gap: 4,
54
+ }}
55
+ >
56
+ <div style={titleStyle}>
57
+ <strong>{event.title || '(no title)'}</strong>
58
+ </div>
59
+ <div style={{ color: '#666', fontSize: 13 }}>{_formatRange(event.start_at, event.end_at)}</div>
60
+ {event.location ? (
61
+ <div style={{ color: '#888', fontSize: 12 }}>{event.location}</div>
62
+ ) : null}
63
+ {event.attendees.length > 0 ? (
64
+ <div style={{ color: '#888', fontSize: 12 }}>
65
+ {event.attendees.length} attendee{event.attendees.length === 1 ? '' : 's'}
66
+ </div>
67
+ ) : null}
68
+ </li>
69
+ );
70
+ }
@@ -0,0 +1,53 @@
1
+ import * as React from 'react';
2
+
3
+ export interface ReconsentPromptCardProps {
4
+ reconsentUrl: string;
5
+ providerLabel?: string;
6
+ message?: string;
7
+ className?: string;
8
+ }
9
+
10
+ export function ReconsentPromptCard({
11
+ reconsentUrl,
12
+ providerLabel = 'this account',
13
+ message,
14
+ className,
15
+ }: ReconsentPromptCardProps) {
16
+ return (
17
+ <div
18
+ data-testid="reconsent-prompt-card"
19
+ className={className}
20
+ style={{
21
+ background: '#fff4e5',
22
+ border: '1px solid #f5c98a',
23
+ borderRadius: 8,
24
+ padding: 16,
25
+ display: 'flex',
26
+ flexDirection: 'column',
27
+ gap: 8,
28
+ }}
29
+ >
30
+ <strong>Reconnect {providerLabel}</strong>
31
+ <p style={{ margin: 0, color: '#7a4a00' }}>
32
+ {message ||
33
+ 'Additional permissions are required to load this data. Please reconnect to grant the missing access.'}
34
+ </p>
35
+ <div>
36
+ <a
37
+ href={reconsentUrl}
38
+ data-action="reconnect"
39
+ style={{
40
+ display: 'inline-block',
41
+ padding: '6px 12px',
42
+ background: '#7a4a00',
43
+ color: '#fff',
44
+ borderRadius: 4,
45
+ textDecoration: 'none',
46
+ }}
47
+ >
48
+ Reconnect now
49
+ </a>
50
+ </div>
51
+ </div>
52
+ );
53
+ }
@@ -0,0 +1,6 @@
1
+ export * from './AccountStatusBadge';
2
+ export * from './ConnectAccountCard';
3
+ export * from './EmailList';
4
+ export * from './EventList';
5
+ export * from './EventListItem';
6
+ export * from './ReconsentPromptCard';
@@ -0,0 +1,4 @@
1
+ export * from './useConnectedAccounts';
2
+ export * from './useCalendarEvents';
3
+ export * from './useEmailMessages';
4
+ export * from './useReconsent';
@@ -0,0 +1,64 @@
1
+ import { useQuery, type UseQueryOptions } from '@tanstack/react-query';
2
+ import { listCalendarEvents } from '../api/index';
3
+ import type {
4
+ CalendarEvent,
5
+ CalendarWindow,
6
+ OAuthProvider,
7
+ PaginatedResponse,
8
+ ReconsentRequiredError,
9
+ } from '../types/index';
10
+
11
+ export interface UseCalendarEventsOptions
12
+ extends Omit<UseQueryOptions<PaginatedResponse<CalendarEvent>>, 'queryKey' | 'queryFn'> {
13
+ provider?: OAuthProvider;
14
+ calendarId?: string;
15
+ /**
16
+ * Page size forwarded to the backend. Defaults to the API default (100).
17
+ * Bump this when rendering a long calendar window so a single fetch
18
+ * returns the full set instead of just page 1. Backend caps at 500.
19
+ */
20
+ pageSize?: number;
21
+ }
22
+
23
+ function _isoFromDate(d: Date | string): string {
24
+ return typeof d === 'string' ? d : d.toISOString();
25
+ }
26
+
27
+ export function useCalendarEvents(
28
+ window: CalendarWindow,
29
+ options: UseCalendarEventsOptions = {},
30
+ ) {
31
+ const { provider, calendarId, pageSize, ...queryOptions } = options;
32
+ const fromIso = _isoFromDate(window.from);
33
+ const toIso = _isoFromDate(window.to);
34
+ const result = useQuery<PaginatedResponse<CalendarEvent>>({
35
+ queryKey: [
36
+ 'startsimpli',
37
+ 'integrations',
38
+ 'calendar-events',
39
+ { from: fromIso, to: toIso, provider, calendarId, pageSize },
40
+ ],
41
+ queryFn: () =>
42
+ listCalendarEvents({
43
+ from: fromIso,
44
+ to: toIso,
45
+ provider,
46
+ calendar_id: calendarId,
47
+ page_size: pageSize,
48
+ }),
49
+ staleTime: 60_000,
50
+ ...queryOptions,
51
+ });
52
+
53
+ // Surface a reconsent_url separately when the API returned 403 with one.
54
+ const reconsentUrl =
55
+ result.error && (result.error as ReconsentRequiredError).reconsent_url
56
+ ? (result.error as ReconsentRequiredError).reconsent_url
57
+ : undefined;
58
+
59
+ return {
60
+ ...result,
61
+ events: result.data?.results ?? [],
62
+ reconsentUrl,
63
+ };
64
+ }
@@ -0,0 +1,25 @@
1
+ import { useQuery, useQueryClient, type UseQueryOptions } from '@tanstack/react-query';
2
+ import { listConnectedAccounts, revokeAccount } from '../api/index';
3
+ import type { ConnectedAccount, OAuthProvider } from '../types/index';
4
+
5
+ export const CONNECTED_ACCOUNTS_KEY = ['startsimpli', 'integrations', 'connected-accounts'] as const;
6
+
7
+ export interface UseConnectedAccountsOptions
8
+ extends Omit<UseQueryOptions<ConnectedAccount[]>, 'queryKey' | 'queryFn'> {}
9
+
10
+ export function useConnectedAccounts(options: UseConnectedAccountsOptions = {}) {
11
+ return useQuery<ConnectedAccount[]>({
12
+ queryKey: CONNECTED_ACCOUNTS_KEY,
13
+ queryFn: () => listConnectedAccounts(),
14
+ staleTime: 30_000,
15
+ ...options,
16
+ });
17
+ }
18
+
19
+ export function useDisconnectAccount() {
20
+ const qc = useQueryClient();
21
+ return async (provider: OAuthProvider, oauthAccountId: string) => {
22
+ await revokeAccount(provider, oauthAccountId);
23
+ await qc.invalidateQueries({ queryKey: CONNECTED_ACCOUNTS_KEY });
24
+ };
25
+ }
@@ -0,0 +1,36 @@
1
+ import { useQuery, type UseQueryOptions } from '@tanstack/react-query';
2
+ import {
3
+ listEmailMessages,
4
+ type ListEmailMessagesOptions,
5
+ } from '../api/index';
6
+ import type {
7
+ EmailMessage,
8
+ PaginatedResponse,
9
+ ReconsentRequiredError,
10
+ } from '../types/index';
11
+
12
+ export interface UseEmailMessagesOptions
13
+ extends Omit<UseQueryOptions<PaginatedResponse<EmailMessage>>, 'queryKey' | 'queryFn'> {}
14
+
15
+ export function useEmailMessages(
16
+ filter: ListEmailMessagesOptions = {},
17
+ options: UseEmailMessagesOptions = {},
18
+ ) {
19
+ const result = useQuery<PaginatedResponse<EmailMessage>>({
20
+ queryKey: ['startsimpli', 'integrations', 'email-messages', filter],
21
+ queryFn: () => listEmailMessages(filter),
22
+ staleTime: 30_000,
23
+ ...options,
24
+ });
25
+
26
+ const reconsentUrl =
27
+ result.error && (result.error as ReconsentRequiredError).reconsent_url
28
+ ? (result.error as ReconsentRequiredError).reconsent_url
29
+ : undefined;
30
+
31
+ return {
32
+ ...result,
33
+ messages: result.data?.results ?? [],
34
+ reconsentUrl,
35
+ };
36
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * useReconsent — drives the re-consent flow when the API surfaces a
3
+ * ReconsentRequiredError carrying a reconsent_url.
4
+ *
5
+ * Usage:
6
+ * const { reconsentUrl, openReconsent } = useReconsent(error);
7
+ * if (reconsentUrl) <ReconsentPromptCard onClick={openReconsent} />
8
+ */
9
+
10
+ import { useCallback } from 'react';
11
+ import { ReconsentRequiredError } from '../types/index';
12
+
13
+ export interface UseReconsentResult {
14
+ reconsentUrl: string | undefined;
15
+ isReconsentRequired: boolean;
16
+ openReconsent: () => void;
17
+ }
18
+
19
+ export function useReconsent(error: unknown): UseReconsentResult {
20
+ const reconsentUrl =
21
+ error instanceof ReconsentRequiredError ? error.reconsent_url : undefined;
22
+
23
+ const openReconsent = useCallback(() => {
24
+ if (reconsentUrl && typeof window !== 'undefined') {
25
+ window.location.assign(reconsentUrl);
26
+ }
27
+ }, [reconsentUrl]);
28
+
29
+ return {
30
+ reconsentUrl,
31
+ isReconsentRequired: Boolean(reconsentUrl),
32
+ openReconsent,
33
+ };
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @startsimpli/integrations
3
+ *
4
+ * Provider-agnostic types, API clients, react-query hooks, and UI primitives
5
+ * for email + calendar integrations across StartSimpli apps. Backed by the
6
+ * start-simpli-api Django service.
7
+ *
8
+ * Reference lifecycle: email-cal-srcs-dests.
9
+ */
10
+
11
+ export * from './types/index';
12
+ export * from './api/index';
13
+ export * from './hooks/index';
14
+ export * from './components/index';
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Provider-agnostic types for email + calendar integrations.
3
+ *
4
+ * Mirrors the Django serializer output from
5
+ * apps/sources/api/calendar.py + email.py + apps/authentication/* OAuth views.
6
+ */
7
+
8
+ export type OAuthProvider = 'google' | 'microsoft';
9
+
10
+ export type AccountStatus = 'connected' | 'disconnected' | 'needs_reauth';
11
+
12
+ export interface ConnectedAccount {
13
+ id: string;
14
+ provider: OAuthProvider;
15
+ email: string;
16
+ scopes: string[];
17
+ has_calendar_scope: boolean;
18
+ has_mail_scope: boolean;
19
+ needs_reauth: boolean;
20
+ is_active: boolean;
21
+ connected_at: string; // ISO 8601
22
+ }
23
+
24
+ export interface CalendarEventAttendee {
25
+ email: string;
26
+ name?: string;
27
+ status?: 'accepted' | 'declined' | 'tentative' | 'needsAction' | string;
28
+ }
29
+
30
+ export type CalendarEventStatus = 'confirmed' | 'cancelled' | 'tentative';
31
+
32
+ export interface CalendarEvent {
33
+ id: string;
34
+ provider: OAuthProvider | '';
35
+ calendar_id: string;
36
+ provider_event_id: string;
37
+ start_at: string; // ISO 8601
38
+ end_at: string; // ISO 8601
39
+ title: string;
40
+ description?: string;
41
+ location?: string;
42
+ attendees: CalendarEventAttendee[];
43
+ status: CalendarEventStatus;
44
+ recurrence_rule?: string;
45
+ }
46
+
47
+ export interface EmailMessage {
48
+ id: string;
49
+ provider: OAuthProvider | '';
50
+ provider_message_id: string;
51
+ thread_id: string;
52
+ sender: string;
53
+ recipients: string[];
54
+ subject: string;
55
+ body_preview: string;
56
+ sent_at: string | null;
57
+ received_at: string | null;
58
+ is_outbound: boolean;
59
+ matched_contact: string | null; // contact UUID
60
+ }
61
+
62
+ export interface PaginatedResponse<T> {
63
+ count?: number;
64
+ next: string | null;
65
+ previous: string | null;
66
+ results: T[];
67
+ }
68
+
69
+ export interface CalendarWindow {
70
+ from: Date | string;
71
+ to: Date | string;
72
+ }
73
+
74
+ export interface ApiErrorPayload {
75
+ error?: string;
76
+ detail?: string;
77
+ reconsent_url?: string;
78
+ }
79
+
80
+ export class ApiError extends Error {
81
+ status: number;
82
+ payload: ApiErrorPayload;
83
+ constructor(message: string, status: number, payload: ApiErrorPayload = {}) {
84
+ super(message);
85
+ this.name = 'ApiError';
86
+ this.status = status;
87
+ this.payload = payload;
88
+ }
89
+ }
90
+
91
+ export class UnauthenticatedError extends ApiError {
92
+ constructor(payload: ApiErrorPayload = {}) {
93
+ super('Unauthenticated', 401, payload);
94
+ this.name = 'UnauthenticatedError';
95
+ }
96
+ }
97
+
98
+ export class ReconsentRequiredError extends ApiError {
99
+ reconsent_url: string;
100
+ constructor(payload: ApiErrorPayload = {}) {
101
+ super('Reconsent required', 403, payload);
102
+ this.name = 'ReconsentRequiredError';
103
+ this.reconsent_url = payload.reconsent_url || '';
104
+ }
105
+ }