feat: allow for having internal subscription types

This commit is contained in:
Chris Anderson 2025-07-15 14:58:22 -05:00
parent b42d022923
commit ec165ba72e
12 changed files with 92 additions and 14 deletions

View file

@ -0,0 +1,11 @@
exports.up = async function(knex) {
await knex.schema.table('subscriptions', function(table) {
table.tinyint('is_public').defaultTo(1)
})
}
exports.down = async function(knex) {
await knex.schema.table('subscriptions', function(table) {
table.dropColumn('is_public')
})
}

View file

@ -59,7 +59,7 @@ export type SegmentPostEvent = {
context: Record<string, any> & SegmentContext
properties: Record<string, any>
traits?: Record<string, any>
type: 'track' | 'alias' | 'identify' | 'device'
type: 'track' | 'alias' | 'identify' | 'device' | 'unsubscribe'
timestamp: string
} & (
{
@ -74,6 +74,10 @@ export type SegmentPostEvent = {
type: 'device'
properties: Record<string, any>
}
| {
type: 'unsubscribe',
properties: Record<string, any>
}
)
export type SegmentPostEventsRequest = SegmentPostEvent[]

View file

@ -10,6 +10,7 @@ import { Job } from '../queue'
import { parseLocale } from '../utilities'
import UserAliasJob from '../users/UserAliasJob'
import UserDeviceJob from '../users/UserDeviceJob'
import UnsubscribeJob from '../subscriptions/UnsubscribeJob'
const router = new Router<ProjectState>()
router.use(projectMiddleware)
@ -65,7 +66,7 @@ const segmentEventsRequest: JSONSchemaType<SegmentPostEventsRequest> = {
],
},
minItems: 1,
maxItems: 1000,
maxItems: 2000,
} as any
router.post('/segment', async ctx => {
const events = validate(segmentEventsRequest, ctx.request.body)
@ -115,6 +116,12 @@ router.post('/segment', async ctx => {
...identity,
...event.properties as any,
}))
} else if (event.type === 'unsubscribe') {
chunks.push(UnsubscribeJob.from({
...identity,
...event.properties as any,
}))
}
// Based on queue max batch size, process in largest chunks

View file

@ -25,6 +25,7 @@ import ScheduledEntranceJob from '../journey/ScheduledEntranceJob'
import ScheduledEntranceOrchestratorJob from '../journey/ScheduledEntranceOrchestratorJob'
import CampaignAbortJob from '../campaigns/CampaignAbortJob'
import MigrateJob from '../organizations/MigrateJob'
import UnsubscribeJob from '../subscriptions/UnsubscribeJob'
export const jobs = [
CampaignAbortJob,
@ -45,6 +46,7 @@ export const jobs = [
ScheduledEntranceJob,
ScheduledEntranceOrchestratorJob,
TextJob,
UnsubscribeJob,
UpdateJourneysJob,
UserAliasJob,
UserDeleteJob,

View file

@ -5,6 +5,7 @@ export default class Subscription extends Model {
project_id!: number
name!: string
channel!: ChannelType
is_public!: boolean
}
export enum SubscriptionState {
@ -21,4 +22,4 @@ export type UserSubscription = {
}
export type SubscriptionParams = Omit<Subscription, ModelParams>
export type SubscriptionUpdateParams = Pick<SubscriptionParams, 'name'>
export type SubscriptionUpdateParams = Pick<SubscriptionParams, 'name' | 'is_public'>

View file

@ -256,6 +256,10 @@ export const subscriptionCreateSchema: JSONSchemaType<SubscriptionParams> = {
type: 'string',
enum: ['email', 'text', 'push', 'webhook'],
},
is_public: {
type: 'boolean',
required: false,
},
},
additionalProperties: false,
}
@ -281,16 +285,20 @@ router.get('/:subscriptionId', async ctx => {
export const subscriptionUpdateSchema: JSONSchemaType<SubscriptionUpdateParams> = {
$id: 'subscriptionUpdate',
type: 'object',
required: ['name'],
required: ['name', 'is_public'],
properties: {
name: {
type: 'string',
},
is_public: {
type: 'boolean',
},
},
additionalProperties: false,
}
router.patch('/:subscriptionId', async ctx => {
const payload = validate(subscriptionUpdateSchema, ctx.request.body)
console.log(payload)
ctx.body = await updateSubscription(ctx.state.subscription!.id, payload)
})

View file

@ -16,9 +16,13 @@ export const pagedSubscriptions = async (params: PageParams, projectId: number)
)
}
export const getUserSubscriptions = async (user: User, params?: PageParams): Promise<SearchResult<UserSubscription>> => {
export const getUserSubscriptions = async (user: User, params?: PageParams, onlyPublic = true): Promise<SearchResult<UserSubscription>> => {
const subscriptions = await Subscription.all(
qb => qb.where('project_id', user.project_id),
qb => {
qb.where('project_id', user.project_id)
if (onlyPublic) qb.where('is_public', true)
return qb
},
)
return {
results: subscriptions.map(subscription => ({

View file

@ -0,0 +1,24 @@
import { Job } from '../queue'
import { toggleSubscription } from './SubscriptionService'
import { SubscriptionState } from './Subscription'
import { getUserFromClientId } from '../users/UserRepository'
type UserUnsubscribeParams = {
external_id: string
project_id: number
subscription_id: number
}
export default class UnsubscribeJob extends Job {
static $name = 'unsubscribe'
static from(data: UserUnsubscribeParams): UnsubscribeJob {
return new this(data)
}
static async handler({ project_id, subscription_id, external_id }: UserUnsubscribeParams) {
const user = await getUserFromClientId(project_id, { external_id })
if (!user) return
await toggleSubscription(user.id, subscription_id, SubscriptionState.unsubscribed)
}
}

View file

@ -177,7 +177,7 @@ router.get('/:userId/events', async ctx => {
router.get('/:userId/subscriptions', async ctx => {
const params = extractQueryParams(ctx.query, searchParamsSchema)
ctx.body = await getUserSubscriptions(ctx.state.user!, params)
ctx.body = await getUserSubscriptions(ctx.state.user!, params, false)
})
router.patch('/:userId/subscriptions', async ctx => {

View file

@ -227,6 +227,7 @@
"missing": "Missing",
"name": "Name",
"new_team_member": "New Team Member",
"no": "No",
"no_providers": "No Providers",
"no_template_alert_body": "There are no templates yet for this campaign. Add a locale above or use the button below to get started.",
"now": "Now",
@ -250,6 +251,8 @@
"projects_description": "Projects are isolated workspaces with their own sets of users, events, lists, campaigns, and journeys.",
"project_settings_saved": "Project settings saved!",
"provider": "Provider",
"public": "Public",
"public_desc": "Should a user be able to see this channel in the preferences center?",
"publish": "Publish",
"published": "Published",
"push": "Push",
@ -385,5 +388,6 @@
"wait": "Wait",
"wait_until": "Wait until",
"webhook": "Webhook",
"welcome": "Welcome!"
"welcome": "Welcome!",
"yes": "Yes"
}

View file

@ -521,11 +521,12 @@ export interface Subscription {
id: number
name: string
channel: ChannelType
is_public: boolean
created_at: string
updated_at: string
}
export type SubscriptionCreateParams = Pick<Subscription, 'name' | 'channel'>
export type SubscriptionUpdateParams = Pick<SubscriptionCreateParams, 'name'>
export type SubscriptionCreateParams = Pick<Subscription, 'name' | 'channel' | 'is_public'>
export type SubscriptionUpdateParams = Pick<SubscriptionCreateParams, 'name' | 'is_public'>
export type ProviderGroup = 'email' | 'text' | 'push' | 'webhook'
export interface Provider {

View file

@ -11,6 +11,7 @@ import Button from '../../ui/Button'
import { PlusIcon } from '../../ui/icons'
import { snakeToTitle } from '../../utils'
import { useTranslation } from 'react-i18next'
import SwitchField from '../../ui/form/SwitchField'
export default function Subscriptions() {
const { t } = useTranslation()
@ -29,6 +30,11 @@ export default function Subscriptions() {
title: t('channel'),
cell: ({ item }) => snakeToTitle(item.channel),
},
{
key: 'is_public',
title: t('public'),
cell: ({ item }) => item.is_public ? t('yes') : t('no'),
},
]}
itemKey={({ item }) => item.id}
onSelectRow={(row) => setEditing(row)}
@ -49,12 +55,12 @@ export default function Subscriptions() {
open={Boolean(editing)}
onClose={() => setEditing(null)}
>
{editing && <FormWrapper<Pick<Subscription, 'id' | 'name' | 'channel'>>
onSubmit={async ({ id, name, channel }) => {
{editing && <FormWrapper<Pick<Subscription, 'id' | 'name' | 'channel' | 'is_public'>>
onSubmit={async ({ id, name, channel, is_public }) => {
if (id) {
await api.subscriptions.update(project.id, id, { name })
await api.subscriptions.update(project.id, id, { name, is_public })
} else {
await api.subscriptions.create(project.id, { name, channel })
await api.subscriptions.create(project.id, { name, channel, is_public })
}
await state.reload()
setEditing(null)
@ -70,6 +76,12 @@ export default function Subscriptions() {
required
label={t('name')}
/>
<SwitchField
form={form}
name="is_public"
subtitle={t('public_desc')}
label={t('public')}
/>
{!editing.id && <SingleSelect.Field
form={form}
name="channel"