mirror of
https://fast.feibisi.com/https://github.com/parcelvoy/platform.git
synced 2025-08-28 11:46:02 +08:00
Allows for tracking settings to be set through org UI (#237)
This commit is contained in:
parent
a053b555da
commit
cc54e8e600
37 changed files with 9077 additions and 8590 deletions
|
@ -0,0 +1,33 @@
|
|||
exports.up = async function(knex) {
|
||||
await knex.schema.alterTable('projects', function(table) {
|
||||
table.boolean('link_wrap').defaultTo(0)
|
||||
})
|
||||
|
||||
if (process.env.TRACKING_LINK_WRAP) {
|
||||
await knex('projects').update({ link_wrap: process.env.TRACKING_LINK_WRAP === 'true' })
|
||||
}
|
||||
|
||||
await knex.schema.alterTable('organizations', function(table) {
|
||||
table.string('tracking_deeplink_mirror_url', 255)
|
||||
table.integer('notification_provider_id')
|
||||
.references('id')
|
||||
.inTable('providers')
|
||||
.onDelete('SET NULL')
|
||||
.nullable()
|
||||
.unsigned()
|
||||
})
|
||||
|
||||
if (process.env.TRACKING_DEEPLINK_MIRROR_URL) {
|
||||
await knex('organizations').update({ tracking_deeplink_mirror_url: process.env.TRACKING_DEEPLINK_MIRROR_URL })
|
||||
}
|
||||
}
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex.schema.alterTable('projects', function(table) {
|
||||
table.dropColumn('link_wrap')
|
||||
})
|
||||
await knex.schema.alterTable('organizations', function(table) {
|
||||
table.dropColumn('tracking_deeplink_mirror_url')
|
||||
table.dropColumn('notification_provider_id')
|
||||
})
|
||||
}
|
1405
apps/platform/package-lock.json
generated
1405
apps/platform/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -21,6 +21,7 @@ import ProjectApiKeyController from '../projects/ProjectApiKeyController'
|
|||
import AdminController from '../auth/AdminController'
|
||||
import OrganizationController from '../organizations/OrganizationController'
|
||||
import App from '../app'
|
||||
import { organizationMiddleware } from '../organizations/OrganizationMiddleware'
|
||||
|
||||
export const register = (parent: Router, ...routers: Router[]) => {
|
||||
for (const router of routers) {
|
||||
|
@ -61,6 +62,7 @@ export default (app: App) => {
|
|||
export const adminRouter = () => {
|
||||
const admin = new Router({ prefix: '/admin' })
|
||||
admin.use(authMiddleware)
|
||||
admin.use(organizationMiddleware)
|
||||
admin.use(scopeMiddleware(['admin', 'secret']))
|
||||
return register(admin,
|
||||
ProjectController,
|
||||
|
@ -125,6 +127,8 @@ export const clientRouter = () => {
|
|||
*/
|
||||
export const publicRouter = () => {
|
||||
const router = new Router()
|
||||
router.use(organizationMiddleware)
|
||||
|
||||
router.get('/health', async (ctx) => {
|
||||
ctx.body = {
|
||||
status: 'ok',
|
||||
|
|
|
@ -23,10 +23,6 @@ export interface Env {
|
|||
auth: AuthConfig
|
||||
error: ErrorConfig
|
||||
redis: RedisConfig
|
||||
tracking: {
|
||||
linkWrap: boolean,
|
||||
deeplinkMirrorUrl: string | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export interface DriverConfig {
|
||||
|
@ -150,9 +146,5 @@ export default (type?: EnvType): Env => {
|
|||
dsn: process.env.ERROR_SENTRY_DSN,
|
||||
}),
|
||||
}),
|
||||
tracking: {
|
||||
linkWrap: (process.env.TRACKING_LINK_WRAP ?? 'true') === 'true',
|
||||
deeplinkMirrorUrl: process.env.TRACKING_DEEPLINK_MIRROR_URL,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,19 @@
|
|||
import { AuthProviderConfig } from '../auth/Auth'
|
||||
import Model from '../core/Model'
|
||||
import Model, { ModelParams } from '../core/Model'
|
||||
|
||||
export interface TrackingOptions {
|
||||
linkWrap: boolean,
|
||||
deeplinkMirrorUrl: string | undefined,
|
||||
}
|
||||
|
||||
export default class Organization extends Model {
|
||||
username!: string
|
||||
domain!: string
|
||||
domain?: string
|
||||
auth!: AuthProviderConfig
|
||||
notification_provider_id?: number
|
||||
tracking_deeplink_mirror_url?: string
|
||||
|
||||
static jsonAttributes = ['auth']
|
||||
}
|
||||
|
||||
export type OrganizationParams = Omit<Organization, ModelParams | 'id' | 'auth' | 'notification_provider_id'>
|
||||
|
|
|
@ -1,12 +1,55 @@
|
|||
import Router from '@koa/router'
|
||||
import { JSONSchemaType, validate } from '../core/validate'
|
||||
import App from '../app'
|
||||
import { Context } from 'koa'
|
||||
import { JwtAdmin } from '../auth/AuthMiddleware'
|
||||
import { getOrganization, organizationIntegrations, updateOrganization } from './OrganizationService'
|
||||
import Organization, { OrganizationParams } from './Organization'
|
||||
|
||||
const router = new Router({
|
||||
const router = new Router<{
|
||||
admin: JwtAdmin
|
||||
organization: Organization
|
||||
}>({
|
||||
prefix: '/organizations',
|
||||
})
|
||||
|
||||
router.use(async (ctx: Context, next: () => void) => {
|
||||
ctx.state.organization = await getOrganization(ctx.state.admin.organization_id)
|
||||
return next()
|
||||
})
|
||||
|
||||
router.get('/', async ctx => {
|
||||
ctx.body = ctx.state.organization
|
||||
})
|
||||
|
||||
router.get('/metrics', async ctx => {
|
||||
ctx.body = await App.main.queue.metrics()
|
||||
})
|
||||
|
||||
router.get('/integrations', async ctx => {
|
||||
ctx.body = await organizationIntegrations(ctx.state.organization)
|
||||
})
|
||||
|
||||
const organizationUpdateParams: JSONSchemaType<OrganizationParams> = {
|
||||
$id: 'organizationUpdate',
|
||||
type: 'object',
|
||||
required: ['username'],
|
||||
properties: {
|
||||
username: { type: 'string' },
|
||||
domain: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
tracking_deeplink_mirror_url: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
}
|
||||
router.patch('/:id', async ctx => {
|
||||
const payload = validate(organizationUpdateParams, ctx.request.body)
|
||||
ctx.body = await updateOrganization(ctx.state.organization, payload)
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
10
apps/platform/src/organizations/OrganizationMiddleware.ts
Normal file
10
apps/platform/src/organizations/OrganizationMiddleware.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { Context } from 'koa'
|
||||
import { getOrganizationByUsername } from './OrganizationService'
|
||||
|
||||
export const organizationMiddleware = async (ctx: Context, next: () => void) => {
|
||||
if (ctx.subdomains && ctx.subdomains[0]) {
|
||||
const subdomain = ctx.subdomains[0]
|
||||
ctx.state.organization = await getOrganizationByUsername(subdomain)
|
||||
}
|
||||
return next()
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import { Admin } from '../auth/Admin'
|
||||
import Provider from '../providers/Provider'
|
||||
import { encodeHashid } from '../utilities'
|
||||
import Organization from './Organization'
|
||||
|
||||
|
@ -41,3 +42,14 @@ export const createOrganization = async (domain: string): Promise<Organization>
|
|||
}
|
||||
return org
|
||||
}
|
||||
|
||||
export const updateOrganization = async (organization: Organization, params: Partial<Organization>) => {
|
||||
return await Organization.updateAndFetch(organization.id, params)
|
||||
}
|
||||
|
||||
export const organizationIntegrations = async (organization: Organization) => {
|
||||
return await Provider.all(
|
||||
qb => qb.leftJoin('projects', 'projects.id', 'providers.project_id')
|
||||
.where('projects.organization_id', organization.id),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ export default class Project extends Model {
|
|||
locale?: string
|
||||
timezone!: string
|
||||
text_opt_out_message?: string
|
||||
link_wrap?: boolean
|
||||
}
|
||||
|
||||
export type ProjectParams = Omit<Project, ModelParams | 'deleted_at' | 'organization_id'>
|
||||
|
|
|
@ -57,9 +57,7 @@ const projectCreateParams: JSONSchemaType<ProjectParams> = {
|
|||
type: 'object',
|
||||
required: ['name', 'timezone'],
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
},
|
||||
name: { type: 'string' },
|
||||
description: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
|
@ -73,6 +71,10 @@ const projectCreateParams: JSONSchemaType<ProjectParams> = {
|
|||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
link_wrap: {
|
||||
type: 'boolean',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
}
|
||||
|
@ -118,6 +120,10 @@ const projectUpdateParams: JSONSchemaType<Partial<ProjectParams>> = {
|
|||
type: 'string',
|
||||
nullable: true,
|
||||
},
|
||||
link_wrap: {
|
||||
type: 'boolean',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
}
|
||||
|
|
|
@ -67,7 +67,6 @@ export async function loadSendJob<T extends TemplateType>({ campaign_id, user_id
|
|||
template_id: template.id,
|
||||
channel: campaign.channel,
|
||||
subscription_id: campaign.subscription_id,
|
||||
project,
|
||||
user_step_id,
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
import { DriverConfig } from '../../config/env'
|
||||
import { AnalyticsProvider, AnalyticsProviderName, AnalyticsUserEvent } from './AnalyticsProvider'
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ export default class EmailJob extends Job {
|
|||
const data = await loadSendJob<EmailTemplate>(trigger)
|
||||
if (!data) return
|
||||
|
||||
const { campaign, template, user, project, event, context } = data
|
||||
const { campaign, template, user, project, context } = data
|
||||
|
||||
// Load email channel so its ready to send
|
||||
const channel = await loadEmailChannel(campaign.provider_id, project.id)
|
||||
|
@ -40,7 +40,7 @@ export default class EmailJob extends Job {
|
|||
if (!isReady) return
|
||||
|
||||
try {
|
||||
await channel.send(template, { user, event, context })
|
||||
await channel.send(template, data)
|
||||
} catch (error: any) {
|
||||
|
||||
// On error, mark as failed and notify just in case
|
||||
|
|
|
@ -21,7 +21,7 @@ export default class PushJob extends Job {
|
|||
const data = await loadSendJob<PushTemplate>(trigger)
|
||||
if (!data) return
|
||||
|
||||
const { campaign, template, user, project, event, context } = data
|
||||
const { campaign, template, user, project, context } = data
|
||||
|
||||
try {
|
||||
// Load email channel so its ready to send
|
||||
|
@ -41,7 +41,7 @@ export default class PushJob extends Job {
|
|||
if (!isReady) return
|
||||
|
||||
// Send the push and update the send record
|
||||
await channel.send(template, { user, event, context })
|
||||
await channel.send(template, data)
|
||||
await updateSendState({
|
||||
campaign,
|
||||
user,
|
||||
|
|
|
@ -37,8 +37,8 @@ export default class TextChannel {
|
|||
user_id: variables.user.id,
|
||||
name: 'text_sent',
|
||||
}))
|
||||
if (!hasReceivedOptOut && variables.context.project.text_opt_out_message) {
|
||||
compiled.text += `\n${variables.context.project.text_opt_out_message}`
|
||||
if (!hasReceivedOptOut && variables.project.text_opt_out_message) {
|
||||
compiled.text += `\n${variables.project.text_opt_out_message}`
|
||||
}
|
||||
|
||||
return compiled
|
||||
|
|
|
@ -20,7 +20,7 @@ export default class TextJob extends Job {
|
|||
const data = await loadSendJob<TextTemplate>(trigger)
|
||||
if (!data) return
|
||||
|
||||
const { campaign, template, user, project, event, context } = data
|
||||
const { campaign, template, user, project, context } = data
|
||||
|
||||
// Send and render text
|
||||
const channel = await loadTextChannel(campaign.provider_id, project.id)
|
||||
|
@ -35,15 +35,13 @@ export default class TextJob extends Job {
|
|||
return
|
||||
}
|
||||
|
||||
const variables = { user, event, context }
|
||||
|
||||
// Check current send rate and if the send is locked
|
||||
// Increment rate limitter by number of segments
|
||||
const segments = await channel.segments(template, variables)
|
||||
const segments = await channel.segments(template, data)
|
||||
const isReady = await prepareSend(channel, data, raw, segments)
|
||||
if (!isReady) return
|
||||
|
||||
await channel.send(template, variables)
|
||||
await channel.send(template, data)
|
||||
|
||||
// Update send record
|
||||
await updateSendState({
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Admin } from '../../../auth/Admin'
|
||||
import { createProject } from '../../../projects/ProjectService'
|
||||
import { Variables } from '../../../render'
|
||||
import { TextTemplate } from '../../../render/Template'
|
||||
import { UserEvent } from '../../../users/UserEvent'
|
||||
import { createUser } from '../../../users/UserRepository'
|
||||
|
@ -10,7 +11,7 @@ import TextChannel from '../TextChannel'
|
|||
describe('TextChannel', () => {
|
||||
|
||||
const optOut = 'Reply STOP to opt out.'
|
||||
const setup = async (text_opt_out_message?: string) => {
|
||||
const setup = async (text_opt_out_message?: string): Promise<{ variables: Variables, template: TextTemplate }> => {
|
||||
const text = 'Hello world!'
|
||||
const admin = await Admin.insertAndFetch({
|
||||
first_name: uuid(),
|
||||
|
@ -36,8 +37,8 @@ describe('TextChannel', () => {
|
|||
template_id: 1,
|
||||
campaign_id: 1,
|
||||
subscription_id: 1,
|
||||
project,
|
||||
},
|
||||
project,
|
||||
},
|
||||
template,
|
||||
}
|
||||
|
@ -60,7 +61,7 @@ describe('TextChannel', () => {
|
|||
await UserEvent.insert({
|
||||
user_id: variables.user.id,
|
||||
name: 'text_sent',
|
||||
project_id: variables.context.project.id,
|
||||
project_id: variables.project.id,
|
||||
})
|
||||
|
||||
const message = await channel.build(template, variables)
|
||||
|
|
|
@ -18,7 +18,7 @@ export default class WebhookJob extends Job {
|
|||
const data = await loadSendJob<WebhookTemplate>(trigger)
|
||||
if (!data) return
|
||||
|
||||
const { campaign, template, user, project, event, context } = data
|
||||
const { campaign, template, user, project, context } = data
|
||||
|
||||
// Send and render webhook
|
||||
const channel = await loadWebhookChannel(campaign.provider_id, project.id)
|
||||
|
@ -36,7 +36,7 @@ export default class WebhookJob extends Job {
|
|||
const isReady = await prepareSend(channel, data, raw)
|
||||
if (!isReady) return
|
||||
|
||||
await channel.send(template, { user, event, context })
|
||||
await channel.send(template, data)
|
||||
|
||||
// Update send record
|
||||
await updateSendState({
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
import Router from '@koa/router'
|
||||
import App from '../app'
|
||||
import { encodedLinkToParts, trackMessageEvent } from './LinkService'
|
||||
import Organization from '../organizations/Organization'
|
||||
|
||||
const router = new Router<{app: App}>()
|
||||
const router = new Router<{
|
||||
app: App
|
||||
organization?: Organization
|
||||
}>()
|
||||
|
||||
router.get('/c', async ctx => {
|
||||
|
||||
|
@ -26,7 +30,7 @@ router.get('/o', async ctx => {
|
|||
})
|
||||
|
||||
router.get('/.well-known/:file', async ctx => {
|
||||
const url = App.main.env.tracking.deeplinkMirrorUrl
|
||||
const url = ctx.state.organization?.tracking_deeplink_mirror_url
|
||||
const file = ctx.params.file
|
||||
if (!url) {
|
||||
ctx.status = 404
|
||||
|
|
|
@ -277,6 +277,7 @@ router.post('/:templateId/preview', async ctx => {
|
|||
user: User.fromJson({ ...payload.user, data: payload.user }),
|
||||
event: UserEvent.fromJson(payload.event || {}),
|
||||
context: payload.context || {},
|
||||
project: ctx.state.project,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ import { loadPushChannel } from '../providers/push'
|
|||
import { getUserFromEmail, getUserFromPhone } from '../users/UserRepository'
|
||||
import { loadWebhookChannel } from '../providers/webhook'
|
||||
import Project from '../projects/Project'
|
||||
import { getProject } from '../projects/ProjectService'
|
||||
|
||||
export const pagedTemplates = async (params: PageParams, projectId: number) => {
|
||||
return await Template.search(
|
||||
|
@ -87,35 +88,35 @@ export const validateTemplates = async (projectId: number, campaignId: number) =
|
|||
export const sendProof = async (template: TemplateType, variables: Variables, recipient: string) => {
|
||||
|
||||
const campaign = await getCampaign(template.campaign_id, template.project_id)
|
||||
if (!campaign) throw new RequestError(CampaignError.CampaignDoesNotExist)
|
||||
const project = await getProject(template.project_id)
|
||||
if (!campaign || !project) throw new RequestError(CampaignError.CampaignDoesNotExist)
|
||||
const event = UserEvent.fromJson(variables.event || {})
|
||||
const context = {
|
||||
...variables.context,
|
||||
campaign_id: template.campaign_id,
|
||||
}
|
||||
const projectId = template.project_id
|
||||
|
||||
const user = (await getUserFromEmail(projectId, recipient))
|
||||
?? (await getUserFromPhone(projectId, recipient))
|
||||
const user = (await getUserFromEmail(project.id, recipient))
|
||||
?? (await getUserFromPhone(project.id, recipient))
|
||||
?? User.fromJson({ ...variables.user, email: recipient, phone: recipient })
|
||||
user.data = { ...user?.data, ...variables.user }
|
||||
variables = { user, event, context }
|
||||
variables = { user, event, context, project }
|
||||
|
||||
if (template.type === 'email') {
|
||||
const channel = await loadEmailChannel(campaign.provider_id, projectId)
|
||||
const channel = await loadEmailChannel(campaign.provider_id, project.id)
|
||||
await channel?.send(template, variables)
|
||||
|
||||
} else if (template.type === 'text') {
|
||||
const channel = await loadTextChannel(campaign.provider_id, projectId)
|
||||
const channel = await loadTextChannel(campaign.provider_id, project.id)
|
||||
await channel?.send(template, variables)
|
||||
|
||||
} else if (template.type === 'push') {
|
||||
const channel = await loadPushChannel(campaign.provider_id, projectId)
|
||||
const channel = await loadPushChannel(campaign.provider_id, project.id)
|
||||
if (!user.id) throw new RequestError('Unable to find a user matching the criteria.')
|
||||
await channel?.send(template, variables)
|
||||
|
||||
} else if (template.type === 'webhook') {
|
||||
const channel = await loadWebhookChannel(campaign.provider_id, projectId)
|
||||
const channel = await loadWebhookChannel(campaign.provider_id, project.id)
|
||||
await channel?.send(template, variables)
|
||||
} else {
|
||||
throw new RequestError('Sending template proofs is only supported for email and text message types as this time.')
|
||||
|
|
|
@ -8,11 +8,9 @@ import * as ArrayHelpers from './Helpers/Array'
|
|||
import { User } from '../users/User'
|
||||
import { preferencesLink, unsubscribeEmailLink } from '../subscriptions/SubscriptionService'
|
||||
import { clickWrapHtml, openWrapHtml, preheaderWrapHtml } from './LinkService'
|
||||
import App from '../app'
|
||||
import Project from '../projects/Project'
|
||||
|
||||
export type RenderContext = {
|
||||
project: Project
|
||||
template_id: number
|
||||
campaign_id: number
|
||||
subscription_id: number
|
||||
|
@ -23,6 +21,7 @@ export interface Variables {
|
|||
context: RenderContext
|
||||
user: User
|
||||
event?: Record<string, any>
|
||||
project: Project
|
||||
}
|
||||
|
||||
export interface TrackingParams {
|
||||
|
@ -54,7 +53,7 @@ interface WrapParams {
|
|||
preheader?: string
|
||||
variables: Variables
|
||||
}
|
||||
export const Wrap = ({ html, preheader, variables: { user, context } }: WrapParams) => {
|
||||
export const Wrap = ({ html, preheader, variables: { user, context, project } }: WrapParams) => {
|
||||
const trackingParams = {
|
||||
userId: user.id,
|
||||
campaignId: context.campaign_id,
|
||||
|
@ -62,7 +61,7 @@ export const Wrap = ({ html, preheader, variables: { user, context } }: WrapPara
|
|||
}
|
||||
|
||||
// Check if link wrapping is enabled first
|
||||
if (App.main.env.tracking.linkWrap) {
|
||||
if (project.link_wrap) {
|
||||
html = clickWrapHtml(html, trackingParams)
|
||||
}
|
||||
|
||||
|
|
2501
apps/ui/package-lock.json
generated
2501
apps/ui/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -1,6 +1,6 @@
|
|||
import Axios from 'axios'
|
||||
import { env } from './config/env'
|
||||
import { Admin, AuthMethod, Campaign, CampaignCreateParams, CampaignLaunchParams, CampaignUpdateParams, CampaignUser, Image, Journey, JourneyStepMap, JourneyStepStats, List, ListCreateParams, ListUpdateParams, Project, ProjectAdmin, ProjectAdminParams, ProjectApiKey, ProjectApiKeyParams, Provider, ProviderCreateParams, ProviderMeta, ProviderUpdateParams, QueueMetric, RuleSuggestions, SearchParams, SearchResult, Subscription, SubscriptionParams, Tag, Template, TemplateCreateParams, TemplatePreviewParams, TemplateProofParams, TemplateUpdateParams, User, UserEvent, UserSubscription } from './types'
|
||||
import { Admin, AuthMethod, Campaign, CampaignCreateParams, CampaignLaunchParams, CampaignUpdateParams, CampaignUser, Image, Journey, JourneyStepMap, JourneyStepStats, List, ListCreateParams, ListUpdateParams, Organization, OrganizationUpdateParams, Project, ProjectAdmin, ProjectAdminParams, ProjectApiKey, ProjectApiKeyParams, Provider, ProviderCreateParams, ProviderMeta, ProviderUpdateParams, QueueMetric, RuleSuggestions, SearchParams, SearchResult, Subscription, SubscriptionParams, Tag, Template, TemplateCreateParams, TemplatePreviewParams, TemplateProofParams, TemplateUpdateParams, User, UserEvent, UserSubscription } from './types'
|
||||
|
||||
function appendValue(params: URLSearchParams, name: string, value: unknown) {
|
||||
if (typeof value === 'undefined' || value === null || typeof value === 'function') return
|
||||
|
@ -277,6 +277,12 @@ const api = {
|
|||
},
|
||||
|
||||
organizations: {
|
||||
get: async () => await client
|
||||
.get<Organization>('/admin/organizations')
|
||||
.then(r => r.data),
|
||||
update: async (id: number | string, params: OrganizationUpdateParams) => await client
|
||||
.patch<Organization>(`/admin/organizations/${id}`, params)
|
||||
.then(r => r.data),
|
||||
metrics: async () => await client
|
||||
.get<QueueMetric>('/admin/organizations/metrics')
|
||||
.then(r => r.data),
|
||||
|
|
|
@ -42,12 +42,12 @@ h2 {
|
|||
}
|
||||
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
font-size: 22px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-size: 18px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
|
|
|
@ -119,11 +119,15 @@ export interface Admin {
|
|||
}
|
||||
|
||||
export interface Organization {
|
||||
id: number
|
||||
username: string
|
||||
domain: string
|
||||
auth: any
|
||||
tracking_deeplink_mirror_url?: string
|
||||
}
|
||||
|
||||
export type OrganizationUpdateParams = Omit<Organization, 'id' | 'auth' | AuditFields>
|
||||
|
||||
export const projectRoles = [
|
||||
'support',
|
||||
'editor',
|
||||
|
@ -150,6 +154,7 @@ export interface Project {
|
|||
locale: string
|
||||
timezone: string
|
||||
text_opt_out_message: string
|
||||
link_wrap: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
deleted_at?: string
|
||||
|
|
|
@ -22,7 +22,6 @@ export default function PreviewImage({
|
|||
|
||||
const handleLoad = (event: React.SyntheticEvent<HTMLIFrameElement, Event> | undefined) => {
|
||||
const state = (event?.target as any).contentWindow?.document.body.innerHTML.length > 0
|
||||
console.log(url, state)
|
||||
setLoaded(state)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { ReactNode, useState } from 'react'
|
||||
import { DeepPartial, DeepRequired, FieldErrorsImpl, FieldValues, useForm, UseFormReturn } from 'react-hook-form'
|
||||
import { DeepRequired, DefaultValues, FieldErrorsImpl, FieldValues, useForm, UseFormReturn } from 'react-hook-form'
|
||||
import { NavigateFunction, useNavigate } from 'react-router-dom'
|
||||
import Alert from '../Alert'
|
||||
import Button from '../Button'
|
||||
|
||||
interface FormWrapperProps<T extends FieldValues> {
|
||||
children: (form: UseFormReturn<T>) => ReactNode
|
||||
defaultValues?: DeepPartial<T>
|
||||
defaultValues?: DefaultValues<T>
|
||||
submitLabel?: string
|
||||
onSubmit: (data: T, navigate: NavigateFunction) => Promise<void>
|
||||
onError?: (error: Error) => void
|
||||
|
|
|
@ -219,3 +219,9 @@ export const QuestionIcon = () => (
|
|||
<path d="M5.255 5.786a.237.237 0 0 0 .241.247h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286zm1.557 5.763c0 .533.425.927 1.01.927.609 0 1.028-.394 1.028-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94z"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const IntegrationsIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="icon">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6.429 9.75L2.25 12l4.179 2.25m0-4.5l5.571 3 5.571-3m-11.142 0L2.25 7.5 12 2.25l9.75 5.25-4.179 2.25m0 0L21.75 12l-4.179 2.25m0 0l4.179 2.25L12 21.75 2.25 16.5l4.179-2.25m11.142 0l-5.571 3-5.571-3" />
|
||||
</svg>
|
||||
)
|
||||
|
|
|
@ -45,7 +45,7 @@ function GrapesReact({ id, mjml, onChange, setAssetState }: GrapesReactProps) {
|
|||
setEditor(editor)
|
||||
editor.on('load', () => {
|
||||
editor.Panels.getButton('views', 'open-blocks')
|
||||
.set('active', true)
|
||||
?.set('active', true)
|
||||
})
|
||||
editor.render()
|
||||
editor.setComponents(mjml ?? '<mjml><mj-body></mj-body></mjml>')
|
||||
|
|
43
apps/ui/src/views/organization/Settings.tsx
Normal file
43
apps/ui/src/views/organization/Settings.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { useContext } from 'react'
|
||||
import PageContent from '../../ui/PageContent'
|
||||
import FormWrapper from '../../ui/form/FormWrapper'
|
||||
import { OrganizationContext } from '../../contexts'
|
||||
import TextInput from '../../ui/form/TextInput'
|
||||
import { Organization } from '../../types'
|
||||
import Heading from '../../ui/Heading'
|
||||
import api from '../../api'
|
||||
import { toast } from 'react-hot-toast'
|
||||
|
||||
export default function Settings() {
|
||||
const [organization] = useContext(OrganizationContext)
|
||||
return (
|
||||
<>
|
||||
<PageContent title="Settings">
|
||||
<FormWrapper<Organization>
|
||||
defaultValues={organization}
|
||||
onSubmit={async ({ username, domain, tracking_deeplink_mirror_url }) => {
|
||||
await api.organizations.update(organization.id, { username, domain, tracking_deeplink_mirror_url })
|
||||
|
||||
toast.success('Saved organization settings')
|
||||
}}
|
||||
submitLabel="Save Settings"
|
||||
>
|
||||
{form => <>
|
||||
<Heading size="h3" title="General" />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="username"
|
||||
subtitle="The organization username. Used for the subdomain that the organization is hosted under." />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="domain"
|
||||
subtitle="If filled, users who log in with SSO and have this domain will be automatically joined to the organization." />
|
||||
<Heading size="h3" title="Tracking" />
|
||||
<TextInput.Field form={form} name="tracking_deeplink_mirror_url" label="Tracking Deeplink Mirror URL"
|
||||
subtitle="The URL to clone universal link settings from." />
|
||||
</>}
|
||||
</FormWrapper>
|
||||
</PageContent>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -3,6 +3,8 @@ import TextInput from '../../ui/form/TextInput'
|
|||
import { Project } from '../../types'
|
||||
import FormWrapper from '../../ui/form/FormWrapper'
|
||||
import { SingleSelect } from '../../ui/form/SingleSelect'
|
||||
import SwitchField from '../../ui/form/SwitchField'
|
||||
import Heading from '../../ui/Heading'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
export declare namespace Intl {
|
||||
|
@ -42,18 +44,23 @@ export default function ProjectForm({ project, onSave }: ProjectFormProps) {
|
|||
return (
|
||||
<FormWrapper<Project>
|
||||
defaultValues={defaults}
|
||||
onSubmit={async ({ id, name, description, locale, timezone, text_opt_out_message }) => {
|
||||
onSubmit={async ({ id, name, description, locale, timezone, text_opt_out_message, link_wrap }) => {
|
||||
|
||||
const params = { name, description, locale, timezone, text_opt_out_message, link_wrap }
|
||||
|
||||
const project = id
|
||||
? await api.projects.update(id, { name, description, locale, timezone, text_opt_out_message })
|
||||
: await api.projects.create({ name, description, locale, timezone, text_opt_out_message })
|
||||
? await api.projects.update(id, params)
|
||||
: await api.projects.create(params)
|
||||
onSave?.(project)
|
||||
}}
|
||||
submitLabel="Save Settings"
|
||||
>
|
||||
{
|
||||
form => (
|
||||
<>
|
||||
<TextInput.Field form={form} name="name" required />
|
||||
<TextInput.Field form={form} name="description" textarea />
|
||||
<Heading size="h4" title="Defaults" />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="locale"
|
||||
|
@ -67,11 +74,13 @@ export default function ProjectForm({ project, onSave }: ProjectFormProps) {
|
|||
label="Timezone"
|
||||
required
|
||||
/>
|
||||
<Heading size="h4" title="Message Settings" />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="text_opt_out_message"
|
||||
label="SMS Opt Out Message"
|
||||
subtitle="Instructions on how to opt out of SMS that will be appended to every text." />
|
||||
<SwitchField form={form} name="link_wrap" label="Link Wrapping" subtitle="Enable link wrapping for all links in messages." />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -91,7 +91,7 @@ export default function ProjectSidebar({ children, links }: PropsWithChildren<Si
|
|||
</div>
|
||||
</div>
|
||||
}>
|
||||
<MenuItem onClick={() => navigate('/settings')}>Settings</MenuItem>
|
||||
<MenuItem onClick={() => navigate('/organization')}>Settings</MenuItem>
|
||||
<MenuItem onClick={() => setPreferences({ ...preferences, mode: preferences.mode === 'dark' ? 'light' : 'dark' })}>Use {preferences.mode === 'dark' ? 'Light' : 'Dark'} Theme</MenuItem>
|
||||
<MenuItem onClick={async () => await api.auth.logout()}>Sign Out</MenuItem>
|
||||
</Menu>
|
||||
|
|
|
@ -42,14 +42,6 @@ export function Projects() {
|
|||
}
|
||||
}, [projects, navigate])
|
||||
|
||||
if (!projects) {
|
||||
return (
|
||||
<div>
|
||||
loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContent
|
||||
title="Projects"
|
||||
|
|
|
@ -4,7 +4,7 @@ import api from '../api'
|
|||
import ErrorPage from './ErrorPage'
|
||||
import Sidebar from '../ui/Sidebar'
|
||||
import { LoaderContextProvider, StatefulLoaderContextProvider } from './LoaderContextProvider'
|
||||
import { AdminContext, CampaignContext, JourneyContext, ListContext, ProjectContext, UserContext } from '../contexts'
|
||||
import { AdminContext, CampaignContext, JourneyContext, ListContext, OrganizationContext, ProjectContext, UserContext } from '../contexts'
|
||||
import ApiKeys from './settings/ApiKeys'
|
||||
import EmailEditor from './campaign/editor/EmailEditor'
|
||||
import Lists from './users/Lists'
|
||||
|
@ -40,6 +40,7 @@ import Performance from './organization/Performance'
|
|||
import Settings from './settings/Settings'
|
||||
import ProjectSidebar from './project/ProjectSidebar'
|
||||
import Admins from './organization/Admins'
|
||||
import OrganizationSettings from './organization/Settings'
|
||||
|
||||
export const useRoute = (includeProject = true) => {
|
||||
const { projectId = '' } = useParams()
|
||||
|
@ -90,9 +91,12 @@ export const router = createBrowserRouter([
|
|||
],
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
path: 'organization',
|
||||
loader: async () => {
|
||||
return await api.organizations.get()
|
||||
},
|
||||
element: (
|
||||
<StatefulLoaderContextProvider context={ProjectContext}>
|
||||
<StatefulLoaderContextProvider context={OrganizationContext}>
|
||||
<Sidebar
|
||||
links={[
|
||||
{
|
||||
|
@ -113,6 +117,12 @@ export const router = createBrowserRouter([
|
|||
children: 'Performance',
|
||||
icon: <PerformanceIcon />,
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
to: 'settings',
|
||||
children: 'Settings',
|
||||
icon: <SettingsIcon />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Outlet />
|
||||
|
@ -138,6 +148,10 @@ export const router = createBrowserRouter([
|
|||
path: 'performance',
|
||||
element: <Performance />,
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
element: <OrganizationSettings />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
3025
docs/package-lock.json
generated
3025
docs/package-lock.json
generated
File diff suppressed because it is too large
Load diff
10412
package-lock.json
generated
10412
package-lock.json
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue