diff --git a/apps/api/package.json b/apps/api/package.json index 3bbba1f..63571e5 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,7 +12,7 @@ "prisma:seed": "tsx prisma/seed.ts", "lint": "echo \"api lint placeholder\"", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json", - "test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts" + "test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts" }, "dependencies": { "@mento/shared": "*", diff --git a/apps/api/src/contacts/birthday-month.ts b/apps/api/src/contacts/birthday-month.ts new file mode 100644 index 0000000..d243e0e --- /dev/null +++ b/apps/api/src/contacts/birthday-month.ts @@ -0,0 +1,70 @@ +import { jalaliMonthName, toJalali } from '../common/jalali'; + +export const BIRTHDAY_SMS_DEFAULT = '{نام} عزیز، تولدتان مبارک.'; + +export type BirthdayRow = { + id: string; + firstName: string; + lastName: string; + mobile: string | null; + birthDate: Date; +}; + +export type BirthdayPick = { + id: string; + firstName: string; + lastName: string; + mobile: string | null; + day: number; +}; + +export function fillBirthdaySmsText(template: string, firstName: string, lastName: string) { + const name = `${firstName} ${lastName}`.trim().replace(/\s+/g, ' '); + return template.replaceAll('{نام}', name).replaceAll('{name}', name).trim(); +} + +function tehranJalali(d: Date) { + const [gy, gm, gd] = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Tehran', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }) + .format(d) + .split('-') + .map(Number); + return toJalali(gy, gm, gd); +} + +function birthJalali(d: Date) { + return toJalali(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate()); +} + +/** متولدین ماه جاری تهران. پیش‌نمایش فقط روزهای امروز و بعد است. */ +export function selectMonthBirthdays(people: BirthdayRow[], now = new Date(), previewLimit = 6) { + const today = tehranJalali(now); + const matched = people.flatMap((person) => { + const birth = birthJalali(person.birthDate); + if (birth.jm !== today.jm) return []; + return [ + { + id: person.id, + firstName: person.firstName, + lastName: person.lastName, + mobile: person.mobile, + day: birth.jd, + }, + ]; + }); + const byDay = (a: BirthdayPick, b: BirthdayPick) => a.day - b.day || a.firstName.localeCompare(b.firstName, 'fa'); + const upcoming = matched.filter((row) => row.day >= today.jd).sort(byDay); + const passed = matched.filter((row) => row.day < today.jd).sort(byDay); + return { + month: today.jm, + monthName: jalaliMonthName(today.jm), + today: today.jd, + total: matched.length, + preview: upcoming.slice(0, previewLimit), + items: [...upcoming, ...passed], + }; +} diff --git a/apps/api/src/contacts/contacts.module.ts b/apps/api/src/contacts/contacts.module.ts index 3a347a4..0639ec2 100644 --- a/apps/api/src/contacts/contacts.module.ts +++ b/apps/api/src/contacts/contacts.module.ts @@ -8,6 +8,7 @@ import { CompaniesService } from './companies.service'; import { PeopleController } from './people.controller'; import { PeopleService } from './people.service'; import { ContactsExcelService } from './contacts-excel.service'; +import { SmsService } from '../common/sms.service'; @Module({ imports: [ @@ -17,7 +18,7 @@ import { ContactsExcelService } from './contacts-excel.service'; PublicApiModule, ], controllers: [PeopleController, CompaniesController], - providers: [PeopleService, CompaniesService, ContactsExcelService], + providers: [PeopleService, CompaniesService, ContactsExcelService, SmsService], exports: [PeopleService, CompaniesService], }) export class ContactsModule {} diff --git a/apps/api/src/contacts/dto.ts b/apps/api/src/contacts/dto.ts index e832722..bab8c67 100644 --- a/apps/api/src/contacts/dto.ts +++ b/apps/api/src/contacts/dto.ts @@ -17,6 +17,13 @@ import { } from 'class-validator'; import { PERSON_IMPORT_SOURCES, PERSON_MOODS } from '@mento/shared'; +export class BirthdaySmsDto { + @IsString() + @MinLength(1) + @MaxLength(500) + text!: string; +} + export class BulkDeletePeopleDto { @IsArray() @ArrayMinSize(1) diff --git a/apps/api/src/contacts/people.controller.ts b/apps/api/src/contacts/people.controller.ts index 8827cc2..3391297 100644 --- a/apps/api/src/contacts/people.controller.ts +++ b/apps/api/src/contacts/people.controller.ts @@ -22,7 +22,7 @@ import { CurrentUser } from '../auth/current-user.decorator'; import type { Actor } from '../common/record-scope'; import { OrgModuleGuard } from '../billing/org-module.guard'; import { RequireOrgModule } from '../billing/org-module.decorator'; -import { BulkDeletePeopleDto, CreatePersonDto, ListQueryDto, UpdatePersonDto } from './dto'; +import { BirthdaySmsDto, BulkDeletePeopleDto, CreatePersonDto, ListQueryDto, UpdatePersonDto } from './dto'; import { PeopleService } from './people.service'; import { ContactsExcelService } from './contacts-excel.service'; import { brand } from '@mento/shared'; @@ -105,6 +105,20 @@ export class PeopleController { }); } + @Get('birthdays') + birthdays(@CurrentUser() user: Actor) { + return this.peopleService.birthdaysThisMonth(user); + } + + @Post(':id/birthday-sms') + birthdaySms( + @CurrentUser() user: Actor, + @Param('id') id: string, + @Body() dto: BirthdaySmsDto, + ) { + return this.peopleService.sendBirthdaySms(user, id, dto.text); + } + @Get(':id') get(@CurrentUser() user: Actor, @Param('id') id: string) { return this.peopleService.get(user, id); diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index 141305d..b5b3b10 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -1,8 +1,11 @@ import { + BadRequestException, ConflictException, + ForbiddenException, Injectable, Logger, NotFoundException, + ServiceUnavailableException, type OnModuleInit, } from '@nestjs/common'; import type { @@ -35,6 +38,8 @@ import { MessageAutomationsService } from '../message-automations/message-automa import { WebhooksService } from '../public-api/webhooks.service'; import { GamificationService } from '../gamification/gamification.service'; import { parseBirthDate } from '../common/excel'; +import { SmsService } from '../common/sms.service'; +import { fillBirthdaySmsText, selectMonthBirthdays } from './birthday-month'; import { normalizeTags } from '../common/tags'; import { buildSearchWhere, ci } from '../common/search'; import { @@ -97,6 +102,7 @@ export class PeopleService implements OnModuleInit { private readonly messageAutomations: MessageAutomationsService, private readonly webhooks: WebhooksService, private readonly game: GamificationService, + private readonly sms: SmsService, ) {} async list( @@ -447,6 +453,107 @@ export class PeopleService implements OnModuleInit { throw new ConflictException('شخص تکراری است'); } + async birthdaysThisMonth(actor: Actor) { + const rows = await this.prisma.person.findMany({ + where: { + organizationId: actor.organizationId, + ownerId: actor.userId, + birthDate: { not: null }, + }, + select: { + id: true, + firstName: true, + lastName: true, + mobile: true, + phone: true, + birthDate: true, + }, + }); + const selected = selectMonthBirthdays( + rows.flatMap((row) => + row.birthDate + ? [ + { + id: row.id, + firstName: row.firstName, + lastName: row.lastName, + mobile: row.mobile || row.phone, + birthDate: row.birthDate, + }, + ] + : [], + ), + ); + const toItem = (row: (typeof selected.items)[number]) => ({ + id: row.id, + firstName: row.firstName, + lastName: row.lastName, + fullName: `${row.firstName} ${row.lastName}`.trim(), + mobile: row.mobile, + day: row.day, + }); + return { + month: selected.month, + monthName: selected.monthName, + today: selected.today, + total: selected.total, + preview: selected.preview.map(toItem), + items: selected.items.map(toItem), + }; + } + + async sendBirthdaySms(actor: Actor, personId: string, text: string) { + const body = text.trim(); + if (!body || body.length > 500) { + throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.'); + } + const person = await this.prisma.person.findFirst({ + where: { id: personId, organizationId: actor.organizationId }, + select: { + id: true, + firstName: true, + lastName: true, + mobile: true, + phone: true, + ownerId: true, + birthDate: true, + }, + }); + if (!person) throw new NotFoundException('مشتری پیدا نشد'); + if (person.ownerId !== actor.userId) { + throw new ForbiddenException('فقط مسئول این مشتری می‌تواند پیامک تبریک بفرستد.'); + } + if (!person.birthDate) { + throw new BadRequestException('برای این مشتری تاریخ تولد ثبت نشده است.'); + } + const inMonth = selectMonthBirthdays( + [ + { + id: person.id, + firstName: person.firstName, + lastName: person.lastName, + mobile: person.mobile || person.phone, + birthDate: person.birthDate, + }, + ], + ); + if (!inMonth.total) { + throw new BadRequestException('تولد این مشتری در ماه جاری نیست.'); + } + const mobile = person.mobile || person.phone; + if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.'); + const message = fillBirthdaySmsText(body, person.firstName, person.lastName); + const result = await this.sms.sendSmsDetailed(mobile, message); + if (!result.ok) { + throw new ServiceUnavailableException( + result.reason === 'SMS not configured' + ? 'ارسال پیامک روی سرور فعال نیست.' + : 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.', + ); + } + return { ok: true }; + } + private toDto(row: PersonRow): PersonDto { const previousFullName = `${row.previousFirstName ?? ''} ${row.previousLastName ?? ''}`.trim(); const importSource = row.importSource || 'manual'; diff --git a/apps/api/test/birthdays.test.ts b/apps/api/test/birthdays.test.ts new file mode 100644 index 0000000..cb586dc --- /dev/null +++ b/apps/api/test/birthdays.test.ts @@ -0,0 +1,52 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { toGregorian } from '../src/common/jalali'; +import { + BIRTHDAY_SMS_DEFAULT, + fillBirthdaySmsText, + selectMonthBirthdays, +} from '../src/contacts/birthday-month'; + +function birth(jy: number, jm: number, jd: number) { + const g = toGregorian(jy, jm, jd); + return new Date(Date.UTC(g.gy, g.gm - 1, g.gd, 12, 0, 0)); +} + +const now = new Date('2026-10-08T08:00:00Z'); + +describe('تولدهای ماه شمسی', () => { + it('فقط متولدین همین ماه را می‌آورد و روزهای مانده را جلوتر می‌گذارد', () => { + const result = selectMonthBirthdays( + [ + { id: 'soon', firstName: 'آیدا', lastName: 'کریمی', mobile: '09120000000', birthDate: birth(1370, 7, 20) }, + { id: 'past', firstName: 'رضا', lastName: 'بهرامی', mobile: null, birthDate: birth(1360, 7, 2) }, + { id: 'other', firstName: 'پریسا', lastName: 'قاسمی', mobile: '09121111111', birthDate: birth(1369, 6, 30) }, + { id: 'today', firstName: 'سارا', lastName: 'محمدی', mobile: '09123333333', birthDate: birth(1375, 7, 16) }, + ], + now, + 2, + ); + + assert.equal(result.month, 7); + assert.equal(result.monthName, 'مهر'); + assert.equal(result.today, 16); + assert.equal(result.total, 3); + assert.deepEqual( + result.preview.map((row) => row.id), + ['today', 'soon'], + ); + assert.deepEqual( + result.items.map((row) => row.id), + ['today', 'soon', 'past'], + ); + assert.equal(result.items.find((row) => row.id === 'past')?.mobile, null); + }); + + it('نام را در متن پیامک می‌گذارد', () => { + assert.equal( + fillBirthdaySmsText(BIRTHDAY_SMS_DEFAULT, 'آیدا', 'کریمی'), + 'آیدا کریمی عزیز، تولدتان مبارک.', + ); + assert.equal(fillBirthdaySmsText(' سلام {نام} ', 'آیدا', ''), 'سلام آیدا'); + }); +}); diff --git a/apps/web/src/components/dashboard/HomeBirthdayStrip.vue b/apps/web/src/components/dashboard/HomeBirthdayStrip.vue new file mode 100644 index 0000000..939f492 --- /dev/null +++ b/apps/web/src/components/dashboard/HomeBirthdayStrip.vue @@ -0,0 +1,300 @@ + + + + + diff --git a/apps/web/src/views/HomeView.vue b/apps/web/src/views/HomeView.vue index 0ddb6a3..d80f8a6 100644 --- a/apps/web/src/views/HomeView.vue +++ b/apps/web/src/views/HomeView.vue @@ -6,6 +6,7 @@ import { formatMoney } from '@/lib/money'; import { formatJalaliDateTime, rewriteGregorianDates, toFaDigits } from '@/lib/jalali'; import HomeHero from '@/components/dashboard/HomeHero.vue'; import HomeKpiRow from '@/components/dashboard/HomeKpiRow.vue'; +import HomeBirthdayStrip from '@/components/dashboard/HomeBirthdayStrip.vue'; import HomeSalesTrend from '@/components/dashboard/HomeSalesTrend.vue'; import HomeSourcesChart from '@/components/dashboard/HomeSourcesChart.vue'; import HomeAiAssistant from '@/components/dashboard/HomeAiAssistant.vue'; @@ -190,6 +191,7 @@ onUnmounted(() => { :format-value="formatKpiValue" :format-change="formatChange" /> +