feat(captain): add audience and schedule controls for assistants

Audience: a nested condition builder (contact attributes, labels, conversation language, and a logged-in/HMAC-verified flag) evaluated in-memory by Captain::AudienceMatcher. Gates Captain at the reply, new-conversation routing, reopen, and template-suppression sites so out-of-audience conversations go to the human queue.

Schedule: a response window (anytime / during business hours / outside business hours) reusing each inbox's business hours. Off-schedule conversations are handed to the team; inboxes without business hours are always covered.
This commit is contained in:
Pranav
2026-06-24 07:19:52 -07:00
parent e86222034e
commit 39606f3944
21 changed files with 1345 additions and 84 deletions
@@ -0,0 +1,147 @@
<script setup>
import { ref, watch, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import AudienceGroup from './audience/AudienceGroup.vue';
import { useAudienceFilterTypes } from './audience/useAudienceFilterTypes.js';
const props = defineProps({
assistant: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit']);
const { t } = useI18n();
const { filterTypes } = useAudienceFilterTypes();
let uid = 0;
const nextId = () => {
uid += 1;
return `audience-root-${uid}`;
};
const hasConditions = node =>
node && Object.prototype.hasOwnProperty.call(node, 'conditions');
const defaultRoot = () => ({ id: nextId(), operator: 'and', conditions: [] });
const root = ref(defaultRoot());
const findOption = (filterType, value) =>
filterType?.options?.find(option => String(option.id) === String(value));
const hydrateValues = (leaf, filterType) => {
const raw = Array.isArray(leaf.values) ? leaf.values : [leaf.values];
const inputType = filterType?.inputType;
if (inputType === 'multiSelect') {
return raw.map(
value => findOption(filterType, value) ?? { id: value, name: value }
);
}
if (['searchSelect', 'booleanSelect'].includes(inputType)) {
return findOption(filterType, raw[0]) ?? { id: raw[0], name: raw[0] };
}
return raw[0] ?? '';
};
const hydrateNode = node => {
if (hasConditions(node)) {
return {
id: nextId(),
operator: node.operator || 'and',
conditions: (node.conditions || []).map(hydrateNode),
};
}
const filterType = filterTypes.value.find(
type => type.attributeKey === node.attribute_key
);
return {
id: nextId(),
attributeKey: node.attribute_key,
filterOperator: node.filter_operator,
values: hydrateValues(node, filterType),
attributeModel: filterType?.attributeModel || 'standard',
};
};
const hydrateRoot = audience => {
if (!audience) return defaultRoot();
const hydrated = hydrateNode(audience);
return hasConditions(hydrated)
? hydrated
: { id: nextId(), operator: 'and', conditions: [hydrated] };
};
const serializeValues = values => {
if (Array.isArray(values)) {
return values[0]?.id ? values.map(value => value.id) : values;
}
if (values && typeof values === 'object') {
return [values.id];
}
if (values === '' || values === null || values === undefined) {
return [];
}
return [values];
};
const serializeNode = node => {
if (hasConditions(node)) {
return {
operator: node.operator,
conditions: node.conditions.map(serializeNode),
};
}
return {
attribute_key: node.attributeKey,
filter_operator: node.filterOperator,
values: serializeValues(node.values),
};
};
const groupRef = useTemplateRef('groupRef');
const handleSubmit = () => {
if (!groupRef.value.validate()) return;
const audience = root.value.conditions.length
? serializeNode(root.value)
: null;
emit('submit', {
config: {
...props.assistant.config,
audience,
},
});
};
watch(
() => props.assistant,
newAssistant => {
if (newAssistant) root.value = hydrateRoot(newAssistant.config?.audience);
},
{ immediate: true }
);
</script>
<template>
<div class="flex flex-col gap-6">
<AudienceGroup
ref="groupRef"
v-model="root"
is-root
:filter-types="filterTypes"
/>
<div>
<Button
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleSubmit"
/>
</div>
</div>
</template>
@@ -0,0 +1,66 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
const props = defineProps({
assistant: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit']);
const { t } = useI18n();
const OPTIONS = ['always', 'business_hours', 'outside_business_hours'];
const selected = ref('always');
const handleSubmit = () => {
emit('submit', {
config: { ...props.assistant.config, response_window: selected.value },
});
};
watch(
() => props.assistant,
newAssistant => {
if (newAssistant) {
selected.value = newAssistant.config?.response_window || 'always';
}
},
{ immediate: true }
);
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3">
<RadioCard
v-for="option in OPTIONS"
:id="option"
:key="option"
:label="
t(`CAPTAIN.ASSISTANTS.FORM.SCHEDULE.${option.toUpperCase()}.LABEL`)
"
:description="
t(`CAPTAIN.ASSISTANTS.FORM.SCHEDULE.${option.toUpperCase()}.DESC`)
"
:is-active="selected === option"
@select="selected = $event"
/>
</div>
<p class="text-sm text-n-slate-11">
{{ t('CAPTAIN.ASSISTANTS.FORM.SCHEDULE.HINT') }}
</p>
<div>
<Button
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleSubmit"
/>
</div>
</div>
</template>
@@ -0,0 +1,167 @@
<script setup>
import { useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'next/button/Button.vue';
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
import ConditionRow from 'dashboard/components-next/filter/ConditionRow.vue';
const props = defineProps({
filterTypes: { type: Array, required: true },
depth: { type: Number, default: 0 },
maxDepth: { type: Number, default: 1 },
isRoot: { type: Boolean, default: false },
});
const emit = defineEmits(['remove']);
const node = defineModel({ type: Object, required: true });
const { t } = useI18n();
let uid = 0;
const nextId = () => {
uid += 1;
return `audience-${Date.now()}-${uid}`;
};
const DEFAULT_LEAF = () => ({
id: nextId(),
attributeKey: 'email',
filterOperator: 'contains',
values: '',
attributeModel: 'standard',
});
const operatorOptions = [
{ value: 'and', label: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.MATCH_ALL') },
{ value: 'or', label: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.MATCH_ANY') },
];
const isGroup = child =>
Object.prototype.hasOwnProperty.call(child, 'conditions');
const addCondition = () => {
node.value.conditions.push(DEFAULT_LEAF());
};
const addGroup = () => {
node.value.conditions.push({
id: nextId(),
operator: 'and',
conditions: [DEFAULT_LEAF()],
});
};
const removeChild = index => {
node.value.conditions.splice(index, 1);
// A nested group must always hold at least one condition; remove the whole group when emptied.
if (!props.isRoot && node.value.conditions.length === 0) {
emit('remove');
}
};
const leafRefs = useTemplateRef('leafRefs');
const groupRefs = useTemplateRef('groupRefs');
const validate = () => {
const leavesValid = (leafRefs.value ?? []).every(row => row.validate());
const groupsValid = (groupRefs.value ?? []).every(group => group.validate());
return leavesValid && groupsValid;
};
defineExpose({ validate });
</script>
<template>
<div
class="flex flex-col gap-4 group/audience-group"
:class="
isRoot
? 'p-4 border border-n-weak rounded-xl'
: 'p-3 border border-n-weak bg-n-alpha-1 rounded-lg'
"
>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1.5 text-sm text-n-slate-11">
<span>{{ t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.MATCH_PREFIX') }}</span>
<FilterSelect
v-model="node.operator"
variant="faded"
hide-icon
class="text-sm"
:options="operatorOptions"
/>
<span>{{ t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.MATCH_SUFFIX') }}</span>
</div>
<Button
v-if="!isRoot"
sm
ghost
slate
icon="i-lucide-trash"
class="ml-auto flex-shrink-0 opacity-0 transition-opacity group-hover/audience-group:opacity-100"
@click="emit('remove')"
/>
</div>
<ul class="grid gap-3 list-none">
<template v-for="(child, index) in node.conditions" :key="child.id">
<AudienceGroup
v-if="isGroup(child)"
ref="groupRefs"
v-model="node.conditions[index]"
:filter-types="filterTypes"
:depth="depth + 1"
:max-depth="maxDepth"
@remove="removeChild(index)"
/>
<ConditionRow
v-else
ref="leafRefs"
v-model:attribute-key="child.attributeKey"
v-model:filter-operator="child.filterOperator"
v-model:values="child.values"
:filter-types="filterTypes"
:show-query-operator="false"
searchable-attributes
@remove="removeChild(index)"
/>
</template>
</ul>
<div
v-if="isRoot && !node.conditions.length"
class="flex flex-col items-center gap-1.5 px-4 py-8 text-center border border-dashed rounded-lg border-n-weak"
>
<span class="text-2xl i-lucide-users-round text-n-slate-10" />
<p class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.EMPTY_TITLE') }}
</p>
<p class="max-w-md text-sm text-n-slate-11">
{{ t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.EMPTY_BODY') }}
</p>
</div>
<div class="flex gap-2">
<Button
sm
ghost
blue
icon="i-lucide-plus"
class="flex-shrink-0"
:label="t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.ADD_CONDITION')"
@click="addCondition"
/>
<Button
v-if="depth < maxDepth"
sm
ghost
blue
icon="i-lucide-plus"
class="flex-shrink-0"
:label="t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.ADD_GROUP')"
@click="addGroup"
/>
</div>
</div>
</template>
@@ -0,0 +1,147 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useContactFilterContext } from 'dashboard/components-next/filter/contactProvider.js';
import { useOperators } from 'dashboard/components-next/filter/operators.js';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
// Icons for the standard contact attributes, keyed by attribute key.
const STANDARD_ICONS = {
name: 'i-lucide-user',
email: 'i-lucide-mail',
phone_number: 'i-lucide-phone',
identifier: 'i-lucide-fingerprint',
country_code: 'i-lucide-flag',
city: 'i-lucide-map-pin',
company_name: 'i-lucide-building-2',
created_at: 'i-lucide-calendar',
last_activity_at: 'i-lucide-activity',
blocked: 'i-lucide-ban',
labels: 'i-lucide-tags',
browser_language: 'i-lucide-globe',
conversation_language: 'i-lucide-languages',
};
// Icons for custom attributes, keyed by the attribute's display type.
const CUSTOM_TYPE_ICONS = {
text: 'i-lucide-type',
number: 'i-lucide-hash',
currency: 'i-lucide-banknote',
percent: 'i-lucide-percent',
link: 'i-lucide-link',
date: 'i-lucide-calendar',
list: 'i-lucide-list',
checkbox: 'i-lucide-square-check',
};
const DEFAULT_ICON = 'i-lucide-tag';
/**
* Filter types for a Captain assistant audience: contact attributes (reused from the contact
* segment builder) plus the two conversation language fields. Each option carries an icon, and the
* options are split into grouped sections (contact / conversation / custom) via disabled headers,
* which FilterSelect renders as non-clickable section titles.
*
* @returns {{ filterTypes: import('vue').ComputedRef<Array> }}
*/
export function useAudienceFilterTypes() {
const { t } = useI18n();
const { filterTypes: contactFilterTypes } = useContactFilterContext();
const { equalityOperators } = useOperators();
const contactAttributes = useMapGetter('attributes/getContactAttributes');
// Map custom attribute key -> display type, so each custom option gets a type-based icon.
const customTypeByKey = computed(() =>
(contactAttributes.value || []).reduce((acc, attr) => {
acc[attr.attributeKey] = attr.attributeDisplayType;
return acc;
}, {})
);
// Conversation-level attributes: the logged-in (HMAC verified) flag and the language fields.
// Languages use a searchable dropdown (same option list as automation rules), not free text.
const conversationFilterTypes = computed(() => [
{
attributeKey: 'hmac_verified',
value: 'hmac_verified',
attributeName: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN'),
label: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN'),
icon: 'i-lucide-user-check',
inputType: 'searchSelect',
options: [
{
id: 'true',
name: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN_TRUE'),
},
{
id: 'false',
name: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN_FALSE'),
},
],
dataType: 'text',
filterOperators: equalityOperators.value,
attributeModel: 'additional',
},
...['browser_language', 'conversation_language'].map(key => ({
attributeKey: key,
value: key,
attributeName: t(`CAPTAIN.ASSISTANTS.FORM.AUDIENCE.${key.toUpperCase()}`),
label: t(`CAPTAIN.ASSISTANTS.FORM.AUDIENCE.${key.toUpperCase()}`),
icon: STANDARD_ICONS[key],
inputType: 'searchSelect',
options: languages,
dataType: 'text',
filterOperators: equalityOperators.value,
attributeModel: 'additional',
})),
]);
const header = (id, label) => ({
value: `__group_${id}`,
label,
disabled: true,
});
const filterTypes = computed(() => {
const standard = [];
const custom = [];
contactFilterTypes.value.forEach(type => {
if (type.attributeModel === 'customAttributes') {
// Only contact-model custom attributes belong here; never conversation/company ones.
if (!(type.attributeKey in customTypeByKey.value)) return;
const displayType = customTypeByKey.value[type.attributeKey];
custom.push({
...type,
icon: CUSTOM_TYPE_ICONS[displayType] || DEFAULT_ICON,
});
} else {
standard.push({
...type,
icon: STANDARD_ICONS[type.attributeKey] || DEFAULT_ICON,
});
}
});
return [
header('contact', t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CONTACT')),
...standard,
header(
'conversation',
t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CONVERSATION')
),
...conversationFilterTypes.value,
...(custom.length
? [
header(
'custom',
t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CUSTOM')
),
...custom,
]
: []),
];
});
return { filterTypes };
}