126 Commits
Author SHA1 Message Date
Mahdi 0998a1ec37 fix(web): serve real dashboard data instead of mocks
Drop forced home/treasury/reports mock fallbacks so API and database content drive the UI.
2026-11-07 09:04:38 +03:30
Mahdi 4979aa8b75 feat(web): refresh Montact brand and sidebar chrome
Ship the new logo assets, set primary to #10897d, and polish sidebar/home-hero UX around roles, org switcher, and the ask bar.
2026-11-05 17:10:03 +03:30
MahdiandCursor 34d3689448 chore(web): keep the raw vuesax icon dump for regenerating the pack
Track icons (1).json so scripts/_setup-vuesax.mjs can rebuild apps/web/src/icons/vuesax-linear.json.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 15:34:34 +03:30
MahdiandCursor 1e0b638a19 fix(web): tidy the notification panel with status icons
Keep cards neutral, show type through icon and color only, and hide the panel scrollbar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 15:29:40 +03:30
Mahdi 3db9c1c0fe merge: bring latest origin/main into dev (design local, logic main)
Keep the local Ant screens and icon pack. Take treasury parties, routine self-service, Tahesab auto-sync, public lead registration, and contact merge from main.
2026-11-05 15:10:48 +03:30
MahdiandCursor 6c6e32e099 feat(web): adopt vuesax icons and shared skeleton loading
Replace the inline SVG icon set with the vuesax linear pack, add skeleton primitives for loading states, and keep page scroll inside the app shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 14:46:35 +03:30
MahdiandCursor 1ef7f91771 fix(contacts): keep one customer when Tahesab repeats a mobile
A Tahesab import now updates the person who already has that mobile instead of creating another, and the boot merge runs once more so existing pairs collapse.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 13:34:41 +03:30
MahdiandCursor 94f0383c79 feat(tahesab): import and refresh customers at chosen hours
Each connection can set how many times a day and at which Tehran hours the server pulls Tahesab customers, including name updates for people already linked.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 12:31:27 +03:30
MahdiandCursor c976c0bcd6 fix(contacts): merge the same mobile stored on phone or mobile
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 11:27:05 +03:30
MahdiandCursor 57a0b379f6 fix(tahesab): store a matched mobile as 09xxxxxxxxx
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 11:11:40 +03:30
MahdiandCursor 7f6d9a84d6 fix(contacts): treat Iranian mobile formats as one person
Tahesab import still replaces the name and keeps the previous name.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 10:51:36 +03:30
MahdiandCursor 6abbf8e8fd fix(sms): keep the charge dialog fully on screen
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 21:52:35 +03:30
MahdiandCursor 39cb5c2548 feat(public-api): let external sites register a lead with their key
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 21:34:07 +03:30
MahdiandCursor 004d1dff93 feat(boards): ease dragging a card toward an off-screen spot
Scroll the column while a card is held near its edge and show a light gap where the card will land.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 20:05:50 +03:30
MahdiandCursor 78a7d6a68a feat(routines): let each person create a routine for themselves
Open the new-routine action to every member, while keeping assignment to other people and teams limited to managers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 19:34:36 +03:30
MahdiandCursor 5b18749005 feat(treasury): list debtors and creditors on the finance report
Show open receivable and payable balances on separate report tabs, and include each list in an Excel export.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 14:16:38 +03:30
MahdiandCursor 08a278e1c8 feat(treasury): number documents and manage categories on their tab
Save a system tracking number on each income and expense, reuse the full contact dialogs for a new party, and create, rename, or delete categories only from the categories tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 13:11:09 +03:30
MahdiandCursor d71c2dbef4 feat(treasury): show who still owes on the finance pages
Partial receipts and payments stay tied to a customer, company, or user, and the open balance is listed on the income and expense screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 11:36:55 +03:30
MahdiandCursor 41f310f3a2 merge: bring latest origin/main into dev (design local, logic main)
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:26:43 +03:30
MahdiandCursor 754cc43c98 fix(web): drop unused viewingSelf after subtitle removal
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:05:29 +03:30
MahdiandCursor a319085dca fix(web): clear vue-tsc errors blocking push
Type attendance rows, remove unused symbols, and harden list item typing after the Ant settings migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:05:29 +03:30
MahdiandCursor cb3a488b21 feat(web): continue Ant settings migration and tighten page chrome
Migrate remaining settings screens to Ant Design Vue, densify teams/roles UI, drop page-header subtitles project-wide, and polish notes cards plus contacts row actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:05:28 +03:30
MahdiandCursor 1ccbefa7a3 fix(treasury): scope finance access and hide it when the module is off
Accounts can be edited, including the opening balance, and finance sections and accounts can be granted on a role or on one user. The finance menu stays hidden when the organization module is disabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:02:31 +03:30
MahdiandCursor 14f4e9d7d8 fix(activities): put the priority flag on the date row and recolor medium and low
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 21:32:49 +03:30
MahdiandCursor 52edccf74e fix(boards): keep card order inside a column and show activity priority
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 21:22:30 +03:30
MahdiandCursor b11affbdfd fix(web): load IRANSans outside the Tailwind pipeline
Tailwind rewrites url() values that contain parentheses, so the FaNum font files never resolve.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 18:32:30 +03:30
MahdiandCursor bffe174162 fix(activities): show form photos in the note instead of the raw file link
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 17:30:45 +03:30
MahdiandCursor 35d74d33ed fix(activities): keep lead activity actions in the detail card
The action row stretched off-screen and comments on automation activities were rejected once the saved note passed 2000 characters.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 17:04:59 +03:30
Mahdi 7aaa15fad2 merge: bring latest origin/main into dev (keep local UI)
Resolved UI conflicts in favor of local Ant Design screens; kept non-UI changes from main.
2026-11-03 16:55:42 +03:30
Mahdi 39c62834e7 fix: resolve Ant switch and unused board menu type errors blocking push 2026-11-03 16:53:49 +03:30
Mahdi 26792f9a37 feat: migrate CRM screens to Ant Design Vue and expand home dashboard
Preserve the local Ant UI work across views/components and add the new home dashboard chart panels before merging latest main.
2026-11-03 16:51:51 +03:30
MahdiandCursor c337b0b4c7 fix(leads): keep a future follow-up as an activity on the lead
A scheduled follow-up only stored a date, and activities opened from a lead were not linked back, so they disappeared from the lead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 16:19:32 +03:30
MahdiandCursor c26353624e fix(leads): keep Persian digits when saving contact-form phones
Mixed Persian and Latin digits were stripped down to the Latin part, so some website leads stored a truncated mobile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 15:50:05 +03:30
MahdiandCursor 534dae45b2 feat(roles): let each section decide who may assign work to others
Section access now only creates a record for yourself. Assigning it to a colleague requires a separate permission on that section.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 13:15:21 +03:30
MahdiandCursor 79733e6c96 feat(tahesab): sync filtered customers and pick Jalali periods
Document refresh can follow the selected group or person instead of every linked customer, and the sales report picks a named month or season.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 10:09:40 +03:30
MahdiandCursor fdabdc54a3 feat(billing): default the 7-day demo to 3 users
Applying or creating a trial plan sets the owner seat cap to 3, and existing demos without a cap get the same default.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-02 19:55:51 +03:30
MahdiandCursor 1b10ee30df fix(web): keep the sidebar org name inside the footer
Long organization names wrap beside the logout button instead of spilling out of the sidebar card.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-02 19:43:47 +03:30
MahdiandCursor c273dd6c58 feat(adm): split chat and billing settings and add a master login
Super admin can filter dashboard counts, edit or delete admins, and sign into a user's CRM account with one shared password.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-02 19:13:57 +03:30
MahdiandCursor acb5d51f24 feat(billing): cap active users on an owner subscription
Super admin can set a seat limit of 3, 10, 20, or a negotiated number so an owner cannot add users past that cap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-02 18:33:51 +03:30
MahdiandCursor 06977d27ed feat(activities): attach voice and files on activity notes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-02 16:34:03 +03:30
MahdiandCursor 43437b0280 fix(test): pass the storage quota service into activity tests
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-02 15:47:32 +03:30
MahdiandCursor 28938e8f77 feat(billing): cap each owner's stored file volume
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-02 15:44:36 +03:30
MahdiandCursor e6e90aabf3 feat(activities): step through the same desk list from a detail
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-02 14:30:17 +03:30
MahdiandCursor a0936bf19f feat(web): assign an activity to a whole team
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-02 10:50:08 +03:30
MahdiandCursor 18a7cd3b5a fix(web): rename the activities nav item
The sidebar says فعالیت‌ها, and the phone tab stays فعالیت so the five-item bar still fits.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-01 22:45:34 +03:30
MahdiandCursor 4af248b06a feat(activities): filter the desk from each column
Assignees are searched instead of listed in full, and start time only filters whether a time is set.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-01 21:20:42 +03:30
MahdiandCursor d5dd13863a feat(web): add next Saturday and next week to postpone
Saturday is the start of the work week, so the sheet can defer a task to next Saturday morning or the same time one week later.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-01 21:20:15 +03:30
MahdiandCursor 81edf61065 feat(notes): let organizations edit the note category bar
The filter chips were fixed labels. Owners can rename, hide, reorder, and add categories.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-01 16:34:34 +03:30
MahdiandCursor 8e1e94a439 feat(notes): let organizations define their own note fields
The note form was fixed. Owners can add fields and each note keeps those values.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-01 15:38:05 +03:30
MahdiandCursor 590f8114cc fix(sms): send form confirmations to the lead number
Lead-only website forms had no person mobile, so the confirmation was marked sent without a number. Use the lead phone and send the full text.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-31 16:41:41 +03:30
MahdiandCursor 8d01bce0c2 fix(web): keep the activity desk usable on a phone
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-31 15:05:05 +03:30
MahdiandCursor 8b2777be4e fix(web): pack the activity desk header so the columns stay visible
The toolbar keeps every control, just on two shorter rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-31 13:57:01 +03:30
MahdiandCursor d7b5085096 feat(leads): keep gender on leads and form submissions
A lead can carry the same honorific as a person, and a form that asks gender writes it onto the lead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-31 12:49:42 +03:30
MahdiandCursor cbc2e4ee8a fix(web): keep the Jalali picker above Ant modals
The date and time panel was opening behind the new-task dialog because its layer sat under the modal.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-31 10:16:02 +03:30
MahdiandCursor a17c7dbabc fix(routines): keep the daily desk for internal team work
Customer-linked tasks stay on their record and no longer appear in today, calendar, score, or reminders.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 20:53:26 +03:30
MahdiandCursor 99d686fefb fix(leads): give a new form lead the same owner as its first call
When a form has no round-robin assignees, the lead now follows the person the automation assigns to the first call.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 19:30:13 +03:30
MahdiandCursor b9bb2ff9fa feat(contacts): keep Tahesab honorifics with person gender
Male and female names can store the title when requested, and Tahesab import and export use the same gender.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 18:46:10 +03:30
MahdiandCursor 0d5e1034e5 feat(leads): edit lead-board columns from the board
New-lead counts follow the first open column instead of its name, and cards no longer show an initials mark.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 17:23:40 +03:30
MahdiandCursor 6f42440b3b fix(activities): return to the same group after opening a card
The back link dropped the board from the address, so the desk reopened on the default group.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 09:22:23 +03:30
MahdiandCursor 63a1803adc feat(activities): let a group show and limit its members
Empty membership stays open to the whole workspace; picking people restricts who can see that activity group.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 20:24:40 +03:30
MahdiandCursor 2cc2d358e4 fix(web): make kanban column titles readable
Widen board columns and keep counts inside the add button so stage and card titles have room.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 17:24:58 +03:30
MahdiandCursor 463a1652a6 feat(platform): text admins when an account opens or a subscription ends
Signup and admin-created accounts notify platform managers, and an ended plan sends one SMS instead of repeating for old expirations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 22:27:05 +03:30
MahdiandCursor f15c5b9141 feat(activities): add photos when registering a follow-up comment
A later comment can attach photos with the same compressor, and they stay on the activity note.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 21:52:37 +03:30
MahdiandCursor 1d3588a69d feat(activities): attach compressed photos to activity notes
Photos on an activity are shrunk with the form-builder compressor and stored only when they fit under 200KB.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 21:30:45 +03:30
MahdiandCursor 9812e4e5ba fix(crm): honor open data scopes and role assignees
Unset sections on a saved role policy were still filtered as own-only, so full lead access showed nothing. Activities can now be assigned by org role, and the Tahesab sales report reads as a result instead of a tag wall.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 18:07:42 +03:30
MahdiandCursor 8ae6a928f4 fix(web): type the customer group parent picker
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 11:14:30 +03:30
MahdiandCursor 2ff35e55fc feat(tahesab): net sales by factor, group, and commission
Report weight after returns for the selected period, with parent customer groups and separate silver and investment commission.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 11:05:34 +03:30
Mahdi 15f89aec04 Readjusting the dashboard header height 2026-10-25 20:53:06 +03:30
Mahdi bebd1be29b Adjusting the lead note and dashboard header height 2026-10-25 20:21:33 +03:30
MahdiandCursor 0daaef0b39 fix(web): calm hero flash, lead detail, and scope sources
Keep the dashboard backdrop out of flow on first paint, clamp long lead notes, scroll the quick-actions rail, return to the lead board, and only list import sources that exist in this environment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-25 19:22:30 +03:30
MahdiandCursor 91637cb3a6 fix(web): search and multi-select data scope filters
Long group and source lists overflowed the team settings panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-25 10:48:15 +03:30
Mahdi 546a7a7e46 fix(web): keep jalali day cells inside calendar panel 2026-10-25 10:05:16 +03:30
Mahdi 48815852fc fix(crm): mesghal by metal, jalali fit, scoped group filters 2026-10-24 22:25:27 +03:30
Mahdi f8128ebe3a fix(tahesab): rename invoice timestamp label to زمان چاپ 2026-10-24 21:54:55 +03:30
MahdiandCursor 6ec7a1a942 fix(tahesab): label invoice timestamp as print date
Rename تاریخ ایجاد to تاریخ چاپ on the asnad invoice preview and PDF.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-24 21:51:32 +03:30
MahdiandCursor 2d690b4c04 feat(tahesab): polish asnad invoice PDF and prior balance
Compute حساب قبل from asnad before this doc date/number, add in-app preview layout, and leave a blank mesghal line for handwriting after print.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-24 21:47:46 +03:30
Mahdi 1ba352f127 fix(tahesab): open invoice PDF window synchronously on click
Awaiting settings before window.open left a blank tab; open about:blank on click then fill via blob URL.
2026-10-24 14:09:46 +03:30
Mahdi f50246dc46 feat(tahesab): PDF invoice export for asnad cards
Add per-document PDF print from account details, plus org invoice settings for logo and texts separate from CRM invoices.
2026-10-24 12:34:02 +03:30
Mahdi 7d9fc8447f fix(reports): show Tahesab sales link only when connection exists
Require both the tahesab org module and at least one enabled accounting connection.
2026-10-24 09:39:45 +03:30
Mahdi 5be0bc6f5e fix(tahesab): register Ant components on sales report page
Without explicit imports the a-button tags rendered as raw custom elements with no styles or click behavior.
2026-10-22 20:35:21 +03:30
Mahdi 2ca2650b69 fix(tahesab): make sales report apply and sync actions fire
Bypass Ant form required validation that blocked the apply button, and surface API errors with toasts.
2026-10-22 20:04:59 +03:30
Mahdi 42b3fc72cc fix(tahesab): type sales report statistic value for vue-tsc 2026-10-22 19:52:11 +03:30
Mahdi 6d8ede8e2a fix(tahesab): polish sales report page chrome
Match leads/invoices header layout, keep title clean, and add a clear apply-filters action for the date range.
2026-10-22 19:47:58 +03:30
Mahdi ade43fea0f feat(tahesab): sales report snapshot with org-module gate
Sync linked asnad into CRM, filter by group/kind/metal, and require the tahesab superadmin module plus reports role access.
2026-10-22 18:20:46 +03:30
Mahdi 65cf4e9a66 feat(auth): allow owner to force OTP-only login per user
Team menu «ورود با کد» blocks password login with a clear message and switches the login screen to OTP.
2026-10-22 15:43:24 +03:30
Mahdi 80fb5bf1bb feat(lead-forms): real file upload under 200KB and Jalali date
Public form file fields compress images or reject oversized files; dates use the full Jalali picker.
2026-10-22 14:28:42 +03:30
Mahdi 31f45a6a31 fix(acl): simplify data access UI to a single filter
Drop multi-rule OR/AND editing; one all-or-filter form per section with clear hints.
2026-10-22 13:13:59 +03:30
Mahdi 73c87b8be4 fix(acl): keep empty draft rules and clarify OR/AND hint
Empty rules were stripped on re-parse so Add rule looked broken; editor now keeps drafts until filled.
2026-10-22 12:18:05 +03:30
Mahdi 09a60b3b5a feat(acl): deep data access by section for roles and users
Role default + user override with OR rules; covers customers, leads, sales, activities, notes, routines, projects, and tickets.
2026-10-22 11:53:26 +03:30
MahdiandCursor cb5944e767 fix(finance): shared treasury layout so tab switches stop jumping
Keep title and nav mounted in TreasuryLayout with stable header height, and finish Ant form chrome (MoneyInput, Jalali) on finance pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 21:47:00 +03:30
Mahdi 23ae273232 feat(contacts): compact layout, mobile filter sheet and redesigned cards 2026-10-21 20:01:16 +03:30
Mahdi 1c2da34983 fix: persist and re-apply contact saved filters correctly 2026-10-21 18:46:12 +03:30
Mahdi 50d7cad66e feat: multi-select contacts filter panel for groups sources and mood 2026-10-21 18:07:21 +03:30
Mahdi 29d99cbded feat: customer groups for people and companies with tahesab group sync and bulk register
Multiple groups per contact, legacy group text migrated, excel import/export, contacts filter and column, richer create-in-tahesab, match-first bulk export
2026-10-21 17:16:31 +03:30
Mahdi ce27df20ca feat: compact logout-time cell and optional login block after logout time
Owners set the daily logout time in a modal and can block re-login until the next Tehran day.
2026-10-21 14:20:15 +03:30
Mahdi 7ae6a5837a feat: enforce role section permissions and add a leads permission
Unticking a section hides it from the menu, closes its routes and blocks its writes on the server; existing roles keep their current access via migration.
2026-10-21 13:45:16 +03:30
Mahdi 631a3267d1 fix: accept createIfMissing when converting a lead to a customer 2026-10-20 21:07:39 +03:30
Mahdi b731fe43aa fix: fold the activity board picker and its actions into one dropdown 2026-10-20 17:59:34 +03:30
Mahdi 15cbd62997 test: update promote-from-prior stage service mock for activity boards 2026-10-20 17:36:30 +03:30
Mahdi e6aa4c64f0 feat: group activities into boards with their own stages, like sales pipelines
Existing stages and activities move into a default board per environment; new activities pick a board in the form.
2026-10-20 17:23:27 +03:30
Mahdi 9127fa334b fix: stop birth dates from saving one day early in Tehran time 2026-10-20 15:20:47 +03:30
Mahdi 9a86f63f48 feat: let the Jalali date picker jump to any month or year from its title 2026-10-20 14:47:25 +03:30
Mahdi 29398a445b fix: validate activity, deal and lead assignees by active membership in the current environment 2026-10-20 14:47:08 +03:30
Mahdi bb8fd489de fix: let owners and admins change a user's role from the edit-user modal 2026-10-20 12:50:32 +03:30
Mahdi 86e9ee6c2c fix: treat Tahesab no-records as an empty customer list instead of a 502
Listing all customers no longer fails on the final empty code range, and importing a missing code no longer creates a placeholder person.
2026-10-20 12:26:20 +03:30
Mahdi adb63447ca fix: stop Tahesab imports from stealing links between shared-mobile customers
List all Tahesab customers in one paginated table with a page-size picker instead of 2000-code pages.
2026-10-20 12:00:11 +03:30
Mahdi c1b38e1737 feat: filter Tahesab sync results to customers not yet imported 2026-10-20 11:07:58 +03:30
Mahdi be7ae0a1d8 fix: keep the SMS credit chip pastel green when the balance is healthy 2026-10-20 10:25:38 +03:30
Mahdi bbbedc6059 fix: polish SMS header chip and add deposit info in adm panel
Tighten the wallet chip layout and let super-admins set charge deposit details shown in the header charge modal.
2026-10-19 21:47:17 +03:30
Mahdi f5d68d83fc feat: add per-org SMS wallet with adm panel and header credit
Bill all platform SMS (including OTP), allow negative balance with manual/auto cutoffs, and support own-panel bypass without wallet debit.
2026-10-19 15:55:48 +03:30
Mahdi f15ebb72ea fix: let only the owner set logout time and move the attendance tab 2026-10-19 13:54:09 +03:30
Mahdi 8590579605 feat: log staff sign-in and sign-out and force logout at a set time 2026-10-19 13:34:55 +03:30
Mahdi 3863c5e7d1 fix: hide role permissions for modules disabled in super admin 2026-10-19 12:20:52 +03:30
Mahdi e6f9876177 fix: keep the customers menu visible when the owner views as another user 2026-10-19 11:56:05 +03:30
MahdiandCursor fddf22d7fe fix: open the card menu from the three-dot button
Anchor the activity and deal menus to the button so they show beside the card.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 11:40:25 +03:30
MahdiandCursor ada9a1f21f fix: show activity board columns again
Give the kanban the remaining page height so the columns are no longer collapsed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 11:18:19 +03:30
MahdiandCursor 2f11105628 fix: keep customer profile tabs from overlapping on mobile
Let the tab row scroll sideways so long labels stay readable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 09:40:59 +03:30
MahdiandCursor 36eda8653e merge: bring Ant Design UI from dev into main
Keep the design refresh together with DISC, the sales copilot, and treasury behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 21:33:56 +03:30
MahdiandCursor 8fdd5147a5 feat(web): migrate finance treasury pages to Ant Design Vue
Replace legacy Ui* chrome on treasury routes with Ant layout, and polish mobile incomes stats colors plus nav visibility.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 21:06:18 +03:30
MahdiandCursor 002f697dfe feat: add DISC profiles and a sales copilot as an org module
Help reps capture a customer's likely behavior style and get negotiation guidance, with the feature gated from super admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 21:04:26 +03:30
MahdiandCursor 96befd3867 feat: add treasury audit, attachment download, and installment SMS
Let cheque clearance pick a cash account and export income, expense, and installment lists to Excel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 19:59:24 +03:30
MahdiandCursor b25b5b125a fix(web): resolve vue-tsc errors blocking push of Ant migration
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 19:48:16 +03:30
MahdiandCursor 4abab55a6b feat(web): migrate CRM screens to Ant Design Vue with central theme
Adopt ant-design-vue and a shared antdTheme for leads, pipeline, invoices, and shared form controls under strict single-page governance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 19:39:59 +03:30
MahdiandCursor 8981a552fc feat: gate treasury as an org module and group money inputs
Link optional invoice and cheque settlements to cash accounts, and let superadmin enable the finance module per organization.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 19:33:09 +03:30
MahdiandCursor ab538b8304 feat: add internal finance ledger for accounts, claims, and payroll
Keep booked income and expense separate from cash balances, with installments, recurring items, and reports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 18:49:49 +03:30
MahdiandCursor 61d17c5cea fix: persist the prior-archive cutoff date when the owner changes it
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 16:31:19 +03:30
438 changed files with 75006 additions and 48986 deletions
+93
View File
@@ -0,0 +1,93 @@
---
description: Hard governance for Ant Design Vue v4 migration — isolated page-by-page, no manual CSS, 24-col grid, default controlHeight 36
alwaysApply: true
---
# Ant Design Vue v4 — Strict Governance (locked)
Source of truth for migrated UI: **Ant Design Vue 4** + central theme [`apps/web/src/theme/antdTheme.ts`](apps/web/src/theme/antdTheme.ts).
Legacy CRM UI (`components/ui/*`, `.crm-*`, `.btn` / `.field` in `styles/`) remains for **unmigrated** screens only. Never mix systems inside one page.
## Hard rules
1. **Single-page migration lock**
Migrate **exactly one** route/view (or one clearly named page folder) per change set. Do not “clean up” neighboring pages, shared legacy primitives, or global CSS while migrating a page.
2. **No edits to legacy kit during Ant work**
Do **not** modify `apps/web/src/components/ui/**` or broaden changes across `views/` beyond the single page under migration, unless the user explicitly names that page.
2b. **Child components of a migrated View (mandatory)**
هنگام بازنویسی یک صفحه (View)، ایجنت موظف است علاوه بر خود فایل View، تمام کامپوننت‌های فرزندی را که در آن View ایمپورت شده‌اند و حاوی اینپوت/سلکت/دکمه هستند نیز بازبینی کرده و درون آن‌ها را به کامپوننت‌های رسمی `ant-design-vue` تبدیل کند. هیچ تگ `<input>`, `<select>`, `<textarea>`, `<button>` خامی نباید در صفحه یا کامپوننت‌های فرزندِ مصرفی آن باقی بماند.
When rewriting a View, the agent **must** also review every component imported by that View that contains inputs/selects/buttons, and convert their internals to official `ant-design-vue` controls.
**No raw** `<input>`, `<select>`, `<textarea>`, or `<button>` may remain in the View **or** in those consumed child components. Prefer native Ant equivalents (`a-select` / `mode="multiple|tags"` / `a-time-picker` / …) over custom pickers when logic allows; if domain logic is required (e.g. Jalali), convert that child file to Ant chrome (`a-input` + `a-popover`, etc.) instead of leaving raw HTML. Do **not** leave legacy `UiInput` / `UiSelect` / `UiButton` / `UiField` / `UiTextarea` in the View or those children.
3. **Ant-only UI on migrated pages**
On a migrated page, build controls with Ant Design Vue (`a-button`, `a-form`, `a-table`, `a-card`, …) and `@ant-design/icons-vue`. Do **not** introduce new usage of `UiButton`, `UiInput`, `.btn`, `.field`, or ad-hoc HTML form controls for the same job.
4. **Ban manual CSS for layout/look**
On migrated pages:
- No new `<style scoped>` rules for color, spacing, radius, shadow, typography, or layout that Ant already covers.
- No hardcoded hex / `rgb()` for chrome UI (charts/data palettes may be excepted when not chrome).
- Prefer ConfigProvider theme tokens, component props, and Ant space/grid utilities.
- Inline `style` only for rare one-off values that tokens cannot express; prefer tokens first.
5. **Default Ant control size (`controlHeight: 36`) — no manual `size`**
- Global default comes from theme token `controlHeight: 36` in `antdTheme.ts` (Ant derives SM/LG from this).
- Do **not** set `size="small"`, `size="middle"`, `size="large"`, or `:size` on `a-button`, `a-input`, `a-select`, `a-input-number`, `a-date-picker`, `a-radio-group`, `a-pagination`, or similar controls on migrated pages.
- Do **not** override `controlHeight` / `controlHeightSM` / `controlHeightLG` inside `components.*` of the theme — inherit the token default.
- Exception: only if the user explicitly requests a one-off non-default size for a named control.
6. **24-column grid only**
Page layout on migrated screens uses Ant Grid: `a-row` / `a-col` with the **24-column** system (`:span`, `:xs`, `:sm`, `:md`, `:lg`, `:xl`). Do not invent parallel flex/grid CSS layouts for page chrome.
7. **RTL + locale + StyleProvider**
App root in `App.vue` **must** wrap ConfigProvider with `<a-style-provider hash-priority="high">` so legacy `main.css` cannot override Ant controls via higher specificity:
```vue
<a-style-provider hash-priority="high">
<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">
<RouterView />
</a-config-provider>
</a-style-provider>
```
Do not override direction to LTR on whole pages; use local `direction="ltr"` only for inherently LTR fragments (e.g. phone numbers, codes).
8. **Auto-import**
`<a-*>` components are resolved via `unplugin-vue-components` + `AntDesignVueResolver({ importStyle: false })`. Do not add blanket `app.use(antd)` unless there is a documented exception. Do not per-component CSS imports (`importStyle` stays `false`; CSS-in-JS + theme). Prefer **explicit** `import { Button as AButton, … } from 'ant-design-vue'` in migrated files when the Vite resolver is missing or unreliable.
9. **Theme is central**
Visual tokens (primary, radius, `controlHeight`, padding/margin scale, shadows, Form/Card overrides) live only in `antdTheme.ts`. Do not fork theme objects inside views.
10. **Icons**
Use `@ant-design/icons-vue` on migrated pages. Do not expand `UiIcon` for Ant pages.
11. **Isolation guarantee**
Infrastructure PRs (deps, vite plugin, theme, App `ConfigProvider`) must not change behavior of unmigrated pages. No `ant-design-vue/dist/reset.css` (or similar global reset) unless explicitly approved — it can break legacy CSS.
## Allowed on migrated pages
- Ant Design Vue 4 components and their props/slots (except banned manual `size` on controls)
- `a-row` / `a-col` (24 columns), `a-space`, `a-flex` where appropriate
- Theme tokens via ConfigProvider
- Minimal page-local script logic (data fetching, forms) unchanged in spirit
## Forbidden on migrated pages
- New legacy `components/ui` usage
- Parallel button/input CSS classes (`.btn`, `.field`, `.crm-*` for chrome that Ant replaces)
- Custom CSS that re-skins Ant components
- Manual `size` props on form/action controls (use theme default 36px)
- Multi-page refactors “while we’re here”
- Adding Tailwind/Bootstrap/other UI kits
## Agent checklist before finishing a migration PR
- [ ] Only one page/route touched for UI migration
- [ ] Imported child components with controls also converted to Ant (no raw HTML left)
- [ ] Grep verification: `<input|<select|<textarea|<button|UiInput|UiSelect|UiField|UiTextarea|UiButton` count is **0** in the View + those children
- [ ] No changes under `components/ui/`
- [ ] No new scoped CSS for look-and-feel
- [ ] No manual `size` on Ant controls
- [ ] Layout uses 24-col Ant grid
- [ ] Uses `antdTheme` / ConfigProvider / StyleProvider `hash-priority="high"` — no local theme fork
- [ ] `npm run build -w @mento/web` (or `vue-tsc`) passes
+7
View File
@@ -0,0 +1,7 @@
{
"plugins": {
"hex": {
"enabled": true
}
}
}
@@ -0,0 +1,90 @@
---
name: antd-system-architect
description: >-
Architect and migrate Montact web UI to Ant Design Vue v4 under strict
governance: single-page lock, no manual CSS, no manual size props, default
controlHeight 36, 24-column grid, central antdTheme, isolated from legacy
components/ui. Use when migrating a page to Ant Design, adding Ant components,
editing antdTheme, or reviewing Ant PRs.
---
# Ant Design Vue System Architect (Montact)
## When to use
- Migrating any `apps/web` view/page to Ant Design Vue 4
- Extending [`apps/web/src/theme/antdTheme.ts`](../../../apps/web/src/theme/antdTheme.ts)
- Touching Ant ConfigProvider / vite Ant auto-import
- Reviewing whether a change violates isolation from legacy UI
Also read and obey [`.cursor/rules/antd-strict-governance.mdc`](../../rules/antd-strict-governance.mdc).
## Non-negotiables
1. **One page per migration** — never batch unrelated views.
2. **Do not edit** `apps/web/src/components/ui/**` during Ant migration work.
3. **Child components of the View are in scope (mandatory)** — هنگام بازنویسی یک صفحه (View)، ایجنت موظف است علاوه بر خود فایل View، تمام کامپوننت‌های فرزندی را که در آن View ایمپورت شده‌اند و حاوی اینپوت/سلکت/دکمه هستند نیز بازبینی کرده و درون آن‌ها را به کامپوننت‌های رسمی `ant-design-vue` تبدیل کند. هیچ تگ `<input>`, `<select>`, `<textarea>`, `<button>` خامی نباید در صفحه یا کامپوننت‌های فرزندِ مصرفی آن باقی بماند. When rewriting a View, also open every imported child that renders inputs/selects/buttons and convert them to official `ant-design-vue` controls. No raw `<input>`, `<select>`, `<textarea>`, or `<button>` (and no `UiInput` / `UiSelect` / `UiButton` / `UiField` / `UiTextarea`) may remain in the View or those children. Prefer Ant natives (`a-select` multiple/tags, `a-time-picker`, …); for Jalali/domain pickers, convert the child file to Ant chrome (`a-input` + `a-popover`, etc.).
4. **No manual look CSS** on migrated pages (no new scoped colors/spacing/radius/shadows for chrome).
5. **24-column Ant grid** (`a-row` / `a-col`) for page layout.
6. **Theme only in** `apps/web/src/theme/antdTheme.ts`.
7. **Default control height is 36** via token `controlHeight: 36`. Do **not** pass `size` / `:size` on buttons, inputs, selects, or similar controls; do **not** set per-component `controlHeight*` in the theme `components` map.
8. **Auto-import** `<a-*>` via `AntDesignVueResolver({ importStyle: false })` — no per-file style imports. Root uses `StyleProvider` with `hash-priority="high"` so legacy `main.css` cannot override Ant.
9. **No global Ant reset CSS** that would restyle legacy pages.
## Stack facts (this repo)
| Piece | Location |
|---|---|
| Dependency | `ant-design-vue` ^4, `@ant-design/icons-vue`, `unplugin-vue-components` in `@mento/web` |
| Vite plugin | `apps/web/vite.config.ts` — `Components` + `AntDesignVueResolver` |
| Theme | `apps/web/src/theme/antdTheme.ts` (`controlHeight: 36`) |
| RTL + locale + theme + StyleProvider | `apps/web/src/App.vue` — `<a-style-provider hash-priority="high">` wrapping `<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">` (mandatory so legacy `main.css` cannot override Ant) |
| Legacy kit (do not touch) | `apps/web/src/components/ui/*`, `styles/main.css` utilities |
### Mandatory child-control conversion (same pass)
هنگام بازنویسی یک صفحه (View)، ایجنت موظف است علاوه بر خود فایل View، تمام کامپوننت‌های فرزندی را که در آن View ایمپورت شده‌اند و حاوی اینپوت/سلکت/دکمه هستند نیز بازبینی کرده و درون آن‌ها را به کامپوننت‌های رسمی `ant-design-vue` تبدیل کند. هیچ تگ `<input>`, `<select>`, `<textarea>`, `<button>` خامی نباید در صفحه یا کامپوننت‌های فرزندِ مصرفی آن باقی بماند.
## Migration workflow
1. Confirm the **single** route/view the user named.
2. Inventory current UI: legacy `Ui*`, `.btn`/`.field`, `.crm-*`, custom tables.
3. List all imported children that contain controls; convert them in the same pass (except `components/ui/**`).
4. Replace with Ant equivalents:
- Button → `a-button` (**no** `size` prop)
- Input/Select/Form field → `a-form` + `a-form-item` + `a-input` / `a-select` / … (**no** `size`)
- Modal/Drawer → `a-modal` / `a-drawer`
- Table → `a-table` (columns via Ant API)
- Page chrome → `a-card`, title + `a-space`, layout via `a-row`/`a-col`
- Icons → `@ant-design/icons-vue`
5. Remove page-local CSS that only existed to skin the old controls.
6. Keep business logic, API calls, and router behavior intact.
7. Grep-verify zero hits for `<input|<select|<textarea|<button|UiInput|UiSelect|UiField|UiTextarea|UiButton` in the View + converted children.
8. Run `npm run build -w @mento/web` (or at least `vue-tsc -b` in `apps/web`).
9. Smoke-check that **other** routes still render with legacy UI unchanged.
## Theme extension rules
- Change tokens in `antdTheme.ts` only when the user asks or when a migrated page proves a missing token.
- Keep `controlHeight: 36` as the single source for control sizing; let Ant derive SM/LG.
- Prefer `token` and limited `components.Card` / `components.Form` overrides — never per-control `controlHeight` forks.
- Do not duplicate `ThemeConfig` objects inside views.
## Anti-patterns (reject)
- Importing both `UiButton` and `a-button` on the same migrated page
- Adding `ant-design-vue/dist/reset.css` “to fix spacing”
- Editing `components/ui` to “look more like Ant”
- `size="small|middle|large"` on Ant controls
- Multi-page visual refactors in one PR
- Custom CSS overriding `.ant-btn` / `.ant-input` selectors
## Done criteria
- Migrated page uses Ant components + 24-col grid
- Imported control-bearing children also Ant-pure (grep = 0)
- Controls use theme default height (no manual `size`)
- No new manual chrome CSS
- Legacy kit untouched
- Build/typecheck clean
- Unmigrated pages untouched in the diff
+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 246.7 46.59">
<defs>
<style>
.cls-1 {
fill: #10897d;
}
.cls-2 {
fill: #16c1a8;
}
.cls-3 {
fill: #43f7dd;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<g>
<path class="cls-1" d="M199.15,23.8v19.86c0,1.48-1.2,2.67-2.67,2.67h-8.09c-1.48,0-2.67-1.2-2.67-2.67V15.92c0-.91.47-1.76,1.23-2.25l12.85-8.23c1.13-.72,2.63-.5,3.49.53l11.02,13.14c.18.21.49.22.68.03l8.04-8.21c1.04-1.06,2.75-1.07,3.8-.02l5.99,5.99c1.05,1.05,1.05,2.74,0,3.78l-16.98,16.98c-1.1,1.1-2.92,1.03-3.93-.15l-11.96-14.01c-.28-.33-.81-.13-.81.3h0Z"/>
<circle class="cls-3" cx="237.24" cy="6.48" r="6.48"/>
<path class="cls-3" d="M242.84,16.26h0c2.13,0,3.86,1.73,3.86,3.86v22.61c0,2.13-1.73,3.86-3.86,3.86h0c-2.13,0-3.86-1.73-3.86-3.86v-22.61c0-2.13,1.73-3.86,3.86-3.86Z"/>
<path class="cls-2" d="M233.49,25.66h0c2.13,0,3.86,1.73,3.86,3.86v13.21c0,2.13-1.73,3.86-3.86,3.86h0c-2.13,0-3.86-1.73-3.86-3.86v-13.21c0-2.13,1.73-3.86,3.86-3.86Z"/>
<rect class="cls-1" x="220.08" y="35.24" width="7.72" height="11.35" rx="3.86" ry="3.86"/>
</g>
<g>
<path class="cls-1" d="M132.19,17.19v2.66c0,1.27,1.03,2.29,2.29,2.29h2.66c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-2.66c-1.27,0-2.29,1.03-2.29,2.29Z"/>
<path class="cls-1" d="M102.53,17.19v2.66c0,1.27,1.03,2.29,2.29,2.29h12.91c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-12.91c-1.27,0-2.29,1.03-2.29,2.29Z"/>
<path class="cls-1" d="M13.73,20.2v2.66c0,1.27,1.03,2.29,2.29,2.29h12.91c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-12.91c-1.27,0-2.29,1.03-2.29,2.29Z"/>
<g>
<path class="cls-1" d="M173.05,30.25c-.52-1.2-1.24-2.25-2.14-3.16-.91-.9-1.96-1.62-3.16-2.14-1.2-.52-2.49-.78-3.88-.78h-3.25c-1.12,0-2.2.19-3.24.58-1.04.39-1.98.92-2.81,1.59-.83.68-1.54,1.49-2.11,2.43-.58.95-.97,1.98-1.16,3.1l-1.16,5.97c-.16.66-.47,1.18-.96,1.56-.48.39-1.05.58-1.71.58h-6.49c-.74,0-1.31-.26-1.74-.78-.43-.52-.56-1.15-.41-1.88l1.35-6.87c.25-1.25-.58-2.47-1.84-2.69l-2.52-.46c-1.22-.22-2.4.57-2.65,1.79l-1.81,8.7c-.12.66-.43,1.19-.93,1.59-.5.4-1.08.61-1.74.61h-10.08c-.74,0-1.31-.26-1.74-.78-.43-.52-.56-1.15-.41-1.88l1.35-6.87c.25-1.25-.58-2.47-1.84-2.69l-2.52-.46c-1.22-.22-2.4.57-2.65,1.79l-1.81,8.7c-.12.66-.43,1.19-.93,1.59-.5.4-1.08.61-1.74.61h-9.5c-.66,0-1.22-.22-1.68-.67-.46-.44-.69-.99-.69-1.65v-3.53c0-1.39-.26-2.69-.78-3.91-.52-1.22-1.24-2.28-2.14-3.19-.91-.91-1.96-1.62-3.16-2.14-1.2-.52-2.49-.78-3.88-.78h-21.78l9.58-11.3c.79-.94.71-2.33-.19-3.17l-1.79-1.65c-.96-.88-2.45-.79-3.29.2l-14.66,17.17c-.79.92-.72,2.3.16,3.14l3.59,3.43h29.14c.54,0,1.02.2,1.45.61.43.4.64.9.64,1.48v4.11c0,.54-.19.99-.58,1.33-.39.35-.79.52-1.22.52h-38.23c-.66,0-1.21-.22-1.65-.67-.44-.44-.67-.99-.67-1.65v-8.54c0-1.27-1.03-2.29-2.29-2.29h-2.6c-1.27,0-2.29,1.03-2.29,2.29v8.6c0,.66-.22,1.2-.67,1.62-.44.43-.99.64-1.65.64H11.53c-1.2,0-2.22-.4-3.07-1.21-.85-.81-1.27-1.83-1.27-3.07v-8.25c0-1.27-1.03-2.29-2.29-2.29h-2.6c-1.27,0-2.29,1.03-2.29,2.29v6.47c0,1.66.32,3.24.96,4.72.64,1.49,1.51,2.79,2.61,3.91,1.1,1.12,2.39,2,3.88,2.63,1.49.64,3.06.96,4.72.96h22.01c1.39,0,2.69-.25,3.91-.75,1.22-.5,2.33-1.33,3.33-2.49,1.93,2.16,4.29,3.24,7.07,3.24h35.8c1.24,0,2.44-.23,3.62-.69,1.18-.46,2.25-1.08,3.22-1.85.93.77,1.98,1.39,3.16,1.85,1.18.46,2.44.69,3.79.69h7.59c1.23,0,2.48-.29,3.74-.87,1.25-.58,2.25-1.37,2.98-2.38.58,1,1.46,1.8,2.64,2.38,1.18.58,2.36.87,3.56.87h9.44c1.23,0,2.48-.29,3.74-.87,1.25-.58,2.25-1.37,2.98-2.38.58,1,1.46,1.8,2.64,2.38,1.18.58,2.37.87,3.56.87h6.03c1.24,0,2.39-.23,3.47-.69,1.08-.46,2.07-1.29,2.95-2.49.89,1.24,1.86,2.08,2.93,2.52,1.06.44,2.15.67,3.27.67h6.6c1.04,0,2.03-.2,2.95-.61.93-.4,1.74-.96,2.43-1.65s1.24-1.51,1.65-2.43c.4-.93.61-1.91.61-2.95v-4.36c0-1.39-.26-2.68-.78-3.88ZM166.65,39.98h-7.99c-.43,0-.77-.15-1.04-.46-.27-.31-.37-.67-.29-1.1l.93-4.64c.08-.42.27-.77.58-1.04.31-.27.67-.4,1.1-.4h4.98c.5,0,.92.17,1.24.52.33.35.49.75.49,1.22v5.91Z"/>
<path class="cls-3" d="M166.7,16.1c-.65.12-1.24.25-1.77.37-.53.12-1.12.26-1.79.4-.67.14-1.34.29-2.02.44-1.3.29-2.73.61-4.28.94-.68.15-1.34-.31-1.45-1l-.24-1.57c-.1-.63.31-1.23.94-1.36l4.01-.88c-.09-.34-.16-.61-.21-.81-.05-.2-.09-.38-.12-.54-.03-.15-.07-.33-.12-.54-.05-.2-.09-.47-.12-.81-.06-.59,0-1.13.21-1.63.2-.5.48-.95.84-1.35.36-.4.78-.73,1.26-.98.48-.25.95-.4,1.42-.47l2.42-.42c.56-.09,1.11-.05,1.65.14.54.19,1.02.46,1.44.81s.78.78,1.07,1.28c.29.5.49,1.01.58,1.54l.23,1.4c.12.62.09,1.21-.09,1.77-.19.56-.47,1.05-.84,1.49-.37.43-.82.81-1.35,1.12-.53.31-1.09.53-1.68.65ZM166.79,11.54s-.02-.1-.05-.21c-.03-.11-.06-.24-.09-.4-.06-.16-.12-.34-.19-.56-.16-.31-.39-.42-.7-.33l-1.44.37c-.37.06-.53.26-.47.6l.51,1.63,1.86-.47c.5-.09.68-.31.56-.65Z"/>
<path class="cls-3" d="M105.49,6.57l11.59-2.48c.64-.14,1.28.27,1.43.91l.27,1.15c.15.66-.26,1.31-.93,1.45l-11.66,2.46c-.67.14-1.32-.3-1.44-.97l-.2-1.13c-.11-.64.3-1.25.94-1.39Z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 197.7 151.02">
<defs>
<style>
.cls-1 {
fill: #10897d;
}
.cls-2 {
fill: #16c1a8;
}
.cls-3 {
fill: #43f7dd;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<path class="cls-1" d="M43.57,77.15v64.37c0,4.79-3.88,8.67-8.67,8.67H8.67c-4.79,0-8.67-3.88-8.67-8.67V51.62c0-2.96,1.51-5.71,3.99-7.3l41.67-26.69c3.67-2.35,8.52-1.61,11.32,1.73l35.74,42.59c.57.68,1.6.72,2.22.09l26.07-26.6c3.38-3.45,8.92-3.47,12.33-.06l19.42,19.42c3.39,3.39,3.39,8.88,0,12.26l-55.06,55.06c-3.58,3.58-9.45,3.35-12.73-.5l-38.76-45.42c-.9-1.06-2.64-.42-2.64.97Z"/>
<circle class="cls-3" cx="167.06" cy="21.02" r="21.02"/>
<rect class="cls-3" x="172.68" y="52.7" width="25.02" height="98.32" rx="12.51" ry="12.51"/>
<rect class="cls-2" x="142.38" y="83.17" width="25.02" height="67.85" rx="12.51" ry="12.51"/>
<rect class="cls-1" x="111.4" y="114.24" width="25.02" height="36.78" rx="12.51" ry="12.51"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+65
View File
@@ -0,0 +1,65 @@
---
description: حاکمیت مطلق معماری رابط کاربری بر پایه Ant Design Vue v4، قفل مهاجرت صفحه به صفحه، ممنوعیت کامل CSS دستی و استاندارد ریاضی چیدمان
globs: apps/web/src/**/*.{vue,ts,tsx,css}
alwaysApply: true
---
# قرارداد حاکمیتی و غیرقابل‌مذاکرهٔ رابط کاربری (Ant Design Vue v4 Strict Contract)
تنها منبع مجاز برای ساخت و بازنویسی عناصر رابط کاربری در این پروژه `ant-design-vue` (نسخه ۴) است. هرگونه تخطی از قوانین زیر خطای معماری محسوب می‌شود:
## ۱. قفل ایزولاسیون و مهاجرت صفحه به صفحه (Single-Page Blast Radius Lock)
1. **ممنوعیت ویرایش دسته‌جمعی:** تو فقط و فقط مجاز به ویرایش صفحه (View) یا کامپوننتی هستی که کاربر صراحتاً در همان نوبت نام برده است. دست زدن به سایر صفحات پروژه مطلقاً ممنوع است.
2. **حفظ لایهٔ قدیمی برای صفحات مهاجرت‌نکرده:** فایل‌های داخل `apps/web/src/components/ui/Ui*.vue` و استایل‌های سراسری `main.css` / `tokens.css` نباید حذف یا ویرایش شوند تا صفحات قدیمی پروژه بدون خطا به کار خود ادامه دهند.
3. **قطع کامل وابستگی در صفحهٔ هدف:** در صفحه‌ای که کاربر دستور بازنویسی آن را می‌دهد، تمام ایمپورت‌های `@/components/ui` و کلاس‌های قدیمی باید از آن فایل حذف شوند و ۱۰۰٪ با کامپوننت‌های رسمی `<a-...>` جایگزین گردند.
4. **کامپوننت‌های تخصصی تاریخ شمسی و مبالغ:** در صفحات بازنویسی‌شده، اگر از `JalaliDateInput` یا `MoneyInput` استفاده شده است، منطق آن‌ها را حفظ کن اما مطمئن شو ظاهر ورودی آن‌ها با ارتفاع `40px`، ردیوس `10px` و عرض `style="width: 100%"` کاملاً هم‌تراز با فیلدهای Ant Design نمایش داده می‌شود.
## ۲. لیست سیاه سینتکس و تگ‌های ممنوعه در صفحات بازنویسی‌شده (Banned Syntax)
در هر فایلی که بازنویسی یا ایجاد می‌کنی، وجود موارد زیر خطای بحرانی است و باید فوراً حذف شوند:
- **تگ‌های خام HTML برای فرم و جدول:** `<button>`, `<input>`, `<select>`, `<textarea>`, `<label>`, `<form>`, `<table>`, `<thead>`, `<tbody>`, `<tr>`, `<td>`.
- **کلاس‌های لگسی پروژه:** `.btn`, `.btn-primary`, `.btn-ghost`, `.btn-danger`, `.btn-save`, `.field`, `.crm-page`, `.crm-page-header`, `.crm-card`, `.crm-table`, `.crm-empty-state`.
- **کلاس‌های Tailwind (پروژه فاقد Tailwind است):** استفاده از کلاس‌های ناموجود مانند `w-full`, `flex`, `gap-4`, `p-4`, `mb-4` مطلقاً ممنوع است. برای عرض کامل کنترل‌ها فقط از `style="width: 100%"` یا پراپ `block` استفاده کن.
## ۳. قوانین ریاضی چیدمان، گرید و ریتم فضایی (Spatial & Layout Mathematics)
1. **ممنوعیت تکه‌تکه کردن فرم به کارت‌های متعدد (Anti-Card-Fragmentation):**
- خرد کردن یک فرم به چندین باکس یا کارت مجزا زیر هم ممنوع است.
- کل فیلدهای یک فرم اصلی باید در **حداکثر ۱ `<a-card :bordered="false">`** تجمیع شوند.
- برای تفکیک بخش‌های منطقی در داخل آن کارت واحد، فقط از `<a-divider orientation="right">عنوان بخش</a-divider>` استفاده کن.
2. **چیدمان استاندارد دو ستونه (فرم اصلی + پنل خلاصه):**
- ردیف اصلی صفحه: `<a-row :gutter="[24, 24]">`
- ستون اصلی (فرم/محتوا): `<a-col :xs="24" :lg="17" :xl="18">`
- ستون کناری (خلاصه وضعیت): `<a-col :xs="24" :lg="7" :xl="6">` همراه با `<a-affix :offset-top="24">`.
- محتوای پنل خلاصه باید الزاماً با `<a-descriptions :column="1" size="small" bordered>` داخل `<a-card title="..." :bordered="false">` پیاده‌سازی شود، نه با `div` و `span` دستی.
3. **قانون عرض فیلدها در فرم (Form Grid Allocation):**
- تمام فرم‌ها باید `<a-form layout="vertical" :model="...">` باشند و تمام فیلدها بدون استثنا داخل `<a-form-item label="..." name="...">` قرار گیرند.
- رها کردن فیلدهای کوتاه در عرض ۱۰۰٪ (`span="24"`) ممنوع است. چیدمان درون فرم باید با `<a-row :gutter="[16, 0]">` به شکل زیر باشد:
- **فیلدهای کوتاه** (تاریخ، ساعت، مدت زمان، مبلغ، وضعیت، نوع، کد): `<a-col :xs="24" :sm="12" :md="8">` (۳ ستونه).
- **فیلدهای متوسط** (انتخاب مشتری، مسئول انجام، فرصت مرتبط، دسته‌بندی، عنوان): `<a-col :xs="24" :md="12">` (۲ ستونه).
- **فیلدهای تمام‌عرض** (توضیحات، یادداشت، چک‌لیست، برچسب‌ها): `<a-col :span="24">`.
- **سوییچ‌ها و چک‌باکس‌های هم‌خانواده:** قرارگیری در یک ردیف با `<a-space :size="24" wrap>`.
4. **مدیریت Margin و Padding بدون CSS دستی:**
- فاصلهٔ عمودی فیلدها فقط توسط `itemMarginBottom` در کانفیگ تم `<a-form-item>` کنترل می‌شود.
- فاصلهٔ افقی/عمودی دکمه‌ها و المان‌ها فقط با `<a-space :size="8 | 12 | 16">` یا `<a-flex gap="small | middle | large" align="center" justify="space-between">` کنترل می‌شود.
## ۴. قفل سینتکس اختصاصی Ant Design Vue v4
- **بایندینگ مقدار (`v-model`):** استفاده از `v-model="..."` بدون آرگومان روی کامپوننت‌های Ant Design Vue خطاست:
- برای `a-input`, `a-textarea`, `a-input-number`, `a-select`, `a-radio-group`, `a-segmented`, `a-date-picker`, `a-time-picker`: الزاماً `v-model:value="..."`.
- برای `a-checkbox`, `a-switch`: الزاماً `v-model:checked="..."`.
- برای `a-modal`, `a-drawer`, `a-dropdown`, `a-popover`, `a-tooltip`: الزاماً `v-model:open="..."` (پراپ `:visible` منسوخ و ممنوع است).
- **الزام عرض ۱۰۰٪ روی کنترل‌های خاص:** کامپوننت‌های `<a-input-number>`, `<a-date-picker>`, `<a-time-picker>` و `<a-select>` در داخل `<a-form-item>` باید دارای `style="width: 100%"` باشند.
- **فیدبک و پیام‌ها:** برای نمایش پیام از `import { message, notification, Modal } from 'ant-design-vue'` استفاده کن.
## ۵. حاکمیت مطلق توکن‌ها و ممنوعیت CSS دستی (Zero Manual Styling & RTL Lock)
1. نوشتن کد رنگ (`#hex`, `rgb`, `rgba`, `hsl`)، سایه (`box-shadow`)، ردیوس (`border-radius`) و فاصله‌های دستی (`margin`, `padding` با `px`/`rem`) در `<style scoped>` ممنوع است.
2. تمام تنظیمات ظاهری (ردیوس، شدو، ارتفاع کنترل‌ها، پدینگ و رنگ‌ها) فقط در فایل مرکزی `apps/web/src/theme/antdTheme.ts` تعریف می‌شوند.
3. در صورت نیاز ضروری به خواندن توکن در یک کانتینر، فقط از هوک `const { token } = theme.useToken()` در `ant-design-vue` استفاده کن.
4. استفاده از `:deep(.ant-...)` برای دست‌کاری ظاهر کامپوننت‌های انت ممنوع است.
5. در RTL، استفاده از `left`, `right`, `margin-left`, `margin-right`, `padding-left`, `padding-right` ممنوع است؛ فقط از خواص منطقی (`inset-inline-start`, `inset-inline-end`, `margin-inline`, `padding-inline`) استفاده شود.
## ۶. گیت خودارزیابی پیش از تحویل کد (Self-Verification Gate)
قبل از اتمام کار روی فایلِ درخواستی، این ۵ شرط را بررسی کن:
- [ ] آیا فقط فایل درخواستی کاربر ویرایش شده و سایر صفحات دست‌نخورده مانده‌اند؟
- [ ] آیا تمام تگ‌های خام فرم، کامپوننت‌های `Ui*`، کلاس‌های `.btn`/`.field`/`.crm-*` و کلاس‌های ناموجود مثل `w-full` از این صفحه حذف شده‌اند؟
- [ ] آیا فرم در ۱ کارت واحد با `<a-divider>` و گرید `<a-row :gutter="[16, 0]">` چیده شده و فیلدهای کوتاه از حالت تمام‌عرض خارج شده‌اند؟
- [ ] آیا تمام بایندینگ‌ها از `v-model:value`، `v-model:checked` و `v-model:open` به همراه `style="width: 100%"` روی سلکت‌ها و شمارنده‌ها استفاده می‌کنند؟
- [ ] آیا `<style scoped>` کاملاً از رنگ‌های Hex، مارجین/پدینگ دستی، ردیوس، شدو و `:deep(.ant-*)` پاک‌سازی شده است؟
+1 -1
View File
@@ -12,7 +12,7 @@
"prisma:seed": "tsx prisma/seed.ts", "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", "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" "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 test/behavior.test.ts test/attendance-time.test.ts test/sales-factors.test.ts test/platform-admin-alert.test.ts test/person-name.test.ts"
}, },
"dependencies": { "dependencies": {
"@mento/shared": "*", "@mento/shared": "*",
@@ -0,0 +1,96 @@
-- CreateTable
CREATE TABLE "FinancialAccount" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"bankName" TEXT,
"accountRef" TEXT,
"responsibleName" TEXT,
"openingBalance" DECIMAL(18,2) NOT NULL DEFAULT 0,
"openingDate" TIMESTAMP(3) NOT NULL,
"notes" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"currency" TEXT NOT NULL DEFAULT 'IRR',
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FinancialAccount_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FinancialCategory" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FinancialCategory_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FinancialDocument" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"title" TEXT NOT NULL,
"amount" DECIMAL(18,2) NOT NULL,
"bookedAt" TIMESTAMP(3) NOT NULL,
"dueAt" TIMESTAMP(3),
"categoryId" TEXT,
"personId" TEXT,
"partyName" TEXT,
"accountId" TEXT,
"method" TEXT,
"status" TEXT NOT NULL DEFAULT 'open',
"reference" TEXT,
"notes" TEXT,
"createdById" TEXT,
"voidedAt" TIMESTAMP(3),
"voidReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FinancialDocument_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FinancialMovement" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"documentId" TEXT,
"direction" TEXT NOT NULL,
"amount" DECIMAL(18,2) NOT NULL,
"kind" TEXT NOT NULL,
"transferGroupId" TEXT,
"occurredAt" TIMESTAMP(3) NOT NULL,
"memo" TEXT,
"reference" TEXT,
"createdById" TEXT,
"voidedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FinancialMovement_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "FinancialAccount_organizationId_isActive_idx" ON "FinancialAccount"("organizationId", "isActive");
CREATE UNIQUE INDEX "FinancialCategory_organizationId_kind_name_key" ON "FinancialCategory"("organizationId", "kind", "name");
CREATE INDEX "FinancialCategory_organizationId_kind_idx" ON "FinancialCategory"("organizationId", "kind");
CREATE INDEX "FinancialDocument_organizationId_kind_bookedAt_idx" ON "FinancialDocument"("organizationId", "kind", "bookedAt");
CREATE INDEX "FinancialDocument_organizationId_status_idx" ON "FinancialDocument"("organizationId", "status");
CREATE INDEX "FinancialDocument_personId_idx" ON "FinancialDocument"("personId");
CREATE INDEX "FinancialMovement_organizationId_accountId_occurredAt_idx" ON "FinancialMovement"("organizationId", "accountId", "occurredAt");
CREATE INDEX "FinancialMovement_transferGroupId_idx" ON "FinancialMovement"("transferGroupId");
CREATE INDEX "FinancialMovement_documentId_idx" ON "FinancialMovement"("documentId");
ALTER TABLE "FinancialAccount" ADD CONSTRAINT "FinancialAccount_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialCategory" ADD CONSTRAINT "FinancialCategory_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FinancialCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "FinancialAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "FinancialAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "FinancialDocument"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,97 @@
-- AlterTable
ALTER TABLE "Person" ADD COLUMN "discLabel" TEXT,
ADD COLUMN "discReliable" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "discAssessedAt" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX "Person_organizationId_discLabel_idx" ON "Person"("organizationId", "discLabel");
-- CreateTable
CREATE TABLE "DiscAssessment" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"personId" TEXT NOT NULL,
"assessorId" TEXT NOT NULL,
"answers" JSONB NOT NULL,
"scoreD" INTEGER NOT NULL,
"scoreI" INTEGER NOT NULL,
"scoreS" INTEGER NOT NULL,
"scoreC" INTEGER NOT NULL,
"percentD" INTEGER NOT NULL,
"percentI" INTEGER NOT NULL,
"percentS" INTEGER NOT NULL,
"percentC" INTEGER NOT NULL,
"validCount" INTEGER NOT NULL,
"primaryTypes" TEXT[] DEFAULT ARRAY[]::TEXT[],
"secondaryTypes" TEXT[] DEFAULT ARRAY[]::TEXT[],
"label" TEXT NOT NULL,
"secondaryLabel" TEXT,
"reliable" BOOLEAN NOT NULL,
"confidence" TEXT NOT NULL,
"note" TEXT,
"status" TEXT NOT NULL DEFAULT 'confirmed',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DiscAssessment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SalesCopilotSession" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"personId" TEXT,
"leadId" TEXT,
"dealId" TEXT,
"userId" TEXT NOT NULL,
"topic" TEXT NOT NULL,
"goal" TEXT NOT NULL,
"customerStatement" TEXT NOT NULL,
"behaviorNotes" TEXT,
"analysis" JSONB NOT NULL,
"confirmedResponse" TEXT,
"nextAction" TEXT,
"followUpAt" TIMESTAMP(3),
"callOutcome" TEXT,
"agentNote" TEXT,
"status" TEXT NOT NULL,
"messageDraft" TEXT,
"activityId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SalesCopilotSession_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "DiscAssessment_organizationId_personId_createdAt_idx" ON "DiscAssessment"("organizationId", "personId", "createdAt");
CREATE INDEX "DiscAssessment_personId_idx" ON "DiscAssessment"("personId");
CREATE INDEX "SalesCopilotSession_organizationId_personId_createdAt_idx" ON "SalesCopilotSession"("organizationId", "personId", "createdAt");
CREATE INDEX "SalesCopilotSession_organizationId_leadId_idx" ON "SalesCopilotSession"("organizationId", "leadId");
CREATE INDEX "SalesCopilotSession_dealId_idx" ON "SalesCopilotSession"("dealId");
-- AddForeignKey
ALTER TABLE "DiscAssessment" ADD CONSTRAINT "DiscAssessment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "DiscAssessment" ADD CONSTRAINT "DiscAssessment_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "DiscAssessment" ADD CONSTRAINT "DiscAssessment_assessorId_fkey" FOREIGN KEY ("assessorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- ماژول برای محیط‌هایی که از قبل ماژول دارند روشن می‌شود و در سوپرادمین قابل خاموش کردن است
UPDATE "Organization"
SET "enabledModules" = array_append("enabledModules", 'behavior')
WHERE cardinality("enabledModules") > 0
AND NOT ('behavior' = ANY("enabledModules"));
-- مجوز رفتار برای نقش‌هایی که فروش دارند؛ پشتیبانی بدون این کلید می‌ماند
UPDATE "OrgRole"
SET "permissions" = array_append("permissions", 'behavior')
WHERE NOT ('behavior' = ANY("permissions"))
AND (
"systemKey" IN ('owner', 'admin', 'sales')
OR 'sales' = ANY("permissions")
);
@@ -0,0 +1,42 @@
ALTER TABLE "FinancialMovement" ADD COLUMN "receivableId" TEXT;
ALTER TABLE "FinancialMovement" ADD COLUMN "installmentId" TEXT;
CREATE TABLE "FinancialReceivable" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"personId" TEXT,
"partyName" TEXT,
"totalAmount" DECIMAL(18,2) NOT NULL,
"notes" TEXT,
"status" TEXT NOT NULL DEFAULT 'open',
"createdById" TEXT,
"voidedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FinancialReceivable_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FinancialInstallment" (
"id" TEXT NOT NULL,
"receivableId" TEXT NOT NULL,
"position" INTEGER NOT NULL,
"dueDate" TIMESTAMP(3) NOT NULL,
"amount" DECIMAL(18,2) NOT NULL,
"reminderNotifiedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FinancialInstallment_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "FinancialMovement_receivableId_idx" ON "FinancialMovement"("receivableId");
CREATE INDEX "FinancialMovement_installmentId_idx" ON "FinancialMovement"("installmentId");
CREATE INDEX "FinancialReceivable_organizationId_status_idx" ON "FinancialReceivable"("organizationId", "status");
CREATE INDEX "FinancialReceivable_personId_idx" ON "FinancialReceivable"("personId");
CREATE UNIQUE INDEX "FinancialInstallment_receivableId_position_key" ON "FinancialInstallment"("receivableId", "position");
CREATE INDEX "FinancialInstallment_dueDate_idx" ON "FinancialInstallment"("dueDate");
ALTER TABLE "FinancialReceivable" ADD CONSTRAINT "FinancialReceivable_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialReceivable" ADD CONSTRAINT "FinancialReceivable_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "FinancialInstallment" ADD CONSTRAINT "FinancialInstallment_receivableId_fkey" FOREIGN KEY ("receivableId") REFERENCES "FinancialReceivable"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_receivableId_fkey" FOREIGN KEY ("receivableId") REFERENCES "FinancialReceivable"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_installmentId_fkey" FOREIGN KEY ("installmentId") REFERENCES "FinancialInstallment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,79 @@
ALTER TABLE "FinancialMovement" ADD COLUMN "payrollId" TEXT;
CREATE INDEX "FinancialMovement_payrollId_idx" ON "FinancialMovement"("payrollId");
CREATE TABLE "FinancialPayroll" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"period" TEXT NOT NULL,
"baseAmount" DECIMAL(18,2) NOT NULL,
"benefits" DECIMAL(18,2) NOT NULL DEFAULT 0,
"bonus" DECIMAL(18,2) NOT NULL DEFAULT 0,
"deductions" DECIMAL(18,2) NOT NULL DEFAULT 0,
"payable" DECIMAL(18,2) NOT NULL,
"dueDate" TIMESTAMP(3) NOT NULL,
"notes" TEXT,
"status" TEXT NOT NULL DEFAULT 'open',
"createdById" TEXT,
"voidedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FinancialPayroll_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FinancialRecurringItem" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"amount" DECIMAL(18,2) NOT NULL,
"interval" TEXT NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3),
"nextDue" TIMESTAMP(3) NOT NULL,
"accountId" TEXT,
"categoryId" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FinancialRecurringItem_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FinancialRecurringOccurrence" (
"id" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"periodKey" TEXT NOT NULL,
"dueDate" TIMESTAMP(3) NOT NULL,
"documentId" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FinancialRecurringOccurrence_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FinancialReconciliation" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"reconciledAt" TIMESTAMP(3) NOT NULL,
"statedBalance" DECIMAL(18,2) NOT NULL,
"systemBalance" DECIMAL(18,2) NOT NULL,
"difference" DECIMAL(18,2) NOT NULL,
"notes" TEXT,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FinancialReconciliation_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "FinancialPayroll_organizationId_userId_period_key" ON "FinancialPayroll"("organizationId", "userId", "period");
CREATE INDEX "FinancialPayroll_organizationId_status_idx" ON "FinancialPayroll"("organizationId", "status");
CREATE INDEX "FinancialRecurringItem_organizationId_isActive_nextDue_idx" ON "FinancialRecurringItem"("organizationId", "isActive", "nextDue");
CREATE UNIQUE INDEX "FinancialRecurringOccurrence_itemId_periodKey_key" ON "FinancialRecurringOccurrence"("itemId", "periodKey");
CREATE INDEX "FinancialReconciliation_organizationId_accountId_reconciledAt_idx" ON "FinancialReconciliation"("organizationId", "accountId", "reconciledAt");
ALTER TABLE "FinancialPayroll" ADD CONSTRAINT "FinancialPayroll_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_payrollId_fkey" FOREIGN KEY ("payrollId") REFERENCES "FinancialPayroll"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "FinancialRecurringItem" ADD CONSTRAINT "FinancialRecurringItem_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialRecurringOccurrence" ADD CONSTRAINT "FinancialRecurringOccurrence_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "FinancialRecurringItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialReconciliation" ADD CONSTRAINT "FinancialReconciliation_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialReconciliation" ADD CONSTRAINT "FinancialReconciliation_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "FinancialAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,37 @@
ALTER TABLE "Payment" ADD COLUMN "financialAccountId" TEXT;
ALTER TABLE "Cheque" ADD COLUMN "financialAccountId" TEXT;
ALTER TABLE "FinancialMovement" ADD COLUMN "paymentId" TEXT;
ALTER TABLE "FinancialMovement" ADD COLUMN "chequeId" TEXT;
CREATE INDEX "FinancialMovement_paymentId_idx" ON "FinancialMovement"("paymentId");
CREATE INDEX "FinancialMovement_chequeId_idx" ON "FinancialMovement"("chequeId");
CREATE TABLE "FinancialAuditLog" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"before" JSONB,
"after" JSONB,
"actorUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FinancialAuditLog_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "FinancialAuditLog_organizationId_entityType_entityId_createdAt_idx" ON "FinancialAuditLog"("organizationId", "entityType", "entityId", "createdAt");
CREATE TABLE "FinancialAttachment" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"documentId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"storageKey" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FinancialAttachment_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "FinancialAttachment_documentId_idx" ON "FinancialAttachment"("documentId");
ALTER TABLE "FinancialAuditLog" ADD CONSTRAINT "FinancialAuditLog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialAttachment" ADD CONSTRAINT "FinancialAttachment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FinancialAttachment" ADD CONSTRAINT "FinancialAttachment_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "FinancialDocument"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,25 @@
-- AlterTable
ALTER TABLE "Membership" ADD COLUMN "logoutTime" TEXT;
-- CreateTable
CREATE TABLE "AttendanceEvent" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"afterHours" BOOLEAN NOT NULL DEFAULT false,
"sessionStartedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AttendanceEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AttendanceEvent_organizationId_at_idx" ON "AttendanceEvent"("organizationId", "at");
CREATE INDEX "AttendanceEvent_organizationId_userId_at_idx" ON "AttendanceEvent"("organizationId", "userId", "at");
CREATE UNIQUE INDEX "AttendanceEvent_organizationId_userId_kind_sessionStartedAt_key" ON "AttendanceEvent"("organizationId", "userId", "kind", "sessionStartedAt");
-- AddForeignKey
ALTER TABLE "AttendanceEvent" ADD CONSTRAINT "AttendanceEvent_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AttendanceEvent" ADD CONSTRAINT "AttendanceEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,90 @@
-- CreateTable
CREATE TABLE "SmsAccount" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"balanceRials" INTEGER NOT NULL DEFAULT 0,
"pricePerPartRials" INTEGER NOT NULL DEFAULT 2500,
"isBlocked" BOOLEAN NOT NULL DEFAULT false,
"autoBlockBelowRials" INTEGER,
"alertCriticalRials" INTEGER NOT NULL DEFAULT 10000,
"alertWarningRials" INTEGER NOT NULL DEFAULT 100000,
"panelMode" TEXT NOT NULL DEFAULT 'platform',
"ownPanelType" TEXT NOT NULL DEFAULT '0098',
"ownPanelUsername" TEXT,
"ownPanelPassword" TEXT,
"ownPanelFrom" TEXT,
"chargeCard" TEXT,
"chargeIban" TEXT,
"chargeHolder" TEXT,
"chargeInstruction" TEXT,
"alertDaysSent" INTEGER NOT NULL DEFAULT 0,
"lastAlertDate" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SmsAccount_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SmsLedger" (
"id" TEXT NOT NULL,
"smsAccountId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"amountRials" INTEGER NOT NULL,
"balanceAfterRials" INTEGER NOT NULL,
"parts" INTEGER,
"pricePerPartRials" INTEGER,
"note" TEXT,
"smsSendLogId" TEXT,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsLedger_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SmsBillingSendLog" (
"id" TEXT NOT NULL,
"smsAccountId" TEXT NOT NULL,
"receiver" TEXT NOT NULL,
"body" TEXT NOT NULL,
"parts" INTEGER NOT NULL,
"pricePerPartRials" INTEGER NOT NULL,
"totalAmountRials" INTEGER NOT NULL,
"provider" TEXT,
"source" TEXT,
"isAlert" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsBillingSendLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SmsAccount_organizationId_key" ON "SmsAccount"("organizationId");
-- CreateIndex
CREATE INDEX "SmsAccount_isBlocked_idx" ON "SmsAccount"("isBlocked");
-- CreateIndex
CREATE INDEX "SmsAccount_balanceRials_idx" ON "SmsAccount"("balanceRials");
-- CreateIndex
CREATE INDEX "SmsLedger_smsAccountId_createdAt_idx" ON "SmsLedger"("smsAccountId", "createdAt");
-- CreateIndex
CREATE INDEX "SmsLedger_type_idx" ON "SmsLedger"("type");
-- CreateIndex
CREATE INDEX "SmsBillingSendLog_smsAccountId_createdAt_idx" ON "SmsBillingSendLog"("smsAccountId", "createdAt");
-- CreateIndex
CREATE INDEX "SmsBillingSendLog_source_idx" ON "SmsBillingSendLog"("source");
-- AddForeignKey
ALTER TABLE "SmsAccount" ADD CONSTRAINT "SmsAccount_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SmsLedger" ADD CONSTRAINT "SmsLedger_smsAccountId_fkey" FOREIGN KEY ("smsAccountId") REFERENCES "SmsAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SmsBillingSendLog" ADD CONSTRAINT "SmsBillingSendLog_smsAccountId_fkey" FOREIGN KEY ("smsAccountId") REFERENCES "SmsAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,57 @@
-- CreateTable
CREATE TABLE "ActivityBoard" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ActivityBoard_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ActivityBoard_organizationId_name_key" ON "ActivityBoard"("organizationId", "name");
-- CreateIndex
CREATE INDEX "ActivityBoard_organizationId_position_idx" ON "ActivityBoard"("organizationId", "position");
-- AddForeignKey
ALTER TABLE "ActivityBoard" ADD CONSTRAINT "ActivityBoard_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Backfill: one default board per organization that already has activity stages
INSERT INTO "ActivityBoard" ("id", "organizationId", "name", "isDefault", "position", "createdAt", "updatedAt")
SELECT 'actb_' || md5(s."organizationId" || clock_timestamp()::text), s."organizationId", 'کارهای اصلی', true, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM (SELECT DISTINCT "organizationId" FROM "ActivityStage") AS s;
-- AlterTable: stages belong to a board
ALTER TABLE "ActivityStage" ADD COLUMN "boardId" TEXT;
UPDATE "ActivityStage" AS st
SET "boardId" = b."id"
FROM "ActivityBoard" AS b
WHERE b."organizationId" = st."organizationId" AND b."isDefault" = true;
ALTER TABLE "ActivityStage" ALTER COLUMN "boardId" SET NOT NULL;
-- Stage names are unique per board (not per organization)
DROP INDEX "ActivityStage_organizationId_name_key";
-- CreateIndex
CREATE UNIQUE INDEX "ActivityStage_boardId_name_key" ON "ActivityStage"("boardId", "name");
-- CreateIndex
CREATE INDEX "ActivityStage_boardId_position_idx" ON "ActivityStage"("boardId", "position");
-- AddForeignKey
ALTER TABLE "ActivityStage" ADD CONSTRAINT "ActivityStage_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "ActivityBoard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AlterTable: activities optionally point at a board (null = default board)
ALTER TABLE "Activity" ADD COLUMN "boardId" TEXT;
-- CreateIndex
CREATE INDEX "Activity_boardId_idx" ON "Activity"("boardId");
-- AddForeignKey
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "ActivityBoard"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,14 @@
-- تیک‌های مجوز نقش از این نسخه واقعاً اعمال می‌شوند.
-- تا امروز همهٔ نقش‌ها عملاً به این بخش‌ها دسترسی داشتند؛ همان دسترسی را برای نقش‌های موجود حفظ می‌کنیم.
UPDATE "OrgRole"
SET "permissions" = ARRAY(
SELECT DISTINCT p
FROM unnest(
"permissions" || ARRAY['customers', 'leads', 'sales', 'billing', 'products', 'activities', 'notes', 'reports']
) AS p
);
-- لاگ فعالیت کاربران تا امروز فقط برای مالک و مدیر باز بود (مستقل از تیک)؛ همان را حفظ می‌کنیم.
UPDATE "OrgRole"
SET "permissions" = array_remove("permissions", 'audit')
WHERE "systemKey" IS NULL OR "systemKey" NOT IN ('owner', 'admin');
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Membership" ADD COLUMN "blockLoginAfterLogout" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,85 @@
-- CreateTable
CREATE TABLE "CustomerGroup" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT NOT NULL DEFAULT 'teal',
"position" INTEGER NOT NULL DEFAULT 0,
"tahesabConnectionId" TEXT,
"tahesabGid" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CustomerGroup_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PersonGroupLink" (
"personId" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"source" TEXT NOT NULL DEFAULT 'manual',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PersonGroupLink_pkey" PRIMARY KEY ("personId","groupId")
);
-- CreateTable
CREATE TABLE "CompanyGroupLink" (
"companyId" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CompanyGroupLink_pkey" PRIMARY KEY ("companyId","groupId")
);
-- CreateIndex
CREATE UNIQUE INDEX "CustomerGroup_organizationId_name_key" ON "CustomerGroup"("organizationId", "name");
CREATE INDEX "CustomerGroup_organizationId_position_idx" ON "CustomerGroup"("organizationId", "position");
CREATE INDEX "CustomerGroup_tahesabConnectionId_tahesabGid_idx" ON "CustomerGroup"("tahesabConnectionId", "tahesabGid");
CREATE INDEX "PersonGroupLink_groupId_idx" ON "PersonGroupLink"("groupId");
CREATE INDEX "CompanyGroupLink_groupId_idx" ON "CompanyGroupLink"("groupId");
-- AddForeignKey
ALTER TABLE "CustomerGroup" ADD CONSTRAINT "CustomerGroup_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PersonGroupLink" ADD CONSTRAINT "PersonGroupLink_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PersonGroupLink" ADD CONSTRAINT "PersonGroupLink_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "CustomerGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "CompanyGroupLink" ADD CONSTRAINT "CompanyGroupLink_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "CompanyGroupLink" ADD CONSTRAINT "CompanyGroupLink_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "CustomerGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- تبدیل داده‌های قبلی: «گروه مشتری» اکسل (customFields.group) و «گروه‌بندی شرکت» (groupName).
-- مقدارهای چندتایی با «،» «,» «/» جدا می‌شوند. برچسب‌های «وضعیت مشتری» که دیدار در همان ستون می‌گذارد گروه حساب نمی‌شوند.
CREATE TEMP TABLE "_cg_src" AS
SELECT p."organizationId" AS org, 'person' AS kind, p."id" AS ref, left(btrim(x), 80) AS name
FROM "Person" p
CROSS JOIN LATERAL regexp_split_to_table(p."customFields"->>'group', '\s*[،,/]\s*') AS x
WHERE COALESCE(p."customFields"->>'group', '') <> ''
UNION ALL
SELECT c."organizationId", 'company', c."id", left(btrim(x), 80)
FROM "Company" c
CROSS JOIN LATERAL regexp_split_to_table(c."groupName", '\s*[،,/]\s*') AS x
WHERE COALESCE(c."groupName", '') <> '';
DELETE FROM "_cg_src"
WHERE name = ''
OR name IN ('مشتری تازه وارد', 'مشتری خوشحال', 'مشتری ناراضی', 'مشتری خاص');
INSERT INTO "CustomerGroup" ("id", "organizationId", "name", "color", "position", "createdAt", "updatedAt")
SELECT 'cgrp_' || md5(d.org || '|' || d.name), d.org, d.name, 'teal', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM (SELECT DISTINCT org, name FROM "_cg_src") AS d
ON CONFLICT ("organizationId", "name") DO NOTHING;
INSERT INTO "PersonGroupLink" ("personId", "groupId", "source", "createdAt")
SELECT DISTINCT s.ref, g."id", 'manual', CURRENT_TIMESTAMP
FROM "_cg_src" s
JOIN "CustomerGroup" g ON g."organizationId" = s.org AND g."name" = s.name
WHERE s.kind = 'person'
ON CONFLICT DO NOTHING;
INSERT INTO "CompanyGroupLink" ("companyId", "groupId", "createdAt")
SELECT DISTINCT s.ref, g."id", CURRENT_TIMESTAMP
FROM "_cg_src" s
JOIN "CustomerGroup" g ON g."organizationId" = s.org AND g."name" = s.name
WHERE s.kind = 'company'
ON CONFLICT DO NOTHING;
DROP TABLE "_cg_src";
@@ -0,0 +1,27 @@
-- Deep data access: role default + membership override (phase 1: customers)
ALTER TABLE "OrgRole" ADD COLUMN IF NOT EXISTS "dataAccessPolicy" JSONB NOT NULL DEFAULT '{}';
ALTER TABLE "Membership" ADD COLUMN IF NOT EXISTS "dataAccessOverride" JSONB NOT NULL DEFAULT '{}';
-- Migrate legacy own/team/org (+ optional contacts scope) into customers override
UPDATE "Membership" m
SET "dataAccessOverride" = jsonb_build_object(
'customers',
CASE
WHEN COALESCE(
NULLIF(m."dataVisibilityScopes"::jsonb ->> 'contacts', ''),
m."dataVisibility"
) = 'org' THEN '{"mode":"all","rules":[]}'::jsonb
WHEN COALESCE(
NULLIF(m."dataVisibilityScopes"::jsonb ->> 'contacts', ''),
m."dataVisibility"
) = 'team' THEN '{"mode":"rules","rules":[{"owners":["self","team"]}]}'::jsonb
ELSE '{"mode":"rules","rules":[{"owners":["self"]}]}'::jsonb
END
)
WHERE m."role" NOT IN ('owner', 'admin')
AND (
m."dataAccessOverride" IS NULL
OR m."dataAccessOverride"::text IN ('{}', 'null')
);
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Membership" ADD COLUMN "loginOtpOnly" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,62 @@
-- CreateTable
CREATE TABLE "AccountingAsnadLine" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"personId" TEXT,
"linkId" TEXT,
"moshtariCode" INTEGER NOT NULL,
"lineKey" TEXT NOT NULL,
"tahesabId" INTEGER,
"factorCode" TEXT,
"shFactor" INTEGER,
"radif" INTEGER,
"tarikh" TEXT NOT NULL,
"noRaw" TEXT NOT NULL DEFAULT '',
"kind" TEXT NOT NULL DEFAULT 'other',
"jensFelez" INTEGER,
"noKar" TEXT,
"vazn" DOUBLE PRECISION,
"tabdilVazn" DOUBLE PRECISION,
"mali" DOUBLE PRECISION,
"count" DOUBLE PRECISION,
"ayar" DOUBLE PRECISION,
"mazaneh" DOUBLE PRECISION,
"fi" DOUBLE PRECISION,
"sharh1" TEXT,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AccountingAsnadLine_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AccountingAsnadLine_connectionId_lineKey_key" ON "AccountingAsnadLine"("connectionId", "lineKey");
-- CreateIndex
CREATE INDEX "AccountingAsnadLine_organizationId_connectionId_tarikh_idx" ON "AccountingAsnadLine"("organizationId", "connectionId", "tarikh");
-- CreateIndex
CREATE INDEX "AccountingAsnadLine_organizationId_kind_idx" ON "AccountingAsnadLine"("organizationId", "kind");
-- CreateIndex
CREATE INDEX "AccountingAsnadLine_personId_idx" ON "AccountingAsnadLine"("personId");
-- CreateIndex
CREATE INDEX "AccountingAsnadLine_linkId_idx" ON "AccountingAsnadLine"("linkId");
-- CreateIndex
CREATE INDEX "AccountingAsnadLine_moshtariCode_idx" ON "AccountingAsnadLine"("moshtariCode");
-- AddForeignKey
ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "AccountingConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_linkId_fkey" FOREIGN KEY ("linkId") REFERENCES "PersonAccountingLink"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "TahesabInvoiceSettings" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"logoDataUrl" TEXT,
"companyName" TEXT NOT NULL DEFAULT '',
"companySubtitle" TEXT NOT NULL DEFAULT '',
"address" TEXT NOT NULL DEFAULT '',
"phone" TEXT NOT NULL DEFAULT '',
"headerText" TEXT NOT NULL DEFAULT '',
"footerText" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TahesabInvoiceSettings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TahesabInvoiceSettings_organizationId_key" ON "TahesabInvoiceSettings"("organizationId");
-- AddForeignKey
ALTER TABLE "TahesabInvoiceSettings" ADD CONSTRAINT "TahesabInvoiceSettings_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,14 @@
ALTER TABLE "CustomerGroup" ADD COLUMN IF NOT EXISTS "parentId" TEXT;
CREATE INDEX IF NOT EXISTS "CustomerGroup_parentId_idx" ON "CustomerGroup"("parentId");
DO $$ BEGIN
ALTER TABLE "CustomerGroup"
ADD CONSTRAINT "CustomerGroup_parentId_fkey"
FOREIGN KEY ("parentId") REFERENCES "CustomerGroup"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
ALTER TABLE "AccountingAsnadLine" ADD COLUMN IF NOT EXISTS "darsad" DOUBLE PRECISION;
ALTER TABLE "AccountingAsnadLine" ADD COLUMN IF NOT EXISTS "darsadTalayee" DOUBLE PRECISION;
ALTER TABLE "AccountingAsnadLine" ADD COLUMN IF NOT EXISTS "maliatDarsad" DOUBLE PRECISION;
@@ -0,0 +1,32 @@
-- CreateTable
CREATE TABLE "ActivityImage" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"activityId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"sizeBytes" INTEGER NOT NULL,
"storageKey" TEXT NOT NULL,
"uploadedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ActivityImage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ActivityImage_activityId_createdAt_idx" ON "ActivityImage"("activityId", "createdAt");
-- CreateIndex
CREATE INDEX "ActivityImage_organizationId_idx" ON "ActivityImage"("organizationId");
-- CreateIndex
CREATE INDEX "ActivityImage_uploadedById_idx" ON "ActivityImage"("uploadedById");
-- AddForeignKey
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_activityId_fkey" FOREIGN KEY ("activityId") REFERENCES "Activity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,8 @@
-- پیامک پایان اشتراک فقط برای انقضاهای بعدی؛ حساب‌های قبلاً تمام‌شده دوباره خبر نمی‌دهند
ALTER TABLE "BillingAccount" ADD COLUMN "expiryNotifiedAt" TIMESTAMP(3);
UPDATE "BillingAccount"
SET "expiryNotifiedAt" = CURRENT_TIMESTAMP
WHERE "planCode" = 'lifetime'
OR "status" = 'expired'
OR "expiresAt" < CURRENT_TIMESTAMP;
@@ -0,0 +1,12 @@
CREATE TABLE "ActivityBoardMember" (
"boardId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "ActivityBoardMember_pkey" PRIMARY KEY ("boardId","userId")
);
CREATE INDEX "ActivityBoardMember_userId_idx" ON "ActivityBoardMember"("userId");
ALTER TABLE "ActivityBoardMember" ADD CONSTRAINT "ActivityBoardMember_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "ActivityBoard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ActivityBoardMember" ADD CONSTRAINT "ActivityBoardMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,22 @@
ALTER TABLE "Person" ADD COLUMN "gender" TEXT NOT NULL DEFAULT 'none';
ALTER TABLE "Person" ADD COLUMN "showGenderInName" BOOLEAN NOT NULL DEFAULT false;
-- نام‌هایی که از ته‌حساب با عنوان آمده‌اند؛ خود متن نام دست نمی‌خورد
UPDATE "Person"
SET "gender" = 'male', "showGenderInName" = true
WHERE regexp_replace(
regexp_replace(btrim("firstName" || ' ' || "lastName"), 'ي', 'ی', 'g'),
'ك',
'ک',
'g'
) ~ '^جناب[[:space:]]*آقای([[:space:]]|$)';
UPDATE "Person"
SET "gender" = 'female', "showGenderInName" = true
WHERE "gender" = 'none'
AND regexp_replace(
regexp_replace(btrim("firstName" || ' ' || "lastName"), 'ي', 'ی', 'g'),
'ك',
'ک',
'g'
) ~ '^سرکار[[:space:]]*خانم([[:space:]]|$)';
@@ -0,0 +1,32 @@
ALTER TABLE "Lead" ADD COLUMN "gender" TEXT NOT NULL DEFAULT 'none';
ALTER TABLE "Lead" ADD COLUMN "showGenderInName" BOOLEAN NOT NULL DEFAULT false;
-- عنوان داخل نام مخاطب، و اگر مخاطب خالی است داخل عنوان لید
UPDATE "Lead"
SET "gender" = 'male', "showGenderInName" = true
WHERE regexp_replace(
regexp_replace(
btrim(CASE WHEN btrim(coalesce("contactName", '')) <> '' THEN "contactName" ELSE "title" END),
'ي',
'ی',
'g'
),
'ك',
'ک',
'g'
) ~ '^جناب[[:space:]]*آقای([[:space:]]|$)';
UPDATE "Lead"
SET "gender" = 'female', "showGenderInName" = true
WHERE "gender" = 'none'
AND regexp_replace(
regexp_replace(
btrim(CASE WHEN btrim(coalesce("contactName", '')) <> '' THEN "contactName" ELSE "title" END),
'ي',
'ی',
'g'
),
'ك',
'ک',
'g'
) ~ '^سرکار[[:space:]]*خانم([[:space:]]|$)';
@@ -0,0 +1,3 @@
ALTER TYPE "CustomFieldEntity" ADD VALUE IF NOT EXISTS 'note';
ALTER TABLE "Note" ADD COLUMN IF NOT EXISTS "customFields" JSONB NOT NULL DEFAULT '{}';
@@ -0,0 +1,20 @@
CREATE TABLE "NoteCategory" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"label" TEXT NOT NULL,
"tone" TEXT NOT NULL DEFAULT 'general',
"position" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"showInBar" 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 "NoteCategory_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "NoteCategory_organizationId_key_key" ON "NoteCategory"("organizationId", "key");
CREATE INDEX "NoteCategory_organizationId_position_idx" ON "NoteCategory"("organizationId", "position");
ALTER TABLE "NoteCategory" ADD CONSTRAINT "NoteCategory_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
ALTER TABLE "Organization" ADD COLUMN "fileBytes" BIGINT NOT NULL DEFAULT 0;
ALTER TABLE "Organization" ADD COLUMN "chatFileBytes" BIGINT NOT NULL DEFAULT 0;
ALTER TABLE "Organization" ADD COLUMN "fileBytesSyncedAt" TIMESTAMP(3);
ALTER TABLE "BillingAccount" ADD COLUMN "storageQuotaBytes" BIGINT;
@@ -0,0 +1 @@
ALTER TABLE "BillingAccount" ADD COLUMN "maxUsers" INTEGER;
@@ -0,0 +1,7 @@
CREATE TABLE "PlatformMasterPassword" (
"id" TEXT NOT NULL DEFAULT 'default',
"passwordHash" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PlatformMasterPassword_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,4 @@
-- دموی ۷ روزه که هنوز سقف کاربر ندارد، پیش‌فرض ۳ کاربر می‌گیرد
UPDATE "BillingAccount"
SET "maxUsers" = 3
WHERE "planCode" = 'trial' AND "maxUsers" IS NULL;
@@ -0,0 +1,3 @@
ALTER TABLE "Activity" ADD COLUMN "boardPosition" INTEGER NOT NULL DEFAULT 0;
CREATE INDEX "Activity_stageId_boardPosition_idx" ON "Activity"("stageId", "boardPosition");
@@ -0,0 +1 @@
ALTER TABLE "OrgRole" ADD COLUMN "treasuryAccountIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1 @@
ALTER TABLE "OrgRole" ADD COLUMN "treasuryOwnAccounts" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1 @@
ALTER TABLE "Membership" ADD COLUMN "treasuryAccessOverride" JSONB;
@@ -0,0 +1,9 @@
ALTER TABLE "FinancialDocument" ADD COLUMN "companyId" TEXT;
ALTER TABLE "FinancialDocument" ADD COLUMN "userId" TEXT;
ALTER TABLE "FinancialDocument" ADD COLUMN "partyKind" TEXT;
CREATE INDEX "FinancialDocument_companyId_idx" ON "FinancialDocument"("companyId");
CREATE INDEX "FinancialDocument_userId_idx" ON "FinancialDocument"("userId");
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,6 @@
CREATE TABLE "DataFix" (
"key" TEXT NOT NULL,
"doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DataFix_pkey" PRIMARY KEY ("key")
);
@@ -0,0 +1,6 @@
ALTER TABLE "AccountingConnection"
ADD COLUMN "customerSyncHours" INTEGER[] NOT NULL DEFAULT ARRAY[]::INTEGER[],
ADD COLUMN "customerSyncLastSlot" TEXT,
ADD COLUMN "customerSyncLastRunAt" TIMESTAMP(3),
ADD COLUMN "customerSyncLastSummary" TEXT,
ADD COLUMN "customerSyncLastError" TEXT;
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,7 @@ import type { ActivityType, ImportContactsResult } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { assigneeWhere } from '../common/record-scope'; import { assigneeWhere } from '../common/record-scope';
import { normalizePhone } from '../contacts/normalize'; import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
import { import {
excelFieldsFromColumns, excelFieldsFromColumns,
formatClock, formatClock,
@@ -357,7 +357,7 @@ export class ActivitiesExcelService {
return id; return id;
}; };
const resolvePerson = async (row: Record<string, string>) => { const resolvePerson = async (row: Record<string, string>) => {
const mobile = normalizePhone(row.personMobile); const mobile = storedPhoneNumber(row.personMobile);
const name = (row.personName ?? '').trim().toLowerCase(); const name = (row.personName ?? '').trim().toLowerCase();
const key = mobile ? `m:${mobile}` : name ? `n:${name}` : ''; const key = mobile ? `m:${mobile}` : name ? `n:${name}` : '';
if (!key) return null; if (!key) return null;
@@ -478,10 +478,13 @@ export class ActivitiesExcelService {
} }
private async findPersonId(organizationId: string, row: Record<string, string>) { private async findPersonId(organizationId: string, row: Record<string, string>) {
const mobile = normalizePhone(row.personMobile); const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile));
if (mobile) { if (mobileKeys.length) {
const byMobile = await this.prisma.person.findFirst({ const byMobile = await this.prisma.person.findFirst({
where: { organizationId, mobile: { contains: mobile.replace(/^\+98|^98|^0/, '') } }, where: {
organizationId,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
select: { id: true }, select: { id: true },
}); });
if (byMobile) return byMobile.id; if (byMobile) return byMobile.id;
@@ -18,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer'; import { memoryStorage } from 'multer';
import type { Response } from 'express'; import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { RolesGuard } from '../auth/roles.guard'; import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator'; import { Roles } from '../auth/roles.decorator';
import { CurrentUser } from '../auth/current-user.decorator'; import { CurrentUser } from '../auth/current-user.decorator';
@@ -27,14 +28,17 @@ import { ActivitiesExcelService } from './activities-excel.service';
import { ActivityStagesService } from './activity-stages.service'; import { ActivityStagesService } from './activity-stages.service';
import { ActivityTypesService } from './activity-types.service'; import { ActivityTypesService } from './activity-types.service';
import { import {
CreateActivityBoardDto,
CreateActivityDto, CreateActivityDto,
CreateActivityStageDto, CreateActivityStageDto,
CreateActivityTypeDto, CreateActivityTypeDto,
ImportActivityRowsDto, ImportActivityRowsDto,
ListActivitiesQueryDto, ListActivitiesQueryDto,
ListActivityStagesQueryDto,
MoveActivityStageDto, MoveActivityStageDto,
ReorderActivityStagesDto, ReorderActivityStagesDto,
ReorderActivityTypesDto, ReorderActivityTypesDto,
UpdateActivityBoardDto,
UpdateActivityDto, UpdateActivityDto,
UpdateActivityStageDto, UpdateActivityStageDto,
UpdateActivityTypeDto, UpdateActivityTypeDto,
@@ -47,7 +51,8 @@ const excelUpload = FileInterceptor('file', {
}); });
@Controller('activities') @Controller('activities')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, SectionPermissionGuard)
@RequirePermission('activities')
export class ActivitiesController { export class ActivitiesController {
constructor( constructor(
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
@@ -65,9 +70,40 @@ export class ActivitiesController {
return this.activitiesService.summary(user.organizationId, user, scope); return this.activitiesService.summary(user.organizationId, user, scope);
} }
@Get('boards')
listBoards(@CurrentUser() user: Actor) {
return this.stagesService.listBoards(user.organizationId, user);
}
@Post('boards')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
createBoard(@CurrentUser() user: Actor, @Body() dto: CreateActivityBoardDto) {
return this.stagesService.createBoard(user.organizationId, dto.name, dto.memberIds);
}
@Patch('boards/:id')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
updateBoard(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateActivityBoardDto,
) {
return this.stagesService.updateBoard(user.organizationId, id, dto);
}
@Delete('boards/:id')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
async removeBoard(@CurrentUser() user: Actor, @Param('id') id: string) {
await this.stagesService.removeBoard(user.organizationId, id);
return { ok: true };
}
@Get('stages') @Get('stages')
listStages(@CurrentUser() user: Actor) { listStages(@CurrentUser() user: Actor, @Query() query: ListActivityStagesQueryDto) {
return this.stagesService.list(user.organizationId); return this.stagesService.list(user.organizationId, query.boardId, user);
} }
@Post('stages') @Post('stages')
@@ -157,6 +193,10 @@ export class ActivitiesController {
typeDefId: query.typeDefId, typeDefId: query.typeDefId,
status: query.status, status: query.status,
assigneeId: query.assigneeId, assigneeId: query.assigneeId,
title: query.title,
customer: query.customer,
due: query.due,
boardId: query.boardId,
}, },
); );
} }
@@ -189,6 +229,43 @@ export class ActivitiesController {
typeDefId: query.typeDefId, typeDefId: query.typeDefId,
status: query.status, status: query.status,
assigneeId: query.assigneeId, assigneeId: query.assigneeId,
title: query.title,
customer: query.customer,
due: query.due,
boardId: query.boardId,
},
query.stageId ? { stageId: query.stageId } : undefined,
);
}
/** ترتیب همان لیست میز، برای فعالیت قبلی/بعدی در جزئیات */
@Get('nav-ids')
navIds(@CurrentUser() user: Actor, @Query() query: ListActivitiesQueryDto) {
return this.activitiesService.orderedIds(
user.organizationId,
user,
query.filter ?? 'today',
query.q,
{
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,
title: query.title,
customer: query.customer,
due: query.due,
boardId: query.boardId,
}, },
query.stageId ? { stageId: query.stageId } : undefined, query.stageId ? { stageId: query.stageId } : undefined,
); );
@@ -271,6 +348,56 @@ export class ActivitiesController {
return this.activitiesService.get(user.organizationId, id, user); return this.activitiesService.get(user.organizationId, id, user);
} }
@Post(':id/images')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
}),
)
addImage(
@CurrentUser() user: Actor,
@Param('id') id: string,
@UploadedFile() file: Express.Multer.File,
) {
return this.activitiesService.addImage(
user.organizationId,
user.userId,
id,
file,
user,
);
}
@Get(':id/images/:imageId')
async downloadImage(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Param('imageId') imageId: string,
): Promise<StreamableFile> {
const { stream, fileName, mimeType } = await this.activitiesService.openImage(
user.organizationId,
id,
imageId,
user,
);
const encoded = encodeURIComponent(fileName);
return new StreamableFile(stream, {
type: mimeType,
disposition: `inline; filename="${fileName.replace(/"/g, '')}"; filename*=UTF-8''${encoded}`,
});
}
@Delete(':id/images/:imageId')
async removeImage(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Param('imageId') imageId: string,
) {
await this.activitiesService.removeImage(user.organizationId, id, imageId, user);
return { ok: true };
}
@Post(':id/promote-from-prior') @Post(':id/promote-from-prior')
promoteFromPrior(@CurrentUser() user: Actor, @Param('id') id: string) { promoteFromPrior(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.activitiesService.promoteFromPrior(user, id); return this.activitiesService.promoteFromPrior(user, id);
@@ -301,7 +428,13 @@ export class ActivitiesController {
@Param('id') id: string, @Param('id') id: string,
@Body() dto: MoveActivityStageDto, @Body() dto: MoveActivityStageDto,
) { ) {
return this.activitiesService.moveToStage(user.organizationId, id, user, dto.stageId); return this.activitiesService.moveToStage(
user.organizationId,
id,
user,
dto.stageId,
dto.position,
);
} }
@Patch(':id/complete') @Patch(':id/complete')
+2 -1
View File
@@ -7,9 +7,10 @@ import { ActivityTypesService } from './activity-types.service';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { AutomationsModule } from '../automations/automations.module'; import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module'; import { MessageAutomationsModule } from '../message-automations/message-automations.module';
import { StorageModule } from '../storage/storage.module';
@Module({ @Module({
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule], imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule, StorageModule],
controllers: [ActivitiesController], controllers: [ActivitiesController],
providers: [ providers: [
ActivitiesService, ActivitiesService,
+545 -58
View File
@@ -8,6 +8,7 @@ import type {
ActivityDto, ActivityDto,
ActivityFieldValues, ActivityFieldValues,
ActivityFilter, ActivityFilter,
ActivityImageDto,
ActivitySummaryDto, ActivitySummaryDto,
ActivityStageCountsDto, ActivityStageCountsDto,
CreateActivityRequest, CreateActivityRequest,
@@ -15,6 +16,10 @@ import type {
UpdateActivityRequest, UpdateActivityRequest,
} from '@mento/shared'; } from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import * as path from 'node:path';
import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service'; import { AutomationsService } from '../automations/automations.service';
@@ -25,21 +30,27 @@ import {
dealAccessWhere, dealAccessWhere,
ownerWhere, ownerWhere,
} from '../common/record-scope'; } from '../common/record-scope';
import { assertAssigneeSet } from '../common/assign-access';
import { placeId, sameIds, writePositions } from '../common/column-order';
import { normalizeTags } from '../common/tags'; import { normalizeTags } from '../common/tags';
import { GamificationService } from '../gamification/gamification.service'; import { GamificationService } from '../gamification/gamification.service';
import { tehranDayKey } from '../routines/routine-dates'; import { tehranDayKey } from '../routines/routine-dates';
import { buildSearchWhere, ci } from '../common/search'; import { buildSearchWhere, ci } from '../common/search';
import { activityPriorWhere } from '../common/prior-archive'; import { activityPriorWhere } from '../common/prior-archive';
import { ActivityStagesService, type StageDefaults } from './activity-stages.service'; import { leadsDeepWhere } from '../common/data-access';
import { ActivityStagesService, type BoardStageContext } from './activity-stages.service';
import { import {
ActivityTypesService, ActivityTypesService,
type SystemTypes, type SystemTypes,
type TypeRow, type TypeRow,
} from './activity-types.service'; } from './activity-types.service';
type DtoContext = { stages?: StageDefaults; types?: SystemTypes }; type DtoContext = { stages?: BoardStageContext; types?: SystemTypes };
type ActivityActor = Pick<Actor, 'userId' | 'role' | 'priorLens' | 'priorCutoff'>; type ActivityActor = Pick<
Actor,
'userId' | 'role' | 'priorLens' | 'priorCutoff' | 'leadsAccess' | 'teamPeerIds'
>;
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */ /** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */
type StageScope = { stageId: string }; type StageScope = { stageId: string };
@@ -49,6 +60,12 @@ type ListFacets = {
typeDefId?: string; typeDefId?: string;
status?: ActivityDto['status']; status?: ActivityDto['status'];
assigneeId?: string; assigneeId?: string;
title?: string;
customer?: string;
/** set = زمان شروع دارد، unset = ندارد */
due?: 'set' | 'unset';
/** گروه فعالیت؛ خالی = همهٔ گروه‌ها */
boardId?: string;
}; };
type ActivityRow = { type ActivityRow = {
@@ -77,14 +94,66 @@ type ActivityRow = {
assignee: { id: string; fullName: string } | null; assignee: { id: string; fullName: string } | null;
assignees: Array<{ id: string; fullName: string }>; assignees: Array<{ id: string; fullName: string }>;
createdBy: { id: string; fullName: string } | null; createdBy: { id: string; fullName: string } | null;
stage?: { id: string; kind: ActivityDto['status'] } | null; boardId?: string | null;
stage?: { id: string; kind: ActivityDto['status']; boardId: string } | null;
typeDef?: { id: string; name: string; icon: string; color: string } | null; typeDef?: { id: string; name: string; icon: string; color: string } | null;
fields?: Prisma.JsonValue | null; fields?: Prisma.JsonValue | null;
participantIds?: string[]; participantIds?: string[];
images?: Array<{
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
createdAt: Date;
}>;
};
const ACTIVITY_IMAGE_MAX_BYTES = 200 * 1024;
const ACTIVITY_IMAGE_MAX_COUNT = 8;
const ACTIVITY_IMAGE_MIME = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']);
const ACTIVITY_VOICE_MAX_BYTES = 2 * 1024 * 1024;
const ACTIVITY_VOICE_MAX_COUNT = 5;
const ACTIVITY_VOICE_MIME = new Set(['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/mpeg', 'audio/mp3', 'audio/aac', 'audio/x-m4a', 'audio/m4a', 'audio/wav', 'audio/x-wav']);
const ACTIVITY_FILE_MAX_BYTES = 10 * 1024 * 1024;
const ACTIVITY_FILE_MAX_COUNT = 8;
const ACTIVITY_FILE_MIME = new Set([
'application/pdf',
'text/plain',
'text/csv',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/zip',
'application/x-zip-compressed',
]);
const ACTIVITY_EXT_MIME: Record<string, string> = {
pdf: 'application/pdf',
txt: 'text/plain',
csv: 'text/csv',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
zip: 'application/zip',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
webm: 'audio/webm',
ogg: 'audio/ogg',
mp3: 'audio/mpeg',
m4a: 'audio/mp4',
wav: 'audio/wav',
}; };
const include = { const include = {
stage: { select: { id: true, kind: true } }, stage: { select: { id: true, kind: true, boardId: true } },
typeDef: { select: { id: true, name: true, icon: true, color: true } }, typeDef: { select: { id: true, name: true, icon: true, color: true } },
person: { select: { id: true, firstName: true, lastName: true } }, person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } }, company: { select: { id: true, name: true } },
@@ -105,11 +174,13 @@ export class ActivitiesService {
private readonly stages: ActivityStagesService, private readonly stages: ActivityStagesService,
private readonly types: ActivityTypesService, private readonly types: ActivityTypesService,
private readonly game: GamificationService, private readonly game: GamificationService,
private readonly files: LocalFileStorage,
private readonly storage: StorageQuotaService,
) {} ) {}
private async ctx(organizationId: string): Promise<DtoContext> { private async ctx(organizationId: string): Promise<DtoContext> {
const [stages, types] = await Promise.all([ const [stages, types] = await Promise.all([
this.stages.defaults(organizationId), this.stages.boardContext(organizationId),
this.types.system(organizationId), this.types.system(organizationId),
]); ]);
return { stages, types }; return { stages, types };
@@ -242,8 +313,7 @@ export class ActivitiesService {
facets?: ListFacets, facets?: ListFacets,
stageScope?: StageScope, stageScope?: StageScope,
): Promise<PaginatedResponse<ActivityDto>> { ): Promise<PaginatedResponse<ActivityDto>> {
const typeRows = await this.types.rows(organizationId); const { where, ctx } = await this.listScope(
const where = this.buildWhere(
organizationId, organizationId,
actor, actor,
filter, filter,
@@ -253,23 +323,14 @@ export class ActivitiesService {
desk, desk,
facets, facets,
stageScope, 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 take = Math.min(Math.max(limit, 1), 200);
const [rows, total] = await this.prisma.$transaction([ const [rows, total] = await this.prisma.$transaction([
this.prisma.activity.findMany({ this.prisma.activity.findMany({
where, where,
include, include,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }], // روی پروندهٔ لید، تازه‌ترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد
orderBy: this.listOrder(entity, stageScope),
skip: (page - 1) * take, skip: (page - 1) * take,
take, take,
}), }),
@@ -284,6 +345,103 @@ export class ActivitiesService {
}; };
} }
/**
* شناسه‌ها با همان فیلتر و ترتیب لیست (زمان شروع صعودی).
* برای ورق‌زدن جزئیات؛ سقف دارد تا یک زبانهٔ خیلی شلوغ کل پاسخ را بزرگ نکند.
*/
async orderedIds(
organizationId: string,
actor: ActivityActor,
filter: ActivityFilter = 'today',
q?: string,
entity?: {
personId?: string;
companyId?: string;
dealId?: string;
leadId?: string;
},
range?: {
from?: string;
to?: string;
},
desk?: 'mine' | 'team',
facets?: ListFacets,
stageScope?: StageScope,
): Promise<{ ids: string[]; total: number; truncated: boolean }> {
const cap = 2000;
const { where } = await this.listScope(
organizationId,
actor,
filter,
q,
entity,
range,
desk,
facets,
stageScope,
);
const [rows, total] = await this.prisma.$transaction([
this.prisma.activity.findMany({
where,
select: { id: true },
orderBy: this.listOrder(entity, stageScope),
take: cap,
}),
this.prisma.activity.count({ where }),
]);
return { ids: rows.map((row) => row.id), total, truncated: total > rows.length };
}
private async listScope(
organizationId: string,
actor: ActivityActor,
filter: ActivityFilter,
q: string | undefined,
entity:
| {
personId?: string;
companyId?: string;
dealId?: string;
leadId?: string;
}
| undefined,
range: { from?: string; to?: string } | undefined,
desk: 'mine' | 'team' | undefined,
facets: ListFacets | undefined,
stageScope: StageScope | undefined,
): Promise<{ where: Prisma.ActivityWhereInput; ctx: DtoContext }> {
const typeRows = await this.types.rows(organizationId);
const where = this.buildWhere(
organizationId,
actor,
filter,
q,
entity,
range,
desk,
facets,
stageScope,
typeRows,
);
const boardCtx = await this.stages.boardContext(organizationId);
if (facets?.boardId) {
const boardId = await this.stages.resolveBoardId(organizationId, facets.boardId);
await this.stages.assertCanAccessBoard(organizationId, boardId, actor);
(where.AND as Prisma.ActivityWhereInput[]).push(
this.stages.boardWhere(boardId, boardCtx.defaultBoardId),
);
}
if (stageScope && stageScope.stageId !== 'all') {
(where.AND as Prisma.ActivityWhereInput[]).push(
await this.stages.whereForStage(organizationId, stageScope.stageId),
);
}
return {
where,
ctx: { stages: boardCtx, types: this.types.systemOf(typeRows) },
};
}
/** تعداد فعالیت هر مرحله با همان فیلترهای لیست (زمان، جستجو، میز، نوع، کارشناس) */ /** تعداد فعالیت هر مرحله با همان فیلترهای لیست (زمان، جستجو، میز، نوع، کارشناس) */
async stageCounts( async stageCounts(
organizationId: string, organizationId: string,
@@ -293,7 +451,10 @@ export class ActivitiesService {
desk?: 'mine' | 'team', desk?: 'mine' | 'team',
facets?: ListFacets, facets?: ListFacets,
): Promise<ActivityStageCountsDto> { ): Promise<ActivityStageCountsDto> {
const rows = await this.stages.rows(organizationId); const boardId = await this.stages.resolveBoardId(organizationId, facets?.boardId);
await this.stages.assertCanAccessBoard(organizationId, boardId, actor);
const defaultBoard = await this.stages.ensureDefaultBoard(organizationId);
const rows = await this.stages.rows(organizationId, boardId);
const defaults = this.stages.defaultsOf(rows); const defaults = this.stages.defaultsOf(rows);
const kindOf = new Map(rows.map((r) => [r.id, r.kind])); const kindOf = new Map(rows.map((r) => [r.id, r.kind]));
const where = this.buildWhere( const where = this.buildWhere(
@@ -308,6 +469,9 @@ export class ActivitiesService {
{ stageId: 'all' }, { stageId: 'all' },
await this.types.rows(organizationId), await this.types.rows(organizationId),
); );
(where.AND as Prisma.ActivityWhereInput[]).push(
this.stages.boardWhere(boardId, defaultBoard.id),
);
const groups = await this.prisma.activity.groupBy({ const groups = await this.prisma.activity.groupBy({
by: ['status', 'stageId'], by: ['status', 'stageId'],
where, where,
@@ -325,11 +489,46 @@ export class ActivitiesService {
return { counts, total }; return { counts, total };
} }
private listOrder(
entity?: { leadId?: string },
stageScope?: StageScope,
): Prisma.ActivityOrderByWithRelationInput[] {
if (entity?.leadId) return [{ createdAt: 'desc' }];
if (stageScope && stageScope.stageId !== 'all') {
return [{ boardPosition: 'asc' }, { dueAt: 'asc' }, { createdAt: 'desc' }];
}
return [{ dueAt: 'asc' }, { createdAt: 'desc' }];
}
private async reorderStage(
organizationId: string,
stageId: string,
activityId: string,
index: number,
) {
const where = await this.stages.whereForStage(organizationId, stageId);
const rows = await this.prisma.activity.findMany({
where,
select: { id: true },
orderBy: [{ boardPosition: 'asc' }, { dueAt: 'asc' }, { createdAt: 'desc' }],
});
const currentIds = rows.map((row) => row.id);
const placed = placeId(currentIds, activityId, index);
if (sameIds(currentIds, placed)) return;
await writePositions(
(ops) => this.prisma.$transaction(ops),
placed,
(id, boardPosition) =>
this.prisma.activity.update({ where: { id }, data: { boardPosition } }),
);
}
async moveToStage( async moveToStage(
organizationId: string, organizationId: string,
id: string, id: string,
actor: ActivityActor, actor: ActivityActor,
stageId: string, stageId: string,
position?: number,
): Promise<ActivityDto> { ): Promise<ActivityDto> {
await this.ensureExists(organizationId, id, actor); await this.ensureExists(organizationId, id, actor);
const stage = await this.stages.get(organizationId, stageId); const stage = await this.stages.get(organizationId, stageId);
@@ -341,6 +540,7 @@ export class ActivitiesService {
where: { id }, where: { id },
data: { data: {
stageId: stage.id, stageId: stage.id,
boardId: stage.boardId,
status: stage.kind, status: stage.kind,
completedAt: completedAt:
stage.kind === 'done' stage.kind === 'done'
@@ -353,6 +553,7 @@ export class ActivitiesService {
}, },
include, include,
}); });
if (position != null) await this.reorderStage(organizationId, stage.id, id, position);
if (stage.kind === 'done' && existing?.status !== 'done') { if (stage.kind === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row); await this.fireDone(organizationId, row);
} }
@@ -366,7 +567,19 @@ export class ActivitiesService {
): Promise<ActivityDto> { ): Promise<ActivityDto> {
const row = await this.prisma.activity.findFirst({ const row = await this.prisma.activity.findFirst({
where: { id, organizationId, ...activityAccessWhere(actor) }, where: { id, organizationId, ...activityAccessWhere(actor) },
include, include: {
...include,
images: {
select: {
id: true,
fileName: true,
mimeType: true,
sizeBytes: true,
createdAt: true,
},
orderBy: { createdAt: 'asc' },
},
},
}); });
if (!row) throw new NotFoundException('فعالیت پیدا نشد'); if (!row) throw new NotFoundException('فعالیت پیدا نشد');
const dto = this.toDto(row, await this.ctx(organizationId)); const dto = this.toDto(row, await this.ctx(organizationId));
@@ -396,11 +609,24 @@ export class ActivitiesService {
? [dto.assigneeId] ? [dto.assigneeId]
: [userId], : [userId],
); );
await assertAssigneeSet(
this.prisma,
{ userId, role: linkActor.role, organizationId },
'activities',
assigneeIds,
);
const assigneeId = assigneeIds[0] ?? userId; const assigneeId = assigneeIds[0] ?? userId;
const initialStageId = emptyToNull(dto.stageId ?? null); const initialStageId = emptyToNull(dto.stageId ?? null);
const initialStage = initialStageId const initialStage = initialStageId
? await this.stages.get(organizationId, initialStageId) ? await this.stages.get(organizationId, initialStageId)
: null; : null;
const requestedBoardId = emptyToNull(dto.boardId ?? null);
const boardId = initialStage
? initialStage.boardId
: requestedBoardId
? await this.stages.resolveBoardId(organizationId, requestedBoardId)
: null;
if (boardId) await this.stages.assertCanAccessBoard(organizationId, boardId, linkActor);
const resolved = await this.types.resolve(organizationId, { const resolved = await this.types.resolve(organizationId, {
typeDefId: dto.typeDefId, typeDefId: dto.typeDefId,
type: dto.type, type: dto.type,
@@ -410,10 +636,17 @@ export class ActivitiesService {
resolved.fields, resolved.fields,
dto.fields, dto.fields,
); );
await assertAssigneeSet(
this.prisma,
{ userId, role: linkActor.role, organizationId },
'activities',
participantIds,
);
const row = await this.prisma.activity.create({ const row = await this.prisma.activity.create({
data: { data: {
organizationId, organizationId,
boardId,
...(initialStage ...(initialStage
? { ? {
stageId: initialStage.id, stageId: initialStage.id,
@@ -437,6 +670,7 @@ export class ActivitiesService {
personId: emptyToNull(dto.personId ?? null), personId: emptyToNull(dto.personId ?? null),
companyId: emptyToNull(dto.companyId ?? null), companyId: emptyToNull(dto.companyId ?? null),
dealId: emptyToNull(dto.dealId ?? null), dealId: emptyToNull(dto.dealId ?? null),
leadId: emptyToNull(dto.leadId ?? null),
assigneeId, assigneeId,
assignees: assigneeIds.length assignees: assigneeIds.length
? { connect: assigneeIds.map((id) => ({ id })) } ? { connect: assigneeIds.map((id) => ({ id })) }
@@ -513,6 +747,20 @@ export class ActivitiesService {
}); });
const data: Prisma.ActivityUncheckedUpdateInput = {}; const data: Prisma.ActivityUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim(); if (dto.title !== undefined) data.title = dto.title.trim();
if ('boardId' in dto) {
const nextBoardId = await this.stages.resolveBoardId(organizationId, dto.boardId);
const current = await this.prisma.activity.findUnique({
where: { id },
select: { boardId: true },
});
const defaultBoard = await this.stages.ensureDefaultBoard(organizationId);
await this.stages.assertCanAccessBoard(organizationId, nextBoardId, actor);
if (nextBoardId !== (current?.boardId ?? defaultBoard.id)) {
// ستون قبلی مال گروه دیگر است؛ فعالیت به ستون اولِ هم‌وضعیت گروه جدید می‌رود
data.boardId = nextBoardId;
data.stageId = null;
}
}
let addedParticipants: string[] = []; let addedParticipants: string[] = [];
const typeChanged = 'typeDefId' in dto || dto.type !== undefined; const typeChanged = 'typeDefId' in dto || dto.type !== undefined;
if (typeChanged || dto.fields !== undefined) { if (typeChanged || dto.fields !== undefined) {
@@ -531,6 +779,13 @@ export class ActivitiesService {
data.typeDefId = resolved.typeDefId; data.typeDefId = resolved.typeDefId;
data.fields = fields as Prisma.InputJsonValue; data.fields = fields as Prisma.InputJsonValue;
data.participantIds = participantIds; data.participantIds = participantIds;
await assertAssigneeSet(
this.prisma,
{ userId: actor.userId, role: actor.role, organizationId },
'activities',
participantIds,
existing?.participantIds ?? [],
);
const before = new Set(existing?.participantIds ?? []); const before = new Set(existing?.participantIds ?? []);
addedParticipants = participantIds.filter((uid) => !before.has(uid) && uid !== actor.userId); addedParticipants = participantIds.filter((uid) => !before.has(uid) && uid !== actor.userId);
} }
@@ -555,23 +810,49 @@ export class ActivitiesService {
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null); if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null); if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null); if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null);
let assigneeConnect: Array<{ id: string }> | undefined; let assigneeConnect: Array<{ id: string }> | undefined;
if (dto.assigneeIds !== undefined) { if (dto.assigneeIds !== undefined || 'assigneeId' in dto) {
const assigneeIds = await this.normalizeAssigneeIds( const current = await this.prisma.activity.findFirst({
organizationId, where: { id, organizationId },
dto.assigneeIds, select: { assigneeId: true, assignees: { select: { id: true } } },
); });
if (!assigneeIds.length) { const previous = current?.assignees.length
throw new BadRequestException('حداقل یک مسئول لازم است'); ? current.assignees.map((item) => item.id)
: current?.assigneeId
? [current.assigneeId]
: [];
if (dto.assigneeIds !== undefined) {
const assigneeIds = await this.normalizeAssigneeIds(
organizationId,
dto.assigneeIds,
);
if (!assigneeIds.length) {
throw new BadRequestException('حداقل یک مسئول لازم است');
}
await assertAssigneeSet(
this.prisma,
{ userId: actor.userId, role: actor.role, organizationId },
'activities',
assigneeIds,
previous,
);
data.assigneeId = assigneeIds[0]!;
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
} else {
const assigneeId = emptyToNull(dto.assigneeId ?? null);
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
await assertAssigneeSet(
this.prisma,
{ userId: actor.userId, role: actor.role, organizationId },
'activities',
[assigneeId],
previous,
);
data.assigneeId = assigneeId;
if (assigneeId) assigneeConnect = [{ id: assigneeId }];
else assigneeConnect = [];
} }
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) { if (dto.status !== undefined) {
data.status = dto.status; data.status = dto.status;
@@ -665,7 +946,145 @@ export class ActivitiesService {
actor: ActivityActor, actor: ActivityActor,
): Promise<void> { ): Promise<void> {
await this.ensureExists(organizationId, id, actor); await this.ensureExists(organizationId, id, actor);
const images = await this.prisma.activityImage.findMany({
where: { activityId: id, organizationId },
select: { storageKey: true, sizeBytes: true },
});
const bytes = images.reduce((sum, image) => sum + image.sizeBytes, 0);
if (bytes) await this.storage.addLocal(organizationId, -bytes);
await this.prisma.activity.delete({ where: { id } }); await this.prisma.activity.delete({ where: { id } });
for (const image of images) {
await this.files.remove(image.storageKey);
}
}
async addImage(
organizationId: string,
userId: string,
activityId: string,
file: Express.Multer.File,
actor: ActivityActor,
): Promise<ActivityImageDto> {
await this.ensureExists(organizationId, activityId, actor);
if (!file?.buffer?.length) {
throw new BadRequestException('فایلی ارسال نشده است');
}
const namedExt = path.extname(file.originalname || '').slice(1).toLowerCase();
let mimeType = (file.mimetype || '').toLowerCase().split(';')[0]?.trim() || '';
if (!mimeType || mimeType === 'application/octet-stream') {
mimeType = ACTIVITY_EXT_MIME[namedExt] || mimeType;
}
const isVoice = mimeType.startsWith('audio/') || ACTIVITY_VOICE_MIME.has(mimeType);
const isImage = ACTIVITY_IMAGE_MIME.has(mimeType);
const isFile = ACTIVITY_FILE_MIME.has(mimeType);
if (!isVoice && !isImage && !isFile) {
throw new BadRequestException('این نوع فایل مجاز نیست.');
}
if (isVoice) {
if (file.size > ACTIVITY_VOICE_MAX_BYTES) {
throw new BadRequestException('حجم صوت باید زیر ۲ مگابایت باشد');
}
const voiceCount = await this.prisma.activityImage.count({
where: { activityId, mimeType: { startsWith: 'audio/' } },
});
if (voiceCount >= ACTIVITY_VOICE_MAX_COUNT) {
throw new BadRequestException(`حداکثر ${ACTIVITY_VOICE_MAX_COUNT} صوت برای هر فعالیت مجاز است`);
}
} else if (isImage) {
if (file.size > ACTIVITY_IMAGE_MAX_BYTES) {
throw new BadRequestException('حجم عکس باید زیر 200 کیلوبایت باشد');
}
const count = await this.prisma.activityImage.count({
where: { activityId, mimeType: { startsWith: 'image/' } },
});
if (count >= ACTIVITY_IMAGE_MAX_COUNT) {
throw new BadRequestException(`حداکثر ${ACTIVITY_IMAGE_MAX_COUNT} عکس برای هر فعالیت مجاز است`);
}
} else {
if (file.size > ACTIVITY_FILE_MAX_BYTES) {
throw new BadRequestException('حجم فایل باید زیر ۱۰ مگابایت باشد');
}
const fileCount = await this.prisma.activityImage.count({
where: {
activityId,
NOT: {
OR: [{ mimeType: { startsWith: 'image/' } }, { mimeType: { startsWith: 'audio/' } }],
},
},
});
if (fileCount >= ACTIVITY_FILE_MAX_COUNT) {
throw new BadRequestException(`حداکثر ${ACTIVITY_FILE_MAX_COUNT} فایل برای هر فعالیت مجاز است`);
}
}
const fileName = sanitizeActivityImageName(file.originalname);
const ext =
path.extname(fileName).slice(0, 12) ||
(mimeType === 'audio/ogg' ? '.ogg' : mimeType === 'audio/mp4' ? '.m4a' : isVoice ? '.webm' : '.jpg');
const imageId = randomUUID().replace(/-/g, '').slice(0, 24);
const storageKey = path
.join(organizationId, 'activities', activityId, `${imageId}${ext}`)
.replace(/\\/g, '/');
await this.storage.assertCanAdd(organizationId, file.size);
await this.files.save(storageKey, file.buffer);
try {
const row = await this.prisma.activityImage.create({
data: {
id: imageId,
organizationId,
activityId,
fileName,
mimeType: mimeType === 'image/jpg' ? 'image/jpeg' : mimeType,
sizeBytes: file.size,
storageKey,
uploadedById: userId,
},
});
await this.storage.addLocal(organizationId, file.size);
return {
id: row.id,
fileName: row.fileName,
mimeType: row.mimeType,
sizeBytes: row.sizeBytes,
createdAt: row.createdAt.toISOString(),
};
} catch (err) {
await this.files.remove(storageKey);
throw err;
}
}
async removeImage(
organizationId: string,
activityId: string,
imageId: string,
actor: ActivityActor,
): Promise<void> {
await this.ensureExists(organizationId, activityId, actor);
const row = await this.prisma.activityImage.findFirst({
where: { id: imageId, activityId, organizationId },
});
if (!row) throw new NotFoundException('فایل پیدا نشد');
await this.files.remove(row.storageKey);
await this.prisma.activityImage.delete({ where: { id: row.id } });
await this.storage.addLocal(organizationId, -row.sizeBytes);
}
async openImage(
organizationId: string,
activityId: string,
imageId: string,
actor: ActivityActor,
): Promise<{ stream: ReturnType<LocalFileStorage['openReadStream']>; fileName: string; mimeType: string }> {
await this.ensureExists(organizationId, activityId, actor);
const row = await this.prisma.activityImage.findFirst({
where: { id: imageId, activityId, organizationId },
});
if (!row) throw new NotFoundException('فایل پیدا نشد');
return {
stream: this.files.openReadStream(row.storageKey),
fileName: row.fileName,
mimeType: row.mimeType,
};
} }
private buildWhere( private buildWhere(
@@ -701,11 +1120,15 @@ export class ActivitiesService {
: activityAccessWhere(actor); : activityAccessWhere(actor);
const facetAssignee = facets?.assigneeId?.trim(); const facetAssignee = facets?.assigneeId?.trim();
const facetTypeDef = facets?.typeDefId?.trim(); const facetTypeDef = facets?.typeDefId?.trim();
const facetTitle = facets?.title?.trim();
const facetCustomer = facets?.customer?.trim();
const andParts: Prisma.ActivityWhereInput[] = [ const andParts: Prisma.ActivityWhereInput[] = [
scope, scope,
...(facets?.type ? [{ type: facets.type }] : []), ...(facets?.type ? [{ type: facets.type }] : []),
...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []), ...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []),
...(facets?.status ? [{ status: facets.status }] : []), ...(facets?.status ? [{ status: facets.status }] : []),
...(facetTitle ? [{ title: ci(facetTitle) }] : []),
...(facetCustomer ? [this.customerWhere(facetCustomer)] : []),
...(facetAssignee ...(facetAssignee
? [ ? [
{ {
@@ -752,15 +1175,18 @@ export class ActivitiesService {
const fromDate = range?.from ? new Date(range.from) : null; const fromDate = range?.from ? new Date(range.from) : null;
const toDate = range?.to ? new Date(range.to) : null; const toDate = range?.to ? new Date(range.to) : null;
if (fromDate || toDate) { if (fromDate || toDate) {
return { return this.withDue(
...base, {
dueAt: { ...base,
...(fromDate && !Number.isNaN(fromDate.getTime()) dueAt: {
? { gte: fromDate } ...(fromDate && !Number.isNaN(fromDate.getTime())
: {}), ? { gte: fromDate }
...(toDate && !Number.isNaN(toDate.getTime()) ? { lt: toDate } : {}), : {}),
...(toDate && !Number.isNaN(toDate.getTime()) ? { lt: toDate } : {}),
},
}, },
}; facets?.due,
);
} }
// Explicit status facet overrides the status implied by time tabs. // Explicit status facet overrides the status implied by time tabs.
@@ -774,28 +1200,62 @@ export class ActivitiesService {
switch (filter) { switch (filter) {
case 'today': case 'today':
return { ...base, ...impliedOpen, dueAt: { gte: start, lt: end } }; return this.withDue(
{ ...base, ...impliedOpen, dueAt: { gte: start, lt: end } },
facets?.due,
);
case 'overdue': case 'overdue':
// عقب‌افتاده ذاتاً یعنی باز و موعدگذشته // عقب‌افتاده ذاتاً یعنی باز و موعدگذشته
return { return this.withDue(
...base, {
...(facets?.status ? { status: facets.status } : { status: 'open' as const }), ...base,
dueAt: { lt: start }, ...(facets?.status ? { status: facets.status } : { status: 'open' as const }),
}; dueAt: { lt: start },
},
facets?.due,
);
case 'upcoming': case 'upcoming':
return { return this.withDue(
...base, {
...impliedOpen, ...base,
OR: [{ dueAt: { gte: end } }, { dueAt: null }], ...impliedOpen,
}; OR: [{ dueAt: { gte: end } }, { dueAt: null }],
},
facets?.due,
);
case 'done': case 'done':
return { ...base, ...impliedDone }; return this.withDue({ ...base, ...impliedDone }, facets?.due);
case 'all': case 'all':
default: default:
return base; return this.withDue(base, facets?.due);
} }
} }
/** نام شخص (چندکلمه‌ای) یا نام شرکت */
private customerWhere(term: string): Prisma.ActivityWhereInput {
const parts = term.split(/\s+/).filter(Boolean);
const person: Prisma.PersonWhereInput =
parts.length <= 1
? { OR: [{ firstName: ci(term) }, { lastName: ci(term) }] }
: {
AND: parts.map((part) => ({
OR: [{ firstName: ci(part) }, { lastName: ci(part) }],
})),
};
return {
OR: [{ person }, { company: { name: ci(term) } }],
};
}
/** وجود یا نبود زمان شروع، روی هر فیلتر زمانی */
private withDue(
where: Prisma.ActivityWhereInput,
due?: 'set' | 'unset',
): Prisma.ActivityWhereInput {
if (due !== 'set' && due !== 'unset') return where;
return { AND: [where, due === 'set' ? { dueAt: { not: null } } : { dueAt: null }] };
}
private async fireDone( private async fireDone(
organizationId: string, organizationId: string,
row: { row: {
@@ -867,11 +1327,13 @@ export class ActivitiesService {
personId?: string | null; personId?: string | null;
companyId?: string | null; companyId?: string | null;
dealId?: string | null; dealId?: string | null;
leadId?: string | null;
}, },
) { ) {
const personId = emptyToNull(dto.personId ?? null); const personId = emptyToNull(dto.personId ?? null);
const companyId = emptyToNull(dto.companyId ?? null); const companyId = emptyToNull(dto.companyId ?? null);
const dealId = emptyToNull(dto.dealId ?? null); const dealId = emptyToNull(dto.dealId ?? null);
const leadId = emptyToNull(dto.leadId ?? null);
const scope = ownerWhere(actor); const scope = ownerWhere(actor);
if (personId) { if (personId) {
@@ -895,6 +1357,13 @@ export class ActivitiesService {
}); });
if (!deal) throw new BadRequestException('معامله انتخاب‌شده معتبر نیست'); if (!deal) throw new BadRequestException('معامله انتخاب‌شده معتبر نیست');
} }
if (leadId) {
const lead = await this.prisma.lead.findFirst({
where: { id: leadId, organizationId, ...leadsDeepWhere(actor) },
select: { id: true },
});
if (!lead) throw new BadRequestException('لید انتخاب‌شده معتبر نیست');
}
} }
private async notifyParticipants( private async notifyParticipants(
@@ -922,6 +1391,7 @@ export class ActivitiesService {
? (row.fields as unknown as ActivityFieldValues) ? (row.fields as unknown as ActivityFieldValues)
: {}; : {};
return { return {
boardId: this.stages.effectiveBoardId(row, ctx?.stages),
stageId: this.stages.effectiveStageId(row, ctx?.stages), stageId: this.stages.effectiveStageId(row, ctx?.stages),
typeDef: row.typeDef typeDef: row.typeDef
? this.types.toSummary(row.typeDef) ? this.types.toSummary(row.typeDef)
@@ -934,6 +1404,13 @@ export class ActivitiesService {
priority: row.priority, priority: row.priority,
title: row.title, title: row.title,
description: row.description, description: row.description,
images: row.images?.map((image) => ({
id: image.id,
fileName: image.fileName,
mimeType: image.mimeType,
sizeBytes: image.sizeBytes,
createdAt: image.createdAt.toISOString(),
})),
dueAt: row.dueAt?.toISOString() ?? null, dueAt: row.dueAt?.toISOString() ?? null,
durationMinutes: row.durationMinutes, durationMinutes: row.durationMinutes,
reminderAt: row.reminderAt?.toISOString() ?? null, reminderAt: row.reminderAt?.toISOString() ?? null,
@@ -976,7 +1453,11 @@ export class ActivitiesService {
]; ];
if (!ids.length) return []; if (!ids.length) return [];
const count = await this.prisma.user.count({ const count = await this.prisma.user.count({
where: { organizationId, id: { in: ids }, isActive: true }, where: {
id: { in: ids },
isActive: true,
memberships: { some: { organizationId, isActive: true } },
},
}); });
if (count !== ids.length) { if (count !== ids.length) {
throw new BadRequestException('یکی از مسئولان انتخاب‌شده معتبر نیست'); throw new BadRequestException('یکی از مسئولان انتخاب‌شده معتبر نیست');
@@ -1001,6 +1482,12 @@ function typesMatching(term: string): ActivityDto['type'][] {
); );
} }
function sanitizeActivityImageName(raw: string): string {
const base = path.basename(raw || 'photo.jpg').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
const trimmed = base.trim() || 'photo.jpg';
return trimmed.slice(0, 180);
}
function emptyToNull(value?: string | null): string | null { function emptyToNull(value?: string | null): string | null {
if (value == null) return null; if (value == null) return null;
const trimmed = value.trim(); const trimmed = value.trim();
@@ -1,5 +1,11 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { import type {
ActivityBoardDto,
ActivityStageColor, ActivityStageColor,
ActivityStageDto, ActivityStageDto,
ActivityStatus, ActivityStatus,
@@ -8,6 +14,8 @@ import { ACTIVITY_STAGE_COLORS } from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
const DEFAULT_BOARD_NAME = 'کارهای اصلی';
const DEFAULT_STAGES: Array<{ name: string; color: ActivityStageColor; kind: ActivityStatus }> = [ const DEFAULT_STAGES: Array<{ name: string; color: ActivityStageColor; kind: ActivityStatus }> = [
{ name: 'در صف', color: 'orange', kind: 'open' }, { name: 'در صف', color: 'orange', kind: 'open' },
{ name: 'در حال انجام', color: 'blue', kind: 'open' }, { name: 'در حال انجام', color: 'blue', kind: 'open' },
@@ -19,24 +27,100 @@ const KINDS: ActivityStatus[] = ['open', 'done', 'canceled'];
type StageRow = { type StageRow = {
id: string; id: string;
boardId: string;
name: string; name: string;
position: number; position: number;
color: string; color: string;
kind: ActivityStatus; kind: ActivityStatus;
}; };
type BoardRow = {
id: string;
name: string;
isDefault: boolean;
position: number;
members: Array<{ userId: string }>;
};
function toBoardDto(row: BoardRow): ActivityBoardDto {
return {
id: row.id,
name: row.name,
isDefault: row.isDefault,
position: row.position,
memberIds: row.members.map((member) => member.userId),
};
}
function managesAllBoards(role: string) {
return role === 'owner' || role === 'admin';
}
const STAGE_SELECT = {
id: true,
boardId: true,
name: true,
position: true,
color: true,
kind: true,
} satisfies Prisma.ActivityStageSelect;
const BOARD_SELECT = {
id: true,
name: true,
isDefault: true,
position: true,
members: { select: { userId: true } },
} satisfies Prisma.ActivityBoardSelect;
export type StageDefaults = Record<ActivityStatus, string | null>; export type StageDefaults = Record<ActivityStatus, string | null>;
/** پیش‌فرض‌های هر گروه برای محاسبهٔ «ستون مؤثر» فعالیت‌ها */
export type BoardStageContext = {
defaultBoardId: string;
defaults: Map<string, StageDefaults>;
};
@Injectable() @Injectable()
export class ActivityStagesService { export class ActivityStagesService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async ensureDefaults(organizationId: string): Promise<void> { // —— گروه‌ها ——
const count = await this.prisma.activityStage.count({ where: { organizationId } });
/** گروه پیش‌فرض محیط را (با ستون‌های اولیه) تضمین می‌کند */
async ensureDefaultBoard(organizationId: string): Promise<BoardRow> {
let board = await this.prisma.activityBoard.findFirst({
where: { organizationId, isDefault: true },
select: BOARD_SELECT,
});
if (!board) {
board = await this.prisma.activityBoard
.create({
data: { organizationId, name: DEFAULT_BOARD_NAME, isDefault: true, position: 0 },
select: BOARD_SELECT,
})
.catch(async (e) => {
// ساخت هم‌زمان در دو درخواست
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return this.prisma.activityBoard.findFirstOrThrow({
where: { organizationId, isDefault: true },
select: BOARD_SELECT,
});
}
throw e;
});
}
await this.ensureBoardStages(organizationId, board.id);
return board;
}
private async ensureBoardStages(organizationId: string, boardId: string) {
const count = await this.prisma.activityStage.count({ where: { boardId } });
if (count > 0) return; if (count > 0) return;
await this.prisma.activityStage.createMany({ await this.prisma.activityStage.createMany({
data: DEFAULT_STAGES.map((s, i) => ({ data: DEFAULT_STAGES.map((s, i) => ({
organizationId, organizationId,
boardId,
name: s.name, name: s.name,
color: s.color, color: s.color,
kind: s.kind, kind: s.kind,
@@ -46,17 +130,175 @@ export class ActivityStagesService {
}); });
} }
async rows(organizationId: string): Promise<StageRow[]> { async listBoards(
await this.ensureDefaults(organizationId); organizationId: string,
return this.prisma.activityStage.findMany({ actor?: { userId: string; role: string },
): Promise<ActivityBoardDto[]> {
await this.ensureDefaultBoard(organizationId);
const rows = await this.prisma.activityBoard.findMany({
where: { organizationId }, where: { organizationId },
orderBy: [{ isDefault: 'desc' }, { position: 'asc' }, { createdAt: 'asc' }],
select: BOARD_SELECT,
});
const boards = rows.map(toBoardDto);
if (!actor || managesAllBoards(actor.role)) return boards;
return boards.filter(
(board) => board.memberIds.length === 0 || board.memberIds.includes(actor.userId),
);
}
/** گروه بدون عضو برای همه باز است؛ مالک و مدیر همهٔ گروه‌ها را می‌بینند */
async assertCanAccessBoard(
organizationId: string,
boardId: string,
actor: { userId: string; role: string },
) {
if (managesAllBoards(actor.role)) return;
const members = await this.prisma.activityBoardMember.findMany({
where: { boardId, board: { organizationId } },
select: { userId: true },
});
if (!members.length || members.some((member) => member.userId === actor.userId)) return;
throw new ForbiddenException('به این گروه فعالیت دسترسی ندارید');
}
/** شناسهٔ گروه معتبر؛ خالی = گروه پیش‌فرض */
async resolveBoardId(organizationId: string, boardId?: string | null): Promise<string> {
const id = boardId?.trim();
if (!id) return (await this.ensureDefaultBoard(organizationId)).id;
const board = await this.prisma.activityBoard.findFirst({
where: { id, organizationId },
select: { id: true },
});
if (!board) throw new BadRequestException('گروه فعالیت انتخاب‌شده معتبر نیست');
return board.id;
}
async createBoard(
organizationId: string,
name: string,
memberIds?: string[],
): Promise<ActivityBoardDto> {
await this.ensureDefaultBoard(organizationId);
const max = await this.prisma.activityBoard.aggregate({
where: { organizationId },
_max: { position: true },
});
try {
const board = await this.prisma.activityBoard.create({
data: {
organizationId,
name: this.cleanBoardName(name),
position: (max._max.position ?? 0) + 1,
},
select: BOARD_SELECT,
});
await this.ensureBoardStages(organizationId, board.id);
if (memberIds !== undefined) await this.replaceMembers(organizationId, board.id, memberIds);
return this.getBoardDto(organizationId, board.id);
} catch (e) {
throw this.mapBoardError(e);
}
}
async updateBoard(
organizationId: string,
id: string,
dto: { name?: string; memberIds?: string[] },
): Promise<ActivityBoardDto> {
await this.getBoard(organizationId, id);
try {
if (dto.name != null) {
await this.prisma.activityBoard.update({
where: { id },
data: { name: this.cleanBoardName(dto.name) },
});
}
if (dto.memberIds !== undefined) await this.replaceMembers(organizationId, id, dto.memberIds);
return this.getBoardDto(organizationId, id);
} catch (e) {
throw this.mapBoardError(e);
}
}
private async replaceMembers(organizationId: string, boardId: string, memberIds: string[]) {
const ids = [...new Set(memberIds.map((id) => id.trim()).filter(Boolean))];
if (ids.length) {
const found = await this.prisma.membership.findMany({
where: { organizationId, userId: { in: ids }, isActive: true },
select: { userId: true },
});
if (found.length !== ids.length) {
throw new BadRequestException('بعضی از اعضا در این محیط نیستند');
}
}
await this.prisma.activityBoardMember.deleteMany({ where: { boardId } });
if (ids.length) {
await this.prisma.activityBoardMember.createMany({
data: ids.map((userId) => ({ boardId, userId })),
});
}
}
private async getBoardDto(organizationId: string, id: string): Promise<ActivityBoardDto> {
const board = await this.prisma.activityBoard.findFirst({
where: { id, organizationId },
select: BOARD_SELECT,
});
if (!board) throw new NotFoundException('گروه فعالیت پیدا نشد');
return toBoardDto(board);
}
/** فعالیت‌های گروه حذف‌شده به گروه پیش‌فرض (ستون اولِ هم‌وضعیت) منتقل می‌شوند */
async removeBoard(organizationId: string, id: string): Promise<void> {
const board = await this.getBoard(organizationId, id);
if (board.isDefault) {
throw new BadRequestException('گروه پیش‌فرض قابل حذف نیست');
}
await this.prisma.$transaction([
this.prisma.activity.updateMany({
where: { organizationId, boardId: id },
data: { boardId: null, stageId: null },
}),
this.prisma.activityBoard.delete({ where: { id } }),
]);
}
private async getBoard(organizationId: string, id: string): Promise<BoardRow> {
const board = await this.prisma.activityBoard.findFirst({
where: { id, organizationId },
select: BOARD_SELECT,
});
if (!board) throw new NotFoundException('گروه فعالیت پیدا نشد');
return board;
}
/** شرط Prisma برای فعالیت‌های یک گروه (فعالیت بدون گروه = گروه پیش‌فرض) */
boardWhere(boardId: string, defaultBoardId: string): Prisma.ActivityWhereInput {
return boardId === defaultBoardId
? { OR: [{ boardId }, { boardId: null }] }
: { boardId };
}
// —— ستون‌ها ——
async rows(organizationId: string, boardId?: string | null): Promise<StageRow[]> {
const resolved = await this.resolveBoardId(organizationId, boardId);
return this.prisma.activityStage.findMany({
where: { organizationId, boardId: resolved },
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }], orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
select: { id: true, name: true, position: true, color: true, kind: true }, select: STAGE_SELECT,
}); });
} }
async list(organizationId: string): Promise<ActivityStageDto[]> { async list(
return (await this.rows(organizationId)).map((r) => this.toDto(r)); organizationId: string,
boardId?: string | null,
actor?: { userId: string; role: string },
): Promise<ActivityStageDto[]> {
const resolved = await this.resolveBoardId(organizationId, boardId);
if (actor) await this.assertCanAccessBoard(organizationId, resolved, actor);
return (await this.rows(organizationId, resolved)).map((r) => this.toDto(r));
} }
/** اولین مرحلهٔ هر نوع = مقصد فعالیت‌های بدون مرحله یا با مرحلهٔ ناهم‌نوع */ /** اولین مرحلهٔ هر نوع = مقصد فعالیت‌های بدون مرحله یا با مرحلهٔ ناهم‌نوع */
@@ -68,8 +310,23 @@ export class ActivityStagesService {
return out; return out;
} }
async defaults(organizationId: string): Promise<StageDefaults> { /** پیش‌فرض‌های ستون برای همهٔ گروه‌های محیط */
return this.defaultsOf(await this.rows(organizationId)); async boardContext(organizationId: string): Promise<BoardStageContext> {
const defaultBoard = await this.ensureDefaultBoard(organizationId);
const rows = await this.prisma.activityStage.findMany({
where: { organizationId },
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
select: STAGE_SELECT,
});
const byBoard = new Map<string, StageRow[]>();
for (const r of rows) {
const list = byBoard.get(r.boardId) ?? [];
list.push(r);
byBoard.set(r.boardId, list);
}
const defaults = new Map<string, StageDefaults>();
for (const [boardId, list] of byBoard) defaults.set(boardId, this.defaultsOf(list));
return { defaultBoardId: defaultBoard.id, defaults };
} }
/** شرط Prisma برای فعالیت‌هایی که «مؤثراً» در این مرحله هستند */ /** شرط Prisma برای فعالیت‌هایی که «مؤثراً» در این مرحله هستند */
@@ -77,33 +334,54 @@ export class ActivityStagesService {
organizationId: string, organizationId: string,
stageId: string, stageId: string,
): Promise<Prisma.ActivityWhereInput> { ): Promise<Prisma.ActivityWhereInput> {
const rows = await this.rows(organizationId); const stage = await this.get(organizationId, stageId);
const stage = rows.find((r) => r.id === stageId); const defaultBoard = await this.ensureDefaultBoard(organizationId);
if (!stage) throw new BadRequestException('مرحلهٔ انتخاب‌شده معتبر نیست'); const boardRows = await this.rows(organizationId, stage.boardId);
const isDefault = this.defaultsOf(rows)[stage.kind] === stage.id; const inBoard = this.boardWhere(stage.boardId, defaultBoard.id);
if (!isDefault) return { status: stage.kind, stageId: stage.id }; const isDefault = this.defaultsOf(boardRows)[stage.kind] === stage.id;
if (!isDefault) {
return { AND: [inBoard, { status: stage.kind, stageId: stage.id }] };
}
return { return {
status: stage.kind, AND: [
OR: [ inBoard,
{ stageId: stage.id }, { status: stage.kind },
{ stageId: null }, {
{ stage: { kind: { not: stage.kind } } }, OR: [
{ stageId: stage.id },
{ stageId: null },
{ stage: { kind: { not: stage.kind } } },
{ stage: { boardId: { not: stage.boardId } } },
],
},
], ],
}; };
} }
effectiveBoardId(row: { boardId?: string | null }, ctx?: BoardStageContext): string | null {
return row.boardId ?? ctx?.defaultBoardId ?? null;
}
effectiveStageId( effectiveStageId(
row: { status: ActivityStatus; stage?: { id: string; kind: ActivityStatus } | null }, row: {
defaults?: StageDefaults, status: ActivityStatus;
boardId?: string | null;
stage?: { id: string; kind: ActivityStatus; boardId: string } | null;
},
ctx?: BoardStageContext,
): string | null { ): string | null {
if (row.stage && row.stage.kind === row.status) return row.stage.id; const boardId = this.effectiveBoardId(row, ctx);
return defaults?.[row.status] ?? null; if (row.stage && row.stage.kind === row.status && (!boardId || row.stage.boardId === boardId)) {
return row.stage.id;
}
if (!ctx || !boardId) return null;
return ctx.defaults.get(boardId)?.[row.status] ?? null;
} }
async get(organizationId: string, id: string): Promise<StageRow> { async get(organizationId: string, id: string): Promise<StageRow> {
const row = await this.prisma.activityStage.findFirst({ const row = await this.prisma.activityStage.findFirst({
where: { id, organizationId }, where: { id, organizationId },
select: { id: true, name: true, position: true, color: true, kind: true }, select: STAGE_SELECT,
}); });
if (!row) throw new NotFoundException('مرحله پیدا نشد'); if (!row) throw new NotFoundException('مرحله پیدا نشد');
return row; return row;
@@ -111,22 +389,25 @@ export class ActivityStagesService {
async create( async create(
organizationId: string, organizationId: string,
dto: { name: string; color?: string; kind?: ActivityStatus }, dto: { boardId?: string; name: string; color?: string; kind?: ActivityStatus },
): Promise<ActivityStageDto> { ): Promise<ActivityStageDto> {
await this.ensureDefaults(organizationId); const boardId = await this.resolveBoardId(organizationId, dto.boardId);
await this.ensureBoardStages(organizationId, boardId);
const max = await this.prisma.activityStage.aggregate({ const max = await this.prisma.activityStage.aggregate({
where: { organizationId }, where: { boardId },
_max: { position: true }, _max: { position: true },
}); });
try { try {
const row = await this.prisma.activityStage.create({ const row = await this.prisma.activityStage.create({
data: { data: {
organizationId, organizationId,
boardId,
name: this.cleanName(dto.name), name: this.cleanName(dto.name),
color: this.cleanColor(dto.color), color: this.cleanColor(dto.color),
kind: dto.kind && KINDS.includes(dto.kind) ? dto.kind : 'open', kind: dto.kind && KINDS.includes(dto.kind) ? dto.kind : 'open',
position: (max._max.position ?? -1) + 1, position: (max._max.position ?? -1) + 1,
}, },
select: STAGE_SELECT,
}); });
return this.toDto(row); return this.toDto(row);
} catch (e) { } catch (e) {
@@ -142,7 +423,7 @@ export class ActivityStagesService {
const stage = await this.get(organizationId, id); const stage = await this.get(organizationId, id);
const nextKind = dto.kind && KINDS.includes(dto.kind) ? dto.kind : stage.kind; const nextKind = dto.kind && KINDS.includes(dto.kind) ? dto.kind : stage.kind;
if (nextKind !== stage.kind) { if (nextKind !== stage.kind) {
await this.assertNotLastOfKind(organizationId, stage); await this.assertNotLastOfKind(stage);
} }
try { try {
const row = await this.prisma.$transaction(async (tx) => { const row = await this.prisma.$transaction(async (tx) => {
@@ -160,6 +441,7 @@ export class ActivityStagesService {
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}), ...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
kind: nextKind, kind: nextKind,
}, },
select: STAGE_SELECT,
}); });
}); });
return this.toDto(row); return this.toDto(row);
@@ -168,34 +450,45 @@ export class ActivityStagesService {
} }
} }
/** ترتیب ستون‌های یک گروه؛ همهٔ شناسه‌ها باید از همان گروه باشند */
async reorder(organizationId: string, stageIds: string[]): Promise<ActivityStageDto[]> { async reorder(organizationId: string, stageIds: string[]): Promise<ActivityStageDto[]> {
if (!stageIds.length) return [];
const rows = await this.prisma.activityStage.findMany({
where: { organizationId, id: { in: stageIds } },
select: { id: true, boardId: true },
});
const boardIds = new Set(rows.map((r) => r.boardId));
if (rows.length !== new Set(stageIds).size || boardIds.size !== 1) {
throw new BadRequestException('ستون‌های ارسالی باید همه از یک گروه باشند');
}
const [boardId] = [...boardIds];
await this.prisma.$transaction( await this.prisma.$transaction(
stageIds.map((id, position) => stageIds.map((id, position) =>
this.prisma.activityStage.updateMany({ this.prisma.activityStage.updateMany({
where: { id, organizationId }, where: { id, organizationId, boardId },
data: { position }, data: { position },
}), }),
), ),
); );
return this.list(organizationId); return this.list(organizationId, boardId);
} }
async remove(organizationId: string, id: string): Promise<void> { async remove(organizationId: string, id: string): Promise<void> {
const stage = await this.get(organizationId, id); const stage = await this.get(organizationId, id);
await this.assertNotLastOfKind(organizationId, stage); await this.assertNotLastOfKind(stage);
// onDelete: SetNull → فعالیت‌ها به اولین مرحلهٔ هم‌نوع وضعیتشان منتقل می‌شوند // onDelete: SetNull → فعالیت‌ها به اولین مرحلهٔ هم‌نوع وضعیتشان در همان گروه منتقل می‌شوند
await this.prisma.activityStage.delete({ where: { id } }); await this.prisma.activityStage.delete({ where: { id } });
} }
private async assertNotLastOfKind(organizationId: string, stage: StageRow) { private async assertNotLastOfKind(stage: StageRow) {
const sameKind = await this.prisma.activityStage.count({ const sameKind = await this.prisma.activityStage.count({
where: { organizationId, kind: stage.kind }, where: { boardId: stage.boardId, kind: stage.kind },
}); });
if (sameKind <= 1) { if (sameKind <= 1) {
const label = const label =
stage.kind === 'done' ? 'انجام‌شده' : stage.kind === 'canceled' ? 'لغو شده' : 'باز'; stage.kind === 'done' ? 'انجام‌شده' : stage.kind === 'canceled' ? 'لغو شده' : 'باز';
throw new BadRequestException( throw new BadRequestException(
`حداقل یک ستون از نوع «${label}» لازم است؛ اول یک ستون دیگر از همین نوع بسازید`, `حداقل یک ستون از نوع «${label}» در هر گروه لازم است؛ اول یک ستون دیگر از همین نوع بسازید`,
); );
} }
} }
@@ -207,6 +500,13 @@ export class ActivityStagesService {
return name; return name;
} }
private cleanBoardName(raw: string): string {
const name = raw?.trim() ?? '';
if (name.length < 2) throw new BadRequestException('نام گروه حداقل ۲ کاراکتر است');
if (name.length > 80) throw new BadRequestException('نام گروه حداکثر ۸۰ کاراکتر است');
return name;
}
private cleanColor(raw?: string): ActivityStageColor { private cleanColor(raw?: string): ActivityStageColor {
return ACTIVITY_STAGE_COLORS.includes(raw as ActivityStageColor) return ACTIVITY_STAGE_COLORS.includes(raw as ActivityStageColor)
? (raw as ActivityStageColor) ? (raw as ActivityStageColor)
@@ -215,7 +515,14 @@ export class ActivityStagesService {
private mapError(e: unknown) { private mapError(e: unknown) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return new BadRequestException('ستونی با این نام وجود دارد'); return new BadRequestException('ستونی با این نام در این گروه وجود دارد');
}
return e;
}
private mapBoardError(e: unknown) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return new BadRequestException('گروهی با این نام وجود دارد');
} }
return e; return e;
} }
@@ -223,6 +530,7 @@ export class ActivityStagesService {
private toDto(row: StageRow): ActivityStageDto { private toDto(row: StageRow): ActivityStageDto {
return { return {
id: row.id, id: row.id,
boardId: row.boardId,
name: row.name, name: row.name,
position: row.position, position: row.position,
color: this.cleanColor(row.color), color: this.cleanColor(row.color),
+87 -2
View File
@@ -69,6 +69,23 @@ export class ListActivitiesQueryDto {
@IsString() @IsString()
assigneeId?: string; assigneeId?: string;
/** فیلتر عنوان فعالیت، جدا از جستجوی کلی */
@IsOptional()
@IsString()
@MaxLength(120)
title?: string;
/** نام شخص یا شرکتِ مشتری */
@IsOptional()
@IsString()
@MaxLength(120)
customer?: string;
/** زمان شروع: set = دارد، unset = ندارد */
@IsOptional()
@IsIn(['set', 'unset'])
due?: 'set' | 'unset';
/** نوع فعالیت (تعریف‌شده در سازمان) */ /** نوع فعالیت (تعریف‌شده در سازمان) */
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -79,6 +96,11 @@ export class ListActivitiesQueryDto {
@IsString() @IsString()
stageId?: string; stageId?: string;
/** گروه فعالیت؛ خالی = همهٔ گروه‌ها (در شمارش ستون‌ها = گروه پیش‌فرض) */
@IsOptional()
@IsString()
boardId?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
personId?: string; personId?: string;
@@ -121,7 +143,43 @@ export class ListActivitiesQueryDto {
const STAGE_COLORS = ['green', 'orange', 'purple', 'blue', 'rose', 'slate'] as const; const STAGE_COLORS = ['green', 'orange', 'purple', 'blue', 'rose', 'slate'] as const;
export class CreateActivityBoardDto {
@IsString()
@MinLength(2)
@MaxLength(80)
name!: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
memberIds?: string[];
}
export class UpdateActivityBoardDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(80)
name?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
memberIds?: string[];
}
export class ListActivityStagesQueryDto {
@IsOptional()
@IsString()
boardId?: string;
}
export class CreateActivityStageDto { export class CreateActivityStageDto {
/** خالی = گروه پیش‌فرض */
@IsOptional()
@IsString()
boardId?: string;
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@MaxLength(80) @MaxLength(80)
@@ -161,6 +219,12 @@ export class ReorderActivityStagesDto {
export class MoveActivityStageDto { export class MoveActivityStageDto {
@IsString() @IsString()
stageId!: string; stageId!: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
position?: number;
} }
/** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی می‌شود */ /** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی می‌شود */
@@ -229,6 +293,11 @@ export class CreateActivityDto {
@MaxLength(200) @MaxLength(200)
title!: string; title!: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
boardId?: string | null;
@IsOptional() @IsOptional()
@ValidateIf((_, v) => v !== '' && v != null) @ValidateIf((_, v) => v !== '' && v != null)
@IsString() @IsString()
@@ -253,7 +322,7 @@ export class CreateActivityDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(2000) @MaxLength(20000)
description?: string; description?: string;
@IsOptional() @IsOptional()
@@ -301,6 +370,11 @@ export class CreateActivityDto {
@IsString() @IsString()
dealId?: string | null; dealId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
leadId?: string | null;
@IsOptional() @IsOptional()
@ValidateIf((_, v) => v !== '' && v != null) @ValidateIf((_, v) => v !== '' && v != null)
@IsString() @IsString()
@@ -319,6 +393,12 @@ export class UpdateActivityDto {
@MaxLength(200) @MaxLength(200)
title?: string; title?: string;
/** null/خالی = گروه پیش‌فرض */
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
boardId?: string | null;
@IsOptional() @IsOptional()
@IsIn(ACTIVITY_TYPES) @IsIn(ACTIVITY_TYPES)
type?: (typeof ACTIVITY_TYPES)[number]; type?: (typeof ACTIVITY_TYPES)[number];
@@ -342,7 +422,7 @@ export class UpdateActivityDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(2000) @MaxLength(20000)
description?: string | null; description?: string | null;
@IsOptional() @IsOptional()
@@ -390,6 +470,11 @@ export class UpdateActivityDto {
@IsString() @IsString()
dealId?: string | null; dealId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
leadId?: string | null;
@IsOptional() @IsOptional()
@ValidateIf((_, v) => v !== '' && v != null) @ValidateIf((_, v) => v !== '' && v != null)
@IsString() @IsString()
+4
View File
@@ -9,6 +9,7 @@ import type {
VoicePersonDraft, VoicePersonDraft,
} from '@mento/shared'; } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { leadsDeepWhere } from '../common/data-access';
import { import {
type Actor, type Actor,
assigneeWhere, assigneeWhere,
@@ -260,6 +261,9 @@ Rules:
} }
private leadWhere(actor: Actor) { private leadWhere(actor: Actor) {
if (actor.leadsAccess !== undefined) {
return leadsDeepWhere(actor);
}
const scoped = forScope(actor, 'leads'); const scoped = forScope(actor, 'leads');
if (elevated(scoped.role) || scoped.dataVisibility === 'org') return {}; if (elevated(scoped.role) || scoped.dataVisibility === 'org') return {};
if (scoped.dataVisibility === 'team' && scoped.visibleUserIds?.length) { if (scoped.dataVisibility === 'team' && scoped.visibleUserIds?.length) {
+8
View File
@@ -33,6 +33,7 @@ import { MemoryModule } from './memory/memory.module';
import { TahesabModule } from './integrations/tahesab/tahesab.module'; import { TahesabModule } from './integrations/tahesab/tahesab.module';
import { InventoryModule } from './inventory/inventory.module'; import { InventoryModule } from './inventory/inventory.module';
import { FinanceModule } from './finance/finance.module'; import { FinanceModule } from './finance/finance.module';
import { TreasuryModule } from './treasury/treasury.module';
import { PlatformModule } from './platform/platform.module'; import { PlatformModule } from './platform/platform.module';
import { BillingModule } from './billing/billing.module'; import { BillingModule } from './billing/billing.module';
import { AuditLogsModule } from './audit-logs/audit-logs.module'; import { AuditLogsModule } from './audit-logs/audit-logs.module';
@@ -51,6 +52,9 @@ import { RoutinesModule } from './routines/routines.module';
import { GamificationModule } from './gamification/gamification.module'; import { GamificationModule } from './gamification/gamification.module';
import { DeletionsModule } from './deletions/deletions.module'; import { DeletionsModule } from './deletions/deletions.module';
import { OutboundSmsModule } from './outbound-sms/outbound-sms.module'; import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
import { BehaviorModule } from './behavior/behavior.module';
import { SmsBillingModule } from './sms-billing/sms-billing.module';
import { CustomerGroupsModule } from './customer-groups/customer-groups.module';
@Module({ @Module({
imports: [ imports: [
@@ -61,6 +65,8 @@ import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
AiModule, AiModule,
PrismaModule, PrismaModule,
SmsBillingModule,
CustomerGroupsModule,
OutboundSmsModule, OutboundSmsModule,
StorageModule, StorageModule,
HealthModule, HealthModule,
@@ -83,6 +89,7 @@ import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
InvoicesModule, InvoicesModule,
InventoryModule, InventoryModule,
FinanceModule, FinanceModule,
TreasuryModule,
SalesModule, SalesModule,
TicketsModule, TicketsModule,
FaqModule, FaqModule,
@@ -109,6 +116,7 @@ import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
RoutinesModule, RoutinesModule,
GamificationModule, GamificationModule,
DeletionsModule, DeletionsModule,
BehaviorModule,
], ],
}) })
export class AppModule {} export class AppModule {}
@@ -0,0 +1,44 @@
const TEHRAN = 'Asia/Tehran';
export type AttendanceKind = 'login' | 'logout' | 'forced_logout';
export function tehranDateKey(date: Date): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: TEHRAN,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date);
return parts;
}
/** ساعت HH:mm امروز به وقت تهران، به صورت لحظهٔ مطلق */
export function tehranCutoff(now: Date, hhmm: string): Date | null {
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(hhmm.trim());
if (!match) return null;
const day = tehranDateKey(now);
return new Date(`${day}T${match[1]}:${match[2]}:00+03:30`);
}
export function isPastLogoutTime(now: Date, hhmm: string | null | undefined): boolean {
if (!hhmm) return false;
const cutoff = tehranCutoff(now, hhmm);
if (!cutoff) return false;
return now.getTime() >= cutoff.getTime();
}
export function loggedInAfterCutoff(at: Date, hhmm: string | null | undefined): boolean {
if (!hhmm) return false;
const cutoff = tehranCutoff(at, hhmm);
if (!cutoff) return false;
return at.getTime() >= cutoff.getTime();
}
export function tehranClock(date: Date): string {
return new Intl.DateTimeFormat('fa-IR', {
timeZone: TEHRAN,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).format(date);
}
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { AttendanceService } from './attendance.service';
@Module({
providers: [AttendanceService],
exports: [AttendanceService],
})
export class AttendanceModule {}
@@ -0,0 +1,216 @@
import { ForbiddenException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { Actor } from '../common/record-scope';
import { PrismaService } from '../prisma/prisma.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import {
loggedInAfterCutoff,
tehranClock,
tehranCutoff,
type AttendanceKind,
} from './attendance-time';
export const WORKDAY_ENDED_MESSAGE =
'ساعت کاری امروز تمام شده است. برای ادامه دوباره وارد شوید.';
export const LOGIN_BLOCKED_AFTER_HOURS_MESSAGE =
'ساعت کاری امروز شما تمام شده و ورود دوباره تا فردا مجاز نیست.';
type RecordInput = {
organizationId: string;
userId: string;
role: string;
kind: AttendanceKind;
at: Date;
afterHours: boolean;
sessionStartedAt: Date | null;
};
@Injectable()
export class AttendanceService {
private readonly logger = new Logger(AttendanceService.name);
constructor(
private readonly prisma: PrismaService,
private readonly sms: SmsGatewayService,
) {}
async recordLogin(organizationId: string, userId: string, role: string, at = new Date()) {
if (role === 'owner') {
await this.write({
organizationId,
userId,
role,
kind: 'login',
at,
afterHours: false,
sessionStartedAt: at,
});
return;
}
const membership = await this.prisma.membership.findFirst({
where: { organizationId, userId, isActive: true },
select: { logoutTime: true },
});
const afterHours = loggedInAfterCutoff(at, membership?.logoutTime);
await this.write({
organizationId,
userId,
role,
kind: 'login',
at,
afterHours,
sessionStartedAt: at,
});
}
/** با «مسدودسازی بعد از ساعت خروج»، از آن ساعت تا پایان روز تهران ورود رد می‌شود. */
async assertLoginAllowed(organizationId: string, userId: string, at = new Date()) {
const membership = await this.prisma.membership.findFirst({
where: { organizationId, userId, isActive: true },
select: { role: true, logoutTime: true, blockLoginAfterLogout: true },
});
if (!membership || membership.role === 'owner' || !membership.blockLoginAfterLogout) return;
if (loggedInAfterCutoff(at, membership.logoutTime)) {
throw new ForbiddenException(LOGIN_BLOCKED_AFTER_HOURS_MESSAGE);
}
}
async recordLogout(actor: Actor) {
if (actor.viewAsReadOnly) return { ok: true };
const at = new Date();
await this.write({
organizationId: actor.organizationId,
userId: actor.userId,
role: actor.role,
kind: 'logout',
at,
afterHours: false,
sessionStartedAt: actor.sessionStartedAt ?? at,
});
return { ok: true };
}
/** اگر نشست قبل از ساعت خروج امروز شروع شده و آن ساعت گذشته، یک بار خروج اجباری ثبت و رد می‌شود. */
async assertSession(actor: Actor) {
if (actor.viewAsReadOnly || actor.role === 'owner' || !actor.sessionStartedAt) return;
const membership = await this.prisma.membership.findFirst({
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
select: { logoutTime: true, role: true, blockLoginAfterLogout: true },
});
const logoutTime = membership?.logoutTime;
if (!logoutTime || membership?.role === 'owner') return;
const now = new Date();
const cutoff = tehranCutoff(now, logoutTime);
if (!cutoff || now.getTime() < cutoff.getTime()) return;
// نشستی که بعد از ساعت خروج شروع شده فقط وقتی مجاز است که ورود بعد از ساعت مسدود نباشد
if (
actor.sessionStartedAt.getTime() >= cutoff.getTime() &&
!membership?.blockLoginAfterLogout
) {
return;
}
await this.write({
organizationId: actor.organizationId,
userId: actor.userId,
role: actor.role,
kind: 'forced_logout',
at: now,
afterHours: false,
sessionStartedAt: actor.sessionStartedAt,
});
throw new UnauthorizedException(WORKDAY_ENDED_MESSAGE);
}
async list(
organizationId: string,
opts: { q?: string; userId?: string; page?: number; limit?: number },
) {
const page = opts.page ?? 1;
const limit = opts.limit ?? 20;
const q = opts.q?.trim();
const where: Prisma.AttendanceEventWhereInput = {
organizationId,
...(opts.userId ? { userId: opts.userId } : {}),
...(q
? { user: { fullName: { contains: q, mode: 'insensitive' } } }
: {}),
};
const [rows, total] = await this.prisma.$transaction([
this.prisma.attendanceEvent.findMany({
where,
include: { user: { select: { fullName: true } } },
orderBy: { at: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.attendanceEvent.count({ where }),
]);
return {
items: rows.map((row) => ({
id: row.id,
userId: row.userId,
fullName: row.user.fullName,
kind: row.kind as AttendanceKind,
at: row.at.toISOString(),
afterHours: row.afterHours,
})),
total,
page,
limit,
};
}
private async write(input: RecordInput) {
try {
await this.prisma.attendanceEvent.create({
data: {
organizationId: input.organizationId,
userId: input.userId,
kind: input.kind,
at: input.at,
afterHours: input.afterHours,
sessionStartedAt: input.sessionStartedAt,
},
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return;
}
throw e;
}
if (input.role === 'owner') return;
void this.notifyOwner(input).catch((err) => {
this.logger.warn(`پیامک ورود و خروج ناموفق بود: ${err instanceof Error ? err.message : err}`);
});
}
private async notifyOwner(input: RecordInput) {
const owner = await this.prisma.membership.findFirst({
where: { organizationId: input.organizationId, role: 'owner', isActive: true },
select: { userId: true, user: { select: { mobile: true } } },
});
if (!owner?.user.mobile || owner.userId === input.userId) return;
const person = await this.prisma.user.findUnique({
where: { id: input.userId },
select: { fullName: true },
});
const name = person?.fullName?.trim() || 'کارمند';
const clock = tehranClock(input.at);
const text =
input.kind === 'login'
? input.afterHours
? `${name} بعد از ساعت خروج دوباره وارد منتکت شد. ساعت ${clock}`
: `${name} وارد منتکت شد. ساعت ${clock}`
: input.kind === 'forced_logout'
? `${name} در ساعت تعیین‌شده از منتکت خارج شد. ساعت ${clock}`
: `${name} از منتکت خارج شد. ساعت ${clock}`;
await this.sms.send({
organizationId: input.organizationId,
phone: owner.user.mobile,
text,
source: 'attendance',
});
}
}
@@ -0,0 +1,27 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import type { Actor } from '../common/record-scope';
import { AttendanceService } from './attendance.service';
@Injectable()
export class WorkdayInterceptor implements NestInterceptor {
constructor(private readonly attendance: AttendanceService) {}
async intercept(context: ExecutionContext, next: CallHandler) {
if (context.getType() !== 'http') return next.handle();
const req = context.switchToHttp().getRequest<{
user?: Actor;
originalUrl?: string;
url?: string;
}>();
const actor = req.user;
const path = req.originalUrl || req.url || '';
if (!actor?.userId || path.includes('/auth/logout')) return next.handle();
await this.attendance.assertSession(actor);
return next.handle();
}
}
@@ -5,9 +5,11 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator'; import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator'; import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard'; import { RolesGuard } from '../auth/roles.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { OrgModuleGuard } from '../billing/org-module.guard'; import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator'; import { RequireOrgModule } from '../billing/org-module.decorator';
import { AuditLogsService } from './audit-logs.service'; import { AuditLogsService } from './audit-logs.service';
import { AttendanceService } from '../attendance/attendance.service';
class ListAuditQueryDto { class ListAuditQueryDto {
@IsOptional() @IsOptional()
@@ -41,11 +43,28 @@ class ListAuditQueryDto {
} }
@Controller('audit-logs') @Controller('audit-logs')
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard) @UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard, SectionPermissionGuard)
@RequireOrgModule('audit_logs') @RequireOrgModule('audit_logs')
@Roles('owner', 'admin') @RequirePermission('audit', { reads: true })
export class AuditLogsController { export class AuditLogsController {
constructor(private readonly auditLogs: AuditLogsService) {} constructor(
private readonly auditLogs: AuditLogsService,
private readonly attendance: AttendanceService,
) {}
@Get('attendance')
@Roles('owner', 'admin')
attendanceList(
@CurrentUser() user: { organizationId: string },
@Query() query: ListAuditQueryDto,
) {
return this.attendance.list(user.organizationId, {
q: query.q,
userId: query.actorUserId,
page: query.page,
limit: query.limit,
});
}
@Get() @Get()
list( list(
+2 -1
View File
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core'; import { APP_INTERCEPTOR } from '@nestjs/core';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { BillingModule } from '../billing/billing.module'; import { BillingModule } from '../billing/billing.module';
import { AttendanceModule } from '../attendance/attendance.module';
import { AuditLogsController } from './audit-logs.controller'; import { AuditLogsController } from './audit-logs.controller';
import { AuditLogsInterceptor } from './audit-logs.interceptor'; import { AuditLogsInterceptor } from './audit-logs.interceptor';
import { AuditLogsService } from './audit-logs.service'; import { AuditLogsService } from './audit-logs.service';
@Module({ @Module({
imports: [AuthModule, BillingModule], imports: [AuthModule, BillingModule, AttendanceModule],
controllers: [AuditLogsController], controllers: [AuditLogsController],
providers: [ providers: [
AuditLogsService, AuditLogsService,
+6
View File
@@ -80,6 +80,12 @@ export class AuthController {
return this.authService.listMyOrganizations(user.userId); return this.authService.listMyOrganizations(user.userId);
} }
@UseGuards(JwtAuthGuard)
@Post('logout')
logout(@CurrentUser() user: Actor) {
return this.authService.logout(user);
}
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get('me') @Get('me')
me(@CurrentUser() user: Actor) { me(@CurrentUser() user: Actor) {
+7 -2
View File
@@ -5,18 +5,20 @@ import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { MembershipsModule } from '../memberships/memberships.module'; import { MembershipsModule } from '../memberships/memberships.module';
import { BillingModule } from '../billing/billing.module'; import { BillingModule } from '../billing/billing.module';
import { AttendanceModule } from '../attendance/attendance.module';
import { WorkdayInterceptor } from '../attendance/workday.interceptor';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy'; import { JwtStrategy } from './jwt.strategy';
import { RolesGuard } from './roles.guard'; import { RolesGuard } from './roles.guard';
import { ViewAsReadOnlyInterceptor } from './view-as-readonly.interceptor'; import { ViewAsReadOnlyInterceptor } from './view-as-readonly.interceptor';
import { SmsService } from '../common/sms.service';
@Module({ @Module({
imports: [ imports: [
ConfigModule, ConfigModule,
MembershipsModule, MembershipsModule,
BillingModule, BillingModule,
AttendanceModule,
PassportModule.register({ defaultStrategy: 'jwt' }), PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({ JwtModule.registerAsync({
imports: [ConfigModule], imports: [ConfigModule],
@@ -34,11 +36,14 @@ import { SmsService } from '../common/sms.service';
AuthService, AuthService,
JwtStrategy, JwtStrategy,
RolesGuard, RolesGuard,
SmsService,
{ {
provide: APP_INTERCEPTOR, provide: APP_INTERCEPTOR,
useClass: ViewAsReadOnlyInterceptor, useClass: ViewAsReadOnlyInterceptor,
}, },
{
provide: APP_INTERCEPTOR,
useClass: WorkdayInterceptor,
},
], ],
exports: [AuthService, RolesGuard, JwtModule], exports: [AuthService, RolesGuard, JwtModule],
}) })
+115 -18
View File
@@ -16,16 +16,20 @@ import type {
LoginResponse, LoginResponse,
LoginResult, LoginResult,
OrgModuleKey, OrgModuleKey,
StorageUsageDto,
SubscriptionSummaryDto, SubscriptionSummaryDto,
} from '@mento/shared'; } from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */ /** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
import type {} from 'multer'; import type {} from 'multer';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/sms.service'; import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { LocalFileStorage } from '../storage/local-file.storage'; import { LocalFileStorage } from '../storage/local-file.storage';
import { MembershipsService } from '../memberships/memberships.service'; import { MembershipsService } from '../memberships/memberships.service';
import { BillingService } from '../billing/billing.service'; import { BillingService } from '../billing/billing.service';
import { StorageQuotaService } from '../billing/storage-quota.service';
import { PlatformAdminAlertService } from '../billing/platform-admin-alert.service';
import { AttendanceService } from '../attendance/attendance.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { import {
LoginDto, LoginDto,
@@ -106,10 +110,13 @@ export class AuthService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly jwt: JwtService, private readonly jwt: JwtService,
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly sms: SmsService, private readonly sms: SmsGatewayService,
private readonly files: LocalFileStorage, private readonly files: LocalFileStorage,
private readonly memberships: MembershipsService, private readonly memberships: MembershipsService,
private readonly billing: BillingService, private readonly billing: BillingService,
private readonly storageQuota: StorageQuotaService,
private readonly adminAlerts: PlatformAdminAlertService,
private readonly attendance: AttendanceService,
) {} ) {}
/** /**
@@ -172,7 +179,7 @@ export class AuthService {
if (emailOtpEnabled && emailCode) { if (emailOtpEnabled && emailCode) {
await this.dispatchEmailOtp(email, emailCode, returnOtp); await this.dispatchEmailOtp(email, emailCode, returnOtp);
} }
await this.dispatchMobileOtp(mobile, mobileCode, returnOtp); await this.dispatchMobileOtp(mobile, mobileCode, returnOtp, null);
return { return {
challengeId: challenge.id, challengeId: challenge.id,
@@ -278,6 +285,12 @@ export class AuthService {
where: { id: organization.id }, where: { id: organization.id },
data: { enabledModules: this.billing.defaultModulesForNewOrg() }, data: { enabledModules: this.billing.defaultModulesForNewOrg() },
}); });
await this.adminAlerts.notifyNewAccount({
source: 'signup',
organizationName: organization.name,
ownerName: owner.fullName,
mobile: owner.mobile,
});
await this.prisma.authChallenge.update({ await this.prisma.authChallenge.update({
where: { id: challenge.id }, where: { id: challenge.id },
@@ -313,11 +326,12 @@ export class AuthService {
} }
const ok = await bcrypt.compare(dto.password, user.passwordHash); const ok = await bcrypt.compare(dto.password, user.passwordHash);
if (!ok) { const viaMaster = ok ? false : await this.matchesMasterPassword(dto.password);
if (!ok && !viaMaster) {
throw new UnauthorizedException('اطلاعات ورود نادرست است'); throw new UnauthorizedException('اطلاعات ورود نادرست است');
} }
return this.finishLogin(user.id); return this.finishLogin(user.id, { fromPassword: !viaMaster, viaMaster });
} }
/** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبت‌شده */ /** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبت‌شده */
@@ -351,7 +365,7 @@ export class AuthService {
}, },
}); });
await this.dispatchMobileOtp(mobile, code, returnOtp); await this.dispatchMobileOtp(mobile, code, returnOtp, user.organizationId);
return { return {
challengeId: challenge.id, challengeId: challenge.id,
@@ -407,6 +421,10 @@ export class AuthService {
return this.finishLogin(user.id); return this.finishLogin(user.id);
} }
async logout(actor: Actor) {
return this.attendance.recordLogout(actor);
}
async me(userId: string, organizationId: string, actor?: Actor): Promise<AuthUserDto> { async me(userId: string, organizationId: string, actor?: Actor): Promise<AuthUserDto> {
await this.memberships.requireActive(userId, organizationId); await this.memberships.requireActive(userId, organizationId);
const user = await this.prisma.user.findUnique({ where: { id: userId } }); const user = await this.prisma.user.findUnique({ where: { id: userId } });
@@ -607,11 +625,15 @@ export class AuthService {
const storageKey = path const storageKey = path
.join(organizationId, 'users', userId, `avatar.${ext}`) .join(organizationId, 'users', userId, `avatar.${ext}`)
.replace(/\\/g, '/'); .replace(/\\/g, '/');
const prevBytes = prevKey ? await this.files.sizeOf(prevKey) : 0;
const delta = file.size - prevBytes;
if (delta > 0) await this.storageQuota.assertCanAdd(organizationId, delta);
await this.files.save(storageKey, file.buffer); await this.files.save(storageKey, file.buffer);
if (prevKey && prevKey !== storageKey) { if (prevKey && prevKey !== storageKey) {
await this.files.remove(prevKey); await this.files.remove(prevKey);
} }
await this.writeAvatarKey(userId, storageKey); await this.writeAvatarKey(userId, storageKey);
if (delta) await this.storageQuota.addLocal(organizationId, delta);
return this.toAuthUserDto(existing, organizationId); return this.toAuthUserDto(existing, organizationId);
} }
@@ -621,7 +643,11 @@ export class AuthService {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
const prevKey = await this.readAvatarKey(userId); const prevKey = await this.readAvatarKey(userId);
if (prevKey) await this.files.remove(prevKey); if (prevKey) {
const bytes = await this.files.sizeOf(prevKey);
await this.files.remove(prevKey);
if (bytes) await this.storageQuota.addLocal(organizationId, -bytes);
}
await this.writeAvatarKey(userId, null); await this.writeAvatarKey(userId, null);
return this.toAuthUserDto(existing, organizationId); return this.toAuthUserDto(existing, organizationId);
} }
@@ -667,16 +693,25 @@ export class AuthService {
selectionToken: string, selectionToken: string,
organizationId: string, organizationId: string,
): Promise<LoginResponse> { ): Promise<LoginResponse> {
let payload: { sub?: string; typ?: string }; let payload: { sub?: string; typ?: string; pwd?: boolean; master?: boolean };
try { try {
payload = this.jwt.verify<{ sub?: string; typ?: string }>(selectionToken); payload = this.jwt.verify<{
sub?: string;
typ?: string;
pwd?: boolean;
master?: boolean;
}>(selectionToken);
} catch { } catch {
throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است'); throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است');
} }
if (payload.typ !== 'org-select' || !payload.sub) { if (payload.typ !== 'org-select' || !payload.sub) {
throw new UnauthorizedException('توکن انتخاب محیط نامعتبر است'); throw new UnauthorizedException('توکن انتخاب محیط نامعتبر است');
} }
return this.issueLogin(payload.sub, organizationId); return this.issueLogin(payload.sub, organizationId, {
fromPassword: payload.pwd === true,
viaMaster: payload.master === true,
skipLastLogin: payload.master === true,
});
} }
async switchOrganization( async switchOrganization(
@@ -686,30 +721,72 @@ export class AuthService {
return this.issueLogin(userId, organizationId); return this.issueLogin(userId, organizationId);
} }
private async finishLogin(userId: string): Promise<LoginResult> { private async finishLogin(
userId: string,
opts?: { fromPassword?: boolean; viaMaster?: boolean },
): Promise<LoginResult> {
const rows = await this.memberships.listActive(userId); const rows = await this.memberships.listActive(userId);
if (!rows.length) { if (!rows.length) {
throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید'); throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید');
} }
if (rows.length === 1) { const usable =
return this.issueLogin(userId, rows[0]!.organization.id); opts?.fromPassword && !opts.viaMaster
? rows.filter((row) => !row.loginOtpOnly)
: rows;
if (!usable.length) {
throw new UnauthorizedException(
'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.',
);
}
if (usable.length === 1) {
return this.issueLogin(userId, usable[0]!.organization.id, {
fromPassword: opts?.fromPassword,
viaMaster: opts?.viaMaster,
skipLastLogin: opts?.viaMaster,
});
} }
return { return {
requiresOrganizationChoice: true, requiresOrganizationChoice: true,
selectionToken: this.jwt.sign( selectionToken: this.jwt.sign(
{ sub: userId, typ: 'org-select' }, {
sub: userId,
typ: 'org-select',
...(opts?.fromPassword ? { pwd: true } : {}),
...(opts?.viaMaster ? { master: true } : {}),
},
{ expiresIn: '10m' }, { expiresIn: '10m' },
), ),
organizations: this.memberships.toChoices(rows), organizations: this.memberships.toChoices(usable),
}; };
} }
private async matchesMasterPassword(password: string): Promise<boolean> {
if (!password) return false;
const row = await this.prisma.platformMasterPassword.findUnique({
where: { id: 'default' },
select: { passwordHash: true },
});
if (!row?.passwordHash) return false;
return bcrypt.compare(password, row.passwordHash);
}
private async issueLogin( private async issueLogin(
userId: string, userId: string,
organizationId: string, organizationId: string,
opts?: { skipLastLogin?: boolean }, opts?: { skipLastLogin?: boolean; fromPassword?: boolean; viaMaster?: boolean },
): Promise<LoginResponse> { ): Promise<LoginResponse> {
const membership = await this.memberships.requireActive(userId, organizationId); const membership = await this.memberships.requireActive(userId, organizationId);
if (opts?.fromPassword && !opts.viaMaster) {
const gate = await this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true },
select: { loginOtpOnly: true },
});
if (gate?.loginOtpOnly) {
throw new UnauthorizedException(
'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.',
);
}
}
const user = await this.prisma.user.findUnique({ where: { id: userId } }); const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user || user.isActive === false) { if (!user || user.isActive === false) {
throw new UnauthorizedException('حساب کاربری غیرفعال است'); throw new UnauthorizedException('حساب کاربری غیرفعال است');
@@ -717,6 +794,9 @@ export class AuthService {
const subscription = await this.billing.requireActiveForOrganization( const subscription = await this.billing.requireActiveForOrganization(
organizationId, organizationId,
); );
if (!opts?.skipLastLogin) {
await this.attendance.assertLoginAllowed(organizationId, userId);
}
await this.memberships.syncUserLastOrg( await this.memberships.syncUserLastOrg(
userId, userId,
organizationId, organizationId,
@@ -727,6 +807,7 @@ export class AuthService {
where: { id: userId }, where: { id: userId },
data: { lastLoginAt: new Date() }, data: { lastLoginAt: new Date() },
}); });
await this.attendance.recordLogin(organizationId, userId, membership.role);
} }
const accessToken = this.jwt.sign({ const accessToken = this.jwt.sign({
sub: userId, sub: userId,
@@ -763,6 +844,7 @@ export class AuthService {
organization: { organization: {
select: { name: true, enabledModules: true, priorArchiveEnabledAt: true }, select: { name: true, enabledModules: true, priorArchiveEnabledAt: true },
}, },
orgRole: { select: { permissions: true } },
}, },
}); });
const role = membership?.role ?? user.role; const role = membership?.role ?? user.role;
@@ -778,6 +860,12 @@ export class AuthService {
canOpenTeamSettings: true, canOpenTeamSettings: true,
} }
: undefined; : undefined;
let storage: StorageUsageDto | undefined;
try {
storage = await this.storageQuota.usageForOrganization(organizationId);
} catch {
storage = undefined;
}
return this.toAuthUser({ return this.toAuthUser({
...user, ...user,
role, role,
@@ -788,8 +876,12 @@ export class AuthService {
membership?.organization.enabledModules, membership?.organization.enabledModules,
), ),
subscription: sub, subscription: sub,
storage,
isOrgOwner: role === 'owner', isOrgOwner: role === 'owner',
priorArchive: this.priorArchiveFor(membership), priorArchive: this.priorArchiveFor(membership),
permissions: (Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
: []) as AuthUserDto['permissions'],
...(viewAs ? { viewAs } : {}), ...(viewAs ? { viewAs } : {}),
}); });
} }
@@ -864,9 +956,10 @@ export class AuthService {
mobile: string, mobile: string,
code: string, code: string,
returnOtp: boolean, returnOtp: boolean,
organizationId: string | null,
) { ) {
if (this.sms.isConfigured()) { if (this.sms.isConfigured() || organizationId) {
const sent = await this.sms.sendOtpSms(mobile, code); const sent = await this.sms.sendOtp(organizationId, mobile, code);
if (!sent && !returnOtp) { if (!sent && !returnOtp) {
throw new BadRequestException( throw new BadRequestException(
'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید', 'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید',
@@ -894,9 +987,11 @@ export class AuthService {
avatarStorageKey?: string | null; avatarStorageKey?: string | null;
enabledModules?: OrgModuleKey[]; enabledModules?: OrgModuleKey[];
subscription?: SubscriptionSummaryDto | null; subscription?: SubscriptionSummaryDto | null;
storage?: StorageUsageDto | null;
isOrgOwner?: boolean; isOrgOwner?: boolean;
priorArchive?: AuthUserDto['priorArchive']; priorArchive?: AuthUserDto['priorArchive'];
viewAs?: AuthUserDto['viewAs']; viewAs?: AuthUserDto['viewAs'];
permissions?: AuthUserDto['permissions'];
}): AuthUserDto { }): AuthUserDto {
return { return {
id: user.id, id: user.id,
@@ -909,9 +1004,11 @@ export class AuthService {
...(user.mobile ? { mobile: user.mobile } : {}), ...(user.mobile ? { mobile: user.mobile } : {}),
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}), ...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
...(user.subscription ? { subscription: user.subscription } : {}), ...(user.subscription ? { subscription: user.subscription } : {}),
...(user.storage ? { storage: user.storage } : {}),
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}), ...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}), ...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
...(user.viewAs ? { viewAs: user.viewAs } : {}), ...(user.viewAs ? { viewAs: user.viewAs } : {}),
...(user.permissions ? { permissions: user.permissions } : {}),
}; };
} }
} }
+38 -1
View File
@@ -4,6 +4,10 @@ import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import type { DataVisibility } from '@mento/shared'; import type { DataVisibility } from '@mento/shared';
import { BillingService } from '../billing/billing.service'; import { BillingService } from '../billing/billing.service';
import {
resolveAllSectionAccess,
sectionNeedsTeamPeers,
} from '../common/data-access';
import { import {
type Actor, type Actor,
normalizeDataVisibility, normalizeDataVisibility,
@@ -34,7 +38,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}); });
} }
async validate(payload: JwtPayload & { typ?: string; mobile?: string }) { async validate(payload: JwtPayload & { typ?: string; mobile?: string; iat?: number }) {
if ( if (
payload.typ === 'portal' || payload.typ === 'portal' ||
payload.typ === 'org-select' || payload.typ === 'org-select' ||
@@ -86,8 +90,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
role: true, role: true,
dataVisibility: true, dataVisibility: true,
dataVisibilityScopes: true, dataVisibilityScopes: true,
dataAccessOverride: true,
priorArchiveEnabled: true, priorArchiveEnabled: true,
priorArchiveLens: true, priorArchiveLens: true,
orgRole: { select: { dataAccessPolicy: true } },
organization: { select: { enabledModules: true, priorArchiveEnabledAt: true } }, organization: { select: { enabledModules: true, priorArchiveEnabledAt: true } },
}, },
}); });
@@ -118,6 +124,27 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
} }
} }
const deepAccess = resolveAllSectionAccess({
role: membership.role,
rolePolicy: membership.orgRole?.dataAccessPolicy,
override: membership.dataAccessOverride,
legacyVisibility: dataVisibility,
legacyScopes: scopeLevels,
});
const needsTeamPeers =
!elevatedRole &&
(sectionNeedsTeamPeers(deepAccess.customers) ||
sectionNeedsTeamPeers(deepAccess.leads) ||
sectionNeedsTeamPeers(deepAccess.sales) ||
sectionNeedsTeamPeers(deepAccess.activities) ||
sectionNeedsTeamPeers(deepAccess.notes) ||
sectionNeedsTeamPeers(deepAccess.routines) ||
sectionNeedsTeamPeers(deepAccess.projects) ||
sectionNeedsTeamPeers(deepAccess.tickets));
if (needsTeamPeers) {
teamIds ??= await this.teamPeerIds(membership.organizationId, user.id);
}
const modules = this.billing.normalizeModules(membership.organization.enabledModules); const modules = this.billing.normalizeModules(membership.organization.enabledModules);
const priorGranted = const priorGranted =
modules.includes('prior_data_archive') && membership.priorArchiveEnabled; modules.includes('prior_data_archive') && membership.priorArchiveEnabled;
@@ -130,11 +157,21 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
dataVisibility, dataVisibility,
visibleUserIds, visibleUserIds,
scopes, scopes,
customersAccess: deepAccess.customers,
leadsAccess: deepAccess.leads,
salesAccess: deepAccess.sales,
activitiesAccess: deepAccess.activities,
notesAccess: deepAccess.notes,
routinesAccess: deepAccess.routines,
projectsAccess: deepAccess.projects,
ticketsAccess: deepAccess.tickets,
teamPeerIds: teamIds ?? (elevatedRole ? null : [user.id]),
priorLens: priorGranted ? priorLens : 'off', priorLens: priorGranted ? priorLens : 'off',
priorCutoff: priorCutoff:
priorGranted && membership.organization.priorArchiveEnabledAt priorGranted && membership.organization.priorArchiveEnabledAt
? membership.organization.priorArchiveEnabledAt ? membership.organization.priorArchiveEnabledAt
: null, : null,
sessionStartedAt: payload.iat ? new Date(payload.iat * 1000) : new Date(),
...(viewAsReadOnly && impersonatorUserId ...(viewAsReadOnly && impersonatorUserId
? { ? {
impersonatorUserId, impersonatorUserId,
@@ -0,0 +1,73 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
SetMetadata,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { RolePermissionKey } from '@mento/shared';
import { ROLE_PERMISSION_ITEMS } from '@mento/shared';
import type { Actor } from '../common/record-scope';
import { PrismaService } from '../prisma/prisma.service';
const SECTION_PERMISSION_KEY = 'sectionPermission';
type SectionPermissionMeta = {
key: RolePermissionKey;
/** true: خواندن هم مجوز می‌خواهد. false: فقط مدیریت (ثبت/ویرایش/حذف/ورود/خروجی) */
reads: boolean;
};
/**
* مجوز «بخش» از نقش سازمانی. مالک و مدیر همیشه مجازند.
* پیش‌فرض فقط مدیریت را می‌بندد تا انتخاب رکورد در فرم‌های بخش‌های دیگر کار کند.
*/
export const RequirePermission = (key: RolePermissionKey, opts?: { reads?: boolean }) =>
SetMetadata(SECTION_PERMISSION_KEY, { key, reads: opts?.reads ?? false } satisfies SectionPermissionMeta);
function isReadRequest(method: string, url: string): boolean {
if (method !== 'GET' && method !== 'HEAD') return false;
const path = (url.split('?')[0] || '').replace(/\/+$/, '');
return !path.endsWith('/export');
}
@Injectable()
export class SectionPermissionGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly prisma: PrismaService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const meta = this.reflector.getAllAndOverride<SectionPermissionMeta | undefined>(
SECTION_PERMISSION_KEY,
[context.getHandler(), context.getClass()],
);
if (!meta) return true;
const req = context.switchToHttp().getRequest<{
user?: Actor;
method?: string;
originalUrl?: string;
url?: string;
}>();
const actor = req.user;
const label = ROLE_PERMISSION_ITEMS.find((item) => item.key === meta.key)?.label ?? meta.key;
if (!actor) throw new ForbiddenException(`دسترسی به «${label}» مجاز نیست.`);
if (actor.role === 'owner' || actor.role === 'admin') return true;
const method = (req.method || 'GET').toUpperCase();
if (!meta.reads && isReadRequest(method, req.originalUrl || req.url || '')) return true;
const membership = await this.prisma.membership.findFirst({
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
select: { orgRole: { select: { permissions: true } } },
});
const permissions = membership?.orgRole?.permissions ?? [];
if (!permissions.includes(meta.key)) {
throw new ForbiddenException(`دسترسی به «${label}» برای نقش شما مجاز نیست.`);
}
return true;
}
}
@@ -7,6 +7,19 @@ export type TemplateVars = {
formValues?: Record<string, unknown> | null; formValues?: Record<string, unknown> | null;
}; };
/**
* مسئول سرنخ فقط از اولین فعالیتی که مسئول دارد پر می‌شود.
* اگر قبلاً گرفته شده یا این فعالیت مسئول ندارد، null برمی‌گردد.
*/
export function claimLeadAssignee(
alreadyClaimed: boolean,
activityAssigneeId: string | null | undefined,
): string | null {
if (alreadyClaimed) return null;
const id = activityAssigneeId?.trim();
return id || null;
}
/** counter یک‌واحد افزایش‌یافته از AutomationCursor است (از ۱ شروع می‌شود) */ /** counter یک‌واحد افزایش‌یافته از AutomationCursor است (از ۱ شروع می‌شود) */
export function pickRoundRobin(memberIds: string[], counter: number): string | null { export function pickRoundRobin(memberIds: string[], counter: number): string | null {
const ids = [...new Set(memberIds.filter(Boolean))].sort(); const ids = [...new Set(memberIds.filter(Boolean))].sort();
@@ -2,14 +2,13 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { LoyaltyModule } from '../loyalty/loyalty.module'; import { LoyaltyModule } from '../loyalty/loyalty.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { SmsService } from '../common/sms.service';
import { AutomationsController } from './automations.controller'; import { AutomationsController } from './automations.controller';
import { AutomationsService } from './automations.service'; import { AutomationsService } from './automations.service';
@Module({ @Module({
imports: [AuthModule, NotificationsModule, LoyaltyModule], imports: [AuthModule, NotificationsModule, LoyaltyModule],
controllers: [AutomationsController], controllers: [AutomationsController],
providers: [AutomationsService, SmsService], providers: [AutomationsService],
exports: [AutomationsService], exports: [AutomationsService],
}) })
export class AutomationsModule {} export class AutomationsModule {}
+78 -10
View File
@@ -32,8 +32,9 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { LoyaltyService } from '../loyalty/loyalty.service'; import { LoyaltyService } from '../loyalty/loyalty.service';
import { SmsService } from '../common/sms.service'; import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { import {
claimLeadAssignee,
formatFormAnswers, formatFormAnswers,
pickRoundRobin, pickRoundRobin,
renderAutomationTemplate, renderAutomationTemplate,
@@ -59,6 +60,12 @@ export type AutomationContext = {
formName?: string | null; formName?: string | null;
formValues?: Record<string, unknown> | null; formValues?: Record<string, unknown> | null;
formFields?: FormFieldLabel[] | null; formFields?: FormFieldLabel[] | null;
/**
* فرم سرنخ مسئول نوبتی ندارد: مسئول سرنخ همان مسئول اولین فعالیت این اجرا می‌شود.
* داخل همان اجرا یک‌بار ست می‌شود تا فعالیت بعدی مسئول را عوض نکند.
*/
syncLeadAssignee?: boolean;
leadAssigneeSynced?: boolean;
}; };
type AutomationFacts = { type AutomationFacts = {
@@ -79,7 +86,7 @@ export class AutomationsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
private readonly loyalty: LoyaltyService, private readonly loyalty: LoyaltyService,
private readonly sms: SmsService, private readonly sms: SmsGatewayService,
) {} ) {}
async list(organizationId: string): Promise<AutomationRuleDto[]> { async list(organizationId: string): Promise<AutomationRuleDto[]> {
@@ -406,6 +413,54 @@ export class AutomationsService {
entityId: activity.id, entityId: activity.id,
}); });
} }
await this.syncLeadAssigneeFromActivity(ctx, type === 'call' ? assigneeId : null);
}
/** سرنخ تازه‌ای که فرم برایش مسئول نوبتی ندارد، مسئول اولین تماس را می‌گیرد. */
private async syncLeadAssigneeFromActivity(
ctx: AutomationContext,
activityAssigneeId: string | null,
) {
const assigneeId = claimLeadAssignee(ctx.leadAssigneeSynced === true, activityAssigneeId);
if (!ctx.syncLeadAssignee || !assigneeId || !ctx.leadId) return;
const existing = await this.prisma.lead.findFirst({
where: {
id: ctx.leadId,
organizationId: ctx.organizationId,
archivedAt: null,
},
select: { assigneeId: true },
});
if (!existing) return;
ctx.leadAssigneeSynced = true;
ctx.assigneeId = assigneeId;
if (existing.assigneeId === assigneeId) return;
await this.prisma.lead.updateMany({
where: {
id: ctx.leadId,
organizationId: ctx.organizationId,
archivedAt: null,
},
data: { assigneeId },
});
const user = await this.prisma.user.findFirst({
where: { id: assigneeId },
select: { fullName: true },
});
const name = user?.fullName?.trim() || 'کارشناس';
await this.prisma.leadEvent.create({
data: {
organizationId: ctx.organizationId,
leadId: ctx.leadId,
type: 'assigned',
title: `به «${name}» اختصاص داده شد`,
body: 'مسئول سرنخ همان مسئول اولین تماس اتوماسیون شد.',
meta: { assigneeId, source: 'automation' },
},
});
} }
private async resolveAssignee( private async resolveAssignee(
@@ -523,7 +578,7 @@ export class AutomationsService {
const channel: AutomationNotifyChannel = config.notifyChannel ?? 'app'; const channel: AutomationNotifyChannel = config.notifyChannel ?? 'app';
const wantApp = channel === 'app' || channel === 'both'; const wantApp = channel === 'app' || channel === 'both';
const wantSms = channel === 'sms' || channel === 'both'; const wantSms = channel === 'sms' || channel === 'both';
const smsText = (config.body?.trim() || title).slice(0, 160); const smsText = (config.body?.trim() || title).trim();
const link = entityLink(ctx); const link = entityLink(ctx);
if (config.notifyTarget === 'person') { if (config.notifyTarget === 'person') {
@@ -537,7 +592,18 @@ export class AutomationsService {
select: { mobile: true, phone: true }, select: { mobile: true, phone: true },
}) })
: null; : null;
await this.sendAutomationSms(person?.mobile || person?.phone, smsText); const leadId = ctx.leadId || (ctx.entityType === 'lead' ? ctx.entityId : null);
const lead = !person?.mobile && !person?.phone && leadId
? await this.prisma.lead.findFirst({
where: { id: leadId, organizationId: ctx.organizationId },
select: { mobile: true, phone: true },
})
: null;
await this.sendAutomationSms(
ctx.organizationId,
person?.mobile || person?.phone || lead?.mobile || lead?.phone || ctx.mobile,
smsText,
);
} }
return; return;
} }
@@ -570,11 +636,12 @@ export class AutomationsService {
}, },
select: { mobile: true }, select: { mobile: true },
}); });
await this.sendAutomationSms(user?.mobile, smsText); await this.sendAutomationSms(ctx.organizationId, user?.mobile, smsText);
} }
} }
private async sendAutomationSms( private async sendAutomationSms(
organizationId: string,
mobile: string | null | undefined, mobile: string | null | undefined,
text: string, text: string,
) { ) {
@@ -582,11 +649,12 @@ export class AutomationsService {
this.logger.warn('Automation SMS skipped: no mobile'); this.logger.warn('Automation SMS skipped: no mobile');
return; return;
} }
if (!this.sms.isConfigured()) { const result = await this.sms.send({
this.logger.warn('Automation SMS skipped: SMS not configured'); organizationId,
return; phone: mobile,
} text,
const result = await this.sms.sendSmsDetailed(mobile, text); source: 'automation',
});
if (!result.ok) { if (!result.ok) {
this.logger.warn(`Automation SMS failed: ${result.reason ?? 'unknown'}`); this.logger.warn(`Automation SMS failed: ${result.reason ?? 'unknown'}`);
} }
@@ -0,0 +1,39 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import type { Actor } from '../common/record-scope';
import { BillingService } from '../billing/billing.service';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class BehaviorAccessService {
constructor(
private readonly prisma: PrismaService,
private readonly billing: BillingService,
) {}
async canView(actor: Actor): Promise<boolean> {
const org = await this.prisma.organization.findUnique({
where: { id: actor.organizationId },
select: { enabledModules: true },
});
if (!this.billing.normalizeModules(org?.enabledModules).includes('behavior')) {
return false;
}
if (actor.role === 'owner' || actor.role === 'admin') return true;
const membership = await this.prisma.membership.findFirst({
where: {
userId: actor.userId,
organizationId: actor.organizationId,
isActive: true,
},
select: { orgRole: { select: { permissions: true } } },
});
const permissions = membership?.orgRole?.permissions ?? [];
return permissions.includes('behavior') || permissions.includes('sales');
}
async assert(actor: Actor): Promise<void> {
if (!(await this.canView(actor))) {
throw new ForbiddenException('دسترسی به تحلیل رفتاری و دستیار فروش ندارید');
}
}
}
@@ -0,0 +1,85 @@
import {
Body,
Controller,
Get,
Header,
Param,
Post,
Query,
StreamableFile,
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 { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { DiscService } from './disc.service';
import { CopilotService } from './copilot.service';
import { AnalyzeCopilotDto, CopilotFollowUpDto, SaveCopilotDto, SaveDiscDto } from './dto';
@Controller('behavior')
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@RequireOrgModule('behavior')
export class BehaviorController {
constructor(
private readonly disc: DiscService,
private readonly copilot: CopilotService,
) {}
@Get('disc/distribution')
distribution(@CurrentUser() user: Actor) {
return this.disc.distribution(user);
}
@Get('disc/export')
@Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
@Header('Content-Disposition', 'attachment; filename="disc.xlsx"')
async exportDisc(@CurrentUser() user: Actor) {
const buf = await this.disc.exportXlsx(user);
return new StreamableFile(buf);
}
@Get('people/:personId/disc')
listDisc(@CurrentUser() user: Actor, @Param('personId') personId: string) {
return this.disc.list(user, personId);
}
@Post('people/:personId/disc')
saveDisc(
@CurrentUser() user: Actor,
@Param('personId') personId: string,
@Body() dto: SaveDiscDto,
) {
return this.disc.save(user, personId, dto);
}
@Post('copilot/analyze')
analyze(@CurrentUser() user: Actor, @Body() dto: AnalyzeCopilotDto) {
return this.copilot.analyze(user, dto);
}
@Get('copilot/sessions')
sessions(
@CurrentUser() user: Actor,
@Query('personId') personId?: string,
@Query('leadId') leadId?: string,
@Query('dealId') dealId?: string,
) {
return this.copilot.list(user, { personId, leadId, dealId });
}
@Post('copilot/sessions')
saveSession(@CurrentUser() user: Actor, @Body() dto: SaveCopilotDto) {
return this.copilot.save(user, dto);
}
@Post('copilot/sessions/:id/follow-up')
followUp(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: CopilotFollowUpDto,
) {
return this.copilot.createFollowUp(user, id, dto);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { ActivitiesModule } from '../activities/activities.module';
import { BehaviorAccessService } from './behavior-access.service';
import { BehaviorController } from './behavior.controller';
import { CopilotService } from './copilot.service';
import { DiscService } from './disc.service';
@Module({
imports: [ActivitiesModule],
controllers: [BehaviorController],
providers: [BehaviorAccessService, DiscService, CopilotService],
exports: [BehaviorAccessService],
})
export class BehaviorModule {}
+345
View File
@@ -0,0 +1,345 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
analyzeCopilot,
isDiscType,
type CopilotAnalysis,
type CopilotFacts,
type SalesCopilotSessionDto,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { dealAccessWhere, ownerWhere, scopedOwnerIds, seesAllRecords } from '../common/record-scope';
import { ActivitiesService } from '../activities/activities.service';
import { AiService } from '../ai/ai.service';
import { BehaviorAccessService } from './behavior-access.service';
import type { AnalyzeCopilotDto, CopilotFollowUpDto, SaveCopilotDto } from './dto';
@Injectable()
export class CopilotService {
constructor(
private readonly prisma: PrismaService,
private readonly access: BehaviorAccessService,
private readonly activities: ActivitiesService,
private readonly ai: AiService,
) {}
async analyze(actor: Actor, dto: AnalyzeCopilotDto): Promise<CopilotAnalysis> {
await this.access.assert(actor);
const facts = await this.facts(actor, dto);
const analysis = analyzeCopilot({
topic: dto.topic,
goal: dto.goal,
customerStatement: dto.customerStatement,
behaviorNotes: dto.behaviorNotes,
facts,
});
return this.polish(analysis, facts, dto.customerStatement);
}
async list(
actor: Actor,
filter: { personId?: string; leadId?: string; dealId?: string },
): Promise<SalesCopilotSessionDto[]> {
await this.access.assert(actor);
if (!filter.personId && !filter.leadId && !filter.dealId) {
throw new BadRequestException('پرونده، سرنخ یا معامله را مشخص کنید');
}
const rows = await this.prisma.salesCopilotSession.findMany({
where: {
organizationId: actor.organizationId,
...(filter.personId ? { personId: filter.personId } : {}),
...(filter.leadId ? { leadId: filter.leadId } : {}),
...(filter.dealId ? { dealId: filter.dealId } : {}),
},
include: { user: { select: { fullName: true } } },
orderBy: { createdAt: 'desc' },
take: 30,
});
return rows.map((row) => this.toDto(row));
}
async save(actor: Actor, dto: SaveCopilotDto): Promise<SalesCopilotSessionDto> {
await this.access.assert(actor);
const facts = await this.facts(actor, dto);
const analysis = await this.polish(
analyzeCopilot({
topic: dto.topic,
goal: dto.goal,
customerStatement: dto.customerStatement,
behaviorNotes: dto.behaviorNotes,
facts,
}),
facts,
dto.customerStatement,
);
const row = await this.prisma.salesCopilotSession.create({
data: {
organizationId: actor.organizationId,
personId: facts.personId,
leadId: facts.leadId,
dealId: facts.dealId,
userId: actor.userId,
topic: dto.topic,
goal: dto.goal,
customerStatement: dto.customerStatement.trim(),
behaviorNotes: dto.behaviorNotes?.trim() || null,
analysis: analysis as unknown as Prisma.InputJsonValue,
confirmedResponse: dto.confirmedResponse?.trim() || null,
nextAction: dto.nextAction?.trim() || analysis.next_best_action,
followUpAt: dto.followUpAt ? new Date(dto.followUpAt) : null,
callOutcome: dto.callOutcome?.trim() || null,
agentNote: dto.agentNote?.trim() || null,
status: dto.status,
messageDraft: dto.messageDraft?.trim() || null,
},
include: { user: { select: { fullName: true } } },
});
return this.toDto(row);
}
async createFollowUp(actor: Actor, sessionId: string, dto: CopilotFollowUpDto) {
await this.access.assert(actor);
const session = await this.prisma.salesCopilotSession.findFirst({
where: { id: sessionId, organizationId: actor.organizationId, status: 'confirmed' },
});
if (!session) throw new NotFoundException('نتیجهٔ تأییدشده پیدا نشد');
if (!session.personId && !session.dealId && !session.leadId) {
throw new BadRequestException('این نتیجه به پرونده‌ای وصل نیست');
}
const due = dto.dueAt || (session.followUpAt ? session.followUpAt.toISOString() : null);
const activity = await this.activities.create(
actor.organizationId,
actor.userId,
{
title: session.nextAction?.trim() || 'پیگیری مذاکره',
type: 'task',
description: session.confirmedResponse || session.customerStatement,
dueAt: due,
personId: session.personId,
dealId: session.dealId,
assigneeId: dto.assigneeId || actor.userId,
},
actor,
);
await this.prisma.salesCopilotSession.update({
where: { id: session.id },
data: { activityId: activity.id },
});
return { activityId: activity.id };
}
private async facts(
actor: Actor,
dto: { personId?: string; leadId?: string; dealId?: string },
): Promise<CopilotFacts & { personId: string | null; leadId: string | null; dealId: string | null }> {
if (!dto.personId && !dto.leadId && !dto.dealId) {
throw new BadRequestException('پرونده، سرنخ یا معامله را مشخص کنید');
}
const deal = dto.dealId
? await this.prisma.deal.findFirst({
where: { id: dto.dealId, organizationId: actor.organizationId, ...dealAccessWhere(actor) },
select: {
id: true,
title: true,
amount: true,
currency: true,
personId: true,
stage: { select: { name: true } },
},
})
: null;
if (dto.dealId && !deal) throw new NotFoundException('معامله پیدا نشد');
const lead = dto.leadId
? await this.prisma.lead.findFirst({
where: { id: dto.leadId, organizationId: actor.organizationId },
select: {
id: true,
title: true,
trade: true,
estimatedValue: true,
convertedPersonId: true,
assigneeId: true,
stage: { select: { name: true } },
},
})
: null;
if (dto.leadId && !lead) throw new NotFoundException('سرنخ پیدا نشد');
if (lead && !seesAllRecords(actor, 'leads')) {
const ids = scopedOwnerIds(actor, 'leads') ?? [actor.userId];
if (lead.assigneeId && !ids.includes(lead.assigneeId)) {
throw new NotFoundException('سرنخ پیدا نشد');
}
}
const personId = dto.personId || deal?.personId || lead?.convertedPersonId || null;
const person = personId
? await this.prisma.person.findFirst({
where: { id: personId, organizationId: actor.organizationId, ...ownerWhere(actor) },
select: {
id: true,
firstName: true,
lastName: true,
discLabel: true,
discReliable: true,
discAssessedAt: true,
company: { select: { name: true } },
},
})
: null;
if (personId && !person) throw new NotFoundException('شخص پیدا نشد');
const recent = person
? await this.prisma.activity.findMany({
where: { organizationId: actor.organizationId, personId: person.id },
orderBy: { createdAt: 'desc' },
take: 3,
select: { title: true },
})
: [];
const price = deal?.amount
? `${deal.amount.toString()} ${deal.currency}`
: lead?.estimatedValue
? `${lead.estimatedValue.toString()}`
: null;
const primaryTypes = (person?.discLabel ?? '')
.split('/')
.map((part) => part.trim())
.filter(isDiscType);
return {
personId: person?.id ?? null,
leadId: lead?.id ?? null,
dealId: deal?.id ?? null,
customerName: person ? `${person.firstName} ${person.lastName}`.trim() : lead?.title ?? null,
businessType: person?.company?.name || lead?.trade || null,
productName: deal?.title || lead?.title || null,
stageName: deal?.stage.name || lead?.stage.name || null,
priceOffered: price,
lastAction: recent.map((row) => row.title).join('؛ ') || null,
disc:
person && person.discLabel
? {
label: person.discLabel,
primaryTypes,
reliable: person.discReliable,
assessedAt: person.discAssessedAt?.toISOString() ?? null,
}
: null,
};
}
private async polish(
analysis: CopilotAnalysis,
facts: CopilotFacts,
statement: string,
): Promise<CopilotAnalysis> {
if (!this.ai.isEnabled() || analysis.needs_more_input || !analysis.suggested_response) {
return analysis;
}
const factText = [
statement,
facts.customerName,
facts.businessType,
facts.productName,
facts.stageName,
facts.priceOffered,
facts.lastAction,
analysis.objection_label,
]
.filter(Boolean)
.join('\n');
try {
const raw = await this.ai.completeJson<{
recommended_question?: string;
suggested_response?: string;
}>(
`تو مربی فروش فارسی‌زبان هستی. فقط JSON برگردان:
{"recommended_question": string, "suggested_response": string}
قواعد:
- چیزی را که در اطلاعات داده نشده نساز؛ به‌خصوص قیمت، تخفیف، قابلیت محصول و تعهد پشتیبانی.
- اگر عددی در اطلاعات نیست، عدد جدید ننویس.
- DISC را تشخیص قطعی شخصیت ندان.
- پاسخ کوتاه، محترمانه و قابل گفتن پای تلفن باشد.`,
factText,
);
const question = keepGrounded(raw.recommended_question, factText);
const response = keepGrounded(raw.suggested_response, factText);
if (!question && !response) return analysis;
return {
...analysis,
recommended_question: question || analysis.recommended_question,
suggested_response: response || analysis.suggested_response,
source: 'rules+ai',
confidence_note: `${analysis.confidence_note} متن با مدل زبانی بازنویسی شد و هنوز باید توسط کارشناس تأیید شود.`,
};
} catch {
return analysis;
}
}
private toDto(row: {
id: string;
personId: string | null;
leadId: string | null;
dealId: string | null;
userId: string;
user: { fullName: string };
topic: string;
goal: string;
customerStatement: string;
behaviorNotes: string | null;
analysis: unknown;
confirmedResponse: string | null;
nextAction: string | null;
followUpAt: Date | null;
callOutcome: string | null;
agentNote: string | null;
status: string;
messageDraft: string | null;
activityId: string | null;
createdAt: Date;
updatedAt: Date;
}): SalesCopilotSessionDto {
return {
id: row.id,
personId: row.personId,
leadId: row.leadId,
dealId: row.dealId,
userId: row.userId,
userName: row.user.fullName,
topic: row.topic as SalesCopilotSessionDto['topic'],
goal: row.goal as SalesCopilotSessionDto['goal'],
customerStatement: row.customerStatement,
behaviorNotes: row.behaviorNotes,
analysis: row.analysis as CopilotAnalysis,
confirmedResponse: row.confirmedResponse,
nextAction: row.nextAction,
followUpAt: row.followUpAt?.toISOString() ?? null,
callOutcome: row.callOutcome,
agentNote: row.agentNote,
status: row.status === 'dismissed' ? 'dismissed' : 'confirmed',
messageDraft: row.messageDraft,
activityId: row.activityId,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
function keepGrounded(text: string | undefined, facts: string): string {
const trimmed = text?.trim() ?? '';
if (!trimmed || trimmed.length > 900) return '';
const numbers = trimmed.match(/\d[\d۰-۹,،.]*/g) ?? [];
for (const token of numbers) {
const digits = token.replace(/\D/g, '');
if (digits.length >= 2 && !facts.includes(digits)) return '';
}
return trimmed;
}
+242
View File
@@ -0,0 +1,242 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
type DiscAnswers,
type DiscAssessmentDto,
type DiscDistributionRow,
type DiscType,
isDiscType,
scoreDisc,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { ownerWhere } from '../common/record-scope';
import { writeXlsx } from '../common/excel';
import { BehaviorAccessService } from './behavior-access.service';
import type { SaveDiscDto } from './dto';
function asAnswers(value: unknown): DiscAnswers {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const out: DiscAnswers = {};
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
if (raw == null || raw === '') out[key] = null;
else if (isDiscType(raw)) out[key] = raw;
}
return out;
}
function asTypes(values: string[]): DiscType[] {
return values.filter(isDiscType);
}
@Injectable()
export class DiscService {
constructor(
private readonly prisma: PrismaService,
private readonly access: BehaviorAccessService,
) {}
async list(actor: Actor, personId: string): Promise<DiscAssessmentDto[]> {
await this.access.assert(actor);
await this.ensurePerson(actor, personId);
const rows = await this.prisma.discAssessment.findMany({
where: { organizationId: actor.organizationId, personId },
include: { assessor: { select: { id: true, fullName: true } } },
orderBy: { createdAt: 'desc' },
});
return rows.map((row) => this.toDto(row));
}
async save(actor: Actor, personId: string, dto: SaveDiscDto): Promise<DiscAssessmentDto> {
await this.access.assert(actor);
await this.ensurePerson(actor, personId);
const answers = asAnswers(dto.answers);
const scored = scoreDisc(answers);
if (scored.validCount === 0) {
throw new BadRequestException('حداقل یک پاسخ معتبر لازم است');
}
const note = dto.note?.trim() || null;
const row = await this.prisma.discAssessment.create({
data: {
organizationId: actor.organizationId,
personId,
assessorId: actor.userId,
answers: answers as Prisma.InputJsonValue,
scoreD: scored.scores.D,
scoreI: scored.scores.I,
scoreS: scored.scores.S,
scoreC: scored.scores.C,
percentD: scored.percents.D,
percentI: scored.percents.I,
percentS: scored.percents.S,
percentC: scored.percents.C,
validCount: scored.validCount,
primaryTypes: scored.primaryTypes,
secondaryTypes: scored.secondaryTypes,
label: scored.label,
secondaryLabel: scored.secondaryLabel,
reliable: scored.reliable,
confidence: scored.confidence,
note,
status: 'confirmed',
},
include: { assessor: { select: { id: true, fullName: true } } },
});
if (scored.reliable) {
await this.prisma.person.update({
where: { id: personId },
data: {
discLabel: scored.label,
discReliable: true,
discAssessedAt: row.createdAt,
},
});
}
return this.toDto(row);
}
async distribution(actor: Actor): Promise<DiscDistributionRow[]> {
await this.access.assert(actor);
const rows = await this.prisma.person.groupBy({
by: ['discLabel'],
where: {
organizationId: actor.organizationId,
discReliable: true,
discLabel: { not: null },
...ownerWhere(actor),
},
_count: { _all: true },
});
return rows
.flatMap((row) =>
row.discLabel
? [{ label: row.discLabel, count: row._count._all }]
: [],
)
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
}
async exportXlsx(actor: Actor): Promise<Buffer> {
await this.access.assert(actor);
const people = await this.prisma.person.findMany({
where: {
organizationId: actor.organizationId,
discReliable: true,
...ownerWhere(actor),
},
select: { id: true, firstName: true, lastName: true, mobile: true, discLabel: true },
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
});
const latest = await this.prisma.discAssessment.findMany({
where: {
organizationId: actor.organizationId,
personId: { in: people.map((p) => p.id) },
reliable: true,
},
include: { assessor: { select: { fullName: true } } },
orderBy: { createdAt: 'desc' },
});
const byPerson = new Map<string, (typeof latest)[number]>();
for (const row of latest) {
if (!byPerson.has(row.personId)) byPerson.set(row.personId, row);
}
return writeXlsx(
'DISC',
[
{ header: 'مشتری', key: 'name', width: 28 },
{ header: 'موبایل', key: 'mobile', width: 16 },
{ header: 'تیپ غالب', key: 'label', width: 16 },
{ header: 'تیپ دوم', key: 'secondary', width: 16 },
{ header: 'D', key: 'd', width: 8 },
{ header: 'I', key: 'i', width: 8 },
{ header: 'S', key: 's', width: 8 },
{ header: 'C', key: 'c', width: 8 },
{ header: 'اطمینان', key: 'confidence', width: 14 },
{ header: 'کارشناس', key: 'assessor', width: 22 },
{ header: 'تاریخ', key: 'at', width: 22 },
{ header: 'یادداشت', key: 'note', width: 40 },
],
people.map((person) => {
const row = byPerson.get(person.id);
return {
name: `${person.firstName} ${person.lastName}`.trim(),
mobile: person.mobile,
label: person.discLabel,
secondary: row?.secondaryLabel,
d: row?.scoreD,
i: row?.scoreI,
s: row?.scoreS,
c: row?.scoreC,
confidence: row?.confidence,
assessor: row?.assessor.fullName,
at: row?.createdAt.toISOString(),
note: row?.note,
};
}),
);
}
private async ensurePerson(actor: Actor, personId: string) {
const found = await this.prisma.person.findFirst({
where: { id: personId, organizationId: actor.organizationId, ...ownerWhere(actor) },
select: { id: true },
});
if (!found) throw new NotFoundException('شخص پیدا نشد');
}
private toDto(row: {
id: string;
personId: string;
assessorId: string;
assessor: { fullName: string };
answers: unknown;
scoreD: number;
scoreI: number;
scoreS: number;
scoreC: number;
percentD: number;
percentI: number;
percentS: number;
percentC: number;
validCount: number;
primaryTypes: string[];
secondaryTypes: string[];
label: string;
secondaryLabel: string | null;
reliable: boolean;
confidence: string;
note: string | null;
createdAt: Date;
updatedAt: Date;
}): DiscAssessmentDto {
const confidence = (['insufficient', 'low', 'medium', 'high'] as const).includes(
row.confidence as 'insufficient',
)
? (row.confidence as DiscAssessmentDto['confidence'])
: 'low';
return {
id: row.id,
personId: row.personId,
assessorId: row.assessorId,
assessorName: row.assessor.fullName,
answers: asAnswers(row.answers),
scores: { D: row.scoreD, I: row.scoreI, S: row.scoreS, C: row.scoreC },
percents: { D: row.percentD, I: row.percentI, S: row.percentS, C: row.percentC },
validCount: row.validCount,
primaryTypes: asTypes(row.primaryTypes),
secondaryTypes: asTypes(row.secondaryTypes),
label: row.label,
secondaryLabel: row.secondaryLabel,
reliable: row.reliable,
confidence,
note: row.note,
status: 'confirmed',
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
+106
View File
@@ -0,0 +1,106 @@
import { Type } from 'class-transformer';
import {
IsBoolean,
IsDateString,
IsIn,
IsObject,
IsOptional,
IsString,
MaxLength,
ValidateIf,
} from 'class-validator';
import {
COPILOT_GOALS,
COPILOT_TOPICS,
type CopilotGoal,
type CopilotTopic,
} from '@mento/shared';
export class SaveDiscDto {
@IsObject()
answers!: Record<string, string | null>;
@IsOptional()
@IsString()
@MaxLength(4000)
note?: string;
}
export class AnalyzeCopilotDto {
@IsOptional()
@IsString()
personId?: string;
@IsOptional()
@IsString()
leadId?: string;
@IsOptional()
@IsString()
dealId?: string;
@IsIn([...COPILOT_TOPICS])
topic!: CopilotTopic;
@IsIn([...COPILOT_GOALS])
goal!: CopilotGoal;
@IsString()
@MaxLength(4000)
customerStatement!: string;
@IsOptional()
@IsString()
@MaxLength(2000)
behaviorNotes?: string;
}
export class SaveCopilotDto extends AnalyzeCopilotDto {
@IsIn(['confirmed', 'dismissed'])
status!: 'confirmed' | 'dismissed';
@IsOptional()
@IsString()
@MaxLength(4000)
confirmedResponse?: string;
@IsOptional()
@IsString()
@MaxLength(500)
nextAction?: string;
@IsOptional()
@ValidateIf((_, value) => value != null && value !== '')
@IsDateString()
followUpAt?: string | null;
@IsOptional()
@IsString()
@MaxLength(500)
callOutcome?: string;
@IsOptional()
@IsString()
@MaxLength(4000)
agentNote?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
messageDraft?: string;
}
export class CopilotFollowUpDto {
@IsOptional()
@IsDateString()
dueAt?: string;
@IsOptional()
@IsString()
assigneeId?: string;
@IsOptional()
@IsBoolean()
@Type(() => Boolean)
createTask?: boolean;
}
@@ -0,0 +1,43 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { PlatformAdminAlertService } from './platform-admin-alert.service';
@Injectable()
export class BillingExpiryService {
private readonly logger = new Logger(BillingExpiryService.name);
constructor(
private readonly prisma: PrismaService,
private readonly alerts: PlatformAdminAlertService,
) {}
@Cron('15 * * * *', { timeZone: 'Asia/Tehran' })
async handleHourly(): Promise<void> {
await this.notifyEnded();
}
async notifyEnded(): Promise<number> {
const rows = await this.prisma.billingAccount.findMany({
where: {
expiryNotifiedAt: null,
planCode: { not: 'lifetime' },
status: { not: 'suspended' },
expiresAt: { lte: new Date() },
},
select: { id: true },
take: 100,
});
for (const row of rows) {
try {
await this.alerts.notifySubscriptionEnded(row.id);
} catch (error) {
this.logger.warn(
`expiry notify failed ${row.id}: ${error instanceof Error ? error.message : 'unknown'}`,
);
}
}
if (rows.length) this.logger.log(`subscription expiry checked: ${rows.length}`);
return rows.length;
}
}
+19 -2
View File
@@ -1,10 +1,27 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { StorageModule } from '../storage/storage.module';
import { BillingExpiryService } from './billing-expiry.service';
import { BillingService } from './billing.service'; import { BillingService } from './billing.service';
import { OrgModuleGuard } from './org-module.guard'; import { OrgModuleGuard } from './org-module.guard';
import { PlatformAdminAlertService } from './platform-admin-alert.service';
import { StorageQuotaService } from './storage-quota.service';
@Global() @Global()
@Module({ @Module({
providers: [BillingService, OrgModuleGuard], imports: [StorageModule],
exports: [BillingService, OrgModuleGuard], providers: [
BillingService,
BillingExpiryService,
PlatformAdminAlertService,
OrgModuleGuard,
StorageQuotaService,
],
exports: [
BillingService,
BillingExpiryService,
PlatformAdminAlertService,
OrgModuleGuard,
StorageQuotaService,
],
}) })
export class BillingModule {} export class BillingModule {}
+80 -1
View File
@@ -11,6 +11,7 @@ import type {
BillingAccountStatus, BillingAccountStatus,
BillingPlanCode, BillingPlanCode,
OrgModuleKey, OrgModuleKey,
OwnerSeatUsageDto,
SubscriptionSummaryDto, SubscriptionSummaryDto,
} from '@mento/shared'; } from '@mento/shared';
import { import {
@@ -22,6 +23,7 @@ import {
isOrgModuleKey, isOrgModuleKey,
} from '@mento/shared'; } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { PlatformAdminAlertService } from './platform-admin-alert.service';
function daysRemaining(expiresAt: Date, now = new Date()): number { function daysRemaining(expiresAt: Date, now = new Date()): number {
const ms = expiresAt.getTime() - now.getTime(); const ms = expiresAt.getTime() - now.getTime();
@@ -50,7 +52,10 @@ export function toSummary(row: {
@Injectable() @Injectable()
export class BillingService { export class BillingService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly adminAlerts: PlatformAdminAlertService,
) {}
toDto(row: { toDto(row: {
id: string; id: string;
@@ -60,6 +65,7 @@ export class BillingService {
startsAt: Date; startsAt: Date;
expiresAt: Date; expiresAt: Date;
maxOrganizations: number; maxOrganizations: number;
maxUsers?: number | null;
externalPaymentRef: string | null; externalPaymentRef: string | null;
notes: string | null; notes: string | null;
}): BillingAccountDto { }): BillingAccountDto {
@@ -68,11 +74,71 @@ export class BillingService {
id: row.id, id: row.id,
ownerUserId: row.ownerUserId, ownerUserId: row.ownerUserId,
...summary, ...summary,
maxUsers: row.maxUsers ?? null,
externalPaymentRef: row.externalPaymentRef, externalPaymentRef: row.externalPaymentRef,
notes: row.notes, notes: row.notes,
}; };
} }
async seatUsage(ownerUserId: string): Promise<OwnerSeatUsageDto> {
const account = await this.getByOwnerUserId(ownerUserId);
const orgIds = await this.ownedOrganizationIds(ownerUserId);
const usedUsers = await this.countActiveUsers(orgIds);
return { usedUsers, maxUsers: account?.maxUsers ?? null };
}
async setMaxUsers(ownerUserId: string, maxUsers: number): Promise<OwnerSeatUsageDto> {
await this.prisma.billingAccount.update({
where: { ownerUserId },
data: { maxUsers },
});
return this.seatUsage(ownerUserId);
}
/** کاربر جدید فقط وقتی سقف اشتراک جا دارد. کسی که از قبل در محیط‌های همین مالک فعال است صندلی تازه نمی‌گیرد. */
async assertSeatAvailable(organizationId: string, userId: string | null): Promise<void> {
const owner = await this.prisma.membership.findFirst({
where: { organizationId, role: 'owner', isActive: true },
orderBy: { createdAt: 'asc' },
select: { userId: true },
});
if (!owner) return;
const account = await this.getByOwnerUserId(owner.userId);
if (!account || account.maxUsers == null) return;
const orgIds = await this.ownedOrganizationIds(owner.userId);
if (userId) {
const already = await this.prisma.membership.findFirst({
where: { userId, organizationId: { in: orgIds }, isActive: true },
select: { id: true },
});
if (already) return;
}
const used = await this.countActiveUsers(orgIds);
if (used >= account.maxUsers) {
throw new ForbiddenException(
`سقف کاربر این اشتراک ${account.maxUsers} نفر است. برای افزودن کاربر باید سقف اشتراک بیشتر شود.`,
);
}
}
private async ownedOrganizationIds(ownerUserId: string): Promise<string[]> {
const rows = await this.prisma.membership.findMany({
where: { userId: ownerUserId, role: 'owner', isActive: true },
select: { organizationId: true },
});
return rows.map((row) => row.organizationId);
}
private async countActiveUsers(organizationIds: string[]): Promise<number> {
if (!organizationIds.length) return 0;
const rows = await this.prisma.membership.findMany({
where: { organizationId: { in: organizationIds }, isActive: true },
select: { userId: true },
distinct: ['userId'],
});
return rows.length;
}
async getByOwnerUserId(ownerUserId: string) { async getByOwnerUserId(ownerUserId: string) {
return this.prisma.billingAccount.findUnique({ where: { ownerUserId } }); return this.prisma.billingAccount.findUnique({ where: { ownerUserId } });
} }
@@ -93,6 +159,7 @@ export class BillingService {
startsAt: now, startsAt: now,
expiresAt, expiresAt,
maxOrganizations: Math.max(1, opts?.maxOrganizations ?? 1), maxOrganizations: Math.max(1, opts?.maxOrganizations ?? 1),
maxUsers: 3,
}, },
}); });
} }
@@ -151,6 +218,7 @@ export class BillingService {
data: { status: 'expired' }, data: { status: 'expired' },
}); });
} }
await this.adminAlerts.notifySubscriptionEnded(account.id);
throw new UnauthorizedException('اشتراک منقضی شده است'); throw new UnauthorizedException('اشتراک منقضی شده است');
} }
return toSummary(account); return toSummary(account);
@@ -220,6 +288,10 @@ export class BillingService {
(expiresAt.getTime() < now.getTime() ? 'expired' : 'active'); (expiresAt.getTime() < now.getTime() ? 'expired' : 'active');
const existing = await this.getByOwnerUserId(ownerUserId); const existing = await this.getByOwnerUserId(ownerUserId);
const expiresChanged =
!existing || existing.expiresAt.getTime() !== expiresAt.getTime();
const expiryNotifiedAt =
status === 'active' || expiresChanged ? null : existing?.expiryNotifiedAt ?? null;
const row = existing const row = existing
? await this.prisma.billingAccount.update({ ? await this.prisma.billingAccount.update({
where: { id: existing.id }, where: { id: existing.id },
@@ -229,6 +301,8 @@ export class BillingService {
startsAt: existing.startsAt ?? now, startsAt: existing.startsAt ?? now,
expiresAt, expiresAt,
maxOrganizations, maxOrganizations,
expiryNotifiedAt,
...(planCode === 'trial' ? { maxUsers: 3 } : {}),
...(dto.notes !== undefined ? { notes: dto.notes } : {}), ...(dto.notes !== undefined ? { notes: dto.notes } : {}),
}, },
}) })
@@ -241,8 +315,13 @@ export class BillingService {
expiresAt, expiresAt,
maxOrganizations, maxOrganizations,
notes: dto.notes ?? null, notes: dto.notes ?? null,
expiryNotifiedAt,
...(planCode === 'trial' ? { maxUsers: 3 } : {}),
}, },
}); });
if (status === 'expired' && !expiryNotifiedAt) {
await this.adminAlerts.notifySubscriptionEnded(row.id);
}
return this.toDto(row); return this.toDto(row);
} }
@@ -0,0 +1,151 @@
import { Injectable, Logger } from '@nestjs/common';
import { isPermanentBillingPlan } from '@mento/shared';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { PrismaService } from '../prisma/prisma.service';
import {
newAccountSms,
subscriptionEndedSms,
type NewAccountSource,
} from './platform-admin-alert';
@Injectable()
export class PlatformAdminAlertService {
private readonly logger = new Logger(PlatformAdminAlertService.name);
constructor(
private readonly prisma: PrismaService,
private readonly sms: SmsGatewayService,
) {}
/** ساخت حساب جدید؛ خطای پیامک جلوی ثبت‌نام یا ساخت محیط را نمی‌گیرد. */
async notifyNewAccount(input: {
source: NewAccountSource;
organizationName: string;
ownerName: string;
mobile: string | null;
}): Promise<void> {
try {
const delivered = await this.sendToAdmins(newAccountSms(input));
if (!delivered) {
this.logger.warn(`new account SMS was not delivered (${input.source})`);
}
} catch (error) {
this.logger.warn(
`new account SMS failed: ${error instanceof Error ? error.message : 'unknown'}`,
);
}
}
/**
* یک‌بار هنگام تمام شدن اشتراک. اگر ارسال نشود پرچم خالی می‌ماند تا بعداً دوباره تلاش شود.
*/
async notifySubscriptionEnded(billingAccountId: string): Promise<void> {
const row = await this.prisma.billingAccount.findUnique({
where: { id: billingAccountId },
select: {
id: true,
planCode: true,
status: true,
expiresAt: true,
expiryNotifiedAt: true,
owner: {
select: {
fullName: true,
mobile: true,
memberships: {
where: { role: 'owner', isActive: true },
select: { organization: { select: { name: true } } },
},
},
},
},
});
if (!row || row.expiryNotifiedAt) return;
if (row.status === 'suspended') return;
if (isPermanentBillingPlan(row.planCode)) {
await this.prisma.billingAccount.update({
where: { id: row.id },
data: { expiryNotifiedAt: new Date() },
});
return;
}
if (row.expiresAt.getTime() > Date.now() && row.status !== 'expired') return;
const claimed = await this.prisma.billingAccount.updateMany({
where: { id: row.id, expiryNotifiedAt: null },
data: { status: 'expired', expiryNotifiedAt: new Date() },
});
if (claimed.count !== 1) return;
try {
const delivered = await this.sendToAdmins(
subscriptionEndedSms({
organizationNames: row.owner.memberships.map((item) => item.organization.name),
ownerName: row.owner.fullName,
mobile: row.owner.mobile,
planCode: row.planCode,
expiresAt: row.expiresAt,
}),
);
if (!delivered) {
await this.prisma.billingAccount.update({
where: { id: row.id },
data: { expiryNotifiedAt: null },
});
}
} catch (error) {
this.logger.warn(
`subscription SMS failed ${row.id}: ${error instanceof Error ? error.message : 'unknown'}`,
);
await this.prisma.billingAccount
.update({
where: { id: row.id },
data: { expiryNotifiedAt: null },
})
.catch(() => undefined);
}
}
/** true یعنی حداقل یک پیامک رفت، یا مدیری با موبایل فعال وجود نداشت. */
private async sendToAdmins(text: string): Promise<boolean> {
const admins = await this.prisma.platformAdmin.findMany({
where: { isActive: true, mobile: { not: null } },
select: { mobile: true },
});
const mobiles = [
...new Set(
admins
.map((admin) => admin.mobile?.trim() ?? '')
.filter((mobile) => mobile.length > 0),
),
];
if (!mobiles.length) {
this.logger.warn('platform admin SMS skipped: no active admin mobile');
return true;
}
let delivered = false;
for (const mobile of mobiles) {
try {
const result = await this.sms.send({
phone: mobile,
text,
source: 'platform-admin',
});
if (result.ok) delivered = true;
else {
this.logger.warn(
`platform admin SMS failed to=…${mobile.slice(-4)} reason=${result.reason ?? 'unknown'}`,
);
}
} catch (error) {
this.logger.warn(
`platform admin SMS failed to=…${mobile.slice(-4)}: ${
error instanceof Error ? error.message : 'unknown'
}`,
);
}
}
return delivered;
}
}
@@ -0,0 +1,43 @@
import { BILLING_PLAN_LABELS, type BillingPlanCode } from '@mento/shared';
import { formatTehranJalali } from '../common/jalali';
export type NewAccountSource = 'signup' | 'admin';
export function newAccountSms(input: {
source: NewAccountSource;
organizationName: string;
ownerName: string;
mobile: string | null;
}): string {
const via = input.source === 'signup' ? 'ثبت‌نام' : 'پنل ادمین';
return [
'منتکت',
`حساب جدید (${via})`,
`محیط: ${input.organizationName.trim() || '—'}`,
`مالک: ${input.ownerName.trim() || '—'}`,
`موبایل: ${input.mobile?.trim() || 'ندارد'}`,
].join('\n');
}
export function subscriptionEndedSms(input: {
organizationNames: string[];
ownerName: string;
mobile: string | null;
planCode: string;
expiresAt: Date;
}): string {
const names = input.organizationNames.map((name) => name.trim()).filter(Boolean);
const orgs = names.slice(0, 3).join('، ') || '—';
const extra = names.length > 3 ? ` و ${names.length - 3} محیط دیگر` : '';
const label =
BILLING_PLAN_LABELS[input.planCode as BillingPlanCode] ?? input.planCode;
return [
'منتکت',
'پایان اشتراک',
`محیط: ${orgs}${extra}`,
`مالک: ${input.ownerName.trim() || '—'}`,
`موبایل: ${input.mobile?.trim() || 'ندارد'}`,
`پلن: ${label}`,
`تاریخ: ${formatTehranJalali(input.expiresAt)}`,
].join('\n');
}
@@ -0,0 +1,190 @@
import { Injectable, PayloadTooLargeException } from '@nestjs/common';
import type { StorageUsageDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage';
const GIB = 1024 * 1024 * 1024;
function asNumber(value: bigint | number | null | undefined): number {
if (value == null) return 0;
const n = typeof value === 'bigint' ? Number(value) : value;
return Number.isFinite(n) ? Math.max(0, n) : 0;
}
function levelOf(used: number, quota: number | null): StorageUsageDto['level'] {
if (quota == null) return 'unlimited';
if (quota <= 0 || used >= quota) return 'full';
if (used >= quota * 0.8) return 'warn';
return 'ok';
}
@Injectable()
export class StorageQuotaService {
constructor(
private readonly prisma: PrismaService,
private readonly files: LocalFileStorage,
) {}
async usageForOrganization(organizationId: string): Promise<StorageUsageDto> {
const ownerUserId = await this.ownerUserId(organizationId);
if (!ownerUserId) return this.empty();
return this.usageForOwner(ownerUserId);
}
async usageForOwner(ownerUserId: string, refresh = false): Promise<StorageUsageDto> {
const orgs = await this.ownerOrgs(ownerUserId);
if (refresh) {
await Promise.all(orgs.map((org) => this.measureLocal(org.id)));
} else {
await Promise.all(
orgs.filter((org) => !org.fileBytesSyncedAt).map((org) => this.measureLocal(org.id)),
);
}
const fresh = refresh || orgs.some((org) => !org.fileBytesSyncedAt)
? await this.ownerOrgs(ownerUserId)
: orgs;
const account = await this.prisma.billingAccount.findUnique({
where: { ownerUserId },
select: { storageQuotaBytes: true },
});
const quota = account?.storageQuotaBytes == null ? null : asNumber(account.storageQuotaBytes);
const byOrganization = fresh.map((org) => ({
id: org.id,
name: org.name,
usedBytes: asNumber(org.fileBytes) + asNumber(org.chatFileBytes),
}));
const used = byOrganization.reduce((sum, org) => sum + org.usedBytes, 0);
const percent = quota == null || quota <= 0 ? null : Math.min(100, Math.round((used / quota) * 100));
return {
usedBytes: used,
quotaBytes: quota,
percent,
level: levelOf(used, quota),
byOrganization,
};
}
async assertCanAdd(organizationId: string, bytes: number): Promise<void> {
if (bytes <= 0) return;
const ownerUserId = await this.ownerUserId(organizationId);
if (!ownerUserId) return;
const usage = await this.usageForOwner(ownerUserId);
if (usage.quotaBytes == null) return;
if (usage.usedBytes + bytes > usage.quotaBytes) {
throw new PayloadTooLargeException(
'حجم فایل این اشتراک پر شده است. تا شارژ حجم، آپلود جدید ممکن نیست.',
);
}
}
async addLocal(organizationId: string, delta: number): Promise<void> {
await this.add(organizationId, 'fileBytes', delta);
}
async addChat(organizationId: string, bytes: number): Promise<StorageUsageDto> {
if (bytes > 0) await this.assertCanAdd(organizationId, bytes);
if (bytes > 0) await this.add(organizationId, 'chatFileBytes', bytes);
return this.usageForOrganization(organizationId);
}
async setQuotaGb(ownerUserId: string, quotaGb: number | null): Promise<StorageUsageDto> {
const bytes = quotaGb == null ? null : BigInt(Math.round(quotaGb * GIB));
await this.prisma.billingAccount.update({
where: { ownerUserId },
data: { storageQuotaBytes: bytes },
});
return this.usageForOwner(ownerUserId, true);
}
private async add(
organizationId: string,
field: 'fileBytes' | 'chatFileBytes',
delta: number,
): Promise<void> {
if (!delta) return;
const row = await this.prisma.organization.update({
where: { id: organizationId },
data: { [field]: { increment: BigInt(Math.round(delta)) } },
select: { fileBytes: true, chatFileBytes: true },
});
const value = field === 'fileBytes' ? row.fileBytes : row.chatFileBytes;
if (value < 0n) {
await this.prisma.organization.update({
where: { id: organizationId },
data: { [field]: 0n },
});
}
}
private async ownerUserId(organizationId: string): Promise<string | null> {
const owner = await this.prisma.membership.findFirst({
where: { organizationId, role: 'owner', isActive: true },
orderBy: { createdAt: 'asc' },
select: { userId: true },
});
return owner?.userId ?? null;
}
private ownerOrgs(ownerUserId: string) {
return this.prisma.membership.findMany({
where: { userId: ownerUserId, role: 'owner', isActive: true },
select: {
organization: {
select: {
id: true,
name: true,
fileBytes: true,
chatFileBytes: true,
fileBytesSyncedAt: true,
},
},
},
}).then((rows) => rows.map((row) => row.organization));
}
private async measureLocal(organizationId: string): Promise<void> {
const [imageSum, noteSum, treasury, leads, forms, users] = await Promise.all([
this.prisma.activityImage.aggregate({
where: { organizationId },
_sum: { sizeBytes: true },
}),
this.prisma.noteAttachment.aggregate({
where: { organizationId },
_sum: { sizeBytes: true },
}),
this.prisma.financialAttachment.findMany({
where: { organizationId },
select: { storageKey: true },
}),
this.prisma.lead.findMany({
where: { organizationId, logoStorageKey: { not: null } },
select: { logoStorageKey: true },
}),
this.prisma.leadForm.findMany({
where: { organizationId, coverStorageKey: { not: null } },
select: { coverStorageKey: true },
}),
this.prisma.user.findMany({
where: { avatarStorageKey: { startsWith: `${organizationId}/` } },
select: { avatarStorageKey: true },
}),
]);
const keys = [
...treasury.map((row) => row.storageKey),
...leads.map((row) => row.logoStorageKey).filter((key): key is string => Boolean(key)),
...forms.map((row) => row.coverStorageKey).filter((key): key is string => Boolean(key)),
...users.map((row) => row.avatarStorageKey).filter((key): key is string => Boolean(key)),
];
let disk = 0;
for (const key of keys) disk += await this.files.sizeOf(key);
const bytes = (imageSum._sum.sizeBytes ?? 0) + (noteSum._sum.sizeBytes ?? 0) + disk;
await this.prisma.organization.update({
where: { id: organizationId },
data: { fileBytes: BigInt(bytes), fileBytesSyncedAt: new Date() },
});
}
private empty(): StorageUsageDto {
return { usedBytes: 0, quotaBytes: null, percent: null, level: 'unlimited' };
}
}
+62
View File
@@ -0,0 +1,62 @@
import { ForbiddenException } from '@nestjs/common';
import {
ASSIGN_SECTION_PERMISSION,
type AssignSection,
} from '@mento/shared';
import type { PrismaService } from '../prisma/prisma.service';
import { elevated } from './record-scope';
const ASSIGN_NOUN: Record<AssignSection, string> = {
customers: 'مشتری',
leads: 'لید',
sales: 'معامله',
activities: 'فعالیت',
routines: 'کار روتین',
projects: 'کارت پروژه',
tickets: 'تیکت',
};
type AssignActor = { userId: string; role: string; organizationId: string };
export function assignOthersDeniedMessage(section: AssignSection): string {
return `تخصیص ${ASSIGN_NOUN[section]} به دیگران برای نقش شما مجاز نیست. فقط می‌توانید برای خودتان ثبت کنید.`;
}
export async function actorCanAssignToOthers(
prisma: PrismaService,
actor: AssignActor,
section: AssignSection,
): Promise<boolean> {
if (elevated(actor.role)) return true;
const membership = await prisma.membership.findFirst({
where: {
userId: actor.userId,
organizationId: actor.organizationId,
isActive: true,
},
select: { orgRole: { select: { permissions: true } } },
});
const permissions = membership?.orgRole?.permissions;
const key = ASSIGN_SECTION_PERMISSION[section];
return Array.isArray(permissions) && permissions.includes(key);
}
/**
* بدون مجوز همان بخش، مجموعهٔ مسئولانِ غیر از خود کاربر نباید عوض شود.
* ثبت تازه یعنی قبلی خالی است، پس فقط خود کاربر مجاز است.
*/
export async function assertAssigneeSet(
prisma: PrismaService,
actor: AssignActor,
section: AssignSection,
nextIds: Array<string | null | undefined>,
previousIds: Array<string | null | undefined> = [],
): Promise<void> {
if (await actorCanAssignToOthers(prisma, actor, section)) return;
const prev = new Set(
previousIds.filter((id): id is string => Boolean(id) && id !== actor.userId),
);
const next = nextIds.filter((id): id is string => Boolean(id) && id !== actor.userId);
const changed = next.length !== prev.size || next.some((id) => !prev.has(id));
if (changed) throw new ForbiddenException(assignOthersDeniedMessage(section));
}
+28
View File
@@ -0,0 +1,28 @@
import type { Prisma } from '@prisma/client';
type PositionUpdate = Prisma.PrismaPromise<unknown>;
/** کارت را در ایندکس خواسته‌شده می‌گذارد. ایندکس نسبت به فهرست بدون خود کارت است. */
export function placeId(ids: string[], movingId: string, index: number): string[] {
const next = ids.filter((id) => id !== movingId);
const at = Math.max(0, Math.min(Math.trunc(index), next.length));
next.splice(at, 0, movingId);
return next;
}
export function sameIds(left: string[], right: string[]): boolean {
return left.length === right.length && left.every((id, index) => id === right[index]);
}
/** ترتیب ستون را ۰ تا n ذخیره می‌کند. */
export async function writePositions(
transaction: (ops: PositionUpdate[]) => Promise<unknown>,
ids: string[],
update: (id: string, position: number) => PositionUpdate,
) {
const size = 60;
for (let start = 0; start < ids.length; start += size) {
const slice = ids.slice(start, start + size);
await transaction(slice.map((id, offset) => update(id, start + offset)));
}
}
+470
View File
@@ -0,0 +1,470 @@
import type {
DataAccessPolicy,
DataAccessRule,
DataAccessSection,
DataVisibility,
DataVisibilityScopes,
SectionDataAccess,
} from '@mento/shared';
import {
DATA_ACCESS_LEGACY_SCOPE,
DATA_ACCESS_SECTIONS,
migrateVisibilityToSectionAccess,
parseDataAccessPolicy,
sectionHasDataAccessLimits,
} from '@mento/shared';
import { canViewAllRecords } from '@mento/shared';
import type { Prisma } from '@prisma/client';
import type { Actor } from './record-scope';
function elevated(role: string | null | undefined): boolean {
return canViewAllRecords(role);
}
export type SectionAccessContext = {
section: SectionDataAccess;
source: 'role' | 'override' | 'default';
};
function ownerIdList(
rule: DataAccessRule,
ctx: { userId: string; teamPeerIds: string[] | null },
): string[] {
const ownerIds = new Set<string>();
if (!rule.owners?.length) return [];
if (rule.owners.includes('self')) ownerIds.add(ctx.userId);
if (rule.owners.includes('team')) {
const peers = ctx.teamPeerIds?.length ? ctx.teamPeerIds : [ctx.userId];
for (const id of peers) ownerIds.add(id);
}
return [...ownerIds];
}
/** Resolve یک بخش: override کاربر بر نقش؛ وگرنه مهاجرت از visibility قدیمی. */
export function resolveSectionAccess(
sectionKey: DataAccessSection,
input: {
role: string;
rolePolicy?: unknown;
override?: unknown;
legacyVisibility?: DataVisibility | string | null;
legacyScopes?: DataVisibilityScopes | null;
},
): SectionAccessContext {
if (elevated(input.role)) {
return { section: { mode: 'all', rules: [] }, source: 'default' };
}
const override = parseDataAccessPolicy(input.override);
if (override[sectionKey]) {
return { section: override[sectionKey]!, source: 'override' };
}
const rolePolicy = parseDataAccessPolicy(input.rolePolicy);
if (rolePolicy[sectionKey]) {
return { section: rolePolicy[sectionKey]!, source: 'role' };
}
// فرم محدودهٔ نقش، بخشِ ننوشته را «بدون محدودیت» نشان می‌دهد.
// اگر نقش حتی یک بخش ذخیره کرده باشد، بقیه نباید به دید قدیمی own/team برگردند.
if (Object.keys(rolePolicy).length > 0) {
return { section: { mode: 'all', rules: [] }, source: 'role' };
}
if (input.legacyVisibility != null || input.legacyScopes) {
return {
section: migrateVisibilityToSectionAccess(
input.legacyVisibility,
input.legacyScopes,
DATA_ACCESS_LEGACY_SCOPE[sectionKey],
),
source: 'default',
};
}
return { section: { mode: 'all', rules: [] }, source: 'default' };
}
/** @deprecated */
export function resolveCustomersAccess(input: {
role: string;
rolePolicy?: unknown;
override?: unknown;
legacyVisibility?: DataVisibility | string | null;
legacyScopes?: DataVisibilityScopes | null;
}): SectionAccessContext {
return resolveSectionAccess('customers', input);
}
export function resolveAllSectionAccess(input: {
role: string;
rolePolicy?: unknown;
override?: unknown;
legacyVisibility?: DataVisibility | string | null;
legacyScopes?: DataVisibilityScopes | null;
}): DataAccessPolicy {
const out: DataAccessPolicy = {};
for (const key of DATA_ACCESS_SECTIONS) {
out[key] = resolveSectionAccess(key, input).section;
}
return out;
}
export function buildContactRuleWhere(
rule: DataAccessRule,
ctx: { userId: string; teamPeerIds: string[] | null },
): Prisma.PersonWhereInput | null {
const and: Prisma.PersonWhereInput[] = [];
const ids = ownerIdList(rule, ctx);
if (rule.owners?.length) {
if (!ids.length) return null;
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
}
if (rule.groupIds?.length) {
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
}
if (rule.sources?.length) {
and.push({
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
});
}
if (rule.moods?.length) {
and.push({ mood: { in: rule.moods } });
}
if (!and.length) return null;
return and.length === 1 ? and[0]! : { AND: and };
}
function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } {
if (!parts.length) return { id: '__no_access__' } as T & { id: string };
return parts.length === 1 ? parts[0]! : { OR: parts };
}
export function customersPersonWhere(
actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>,
): Prisma.PersonWhereInput {
if (elevated(actor.role)) return {};
const section = actor.customersAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.PersonWhereInput[] = [];
for (const rule of section.rules ?? []) {
const w = buildContactRuleWhere(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (w) parts.push(w);
}
return orParts(parts);
}
export function customersCompanyWhere(
actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>,
): Prisma.CompanyWhereInput {
if (elevated(actor.role)) return {};
const section = actor.customersAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.CompanyWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.CompanyWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
}
if (rule.groupIds?.length) {
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
}
if (rule.sources?.length) {
and.push({
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
});
}
if (
rule.moods?.length &&
!rule.owners?.length &&
!rule.groupIds?.length &&
!rule.sources?.length
) {
continue;
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function leadsDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'leadsAccess' | 'teamPeerIds'>,
): Prisma.LeadWhereInput {
if (elevated(actor.role)) return {};
const section = actor.leadsAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.LeadWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.LeadWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
}
if (rule.sources?.length) {
and.push({
OR: rule.sources.map((s) => ({
source: { equals: s, mode: 'insensitive' as const },
})),
});
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function dealsDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'salesAccess' | 'teamPeerIds'>,
): Prisma.DealWhereInput {
if (elevated(actor.role)) return {};
const section = actor.salesAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.DealWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.DealWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
});
}
if (rule.pipelineIds?.length) {
and.push({ pipelineId: { in: rule.pipelineIds } });
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function activitiesDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'activitiesAccess' | 'teamPeerIds'>,
): Prisma.ActivityWhereInput {
if (elevated(actor.role)) return {};
const section = actor.activitiesAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.ActivityWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.ActivityWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ assigneeId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
{ participantIds: { hasSome: ids } },
],
});
}
if (rule.boardIds?.length) {
and.push({ boardId: { in: rule.boardIds } });
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
}
if (rule.typeIds?.length) {
and.push({ typeDefId: { in: rule.typeIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function notesDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'notesAccess' | 'teamPeerIds'>,
): Prisma.NoteWhereInput {
const personalGate: Prisma.NoteWhereInput = {
OR: [
{ visibility: { not: 'personal' } },
{ visibility: 'personal', authorId: actor.userId },
],
};
if (elevated(actor.role)) return personalGate;
const section = actor.notesAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return personalGate;
const parts: Prisma.NoteWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.NoteWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ authorId: { in: ids } },
{
deal: {
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
},
},
{ person: { ownerId: { in: ids } } },
{ company: { ownerId: { in: ids } } },
],
});
}
if (rule.sources?.length) {
and.push({ noteType: { in: rule.sources } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return {
AND: [personalGate, orParts(parts)],
};
}
export function ticketsDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'ticketsAccess' | 'teamPeerIds'>,
): Prisma.TicketWhereInput {
if (elevated(actor.role)) return {};
const section = actor.ticketsAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.TicketWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.TicketWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
});
}
if (rule.sources?.length) {
and.push({
status: {
in: rule.sources as Array<'open' | 'in_progress' | 'waiting' | 'resolved' | 'closed'>,
},
});
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function projectsDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'projectsAccess' | 'teamPeerIds'>,
): Prisma.ProjectCardWhereInput {
if (elevated(actor.role)) return {};
const section = actor.projectsAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.ProjectCardWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.ProjectCardWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
});
}
if (rule.boardIds?.length) {
and.push({ boardId: { in: rule.boardIds } });
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function routinesDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'routinesAccess' | 'teamPeerIds'>,
): Prisma.RoutineTaskWhereInput {
if (elevated(actor.role)) return {};
const section = actor.routinesAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.RoutineTaskWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.RoutineTaskWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
}
if (rule.typeIds?.length) {
and.push({ categoryId: { in: rule.typeIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
/** اتحاد شناسهٔ owners در قوانین؛ null = بدون محدودیت مالکی (یا mode=all) */
export function sectionOwnerIdUnion(
section: SectionDataAccess | null | undefined,
ctx: { userId: string; teamPeerIds: string[] | null },
): string[] | null {
if (!sectionHasDataAccessLimits(section)) return null;
const ids = new Set<string>();
let sawOwnerRule = false;
let sawNonOwnerRule = false;
for (const rule of section?.rules ?? []) {
if (rule.owners?.length) {
sawOwnerRule = true;
for (const id of ownerIdList(rule, ctx)) ids.add(id);
} else if (rule.groupIds?.length || rule.sources?.length || rule.moods?.length ||
rule.stageIds?.length || rule.pipelineIds?.length || rule.boardIds?.length ||
rule.typeIds?.length) {
sawNonOwnerRule = true;
}
}
if (sawNonOwnerRule && !sawOwnerRule) return null;
if (!sawOwnerRule) return null;
return [...ids];
}
export function sectionNeedsTeamPeers(section: SectionDataAccess | null | undefined): boolean {
if (!sectionHasDataAccessLimits(section)) return false;
return (section?.rules ?? []).some((r) => r.owners?.includes('team'));
}
export function sanitizeDataAccessPolicyInput(raw: unknown): DataAccessPolicy {
return parseDataAccessPolicy(raw);
}
+25
View File
@@ -170,9 +170,34 @@ export function parseAmount(raw?: string): number | null {
} }
/** تاریخ تولد: سال زیر ۱۷۰۰ شمسی است، سال ۱۷۰۰ به بعد میلادی. ظهر UTC تا روز جابه‌جا نشود. */ /** تاریخ تولد: سال زیر ۱۷۰۰ شمسی است، سال ۱۷۰۰ به بعد میلادی. ظهر UTC تا روز جابه‌جا نشود. */
/** «۲۰۲۴-۰۵-۰۱T۱۹:۳۰:۰۰Z» و مشابه: لحظهٔ دقیق با منطقهٔ زمانی */
const ZONED_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})$/i;
function tehranCalendarDay(d: Date): { y: number; m: number; d: number } {
const [y, m, day] = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
.format(d)
.split('-')
.map(Number);
return { y, m, d: day };
}
export function parseBirthDate(raw?: string | null): Date | null { export function parseBirthDate(raw?: string | null): Date | null {
const text = toAsciiDigits(String(raw ?? '')).trim(); const text = toAsciiDigits(String(raw ?? '')).trim();
if (!text || text === '-' || text === '0' || text === '0000-00-00') return null; if (!text || text === '-' || text === '0' || text === '0000-00-00') return null;
// تاریخ تولد فقط «روز» است. لحظهٔ UTC (مثلاً نیمه‌شب تهران که به روز قبلِ UTC می‌افتد)
// باید با تقویم تهران به روز تبدیل شود، نه با بریدن بخش تاریخِ UTC.
if (ZONED_TIMESTAMP.test(text)) {
const instant = new Date(text);
if (!Number.isNaN(instant.getTime())) {
const day = tehranCalendarDay(instant);
return new Date(Date.UTC(day.y, day.m - 1, day.d, 12, 0, 0));
}
}
const datePart = text.split(/[T\s]/)[0] ?? text; const datePart = text.split(/[T\s]/)[0] ?? text;
const match = /^(\d{3,4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})$/.exec(datePart); const match = /^(\d{3,4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})$/.exec(datePart);
if (!match) { if (!match) {
+64 -12
View File
@@ -1,6 +1,20 @@
import type { DataScope, DataVisibility, DataVisibilityScopes, UserRole } from '@mento/shared'; import type {
DataScope,
DataVisibility,
DataVisibilityScopes,
SectionDataAccess,
UserRole,
} from '@mento/shared';
import { DATA_SCOPES, canViewAllRecords } from '@mento/shared'; import { DATA_SCOPES, canViewAllRecords } from '@mento/shared';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import {
activitiesDeepWhere,
dealsDeepWhere,
notesDeepWhere,
projectsDeepWhere,
routinesDeepWhere,
ticketsDeepWhere,
} from './data-access';
export type ScopeVisibility = { export type ScopeVisibility = {
dataVisibility: DataVisibility; dataVisibility: DataVisibility;
@@ -20,6 +34,17 @@ export type Actor = {
visibleUserIds?: string[] | null; visibleUserIds?: string[] | null;
/** سطح دید تفکیکی هر بخش (از Membership.dataVisibilityScopes) */ /** سطح دید تفکیکی هر بخش (از Membership.dataVisibilityScopes) */
scopes?: Partial<Record<DataScope, ScopeVisibility>>; scopes?: Partial<Record<DataScope, ScopeVisibility>>;
/** عمق دادهٔ بخش‌ها (resolve شده از نقش + override) */
customersAccess?: SectionDataAccess;
leadsAccess?: SectionDataAccess;
salesAccess?: SectionDataAccess;
activitiesAccess?: SectionDataAccess;
notesAccess?: SectionDataAccess;
routinesAccess?: SectionDataAccess;
projectsAccess?: SectionDataAccess;
ticketsAccess?: SectionDataAccess;
/** هم‌تیمی‌ها برای قوانین owners:team (شامل خود کاربر) */
teamPeerIds?: string[] | null;
/** off = فیلتر خاموش؛ current = کار جاری؛ archive = بایگانی اطلاعات قبل */ /** off = فیلتر خاموش؛ current = کار جاری؛ archive = بایگانی اطلاعات قبل */
priorLens?: 'off' | 'current' | 'archive'; priorLens?: 'off' | 'current' | 'archive';
/** لحظهٔ روشن شدن ماژول برای این محیط؛ فعالیت‌های ساخته‌شده قبل از آن می‌توانند قبلی باشند */ /** لحظهٔ روشن شدن ماژول برای این محیط؛ فعالیت‌های ساخته‌شده قبل از آن می‌توانند قبلی باشند */
@@ -27,6 +52,8 @@ export type Actor = {
/** مالک واقعی وقتی در حالت مشاهده به‌جای کاربر دیگر است */ /** مالک واقعی وقتی در حالت مشاهده به‌جای کاربر دیگر است */
impersonatorUserId?: string; impersonatorUserId?: string;
impersonatorRole?: string; impersonatorRole?: string;
/** شروع این نشست؛ برای ساعت خروج روزانه */
sessionStartedAt?: Date;
/** در حالت مشاهده، سرور تغییر را رد می‌کند */ /** در حالت مشاهده، سرور تغییر را رد می‌کند */
viewAsReadOnly?: boolean; viewAsReadOnly?: boolean;
}; };
@@ -91,10 +118,13 @@ export function ownerWhere(
return { ownerId: { in: ids } }; return { ownerId: { in: ids } };
} }
/** معامله: مالک اصلی یا یکی از مسئولان مشترک در محدوده دید */ /** معامله: عمق دادهٔ sales (JWT) یا مدل قدیمی own/team/org */
export function dealAccessWhere( export function dealAccessWhere(
actor: ScopedActor, actor: ScopedActor & Pick<Actor, 'salesAccess' | 'teamPeerIds'>,
): Prisma.DealWhereInput { ): Prisma.DealWhereInput {
if (actor.salesAccess !== undefined) {
return dealsDeepWhere(actor as Actor);
}
const ids = scopedOwnerIds(actor, 'deals'); const ids = scopedOwnerIds(actor, 'deals');
if (!ids) return {}; if (!ids) return {};
return { return {
@@ -105,10 +135,13 @@ export function dealAccessWhere(
}; };
} }
/** کارت پروژه: مالک یا یکی از مسئولان در محدوده دید */ /** کارت پروژه: عمق داده یا مدل قدیمی own/team/org */
export function projectCardAccessWhere( export function projectCardAccessWhere(
actor: ScopedActor, actor: ScopedActor & Pick<Actor, 'projectsAccess' | 'teamPeerIds'>,
): Prisma.ProjectCardWhereInput { ): Prisma.ProjectCardWhereInput {
if (actor.projectsAccess !== undefined) {
return projectsDeepWhere(actor as Actor);
}
const ids = scopedOwnerIds(actor, 'projects'); const ids = scopedOwnerIds(actor, 'projects');
if (!ids) return {}; if (!ids) return {};
return { return {
@@ -133,10 +166,13 @@ export function assigneeWhere(
}; };
} }
/** دسترسی به فعالیت: مسئولان یا شرکت‌کنندگان (فیلد شرکت‌کنندگان) در محدوده دید */ /** دسترسی به فعالیت: عمق دادهٔ activities (JWT) یا مدل قدیمی */
export function activityAccessWhere( export function activityAccessWhere(
actor: ScopedActor, actor: ScopedActor & Pick<Actor, 'activitiesAccess' | 'teamPeerIds'>,
): Prisma.ActivityWhereInput { ): Prisma.ActivityWhereInput {
if (actor.activitiesAccess !== undefined) {
return activitiesDeepWhere(actor as Actor);
}
const ids = scopedOwnerIds(actor, 'activities'); const ids = scopedOwnerIds(actor, 'activities');
if (!ids) return {}; if (!ids) return {};
return { return {
@@ -148,10 +184,13 @@ export function activityAccessWhere(
}; };
} }
/** یادداشت: نویسنده یا پرونده‌های تحت مالکیت در محدوده دید + محرمانگی شخصی */ /** یادداشت: عمق داده یا مدل قدیمی + محرمانگی شخصی */
export function noteAccessWhere( export function noteAccessWhere(
actor: ScopedActor, actor: ScopedActor & Pick<Actor, 'notesAccess' | 'teamPeerIds'>,
): Prisma.NoteWhereInput { ): Prisma.NoteWhereInput {
if (actor.notesAccess !== undefined) {
return notesDeepWhere(actor as Actor);
}
const ids = scopedOwnerIds(actor, 'notes'); const ids = scopedOwnerIds(actor, 'notes');
const personalGate: Prisma.NoteWhereInput = { const personalGate: Prisma.NoteWhereInput = {
OR: [ OR: [
@@ -186,17 +225,22 @@ export function noteAccessWhere(
export function resolveOwnerId( export function resolveOwnerId(
actor: Pick<Actor, 'userId' | 'role'>, actor: Pick<Actor, 'userId' | 'role'>,
requested?: string | null, requested?: string | null,
allowOthers?: boolean,
): string { ): string {
if (elevated(actor.role) && requested?.trim()) { const allowed = allowOthers ?? elevated(actor.role);
if (allowed && requested?.trim()) {
return requested.trim(); return requested.trim();
} }
return actor.userId; return actor.userId;
} }
/** تیکت: مسئول یا ثبت‌کننده در محدوده دید */ /** تیکت: عمق داده یا مدل قدیمی */
export function ticketAccessWhere( export function ticketAccessWhere(
actor: ScopedActor, actor: ScopedActor & Pick<Actor, 'ticketsAccess' | 'teamPeerIds'>,
): Prisma.TicketWhereInput { ): Prisma.TicketWhereInput {
if (actor.ticketsAccess !== undefined) {
return ticketsDeepWhere(actor as Actor);
}
const ids = scopedOwnerIds(actor, 'tickets'); const ids = scopedOwnerIds(actor, 'tickets');
if (!ids) return {}; if (!ids) return {};
return { return {
@@ -204,6 +248,14 @@ export function ticketAccessWhere(
}; };
} }
/** کار روتین: عمق داده (وقتی JWT ست کرده) */
export function routineTaskAccessWhere(
actor: Pick<Actor, 'userId' | 'role' | 'routinesAccess' | 'teamPeerIds'>,
): Prisma.RoutineTaskWhereInput {
if (actor.routinesAccess === undefined) return {};
return routinesDeepWhere(actor);
}
export function canReassignOwner(role: string | null | undefined): boolean { export function canReassignOwner(role: string | null | undefined): boolean {
return elevated(role); return elevated(role);
} }
+13
View File
@@ -79,6 +79,19 @@ export function buildSearchWhere<W extends object>(
return (parts.length === 1 ? parts[0] : { AND: parts }) as unknown as W; return (parts.length === 1 ? parts[0] : { AND: parts }) as unknown as W;
} }
/** پارامتر چندتایی کوئری: «a,b,c» → ['a','b','c'] */
export function splitCsv(raw: string | null | undefined, max = 50): string[] {
if (!raw) return [];
return [
...new Set(
String(raw)
.split(',')
.map((v) => v.trim())
.filter(Boolean),
),
].slice(0, max);
}
/** کمک برای فیلدهای متنی ساده: `{ field: { contains, mode: 'insensitive' } }` */ /** کمک برای فیلدهای متنی ساده: `{ field: { contains, mode: 'insensitive' } }` */
export function ci(term: string) { export function ci(term: string) {
return { contains: term, mode: 'insensitive' as const }; return { contains: term, mode: 'insensitive' as const };
+30 -7
View File
@@ -10,8 +10,19 @@ const SMS_0098_SOAP_URL = 'https://webservice.0098sms.com/service.asmx';
export type SmsSendResult = { export type SmsSendResult = {
ok: boolean; ok: boolean;
transport: 'trez' | 'soap' | 'link'; blocked?: boolean;
transport: 'trez' | 'soap' | 'link' | 'none';
reason?: string; reason?: string;
parts?: number;
costRials?: number;
};
export type SmsTransportOverride = {
username: string;
password: string;
sender: string;
/** trez | 0098 — default 0098 for own panels */
provider?: 'trez' | '0098';
}; };
@Injectable() @Injectable()
@@ -50,17 +61,29 @@ export class SmsService {
return result.ok; return result.ok;
} }
async sendSmsDetailed(phone: string, text: string): Promise<SmsSendResult> { buildOtpText(code: string): string {
const username = this.config.get<string>('SMS_USERNAME'); const template =
const password = this.getPassword(); this.config.get<string>('SMS_OTP_TEMPLATE')?.trim() ||
const sender = this.config.get<string>('SMS_SENDER'); `کد ورود منتکت: {code}`;
return template.replace('{code}', code);
}
async sendSmsDetailed(
phone: string,
text: string,
override?: SmsTransportOverride,
): Promise<SmsSendResult> {
const username = override?.username || this.config.get<string>('SMS_USERNAME');
const password = override?.password || this.getPassword();
const sender = override?.sender || this.config.get<string>('SMS_SENDER');
if (!username || !password || !sender) { if (!username || !password || !sender) {
return { ok: false, transport: 'trez', reason: 'SMS not configured' }; return { ok: false, transport: 'none', reason: 'SMS not configured' };
} }
const to = this.normalizePhoneForSms(phone); const to = this.normalizePhoneForSms(phone);
const provider = this.resolveProvider(); const provider =
override?.provider ?? this.resolveProvider();
if (provider === 'trez') { if (provider === 'trez') {
return this.sendViaTrez(username, password, sender, to, text); return this.sendViaTrez(username, password, sender, to, text);
@@ -18,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer'; import { memoryStorage } from 'multer';
import type { Response } from 'express'; import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator'; import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { CompaniesService } from './companies.service'; import { CompaniesService } from './companies.service';
@@ -31,7 +32,8 @@ const excelUpload = FileInterceptor('file', {
}); });
@Controller('companies') @Controller('companies')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, SectionPermissionGuard)
@RequirePermission('customers')
export class CompaniesController { export class CompaniesController {
constructor( constructor(
private readonly companiesService: CompaniesService, private readonly companiesService: CompaniesService,
@@ -45,9 +47,15 @@ export class CompaniesController {
query.q, query.q,
query.page ?? 1, query.page ?? 1,
query.limit ?? 20, query.limit ?? 20,
{ groupId: query.groupId },
); );
} }
@Get('group-stats')
groupStats(@CurrentUser() user: Actor) {
return this.companiesService.groupStats(user);
}
@Get('export') @Get('export')
async export( async export(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
+82 -10
View File
@@ -6,6 +6,7 @@ import {
import type { import type {
CompanyDto, CompanyDto,
CreateCompanyRequest, CreateCompanyRequest,
CustomerGroupDto,
PaginatedResponse, PaginatedResponse,
UpdateCompanyRequest, UpdateCompanyRequest,
} from '@mento/shared'; } from '@mento/shared';
@@ -16,17 +17,21 @@ import {
asCustomFields, asCustomFields,
toJsonCustomFields, toJsonCustomFields,
} from '../common/custom-fields'; } from '../common/custom-fields';
import { customersCompanyWhere } from '../common/data-access';
import { import {
type Actor, type Actor,
canReassignOwner,
elevated, elevated,
ownerWhere,
resolveOwnerId, resolveOwnerId,
} from '../common/record-scope'; } from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { normalizeTags } from '../common/tags'; import { normalizeTags } from '../common/tags';
import { normalizePersonImportSource } from './person-source'; import { normalizePersonImportSource } from './person-source';
import { buildSearchWhere, ci } from '../common/search'; import { buildSearchWhere, ci, splitCsv } from '../common/search';
import { GamificationService } from '../gamification/gamification.service'; import { GamificationService } from '../gamification/gamification.service';
import {
CustomerGroupsService,
groupSummarySelect,
} from '../customer-groups/customer-groups.service';
function parseDateOrNull(raw?: string | null): Date | null { function parseDateOrNull(raw?: string | null): Date | null {
if (!raw?.trim()) return null; if (!raw?.trim()) return null;
@@ -37,6 +42,10 @@ function parseDateOrNull(raw?: string | null): Date | null {
const companyInclude = { const companyInclude = {
_count: { select: { people: true } }, _count: { select: { people: true } },
owner: { select: { id: true, fullName: true } }, owner: { select: { id: true, fullName: true } },
groupLinks: {
select: { group: { select: groupSummarySelect } },
orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }],
},
} satisfies Prisma.CompanyInclude; } satisfies Prisma.CompanyInclude;
type CompanyRow = { type CompanyRow = {
@@ -67,6 +76,7 @@ type CompanyRow = {
updatedAt: Date; updatedAt: Date;
_count: { people: number }; _count: { people: number };
owner: { id: string; fullName: string } | null; owner: { id: string; fullName: string } | null;
groupLinks: Array<{ group: { id: string; name: string; color: string } }>;
}; };
@Injectable() @Injectable()
@@ -74,6 +84,7 @@ export class CompaniesService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly game: GamificationService, private readonly game: GamificationService,
private readonly groups: CustomerGroupsService,
) {} ) {}
async list( async list(
@@ -81,6 +92,7 @@ export class CompaniesService {
q?: string, q?: string,
page = 1, page = 1,
limit = 20, limit = 20,
opts?: { groupId?: string },
): Promise<PaginatedResponse<CompanyDto>> { ): Promise<PaginatedResponse<CompanyDto>> {
const search = buildSearchWhere<Prisma.CompanyWhereInput>(q, (t) => [ const search = buildSearchWhere<Prisma.CompanyWhereInput>(q, (t) => [
{ name: ci(t) }, { name: ci(t) },
@@ -94,10 +106,15 @@ export class CompaniesService {
{ city: ci(t) }, { city: ci(t) },
{ province: ci(t) }, { province: ci(t) },
]); ]);
const groupIds = splitCsv(opts?.groupId);
const and: Prisma.CompanyWhereInput[] = [
...(search ? [search] : []),
...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []),
];
const where: Prisma.CompanyWhereInput = { const where: Prisma.CompanyWhereInput = {
organizationId: actor.organizationId, organizationId: actor.organizationId,
...ownerWhere(actor), ...customersCompanyWhere(actor),
...(search ? { AND: [search] } : {}), ...(and.length ? { AND: and } : {}),
}; };
const [rows, total] = await this.prisma.$transaction([ const [rows, total] = await this.prisma.$transaction([
@@ -119,9 +136,41 @@ export class CompaniesService {
}; };
} }
/** آمار گروه برای فیلتر شرکت‌ها — فقط شرکت‌های در دسترس کاربر */
async groupStats(actor: Actor): Promise<CustomerGroupDto[]> {
const counts = await this.prisma.companyGroupLink.groupBy({
by: ['groupId'],
where: {
company: {
organizationId: actor.organizationId,
...customersCompanyWhere(actor),
},
},
_count: { _all: true },
});
if (!counts.length) return [];
const countMap = new Map(counts.map((c) => [c.groupId, c._count._all]));
const groups = await this.prisma.customerGroup.findMany({
where: {
organizationId: actor.organizationId,
id: { in: counts.map((c) => c.groupId) },
},
orderBy: [{ position: 'asc' }, { name: 'asc' }],
});
return groups.map((g) => ({
id: g.id,
name: g.name,
color: g.color,
position: g.position,
fromTahesab: Boolean(g.tahesabGid),
personCount: 0,
companyCount: countMap.get(g.id) ?? 0,
}));
}
async get(actor: Actor, id: string): Promise<CompanyDto> { async get(actor: Actor, id: string): Promise<CompanyDto> {
const row = await this.prisma.company.findFirst({ const row = await this.prisma.company.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) }, where: { id, organizationId: actor.organizationId, ...customersCompanyWhere(actor) },
include: companyInclude, include: companyInclude,
}); });
if (!row) throw new NotFoundException('شرکت پیدا نشد'); if (!row) throw new NotFoundException('شرکت پیدا نشد');
@@ -152,15 +201,27 @@ export class CompaniesService {
notes: emptyToNull(dto.notes), notes: emptyToNull(dto.notes),
registeredAt: parseDateOrNull(dto.registeredAt), registeredAt: parseDateOrNull(dto.registeredAt),
tags: normalizeTags(dto.tags), tags: normalizeTags(dto.tags),
ownerId: resolveOwnerId(actor, dto.ownerId), ownerId: resolveOwnerId(
actor,
dto.ownerId,
await actorCanAssignToOthers(this.prisma, actor, 'customers'),
),
customFields: toJsonCustomFields(dto.customFields), customFields: toJsonCustomFields(dto.customFields),
}; };
await this.assertNoDuplicate(actor.organizationId, data); await this.assertNoDuplicate(actor.organizationId, data);
const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds);
const row = await this.prisma.company.create({ let row = await this.prisma.company.create({
data, data,
include: companyInclude, include: companyInclude,
}); });
if (groupIds.length) {
await this.groups.addCompanyGroups(row.id, groupIds);
row = await this.prisma.company.findUniqueOrThrow({
where: { id: row.id },
include: companyInclude,
});
}
if (row.importSource === 'manual') { if (row.importSource === 'manual') {
this.game.emit({ this.game.emit({
organizationId: actor.organizationId, organizationId: actor.organizationId,
@@ -209,11 +270,21 @@ export class CompaniesService {
if ('customFields' in dto) { if ('customFields' in dto) {
data.customFields = toJsonCustomFields(dto.customFields); data.customFields = toJsonCustomFields(dto.customFields);
} }
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) { if (dto.ownerId !== undefined) {
data.ownerId = resolveOwnerId(actor, dto.ownerId); const current = await this.prisma.company.findFirst({
where: { id, organizationId: actor.organizationId },
select: { ownerId: true },
});
const nextOwner = emptyToNull(dto.ownerId ?? null);
await assertAssigneeSet(this.prisma, actor, 'customers', [nextOwner], [current?.ownerId]);
data.ownerId = nextOwner ?? actor.userId;
} }
await this.assertNoDuplicate(actor.organizationId, data, id); await this.assertNoDuplicate(actor.organizationId, data, id);
if (dto.groupIds !== undefined) {
const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds);
await this.groups.setCompanyGroups(id, groupIds);
}
const row = await this.prisma.company.update({ const row = await this.prisma.company.update({
where: { id }, where: { id },
data, data,
@@ -315,6 +386,7 @@ export class CompaniesService {
registeredAt: row.registeredAt ? row.registeredAt.toISOString() : null, registeredAt: row.registeredAt ? row.registeredAt.toISOString() : null,
tags: row.tags ?? [], tags: row.tags ?? [],
customFields: asCustomFields(row.customFields), customFields: asCustomFields(row.customFields),
groups: (row.groupLinks ?? []).map((l) => l.group),
peopleCount: row._count.people, peopleCount: row._count.people,
owner: row.owner, owner: row.owner,
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
@@ -2,8 +2,9 @@ import { BadRequestException, ConflictException, Injectable } from '@nestjs/comm
import type { ImportContactsResult, PersonMood } from '@mento/shared'; import type { ImportContactsResult, PersonMood } from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@mento/shared'; import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { customersCompanyWhere } from '../common/data-access';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { ownerWhere, resolveOwnerId } from '../common/record-scope'; import { resolveOwnerId } from '../common/record-scope';
import { import {
collectAddresses, collectAddresses,
} from '../common/custom-fields'; } from '../common/custom-fields';
@@ -25,6 +26,10 @@ import { CompaniesService } from './companies.service';
import { PeopleService } from './people.service'; import { PeopleService } from './people.service';
import { normalizeEmail, normalizePhone } from './normalize'; import { normalizeEmail, normalizePhone } from './normalize';
import { normalizePersonImportSource } from './person-source'; import { normalizePersonImportSource } from './person-source';
import {
CustomerGroupsService,
splitGroupNames,
} from '../customer-groups/customer-groups.service';
type ImportChunkOpts = { type ImportChunkOpts = {
sessionId?: string; sessionId?: string;
@@ -39,6 +44,16 @@ function parseMood(raw?: string): PersonMood | undefined {
return PERSON_MOODS.find((mood) => PERSON_MOOD_LABELS[mood] === value); return PERSON_MOODS.find((mood) => PERSON_MOOD_LABELS[mood] === value);
} }
/** ستون‌های گروه اکسل → نام گروه‌ها (برچسب‌های وضعیت مشتری گروه حساب نمی‌شوند) */
function groupNamesFrom(...raws: Array<string | undefined>): string[] {
const names = raws.flatMap((raw) => splitGroupNames(raw));
return [...new Set(names.filter((name) => !parseMood(name)))];
}
function groupNamesText(groups: Array<{ name: string }> | undefined, fallback?: string) {
return groups?.length ? groups.map((g) => g.name).join('، ') : (fallback ?? '');
}
function splitDidarName(firstName: string, lastName: string, title?: string) { function splitDidarName(firstName: string, lastName: string, title?: string) {
const first = firstName.trim(); const first = firstName.trim();
const last = lastName.trim(); const last = lastName.trim();
@@ -369,6 +384,7 @@ export class ContactsExcelService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly people: PeopleService, private readonly people: PeopleService,
private readonly companies: CompaniesService, private readonly companies: CompaniesService,
private readonly groups: CustomerGroupsService,
) {} ) {}
async exportPeople(actor: Actor, q?: string): Promise<Buffer> { async exportPeople(actor: Actor, q?: string): Promise<Buffer> {
@@ -378,6 +394,7 @@ export class ContactsExcelService {
CONTACT_COLUMNS, CONTACT_COLUMNS,
items.map((p) => { items.map((p) => {
const extra = (p.customFields ?? {}) as Record<string, string>; const extra = (p.customFields ?? {}) as Record<string, string>;
const groupText = groupNamesText(p.groups, extra.group);
return { return {
_type: 'Contact', _type: 'Contact',
didarId: extra.didarId ?? p.id, didarId: extra.didarId ?? p.id,
@@ -402,7 +419,7 @@ export class ContactsExcelService {
nationalId: extra.nationalId ?? '', nationalId: extra.nationalId ?? '',
registeredAt: formatJalaliDate(p.createdAt), registeredAt: formatJalaliDate(p.createdAt),
birthDate: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''), birthDate: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
mood: extra.group || PERSON_MOOD_LABELS[p.mood || 'newcomer'], mood: PERSON_MOOD_LABELS[p.mood || 'newcomer'],
otherPhones: extra.otherPhones ?? '', otherPhones: extra.otherPhones ?? '',
address: p.addresses[0] ?? extra.address ?? '', address: p.addresses[0] ?? extra.address ?? '',
website: p.website ?? extra.website ?? '', website: p.website ?? extra.website ?? '',
@@ -410,7 +427,7 @@ export class ContactsExcelService {
position2: p.position ?? extra.position ?? '', position2: p.position ?? extra.position ?? '',
city2: extra.city ?? '', city2: extra.city ?? '',
code2: extra.customerCode ?? '', code2: extra.customerCode ?? '',
group2: extra.group ?? '', group2: groupText,
address2: p.addresses[1] ?? extra.address ?? '', address2: p.addresses[1] ?? extra.address ?? '',
phone2: extra.phone2 ?? p.phone ?? '', phone2: extra.phone2 ?? p.phone ?? '',
postalCode2: extra.postalCode ?? '', postalCode2: extra.postalCode ?? '',
@@ -418,7 +435,7 @@ export class ContactsExcelService {
homeAddress: p.addresses[2] ?? extra.homeAddress ?? '', homeAddress: p.addresses[2] ?? extra.homeAddress ?? '',
birthDate2: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''), birthDate2: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
instagram: p.instagram ?? extra.instagram ?? '', instagram: p.instagram ?? extra.instagram ?? '',
group: extra.group ?? '', group: groupText,
colleagueContact: extra.colleagueContact ?? '', colleagueContact: extra.colleagueContact ?? '',
previousCompany: extra.previousCompany ?? '', previousCompany: extra.previousCompany ?? '',
customerType: extra.customerType ?? '', customerType: extra.customerType ?? '',
@@ -456,7 +473,7 @@ export class ContactsExcelService {
registeredAt: formatJalaliDate( registeredAt: formatJalaliDate(
c.registeredAt ? new Date(c.registeredAt) : new Date(c.createdAt), c.registeredAt ? new Date(c.registeredAt) : new Date(c.createdAt),
), ),
groupName: c.groupName ?? '', groupName: groupNamesText(c.groups, c.groupName ?? ''),
otherPhones: c.otherPhones ?? '', otherPhones: c.otherPhones ?? '',
address: c.address ?? '', address: c.address ?? '',
website: c.website ?? '', website: c.website ?? '',
@@ -551,6 +568,8 @@ export class ContactsExcelService {
const value = row[key]?.trim(); const value = row[key]?.trim();
if (value) customFields[key] = value; if (value) customFields[key] = value;
} }
const groupNames = groupNamesFrom(row.group, parseMood(row.mood) ? undefined : row.mood);
if (groupNames.length) delete customFields.group;
const addresses = collectAddresses( const addresses = collectAddresses(
row.address, row.address,
@@ -576,6 +595,7 @@ export class ContactsExcelService {
? normalizePersonImportSource(sourceCol) ? normalizePersonImportSource(sourceCol)
: 'excel'; : 'excel';
const sourceRef = didarId || customerCode || null; const sourceRef = didarId || customerCode || null;
const groupIds = await this.groups.idsForNames(actor.organizationId, groupNames);
await this.people.create(actor, { await this.people.create(actor, {
firstName: names.firstName, firstName: names.firstName,
@@ -596,6 +616,7 @@ export class ContactsExcelService {
importSource, importSource,
sourceRef, sourceRef,
registeredAt: registeredAt?.toISOString() ?? null, registeredAt: registeredAt?.toISOString() ?? null,
groupIds,
}); });
result.created += 1; result.created += 1;
if (ownerMissing) { if (ownerMissing) {
@@ -701,8 +722,13 @@ export class ContactsExcelService {
}; };
try { try {
const groupIds = await this.groups.idsForNames(
actor.organizationId,
groupNamesFrom(row.groupName),
);
const existing = await this.findImportedCompany(actor, didarId, name); const existing = await this.findImportedCompany(actor, didarId, name);
if (existing) { if (existing) {
if (groupIds.length) await this.groups.addCompanyGroups(existing.id, groupIds);
const merged: Record<string, unknown> = {}; const merged: Record<string, unknown> = {};
for (const [k, v] of Object.entries(payload)) { for (const [k, v] of Object.entries(payload)) {
if (k === 'customFields') continue; if (k === 'customFields') continue;
@@ -719,6 +745,7 @@ export class ContactsExcelService {
await this.companies.create(actor, { await this.companies.create(actor, {
...payload, ...payload,
ownerId: resolveOwnerId(actor, ownerId ?? null), ownerId: resolveOwnerId(actor, ownerId ?? null),
groupIds,
}); });
result.created += 1; result.created += 1;
} }
@@ -750,7 +777,7 @@ export class ContactsExcelService {
where: { where: {
organizationId: actor.organizationId, organizationId: actor.organizationId,
sourceRef: didarId, sourceRef: didarId,
...ownerWhere(actor), ...customersCompanyWhere(actor),
}, },
select: { id: true, customFields: true }, select: { id: true, customFields: true },
}); });
@@ -761,7 +788,7 @@ export class ContactsExcelService {
organizationId: actor.organizationId, organizationId: actor.organizationId,
name: { equals: name, mode: 'insensitive' }, name: { equals: name, mode: 'insensitive' },
...(didarId ? { sourceRef: null } : {}), ...(didarId ? { sourceRef: null } : {}),
...ownerWhere(actor), ...customersCompanyWhere(actor),
}, },
select: { id: true, customFields: true }, select: { id: true, customFields: true },
}); });
@@ -779,7 +806,7 @@ export class ContactsExcelService {
where: { where: {
organizationId: actor.organizationId, organizationId: actor.organizationId,
sourceRef: ref, sourceRef: ref,
...ownerWhere(actor), ...customersCompanyWhere(actor),
}, },
select: { id: true }, select: { id: true },
}); });
@@ -789,7 +816,7 @@ export class ContactsExcelService {
where: { where: {
organizationId: actor.organizationId, organizationId: actor.organizationId,
name: { equals: name, mode: 'insensitive' }, name: { equals: name, mode: 'insensitive' },
...ownerWhere(actor), ...customersCompanyWhere(actor),
}, },
select: { id: true, sourceRef: true }, select: { id: true, sourceRef: true },
}); });
+6 -2
View File
@@ -8,7 +8,9 @@ import { CompaniesService } from './companies.service';
import { PeopleController } from './people.controller'; import { PeopleController } from './people.controller';
import { PeopleService } from './people.service'; import { PeopleService } from './people.service';
import { ContactsExcelService } from './contacts-excel.service'; import { ContactsExcelService } from './contacts-excel.service';
import { SmsService } from '../common/sms.service'; import { SameMobileMergeService } from './same-mobile-merge.service';
import { BehaviorModule } from '../behavior/behavior.module';
import { CustomerGroupsModule } from '../customer-groups/customer-groups.module';
@Module({ @Module({
imports: [ imports: [
@@ -16,9 +18,11 @@ import { SmsService } from '../common/sms.service';
AutomationsModule, AutomationsModule,
MessageAutomationsModule, MessageAutomationsModule,
PublicApiModule, PublicApiModule,
BehaviorModule,
CustomerGroupsModule,
], ],
controllers: [PeopleController, CompaniesController], controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService, SmsService], providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService],
exports: [PeopleService, CompaniesService], exports: [PeopleService, CompaniesService],
}) })
export class ContactsModule {} export class ContactsModule {}
+51 -5
View File
@@ -3,6 +3,7 @@ import {
ArrayMaxSize, ArrayMaxSize,
ArrayMinSize, ArrayMinSize,
IsArray, IsArray,
IsBoolean,
IsEmail, IsEmail,
IsIn, IsIn,
IsInt, IsInt,
@@ -15,7 +16,7 @@ import {
MinLength, MinLength,
ValidateIf, ValidateIf,
} from 'class-validator'; } from 'class-validator';
import { PERSON_IMPORT_SOURCES, PERSON_MOODS } from '@mento/shared'; import { PERSON_GENDERS, PERSON_IMPORT_SOURCES, PERSON_MOODS } from '@mento/shared';
export class BirthdaySmsDto { export class BirthdaySmsDto {
@IsString() @IsString()
@@ -50,18 +51,20 @@ export class ListQueryDto {
@Max(100) @Max(100)
limit?: number = 20; limit?: number = 20;
/** وضعیت مشتری؛ چندتایی با کاما */
@IsOptional() @IsOptional()
@IsIn([...PERSON_MOODS]) @IsString()
mood?: (typeof PERSON_MOODS)[number]; @MaxLength(120)
mood?: string;
@IsOptional() @IsOptional()
@IsIn(['name', 'newest']) @IsIn(['name', 'newest'])
sort?: 'name' | 'newest'; sort?: 'name' | 'newest';
/** فیلتر منبع ورود شخص */ /** فیلتر منبع ورود شخص؛ چندتایی با کاما */
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(40) @MaxLength(400)
importSource?: string; importSource?: string;
/** استان شرکت مرتبط */ /** استان شرکت مرتبط */
@@ -80,6 +83,18 @@ export class ListQueryDto {
@IsOptional() @IsOptional()
@IsIn(['1', 'true', '0', 'false']) @IsIn(['1', 'true', '0', 'false'])
hasMobile?: string; hasMobile?: string;
/** فیلتر تیپ DISC غالب یا ترکیبی، مثل D یا D/C */
@IsOptional()
@IsString()
@MaxLength(12)
disc?: string;
/** فیلتر گروه مشتری؛ چندتایی با کاما (هر کدام از گروه‌ها) */
@IsOptional()
@IsString()
@MaxLength(2000)
groupId?: string;
} }
class CompanyExtraFieldsDto { class CompanyExtraFieldsDto {
@@ -147,6 +162,11 @@ class CompanyExtraFieldsDto {
@ValidateIf((_, v) => v !== '' && v != null) @ValidateIf((_, v) => v !== '' && v != null)
@IsString() @IsString()
registeredAt?: string | null; registeredAt?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
groupIds?: string[];
} }
export class CreateCompanyDto extends CompanyExtraFieldsDto { export class CreateCompanyDto extends CompanyExtraFieldsDto {
@@ -263,6 +283,14 @@ export class CreatePersonDto {
@MaxLength(80) @MaxLength(80)
lastName?: string; lastName?: string;
@IsOptional()
@IsIn(PERSON_GENDERS)
gender?: (typeof PERSON_GENDERS)[number];
@IsOptional()
@IsBoolean()
showGenderInName?: boolean;
@IsOptional() @IsOptional()
@ValidateIf((_, v) => v !== '' && v != null) @ValidateIf((_, v) => v !== '' && v != null)
@IsString() @IsString()
@@ -364,6 +392,11 @@ export class CreatePersonDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
registeredAt?: string | null; registeredAt?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
groupIds?: string[];
} }
export class UpdatePersonDto { export class UpdatePersonDto {
@@ -377,6 +410,14 @@ export class UpdatePersonDto {
@MaxLength(80) @MaxLength(80)
lastName?: string; lastName?: string;
@IsOptional()
@IsIn(PERSON_GENDERS)
gender?: (typeof PERSON_GENDERS)[number];
@IsOptional()
@IsBoolean()
showGenderInName?: boolean;
@IsOptional() @IsOptional()
@ValidateIf((_, v) => v !== '' && v != null) @ValidateIf((_, v) => v !== '' && v != null)
@IsString() @IsString()
@@ -472,4 +513,9 @@ export class UpdatePersonDto {
@ValidateIf((_, v) => v !== '' && v != null) @ValidateIf((_, v) => v !== '' && v != null)
@IsString() @IsString()
ownerId?: string | null; ownerId?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
groupIds?: string[];
} }
+367
View File
@@ -0,0 +1,367 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { Prisma, PrismaClient } from '@prisma/client';
import { canonicalIranMobile, storedPhoneNumber } from './normalize';
const personSelect = {
id: true,
createdAt: true,
firstName: true,
lastName: true,
gender: true,
showGenderInName: true,
previousFirstName: true,
previousLastName: true,
email: true,
phone: true,
mobile: true,
position: true,
website: true,
instagram: true,
birthDate: true,
referrer: true,
addresses: true,
notes: true,
tags: true,
companyId: true,
customFields: true,
importSource: true,
sourceRef: true,
previousImportSource: true,
previousSourceRef: true,
loyaltyPoints: true,
discLabel: true,
discReliable: true,
discAssessedAt: true,
} satisfies Prisma.PersonSelect;
type PersonRow = Prisma.PersonGetPayload<{ select: typeof personSelect }>;
function blank(value: string | null | undefined) {
return !value?.trim();
}
function asStrings(value: Prisma.JsonValue): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => String(item)).filter(Boolean);
}
function asObject(value: Prisma.JsonValue): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return { ...(value as Record<string, unknown>) };
}
function mergedFields(keeper: PersonRow, extras: PersonRow[], canon: string): Prisma.PersonUncheckedUpdateInput {
const notes = [keeper.notes?.trim() || ''];
let email = keeper.email;
let phone = storedPhoneNumber(keeper.phone);
let position = keeper.position;
let website = keeper.website;
let instagram = keeper.instagram;
let birthDate = keeper.birthDate;
let referrer = keeper.referrer;
let companyId = keeper.companyId;
let gender = keeper.gender;
let showGenderInName = keeper.showGenderInName;
let previousFirstName = keeper.previousFirstName;
let previousLastName = keeper.previousLastName;
let previousImportSource = keeper.previousImportSource;
let previousSourceRef = keeper.previousSourceRef;
let discLabel = keeper.discLabel;
let discReliable = keeper.discReliable;
let discAssessedAt = keeper.discAssessedAt;
let loyaltyPoints = keeper.loyaltyPoints;
const addresses = asStrings(keeper.addresses);
const tags = new Set(keeper.tags);
const customFields = asObject(keeper.customFields);
for (const extra of extras) {
const name = `${extra.firstName} ${extra.lastName}`.trim();
notes.push(
`ادغام شماره تکراری: ${name || 'بدون نام'} (${extra.mobile || 'بدون موبایل'})`,
);
if (blank(email) && extra.email) email = extra.email;
if (blank(phone)) phone = storedPhoneNumber(extra.phone);
if (blank(position) && extra.position) position = extra.position;
if (blank(website) && extra.website) website = extra.website;
if (blank(instagram) && extra.instagram) instagram = extra.instagram;
if (!birthDate && extra.birthDate) birthDate = extra.birthDate;
if (blank(referrer) && extra.referrer) referrer = extra.referrer;
if (!companyId && extra.companyId) companyId = extra.companyId;
if (gender === 'none' && extra.gender !== 'none') {
gender = extra.gender;
showGenderInName = extra.showGenderInName;
}
if (blank(previousFirstName) && extra.previousFirstName) {
previousFirstName = extra.previousFirstName;
}
if (blank(previousLastName) && extra.previousLastName) {
previousLastName = extra.previousLastName;
}
if (
blank(previousImportSource) &&
extra.importSource &&
extra.importSource !== keeper.importSource
) {
previousImportSource = extra.importSource;
previousSourceRef = extra.sourceRef;
}
if (
keeper.importSource === 'tahesab' &&
blank(previousFirstName) &&
blank(previousLastName)
) {
const extraName = `${extra.firstName} ${extra.lastName}`.trim();
const keeperName = `${keeper.firstName} ${keeper.lastName}`.trim();
if (extraName && extraName !== keeperName) {
previousFirstName = extra.firstName || null;
previousLastName = extra.lastName || null;
}
}
if (!discLabel && extra.discLabel) {
discLabel = extra.discLabel;
discReliable = extra.discReliable;
discAssessedAt = extra.discAssessedAt;
}
loyaltyPoints = Math.max(loyaltyPoints, extra.loyaltyPoints);
for (const address of asStrings(extra.addresses)) {
if (!addresses.includes(address)) addresses.push(address);
}
for (const tag of extra.tags) tags.add(tag);
for (const [key, value] of Object.entries(asObject(extra.customFields))) {
if (customFields[key] == null || customFields[key] === '') customFields[key] = value;
}
}
return {
mobile: canon,
email,
phone,
position,
website,
instagram,
birthDate,
referrer,
companyId,
gender,
showGenderInName,
previousFirstName,
previousLastName,
previousImportSource,
previousSourceRef,
discLabel,
discReliable,
discAssessedAt,
loyaltyPoints,
addresses,
tags: [...tags],
customFields: customFields as Prisma.InputJsonValue,
notes: notes.filter(Boolean).join('\n') || null,
};
}
async function personIdTables(prisma: PrismaClient | Prisma.TransactionClient) {
const rows = await prisma.$queryRaw<{ table_name: string }[]>`
SELECT table_name
FROM information_schema.columns
WHERE table_schema = 'public' AND column_name = 'personId'
`;
return rows
.map((row) => row.table_name)
.filter((name) => /^[A-Za-z0-9_]+$/.test(name) && name !== 'Person');
}
async function moveLinks(
tx: Prisma.TransactionClient,
tables: string[],
keeperId: string,
dupId: string,
) {
await tx.$executeRaw`
DELETE FROM "PersonGroupLink" AS d
USING "PersonGroupLink" AS k
WHERE d."personId" = ${dupId}
AND k."personId" = ${keeperId}
AND d."groupId" = k."groupId"
`;
await tx.$executeRaw`
DELETE FROM "PersonAccountingLink" AS d
USING "PersonAccountingLink" AS k
WHERE d."personId" = ${dupId}
AND k."personId" = ${keeperId}
AND d."connectionId" = k."connectionId"
`;
await tx.$executeRaw`
DELETE FROM "PartyOpeningBalance"
WHERE "personId" = ${dupId}
AND EXISTS (
SELECT 1 FROM "PartyOpeningBalance" AS k WHERE k."personId" = ${keeperId}
)
`;
for (const table of tables) {
await tx.$executeRawUnsafe(
`UPDATE "${table}" SET "personId" = $1 WHERE "personId" = $2`,
keeperId,
dupId,
);
}
await tx.$executeRaw`
UPDATE "Lead" SET "convertedPersonId" = ${keeperId} WHERE "convertedPersonId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "MontifyLink"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'customer' AND "entityId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "Notification"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "GamificationTransaction"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "FinancialAuditLog"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
`;
}
export async function mergeSameMobilePeople(
prisma: PrismaClient,
organizationId: string,
apply: boolean,
) {
const people = await prisma.person.findMany({
where: { organizationId },
select: personSelect,
orderBy: { createdAt: 'asc' },
});
const groups = new Map<string, PersonRow[]>();
for (const person of people) {
const key = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
if (!key) continue;
const list = groups.get(key) ?? [];
list.push(person);
groups.set(key, list);
}
const duplicateGroups = [...groups.entries()].filter(([, list]) => list.length > 1);
const preview = duplicateGroups.map(([mobile, list]) => ({
mobile,
people: list.map((person) => ({
id: person.id,
stored: person.mobile,
name: `${person.firstName} ${person.lastName}`.trim(),
})),
}));
if (!apply) {
const needsRewrite = people.filter((person) => {
const canon = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
return Boolean(canon && (person.mobile !== canon || canonicalIranMobile(person.phone)));
}).length;
return { merged: 0, rewritten: 0, needsRewrite, failed: 0, groups: preview };
}
const tables = await personIdTables(prisma);
let merged = 0;
let rewritten = 0;
let failed = 0;
for (const [canon, list] of groups) {
const ordered = [...list].sort((a, b) => {
const aTahesab = a.importSource === 'tahesab' ? 0 : 1;
const bTahesab = b.importSource === 'tahesab' ? 0 : 1;
if (aTahesab !== bTahesab) return aTahesab - bTahesab;
return a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id);
});
const [keeper, ...extras] = ordered;
if (!keeper) continue;
if (!extras.length) {
const phoneCanon = canonicalIranMobile(keeper.phone);
const data: Prisma.PersonUpdateInput = {};
if (keeper.mobile !== canon) data.mobile = canon;
if (phoneCanon && keeper.phone !== phoneCanon) data.phone = phoneCanon;
if (Object.keys(data).length) {
await prisma.person.update({ where: { id: keeper.id }, data });
rewritten += 1;
}
continue;
}
try {
await prisma.$transaction(
async (tx) => {
await tx.person.update({
where: { id: keeper.id },
data: mergedFields(keeper, extras, canon),
});
for (const extra of extras) {
await moveLinks(tx, tables, keeper.id, extra.id);
await tx.person.delete({ where: { id: extra.id } });
merged += 1;
}
},
{ timeout: 60_000 },
);
} catch (error) {
failed += 1;
console.error(
`merge mobile ${canon} failed`,
error instanceof Error ? error.message : error,
);
}
}
return { merged, rewritten, needsRewrite: 0, failed, groups: preview };
}
function loadEnv(file: string) {
const text = readFileSync(file, 'utf8');
for (const line of text.split(/\r?\n/)) {
const match = line.match(/^([A-Za-z0-9_]+)=(.*)$/);
if (!match || process.env[match[1]]) continue;
let value = match[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[match[1]] = value;
}
}
const direct = process.argv[1]?.replace(/\\/g, '/').includes('merge-same-mobile');
if (direct) {
loadEnv(resolve(__dirname, '../../.env'));
const orgId = process.argv.find((arg) => arg.startsWith('--org='))?.slice(6);
const apply = process.argv.includes('--apply');
if (!orgId) {
console.error('org id required: --org=<id>');
process.exit(1);
}
const prisma = new PrismaClient();
mergeSameMobilePeople(prisma, orgId, apply)
.then((result) => {
console.log(JSON.stringify({
apply,
merged: result.merged,
rewritten: result.rewritten,
needsRewrite: result.needsRewrite,
groupCount: result.groups.length,
groups: result.groups,
}, null, 2));
})
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
}
+44 -1
View File
@@ -9,11 +9,54 @@ export function normalizeEmail(value?: string | null): string | null {
return v ? v.toLowerCase() : null; return v ? v.toLowerCase() : null;
} }
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
const AR_DIGITS = '٠١٢٣٤٥٦٧٨٩';
/** ارقام فارسی و عربی را به لاتین برمی‌گرداند تا از شماره حذف نشوند. */
function latinDigits(value: string): string {
return value.replace(/[۰-۹٠-٩]/g, (ch) => {
const fa = FA_DIGITS.indexOf(ch);
return String(fa >= 0 ? fa : AR_DIGITS.indexOf(ch));
});
}
/** Keep digits and leading + for Iranian/international numbers. */ /** Keep digits and leading + for Iranian/international numbers. */
export function normalizePhone(value?: string | null): string | null { export function normalizePhone(value?: string | null): string | null {
const v = emptyToNull(value); const v = emptyToNull(value);
if (!v) return null; if (!v) return null;
const cleaned = v.replace(/[^\d+]/g, ''); const cleaned = latinDigits(v).replace(/[^\d+]/g, '');
return cleaned.length ? cleaned : null; return cleaned.length ? cleaned : null;
} }
/** موبایل ایران به شکل 09xxxxxxxxx. 912… و 98912… و +98912… همان شماره‌اند. */
export function canonicalIranMobile(value?: string | null): string | null {
const n = normalizePhone(value);
if (!n) return null;
let d = n.replace(/\D/g, '');
if (!d) return null;
if (d.startsWith('0098') && d.length >= 14) d = d.slice(4);
else if (d.startsWith('98') && d.length >= 12) d = d.slice(2);
if (d.length === 10 && d.startsWith('9')) d = `0${d}`;
return /^09\d{9}$/.test(d) ? d : null;
}
/** شکل‌هایی که ممکن است همین موبایل با آن‌ها ذخیره شده باشد. */
export function iranMobileLookupKeys(value?: string | null): string[] {
const canon = canonicalIranMobile(value);
if (!canon) return [];
const core = canon.slice(1);
return [canon, core, `98${core}`, `+98${core}`, `0098${core}`];
}
/** برای تطبیق: اگر موبایل ایران است همه شکل‌ها، وگرنه همان متن نرمال‌شده. */
export function phoneIdentityKeys(value?: string | null): string[] {
const keys = iranMobileLookupKeys(value);
if (keys.length) return keys;
const n = normalizePhone(value);
return n ? [n] : [];
}
/** ذخیره: موبایل ایران همیشه 09… ؛ بقیه شماره‌ها فقط تمیز می‌شوند. */
export function storedPhoneNumber(value?: string | null): string | null {
return canonicalIranMobile(value) ?? normalizePhone(value);
}
+12 -1
View File
@@ -18,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer'; import { memoryStorage } from 'multer';
import type { Response } from 'express'; import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator'; import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { OrgModuleGuard } from '../billing/org-module.guard'; import { OrgModuleGuard } from '../billing/org-module.guard';
@@ -33,8 +34,9 @@ const excelUpload = FileInterceptor('file', {
}); });
@Controller('people') @Controller('people')
@UseGuards(JwtAuthGuard, OrgModuleGuard) @UseGuards(JwtAuthGuard, OrgModuleGuard, SectionPermissionGuard)
@RequireOrgModule('customers') @RequireOrgModule('customers')
@RequirePermission('customers')
export class PeopleController { export class PeopleController {
constructor( constructor(
private readonly peopleService: PeopleService, private readonly peopleService: PeopleService,
@@ -59,6 +61,8 @@ export class PeopleController {
province: query.province, province: query.province,
city: query.city, city: query.city,
hasMobile, hasMobile,
disc: query.disc,
groupId: query.groupId,
}, },
); );
} }
@@ -75,6 +79,8 @@ export class PeopleController {
province: query.province, province: query.province,
city: query.city, city: query.city,
hasMobile, hasMobile,
disc: query.disc,
groupId: query.groupId,
}); });
} }
@@ -83,6 +89,11 @@ export class PeopleController {
return this.peopleService.sourceStats(user); return this.peopleService.sourceStats(user);
} }
@Get('group-stats')
groupStats(@CurrentUser() user: Actor) {
return this.peopleService.groupStats(user);
}
@Get('export') @Get('export')
async export( async export(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
+215 -48
View File
@@ -10,6 +10,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import type { import type {
CreatePersonRequest, CreatePersonRequest,
CustomerGroupDto,
FilteredIdsResponse, FilteredIdsResponse,
PaginatedResponse, PaginatedResponse,
PersonDto, PersonDto,
@@ -17,39 +18,54 @@ import type {
PersonMood, PersonMood,
UpdatePersonRequest, UpdatePersonRequest,
} from '@mento/shared'; } from '@mento/shared';
import { FILTERED_IDS_MAX } from '@mento/shared'; import {
FILTERED_IDS_MAX,
PERSON_MOODS,
applyPersonGenderTitle,
splitPersonHonorific,
} from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service'; import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
import { CompaniesService } from './companies.service'; import { CompaniesService } from './companies.service';
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize'; import {
emptyToNull,
normalizeEmail,
phoneIdentityKeys,
storedPhoneNumber,
} from './normalize';
import { import {
asAddresses, asAddresses,
asCustomFields, asCustomFields,
toJsonAddresses, toJsonAddresses,
toJsonCustomFields, toJsonCustomFields,
} from '../common/custom-fields'; } from '../common/custom-fields';
import { customersPersonWhere } from '../common/data-access';
import { import {
type Actor, type Actor,
canReassignOwner,
ownerWhere,
resolveOwnerId, resolveOwnerId,
} from '../common/record-scope'; } from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { LoyaltyService } from '../loyalty/loyalty.service'; import { LoyaltyService } from '../loyalty/loyalty.service';
import { AutomationsService } from '../automations/automations.service'; import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service'; import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { WebhooksService } from '../public-api/webhooks.service'; import { WebhooksService } from '../public-api/webhooks.service';
import { GamificationService } from '../gamification/gamification.service'; import { GamificationService } from '../gamification/gamification.service';
import { parseBirthDate } from '../common/excel'; import { parseBirthDate } from '../common/excel';
import { SmsService } from '../common/sms.service'; import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { selectMonthBirthdays } from './birthday-month'; import { selectMonthBirthdays } from './birthday-month';
import { renderSmsText } from '../common/sms-text'; import { renderSmsText } from '../common/sms-text';
import { normalizeTags } from '../common/tags'; import { normalizeTags } from '../common/tags';
import { buildSearchWhere, ci } from '../common/search'; import { buildSearchWhere, ci, splitCsv } from '../common/search';
import { import {
normalizePersonImportSource, normalizePersonImportSource,
personImportSourceLabel, personImportSourceLabel,
} from './person-source'; } from './person-source';
import { BehaviorAccessService } from '../behavior/behavior-access.service';
import {
CustomerGroupsService,
groupSummarySelect,
} from '../customer-groups/customer-groups.service';
const personInclude = { const personInclude = {
company: { select: { id: true, name: true, province: true, city: true } }, company: { select: { id: true, name: true, province: true, city: true } },
@@ -57,12 +73,18 @@ const personInclude = {
loyaltyTier: { loyaltyTier: {
select: { id: true, name: true, minPoints: true, color: true }, select: { id: true, name: true, minPoints: true, color: true },
}, },
groupLinks: {
select: { source: true, group: { select: groupSummarySelect } },
orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }],
},
} satisfies Prisma.PersonInclude; } satisfies Prisma.PersonInclude;
type PersonRow = { type PersonRow = {
id: string; id: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
gender: string;
showGenderInName: boolean;
previousFirstName: string | null; previousFirstName: string | null;
previousLastName: string | null; previousLastName: string | null;
importSource: string; importSource: string;
@@ -98,8 +120,28 @@ type PersonRow = {
minPoints: number; minPoints: number;
color: string | null; color: string | null;
} | null; } | null;
discLabel: string | null;
discReliable: boolean;
discAssessedAt: Date | null;
groupLinks: Array<{ source: string; group: { id: string; name: string; color: string } }>;
}; };
function discFilter(raw: string | undefined): Prisma.PersonWhereInput | null {
const value = raw?.trim().toUpperCase();
if (!value || !/^[DISC](\/[DISC]){0,3}$/.test(value)) return null;
if (value.includes('/')) {
return { discReliable: true, discLabel: value };
}
return {
discReliable: true,
OR: [
{ discLabel: value },
{ discLabel: { startsWith: `${value}/` } },
{ discLabel: { contains: `/${value}` } },
],
};
}
@Injectable() @Injectable()
export class PeopleService implements OnModuleInit { export class PeopleService implements OnModuleInit {
private readonly logger = new Logger(PeopleService.name); private readonly logger = new Logger(PeopleService.name);
@@ -111,24 +153,30 @@ export class PeopleService implements OnModuleInit {
private readonly messageAutomations: MessageAutomationsService, private readonly messageAutomations: MessageAutomationsService,
private readonly webhooks: WebhooksService, private readonly webhooks: WebhooksService,
private readonly game: GamificationService, private readonly game: GamificationService,
private readonly sms: SmsService, private readonly sms: SmsGatewayService,
private readonly outboundSms: OutboundSmsLogService, private readonly outboundSms: OutboundSmsLogService,
private readonly behaviorAccess: BehaviorAccessService,
private readonly groups: CustomerGroupsService,
) {} ) {}
private personListWhere( private personListWhere(
actor: Actor, actor: Actor,
q?: string, q?: string,
opts?: { opts?: {
mood?: PersonMood; mood?: string;
importSource?: string; importSource?: string;
province?: string; province?: string;
city?: string; city?: string;
hasMobile?: boolean; hasMobile?: boolean;
disc?: string;
groupId?: string;
}, },
): Prisma.PersonWhereInput { ): Prisma.PersonWhereInput {
const sourceFilter = opts?.importSource?.trim() const sources = [...new Set(splitCsv(opts?.importSource).map(normalizePersonImportSource))];
? normalizePersonImportSource(opts.importSource) const moods = splitCsv(opts?.mood).filter((m): m is PersonMood =>
: undefined; (PERSON_MOODS as readonly string[]).includes(m),
);
const groupIds = splitCsv(opts?.groupId);
const search = buildSearchWhere<Prisma.PersonWhereInput>(q, (t) => [ const search = buildSearchWhere<Prisma.PersonWhereInput>(q, (t) => [
{ firstName: ci(t) }, { firstName: ci(t) },
{ lastName: ci(t) }, { lastName: ci(t) },
@@ -166,11 +214,14 @@ export class PeopleService implements OnModuleInit {
if (opts?.hasMobile) { if (opts?.hasMobile) {
and.push({ mobile: { not: null } }, { NOT: { mobile: '' } }); and.push({ mobile: { not: null } }, { NOT: { mobile: '' } });
} }
const disc = discFilter(opts?.disc);
if (disc) and.push(disc);
if (groupIds.length) and.push({ groupLinks: { some: { groupId: { in: groupIds } } } });
return { return {
organizationId: actor.organizationId, organizationId: actor.organizationId,
...ownerWhere(actor), ...customersPersonWhere(actor),
...(opts?.mood ? { mood: opts.mood } : {}), ...(moods.length ? { mood: { in: moods } } : {}),
...(sourceFilter ? { importSource: sourceFilter } : {}), ...(sources.length ? { importSource: { in: sources } } : {}),
...(and.length ? { AND: and } : {}), ...(and.length ? { AND: and } : {}),
}; };
} }
@@ -181,15 +232,18 @@ export class PeopleService implements OnModuleInit {
page = 1, page = 1,
limit = 20, limit = 20,
opts?: { opts?: {
mood?: PersonMood; mood?: string;
sort?: 'name' | 'newest'; sort?: 'name' | 'newest';
importSource?: string; importSource?: string;
province?: string; province?: string;
city?: string; city?: string;
hasMobile?: boolean; hasMobile?: boolean;
disc?: string;
groupId?: string;
}, },
): Promise<PaginatedResponse<PersonDto>> { ): Promise<PaginatedResponse<PersonDto>> {
const where = this.personListWhere(actor, q, opts); const seeDisc = await this.behaviorAccess.canView(actor);
const where = this.personListWhere(actor, q, seeDisc ? opts : { ...opts, disc: undefined });
const [rows, total] = await this.prisma.$transaction([ const [rows, total] = await this.prisma.$transaction([
this.prisma.person.findMany({ this.prisma.person.findMany({
@@ -206,7 +260,7 @@ export class PeopleService implements OnModuleInit {
]); ]);
return { return {
items: rows.map((row) => this.toDto(row)), items: rows.map((row) => this.toDto(row, seeDisc)),
total, total,
page, page,
limit, limit,
@@ -217,15 +271,18 @@ export class PeopleService implements OnModuleInit {
actor: Actor, actor: Actor,
q?: string, q?: string,
opts?: { opts?: {
mood?: PersonMood; mood?: string;
importSource?: string; importSource?: string;
province?: string; province?: string;
city?: string; city?: string;
hasMobile?: boolean; hasMobile?: boolean;
disc?: string;
groupId?: string;
}, },
max = FILTERED_IDS_MAX, max = FILTERED_IDS_MAX,
): Promise<FilteredIdsResponse> { ): Promise<FilteredIdsResponse> {
const where = this.personListWhere(actor, q, opts); const seeDisc = await this.behaviorAccess.canView(actor);
const where = this.personListWhere(actor, q, seeDisc ? opts : { ...opts, disc: undefined });
const total = await this.prisma.person.count({ where }); const total = await this.prisma.person.count({ where });
if (total > max) { if (total > max) {
throw new BadRequestException( throw new BadRequestException(
@@ -246,7 +303,7 @@ export class PeopleService implements OnModuleInit {
by: ['importSource'], by: ['importSource'],
where: { where: {
organizationId: actor.organizationId, organizationId: actor.organizationId,
...ownerWhere(actor), ...customersPersonWhere(actor),
}, },
_count: { _all: true }, _count: { _all: true },
}); });
@@ -259,6 +316,38 @@ export class PeopleService implements OnModuleInit {
.sort((a, b) => b.count - a.count); .sort((a, b) => b.count - a.count);
} }
/** آمار گروه برای فیلتر اشخاص — فقط افراد در دسترس کاربر */
async groupStats(actor: Actor): Promise<CustomerGroupDto[]> {
const counts = await this.prisma.personGroupLink.groupBy({
by: ['groupId'],
where: {
person: {
organizationId: actor.organizationId,
...customersPersonWhere(actor),
},
},
_count: { _all: true },
});
if (!counts.length) return [];
const countMap = new Map(counts.map((c) => [c.groupId, c._count._all]));
const groups = await this.prisma.customerGroup.findMany({
where: {
organizationId: actor.organizationId,
id: { in: counts.map((c) => c.groupId) },
},
orderBy: [{ position: 'asc' }, { name: 'asc' }],
});
return groups.map((g) => ({
id: g.id,
name: g.name,
color: g.color,
position: g.position,
fromTahesab: Boolean(g.tahesabGid),
personCount: countMap.get(g.id) ?? 0,
companyCount: 0,
}));
}
onModuleInit() { onModuleInit() {
void this.backfillStoredBirthDates(); void this.backfillStoredBirthDates();
} }
@@ -315,7 +404,10 @@ export class PeopleService implements OnModuleInit {
input: { mobile?: string | null; email?: string | null; birthDate: Date }, input: { mobile?: string | null; email?: string | null; birthDate: Date },
) { ) {
const or: Prisma.PersonWhereInput[] = []; const or: Prisma.PersonWhereInput[] = [];
if (input.mobile) or.push({ mobile: input.mobile }); const mobileKeys = phoneIdentityKeys(input.mobile);
if (mobileKeys.length) {
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
}
if (input.email) or.push({ email: input.email }); if (input.email) or.push({ email: input.email });
if (!or.length) return; if (!or.length) return;
await this.prisma.person.updateMany({ await this.prisma.person.updateMany({
@@ -326,11 +418,11 @@ export class PeopleService implements OnModuleInit {
async get(actor: Actor, id: string): Promise<PersonDto> { async get(actor: Actor, id: string): Promise<PersonDto> {
const row = await this.prisma.person.findFirst({ const row = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) }, where: { id, organizationId: actor.organizationId, ...customersPersonWhere(actor) },
include: personInclude, include: personInclude,
}); });
if (!row) throw new NotFoundException('شخص پیدا نشد'); if (!row) throw new NotFoundException('شخص پیدا نشد');
return this.toDto(row); return this.toDto(row, await this.behaviorAccess.canView(actor));
} }
async create(actor: Actor, dto: CreatePersonRequest): Promise<PersonDto> { async create(actor: Actor, dto: CreatePersonRequest): Promise<PersonDto> {
@@ -340,17 +432,25 @@ export class PeopleService implements OnModuleInit {
} }
const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null; const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null;
const named = applyPersonGenderTitle({
firstName: dto.firstName ?? '',
lastName: dto.lastName ?? '',
gender: dto.gender,
showGenderInName: dto.showGenderInName,
});
const data: Prisma.PersonUncheckedCreateInput = { const data: Prisma.PersonUncheckedCreateInput = {
organizationId: actor.organizationId, organizationId: actor.organizationId,
firstName: (dto.firstName ?? '').trim(), firstName: named.firstName,
lastName: (dto.lastName ?? '').trim(), lastName: named.lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
previousFirstName: emptyToNull(dto.previousFirstName), previousFirstName: emptyToNull(dto.previousFirstName),
previousLastName: emptyToNull(dto.previousLastName), previousLastName: emptyToNull(dto.previousLastName),
importSource: normalizePersonImportSource(dto.importSource), importSource: normalizePersonImportSource(dto.importSource),
sourceRef: emptyToNull(dto.sourceRef), sourceRef: emptyToNull(dto.sourceRef),
email: normalizeEmail(dto.email), email: normalizeEmail(dto.email),
phone: normalizePhone(dto.phone), phone: storedPhoneNumber(dto.phone),
mobile: normalizePhone(dto.mobile), mobile: storedPhoneNumber(dto.mobile),
position: emptyToNull(dto.position), position: emptyToNull(dto.position),
website: emptyToNull(dto.website), website: emptyToNull(dto.website),
instagram: emptyToNull(dto.instagram), instagram: emptyToNull(dto.instagram),
@@ -361,18 +461,24 @@ export class PeopleService implements OnModuleInit {
mood: dto.mood ?? 'newcomer', mood: dto.mood ?? 'newcomer',
tags: normalizeTags(dto.tags), tags: normalizeTags(dto.tags),
companyId, companyId,
ownerId: resolveOwnerId(actor, dto.ownerId), ownerId: resolveOwnerId(
actor,
dto.ownerId,
await actorCanAssignToOthers(this.prisma, actor, 'customers'),
),
customFields: toJsonCustomFields(dto.customFields), customFields: toJsonCustomFields(dto.customFields),
...(registeredAt && !Number.isNaN(registeredAt.getTime()) ...(registeredAt && !Number.isNaN(registeredAt.getTime())
? { createdAt: registeredAt } ? { createdAt: registeredAt }
: {}), : {}),
}; };
await this.assertNoDuplicate(actor.organizationId, data); await this.assertNoDuplicate(actor.organizationId, data);
const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds);
const row = await this.prisma.person.create({ const row = await this.prisma.person.create({
data, data,
include: personInclude, include: personInclude,
}); });
if (groupIds.length) await this.groups.addPersonGroups(row.id, groupIds);
await this.loyalty.applyWelcomePoints(actor.organizationId, row.id); await this.loyalty.applyWelcomePoints(actor.organizationId, row.id);
const createdCtx = { const createdCtx = {
organizationId: actor.organizationId, organizationId: actor.organizationId,
@@ -407,7 +513,7 @@ export class PeopleService implements OnModuleInit {
where: { id: row.id }, where: { id: row.id },
include: personInclude, include: personInclude,
}); });
return this.toDto(fresh ?? row); return this.toDto(fresh ?? row, await this.behaviorAccess.canView(actor));
} }
async update( async update(
@@ -418,8 +524,32 @@ export class PeopleService implements OnModuleInit {
await this.ensureExists(actor, id); await this.ensureExists(actor, id);
const data: Prisma.PersonUncheckedUpdateInput = {}; const data: Prisma.PersonUncheckedUpdateInput = {};
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim(); const touchesName =
if (dto.lastName !== undefined) data.lastName = dto.lastName.trim(); dto.firstName !== undefined ||
dto.lastName !== undefined ||
dto.gender !== undefined ||
dto.showGenderInName !== undefined;
if (touchesName) {
const existing = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId },
select: { firstName: true, lastName: true, gender: true, showGenderInName: true },
});
if (!existing) throw new NotFoundException('شخص پیدا نشد');
const bare = splitPersonHonorific(existing.firstName, existing.lastName);
const storedGender =
existing.gender === 'male' || existing.gender === 'female' ? existing.gender : 'none';
const named = applyPersonGenderTitle({
firstName: dto.firstName !== undefined ? dto.firstName : bare.firstName,
lastName: dto.lastName !== undefined ? dto.lastName : bare.lastName,
gender: dto.gender !== undefined ? dto.gender : storedGender,
showGenderInName:
dto.showGenderInName !== undefined ? dto.showGenderInName : existing.showGenderInName,
});
data.firstName = named.firstName;
data.lastName = named.lastName;
data.gender = named.gender;
data.showGenderInName = named.showGenderInName;
}
if ('previousFirstName' in dto) { if ('previousFirstName' in dto) {
data.previousFirstName = emptyToNull(dto.previousFirstName); data.previousFirstName = emptyToNull(dto.previousFirstName);
} }
@@ -431,8 +561,8 @@ export class PeopleService implements OnModuleInit {
} }
if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef); if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef);
if ('email' in dto) data.email = normalizeEmail(dto.email); if ('email' in dto) data.email = normalizeEmail(dto.email);
if ('phone' in dto) data.phone = normalizePhone(dto.phone); if ('phone' in dto) data.phone = storedPhoneNumber(dto.phone);
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile); if ('mobile' in dto) data.mobile = storedPhoneNumber(dto.mobile);
if ('position' in dto) data.position = emptyToNull(dto.position); if ('position' in dto) data.position = emptyToNull(dto.position);
if ('website' in dto) data.website = emptyToNull(dto.website); if ('website' in dto) data.website = emptyToNull(dto.website);
if ('instagram' in dto) data.instagram = emptyToNull(dto.instagram); if ('instagram' in dto) data.instagram = emptyToNull(dto.instagram);
@@ -456,11 +586,21 @@ export class PeopleService implements OnModuleInit {
} }
data.companyId = nextCompanyId; data.companyId = nextCompanyId;
} }
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) { if (dto.ownerId !== undefined) {
data.ownerId = resolveOwnerId(actor, dto.ownerId); const current = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId },
select: { ownerId: true },
});
const nextOwner = emptyToNull(dto.ownerId ?? null);
await assertAssigneeSet(this.prisma, actor, 'customers', [nextOwner], [current?.ownerId]);
data.ownerId = nextOwner ?? actor.userId;
} }
await this.assertNoDuplicate(actor.organizationId, data, id); await this.assertNoDuplicate(actor.organizationId, data, id);
if (dto.groupIds !== undefined) {
const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds);
await this.groups.setPersonGroups(id, groupIds);
}
const row = await this.prisma.person.update({ const row = await this.prisma.person.update({
where: { id }, where: { id },
data, data,
@@ -473,7 +613,7 @@ export class PeopleService implements OnModuleInit {
email: row.email, email: row.email,
companyId: row.companyId, companyId: row.companyId,
}); });
return this.toDto(row); return this.toDto(row, await this.behaviorAccess.canView(actor));
} }
async remove(actor: Actor, id: string): Promise<void> { async remove(actor: Actor, id: string): Promise<void> {
@@ -488,7 +628,7 @@ export class PeopleService implements OnModuleInit {
where: { where: {
id: { in: unique }, id: { in: unique },
organizationId: actor.organizationId, organizationId: actor.organizationId,
...ownerWhere(actor), ...customersPersonWhere(actor),
}, },
}); });
return { deleted: res.count }; return { deleted: res.count };
@@ -496,7 +636,7 @@ export class PeopleService implements OnModuleInit {
private async ensureExists(actor: Actor, id: string) { private async ensureExists(actor: Actor, id: string) {
const found = await this.prisma.person.findFirst({ const found = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) }, where: { id, organizationId: actor.organizationId, ...customersPersonWhere(actor) },
select: { id: true }, select: { id: true },
}); });
if (!found) throw new NotFoundException('شخص پیدا نشد'); if (!found) throw new NotFoundException('شخص پیدا نشد');
@@ -509,10 +649,13 @@ export class PeopleService implements OnModuleInit {
) { ) {
const email = typeof data.email === 'string' ? data.email : null; const email = typeof data.email === 'string' ? data.email : null;
const mobile = typeof data.mobile === 'string' ? data.mobile : null; const mobile = typeof data.mobile === 'string' ? data.mobile : null;
const mobileKeys = phoneIdentityKeys(mobile);
const or: Prisma.PersonWhereInput[] = []; const or: Prisma.PersonWhereInput[] = [];
if (email) or.push({ email }); if (email) or.push({ email });
if (mobile) or.push({ mobile }); if (mobileKeys.length) {
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
}
if (!or.length) return; if (!or.length) return;
const clash = await this.prisma.person.findFirst({ const clash = await this.prisma.person.findFirst({
@@ -634,7 +777,12 @@ export class PeopleService implements OnModuleInit {
company: person.company?.name ?? '', company: person.company?.name ?? '',
}); });
if (!message) throw new BadRequestException('متن پیامک خالی است.'); if (!message) throw new BadRequestException('متن پیامک خالی است.');
const result = await this.sms.sendSmsDetailed(mobile, message); const result = await this.sms.send({
organizationId: actor.organizationId,
phone: mobile,
text: message,
source: 'manual',
});
await this.outboundSms.record({ await this.outboundSms.record({
organizationId: actor.organizationId, organizationId: actor.organizationId,
personId: person.id, personId: person.id,
@@ -647,9 +795,11 @@ export class PeopleService implements OnModuleInit {
}); });
if (!result.ok) { if (!result.ok) {
throw new ServiceUnavailableException( throw new ServiceUnavailableException(
result.reason === 'SMS not configured' result.blocked
? 'ارسال پیامک روی سرور فعال نیست.' ? 'ارسال پیامک به‌خاطر محدودیت اعتبار مسدود است.'
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.', : result.reason === 'SMS not configured'
? 'ارسال پیامک روی سرور فعال نیست.'
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
); );
} }
return { ok: true }; return { ok: true };
@@ -670,7 +820,12 @@ export class PeopleService implements OnModuleInit {
company: person.company?.name ?? '', company: person.company?.name ?? '',
}); });
if (!message) throw new BadRequestException('متن پیامک خالی است.'); if (!message) throw new BadRequestException('متن پیامک خالی است.');
const result = await this.sms.sendSmsDetailed(mobile, message); const result = await this.sms.send({
organizationId: actor.organizationId,
phone: mobile,
text: message,
source: 'manual',
});
await this.outboundSms.record({ await this.outboundSms.record({
organizationId: actor.organizationId, organizationId: actor.organizationId,
personId: person.id, personId: person.id,
@@ -683,9 +838,11 @@ export class PeopleService implements OnModuleInit {
}); });
if (!result.ok) { if (!result.ok) {
throw new ServiceUnavailableException( throw new ServiceUnavailableException(
result.reason === 'SMS not configured' result.blocked
? 'ارسال پیامک روی سرور فعال نیست.' ? 'ارسال پیامک به‌خاطر محدودیت اعتبار مسدود است.'
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.', : result.reason === 'SMS not configured'
? 'ارسال پیامک روی سرور فعال نیست.'
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
); );
} }
return { ok: true }; return { ok: true };
@@ -697,7 +854,7 @@ export class PeopleService implements OnModuleInit {
); );
} }
private toDto(row: PersonRow): PersonDto { private toDto(row: PersonRow, includeDisc: boolean): PersonDto {
const previousFullName = `${row.previousFirstName ?? ''} ${row.previousLastName ?? ''}`.trim(); const previousFullName = `${row.previousFirstName ?? ''} ${row.previousLastName ?? ''}`.trim();
const importSource = row.importSource || 'manual'; const importSource = row.importSource || 'manual';
return { return {
@@ -712,6 +869,8 @@ export class PeopleService implements OnModuleInit {
previousImportSource: row.previousImportSource, previousImportSource: row.previousImportSource,
previousSourceRef: row.previousSourceRef, previousSourceRef: row.previousSourceRef,
fullName: `${row.firstName} ${row.lastName}`.trim(), fullName: `${row.firstName} ${row.lastName}`.trim(),
gender: row.gender === 'male' || row.gender === 'female' ? row.gender : 'none',
showGenderInName: row.showGenderInName,
email: row.email, email: row.email,
phone: row.phone, phone: row.phone,
mobile: row.mobile, mobile: row.mobile,
@@ -734,8 +893,16 @@ export class PeopleService implements OnModuleInit {
mood: row.mood, mood: row.mood,
tags: row.tags ?? [], tags: row.tags ?? [],
customFields: asCustomFields(row.customFields), customFields: asCustomFields(row.customFields),
groups: (row.groupLinks ?? []).map((l) => ({
...l.group,
source: l.source === 'tahesab' ? ('tahesab' as const) : ('manual' as const),
})),
loyaltyPoints: row.loyaltyPoints ?? 0, loyaltyPoints: row.loyaltyPoints ?? 0,
loyaltyTier: row.loyaltyTier, loyaltyTier: row.loyaltyTier,
discLabel: includeDisc && row.discReliable ? row.discLabel : null,
discReliable: includeDisc ? row.discReliable : false,
discAssessedAt:
includeDisc && row.discAssessedAt ? row.discAssessedAt.toISOString() : null,
company: row.company, company: row.company,
owner: row.owner, owner: row.owner,
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
@@ -0,0 +1,85 @@
import { Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { mergeSameMobilePeople } from './merge-same-mobile';
const FIX_KEY = 'person-mobile-canonical-v3';
@Injectable()
export class SameMobileMergeService implements OnApplicationBootstrap {
private readonly logger = new Logger(SameMobileMergeService.name);
constructor(private readonly prisma: PrismaService) {}
onApplicationBootstrap() {
if (process.env.NODE_ENV === 'test') return;
void this.run();
}
private async run() {
try {
const ready = await this.prisma.$queryRaw<{ ok: number }[]>`
SELECT 1 AS ok
FROM information_schema.columns
WHERE table_schema = 'public'
AND lower(table_name) = 'person'
AND lower(column_name) = 'gender'
`;
if (!ready.length) {
this.logger.warn('یکسان‌سازی موبایل رد شد: ستون gender هنوز نیست');
return;
}
await this.prisma.$executeRaw`
CREATE TABLE IF NOT EXISTS "DataFix" (
"key" TEXT PRIMARY KEY,
"doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`;
const done = await this.prisma.$queryRaw<{ key: string }[]>`
SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY}
`;
if (done.length) return;
await this.prisma.$executeRaw`SELECT pg_advisory_lock(86051205)`;
try {
const again = await this.prisma.$queryRaw<{ key: string }[]>`
SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY}
`;
if (again.length) return;
const orgs = await this.prisma.organization.findMany({ select: { id: true } });
let merged = 0;
let rewritten = 0;
let failed = 0;
for (const org of orgs) {
try {
const result = await mergeSameMobilePeople(this.prisma, org.id, true);
merged += result.merged;
rewritten += result.rewritten;
failed += result.failed;
} catch (error) {
failed += 1;
this.logger.error(
`یکسان‌سازی موبایل برای محیط ${org.id} انجام نشد`,
error instanceof Error ? error.message : String(error),
);
}
}
if (failed) return;
await this.prisma.$executeRaw`
INSERT INTO "DataFix" ("key") VALUES (${FIX_KEY})
ON CONFLICT ("key") DO NOTHING
`;
this.logger.log(`شماره‌های تکراری یکی شد: ${merged} ادغام، ${rewritten} بازنویسی`);
} finally {
await this.prisma.$executeRaw`SELECT pg_advisory_unlock(86051205)`;
}
} catch (error) {
this.logger.error(
'یکسان‌سازی موبایل مشتری‌ها انجام نشد',
error instanceof Error ? error.stack : String(error),
);
}
}
}
@@ -4,6 +4,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
import { RequireOrgModule } from '../billing/org-module.decorator'; import { RequireOrgModule } from '../billing/org-module.decorator';
import { OrgModuleGuard } from '../billing/org-module.guard'; import { OrgModuleGuard } from '../billing/org-module.guard';
import { import {
ConversationFileBytesDto,
CreateConversationDmDto, CreateConversationDmDto,
CreateConversationGroupDto, CreateConversationGroupDto,
CreateConversationChannelDto, CreateConversationChannelDto,
@@ -19,12 +20,34 @@ import {
ToggleMessageReactionDto, ToggleMessageReactionDto,
} from './dto'; } from './dto';
import { ConversationsService } from './conversations.service'; import { ConversationsService } from './conversations.service';
import { StorageQuotaService } from '../billing/storage-quota.service';
@Controller('conversations') @Controller('conversations')
@UseGuards(JwtAuthGuard, OrgModuleGuard) @UseGuards(JwtAuthGuard, OrgModuleGuard)
@RequireOrgModule('conversations') @RequireOrgModule('conversations')
export class ConversationsController { export class ConversationsController {
constructor(private readonly conversations: ConversationsService) {} constructor(
private readonly conversations: ConversationsService,
private readonly storage: StorageQuotaService,
) {}
@Post('files/reserve')
reserveFile(
@CurrentUser() user: { organizationId: string },
@Body() dto: ConversationFileBytesDto,
) {
return this.storage.assertCanAdd(user.organizationId, dto.bytes).then(() =>
this.storage.usageForOrganization(user.organizationId),
);
}
@Post('files/commit')
commitFile(
@CurrentUser() user: { organizationId: string },
@Body() dto: ConversationFileBytesDto,
) {
return this.storage.addChat(user.organizationId, dto.bytes);
}
@Get('access') @Get('access')
access(@CurrentUser() user: { userId: string; organizationId: string; role: string }) { access(@CurrentUser() user: { userId: string; organizationId: string; role: string }) {
@@ -656,7 +656,7 @@ export class ConversationsService {
}); });
if (!row) { if (!row) {
throw new BadRequestException( throw new BadRequestException(
'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → محیط‌ها → تنظیمات گفتگو پیکربندی کنید.', 'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → گفتگو پیکربندی کنید.',
); );
} }
try { try {
+12
View File
@@ -1,9 +1,13 @@
import { Type } from 'class-transformer';
import { import {
IsArray, IsArray,
IsBoolean, IsBoolean,
IsInt,
IsOptional, IsOptional,
IsString, IsString,
Max,
MaxLength, MaxLength,
Min,
MinLength, MinLength,
ArrayMinSize, ArrayMinSize,
ArrayMaxSize, ArrayMaxSize,
@@ -32,6 +36,14 @@ export class UpdateConversationSettingsDto {
downloadUrl?: string; downloadUrl?: string;
} }
export class ConversationFileBytesDto {
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100 * 1024 * 1024)
bytes!: number;
}
export class CreateConversationDmDto { export class CreateConversationDmDto {
@IsString() @IsString()
@MinLength(1) @MinLength(1)
+1
View File
@@ -15,6 +15,7 @@ export enum CustomFieldEntityDto {
person = 'person', person = 'person',
company = 'company', company = 'company',
deal = 'deal', deal = 'deal',
note = 'note',
} }
export enum CustomFieldTypeDto { export enum CustomFieldTypeDto {
@@ -0,0 +1,80 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import { CUSTOMER_GROUP_COLORS, type CustomerGroupColor } from '@mento/shared';
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 { CustomerGroupsService } from './customer-groups.service';
class CreateCustomerGroupDto {
@IsString()
@MinLength(1)
@MaxLength(80)
name!: string;
@IsOptional()
@IsIn([...CUSTOMER_GROUP_COLORS])
color?: CustomerGroupColor;
}
class UpdateCustomerGroupDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(80)
name?: string;
@IsOptional()
@IsIn([...CUSTOMER_GROUP_COLORS])
color?: CustomerGroupColor;
/** null گروه را اصلی می‌کند */
@IsOptional()
@IsString()
parentId?: string | null;
}
@Controller('customer-groups')
@UseGuards(JwtAuthGuard, RolesGuard)
export class CustomerGroupsController {
constructor(private readonly groups: CustomerGroupsService) {}
@Get()
list(@CurrentUser() user: Actor) {
return this.groups.list(user.organizationId);
}
@Post()
@Roles('owner', 'admin')
create(@CurrentUser() user: Actor, @Body() dto: CreateCustomerGroupDto) {
return this.groups.create(user.organizationId, dto);
}
@Patch(':id')
@Roles('owner', 'admin')
update(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateCustomerGroupDto,
) {
return this.groups.update(user.organizationId, id, dto);
}
@Delete(':id')
@Roles('owner', 'admin')
async remove(@CurrentUser() user: Actor, @Param('id') id: string) {
await this.groups.remove(user.organizationId, id);
return { ok: true };
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { CustomerGroupsController } from './customer-groups.controller';
import { CustomerGroupsService } from './customer-groups.service';
@Module({
imports: [AuthModule],
controllers: [CustomerGroupsController],
providers: [CustomerGroupsService],
exports: [CustomerGroupsService],
})
export class CustomerGroupsModule {}
@@ -0,0 +1,346 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { CustomerGroupColor, CustomerGroupDto } from '@mento/shared';
import { CUSTOMER_GROUP_COLORS } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
const NAME_MAX = 80;
/** نام گروه برای مقایسه: فاصله‌های اضافه و ی/ک عربی یکسان می‌شوند */
function normalizeGroupName(raw: string): string {
return raw
.replace(/\u064A/g, '\u06CC')
.replace(/\u0643/g, '\u06A9')
.replace(/\s+/g, ' ')
.trim()
.slice(0, NAME_MAX);
}
/** «الف، ب / ج» → ['الف', 'ب', 'ج'] */
export function splitGroupNames(raw: string | null | undefined): string[] {
if (!raw) return [];
return [
...new Set(
raw
.split(/\s*[،,/]\s*/)
.map(normalizeGroupName)
.filter(Boolean),
),
];
}
export const groupSummarySelect = {
id: true,
name: true,
color: true,
} satisfies Prisma.CustomerGroupSelect;
@Injectable()
export class CustomerGroupsService {
constructor(private readonly prisma: PrismaService) {}
async list(organizationId: string): Promise<CustomerGroupDto[]> {
const rows = await this.prisma.customerGroup.findMany({
where: { organizationId },
orderBy: [{ position: 'asc' }, { name: 'asc' }],
include: {
_count: { select: { people: true, companies: true } },
parent: { select: { name: true } },
},
});
return rows.map((r) => ({
id: r.id,
name: r.name,
color: r.color,
position: r.position,
fromTahesab: Boolean(r.tahesabGid),
personCount: r._count.people,
companyCount: r._count.companies,
parentId: r.parentId,
parentName: r.parent?.name ?? null,
}));
}
async create(
organizationId: string,
dto: { name: string; color?: CustomerGroupColor },
): Promise<CustomerGroupDto> {
const name = this.cleanName(dto.name);
const max = await this.prisma.customerGroup.aggregate({
where: { organizationId },
_max: { position: true },
});
try {
await this.prisma.customerGroup.create({
data: {
organizationId,
name,
color: this.cleanColor(dto.color),
position: (max._max.position ?? -1) + 1,
},
});
} catch (e) {
throw this.mapError(e);
}
return (await this.list(organizationId)).find((g) => g.name === name)!;
}
async update(
organizationId: string,
id: string,
dto: { name?: string; color?: CustomerGroupColor; parentId?: string | null },
): Promise<CustomerGroupDto> {
await this.require(organizationId, id);
const parentId =
dto.parentId === undefined ? undefined : dto.parentId?.trim() || null;
if (parentId !== undefined) {
await this.assertParent(organizationId, id, parentId);
}
try {
await this.prisma.customerGroup.update({
where: { id },
data: {
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
...(parentId !== undefined ? { parentId } : {}),
},
});
} catch (e) {
throw this.mapError(e);
}
return (await this.list(organizationId)).find((g) => g.id === id)!;
}
/** فقط یک سطح: گروه اصلی خودش زیرگروه نیست و گروه فرعی زیرگروه ندارد. */
private async assertParent(
organizationId: string,
id: string,
parentId: string | null,
) {
if (parentId == null) return;
if (parentId === id) {
throw new BadRequestException('گروه نمی‌تواند زیرمجموعهٔ خودش باشد.');
}
const parent = await this.prisma.customerGroup.findFirst({
where: { id: parentId, organizationId },
select: { id: true, parentId: true },
});
if (!parent) throw new NotFoundException('گروه اصلی پیدا نشد.');
if (parent.parentId) {
throw new BadRequestException('فقط یک سطح گروه اصلی و فرعی ممکن است.');
}
const childCount = await this.prisma.customerGroup.count({
where: { organizationId, parentId: id },
});
if (childCount > 0) {
throw new BadRequestException('این گروه خودش زیرگروه دارد و نمی‌تواند فرعی شود.');
}
}
/** مشتری‌ها حذف نمی‌شوند؛ فقط این گروه از رویشان برداشته می‌شود */
async remove(organizationId: string, id: string): Promise<void> {
await this.require(organizationId, id);
await this.prisma.customerGroup.delete({ where: { id } });
}
/** شناسه‌های معتبر همین محیط (بقیه نادیده گرفته می‌شوند) */
async validIds(organizationId: string, ids: string[] | undefined): Promise<string[]> {
const unique = [...new Set((ids ?? []).map((id) => id?.trim()).filter(Boolean))];
if (!unique.length) return [];
const rows = await this.prisma.customerGroup.findMany({
where: { organizationId, id: { in: unique } },
select: { id: true },
});
if (rows.length !== unique.length) {
throw new BadRequestException('یکی از گروه‌های انتخاب‌شده معتبر نیست');
}
return rows.map((r) => r.id);
}
/** گروه‌ها با نام (برای اکسل)؛ نبود → ساخته می‌شود */
async idsForNames(organizationId: string, names: string[]): Promise<string[]> {
const clean = [...new Set(names.map(normalizeGroupName).filter(Boolean))];
if (!clean.length) return [];
const ids: string[] = [];
for (const name of clean) {
ids.push((await this.findOrCreateByName(organizationId, name)).id);
}
return ids;
}
private async findOrCreateByName(organizationId: string, name: string) {
const existing = await this.prisma.customerGroup.findFirst({
where: { organizationId, name: { equals: name, mode: 'insensitive' } },
select: { id: true, tahesabGid: true },
});
if (existing) return existing;
const max = await this.prisma.customerGroup.aggregate({
where: { organizationId },
_max: { position: true },
});
try {
return await this.prisma.customerGroup.create({
data: { organizationId, name, position: (max._max.position ?? -1) + 1 },
select: { id: true, tahesabGid: true },
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return this.prisma.customerGroup.findFirstOrThrow({
where: { organizationId, name },
select: { id: true, tahesabGid: true },
});
}
throw e;
}
}
/**
* گروه متناظر یک گروه ته‌حساب: اول با GID روی همان اتصال، بعد با نام.
* با تغییر نام در ته‌حساب گروه تکراری ساخته نمی‌شود.
*/
async ensureTahesabGroup(
organizationId: string,
connectionId: string,
gid: string | null,
rawName: string,
): Promise<string | null> {
const name = normalizeGroupName(rawName);
const cleanGid = gid?.trim() || null;
if (!name && !cleanGid) return null;
if (cleanGid) {
const byGid = await this.prisma.customerGroup.findFirst({
where: { organizationId, tahesabConnectionId: connectionId, tahesabGid: cleanGid },
select: { id: true, name: true },
});
if (byGid) {
if (name && byGid.name !== name) {
await this.prisma.customerGroup
.update({ where: { id: byGid.id }, data: { name } })
.catch(() => undefined); // نام در منتکت تکراری است؛ نام فعلی می‌ماند
}
return byGid.id;
}
}
if (!name) return null;
const group = await this.findOrCreateByName(organizationId, name);
if (cleanGid && !group.tahesabGid) {
await this.prisma.customerGroup.update({
where: { id: group.id },
data: { tahesabConnectionId: connectionId, tahesabGid: cleanGid },
});
}
return group.id;
}
/** گروه‌های دستی شخص (جایگزین کامل). منبع لینک‌هایی که می‌مانند حفظ می‌شود. */
async setPersonGroups(personId: string, groupIds: string[]): Promise<void> {
await this.prisma.$transaction([
this.prisma.personGroupLink.deleteMany({
where: { personId, groupId: { notIn: groupIds } },
}),
this.prisma.personGroupLink.createMany({
data: groupIds.map((groupId) => ({ personId, groupId, source: 'manual' })),
skipDuplicates: true,
}),
]);
}
async setCompanyGroups(companyId: string, groupIds: string[]): Promise<void> {
await this.prisma.$transaction([
this.prisma.companyGroupLink.deleteMany({
where: { companyId, groupId: { notIn: groupIds } },
}),
this.prisma.companyGroupLink.createMany({
data: groupIds.map((groupId) => ({ companyId, groupId })),
skipDuplicates: true,
}),
]);
}
/** فقط اضافه می‌کند (ایمپورت اکسل) */
async addPersonGroups(personId: string, groupIds: string[]): Promise<void> {
if (!groupIds.length) return;
await this.prisma.personGroupLink.createMany({
data: groupIds.map((groupId) => ({ personId, groupId, source: 'manual' })),
skipDuplicates: true,
});
}
async addCompanyGroups(companyId: string, groupIds: string[]): Promise<void> {
if (!groupIds.length) return;
await this.prisma.companyGroupLink.createMany({
data: groupIds.map((groupId) => ({ companyId, groupId })),
skipDuplicates: true,
});
}
/**
* گروهِ آمده از ته‌حساب: گروه ته‌حسابی قبلی برداشته و این یکی گذاشته می‌شود.
* گروه‌های دستی دست نمی‌خورند. اگر همین گروه از قبل دستی بوده، دستی می‌ماند.
* @returns آیا چیزی عوض شد
*/
async setPersonTahesabGroup(personId: string, groupId: string | null): Promise<boolean> {
const current = await this.prisma.personGroupLink.findMany({
where: { personId },
select: { groupId: true, source: true },
});
const staleTahesab = current.filter((l) => l.source === 'tahesab' && l.groupId !== groupId);
const alreadyLinked = groupId ? current.some((l) => l.groupId === groupId) : true;
if (!staleTahesab.length && alreadyLinked) return false;
await this.prisma.$transaction([
...(staleTahesab.length
? [
this.prisma.personGroupLink.deleteMany({
where: { personId, groupId: { in: staleTahesab.map((l) => l.groupId) } },
}),
]
: []),
...(groupId && !alreadyLinked
? [this.prisma.personGroupLink.create({ data: { personId, groupId, source: 'tahesab' } })]
: []),
]);
return true;
}
/** گروهی که موقع «ثبت در ته‌حساب» فرستاده می‌شود: اولین گروه (به ترتیب گروه‌ها) */
async firstGroupName(personId: string): Promise<string | null> {
const link = await this.prisma.personGroupLink.findFirst({
where: { personId },
orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }],
select: { group: { select: { name: true } } },
});
return link?.group.name ?? null;
}
private async require(organizationId: string, id: string) {
const row = await this.prisma.customerGroup.findFirst({
where: { id, organizationId },
select: { id: true },
});
if (!row) throw new NotFoundException('گروه پیدا نشد');
}
private cleanName(raw: string): string {
const name = normalizeGroupName(raw ?? '');
if (!name) throw new BadRequestException('نام گروه لازم است');
return name;
}
private cleanColor(raw?: string): CustomerGroupColor {
return (CUSTOMER_GROUP_COLORS as readonly string[]).includes(raw ?? '')
? (raw as CustomerGroupColor)
: 'teal';
}
private mapError(e: unknown) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return new ConflictException('گروهی با این نام وجود دارد');
}
return e;
}
}

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