Author SHA1 Message Date
MahdiandCursor d62e004e90 feat: SMS campaigns for people or leads with filters and batching
Add audience tabs, province/city filters, select-all ids, dynamic SMS tokens, and 50-sized create/send chunks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 20:54:32 +03:30
MahdiandCursor 32c722f42a docs: spec SMS campaign people/leads tabs and batch send
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 20:47:48 +03:30
MahdiandCursor f7b07f6b7a feat: owner read-only view-as from team users table
Allow org owners to inspect any member's data scope via dual JWT; server blocks mutations and team page stays reachable for return.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 19:57:26 +03:30
MahdiandCursor 52c7306e78 fix(web): shorten prior-switch label to اطلاعات قبل
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 19:19:31 +03:30
MahdiandCursor 960e01938d fix(web): apply prior lens on user ProfileView timeline
Filter my-desk activities and deals by archive cutoff so current work stays clean.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 19:12:48 +03:30
MahdiandCursor 3fb5f32ac3 fix: align activity badge and person profile with prior lens
Count nav badge for desk=mine, always apply activity prior filter on person records, and stop loading prior=all deals on profiles.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 18:56:44 +03:30
MahdiandCursor 0ce2533066 fix(api): load multer types for avatar upload typecheck
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 18:08:31 +03:30
MahdiandCursor bc68582fe2 Fix prior archive filter collisions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 17:49:51 +03:30
MahdiandCursor e86841fc60 feat(web): add prior archive promote actions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 17:28:57 +03:30
MahdiandCursor 5069647f79 Add prior archive team settings UI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 17:20:28 +03:30
MahdiandCursor 008733d338 Add prior promotion endpoints
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 17:13:01 +03:30
Mahdi c5708ed59b chore: stop tracking local SDD scratch files 2026-10-17 17:03:23 +03:30
MahdiandCursor 65d66bb331 Implement prior archive cutoff API
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 17:01:52 +03:30
MahdiandCursor 79ba146118 Wire prior archive cutoff through API
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 16:52:20 +03:30
MahdiandCursor 02841a5b8c refactor(api): cutoff-based prior-archive where helpers
Replace didar/live-deal filters with createdAt cutoff and promotedFromPriorAt; add unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 16:33:32 +03:30
Mahdi a04743c6df feat: add prior-archive promote fields and shared cutoff DTOs 2026-10-17 16:27:29 +03:30
MahdiandCursor 27635c0752 docs: add prior-archive cutoff plan and ignore worktrees
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 16:07:59 +03:30
MahdiandCursor 03e1c01023 docs: specify date-cutoff prior archive and one-way promote
Replace Didar-source classification with org cutoff date, deals/activities-only filter, and a promote tag that does not mutate record fields.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 15:29:37 +03:30
MahdiandCursor 177e5cc31b fix: keep list menus on screen and clip long activity subtitles
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 13:26:11 +03:30
MahdiandCursor 2c7c943802 feat: allow bulk select and delete on activities and deal list
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 12:47:07 +03:30
MahdiandCursor 9611803567 feat: serve the same CRM on crm.montact.ir as an alias
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 23:24:50 +03:30
MahdiandCursor a494b5308e fix: make the user activity log readable and show Montact
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 14:18:56 +03:30
MahdiandCursor 582733e2ea fix: hide unlinked old activities from current work
Imported follow-ups with no person, company, or deal stayed on the board because only linked prior records were archived.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 13:02:01 +03:30
MahdiandCursor 6bff9d2888 fix: keep imported follow-ups out of current work
They stayed on the board when a non-prior deal was attached, even though the person was prior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 12:34:40 +03:30
MahdiandCursor 6bc5e1bec7 feat: separate prior records behind an owner switch and count last-period questions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 10:48:30 +03:30
MahdiandCursor 3a8a684aa4 docs: lock the prior-data archive design for a later start
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 20:44:30 +03:30
MahdiandCursor 831c682eb3 feat: show the full work history and load every lead stage on the board
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 19:47:32 +03:30
MahdiandCursor 2cc81bb596 feat: keep deletions restorable and confirm a pipeline's deals before it is removed
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 22:23:09 +03:30
MahdiandCursor c04582072d fix: drop the unused button import that blocked the type check
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 21:08:14 +03:30
MahdiandCursor 42f0573a5f feat: send one SMS from a person or lead and keep the dashboard inside the phone frame
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 21:05:17 +03:30
MahdiandCursor 13ee70764b feat: show this month's birthdays and send a greeting SMS
Each owner sees their customers born this Jalali month and can text a custom greeting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 19:55:11 +03:30
MahdiandCursor cf326d1ed9 fix: keep birthdays and show Shamsi dates
Imported customers keep their birthday, and assistant text plus remaining calendars use the Jalali date.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 18:04:53 +03:30
MahdiandCursor 5f4cc47c41 fix: share the dashboard assistant and count leads from data
The hero ask box now uses the same confirm-before-create path as the assistant, count questions use real filters, and lead mobile, Jalali follow-ups, and rial labels stay consistent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 14:45:29 +03:30
MahdiandCursor 0e420a443f fix: keep deal edits and right-align club leaderboard
Empty product lists were wiping the manual amount on update, and follow-ups were only created for new deals. Leaderboard rows were laid out left-to-right on an RTL page.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 11:38:59 +03:30
MahdiandCursor 6b4bd0b03e fix(web): drop unused merge wizard props after preview load fix
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 22:02:56 +03:30
MahdiandCursor 910c7f51b0 fix(web): load pipeline merge preview when wizard opens
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 21:59:46 +03:30
MahdiandCursor 609ce3d006 rename pipeline settings hub card to تنظیمات مسیر فروش
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 21:46:04 +03:30
MahdiandCursor 9bed6cac03 fix(api): guard unmoved deals before pipeline merge delete
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 21:32:21 +03:30
MahdiandCursor fd6e79c15d feat(web): pipeline merge wizard
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 20:55:14 +03:30
MahdiandCursor 9b42fec0a8 feat(web): pipeline settings page and nav entry
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 20:33:29 +03:30
MahdiandCursor d00f362376 feat(api): pipeline merge preview and merge endpoints
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 20:23:56 +03:30
MahdiandCursor fd8966e13f feat(pipelines): merge stage-map util and shared DTOs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 20:15:11 +03:30
MahdiandCursor 91eb9be1d4 docs: add pipeline merge implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 20:10:03 +03:30
MahdiandCursor ffae63edbf docs: add pipeline merge design spec
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 20:07:09 +03:30
Mahdi cba4ad6a49 fix deal edit save; add new person from deal form; club and multi-select 2026-10-12 19:23:07 +03:30
Mahdi faac26d07d club home redesign; deal multi-select delete; anchored card menus 2026-10-12 17:59:07 +03:30
Mahdi a2b01f787e club home redesign; deal card menu anchored to button + delete 2026-10-12 14:54:32 +03:30
Mahdi 8af78047d9 club home redesign; deal card menu: delete with confirmation 2026-10-12 12:39:52 +03:30
Mahdi b43ed68e82 feat(automations): multi-option form-field conditions (all / any / none / exact) 2026-10-11 17:26:17 +03:30
Mahdi f4398cdb3e fix(web): mobile automation canvas/inspector, compact activity row, timestamped comments 2026-10-11 16:48:37 +03:30
Mahdi 17638786a8 feat(automations): parallel branch canvas, simple/advanced level per org, rules list UX 2026-10-11 14:48:12 +03:30
Mahdi b3b92abae9 feat(automations): form-driven branching rules, round-robin assignment, lead-linked activities 2026-10-11 14:12:36 +03:30
Mahdi b30f68563d docs: update user guide, roadmap and progress; comprehensive features catalog 2026-10-11 12:06:23 +03:30
Mahdi 51ca23b398 docs: update user guide, roadmap and progress; add comprehensive features catalog 2026-10-11 11:54:08 +03:30
MahdiandCursor 60d797f184 feat: باشگاه امتیازات کارکنان (موتور امتیاز، جدول رتبه، فروشگاه پاداش، نشان‌ها، پنل مدیریت و گزارش ROI)
- موتور رویدادمحور با شرط، محدودیت، تأیید مدیر و idempotency
- اتصال به لید، مشتری، معامله، فعالیت، روتین، پیش‌فاکتور، فاکتور و تیکت
- صفحه /club و /club/admin، اعلان زنده امتیاز، آیتم منو
- انتقال فرم‌ساز سرنخ به ابزارها

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 21:28:49 +03:30
Mahdi b87ebb22e0 Montify: robust pagination (beyond 50), endpoint diagnose button 2026-10-10 18:01:55 +03:30
Mahdi 792b3d29fa Montify: allow restarting stuck sync from UI; log sync progress 2026-10-10 17:11:51 +03:30
Mahdi 0802c2f436 Montify sync: save progress every 5 customers; skip endpoints returning 404, summarize instead of per-customer errors 2026-10-10 16:56:58 +03:30
Mahdi 37157620e2 Montify settings: send empty JSON body on sync (fix 400 null body) 2026-10-10 16:35:23 +03:30
Mahdi f27b4c264a Montify: mark sync running before responding; no 400 when already running 2026-10-10 16:18:25 +03:30
Mahdi e82bd2b2df Montify stores integration: multi-store connections, customer/order/transaction sync, webhooks 2026-10-10 15:59:12 +03:30
MahdiandCursor fa3fe0d364 Web: fix mobile overflow on activities, activity form, assets panel, routines, team/audit/API settings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 11:58:45 +03:30
MahdiandCursor 4e26658092 e2e: public form test finds an active form itself; drop E2E_FORM_TOKEN
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 21:05:38 +03:30
MahdiandCursor 287f88abc0 e2e: public form test stays read-only and reports a wrong token clearly
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 20:49:55 +03:30
MahdiandCursor 09f05d4ecc e2e: fix ambiguous selector in Tahesab assets test
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 20:28:38 +03:30
MahdiandCursor 46a7693ea0 QA: unit tests for API logic, Playwright smoke tests, pre-push check, manual QA checklist
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 20:11:07 +03:30
MahdiandCursor e6755d0c2e Tahesab: show raw data from Tahesab, invoice-style document view with metal labels
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 13:49:54 +03:30
MahdiandCursor 2f3646d085 feat(ui): modals close only via Esc/X/cancel; standard 3:1 form cover processing
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 15:23:14 +03:30
MahdiandCursor f5b6a47f3c feat(lead-forms): public form matches builder preview; live unsaved preview in new tab
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 14:25:52 +03:30
MahdiandCursor 678cb87de7 fix(lead-forms): multiline options, editable title, color palette, builder validation and unique new form names
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 13:49:27 +03:30
MahdiandCursor d683932d1b feat(forms): merge notes tab into main info for activity and deal forms; show activity hashtags
- Activity form: notes/tags card inline, live hashtag preview, focus notes when required
- Hashtags shown in activity detail (clickable search) and list rows; search accepts #tag
- Deal form: remove mobile tabs

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 13:17:29 +03:30
MahdiandCursor 13668c4ba4 feat(activities): split customer column, wider search, custom activity types with type-specific fields
- Table: separate assignee and customer columns; search by assignee and activity type
- ActivityTypeDef model with per-type field definitions (text, select, checklist, participants, ...)
- Default types incl. meeting subject/participants and task checklist; admins manage types
- Participants see activities in their lists and get notified

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 12:29:23 +03:30
MahdiandCursor aa2f39256a feat(activities): editable stages with tab and kanban views
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 11:18:53 +03:30
MahdiandCursor b4ffef9b6f rename: کاریز فروش → مسیر فروش, کاریز پروژه → تابلوی پروژه
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 20:51:50 +03:30
MahdiandCursor b3784818fc feat: routines module; super admin subscription badge, 7-day trial and lifetime plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 19:54:38 +03:30
Mahdi 3de2d0fa67 Deals import: imported pipelines keep only Didar stages; remove unused default stages 2026-10-06 16:58:25 +03:30
Mahdi 2a2728e5ee Deals Excel import/export aligned with Didar: browser-side import, new deal fields (code, probability, closedAt, creator, lostNote, referrer), Didar custom fields 2026-10-06 16:36:04 +03:30
Mahdi e08f1452ef Unified search algorithm across all lists; per-section record visibility (contacts, deals, activities, leads, finance, tickets, notes, projects) 2026-10-06 15:04:46 +03:30
Mahdi 43717392b2 Profile picture display has been standardized across the board. 2026-10-06 14:23:59 +03:30
Mahdi bc01384551 Fix error sending reminder SMS 2026-10-06 13:49:03 +03:30
Mahdi 5074c6d791 Add column visibility menu to activities and API keys tables, and make related person optional when creating an activity 2026-10-06 13:35:13 +03:30
Mahdi 0d7555f219 Fixing the error in today's task count 2026-10-06 12:44:09 +03:30
Mahdi f71f09c85d Align companies with the Didar export: new fields, Didar-format Excel import/export, and people linked to companies by Didar code 2026-10-06 12:25:20 +03:30
MahdiandCursor f88786ef61 Import Tahesab customers in batches with progress overlay and prefetched rows
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 21:08:38 +03:30
MahdiandCursor 46dcf296d3 Keep previous import source and code when a person is re-imported from another source
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 20:54:23 +03:30
MahdiandCursor c8753a4c4e Fix person profile more-menu opening twice
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 20:18:05 +03:30
MahdiandCursor 48de552f36 Add row selection, bulk delete, row numbers and page size picker to tables; redesign person profile header
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 20:00:21 +03:30
MahdiandCursor 71b3a7255a Polish activity person picker layout and move add-person action beside the label
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 18:19:37 +03:30
MahdiandCursor df8fac187e Improve assignee and person pickers with search, chips and inline person creation
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 17:49:17 +03:30
MahdiandCursor 3296b1c209 Remove BOM from user table preferences migration
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 11:59:59 +03:30
MahdiandCursor 23c83ffbee Add per-user saved column visibility for tables
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 11:56:49 +03:30
MahdiandCursor 75ff2c36c4 Allow editing org team member name, mobile, and password from settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 12:31:07 +03:30
MahdiandCursor 39e2eb24a6 Keep desktop modals open on outside click and allow larger group member lists.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 12:12:05 +03:30
MahdiandCursor 8b37525f2f Track person import source and previous names; add source report and edit UX.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 12:01:21 +03:30
MahdiandCursor e144b2277a Normalize chat uploads for montifyshop file-type whitelist to stop 400 errors.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 11:33:48 +03:30
MahdiandCursor 236f73de93 Paginate Tahesab customer list by code windows past the 5000 cap.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 11:09:40 +03:30
MahdiandCursor 60c16053a4 Support Didar activity Excel exports without header rows via positional columns.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 10:44:00 +03:30
MahdiandCursor cbfe470f33 Import large activity Excel in the browser via JSON chunks to avoid 502.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 18:28:47 +03:30
MahdiandCursor ac57b8ac33 Parse large Excel imports asynchronously to avoid gateway 502 timeouts.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 18:14:17 +03:30
MahdiandCursor d3fb92a5e2 Split large activity Excel prepare from chunk import and clarify errors.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 17:58:41 +03:30
MahdiandCursor 07bdc4a3b6 Show percent progress bar during long Excel imports.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 17:45:08 +03:30
MahdiandCursor c629729a9a Allow large activity Excel imports (40k+ rows) with bigger chunks and caches.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 17:42:11 +03:30
MahdiandCursor beae16437b Harden Tahesab HTTP client against keep-alive hangs and retry health on alt ports.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 17:02:14 +03:30
MahdiandCursor 17628722d3 Fix Tahesab health check to match Postman root-endpoint flow.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 16:45:04 +03:30
Mahdi c322a24424 Fixing the account balance entry error 2026-10-03 15:28:08 +03:30
Mahdi 2e315c9cfd Fixing the error when importing a large Excel file 2026-10-03 15:01:01 +03:30
Mahdi f8313c0ea5 Fixed error regarding the selection of multiple assignees and role sub-categories. 2026-10-03 13:27:16 +03:30
Mahdi 3b128bd980 Selection of multiple officials and role sub-categories 2026-10-03 13:16:13 +03:30
Mahdi 9f97371391 Fixed error when adding creation date and multiple addresses for contacts and leads. 2026-10-03 12:40:16 +03:30
Mahdi 1309da2281 Adding registration date and multiple addresses for contacts and leads. 2026-10-03 12:29:11 +03:30
Mahdi 989c03b381 Filtering tasks and assigning a person in charge during Excel import 2026-10-03 12:00:46 +03:30
Mahdi 5784b09232 Fixing Excel login error 2026-10-03 11:32:37 +03:30
Mahdi 5d5b3a3213 Implementing a link tracking and user click logging system.Implementing a link tracking and user click logging system. 2026-10-01 15:59:18 +03:30
Mahdi ae21d9bbc7 Addition of an AI chat assistant and a 100MB file upload limit. 2026-09-30 16:30:41 +03:30
Mahdi e94600a02e Adjusted the bold button, added italics, and fixed the broken emoji box. 2026-09-30 15:23:19 +03:30
Mahdi 75b83b7f1b Edit text and links, mute and unmute chat 2026-09-30 14:58:44 +03:30
Mahdi d3986da28b Editing applyTypingFromServer & clearTypingForRoom 2026-09-30 14:22:07 +03:30
Mahdi c7a60c524d Editing correction (in progress) 2026-09-30 13:01:42 +03:30
Mahdi 3c0ed3369e Editing and error correction (in progress) 2026-09-30 12:43:19 +03:30
Mahdi a3ed6187e9 Edit typing... indicator, loading older message. 2026-09-30 12:20:49 +03:30
Mahdi e1d456ad11 Adding the typing... indicator, loading older messages, and managing members/admins after creation. 2026-09-30 11:53:04 +03:30
Mahdi b978708487 Added message-to-activity conversion and edited the colleague selection modal. 2026-09-30 11:24:58 +03:30
Mahdi 4c8731122b Add channel to conversation 2026-09-30 11:08:33 +03:30
Mahdi f71e78f3a7 Add mentions, pin messages and conversations, and search within chats 2026-09-30 10:40:29 +03:30
Mahdi 7c4b24fad6 Wrapping to the next line in mobile view 2026-09-30 10:04:54 +03:30
Mahdi e6fb5c3700 Fixing the pop-up error and enabling the ability to reply to messages. 2026-09-30 09:41:49 +03:30
Mahdi 298b3316b4 Fixed message deletion error and layout changes for the message input box. 2026-09-30 09:19:36 +03:30
Mahdi 58e89246d9 Fix chat filter typescript error 2026-09-29 21:54:54 +03:30
Mahdi 5bb8e68521 Fixing the issue with complete deletion from the database 2026-09-29 21:46:41 +03:30
Mahdi 88dc7c5a1c Fixing the complete removal 2026-09-29 21:36:31 +03:30
Mahdi 83504816c5 Add, edit, delete, and resend messages. 2026-09-29 21:26:07 +03:30
Mahdi c20dc0a7ca Aligning the chat bar with Telegram 2026-09-29 21:06:04 +03:30
Mahdi e20b904bbe Fixing video retrieval error 2026-09-29 20:48:14 +03:30
Mahdi 7996fc7f73 Fixing the video-to-file conversion error and audio appearance changes. 2026-09-29 20:36:15 +03:30
Mahdi 70bcd15847 Convert video to file and change the appearance of mobile chat 2026-09-29 20:18:17 +03:30
Mahdi b0f05b6a27 Add file & video & voice in chat Crm 2026-09-29 17:22:34 +03:30
Mahdi d4972891f1 Updating ConversationsView wiring and the nav badge next. 2026-09-29 16:22:37 +03:30
Mahdi 032eb70524 Edit BadGatewayException 2026-09-29 15:27:14 +03:30
Mahdi 2309e2c5ab Edit ExcelImportFlow 2026-09-29 14:57:10 +03:30
Mahdi 66af7e577c Chat settings in Super Admin and changing the chat title and position. 2026-09-29 14:45:19 +03:30
Mahdi 125976f3eb Refining the CRM chat interface 2026-09-29 11:56:32 +03:30
Mahdi 0670067e7f Add conversations settings link to settings hub menu. 2026-09-29 11:34:31 +03:30
MahdiandCursor a1655432fc Add internal org chat (phase 1) with DM, groups, and org settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 10:34:01 +03:30
Mahdi 589c763ef8 Polish desktop sidebar branding and tighten automation rules card.
Move profile and collapse control, show org even for one workspace, and center rules labels in the toolbar-height card.
2026-09-28 17:35:03 +03:30
Mahdi d2c82f2115 Restore Persian UTF-8 labels in shared package.
Undo double-encoding mojibake so automation and other UI strings render correctly.
2026-09-28 16:18:54 +03:30
MahdiandCursor 319493171f Polish lead form builder header and settings layout.
Use shared UiPageHeader back control, align ready-field chips with the toolbox, and organize CRM settings into clearer mobile/desktop cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 14:29:57 +03:30
Mahdi f20206b045 Connect public lead forms to CRM Leads on submit. 2026-09-28 13:39:44 +03:30
Mahdi 32e27d45f7 Add desk-style task workflow on Activities and Project boards.
Managers can switch my desk vs team, reassign activities, and filter project cards to their work; assignees can see and move cards.
2026-09-28 12:28:14 +03:30
Mahdi 8effd4cbc6 Improve Leads mobile UX and nest leads under Customers nav.
Restore the 5-tab mobile bar, add responsive lead cards with long-press select, and fix stats/filters layout.
2026-09-28 11:50:44 +03:30
Mahdi 7e73ba9936 Fix web build failure from corrupted Tahesab status types.
Restore MandehValueDto status encoding and derive بس/بد labels from amount.
2026-09-27 21:26:56 +03:30
Mahdi 647503810d Add lead company logos and refine Leads stats cards.
Leads can upload/change/remove a logo shown in the list; stats use fixed stage labels with accurate non-archived counts.
2026-09-27 21:17:42 +03:30
Mahdi ec5c7639e3 Match Leads stats cards to the mockup and fix Contacts double menus.
Stage summary uses the green total card plus paired/solo white cells; people row menus use separate mobile/desktop keys.
2026-09-27 20:35:57 +03:30
Mahdi 3833788aa8 Fix Leads row menu overflow and add edit/delete actions.
Use a teleported dropdown so the menu stays above the table, with edit modal and archive delete.
2026-09-27 20:18:44 +03:30
Mahdi 9edbb92b13 Align Leads header with Contacts and add phone-contact lead import.
Leads actions mirror people import/export, and create-lead can fill fields from device contacts like Person form.
2026-09-27 19:51:02 +03:30
Mahdi 05f205cadf Redesign Leads list to match the dashboard mockup.
Add stage stats cards, richer filters/table columns, create modal, and mockup-style pagination.
2026-09-27 18:22:35 +03:30
Mahdi ac3ffb8ab2 Promote Leads to a top-level nav hub and expire login OTP after 90s.
Leads items sit under their own hub after Customers; login OTP shows a countdown and returns to the mobile step when time runs out.
2026-09-27 18:00:59 +03:30
MahdiandCursor c079295ff4 Add standalone Leads module for prospect outreach before customers.
Import large Excel lists with duplicate checks, assign and kanban follow-up, convert to Person when ready; also fix OTP mobile mask order and SMS brand name to منتکت.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 17:05:11 +03:30
MahdiandCursor 22980e5152 Add Didar-style conversion rate to sales reports.
Show won-over-incoming deals for the selected period with time, user, and source breakdowns alongside the existing win-rate metric.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 15:36:34 +03:30
MahdiandCursor c5c903d4b4 Add automatic VoIP call summaries and sync progress checklist.
When a PBX webhook includes recordingUrl or transcript, queue STT and AI to create a call_result note; mark API, bulk SMS, WooCommerce, and VoIP as done and defer online payments.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 15:06:41 +03:30
Mahdi c06c92b333 Add deal co-assignees and saved filters on people/pipeline lists.
Deals keep a primary owner plus shared assignees with matching visibility; Contacts and Pipeline can save and reuse filter presets.
2026-09-27 14:25:32 +03:30
Mahdi 00f8b91d78 Add personal/team note visibility and edit history.
Notes can be marked personal or team, with revision snapshots and restore from the notes UI.
2026-09-27 13:19:08 +03:30
Mahdi 55a287563d Add simple report builder with metrics, dimensions, and user/team filters. 2026-09-27 11:54:23 +03:30
Mahdi a16c2f0a9b Add saved report views with personal/org filters and in-org share links. 2026-09-27 11:11:33 +03:30
Mahdi 6a9e864b07 Add deeper reports: stage conversion, period compare, and deal drill-down. 2026-09-27 10:39:09 +03:30
MahdiandCursor d133c8ca34 Add reservations module and rename teams visibility label to file-access level.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 09:38:17 +03:30
Mahdi db65b55660 Add VoIP webhook connector for call logging, notifications, and optional click-to-call; align brand to Montact. 2026-09-27 08:47:11 +03:30
Mahdi 26085b54a9 Add WooCommerce integration: sync orders to invoices/people with webhook, cron, and settings UI. 2026-09-26 17:59:16 +03:30
Mahdi 1b160c8cd3 Add named teams and per-user data visibility (own/team/org) with settings UI and org module. 2026-09-26 17:15:09 +03:30
Mahdi 499d13d1df Fix SMS campaigns people list 400 by capping limit at 100 and searching via API. 2026-09-26 16:39:11 +03:30
Mahdi ef600d149d Add public API keys, outbound webhooks, and bulk SMS campaigns with settings UI and org modules. 2026-09-26 16:12:29 +03:30
Mahdi ccddd009e3 Edit Didar gap features: lost-reason report 2026-09-26 15:20:38 +03:30
Mahdi 6463275c3d Add Didar gap features: lost-reason reports, audit logs, tags, card assignees, and saved-views API.
Ignore Graphify local artifacts and expose new modules in superadmin.
2026-09-26 14:59:48 +03:30
Mahdi 35a618d0af Edit a modal for the assistant 2026-09-23 17:30:30 +03:30
Mahdi 011b316b5b Creating a modal for the assistant 2026-09-23 17:18:04 +03:30
Mahdi b7e3d1e290 Create a project workflow by typing to generate approval cards. 2026-09-23 16:32:41 +03:30
Mahdi 7dc4344e0e Creating a project workflow using a voice assistant and converting it into an approval card. 2026-09-23 15:49:44 +03:30
Mahdi 99df327ded Adding two fields to the mesh form 2026-09-23 14:41:16 +03:30
Mahdi 230d7b3bf6 AI-powered speech-to-text transformation 2026-09-23 13:58:43 +03:30
Mahdi 58c3e25db6 Correcting the speech-to-text output 2026-09-22 12:24:53 +03:30
Mahdi 75a73559b5 Modification of the structure 2026-09-22 12:04:35 +03:30
Mahdi 41f0025883 Modification of the STT structure 2026-09-22 11:56:16 +03:30
Mahdi 42db5919e1 Modification of the STT structure timeout 2026-09-22 11:37:59 +03:30
Mahdi d53136d91c Restructuring the STT structure 2026-09-22 11:30:07 +03:30
Mahdi dac0454e69 Restructuring STT 2026-09-22 11:16:17 +03:30
Mahdi 4b335aa7ff Refining the speech-to-text conversion structure 2026-09-22 11:02:08 +03:30
Mahdi 17fb33b3e7 Adding a task generated from the account balance to products 2026-09-21 21:25:34 +03:30
Mahdi be97a067f9 Edit: Add from Android phone contacts 2026-09-21 20:51:06 +03:30
Mahdi 9249302eac Edit from phone contacts 2026-09-21 20:26:19 +03:30
Mahdi 5d9a572ff8 Edit: Add from phone contacts 2026-09-21 20:04:18 +03:30
Mahdi f4cb41d96f Configure the AI assistant for more information. 2026-09-21 19:24:39 +03:30
Mahdi 7a58b9d469 Adding contacts from the phone to the customer list 2026-09-21 18:27:37 +03:30
Mahdi 1adc217375 Edit dashboard appearance 2026-09-21 17:55:05 +03:30
Mahdi d7d82102af Edit the artificial intelligence of the Mentact assistant 2026-09-21 16:58:49 +03:30
Mahdi f5a4eb577b Editing permissions in the CRM Super Admin 2026-09-21 16:38:24 +03:30
519 changed files with 99126 additions and 7177 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ SMS_PASSWORD=
SMS_SENDER=
# SMS_BASE_URL=http://smspanel.Trez.ir
# SMS_SEND_PATH=/SendMessageWithCode.ashx
# SMS_OTP_TEMPLATE=کد ورود پرتال منتکت: {code}
# SMS_OTP_TEMPLATE=کد ورود منتکت: {code}
# SMS_TIMEOUT_MS=20000
# رمزنگاری توکن‌های اتصال حسابداری (ته‌حساب) — ۳۲ بایت به‌صورت hex (۶۴ کاراکتر)
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
# فعال‌سازی: npm install (اسکریپت prepare) یا git config core.hooksPath .githooks
npm run check
+15
View File
@@ -21,3 +21,18 @@ docs/~$*
*.py[cod]
apps/stt/.venv/
crm-node_modules.tgz
# Playwright
e2e/.auth/
e2e/.env
test-results/
playwright-report/
# Graphify (local architecture graph — do not commit)
graphify-out/
**/graphify-out/
.cursor/rules/graphify.mdc
# Local git worktrees + SDD scratch
.worktrees/
.superpowers/
+1 -1
View File
@@ -80,7 +80,7 @@ $env:MENTO_DEV_API="http://127.0.0.1:3000"; npm run dev
## فاز فعلی
فاز ۱ هسته فروش/پشتیبانی و Smart Notes عملاً کامل است. جزئیات تیک‌خورده و باقی‌مانده:
فاز ۱ هسته فروش/پشتیبانی، Smart Notes، مالی سبک و اتصال‌ها (API، ووکامرس، VoIP، پیامک گروهی) عملاً کامل است. جزئیات تیک‌خورده و باقی‌مانده:
- [`docs/وضعیت-پیشرفت.md`](docs/وضعیت-پیشرفت.md) ← **چک‌لیست زنده**
- [`docs/قابلیت‌های-منتکت.md`](docs/قابلیت‌های-منتکت.md) ← فهرست کوتاه قابلیت‌ها
+3 -1
View File
@@ -10,7 +10,9 @@
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "tsx prisma/seed.ts",
"lint": "echo \"api lint placeholder\""
"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/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 test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts"
},
"dependencies": {
"@mento/shared": "*",
@@ -0,0 +1,33 @@
-- CreateTable
CREATE TABLE "ProductAccountingLink" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"externalName" TEXT NOT NULL,
"ayar" INTEGER,
"vaznSabet" DOUBLE PRECISION,
"matchMethod" TEXT NOT NULL DEFAULT 'import',
"linkedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductAccountingLink_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ProductAccountingLink_productId_idx" ON "ProductAccountingLink"("productId");
-- CreateIndex
CREATE INDEX "ProductAccountingLink_connectionId_idx" ON "ProductAccountingLink"("connectionId");
-- CreateIndex
CREATE UNIQUE INDEX "ProductAccountingLink_productId_connectionId_key" ON "ProductAccountingLink"("productId", "connectionId");
-- CreateIndex
CREATE UNIQUE INDEX "ProductAccountingLink_connectionId_externalName_key" ON "ProductAccountingLink"("connectionId", "externalName");
-- AddForeignKey
ALTER TABLE "ProductAccountingLink" ADD CONSTRAINT "ProductAccountingLink_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductAccountingLink" ADD CONSTRAINT "ProductAccountingLink_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "AccountingConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Person" ADD COLUMN "website" TEXT;
ALTER TABLE "Person" ADD COLUMN "referrer" TEXT;
@@ -0,0 +1,84 @@
-- AlterTable
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- AlterTable
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- AlterTable
ALTER TABLE "Activity" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- AlterTable
ALTER TABLE "ProjectCard" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- CreateTable
CREATE TABLE IF NOT EXISTS "AuditLog" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"actorUserId" TEXT,
"action" TEXT NOT NULL,
"method" TEXT NOT NULL,
"path" TEXT NOT NULL,
"entityType" TEXT,
"entityId" TEXT,
"summary" TEXT NOT NULL,
"meta" JSONB,
"ip" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "SavedView" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"ownerUserId" TEXT,
"name" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"filters" JSONB NOT NULL DEFAULT '{}',
"isShared" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SavedView_pkey" PRIMARY KEY ("id")
);
-- CreateTable (implicit many-to-many ProjectCard <-> User assignees)
CREATE TABLE IF NOT EXISTS "_ProjectCardAssignees" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "AuditLog_organizationId_createdAt_idx" ON "AuditLog"("organizationId", "createdAt");
CREATE INDEX IF NOT EXISTS "AuditLog_organizationId_actorUserId_createdAt_idx" ON "AuditLog"("organizationId", "actorUserId", "createdAt");
CREATE INDEX IF NOT EXISTS "AuditLog_organizationId_entityType_createdAt_idx" ON "AuditLog"("organizationId", "entityType", "createdAt");
CREATE INDEX IF NOT EXISTS "SavedView_organizationId_scope_idx" ON "SavedView"("organizationId", "scope");
CREATE INDEX IF NOT EXISTS "SavedView_organizationId_ownerUserId_idx" ON "SavedView"("organizationId", "ownerUserId");
CREATE UNIQUE INDEX IF NOT EXISTS "_ProjectCardAssignees_AB_unique" ON "_ProjectCardAssignees"("A", "B");
CREATE INDEX IF NOT EXISTS "_ProjectCardAssignees_B_index" ON "_ProjectCardAssignees"("B");
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "SavedView" ADD CONSTRAINT "SavedView_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "SavedView" ADD CONSTRAINT "SavedView_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "_ProjectCardAssignees" ADD CONSTRAINT "_ProjectCardAssignees_A_fkey" FOREIGN KEY ("A") REFERENCES "ProjectCard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "_ProjectCardAssignees" ADD CONSTRAINT "_ProjectCardAssignees_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
@@ -0,0 +1,93 @@
-- CreateTable
CREATE TABLE "OrganizationApiKey" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"prefix" TEXT NOT NULL,
"keyHash" TEXT NOT NULL,
"lastUsedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrganizationApiKey_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "WebhookEndpoint" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"url" TEXT NOT NULL,
"secret" TEXT NOT NULL,
"events" TEXT[] DEFAULT ARRAY[]::TEXT[],
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WebhookEndpoint_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "WebhookDelivery" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"endpointId" TEXT NOT NULL,
"event" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"statusCode" INTEGER,
"attempts" INTEGER NOT NULL DEFAULT 0,
"lastError" TEXT,
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deliveredAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WebhookDelivery_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SmsCampaign" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"body" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'draft',
"scheduledAt" TIMESTAMP(3),
"totalCount" INTEGER NOT NULL DEFAULT 0,
"sentCount" INTEGER NOT NULL DEFAULT 0,
"failedCount" INTEGER NOT NULL DEFAULT 0,
"skippedCount" INTEGER NOT NULL DEFAULT 0,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsCampaign_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SmsCampaignRecipient" (
"id" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"personId" TEXT,
"mobile" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"error" TEXT,
"sentAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsCampaignRecipient_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "OrganizationApiKey_keyHash_key" ON "OrganizationApiKey"("keyHash");
CREATE INDEX "OrganizationApiKey_organizationId_revokedAt_idx" ON "OrganizationApiKey"("organizationId", "revokedAt");
CREATE INDEX "OrganizationApiKey_prefix_idx" ON "OrganizationApiKey"("prefix");
CREATE INDEX "WebhookEndpoint_organizationId_isActive_idx" ON "WebhookEndpoint"("organizationId", "isActive");
CREATE INDEX "WebhookDelivery_organizationId_createdAt_idx" ON "WebhookDelivery"("organizationId", "createdAt");
CREATE INDEX "WebhookDelivery_status_nextAttemptAt_idx" ON "WebhookDelivery"("status", "nextAttemptAt");
CREATE INDEX "WebhookDelivery_endpointId_createdAt_idx" ON "WebhookDelivery"("endpointId", "createdAt");
CREATE INDEX "SmsCampaign_organizationId_status_scheduledAt_idx" ON "SmsCampaign"("organizationId", "status", "scheduledAt");
CREATE INDEX "SmsCampaignRecipient_campaignId_status_idx" ON "SmsCampaignRecipient"("campaignId", "status");
ALTER TABLE "OrganizationApiKey" ADD CONSTRAINT "OrganizationApiKey_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "WebhookEndpoint" ADD CONSTRAINT "WebhookEndpoint_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_endpointId_fkey" FOREIGN KEY ("endpointId") REFERENCES "WebhookEndpoint"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SmsCampaign" ADD CONSTRAINT "SmsCampaign_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SmsCampaignRecipient" ADD CONSTRAINT "SmsCampaignRecipient_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "SmsCampaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,49 @@
-- AlterTable
ALTER TABLE "Membership" ADD COLUMN "dataVisibility" TEXT NOT NULL DEFAULT 'own';
-- CreateTable
CREATE TABLE "Team" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TeamMember" (
"id" TEXT NOT NULL,
"teamId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'member',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Team_organizationId_idx" ON "Team"("organizationId");
-- CreateIndex
CREATE UNIQUE INDEX "Team_organizationId_name_key" ON "Team"("organizationId", "name");
-- CreateIndex
CREATE INDEX "TeamMember_userId_idx" ON "TeamMember"("userId");
-- CreateIndex
CREATE INDEX "TeamMember_teamId_idx" ON "TeamMember"("teamId");
-- CreateIndex
CREATE UNIQUE INDEX "TeamMember_teamId_userId_key" ON "TeamMember"("teamId", "userId");
-- AddForeignKey
ALTER TABLE "Team" ADD CONSTRAINT "Team_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,63 @@
-- CreateTable
CREATE TABLE "WooCommerceConnection" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"storeUrl" TEXT NOT NULL,
"consumerKeyCiphertext" TEXT NOT NULL,
"consumerKeyIv" TEXT NOT NULL,
"consumerKeyTag" TEXT NOT NULL,
"consumerKeyLast4" TEXT NOT NULL DEFAULT '',
"consumerSecretCiphertext" TEXT NOT NULL,
"consumerSecretIv" TEXT NOT NULL,
"consumerSecretTag" TEXT NOT NULL,
"webhookSecretCiphertext" TEXT,
"webhookSecretIv" TEXT,
"webhookSecretTag" TEXT,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"lastSyncAt" TIMESTAMP(3),
"lastHealthAt" TIMESTAMP(3),
"lastHealthOk" BOOLEAN,
"lastError" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WooCommerceConnection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WooCommerceOrderLink" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"wooOrderId" TEXT NOT NULL,
"wooOrderNumber" TEXT,
"invoiceId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT '',
"total" DECIMAL(18,2) NOT NULL DEFAULT 0,
"currency" TEXT NOT NULL DEFAULT 'IRR',
"rawPayload" JSONB,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WooCommerceOrderLink_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "WooCommerceConnection_organizationId_enabled_idx" ON "WooCommerceConnection"("organizationId", "enabled");
-- CreateIndex
CREATE INDEX "WooCommerceOrderLink_organizationId_syncedAt_idx" ON "WooCommerceOrderLink"("organizationId", "syncedAt");
-- CreateIndex
CREATE INDEX "WooCommerceOrderLink_invoiceId_idx" ON "WooCommerceOrderLink"("invoiceId");
-- CreateIndex
CREATE UNIQUE INDEX "WooCommerceOrderLink_connectionId_wooOrderId_key" ON "WooCommerceOrderLink"("connectionId", "wooOrderId");
-- AddForeignKey
ALTER TABLE "WooCommerceConnection" ADD CONSTRAINT "WooCommerceConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WooCommerceOrderLink" ADD CONSTRAINT "WooCommerceOrderLink_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "WooCommerceConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,61 @@
-- CreateTable
CREATE TABLE "VoipConnection" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"webhookSecretCiphertext" TEXT,
"webhookSecretIv" TEXT,
"webhookSecretTag" TEXT,
"clickToCallUrl" TEXT,
"lastEventAt" TIMESTAMP(3),
"lastError" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VoipConnection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VoipCallLog" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"externalCallId" TEXT NOT NULL,
"direction" TEXT NOT NULL DEFAULT 'inbound',
"status" TEXT NOT NULL DEFAULT 'completed',
"fromNumber" TEXT,
"toNumber" TEXT,
"personId" TEXT,
"activityId" TEXT,
"durationSec" INTEGER,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"endedAt" TIMESTAMP(3),
"rawPayload" JSONB,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VoipCallLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "VoipConnection_organizationId_enabled_idx" ON "VoipConnection"("organizationId", "enabled");
-- CreateIndex
CREATE INDEX "VoipCallLog_organizationId_syncedAt_idx" ON "VoipCallLog"("organizationId", "syncedAt");
-- CreateIndex
CREATE INDEX "VoipCallLog_personId_idx" ON "VoipCallLog"("personId");
-- CreateIndex
CREATE INDEX "VoipCallLog_activityId_idx" ON "VoipCallLog"("activityId");
-- CreateIndex
CREATE UNIQUE INDEX "VoipCallLog_connectionId_externalCallId_key" ON "VoipCallLog"("connectionId", "externalCallId");
-- AddForeignKey
ALTER TABLE "VoipConnection" ADD CONSTRAINT "VoipConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VoipCallLog" ADD CONSTRAINT "VoipCallLog_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "VoipConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,66 @@
-- CreateTable
CREATE TABLE "ReservationService" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"durationMinutes" INTEGER NOT NULL DEFAULT 30,
"workDays" INTEGER[] DEFAULT ARRAY[0, 1, 2, 3, 4, 5, 6]::INTEGER[],
"dayStartHour" INTEGER NOT NULL DEFAULT 9,
"dayEndHour" INTEGER NOT NULL DEFAULT 17,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ReservationService_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ReservationAppointment" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"serviceId" TEXT NOT NULL,
"personId" TEXT NOT NULL,
"assigneeId" TEXT,
"startsAt" TIMESTAMP(3) NOT NULL,
"endsAt" TIMESTAMP(3) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ReservationAppointment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ReservationService_organizationId_isActive_idx" ON "ReservationService"("organizationId", "isActive");
-- CreateIndex
CREATE INDEX "ReservationAppointment_organizationId_startsAt_idx" ON "ReservationAppointment"("organizationId", "startsAt");
-- CreateIndex
CREATE INDEX "ReservationAppointment_organizationId_status_idx" ON "ReservationAppointment"("organizationId", "status");
-- CreateIndex
CREATE INDEX "ReservationAppointment_serviceId_startsAt_idx" ON "ReservationAppointment"("serviceId", "startsAt");
-- CreateIndex
CREATE INDEX "ReservationAppointment_personId_idx" ON "ReservationAppointment"("personId");
-- CreateIndex
CREATE INDEX "ReservationAppointment_assigneeId_startsAt_idx" ON "ReservationAppointment"("assigneeId", "startsAt");
-- AddForeignKey
ALTER TABLE "ReservationService" ADD CONSTRAINT "ReservationService_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "ReservationService"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,40 @@
-- AlterTable
ALTER TABLE "Note" ADD COLUMN "visibility" TEXT NOT NULL DEFAULT 'team';
-- CreateIndex
CREATE INDEX "Note_organizationId_visibility_idx" ON "Note"("organizationId", "visibility");
-- CreateTable
CREATE TABLE "NoteRevision" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"noteId" TEXT NOT NULL,
"editorId" TEXT,
"title" TEXT,
"body" TEXT NOT NULL,
"noteType" TEXT NOT NULL,
"visibility" TEXT NOT NULL DEFAULT 'team',
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
"checklist" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "NoteRevision_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "NoteRevision_noteId_createdAt_idx" ON "NoteRevision"("noteId", "createdAt");
-- CreateIndex
CREATE INDEX "NoteRevision_organizationId_idx" ON "NoteRevision"("organizationId");
-- CreateIndex
CREATE INDEX "NoteRevision_editorId_idx" ON "NoteRevision"("editorId");
-- AddForeignKey
ALTER TABLE "NoteRevision" ADD CONSTRAINT "NoteRevision_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NoteRevision" ADD CONSTRAINT "NoteRevision_noteId_fkey" FOREIGN KEY ("noteId") REFERENCES "Note"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NoteRevision" ADD CONSTRAINT "NoteRevision_editorId_fkey" FOREIGN KEY ("editorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "_DealAssignees" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_DealAssignees_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE INDEX "_DealAssignees_B_index" ON "_DealAssignees"("B");
-- AddForeignKey
ALTER TABLE "_DealAssignees" ADD CONSTRAINT "_DealAssignees_A_fkey" FOREIGN KEY ("A") REFERENCES "Deal"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_DealAssignees" ADD CONSTRAINT "_DealAssignees_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
-- AlterTable
ALTER TABLE "VoipCallLog" ADD COLUMN "recordingUrl" TEXT;
ALTER TABLE "VoipCallLog" ADD COLUMN "transcript" TEXT;
ALTER TABLE "VoipCallLog" ADD COLUMN "summary" TEXT;
ALTER TABLE "VoipCallLog" ADD COLUMN "summaryStatus" TEXT NOT NULL DEFAULT 'none';
ALTER TABLE "VoipCallLog" ADD COLUMN "summaryNoteId" TEXT;
ALTER TABLE "VoipCallLog" ADD COLUMN "summaryError" TEXT;
ALTER TABLE "VoipCallLog" ADD COLUMN "summaryAttempts" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "VoipCallLog" ADD COLUMN "summaryNextAttemptAt" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX "VoipCallLog_summaryStatus_summaryNextAttemptAt_idx" ON "VoipCallLog"("summaryStatus", "summaryNextAttemptAt");
@@ -0,0 +1,113 @@
-- CreateTable
CREATE TABLE "LeadStage" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"color" TEXT NOT NULL DEFAULT 'teal',
"showOnBoard" BOOLEAN NOT NULL DEFAULT true,
"isConverted" BOOLEAN NOT NULL DEFAULT false,
"isLost" BOOLEAN NOT NULL DEFAULT false,
"isUnqualified" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LeadStage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Lead" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"contactName" TEXT,
"mobile" TEXT NOT NULL,
"phone" TEXT,
"city" TEXT,
"region" TEXT,
"trade" TEXT,
"source" TEXT,
"notes" TEXT,
"estimatedValue" DECIMAL(18,2),
"stageId" TEXT NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"assigneeId" TEXT,
"createdById" TEXT,
"lastContactAt" TIMESTAMP(3),
"nextFollowUpAt" TIMESTAMP(3),
"convertedPersonId" TEXT,
"convertedAt" TIMESTAMP(3),
"archivedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Lead_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LeadEvent" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"leadId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT,
"meta" JSONB,
"actorId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LeadEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LeadImportJob" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"createdById" TEXT,
"fileName" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"totalCount" INTEGER NOT NULL DEFAULT 0,
"processedCount" INTEGER NOT NULL DEFAULT 0,
"successCount" INTEGER NOT NULL DEFAULT 0,
"duplicateCount" INTEGER NOT NULL DEFAULT 0,
"errorCount" INTEGER NOT NULL DEFAULT 0,
"rows" JSONB NOT NULL DEFAULT '[]',
"errorSamples" JSONB NOT NULL DEFAULT '[]',
"startedAt" TIMESTAMP(3),
"finishedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LeadImportJob_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "LeadStage_organizationId_position_idx" ON "LeadStage"("organizationId", "position");
CREATE UNIQUE INDEX "LeadStage_organizationId_name_key" ON "LeadStage"("organizationId", "name");
CREATE UNIQUE INDEX "Lead_organizationId_mobile_key" ON "Lead"("organizationId", "mobile");
CREATE INDEX "Lead_organizationId_stageId_position_idx" ON "Lead"("organizationId", "stageId", "position");
CREATE INDEX "Lead_organizationId_assigneeId_idx" ON "Lead"("organizationId", "assigneeId");
CREATE INDEX "Lead_organizationId_nextFollowUpAt_idx" ON "Lead"("organizationId", "nextFollowUpAt");
CREATE INDEX "Lead_organizationId_lastContactAt_idx" ON "Lead"("organizationId", "lastContactAt");
CREATE INDEX "Lead_organizationId_archivedAt_idx" ON "Lead"("organizationId", "archivedAt");
CREATE INDEX "Lead_organizationId_createdAt_idx" ON "Lead"("organizationId", "createdAt");
CREATE INDEX "Lead_organizationId_city_idx" ON "Lead"("organizationId", "city");
CREATE INDEX "Lead_organizationId_trade_idx" ON "Lead"("organizationId", "trade");
CREATE INDEX "Lead_organizationId_source_idx" ON "Lead"("organizationId", "source");
CREATE INDEX "LeadEvent_leadId_createdAt_idx" ON "LeadEvent"("leadId", "createdAt");
CREATE INDEX "LeadEvent_organizationId_createdAt_idx" ON "LeadEvent"("organizationId", "createdAt");
CREATE INDEX "LeadImportJob_organizationId_status_createdAt_idx" ON "LeadImportJob"("organizationId", "status", "createdAt");
CREATE INDEX "LeadImportJob_status_createdAt_idx" ON "LeadImportJob"("status", "createdAt");
-- AddForeignKey
ALTER TABLE "LeadStage" ADD CONSTRAINT "LeadStage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Lead" ADD CONSTRAINT "Lead_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Lead" ADD CONSTRAINT "Lead_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "LeadStage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Lead" ADD CONSTRAINT "Lead_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Lead" ADD CONSTRAINT "Lead_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "LeadEvent" ADD CONSTRAINT "LeadEvent_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "LeadEvent" ADD CONSTRAINT "LeadEvent_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "LeadImportJob" ADD CONSTRAINT "LeadImportJob_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Lead" ADD COLUMN IF NOT EXISTS "logoStorageKey" TEXT;
@@ -0,0 +1,21 @@
-- AlterTable LeadForm: publish → Lead connection
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "createLead" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "leadStageId" TEXT;
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "duplicateMode" TEXT NOT NULL DEFAULT 'update';
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "viewCount" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "startCount" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "submitCount" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "leadCreateCount" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "duplicateCount" INTEGER NOT NULL DEFAULT 0;
-- Existing forms: keep person/deal behavior unless already lead-oriented
ALTER TABLE "LeadForm" ALTER COLUMN "createPerson" SET DEFAULT false;
ALTER TABLE "LeadForm" ALTER COLUMN "createDeal" SET DEFAULT false;
-- AlterTable LeadFormSubmission
ALTER TABLE "LeadFormSubmission" ADD COLUMN IF NOT EXISTS "leadId" TEXT;
ALTER TABLE "LeadFormSubmission" ADD COLUMN IF NOT EXISTS "status" TEXT NOT NULL DEFAULT 'created';
ALTER TABLE "LeadFormSubmission" ADD COLUMN IF NOT EXISTS "utm" JSONB;
ALTER TABLE "LeadFormSubmission" ADD COLUMN IF NOT EXISTS "meta" JSONB;
CREATE INDEX IF NOT EXISTS "LeadFormSubmission_leadId_idx" ON "LeadFormSubmission"("leadId");
@@ -0,0 +1,45 @@
-- CreateTable
CREATE TABLE "ConversationSettings" (
"organizationId" TEXT NOT NULL,
"chatHost" TEXT NOT NULL,
"apiKeyCipher" TEXT NOT NULL,
"apiKeyIv" TEXT NOT NULL,
"apiKeyTag" TEXT NOT NULL,
"uploadUrl" TEXT NOT NULL DEFAULT '',
"downloadUrl" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ConversationSettings_pkey" PRIMARY KEY ("organizationId")
);
-- CreateTable
CREATE TABLE "ConversationRoom" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"externalRoomId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"name" TEXT,
"dmUserMinId" TEXT,
"dmUserMaxId" TEXT,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ConversationRoom_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ConversationRoom_organizationId_externalRoomId_key" ON "ConversationRoom"("organizationId", "externalRoomId");
-- CreateIndex
CREATE UNIQUE INDEX "ConversationRoom_organizationId_dmUserMinId_dmUserMaxId_key" ON "ConversationRoom"("organizationId", "dmUserMinId", "dmUserMaxId");
-- CreateIndex
CREATE INDEX "ConversationRoom_organizationId_kind_idx" ON "ConversationRoom"("organizationId", "kind");
-- AddForeignKey
ALTER TABLE "ConversationSettings" ADD CONSTRAINT "ConversationSettings_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ConversationRoom" ADD CONSTRAINT "ConversationRoom_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "ConversationHiddenMessage" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"externalRoomId" TEXT NOT NULL,
"messageId" TEXT NOT NULL,
"hiddenByUserId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ConversationHiddenMessage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ConversationHiddenMessage_organizationId_externalRoomId_idx" ON "ConversationHiddenMessage"("organizationId", "externalRoomId");
-- CreateIndex
CREATE UNIQUE INDEX "ConversationHiddenMessage_organizationId_externalRoomId_messageId_key" ON "ConversationHiddenMessage"("organizationId", "externalRoomId", "messageId");
-- AddForeignKey
ALTER TABLE "ConversationHiddenMessage" ADD CONSTRAINT "ConversationHiddenMessage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "ConversationHiddenMessage" ADD COLUMN "scope" TEXT NOT NULL DEFAULT 'all';
-- DropIndex
DROP INDEX "ConversationHiddenMessage_organizationId_externalRoomId_messageId_key";
-- CreateIndex
CREATE UNIQUE INDEX "ConversationHiddenMessage_organizationId_externalRoomId_messageId_scope_hiddenByUserId_key" ON "ConversationHiddenMessage"("organizationId", "externalRoomId", "messageId", "scope", "hiddenByUserId");
@@ -0,0 +1,42 @@
-- CreateTable
CREATE TABLE "ConversationPinnedMessage" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"externalRoomId" TEXT NOT NULL,
"messageId" TEXT NOT NULL,
"preview" TEXT NOT NULL DEFAULT '',
"pinnedByUserId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ConversationPinnedMessage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ConversationRoomUserPin" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"externalRoomId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ConversationRoomUserPin_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ConversationPinnedMessage_organizationId_externalRoomId_key" ON "ConversationPinnedMessage"("organizationId", "externalRoomId");
-- CreateIndex
CREATE INDEX "ConversationPinnedMessage_organizationId_externalRoomId_idx" ON "ConversationPinnedMessage"("organizationId", "externalRoomId");
-- CreateIndex
CREATE UNIQUE INDEX "ConversationRoomUserPin_organizationId_userId_externalRoomId_key" ON "ConversationRoomUserPin"("organizationId", "userId", "externalRoomId");
-- CreateIndex
CREATE INDEX "ConversationRoomUserPin_organizationId_userId_idx" ON "ConversationRoomUserPin"("organizationId", "userId");
-- AddForeignKey
ALTER TABLE "ConversationPinnedMessage" ADD CONSTRAINT "ConversationPinnedMessage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ConversationRoomUserPin" ADD CONSTRAINT "ConversationRoomUserPin_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "ConversationRoomMember" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"externalRoomId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'member',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ConversationRoomMember_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ConversationRoomMember_organizationId_externalRoomId_userId_key" ON "ConversationRoomMember"("organizationId", "externalRoomId", "userId");
-- CreateIndex
CREATE INDEX "ConversationRoomMember_organizationId_externalRoomId_idx" ON "ConversationRoomMember"("organizationId", "externalRoomId");
-- AddForeignKey
ALTER TABLE "ConversationRoomMember" ADD CONSTRAINT "ConversationRoomMember_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,33 @@
-- Mute per-user (smart: mentions can still notify)
CREATE TABLE "ConversationRoomUserMute" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"externalRoomId" TEXT NOT NULL,
"allowMentions" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ConversationRoomUserMute_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "ConversationRoomUserMute_organizationId_userId_externalRoomId_key" ON "ConversationRoomUserMute"("organizationId", "userId", "externalRoomId");
CREATE INDEX "ConversationRoomUserMute_organizationId_userId_idx" ON "ConversationRoomUserMute"("organizationId", "userId");
ALTER TABLE "ConversationRoomUserMute" ADD CONSTRAINT "ConversationRoomUserMute_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Message emoji reactions
CREATE TABLE "ConversationMessageReaction" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"externalRoomId" TEXT NOT NULL,
"messageId" TEXT NOT NULL,
"emoji" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ConversationMessageReaction_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "ConversationMessageReaction_organizationId_externalRoomId_messageId_userId_emoji_key" ON "ConversationMessageReaction"("organizationId", "externalRoomId", "messageId", "userId", "emoji");
CREATE INDEX "ConversationMessageReaction_organizationId_externalRoomId_idx" ON "ConversationMessageReaction"("organizationId", "externalRoomId");
ALTER TABLE "ConversationMessageReaction" ADD CONSTRAINT "ConversationMessageReaction_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,122 @@
-- Extend AutomationTrigger
CREATE TYPE "AutomationTrigger_new" AS ENUM (
'person_created',
'lead_submitted',
'deal_created',
'deal_stage_changed',
'deal_won',
'deal_lost',
'ticket_created',
'ticket_resolved',
'invoice_issued',
'invoice_paid',
'activity_done',
'sms_link_clicked'
);
ALTER TABLE "AutomationRule"
ALTER COLUMN "trigger" TYPE "AutomationTrigger_new"
USING ("trigger"::text::"AutomationTrigger_new");
ALTER TYPE "AutomationTrigger" RENAME TO "AutomationTrigger_old";
ALTER TYPE "AutomationTrigger_new" RENAME TO "AutomationTrigger";
DROP TYPE "AutomationTrigger_old";
-- Extend MessageAutomationTrigger
CREATE TYPE "MessageAutomationTrigger_new" AS ENUM (
'person_created',
'lead_submitted',
'deal_created',
'deal_stage_changed',
'deal_won',
'deal_lost',
'ticket_created',
'ticket_resolved',
'invoice_issued',
'invoice_paid',
'activity_created',
'activity_done',
'sms_link_clicked'
);
ALTER TABLE "MessageAutomationRule"
ALTER COLUMN "trigger" TYPE "MessageAutomationTrigger_new"
USING ("trigger"::text::"MessageAutomationTrigger_new");
ALTER TABLE "MessageAutomationJob"
ALTER COLUMN "trigger" TYPE "MessageAutomationTrigger_new"
USING ("trigger"::text::"MessageAutomationTrigger_new");
ALTER TYPE "MessageAutomationTrigger" RENAME TO "MessageAutomationTrigger_old";
ALTER TYPE "MessageAutomationTrigger_new" RENAME TO "MessageAutomationTrigger";
DROP TYPE "MessageAutomationTrigger_old";
-- Recipient: optional leadId for future Lead campaigns
ALTER TABLE "SmsCampaignRecipient" ADD COLUMN "leadId" TEXT;
CREATE INDEX "SmsCampaignRecipient_personId_idx" ON "SmsCampaignRecipient"("personId");
CREATE INDEX "SmsCampaignRecipient_leadId_idx" ON "SmsCampaignRecipient"("leadId");
-- TrackingLink
CREATE TABLE "TrackingLink" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"originalUrl" TEXT NOT NULL,
"channel" TEXT NOT NULL DEFAULT 'sms',
"sourceType" TEXT NOT NULL,
"sourceId" TEXT NOT NULL,
"recipientType" TEXT,
"recipientId" TEXT,
"smsRecipientId" TEXT,
"personId" TEXT,
"leadId" TEXT,
"clickCount" INTEGER NOT NULL DEFAULT 0,
"firstClickedAt" TIMESTAMP(3),
"lastClickedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TrackingLink_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "TrackingLink_token_key" ON "TrackingLink"("token");
CREATE INDEX "TrackingLink_organizationId_sourceType_sourceId_idx" ON "TrackingLink"("organizationId", "sourceType", "sourceId");
CREATE INDEX "TrackingLink_organizationId_personId_idx" ON "TrackingLink"("organizationId", "personId");
CREATE INDEX "TrackingLink_organizationId_leadId_idx" ON "TrackingLink"("organizationId", "leadId");
CREATE INDEX "TrackingLink_smsRecipientId_idx" ON "TrackingLink"("smsRecipientId");
CREATE INDEX "TrackingLink_token_idx" ON "TrackingLink"("token");
ALTER TABLE "TrackingLink" ADD CONSTRAINT "TrackingLink_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TrackingLink" ADD CONSTRAINT "TrackingLink_smsRecipientId_fkey" FOREIGN KEY ("smsRecipientId") REFERENCES "SmsCampaignRecipient"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "TrackingLink" ADD CONSTRAINT "TrackingLink_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "TrackingLink" ADD CONSTRAINT "TrackingLink_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- TrackingEvent
CREATE TABLE "TrackingEvent" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"trackingLinkId" TEXT,
"eventType" TEXT NOT NULL,
"channel" TEXT NOT NULL DEFAULT 'sms',
"sourceType" TEXT,
"sourceId" TEXT,
"recipientType" TEXT,
"recipientId" TEXT,
"personId" TEXT,
"leadId" TEXT,
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TrackingEvent_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "TrackingEvent_organizationId_eventType_occurredAt_idx" ON "TrackingEvent"("organizationId", "eventType", "occurredAt");
CREATE INDEX "TrackingEvent_organizationId_sourceType_sourceId_eventType_idx" ON "TrackingEvent"("organizationId", "sourceType", "sourceId", "eventType");
CREATE INDEX "TrackingEvent_organizationId_personId_eventType_idx" ON "TrackingEvent"("organizationId", "personId", "eventType");
CREATE INDEX "TrackingEvent_organizationId_leadId_eventType_idx" ON "TrackingEvent"("organizationId", "leadId", "eventType");
CREATE INDEX "TrackingEvent_trackingLinkId_occurredAt_idx" ON "TrackingEvent"("trackingLinkId", "occurredAt");
ALTER TABLE "TrackingEvent" ADD CONSTRAINT "TrackingEvent_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TrackingEvent" ADD CONSTRAINT "TrackingEvent_trackingLinkId_fkey" FOREIGN KEY ("trackingLinkId") REFERENCES "TrackingLink"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "TrackingEvent" ADD CONSTRAINT "TrackingEvent_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "TrackingEvent" ADD CONSTRAINT "TrackingEvent_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "instagram" TEXT;
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "addresses" JSONB NOT NULL DEFAULT '[]';
-- AlterTable
ALTER TABLE "Lead" ADD COLUMN IF NOT EXISTS "instagram" TEXT;
ALTER TABLE "Lead" ADD COLUMN IF NOT EXISTS "addresses" JSONB NOT NULL DEFAULT '[]';
@@ -0,0 +1,17 @@
-- OrgRole hierarchy (parent / child sub-roles)
ALTER TABLE "OrgRole" ADD COLUMN IF NOT EXISTS "parentId" TEXT;
CREATE INDEX IF NOT EXISTS "OrgRole_parentId_idx" ON "OrgRole"("parentId");
CREATE INDEX IF NOT EXISTS "OrgRole_organizationId_parentId_idx" ON "OrgRole"("organizationId", "parentId");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'OrgRole_parentId_fkey'
) THEN
ALTER TABLE "OrgRole"
ADD CONSTRAINT "OrgRole_parentId_fkey"
FOREIGN KEY ("parentId") REFERENCES "OrgRole"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
END $$;
@@ -0,0 +1,35 @@
-- Multi-assignee support for activities (implicit many-to-many)
CREATE TABLE IF NOT EXISTS "_ActivityAssignees" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "_ActivityAssignees_AB_unique" ON "_ActivityAssignees"("A", "B");
CREATE INDEX IF NOT EXISTS "_ActivityAssignees_B_index" ON "_ActivityAssignees"("B");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = '_ActivityAssignees_A_fkey'
) THEN
ALTER TABLE "_ActivityAssignees"
ADD CONSTRAINT "_ActivityAssignees_A_fkey"
FOREIGN KEY ("A") REFERENCES "Activity"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = '_ActivityAssignees_B_fkey'
) THEN
ALTER TABLE "_ActivityAssignees"
ADD CONSTRAINT "_ActivityAssignees_B_fkey"
FOREIGN KEY ("B") REFERENCES "User"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- Backfill from legacy single assigneeId
INSERT INTO "_ActivityAssignees" ("A", "B")
SELECT a."id", a."assigneeId"
FROM "Activity" a
WHERE a."assigneeId" IS NOT NULL
ON CONFLICT DO NOTHING;
@@ -0,0 +1,27 @@
-- AlterTable
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "previousFirstName" TEXT;
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "previousLastName" TEXT;
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "importSource" TEXT NOT NULL DEFAULT 'manual';
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "sourceRef" TEXT;
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Person_organizationId_importSource_idx" ON "Person"("organizationId", "importSource");
CREATE INDEX IF NOT EXISTS "Person_organizationId_sourceRef_idx" ON "Person"("organizationId", "sourceRef");
-- Backfill from legacy customFields / notes
UPDATE "Person"
SET
"importSource" = 'didar',
"sourceRef" = COALESCE(NULLIF(BTRIM("sourceRef"), ''), NULLIF(BTRIM("customFields"->>'didarId'), ''))
WHERE COALESCE(BTRIM("customFields"->>'didarId'), '') <> '';
UPDATE "Person"
SET "importSource" = 'tahesab'
WHERE "importSource" = 'manual'
AND notes ILIKE '%ته‌حساب%';
UPDATE "Person"
SET "importSource" = 'excel'
WHERE "importSource" = 'manual'
AND COALESCE(BTRIM("customFields"->>'customerCode'), '') <> ''
AND COALESCE(BTRIM("customFields"->>'didarId'), '') = '';
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE IF NOT EXISTS "UserTablePreference" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"tableKey" TEXT NOT NULL,
"hiddenColumns" TEXT[] DEFAULT ARRAY[]::TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserTablePreference_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "UserTablePreference_userId_organizationId_tableKey_key" ON "UserTablePreference"("userId", "organizationId", "tableKey");
CREATE INDEX IF NOT EXISTS "UserTablePreference_userId_organizationId_idx" ON "UserTablePreference"("userId", "organizationId");
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "UserTablePreference" ADD CONSTRAINT "UserTablePreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
@@ -0,0 +1,14 @@
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "previousImportSource" TEXT;
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "previousSourceRef" TEXT;
CREATE INDEX IF NOT EXISTS "Person_organizationId_previousSourceRef_idx" ON "Person"("organizationId", "previousSourceRef");
-- اشخاصی که قبلاً از دیدار آمده‌اند و بعد با ته‌حساب بازنویسی شده‌اند: کد دیدار را به‌عنوان کد قبلی بازیابی کن
UPDATE "Person"
SET "previousImportSource" = 'didar',
"previousSourceRef" = TRIM("customFields"->>'didarId')
WHERE "importSource" = 'tahesab'
AND "previousSourceRef" IS NULL
AND "customFields" IS NOT NULL
AND jsonb_typeof("customFields") = 'object'
AND COALESCE(TRIM("customFields"->>'didarId'), '') <> '';
@@ -0,0 +1,33 @@
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "code" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "importSource" TEXT NOT NULL DEFAULT 'manual';
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "sourceRef" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "economicCode" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "registrationNumber" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "otherPhones" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "fax" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "otherEmails" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "province" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "city" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "postalCode" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "groupName" TEXT;
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "registeredAt" TIMESTAMP(3);
CREATE INDEX IF NOT EXISTS "Company_organizationId_sourceRef_idx" ON "Company"("organizationId", "sourceRef");
CREATE INDEX IF NOT EXISTS "Company_organizationId_code_idx" ON "Company"("organizationId", "code");
-- شرکت‌هایی که از ورود اشخاص دیدار ساخته شده‌اند: کد دیدار شرکت را از customFields اشخاص بازیابی کن
UPDATE "Company" c
SET "sourceRef" = sub.didar_code,
"importSource" = 'didar'
FROM (
SELECT DISTINCT ON (p."companyId")
p."companyId" AS company_id,
TRIM(p."customFields"->>'companyDidarId') AS didar_code
FROM "Person" p
WHERE p."companyId" IS NOT NULL
AND jsonb_typeof(p."customFields") = 'object'
AND COALESCE(TRIM(p."customFields"->>'companyDidarId'), '') <> ''
ORDER BY p."companyId", p."createdAt"
) sub
WHERE c."id" = sub.company_id
AND c."sourceRef" IS NULL;
@@ -0,0 +1,3 @@
ALTER TABLE "Activity" ADD COLUMN IF NOT EXISTS "reminderSentAt" TIMESTAMP(3);
CREATE INDEX IF NOT EXISTS "Activity_reminderAt_reminderSentAt_idx" ON "Activity"("reminderAt", "reminderSentAt");
@@ -0,0 +1 @@
ALTER TABLE "Membership" ADD COLUMN IF NOT EXISTS "dataVisibilityScopes" JSONB NOT NULL DEFAULT '{}';
@@ -0,0 +1,20 @@
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "code" TEXT;
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "importSource" TEXT NOT NULL DEFAULT 'manual';
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "sourceRef" TEXT;
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "probability" INTEGER;
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "sourceDetail" TEXT;
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "referrer" TEXT;
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "lostNote" TEXT;
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "closedAt" TIMESTAMP(3);
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "createdById" TEXT;
DO $$ BEGIN
ALTER TABLE "Deal" ADD CONSTRAINT "Deal_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
CREATE INDEX IF NOT EXISTS "Deal_organizationId_sourceRef_idx" ON "Deal"("organizationId", "sourceRef");
UPDATE "Deal"
SET "importSource" = 'didar', "sourceRef" = "customFields"->>'didarId'
WHERE "sourceRef" IS NULL AND COALESCE("customFields"->>'didarId', '') <> '';
@@ -0,0 +1,162 @@
-- CreateTable
CREATE TABLE IF NOT EXISTS "MontifyConnection" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"baseUrl" TEXT NOT NULL,
"storeId" TEXT NOT NULL DEFAULT '',
"tokenCiphertext" TEXT NOT NULL,
"tokenIv" TEXT NOT NULL,
"tokenTag" TEXT NOT NULL,
"tokenLast4" TEXT NOT NULL DEFAULT '',
"enabled" BOOLEAN NOT NULL DEFAULT true,
"webhookRemoteId" TEXT,
"webhookSecretCiphertext" TEXT,
"webhookSecretIv" TEXT,
"webhookSecretTag" TEXT,
"customersCursor" TIMESTAMP(3),
"syncStatus" TEXT NOT NULL DEFAULT 'idle',
"syncStartedAt" TIMESTAMP(3),
"lastSyncAt" TIMESTAMP(3),
"lastSyncResult" JSONB,
"lastHealthAt" TIMESTAMP(3),
"lastHealthOk" BOOLEAN,
"lastError" TEXT,
"lastWebhookAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MontifyConnection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "MontifyLink" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"externalId" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"externalUpdatedAt" TIMESTAMP(3),
"ordersSyncedAt" TIMESTAMP(3),
"data" JSONB,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MontifyLink_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "MontifyOrder" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"externalId" TEXT NOT NULL,
"customerExternalId" TEXT,
"personId" TEXT,
"orderReference" TEXT,
"orderStatus" TEXT,
"paymentStatus" TEXT,
"paymentType" TEXT,
"totalPrice" DECIMAL(20,2),
"discountTotalPrice" DECIMAL(20,2),
"totalDiscounts" DECIMAL(20,2),
"totalTax" DECIMAL(20,2),
"totalQty" DECIMAL(14,3),
"profit" DECIMAL(20,2),
"percentProfit" DECIMAL(10,3),
"sumGold" DECIMAL(14,3),
"sumSilver" DECIMAL(14,3),
"address" JSONB,
"note" TEXT,
"cancelReason" TEXT,
"cancelledAt" TIMESTAMP(3),
"closedAt" TIMESTAMP(3),
"codeDiscount" TEXT,
"items" JSONB NOT NULL DEFAULT '[]',
"externalCreatedAt" TIMESTAMP(3),
"externalUpdatedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"raw" JSONB,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MontifyOrder_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "MontifyTransaction" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"externalId" TEXT NOT NULL,
"customerExternalId" TEXT,
"personId" TEXT,
"type" TEXT,
"amount" DECIMAL(24,4),
"currency" TEXT,
"status" TEXT,
"referenceCode" TEXT,
"externalCreatedAt" TIMESTAMP(3),
"raw" JSONB,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MontifyTransaction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "MontifyWebhookDelivery" (
"id" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"deliveryId" TEXT NOT NULL,
"event" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'received',
"error" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MontifyWebhookDelivery_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "MontifyConnection_organizationId_enabled_idx" ON "MontifyConnection"("organizationId", "enabled");
CREATE UNIQUE INDEX IF NOT EXISTS "MontifyLink_connectionId_entityType_externalId_key" ON "MontifyLink"("connectionId", "entityType", "externalId");
CREATE INDEX IF NOT EXISTS "MontifyLink_organizationId_entityType_entityId_idx" ON "MontifyLink"("organizationId", "entityType", "entityId");
CREATE UNIQUE INDEX IF NOT EXISTS "MontifyOrder_connectionId_externalId_key" ON "MontifyOrder"("connectionId", "externalId");
CREATE INDEX IF NOT EXISTS "MontifyOrder_organizationId_personId_idx" ON "MontifyOrder"("organizationId", "personId");
CREATE INDEX IF NOT EXISTS "MontifyOrder_organizationId_externalCreatedAt_idx" ON "MontifyOrder"("organizationId", "externalCreatedAt");
CREATE UNIQUE INDEX IF NOT EXISTS "MontifyTransaction_connectionId_externalId_key" ON "MontifyTransaction"("connectionId", "externalId");
CREATE INDEX IF NOT EXISTS "MontifyTransaction_organizationId_personId_idx" ON "MontifyTransaction"("organizationId", "personId");
CREATE UNIQUE INDEX IF NOT EXISTS "MontifyWebhookDelivery_connectionId_deliveryId_key" ON "MontifyWebhookDelivery"("connectionId", "deliveryId");
CREATE INDEX IF NOT EXISTS "MontifyWebhookDelivery_connectionId_createdAt_idx" ON "MontifyWebhookDelivery"("connectionId", "createdAt");
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "MontifyConnection" ADD CONSTRAINT "MontifyConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "MontifyLink" ADD CONSTRAINT "MontifyLink_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MontifyConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "MontifyOrder" ADD CONSTRAINT "MontifyOrder_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MontifyConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "MontifyTransaction" ADD CONSTRAINT "MontifyTransaction_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MontifyConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "MontifyWebhookDelivery" ADD CONSTRAINT "MontifyWebhookDelivery_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MontifyConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ماژول جدید برای محیط‌هایی که فهرست ماژول ذخیره‌شده دارند و ووکامرس فعال است
UPDATE "Organization"
SET "enabledModules" = array_append("enabledModules", 'montify')
WHERE cardinality("enabledModules") > 0
AND 'woocommerce' = ANY("enabledModules")
AND NOT ('montify' = ANY("enabledModules"));
@@ -0,0 +1,247 @@
-- CreateEnum
CREATE TYPE "RoutineTaskStatus" AS ENUM ('todo', 'in_progress', 'completed', 'overdue', 'skipped', 'cancelled');
-- CreateEnum
CREATE TYPE "RoutineRecurrence" AS ENUM ('daily', 'weekdays', 'weekly', 'biweekly', 'monthly', 'every_n_days', 'specific_dates');
-- CreateEnum
CREATE TYPE "RoutineTaskSource" AS ENUM ('routine', 'manual', 'follow_up', 'lead', 'customer', 'deal');
-- CreateTable
CREATE TABLE "RoutineCategory" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT NOT NULL DEFAULT '#14b8a6',
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RoutineCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Routine" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"assigneeId" TEXT,
"teamId" TEXT,
"categoryId" TEXT,
"priority" "ActivityPriority" NOT NULL DEFAULT 'medium',
"timeOfDay" TEXT NOT NULL DEFAULT '09:00',
"durationMinutes" INTEGER,
"startDate" DATE NOT NULL,
"endDate" DATE,
"recurrence" "RoutineRecurrence" NOT NULL DEFAULT 'daily',
"weekdays" INTEGER[] DEFAULT ARRAY[]::INTEGER[],
"intervalDays" INTEGER,
"monthDay" INTEGER,
"specificDates" DATE[],
"isMandatory" BOOLEAN NOT NULL DEFAULT true,
"allowSkip" BOOLEAN NOT NULL DEFAULT false,
"skipReasonRequired" BOOLEAN NOT NULL DEFAULT true,
"reportRequired" BOOLEAN NOT NULL DEFAULT false,
"allowAfterDeadline" BOOLEAN NOT NULL DEFAULT true,
"carryOverOverdue" BOOLEAN NOT NULL DEFAULT false,
"entityLink" TEXT NOT NULL DEFAULT 'none',
"reminderMinutesBefore" INTEGER,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdById" TEXT,
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Routine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RoutineTask" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"routineId" TEXT,
"assigneeId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"categoryId" TEXT,
"priority" "ActivityPriority" NOT NULL DEFAULT 'medium',
"status" "RoutineTaskStatus" NOT NULL DEFAULT 'todo',
"scheduledFor" DATE NOT NULL,
"dueAt" TIMESTAMP(3),
"startedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"resultNote" TEXT,
"skipReason" TEXT,
"source" "RoutineTaskSource" NOT NULL DEFAULT 'manual',
"isMandatory" BOOLEAN NOT NULL DEFAULT false,
"leadId" TEXT,
"personId" TEXT,
"companyId" TEXT,
"dealId" TEXT,
"invoiceId" TEXT,
"activityId" TEXT,
"reminderAt" TIMESTAMP(3),
"reminderSentAt" TIMESTAMP(3),
"overdueNotifiedAt" TIMESTAMP(3),
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RoutineTask_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RoutineTaskEvent" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"taskId" TEXT NOT NULL,
"actorId" TEXT,
"type" TEXT NOT NULL,
"fromStatus" "RoutineTaskStatus",
"toStatus" "RoutineTaskStatus",
"note" TEXT,
"meta" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RoutineTaskEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RoutineUserSettings" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"remindBeforeMinutes" INTEGER DEFAULT 30,
"endOfDaySummary" BOOLEAN NOT NULL DEFAULT true,
"overdueAlert" BOOLEAN NOT NULL DEFAULT true,
"summaryHour" INTEGER NOT NULL DEFAULT 16,
"lastSummaryOn" DATE,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RoutineUserSettings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "RoutineCategory_organizationId_sortOrder_idx" ON "RoutineCategory"("organizationId", "sortOrder");
-- CreateIndex
CREATE UNIQUE INDEX "RoutineCategory_organizationId_name_key" ON "RoutineCategory"("organizationId", "name");
-- CreateIndex
CREATE INDEX "Routine_organizationId_isActive_deletedAt_idx" ON "Routine"("organizationId", "isActive", "deletedAt");
-- CreateIndex
CREATE INDEX "Routine_assigneeId_idx" ON "Routine"("assigneeId");
-- CreateIndex
CREATE INDEX "Routine_teamId_idx" ON "Routine"("teamId");
-- CreateIndex
CREATE INDEX "RoutineTask_organizationId_assigneeId_scheduledFor_idx" ON "RoutineTask"("organizationId", "assigneeId", "scheduledFor");
-- CreateIndex
CREATE INDEX "RoutineTask_organizationId_status_dueAt_idx" ON "RoutineTask"("organizationId", "status", "dueAt");
-- CreateIndex
CREATE INDEX "RoutineTask_reminderAt_reminderSentAt_idx" ON "RoutineTask"("reminderAt", "reminderSentAt");
-- CreateIndex
CREATE INDEX "RoutineTask_leadId_idx" ON "RoutineTask"("leadId");
-- CreateIndex
CREATE INDEX "RoutineTask_personId_idx" ON "RoutineTask"("personId");
-- CreateIndex
CREATE INDEX "RoutineTask_companyId_idx" ON "RoutineTask"("companyId");
-- CreateIndex
CREATE INDEX "RoutineTask_dealId_idx" ON "RoutineTask"("dealId");
-- CreateIndex
CREATE INDEX "RoutineTask_activityId_idx" ON "RoutineTask"("activityId");
-- CreateIndex
CREATE UNIQUE INDEX "RoutineTask_routineId_assigneeId_scheduledFor_key" ON "RoutineTask"("routineId", "assigneeId", "scheduledFor");
-- CreateIndex
CREATE INDEX "RoutineTaskEvent_taskId_createdAt_idx" ON "RoutineTaskEvent"("taskId", "createdAt");
-- CreateIndex
CREATE INDEX "RoutineTaskEvent_organizationId_createdAt_idx" ON "RoutineTaskEvent"("organizationId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "RoutineUserSettings_organizationId_userId_key" ON "RoutineUserSettings"("organizationId", "userId");
-- AddForeignKey
ALTER TABLE "RoutineCategory" ADD CONSTRAINT "RoutineCategory_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Routine" ADD CONSTRAINT "Routine_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Routine" ADD CONSTRAINT "Routine_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Routine" ADD CONSTRAINT "Routine_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Routine" ADD CONSTRAINT "Routine_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "RoutineCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Routine" ADD CONSTRAINT "Routine_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_routineId_fkey" FOREIGN KEY ("routineId") REFERENCES "Routine"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "RoutineCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_activityId_fkey" FOREIGN KEY ("activityId") REFERENCES "Activity"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTaskEvent" ADD CONSTRAINT "RoutineTaskEvent_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTaskEvent" ADD CONSTRAINT "RoutineTaskEvent_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "RoutineTask"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineTaskEvent" ADD CONSTRAINT "RoutineTaskEvent_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineUserSettings" ADD CONSTRAINT "RoutineUserSettings_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutineUserSettings" ADD CONSTRAINT "RoutineUserSettings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Enable the new routines module for organizations with an explicit module list
UPDATE "Organization"
SET "enabledModules" = array_append("enabledModules", 'routines')
WHERE cardinality("enabledModules") > 0 AND NOT ('routines' = ANY("enabledModules"));
@@ -0,0 +1,2 @@
-- Rename the default sales pipeline: «کاریز فروش» → «مسیر فروش»
UPDATE "Pipeline" SET "name" = 'مسیر فروش' WHERE "name" = 'کاریز فروش';
@@ -0,0 +1,31 @@
-- CreateTable
CREATE TABLE "ActivityStage" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"color" TEXT NOT NULL DEFAULT 'green',
"kind" "ActivityStatus" NOT NULL DEFAULT 'open',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ActivityStage_pkey" PRIMARY KEY ("id")
);
-- AlterTable
ALTER TABLE "Activity" ADD COLUMN "stageId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "ActivityStage_organizationId_name_key" ON "ActivityStage"("organizationId", "name");
-- CreateIndex
CREATE INDEX "ActivityStage_organizationId_position_idx" ON "ActivityStage"("organizationId", "position");
-- CreateIndex
CREATE INDEX "Activity_stageId_idx" ON "Activity"("stageId");
-- AddForeignKey
ALTER TABLE "ActivityStage" ADD CONSTRAINT "ActivityStage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "ActivityStage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,37 @@
-- AlterTable
ALTER TABLE "Activity" ADD COLUMN "fields" JSONB,
ADD COLUMN "participantIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "typeDefId" TEXT;
-- CreateTable
CREATE TABLE "ActivityTypeDef" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"baseType" "ActivityType" NOT NULL DEFAULT 'task',
"icon" TEXT NOT NULL DEFAULT 'check',
"color" TEXT NOT NULL DEFAULT 'green',
"fields" JSONB NOT NULL DEFAULT '[]',
"position" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isSystem" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ActivityTypeDef_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ActivityTypeDef_organizationId_position_idx" ON "ActivityTypeDef"("organizationId", "position");
-- CreateIndex
CREATE UNIQUE INDEX "ActivityTypeDef_organizationId_name_key" ON "ActivityTypeDef"("organizationId", "name");
-- CreateIndex
CREATE INDEX "Activity_typeDefId_idx" ON "Activity"("typeDefId");
-- AddForeignKey
ALTER TABLE "ActivityTypeDef" ADD CONSTRAINT "ActivityTypeDef_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_typeDefId_fkey" FOREIGN KEY ("typeDefId") REFERENCES "ActivityTypeDef"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,255 @@
-- CreateTable
CREATE TABLE "GamificationSettings" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"levels" JSONB NOT NULL DEFAULT '[]',
"monthlyGoal" INTEGER NOT NULL DEFAULT 1500,
"showFullLeaderboard" BOOLEAN NOT NULL DEFAULT true,
"enabledAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GamificationSettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GamificationRule" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"event" TEXT NOT NULL,
"score" INTEGER NOT NULL DEFAULT 0,
"coins" INTEGER NOT NULL DEFAULT 0,
"conditions" JSONB NOT NULL DEFAULT '[]',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"maxPerDay" INTEGER,
"maxCoinsPerDay" INTEGER,
"maxPerUser" INTEGER,
"cooldownMinutes" INTEGER,
"oncePerEntity" BOOLEAN NOT NULL DEFAULT false,
"firstTimeOnly" BOOLEAN NOT NULL DEFAULT false,
"requiresApproval" BOOLEAN NOT NULL DEFAULT false,
"roles" TEXT[] DEFAULT ARRAY[]::TEXT[],
"teamIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GamificationRule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GamificationTransaction" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"event" TEXT,
"ruleId" TEXT,
"bonusId" TEXT,
"badgeId" TEXT,
"score" INTEGER NOT NULL DEFAULT 0,
"coins" INTEGER NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'approved',
"title" TEXT NOT NULL,
"entityType" TEXT,
"entityId" TEXT,
"entityTitle" TEXT,
"idempotencyKey" TEXT,
"reason" TEXT,
"createdById" TEXT,
"decidedById" TEXT,
"decidedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GamificationTransaction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GamificationWallet" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"score" INTEGER NOT NULL DEFAULT 0,
"coins" INTEGER NOT NULL DEFAULT 0,
"coinsEarned" INTEGER NOT NULL DEFAULT 0,
"bestMonthScore" INTEGER NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GamificationWallet_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GamificationReward" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"cost" INTEGER NOT NULL,
"icon" TEXT NOT NULL DEFAULT 'gift',
"color" TEXT NOT NULL DEFAULT '#14b8a6',
"stock" INTEGER,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"requiresApproval" BOOLEAN NOT NULL DEFAULT true,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GamificationReward_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GamificationRedemption" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"rewardId" TEXT NOT NULL,
"cost" INTEGER NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"note" TEXT,
"decisionNote" TEXT,
"decidedById" TEXT,
"decidedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GamificationRedemption_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GamificationBadge" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"icon" TEXT NOT NULL DEFAULT 'medal',
"color" TEXT NOT NULL DEFAULT '#f59e0b',
"metric" TEXT NOT NULL,
"event" TEXT,
"threshold" INTEGER NOT NULL,
"coins" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GamificationBadge_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GamificationUserBadge" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"badgeId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"earnedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GamificationUserBadge_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GamificationBonus" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"kind" TEXT NOT NULL,
"event" TEXT,
"threshold" INTEGER NOT NULL DEFAULT 1,
"period" TEXT NOT NULL DEFAULT 'month',
"score" INTEGER NOT NULL DEFAULT 0,
"coins" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GamificationBonus_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "GamificationSettings_organizationId_key" ON "GamificationSettings"("organizationId");
-- CreateIndex
CREATE INDEX "GamificationRule_organizationId_event_isActive_idx" ON "GamificationRule"("organizationId", "event", "isActive");
-- CreateIndex
CREATE INDEX "GamificationTransaction_organizationId_createdAt_idx" ON "GamificationTransaction"("organizationId", "createdAt");
-- CreateIndex
CREATE INDEX "GamificationTransaction_organizationId_userId_createdAt_idx" ON "GamificationTransaction"("organizationId", "userId", "createdAt");
-- CreateIndex
CREATE INDEX "GamificationTransaction_organizationId_ruleId_userId_create_idx" ON "GamificationTransaction"("organizationId", "ruleId", "userId", "createdAt");
-- CreateIndex
CREATE INDEX "GamificationTransaction_organizationId_status_idx" ON "GamificationTransaction"("organizationId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "GamificationTransaction_organizationId_idempotencyKey_key" ON "GamificationTransaction"("organizationId", "idempotencyKey");
-- CreateIndex
CREATE INDEX "GamificationWallet_organizationId_score_idx" ON "GamificationWallet"("organizationId", "score");
-- CreateIndex
CREATE UNIQUE INDEX "GamificationWallet_organizationId_userId_key" ON "GamificationWallet"("organizationId", "userId");
-- CreateIndex
CREATE INDEX "GamificationReward_organizationId_isActive_idx" ON "GamificationReward"("organizationId", "isActive");
-- CreateIndex
CREATE INDEX "GamificationRedemption_organizationId_status_idx" ON "GamificationRedemption"("organizationId", "status");
-- CreateIndex
CREATE INDEX "GamificationRedemption_organizationId_userId_idx" ON "GamificationRedemption"("organizationId", "userId");
-- CreateIndex
CREATE INDEX "GamificationBadge_organizationId_isActive_idx" ON "GamificationBadge"("organizationId", "isActive");
-- CreateIndex
CREATE INDEX "GamificationUserBadge_organizationId_userId_idx" ON "GamificationUserBadge"("organizationId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "GamificationUserBadge_badgeId_userId_key" ON "GamificationUserBadge"("badgeId", "userId");
-- CreateIndex
CREATE INDEX "GamificationBonus_organizationId_isActive_idx" ON "GamificationBonus"("organizationId", "isActive");
-- AddForeignKey
ALTER TABLE "GamificationSettings" ADD CONSTRAINT "GamificationSettings_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GamificationRule" ADD CONSTRAINT "GamificationRule_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GamificationTransaction" ADD CONSTRAINT "GamificationTransaction_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GamificationWallet" ADD CONSTRAINT "GamificationWallet_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GamificationReward" ADD CONSTRAINT "GamificationReward_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GamificationRedemption" ADD CONSTRAINT "GamificationRedemption_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GamificationRedemption" ADD CONSTRAINT "GamificationRedemption_rewardId_fkey" FOREIGN KEY ("rewardId") REFERENCES "GamificationReward"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GamificationBadge" ADD CONSTRAINT "GamificationBadge_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GamificationUserBadge" ADD CONSTRAINT "GamificationUserBadge_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GamificationUserBadge" ADD CONSTRAINT "GamificationUserBadge_badgeId_fkey" FOREIGN KEY ("badgeId") REFERENCES "GamificationBadge"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GamificationBonus" ADD CONSTRAINT "GamificationBonus_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Enable the new module for orgs with an explicit module list
UPDATE "Organization"
SET "enabledModules" = array_append("enabledModules", 'gamification')
WHERE cardinality("enabledModules") > 0 AND NOT ('gamification' = ANY("enabledModules"));
@@ -0,0 +1,25 @@
-- AlterTable
ALTER TABLE "Activity" ADD COLUMN "leadId" TEXT;
-- CreateTable
CREATE TABLE "AutomationCursor" (
"id" TEXT NOT NULL,
"ruleId" TEXT NOT NULL,
"stepId" TEXT NOT NULL,
"counter" INTEGER NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AutomationCursor_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Activity_leadId_idx" ON "Activity"("leadId");
-- CreateIndex
CREATE UNIQUE INDEX "AutomationCursor_ruleId_stepId_key" ON "AutomationCursor"("ruleId", "stepId");
-- AddForeignKey
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AutomationCursor" ADD CONSTRAINT "AutomationCursor_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "AutomationRule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Person" ADD COLUMN "birthDate" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "Lead" ADD COLUMN "birthDate" TIMESTAMP(3);
@@ -0,0 +1,20 @@
ALTER TABLE "Organization" ADD COLUMN "deletionRetention" TEXT NOT NULL DEFAULT 'never';
CREATE TABLE "DeletionArchive" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"model" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"snapshot" JSONB NOT NULL,
"deletedById" TEXT,
"deletedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DeletionArchive_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "DeletionArchive_organizationId_deletedAt_idx" ON "DeletionArchive"("organizationId", "deletedAt");
CREATE INDEX "DeletionArchive_organizationId_model_idx" ON "DeletionArchive"("organizationId", "model");
ALTER TABLE "DeletionArchive" ADD CONSTRAINT "DeletionArchive_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "DeletionArchive" ADD CONSTRAINT "DeletionArchive_deletedById_fkey" FOREIGN KEY ("deletedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,19 @@
-- بایگانی اطلاعات قبل: مرز محیط، مجوز عضو، و منبع فعالیت
ALTER TABLE "Organization" ADD COLUMN "priorArchiveEnabledAt" TIMESTAMP(3);
ALTER TABLE "Membership" ADD COLUMN "priorArchiveEnabled" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Membership" ADD COLUMN "priorArchiveLens" TEXT NOT NULL DEFAULT 'current';
ALTER TABLE "Activity" ADD COLUMN "importSource" TEXT NOT NULL DEFAULT 'manual';
CREATE INDEX "Activity_organizationId_importSource_idx" ON "Activity"("organizationId", "importSource");
-- فعالیت وصل به معاملهٔ دیدار از همین حالا قبلی است. بقیه بعد از روشن شدن ماژول و با تاریخ مرز پر می‌شوند.
UPDATE "Activity" AS a
SET "importSource" = 'didar'
WHERE a."importSource" = 'manual'
AND a."dealId" IS NOT NULL
AND EXISTS (
SELECT 1 FROM "Deal" AS d
WHERE d."id" = a."dealId" AND d."importSource" = 'didar'
);
@@ -0,0 +1,8 @@
-- Prior archive: promote tag + cutoff query indexes
ALTER TABLE "Deal" ADD COLUMN "promotedFromPriorAt" TIMESTAMP(3);
ALTER TABLE "Activity" ADD COLUMN "promotedFromPriorAt" TIMESTAMP(3);
CREATE INDEX "Deal_organizationId_createdAt_idx" ON "Deal"("organizationId", "createdAt");
CREATE INDEX "Activity_organizationId_createdAt_idx" ON "Activity"("organizationId", "createdAt");
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -335,7 +335,7 @@ async function main() {
pipeline = await prisma.pipeline.create({
data: {
organizationId: org.id,
name: 'کاریز فروش',
name: 'مسیر فروش',
isDefault: true,
stages: {
create: DEFAULT_STAGES.map((s, index) => ({
@@ -354,13 +354,13 @@ async function main() {
}
const servicesPipeline = await prisma.pipeline.findFirst({
where: { organizationId: org.id, name: 'کاریز خدمات' },
where: { organizationId: org.id, name: 'مسیر خدمات' },
});
if (!servicesPipeline) {
await prisma.pipeline.create({
data: {
organizationId: org.id,
name: 'کاریز خدمات',
name: 'مسیر خدمات',
isDefault: false,
stages: {
create: DEFAULT_STAGES.map((s, index) => ({
@@ -377,7 +377,7 @@ async function main() {
console.log('Services pipeline already seeded');
}
// کاریز منتظر — مطابق طراحی UI (۳ مرحله + معاملات نمونه)
// مسیر منتظر — مطابق طراحی UI (۳ مرحله + معاملات نمونه)
const WAITING_STAGES = [
{ name: 'پیگیری', isWon: false, isLost: false },
{ name: 'مذاکرات اولیه', isWon: false, isLost: false },
@@ -483,7 +483,7 @@ async function main() {
];
let waitingPipeline = await prisma.pipeline.findFirst({
where: { organizationId: org.id, name: 'کاریز منتظر' },
where: { organizationId: org.id, name: 'مسیر منتظر' },
include: { stages: { orderBy: { position: 'asc' } } },
});
@@ -491,7 +491,7 @@ async function main() {
waitingPipeline = await prisma.pipeline.create({
data: {
organizationId: org.id,
name: 'کاریز منتظر',
name: 'مسیر منتظر',
isDefault: false,
stages: {
create: WAITING_STAGES.map((s, index) => ({
@@ -504,7 +504,7 @@ async function main() {
},
include: { stages: { orderBy: { position: 'asc' } } },
});
console.log('Seeded waiting pipeline (کاریز منتظر)');
console.log('Seeded waiting pipeline (مسیر منتظر)');
} else {
console.log('Waiting pipeline already seeded');
}
@@ -534,7 +534,7 @@ async function main() {
expectedCloseAt: new Date(d.expectedCloseAt),
ownerId: owner?.id,
position: d.position,
tags: ['دمو-کاریز'],
tags: ['دمو-مسیر'],
};
}),
});
@@ -763,7 +763,7 @@ async function main() {
{
organizationId: org.id,
authorId: owner?.id,
body: 'در دمو نوا روی گزارش کاریز خیلی حساس بودند. نسخه چاپی PDF را آماده کنیم.',
body: 'در دمو نوا روی گزارش مسیر فروش خیلی حساس بودند. نسخه چاپی PDF را آماده کنیم.',
noteType: 'call_result',
color: 'sky',
tags: ['دمو'],
@@ -971,7 +971,7 @@ async function main() {
};
await ensure('deal_moved', {
title: 'جابه‌جایی معامله در کاریز',
title: 'جابه‌جایی معامله در مسیر فروش',
body: sampleDeal
? `«${sampleDeal.title}» به مرحله جدید منتقل شد`
: 'یک معامله جابه‌جا شد',
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import type { ActivityType, ImportContactsResult } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
@@ -16,35 +16,60 @@ import {
writeXlsx,
type ExcelPreviewResult,
} from '../common/excel';
import {
beginExcelImportPrepare,
chunkImportMeta,
createExcelImportCacheSession,
destroyExcelImportSession,
getExcelImportPrepareStatus,
getExcelImportSessionCaches,
resolveExcelImportChunk,
type ExcelImportLookupCaches,
type ExcelImportSessionStatus,
} from '../common/excel-import-session';
import type { ImportActivityRowsDto } from './dto';
const ACTIVITY_COLUMNS = [
{ header: '_type', key: '_type', width: 12 },
{ header: 'نوع فعالیت', key: 'kind', width: 16 },
{ header: 'عنوان فعالیت', key: 'title', width: 24 },
{ header: 'متن فعالیت', key: 'body', width: 36 },
{ header: 'نتیجه فعالیت', key: 'result', width: 20 },
{ header: 'مسئول اجرا فعالیت', key: 'assignee', width: 18 },
{ header: 'ثبت کننده فعالیت', key: 'creator', width: 18 },
{ header: 'تاریخ برنامه ریزی شده اجرا فعالیت', key: 'dueDate', width: 18 },
{ header: 'زمان برنامه ریزی شده اجرا فعالیت', key: 'dueTime', width: 14 },
{ header: 'وضعیت اجرای فعالیت', key: 'status', width: 16 },
{ header: 'تاریخ انجام شدن فعالیت', key: 'doneDate', width: 16 },
{ header: 'کد معامله فعالیت', key: 'dealCode', width: 14 },
{ header: 'کد کارت فعالیت', key: 'cardCode', width: 14 },
{ header: 'زمان انجام شدن فعالیت', key: 'doneTime', width: 14 },
{ header: 'اشخاص فعالیت', key: 'personName', width: 18 },
{ header: 'کد اشخاص فعالیت', key: 'personCode', width: 14 },
{ header: 'موبایل اشخاص فعالیت', key: 'personMobile', width: 16 },
{ header: 'شرکت های فعالیت', key: 'companyName', width: 20 },
{ header: 'کد شرکت های فعالیت', key: 'companyCode', width: 14 },
{ header: 'معامله فعالیت_1', key: 'dealTitle', width: 24 },
{ header: 'کارت فعالیت_1', key: 'cardTitle', width: 20 },
{ header: 'هشتگ', key: 'hashtag', width: 16 },
];
/** ترتیب دقیق خروجی فعالیت دیدار — برای فایل‌های بدون ردیف هدر */
const DIDAR_ACTIVITY_POSITIONAL = [
{ key: '_type', label: '_type' },
{ key: 'kind', label: 'نوع فعالیت' },
{ key: 'title', label: 'عنوان فعالیت' },
{ key: 'body', label: 'متن فعالیت' },
{ key: 'result', label: 'نتیجه فعالیت' },
{ key: 'assignee', label: 'مسئول اجرا فعالیت' },
{ key: 'creator', label: 'ثبت کننده فعالیت' },
{ key: 'dueDate', label: 'تاریخ برنامه ریزی شده اجرا فعالیت' },
{ key: 'dueTime', label: 'زمان برنامه ریزی شده اجرا فعالیت' },
{ key: 'status', label: 'وضعیت اجرای فعالیت' },
{ key: 'doneDate', label: 'تاریخ انجام شدن فعالیت' },
{ key: 'dealCode', label: 'کد معامله فعالیت' },
{ key: 'cardCode', label: 'کد کارت فعالیت' },
{ key: 'doneTime', label: 'زمان انجام شدن فعالیت' },
{ key: 'personName', label: 'اشخاص فعالیت' },
{ key: 'personCode', label: 'کد اشخاص فعالیت' },
{ key: 'personMobile', label: 'موبایل اشخاص فعالیت' },
{ key: 'companyName', label: 'شرکت های فعالیت' },
{ key: 'companyCode', label: 'کد شرکت های فعالیت' },
{ key: 'dealTitle', label: 'معامله فعالیت_1' },
{ key: 'cardTitle', label: 'کارت فعالیت_1' },
{ key: 'hashtag', label: 'هشتگ' },
] as const;
const ACTIVITY_COLUMNS = DIDAR_ACTIVITY_POSITIONAL.map((c) => ({
header: c.label,
key: c.key,
width:
c.key === 'body' || c.key === 'result'
? 36
: c.key === 'title' || c.key === 'dealTitle'
? 24
: 16,
}));
const ACTIVITY_ALIASES: Record<string, string> = {
_type: '_type',
type: '_type',
activity: '_type',
نوعفعالیت: 'kind',
عنوانفعالیت: 'title',
عنوان: 'title',
@@ -65,10 +90,27 @@ const ACTIVITY_ALIASES: Record<string, string> = {
شرکتهایفعالیت: 'companyName',
کدشرکتهایفعالیت: 'companyCode',
معاملهفعالیت1: 'dealTitle',
معاملهفعالیت_1: 'dealTitle',
کارتفعالیت1: 'cardTitle',
کارتفعالیت_1: 'cardTitle',
هشتگ: 'hashtag',
};
function looksLikeDidarActivityHeaderless(firstRow: string[]) {
const first = (firstRow[0] ?? '').trim().toLowerCase();
if (first !== 'activity' && first !== '_type') return false;
const kind = (firstRow[1] ?? '').trim();
return /تماس|پیگیری|جلسه|یادداشت|ملاقات|call|task|meeting|note|پیام/i.test(kind);
}
const ACTIVITY_EXCEL_OPTIONS = {
positionalColumns: DIDAR_ACTIVITY_POSITIONAL.map((c) => ({
key: c.key,
label: c.label,
})),
detectHeaderless: looksLikeDidarActivityHeaderless,
};
const TYPE_LABEL: Record<ActivityType, string> = {
call: 'تماس با مشتری',
meeting: 'جلسه',
@@ -109,6 +151,7 @@ export class ActivitiesExcelService {
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
assignee: { select: { fullName: true } },
assignees: { select: { fullName: true } },
createdBy: { select: { fullName: true } },
},
orderBy: [{ dueAt: 'desc' }, { createdAt: 'desc' }],
@@ -124,7 +167,9 @@ export class ActivitiesExcelService {
title: row.title,
body: row.description ?? '',
result: '',
assignee: row.assignee?.fullName ?? '',
assignee: row.assignees?.length
? row.assignees.map((a) => a.fullName).join('، ')
: (row.assignee?.fullName ?? ''),
creator: row.createdBy?.fullName ?? '',
dueDate: formatJalaliDate(row.dueAt),
dueTime: formatClock(row.dueAt),
@@ -148,20 +193,199 @@ export class ActivitiesExcelService {
}
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
return previewExcel(file, ACTIVITY_ALIASES, excelFieldsFromColumns(ACTIVITY_COLUMNS, ['title']));
return previewExcel(
file,
ACTIVITY_ALIASES,
excelFieldsFromColumns(ACTIVITY_COLUMNS, ['title']),
ACTIVITY_EXCEL_OPTIONS,
);
}
/**
* آپلود فوری + پارس در پس‌زمینه (جلوگیری از ۵۰۲ nginx روی فایل‌های بزرگ).
* وضعیت را با prepareStatus پول کنید.
*/
prepare(
actor: Actor,
file: Express.Multer.File,
mappingRaw?: string,
): ImportContactsResult {
if (!file?.buffer?.length) {
throw new BadRequestException('فایل ارسال نشده');
}
const mapping = parseExcelMapping(mappingRaw);
const sessionId = beginExcelImportPrepare(
actor.organizationId,
file.buffer,
(f) =>
readMappedRows(f, ACTIVITY_ALIASES, mapping, ACTIVITY_EXCEL_OPTIONS),
);
return {
created: 0,
skipped: 0,
errors: [],
sessionId,
status: 'parsing',
total: 0,
parsed: 0,
processed: 0,
hasMore: true,
nextOffset: 0,
};
}
prepareStatus(
actor: Actor,
sessionId: string,
): ExcelImportSessionStatus {
return getExcelImportPrepareStatus(sessionId, actor.organizationId);
}
async import(
actor: Actor,
file: Express.Multer.File,
file: Express.Multer.File | undefined,
mappingRaw?: string,
chunk?: { sessionId?: string; offset?: string | number; limit?: string | number },
): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, ACTIVITY_ALIASES, parseExcelMapping(mappingRaw));
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
let ctx;
try {
ctx = await resolveExcelImportChunk({
organizationId: actor.organizationId,
file,
sessionId: chunk?.sessionId,
offset: chunk?.offset,
limit: chunk?.limit,
loadRows: () =>
readMappedRows(
file!,
ACTIVITY_ALIASES,
parseExcelMapping(mappingRaw),
ACTIVITY_EXCEL_OPTIONS,
),
});
} catch (e) {
if (e instanceof BadRequestException) throw e;
const message =
e instanceof Error ? e.message : 'خواندن فایل اکسل ناموفق بود';
result.errors.push({ row: 1, message });
return result;
}
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
const rowNum = i + 2;
const MAX_ROWS = 100_000;
if (ctx.total > MAX_ROWS) {
destroyExcelImportSession(ctx.sessionId);
result.errors.push({
row: 1,
message: `فایل بیش از ${MAX_ROWS} ردیف دارد. فایل را تقسیم کنید.`,
});
return { ...result, hasMore: false, total: ctx.total, processed: 0 };
}
const caches = getExcelImportSessionCaches(ctx.sessionId, actor.organizationId);
await this.processActivityRows(
actor,
ctx.slice,
ctx.offset,
result,
caches,
{ skipDuplicateCheck: false },
);
return { ...result, ...chunkImportMeta(ctx) };
}
/**
* ایمپورت تکه‌ای JSON از مرورگر — فایل اکسل روی سرور آپلود/پارس نمی‌شود.
*/
async importRows(
actor: Actor,
dto: ImportActivityRowsDto,
): Promise<ImportContactsResult> {
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
const rows = Array.isArray(dto.rows) ? dto.rows : [];
if (!rows.length) {
result.errors.push({ row: 1, message: 'ردیف ارسال‌شده خالی است' });
return result;
}
if (rows.length > 80) {
result.errors.push({ row: 1, message: 'حداکثر ۸۰ ردیف در هر درخواست' });
return result;
}
const total = Math.max(0, Number(dto.total) || 0);
const offset = Math.max(0, Number(dto.offset) || 0);
let sessionId = (dto.sessionId ?? '').trim();
if (!sessionId) {
sessionId = createExcelImportCacheSession(actor.organizationId);
}
const caches = getExcelImportSessionCaches(sessionId, actor.organizationId);
await this.processActivityRows(actor, rows, offset, result, caches, {
// برای فایل‌های خیلی بزرگ، بررسی تکراری هر ردیف درخواست را طولانی و ۵۰۲ می‌کند
skipDuplicateCheck: total > 2000,
});
const processed = offset + rows.length;
const hasMore = total > 0 ? processed < total : false;
if (!hasMore) destroyExcelImportSession(sessionId);
return {
...result,
sessionId: hasMore ? sessionId : undefined,
total,
processed,
hasMore,
nextOffset: hasMore ? processed : undefined,
};
}
private async processActivityRows(
actor: Actor,
slice: Array<Record<string, string>>,
offset: number,
result: ImportContactsResult,
caches: ExcelImportLookupCaches,
opts: { skipDuplicateCheck: boolean },
) {
const resolveUser = async (name?: string) => {
const key = (name ?? '').trim().toLowerCase();
if (!key) return null;
if (caches.user.has(key)) return caches.user.get(key) ?? null;
const id = await this.findUserId(actor.organizationId, name);
caches.user.set(key, id);
return id;
};
const resolvePerson = async (row: Record<string, string>) => {
const mobile = normalizePhone(row.personMobile);
const name = (row.personName ?? '').trim().toLowerCase();
const key = mobile ? `m:${mobile}` : name ? `n:${name}` : '';
if (!key) return null;
if (caches.person.has(key)) return caches.person.get(key) ?? null;
const id = await this.findPersonId(actor.organizationId, row);
caches.person.set(key, id);
return id;
};
const resolveCompany = async (name?: string) => {
const key = (name ?? '').trim().toLowerCase();
if (!key) return null;
if (caches.company.has(key)) return caches.company.get(key) ?? null;
const id = await this.findCompanyId(actor.organizationId, name);
caches.company.set(key, id);
return id;
};
const resolveDeal = async (title?: string) => {
const key = (title ?? '').trim().toLowerCase();
if (!key) return null;
if (caches.deal.has(key)) return caches.deal.get(key) ?? null;
const id = await this.findDealId(actor.organizationId, title);
caches.deal.set(key, id);
return id;
};
for (let i = 0; i < slice.length; i++) {
const row = slice[i]!;
const rowNum = offset + i + 2;
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'activity') {
continue;
}
@@ -179,31 +403,40 @@ export class ActivitiesExcelService {
const status = parseStatus(row.status);
try {
const personId = await this.findPersonId(actor.organizationId, row);
const companyId = await this.findCompanyId(actor.organizationId, row.companyName);
const dealId = await this.findDealId(actor.organizationId, row.dealTitle);
const assigneeId =
(await this.findUserId(actor.organizationId, row.assignee)) ?? actor.userId;
const personId = await resolvePerson(row);
const companyId = await resolveCompany(row.companyName);
const dealId = await resolveDeal(row.dealTitle);
const assigneeNames = String(row.assignee ?? '')
.split(/[,،]/)
.map((s) => s.trim())
.filter(Boolean);
const assigneeIds: string[] = [];
for (const name of assigneeNames) {
const uid = await resolveUser(name);
if (uid && !assigneeIds.includes(uid)) assigneeIds.push(uid);
}
if (!assigneeIds.length) assigneeIds.push(actor.userId);
const assigneeId = assigneeIds[0]!;
const creatorId =
(await this.findUserId(actor.organizationId, row.creator)) ?? actor.userId;
(await resolveUser(row.creator)) ?? actor.userId;
const duplicate = dueAt
? await this.prisma.activity.findFirst({
where: {
organizationId: actor.organizationId,
title,
dueAt,
personId: personId ?? null,
},
select: { id: true },
})
: null;
if (duplicate) {
result.skipped += 1;
continue;
if (!opts.skipDuplicateCheck && dueAt) {
const duplicate = await this.prisma.activity.findFirst({
where: {
organizationId: actor.organizationId,
title,
dueAt,
personId: personId ?? null,
},
select: { id: true },
});
if (duplicate) {
result.skipped += 1;
continue;
}
}
await this.prisma.activity.create({
const created = await this.prisma.activity.create({
data: {
organizationId: actor.organizationId,
title,
@@ -220,7 +453,20 @@ export class ActivitiesExcelService {
assigneeId,
createdById: creatorId,
},
select: { id: true },
});
if (assigneeIds.length > 1) {
try {
await this.prisma.activity.update({
where: { id: created.id },
data: {
assignees: { set: assigneeIds.map((id) => ({ id })) },
},
});
} catch {
/* assigneeId به‌تنهایی کافی است */
}
}
result.created += 1;
} catch (e) {
result.errors.push({
@@ -229,8 +475,6 @@ export class ActivitiesExcelService {
});
}
}
return result;
}
private async findPersonId(organizationId: string, row: Record<string, string>) {
+194 -19
View File
@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Delete,
@@ -17,28 +18,147 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { ActivitiesService } from './activities.service';
import { ActivitiesExcelService } from './activities-excel.service';
import { ActivityStagesService } from './activity-stages.service';
import { ActivityTypesService } from './activity-types.service';
import {
CreateActivityDto,
CreateActivityStageDto,
CreateActivityTypeDto,
ImportActivityRowsDto,
ListActivitiesQueryDto,
MoveActivityStageDto,
ReorderActivityStagesDto,
ReorderActivityTypesDto,
UpdateActivityDto,
UpdateActivityStageDto,
UpdateActivityTypeDto,
} from './dto';
import { brand } from '@mento/shared';
const excelUpload = FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 15_000_000 },
});
@Controller('activities')
@UseGuards(JwtAuthGuard)
export class ActivitiesController {
constructor(
private readonly activitiesService: ActivitiesService,
private readonly excelService: ActivitiesExcelService,
private readonly stagesService: ActivityStagesService,
private readonly typesService: ActivityTypesService,
) {}
@Get('summary')
summary(@CurrentUser() user: Actor) {
return this.activitiesService.summary(user.organizationId, user);
summary(
@CurrentUser() user: Actor,
@Query('desk') desk?: string,
) {
const scope = desk === 'mine' || desk === 'team' ? desk : undefined;
return this.activitiesService.summary(user.organizationId, user, scope);
}
@Get('stages')
listStages(@CurrentUser() user: Actor) {
return this.stagesService.list(user.organizationId);
}
@Post('stages')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
createStage(@CurrentUser() user: Actor, @Body() dto: CreateActivityStageDto) {
return this.stagesService.create(user.organizationId, dto);
}
@Patch('stages/reorder')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
reorderStages(@CurrentUser() user: Actor, @Body() dto: ReorderActivityStagesDto) {
return this.stagesService.reorder(user.organizationId, dto.stageIds);
}
@Patch('stages/:id')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
updateStage(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateActivityStageDto,
) {
return this.stagesService.update(user.organizationId, id, dto);
}
@Delete('stages/:id')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
async removeStage(@CurrentUser() user: Actor, @Param('id') id: string) {
await this.stagesService.remove(user.organizationId, id);
return { ok: true };
}
@Get('types')
listTypes(@CurrentUser() user: Actor) {
return this.typesService.list(user.organizationId);
}
@Post('types')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
createType(@CurrentUser() user: Actor, @Body() dto: CreateActivityTypeDto) {
return this.typesService.create(user.organizationId, dto);
}
@Patch('types/reorder')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
reorderTypes(@CurrentUser() user: Actor, @Body() dto: ReorderActivityTypesDto) {
return this.typesService.reorder(user.organizationId, dto.typeIds);
}
@Patch('types/:id')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
updateType(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateActivityTypeDto,
) {
return this.typesService.update(user.organizationId, id, dto);
}
@Delete('types/:id')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
async removeType(@CurrentUser() user: Actor, @Param('id') id: string) {
await this.typesService.remove(user.organizationId, id);
return { ok: true };
}
@Get('stage-counts')
stageCounts(
@CurrentUser() user: Actor,
@Query() query: ListActivitiesQueryDto,
) {
return this.activitiesService.stageCounts(
user.organizationId,
user,
query.filter ?? 'all',
query.q,
query.desk,
{
type: query.type,
typeDefId: query.typeDefId,
status: query.status,
assigneeId: query.assigneeId,
},
);
}
@Get()
@@ -57,11 +177,20 @@ export class ActivitiesController {
personId: query.personId,
companyId: query.companyId,
dealId: query.dealId,
leadId: query.leadId,
},
{
from: query.from,
to: query.to,
},
query.desk,
{
type: query.type,
typeDefId: query.typeDefId,
status: query.status,
assigneeId: query.assigneeId,
},
query.stageId ? { stageId: query.stageId } : undefined,
);
}
@@ -80,29 +209,61 @@ export class ActivitiesController {
}
@Post('import/preview')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 15_000_000 },
}),
)
importPreview(@UploadedFile() file: Express.Multer.File) {
@UseInterceptors(excelUpload)
importPreview(@UploadedFile() file?: Express.Multer.File) {
if (!file?.buffer?.length) {
throw new BadRequestException('فایل ارسال نشده');
}
return this.excelService.preview(file);
}
@Post('import')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 15_000_000 },
}),
)
import(
@Post('import/prepare')
@UseInterceptors(excelUpload)
importPrepare(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
@UploadedFile() file?: Express.Multer.File,
@Body('mapping') mapping?: string,
) {
return this.excelService.import(user, file, mapping);
if (!file?.buffer?.length) {
throw new BadRequestException('فایل ارسال نشده');
}
return this.excelService.prepare(user, file, mapping);
}
@Get('import/prepare/:sessionId')
importPrepareStatus(
@CurrentUser() user: Actor,
@Param('sessionId') sessionId: string,
) {
return this.excelService.prepareStatus(user, sessionId);
}
@Post('import/rows')
importRows(
@CurrentUser() user: Actor,
@Body() dto: ImportActivityRowsDto,
) {
return this.excelService.importRows(user, dto);
}
@Post('import')
@UseInterceptors(excelUpload)
import(
@CurrentUser() user: Actor,
@UploadedFile() file?: Express.Multer.File,
@Body('mapping') mapping?: string,
@Body('sessionId') sessionId?: string,
@Body('offset') offset?: string,
@Body('limit') limit?: string,
) {
if (!file?.buffer?.length && !sessionId) {
throw new BadRequestException('فایل ارسال نشده');
}
return this.excelService.import(user, file, mapping, {
sessionId,
offset,
limit,
});
}
@Get(':id')
@@ -110,6 +271,11 @@ export class ActivitiesController {
return this.activitiesService.get(user.organizationId, id, user);
}
@Post(':id/promote-from-prior')
promoteFromPrior(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.activitiesService.promoteFromPrior(user, id);
}
@Post()
create(@CurrentUser() user: Actor, @Body() dto: CreateActivityDto) {
return this.activitiesService.create(
@@ -129,6 +295,15 @@ export class ActivitiesController {
return this.activitiesService.update(user.organizationId, id, user, dto);
}
@Patch(':id/stage')
moveStage(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: MoveActivityStageDto,
) {
return this.activitiesService.moveToStage(user.organizationId, id, user, dto.stageId);
}
@Patch(':id/complete')
complete(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.activitiesService.complete(user.organizationId, id, user);
+9 -2
View File
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
import { ActivitiesController } from './activities.controller';
import { ActivitiesService } from './activities.service';
import { ActivitiesExcelService } from './activities-excel.service';
import { ActivityStagesService } from './activity-stages.service';
import { ActivityTypesService } from './activity-types.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
@@ -9,7 +11,12 @@ import { MessageAutomationsModule } from '../message-automations/message-automat
@Module({
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule],
controllers: [ActivitiesController],
providers: [ActivitiesService, ActivitiesExcelService],
exports: [ActivitiesService],
providers: [
ActivitiesService,
ActivitiesExcelService,
ActivityStagesService,
ActivityTypesService,
],
exports: [ActivitiesService, ActivityStagesService, ActivityTypesService],
})
export class ActivitiesModule {}
+573 -65
View File
@@ -1,12 +1,15 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
ActivityDto,
ActivityFieldValues,
ActivityFilter,
ActivitySummaryDto,
ActivityStageCountsDto,
CreateActivityRequest,
PaginatedResponse,
UpdateActivityRequest,
@@ -16,9 +19,37 @@ import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { type Actor, assigneeWhere, ownerWhere } from '../common/record-scope';
import {
type Actor,
activityAccessWhere,
dealAccessWhere,
ownerWhere,
} from '../common/record-scope';
import { normalizeTags } from '../common/tags';
import { GamificationService } from '../gamification/gamification.service';
import { tehranDayKey } from '../routines/routine-dates';
import { buildSearchWhere, ci } from '../common/search';
import { activityPriorWhere } from '../common/prior-archive';
import { ActivityStagesService, type StageDefaults } from './activity-stages.service';
import {
ActivityTypesService,
type SystemTypes,
type TypeRow,
} from './activity-types.service';
type ActivityActor = Pick<Actor, 'userId' | 'role'>;
type DtoContext = { stages?: StageDefaults; types?: SystemTypes };
type ActivityActor = Pick<Actor, 'userId' | 'role' | 'priorLens' | 'priorCutoff'>;
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */
type StageScope = { stageId: string };
type ListFacets = {
type?: ActivityDto['type'];
typeDefId?: string;
status?: ActivityDto['status'];
assigneeId?: string;
};
type ActivityRow = {
id: string;
@@ -32,21 +63,35 @@ type ActivityRow = {
reminderAt: Date | null;
reminderNotifyApp: boolean;
reminderNotifySms: boolean;
reminderSentAt: Date | null;
completedAt: Date | null;
importSource?: string;
promotedFromPriorAt?: Date | null;
tags: string[];
createdAt: Date;
updatedAt: Date;
person: { id: string; firstName: string; lastName: string } | null;
company: { id: string; name: string } | null;
deal: { id: string; title: string } | null;
lead?: { id: string; title: string } | null;
assignee: { id: string; fullName: string } | null;
assignees: Array<{ id: string; fullName: string }>;
createdBy: { id: string; fullName: string } | null;
stage?: { id: string; kind: ActivityDto['status'] } | null;
typeDef?: { id: string; name: string; icon: string; color: string } | null;
fields?: Prisma.JsonValue | null;
participantIds?: string[];
};
const include = {
stage: { select: { id: true, kind: true } },
typeDef: { select: { id: true, name: true, icon: true, color: true } },
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
lead: { select: { id: true, title: true } },
assignee: { select: { id: true, fullName: true } },
assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' as const } },
createdBy: { select: { id: true, fullName: true } },
} satisfies Prisma.ActivityInclude;
@@ -57,21 +102,76 @@ export class ActivitiesService {
private readonly notifications: NotificationsService,
private readonly automations: AutomationsService,
private readonly messageAutomations: MessageAutomationsService,
private readonly stages: ActivityStagesService,
private readonly types: ActivityTypesService,
private readonly game: GamificationService,
) {}
private async ctx(organizationId: string): Promise<DtoContext> {
const [stages, types] = await Promise.all([
this.stages.defaults(organizationId),
this.types.system(organizationId),
]);
return { stages, types };
}
async summary(
organizationId: string,
actor: ActivityActor,
desk?: 'mine' | 'team',
): Promise<ActivitySummaryDto> {
const { start, end } = dayBounds();
const scope = assigneeWhere(actor);
const scope: Prisma.ActivityWhereInput =
desk === 'mine'
? {
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
}
: activityAccessWhere(actor);
const prior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
const [todayCount, overdueCount] = await Promise.all([
this.prisma.activity.count({
where: {
AND: [
{
organizationId,
...scope,
status: 'open',
dueAt: { gte: start, lt: end },
},
prior,
],
},
}),
this.prisma.activity.count({
where: {
AND: [
{
organizationId,
...scope,
status: 'open',
dueAt: { lt: start },
},
prior,
],
},
}),
]);
const [today, overdue, upcoming] = await Promise.all([
this.prisma.activity.findMany({
where: {
organizationId,
...scope,
status: 'open',
dueAt: { gte: start, lt: end },
AND: [
{
organizationId,
...scope,
status: 'open',
dueAt: { gte: start, lt: end },
},
prior,
],
},
include,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
@@ -79,10 +179,15 @@ export class ActivitiesService {
}),
this.prisma.activity.findMany({
where: {
organizationId,
...scope,
status: 'open',
dueAt: { lt: start },
AND: [
{
organizationId,
...scope,
status: 'open',
dueAt: { lt: start },
},
prior,
],
},
include,
orderBy: [{ dueAt: 'asc' }],
@@ -90,10 +195,15 @@ export class ActivitiesService {
}),
this.prisma.activity.findMany({
where: {
organizationId,
...scope,
status: 'open',
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
AND: [
{
organizationId,
...scope,
status: 'open',
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
},
prior,
],
},
include,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
@@ -101,12 +211,13 @@ export class ActivitiesService {
}),
]);
const ctx = await this.ctx(organizationId);
return {
today: today.map((row) => this.toDto(row)),
overdue: overdue.map((row) => this.toDto(row)),
upcoming: upcoming.map((row) => this.toDto(row)),
todayCount: today.length,
overdueCount: overdue.length,
today: today.map((row) => this.toDto(row, ctx)),
overdue: overdue.map((row) => this.toDto(row, ctx)),
upcoming: upcoming.map((row) => this.toDto(row, ctx)),
todayCount,
overdueCount,
};
}
@@ -121,12 +232,17 @@ export class ActivitiesService {
personId?: string;
companyId?: string;
dealId?: string;
leadId?: string;
},
range?: {
from?: string;
to?: string;
},
desk?: 'mine' | 'team',
facets?: ListFacets,
stageScope?: StageScope,
): Promise<PaginatedResponse<ActivityDto>> {
const typeRows = await this.types.rows(organizationId);
const where = this.buildWhere(
organizationId,
actor,
@@ -134,7 +250,20 @@ export class ActivitiesService {
q,
entity,
range,
desk,
facets,
stageScope,
typeRows,
);
if (stageScope && stageScope.stageId !== 'all') {
(where.AND as Prisma.ActivityWhereInput[]).push(
await this.stages.whereForStage(organizationId, stageScope.stageId),
);
}
const ctx: DtoContext = {
stages: await this.stages.defaults(organizationId),
types: this.types.systemOf(typeRows),
};
const take = Math.min(Math.max(limit, 1), 200);
const [rows, total] = await this.prisma.$transaction([
this.prisma.activity.findMany({
@@ -148,24 +277,107 @@ export class ActivitiesService {
]);
return {
items: rows.map((row) => this.toDto(row)),
items: rows.map((row) => this.toDto(row, ctx)),
total,
page,
limit: take,
};
}
/** تعداد فعالیت هر مرحله با همان فیلترهای لیست (زمان، جستجو، میز، نوع، کارشناس) */
async stageCounts(
organizationId: string,
actor: ActivityActor,
filter: ActivityFilter,
q?: string,
desk?: 'mine' | 'team',
facets?: ListFacets,
): Promise<ActivityStageCountsDto> {
const rows = await this.stages.rows(organizationId);
const defaults = this.stages.defaultsOf(rows);
const kindOf = new Map(rows.map((r) => [r.id, r.kind]));
const where = this.buildWhere(
organizationId,
actor,
filter,
q,
undefined,
undefined,
desk,
facets,
{ stageId: 'all' },
await this.types.rows(organizationId),
);
const groups = await this.prisma.activity.groupBy({
by: ['status', 'stageId'],
where,
_count: { _all: true },
});
const counts: Record<string, number> = Object.fromEntries(rows.map((r) => [r.id, 0]));
let total = 0;
for (const g of groups) {
const n = g._count._all;
total += n;
const target =
g.stageId && kindOf.get(g.stageId) === g.status ? g.stageId : defaults[g.status];
if (target) counts[target] = (counts[target] ?? 0) + n;
}
return { counts, total };
}
async moveToStage(
organizationId: string,
id: string,
actor: ActivityActor,
stageId: string,
): Promise<ActivityDto> {
await this.ensureExists(organizationId, id, actor);
const stage = await this.stages.get(organizationId, stageId);
const existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: { status: true, completedAt: true },
});
const row = await this.prisma.activity.update({
where: { id },
data: {
stageId: stage.id,
status: stage.kind,
completedAt:
stage.kind === 'done'
? existing?.status === 'done'
? existing.completedAt ?? new Date()
: new Date()
: stage.kind === 'open'
? null
: undefined,
},
include,
});
if (stage.kind === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row, await this.ctx(organizationId));
}
async get(
organizationId: string,
id: string,
actor: ActivityActor,
): Promise<ActivityDto> {
const row = await this.prisma.activity.findFirst({
where: { id, organizationId, ...assigneeWhere(actor) },
where: { id, organizationId, ...activityAccessWhere(actor) },
include,
});
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
return this.toDto(row);
const dto = this.toDto(row, await this.ctx(organizationId));
if (row.participantIds.length) {
dto.participantUsers = await this.prisma.user.findMany({
where: { id: { in: row.participantIds } },
select: { id: true, fullName: true },
orderBy: { fullName: 'asc' },
});
}
return dto;
}
async create(
@@ -176,14 +388,44 @@ export class ActivitiesService {
): Promise<ActivityDto> {
const linkActor = actor ?? { userId, role: 'owner' };
await this.assertLinks(organizationId, linkActor, dto);
const assigneeId = emptyToNull(dto.assigneeId ?? null) ?? userId;
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
const assigneeIds = await this.normalizeAssigneeIds(
organizationId,
dto.assigneeIds?.length
? dto.assigneeIds
: dto.assigneeId
? [dto.assigneeId]
: [userId],
);
const assigneeId = assigneeIds[0] ?? userId;
const initialStageId = emptyToNull(dto.stageId ?? null);
const initialStage = initialStageId
? await this.stages.get(organizationId, initialStageId)
: null;
const resolved = await this.types.resolve(organizationId, {
typeDefId: dto.typeDefId,
type: dto.type,
});
const { fields, participantIds } = await this.types.sanitizeValues(
organizationId,
resolved.fields,
dto.fields,
);
const row = await this.prisma.activity.create({
data: {
organizationId,
...(initialStage
? {
stageId: initialStage.id,
status: initialStage.kind,
completedAt: initialStage.kind === 'done' ? new Date() : null,
}
: {}),
title: dto.title.trim(),
type: dto.type ?? 'task',
type: resolved.baseType,
typeDefId: resolved.typeDefId,
fields: fields as Prisma.InputJsonValue,
participantIds,
priority: dto.priority ?? 'medium',
description: emptyToNull(dto.description),
dueAt: dto.dueAt ? new Date(dto.dueAt) : null,
@@ -191,19 +433,23 @@ export class ActivitiesService {
reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : null,
reminderNotifyApp: dto.reminderNotifyApp ?? true,
reminderNotifySms: dto.reminderNotifySms ?? false,
tags: normalizeTags(dto.tags),
personId: emptyToNull(dto.personId ?? null),
companyId: emptyToNull(dto.companyId ?? null),
dealId: emptyToNull(dto.dealId ?? null),
assigneeId,
assignees: assigneeIds.length
? { connect: assigneeIds.map((id) => ({ id })) }
: undefined,
createdById: userId,
},
include,
});
if (assigneeId) {
for (const uid of assigneeIds) {
await this.notifications.create({
organizationId,
userId: assigneeId,
userId: uid,
type: 'activity_assigned',
title: 'فعالیت جدید برای شما',
body: row.title,
@@ -212,6 +458,11 @@ export class ActivitiesService {
entityId: row.id,
});
}
await this.notifyParticipants(
organizationId,
row,
participantIds.filter((uid) => !assigneeIds.includes(uid) && uid !== userId),
);
await this.messageAutomations.onEvent('activity_created', {
organizationId,
@@ -224,8 +475,27 @@ export class ActivitiesService {
assigneeId: row.assignee?.id ?? null,
ownerId: row.assignee?.id ?? null,
});
const gameBase = {
organizationId,
userId,
entityType: 'activity',
entityId: row.id,
entityTitle: row.title,
facts: {
activityType: row.type,
typeName: row.typeDef?.name ?? null,
priority: row.priority,
hasDescription: !!row.description?.trim(),
},
};
this.game.emit({ ...gameBase, event: 'ACTIVITY_CREATED' });
if (row.type === 'call') this.game.emit({ ...gameBase, event: 'CALL_LOGGED' });
if (row.type === 'meeting') this.game.emit({ ...gameBase, event: 'MEETING_CREATED' });
if (initialStage?.kind === 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row);
return this.toDto(row, await this.ctx(organizationId));
}
async update(
@@ -237,23 +507,71 @@ export class ActivitiesService {
await this.ensureExists(organizationId, id, actor);
await this.assertLinks(organizationId, actor, dto);
const existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: { status: true, type: true, typeDefId: true, fields: true, participantIds: true },
});
const data: Prisma.ActivityUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim();
if (dto.type !== undefined) data.type = dto.type;
let addedParticipants: string[] = [];
const typeChanged = 'typeDefId' in dto || dto.type !== undefined;
if (typeChanged || dto.fields !== undefined) {
const resolved = await this.types.resolve(
organizationId,
typeChanged
? { typeDefId: dto.typeDefId, type: dto.type }
: { typeDefId: existing?.typeDefId, type: existing?.type },
);
const { fields, participantIds } = await this.types.sanitizeValues(
organizationId,
resolved.fields,
dto.fields ?? existing?.fields,
);
data.type = resolved.baseType;
data.typeDefId = resolved.typeDefId;
data.fields = fields as Prisma.InputJsonValue;
data.participantIds = participantIds;
const before = new Set(existing?.participantIds ?? []);
addedParticipants = participantIds.filter((uid) => !before.has(uid) && uid !== actor.userId);
}
if (dto.priority !== undefined) data.priority = dto.priority;
if ('description' in dto) data.description = emptyToNull(dto.description);
if ('dueAt' in dto) data.dueAt = dto.dueAt ? new Date(dto.dueAt) : null;
if ('durationMinutes' in dto) data.durationMinutes = dto.durationMinutes ?? null;
if ('reminderAt' in dto) data.reminderAt = dto.reminderAt ? new Date(dto.reminderAt) : null;
if ('reminderAt' in dto) {
const nextReminder = dto.reminderAt ? new Date(dto.reminderAt) : null;
const current = await this.prisma.activity.findUnique({
where: { id },
select: { reminderAt: true },
});
const prevMs = current?.reminderAt?.getTime() ?? null;
const nextMs = nextReminder?.getTime() ?? null;
data.reminderAt = nextReminder;
if (prevMs !== nextMs) data.reminderSentAt = null;
}
if (dto.reminderNotifyApp !== undefined) data.reminderNotifyApp = dto.reminderNotifyApp;
if (dto.reminderNotifySms !== undefined) data.reminderNotifySms = dto.reminderNotifySms;
if ('tags' in dto) data.tags = normalizeTags(dto.tags);
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if ('assigneeId' in dto) {
let assigneeConnect: Array<{ id: string }> | undefined;
if (dto.assigneeIds !== undefined) {
const assigneeIds = await this.normalizeAssigneeIds(
organizationId,
dto.assigneeIds,
);
if (!assigneeIds.length) {
throw new BadRequestException('حداقل یک مسئول لازم است');
}
data.assigneeId = assigneeIds[0]!;
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
} else if ('assigneeId' in dto) {
const assigneeId = emptyToNull(dto.assigneeId ?? null);
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
data.assigneeId = assigneeId;
if (assigneeId) assigneeConnect = [{ id: assigneeId }];
else assigneeConnect = [];
}
if (dto.status !== undefined) {
data.status = dto.status;
@@ -261,19 +579,31 @@ export class ActivitiesService {
dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined;
}
const existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: { status: true },
});
const row = await this.prisma.activity.update({
where: { id },
data,
data: {
...data,
...(assigneeConnect
? { assignees: { set: assigneeConnect } }
: {}),
},
include,
});
if (addedParticipants.length) {
const assigneeSet = new Set([
...(row.assignee ? [row.assignee.id] : []),
...row.assignees.map((a) => a.id),
]);
await this.notifyParticipants(
organizationId,
row,
addedParticipants.filter((uid) => !assigneeSet.has(uid)),
);
}
if (dto.status === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row);
return this.toDto(row, await this.ctx(organizationId));
}
async complete(
@@ -294,7 +624,39 @@ export class ActivitiesService {
if (existing?.status !== 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row);
return this.toDto(row, await this.ctx(organizationId));
}
async promoteFromPrior(actor: Actor, id: string): Promise<ActivityDto> {
if (
(actor.priorLens !== 'current' && actor.priorLens !== 'archive') ||
!actor.priorCutoff
) {
throw new ForbiddenException('بایگانی اطلاعات قبل برای شما فعال نیست.');
}
const row = await this.prisma.activity.findFirst({
where: {
id,
organizationId: actor.organizationId,
...activityAccessWhere(actor),
},
select: { id: true, createdAt: true, promotedFromPriorAt: true },
});
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
if (row.promotedFromPriorAt) {
throw new BadRequestException('این فعالیت قبلاً منتقل شده است.');
}
if (row.createdAt >= actor.priorCutoff) {
throw new BadRequestException('این فعالیت در بایگانی نیست.');
}
const updated = await this.prisma.activity.update({
where: { id },
data: { promotedFromPriorAt: new Date() },
include,
});
return this.toDto(updated, await this.ctx(actor.organizationId));
}
async remove(
@@ -315,36 +677,76 @@ export class ActivitiesService {
personId?: string;
companyId?: string;
dealId?: string;
leadId?: string;
},
range?: {
from?: string;
to?: string;
},
desk?: 'mine' | 'team',
facets?: ListFacets,
stageScope?: StageScope,
typeRows: TypeRow[] = [],
): Prisma.ActivityWhereInput {
const { start, end } = dayBounds();
const base: Prisma.ActivityWhereInput = {
organizationId,
...assigneeWhere(actor),
...(entity?.personId ? { personId: entity.personId } : {}),
...(entity?.companyId ? { companyId: entity.companyId } : {}),
...(entity?.dealId ? { dealId: entity.dealId } : {}),
...(q?.trim()
const scope: Prisma.ActivityWhereInput =
desk === 'mine'
? {
OR: [
{ title: { contains: q.trim(), mode: 'insensitive' } },
{ description: { contains: q.trim(), mode: 'insensitive' } },
{
person: {
OR: [
{ firstName: { contains: q.trim(), mode: 'insensitive' } },
{ lastName: { contains: q.trim(), mode: 'insensitive' } },
],
},
},
{ deal: { title: { contains: q.trim(), mode: 'insensitive' } } },
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
}
: {}),
: activityAccessWhere(actor);
const facetAssignee = facets?.assigneeId?.trim();
const facetTypeDef = facets?.typeDefId?.trim();
const andParts: Prisma.ActivityWhereInput[] = [
scope,
...(facets?.type ? [{ type: facets.type }] : []),
...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []),
...(facets?.status ? [{ status: facets.status }] : []),
...(facetAssignee
? [
{
OR: [
{ assigneeId: facetAssignee },
{ assignees: { some: { id: facetAssignee } } },
],
},
]
: []),
...(entity?.personId ? [{ personId: entity.personId }] : []),
...(entity?.companyId ? [{ companyId: entity.companyId }] : []),
...(entity?.dealId ? [{ dealId: entity.dealId }] : []),
...(entity?.leadId ? [{ leadId: entity.leadId }] : []),
];
const search = buildSearchWhere<Prisma.ActivityWhereInput>(q, (t) => [
{ title: ci(t) },
{ description: ci(t) },
{ tags: { has: t.replace(/^#/, '') } },
{
person: {
OR: [
{ firstName: ci(t) },
{ lastName: ci(t) },
{ mobile: { contains: t } },
{ phone: { contains: t } },
],
},
},
{ company: { name: ci(t) } },
{ deal: { title: ci(t) } },
{ assignee: { fullName: ci(t) } },
{ assignees: { some: { fullName: ci(t) } } },
...typesMatching(t).map((type) => ({ type })),
...this.types.searchByName(typeRows, t),
]);
if (search) andParts.push(search);
andParts.push(activityPriorWhere(actor.priorLens, actor.priorCutoff));
const base: Prisma.ActivityWhereInput = {
organizationId,
AND: andParts,
};
const fromDate = range?.from ? new Date(range.from) : null;
@@ -361,19 +763,33 @@ export class ActivitiesService {
};
}
// Explicit status facet overrides the status implied by time tabs.
// With stage tabs/columns the stage decides status; time filters only narrow dueAt.
const impliedOpen = facets?.status
? { status: facets.status }
: stageScope
? {}
: { status: 'open' as const };
const impliedDone = facets?.status ? { status: facets.status } : { status: 'done' as const };
switch (filter) {
case 'today':
return { ...base, status: 'open', dueAt: { gte: start, lt: end } };
return { ...base, ...impliedOpen, dueAt: { gte: start, lt: end } };
case 'overdue':
return { ...base, status: 'open', dueAt: { lt: start } };
// عقب‌افتاده ذاتاً یعنی باز و موعدگذشته
return {
...base,
...(facets?.status ? { status: facets.status } : { status: 'open' as const }),
dueAt: { lt: start },
};
case 'upcoming':
return {
...base,
status: 'open',
...impliedOpen,
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
};
case 'done':
return { ...base, status: 'done' };
return { ...base, ...impliedDone };
case 'all':
default:
return base;
@@ -389,8 +805,26 @@ export class ActivitiesService {
company: { id: string } | null;
deal: { id: string } | null;
assignee: { id: string } | null;
type: string;
priority: string;
dueAt: Date | null;
typeDef?: { name: string } | null;
},
) {
this.game.emit({
organizationId,
userId: row.assignee?.id,
event: 'TASK_COMPLETED',
entityType: 'activity',
entityId: row.id,
entityTitle: row.title,
facts: {
activityType: row.type,
typeName: row.typeDef?.name ?? null,
priority: row.priority,
onTime: !row.dueAt || tehranDayKey() <= tehranDayKey(row.dueAt),
},
});
const ctx = {
organizationId,
entityType: 'activity' as const,
@@ -412,7 +846,7 @@ export class ActivitiesService {
actor: ActivityActor,
) {
const found = await this.prisma.activity.findFirst({
where: { id, organizationId, ...assigneeWhere(actor) },
where: { id, organizationId, ...activityAccessWhere(actor) },
select: { id: true },
});
if (!found) throw new NotFoundException('فعالیت پیدا نشد');
@@ -456,15 +890,44 @@ export class ActivitiesService {
}
if (dealId) {
const deal = await this.prisma.deal.findFirst({
where: { id: dealId, organizationId, ...scope },
where: { id: dealId, organizationId, ...dealAccessWhere(actor) },
select: { id: true },
});
if (!deal) throw new BadRequestException('معامله انتخاب‌شده معتبر نیست');
}
}
private toDto(row: ActivityRow): ActivityDto {
private async notifyParticipants(
organizationId: string,
row: { id: string; title: string },
userIds: string[],
) {
for (const uid of new Set(userIds)) {
await this.notifications.create({
organizationId,
userId: uid,
type: 'activity_assigned',
title: 'شما به‌عنوان شرکت‌کننده اضافه شدید',
body: row.title,
link: `/activities/${row.id}`,
entityType: 'activity',
entityId: row.id,
});
}
}
private toDto(row: ActivityRow, ctx?: DtoContext): ActivityDto {
const fields =
row.fields && typeof row.fields === 'object' && !Array.isArray(row.fields)
? (row.fields as unknown as ActivityFieldValues)
: {};
return {
stageId: this.stages.effectiveStageId(row, ctx?.stages),
typeDef: row.typeDef
? this.types.toSummary(row.typeDef)
: (ctx?.types?.[row.type] ?? null),
fields,
participantIds: row.participantIds ?? [],
id: row.id,
type: row.type,
status: row.status,
@@ -476,7 +939,11 @@ export class ActivitiesService {
reminderAt: row.reminderAt?.toISOString() ?? null,
reminderNotifyApp: row.reminderNotifyApp,
reminderNotifySms: row.reminderNotifySms,
reminderSentAt: row.reminderSentAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null,
importSource: row.importSource ?? 'manual',
promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null,
tags: row.tags ?? [],
person: row.person
? {
id: row.person.id,
@@ -485,12 +952,53 @@ export class ActivitiesService {
: null,
company: row.company,
deal: row.deal,
lead: row.lead ?? null,
assignee: row.assignee,
assignees: (() => {
const list = [...(row.assignees ?? [])];
if (row.assignee && !list.some((a) => a.id === row.assignee!.id)) {
list.unshift(row.assignee);
}
return list;
})(),
createdBy: row.createdBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
private async normalizeAssigneeIds(
organizationId: string,
raw?: string[] | null,
): Promise<string[]> {
const ids = [
...new Set((raw ?? []).map((id) => id.trim()).filter(Boolean)),
];
if (!ids.length) return [];
const count = await this.prisma.user.count({
where: { organizationId, id: { in: ids }, isActive: true },
});
if (count !== ids.length) {
throw new BadRequestException('یکی از مسئولان انتخاب‌شده معتبر نیست');
}
return ids;
}
}
const TYPE_SEARCH_TERMS: Record<ActivityDto['type'], string[]> = {
call: ['تماس', 'تلفن', 'call'],
meeting: ['جلسه', 'ملاقات', 'meeting'],
task: ['وظیفه', 'تسک', 'task'],
note: ['پیام', 'یادداشت', 'note'],
};
/** جستجوی «جلسه» یا «تماس» → فیلتر نوع فعالیت */
function typesMatching(term: string): ActivityDto['type'][] {
const t = term.trim().toLowerCase();
if (t.length < 2) return [];
return (Object.keys(TYPE_SEARCH_TERMS) as ActivityDto['type'][]).filter((type) =>
TYPE_SEARCH_TERMS[type].some((label) => label.includes(t) || t.includes(label)),
);
}
function emptyToNull(value?: string | null): string | null {
@@ -0,0 +1,232 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import type {
ActivityStageColor,
ActivityStageDto,
ActivityStatus,
} from '@mento/shared';
import { ACTIVITY_STAGE_COLORS } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
const DEFAULT_STAGES: Array<{ name: string; color: ActivityStageColor; kind: ActivityStatus }> = [
{ name: 'در صف', color: 'orange', kind: 'open' },
{ name: 'در حال انجام', color: 'blue', kind: 'open' },
{ name: 'انجام‌شده', color: 'green', kind: 'done' },
{ name: 'لغو شده', color: 'rose', kind: 'canceled' },
];
const KINDS: ActivityStatus[] = ['open', 'done', 'canceled'];
type StageRow = {
id: string;
name: string;
position: number;
color: string;
kind: ActivityStatus;
};
export type StageDefaults = Record<ActivityStatus, string | null>;
@Injectable()
export class ActivityStagesService {
constructor(private readonly prisma: PrismaService) {}
async ensureDefaults(organizationId: string): Promise<void> {
const count = await this.prisma.activityStage.count({ where: { organizationId } });
if (count > 0) return;
await this.prisma.activityStage.createMany({
data: DEFAULT_STAGES.map((s, i) => ({
organizationId,
name: s.name,
color: s.color,
kind: s.kind,
position: i,
})),
skipDuplicates: true,
});
}
async rows(organizationId: string): Promise<StageRow[]> {
await this.ensureDefaults(organizationId);
return this.prisma.activityStage.findMany({
where: { organizationId },
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
select: { id: true, name: true, position: true, color: true, kind: true },
});
}
async list(organizationId: string): Promise<ActivityStageDto[]> {
return (await this.rows(organizationId)).map((r) => this.toDto(r));
}
/** اولین مرحلهٔ هر نوع = مقصد فعالیت‌های بدون مرحله یا با مرحلهٔ ناهم‌نوع */
defaultsOf(rows: StageRow[]): StageDefaults {
const out: StageDefaults = { open: null, done: null, canceled: null };
for (const r of rows) {
if (!out[r.kind]) out[r.kind] = r.id;
}
return out;
}
async defaults(organizationId: string): Promise<StageDefaults> {
return this.defaultsOf(await this.rows(organizationId));
}
/** شرط Prisma برای فعالیت‌هایی که «مؤثراً» در این مرحله هستند */
async whereForStage(
organizationId: string,
stageId: string,
): Promise<Prisma.ActivityWhereInput> {
const rows = await this.rows(organizationId);
const stage = rows.find((r) => r.id === stageId);
if (!stage) throw new BadRequestException('مرحلهٔ انتخاب‌شده معتبر نیست');
const isDefault = this.defaultsOf(rows)[stage.kind] === stage.id;
if (!isDefault) return { status: stage.kind, stageId: stage.id };
return {
status: stage.kind,
OR: [
{ stageId: stage.id },
{ stageId: null },
{ stage: { kind: { not: stage.kind } } },
],
};
}
effectiveStageId(
row: { status: ActivityStatus; stage?: { id: string; kind: ActivityStatus } | null },
defaults?: StageDefaults,
): string | null {
if (row.stage && row.stage.kind === row.status) return row.stage.id;
return defaults?.[row.status] ?? null;
}
async get(organizationId: string, id: string): Promise<StageRow> {
const row = await this.prisma.activityStage.findFirst({
where: { id, organizationId },
select: { id: true, name: true, position: true, color: true, kind: true },
});
if (!row) throw new NotFoundException('مرحله پیدا نشد');
return row;
}
async create(
organizationId: string,
dto: { name: string; color?: string; kind?: ActivityStatus },
): Promise<ActivityStageDto> {
await this.ensureDefaults(organizationId);
const max = await this.prisma.activityStage.aggregate({
where: { organizationId },
_max: { position: true },
});
try {
const row = await this.prisma.activityStage.create({
data: {
organizationId,
name: this.cleanName(dto.name),
color: this.cleanColor(dto.color),
kind: dto.kind && KINDS.includes(dto.kind) ? dto.kind : 'open',
position: (max._max.position ?? -1) + 1,
},
});
return this.toDto(row);
} catch (e) {
throw this.mapError(e);
}
}
async update(
organizationId: string,
id: string,
dto: { name?: string; color?: string; kind?: ActivityStatus },
): Promise<ActivityStageDto> {
const stage = await this.get(organizationId, id);
const nextKind = dto.kind && KINDS.includes(dto.kind) ? dto.kind : stage.kind;
if (nextKind !== stage.kind) {
await this.assertNotLastOfKind(organizationId, stage);
}
try {
const row = await this.prisma.$transaction(async (tx) => {
if (nextKind !== stage.kind) {
// فعالیت‌ها وضعیتشان را نگه می‌دارند و به اولین مرحلهٔ هم‌نوع خودشان برمی‌گردند
await tx.activity.updateMany({
where: { organizationId, stageId: id },
data: { stageId: null },
});
}
return tx.activityStage.update({
where: { id },
data: {
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
kind: nextKind,
},
});
});
return this.toDto(row);
} catch (e) {
throw this.mapError(e);
}
}
async reorder(organizationId: string, stageIds: string[]): Promise<ActivityStageDto[]> {
await this.prisma.$transaction(
stageIds.map((id, position) =>
this.prisma.activityStage.updateMany({
where: { id, organizationId },
data: { position },
}),
),
);
return this.list(organizationId);
}
async remove(organizationId: string, id: string): Promise<void> {
const stage = await this.get(organizationId, id);
await this.assertNotLastOfKind(organizationId, stage);
// onDelete: SetNull → فعالیت‌ها به اولین مرحلهٔ هم‌نوع وضعیتشان منتقل می‌شوند
await this.prisma.activityStage.delete({ where: { id } });
}
private async assertNotLastOfKind(organizationId: string, stage: StageRow) {
const sameKind = await this.prisma.activityStage.count({
where: { organizationId, kind: stage.kind },
});
if (sameKind <= 1) {
const label =
stage.kind === 'done' ? 'انجام‌شده' : stage.kind === 'canceled' ? 'لغو شده' : 'باز';
throw new BadRequestException(
`حداقل یک ستون از نوع «${label}» لازم است؛ اول یک ستون دیگر از همین نوع بسازید`,
);
}
}
private cleanName(raw: string): string {
const name = raw?.trim() ?? '';
if (!name) throw new BadRequestException('نام ستون لازم است');
if (name.length > 80) throw new BadRequestException('نام ستون حداکثر ۸۰ کاراکتر است');
return name;
}
private cleanColor(raw?: string): ActivityStageColor {
return ACTIVITY_STAGE_COLORS.includes(raw as ActivityStageColor)
? (raw as ActivityStageColor)
: 'green';
}
private mapError(e: unknown) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return new BadRequestException('ستونی با این نام وجود دارد');
}
return e;
}
private toDto(row: StageRow): ActivityStageDto {
return {
id: row.id,
name: row.name,
position: row.position,
color: this.cleanColor(row.color),
kind: row.kind,
};
}
}
@@ -0,0 +1,649 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import type {
ActivityChecklistItem,
ActivityFieldDef,
ActivityFieldType,
ActivityFieldValue,
ActivityFieldValues,
ActivityParticipantsValue,
ActivityStageColor,
ActivityType,
ActivityTypeDefDto,
ActivityTypeIcon,
ActivityTypeSummaryDto,
CreateActivityTypeRequest,
UpdateActivityTypeRequest,
} from '@mento/shared';
import {
ACTIVITY_FIELD_TYPES,
ACTIVITY_FIELD_TYPES_WITH_OPTIONS,
ACTIVITY_STAGE_COLORS,
ACTIVITY_TYPE_ICONS,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
const BASE_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
const MAX_FIELDS = 30;
const MAX_OPTIONS = 50;
const MAX_CHECKLIST = 100;
const MAX_PARTICIPANTS = 100;
type Seed = {
name: string;
baseType: ActivityType;
icon: ActivityTypeIcon;
color: ActivityStageColor;
isSystem?: boolean;
fields: ActivityFieldDef[];
};
const DEFAULT_TYPES: Seed[] = [
{
name: 'تماس',
baseType: 'call',
icon: 'phone',
color: 'green',
isSystem: true,
fields: [
{ key: 'call_direction', label: 'جهت تماس', type: 'select', options: ['خروجی', 'ورودی'] },
{
key: 'call_result',
label: 'نتیجه تماس',
type: 'select',
options: [
'پاسخ داد',
'پاسخ نداد',
'مشغول بود',
'خاموش / در دسترس نبود',
'شماره اشتباه',
'نیاز به پیگیری مجدد',
],
},
],
},
{
name: 'جلسه',
baseType: 'meeting',
icon: 'users',
color: 'purple',
isSystem: true,
fields: [
{
key: 'meeting_subject',
label: 'موضوع جلسه',
type: 'text',
placeholder: 'مثلاً: بررسی پیش‌فاکتور و شرایط پرداخت',
},
{ key: 'meeting_participants', label: 'شرکت‌کنندگان', type: 'participants' },
{
key: 'meeting_location',
label: 'محل برگزاری / لینک جلسه',
type: 'text',
placeholder: 'آدرس یا لینک جلسهٔ آنلاین',
},
{ key: 'meeting_minutes', label: 'صورت‌جلسه', type: 'textarea' },
],
},
{
name: 'وظیفه',
baseType: 'task',
icon: 'check',
color: 'orange',
isSystem: true,
fields: [{ key: 'task_checklist', label: 'لیست کارها', type: 'checklist' }],
},
{
name: 'پیام',
baseType: 'note',
icon: 'chat',
color: 'blue',
isSystem: true,
fields: [
{
key: 'message_channel',
label: 'کانال ارسال',
type: 'select',
options: ['پیامک', 'واتساپ', 'تلگرام', 'ایتا', 'بله', 'ایمیل', 'دایرکت اینستاگرام'],
},
],
},
{
name: 'بازدید حضوری',
baseType: 'meeting',
icon: 'pin',
color: 'rose',
fields: [
{ key: 'visit_address', label: 'آدرس محل بازدید', type: 'text' },
{ key: 'visit_participants', label: 'همراهان بازدید', type: 'participants' },
{ key: 'visit_result', label: 'نتیجهٔ بازدید', type: 'textarea' },
],
},
{
name: 'ارائه / دمو محصول',
baseType: 'meeting',
icon: 'star',
color: 'purple',
fields: [
{ key: 'demo_product', label: 'محصول / خدمت', type: 'text' },
{ key: 'demo_participants', label: 'شرکت‌کنندگان', type: 'participants' },
{ key: 'demo_link', label: 'لینک جلسه', type: 'url' },
{
key: 'demo_result',
label: 'نتیجهٔ دمو',
type: 'select',
options: ['علاقه‌مند', 'نیاز به بررسی بیشتر', 'درخواست پیش‌فاکتور', 'عدم علاقه'],
},
],
},
{
name: 'پیگیری',
baseType: 'call',
icon: 'bell',
color: 'orange',
fields: [
{
key: 'followup_reason',
label: 'دلیل پیگیری',
type: 'select',
options: ['پیش‌فاکتور', 'پرداخت', 'ارسال کالا', 'پشتیبانی', 'تمدید قرارداد', 'سایر'],
},
{ key: 'followup_result', label: 'نتیجهٔ پیگیری', type: 'textarea' },
{ key: 'followup_next', label: 'تاریخ پیگیری بعدی', type: 'date' },
],
},
{
name: 'ایمیل',
baseType: 'note',
icon: 'mail',
color: 'slate',
fields: [
{ key: 'email_subject', label: 'موضوع ایمیل', type: 'text' },
{ key: 'email_to', label: 'گیرنده', type: 'text', placeholder: 'example@domain.com' },
],
},
];
export type TypeRow = {
id: string;
name: string;
baseType: ActivityType;
icon: string;
color: string;
fields: Prisma.JsonValue;
position: number;
isActive: boolean;
isSystem: boolean;
};
/** نوع اصلی هر baseType — مقصد فعالیت‌های بدون typeDefId */
export type SystemTypes = Record<ActivityType, ActivityTypeSummaryDto | null>;
export type ResolvedType = {
/** مقداری که در Activity.typeDefId ذخیره می‌شود (نوع اصلی → null) */
typeDefId: string | null;
baseType: ActivityType;
fields: ActivityFieldDef[];
};
const select = {
id: true,
name: true,
baseType: true,
icon: true,
color: true,
fields: true,
position: true,
isActive: true,
isSystem: true,
} satisfies Prisma.ActivityTypeDefSelect;
@Injectable()
export class ActivityTypesService {
constructor(private readonly prisma: PrismaService) {}
async ensureDefaults(organizationId: string): Promise<void> {
const count = await this.prisma.activityTypeDef.count({ where: { organizationId } });
if (count > 0) return;
await this.prisma.activityTypeDef.createMany({
data: DEFAULT_TYPES.map((t, i) => ({
organizationId,
name: t.name,
baseType: t.baseType,
icon: t.icon,
color: t.color,
isSystem: Boolean(t.isSystem),
fields: t.fields as unknown as Prisma.InputJsonValue,
position: i,
})),
skipDuplicates: true,
});
}
async rows(organizationId: string): Promise<TypeRow[]> {
await this.ensureDefaults(organizationId);
return this.prisma.activityTypeDef.findMany({
where: { organizationId },
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
select,
});
}
async list(organizationId: string): Promise<ActivityTypeDefDto[]> {
return (await this.rows(organizationId)).map((r) => this.toDto(r));
}
systemOf(rows: TypeRow[]): SystemTypes {
const out: SystemTypes = { call: null, meeting: null, task: null, note: null };
for (const r of rows) {
if (r.isSystem && !out[r.baseType]) out[r.baseType] = this.toSummary(r);
}
return out;
}
async system(organizationId: string): Promise<SystemTypes> {
return this.systemOf(await this.rows(organizationId));
}
/** فیلتر Prisma برای فعالیت‌هایی که «مؤثراً» از این نوع هستند */
whereForType(rows: TypeRow[], typeDefId: string): Prisma.ActivityWhereInput {
const def = rows.find((r) => r.id === typeDefId);
if (!def) return { id: '__none__' };
if (!def.isSystem) return { typeDefId: def.id };
return { OR: [{ typeDefId: def.id }, { typeDefId: null, type: def.baseType }] };
}
/** جستجوی نام نوع → شرط‌های OR */
searchByName(rows: TypeRow[], term: string): Prisma.ActivityWhereInput[] {
const t = term.trim();
if (t.length < 2) return [];
return rows
.filter((r) => r.name.includes(t))
.map((r) => this.whereForType(rows, r.id));
}
/**
* نوع مؤثر برای ذخیره: typeDefId صریح، یا نوع اصلیِ type.
* نوع اصلی با typeDefId=null ذخیره می‌شود تا با داده‌های قدیمی یکسان باشد.
*/
async resolve(
organizationId: string,
input: { typeDefId?: string | null; type?: ActivityType | null },
): Promise<ResolvedType> {
const rows = await this.rows(organizationId);
const requested = input.typeDefId?.trim();
if (requested) {
const def = rows.find((r) => r.id === requested);
if (!def) throw new BadRequestException('نوع فعالیت انتخاب‌شده معتبر نیست');
return {
typeDefId: def.isSystem ? null : def.id,
baseType: def.baseType,
fields: this.parseFields(def.fields),
};
}
const baseType = input.type && BASE_TYPES.includes(input.type) ? input.type : 'task';
const sys = rows.find((r) => r.isSystem && r.baseType === baseType);
return { typeDefId: null, baseType, fields: sys ? this.parseFields(sys.fields) : [] };
}
/**
* پاک‌سازی مقادیر بر اساس تعریف فیلدها؛ فقط کلیدهای همین نوع نگه داشته می‌شوند.
* participantIds = کاربران معتبر همهٔ فیلدهای شرکت‌کنندگان.
*/
async sanitizeValues(
organizationId: string,
defs: ActivityFieldDef[],
raw: unknown,
): Promise<{ fields: ActivityFieldValues; participantIds: string[] }> {
const input =
raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
const out: ActivityFieldValues = {};
const participantFields: Array<{ key: string; value: ActivityParticipantsValue }> = [];
for (const def of defs) {
const value = this.cleanValue(def, input[def.key]);
if (value === null) {
if (def.required && def.type !== 'checkbox') {
throw new BadRequestException(`فیلد «${def.label}» را تکمیل کنید`);
}
continue;
}
if (def.type === 'participants') {
participantFields.push({ key: def.key, value: value as ActivityParticipantsValue });
}
out[def.key] = value;
}
const userIds = [...new Set(participantFields.flatMap((p) => p.value.userIds))];
const personIds = [...new Set(participantFields.flatMap((p) => p.value.people.map((x) => x.id)))];
const [validUsers, validPeople] = await Promise.all([
userIds.length
? this.prisma.user.findMany({
where: { id: { in: userIds }, memberships: { some: { organizationId } } },
select: { id: true },
})
: Promise.resolve([]),
personIds.length
? this.prisma.person.findMany({
where: { id: { in: personIds }, organizationId },
select: { id: true, firstName: true, lastName: true },
})
: Promise.resolve([]),
]);
const userSet = new Set(validUsers.map((u) => u.id));
const personNames = new Map(
validPeople.map((p) => [p.id, `${p.firstName} ${p.lastName}`.trim()]),
);
for (const p of participantFields) {
const next: ActivityParticipantsValue = {
userIds: p.value.userIds.filter((id) => userSet.has(id)),
people: p.value.people
.filter((x) => personNames.has(x.id))
.map((x) => ({ id: x.id, name: personNames.get(x.id) || x.name })),
guests: p.value.guests,
};
const def = defs.find((d) => d.key === p.key)!;
if (!next.userIds.length && !next.people.length && !next.guests.length) {
if (def.required) throw new BadRequestException(`فیلد «${def.label}» را تکمیل کنید`);
delete out[p.key];
} else {
out[p.key] = next;
}
}
const participantIds = [
...new Set(
Object.values(out)
.filter((v): v is ActivityParticipantsValue => isParticipants(v))
.flatMap((v) => v.userIds),
),
];
return { fields: out, participantIds };
}
async create(
organizationId: string,
dto: CreateActivityTypeRequest,
): Promise<ActivityTypeDefDto> {
await this.ensureDefaults(organizationId);
const max = await this.prisma.activityTypeDef.aggregate({
where: { organizationId },
_max: { position: true },
});
try {
const row = await this.prisma.activityTypeDef.create({
data: {
organizationId,
name: this.cleanName(dto.name),
baseType: this.cleanBase(dto.baseType),
icon: this.cleanIcon(dto.icon),
color: this.cleanColor(dto.color),
fields: this.cleanFieldDefs(dto.fields ?? []) as unknown as Prisma.InputJsonValue,
position: (max._max.position ?? -1) + 1,
},
select,
});
return this.toDto(row);
} catch (e) {
throw this.mapError(e);
}
}
async update(
organizationId: string,
id: string,
dto: UpdateActivityTypeRequest,
): Promise<ActivityTypeDefDto> {
const current = await this.get(organizationId, id);
if (current.isSystem && dto.baseType && dto.baseType !== current.baseType) {
throw new BadRequestException('نوع پایهٔ انواع اصلی قابل تغییر نیست');
}
if (current.isSystem && dto.isActive === false) {
throw new BadRequestException('انواع اصلی را نمی‌توان غیرفعال کرد');
}
const nextBase = dto.baseType ? this.cleanBase(dto.baseType) : current.baseType;
try {
const row = await this.prisma.$transaction(async (tx) => {
if (nextBase !== current.baseType) {
await tx.activity.updateMany({
where: { organizationId, typeDefId: id },
data: { type: nextBase },
});
}
return tx.activityTypeDef.update({
where: { id },
data: {
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
...(dto.icon != null ? { icon: this.cleanIcon(dto.icon) } : {}),
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
...(dto.fields != null
? { fields: this.cleanFieldDefs(dto.fields) as unknown as Prisma.InputJsonValue }
: {}),
...(dto.isActive != null ? { isActive: dto.isActive } : {}),
baseType: nextBase,
},
select,
});
});
return this.toDto(row);
} catch (e) {
throw this.mapError(e);
}
}
async reorder(organizationId: string, typeIds: string[]): Promise<ActivityTypeDefDto[]> {
await this.prisma.$transaction(
typeIds.map((id, position) =>
this.prisma.activityTypeDef.updateMany({
where: { id, organizationId },
data: { position },
}),
),
);
return this.list(organizationId);
}
async remove(organizationId: string, id: string): Promise<void> {
const def = await this.get(organizationId, id);
if (def.isSystem) throw new BadRequestException('انواع اصلی قابل حذف نیستند');
// onDelete: SetNull → فعالیت‌ها به نوع اصلیِ نوع پایهٔ خود برمی‌گردند
await this.prisma.activityTypeDef.delete({ where: { id } });
}
toSummary(row: Pick<TypeRow, 'id' | 'name' | 'icon' | 'color'>): ActivityTypeSummaryDto {
return {
id: row.id,
name: row.name,
icon: this.cleanIcon(row.icon),
color: this.cleanColor(row.color),
};
}
parseFields(raw: Prisma.JsonValue): ActivityFieldDef[] {
if (!Array.isArray(raw)) return [];
return raw.filter(
(f): f is ActivityFieldDef & Prisma.JsonObject =>
Boolean(f) && typeof f === 'object' && !Array.isArray(f) && typeof (f as { key?: unknown }).key === 'string',
) as ActivityFieldDef[];
}
private async get(organizationId: string, id: string): Promise<TypeRow> {
const row = await this.prisma.activityTypeDef.findFirst({
where: { id, organizationId },
select,
});
if (!row) throw new NotFoundException('نوع فعالیت پیدا نشد');
return row;
}
private cleanValue(def: ActivityFieldDef, raw: unknown): ActivityFieldValue {
switch (def.type) {
case 'text':
return cleanText(raw, 500);
case 'textarea':
return cleanText(raw, 5000);
case 'url':
return cleanText(raw, 1000);
case 'number': {
if (raw === '' || raw == null) return null;
const n = typeof raw === 'number' ? raw : Number(String(raw).replace(/,/g, ''));
return Number.isFinite(n) ? n : null;
}
case 'date': {
const s = cleanText(raw, 40);
return s && /^\d{4}-\d{2}-\d{2}/.test(s) && !Number.isNaN(new Date(s).getTime())
? s
: null;
}
case 'select': {
const s = cleanText(raw, 200);
return s && (def.options ?? []).includes(s) ? s : null;
}
case 'multiselect': {
if (!Array.isArray(raw)) return null;
const opts = new Set(def.options ?? []);
const list = [...new Set(raw.filter((v): v is string => typeof v === 'string' && opts.has(v)))];
return list.length ? list : null;
}
case 'checkbox':
return raw === true ? true : raw === false ? false : null;
case 'checklist': {
if (!Array.isArray(raw)) return null;
const items: ActivityChecklistItem[] = [];
for (const it of raw.slice(0, MAX_CHECKLIST)) {
if (!it || typeof it !== 'object') continue;
const o = it as Record<string, unknown>;
const text = cleanText(o.text, 300);
if (!text) continue;
const id = typeof o.id === 'string' && o.id.trim() ? o.id.trim().slice(0, 64) : randomUUID();
items.push({ id, text, done: o.done === true });
}
return items.length ? items : null;
}
case 'participants': {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const o = raw as Record<string, unknown>;
const userIds = Array.isArray(o.userIds)
? [...new Set(o.userIds.filter((v): v is string => typeof v === 'string' && Boolean(v.trim())))]
: [];
const people = Array.isArray(o.people)
? o.people
.filter(
(p): p is { id: string; name?: unknown } =>
Boolean(p) && typeof p === 'object' && typeof (p as { id?: unknown }).id === 'string',
)
.map((p) => ({ id: p.id, name: cleanText(p.name, 200) ?? '' }))
: [];
const guests = Array.isArray(o.guests)
? [
...new Set(
o.guests
.map((g) => cleanText(g, 120))
.filter((g): g is string => Boolean(g)),
),
]
: [];
if (!userIds.length && !people.length && !guests.length) return null;
return {
userIds: userIds.slice(0, MAX_PARTICIPANTS),
people: people.slice(0, MAX_PARTICIPANTS),
guests: guests.slice(0, MAX_PARTICIPANTS),
};
}
default:
return null;
}
}
private cleanFieldDefs(raw: ActivityFieldDef[]): ActivityFieldDef[] {
if (!Array.isArray(raw)) throw new BadRequestException('تعریف فیلدها نامعتبر است');
if (raw.length > MAX_FIELDS) {
throw new BadRequestException(`حداکثر ${MAX_FIELDS} فیلد برای هر نوع مجاز است`);
}
const keys = new Set<string>();
return raw.map((f, i) => {
const label = typeof f?.label === 'string' ? f.label.trim() : '';
if (!label) throw new BadRequestException(`عنوان فیلد ${i + 1} لازم است`);
if (label.length > 80) throw new BadRequestException('عنوان فیلد حداکثر ۸۰ کاراکتر است');
const type = ACTIVITY_FIELD_TYPES.includes(f.type as ActivityFieldType)
? (f.type as ActivityFieldType)
: null;
if (!type) throw new BadRequestException(`نوع فیلد «${label}» معتبر نیست`);
let key = typeof f.key === 'string' ? f.key.trim().toLowerCase() : '';
if (!/^[a-z][a-z0-9_]{0,39}$/.test(key)) key = `f_${randomUUID().slice(0, 8)}`;
if (keys.has(key)) throw new BadRequestException(`کلید فیلد «${label}» تکراری است`);
keys.add(key);
const def: ActivityFieldDef = { key, label, type };
if (ACTIVITY_FIELD_TYPES_WITH_OPTIONS.includes(type)) {
const options = [
...new Set(
(Array.isArray(f.options) ? f.options : [])
.map((o) => (typeof o === 'string' ? o.trim() : ''))
.filter(Boolean)
.map((o) => o.slice(0, 200)),
),
].slice(0, MAX_OPTIONS);
if (!options.length) {
throw new BadRequestException(`برای فیلد «${label}» حداقل یک گزینه تعریف کنید`);
}
def.options = options;
}
if (f.required) def.required = true;
const placeholder = typeof f.placeholder === 'string' ? f.placeholder.trim().slice(0, 200) : '';
if (placeholder) def.placeholder = placeholder;
return def;
});
}
private cleanName(raw: string): string {
const name = raw?.trim() ?? '';
if (!name) throw new BadRequestException('نام نوع فعالیت لازم است');
if (name.length > 60) throw new BadRequestException('نام نوع فعالیت حداکثر ۶۰ کاراکتر است');
return name;
}
private cleanBase(raw?: ActivityType): ActivityType {
return raw && BASE_TYPES.includes(raw) ? raw : 'task';
}
private cleanIcon(raw?: string): ActivityTypeIcon {
return (ACTIVITY_TYPE_ICONS as readonly string[]).includes(raw ?? '')
? (raw as ActivityTypeIcon)
: 'check';
}
private cleanColor(raw?: string): ActivityStageColor {
return ACTIVITY_STAGE_COLORS.includes(raw as ActivityStageColor)
? (raw as ActivityStageColor)
: 'green';
}
private mapError(e: unknown) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return new BadRequestException('نوع فعالیتی با این نام وجود دارد');
}
return e;
}
private toDto(row: TypeRow): ActivityTypeDefDto {
return {
...this.toSummary(row),
baseType: row.baseType,
fields: this.parseFields(row.fields),
position: row.position,
isActive: row.isActive,
isSystem: row.isSystem,
};
}
}
function cleanText(raw: unknown, max: number): string | null {
if (typeof raw !== 'string' && typeof raw !== 'number') return null;
const s = String(raw).trim();
return s ? s.slice(0, max) : null;
}
function isParticipants(v: unknown): v is ActivityParticipantsValue {
return Boolean(v) && typeof v === 'object' && !Array.isArray(v) && Array.isArray((v as ActivityParticipantsValue).userIds);
}
+203
View File
@@ -1,9 +1,12 @@
import { Type } from 'class-transformer';
import {
Allow,
IsArray,
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsObject,
IsOptional,
IsString,
Max,
@@ -12,6 +15,29 @@ import {
MinLength,
ValidateIf,
} from 'class-validator';
import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared';
import { ACTIVITY_TYPE_ICONS } from '@mento/shared';
/** تکه‌های JSON از ایمپورت مرورگرگرا */
export class ImportActivityRowsDto {
@IsArray()
@Allow()
rows!: Array<Record<string, string>>;
@Type(() => Number)
@IsInt()
@Min(0)
offset!: number;
@Type(() => Number)
@IsInt()
@Min(0)
total!: number;
@IsOptional()
@IsString()
sessionId?: string;
}
const ACTIVITY_TYPES = ['call', 'meeting', 'task', 'note'] as const;
const ACTIVITY_STATUSES = ['open', 'done', 'canceled'] as const;
@@ -27,6 +53,32 @@ export class ListActivitiesQueryDto {
@IsString()
q?: string;
@IsOptional()
@IsIn(['mine', 'team'])
desk?: 'mine' | 'team';
@IsOptional()
@IsIn(ACTIVITY_TYPES)
type?: (typeof ACTIVITY_TYPES)[number];
@IsOptional()
@IsIn(ACTIVITY_STATUSES)
status?: (typeof ACTIVITY_STATUSES)[number];
@IsOptional()
@IsString()
assigneeId?: string;
/** نوع فعالیت (تعریف‌شده در سازمان) */
@IsOptional()
@IsString()
typeDefId?: string;
/** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */
@IsOptional()
@IsString()
stageId?: string;
@IsOptional()
@IsString()
personId?: string;
@@ -39,6 +91,10 @@ export class ListActivitiesQueryDto {
@IsString()
dealId?: string;
@IsOptional()
@IsString()
leadId?: string;
/** Inclusive start (ISO). When set with/without filter, restricts dueAt. */
@IsOptional()
@IsDateString()
@@ -63,16 +119,134 @@ export class ListActivitiesQueryDto {
limit?: number = 50;
}
const STAGE_COLORS = ['green', 'orange', 'purple', 'blue', 'rose', 'slate'] as const;
export class CreateActivityStageDto {
@IsString()
@MinLength(1)
@MaxLength(80)
name!: string;
@IsOptional()
@IsIn(STAGE_COLORS)
color?: (typeof STAGE_COLORS)[number];
@IsOptional()
@IsIn(ACTIVITY_STATUSES)
kind?: (typeof ACTIVITY_STATUSES)[number];
}
export class UpdateActivityStageDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(80)
name?: string;
@IsOptional()
@IsIn(STAGE_COLORS)
color?: (typeof STAGE_COLORS)[number];
@IsOptional()
@IsIn(ACTIVITY_STATUSES)
kind?: (typeof ACTIVITY_STATUSES)[number];
}
export class ReorderActivityStagesDto {
@IsArray()
@IsString({ each: true })
stageIds!: string[];
}
export class MoveActivityStageDto {
@IsString()
stageId!: string;
}
/** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی می‌شود */
export class CreateActivityTypeDto {
@IsString()
@MinLength(1)
@MaxLength(60)
name!: string;
@IsOptional()
@IsIn(ACTIVITY_TYPES)
baseType?: (typeof ACTIVITY_TYPES)[number];
@IsOptional()
@IsIn(ACTIVITY_TYPE_ICONS)
icon?: ActivityTypeIcon;
@IsOptional()
@IsIn(STAGE_COLORS)
color?: (typeof STAGE_COLORS)[number];
@IsOptional()
@IsArray()
@Allow()
fields?: ActivityFieldDef[];
}
export class UpdateActivityTypeDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(60)
name?: string;
@IsOptional()
@IsIn(ACTIVITY_TYPES)
baseType?: (typeof ACTIVITY_TYPES)[number];
@IsOptional()
@IsIn(ACTIVITY_TYPE_ICONS)
icon?: ActivityTypeIcon;
@IsOptional()
@IsIn(STAGE_COLORS)
color?: (typeof STAGE_COLORS)[number];
@IsOptional()
@IsArray()
@Allow()
fields?: ActivityFieldDef[];
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class ReorderActivityTypesDto {
@IsArray()
@IsString({ each: true })
typeIds!: string[];
}
export class CreateActivityDto {
@IsString()
@MinLength(2)
@MaxLength(200)
title!: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
stageId?: string | null;
@IsOptional()
@IsIn(ACTIVITY_TYPES)
type?: (typeof ACTIVITY_TYPES)[number];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
typeDefId?: string | null;
@IsOptional()
@IsObject()
fields?: ActivityFieldValues;
@IsOptional()
@IsIn(ACTIVITY_PRIORITIES)
priority?: (typeof ACTIVITY_PRIORITIES)[number];
@@ -107,6 +281,11 @@ export class CreateActivityDto {
@IsBoolean()
reminderNotifySms?: boolean;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@@ -126,6 +305,11 @@ export class CreateActivityDto {
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
assigneeId?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
}
export class UpdateActivityDto {
@@ -139,6 +323,15 @@ export class UpdateActivityDto {
@IsIn(ACTIVITY_TYPES)
type?: (typeof ACTIVITY_TYPES)[number];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
typeDefId?: string | null;
@IsOptional()
@IsObject()
fields?: ActivityFieldValues;
@IsOptional()
@IsIn(ACTIVITY_STATUSES)
status?: (typeof ACTIVITY_STATUSES)[number];
@@ -177,6 +370,11 @@ export class UpdateActivityDto {
@IsBoolean()
reminderNotifySms?: boolean;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@@ -196,4 +394,9 @@ export class UpdateActivityDto {
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
assigneeId?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
}
File diff suppressed because it is too large Load Diff
-80
View File
@@ -1,80 +0,0 @@
import {
BadRequestException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
const MAX_AUDIO_BYTES = 8 * 1024 * 1024;
@Injectable()
export class AiSttService {
constructor(private readonly config: ConfigService) {}
isEnabled(): boolean {
return (
this.config.get<string>('STT_ENABLED') === 'true' &&
Boolean(this.sttUrl())
);
}
async transcribe(file: Express.Multer.File | undefined) {
if (!this.isEnabled()) {
throw new ServiceUnavailableException('تبدیل صوت به متن فعال نیست');
}
if (!file?.buffer?.length) {
throw new BadRequestException('فایل صوتی ارسال نشده است');
}
if (file.size > MAX_AUDIO_BYTES) {
throw new BadRequestException('حجم فایل صوتی بیش از حد مجاز است');
}
const form = new FormData();
form.append(
'audio',
new Blob([new Uint8Array(file.buffer)], {
type: file.mimetype || 'audio/wav',
}),
file.originalname || 'audio.wav',
);
const timeoutMs = Number(this.config.get<string>('STT_TIMEOUT_MS') || 60000);
const headers: Record<string, string> = {};
const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim();
if (token) headers['X-STT-Token'] = token;
let res: Response;
try {
res = await fetch(`${this.sttUrl()}/transcribe`, {
method: 'POST',
headers,
body: form,
signal: AbortSignal.timeout(timeoutMs),
});
} catch {
throw new ServiceUnavailableException(
'سرویس تبدیل صوت در دسترس نیست. Pipecat STT را اجرا کنید.',
);
}
const payload = (await res.json().catch(() => ({}))) as {
text?: string;
detail?: string | Array<{ msg?: string }>;
};
if (!res.ok) {
const detail = Array.isArray(payload.detail)
? payload.detail.map((item) => item.msg).filter(Boolean).join('، ')
: payload.detail;
throw new ServiceUnavailableException(
detail || 'تبدیل صوت به متن ناموفق بود',
);
}
return { text: (payload.text ?? '').trim() };
}
private sttUrl(): string {
return (this.config.get<string>('STT_URL') ?? '').trim().replace(/\/$/, '');
}
}
+7 -26
View File
@@ -1,22 +1,10 @@
import {
Body,
Controller,
Get,
Param,
Post,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { AiAskService } from './ai-ask.service';
import { AskMentoDto } from './dto';
import { AskMentoDto, VoiceCommandDto } from './dto';
import { AiService } from './ai.service';
import { AiSttService } from './ai-stt.service';
@Controller('ai')
@UseGuards(JwtAuthGuard)
@@ -24,28 +12,21 @@ export class AiController {
constructor(
private readonly ai: AiService,
private readonly ask: AiAskService,
private readonly stt: AiSttService,
) {}
@Get('status')
status() {
return { enabled: this.ai.isEnabled(), stt: this.stt.isEnabled() };
return { enabled: this.ai.isEnabled() };
}
@Post('transcribe')
@UseInterceptors(
FileInterceptor('audio', {
storage: memoryStorage(),
limits: { fileSize: 8 * 1024 * 1024 },
}),
)
transcribe(@UploadedFile() file: Express.Multer.File) {
return this.stt.transcribe(file);
@Post('voice-command')
voiceCommand(@CurrentUser() user: Actor, @Body() dto: VoiceCommandDto) {
return this.ask.parseVoiceCommand(user, dto.text);
}
@Post('ask')
askMento(@CurrentUser() user: Actor, @Body() dto: AskMentoDto) {
return this.ask.ask(user, dto.question, dto.personId);
return this.ask.ask(user, dto.question, dto.personId, dto.chatContext);
}
@Get('people/:personId/summary')
+2 -3
View File
@@ -2,12 +2,11 @@ import { Global, Module } from '@nestjs/common';
import { AiAskService } from './ai-ask.service';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';
import { AiSttService } from './ai-stt.service';
@Global()
@Module({
controllers: [AiController],
providers: [AiService, AiAskService, AiSttService],
exports: [AiService, AiSttService],
providers: [AiService, AiAskService],
exports: [AiService],
})
export class AiModule {}
+45 -4
View File
@@ -4,6 +4,7 @@ import {
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { rewriteGregorianDates } from '../common/jalali';
export type NoteAiExtract = {
nextAction: {
@@ -44,6 +45,7 @@ Rules:
- dueAt: ISO 8601 date-time if a deadline is mentioned; otherwise null.
- memoryItems: durable facts about the customer (preferences, budget, decisions, family, objections) — NOT tasks.
- Keep text in Persian when the note is Persian.
- If summary mentions a date, write it Shamsi/Jalali like ۱۴۰۴/۰۷/۲۱, never Gregorian month names such as سپتامبر.
- Do not invent facts not supported by the note.
- Maximum 5 memory items.`;
@@ -61,6 +63,45 @@ Rules:
return this.normalizeExtract(raw);
}
async summarizeCallTranscript(input: {
transcript: string;
personName?: string | null;
direction?: string | null;
}): Promise<{ summary: string; nextAction: string | null }> {
const system = `You summarize phone call transcripts for a Persian CRM.
Return ONLY valid JSON:
{
"summary": string,
"nextAction": string | null
}
Rules:
- summary: 3–8 concise Persian sentences covering purpose, customer needs, outcomes, and commitments.
- nextAction: one short Persian follow-up if needed; otherwise null.
- Do not invent facts not in the transcript.
- Keep names, numbers, and amounts accurate.
- Dates in summary and nextAction must be Shamsi/Jalali like ۱۴۰۴/۰۷/۲۱, never Gregorian month names such as سپتامبر.`;
const userParts = [
input.personName ? `Customer: ${input.personName}` : null,
input.direction ? `Direction: ${input.direction}` : null,
`Transcript:\n${input.transcript.trim().slice(0, 12000)}`,
].filter(Boolean);
const raw = await this.completeJson<{
summary?: string;
nextAction?: string | null;
}>(system, userParts.join('\n'));
const summary = rewriteGregorianDates(String(raw.summary ?? '').trim()).slice(0, 4000);
if (!summary) {
throw new BadRequestException('خلاصه تماس خالی بود');
}
const nextAction = raw.nextAction
? rewriteGregorianDates(String(raw.nextAction).trim()).slice(0, 500) || null
: null;
return { summary, nextAction };
}
async completeJson<T>(system: string, user: string): Promise<T> {
if (!this.isEnabled()) {
throw new ServiceUnavailableException('هوش مصنوعی فعال نیست');
@@ -131,9 +172,9 @@ Rules:
const nextAction =
raw.nextAction && typeof raw.nextAction === 'object'
? {
title: String(raw.nextAction.title ?? '').trim().slice(0, 200),
title: rewriteGregorianDates(String(raw.nextAction.title ?? '').trim()).slice(0, 200),
description: raw.nextAction.description
? String(raw.nextAction.description).trim().slice(0, 2000)
? rewriteGregorianDates(String(raw.nextAction.description).trim()).slice(0, 2000)
: undefined,
dueAt: raw.nextAction.dueAt
? String(raw.nextAction.dueAt).trim()
@@ -144,7 +185,7 @@ Rules:
const memoryItems = Array.isArray(raw.memoryItems)
? raw.memoryItems
.map((item) => ({
content: String(item?.content ?? '').trim().slice(0, 500),
content: rewriteGregorianDates(String(item?.content ?? '').trim()).slice(0, 500),
category: item?.category
? String(item.category).trim().slice(0, 40)
: undefined,
@@ -159,7 +200,7 @@ Rules:
return {
nextAction: cleanedNext,
memoryItems,
summary: raw.summary ? String(raw.summary).trim().slice(0, 500) : undefined,
summary: raw.summary ? rewriteGregorianDates(String(raw.summary).trim()).slice(0, 500) : undefined,
};
}
}
+247
View File
@@ -0,0 +1,247 @@
import {
PERSON_IMPORT_SOURCES,
type PersonImportSource,
} from '@mento/shared';
export type CountEntity =
| 'lead'
| 'person'
| 'company'
| 'deal'
| 'invoice'
| 'quote'
| 'product'
| 'activity'
| 'ticket'
| 'note'
| 'cheque'
| 'reservation'
| 'routine'
| 'project';
export type CountPeriod = 'today' | 'yesterday' | 'week' | 'last_week' | 'month' | 'last_month';
export type LeadBucket = 'summary' | 'open' | 'archived' | 'converted';
export type DealBucket = 'all' | 'open' | 'won' | 'lost' | 'stale';
export type CountIntent = {
entity: CountEntity;
personSource?: PersonImportSource;
mood?: 'newcomer' | 'happy' | 'unhappy' | 'special';
leadBucket: LeadBucket;
dealBucket: DealBucket;
period?: CountPeriod;
/** متن باقی‌مانده برای تطبیق مرحله یا منبع لید */
hint: string;
};
const SOURCE_PATTERNS: Array<{ re: RegExp; source: PersonImportSource }> = [
{ re: /دستی/, source: 'manual' },
{ re: /اکسل/, source: 'excel' },
{ re: /دیدار/, source: 'didar' },
{ re: /ته\s*حساب/, source: 'tahesab' },
{ re: /گوشی|مخاطب\s*گوشی|مخاطبین/, source: 'phone' },
{ re: /فرم/, source: 'lead_form' },
{ re: /مونتیفای/, source: 'montify' },
{ re: /سایر|بقیه/, source: 'other' },
];
export function normalizeAskText(raw: string) {
return raw
.replace(/ي/g, 'ی')
.replace(/ك/g, 'ک')
.replace(/‌/g, ' ')
.replace(/[؟?!.,،؛:]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
export function isCountQuestion(q: string) {
return /چند|تعداد|چقدر|چنتا|چند\s*تا/.test(q);
}
function detectEntity(q: string): CountEntity | null {
if (/لید|سرنخ/.test(q)) return 'lead';
if (/پیش\s*فاکتور/.test(q)) return 'quote';
if (/فاکتور/.test(q)) return 'invoice';
if (/معاملات|معامله/.test(q)) return 'deal';
if (/چک/.test(q)) return 'cheque';
if (/رزرو|نوبت/.test(q)) return 'reservation';
if (/روتین/.test(q)) return 'routine';
if (/پروژه/.test(q)) return 'project';
if (/تیکت/.test(q)) return 'ticket';
if (/یادداشت/.test(q)) return 'note';
if (/فعالیت/.test(q)) return 'activity';
if (/محصول|کالا/.test(q)) return 'product';
if (/شرکت/.test(q) && !/مشتری/.test(q)) return 'company';
if (/مشتری|اشخاص|شخص ها|افراد|مخاطب/.test(q)) return 'person';
return null;
}
function detectPeriod(q: string): CountPeriod | undefined {
if (/دیروز/.test(q)) return 'yesterday';
if (/امروز/.test(q)) return 'today';
if (/هفته\s*(?:ی\s+)?(گذشته|قبل|پیش)/.test(q)) return 'last_week';
if (/این\s*هفته|هفته\s*جاری/.test(q)) return 'week';
if (/ماه\s*(?:ی\s+)?(گذشته|قبل|پیش)/.test(q)) return 'last_month';
if (/این\s*ماه|ماه\s*جاری/.test(q)) return 'month';
return undefined;
}
function detectLeadBucket(q: string): LeadBucket {
if (/بایگانی/.test(q)) return 'archived';
if (/تبدیل/.test(q)) return 'converted';
if (/باز|فعال|جاری/.test(q)) return 'open';
return 'summary';
}
function detectDealBucket(q: string): DealBucket {
if (/در\s*خطر|راکد|گندیده/.test(q)) return 'stale';
if (/برنده|موفق|بسته\s*شده\s*موفق/.test(q)) return 'won';
if (/باخته|از\s*دست\s*رفته|ناموفق/.test(q)) return 'lost';
if (/باز|جاری|فعال/.test(q)) return 'open';
return 'all';
}
function detectMood(q: string): CountIntent['mood'] {
if (/ناراضی/.test(q)) return 'unhappy';
if (/خوشحال/.test(q)) return 'happy';
if (/خاص/.test(q)) return 'special';
if (/تازه/.test(q)) return 'newcomer';
return undefined;
}
const HINT_STOP = [
'چند تا',
'ته حساب',
'مخاطب گوشی',
'مخاطبین',
'پیش فاکتور',
'بایگانی',
'مونتیفای',
'معاملات',
'مشتریان',
'سرنخ',
'لیدها',
'فعالیت',
'یادداشت',
'بگویید',
'داریم',
'دارید',
'هستند',
'تعداد',
'چقدر',
'چنتا',
'امروز',
'دیروز',
'جاری',
'تبدیل',
'برنده',
'باخته',
'ناراضی',
'خوشحال',
'محصول',
'فاکتور',
'معامله',
'مشتری',
'شرکت',
'پروژه',
'روتین',
'رزرو',
'نوبت',
'تیکت',
'منبع',
'مرحله',
'وضعیت',
'هفته',
'گذشته',
'پیش',
'قبل',
'لطفا',
'الان',
'شده',
'وارد',
'تازه',
'خاص',
'بگو',
'دارم',
'هست',
'است',
'جاری',
'فعال',
'باز',
'لید',
'کالا',
'چک',
'ماه',
'پیش',
'های',
'چند',
'کل',
'همه',
'این',
'یک',
'یه',
'از',
'با',
'در',
'تا',
'ها',
].sort((a, b) => b.length - a.length);
const PERSON_HINT_STOP = [
'ته حساب',
'مخاطب گوشی',
'مخاطبین',
'مونتیفای',
'ناراضی',
'خوشحال',
'دستی',
'اکسل',
'دیدار',
'گوشی',
'مخاطب',
'فرم',
'سایر',
'تازه',
'خاص',
].sort((a, b) => b.length - a.length);
function leftoverHint(q: string, entity: CountEntity) {
let text = ` ${q} `;
const words = entity === 'person' || entity === 'company'
? [...HINT_STOP, ...PERSON_HINT_STOP]
: HINT_STOP;
for (const word of [...words].sort((a, b) => b.length - a.length)) {
text = text.replaceAll(word, ' ');
}
return text.replace(/\s+/g, ' ').trim();
}
export function classifyCountQuestion(raw: string): CountIntent | null {
const q = normalizeAskText(raw);
if (!isCountQuestion(q)) return null;
const entity = detectEntity(q);
if (!entity) return null;
const personSource = SOURCE_PATTERNS.find((item) => item.re.test(q))?.source;
const period = detectPeriod(q);
let hint = leftoverHint(q, entity);
if (period && entity === 'lead' && !/مرحله|وضعیت|ستون/.test(q)) {
hint = hint.replace(/(^|\s)جدید(?=\s|$)/g, ' ').replace(/\s+/g, ' ').trim();
}
return {
entity,
personSource:
entity === 'person' || entity === 'company' ? personSource : undefined,
mood: entity === 'person' ? detectMood(q) : undefined,
leadBucket: detectLeadBucket(q),
dealBucket: detectDealBucket(q),
period,
hint,
};
}
export function personSourceKeys(): readonly PersonImportSource[] {
return PERSON_IMPORT_SOURCES;
}
+12
View File
@@ -1,5 +1,12 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class VoiceCommandDto {
@IsString()
@MinLength(2)
@MaxLength(800)
text!: string;
}
export class AskMentoDto {
@IsString()
@MinLength(2)
@@ -9,4 +16,9 @@ export class AskMentoDto {
@IsOptional()
@IsString()
personId?: string;
@IsOptional()
@IsString()
@MaxLength(4000)
chatContext?: string;
}
+32
View File
@@ -28,12 +28,28 @@ import { LoyaltyModule } from './loyalty/loyalty.module';
import { AutomationsModule } from './automations/automations.module';
import { MessageAutomationsModule } from './message-automations/message-automations.module';
import { AiModule } from './ai/ai.module';
import { TrackingModule } from './tracking/tracking.module';
import { MemoryModule } from './memory/memory.module';
import { TahesabModule } from './integrations/tahesab/tahesab.module';
import { InventoryModule } from './inventory/inventory.module';
import { FinanceModule } from './finance/finance.module';
import { PlatformModule } from './platform/platform.module';
import { BillingModule } from './billing/billing.module';
import { AuditLogsModule } from './audit-logs/audit-logs.module';
import { SavedViewsModule } from './saved-views/saved-views.module';
import { TablePrefsModule } from './table-prefs/table-prefs.module';
import { PublicApiModule } from './public-api/public-api.module';
import { SmsCampaignsModule } from './sms-campaigns/sms-campaigns.module';
import { TeamsModule } from './teams/teams.module';
import { WooCommerceModule } from './integrations/woocommerce/woocommerce.module';
import { MontifyModule } from './integrations/montify/montify.module';
import { VoipModule } from './integrations/voip/voip.module';
import { ReservationsModule } from './reservations/reservations.module';
import { LeadsModule } from './leads/leads.module';
import { ConversationsModule } from './conversations/conversations.module';
import { RoutinesModule } from './routines/routines.module';
import { GamificationModule } from './gamification/gamification.module';
import { DeletionsModule } from './deletions/deletions.module';
@Module({
imports: [
@@ -53,6 +69,7 @@ import { BillingModule } from './billing/billing.module';
OrgRolesModule,
OrganizationsModule,
ContactsModule,
LeadsModule,
PipelinesModule,
ActivitiesModule,
NotesModule,
@@ -75,6 +92,21 @@ import { BillingModule } from './billing/billing.module';
MessageAutomationsModule,
MemoryModule,
TahesabModule,
AuditLogsModule,
SavedViewsModule,
TablePrefsModule,
PublicApiModule,
SmsCampaignsModule,
TrackingModule,
TeamsModule,
WooCommerceModule,
MontifyModule,
VoipModule,
ReservationsModule,
ConversationsModule,
RoutinesModule,
GamificationModule,
DeletionsModule,
],
})
export class AppModule {}
+78
View File
@@ -0,0 +1,78 @@
const FIELD_LABELS: Record<string, string> = {
title: 'عنوان',
name: 'نام',
firstName: 'نام',
lastName: 'نام خانوادگی',
fullName: 'نام',
description: 'توضیحات',
body: 'متن',
mobile: 'موبایل',
phone: 'تلفن',
email: 'ایمیل',
status: 'وضعیت',
stageId: 'مرحله',
priority: 'اولویت',
dueAt: 'موعد',
amount: 'مبلغ',
subject: 'موضوع',
type: 'نوع',
city: 'شهر',
address: 'آدرس',
notes: 'یادداشت',
contactName: 'نام مخاطب',
};
const SECRET = /password|token|secret|otp|pin/i;
function textOf(value: unknown) {
if (typeof value === 'string') return value.trim().replace(/\s+/g, ' ');
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return '';
}
function labelOf(source: unknown) {
if (!source || typeof source !== 'object' || Array.isArray(source)) return '';
const body = source as Record<string, unknown>;
const person = [textOf(body.firstName), textOf(body.lastName)].filter(Boolean).join(' ');
return (
person ||
textOf(body.title) ||
textOf(body.name) ||
textOf(body.subject) ||
textOf(body.fullName)
).slice(0, 120);
}
function changesOf(source: unknown) {
if (!source || typeof source !== 'object' || Array.isArray(source)) return '';
const bits: string[] = [];
for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
if (SECRET.test(key)) continue;
const name = FIELD_LABELS[key];
if (!name) continue;
if (key.endsWith('Id')) {
bits.push(name);
} else {
const text = textOf(value).slice(0, 40);
if (!text) continue;
bits.push(`${name}: ${text}`);
}
if (bits.length >= 4) break;
}
return bits.join('، ');
}
export function auditStory(
method: string,
requestBody: unknown,
responseBody: unknown,
) {
const label = labelOf(responseBody) || labelOf(requestBody);
const detail = method === 'DELETE' ? '' : changesOf(requestBody);
const summary = [label, detail].filter(Boolean).join(' — ').slice(0, 500);
return {
label,
detail: detail.slice(0, 240),
summary,
};
}
@@ -0,0 +1,64 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { AuditLogsService } from './audit-logs.service';
class ListAuditQueryDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsString()
actorUserId?: string;
@IsOptional()
@IsString()
entityType?: string;
@IsOptional()
@IsString()
action?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number;
}
@Controller('audit-logs')
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@RequireOrgModule('audit_logs')
@Roles('owner', 'admin')
export class AuditLogsController {
constructor(private readonly auditLogs: AuditLogsService) {}
@Get()
list(
@CurrentUser() user: { organizationId: string },
@Query() query: ListAuditQueryDto,
) {
return this.auditLogs.list(user.organizationId, {
q: query.q,
actorUserId: query.actorUserId,
entityType: query.entityType,
action: query.action,
page: query.page,
limit: query.limit,
});
}
}
@@ -0,0 +1,105 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import type { Request } from 'express';
import { Observable, tap } from 'rxjs';
import { auditStory } from './audit-detail';
import { AuditLogsService } from './audit-logs.service';
const SKIP_PREFIXES = [
'/api/health',
'/api/auth/login',
'/api/auth/register',
'/api/auth/otp',
'/api/auth/refresh',
'/api/portal',
'/api/public',
'/api/platform',
'/api/audit-logs',
];
type AuthedRequest = Request & {
user?: {
userId?: string;
id?: string;
organizationId?: string;
};
};
function inferEntity(path: string): { entityType: string | null; entityId: string | null } {
const clean = path.replace(/^\/api\/?/, '').split('?')[0] ?? '';
const parts = clean.split('/').filter(Boolean);
if (!parts.length) return { entityType: null, entityId: null };
const entityType = parts[0] ?? null;
const maybeId = parts[1];
const entityId =
maybeId && !['new', 'me', 'summary', 'dashboard', 'board'].includes(maybeId)
? maybeId
: null;
return { entityType, entityId };
}
function actionLabel(method: string, path: string): string {
const m = method.toUpperCase();
if (m === 'POST') return 'ایجاد';
if (m === 'PATCH' || m === 'PUT') return 'ویرایش';
if (m === 'DELETE') return 'حذف';
return m;
void path;
}
@Injectable()
export class AuditLogsInterceptor implements NestInterceptor {
constructor(private readonly audit: AuditLogsService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const http = context.switchToHttp();
const req = http.getRequest<AuthedRequest>();
const method = (req.method ?? 'GET').toUpperCase();
if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) {
return next.handle();
}
const path = req.originalUrl || req.url || '';
if (SKIP_PREFIXES.some((p) => path.startsWith(p))) {
return next.handle();
}
const organizationId = req.user?.organizationId;
if (!organizationId) return next.handle();
const actorUserId = req.user?.userId ?? req.user?.id ?? null;
const { entityType, entityId } = inferEntity(path);
const action = actionLabel(method, path);
const ip =
(req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() ||
req.ip ||
null;
return next.handle().pipe(
tap({
next: (response) => {
const story = auditStory(method, req.body, response);
const summary =
story.summary ||
`${action} ${entityType ?? 'منبع'} از مسیر ${path.split('?')[0]}`;
void this.audit.write({
organizationId,
actorUserId,
action,
method,
path: path.split('?')[0] ?? path,
entityType,
entityId,
summary,
meta: story.label || story.detail ? { label: story.label, detail: story.detail } : null,
ip,
});
},
}),
);
}
}
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { AuthModule } from '../auth/auth.module';
import { BillingModule } from '../billing/billing.module';
import { AuditLogsController } from './audit-logs.controller';
import { AuditLogsInterceptor } from './audit-logs.interceptor';
import { AuditLogsService } from './audit-logs.service';
@Module({
imports: [AuthModule, BillingModule],
controllers: [AuditLogsController],
providers: [
AuditLogsService,
{
provide: APP_INTERCEPTOR,
useClass: AuditLogsInterceptor,
},
],
exports: [AuditLogsService],
})
export class AuditLogsModule {}
@@ -0,0 +1,128 @@
import { Injectable } from '@nestjs/common';
import type { AuditLogDto, AuditLogListResponse } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { buildSearchWhere, ci } from '../common/search';
export type AuditWriteInput = {
organizationId: string;
actorUserId?: string | null;
action: string;
method: string;
path: string;
entityType?: string | null;
entityId?: string | null;
summary: string;
meta?: Record<string, unknown> | null;
ip?: string | null;
};
@Injectable()
export class AuditLogsService {
constructor(private readonly prisma: PrismaService) {}
async write(input: AuditWriteInput): Promise<void> {
try {
await this.prisma.auditLog.create({
data: {
organizationId: input.organizationId,
actorUserId: input.actorUserId ?? null,
action: input.action,
method: input.method.slice(0, 16),
path: input.path.slice(0, 400),
entityType: input.entityType ?? null,
entityId: input.entityId ?? null,
summary: input.summary.slice(0, 500),
meta:
input.meta == null
? undefined
: (input.meta as Prisma.InputJsonValue),
ip: input.ip ?? null,
},
});
} catch {
// never break the main request because of audit write failures
}
}
async list(
organizationId: string,
opts: {
q?: string;
actorUserId?: string;
entityType?: string;
action?: string;
page?: number;
limit?: number;
},
): Promise<AuditLogListResponse> {
const page = Math.max(1, opts.page ?? 1);
const limit = Math.min(100, Math.max(1, opts.limit ?? 30));
const where: Prisma.AuditLogWhereInput = {
organizationId,
...(opts.actorUserId ? { actorUserId: opts.actorUserId } : {}),
...(opts.entityType ? { entityType: opts.entityType } : {}),
...(opts.action ? { action: opts.action } : {}),
};
const search = buildSearchWhere<Prisma.AuditLogWhereInput>(opts.q, (t) => [
{ summary: ci(t) },
{ path: ci(t) },
{ action: ci(t) },
{ actor: { fullName: ci(t) } },
]);
if (search) where.AND = [search];
const [total, rows] = await Promise.all([
this.prisma.auditLog.count({ where }),
this.prisma.auditLog.findMany({
where,
include: { actor: { select: { id: true, fullName: true } } },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
]);
return {
total,
page,
limit,
items: rows.map((row) => this.toDto(row)),
};
}
private toDto(row: {
id: string;
action: string;
method: string;
path: string;
entityType: string | null;
entityId: string | null;
summary: string;
meta: Prisma.JsonValue | null;
ip: string | null;
createdAt: Date;
actor: { id: string; fullName: string } | null;
}): AuditLogDto {
const meta =
row.meta && typeof row.meta === 'object' && !Array.isArray(row.meta)
? (row.meta as { label?: unknown; detail?: unknown })
: null;
const label = typeof meta?.label === 'string' ? meta.label : null;
const detail = typeof meta?.detail === 'string' ? meta.detail : null;
return {
id: row.id,
action: row.action,
method: row.method,
path: row.path,
entityType: row.entityType,
entityId: row.entityId,
summary: row.summary,
label,
detail,
actor: row.actor,
ip: row.ip,
createdAt: row.createdAt.toISOString(),
};
}
}
+39 -10
View File
@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Delete,
@@ -25,8 +26,7 @@ import {
} from './dto';
import { JwtAuthGuard } from './jwt-auth.guard';
import { CurrentUser } from './current-user.decorator';
type AuthActor = { userId: string; organizationId: string };
import type { Actor } from '../common/record-scope';
@Controller('auth')
export class AuthController {
@@ -68,7 +68,7 @@ export class AuthController {
@UseGuards(JwtAuthGuard)
@Post('switch-organization')
switchOrganization(
@CurrentUser() user: AuthActor,
@CurrentUser() user: Actor,
@Body() dto: SwitchOrganizationDto,
) {
return this.authService.switchOrganization(user.userId, dto.organizationId);
@@ -76,26 +76,55 @@ export class AuthController {
@UseGuards(JwtAuthGuard)
@Get('organizations')
listOrganizations(@CurrentUser() user: AuthActor) {
listOrganizations(@CurrentUser() user: Actor) {
return this.authService.listMyOrganizations(user.userId);
}
@UseGuards(JwtAuthGuard)
@Get('me')
me(@CurrentUser() user: AuthActor) {
return this.authService.me(user.userId, user.organizationId);
me(@CurrentUser() user: Actor) {
return this.authService.me(user.userId, user.organizationId, user);
}
@UseGuards(JwtAuthGuard)
@Post('view-as')
viewAs(
@CurrentUser() user: Actor,
@Body() body: { targetUserId?: string },
) {
if (!body.targetUserId?.trim()) {
throw new BadRequestException('کاربر هدف مشخص نیست.');
}
return this.authService.viewAs(user, body.targetUserId.trim());
}
@UseGuards(JwtAuthGuard)
@Patch('me')
updateMe(@CurrentUser() user: AuthActor, @Body() dto: UpdateProfileDto) {
updateMe(@CurrentUser() user: Actor, @Body() dto: UpdateProfileDto) {
return this.authService.updateMe(user.userId, user.organizationId, dto);
}
@UseGuards(JwtAuthGuard)
@Patch('me/prior-archive')
setPriorLens(
@CurrentUser() user: Actor,
@Body() body: { lens?: string },
) {
if (body.lens !== 'archive' && body.lens !== 'current') {
throw new BadRequestException('حالت بایگانی نامعتبر است');
}
return this.authService.setPriorLens(
user.userId,
user.organizationId,
body.lens,
user,
);
}
/** عکس پروفایل کاربر جاری */
@UseGuards(JwtAuthGuard)
@Get('me/avatar')
async avatar(@CurrentUser() user: AuthActor) {
async avatar(@CurrentUser() user: Actor) {
const file = await this.authService.openAvatar(user.userId);
return new StreamableFile(file.stream, { type: file.mimeType });
}
@@ -109,7 +138,7 @@ export class AuthController {
}),
)
uploadAvatar(
@CurrentUser() user: AuthActor,
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
) {
return this.authService.saveAvatar(user.userId, user.organizationId, file);
@@ -117,7 +146,7 @@ export class AuthController {
@UseGuards(JwtAuthGuard)
@Delete('me/avatar')
removeAvatar(@CurrentUser() user: AuthActor) {
removeAvatar(@CurrentUser() user: Actor) {
return this.authService.removeAvatar(user.userId, user.organizationId);
}
}
+12 -1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
@@ -8,6 +9,7 @@ import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
import { RolesGuard } from './roles.guard';
import { ViewAsReadOnlyInterceptor } from './view-as-readonly.interceptor';
import { SmsService } from '../common/sms.service';
@Module({
@@ -28,7 +30,16 @@ import { SmsService } from '../common/sms.service';
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, RolesGuard, SmsService],
providers: [
AuthService,
JwtStrategy,
RolesGuard,
SmsService,
{
provide: APP_INTERCEPTOR,
useClass: ViewAsReadOnlyInterceptor,
},
],
exports: [AuthService, RolesGuard, JwtModule],
})
export class AuthModule {}
+169 -9
View File
@@ -1,6 +1,7 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
@@ -18,11 +19,14 @@ import type {
SubscriptionSummaryDto,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
import type {} from 'multer';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/sms.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import { MembershipsService } from '../memberships/memberships.service';
import { BillingService } from '../billing/billing.service';
import type { Actor } from '../common/record-scope';
import {
LoginDto,
LoginOtpRequestDto,
@@ -33,7 +37,7 @@ import {
} from './dto';
const OTP_LENGTH = 6;
const OTP_TTL_SECONDS = 5 * 60;
const OTP_TTL_SECONDS = 90;
const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
const AVATAR_EXT: Record<string, string> = {
'image/jpeg': 'jpg',
@@ -77,7 +81,8 @@ function maskEmail(email: string): string {
function maskMobile(mobile: string): string {
if (mobile.length < 4) return '****';
return `${mobile.slice(0, 4)}***${mobile.slice(-2)}`;
// نمایش RTL: دو رقم آخر سپس *** سپس پیش‌شماره (مثلاً 69***0913)
return `${mobile.slice(-2)}***${mobile.slice(0, 4)}`;
}
function generateOtp(): string {
@@ -402,13 +407,130 @@ export class AuthService {
return this.finishLogin(user.id);
}
async me(userId: string, organizationId: string): Promise<AuthUserDto> {
async me(userId: string, organizationId: string, actor?: Actor): Promise<AuthUserDto> {
await this.memberships.requireActive(userId, organizationId);
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new UnauthorizedException();
}
return this.toAuthUserDto(user, organizationId);
let impersonatorFullName: string | undefined;
if (actor?.viewAsReadOnly && actor.impersonatorUserId) {
const owner = await this.prisma.user.findUnique({
where: { id: actor.impersonatorUserId },
select: { fullName: true },
});
impersonatorFullName = owner?.fullName;
}
return this.toAuthUserDto(user, organizationId, undefined, actor, impersonatorFullName);
}
/**
* مالک محیط: مشاهده فقط‌خواندنی به‌جای کاربر هدف، یا بازگشت به حساب خودش.
*/
async viewAs(actor: Actor, targetUserId: string): Promise<LoginResponse> {
const ownerId = actor.impersonatorUserId ?? actor.userId;
const ownerMembership = await this.prisma.membership.findFirst({
where: {
userId: ownerId,
organizationId: actor.organizationId,
isActive: true,
role: 'owner',
},
select: { userId: true, organizationId: true },
});
if (!ownerMembership) {
throw new ForbiddenException('فقط مالک محیط می‌تواند به‌جای کاربر دیگر مشاهده کند.');
}
if (!targetUserId?.trim()) {
throw new BadRequestException('کاربر هدف مشخص نیست.');
}
if (targetUserId === ownerId) {
return this.issueLogin(ownerId, actor.organizationId, { skipLastLogin: true });
}
const target = await this.prisma.user.findFirst({
where: {
id: targetUserId,
isActive: true,
memberships: {
some: {
organizationId: actor.organizationId,
isActive: true,
},
},
},
select: {
id: true,
email: true,
mobile: true,
fullName: true,
memberships: {
where: { organizationId: actor.organizationId, isActive: true },
select: { role: true },
take: 1,
},
},
});
if (!target?.memberships[0]) {
throw new NotFoundException('کاربر در این محیط پیدا نشد.');
}
const role = target.memberships[0].role as AuthUserDto['role'];
const subscription = await this.billing.requireActiveForOrganization(
actor.organizationId,
);
const accessToken = this.jwt.sign({
sub: target.id,
organizationId: actor.organizationId,
role,
impersonatorUserId: ownerId,
viewAsReadOnly: true,
});
const ownerUser = await this.prisma.user.findUnique({
where: { id: ownerId },
select: { fullName: true },
});
const userDto = await this.toAuthUserDto(
{ ...target, role, organizationId: actor.organizationId },
actor.organizationId,
subscription,
{
userId: target.id,
role,
organizationId: actor.organizationId,
impersonatorUserId: ownerId,
impersonatorRole: 'owner',
viewAsReadOnly: true,
},
ownerUser?.fullName ?? 'مالک',
);
return { accessToken, user: userDto };
}
async setPriorLens(
userId: string,
organizationId: string,
lens: 'current' | 'archive',
actor?: Actor,
): Promise<AuthUserDto> {
const membership = await this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true },
include: { organization: { select: { enabledModules: true } } },
});
if (!membership?.priorArchiveEnabled) {
throw new ForbiddenException('بایگانی اطلاعات قبل برای شما فعال نیست');
}
const modules = this.billing.normalizeModules(membership.organization.enabledModules);
if (!modules.includes('prior_data_archive')) {
throw new ForbiddenException('بایگانی اطلاعات قبل برای این محیط فعال نیست');
}
await this.prisma.membership.update({
where: { id: membership.id },
data: { priorArchiveLens: lens },
});
return this.me(userId, organizationId, actor);
}
async updateMe(userId: string, organizationId: string, dto: UpdateProfileDto): Promise<AuthUserDto> {
@@ -585,6 +707,7 @@ export class AuthService {
private async issueLogin(
userId: string,
organizationId: string,
opts?: { skipLastLogin?: boolean },
): Promise<LoginResponse> {
const membership = await this.memberships.requireActive(userId, organizationId);
const user = await this.prisma.user.findUnique({ where: { id: userId } });
@@ -599,10 +722,12 @@ export class AuthService {
organizationId,
membership.role,
);
await this.prisma.user.update({
where: { id: userId },
data: { lastLoginAt: new Date() },
});
if (!opts?.skipLastLogin) {
await this.prisma.user.update({
where: { id: userId },
data: { lastLoginAt: new Date() },
});
}
const accessToken = this.jwt.sign({
sub: userId,
organizationId,
@@ -629,11 +754,15 @@ export class AuthService {
},
organizationId: string,
subscription?: SubscriptionSummaryDto | null,
actor?: Actor,
impersonatorFullName?: string,
): Promise<AuthUserDto> {
const membership = await this.prisma.membership.findFirst({
where: { userId: user.id, organizationId, isActive: true },
include: {
organization: { select: { name: true, enabledModules: true } },
organization: {
select: { name: true, enabledModules: true, priorArchiveEnabledAt: true },
},
},
});
const role = membership?.role ?? user.role;
@@ -641,6 +770,14 @@ export class AuthService {
const sub =
subscription ??
(await this.billing.requireActiveForOrganization(organizationId));
const viewAs =
actor?.viewAsReadOnly && actor.impersonatorUserId
? {
impersonatorId: actor.impersonatorUserId,
impersonatorFullName: impersonatorFullName?.trim() || 'مالک',
canOpenTeamSettings: true,
}
: undefined;
return this.toAuthUser({
...user,
role,
@@ -652,9 +789,28 @@ export class AuthService {
),
subscription: sub,
isOrgOwner: role === 'owner',
priorArchive: this.priorArchiveFor(membership),
...(viewAs ? { viewAs } : {}),
});
}
private priorArchiveFor(
membership: {
priorArchiveEnabled?: boolean;
priorArchiveLens?: string;
organization?: { enabledModules?: string[]; priorArchiveEnabledAt?: Date | null };
} | null,
): AuthUserDto['priorArchive'] {
if (!membership?.priorArchiveEnabled) return undefined;
const modules = this.billing.normalizeModules(membership.organization?.enabledModules);
if (!modules.includes('prior_data_archive')) return undefined;
const orgCutoff = membership.organization?.priorArchiveEnabledAt ?? null;
return {
lens: membership.priorArchiveLens === 'archive' ? 'archive' : 'current',
cutoffAt: orgCutoff ? orgCutoff.toISOString() : null,
};
}
private async throttleChallenges(
kind: string,
email: string | null | undefined,
@@ -739,6 +895,8 @@ export class AuthService {
enabledModules?: OrgModuleKey[];
subscription?: SubscriptionSummaryDto | null;
isOrgOwner?: boolean;
priorArchive?: AuthUserDto['priorArchive'];
viewAs?: AuthUserDto['viewAs'];
}): AuthUserDto {
return {
id: user.id,
@@ -752,6 +910,8 @@ export class AuthService {
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
...(user.subscription ? { subscription: user.subscription } : {}),
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
...(user.viewAs ? { viewAs: user.viewAs } : {}),
};
}
}
+102 -1
View File
@@ -2,13 +2,22 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import type { DataVisibility } from '@mento/shared';
import { BillingService } from '../billing/billing.service';
import {
type Actor,
normalizeDataVisibility,
parseVisibilityScopes,
} from '../common/record-scope';
import { PrismaService } from '../prisma/prisma.service';
import { DATA_SCOPES, canViewAllRecords } from '@mento/shared';
export type JwtPayload = {
sub: string;
organizationId: string;
role: string;
impersonatorUserId?: string;
viewAsReadOnly?: boolean;
};
@Injectable()
@@ -36,6 +45,28 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
if (!payload.sub || !payload.organizationId) {
throw new UnauthorizedException();
}
let impersonatorUserId: string | undefined;
let impersonatorRole: string | undefined;
const viewAsReadOnly = Boolean(payload.viewAsReadOnly && payload.impersonatorUserId);
if (viewAsReadOnly && payload.impersonatorUserId) {
const ownerMembership = await this.prisma.membership.findFirst({
where: {
userId: payload.impersonatorUserId,
organizationId: payload.organizationId,
isActive: true,
role: 'owner',
organization: { isActive: true },
},
select: { userId: true, role: true },
});
if (!ownerMembership) {
throw new UnauthorizedException();
}
impersonatorUserId = ownerMembership.userId;
impersonatorRole = ownerMembership.role;
}
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, isActive: true },
@@ -50,16 +81,86 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
isActive: true,
organization: { isActive: true },
},
select: { organizationId: true, role: true },
select: {
organizationId: true,
role: true,
dataVisibility: true,
dataVisibilityScopes: true,
priorArchiveEnabled: true,
priorArchiveLens: true,
organization: { select: { enabledModules: true, priorArchiveEnabledAt: true } },
},
});
if (!membership) {
throw new UnauthorizedException();
}
await this.billing.requireActiveForOrganization(membership.organizationId);
const dataVisibility = normalizeDataVisibility(membership.dataVisibility);
const scopeLevels = parseVisibilityScopes(membership.dataVisibilityScopes);
const elevatedRole = canViewAllRecords(membership.role);
let teamIds: string[] | undefined;
const resolve = async (level: DataVisibility): Promise<string[] | null> => {
if (elevatedRole || level === 'org') return null;
if (level === 'own') return [user.id];
teamIds ??= await this.teamPeerIds(membership.organizationId, user.id);
return teamIds;
};
const visibleUserIds = await resolve(dataVisibility);
const scopes: Actor['scopes'] = {};
if (!elevatedRole) {
for (const scope of DATA_SCOPES) {
const level = scopeLevels[scope];
if (!level || level === dataVisibility) continue;
scopes[scope] = { dataVisibility: level, visibleUserIds: await resolve(level) };
}
}
const modules = this.billing.normalizeModules(membership.organization.enabledModules);
const priorGranted =
modules.includes('prior_data_archive') && membership.priorArchiveEnabled;
const priorLens = membership.priorArchiveLens === 'archive' ? 'archive' : 'current';
return {
userId: user.id,
organizationId: membership.organizationId,
role: membership.role,
dataVisibility,
visibleUserIds,
scopes,
priorLens: priorGranted ? priorLens : 'off',
priorCutoff:
priorGranted && membership.organization.priorArchiveEnabledAt
? membership.organization.priorArchiveEnabledAt
: null,
...(viewAsReadOnly && impersonatorUserId
? {
impersonatorUserId,
impersonatorRole: impersonatorRole ?? 'owner',
viewAsReadOnly: true,
}
: {}),
};
}
private async teamPeerIds(organizationId: string, userId: string): Promise<string[]> {
const memberships = await this.prisma.teamMember.findMany({
where: {
userId,
team: { organizationId },
},
select: { teamId: true },
});
if (!memberships.length) return [userId];
const peers = await this.prisma.teamMember.findMany({
where: {
teamId: { in: memberships.map((m) => m.teamId) },
},
select: { userId: true },
});
return [...new Set([userId, ...peers.map((p) => p.userId)])];
}
}
+60 -7
View File
@@ -6,26 +6,79 @@ import {
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { UserRole } from '@mento/shared';
import type { Actor } from '../common/record-scope';
import { ROLES_KEY } from './roles.decorator';
const VIEW_AS_MESSAGE =
'در حالت مشاهده به‌جای کاربر دیگر امکان تغییر وجود ندارد.';
/** خواندن‌های صفحهٔ تیم وقتی مالک در حالت مشاهده است. */
function isTeamSettingsRead(method: string, url: string): boolean {
if (method.toUpperCase() !== 'GET') return false;
const path = (url.split('?')[0] || '').replace(/\/+$/, '');
return (
/\/users(\/|$)/.test(path) ||
/\/org-roles(\/|$)/.test(path) ||
/\/teams(\/|$)/.test(path) ||
/\/organizations\/current/.test(path)
);
}
function isViewAsAllowedMutation(method: string, url: string): boolean {
const path = (url.split('?')[0] || '').replace(/\/+$/, '') || '/';
const m = method.toUpperCase();
if (m === 'POST' && (path.endsWith('/auth/view-as') || path.endsWith('/auth/view-as/stop'))) {
return true;
}
if (m === 'PATCH' && path.endsWith('/auth/me/prior-archive')) {
return true;
}
return false;
}
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{
user?: Actor;
method?: string;
originalUrl?: string;
url?: string;
}>();
const method = (request.method || 'GET').toUpperCase();
const url = request.originalUrl || request.url || '';
const actor = request.user;
if (
actor?.viewAsReadOnly &&
method !== 'GET' &&
method !== 'HEAD' &&
method !== 'OPTIONS' &&
!isViewAsAllowedMutation(method, url)
) {
throw new ForbiddenException(VIEW_AS_MESSAGE);
}
const roles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!roles?.length) return true;
const request = context.switchToHttp().getRequest<{
user?: { role?: string };
}>();
const role = request.user?.role as UserRole | undefined;
if (!role || !roles.includes(role)) {
throw new ForbiddenException('دسترسی به این بخش برای نقش شما مجاز نیست');
const role = actor?.role as UserRole | undefined;
if (role && roles.includes(role)) return true;
if (
actor?.viewAsReadOnly &&
actor.impersonatorRole === 'owner' &&
roles.includes('owner') &&
isTeamSettingsRead(method, url)
) {
return true;
}
return true;
throw new ForbiddenException('دسترسی به این بخش برای نقش شما مجاز نیست');
}
}
@@ -0,0 +1,51 @@
import {
CallHandler,
ExecutionContext,
ForbiddenException,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import type { Request } from 'express';
import type { Observable } from 'rxjs';
import type { Actor } from '../common/record-scope';
const VIEW_AS_MESSAGE =
'در حالت مشاهده به‌جای کاربر دیگر امکان تغییر وجود ندارد.';
function normalizePath(url: string): string {
const path = (url.split('?')[0] || '').replace(/\/+$/, '') || '/';
return path;
}
/** مسیرهایی که در حالت مشاهده مجاز به تغییرند (خروج از مشاهده و لنز بایگانی). */
function isAllowedWhileViewing(method: string, url: string): boolean {
const path = normalizePath(url);
const m = method.toUpperCase();
if (m === 'POST' && (path.endsWith('/auth/view-as') || path.endsWith('/auth/view-as/stop'))) {
return true;
}
if (m === 'PATCH' && path.endsWith('/auth/me/prior-archive')) {
return true;
}
return false;
}
@Injectable()
export class ViewAsReadOnlyInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = context.switchToHttp().getRequest<Request & { user?: Actor }>();
const method = (req.method || 'GET').toUpperCase();
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
return next.handle();
}
const actor = req.user;
if (!actor?.viewAsReadOnly) {
return next.handle();
}
const url = req.originalUrl || req.url || '';
if (isAllowedWhileViewing(method, url)) {
return next.handle();
}
throw new ForbiddenException(VIEW_AS_MESSAGE);
}
}
@@ -0,0 +1,51 @@
export type FormFieldLabel = { key: string; label: string };
export type TemplateVars = {
title: string;
mobile?: string | null;
formName?: string | null;
formValues?: Record<string, unknown> | null;
};
/** counter یک‌واحد افزایش‌یافته از AutomationCursor است (از ۱ شروع می‌شود) */
export function pickRoundRobin(memberIds: string[], counter: number): string | null {
const ids = [...new Set(memberIds.filter(Boolean))].sort();
if (!ids.length) return null;
const n = Math.max(1, Math.trunc(counter));
return ids[(n - 1) % ids.length] ?? null;
}
/** جایگزینی {{title}}، {{mobile}}، {{form}} و {{field.<key>}} */
export function renderAutomationTemplate(text: string, vars: TemplateVars): string {
return text.replace(/\{\{\s*([\w.\u0600-\u06FF-]+)\s*\}\}/g, (_m, name: string) => {
if (name === 'title') return vars.title ?? '';
if (name === 'mobile') return vars.mobile ?? '';
if (name === 'form') return vars.formName ?? '';
if (name.startsWith('field.')) {
const v = vars.formValues?.[name.slice(6)];
return v == null ? '' : String(v);
}
return '';
});
}
export function formatFormAnswers(
fields: FormFieldLabel[] | null | undefined,
values: Record<string, unknown> | null | undefined,
): string {
if (!values) return '';
const lines: string[] = [];
const seen = new Set<string>();
for (const f of fields ?? []) {
seen.add(f.key);
const raw = values[f.key];
const v = raw == null ? '' : String(raw).trim();
if (v) lines.push(`${f.label}: ${v}`);
}
for (const [key, raw] of Object.entries(values)) {
if (seen.has(key)) continue;
const v = raw == null ? '' : String(raw).trim();
if (v) lines.push(`${key}: ${v}`);
}
return lines.join('\n');
}
+285 -42
View File
@@ -6,6 +6,7 @@ import {
} from '@nestjs/common';
import type {
AutomationAction,
AutomationChainMode,
AutomationConditionField,
AutomationConditionOp,
AutomationNotifyChannel,
@@ -19,27 +20,45 @@ import type {
import {
MAX_AUTOMATION_STEPS,
automationStepToConfig,
automationUsesAdvanced,
firstAutomationAction,
isAutomationCondition,
formConditionValues,
matchFormFieldCondition,
normalizeAutomationSteps,
packAutomationConfig,
planAutomationActions,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { LoyaltyService } from '../loyalty/loyalty.service';
import { SmsService } from '../common/sms.service';
import {
formatFormAnswers,
pickRoundRobin,
renderAutomationTemplate,
type FormFieldLabel,
} from './automation-engine.util';
export type AutomationContext = {
organizationId: string;
entityType: 'deal' | 'ticket' | 'person' | 'invoice' | 'activity';
entityType: 'deal' | 'ticket' | 'person' | 'invoice' | 'activity' | 'lead';
entityId: string;
title: string;
personId?: string | null;
companyId?: string | null;
dealId?: string | null;
leadId?: string | null;
ownerId?: string | null;
assigneeId?: string | null;
/** مثلاً id کمپین SMS یا id فرم سرنخ برای فیلتر قانون */
sourceId?: string | null;
sourceType?: string | null;
clickCount?: number | null;
mobile?: string | null;
formName?: string | null;
formValues?: Record<string, unknown> | null;
formFields?: FormFieldLabel[] | null;
};
type AutomationFacts = {
@@ -47,8 +66,11 @@ type AutomationFacts = {
hasPerson: boolean;
hasCompany: boolean;
ticketPriority: string | null;
formValues: Record<string, unknown>;
};
type StepRef = { ruleId: string; stepId: string };
@Injectable()
export class AutomationsService {
private readonly logger = new Logger(AutomationsService.name);
@@ -75,6 +97,8 @@ export class AutomationsService {
const steps = resolveSteps(dto.action, dto.config);
const action = firstAutomationAction(steps);
if (!action) throw new BadRequestException('حداقل یک عمل لازم است');
await this.assertStepRefs(organizationId, steps);
await this.assertLevelAllows(organizationId, steps, chainExtra(dto.config).chainMode);
const row = await this.prisma.automationRule.create({
data: {
organizationId,
@@ -82,7 +106,7 @@ export class AutomationsService {
trigger: dto.trigger,
action,
isActive: dto.isActive ?? true,
config: packAutomationConfig(steps) as Prisma.InputJsonValue,
config: packAutomationConfig(steps, chainExtra(dto.config)) as Prisma.InputJsonValue,
},
});
return this.toDto(row);
@@ -110,6 +134,14 @@ export class AutomationsService {
if (steps && !nextAction) {
throw new BadRequestException('حداقل یک عمل لازم است');
}
if (steps) {
await this.assertStepRefs(organizationId, steps);
await this.assertLevelAllows(
organizationId,
steps,
chainExtra(dto.config ?? asConfig(existing.config)).chainMode,
);
}
const row = await this.prisma.automationRule.update({
where: { id },
@@ -120,7 +152,10 @@ export class AutomationsService {
...(steps && nextAction
? {
action: nextAction,
config: packAutomationConfig(steps) as Prisma.InputJsonValue,
config: packAutomationConfig(
steps,
chainExtra(dto.config ?? asConfig(existing.config)),
) as Prisma.InputJsonValue,
}
: {}),
},
@@ -147,21 +182,40 @@ export class AutomationsService {
trigger,
},
});
const facts = await this.loadFacts(ctx);
if (!rules.length) return;
const facts: AutomationFacts = {
...(await this.loadFacts(ctx)),
formValues: ctx.formValues ?? {},
};
for (const rule of rules) {
try {
const config = asConfig(rule.config);
if (
config.sourceId &&
ctx.sourceId &&
config.sourceId !== ctx.sourceId
) {
continue;
}
if (config.sourceId && !ctx.sourceId) continue;
const steps = normalizeAutomationSteps(
rule.action as AutomationAction,
config,
);
for (const step of steps) {
if (isAutomationCondition(step)) {
if (!matchCondition(step, facts)) break;
continue;
const actions = planAutomationActions(steps, config.chainMode, (step) =>
matchCondition(step, facts),
);
for (const step of actions) {
try {
await this.execute(step.action!, automationStepToConfig(step), ctx, {
ruleId: rule.id,
stepId: step.id,
});
} catch (e) {
this.logger.warn(
`Automation ${rule.id} step ${step.id} failed: ${(e as Error)?.message ?? e}`,
);
}
if (!step.action) continue;
await this.execute(step.action, automationStepToConfig(step), ctx);
}
} catch {
// قوانین نباید شکست عملیات اصلی را ایجاد کنند
@@ -169,7 +223,9 @@ export class AutomationsService {
}
}
private async loadFacts(ctx: AutomationContext): Promise<AutomationFacts> {
private async loadFacts(
ctx: AutomationContext,
): Promise<Omit<AutomationFacts, 'formValues'>> {
if (ctx.entityType === 'deal') {
const deal = await this.prisma.deal.findFirst({
where: { id: ctx.entityId, organizationId: ctx.organizationId },
@@ -246,42 +302,26 @@ export class AutomationsService {
private async execute(
action: AutomationAction,
config: AutomationRuleConfig,
rawConfig: AutomationRuleConfig,
ctx: AutomationContext,
ref: StepRef,
) {
const vars = {
title: ctx.title,
mobile: ctx.mobile,
formName: ctx.formName,
formValues: ctx.formValues,
};
const config: AutomationRuleConfig = {
...rawConfig,
title: rawConfig.title ? renderAutomationTemplate(rawConfig.title, vars) : rawConfig.title,
body: rawConfig.body ? renderAutomationTemplate(rawConfig.body, vars) : rawConfig.body,
};
const title =
(config.title?.trim() || defaultTitle(action, ctx.title)).slice(0, 200);
if (action === 'create_activity') {
const assigneeId =
config.assigneeId?.trim() ||
ctx.assigneeId ||
ctx.ownerId ||
null;
await this.prisma.activity.create({
data: {
organizationId: ctx.organizationId,
type: (config.activityType as never) || 'task',
title,
description: config.body?.trim() || null,
personId: ctx.personId ?? null,
companyId: ctx.companyId ?? null,
dealId: ctx.dealId ?? (ctx.entityType === 'deal' ? ctx.entityId : null),
assigneeId,
},
});
if (assigneeId) {
await this.notifications.create({
organizationId: ctx.organizationId,
userId: assigneeId,
type: 'automation',
title: 'فعالیت خودکار',
body: title,
link: entityLink(ctx),
entityType: ctx.entityType,
entityId: ctx.entityId,
});
}
await this.executeCreateActivity(title, config, ctx, ref);
return;
}
@@ -301,6 +341,180 @@ export class AutomationsService {
}
}
private async executeCreateActivity(
title: string,
config: AutomationRuleConfig,
ctx: AutomationContext,
ref: StepRef,
) {
const assigneeId = await this.resolveAssignee(config, ctx, ref);
let type = (config.activityType as 'call' | 'meeting' | 'task' | 'note') || 'task';
let typeDefId: string | null = null;
if (config.activityTypeDefId) {
const def = await this.prisma.activityTypeDef.findFirst({
where: { id: config.activityTypeDefId, organizationId: ctx.organizationId },
select: { id: true, baseType: true },
});
if (def) {
typeDefId = def.id;
type = def.baseType as typeof type;
}
}
const offset = config.dueOffsetMinutes;
const dueAt =
typeof offset === 'number' && Number.isFinite(offset)
? new Date(Date.now() + Math.max(0, offset) * 60_000)
: null;
const description = [
config.body?.trim(),
leadSummary(ctx),
]
.filter(Boolean)
.join('\n\n')
.slice(0, 5000);
const activity = await this.prisma.activity.create({
data: {
organizationId: ctx.organizationId,
type,
typeDefId,
title,
description: description || null,
dueAt,
personId: ctx.personId ?? null,
companyId: ctx.companyId ?? null,
dealId: ctx.dealId ?? (ctx.entityType === 'deal' ? ctx.entityId : null),
leadId: ctx.leadId ?? (ctx.entityType === 'lead' ? ctx.entityId : null),
assigneeId,
assignees: assigneeId ? { connect: [{ id: assigneeId }] } : undefined,
tags: ['automation'],
},
select: { id: true },
});
if (assigneeId) {
await this.notifications.create({
organizationId: ctx.organizationId,
userId: assigneeId,
type: 'automation',
title: 'فعالیت خودکار',
body: title,
link: `/activities/${activity.id}`,
entityType: 'activity',
entityId: activity.id,
});
}
}
private async resolveAssignee(
config: AutomationRuleConfig,
ctx: AutomationContext,
ref: StepRef,
): Promise<string | null> {
const fallback = ctx.assigneeId || ctx.ownerId || null;
const mode = config.assignMode ?? (config.assigneeId ? 'user' : 'owner');
if (mode === 'user') {
const id = config.assigneeId?.trim();
if (id && (await this.isMember(ctx.organizationId, id))) return id;
return fallback;
}
if (mode === 'team_round_robin' || mode === 'users_round_robin') {
let candidates: string[] = [];
if (mode === 'team_round_robin' && config.assigneeTeamId) {
const members = await this.prisma.teamMember.findMany({
where: {
teamId: config.assigneeTeamId,
team: { organizationId: ctx.organizationId },
},
select: { userId: true },
});
candidates = members.map((m) => m.userId);
} else if (mode === 'users_round_robin') {
candidates = config.assigneeUserIds ?? [];
}
const valid = await this.filterMembers(ctx.organizationId, candidates);
if (!valid.length) return fallback;
const cursor = await this.prisma.automationCursor.upsert({
where: { ruleId_stepId: { ruleId: ref.ruleId, stepId: ref.stepId } },
create: { ruleId: ref.ruleId, stepId: ref.stepId, counter: 1 },
update: { counter: { increment: 1 } },
select: { counter: true },
});
return pickRoundRobin(valid, cursor.counter) ?? fallback;
}
return fallback;
}
private async isMember(organizationId: string, userId: string) {
return (await this.filterMembers(organizationId, [userId])).length > 0;
}
private async filterMembers(organizationId: string, userIds: string[]) {
const ids = [...new Set(userIds.filter(Boolean))];
if (!ids.length) return [];
const rows = await this.prisma.user.findMany({
where: {
id: { in: ids },
memberships: { some: { organizationId } },
},
select: { id: true },
});
return rows.map((r) => r.id);
}
private async assertStepRefs(organizationId: string, steps: AutomationStep[]) {
for (const step of steps) {
if (step.kind === 'condition' || step.action !== 'create_activity') continue;
if (step.assignMode === 'team_round_robin') {
if (!step.assigneeTeamId) {
throw new BadRequestException('برای تقسیم نوبتی، تیم را انتخاب کنید');
}
const team = await this.prisma.team.findFirst({
where: { id: step.assigneeTeamId, organizationId },
select: { id: true },
});
if (!team) throw new BadRequestException('تیم انتخاب‌شده معتبر نیست');
}
if (step.assignMode === 'users_round_robin') {
const ids = step.assigneeUserIds ?? [];
if (!ids.length) {
throw new BadRequestException('برای تقسیم نوبتی، حداقل یک کاربر انتخاب کنید');
}
const valid = await this.filterMembers(organizationId, ids);
if (valid.length !== new Set(ids).size) {
throw new BadRequestException('یکی از کاربران انتخاب‌شده معتبر نیست');
}
}
if (step.assignMode === 'user' && step.assigneeId) {
if (!(await this.isMember(organizationId, step.assigneeId))) {
throw new BadRequestException('مسئول انتخاب‌شده معتبر نیست');
}
}
}
}
private async assertLevelAllows(
organizationId: string,
steps: AutomationStep[],
chainMode: AutomationChainMode | undefined,
) {
if (!automationUsesAdvanced(steps, chainMode)) return;
const org = await this.prisma.organization.findUnique({
where: { id: organizationId },
select: { enabledModules: true },
});
if (!org?.enabledModules.includes('workflow_automations_advanced')) {
throw new BadRequestException(
'شاخه‌ها، شرط روی پاسخ فرم و تقسیم نوبتی فقط در اتوماسیون حرفه‌ای فعال است',
);
}
}
private async executeNotify(
title: string,
config: AutomationRuleConfig,
@@ -452,9 +666,37 @@ function matchCondition(step: AutomationStep, facts: AutomationFacts): boolean {
return current === value;
}
if (field === 'form_field') {
const key = step.conditionKey?.trim();
if (!key) return false;
return matchFormFieldCondition(op, facts.formValues[key], formConditionValues(step));
}
return false;
}
function chainExtra(
config?: AutomationRuleConfig | null,
): Pick<AutomationRuleConfig, 'sourceId' | 'chainMode'> {
return {
sourceId: config?.sourceId ?? null,
chainMode: config?.chainMode === 'branches' ? 'branches' : config?.chainMode === 'stop' ? 'stop' : undefined,
};
}
function leadSummary(ctx: AutomationContext): string {
if (ctx.sourceType !== 'lead_form') return '';
const head = [
ctx.formName ? `فرم: ${ctx.formName}` : '',
ctx.title ? `نام: ${ctx.title}` : '',
ctx.mobile ? `موبایل: ${ctx.mobile}` : '',
].filter(Boolean);
const answers = formatFormAnswers(ctx.formFields, ctx.formValues);
return [head.join('\n'), answers ? `پاسخ‌های فرم:\n${answers}` : '']
.filter(Boolean)
.join('\n\n');
}
function defaultTitle(action: AutomationAction, entityTitle: string) {
if (action === 'create_activity') return `پیگیری خودکار: ${entityTitle}`;
if (action === 'create_notification') return `اتوماسیون: ${entityTitle}`;
@@ -467,5 +709,6 @@ function entityLink(ctx: AutomationContext) {
if (ctx.entityType === 'invoice') return `/invoices/${ctx.entityId}`;
if (ctx.entityType === 'person') return `/contacts/people/${ctx.entityId}`;
if (ctx.entityType === 'activity') return `/activities/${ctx.entityId}`;
if (ctx.entityType === 'lead') return `/leads/${ctx.entityId}`;
return '/';
}
+1
View File
@@ -24,6 +24,7 @@ const TRIGGERS = [
'invoice_issued',
'invoice_paid',
'activity_done',
'sms_link_clicked',
] as const;
const ACTIONS = [
'create_activity',
+64 -4
View File
@@ -4,6 +4,7 @@ import {
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type {
ApplyBillingPlanRequest,
BillingAccountDto,
@@ -15,7 +16,9 @@ import type {
import {
BILLING_PLAN_DAYS,
BILLING_PLAN_DEFAULT_MAX_ORGS,
BILLING_TRIAL_DAYS,
defaultEnabledModules,
isPermanentBillingPlan,
isOrgModuleKey,
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
@@ -25,7 +28,7 @@ function daysRemaining(expiresAt: Date, now = new Date()): number {
return Math.max(0, Math.ceil(ms / (24 * 60 * 60 * 1000)));
}
function toSummary(row: {
export function toSummary(row: {
planCode: string;
status: string;
startsAt: Date;
@@ -41,7 +44,7 @@ function toSummary(row: {
expiresAt: row.expiresAt.toISOString(),
maxOrganizations: row.maxOrganizations,
daysRemaining: remaining,
warn: remaining > 0 && remaining <= 3,
warn: !isPermanentBillingPlan(row.planCode) && remaining > 0 && remaining <= 3,
};
}
@@ -81,7 +84,7 @@ export class BillingService {
const existing = await this.getByOwnerUserId(ownerUserId);
if (existing) return existing;
const now = new Date();
const expiresAt = new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000);
const expiresAt = new Date(now.getTime() + BILLING_TRIAL_DAYS * 24 * 60 * 60 * 1000);
return this.prisma.billingAccount.create({
data: {
ownerUserId,
@@ -249,13 +252,70 @@ export class BillingService {
modules.filter(isOrgModuleKey),
),
] as OrgModuleKey[];
const prev = await this.prisma.organization.findUnique({
where: { id: organizationId },
select: { priorArchiveEnabledAt: true },
});
const enablePrior = cleaned.includes('prior_data_archive');
await this.prisma.organization.update({
where: { id: organizationId },
data: { enabledModules: cleaned },
data: {
enabledModules: cleaned,
},
});
if (enablePrior && prev?.priorArchiveEnabledAt) {
await this.backfillPriorActivities(organizationId, prev.priorArchiveEnabledAt);
}
return cleaned;
}
/** فعالیت‌های منتقل‌شده را یک‌بار (و تکرار بی‌اثر) به‌عنوان اطلاعات قبل علامت می‌زند. */
private async backfillPriorActivities(organizationId: string, cutoff: Date) {
await this.prisma.$executeRaw(Prisma.sql`
UPDATE "Activity" AS a
SET "importSource" = 'didar'
WHERE a."organizationId" = ${organizationId}
AND a."importSource" = 'manual'
AND (
(
a."dealId" IS NOT NULL
AND EXISTS (
SELECT 1 FROM "Deal" AS d
WHERE d."id" = a."dealId" AND d."importSource" = 'didar'
)
)
OR (
a."createdAt" < ${cutoff}
AND (
EXISTS (
SELECT 1 FROM "Person" AS p
WHERE p."id" = a."personId"
AND (
p."importSource" = 'didar'
OR p."previousImportSource" = 'didar'
)
)
OR EXISTS (
SELECT 1 FROM "Company" AS c
WHERE c."id" = a."companyId" AND c."importSource" = 'didar'
)
OR EXISTS (
SELECT 1 FROM "Deal" AS d2
WHERE d2."id" = a."dealId" AND d2."importSource" = 'didar'
)
OR (
a."personId" IS NULL
AND a."companyId" IS NULL
AND a."dealId" IS NULL
AND a."dueAt" IS NOT NULL
AND a."dueAt" < ${cutoff}
)
)
)
)
`);
}
normalizeModules(raw: string[] | null | undefined): OrgModuleKey[] {
if (!raw?.length) return defaultEnabledModules();
const cleaned = raw.filter(isOrgModuleKey) as OrgModuleKey[];
+20
View File
@@ -12,3 +12,23 @@ export function toJsonCustomFields(
): Prisma.InputJsonValue {
return (value ?? {}) as Prisma.InputJsonValue;
}
export function asAddresses(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const out: string[] = [];
for (const item of value) {
const text = String(item ?? '').trim();
if (text && !out.includes(text)) out.push(text);
if (out.length >= 20) break;
}
return out;
}
export function toJsonAddresses(value?: string[] | null): Prisma.InputJsonValue {
return asAddresses(value ?? []);
}
/** Collect address-like cells from an excel row into a unique list. */
export function collectAddresses(...parts: Array<string | undefined | null>): string[] {
return asAddresses(parts.map((p) => (p ?? '').trim()).filter(Boolean));
}
+341
View File
@@ -0,0 +1,341 @@
import { randomBytes } from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { BadRequestException } from '@nestjs/common';
export const EXCEL_IMPORT_CHUNK_SIZE = 150;
/** بعد از این تعداد ردیف، کلاینت حتماً چندمرحله‌ای می‌رود (پیش‌نمایش هم نشان می‌دهد) */
export const EXCEL_IMPORT_LARGE_THRESHOLD = 80;
const SESSION_TTL_MS = 45 * 60 * 1000;
const MAX_SESSIONS = 40;
const MAX_ASYNC_ROWS = 100_000;
export type ExcelImportLookupCaches = {
user: Map<string, string | null>;
person: Map<string, string | null>;
company: Map<string, string | null>;
deal: Map<string, string | null>;
};
export type ExcelImportSessionStatus = {
sessionId: string;
status: 'parsing' | 'ready' | 'error';
total: number;
parsed: number;
error?: string;
hasMore?: boolean;
nextOffset?: number;
};
type ExcelImportSession = {
organizationId: string;
rows: Array<Record<string, string>>;
expiresAt: number;
caches: ExcelImportLookupCaches;
status: 'parsing' | 'ready' | 'error';
error?: string;
total: number;
parsed: number;
};
const sessions = new Map<string, ExcelImportSession>();
const TMP_DIR = path.join(os.tmpdir(), 'mento-excel-import');
function emptyCaches(): ExcelImportLookupCaches {
return {
user: new Map(),
person: new Map(),
company: new Map(),
deal: new Map(),
};
}
function purgeExpired() {
const now = Date.now();
for (const [id, session] of sessions) {
if (session.expiresAt <= now) {
sessions.delete(id);
cleanupSessionFiles(id);
}
}
if (sessions.size <= MAX_SESSIONS) return;
const ordered = [...sessions.entries()].sort(
(a, b) => a[1].expiresAt - b[1].expiresAt,
);
while (sessions.size > MAX_SESSIONS && ordered.length) {
const [id] = ordered.shift()!;
sessions.delete(id);
cleanupSessionFiles(id);
}
}
function cleanupSessionFiles(sessionId: string) {
for (const ext of ['.xlsx', '.jsonl']) {
try {
fs.unlinkSync(path.join(TMP_DIR, `${sessionId}${ext}`));
} catch {
/* ignore */
}
}
}
function touch(session: ExcelImportSession) {
session.expiresAt = Date.now() + SESSION_TTL_MS;
}
export function createExcelImportSession(
organizationId: string,
rows: Array<Record<string, string>>,
): string {
purgeExpired();
const id = randomBytes(16).toString('hex');
sessions.set(id, {
organizationId,
rows,
expiresAt: Date.now() + SESSION_TTL_MS,
caches: emptyCaches(),
status: 'ready',
total: rows.length,
parsed: rows.length,
});
return id;
}
/** فقط کش lookup برای ایمپورت JSON تکه‌ای از مرورگر (بدون نگه داشتن کل فایل) */
export function createExcelImportCacheSession(organizationId: string): string {
purgeExpired();
const id = randomBytes(16).toString('hex');
sessions.set(id, {
organizationId,
rows: [],
expiresAt: Date.now() + SESSION_TTL_MS,
caches: emptyCaches(),
status: 'ready',
total: 0,
parsed: 0,
});
return id;
}
/**
* آپلود را فوری قبول می‌کند و پارس را در پس‌زمینه انجام می‌دهد
* تا پروکسی nginx با ۵۰۲ قطع نکند.
*/
export function beginExcelImportPrepare(
organizationId: string,
buffer: Buffer,
loadRows: (file: Express.Multer.File) => Promise<Array<Record<string, string>>>,
): string {
purgeExpired();
fs.mkdirSync(TMP_DIR, { recursive: true });
const id = randomBytes(16).toString('hex');
const xlsxPath = path.join(TMP_DIR, `${id}.xlsx`);
fs.writeFileSync(xlsxPath, buffer);
sessions.set(id, {
organizationId,
rows: [],
expiresAt: Date.now() + SESSION_TTL_MS,
caches: emptyCaches(),
status: 'parsing',
total: 0,
parsed: 0,
});
const fakeFile = {
fieldname: 'file',
originalname: 'import.xlsx',
encoding: '7bit',
mimetype:
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
size: buffer.length,
buffer,
destination: '',
filename: '',
path: xlsxPath,
} as Express.Multer.File;
void (async () => {
const session = sessions.get(id);
if (!session) return;
try {
const rows = await loadRows(fakeFile);
const current = sessions.get(id);
if (!current) return;
if (rows.length > MAX_ASYNC_ROWS) {
current.status = 'error';
current.error = `فایل بیش از ${MAX_ASYNC_ROWS} ردیف دارد. فایل را تقسیم کنید.`;
current.total = rows.length;
current.parsed = rows.length;
touch(current);
cleanupSessionFiles(id);
return;
}
current.rows = rows;
current.total = rows.length;
current.parsed = rows.length;
current.status = 'ready';
touch(current);
try {
fs.unlinkSync(xlsxPath);
} catch {
/* ignore */
}
} catch (e) {
const current = sessions.get(id);
if (!current) return;
current.status = 'error';
current.error =
e instanceof Error ? e.message : 'خواندن فایل اکسل ناموفق بود';
touch(current);
cleanupSessionFiles(id);
}
})();
return id;
}
export function getExcelImportPrepareStatus(
sessionId: string,
organizationId: string,
): ExcelImportSessionStatus {
purgeExpired();
const session = sessions.get(sessionId);
if (!session || session.organizationId !== organizationId) {
throw new BadRequestException(
'جلسه ورود اکسل منقضی یا نامعتبر است. فایل را دوباره انتخاب کنید.',
);
}
touch(session);
return {
sessionId,
status: session.status,
total: session.total,
parsed: session.parsed,
error: session.error,
hasMore: session.status === 'ready' ? session.total > 0 : true,
nextOffset: 0,
};
}
export function getExcelImportSession(
sessionId: string,
organizationId: string,
): Array<Record<string, string>> {
purgeExpired();
const session = sessions.get(sessionId);
if (!session || session.organizationId !== organizationId) {
throw new BadRequestException(
'جلسه ورود اکسل منقضی یا نامعتبر است. فایل را دوباره انتخاب کنید.',
);
}
if (session.status === 'parsing') {
throw new BadRequestException(
'فایل هنوز در حال آماده‌سازی است. چند لحظه صبر کنید.',
);
}
if (session.status === 'error') {
throw new BadRequestException(
session.error || 'آماده‌سازی فایل اکسل ناموفق بود.',
);
}
touch(session);
return session.rows;
}
export function destroyExcelImportSession(sessionId: string | undefined) {
if (!sessionId) return;
sessions.delete(sessionId);
cleanupSessionFiles(sessionId);
}
export function getExcelImportSessionCaches(
sessionId: string,
organizationId: string,
): ExcelImportLookupCaches {
purgeExpired();
const session = sessions.get(sessionId);
if (!session || session.organizationId !== organizationId) {
return emptyCaches();
}
touch(session);
return session.caches;
}
export function parseChunkParams(raw: {
offset?: string | number;
limit?: string | number;
}): { offset: number; limit: number } {
const offset = Math.max(0, Number(raw.offset ?? 0) || 0);
const limit = Math.min(
250,
Math.max(1, Number(raw.limit ?? EXCEL_IMPORT_CHUNK_SIZE) || EXCEL_IMPORT_CHUNK_SIZE),
);
return { offset, limit };
}
export type ExcelImportChunkContext = {
sessionId: string;
allRows: Array<Record<string, string>>;
slice: Array<Record<string, string>>;
offset: number;
nextOffset: number;
hasMore: boolean;
total: number;
};
/**
* فایل را یک‌بار می‌خواند و در حافظه نگه می‌دارد؛ درخواست‌های بعدی فقط با sessionId + offset.
*/
export async function resolveExcelImportChunk(opts: {
organizationId: string;
file?: Express.Multer.File | null;
sessionId?: string;
offset?: string | number;
limit?: string | number;
loadRows: () => Promise<Array<Record<string, string>>>;
}): Promise<ExcelImportChunkContext> {
const { offset, limit } = parseChunkParams({
offset: opts.offset,
limit: opts.limit,
});
let sessionId = (opts.sessionId ?? '').trim();
let allRows: Array<Record<string, string>>;
if (sessionId) {
allRows = getExcelImportSession(sessionId, opts.organizationId);
} else {
if (!opts.file?.buffer?.length) {
throw new BadRequestException('فایل ارسال نشده');
}
allRows = await opts.loadRows();
sessionId = createExcelImportSession(opts.organizationId, allRows);
}
const slice = allRows.slice(offset, offset + limit);
const nextOffset = offset + slice.length;
const hasMore = nextOffset < allRows.length;
if (!hasMore) destroyExcelImportSession(sessionId);
return {
sessionId,
allRows,
slice,
offset,
nextOffset,
hasMore,
total: allRows.length,
};
}
export function chunkImportMeta(ctx: ExcelImportChunkContext) {
return {
sessionId: ctx.hasMore ? ctx.sessionId : undefined,
total: ctx.total,
processed: ctx.nextOffset,
hasMore: ctx.hasMore,
nextOffset: ctx.hasMore ? ctx.nextOffset : undefined,
};
}
+160 -44
View File
@@ -1,24 +1,39 @@
import { BadRequestException } from '@nestjs/common';
import ExcelJS from 'exceljs';
import { EXCEL_IMPORT_LARGE_THRESHOLD } from './excel-import-session';
export function cellStr(value: ExcelJS.CellValue | undefined): string {
if (value == null) return '';
if (typeof value === 'string' || typeof value === 'number') {
return String(value).trim();
try {
if (value == null) return '';
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value).trim();
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? '' : value.toISOString();
}
if (typeof value === 'object' && 'richText' in value && Array.isArray(value.richText)) {
return value.richText.map((part) => part?.text ?? '').join('').trim();
}
if (typeof value === 'object' && 'text' in value && typeof value.text === 'string') {
return value.text.trim();
}
if (typeof value === 'object' && 'result' in value && value.result != null) {
const result = value.result;
if (typeof result === 'string' || typeof result === 'number' || typeof result === 'boolean') {
return String(result).trim();
}
if (result instanceof Date) {
return Number.isNaN(result.getTime()) ? '' : result.toISOString();
}
return '';
}
if (typeof value === 'object' && 'error' in value && value.error != null) {
return String(value.error).trim();
}
return '';
} catch {
return '';
}
if (typeof value === 'object' && 'richText' in value && Array.isArray(value.richText)) {
return value.richText.map((part) => part.text).join('').trim();
}
if (typeof value === 'object' && 'text' in value && typeof value.text === 'string') {
return value.text.trim();
}
if (typeof value === 'object' && 'result' in value && value.result != null) {
return String(value.result).trim();
}
if (value instanceof Date) {
return value.toISOString();
}
return String(value).trim();
}
export function normalizeHeader(raw: string) {
@@ -154,6 +169,28 @@ export function parseAmount(raw?: string): number | null {
return Number.isFinite(n) ? n : null;
}
/** تاریخ تولد: سال زیر ۱۷۰۰ شمسی است، سال ۱۷۰۰ به بعد میلادی. ظهر UTC تا روز جابه‌جا نشود. */
export function parseBirthDate(raw?: string | null): Date | null {
const text = toAsciiDigits(String(raw ?? '')).trim();
if (!text || text === '-' || text === '0' || text === '0000-00-00') return null;
const datePart = text.split(/[T\s]/)[0] ?? text;
const match = /^(\d{3,4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})$/.exec(datePart);
if (!match) {
const fallback = new Date(text);
return Number.isNaN(fallback.getTime()) ? null : fallback;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
if (year >= 1700) {
return new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
}
if (year < 1200) return null;
const { gy, gm, gd } = toGregorian(year, month, day);
return new Date(Date.UTC(gy, gm - 1, gd, 12, 0, 0));
}
export function parseJalaliDateTime(dateRaw?: string, timeRaw?: string): Date | null {
const dateText = toAsciiDigits(dateRaw ?? '').trim();
if (!dateText) return null;
@@ -182,7 +219,17 @@ export async function workbookFromUpload(file: Express.Multer.File | undefined)
throw new BadRequestException('فایل اکسل انتخاب نشده است');
}
const wb = new ExcelJS.Workbook();
await wb.xlsx.load(Uint8Array.from(file.buffer) as unknown as ExcelJS.Buffer);
try {
// Pass Node Buffer directly — Uint8Array copies can break ExcelJS on some runtimes.
await wb.xlsx.load(file.buffer as unknown as ExcelJS.Buffer);
} catch (err) {
const detail = err instanceof Error ? err.message : '';
throw new BadRequestException(
detail.includes('central directory') || detail.includes('zip')
? 'فایل اکسل نامعتبر است. فقط فرمت .xlsx پشتیبانی می‌شود (نه .xls یا csv).'
: 'خواندن فایل اکسل ناموفق بود. فایل را با فرمت .xlsx ذخیره کنید و دوباره تلاش کنید.',
);
}
const sheet = wb.worksheets[0];
if (!sheet) throw new BadRequestException('شیت اکسل پیدا نشد');
return sheet;
@@ -205,6 +252,8 @@ export type ExcelPreviewResult = {
fields: ExcelFieldOption[];
suggested: Record<string, string>;
needsMapping: boolean;
rowCount: number;
largeFile?: boolean;
};
export function excelFieldsFromColumns(
@@ -254,34 +303,76 @@ function resolveFieldKey(
return aliases[compact] ?? aliases[raw.trim()] ?? aliases[normalizeHeader(raw)] ?? '';
}
export type ExcelPositionalColumn = { key: string; label: string };
export type ExcelReadOptions = {
/** اگر ردیف اول هدر نباشد، از این ترتیب ستون استفاده شود */
positionalColumns?: ExcelPositionalColumn[];
/** تشخیص فایل بدون هدر (مثل خروجی دیدار) */
detectHeaderless?: (firstRowCells: string[]) => boolean;
};
function readFirstRowCells(sheet: ExcelJS.Worksheet): string[] {
const cells: string[] = [];
const row = sheet.getRow(1);
const max = Math.max(row.cellCount || 0, sheet.columnCount || 0, 22);
for (let col = 1; col <= max; col += 1) {
cells.push(cellStr(row.getCell(col).value));
}
while (cells.length && !cells[cells.length - 1]) cells.pop();
return cells;
}
export async function previewExcel(
file: Express.Multer.File,
aliases: Record<string, string>,
fields: ExcelFieldOption[],
options?: ExcelReadOptions,
): Promise<ExcelPreviewResult> {
const sheet = await workbookFromUpload(file);
const headerRow = sheet.getRow(1);
const firstCells = readFirstRowCells(sheet);
const headerless =
Boolean(options?.positionalColumns?.length) &&
Boolean(options?.detectHeaderless?.(firstCells));
const columns: ExcelColumnPreview[] = [];
const suggested: Record<string, string> = {};
const used = new Set<string>();
headerRow.eachCell((cell, col) => {
const header = cellStr(cell.value);
if (!header) return;
const samples: string[] = [];
const last = Math.min(sheet.rowCount, 6);
for (let r = 2; r <= last; r += 1) {
const value = cellStr(sheet.getRow(r).getCell(col).value);
if (value) samples.push(value);
if (samples.length >= 3) break;
}
columns.push({ header, index: col, samples });
const key = resolveFieldKey(header, aliases);
if (key && !used.has(key)) {
suggested[header] = key;
used.add(key);
}
});
if (headerless && options?.positionalColumns) {
const last = Math.min(sheet.rowCount, 5);
options.positionalColumns.forEach((colDef, idx) => {
const col = idx + 1;
const samples: string[] = [];
for (let r = 1; r <= last; r += 1) {
const value = cellStr(sheet.getRow(r).getCell(col).value);
if (value) samples.push(value);
if (samples.length >= 3) break;
}
columns.push({ header: colDef.label, index: col, samples });
suggested[colDef.label] = colDef.key;
used.add(colDef.key);
});
} else {
const headerRow = sheet.getRow(1);
headerRow.eachCell((cell, col) => {
const header = cellStr(cell.value);
if (!header) return;
const samples: string[] = [];
const last = Math.min(sheet.rowCount, 6);
for (let r = 2; r <= last; r += 1) {
const value = cellStr(sheet.getRow(r).getCell(col).value);
if (value) samples.push(value);
if (samples.length >= 3) break;
}
columns.push({ header, index: col, samples });
const key = resolveFieldKey(header, aliases);
if (key && !used.has(key)) {
suggested[header] = key;
used.add(key);
}
});
}
if (!columns.length) {
throw new BadRequestException('ستون عنوان در ردیف اول فایل پیدا نشد');
@@ -291,11 +382,19 @@ export async function previewExcel(
const mapped = new Set(Object.values(suggested));
const missingRequired = required.some((key) => !mapped.has(key));
const unrecognized = columns.some((col) => !suggested[col.header]);
const rawRows = Math.max(0, sheet.actualRowCount || sheet.rowCount || 0);
const rowCount = headerless ? rawRows : Math.max(0, rawRows - 1);
return {
columns,
fields,
suggested,
needsMapping: unrecognized || missingRequired || !mapped.size,
needsMapping: headerless
? false
: unrecognized || missingRequired || !mapped.size,
rowCount,
largeFile: rowCount >= EXCEL_IMPORT_LARGE_THRESHOLD,
};
}
@@ -303,16 +402,33 @@ export async function readMappedRows(
file: Express.Multer.File,
aliases: Record<string, string>,
mapping?: Record<string, string>,
options?: ExcelReadOptions,
): Promise<Record<string, string>[]> {
const sheet = await workbookFromUpload(file);
const headerRow = sheet.getRow(1);
const firstCells = readFirstRowCells(sheet);
const headerless =
!mapping &&
Boolean(options?.positionalColumns?.length) &&
Boolean(options?.detectHeaderless?.(firstCells));
const map = new Map<number, string>();
headerRow.eachCell((cell, col) => {
const raw = cellStr(cell.value);
if (!raw) return;
const key = resolveFieldKey(raw, aliases, mapping);
if (key) map.set(col, key);
});
let dataStartRow = 2;
if (headerless && options?.positionalColumns) {
options.positionalColumns.forEach((colDef, idx) => {
map.set(idx + 1, colDef.key);
});
dataStartRow = 1;
} else {
const headerRow = sheet.getRow(1);
headerRow.eachCell((cell, col) => {
const raw = cellStr(cell.value);
if (!raw) return;
const key = resolveFieldKey(raw, aliases, mapping);
if (key) map.set(col, key);
});
}
if (!map.size) {
throw new BadRequestException(
'ستون‌های فایل شناسایی نشد. ستون‌های فایل را با فیلدهای سیستم تطبیق دهید.',
@@ -321,7 +437,7 @@ export async function readMappedRows(
const rows: Record<string, string>[] = [];
sheet.eachRow((row, rowNumber) => {
if (rowNumber === 1) return;
if (rowNumber < dataStartRow) return;
const item: Record<string, string> = {};
let any = false;
for (const [col, key] of map) {
+115
View File
@@ -103,6 +103,121 @@ export function jalaliMonthKey(d: Date): string {
return `${jy}-${String(jm).padStart(2, '0')}`;
}
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
function toAsciiDigits(value: string) {
return value
.replace(/[۰-۹]/g, (d) => String(FA_DIGITS.indexOf(d)))
.replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)));
}
function toFaDigits(value: string | number) {
return String(value).replace(/\d/g, (d) => FA_DIGITS[Number(d)] ?? d);
}
function tehranYmd(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 { gy, gm, gd };
}
/** تاریخ شمسی تهران، مثل ۱۴۰۲/۰۶/۱۲ */
export function formatTehranJalali(d: Date): string {
const { gy, gm, gd } = tehranYmd(d);
const j = toJalali(gy, gm, gd);
return `${toFaDigits(j.jy)}/${toFaDigits(String(j.jm).padStart(2, '0'))}/${toFaDigits(String(j.jd).padStart(2, '0'))}`;
}
const GREG_MONTHS: Array<[string, number]> = [
['ژانویه', 1],
['january', 1],
['فوریه', 2],
['february', 2],
['مارس', 3],
['march', 3],
['آوریل', 4],
['april', 4],
['ژوئن', 6],
['june', 6],
['ژوئیه', 7],
['جولای', 7],
['july', 7],
['اوت', 8],
['آگوست', 8],
['آگست', 8],
['august', 8],
['سپتامبر', 9],
['september', 9],
['اکتبر', 10],
['october', 10],
['نوامبر', 11],
['november', 11],
['دسامبر', 12],
['december', 12],
['مه', 5],
['می', 5],
['may', 5],
];
function gregMonthNumber(name: string): number | null {
const key = name.trim().toLowerCase();
return GREG_MONTHS.find(([label]) => label === key)?.[1] ?? null;
}
function inferGregorianYear(month: number, day: number, now = new Date()): number {
const { gy, gm, gd } = tehranYmd(now);
if (month > gm || (month === gm && day > gd)) return gy - 1;
return gy;
}
function jalaliFromGregorian(year: number, month: number, day: number): string | null {
if (month < 1 || month > 12 || day < 1 || day > 31 || year < 1700 || year > 2500) return null;
const j = toJalali(year, month, day);
return `${toFaDigits(j.jy)}/${toFaDigits(String(j.jm).padStart(2, '0'))}/${toFaDigits(String(j.jd).padStart(2, '0'))}`;
}
/** «21 سپتامبر» و تاریخ میلادی عددی را در متن کاربر به شمسی تبدیل می‌کند. */
export function rewriteGregorianDates(text: string, now = new Date()): string {
const monthAlt = GREG_MONTHS.map(([name]) => name).join('|');
const dayMonth = new RegExp(
`(^|[^\\p{L}\\d])([0-9۰-۹]{1,2})\\s*(${monthAlt})(?![\\p{L}\\u200c])(?:\\s+([0-9۰-۹]{4}))?`,
'giu',
);
const monthDay = new RegExp(
`(^|[^\\p{L}\\d])(${monthAlt})(?![\\p{L}\\u200c])\\s+([0-9۰-۹]{1,2})(?:\\s*,?\\s*([0-9۰-۹]{4}))?`,
'giu',
);
const iso = /\b((?:19|20)\d{2})-(\d{2})-(\d{2})\b/g;
const fromParts = (dayRaw: string, monthName: string, yearRaw?: string) => {
const day = Number(toAsciiDigits(dayRaw));
const month = gregMonthNumber(monthName);
if (!month) return null;
const year = yearRaw ? Number(toAsciiDigits(yearRaw)) : inferGregorianYear(month, day, now);
return jalaliFromGregorian(year, month, day);
};
let out = text.replace(dayMonth, (full, lead: string, day: string, month: string, year?: string) => {
const next = fromParts(day, month, year);
return next ? `${lead}${next}` : full;
});
out = out.replace(monthDay, (full, lead: string, month: string, day: string, year?: string) => {
const next = fromParts(day, month, year);
return next ? `${lead}${next}` : full;
});
out = out.replace(iso, (full, year: string, month: string, day: string) => {
return jalaliFromGregorian(Number(year), Number(month), Number(day)) ?? full;
});
return out;
}
export function startOfJalaliMonth(jy: number, jm: number): Date {
return jalaliToDate(jy, jm, 1);
}
+64
View File
@@ -0,0 +1,64 @@
import type { Prisma } from '@prisma/client';
import type { Actor } from './record-scope';
export type PriorLens = 'off' | 'current' | 'archive';
export function actorPriorLens(actor: Pick<Actor, 'priorLens'> | undefined): PriorLens {
return actor?.priorLens ?? 'off';
}
function hasCutoff(cutoff?: Date | null): cutoff is Date {
return cutoff instanceof Date && !Number.isNaN(cutoff.getTime());
}
/** شخص/شرکت دیگر با این لنز فیلتر نمی‌شوند. */
export function personPriorWhere(_lens?: PriorLens): Prisma.PersonWhereInput {
return {};
}
export function companyPriorWhere(_lens?: PriorLens): Prisma.CompanyWhereInput {
return {};
}
export function dealPriorWhere(
lens: PriorLens | undefined,
cutoff?: Date | null,
): Prisma.DealWhereInput {
if (lens !== 'current' && lens !== 'archive') return {};
if (!hasCutoff(cutoff)) return {};
if (lens === 'current') {
return {
OR: [
{ createdAt: { gte: cutoff } },
{ promotedFromPriorAt: { not: null } },
],
};
}
return { createdAt: { lt: cutoff }, promotedFromPriorAt: null };
}
export function activityPriorWhere(
lens: PriorLens | undefined,
cutoff?: Date | null,
): Prisma.ActivityWhereInput {
if (lens !== 'current' && lens !== 'archive') return {};
if (!hasCutoff(cutoff)) return {};
if (lens === 'current') {
return {
OR: [
{ createdAt: { gte: cutoff } },
{ promotedFromPriorAt: { not: null } },
],
};
}
return { createdAt: { lt: cutoff }, promotedFromPriorAt: null };
}
/** فهرست شخص/شرکت: همیشه بدون فیلتر لنز (سازگاری با call-siteهای قدیمی). */
export function listPriorLens(
_lens: PriorLens | undefined,
_q: string | undefined,
_explicitSource: boolean,
): PriorLens {
return 'off';
}
+176 -28
View File
@@ -1,44 +1,184 @@
import type { UserRole } from '@mento/shared';
import { canViewAllRecords } from '@mento/shared';
import type { DataScope, DataVisibility, DataVisibilityScopes, UserRole } from '@mento/shared';
import { DATA_SCOPES, canViewAllRecords } from '@mento/shared';
import type { Prisma } from '@prisma/client';
export type ScopeVisibility = {
dataVisibility: DataVisibility;
visibleUserIds: string[] | null;
};
export type Actor = {
userId: string;
role: string;
organizationId: string;
/** own | team | org — سطح پیش‌فرض از Membership */
dataVisibility?: DataVisibility;
/**
* شناسه کاربرانی که پرونده‌شان برای این Actor قابل‌مشاهده است.
* اگر undefined باشد فقط خود کاربر؛ اگر null یعنی همه (elevated/org).
*/
visibleUserIds?: string[] | null;
/** سطح دید تفکیکی هر بخش (از Membership.dataVisibilityScopes) */
scopes?: Partial<Record<DataScope, ScopeVisibility>>;
/** off = فیلتر خاموش؛ current = کار جاری؛ archive = بایگانی اطلاعات قبل */
priorLens?: 'off' | 'current' | 'archive';
/** لحظهٔ روشن شدن ماژول برای این محیط؛ فعالیت‌های ساخته‌شده قبل از آن می‌توانند قبلی باشند */
priorCutoff?: Date | null;
/** مالک واقعی وقتی در حالت مشاهده به‌جای کاربر دیگر است */
impersonatorUserId?: string;
impersonatorRole?: string;
/** در حالت مشاهده، سرور تغییر را رد می‌کند */
viewAsReadOnly?: boolean;
};
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>;
/** Actor با سطح دید مخصوص یک بخش (اگر تنظیم شده باشد). */
export function forScope<T extends ScopedActor>(actor: T, scope: DataScope): T {
const s = actor.scopes?.[scope];
if (!s) return actor;
return { ...actor, dataVisibility: s.dataVisibility, visibleUserIds: s.visibleUserIds };
}
export function parseVisibilityScopes(raw: unknown): DataVisibilityScopes {
const out: DataVisibilityScopes = {};
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
for (const scope of DATA_SCOPES) {
const v = (raw as Record<string, unknown>)[scope];
if (v === 'own' || v === 'team' || v === 'org') out[scope] = v;
}
return out;
}
export function elevated(role: string | null | undefined): boolean {
return canViewAllRecords(role);
}
/** فیلتر مالکیت برای Deal / Quote / Invoice / Person / Company */
export function seesAllRecords(
actor: Pick<Actor, 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>,
scope?: DataScope,
): boolean {
if (elevated(actor.role)) return true;
const s = scope ? actor.scopes?.[scope] : undefined;
const visibility = s ? s.dataVisibility : actor.dataVisibility;
const userIds = s ? s.visibleUserIds : actor.visibleUserIds;
if (visibility === 'org') return true;
if (userIds === null) return true;
return false;
}
/** شناسه کاربرانی که پرونده‌شان در این بخش دیده می‌شود؛ null یعنی همه. */
export function scopedOwnerIds(
actor: ScopedActor,
scope?: DataScope,
): string[] | null {
const a = scope ? forScope(actor, scope) : actor;
if (seesAllRecords(a)) return null;
const ids = a.visibleUserIds?.length
? a.visibleUserIds
: [a.userId];
return [...new Set(ids.filter(Boolean))];
}
/** فیلتر مالکیت؛ پیش‌فرض بخش «مشتریان» (Person / Company). برای فاکتور/پیش‌فاکتور scope=finance. */
export function ownerWhere(
actor: Pick<Actor, 'userId' | 'role'>,
): { ownerId: string } | Record<string, never> {
if (elevated(actor.role)) return {};
return { ownerId: actor.userId };
actor: ScopedActor,
scope: DataScope = 'contacts',
): { ownerId: string } | { ownerId: { in: string[] } } | Record<string, never> {
const ids = scopedOwnerIds(actor, scope);
if (!ids) return {};
if (ids.length === 1) return { ownerId: ids[0]! };
return { ownerId: { in: ids } };
}
/** فعالیت‌ها بر اساس مسئول */
export function assigneeWhere(
actor: Pick<Actor, 'userId' | 'role'>,
): { assigneeId: string } | Record<string, never> {
if (elevated(actor.role)) return {};
return { assigneeId: actor.userId };
}
/** یادداشت: نویسنده یا پرونده‌های تحت مالکیت */
export function noteAccessWhere(
actor: Pick<Actor, 'userId' | 'role'>,
): Prisma.NoteWhereInput {
if (elevated(actor.role)) return {};
/** معامله: مالک اصلی یا یکی از مسئولان مشترک در محدوده دید */
export function dealAccessWhere(
actor: ScopedActor,
): Prisma.DealWhereInput {
const ids = scopedOwnerIds(actor, 'deals');
if (!ids) return {};
return {
OR: [
{ authorId: actor.userId },
{ deal: { ownerId: actor.userId } },
{ person: { ownerId: actor.userId } },
{ company: { ownerId: actor.userId } },
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
};
}
/** کارت پروژه: مالک یا یکی از مسئولان در محدوده دید */
export function projectCardAccessWhere(
actor: ScopedActor,
): Prisma.ProjectCardWhereInput {
const ids = scopedOwnerIds(actor, 'projects');
if (!ids) return {};
return {
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
};
}
/** فعالیت‌ها بر اساس مسئول اصلی یا مسئولان مشترک */
export function assigneeWhere(
actor: ScopedActor,
): Prisma.ActivityWhereInput {
const ids = scopedOwnerIds(actor, 'activities');
if (!ids) return {};
return {
OR: [
{ assigneeId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
};
}
/** دسترسی به فعالیت: مسئولان یا شرکت‌کنندگان (فیلد شرکت‌کنندگان) در محدوده دید */
export function activityAccessWhere(
actor: ScopedActor,
): Prisma.ActivityWhereInput {
const ids = scopedOwnerIds(actor, 'activities');
if (!ids) return {};
return {
OR: [
{ assigneeId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
{ participantIds: { hasSome: ids } },
],
};
}
/** یادداشت: نویسنده یا پرونده‌های تحت مالکیت در محدوده دید + محرمانگی شخصی */
export function noteAccessWhere(
actor: ScopedActor,
): Prisma.NoteWhereInput {
const ids = scopedOwnerIds(actor, 'notes');
const personalGate: Prisma.NoteWhereInput = {
OR: [
{ visibility: { not: 'personal' } },
{ visibility: 'personal', authorId: actor.userId },
// رکوردهای قدیمی بدون فیلد visibility در DB با default team می‌آیند
],
};
if (!ids) return personalGate;
return {
AND: [
personalGate,
{
OR: [
{ authorId: { in: ids } },
{
deal: {
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
},
},
{ person: { ownerId: { in: ids } } },
{ company: { ownerId: { in: ids } } },
],
},
],
};
}
@@ -53,13 +193,14 @@ export function resolveOwnerId(
return actor.userId;
}
/** تیکت: مسئول یا ثبت‌کننده */
/** تیکت: مسئول یا ثبت‌کننده در محدوده دید */
export function ticketAccessWhere(
actor: Pick<Actor, 'userId' | 'role'>,
actor: ScopedActor,
): Prisma.TicketWhereInput {
if (elevated(actor.role)) return {};
const ids = scopedOwnerIds(actor, 'tickets');
if (!ids) return {};
return {
OR: [{ assigneeId: actor.userId }, { reporterId: actor.userId }],
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
};
}
@@ -68,3 +209,10 @@ export function canReassignOwner(role: string | null | undefined): boolean {
}
export type OrgRole = UserRole;
export function normalizeDataVisibility(
value: string | null | undefined,
): DataVisibility {
if (value === 'team' || value === 'org' || value === 'own') return value;
return 'own';
}
+85
View File
@@ -0,0 +1,85 @@
/**
* الگوریتم مشترک جستجو در کل API:
* - عبارت به کلمات شکسته می‌شود و همه کلمات باید (در هر فیلدی) پیدا شوند؛
* پس «علی رضا» هم «علی رضایی» را پیدا می‌کند و بخشی از نام هم کافی است.
* - حروف عربی/فارسی (ي/ی، ك/ک، ة/ه، أ/ا …) و ارقام فارسی/عربی/لاتین یکسان در نظر گرفته می‌شوند.
* - برای شماره تلفن، پیشوند 0 / 98 / +98 نادیده گرفته می‌شود.
*/
const MAX_TOKENS = 6;
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
const AR_DIGITS = '٠١٢٣٤٥٦٧٨٩';
function toAsciiDigits(s: string): string {
return s.replace(/[۰-۹٠-٩]/g, (ch) => {
const fa = FA_DIGITS.indexOf(ch);
return String(fa >= 0 ? fa : AR_DIGITS.indexOf(ch));
});
}
function toFaDigits(s: string): string {
return s.replace(/[0-9]/g, (d) => FA_DIGITS[Number(d)]!);
}
function toPersianLetters(s: string): string {
return s
.replace(/[يىئ]/g, 'ی')
.replace(/ك/g, 'ک')
.replace(/ة/g, 'ه')
.replace(/[أإٱ]/g, 'ا')
.replace(/ؤ/g, 'و');
}
function toArabicLetters(s: string): string {
return s.replace(/ی/g, 'ي').replace(/ک/g, 'ك');
}
/** متن ورودی جستجو را یکدست می‌کند (ارقام لاتین، حروف فارسی، فاصله‌های یکدست). */
export function normalizeSearchText(raw: string | null | undefined): string {
return toPersianLetters(toAsciiDigits(String(raw ?? '')))
.replace(/[\u064B-\u065F\u0670]/g, '')
.replace(/[\u200c\u200f\u200e]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
export function searchTokens(raw: string | null | undefined): string[] {
const text = normalizeSearchText(raw);
if (!text) return [];
return [...new Set(text.split(' ').filter(Boolean))].slice(0, MAX_TOKENS);
}
/** شکل‌های مختلف یک کلمه که ممکن است در دیتابیس ذخیره شده باشد. */
export function tokenVariants(token: string): string[] {
const out = new Set<string>([token, toArabicLetters(token)]);
if (/[0-9]/.test(token)) {
out.add(toFaDigits(token));
if (/^\+?[0-9]+$/.test(token)) {
const core = token.replace(/^\+/, '').replace(/^(0098|98|0)/, '');
if (core.length >= 3) out.add(core);
}
}
return [...out].filter(Boolean);
}
/**
* شرط Prisma برای جستجو: AND روی کلمات، OR روی شکل‌ها و فیلدها.
* `fieldsFor(term)` شرط‌های OR یک کلمه را برای فیلدهای آن مدل برمی‌گرداند.
*/
export function buildSearchWhere<W extends object>(
raw: string | null | undefined,
fieldsFor: (term: string) => W[],
): W | undefined {
const tokens = searchTokens(raw);
if (!tokens.length) return undefined;
const parts = tokens.map((token) => ({
OR: tokenVariants(token).flatMap((v) => fieldsFor(v)),
}));
return (parts.length === 1 ? parts[0] : { AND: parts }) as unknown as W;
}
/** کمک برای فیلدهای متنی ساده: `{ field: { contains, mode: 'insensitive' } }` */
export function ci(term: string) {
return { contains: term, mode: 'insensitive' as const };
}
+35
View File
@@ -0,0 +1,35 @@
import { jalaliMonthName, toJalali } from './jalali';
import { applyMessageTemplate } from '../message-automations/message-automation.util';
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
function toFaDigits(value: string | number) {
return String(value).replace(/\d/g, (d) => FA_DIGITS[Number(d)] ?? d);
}
function tehranJalaliLong(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);
const j = toJalali(gy, gm, gd);
return `${toFaDigits(j.jd)} ${jalaliMonthName(j.jm)} ${toFaDigits(j.jy)}`;
}
export function renderSmsText(
text: string,
vars: { name?: string; phone?: string; title?: string; company?: string },
) {
return applyMessageTemplate(text, {
name: vars.name,
phone: vars.phone,
title: vars.title,
company: vars.company,
date: tehranJalaliLong(new Date()),
}).trim();
}
+2 -3
View File
@@ -1,6 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { brand } from '@mento/shared';
/** پنل ترِز / رایگان — همان الگوی montime */
const SMS_TREZ_DEFAULT_BASE_URL = 'http://smspanel.Trez.ir';
@@ -44,8 +43,8 @@ export class SmsService {
async sendOtpSms(mobile: string, code: string): Promise<boolean> {
const template =
this.config.get<string>('SMS_OTP_TEMPLATE') ??
`کد ورود پرتال ${brand.nameFa}: {code}`;
this.config.get<string>('SMS_OTP_TEMPLATE')?.trim() ||
`کد ورود منتکت: {code}`;
const text = template.replace('{code}', code);
const result = await this.sendSmsDetailed(mobile, text);
return result.ok;
+10
View File
@@ -0,0 +1,10 @@
/** Normalize free-form tag lists (max 40 unique non-empty tags). */
export function normalizeTags(input?: string[] | null): string[] {
if (!input?.length) return [];
const cleaned = input
.map((t) => String(t ?? '').trim())
.filter(Boolean)
.map((t) => (t.startsWith('#') ? t.slice(1).trim() : t))
.filter(Boolean);
return [...new Set(cleaned)].slice(0, 40);
}
+70
View File
@@ -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],
};
}
+25 -5
View File
@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Delete,
@@ -14,6 +15,7 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
@@ -23,6 +25,11 @@ import { CreateCompanyDto, ListQueryDto, UpdateCompanyDto } from './dto';
import { ContactsExcelService } from './contacts-excel.service';
import { brand } from '@mento/shared';
const excelUpload = FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 15_000_000 },
});
@Controller('companies')
@UseGuards(JwtAuthGuard)
export class CompaniesController {
@@ -57,19 +64,32 @@ export class CompaniesController {
}
@Post('import/preview')
@UseInterceptors(FileInterceptor('file'))
previewImport(@UploadedFile() file: Express.Multer.File) {
@UseInterceptors(excelUpload)
previewImport(@UploadedFile() file?: Express.Multer.File) {
if (!file?.buffer?.length) {
throw new BadRequestException('فایل ارسال نشده');
}
return this.excelService.previewCompanies(file);
}
@Post('import')
@UseInterceptors(FileInterceptor('file'))
@UseInterceptors(excelUpload)
import(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
@UploadedFile() file?: Express.Multer.File,
@Body('mapping') mapping?: string,
@Body('sessionId') sessionId?: string,
@Body('offset') offset?: string,
@Body('limit') limit?: string,
) {
return this.excelService.importCompanies(user, file, mapping);
if (!file?.buffer?.length && !sessionId) {
throw new BadRequestException('فایل ارسال نشده');
}
return this.excelService.importCompanies(user, file, mapping, {
sessionId,
offset,
limit,
});
}
@Get(':id')
+97 -11
View File
@@ -23,6 +23,16 @@ import {
ownerWhere,
resolveOwnerId,
} from '../common/record-scope';
import { normalizeTags } from '../common/tags';
import { normalizePersonImportSource } from './person-source';
import { buildSearchWhere, ci } from '../common/search';
import { GamificationService } from '../gamification/gamification.service';
function parseDateOrNull(raw?: string | null): Date | null {
if (!raw?.trim()) return null;
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? null : d;
}
const companyInclude = {
_count: { select: { people: true } },
@@ -32,12 +42,26 @@ const companyInclude = {
type CompanyRow = {
id: string;
name: string;
code: string | null;
importSource: string;
sourceRef: string | null;
nationalId: string | null;
economicCode: string | null;
registrationNumber: string | null;
phone: string | null;
otherPhones: string | null;
fax: string | null;
email: string | null;
otherEmails: string | null;
website: string | null;
province: string | null;
city: string | null;
postalCode: string | null;
address: string | null;
groupName: string | null;
notes: string | null;
registeredAt: Date | null;
tags: string[];
customFields: unknown;
createdAt: Date;
updatedAt: Date;
@@ -47,7 +71,10 @@ type CompanyRow = {
@Injectable()
export class CompaniesService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly game: GamificationService,
) {}
async list(
actor: Actor,
@@ -55,19 +82,22 @@ export class CompaniesService {
page = 1,
limit = 20,
): Promise<PaginatedResponse<CompanyDto>> {
const search = buildSearchWhere<Prisma.CompanyWhereInput>(q, (t) => [
{ name: ci(t) },
{ email: ci(t) },
{ phone: { contains: t } },
{ otherPhones: { contains: t } },
{ nationalId: { contains: t } },
{ economicCode: { contains: t } },
{ code: ci(t) },
{ sourceRef: ci(t) },
{ city: ci(t) },
{ province: ci(t) },
]);
const where: Prisma.CompanyWhereInput = {
organizationId: actor.organizationId,
...ownerWhere(actor),
...(q?.trim()
? {
OR: [
{ name: { contains: q.trim(), mode: 'insensitive' } },
{ email: { contains: q.trim(), mode: 'insensitive' } },
{ phone: { contains: q.trim() } },
{ nationalId: { contains: q.trim() } },
],
}
: {}),
...(search ? { AND: [search] } : {}),
};
const [rows, total] = await this.prisma.$transaction([
@@ -102,12 +132,26 @@ export class CompaniesService {
const data: Prisma.CompanyUncheckedCreateInput = {
organizationId: actor.organizationId,
name: dto.name.trim(),
code: emptyToNull(dto.code),
importSource: normalizePersonImportSource(dto.importSource),
sourceRef: emptyToNull(dto.sourceRef),
nationalId: emptyToNull(dto.nationalId),
economicCode: emptyToNull(dto.economicCode),
registrationNumber: emptyToNull(dto.registrationNumber),
phone: normalizePhone(dto.phone),
otherPhones: emptyToNull(dto.otherPhones),
fax: emptyToNull(dto.fax),
email: normalizeEmail(dto.email),
otherEmails: emptyToNull(dto.otherEmails),
website: emptyToNull(dto.website),
province: emptyToNull(dto.province),
city: emptyToNull(dto.city),
postalCode: emptyToNull(dto.postalCode),
address: emptyToNull(dto.address),
groupName: emptyToNull(dto.groupName),
notes: emptyToNull(dto.notes),
registeredAt: parseDateOrNull(dto.registeredAt),
tags: normalizeTags(dto.tags),
ownerId: resolveOwnerId(actor, dto.ownerId),
customFields: toJsonCustomFields(dto.customFields),
};
@@ -117,6 +161,16 @@ export class CompaniesService {
data,
include: companyInclude,
});
if (row.importSource === 'manual') {
this.game.emit({
organizationId: actor.organizationId,
userId: actor.userId,
event: 'COMPANY_CREATED',
entityType: 'company',
entityId: row.id,
entityTitle: row.name,
});
}
return this.toDto(row);
}
@@ -128,12 +182,30 @@ export class CompaniesService {
await this.ensureExists(actor.organizationId, id, actor);
const data: Prisma.CompanyUncheckedUpdateInput = {};
if (dto.name !== undefined) data.name = dto.name.trim();
if ('code' in dto) data.code = emptyToNull(dto.code);
if (dto.importSource !== undefined) {
data.importSource = normalizePersonImportSource(dto.importSource);
}
if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef);
if ('nationalId' in dto) data.nationalId = emptyToNull(dto.nationalId);
if ('economicCode' in dto) data.economicCode = emptyToNull(dto.economicCode);
if ('registrationNumber' in dto) {
data.registrationNumber = emptyToNull(dto.registrationNumber);
}
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
if ('otherPhones' in dto) data.otherPhones = emptyToNull(dto.otherPhones);
if ('fax' in dto) data.fax = emptyToNull(dto.fax);
if ('email' in dto) data.email = normalizeEmail(dto.email);
if ('otherEmails' in dto) data.otherEmails = emptyToNull(dto.otherEmails);
if ('website' in dto) data.website = emptyToNull(dto.website);
if ('province' in dto) data.province = emptyToNull(dto.province);
if ('city' in dto) data.city = emptyToNull(dto.city);
if ('postalCode' in dto) data.postalCode = emptyToNull(dto.postalCode);
if ('address' in dto) data.address = emptyToNull(dto.address);
if ('groupName' in dto) data.groupName = emptyToNull(dto.groupName);
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
if ('registeredAt' in dto) data.registeredAt = parseDateOrNull(dto.registeredAt);
if ('tags' in dto) data.tags = normalizeTags(dto.tags);
if ('customFields' in dto) {
data.customFields = toJsonCustomFields(dto.customFields);
}
@@ -222,12 +294,26 @@ export class CompaniesService {
return {
id: row.id,
name: row.name,
code: row.code,
importSource: row.importSource || 'manual',
sourceRef: row.sourceRef,
nationalId: row.nationalId,
economicCode: row.economicCode,
registrationNumber: row.registrationNumber,
phone: row.phone,
otherPhones: row.otherPhones,
fax: row.fax,
email: row.email,
otherEmails: row.otherEmails,
website: row.website,
province: row.province,
city: row.city,
postalCode: row.postalCode,
address: row.address,
groupName: row.groupName,
notes: row.notes,
registeredAt: row.registeredAt ? row.registeredAt.toISOString() : null,
tags: row.tags ?? [],
customFields: asCustomFields(row.customFields),
peopleCount: row._count.people,
owner: row.owner,
+445 -59
View File
@@ -3,17 +3,34 @@ import type { ImportContactsResult, PersonMood } from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { ownerWhere } from '../common/record-scope';
import { ownerWhere, resolveOwnerId } from '../common/record-scope';
import {
collectAddresses,
} from '../common/custom-fields';
import {
formatJalaliDate,
parseBirthDate,
parseExcelMapping,
parseJalaliDateTime,
previewExcel,
readMappedRows,
writeXlsx,
type ExcelPreviewResult,
} from '../common/excel';
import {
chunkImportMeta,
resolveExcelImportChunk,
} from '../common/excel-import-session';
import { CompaniesService } from './companies.service';
import { PeopleService } from './people.service';
import { normalizeEmail, normalizePhone } from './normalize';
import { normalizePersonImportSource } from './person-source';
type ImportChunkOpts = {
sessionId?: string;
offset?: string | number;
limit?: string | number;
};
function parseMood(raw?: string): PersonMood | undefined {
const value = raw?.trim();
@@ -73,8 +90,17 @@ const PERSON_ALIASES: Record<string, string> = {
گروهمشتری: 'group',
آدرس: 'address',
آدرس1: 'address',
آدرساصلی: 'address',
address: 'address',
address2: 'address2',
آدرس۲: 'address2',
آدرس2: 'address2',
آدرس_1: 'address2',
آدرسدوم: 'address2',
آدرسمنزل: 'homeAddress',
آدرمنزل: 'homeAddress',
آدرسخانه: 'homeAddress',
homeaddress: 'homeAddress',
کدپستی: 'postalCode',
کدپستی1: 'postalCode',
استانفیلدپیشفرض: 'province',
@@ -88,17 +114,33 @@ const PERSON_ALIASES: Record<string, string> = {
تاریختولدمشتری: 'birthDate',
تاریختولد1: 'birthDate',
تاریختولد: 'birthDate',
تاریختولدشمسی: 'birthDate',
تاریخمیلادت: 'birthDate',
birthdate: 'birthDate',
dateofbirth: 'birthDate',
bdate: 'birthDate',
منبعاطلاعات: 'source',
نوعمشتری: 'customerType',
type: '_type',
_type: '_type',
آیدیاینستاگرام: 'instagram',
اینستاگرام: 'instagram',
پیجاینستاگرام: 'instagram',
instagram: 'instagram',
معرف: 'referrer',
سایتهایمشتری: 'website',
تاریخثبت: 'registeredAt',
registeredat: 'registeredAt',
شمارهفکس: 'fax',
تلفن2: 'phone2',
تلفنهایدیگرمشتری: 'otherPhones',
مسئولمشتری: 'ownerName',
مسئول: 'ownerName',
مسئولفروش: 'ownerName',
کارشناسفروش: 'ownerName',
کارشناس: 'ownerName',
owner: 'ownerName',
ownername: 'ownerName',
تلفنثابتشرکت: 'companyPhone',
کددیدارشرکت: 'companyDidarId',
مجوزمشاهدهمشتری: 'visibility',
@@ -114,44 +156,135 @@ const PERSON_FIELDS = [
{ key: 'firstName', label: 'نام' },
{ key: 'lastName', label: 'نام خانوادگی' },
{ key: 'title', label: 'نام کامل / عنوان' },
{ key: 'didarId', label: 'کد مبدأ (کد دیدار)' },
{ key: 'customerCode', label: 'کد مشتری' },
{ key: 'mobile', label: 'موبایل' },
{ key: 'phone', label: 'تلفن' },
{ key: 'email', label: 'ایمیل' },
{ key: 'position', label: 'سمت' },
{ key: 'companyName', label: 'شرکت' },
{ key: 'ownerName', label: 'کارشناس فروش' },
{ key: 'address', label: 'آدرس' },
{ key: 'address2', label: 'آدرس ۲' },
{ key: 'homeAddress', label: 'آدرس منزل' },
{ key: 'instagram', label: 'اینستاگرام' },
{ key: 'registeredAt', label: 'تاریخ ثبت' },
{ key: 'birthDate', label: 'تاریخ تولد' },
{ key: 'notes', label: 'یادداشت' },
{ key: 'mood', label: 'وضعیت مشتری' },
];
const COMPANY_ALIASES: Record<string, string> = {
type: '_type',
_type: '_type',
name: 'name',
نام: 'name',
نامشرکت: 'name',
کددیدارشرکت: 'didarId',
didarid: 'didarId',
کدشرکت: 'code',
code: 'code',
استانفیلدپیشفرض: 'province',
استان: 'province',
province: 'province',
شهرستان: 'city',
شهر: 'city',
city: 'city',
nationalid: 'nationalId',
شناسهملی: 'nationalId',
شمارهاقتصادی: 'economicCode',
کداقتصادی: 'economicCode',
شمارهثبت: 'registrationNumber',
phone: 'phone',
تلفن: 'phone',
تلفنشرکت: 'phone',
تلفنثابتشرکت: 'phone',
تلفنهایدیگرشرکت: 'otherPhones',
شمارهفکس: 'fax',
فکس: 'fax',
fax: 'fax',
email: 'email',
ایمیل: 'email',
ایمیلشرکت: 'email',
ایمیلهایدیگرشرکت: 'otherEmails',
website: 'website',
وبسایت: 'website',
سایتهایشرکت: 'website',
کدپستی: 'postalCode',
postalcode: 'postalCode',
address: 'address',
آدرس: 'address',
notes: 'notes',
یادداشت: 'notes',
توضیحاتشرکت: 'notes',
مسئولشرکت: 'ownerName',
مسئول: 'ownerName',
owner: 'ownerName',
مجوزمشاهدهشرکت: 'visibility',
تاریخثبتشرکت: 'registeredAt',
تاریخثبت: 'registeredAt',
گروهبندیشرکت: 'groupName',
گروه: 'groupName',
};
const COMPANY_FIELDS = [
{ key: 'name', label: 'نام شرکت', required: true },
{ key: 'didarId', label: 'کد دیدار شرکت' },
{ key: 'code', label: 'کد شرکت' },
{ key: 'province', label: 'استان' },
{ key: 'city', label: 'شهرستان' },
{ key: 'phone', label: 'تلفن شرکت' },
{ key: 'email', label: 'ایمیل شرکت' },
{ key: 'economicCode', label: 'شماره اقتصادی' },
{ key: 'registrationNumber', label: 'شماره ثبت' },
{ key: 'nationalId', label: 'شناسه ملی' },
{ key: 'phone', label: 'تلفن' },
{ key: 'email', label: 'ایمیل' },
{ key: 'website', label: 'وب‌سایت' },
{ key: 'fax', label: 'شماره فکس' },
{ key: 'postalCode', label: 'کد پستی' },
{ key: 'notes', label: 'توضیحات شرکت' },
{ key: 'ownerName', label: 'مسئول شرکت' },
{ key: 'visibility', label: 'مجوز مشاهده شرکت' },
{ key: 'registeredAt', label: 'تاریخ ثبت شرکت' },
{ key: 'groupName', label: 'گروه بندی شرکت' },
{ key: 'otherPhones', label: 'تلفن های دیگر شرکت' },
{ key: 'address', label: 'آدرس' },
{ key: 'notes', label: 'یادداشت' },
{ key: 'website', label: 'سایت های شرکت' },
{ key: 'otherEmails', label: 'ایمیل های دیگر شرکت' },
];
const COMPANY_COLUMNS = [
{ header: '_type', key: '_type', width: 12 },
{ header: 'کد دیدار شرکت', key: 'didarId', width: 14 },
{ header: 'کد شرکت', key: 'code', width: 12 },
{ header: 'نام شرکت', key: 'name', width: 28 },
{ header: 'استان (فیلد پیش فرض)', key: 'province', width: 16 },
{ header: 'شهرستان', key: 'city', width: 14 },
{ header: 'تلفن شرکت', key: 'phone', width: 16 },
{ header: 'ایمیل شرکت', key: 'email', width: 24 },
{ header: 'شماره اقتصادی', key: 'economicCode', width: 16 },
{ header: 'شماره ثبت', key: 'registrationNumber', width: 14 },
{ header: 'شناسه ملی', key: 'nationalId', width: 16 },
{ header: 'شماره فکس', key: 'fax', width: 14 },
{ header: 'کد پستی', key: 'postalCode', width: 12 },
{ header: 'توضیحات شرکت', key: 'notes', width: 32 },
{ header: 'مسئول شرکت', key: 'ownerName', width: 16 },
{ header: 'مجوز مشاهده شرکت', key: 'visibility', width: 18 },
{ header: 'تاریخ ثبت شرکت', key: 'registeredAt', width: 16 },
{ header: 'گروه بندی شرکت', key: 'groupName', width: 16 },
{ header: 'تلفن های دیگر شرکت', key: 'otherPhones', width: 18 },
{ header: 'آدرس', key: 'address', width: 28 },
{ header: 'سایت های شرکت', key: 'website', width: 20 },
{ header: 'ایمیل های دیگر شرکت', key: 'otherEmails', width: 20 },
];
function parseLooseDate(raw?: string): Date | null {
const jalali = parseJalaliDateTime(raw);
if (jalali) return jalali;
const v = raw?.trim();
if (!v) return null;
const d = new Date(v);
return Number.isNaN(d.getTime()) ? null : d;
}
const CONTACT_COLUMNS = [
{ header: '_type', key: '_type', width: 12 },
{ header: 'کد دیدار مشتری', key: 'didarId', width: 16 },
@@ -209,6 +342,7 @@ const CUSTOM_KEYS = [
'province',
'city',
'address',
'address2',
'nationalId',
'birthDate',
'group',
@@ -267,23 +401,23 @@ export class ContactsExcelService {
visibility: 'همه افراد شرکت',
nationalId: extra.nationalId ?? '',
registeredAt: formatJalaliDate(p.createdAt),
birthDate: extra.birthDate ?? '',
birthDate: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
mood: extra.group || PERSON_MOOD_LABELS[p.mood || 'newcomer'],
otherPhones: extra.otherPhones ?? '',
address: extra.address ?? '',
website: extra.website ?? '',
address: p.addresses[0] ?? extra.address ?? '',
website: p.website ?? extra.website ?? '',
otherEmails: extra.otherEmails ?? '',
position2: p.position ?? extra.position ?? '',
city2: extra.city ?? '',
code2: extra.customerCode ?? '',
group2: extra.group ?? '',
address2: extra.address ?? '',
address2: p.addresses[1] ?? extra.address ?? '',
phone2: extra.phone2 ?? p.phone ?? '',
postalCode2: extra.postalCode ?? '',
referrer: extra.referrer ?? '',
homeAddress: extra.homeAddress ?? '',
birthDate2: extra.birthDate ?? '',
instagram: extra.instagram ?? '',
referrer: extra.referrer ?? p.referrer ?? '',
homeAddress: p.addresses[2] ?? extra.homeAddress ?? '',
birthDate2: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
instagram: p.instagram ?? extra.instagram ?? '',
group: extra.group ?? '',
colleagueContact: extra.colleagueContact ?? '',
previousCompany: extra.previousCompany ?? '',
@@ -298,25 +432,37 @@ export class ContactsExcelService {
async exportCompanies(actor: Actor, q?: string): Promise<Buffer> {
const { items } = await this.companies.list(actor, q, 1, 5000);
return writeXlsx(
'شرکت‌ها',
[
{ header: 'نام شرکت', key: 'name', width: 28 },
{ header: 'شناسه ملی', key: 'nationalId', width: 16 },
{ header: 'تلفن', key: 'phone', width: 16 },
{ header: 'ایمیل', key: 'email', width: 28 },
{ header: 'وب‌سایت', key: 'website', width: 24 },
{ header: 'آدرس', key: 'address', width: 32 },
{ header: 'یادداشت', key: 'notes', width: 32 },
],
items.map((c) => ({
name: c.name,
nationalId: c.nationalId ?? '',
phone: c.phone ?? '',
email: c.email ?? '',
website: c.website ?? '',
address: c.address ?? '',
notes: c.notes ?? '',
})),
'Company',
COMPANY_COLUMNS,
items.map((c) => {
const extra = (c.customFields ?? {}) as Record<string, string>;
return {
_type: 'Company',
didarId: c.importSource === 'didar' && c.sourceRef ? c.sourceRef : '',
code: c.code ?? '',
name: c.name,
province: c.province ?? '',
city: c.city ?? '',
phone: c.phone ?? '',
email: c.email ?? '',
economicCode: c.economicCode ?? '',
registrationNumber: c.registrationNumber ?? '',
nationalId: c.nationalId ?? '',
fax: c.fax ?? '',
postalCode: c.postalCode ?? '',
notes: c.notes ?? '',
ownerName: c.owner?.fullName ?? '',
visibility: extra.visibility || 'همه افراد شرکت',
registeredAt: formatJalaliDate(
c.registeredAt ? new Date(c.registeredAt) : new Date(c.createdAt),
),
groupName: c.groupName ?? '',
otherPhones: c.otherPhones ?? '',
address: c.address ?? '',
website: c.website ?? '',
otherEmails: c.otherEmails ?? '',
};
}),
);
}
@@ -330,15 +476,25 @@ export class ContactsExcelService {
async importPeople(
actor: Actor,
file: Express.Multer.File,
file: Express.Multer.File | undefined,
mappingRaw?: string,
chunk?: ImportChunkOpts,
): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, PERSON_ALIASES, parseExcelMapping(mappingRaw));
const ctx = await resolveExcelImportChunk({
organizationId: actor.organizationId,
file,
sessionId: chunk?.sessionId,
offset: chunk?.offset,
limit: chunk?.limit,
loadRows: () =>
readMappedRows(file!, PERSON_ALIASES, parseExcelMapping(mappingRaw)),
});
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
const ownerCache = new Map<string, string | null>();
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
const rowNum = i + 2;
for (let i = 0; i < ctx.slice.length; i++) {
const row = ctx.slice[i]!;
const rowNum = ctx.offset + i + 2;
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'contact') {
continue;
}
@@ -360,16 +516,67 @@ export class ContactsExcelService {
let companyId: string | null = null;
const companyName = row.companyName?.trim();
if (companyName) {
companyId = await this.findOrCreateCompanyId(actor, companyName);
companyId = await this.findOrCreateCompanyId(
actor,
companyName,
row.companyDidarId,
row.companyPhone,
);
}
const ownerName = row.ownerName?.trim() ?? '';
let ownerId: string | undefined;
let ownerMissing = false;
if (ownerName) {
const key = ownerName.toLowerCase();
if (!ownerCache.has(key)) {
ownerCache.set(key, await this.findUserId(actor.organizationId, ownerName));
}
const matched = ownerCache.get(key);
if (matched) ownerId = matched;
else ownerMissing = true;
}
const customFields: Record<string, string> = {};
for (const key of CUSTOM_KEYS) {
if (
key === 'address' ||
key === 'address2' ||
key === 'homeAddress' ||
key === 'instagram' ||
key === 'website'
) {
continue;
}
const value = row[key]?.trim();
if (value) customFields[key] = value;
}
const addresses = collectAddresses(
row.address,
row.address2,
row.homeAddress,
);
const registeredAt =
parseJalaliDateTime(row.registeredAt) ??
(() => {
const raw = row.registeredAt?.trim();
if (!raw) return null;
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? null : d;
})();
try {
const didarId = customFields.didarId?.trim() || '';
const customerCode = customFields.customerCode?.trim() || '';
const sourceCol = customFields.source?.trim() || '';
const importSource = didarId
? 'didar'
: sourceCol
? normalizePersonImportSource(sourceCol)
: 'excel';
const sourceRef = didarId || customerCode || null;
await this.people.create(actor, {
firstName: names.firstName,
lastName: names.lastName,
@@ -377,14 +584,36 @@ export class ContactsExcelService {
phone: row.phone || row.phone2 || undefined,
mobile: row.mobile || undefined,
position: row.position || undefined,
website: row.website || undefined,
instagram: row.instagram || undefined,
birthDate: parseBirthDate(row.birthDate)?.toISOString() ?? null,
addresses,
notes: row.notes || undefined,
mood: parseMood(row.mood) ?? parseMood(row.group),
companyId,
ownerId: resolveOwnerId(actor, ownerId ?? null),
customFields,
importSource,
sourceRef,
registeredAt: registeredAt?.toISOString() ?? null,
});
result.created += 1;
if (ownerMissing) {
result.errors.push({
row: rowNum,
message: `کارشناس فروش «${ownerName}» در تیم پیدا نشد؛ به کاربر ایمپورتر اختصاص داده شد`,
});
}
} catch (e) {
if (e instanceof ConflictException) {
const birthDate = parseBirthDate(row.birthDate);
if (birthDate) {
await this.people.fillBirthDateIfEmpty(actor.organizationId, {
mobile: normalizePhone(row.mobile),
email: normalizeEmail(row.email),
birthDate,
});
}
result.skipped += 1;
} else {
result.errors.push({
@@ -395,36 +624,110 @@ export class ContactsExcelService {
}
}
return result;
return { ...result, ...chunkImportMeta(ctx) };
}
async importCompanies(
actor: Actor,
file: Express.Multer.File,
file: Express.Multer.File | undefined,
mappingRaw?: string,
chunk?: ImportChunkOpts,
): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, COMPANY_ALIASES, parseExcelMapping(mappingRaw));
const ctx = await resolveExcelImportChunk({
organizationId: actor.organizationId,
file,
sessionId: chunk?.sessionId,
offset: chunk?.offset,
limit: chunk?.limit,
loadRows: () =>
readMappedRows(file!, COMPANY_ALIASES, parseExcelMapping(mappingRaw)),
});
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
const ownerCache = new Map<string, string | null>();
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
const rowNum = i + 2;
for (let i = 0; i < ctx.slice.length; i++) {
const row = ctx.slice[i]!;
const rowNum = ctx.offset + i + 2;
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'company') {
continue;
}
const name = row.name?.trim() ?? '';
if (!name) {
result.errors.push({ row: rowNum, message: 'نام شرکت الزامی است' });
continue;
}
const ownerName = row.ownerName?.trim() ?? '';
let ownerId: string | undefined;
let ownerMissing = false;
if (ownerName) {
const key = ownerName.toLowerCase();
if (!ownerCache.has(key)) {
ownerCache.set(key, await this.findUserId(actor.organizationId, ownerName));
}
const matched = ownerCache.get(key);
if (matched) ownerId = matched;
else ownerMissing = true;
}
const didarId = row.didarId?.trim() || '';
const customFields: Record<string, string> = {};
const visibility = row.visibility?.trim();
if (visibility) customFields.visibility = visibility;
const registeredAt = parseLooseDate(row.registeredAt);
const payload = {
name,
code: row.code?.trim() || null,
importSource: didarId ? 'didar' : 'excel',
sourceRef: didarId || null,
nationalId: row.nationalId || undefined,
economicCode: row.economicCode?.trim() || null,
registrationNumber: row.registrationNumber?.trim() || null,
phone: row.phone || undefined,
otherPhones: row.otherPhones?.trim() || null,
fax: row.fax?.trim() || null,
email: row.email || undefined,
otherEmails: row.otherEmails?.trim() || null,
website: row.website || undefined,
province: row.province?.trim() || null,
city: row.city?.trim() || null,
postalCode: row.postalCode?.trim() || null,
address: row.address || undefined,
groupName: row.groupName?.trim() || null,
notes: row.notes || undefined,
registeredAt: registeredAt?.toISOString() ?? null,
customFields,
};
try {
await this.companies.create(actor, {
name,
nationalId: row.nationalId || undefined,
phone: row.phone || undefined,
email: row.email || undefined,
website: row.website || undefined,
address: row.address || undefined,
notes: row.notes || undefined,
});
result.created += 1;
const existing = await this.findImportedCompany(actor, didarId, name);
if (existing) {
const merged: Record<string, unknown> = {};
for (const [k, v] of Object.entries(payload)) {
if (k === 'customFields') continue;
if (v != null && v !== '') merged[k] = v;
}
merged.customFields = {
...((existing.customFields ?? {}) as Record<string, unknown>),
...customFields,
};
if (ownerId) merged.ownerId = ownerId;
await this.companies.update(actor, existing.id, merged);
result.skipped += 1;
} else {
await this.companies.create(actor, {
...payload,
ownerId: resolveOwnerId(actor, ownerId ?? null),
});
result.created += 1;
}
if (ownerMissing) {
result.errors.push({
row: rowNum,
message: `مسئول شرکت «${ownerName}» در تیم پیدا نشد؛ به کاربر ایمپورتر اختصاص داده شد`,
});
}
} catch (e) {
if (e instanceof ConflictException) {
result.skipped += 1;
@@ -437,20 +740,103 @@ export class ContactsExcelService {
}
}
return result;
return { ...result, ...chunkImportMeta(ctx) };
}
private async findOrCreateCompanyId(actor: Actor, name: string): Promise<string> {
/** شرکت موجود برای ورود مجدد: اول با کد دیدار، بعد با نام (اگر کد مبدأ نداشته باشد). */
private async findImportedCompany(actor: Actor, didarId: string, name: string) {
if (didarId) {
const byRef = await this.prisma.company.findFirst({
where: {
organizationId: actor.organizationId,
sourceRef: didarId,
...ownerWhere(actor),
},
select: { id: true, customFields: true },
});
if (byRef) return byRef;
}
return this.prisma.company.findFirst({
where: {
organizationId: actor.organizationId,
name: { equals: name, mode: 'insensitive' },
...(didarId ? { sourceRef: null } : {}),
...ownerWhere(actor),
},
select: { id: true, customFields: true },
});
}
private async findOrCreateCompanyId(
actor: Actor,
name: string,
didarId?: string,
phone?: string,
): Promise<string> {
const ref = didarId?.trim() || '';
if (ref) {
const byRef = await this.prisma.company.findFirst({
where: {
organizationId: actor.organizationId,
sourceRef: ref,
...ownerWhere(actor),
},
select: { id: true },
});
if (byRef) return byRef.id;
}
const existing = await this.prisma.company.findFirst({
where: {
organizationId: actor.organizationId,
name: { equals: name, mode: 'insensitive' },
...ownerWhere(actor),
},
select: { id: true, sourceRef: true },
});
if (existing) {
if (ref && !existing.sourceRef) {
await this.prisma.company.update({
where: { id: existing.id },
data: { sourceRef: ref, importSource: 'didar' },
});
}
return existing.id;
}
try {
const created = await this.companies.create(actor, {
name,
...(ref ? { sourceRef: ref, importSource: 'didar' } : {}),
...(phone?.trim() ? { phone: phone.trim() } : {}),
});
return created.id;
} catch (e) {
if (!(e instanceof ConflictException) || !phone) throw e;
const created = await this.companies.create(actor, {
name,
...(ref ? { sourceRef: ref, importSource: 'didar' } : {}),
});
return created.id;
}
}
private async findUserId(organizationId: string, name?: string) {
const n = name?.trim();
if (!n) return null;
const exact = await this.prisma.user.findFirst({
where: {
fullName: { equals: n, mode: 'insensitive' },
memberships: { some: { organizationId } },
},
select: { id: true },
});
if (existing) return existing.id;
const created = await this.companies.create(actor, { name });
return created.id;
if (exact) return exact.id;
const partial = await this.prisma.user.findFirst({
where: {
fullName: { contains: n, mode: 'insensitive' },
memberships: { some: { organizationId } },
},
select: { id: true },
});
return partial?.id ?? null;
}
}
+9 -2
View File
@@ -2,16 +2,23 @@ import { Module } from '@nestjs/common';
import { LoyaltyModule } from '../loyalty/loyalty.module';
import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
import { PublicApiModule } from '../public-api/public-api.module';
import { CompaniesController } from './companies.controller';
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: [LoyaltyModule, AutomationsModule, MessageAutomationsModule],
imports: [
LoyaltyModule,
AutomationsModule,
MessageAutomationsModule,
PublicApiModule,
],
controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService],
providers: [PeopleService, CompaniesService, ContactsExcelService, SmsService],
exports: [PeopleService, CompaniesService],
})
export class ContactsModule {}
+233 -3
View File
@@ -1,5 +1,8 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsEmail,
IsIn,
IsInt,
@@ -12,7 +15,22 @@ import {
MinLength,
ValidateIf,
} from 'class-validator';
import { PERSON_MOODS } from '@mento/shared';
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)
@ArrayMaxSize(500)
@IsString({ each: true })
ids!: string[];
}
export class ListQueryDto {
@IsOptional()
@@ -39,9 +57,99 @@ export class ListQueryDto {
@IsOptional()
@IsIn(['name', 'newest'])
sort?: 'name' | 'newest';
/** فیلتر منبع ورود شخص */
@IsOptional()
@IsString()
@MaxLength(40)
importSource?: string;
/** استان شرکت مرتبط */
@IsOptional()
@IsString()
@MaxLength(80)
province?: string;
/** شهر/شهرستان شرکت مرتبط */
@IsOptional()
@IsString()
@MaxLength(80)
city?: string;
/** فقط کسانی که موبایل دارند */
@IsOptional()
@IsIn(['1', 'true', '0', 'false'])
hasMobile?: string;
}
export class CreateCompanyDto {
class CompanyExtraFieldsDto {
@IsOptional()
@IsString()
@MaxLength(60)
code?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
importSource?: string;
@IsOptional()
@IsString()
@MaxLength(80)
sourceRef?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
economicCode?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
registrationNumber?: string | null;
@IsOptional()
@IsString()
@MaxLength(400)
otherPhones?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
fax?: string | null;
@IsOptional()
@IsString()
@MaxLength(400)
otherEmails?: string | null;
@IsOptional()
@IsString()
@MaxLength(80)
province?: string | null;
@IsOptional()
@IsString()
@MaxLength(80)
city?: string | null;
@IsOptional()
@IsString()
@MaxLength(20)
postalCode?: string | null;
@IsOptional()
@IsString()
@MaxLength(120)
groupName?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
registeredAt?: string | null;
}
export class CreateCompanyDto extends CompanyExtraFieldsDto {
@IsString()
@MinLength(2)
@MaxLength(160)
@@ -77,6 +185,11 @@ export class CreateCompanyDto {
@MaxLength(2000)
notes?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
@@ -87,7 +200,7 @@ export class CreateCompanyDto {
ownerId?: string | null;
}
export class UpdateCompanyDto {
export class UpdateCompanyDto extends CompanyExtraFieldsDto {
@IsOptional()
@IsString()
@MinLength(2)
@@ -124,6 +237,11 @@ export class UpdateCompanyDto {
@MaxLength(2000)
notes?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
@@ -145,6 +263,30 @@ export class CreatePersonDto {
@MaxLength(80)
lastName?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(80)
previousFirstName?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(80)
previousLastName?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
@IsIn([...PERSON_IMPORT_SOURCES])
importSource?: (typeof PERSON_IMPORT_SOURCES)[number];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(120)
sourceRef?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsEmail()
@@ -165,6 +307,31 @@ export class CreatePersonDto {
@MaxLength(120)
position?: string;
@IsOptional()
@IsString()
@MaxLength(300)
website?: string;
@IsOptional()
@IsString()
@MaxLength(200)
instagram?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
birthDate?: string | null;
@IsOptional()
@IsString()
@MaxLength(120)
referrer?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
addresses?: string[];
@IsOptional()
@IsString()
@MaxLength(2000)
@@ -174,6 +341,11 @@ export class CreatePersonDto {
@IsIn([...PERSON_MOODS])
mood?: (typeof PERSON_MOODS)[number];
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@@ -187,6 +359,11 @@ export class CreatePersonDto {
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
ownerId?: string | null;
/** فقط برای ایمپورت اکسل — تاریخ ثبت مبدأ */
@IsOptional()
@IsString()
registeredAt?: string | null;
}
export class UpdatePersonDto {
@@ -200,6 +377,29 @@ export class UpdatePersonDto {
@MaxLength(80)
lastName?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(80)
previousFirstName?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(80)
previousLastName?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
importSource?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(120)
sourceRef?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsEmail()
@@ -220,6 +420,31 @@ export class UpdatePersonDto {
@MaxLength(120)
position?: string;
@IsOptional()
@IsString()
@MaxLength(300)
website?: string;
@IsOptional()
@IsString()
@MaxLength(200)
instagram?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
birthDate?: string | null;
@IsOptional()
@IsString()
@MaxLength(120)
referrer?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
addresses?: string[];
@IsOptional()
@IsString()
@MaxLength(2000)
@@ -229,6 +454,11 @@ export class UpdatePersonDto {
@IsIn([...PERSON_MOODS])
mood?: (typeof PERSON_MOODS)[number];
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
+86 -9
View File
@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Delete,
@@ -14,17 +15,23 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
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 { 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';
const excelUpload = FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 15_000_000 },
});
@Controller('people')
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@RequireOrgModule('customers')
@@ -36,15 +43,46 @@ export class PeopleController {
@Get()
list(@CurrentUser() user: Actor, @Query() query: ListQueryDto) {
const hasMobile =
query.hasMobile === '1' || query.hasMobile === 'true'
? true
: undefined;
return this.peopleService.list(
user,
query.q,
query.page ?? 1,
query.limit ?? 20,
{ mood: query.mood, sort: query.sort },
{
mood: query.mood,
sort: query.sort,
importSource: query.importSource,
province: query.province,
city: query.city,
hasMobile,
},
);
}
@Get('ids')
listIds(@CurrentUser() user: Actor, @Query() query: ListQueryDto) {
const hasMobile =
query.hasMobile === '1' || query.hasMobile === 'true'
? true
: undefined;
return this.peopleService.listIds(user, query.q, {
mood: query.mood,
importSource: query.importSource,
province: query.province,
city: query.city,
hasMobile,
});
}
@Get('source-stats')
sourceStats(@CurrentUser() user: Actor) {
return this.peopleService.sourceStats(user);
}
@Get('export')
async export(
@CurrentUser() user: Actor,
@@ -61,21 +99,55 @@ export class PeopleController {
}
@Post('import/preview')
@UseInterceptors(FileInterceptor('file'))
previewImport(
@UploadedFile() file: Express.Multer.File,
) {
@UseInterceptors(excelUpload)
previewImport(@UploadedFile() file?: Express.Multer.File) {
if (!file?.buffer?.length) {
throw new BadRequestException('فایل ارسال نشده');
}
return this.excelService.previewPeople(file);
}
@Post('import')
@UseInterceptors(FileInterceptor('file'))
@UseInterceptors(excelUpload)
import(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
@UploadedFile() file?: Express.Multer.File,
@Body('mapping') mapping?: string,
@Body('sessionId') sessionId?: string,
@Body('offset') offset?: string,
@Body('limit') limit?: string,
) {
return this.excelService.importPeople(user, file, mapping);
if (!file?.buffer?.length && !sessionId) {
throw new BadRequestException('فایل ارسال نشده');
}
return this.excelService.importPeople(user, file, mapping, {
sessionId,
offset,
limit,
});
}
@Get('birthdays')
birthdays(@CurrentUser() user: Actor) {
return this.peopleService.birthdaysThisMonth(user);
}
@Post(':id/sms')
sms(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: BirthdaySmsDto,
) {
return this.peopleService.sendSms(user, id, dto.text);
}
@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')
@@ -83,6 +155,11 @@ export class PeopleController {
return this.peopleService.get(user, id);
}
@Post('bulk-delete')
bulkDelete(@CurrentUser() user: Actor, @Body() dto: BulkDeletePeopleDto) {
return this.peopleService.removeMany(user, dto.ids);
}
@Post()
create(@CurrentUser() user: Actor, @Body() dto: CreatePersonDto) {
return this.peopleService.create(user, {
+456 -21
View File
@@ -1,21 +1,31 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
ServiceUnavailableException,
type OnModuleInit,
} from '@nestjs/common';
import type {
CreatePersonRequest,
FilteredIdsResponse,
PaginatedResponse,
PersonDto,
PersonImportSourceStatDto,
PersonMood,
UpdatePersonRequest,
} from '@mento/shared';
import { FILTERED_IDS_MAX } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CompaniesService } from './companies.service';
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize';
import {
asAddresses,
asCustomFields,
toJsonAddresses,
toJsonCustomFields,
} from '../common/custom-fields';
import {
@@ -27,9 +37,21 @@ import {
import { LoyaltyService } from '../loyalty/loyalty.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
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 { selectMonthBirthdays } from './birthday-month';
import { renderSmsText } from '../common/sms-text';
import { normalizeTags } from '../common/tags';
import { buildSearchWhere, ci } from '../common/search';
import {
normalizePersonImportSource,
personImportSourceLabel,
} from './person-source';
const personInclude = {
company: { select: { id: true, name: true } },
company: { select: { id: true, name: true, province: true, city: true } },
owner: { select: { id: true, fullName: true } },
loyaltyTier: {
select: { id: true, name: true, minPoints: true, color: true },
@@ -40,17 +62,34 @@ type PersonRow = {
id: string;
firstName: string;
lastName: string;
previousFirstName: string | null;
previousLastName: string | null;
importSource: string;
sourceRef: string | null;
previousImportSource: string | null;
previousSourceRef: string | null;
email: string | null;
phone: string | null;
mobile: string | null;
position: string | null;
website: string | null;
instagram: string | null;
birthDate: Date | null;
referrer: string | null;
addresses: unknown;
notes: string | null;
mood: PersonMood;
tags: string[];
customFields: unknown;
loyaltyPoints: number;
createdAt: Date;
updatedAt: Date;
company: { id: string; name: string } | null;
company: {
id: string;
name: string;
province: string | null;
city: string | null;
} | null;
owner: { id: string; fullName: string } | null;
loyaltyTier: {
id: string;
@@ -61,39 +100,94 @@ type PersonRow = {
};
@Injectable()
export class PeopleService {
export class PeopleService implements OnModuleInit {
private readonly logger = new Logger(PeopleService.name);
constructor(
private readonly prisma: PrismaService,
private readonly companies: CompaniesService,
private readonly loyalty: LoyaltyService,
private readonly automations: AutomationsService,
private readonly messageAutomations: MessageAutomationsService,
private readonly webhooks: WebhooksService,
private readonly game: GamificationService,
private readonly sms: SmsService,
) {}
private personListWhere(
actor: Actor,
q?: string,
opts?: {
mood?: PersonMood;
importSource?: string;
province?: string;
city?: string;
hasMobile?: boolean;
},
): Prisma.PersonWhereInput {
const sourceFilter = opts?.importSource?.trim()
? normalizePersonImportSource(opts.importSource)
: undefined;
const search = buildSearchWhere<Prisma.PersonWhereInput>(q, (t) => [
{ firstName: ci(t) },
{ lastName: ci(t) },
{ previousFirstName: ci(t) },
{ previousLastName: ci(t) },
{ email: ci(t) },
{ mobile: { contains: t } },
{ phone: { contains: t } },
{ sourceRef: ci(t) },
{ previousSourceRef: ci(t) },
{ company: { name: ci(t) } },
{ company: { province: ci(t) } },
{ company: { city: ci(t) } },
]);
const province = opts?.province?.trim();
const city = opts?.city?.trim();
const and: Prisma.PersonWhereInput[] = [];
if (search) and.push(search);
if (province) {
and.push({
OR: [
{ company: { province: { contains: province, mode: 'insensitive' } } },
{ company: { city: { contains: province, mode: 'insensitive' } } },
],
});
}
if (city) {
and.push({
OR: [
{ company: { city: { contains: city, mode: 'insensitive' } } },
{ company: { province: { contains: city, mode: 'insensitive' } } },
],
});
}
if (opts?.hasMobile) {
and.push({ mobile: { not: null } }, { NOT: { mobile: '' } });
}
return {
organizationId: actor.organizationId,
...ownerWhere(actor),
...(opts?.mood ? { mood: opts.mood } : {}),
...(sourceFilter ? { importSource: sourceFilter } : {}),
...(and.length ? { AND: and } : {}),
};
}
async list(
actor: Actor,
q?: string,
page = 1,
limit = 20,
opts?: { mood?: PersonMood; sort?: 'name' | 'newest' },
opts?: {
mood?: PersonMood;
sort?: 'name' | 'newest';
importSource?: string;
province?: string;
city?: string;
hasMobile?: boolean;
},
): Promise<PaginatedResponse<PersonDto>> {
const where: Prisma.PersonWhereInput = {
organizationId: actor.organizationId,
...ownerWhere(actor),
...(opts?.mood ? { mood: opts.mood } : {}),
...(q?.trim()
? {
OR: [
{ firstName: { contains: q.trim(), mode: 'insensitive' } },
{ lastName: { contains: q.trim(), mode: 'insensitive' } },
{ email: { contains: q.trim(), mode: 'insensitive' } },
{ mobile: { contains: q.trim() } },
{ phone: { contains: q.trim() } },
{ company: { name: { contains: q.trim(), mode: 'insensitive' } } },
],
}
: {}),
};
const where = this.personListWhere(actor, q, opts);
const [rows, total] = await this.prisma.$transaction([
this.prisma.person.findMany({
@@ -117,6 +211,117 @@ export class PeopleService {
};
}
async listIds(
actor: Actor,
q?: string,
opts?: {
mood?: PersonMood;
importSource?: string;
province?: string;
city?: string;
hasMobile?: boolean;
},
max = FILTERED_IDS_MAX,
): Promise<FilteredIdsResponse> {
const where = this.personListWhere(actor, q, opts);
const total = await this.prisma.person.count({ where });
if (total > max) {
throw new BadRequestException(
`تعداد نتایج (${total}) از سقف مجاز بیشتر است؛ فیلتر را محدودتر کنید.`,
);
}
const rows = await this.prisma.person.findMany({
where,
select: { id: true },
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
take: max,
});
return { ids: rows.map((r) => r.id), total };
}
async sourceStats(actor: Actor): Promise<PersonImportSourceStatDto[]> {
const rows = await this.prisma.person.groupBy({
by: ['importSource'],
where: {
organizationId: actor.organizationId,
...ownerWhere(actor),
},
_count: { _all: true },
});
return rows
.map((row) => ({
source: row.importSource || 'manual',
label: personImportSourceLabel(row.importSource || 'manual'),
count: row._count._all,
}))
.sort((a, b) => b.count - a.count);
}
onModuleInit() {
void this.backfillStoredBirthDates();
}
/** تاریخ تولدی که قبلاً در فیلد اضافی دیدار/اکسل یا پروفایل مونتیفای مانده، روی شخص می‌نشیند. */
async backfillStoredBirthDates() {
try {
const fromFields = await this.prisma.$queryRaw<Array<{ id: string; birth: string }>>`
SELECT id,
COALESCE(
NULLIF("customFields"->>'birthDate', ''),
NULLIF("customFields"->>'birthDate2', '')
) AS birth
FROM "Person"
WHERE "birthDate" IS NULL
AND (
COALESCE("customFields"->>'birthDate', '') <> ''
OR COALESCE("customFields"->>'birthDate2', '') <> ''
)
LIMIT 20000
`;
const fromMontify = await this.prisma.$queryRaw<Array<{ id: string; birth: string }>>`
SELECT p.id, m.data->>'birthday' AS birth
FROM "MontifyLink" m
JOIN "Person" p ON p.id = m."entityId"
WHERE m."entityType" = 'customer'
AND p."birthDate" IS NULL
AND COALESCE(m.data->>'birthday', '') <> ''
LIMIT 20000
`;
const seen = new Set<string>();
let filled = 0;
for (const row of [...fromFields, ...fromMontify]) {
if (!row.birth || seen.has(row.id)) continue;
seen.add(row.id);
const birthDate = parseBirthDate(row.birth);
if (!birthDate) continue;
const updated = await this.prisma.person.updateMany({
where: { id: row.id, birthDate: null },
data: { birthDate },
});
filled += updated.count;
}
if (filled) this.logger.log(`تاریخ تولد ${filled} مشتری از داده‌های ذخیره‌شده پر شد`);
} catch (err) {
this.logger.warn(
`پر کردن تاریخ تولد ذخیره‌شده ناموفق بود: ${err instanceof Error ? err.message : err}`,
);
}
}
async fillBirthDateIfEmpty(
organizationId: string,
input: { mobile?: string | null; email?: string | null; birthDate: Date },
) {
const or: Prisma.PersonWhereInput[] = [];
if (input.mobile) or.push({ mobile: input.mobile });
if (input.email) or.push({ email: input.email });
if (!or.length) return;
await this.prisma.person.updateMany({
where: { organizationId, birthDate: null, OR: or },
data: { birthDate: input.birthDate },
});
}
async get(actor: Actor, id: string): Promise<PersonDto> {
const row = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
@@ -132,19 +337,33 @@ export class PeopleService {
await this.companies.ensureInOrg(actor.organizationId, companyId, actor);
}
const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null;
const data: Prisma.PersonUncheckedCreateInput = {
organizationId: actor.organizationId,
firstName: (dto.firstName ?? '').trim(),
lastName: (dto.lastName ?? '').trim(),
previousFirstName: emptyToNull(dto.previousFirstName),
previousLastName: emptyToNull(dto.previousLastName),
importSource: normalizePersonImportSource(dto.importSource),
sourceRef: emptyToNull(dto.sourceRef),
email: normalizeEmail(dto.email),
phone: normalizePhone(dto.phone),
mobile: normalizePhone(dto.mobile),
position: emptyToNull(dto.position),
website: emptyToNull(dto.website),
instagram: emptyToNull(dto.instagram),
birthDate: parseBirthDate(dto.birthDate),
referrer: emptyToNull(dto.referrer),
addresses: toJsonAddresses(dto.addresses),
notes: emptyToNull(dto.notes),
mood: dto.mood ?? 'newcomer',
tags: normalizeTags(dto.tags),
companyId,
ownerId: resolveOwnerId(actor, dto.ownerId),
customFields: toJsonCustomFields(dto.customFields),
...(registeredAt && !Number.isNaN(registeredAt.getTime())
? { createdAt: registeredAt }
: {}),
};
await this.assertNoDuplicate(actor.organizationId, data);
@@ -164,6 +383,24 @@ export class PeopleService {
};
await this.automations.runTrigger('person_created', createdCtx);
await this.messageAutomations.onEvent('person_created', createdCtx);
if (row.importSource === 'manual') {
this.game.emit({
organizationId: actor.organizationId,
userId: actor.userId,
event: 'CUSTOMER_CREATED',
entityType: 'person',
entityId: row.id,
entityTitle: createdCtx.title,
facts: { source: row.importSource },
});
}
this.webhooks.emit(actor.organizationId, 'person.created', {
id: row.id,
fullName: createdCtx.title,
mobile: row.mobile,
email: row.email,
companyId: row.companyId,
});
const fresh = await this.prisma.person.findFirst({
where: { id: row.id },
include: personInclude,
@@ -181,12 +418,28 @@ export class PeopleService {
const data: Prisma.PersonUncheckedUpdateInput = {};
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim();
if (dto.lastName !== undefined) data.lastName = dto.lastName.trim();
if ('previousFirstName' in dto) {
data.previousFirstName = emptyToNull(dto.previousFirstName);
}
if ('previousLastName' in dto) {
data.previousLastName = emptyToNull(dto.previousLastName);
}
if (dto.importSource !== undefined) {
data.importSource = normalizePersonImportSource(dto.importSource);
}
if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef);
if ('email' in dto) data.email = normalizeEmail(dto.email);
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile);
if ('position' in dto) data.position = emptyToNull(dto.position);
if ('website' in dto) data.website = emptyToNull(dto.website);
if ('instagram' in dto) data.instagram = emptyToNull(dto.instagram);
if ('birthDate' in dto) data.birthDate = parseBirthDate(dto.birthDate);
if ('referrer' in dto) data.referrer = emptyToNull(dto.referrer);
if ('addresses' in dto) data.addresses = toJsonAddresses(dto.addresses);
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
if (dto.mood !== undefined) data.mood = dto.mood;
if ('tags' in dto) data.tags = normalizeTags(dto.tags);
if ('customFields' in dto) {
data.customFields = toJsonCustomFields(dto.customFields);
}
@@ -211,6 +464,13 @@ export class PeopleService {
data,
include: personInclude,
});
this.webhooks.emit(actor.organizationId, 'person.updated', {
id: row.id,
fullName: `${row.firstName} ${row.lastName}`.trim(),
mobile: row.mobile,
email: row.email,
companyId: row.companyId,
});
return this.toDto(row);
}
@@ -219,6 +479,19 @@ export class PeopleService {
await this.prisma.person.delete({ where: { id } });
}
async removeMany(actor: Actor, ids: string[]): Promise<{ deleted: number }> {
const unique = [...new Set(ids.filter(Boolean))];
if (!unique.length) return { deleted: 0 };
const res = await this.prisma.person.deleteMany({
where: {
id: { in: unique },
organizationId: actor.organizationId,
...ownerWhere(actor),
},
});
return { deleted: res.count };
}
private async ensureExists(actor: Actor, id: string) {
const found = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
@@ -258,18 +531,180 @@ export class PeopleService {
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,
company: { select: { name: 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 companyById = new Map(rows.map((row) => [row.id, row.company?.name ?? '']));
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,
company: companyById.get(row.id) ?? '',
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,
company: { select: { name: 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 fullName = `${person.firstName} ${person.lastName}`.trim();
const message = renderSmsText(body, {
name: fullName,
phone: mobile,
title: fullName,
company: person.company?.name ?? '',
});
if (!message) throw new BadRequestException('متن پیامک خالی است.');
const result = await this.sms.sendSmsDetailed(mobile, message);
if (!result.ok) {
throw new ServiceUnavailableException(
result.reason === 'SMS not configured'
? 'ارسال پیامک روی سرور فعال نیست.'
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
);
}
return { ok: true };
}
async sendSms(actor: Actor, personId: string, text: string) {
const body = text.trim();
if (!body || body.length > 500) {
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.');
}
const person = await this.get(actor, personId);
const mobile = person.mobile || person.phone;
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
const message = renderSmsText(body, {
name: person.fullName,
phone: mobile,
title: person.fullName,
company: person.company?.name ?? '',
});
if (!message) throw new BadRequestException('متن پیامک خالی است.');
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';
return {
id: row.id,
firstName: row.firstName,
lastName: row.lastName,
previousFirstName: row.previousFirstName,
previousLastName: row.previousLastName,
previousFullName: previousFullName || null,
importSource,
sourceRef: row.sourceRef,
previousImportSource: row.previousImportSource,
previousSourceRef: row.previousSourceRef,
fullName: `${row.firstName} ${row.lastName}`.trim(),
email: row.email,
phone: row.phone,
mobile: row.mobile,
position: row.position,
website: row.website,
instagram: row.instagram,
birthDate: row.birthDate?.toISOString() ?? null,
referrer: row.referrer,
addresses: (() => {
const fromCol = asAddresses(row.addresses);
if (fromCol.length) return fromCol;
const extra = asCustomFields(row.customFields);
return asAddresses(
[extra.address, extra.homeAddress, extra.address2]
.map((v) => (typeof v === 'string' ? v : ''))
.filter(Boolean),
);
})(),
notes: row.notes,
mood: row.mood,
tags: row.tags ?? [],
customFields: asCustomFields(row.customFields),
loyaltyPoints: row.loyaltyPoints ?? 0,
loyaltyTier: row.loyaltyTier,
+80
View File
@@ -0,0 +1,80 @@
import {
PERSON_IMPORT_SOURCE_LABELS,
PERSON_IMPORT_SOURCES,
type PersonImportSource,
} from '@mento/shared';
export function normalizePersonImportSource(
raw?: string | null,
): PersonImportSource {
const v = String(raw ?? '')
.trim()
.toLowerCase();
if (!v) return 'manual';
if ((PERSON_IMPORT_SOURCES as readonly string[]).includes(v)) {
return v as PersonImportSource;
}
if (/دیدار|didar/.test(v)) return 'didar';
if (/ته.?حساب|tahesab|accounting/.test(v)) return 'tahesab';
if (/اکسل|excel|xlsx|csv/.test(v)) return 'excel';
if (/گوشی|phone|مخاطب|vcard|contact/.test(v)) return 'phone';
if (/لید|lead|فرم/.test(v)) return 'lead_form';
return 'other';
}
export function personImportSourceLabel(source: string): string {
if (source in PERSON_IMPORT_SOURCE_LABELS) {
return PERSON_IMPORT_SOURCE_LABELS[source as PersonImportSource];
}
return source || PERSON_IMPORT_SOURCE_LABELS.manual;
}
type SourceSnapshot = {
importSource?: string | null;
sourceRef?: string | null;
previousImportSource?: string | null;
previousSourceRef?: string | null;
customFields?: unknown;
};
function didarIdFrom(customFields: unknown): string | null {
if (!customFields || typeof customFields !== 'object') return null;
const raw = (customFields as Record<string, unknown>).didarId;
if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw);
if (typeof raw === 'string' && raw.trim()) return raw.trim();
return null;
}
/**
* وقتی شخصِ موجود از منبع دیگری دوباره وارد می‌شود، منبع و کد فعلی را به‌عنوان «قبلی» نگه می‌دارد.
* اگر منبع یکسان باشد (ورود مجدد از همان منبع)، مقادیر قبلی دست نمی‌خورند.
*/
export function carryPreviousSource(
existing: SourceSnapshot,
nextSource: PersonImportSource,
): { previousImportSource?: string | null; previousSourceRef?: string | null } {
const currentSource = normalizePersonImportSource(existing.importSource);
if (currentSource === nextSource) return {};
let ref = existing.sourceRef?.trim() || null;
let source: string = currentSource;
if (!ref) {
const didarId = didarIdFrom(existing.customFields);
if (didarId && nextSource !== 'didar') {
ref = didarId;
source = 'didar';
}
}
if (source === 'manual' && !ref) return {};
return { previousImportSource: source, previousSourceRef: ref };
}
export function namesDiffer(
aFirst: string,
aLast: string,
bFirst: string,
bLast: string,
): boolean {
const a = `${aFirst} ${aLast}`.trim().replace(/\s+/g, ' ');
const b = `${bFirst} ${bLast}`.trim().replace(/\s+/g, ' ');
return Boolean(a && b && a !== b);
}
@@ -0,0 +1,208 @@
import {
BadRequestException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
export type ExternalRoomRow = Record<string, unknown>;
const FETCH_TIMEOUT_MS = 12_000;
@Injectable()
export class ChatExternalClient {
normalizeHost(host: string): string {
return host
.trim()
.replace(/^wss?:\/\//, '')
.replace(/^https?:\/\//, '')
.replace(/\/+$/, '');
}
private async fetchJson(
url: string,
init: RequestInit,
): Promise<{ ok: boolean; status: number; bodyText: string; json: unknown }> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, { ...init, signal: controller.signal });
const bodyText = await response.text().catch(() => '');
let json: unknown = null;
if (bodyText) {
try {
json = JSON.parse(bodyText);
} catch {
json = null;
}
}
return { ok: response.ok, status: response.status, bodyText, json };
} catch (e) {
const aborted = e instanceof Error && e.name === 'AbortError';
throw new ServiceUnavailableException(
aborted
? `سرویس گفتگو پاسخ نداد (timeout ${FETCH_TIMEOUT_MS / 1000}s). Host و شبکه سرور را بررسی کنید.`
: `اتصال به سرویس گفتگو برقرار نشد: ${e instanceof Error ? e.message : 'unknown'}`,
);
} finally {
clearTimeout(timer);
}
}
private fail(status: number, body: string, action: string): never {
const snippet = body.trim().slice(0, 220);
throw new ServiceUnavailableException(
`${action} ناموفق بود (HTTP ${status})${snippet ? `: ${snippet}` : ''}`,
);
}
async createRoom(
host: string,
apiKey: string,
payload: {
name: string;
type: string;
members: string[];
},
): Promise<{ room_id: string }> {
const base = this.normalizeHost(host);
if (!base) throw new BadRequestException('Host سرویس گفتگو خالی است');
const url = `https://${base}/api/rooms/create`;
const { ok, status, bodyText, json } = await this.fetchJson(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
api_key: apiKey,
name: payload.name,
type: payload.type,
members: payload.members,
}),
});
if (!ok) this.fail(status, bodyText, 'ایجاد اتاق گفتگو');
const data = (json ?? {}) as { room_id?: string; id?: string };
const roomId = String(data.room_id ?? data.id ?? '').trim();
if (!roomId) {
throw new ServiceUnavailableException('پاسخ سرویس گفتگو فاقد room_id است');
}
return { room_id: roomId };
}
async listUserRooms(
host: string,
apiKey: string,
chatUserId: string,
): Promise<ExternalRoomRow[]> {
const base = this.normalizeHost(host);
if (!base) throw new BadRequestException('Host سرویس گفتگو خالی است');
const params = new URLSearchParams({
api_key: apiKey,
user_id: chatUserId,
});
const url = `https://${base}/api/users/rooms?${params.toString()}`;
const { ok, status, bodyText, json } = await this.fetchJson(url, {
method: 'GET',
headers: { Accept: 'application/json' },
cache: 'no-store',
});
// کاربر جدید بدون room — خالی برگردان
if (status === 404 || status === 204) return [];
if (!ok) this.fail(status, bodyText, 'دریافت لیست گفتگو از سرویس چت');
const result = json;
if (Array.isArray(result)) return result as ExternalRoomRow[];
if (result && typeof result === 'object') {
const obj = result as Record<string, unknown>;
if (Array.isArray(obj.data)) return obj.data as ExternalRoomRow[];
if (Array.isArray(obj.rooms)) return obj.rooms as ExternalRoomRow[];
if (Array.isArray(obj.items)) return obj.items as ExternalRoomRow[];
}
return [];
}
async ping(host: string, apiKey: string, chatUserId: string): Promise<boolean> {
try {
await this.listUserRooms(host, apiKey, chatUserId);
return true;
} catch {
return false;
}
}
/** تلاش برای افزودن عضو به اتاق روی سرویس خارجی (endpointهای رایج). */
async addRoomMembers(
host: string,
apiKey: string,
roomId: string,
members: string[],
): Promise<boolean> {
const base = this.normalizeHost(host);
if (!base || !roomId || !members.length) return false;
const attempts: Array<{ path: string; body: Record<string, unknown> }> = [
{
path: '/api/rooms/add_members',
body: { api_key: apiKey, room_id: roomId, members },
},
{
path: '/api/rooms/members/add',
body: { api_key: apiKey, room_id: roomId, members },
},
{
path: '/api/rooms/members',
body: { api_key: apiKey, room_id: roomId, members, action: 'add' },
},
];
for (const attempt of attempts) {
try {
const { ok, status } = await this.fetchJson(`https://${base}${attempt.path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(attempt.body),
});
if (ok || status === 204) return true;
if (status === 404 || status === 405) continue;
} catch {
// try next
}
}
return false;
}
async removeRoomMembers(
host: string,
apiKey: string,
roomId: string,
members: string[],
): Promise<boolean> {
const base = this.normalizeHost(host);
if (!base || !roomId || !members.length) return false;
const attempts: Array<{ path: string; body: Record<string, unknown> }> = [
{
path: '/api/rooms/remove_members',
body: { api_key: apiKey, room_id: roomId, members },
},
{
path: '/api/rooms/members/remove',
body: { api_key: apiKey, room_id: roomId, members },
},
{
path: '/api/rooms/members',
body: { api_key: apiKey, room_id: roomId, members, action: 'remove' },
},
];
for (const attempt of attempts) {
try {
const { ok, status } = await this.fetchJson(`https://${base}${attempt.path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(attempt.body),
});
if (ok || status === 204) return true;
if (status === 404 || status === 405) continue;
} catch {
// try next
}
}
return false;
}
}
@@ -0,0 +1,10 @@
/** شناسه پایدار کاربر در سرویس چت خارجی (scoped به org) */
export function buildChatUserId(organizationId: string, userId: string): string {
const o = organizationId.replace(/-/g, '').slice(0, 12);
const u = userId.replace(/-/g, '').slice(0, 12);
return `m${o}u${u}`;
}
export function dmPairKey(userA: string, userB: string): [string, string] {
return userA < userB ? [userA, userB] : [userB, userA];
}

Some files were not shown because too many files have changed in this diff Show More