Add public API keys, outbound webhooks, and bulk SMS campaigns with settings UI and org modules.

This commit is contained in:
2026-09-26 16:12:29 +03:30
parent ccddd009e3
commit ef600d149d
23 changed files with 2434 additions and 5 deletions
+4 -1
View File
@@ -54,7 +54,8 @@ const supportActive = computed(() => pathIn(['/tickets']));
const automationsActive = computed(
() =>
route.path.startsWith('/settings/automations') ||
route.path.startsWith('/settings/message-automations'),
route.path.startsWith('/settings/message-automations') ||
route.path.startsWith('/settings/sms-campaigns'),
);
const toolsActive = computed(() =>
pathIn(['/settings/lead-forms', '/projects', '/project-cards', '/inventory', '/cheques', '/attachments']),
@@ -62,6 +63,8 @@ const toolsActive = computed(() =>
const settingsActive = computed(() =>
pathIn([
'/settings/team',
'/settings/audit-logs',
'/settings/public-api',
'/settings/custom-fields',
'/settings/currencies',
'/settings/tahesab',
+24
View File
@@ -227,6 +227,12 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'send',
module: 'message_automations',
},
{
to: '/settings/sms-campaigns',
title: 'پیامک گروهی',
icon: 'send',
module: 'sms_campaigns',
},
],
cards: [
{
@@ -247,6 +253,15 @@ export const NAV_HUBS: NavHubDef[] = [
admin: true,
module: 'message_automations',
},
{
to: '/settings/sms-campaigns',
title: 'پیامک گروهی',
desc: 'ارسال انبوه به مخاطبان انتخاب‌شده',
icon: 'send',
tint: 'mint',
admin: true,
module: 'sms_campaigns',
},
],
},
{
@@ -344,6 +359,15 @@ export const NAV_HUBS: NavHubDef[] = [
module: 'audit_logs',
admin: true,
},
{
to: '/settings/public-api',
title: 'API و وب‌هوک',
desc: 'کلید عمومی و ارسال رویداد به سرور شما',
icon: 'apps-plus',
tint: 'sky',
module: 'public_api',
admin: true,
},
{
to: '/settings/custom-fields',
title: 'فیلدهای سفارشی',
+12
View File
@@ -258,6 +258,18 @@ export const router = createRouter({
component: () => import('@/views/AuditLogsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/public-api',
name: 'public-api',
component: () => import('@/views/PublicApiSettingsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/sms-campaigns',
name: 'sms-campaigns',
component: () => import('@/views/SmsCampaignsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/custom-fields',
name: 'custom-fields',
@@ -0,0 +1,444 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
CreateOrganizationApiKeyResponse,
CreateWebhookEndpointResponse,
OrganizationApiKeyDto,
WebhookDeliveryDto,
WebhookEndpointDto,
WebhookEvent,
} from '@mento/shared';
import { WEBHOOK_EVENTS } from '@mento/shared';
import {
UiButton,
UiCard,
UiEmptyState,
UiField,
UiInput,
UiPageHeader,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const keys = ref<OrganizationApiKeyDto[]>([]);
const endpoints = ref<WebhookEndpointDto[]>([]);
const deliveries = ref<WebhookDeliveryDto[]>([]);
const newKeyName = ref('');
const createdRawKey = ref('');
const createdWebhookSecret = ref('');
const whName = ref('');
const whUrl = ref('');
const whEvents = ref<string[]>(['person.created', 'deal.created']);
const EVENT_LABELS: Record<WebhookEvent, string> = {
'person.created': 'ایجاد شخص',
'person.updated': 'ویرایش شخص',
'deal.created': 'ایجاد معامله',
'deal.updated': 'ویرایش معامله',
'deal.won': 'برد معامله',
'deal.lost': 'شکست معامله',
'invoice.paid': 'پرداخت فاکتور',
'activity.created': 'ایجاد فعالیت',
};
async function load() {
loading.value = true;
error.value = '';
try {
const [keysRes, epRes, delRes] = await Promise.all([
api.get<OrganizationApiKeyDto[]>('/api-keys'),
api.get<WebhookEndpointDto[]>('/webhooks'),
api.get<WebhookDeliveryDto[]>('/webhooks/deliveries'),
]);
keys.value = keysRes.data;
endpoints.value = epRes.data;
deliveries.value = delRes.data;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'ماژول API و وب‌هوک برای این محیط فعال نیست.';
} else {
error.value = 'بارگذاری تنظیمات ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function createKey() {
if (!newKeyName.value.trim()) {
error.value = 'نام کلید را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
ok.value = '';
createdRawKey.value = '';
try {
const { data } = await api.post<CreateOrganizationApiKeyResponse>(
'/api-keys',
{ name: newKeyName.value.trim() },
);
createdRawKey.value = data.rawKey;
newKeyName.value = '';
ok.value = 'کلید ساخته شد. همین الان کپی کنید؛ دوباره نمایش داده نمی‌شود.';
await load();
} catch {
error.value = 'ساخت کلید ناموفق بود.';
} finally {
saving.value = false;
}
}
async function revokeKey(id: string) {
if (!confirm('این کلید باطل شود؟')) return;
try {
await api.delete(`/api-keys/${id}`);
await load();
} catch {
error.value = 'باطل‌سازی کلید ناموفق بود.';
}
}
function toggleEvent(ev: string) {
if (whEvents.value.includes(ev)) {
whEvents.value = whEvents.value.filter((e) => e !== ev);
} else {
whEvents.value = [...whEvents.value, ev];
}
}
async function createWebhook() {
if (!whName.value.trim() || !whUrl.value.trim() || !whEvents.value.length) {
error.value = 'نام، آدرس و حداقل یک رویداد لازم است.';
return;
}
saving.value = true;
error.value = '';
createdWebhookSecret.value = '';
try {
const { data } = await api.post<CreateWebhookEndpointResponse>('/webhooks', {
name: whName.value.trim(),
url: whUrl.value.trim(),
events: whEvents.value,
});
whName.value = '';
whUrl.value = '';
createdWebhookSecret.value = data.secret;
ok.value = 'وب‌هوک ذخیره شد. secret را همین الان کپی کنید.';
await load();
} catch {
error.value = 'ذخیره وب‌هوک ناموفق بود.';
} finally {
saving.value = false;
}
}
async function removeWebhook(id: string) {
if (!confirm('وب‌هوک حذف شود؟')) return;
try {
await api.delete(`/webhooks/${id}`);
await load();
} catch {
error.value = 'حذف وب‌هوک ناموفق بود.';
}
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text);
ok.value = 'کپی شد.';
} catch {
error.value = 'کپی در کلیپ‌بورد ممکن نشد.';
}
}
onMounted(() => {
void load();
});
</script>
<template>
<div class="page">
<UiPageHeader
title="API و وب‌هوک"
description="کلید عمومی برای یکپارچه‌سازی و ارسال رویداد به سرور شما"
>
<template #actions>
<UiButton variant="secondary" :disabled="loading" @click="load">
<UiIcon name="refresh" />
بروزرسانی
</UiButton>
</template>
</UiPageHeader>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<UiCard title="کلیدهای API">
<p class="hint">
هدر درخواست:
<code>X-Api-Key: mk_…</code>
یا
<code>Authorization: Bearer mk_…</code>
— مسیرها:
<code>/api/public/v1/people</code>
و
<code>/api/public/v1/deals</code>
</p>
<div class="row">
<UiField label="نام کلید">
<UiInput v-model="newKeyName" placeholder="مثلاً اتصال سایت" />
</UiField>
<UiButton :loading="saving" @click="createKey">ساخت کلید</UiButton>
</div>
<div v-if="createdRawKey" class="raw-key">
<code>{{ createdRawKey }}</code>
<UiButton variant="secondary" @click="copyText(createdRawKey)">کپی</UiButton>
</div>
<UiEmptyState
v-if="!loading && !keys.length"
title="کلیدی نیست"
description="برای دسترسی خارجی یک کلید بسازید."
/>
<div v-else class="table-wrap">
<table>
<thead>
<tr>
<th>نام</th>
<th>پیشوند</th>
<th>آخرین استفاده</th>
<th>وضعیت</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="k in keys" :key="k.id">
<td>{{ k.name }}</td>
<td><code>{{ k.prefix }}…</code></td>
<td>
{{
k.lastUsedAt
? formatJalaliNumeric(new Date(k.lastUsedAt))
: '—'
}}
</td>
<td>{{ k.revokedAt ? 'باطل' : 'فعال' }}</td>
<td>
<UiButton
v-if="!k.revokedAt"
variant="secondary"
@click="revokeKey(k.id)"
>
باطل‌سازی
</UiButton>
</td>
</tr>
</tbody>
</table>
</div>
</UiCard>
<UiCard title="وب‌هوک‌های خروجی">
<div class="form-grid">
<UiField label="نام">
<UiInput v-model="whName" placeholder="مثلاً n8n" />
</UiField>
<UiField label="آدرس URL">
<UiInput v-model="whUrl" placeholder="https://example.com/hook" />
</UiField>
</div>
<div class="events">
<button
v-for="ev in WEBHOOK_EVENTS"
:key="ev"
type="button"
class="chip"
:class="{ on: whEvents.includes(ev) }"
@click="toggleEvent(ev)"
>
{{ EVENT_LABELS[ev] }}
</button>
</div>
<UiButton :loading="saving" @click="createWebhook">افزودن وب‌هوک</UiButton>
<div v-if="createdWebhookSecret" class="raw-key">
<span>HMAC secret:</span>
<code>{{ createdWebhookSecret }}</code>
<UiButton variant="secondary" @click="copyText(createdWebhookSecret)">کپی</UiButton>
</div>
<UiEmptyState
v-if="!loading && !endpoints.length"
title="وب‌هوکی نیست"
description="رویدادهای CRM به آدرس شما POST می‌شوند."
/>
<div v-else class="list">
<article v-for="ep in endpoints" :key="ep.id" class="item">
<div>
<strong>{{ ep.name }}</strong>
<p class="muted">{{ ep.url }}</p>
<p class="muted">
{{ ep.events.map((e) => EVENT_LABELS[e as WebhookEvent] ?? e).join('، ') }}
</p>
</div>
<UiButton variant="secondary" @click="removeWebhook(ep.id)">حذف</UiButton>
</article>
</div>
</UiCard>
<UiCard title="آخرین ارسال‌های وب‌هوک">
<UiEmptyState
v-if="!deliveries.length"
title="ارسالی ثبت نشده"
description="پس از وقوع رویدادها اینجا دیده می‌شوند."
/>
<div v-else class="table-wrap">
<table>
<thead>
<tr>
<th>زمان</th>
<th>رویداد</th>
<th>وضعیت</th>
<th>کد</th>
<th>تلاش</th>
</tr>
</thead>
<tbody>
<tr v-for="d in deliveries" :key="d.id">
<td>{{ formatJalaliNumeric(new Date(d.createdAt)) }}</td>
<td>{{ EVENT_LABELS[d.event as WebhookEvent] ?? d.event }}</td>
<td>{{ d.status }}</td>
<td>{{ d.statusCode ?? '—' }}</td>
<td>{{ toFaDigits(d.attempts) }}</td>
</tr>
</tbody>
</table>
</div>
</UiCard>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
}
.hint {
color: #64748b;
font-size: 0.88rem;
line-height: 1.7;
}
.hint code {
background: #f1f5f9;
padding: 0.1rem 0.35rem;
border-radius: 6px;
font-size: 0.8rem;
}
.row {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.75rem;
align-items: end;
margin: 0.75rem 0;
}
.raw-key {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
padding: 0.75rem;
background: #f0fdfa;
border-radius: var(--radius);
margin-bottom: 0.75rem;
}
.raw-key code {
word-break: break-all;
font-weight: 700;
color: #0f766e;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1.4fr;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.events {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-bottom: 0.85rem;
}
.chip {
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 999px;
padding: 0.35rem 0.75rem;
font: inherit;
cursor: pointer;
color: #334155;
}
.chip.on {
background: #f0fdfa;
border-color: #99f6e4;
color: #0f766e;
font-weight: 700;
}
.list {
display: grid;
gap: 0.65rem;
margin-top: 1rem;
}
.item {
display: flex;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid #eef2f7;
border-radius: var(--radius);
}
.muted {
color: #64748b;
margin: 0.2rem 0 0;
font-size: 0.85rem;
}
.error {
color: #b91c1c;
font-weight: 600;
}
.ok {
color: #0f766e;
font-weight: 600;
}
.table-wrap {
overflow: auto;
margin-top: 0.75rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
text-align: right;
padding: 0.55rem;
border-bottom: 1px solid #eef2f7;
}
th {
color: #64748b;
font-size: 0.8rem;
}
@media (max-width: 800px) {
.row,
.form-grid {
grid-template-columns: 1fr;
}
}
</style>
+350
View File
@@ -0,0 +1,350 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
PaginatedResponse,
PersonDto,
SmsCampaignDto,
SmsCampaignListResponse,
} from '@mento/shared';
import {
UiButton,
UiCard,
UiEmptyState,
UiField,
UiInput,
UiPageHeader,
UiTextarea,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const campaigns = ref<SmsCampaignDto[]>([]);
const people = ref<PersonDto[]>([]);
const selectedIds = ref<string[]>([]);
const name = ref('');
const body = ref('');
const queueNow = ref(true);
const peopleQ = ref('');
const filteredPeople = computed(() => {
const q = peopleQ.value.trim();
if (!q) return people.value.slice(0, 80);
return people.value
.filter((p) => {
const hay = `${p.fullName} ${p.mobile ?? ''} ${p.email ?? ''}`.toLowerCase();
return hay.includes(q.toLowerCase());
})
.slice(0, 80);
});
const statusLabel: Record<string, string> = {
draft: 'پیش‌نویس',
queued: 'در صف',
sending: 'در حال ارسال',
done: 'تمام‌شده',
cancelled: 'لغو شده',
};
async function load() {
loading.value = true;
error.value = '';
try {
const [campRes, peopleRes] = await Promise.all([
api.get<SmsCampaignListResponse>('/sms-campaigns'),
api.get<PaginatedResponse<PersonDto>>('/people', {
params: { page: 1, limit: 200 },
}),
]);
campaigns.value = campRes.data.items;
people.value = peopleRes.data.items;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'ماژول پیامک گروهی برای این محیط فعال نیست.';
} else {
error.value = 'بارگذاری ناموفق بود.';
}
} finally {
loading.value = false;
}
}
function togglePerson(id: string) {
if (selectedIds.value.includes(id)) {
selectedIds.value = selectedIds.value.filter((x) => x !== id);
} else {
selectedIds.value = [...selectedIds.value, id];
}
}
function selectVisible() {
const ids = filteredPeople.value.map((p) => p.id);
selectedIds.value = [...new Set([...selectedIds.value, ...ids])];
}
async function createCampaign() {
if (!name.value.trim() || body.value.trim().length < 2) {
error.value = 'نام و متن پیامک را کامل کنید.';
return;
}
if (!selectedIds.value.length) {
error.value = 'حداقل یک مخاطب انتخاب کنید.';
return;
}
saving.value = true;
error.value = '';
ok.value = '';
try {
await api.post('/sms-campaigns', {
name: name.value.trim(),
body: body.value.trim(),
personIds: selectedIds.value,
queueNow: queueNow.value,
});
name.value = '';
body.value = '';
selectedIds.value = [];
ok.value = queueNow.value
? 'کمپین در صف ارسال قرار گرفت.'
: 'کمپین به‌صورت پیش‌نویس ذخیره شد.';
await load();
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ساخت کمپین ناموفق بود.');
} else {
error.value = 'ساخت کمپین ناموفق بود.';
}
} finally {
saving.value = false;
}
}
async function queue(id: string) {
try {
await api.post(`/sms-campaigns/${id}/queue`);
await load();
} catch {
error.value = 'صف‌بندی ناموفق بود.';
}
}
async function cancel(id: string) {
try {
await api.post(`/sms-campaigns/${id}/cancel`);
await load();
} catch {
error.value = 'لغو ناموفق بود.';
}
}
onMounted(() => {
void load();
});
</script>
<template>
<div class="page">
<UiPageHeader
title="پیامک گروهی"
description="ارسال انبوه پیامک به مخاطبان انتخاب‌شده"
>
<template #actions>
<UiButton variant="secondary" :disabled="loading" @click="load">
<UiIcon name="refresh" />
بروزرسانی
</UiButton>
</template>
</UiPageHeader>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<UiCard title="کمپین جدید">
<div class="form-grid">
<UiField label="نام کمپین">
<UiInput v-model="name" placeholder="مثلاً اطلاع‌رسانی جشنواره" />
</UiField>
<label class="check">
<input v-model="queueNow" type="checkbox" />
ارسال فوری پس از ذخیره
</label>
</div>
<UiField label="متن پیامک">
<UiTextarea
v-model="body"
:rows="4"
placeholder="متن پیامک را بنویسید…"
/>
</UiField>
<div class="people-head">
<UiField label="جستجوی مخاطب">
<UiInput v-model="peopleQ" placeholder="نام یا موبایل…" />
</UiField>
<UiButton variant="secondary" @click="selectVisible">
انتخاب نتایج
</UiButton>
</div>
<p class="muted">
انتخاب‌شده: {{ toFaDigits(selectedIds.length) }} نفر
</p>
<div class="people-list">
<label v-for="p in filteredPeople" :key="p.id" class="person">
<input
type="checkbox"
:checked="selectedIds.includes(p.id)"
@change="togglePerson(p.id)"
/>
<span>
<strong>{{ p.fullName }}</strong>
<small>{{ p.mobile || 'بدون موبایل' }}</small>
</span>
</label>
</div>
<UiButton :loading="saving" @click="createCampaign">ساخت کمپین</UiButton>
</UiCard>
<UiCard title="کمپین‌ها">
<UiEmptyState
v-if="!loading && !campaigns.length"
title="کمپینی نیست"
description="اولین کمپین پیامک گروهی را بسازید."
/>
<div v-else class="campaigns">
<article v-for="c in campaigns" :key="c.id" class="campaign">
<div>
<strong>{{ c.name }}</strong>
<p class="muted">{{ c.body }}</p>
<p class="meta">
{{ statusLabel[c.status] ?? c.status }}
· کل {{ toFaDigits(c.totalCount) }}
· ارسال {{ toFaDigits(c.sentCount) }}
· ناموفق {{ toFaDigits(c.failedCount) }}
· ردشده {{ toFaDigits(c.skippedCount) }}
· {{ formatJalaliNumeric(new Date(c.createdAt)) }}
</p>
</div>
<div class="actions">
<UiButton
v-if="c.status === 'draft' || c.status === 'cancelled'"
variant="secondary"
@click="queue(c.id)"
>
صف ارسال
</UiButton>
<UiButton
v-if="c.status === 'queued' || c.status === 'sending' || c.status === 'draft'"
variant="secondary"
@click="cancel(c.id)"
>
لغو
</UiButton>
</div>
</article>
</div>
</UiCard>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
}
.form-grid {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.75rem;
align-items: end;
margin-bottom: 0.75rem;
}
.check {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-weight: 600;
color: #334155;
min-height: var(--control-h);
}
.people-head {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.75rem;
align-items: end;
margin-top: 0.75rem;
}
.people-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.45rem;
max-height: 280px;
overflow: auto;
margin: 0.75rem 0 1rem;
padding: 0.35rem;
border: 1px solid #eef2f7;
border-radius: var(--radius);
}
.person {
display: flex;
gap: 0.45rem;
align-items: flex-start;
padding: 0.45rem;
border-radius: var(--radius);
cursor: pointer;
}
.person:hover {
background: #f8fafc;
}
.person span {
display: grid;
}
.person small {
color: #64748b;
}
.campaigns {
display: grid;
gap: 0.75rem;
}
.campaign {
display: flex;
justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem;
border: 1px solid #eef2f7;
border-radius: var(--radius);
}
.actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
align-items: start;
}
.muted,
.meta {
color: #64748b;
margin: 0.25rem 0 0;
font-size: 0.85rem;
}
.error {
color: #b91c1c;
font-weight: 600;
}
.ok {
color: #0f766e;
font-weight: 600;
}
@media (max-width: 800px) {
.form-grid,
.people-head,
.campaign {
grid-template-columns: 1fr;
flex-direction: column;
}
}
</style>