diff --git a/assets/src/api.ts b/assets/src/api.ts index 6490c5bc1..8e69b76a4 100644 --- a/assets/src/api.ts +++ b/assets/src/api.ts @@ -181,7 +181,7 @@ export const fetchCustomer = async (id: string, token = getAccessToken()) => { export const updateCustomer = async ( id: string, - updates: any, + updates: Record, token = getAccessToken() ) => { if (!token) { @@ -198,7 +198,7 @@ export const updateCustomer = async ( }; export const createNewCompany = async ( - params: any, + params: Record, token = getAccessToken() ) => { if (!token) { @@ -236,7 +236,7 @@ export const fetchCompany = async (id: string, token = getAccessToken()) => { export const updateCompany = async ( id: string, - updates: any, + updates: Record, token = getAccessToken() ) => { if (!token) { @@ -291,7 +291,7 @@ export const fetchAccountInfo = async (token = getAccessToken()) => { }; export const updateAccountInfo = async ( - updates: any, + updates: Record, token = getAccessToken() ) => { if (!token) { @@ -319,7 +319,7 @@ export const fetchUserProfile = async (token = getAccessToken()) => { }; export const updateUserProfile = async ( - updates: any, + updates: Record, token = getAccessToken() ) => { if (!token) { @@ -347,7 +347,7 @@ export const fetchUserSettings = async (token = getAccessToken()) => { }; export const updateUserSettings = async ( - updates: any, + updates: Record, token = getAccessToken() ) => { if (!token) { @@ -460,7 +460,7 @@ export const fetchSharedConversation = async ( export const updateConversation = async ( conversationId: string, - updates: any, + updates: Record, token = getAccessToken() ) => { if (!token) { @@ -558,6 +558,19 @@ export const fetchSlackAuthorization = async ( .then((res) => res.body.data); }; +export const deleteSlackAuthorization = async ( + authorizationId: string, + token = getAccessToken() +) => { + if (!token) { + throw new Error('Invalid token!'); + } + + return request + .delete(`/api/slack/authorizations/${authorizationId}`) + .set('Authorization', token); +}; + export const fetchSlackChannels = async ( query = {}, token = getAccessToken() diff --git a/assets/src/components/account/AccountOverview.tsx b/assets/src/components/account/AccountOverview.tsx index 856740542..54926ab01 100644 --- a/assets/src/components/account/AccountOverview.tsx +++ b/assets/src/components/account/AccountOverview.tsx @@ -15,14 +15,14 @@ import DisabledUsersTable from './DisabledUsersTable'; import WorkingHoursSelector from './WorkingHoursSelector'; import {WorkingHours} from './support'; import * as API from '../../api'; -import {User} from '../../types'; +import {Account, User} from '../../types'; import {FRONTEND_BASE_URL} from '../../config'; import {sleep, hasValidStripeKey} from '../../utils'; import logger from '../../logger'; type Props = {}; type State = { - account: any; + account: Account | null; companyName: string; currentUser: User | null; inviteUrl: string; @@ -125,7 +125,7 @@ class AccountOverview extends React.Component { } }; - handleChangeCompanyName = (e: any) => { + handleChangeCompanyName = (e: React.ChangeEvent) => { this.setState({companyName: e.target.value}); }; diff --git a/assets/src/components/account/GettingStartedOverview.tsx b/assets/src/components/account/GettingStartedOverview.tsx index fba40d9e8..4fa4187ce 100644 --- a/assets/src/components/account/GettingStartedOverview.tsx +++ b/assets/src/components/account/GettingStartedOverview.tsx @@ -70,25 +70,27 @@ class GettingStartedOverview extends React.Component { 400 ); - handleChangeTitle = (e: any) => { + handleChangeTitle = (e: React.ChangeEvent) => { this.setState({title: e.target.value}, this.debouncedUpdateWidgetSettings); }; - handleChangeSubtitle = (e: any) => { + handleChangeSubtitle = (e: React.ChangeEvent) => { this.setState( {subtitle: e.target.value}, this.debouncedUpdateWidgetSettings ); }; - handleChangeGreeting = (e: any) => { + handleChangeGreeting = (e: React.ChangeEvent) => { this.setState( {greeting: e.target.value}, this.debouncedUpdateWidgetSettings ); }; - handleChangeNewMessagePlaceholder = (e: any) => { + handleChangeNewMessagePlaceholder = ( + e: React.ChangeEvent + ) => { this.setState( {newMessagePlaceholder: e.target.value}, this.debouncedUpdateWidgetSettings diff --git a/assets/src/components/account/UserProfile.tsx b/assets/src/components/account/UserProfile.tsx index a802131af..b9878f53b 100644 --- a/assets/src/components/account/UserProfile.tsx +++ b/assets/src/components/account/UserProfile.tsx @@ -87,25 +87,25 @@ class UserProfile extends React.Component { } }; - handleChangeFullName = (e: any) => { + handleChangeFullName = (e: React.ChangeEvent) => { this.setState({fullName: e.target.value}); }; - handleChangeDisplayName = (e: any) => { + handleChangeDisplayName = (e: React.ChangeEvent) => { this.setState({displayName: e.target.value}); }; - handleChangeProfilePhotoUrl = (e: any) => { + handleChangeProfilePhotoUrl = (e: React.ChangeEvent) => { this.setState({profilePhotoUrl: e.target.value}); }; - handleCancel = () => { + handleCancel = async () => { return this.fetchLatestProfile().then(() => this.setState({isEditing: false}) ); }; - handleUpdate = () => { + handleUpdate = async () => { const {displayName, fullName, profilePhotoUrl} = this.state; return API.updateUserProfile({ diff --git a/assets/src/components/account/WorkingHoursSelector.tsx b/assets/src/components/account/WorkingHoursSelector.tsx index faba6efce..0aadbcf1f 100644 --- a/assets/src/components/account/WorkingHoursSelector.tsx +++ b/assets/src/components/account/WorkingHoursSelector.tsx @@ -67,7 +67,7 @@ const filterSelectOption = (input: string, option: any) => { }; type Props = { - timezone: string | null; + timezone?: string | null; workingHours: Array; onCancel?: () => void; onSave: (data: { diff --git a/assets/src/components/auth/Login.tsx b/assets/src/components/auth/Login.tsx index a08b17b34..96adff9c6 100644 --- a/assets/src/components/auth/Login.tsx +++ b/assets/src/components/auth/Login.tsx @@ -32,15 +32,15 @@ class Login extends React.Component { this.setState({redirect: String(redirect)}); } - handleChangeEmail = (e: any) => { + handleChangeEmail = (e: React.ChangeEvent) => { this.setState({email: e.target.value}); }; - handleChangePassword = (e: any) => { + handleChangePassword = (e: React.ChangeEvent) => { this.setState({password: e.target.value}); }; - handleSubmit = (e: any) => { + handleSubmit = (e: React.FormEvent) => { e.preventDefault(); this.setState({loading: true, error: null}); diff --git a/assets/src/components/auth/PasswordReset.tsx b/assets/src/components/auth/PasswordReset.tsx index 297ec347e..b44fff913 100644 --- a/assets/src/components/auth/PasswordReset.tsx +++ b/assets/src/components/auth/PasswordReset.tsx @@ -41,11 +41,13 @@ class PasswordReset extends React.Component { } } - handleChangePassword = (e: any) => { + handleChangePassword = (e: React.ChangeEvent) => { this.setState({password: e.target.value}); }; - handleChangePasswordConfirmation = (e: any) => { + handleChangePasswordConfirmation = ( + e: React.ChangeEvent + ) => { this.setState({passwordConfirmation: e.target.value}); }; @@ -71,7 +73,7 @@ class PasswordReset extends React.Component { this.setState({error: this.getValidationError()}); }; - handleSubmit = (e: any) => { + handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const error = this.getValidationError(); diff --git a/assets/src/components/auth/Register.tsx b/assets/src/components/auth/Register.tsx index c1cde0966..4be87c9f3 100644 --- a/assets/src/components/auth/Register.tsx +++ b/assets/src/components/auth/Register.tsx @@ -43,19 +43,21 @@ class Register extends React.Component { this.setState({inviteToken, redirect: String(redirect)}); } - handleChangeCompanyName = (e: any) => { + handleChangeCompanyName = (e: React.ChangeEvent) => { this.setState({companyName: e.target.value}); }; - handleChangeEmail = (e: any) => { + handleChangeEmail = (e: React.ChangeEvent) => { this.setState({email: e.target.value}); }; - handleChangePassword = (e: any) => { + handleChangePassword = (e: React.ChangeEvent) => { this.setState({password: e.target.value}); }; - handleChangePasswordConfirmation = (e: any) => { + handleChangePasswordConfirmation = ( + e: React.ChangeEvent + ) => { this.setState({passwordConfirmation: e.target.value}); }; @@ -91,7 +93,7 @@ class Register extends React.Component { this.setState({error: this.getValidationError()}); }; - handleSubmit = (e: any) => { + handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const error = this.getValidationError(); diff --git a/assets/src/components/auth/RequestPasswordReset.tsx b/assets/src/components/auth/RequestPasswordReset.tsx index 67e23c105..a849d7d4d 100644 --- a/assets/src/components/auth/RequestPasswordReset.tsx +++ b/assets/src/components/auth/RequestPasswordReset.tsx @@ -26,11 +26,11 @@ class RequestPasswordReset extends React.Component { // } - handleChangeEmail = (e: any) => { + handleChangeEmail = (e: React.ChangeEvent) => { this.setState({email: e.target.value}); }; - handleSubmit = (e: any) => { + handleSubmit = (e: React.FormEvent) => { e.preventDefault(); this.setState({loading: true, error: null}); diff --git a/assets/src/components/billing/PaymentForm.tsx b/assets/src/components/billing/PaymentForm.tsx index 5076ba267..226957c84 100644 --- a/assets/src/components/billing/PaymentForm.tsx +++ b/assets/src/components/billing/PaymentForm.tsx @@ -25,7 +25,7 @@ const PaymentForm = ({onSuccess, onCancel}: Props) => { } }; - const handleSubmit = async (e: any) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!stripe || !elements) { diff --git a/assets/src/components/common.tsx b/assets/src/components/common.tsx index b4624e96a..d5c92f516 100644 --- a/assets/src/components/common.tsx +++ b/assets/src/components/common.tsx @@ -72,7 +72,7 @@ export const TextArea = Input.TextArea; /* Whitelist node types that we allow when we render markdown. * Reference https://github.com/rexxars/react-markdown#node-types */ -export const allowedNodeTypes: any[] = [ +export const allowedNodeTypes: Array = [ 'root', 'text', 'break', diff --git a/assets/src/components/companies/CompanyDetailsPage.tsx b/assets/src/components/companies/CompanyDetailsPage.tsx index 169f53fa5..c658ce281 100644 --- a/assets/src/components/companies/CompanyDetailsPage.tsx +++ b/assets/src/components/companies/CompanyDetailsPage.tsx @@ -12,6 +12,7 @@ import { } from '../common'; import {ArrowLeftOutlined, DeleteOutlined} from '../icons'; import * as API from '../../api'; +import {Company} from '../../types'; import {sleep} from '../../utils'; import Spinner from '../Spinner'; import logger from '../../logger'; @@ -43,7 +44,7 @@ type State = { loading: boolean; deleting: boolean; refreshing: boolean; - company: any; + company: Company | null; customers: Array; }; diff --git a/assets/src/components/companies/UpdateCompanyPage.tsx b/assets/src/components/companies/UpdateCompanyPage.tsx index a011949f6..fe342771a 100644 --- a/assets/src/components/companies/UpdateCompanyPage.tsx +++ b/assets/src/components/companies/UpdateCompanyPage.tsx @@ -4,13 +4,14 @@ import {Box, Flex} from 'theme-ui'; import {Button, Input, Select, Title} from '../common'; import {ArrowLeftOutlined} from '../icons'; import * as API from '../../api'; +import {Company} from '../../types'; import logger from '../../logger'; type Props = RouteComponentProps<{id: string}>; type State = { loading: boolean; saving: boolean; - company: any; + company: Company | null; name: string; description: string; websiteUrl: string; diff --git a/assets/src/components/conversations/ConversationFooter.tsx b/assets/src/components/conversations/ConversationFooter.tsx index 1ed1569c0..095fc3bd1 100644 --- a/assets/src/components/conversations/ConversationFooter.tsx +++ b/assets/src/components/conversations/ConversationFooter.tsx @@ -11,7 +11,8 @@ const ConversationFooter = ({ }) => { const [message, setMessage] = React.useState(''); - const handleMessageChange = (e: any) => setMessage(e.target.value); + const handleMessageChange = (e: React.ChangeEvent) => + setMessage(e.target.value); const handleKeyDown = (e: any) => { const {key, metaKey} = e; diff --git a/assets/src/components/conversations/ConversationMessages.tsx b/assets/src/components/conversations/ConversationMessages.tsx index 6b62ca17d..ddd13dcd0 100644 --- a/assets/src/components/conversations/ConversationMessages.tsx +++ b/assets/src/components/conversations/ConversationMessages.tsx @@ -49,7 +49,7 @@ const ConversationMessages = ({ isAgentMessage, }: { messages: Array; - currentUser?: User; + currentUser?: User | null; customer: Customer | null; loading?: boolean; isClosing?: boolean; diff --git a/assets/src/components/conversations/ConversationsContainer.tsx b/assets/src/components/conversations/ConversationsContainer.tsx index f20e51487..314e5b9af 100644 --- a/assets/src/components/conversations/ConversationsContainer.tsx +++ b/assets/src/components/conversations/ConversationsContainer.tsx @@ -3,7 +3,7 @@ import {Box, Flex} from 'theme-ui'; import qs from 'query-string'; import {colors, Layout, notification, Sider, Text, Title} from '../common'; import {sleep} from '../../utils'; -import {Conversation, Message, User} from '../../types'; +import {Account, Conversation, Message, User} from '../../types'; import ConversationHeader from './ConversationHeader'; import ConversationItem from './ConversationItem'; import ConversationClosing from './ConversationClosing'; @@ -14,8 +14,8 @@ import {getColorByUuid} from './support'; type Props = { title?: string; - account: any; - currentUser: User; + account: Account | null; + currentUser: User | null; currentlyOnline?: any; loading: boolean; showGetStarted: boolean; diff --git a/assets/src/components/conversations/ConversationsProvider.tsx b/assets/src/components/conversations/ConversationsProvider.tsx index c2c0f1555..3c90983f9 100644 --- a/assets/src/components/conversations/ConversationsProvider.tsx +++ b/assets/src/components/conversations/ConversationsProvider.tsx @@ -3,15 +3,15 @@ import {Channel, Socket} from 'phoenix'; import {throttle} from 'lodash'; import * as API from '../../api'; import {notification} from '../common'; -import {Conversation, Message} from '../../types'; +import {Account, Conversation, Message, User} from '../../types'; import {sleep, isWindowHidden, updateQueryParams} from '../../utils'; import {SOCKET_URL} from '../../socket'; import logger from '../../logger'; export const ConversationsContext = React.createContext<{ loading: boolean; - account: any; - currentUser: any; + account: Account | null; + currentUser: User | null; isNewUser: boolean; all: Array; @@ -147,8 +147,8 @@ export const updatePresenceWithExiters = ( type Props = React.PropsWithChildren<{}>; type State = { loading: boolean; - account: any | null; - currentUser: any | null; + account: Account | null; + currentUser: User | null; isNewUser: boolean; selectedConversationId: string | null; diff --git a/assets/src/components/customers/CustomerDetailsModal.tsx b/assets/src/components/customers/CustomerDetailsModal.tsx index a31580f10..565aa148d 100644 --- a/assets/src/components/customers/CustomerDetailsModal.tsx +++ b/assets/src/components/customers/CustomerDetailsModal.tsx @@ -94,15 +94,15 @@ class CustomerDetailsModal extends React.Component { this.setState({updates: {...this.state.updates, ...updates}}); }; - handleChangeName = (e: any) => { + handleChangeName = (e: React.ChangeEvent) => { this.handleEditCustomer({name: e.target.value}); }; - handleChangeEmail = (e: any) => { + handleChangeEmail = (e: React.ChangeEvent) => { this.handleEditCustomer({email: e.target.value}); }; - handleChangePhone = (e: any) => { + handleChangePhone = (e: React.ChangeEvent) => { this.handleEditCustomer({phone: e.target.value}); }; diff --git a/assets/src/components/demo/BotDemo.tsx b/assets/src/components/demo/BotDemo.tsx index 1744cc9a4..f3047d352 100644 --- a/assets/src/components/demo/BotDemo.tsx +++ b/assets/src/components/demo/BotDemo.tsx @@ -5,10 +5,10 @@ import request from 'superagent'; import { colors, notification, + Alert, Button, Divider, Input, - Paragraph, Text, TextArea, Title, @@ -114,11 +114,11 @@ class Demo extends React.Component { }; }; - handleChangeQuestion = (e: any) => { + handleChangeQuestion = (e: React.ChangeEvent) => { this.setState({newQuestion: e.target.value}); }; - handleChangeAnswer = (e: any) => { + handleChangeAnswer = (e: React.ChangeEvent) => { this.setState({newAnswer: e.target.value}); }; @@ -185,17 +185,16 @@ class Demo extends React.Component { > Papercups Bot Demo - - Hello! Try asking a question in the chat window.{' '} - - 🤖 - - - - The bot will try to respond to your questions in the chat based on - the training data below. You can add new questions/answers as well - if you'd like to try it out! - + + This bot is temporarily disabled, click{' '} + here to view our standard demo. + + } + type="error" + showIcon + /> diff --git a/assets/src/components/demo/Demo.tsx b/assets/src/components/demo/Demo.tsx index cd2303c4d..5f26001c1 100644 --- a/assets/src/components/demo/Demo.tsx +++ b/assets/src/components/demo/Demo.tsx @@ -80,11 +80,11 @@ class Demo extends React.Component { this.storytime && this.storytime.finish(); } - handleChangeTitle = (e: any) => { + handleChangeTitle = (e: React.ChangeEvent) => { this.setState({title: e.target.value}); }; - handleChangeSubtitle = (e: any) => { + handleChangeSubtitle = (e: React.ChangeEvent) => { this.setState({subtitle: e.target.value}); }; diff --git a/assets/src/components/integrations/IntegrationsOverview.tsx b/assets/src/components/integrations/IntegrationsOverview.tsx index eb320c925..cd049f40b 100644 --- a/assets/src/components/integrations/IntegrationsOverview.tsx +++ b/assets/src/components/integrations/IntegrationsOverview.tsx @@ -15,6 +15,7 @@ import NewWebhookModal from './NewWebhookModal'; type Props = RouteComponentProps<{type?: string}> & {}; type State = { loading: boolean; + refreshing: boolean; isWebhookModalOpen: boolean; selectedWebhook: WebhookEventSubscription | null; integrations: Array; @@ -24,6 +25,7 @@ type State = { class IntegrationsOverview extends React.Component { state: State = { loading: true, + refreshing: false, isWebhookModalOpen: false, selectedWebhook: null, integrations: [], @@ -60,6 +62,27 @@ class IntegrationsOverview extends React.Component { } } + refreshAllIntegrations = async () => { + try { + this.setState({refreshing: true}); + + const integrations = await Promise.all([ + this.fetchSlackIntegration(), + this.fetchSlackSupportIntegration(), + this.fetchGmailIntegration(), + this.fetchTwilioIntegration(), + this.fetchMicrosoftTeamsIntegration(), + this.fetchWhatsAppIntegration(), + ]); + + this.setState({integrations, refreshing: false}); + } catch (err) { + logger.error('Error refreshing integrations:', err); + + this.setState({refreshing: false}); + } + }; + fetchSlackIntegration = async (): Promise => { const auth = await API.fetchSlackAuthorization('reply'); @@ -68,6 +91,7 @@ class IntegrationsOverview extends React.Component { integration: 'Slack', status: auth ? 'connected' : 'not_connected', created_at: auth ? auth.created_at : null, + authorization_id: auth ? auth.id : null, icon: '/slack.svg', }; }; @@ -80,6 +104,7 @@ class IntegrationsOverview extends React.Component { integration: 'Sync with Slack (beta)', status: auth ? 'connected' : 'not_connected', created_at: auth ? auth.created_at : null, + authorization_id: auth ? auth.id : null, icon: '/slack.svg', }; }; @@ -92,6 +117,7 @@ class IntegrationsOverview extends React.Component { integration: 'Gmail (beta)', status: auth ? 'connected' : 'not_connected', created_at: auth ? auth.created_at : null, + authorization_id: auth ? auth.id : null, icon: '/gmail.svg', }; }; @@ -102,6 +128,7 @@ class IntegrationsOverview extends React.Component { integration: 'Microsoft Teams', status: 'not_connected', created_at: null, + authorization_id: null, icon: '/microsoft-teams.svg', }; }; @@ -112,6 +139,7 @@ class IntegrationsOverview extends React.Component { integration: 'Twilio', status: 'not_connected', created_at: null, + authorization_id: null, icon: '/twilio.svg', }; }; @@ -122,6 +150,7 @@ class IntegrationsOverview extends React.Component { integration: 'WhatsApp', status: 'not_connected', created_at: null, + authorization_id: null, icon: '/whatsapp.svg', }; }; @@ -155,6 +184,14 @@ class IntegrationsOverview extends React.Component { } }; + handleDisconnectSlack = async (authorizationId: string) => { + return API.deleteSlackAuthorization(authorizationId) + .then(() => this.refreshAllIntegrations()) + .catch((err) => + logger.error('Failed to remove Slack authorization:', err) + ); + }; + handleAddWebhook = () => { this.setState({isWebhookModalOpen: true}); }; @@ -200,6 +237,7 @@ class IntegrationsOverview extends React.Component { render() { const { loading, + refreshing, isWebhookModalOpen, selectedWebhook, webhooks = [], @@ -247,7 +285,11 @@ class IntegrationsOverview extends React.Component { /> - + diff --git a/assets/src/components/integrations/IntegrationsTable.tsx b/assets/src/components/integrations/IntegrationsTable.tsx index 3aef2fba8..21170844c 100644 --- a/assets/src/components/integrations/IntegrationsTable.tsx +++ b/assets/src/components/integrations/IntegrationsTable.tsx @@ -2,7 +2,7 @@ import React from 'react'; import dayjs from 'dayjs'; import {Box, Flex} from 'theme-ui'; import qs from 'query-string'; -import {colors, Button, Table, Tag, Text, Tooltip} from '../common'; +import {colors, Button, Popconfirm, Table, Tag, Text, Tooltip} from '../common'; import {SLACK_CLIENT_ID, isDev} from '../../config'; import {IntegrationType} from './support'; @@ -44,16 +44,20 @@ const getGmailAuthUrl = () => { }; const IntegrationsTable = ({ + loading, integrations, + onDisconnectSlack, }: { + loading?: boolean; integrations: Array; + onDisconnectSlack: (id: string) => void; }) => { const columns = [ { title: 'Integration', dataIndex: 'integration', key: 'integration', - render: (value: string, record: any) => { + render: (value: string, record: IntegrationType) => { const {icon} = record; return ( @@ -94,18 +98,64 @@ const IntegrationsTable = ({ title: '', dataIndex: 'action', key: 'action', - render: (action: any, record: any) => { - const {key, status} = record; + render: (action: any, record: IntegrationType) => { + const {key, status, authorization_id: authorizationId} = record; const isConnected = status === 'connected'; switch (key) { case 'slack': + if (isConnected && authorizationId) { + return ( + + + + + + + + onDisconnectSlack(authorizationId)} + > + + + + + ); + } + return ( ); case 'slack:sync': + if (isConnected && authorizationId) { + return ( + + + + + + + + onDisconnectSlack(authorizationId)} + > + + + + + ); + } + return ( @@ -133,7 +183,9 @@ const IntegrationsTable = ({ }, ]; - return ; + return ( +
+ ); }; export default IntegrationsTable; diff --git a/assets/src/components/integrations/support.ts b/assets/src/components/integrations/support.ts index 081894841..2550ec624 100644 --- a/assets/src/components/integrations/support.ts +++ b/assets/src/components/integrations/support.ts @@ -9,6 +9,7 @@ export type IntegrationType = { integration: string; status: 'connected' | 'not_connected'; created_at?: string | null; + authorization_id: string | null; icon: string; }; diff --git a/assets/src/components/reporting/ReportingDashboard.tsx b/assets/src/components/reporting/ReportingDashboard.tsx index fa6085f17..9cab65d55 100644 --- a/assets/src/components/reporting/ReportingDashboard.tsx +++ b/assets/src/components/reporting/ReportingDashboard.tsx @@ -115,7 +115,7 @@ class ReportingDashboard extends React.Component { }; formatCustomerBreakdownStats = (stats: Array, field: string) => { - const MAX_NUM_SHOWN = 10; + const MAX_NUM_SHOWN = 5; const formatted = stats .map((data) => ({ name: data[field] || 'Unknown', diff --git a/assets/src/components/sessions/ConversationSidebar.tsx b/assets/src/components/sessions/ConversationSidebar.tsx index b70ed78c6..36fc5fe04 100644 --- a/assets/src/components/sessions/ConversationSidebar.tsx +++ b/assets/src/components/sessions/ConversationSidebar.tsx @@ -8,7 +8,7 @@ import {Conversation, Message, User} from '../../types'; type Props = { conversation: Conversation; - currentUser: User; + currentUser: User | null; messages: Array; onSendMessage: ( message: string, diff --git a/assets/src/types.ts b/assets/src/types.ts index a3ce6e653..bbf0259a6 100644 --- a/assets/src/types.ts +++ b/assets/src/types.ts @@ -1,3 +1,13 @@ +export type Account = { + id: string; + company_name: string; + time_zone?: string; + subscription_plan?: string; + users?: Array; + widget_settings: any; + working_hours: Array; +}; + export type User = { id: number; email: string; @@ -31,7 +41,16 @@ export type Customer = { updated_at?: string; }; -// NB: actual message records will look slightly different +export type Company = { + id: string; + name: string; + description?: string; + website_url?: string; + external_id?: string; + slack_channel_id?: string; + slack_channel_name?: string; +}; + export type Message = { body: string; created_at: string; @@ -43,7 +62,6 @@ export type Message = { user?: User; }; -// NB: actual conversation records will look different export type Conversation = { id: string; account_id: string; diff --git a/lib/chat_api/browser_replay_events/browser_replay_event.ex b/lib/chat_api/browser_replay_events/browser_replay_event.ex index dd318eaff..6d5e2b86b 100644 --- a/lib/chat_api/browser_replay_events/browser_replay_event.ex +++ b/lib/chat_api/browser_replay_events/browser_replay_event.ex @@ -5,6 +5,19 @@ defmodule ChatApi.BrowserReplayEvents.BrowserReplayEvent do alias ChatApi.Accounts.Account alias ChatApi.BrowserSessions.BrowserSession + @type t :: %__MODULE__{ + event: any(), + timestamp: any(), + # Relations + account_id: any(), + account: any(), + browser_session_id: any(), + browser_session: any(), + # Timestamps + inserted_at: any(), + updated_at: any() + } + @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "browser_replay_events" do diff --git a/lib/chat_api/browser_sessions/browser_session.ex b/lib/chat_api/browser_sessions/browser_session.ex index 1c5217180..5da2af5fb 100644 --- a/lib/chat_api/browser_sessions/browser_session.ex +++ b/lib/chat_api/browser_sessions/browser_session.ex @@ -6,6 +6,20 @@ defmodule ChatApi.BrowserSessions.BrowserSession do alias ChatApi.BrowserReplayEvents.BrowserReplayEvent alias ChatApi.Customers.Customer + @type t :: %__MODULE__{ + started_at: any(), + finished_at: any(), + metadata: any(), + # Relations + account_id: any(), + account: any(), + customer_id: any(), + customer: any(), + # Timestamps + inserted_at: any(), + updated_at: any() + } + @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "browser_sessions" do diff --git a/lib/chat_api/companies/company.ex b/lib/chat_api/companies/company.ex index 3cf77a634..d574d2b80 100644 --- a/lib/chat_api/companies/company.ex +++ b/lib/chat_api/companies/company.ex @@ -4,6 +4,24 @@ defmodule ChatApi.Companies.Company do alias ChatApi.{Accounts.Account, Customers.Customer} + @type t :: %__MODULE__{ + name: String.t(), + description: String.t() | nil, + external_id: String.t() | nil, + website_url: String.t() | nil, + industry: String.t() | nil, + logo_image_url: String.t() | nil, + slack_channel_id: String.t() | nil, + slack_channel_name: String.t() | nil, + metadata: any(), + # Relations + account_id: any(), + account: any(), + # Timestamps + inserted_at: any(), + updated_at: any() + } + @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "companies" do diff --git a/lib/chat_api/conversations.ex b/lib/chat_api/conversations.ex index 20ea9a707..672476cba 100644 --- a/lib/chat_api/conversations.ex +++ b/lib/chat_api/conversations.ex @@ -8,6 +8,7 @@ defmodule ChatApi.Conversations do alias ChatApi.Accounts.Account alias ChatApi.Conversations.Conversation + alias ChatApi.Customers.Customer alias ChatApi.Messages.Message alias ChatApi.Tags.{Tag, ConversationTag} diff --git a/lib/chat_api/customers/customer.ex b/lib/chat_api/customers/customer.ex index 7123d7424..15d044e50 100644 --- a/lib/chat_api/customers/customer.ex +++ b/lib/chat_api/customers/customer.ex @@ -11,6 +11,38 @@ defmodule ChatApi.Customers.Customer do Tags.CustomerTag } + @type t :: %__MODULE__{ + first_seen: any(), + last_seen: any(), + email: String.t() | nil, + name: String.t() | nil, + phone: String.t() | nil, + external_id: String.t() | nil, + # Browser metadata + browser: String.t() | nil, + browser_version: String.t() | nil, + browser_language: String.t() | nil, + os: String.t() | nil, + ip: String.t() | nil, + last_seen_at: any(), + current_url: String.t() | nil, + host: String.t() | nil, + pathname: String.t() | nil, + screen_height: integer() | nil, + screen_width: integer() | nil, + lib: String.t() | nil, + time_zone: String.t() | nil, + metadata: any(), + # Relations + account_id: any(), + account: any(), + company_id: any(), + company: any(), + # Timestamps + inserted_at: any(), + updated_at: any() + } + @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "customers" do diff --git a/lib/chat_api/emails/helpers.ex b/lib/chat_api/emails/helpers.ex index e575ccc28..aa5e9434a 100644 --- a/lib/chat_api/emails/helpers.ex +++ b/lib/chat_api/emails/helpers.ex @@ -51,8 +51,6 @@ defmodule ChatApi.Emails.Helpers do |> normalize_mx_records_to_string() end - defp normalize_mx_records_to_string(nil), do: [] - defp normalize_mx_records_to_string(domains) do normalize_mx_records_to_string(domains, []) end @@ -65,8 +63,6 @@ defmodule ChatApi.Emails.Helpers do normalize_mx_records_to_string(domains, [{priority, to_string(domain)} | normalized_domains]) end - defp sort_mx_records_by_priority(nil), do: [] - defp sort_mx_records_by_priority(domains) do Enum.sort(domains, fn {priority, _domain}, {other_priority, _other_domain} -> priority < other_priority diff --git a/lib/chat_api/notes/note.ex b/lib/chat_api/notes/note.ex index 4b2ec9a77..19483cb51 100644 --- a/lib/chat_api/notes/note.ex +++ b/lib/chat_api/notes/note.ex @@ -6,6 +6,20 @@ defmodule ChatApi.Notes.Note do alias ChatApi.Customers.Customer alias ChatApi.Users.User + @type t :: %__MODULE__{ + body: String.t(), + # Relations + account_id: any(), + account: any(), + customer_id: any(), + customer: any(), + author_id: any(), + author: any(), + # Timestamps + inserted_at: any(), + updated_at: any() + } + @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "notes" do diff --git a/lib/chat_api/slack/helpers.ex b/lib/chat_api/slack/helpers.ex index ee97e595a..b437d3a61 100644 --- a/lib/chat_api/slack/helpers.ex +++ b/lib/chat_api/slack/helpers.ex @@ -299,8 +299,11 @@ defmodule ChatApi.Slack.Helpers do def extract_slack_message(%{body: %{"ok" => true, "messages" => []}}), do: {:error, "No messages were found"} - def extract_slack_message(%{body: %{"ok" => false} = body}), - do: {:error, "conversations.history returned ok=false: #{inspect(body)}"} + def extract_slack_message(%{body: %{"ok" => false} = body}) do + Logger.error("conversations.history returned ok=false: #{inspect(body)}") + + {:error, "conversations.history returned ok=false: #{inspect(body)}"} + end def extract_slack_message(response), do: {:error, "Invalid response: #{inspect(response)}"} @@ -309,8 +312,11 @@ defmodule ChatApi.Slack.Helpers do def extract_slack_channel(%{body: %{"ok" => true, "channel" => channel}}) when is_map(channel), do: {:ok, channel} - def extract_slack_channel(%{body: %{"ok" => false} = body}), - do: {:error, "conversations.info returned ok=false: #{inspect(body)}"} + def extract_slack_channel(%{body: %{"ok" => false} = body}) do + Logger.error("conversations.info returned ok=false: #{inspect(body)}") + + {:error, "conversations.info returned ok=false: #{inspect(body)}"} + end def extract_slack_channel(response), do: {:error, "Invalid response: #{inspect(response)}"} diff --git a/lib/chat_api/tags/tag.ex b/lib/chat_api/tags/tag.ex index 7064734f7..55a0d4270 100644 --- a/lib/chat_api/tags/tag.ex +++ b/lib/chat_api/tags/tag.ex @@ -6,6 +6,20 @@ defmodule ChatApi.Tags.Tag do alias ChatApi.Tags.{ConversationTag, CustomerTag} alias ChatApi.Users.User + @type t :: %__MODULE__{ + name: String.t(), + description: String.t() | nil, + color: String.t() | nil, + # Relations + account_id: any(), + account: any(), + creator_id: any(), + creator: any(), + # Timestamps + inserted_at: any(), + updated_at: any() + } + @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "tags" do diff --git a/lib/chat_api/users.ex b/lib/chat_api/users.ex index b23b4aa10..e28a8d38c 100644 --- a/lib/chat_api/users.ex +++ b/lib/chat_api/users.ex @@ -20,12 +20,12 @@ defmodule ChatApi.Users do User |> where(account_id: ^account_id, email: ^email) |> Repo.one() end - @spec find_by_id!(integer()) :: User.t() + @spec find_by_id!(integer() | binary()) :: User.t() def find_by_id!(user_id) do Repo.get!(User, user_id) end - @spec find_by_id(integer(), binary()) :: User.t() | nil + @spec find_by_id(integer() | binary(), binary()) :: User.t() | nil def find_by_id(user_id, account_id) do User |> where(account_id: ^account_id, id: ^user_id) |> Repo.one() end diff --git a/lib/chat_api/users/user.ex b/lib/chat_api/users/user.ex index 1cc9a0373..fda670d3d 100644 --- a/lib/chat_api/users/user.ex +++ b/lib/chat_api/users/user.ex @@ -8,6 +8,25 @@ defmodule ChatApi.Users.User do alias ChatApi.Accounts.Account alias ChatApi.Users.{UserProfile, UserSettings} + @type t :: %__MODULE__{ + email_confirmation_token: String.t() | nil, + password_reset_token: String.t() | nil, + email_confirmed_at: any(), + disabled_at: any(), + archived_at: any(), + role: String.t() | nil, + has_valid_email: boolean() | nil, + # Pow fields + email: String.t(), + password_hash: String.t(), + # Relations + account_id: any(), + account: any(), + # Timestamps + inserted_at: any(), + updated_at: any() + } + schema "users" do field(:email_confirmation_token, :string) field(:password_reset_token, :string) diff --git a/lib/chat_api_web/controllers/slack_controller.ex b/lib/chat_api_web/controllers/slack_controller.ex index 834c1db93..59a240ee6 100644 --- a/lib/chat_api_web/controllers/slack_controller.ex +++ b/lib/chat_api_web/controllers/slack_controller.ex @@ -18,7 +18,8 @@ defmodule ChatApiWeb.SlackController do @spec oauth(Plug.Conn.t(), map()) :: Plug.Conn.t() def oauth(conn, %{"code" => code} = params) do Logger.info("Code from Slack OAuth: #{inspect(code)}") - # TODO: improve error handling! + # TODO: improve error handling, incorporate these lines into the + # `with` statement rather than doing `if Map.get(body, "ok") do...` {:ok, response} = Slack.Client.get_access_token(code) Logger.info("Slack OAuth response: #{inspect(response)}") @@ -26,7 +27,7 @@ defmodule ChatApiWeb.SlackController do %{body: body} = response if Map.get(body, "ok") do - with %{account_id: account_id} <- conn.assigns.current_user, + with %{account_id: account_id, email: email} <- conn.assigns.current_user, %{ "access_token" => access_token, "app_id" => app_id, @@ -62,11 +63,18 @@ defmodule ChatApiWeb.SlackController do type: Map.get(params, "type", "reply") } + # TODO: after creating, check if connected channel is private; + # If yes, use webhook_url to send notification that Papercups app needs + # to be added manually, along with instructions for how to do so SlackAuthorizations.create_or_update(account_id, params) - conn - |> notify_slack() - |> json(%{data: %{ok: true}}) + send_internal_notification( + "#{email} successfully linked Slack `#{inspect(params.type)}` integration to channel `#{ + channel + }`" + ) + + json(conn, %{data: %{ok: true}}) else _ -> raise "Unrecognized OAuth response" @@ -91,6 +99,7 @@ defmodule ChatApiWeb.SlackController do auth -> json(conn, %{ data: %{ + id: auth.id, created_at: auth.inserted_at, channel: auth.channel, configuration_url: auth.configuration_url, @@ -100,6 +109,16 @@ defmodule ChatApiWeb.SlackController do end end + @spec delete(Plug.Conn.t(), map()) :: Plug.Conn.t() + def delete(conn, %{"id" => id}) do + with %{account_id: _account_id} <- conn.assigns.current_user, + %SlackAuthorization{} = auth <- + SlackAuthorizations.get_slack_authorization!(id), + {:ok, %SlackAuthorization{}} <- SlackAuthorizations.delete_slack_authorization(auth) do + send_resp(conn, :no_content, "") + end + end + @spec webhook(Plug.Conn.t(), map()) :: Plug.Conn.t() def webhook(conn, payload) do Logger.debug("Payload from Slack webhook: #{inspect(payload)}") @@ -220,6 +239,7 @@ defmodule ChatApiWeb.SlackController do end end + # TODO: ignore message if it's from a bot? defp handle_event( %{ "type" => "message", @@ -237,6 +257,8 @@ defmodule ChatApiWeb.SlackController do team_id: team, type: "support" }), + # TODO: remove after debugging! + :ok <- Logger.info("Handling Slack new message event: #{inspect(event)}"), :ok <- validate_channel_supported(authorization, slack_channel_id), :ok <- validate_non_admin_user(authorization, slack_user_id), {:ok, customer} <- @@ -293,7 +315,7 @@ defmodule ChatApiWeb.SlackController do } } = event ) do - Logger.debug("Handling Slack reaction event: #{inspect(event)}") + Logger.info("Handling Slack reaction event: #{inspect(event)}") with :ok <- validate_no_existing_thread(channel, ts), {:ok, account_id} <- find_account_id_by_support_channel(channel), @@ -344,7 +366,10 @@ defmodule ChatApiWeb.SlackController do slack_channel_id: slack_channel_id } - Logger.info("Papercups app added to Slack channel: ##{name}") + send_internal_notification( + "Papercups app was added to Slack channel `##{name}` for account `#{account_id}`" + ) + # TODO: should we do this? might make onboarding a bit easier, but would also set up # companies with "weird" names (i.e. in the format of a Slack channel name) Logger.info("Would have created company with fields:") @@ -481,16 +506,11 @@ defmodule ChatApiWeb.SlackController do end end - @spec notify_slack(Plug.Conn.t()) :: Plug.Conn.t() - defp notify_slack(conn) do - with %{email: email} <- conn.assigns.current_user do - # Putting in an async Task for now, since we don't care if this succeeds - # or fails (and we also don't want it to block anything) - Task.start(fn -> - Slack.Notifications.log("#{email} successfully linked Slack!") - end) - end - - conn + @spec send_internal_notification(binary()) :: any() + defp send_internal_notification(message) do + Logger.info(message) + # Putting in an async Task for now, since we don't care if this succeeds + # or fails (and we also don't want it to block anything) + Task.start(fn -> Slack.Notifications.log(message) end) end end diff --git a/lib/chat_api_web/router.ex b/lib/chat_api_web/router.ex index 7ba0d80d2..33fa326f2 100644 --- a/lib/chat_api_web/router.ex +++ b/lib/chat_api_web/router.ex @@ -81,6 +81,7 @@ defmodule ChatApiWeb.Router do get("/slack/oauth", SlackController, :oauth) get("/slack/authorization", SlackController, :authorization) + delete("/slack/authorizations/:id", SlackController, :delete) get("/slack/channels", SlackController, :channels) get("/gmail/auth", GmailController, :auth) get("/gmail/oauth", GmailController, :callback) diff --git a/test/chat_api_web/controllers/slack_controller_test.exs b/test/chat_api_web/controllers/slack_controller_test.exs index 6dbb69eef..bd6fa0e7e 100644 --- a/test/chat_api_web/controllers/slack_controller_test.exs +++ b/test/chat_api_web/controllers/slack_controller_test.exs @@ -59,6 +59,25 @@ defmodule ChatApiWeb.SlackControllerTest do assert %{"data" => nil} = json_response(resp, 200) end + + test "deletes the authorization if it exists", %{authed_conn: authed_conn, auth: auth} do + resp = get(authed_conn, Routes.slack_path(authed_conn, :authorization), %{}) + + # First verify that it exists + assert %{ + "channel" => channel, + "team_name" => team_name + } = json_response(resp, 200)["data"] + + # Then, delete and verify it no longer exists + resp = delete(authed_conn, Routes.slack_path(authed_conn, :delete, auth)) + + assert response(resp, 204) + + resp = get(authed_conn, Routes.slack_path(authed_conn, :authorization), %{}) + + assert %{"data" => nil} = json_response(resp, 200) + end end describe "webhook" do