Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Deliverability Solution #153

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions migrations/20190513132200_add_shortlink_domain_tables.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Create link_domain and unhealthy_link_domain tables
exports.up = function(knex, Promise) {
const linkDomainPromise = knex.schema.createTable('link_domain', table => {
table.increments('id').primary()
table.integer('organization_id').notNullable()
table.text('domain').notNullable().unique().index()
table.integer('max_usage_count').notNullable()
table.integer('current_usage_count').notNullable().default(0)
table.boolean('is_manually_disabled').notNullable().default(false)
table.timestamp('cycled_out_at').notNullable().defaultTo(knex.fn.now())
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now())

table.index('organization_id')
table.foreign('organization_id').references('organization.id')
})

const unhealthyDomainPromise = knex.schema.createTable('unhealthy_link_domain', table => {
table.increments('id').primary()
table.text('domain').notNullable()
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now())
table.timestamp('healthy_again_at')

table.index(['domain', 'created_at'])
})

return Promise.all([linkDomainPromise, unhealthyDomainPromise])
}

// Drop link_domain and unhealthy_link_domain tables
exports.down = function(knex, Promise) {
return Promise.all([
knex.schema.dropTable('link_domain'),
knex.schema.dropTable('unhealthy_link_domain')
])
}
19 changes: 19 additions & 0 deletions src/api/link-domain.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export const schema = `
type LinkDomain {
id: ID!
domain: String!
maxUsageCount: Int!
currentUsageCount: Int!
isManuallyDisabled: Boolean!
isHealthy: Boolean!
cycledOutAt: Date!
createdAt: Date!
}

type UnhealthyLinkDomain {
id: ID!
domain: String!
createdAt: Date!
healthyAgainAt: Date
}
`
2 changes: 2 additions & 0 deletions src/api/organization.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,7 @@ export const schema = `
currentAssignmentTarget: AssignmentTarget
escalationUserId: Int
escalatedConversationCount: Int!
linkDomains: [LinkDomain]!
unhealthyLinkDomains: [UnhealthyLinkDomain]!
}
`
10 changes: 10 additions & 0 deletions src/api/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
resolvers as cannedResponseResolvers
} from './canned-response'
import { schema as inviteSchema, resolvers as inviteResolvers } from './invite'
import { schema as linkDomainSchema } from './link-domain'


const rootSchema = `
Expand Down Expand Up @@ -149,6 +150,11 @@ const rootSchema = `
messageIds: [Int]!
}

input UpdateLinkDomain {
maxUsageCount: Int
isManuallyDisabled: Boolean
}

enum ReleaseActionTarget {
UNSENT
UNREPLIED
Expand Down Expand Up @@ -243,6 +249,9 @@ const rootSchema = `
requestTexts(count: Int!, email: String!, organizationId: String!): String!
releaseMessages(campaignId: String!, target: ReleaseActionTarget!, ageInHours: Int): String!
markForSecondPass(campaignId: String!): String!
insertLinkDomain(organizationId: String!, domain: String!, maxUsageCount: Int!): LinkDomain!
updateLinkDomain(organizationId: String!, domainId: String!, payload: UpdateLinkDomain!): LinkDomain!
deleteLinkDomain(organizationId: String!, domainId: String!): Boolean!
}

schema {
Expand All @@ -268,5 +277,6 @@ export const schema = [
questionResponseSchema,
questionSchema,
inviteSchema,
linkDomainSchema,
conversationSchema
]
4 changes: 4 additions & 0 deletions src/components/AdminDashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ class AdminDashboard extends React.Component {
name: 'Bulk Script Editor',
path: 'bulk-script-editor',
role: 'ADMIN'
}, {
name: 'Short Link Domains',
path: 'short-link-domains',
role: 'ADMIN'
}, {
name: 'Settings',
path: 'settings',
Expand Down
81 changes: 81 additions & 0 deletions src/containers/AdminShortLinkDomains/AddDomainDialog.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'

import Dialog from 'material-ui/Dialog'
import FlatButton from 'material-ui/FlatButton'
import RaisedButton from 'material-ui/RaisedButton'
import TextField from 'material-ui/TextField'

class AddDomainDialog extends Component {
state = {
domain: '',
maxUsageCount: 100
}

handleDomainChange = event => this.setState({ domain: event.target.value })
handleMaxUsageCountChange = event => this.setState({ maxUsageCount: parseInt(event.target.value, 10) })

handleAddDomainClick = () => {
const { onAddNewDomain } = this.props
const { domain, maxUsageCount } = this.state
onAddNewDomain(domain, maxUsageCount)
}

render() {
const { open, onRequestClose } = this.props
const { domain, maxUsageCount } = this.state

const isDomainValid = domain !== ''
const isMaxUsageCountValid = maxUsageCount > 0
const isSubmitDisabled = !isDomainValid || !isMaxUsageCountValid

const actions = [
<FlatButton
label="Close"
primary={false}
onClick={onRequestClose}
/>,
<RaisedButton
label="Add"
primary={true}
disabled={isSubmitDisabled}
onClick={this.handleAddDomainClick}
/>
]

return (
<Dialog
title="Add Domain"
actions={actions}
modal={false}
open={open}
onRequestClose={onRequestClose}
>
<p>Add a new shortlink domain.</p>
<TextField
floatingLabelText="Shortlink Domain"
hintText="bit.ly"
value={domain}
errorText={isDomainValid ? undefined : 'You must provide a domain.'}
onChange={this.handleDomainChange}
/>
<br />
<TextField
floatingLabelText="Shortlink Domain"
value={maxUsageCount}
errorText={isMaxUsageCountValid ? undefined : 'You must provide a maximum usage count.'}
type="number"
onChange={this.handleMaxUsageCountChange}
/>
</Dialog>
)
}
}

AddDomainDialog.propTypes = {
open: PropTypes.bool,
onRequestClose: PropTypes.func,
onAddNewDomain: PropTypes.func
}

export default AddDomainDialog
136 changes: 136 additions & 0 deletions src/containers/AdminShortLinkDomains/ShortLinkDomainList.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import moment from 'moment'

import DataTables from 'material-ui-datatables'
import Toggle from 'material-ui/Toggle'
import IconButton from 'material-ui/IconButton'
import CheckCircleIcon from 'material-ui/svg-icons/action/check-circle'
import BlockIcon from 'material-ui/svg-icons/content/block'
import ThumbUpIcon from 'material-ui/svg-icons/action/thumb-up'
import ThumbDownIcon from 'material-ui/svg-icons/action/thumb-down'
import DeleteForeverIcon from 'material-ui/svg-icons/action/delete-forever'
import { red500, green500 } from 'material-ui/styles/colors'

class ShortLinkDomainList extends Component {

tableColumns = () => ([
{
label: 'Eligible',
tooltip: 'Whether the domain eligible for rotation.',
render: (value, row) => {
const isEligible = row.isHealthy && !row.isManuallyDisabled
return isEligible
? <CheckCircleIcon color={green500} />
: <BlockIcon color={red500} />
}
}, {
key: 'domain',
label: 'Domain'
}, {
key: 'currentUsageCount',
label: 'Current Usage',
tooltip: 'How many times the domain has been used in the current rotation.'
}, {
key: 'maxUsageCount',
label: 'Maximum Usage',
tooltip: 'Maximum numbers of times the domain should be used per rotation.'
}, {
key: 'isManuallyDisabled',
label: 'Manual Disable',
tooltip: 'Whether an admin has manually disabled this domain.',
render: (value, row) => {
return (
<Toggle
toggled={value}
disabled={row.isRowDisabled}
onToggle={this.createHandleDisableToggle(row.id)}
/>
)
}
}, {
key: 'isHealthy',
label: 'Health',
tooltip: 'Health of the domain based on text delivery report summaries.',
render: (value, row) => {
return value
? <ThumbUpIcon color={green500} />
: <ThumbDownIcon color={red500} />
}
}, {
key: 'cycledOutAt',
label: 'Last Cycled Out',
tooltip: 'The last time this domain was cycled out of rotation.',
render: (value, row) => moment(value).fromNow()
}, {
key: 'createdAt',
label: 'Created',
render: (value, row) => new Date(value).toLocaleString()
}, {
label: '',
style: { width: '50px' },
render: (value, row) => {
return (
<IconButton
disabled={row.isRowDisabled}
onClick={this.createHandleDeleteClick(row.id)}
>
<DeleteForeverIcon color={red500} />
</IconButton>
)
}
}
])

createHandleDisableToggle = domainId => (event, value) => {
// These don't appear to be doing anything to stop handleCellClick being called...
event.stopPropagation()
event.nativeEvent.stopImmediatePropagation()
event.preventDefault()

this.props.onManualDisableToggle(domainId, value)
}

createHandleDeleteClick = domainId => event => {
// These don't appear to be doing anything to stop handleCellClick being called...
event.stopPropagation()
event.nativeEvent.stopImmediatePropagation()
event.preventDefault()

this.props.onDeleteDomain(domainId)
}

render() {
let { domains, disabledDomainIds } = this.props
domains = domains.map(domain => {
const isRowDisabled = disabledDomainIds.indexOf(domain.id) > -1
return Object.assign({}, domain, { isRowDisabled })
})

return (
<DataTables
height="auto"
selectable={false}
showRowHover={false}
columns={this.tableColumns()}
data={domains}
showHeaderToolbar={false}
showFooterToolbar={false}
showCheckboxes={false}
/>
)
}
}

ShortLinkDomainList.defaultProps = {
disabledDomainIds: []
}

ShortLinkDomainList.propTypes = {
domains: PropTypes.arrayOf(PropTypes.object).isRequired,
disabledDomainIds: PropTypes.arrayOf(PropTypes.string),
onManualDisableToggle: PropTypes.func.isRequired,
onDeleteDomain: PropTypes.func.isRequired
}

export default ShortLinkDomainList
Loading