diff --git a/apps/api/prisma/migrations/20260913200000_message_delay_weeks_months/migration.sql b/apps/api/prisma/migrations/20260913200000_message_delay_weeks_months/migration.sql new file mode 100644 index 0000000..313a851 --- /dev/null +++ b/apps/api/prisma/migrations/20260913200000_message_delay_weeks_months/migration.sql @@ -0,0 +1,2 @@ +ALTER TYPE "MessageAutomationDelayUnit" ADD VALUE IF NOT EXISTS 'weeks'; +ALTER TYPE "MessageAutomationDelayUnit" ADD VALUE IF NOT EXISTS 'months'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f963c31..49f18c4 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -1104,6 +1104,8 @@ enum MessageAutomationDelayUnit { minutes hours days + weeks + months } enum MessageAutomationChannel { diff --git a/apps/api/src/message-automations/__tests__/message-automation.check.ts b/apps/api/src/message-automations/__tests__/message-automation.check.ts index dff4156..abefba6 100644 --- a/apps/api/src/message-automations/__tests__/message-automation.check.ts +++ b/apps/api/src/message-automations/__tests__/message-automation.check.ts @@ -14,6 +14,7 @@ function testDelay() { assert(delayMs(0, 'minutes') === 0, 'zero delay'); assert(delayMs(2, 'hours') === 2 * 3_600_000, '2 hours'); assert(delayMs(2, 'days') === 2 * 86_400_000, '2 days'); + assert(delayMs(1, 'weeks') === 7 * 86_400_000, '1 week'); } function testSendAts() { diff --git a/apps/api/src/message-automations/dto.ts b/apps/api/src/message-automations/dto.ts index 3342581..4ab96a7 100644 --- a/apps/api/src/message-automations/dto.ts +++ b/apps/api/src/message-automations/dto.ts @@ -18,7 +18,7 @@ import { type MessageAutomationTrigger, } from '@mento/shared'; -const UNITS = ['minutes', 'hours', 'days'] as const; +const UNITS = ['minutes', 'hours', 'days', 'weeks', 'months'] as const; const CHANNELS = ['app', 'sms', 'both'] as const; const TARGETS = ['staff', 'person', 'both'] as const; diff --git a/apps/api/src/message-automations/message-automation.util.ts b/apps/api/src/message-automations/message-automation.util.ts index 2362fa9..094d324 100644 --- a/apps/api/src/message-automations/message-automation.util.ts +++ b/apps/api/src/message-automations/message-automation.util.ts @@ -1,4 +1,4 @@ -export type MessageDelayUnit = 'minutes' | 'hours' | 'days'; +export type MessageDelayUnit = 'minutes' | 'hours' | 'days' | 'weeks' | 'months'; export const MESSAGE_AUTOMATION_TRIGGERS = [ 'person_created', @@ -22,7 +22,23 @@ export function delayMs(value: number, unit: MessageDelayUnit): number { const n = Math.max(0, Math.trunc(Number(value) || 0)); if (unit === 'minutes') return n * 60_000; if (unit === 'hours') return n * 3_600_000; - return n * 86_400_000; + if (unit === 'days') return n * 86_400_000; + if (unit === 'weeks') return n * 7 * 86_400_000; + return n * 30 * 86_400_000; +} + +export function addDelay( + from: Date, + value: number, + unit: MessageDelayUnit, +): Date { + const n = Math.max(0, Math.trunc(Number(value) || 0)); + if (unit === 'months') { + const next = new Date(from.getTime()); + next.setMonth(next.getMonth() + n); + return next; + } + return new Date(from.getTime() + delayMs(n, unit)); } export function buildSendAts( @@ -37,9 +53,14 @@ export function buildSendAts( MAX_MESSAGE_REPEAT, Math.max(1, Math.trunc(Number(repeatCount) || 1)), ); - const first = new Date(now.getTime() + delayMs(startValue, startUnit)); - const gap = delayMs(Math.max(1, intervalValue), intervalUnit); - return Array.from({ length: count }, (_, i) => new Date(first.getTime() + i * gap)); + const first = addDelay(now, startValue, startUnit); + const times = [first]; + let cursor = first; + for (let i = 1; i < count; i += 1) { + cursor = addDelay(cursor, Math.max(1, intervalValue), intervalUnit); + times.push(cursor); + } + return times; } export function applyMessageTemplate(text: string, title: string): string { diff --git a/apps/web/src/components/MessageIntervalPicker.vue b/apps/web/src/components/MessageIntervalPicker.vue new file mode 100644 index 0000000..aa3a6e8 --- /dev/null +++ b/apps/web/src/components/MessageIntervalPicker.vue @@ -0,0 +1,342 @@ + + + + + + {{ display }} + + + + + + + + + + {{ interval ? 'فاصله بین پیامها' : 'شروع ارسال' }} + + {{ + interval + ? 'پیام بعدی بعد از چند وقت برود؟' + : 'اولین پیام کی بعد از رویداد برود؟' + }} + + + + + + + + + همان لحظه + بلافاصله بعد از رویداد + + + + + {{ group.title }} + {{ interval ? group.hintInterval : group.hintStart }} + + + + {{ toFaDigits(n) }} + + + + + + + + + + diff --git a/apps/web/src/views/MessageAutomationsView.vue b/apps/web/src/views/MessageAutomationsView.vue index 5f4263f..68effe4 100644 --- a/apps/web/src/views/MessageAutomationsView.vue +++ b/apps/web/src/views/MessageAutomationsView.vue @@ -2,8 +2,8 @@ import { computed, onMounted, ref } from 'vue'; import axios from 'axios'; import { api } from '@/lib/api'; -import JalaliDateInput from '@/components/JalaliDateInput.vue'; -import { formatLocalDateTimeInput, parseLocalInput } from '@/lib/jalali'; +import MessageIntervalPicker from '@/components/MessageIntervalPicker.vue'; +import { toFaDigits } from '@/lib/jalali'; import type { CreateMessageAutomationRuleRequest, MessageAutomationChannel, @@ -30,9 +30,11 @@ const editingId = ref(null); const name = ref(''); const trigger = ref('invoice_issued'); -const startAt = ref(''); +const startDelayValue = ref(0); +const startDelayUnit = ref('minutes'); const repeatCount = ref(1); -const intervalAt = ref(''); +const intervalValue = ref(2); +const intervalUnit = ref('days'); const notifyTarget = ref('person'); const channel = ref('sms'); const title = ref(''); @@ -44,38 +46,15 @@ const personNote = computed( 'ناتیف داخل برنامه برای کارکنان است؛ برای مشتری پیامک ارسال میشود.', ); -function delayMs(value: number, unit: MessageAutomationDelayUnit) { - const n = Math.max(0, Math.trunc(Number(value) || 0)); - if (unit === 'minutes') return n * 60_000; - if (unit === 'hours') return n * 3_600_000; - return n * 86_400_000; -} - -function delayToIso(value: number, unit: MessageAutomationDelayUnit) { - if (!value) return ''; - return formatLocalDateTimeInput(new Date(Date.now() + delayMs(value, unit))); -} - -function isoToDelay(iso: string): { - value: number; - unit: MessageAutomationDelayUnit; -} { - const date = parseLocalInput(iso); - if (!date) return { value: 0, unit: 'minutes' }; - const mins = Math.max(0, Math.round((date.getTime() - Date.now()) / 60_000)); - if (mins === 0) return { value: 0, unit: 'minutes' }; - if (mins % (24 * 60) === 0) return { value: mins / (24 * 60), unit: 'days' }; - if (mins % 60 === 0) return { value: mins / 60, unit: 'hours' }; - return { value: mins, unit: 'minutes' }; -} - function resetForm() { editingId.value = null; name.value = ''; trigger.value = 'invoice_issued'; - startAt.value = ''; + startDelayValue.value = 0; + startDelayUnit.value = 'minutes'; repeatCount.value = 1; - intervalAt.value = delayToIso(2, 'days'); + intervalValue.value = 2; + intervalUnit.value = 'days'; notifyTarget.value = 'person'; channel.value = 'sms'; title.value = ''; @@ -87,9 +66,11 @@ function startEdit(row: MessageAutomationRuleDto) { editingId.value = row.id; name.value = row.name; trigger.value = row.trigger; - startAt.value = delayToIso(row.startDelayValue, row.startDelayUnit); + startDelayValue.value = row.startDelayValue; + startDelayUnit.value = row.startDelayUnit; repeatCount.value = row.repeatCount; - intervalAt.value = delayToIso(row.intervalValue, row.intervalUnit); + intervalValue.value = row.intervalValue; + intervalUnit.value = row.intervalUnit; notifyTarget.value = row.notifyTarget; channel.value = row.channel; title.value = row.title; @@ -101,9 +82,9 @@ function scheduleLabel(row: MessageAutomationRuleDto) { const start = row.startDelayValue === 0 ? 'همان لحظه' - : `${row.startDelayValue} ${MESSAGE_AUTOMATION_UNIT_LABELS[row.startDelayUnit]} بعد`; + : `${toFaDigits(row.startDelayValue)} ${MESSAGE_AUTOMATION_UNIT_LABELS[row.startDelayUnit]} بعد`; if (row.repeatCount <= 1) return start; - return `${start} · ${row.repeatCount} بار، هر ${row.intervalValue} ${MESSAGE_AUTOMATION_UNIT_LABELS[row.intervalUnit]}`; + return `${start} · ${toFaDigits(row.repeatCount)} بار، هر ${toFaDigits(row.intervalValue)} ${MESSAGE_AUTOMATION_UNIT_LABELS[row.intervalUnit]}`; } async function load() { @@ -122,17 +103,15 @@ async function load() { } function payload(): CreateMessageAutomationRuleRequest { - const start = isoToDelay(startAt.value); - const interval = isoToDelay(intervalAt.value); return { name: name.value.trim(), isActive: true, trigger: trigger.value, - startDelayValue: start.value, - startDelayUnit: start.unit, + startDelayValue: startDelayValue.value, + startDelayUnit: startDelayUnit.value, repeatCount: repeatCount.value, - intervalValue: Math.max(1, interval.value || 1), - intervalUnit: interval.value ? interval.unit : 'days', + intervalValue: Math.max(1, intervalValue.value || 1), + intervalUnit: intervalUnit.value, notifyTarget: notifyTarget.value, channel: channel.value, title: title.value.trim(), @@ -230,10 +209,10 @@ onMounted(load); شروع ارسال - @@ -242,10 +221,10 @@ onMounted(load); فاصله بین پیامها - diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index aafac5d..091c6cd 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2261,7 +2261,12 @@ export type MessageAutomationTrigger = | "activity_created" | "activity_done"; -export type MessageAutomationDelayUnit = "minutes" | "hours" | "days"; +export type MessageAutomationDelayUnit = + | "minutes" + | "hours" + | "days" + | "weeks" + | "months"; export type MessageAutomationChannel = "app" | "sms" | "both"; export type MessageAutomationTarget = "staff" | "person" | "both"; @@ -2305,6 +2310,8 @@ export const MESSAGE_AUTOMATION_UNIT_LABELS: Record< minutes: "دقیقه", hours: "ساعت", days: "روز", + weeks: "هفته", + months: "ماه", }; export const MESSAGE_AUTOMATION_CHANNEL_LABELS: Record<
+ {{ + interval + ? 'پیام بعدی بعد از چند وقت برود؟' + : 'اولین پیام کی بعد از رویداد برود؟' + }} +