@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.
- package/package.json +42 -0
- package/src/__tests__/SPEC.md +160 -0
- package/src/__tests__/api.test.ts +229 -0
- package/src/api/_fetch.ts +121 -0
- package/src/api/accounts.ts +16 -0
- package/src/api/calendar.ts +37 -0
- package/src/api/email.ts +40 -0
- package/src/api/index.ts +5 -0
- package/src/api/oauth.ts +34 -0
- package/src/components/AccountStatusBadge.tsx +40 -0
- package/src/components/ConnectAccountCard.tsx +83 -0
- package/src/components/EmailList.tsx +76 -0
- package/src/components/EventList.tsx +52 -0
- package/src/components/EventListItem.tsx +70 -0
- package/src/components/ReconsentPromptCard.tsx +53 -0
- package/src/components/index.ts +6 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/useCalendarEvents.ts +64 -0
- package/src/hooks/useConnectedAccounts.ts +25 -0
- package/src/hooks/useEmailMessages.ts +36 -0
- package/src/hooks/useReconsent.ts +34 -0
- package/src/index.ts +14 -0
- package/src/types/index.ts +105 -0
|
@@ -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,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
|
+
}
|