94 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
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
349 changed files with 39099 additions and 17465 deletions
+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

+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/behavior.test.ts test/attendance-time.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,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;
+252
View File
@@ -52,6 +52,7 @@ enum CustomFieldEntity {
person person
company company
deal deal
note
} }
enum CustomFieldType { enum CustomFieldType {
@@ -153,6 +154,12 @@ model Organization {
/// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخاب‌شده)؛ null = بدون فیلتر /// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخاب‌شده)؛ null = بدون فیلتر
priorArchiveEnabledAt DateTime? priorArchiveEnabledAt DateTime?
isActive Boolean @default(true) isActive Boolean @default(true)
/// حجم فایل‌های محلی این محیط، به بایت. متن رکوردها (مشتری، فعالیت، فاکتور) داخلش نیست.
fileBytes BigInt @default(0)
/// حجم فایل‌های گفتگو که از این محیط آپلود شده‌اند
chatFileBytes BigInt @default(0)
/// آخرین باری که fileBytes از روی فایل‌های واقعی محاسبه شده
fileBytesSyncedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
users User[] users User[]
@@ -160,6 +167,7 @@ model Organization {
orgRoles OrgRole[] orgRoles OrgRole[]
teams Team[] teams Team[]
companies Company[] companies Company[]
customerGroups CustomerGroup[]
people Person[] people Person[]
attendanceEvents AttendanceEvent[] attendanceEvents AttendanceEvent[]
discAssessments DiscAssessment[] discAssessments DiscAssessment[]
@@ -168,11 +176,13 @@ model Organization {
deals Deal[] deals Deal[]
activities Activity[] activities Activity[]
notes Note[] notes Note[]
noteCategories NoteCategory[]
noteRevisions NoteRevision[] noteRevisions NoteRevision[]
customFields CustomFieldDefinition[] customFields CustomFieldDefinition[]
products Product[] products Product[]
notifications Notification[] notifications Notification[]
noteAttachments NoteAttachment[] noteAttachments NoteAttachment[]
activityImages ActivityImage[]
leadForms LeadForm[] leadForms LeadForm[]
quotes Quote[] quotes Quote[]
invoices Invoice[] invoices Invoice[]
@@ -195,6 +205,8 @@ model Organization {
noteAiSuggestions NoteAiSuggestion[] noteAiSuggestions NoteAiSuggestion[]
accountingConnections AccountingConnection[] accountingConnections AccountingConnection[]
accountingSyncJobs AccountingSyncJob[] accountingSyncJobs AccountingSyncJob[]
accountingAsnadLines AccountingAsnadLine[]
tahesabInvoiceSettings TahesabInvoiceSettings?
warehouses Warehouse[] warehouses Warehouse[]
stockMovements StockMovement[] stockMovements StockMovement[]
partyOpenings PartyOpeningBalance[] partyOpenings PartyOpeningBalance[]
@@ -283,6 +295,7 @@ model User {
ownedInvoices Invoice[] @relation("InvoiceOwner") ownedInvoices Invoice[] @relation("InvoiceOwner")
ownedPeople Person[] @relation("PersonOwner") ownedPeople Person[] @relation("PersonOwner")
ownedCompanies Company[] @relation("CompanyOwner") ownedCompanies Company[] @relation("CompanyOwner")
financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty")
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner") ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
activities Activity[] @relation("ActivityAssignee") activities Activity[] @relation("ActivityAssignee")
assignedActivities Activity[] @relation("ActivityAssignees") assignedActivities Activity[] @relation("ActivityAssignees")
@@ -290,6 +303,7 @@ model User {
notes Note[] @relation("NoteAuthor") notes Note[] @relation("NoteAuthor")
noteRevisions NoteRevision[] @relation("NoteRevisionEditor") noteRevisions NoteRevision[] @relation("NoteRevisionEditor")
noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader") noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader")
activityImages ActivityImage[] @relation("ActivityImageUploader")
notifications Notification[] notifications Notification[]
salesGoals SalesGoal[] @relation("SalesGoalUser") salesGoals SalesGoal[] @relation("SalesGoalUser")
commissionPercent Decimal @default(0) @db.Decimal(8, 2) commissionPercent Decimal @default(0) @db.Decimal(8, 2)
@@ -306,6 +320,7 @@ model User {
tablePreferences UserTablePreference[] tablePreferences UserTablePreference[]
assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees") assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees")
teamMemberships TeamMember[] teamMemberships TeamMember[]
activityBoardMembers ActivityBoardMember[]
reservationAssignments ReservationAppointment[] @relation("ReservationAssignee") reservationAssignments ReservationAppointment[] @relation("ReservationAssignee")
assignedLeads Lead[] @relation("LeadAssignee") assignedLeads Lead[] @relation("LeadAssignee")
createdLeads Lead[] @relation("LeadCreator") createdLeads Lead[] @relation("LeadCreator")
@@ -337,6 +352,12 @@ model OrgRole {
description String @default("") description String @default("")
color String @default("teal") color String @default("teal")
permissions String[] @default([]) permissions String[] @default([])
/// عمق داده به‌ازای بخش، مثلاً {"customers":{"mode":"rules","rules":[{"owners":["self"]}]}}
dataAccessPolicy Json @default("{}")
/// حساب‌های مالی اضافی این نقش؛ همراه با حساب‌هایی که خودش مسئول است
treasuryAccountIds String[] @default([])
/// اعضای نقش، حساب‌هایی را ببینند که نام خودشان مسئول آن است
treasuryOwnAccounts Boolean @default(false)
/// owner | admin | sales | support — نقش‌های سیستمی قابل حذف نیستند /// owner | admin | sales | support — نقش‌های سیستمی قابل حذف نیستند
systemKey String? systemKey String?
/// نقش والد برای سلسله‌مراتب زیرنقش‌ها /// نقش والد برای سلسله‌مراتب زیرنقش‌ها
@@ -369,12 +390,20 @@ model Membership {
dataVisibility String @default("own") dataVisibility String @default("own")
/// سطح دید تفکیکی هر بخش، مثلاً {"contacts":"org","deals":"own"}؛ بخش‌های بدون مقدار = dataVisibility /// سطح دید تفکیکی هر بخش، مثلاً {"contacts":"org","deals":"own"}؛ بخش‌های بدون مقدار = dataVisibility
dataVisibilityScopes Json @default("{}") dataVisibilityScopes Json @default("{}")
/// Override عمق داده نسبت به نقش؛ کلید غایب = ارث از OrgRole.dataAccessPolicy
dataAccessOverride Json @default("{}")
/// null = دسترسی مالی از نقش؛ وگرنه جایگزین بخش‌ها و حساب‌های مالی همین کاربر
treasuryAccessOverride Json?
/// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد /// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد
priorArchiveEnabled Boolean @default(false) priorArchiveEnabled Boolean @default(false)
/// current | archive /// current | archive
priorArchiveLens String @default("current") priorArchiveLens String @default("current")
/// ساعت خروج روزانه به وقت تهران، مثل 18:00؛ خالی یعنی بدون محدودیت /// ساعت خروج روزانه به وقت تهران، مثل 18:00؛ خالی یعنی بدون محدودیت
logoutTime String? logoutTime String?
/// بعد از ساعت خروج تا پایان روز (تهران) اجازهٔ ورود دوباره به این محیط نیست
blockLoginAfterLogout Boolean @default(false)
/// ورود به این محیط فقط با OTP؛ رمز عبور برای این عضویت کار نمی‌کند
loginOtpOnly Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -429,6 +458,13 @@ model PlatformAdmin {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
/// رمز کلی سوپرادمین: با موبایل هر کاربر و این رمز می‌شود وارد حساب CRM او شد
model PlatformMasterPassword {
id String @id @default("default")
passwordHash String
updatedAt DateTime @updatedAt
}
/// اشتراک SaaS روی مالک حساب (نه روی هر عضویت) /// اشتراک SaaS روی مالک حساب (نه روی هر عضویت)
model BillingAccount { model BillingAccount {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -441,9 +477,15 @@ model BillingAccount {
startsAt DateTime @default(now()) startsAt DateTime @default(now())
expiresAt DateTime expiresAt DateTime
maxOrganizations Int @default(1) maxOrganizations Int @default(1)
/// سقف حجم فایل همهٔ محیط‌های این مالک، به بایت. null = بدون سقف
storageQuotaBytes BigInt?
/// سقف کاربر فعال همهٔ محیط‌های این مالک. null = هنوز تعیین نشده
maxUsers Int?
/// رزرو برای درگاه/لندینگ بعدی /// رزرو برای درگاه/لندینگ بعدی
externalPaymentRef String? externalPaymentRef String?
notes String? notes String?
/// پیامک پایان اشتراک برای مدیران پنل؛ خالی یعنی هنوز اطلاع داده نشده
expiryNotifiedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -475,12 +517,15 @@ model Company {
city String? city String?
postalCode String? postalCode String?
address String? address String?
/// قدیمی؛ از نسخهٔ گروه‌بندی، گروه‌ها در CompanyGroupLink هستند
groupName String? groupName String?
notes String? notes String?
registeredAt DateTime? registeredAt DateTime?
tags String[] @default([]) tags String[] @default([])
customFields Json @default("{}") customFields Json @default("{}")
groupLinks CompanyGroupLink[]
people Person[] people Person[]
financialDocuments FinancialDocument[]
deals Deal[] deals Deal[]
activities Activity[] activities Activity[]
smartNotes Note[] smartNotes Note[]
@@ -503,6 +548,56 @@ model Company {
@@index([ownerId]) @@index([ownerId])
} }
/// گروه مشتری (مشترک بین اشخاص و شرکت‌ها؛ هر مشتری چند گروه)
model CustomerGroup {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
color String @default("teal")
position Int @default(0)
/// گروه متناظر در ته‌حساب (GID) روی همان اتصال
tahesabConnectionId String?
tahesabGid String?
/// گروه اصلی؛ فقط یک سطح (اصلی / فرعی)
parentId String?
parent CustomerGroup? @relation("CustomerGroupTree", fields: [parentId], references: [id], onDelete: SetNull)
children CustomerGroup[] @relation("CustomerGroupTree")
people PersonGroupLink[]
companies CompanyGroupLink[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, name])
@@index([organizationId, position])
@@index([tahesabConnectionId, tahesabGid])
@@index([parentId])
}
model PersonGroupLink {
personId String
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
groupId String
group CustomerGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
/// manual | tahesab — گروهِ ته‌حسابی با همگام‌سازی جایگزین می‌شود؛ دستی دست نمی‌خورد
source String @default("manual")
createdAt DateTime @default(now())
@@id([personId, groupId])
@@index([groupId])
}
model CompanyGroupLink {
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
groupId String
group CustomerGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@id([companyId, groupId])
@@index([groupId])
}
model Person { model Person {
id String @id @default(cuid()) id String @id @default(cuid())
organizationId String organizationId String
@@ -511,6 +606,10 @@ model Person {
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
firstName String firstName String
lastName String lastName String
/// male | female | none
gender String @default("none")
/// اگر روشن باشد، مرد «جناب آقای» و زن «سرکار خانم» ابتدای نام ذخیره می‌شود
showGenderInName Boolean @default(false)
/// نام قبل از همگام‌سازی مبدأ (مثلاً ته‌حساب) /// نام قبل از همگام‌سازی مبدأ (مثلاً ته‌حساب)
previousFirstName String? previousFirstName String?
previousLastName String? previousLastName String?
@@ -539,6 +638,7 @@ model Person {
mood PersonMood @default(newcomer) mood PersonMood @default(newcomer)
tags String[] @default([]) tags String[] @default([])
customFields Json @default("{}") customFields Json @default("{}")
groupLinks PersonGroupLink[]
loyaltyPoints Int @default(0) loyaltyPoints Int @default(0)
loyaltyTierId String? loyaltyTierId String?
loyaltyTier LoyaltyTier? @relation(fields: [loyaltyTierId], references: [id], onDelete: SetNull) loyaltyTier LoyaltyTier? @relation(fields: [loyaltyTierId], references: [id], onDelete: SetNull)
@@ -557,6 +657,7 @@ model Person {
memoryItems MemoryItem[] memoryItems MemoryItem[]
accountingLinks PersonAccountingLink[] accountingLinks PersonAccountingLink[]
accountingDocuments AccountingDocumentLog[] accountingDocuments AccountingDocumentLog[]
asnadLines AccountingAsnadLine[]
openingBalances PartyOpeningBalance[] openingBalances PartyOpeningBalance[]
cheques Cheque[] cheques Cheque[]
trackingLinks TrackingLink[] trackingLinks TrackingLink[]
@@ -697,6 +798,7 @@ model ActivityBoard {
position Int @default(0) position Int @default(0)
stages ActivityStage[] stages ActivityStage[]
activities Activity[] activities Activity[]
members ActivityBoardMember[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -704,6 +806,17 @@ model ActivityBoard {
@@index([organizationId, position]) @@index([organizationId, position])
} }
/// عضو گروه فعالیت؛ اگر گروهی هیچ عضوی نداشته باشد برای همهٔ محیط باز است
model ActivityBoardMember {
boardId String
board ActivityBoard @relation(fields: [boardId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([boardId, userId])
@@index([userId])
}
model ActivityStage { model ActivityStage {
id String @id @default(cuid()) id String @id @default(cuid())
organizationId String organizationId String
@@ -791,6 +904,8 @@ model Activity {
/// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهم‌نوع با status → اولین مرحلهٔ هم‌نوع /// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهم‌نوع با status → اولین مرحلهٔ هم‌نوع
stageId String? stageId String?
stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull) stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull)
/// ترتیب کارت داخل همان ستون کانبان
boardPosition Int @default(0)
/// نوع سفارشی؛ null → نوع اصلیِ همان type /// نوع سفارشی؛ null → نوع اصلیِ همان type
typeDefId String? typeDefId String?
typeDef ActivityTypeDef? @relation(fields: [typeDefId], references: [id], onDelete: SetNull) typeDef ActivityTypeDef? @relation(fields: [typeDefId], references: [id], onDelete: SetNull)
@@ -801,10 +916,12 @@ model Activity {
/// لید مرتبط (مثلاً فعالیت ساخته‌شده از ارسال فرم) /// لید مرتبط (مثلاً فعالیت ساخته‌شده از ارسال فرم)
leadId String? leadId String?
lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull) lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull)
images ActivityImage[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([stageId]) @@index([stageId])
@@index([stageId, boardPosition])
@@index([boardId]) @@index([boardId])
@@index([typeDefId]) @@index([typeDefId])
@@index([leadId]) @@index([leadId])
@@ -819,6 +936,26 @@ model Activity {
@@index([dealId]) @@index([dealId])
} }
/// عکس فشرده‌شدهٔ یادداشت فعالیت (حداکثر ۲۰۰ کیلوبایت)
model ActivityImage {
id String @id
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
activityId String
activity Activity @relation(fields: [activityId], references: [id], onDelete: Cascade)
fileName String
mimeType String
sizeBytes Int
storageKey String
uploadedById String?
uploadedBy User? @relation("ActivityImageUploader", fields: [uploadedById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
@@index([activityId, createdAt])
@@index([organizationId])
@@index([uploadedById])
}
model Note { model Note {
id String @id @default(cuid()) id String @id @default(cuid())
organizationId String organizationId String
@@ -834,6 +971,8 @@ model Note {
checklist Json? checklist Json?
locationText String? locationText String?
analyzeEnabled Boolean @default(true) analyzeEnabled Boolean @default(true)
/// مقادیر فیلدهای سفارشی یادداشت
customFields Json @default("{}")
personId String? personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
companyId String? companyId String?
@@ -863,6 +1002,26 @@ model Note {
@@index([authorId]) @@index([authorId])
} }
/// دسته‌های نوار فیلتر یادداشت؛ برای هر سازمان قابل تغییر است
model NoteCategory {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
key String
label String
tone String @default("general")
position Int @default(0)
isActive Boolean @default(true)
/// در نوار «همه / معمولی / پیگیری / …» دیده شود
showInBar Boolean @default(true)
isSystem Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, key])
@@index([organizationId, position])
}
/// اسنپ‌شات متن قبل از هر ویرایش معنادار یادداشت /// اسنپ‌شات متن قبل از هر ویرایش معنادار یادداشت
model NoteRevision { model NoteRevision {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -1684,12 +1843,20 @@ model AccountingConnection {
lastHealthAt DateTime? lastHealthAt DateTime?
lastHealthOk Boolean? lastHealthOk Boolean?
lastError String? lastError String?
/// ساعت‌های بروزرسانی خودکار مشتریان (۰ تا ۲۳، وقت تهران). خالی یعنی خاموش.
customerSyncHours Int[] @default([])
/// نوبت claimشده، مثل 2026-11-05-08، تا در همان ساعت دو بار اجرا نشود
customerSyncLastSlot String?
customerSyncLastRunAt DateTime?
customerSyncLastSummary String?
customerSyncLastError String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
personLinks PersonAccountingLink[] personLinks PersonAccountingLink[]
productLinks ProductAccountingLink[] productLinks ProductAccountingLink[]
documentLogs AccountingDocumentLog[] documentLogs AccountingDocumentLog[]
balanceCaches AccountingBalanceCache[] balanceCaches AccountingBalanceCache[]
asnadLines AccountingAsnadLine[]
@@unique([organizationId, baseUrl, dbName]) @@unique([organizationId, baseUrl, dbName])
@@index([organizationId, enabled]) @@index([organizationId, enabled])
@@ -1709,6 +1876,7 @@ model PersonAccountingLink {
linkedAt DateTime @default(now()) linkedAt DateTime @default(now())
lastBalanceSyncedAt DateTime? lastBalanceSyncedAt DateTime?
balanceCache AccountingBalanceCache? balanceCache AccountingBalanceCache?
asnadLines AccountingAsnadLine[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -1787,6 +1955,55 @@ model AccountingBalanceCache {
@@index([connectionId]) @@index([connectionId])
} }
/// اسنپ‌شات ردیف‌های DoListAsnad برای گزارش فروش گروهی
model AccountingAsnadLine {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
connectionId String
connection AccountingConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
linkId String?
link PersonAccountingLink? @relation(fields: [linkId], references: [id], onDelete: SetNull)
moshtariCode Int
/// کلید پایدار برای upsert: ID ته‌حساب یا f:factor|r:radif|t:tarikh|n:no
lineKey String
tahesabId Int?
factorCode String?
shFactor Int?
radif Int?
/// شمسی YYYY-MM-DD
tarikh String
noRaw String @default("")
/// sale | return | purchase | other
kind String @default("other")
jensFelez Int?
noKar String?
vazn Float?
tabdilVazn Float?
mali Float?
count Float?
ayar Float?
/// درصد اجرت سند ته‌حساب؛ بعد از همگام‌سازی پر می‌شود
darsad Float?
darsadTalayee Float?
maliatDarsad Float?
mazaneh Float?
fi Float?
sharh1 String?
syncedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([connectionId, lineKey])
@@index([organizationId, connectionId, tarikh])
@@index([organizationId, kind])
@@index([personId])
@@index([linkId])
@@index([moshtariCode])
}
model AccountingSyncJob { model AccountingSyncJob {
id String @id @default(cuid()) id String @id @default(cuid())
organizationId String organizationId String
@@ -1804,6 +2021,23 @@ model AccountingSyncJob {
@@index([organizationId, createdAt]) @@index([organizationId, createdAt])
} }
/// تنظیمات ظاهر فاکتور PDF اسناد ته‌حساب (جدا از فاکتور CRM)
model TahesabInvoiceSettings {
id String @id @default(cuid())
organizationId String @unique
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
/// data URL تصویر لوگو (مثلاً data:image/png;base64,...)
logoDataUrl String? @db.Text
companyName String @default("")
companySubtitle String @default("")
address String @default("")
phone String @default("")
headerText String @default("")
footerText String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
/// انبار داخلی — وقتی ته‌حساب وصل نیست /// انبار داخلی — وقتی ته‌حساب وصل نیست
model Warehouse { model Warehouse {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -2105,6 +2339,12 @@ model FinancialDocument {
category FinancialCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull) category FinancialCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
personId String? personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
companyId String?
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
userId String?
partyUser User? @relation("FinancialDocumentParty", fields: [userId], references: [id], onDelete: SetNull)
/// person | company | user | other
partyKind String?
partyName String? partyName String?
accountId String? accountId String?
account FinancialAccount? @relation("DocumentAccount", fields: [accountId], references: [id], onDelete: SetNull) account FinancialAccount? @relation("DocumentAccount", fields: [accountId], references: [id], onDelete: SetNull)
@@ -2125,6 +2365,8 @@ model FinancialDocument {
@@index([organizationId, kind, bookedAt]) @@index([organizationId, kind, bookedAt])
@@index([organizationId, status]) @@index([organizationId, status])
@@index([personId]) @@index([personId])
@@index([companyId])
@@index([userId])
} }
/// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال. /// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال.
@@ -2848,6 +3090,10 @@ model Lead {
title String title String
/// نام مالک یا مسئول /// نام مالک یا مسئول
contactName String? contactName String?
/// male | female | none
gender String @default("none")
/// عنوان «جناب آقای» / «سرکار خانم» داخل نام مخاطب
showGenderInName Boolean @default(false)
mobile String mobile String
phone String? phone String?
city String? city String?
@@ -3540,3 +3786,9 @@ model AttendanceEvent {
@@index([organizationId, userId, at]) @@index([organizationId, userId, at])
@@unique([organizationId, userId, kind, sessionStartedAt]) @@unique([organizationId, userId, kind, sessionStartedAt])
} }
/// علامت یک‌بار اجرای اصلاح داده
model DataFix {
key String @id
doneAt DateTime @default(now())
}
@@ -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';
@@ -50,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,
@@ -70,14 +72,14 @@ export class ActivitiesController {
@Get('boards') @Get('boards')
listBoards(@CurrentUser() user: Actor) { listBoards(@CurrentUser() user: Actor) {
return this.stagesService.listBoards(user.organizationId); return this.stagesService.listBoards(user.organizationId, user);
} }
@Post('boards') @Post('boards')
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@Roles('owner', 'admin') @Roles('owner', 'admin')
createBoard(@CurrentUser() user: Actor, @Body() dto: CreateActivityBoardDto) { createBoard(@CurrentUser() user: Actor, @Body() dto: CreateActivityBoardDto) {
return this.stagesService.createBoard(user.organizationId, dto.name); return this.stagesService.createBoard(user.organizationId, dto.name, dto.memberIds);
} }
@Patch('boards/:id') @Patch('boards/:id')
@@ -101,7 +103,7 @@ export class ActivitiesController {
@Get('stages') @Get('stages')
listStages(@CurrentUser() user: Actor, @Query() query: ListActivityStagesQueryDto) { listStages(@CurrentUser() user: Actor, @Query() query: ListActivityStagesQueryDto) {
return this.stagesService.list(user.organizationId, query.boardId); return this.stagesService.list(user.organizationId, query.boardId, user);
} }
@Post('stages') @Post('stages')
@@ -191,6 +193,9 @@ 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, boardId: query.boardId,
}, },
); );
@@ -224,6 +229,42 @@ 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, boardId: query.boardId,
}, },
query.stageId ? { stageId: query.stageId } : undefined, query.stageId ? { stageId: query.stageId } : undefined,
@@ -307,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);
@@ -337,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,
+504 -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,11 +30,14 @@ 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 { leadsDeepWhere } from '../common/data-access';
import { ActivityStagesService, type BoardStageContext } from './activity-stages.service'; import { ActivityStagesService, type BoardStageContext } from './activity-stages.service';
import { import {
ActivityTypesService, ActivityTypesService,
@@ -39,7 +47,10 @@ import {
type DtoContext = { stages?: BoardStageContext; 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,10 @@ 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; boardId?: string;
}; };
@@ -84,6 +99,57 @@ type ActivityRow = {
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 = {
@@ -108,6 +174,8 @@ 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> {
@@ -245,6 +313,103 @@ export class ActivitiesService {
facets?: ListFacets, facets?: ListFacets,
stageScope?: StageScope, stageScope?: StageScope,
): Promise<PaginatedResponse<ActivityDto>> { ): Promise<PaginatedResponse<ActivityDto>> {
const { where, ctx } = await this.listScope(
organizationId,
actor,
filter,
q,
entity,
range,
desk,
facets,
stageScope,
);
const take = Math.min(Math.max(limit, 1), 200);
const [rows, total] = await this.prisma.$transaction([
this.prisma.activity.findMany({
where,
include,
// روی پروندهٔ لید، تازه‌ترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد
orderBy: this.listOrder(entity, stageScope),
skip: (page - 1) * take,
take,
}),
this.prisma.activity.count({ where }),
]);
return {
items: rows.map((row) => this.toDto(row, ctx)),
total,
page,
limit: take,
};
}
/**
* شناسه‌ها با همان فیلتر و ترتیب لیست (زمان شروع صعودی).
* برای ورق‌زدن جزئیات؛ سقف دارد تا یک زبانهٔ خیلی شلوغ کل پاسخ را بزرگ نکند.
*/
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 typeRows = await this.types.rows(organizationId);
const where = this.buildWhere( const where = this.buildWhere(
organizationId, organizationId,
@@ -261,6 +426,7 @@ export class ActivitiesService {
const boardCtx = await this.stages.boardContext(organizationId); const boardCtx = await this.stages.boardContext(organizationId);
if (facets?.boardId) { if (facets?.boardId) {
const boardId = await this.stages.resolveBoardId(organizationId, facets.boardId); const boardId = await this.stages.resolveBoardId(organizationId, facets.boardId);
await this.stages.assertCanAccessBoard(organizationId, boardId, actor);
(where.AND as Prisma.ActivityWhereInput[]).push( (where.AND as Prisma.ActivityWhereInput[]).push(
this.stages.boardWhere(boardId, boardCtx.defaultBoardId), this.stages.boardWhere(boardId, boardCtx.defaultBoardId),
); );
@@ -270,27 +436,9 @@ export class ActivitiesService {
await this.stages.whereForStage(organizationId, stageScope.stageId), await this.stages.whereForStage(organizationId, stageScope.stageId),
); );
} }
const ctx: DtoContext = {
stages: boardCtx,
types: this.types.systemOf(typeRows),
};
const take = Math.min(Math.max(limit, 1), 200);
const [rows, total] = await this.prisma.$transaction([
this.prisma.activity.findMany({
where,
include,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * take,
take,
}),
this.prisma.activity.count({ where }),
]);
return { return {
items: rows.map((row) => this.toDto(row, ctx)), where,
total, ctx: { stages: boardCtx, types: this.types.systemOf(typeRows) },
page,
limit: take,
}; };
} }
@@ -304,6 +452,7 @@ export class ActivitiesService {
facets?: ListFacets, facets?: ListFacets,
): Promise<ActivityStageCountsDto> { ): Promise<ActivityStageCountsDto> {
const boardId = await this.stages.resolveBoardId(organizationId, facets?.boardId); const boardId = await this.stages.resolveBoardId(organizationId, facets?.boardId);
await this.stages.assertCanAccessBoard(organizationId, boardId, actor);
const defaultBoard = await this.stages.ensureDefaultBoard(organizationId); const defaultBoard = await this.stages.ensureDefaultBoard(organizationId);
const rows = await this.stages.rows(organizationId, boardId); const rows = await this.stages.rows(organizationId, boardId);
const defaults = this.stages.defaultsOf(rows); const defaults = this.stages.defaultsOf(rows);
@@ -340,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);
@@ -369,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);
} }
@@ -382,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));
@@ -412,6 +609,12 @@ 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
@@ -423,6 +626,7 @@ export class ActivitiesService {
: requestedBoardId : requestedBoardId
? await this.stages.resolveBoardId(organizationId, requestedBoardId) ? await this.stages.resolveBoardId(organizationId, requestedBoardId)
: null; : 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,
@@ -432,6 +636,12 @@ 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: {
@@ -460,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 })) }
@@ -543,6 +754,7 @@ export class ActivitiesService {
select: { boardId: true }, select: { boardId: true },
}); });
const defaultBoard = await this.stages.ensureDefaultBoard(organizationId); const defaultBoard = await this.stages.ensureDefaultBoard(organizationId);
await this.stages.assertCanAccessBoard(organizationId, nextBoardId, actor);
if (nextBoardId !== (current?.boardId ?? defaultBoard.id)) { if (nextBoardId !== (current?.boardId ?? defaultBoard.id)) {
// ستون قبلی مال گروه دیگر است؛ فعالیت به ستون اولِ هم‌وضعیت گروه جدید می‌رود // ستون قبلی مال گروه دیگر است؛ فعالیت به ستون اولِ هم‌وضعیت گروه جدید می‌رود
data.boardId = nextBoardId; data.boardId = nextBoardId;
@@ -567,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);
} }
@@ -591,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;
@@ -701,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(
@@ -737,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
? [ ? [
{ {
@@ -788,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.
@@ -810,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: {
@@ -903,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) {
@@ -931,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(
@@ -971,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,
@@ -1042,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,4 +1,9 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { import type {
ActivityBoardDto, ActivityBoardDto,
ActivityStageColor, ActivityStageColor,
@@ -34,8 +39,23 @@ type BoardRow = {
name: string; name: string;
isDefault: boolean; isDefault: boolean;
position: number; 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 = { const STAGE_SELECT = {
id: true, id: true,
boardId: true, boardId: true,
@@ -50,6 +70,7 @@ const BOARD_SELECT = {
name: true, name: true,
isDefault: true, isDefault: true,
position: true, position: true,
members: { select: { userId: true } },
} satisfies Prisma.ActivityBoardSelect; } satisfies Prisma.ActivityBoardSelect;
export type StageDefaults = Record<ActivityStatus, string | null>; export type StageDefaults = Record<ActivityStatus, string | null>;
@@ -109,14 +130,36 @@ export class ActivityStagesService {
}); });
} }
async listBoards(organizationId: string): Promise<ActivityBoardDto[]> { async listBoards(
organizationId: string,
actor?: { userId: string; role: string },
): Promise<ActivityBoardDto[]> {
await this.ensureDefaultBoard(organizationId); await this.ensureDefaultBoard(organizationId);
const rows = await this.prisma.activityBoard.findMany({ const rows = await this.prisma.activityBoard.findMany({
where: { organizationId }, where: { organizationId },
orderBy: [{ isDefault: 'desc' }, { position: 'asc' }, { createdAt: 'asc' }], orderBy: [{ isDefault: 'desc' }, { position: 'asc' }, { createdAt: 'asc' }],
select: BOARD_SELECT, select: BOARD_SELECT,
}); });
return rows.map((b) => ({ ...b })); 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('به این گروه فعالیت دسترسی ندارید');
} }
/** شناسهٔ گروه معتبر؛ خالی = گروه پیش‌فرض */ /** شناسهٔ گروه معتبر؛ خالی = گروه پیش‌فرض */
@@ -131,7 +174,11 @@ export class ActivityStagesService {
return board.id; return board.id;
} }
async createBoard(organizationId: string, name: string): Promise<ActivityBoardDto> { async createBoard(
organizationId: string,
name: string,
memberIds?: string[],
): Promise<ActivityBoardDto> {
await this.ensureDefaultBoard(organizationId); await this.ensureDefaultBoard(organizationId);
const max = await this.prisma.activityBoard.aggregate({ const max = await this.prisma.activityBoard.aggregate({
where: { organizationId }, where: { organizationId },
@@ -147,7 +194,8 @@ export class ActivityStagesService {
select: BOARD_SELECT, select: BOARD_SELECT,
}); });
await this.ensureBoardStages(organizationId, board.id); await this.ensureBoardStages(organizationId, board.id);
return { ...board }; if (memberIds !== undefined) await this.replaceMembers(organizationId, board.id, memberIds);
return this.getBoardDto(organizationId, board.id);
} catch (e) { } catch (e) {
throw this.mapBoardError(e); throw this.mapBoardError(e);
} }
@@ -156,21 +204,51 @@ export class ActivityStagesService {
async updateBoard( async updateBoard(
organizationId: string, organizationId: string,
id: string, id: string,
dto: { name?: string }, dto: { name?: string; memberIds?: string[] },
): Promise<ActivityBoardDto> { ): Promise<ActivityBoardDto> {
await this.getBoard(organizationId, id); await this.getBoard(organizationId, id);
try { try {
const board = await this.prisma.activityBoard.update({ if (dto.name != null) {
where: { id }, await this.prisma.activityBoard.update({
data: dto.name != null ? { name: this.cleanBoardName(dto.name) } : {}, where: { id },
select: BOARD_SELECT, data: { name: this.cleanBoardName(dto.name) },
}); });
return { ...board }; }
if (dto.memberIds !== undefined) await this.replaceMembers(organizationId, id, dto.memberIds);
return this.getBoardDto(organizationId, id);
} catch (e) { } catch (e) {
throw this.mapBoardError(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> { async removeBoard(organizationId: string, id: string): Promise<void> {
const board = await this.getBoard(organizationId, id); const board = await this.getBoard(organizationId, id);
@@ -213,8 +291,14 @@ export class ActivityStagesService {
}); });
} }
async list(organizationId: string, boardId?: string | null): Promise<ActivityStageDto[]> { async list(
return (await this.rows(organizationId, boardId)).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));
} }
/** اولین مرحلهٔ هر نوع = مقصد فعالیت‌های بدون مرحله یا با مرحلهٔ ناهم‌نوع */ /** اولین مرحلهٔ هر نوع = مقصد فعالیت‌های بدون مرحله یا با مرحلهٔ ناهم‌نوع */
+45 -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()
@@ -131,6 +148,11 @@ export class CreateActivityBoardDto {
@MinLength(2) @MinLength(2)
@MaxLength(80) @MaxLength(80)
name!: string; name!: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
memberIds?: string[];
} }
export class UpdateActivityBoardDto { export class UpdateActivityBoardDto {
@@ -139,6 +161,11 @@ export class UpdateActivityBoardDto {
@MinLength(2) @MinLength(2)
@MaxLength(80) @MaxLength(80)
name?: string; name?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
memberIds?: string[];
} }
export class ListActivityStagesQueryDto { export class ListActivityStagesQueryDto {
@@ -192,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 در سرویس بر اساس تعریف نوع اعتبارسنجی می‌شود */
@@ -289,7 +322,7 @@ export class CreateActivityDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(2000) @MaxLength(20000)
description?: string; description?: string;
@IsOptional() @IsOptional()
@@ -337,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()
@@ -384,7 +422,7 @@ export class UpdateActivityDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(2000) @MaxLength(20000)
description?: string | null; description?: string | null;
@IsOptional() @IsOptional()
@@ -432,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) {
+2
View File
@@ -54,6 +54,7 @@ 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 { BehaviorModule } from './behavior/behavior.module';
import { SmsBillingModule } from './sms-billing/sms-billing.module'; import { SmsBillingModule } from './sms-billing/sms-billing.module';
import { CustomerGroupsModule } from './customer-groups/customer-groups.module';
@Module({ @Module({
imports: [ imports: [
@@ -65,6 +66,7 @@ import { SmsBillingModule } from './sms-billing/sms-billing.module';
AiModule, AiModule,
PrismaModule, PrismaModule,
SmsBillingModule, SmsBillingModule,
CustomerGroupsModule,
OutboundSmsModule, OutboundSmsModule,
StorageModule, StorageModule,
HealthModule, HealthModule,
+24 -3
View File
@@ -1,4 +1,4 @@
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; import { ForbiddenException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -13,6 +13,9 @@ import {
export const WORKDAY_ENDED_MESSAGE = export const WORKDAY_ENDED_MESSAGE =
'ساعت کاری امروز تمام شده است. برای ادامه دوباره وارد شوید.'; 'ساعت کاری امروز تمام شده است. برای ادامه دوباره وارد شوید.';
export const LOGIN_BLOCKED_AFTER_HOURS_MESSAGE =
'ساعت کاری امروز شما تمام شده و ورود دوباره تا فردا مجاز نیست.';
type RecordInput = { type RecordInput = {
organizationId: string; organizationId: string;
userId: string; userId: string;
@@ -61,6 +64,18 @@ export class AttendanceService {
}); });
} }
/** با «مسدودسازی بعد از ساعت خروج»، از آن ساعت تا پایان روز تهران ورود رد می‌شود. */
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) { async recordLogout(actor: Actor) {
if (actor.viewAsReadOnly) return { ok: true }; if (actor.viewAsReadOnly) return { ok: true };
const at = new Date(); const at = new Date();
@@ -81,14 +96,20 @@ export class AttendanceService {
if (actor.viewAsReadOnly || actor.role === 'owner' || !actor.sessionStartedAt) return; if (actor.viewAsReadOnly || actor.role === 'owner' || !actor.sessionStartedAt) return;
const membership = await this.prisma.membership.findFirst({ const membership = await this.prisma.membership.findFirst({
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true }, where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
select: { logoutTime: true, role: true }, select: { logoutTime: true, role: true, blockLoginAfterLogout: true },
}); });
const logoutTime = membership?.logoutTime; const logoutTime = membership?.logoutTime;
if (!logoutTime || membership?.role === 'owner') return; if (!logoutTime || membership?.role === 'owner') return;
const now = new Date(); const now = new Date();
const cutoff = tehranCutoff(now, logoutTime); const cutoff = tehranCutoff(now, logoutTime);
if (!cutoff || now.getTime() < cutoff.getTime()) return; if (!cutoff || now.getTime() < cutoff.getTime()) return;
if (actor.sessionStartedAt.getTime() >= cutoff.getTime()) return; // نشستی که بعد از ساعت خروج شروع شده فقط وقتی مجاز است که ورود بعد از ساعت مسدود نباشد
if (
actor.sessionStartedAt.getTime() >= cutoff.getTime() &&
!membership?.blockLoginAfterLogout
) {
return;
}
await this.write({ await this.write({
organizationId: actor.organizationId, organizationId: actor.organizationId,
@@ -5,6 +5,7 @@ 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';
@@ -42,9 +43,9 @@ 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( constructor(
private readonly auditLogs: AuditLogsService, private readonly auditLogs: AuditLogsService,
@@ -52,6 +53,7 @@ export class AuditLogsController {
) {} ) {}
@Get('attendance') @Get('attendance')
@Roles('owner', 'admin')
attendanceList( attendanceList(
@CurrentUser() user: { organizationId: string }, @CurrentUser() user: { organizationId: string },
@Query() query: ListAuditQueryDto, @Query() query: ListAuditQueryDto,
+95 -12
View File
@@ -16,6 +16,7 @@ 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';
@@ -26,6 +27,8 @@ 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 { AttendanceService } from '../attendance/attendance.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { import {
@@ -111,6 +114,8 @@ export class AuthService {
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, private readonly attendance: AttendanceService,
) {} ) {}
@@ -280,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 },
@@ -315,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 (بدون رمز) — فقط کاربران ثبت‌شده */
@@ -613,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);
} }
@@ -627,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);
} }
@@ -673,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(
@@ -692,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('حساب کاربری غیرفعال است');
@@ -723,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,
@@ -786,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,
@@ -796,6 +876,7 @@ 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) permissions: (Array.isArray(membership?.orgRole?.permissions)
@@ -906,6 +987,7 @@ 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'];
@@ -922,6 +1004,7 @@ 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 } : {}),
+36
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,
@@ -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,6 +157,15 @@ 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
@@ -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();
@@ -34,6 +34,7 @@ import { NotificationsService } from '../notifications/notifications.service';
import { LoyaltyService } from '../loyalty/loyalty.service'; import { LoyaltyService } from '../loyalty/loyalty.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.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 = {
@@ -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,9 +592,16 @@ export class AutomationsService {
select: { mobile: true, phone: true }, select: { mobile: true, phone: true },
}) })
: null; : null;
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( await this.sendAutomationSms(
ctx.organizationId, ctx.organizationId,
person?.mobile || person?.phone, person?.mobile || person?.phone || lead?.mobile || lead?.phone || ctx.mobile,
smsText, smsText,
); );
} }
@@ -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);
}
+62 -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';
/** لحظهٔ روشن شدن ماژول برای این محیط؛ فعالیت‌های ساخته‌شده قبل از آن می‌توانند قبلی باشند */ /** لحظهٔ روشن شدن ماژول برای این محیط؛ فعالیت‌های ساخته‌شده قبل از آن می‌توانند قبلی باشند */
@@ -93,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 {
@@ -107,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 {
@@ -135,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 {
@@ -150,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: [
@@ -188,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 {
@@ -206,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 };
@@ -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 },
}); });
+4 -1
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 { SameMobileMergeService } from './same-mobile-merge.service';
import { BehaviorModule } from '../behavior/behavior.module'; import { BehaviorModule } from '../behavior/behavior.module';
import { CustomerGroupsModule } from '../customer-groups/customer-groups.module';
@Module({ @Module({
imports: [ imports: [
@@ -17,9 +19,10 @@ import { BehaviorModule } from '../behavior/behavior.module';
MessageAutomationsModule, MessageAutomationsModule,
PublicApiModule, PublicApiModule,
BehaviorModule, BehaviorModule,
CustomerGroupsModule,
], ],
controllers: [PeopleController, CompaniesController], controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService], providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService],
exports: [PeopleService, CompaniesService], exports: [PeopleService, CompaniesService],
}) })
export class ContactsModule {} export class ContactsModule {}
+45 -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;
/** استان شرکت مرتبط */ /** استان شرکت مرتبط */
@@ -86,6 +89,12 @@ export class ListQueryDto {
@IsString() @IsString()
@MaxLength(12) @MaxLength(12)
disc?: string; disc?: string;
/** فیلتر گروه مشتری؛ چندتایی با کاما (هر کدام از گروه‌ها) */
@IsOptional()
@IsString()
@MaxLength(2000)
groupId?: string;
} }
class CompanyExtraFieldsDto { class CompanyExtraFieldsDto {
@@ -153,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 {
@@ -269,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()
@@ -370,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 {
@@ -383,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()
@@ -478,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());
}
+62 -19
View File
@@ -1,19 +1,62 @@
export function emptyToNull(value?: string | null): string | null { export 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();
return trimmed.length ? trimmed : null; return trimmed.length ? trimmed : null;
} }
export function normalizeEmail(value?: string | null): string | null { export function normalizeEmail(value?: string | null): string | null {
const v = emptyToNull(value); const v = emptyToNull(value);
return v ? v.toLowerCase() : null; return v ? v.toLowerCase() : null;
} }
/** Keep digits and leading + for Iranian/international numbers. */ const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
export function normalizePhone(value?: string | null): string | null { const AR_DIGITS = '٠١٢٣٤٥٦٧٨٩';
const v = emptyToNull(value);
if (!v) return null; /** ارقام فارسی و عربی را به لاتین برمی‌گرداند تا از شماره حذف نشوند. */
const cleaned = v.replace(/[^\d+]/g, ''); function latinDigits(value: string): string {
return cleaned.length ? cleaned : null; 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. */
export function normalizePhone(value?: string | null): string | null {
const v = emptyToNull(value);
if (!v) return null;
const cleaned = latinDigits(v).replace(/[^\d+]/g, '');
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);
}
+10 -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,
@@ -60,6 +62,7 @@ export class PeopleController {
city: query.city, city: query.city,
hasMobile, hasMobile,
disc: query.disc, disc: query.disc,
groupId: query.groupId,
}, },
); );
} }
@@ -77,6 +80,7 @@ export class PeopleController {
city: query.city, city: query.city,
hasMobile, hasMobile,
disc: query.disc, disc: query.disc,
groupId: query.groupId,
}); });
} }
@@ -85,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,
+152 -31
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,24 +18,34 @@ 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';
@@ -45,12 +56,16 @@ 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 { 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 } },
@@ -58,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;
@@ -102,6 +123,7 @@ type PersonRow = {
discLabel: string | null; discLabel: string | null;
discReliable: boolean; discReliable: boolean;
discAssessedAt: Date | null; discAssessedAt: Date | null;
groupLinks: Array<{ source: string; group: { id: string; name: string; color: string } }>;
}; };
function discFilter(raw: string | undefined): Prisma.PersonWhereInput | null { function discFilter(raw: string | undefined): Prisma.PersonWhereInput | null {
@@ -134,23 +156,27 @@ export class PeopleService implements OnModuleInit {
private readonly sms: SmsGatewayService, private readonly sms: SmsGatewayService,
private readonly outboundSms: OutboundSmsLogService, private readonly outboundSms: OutboundSmsLogService,
private readonly behaviorAccess: BehaviorAccessService, 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; 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) },
@@ -190,11 +216,12 @@ export class PeopleService implements OnModuleInit {
} }
const disc = discFilter(opts?.disc); const disc = discFilter(opts?.disc);
if (disc) and.push(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 } : {}),
}; };
} }
@@ -205,13 +232,14 @@ 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; disc?: string;
groupId?: string;
}, },
): Promise<PaginatedResponse<PersonDto>> { ): Promise<PaginatedResponse<PersonDto>> {
const seeDisc = await this.behaviorAccess.canView(actor); const seeDisc = await this.behaviorAccess.canView(actor);
@@ -243,12 +271,13 @@ 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; disc?: string;
groupId?: string;
}, },
max = FILTERED_IDS_MAX, max = FILTERED_IDS_MAX,
): Promise<FilteredIdsResponse> { ): Promise<FilteredIdsResponse> {
@@ -274,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 },
}); });
@@ -287,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();
} }
@@ -343,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({
@@ -354,7 +418,7 @@ 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('شخص پیدا نشد');
@@ -368,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),
@@ -389,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,
@@ -446,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);
} }
@@ -459,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);
@@ -484,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,
@@ -516,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 };
@@ -524,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('شخص پیدا نشد');
@@ -537,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({
@@ -754,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,
@@ -776,6 +893,10 @@ 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, discLabel: includeDisc && row.discReliable ? row.discLabel : null,
@@ -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;
}
}
@@ -30,6 +30,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { type Actor, elevated } from '../common/record-scope'; import { type Actor, elevated } from '../common/record-scope';
import { dayKeyToDate, tehranDayKey, addDays } from '../routines/routine-dates'; import { dayKeyToDate, tehranDayKey, addDays } from '../routines/routine-dates';
import { internalRoutineTaskWhere } from '../routines/internal-routine';
import { normalizeLevels, periodStart, previousPeriod, sanitizeConditions } from './gamification-engine'; import { normalizeLevels, periodStart, previousPeriod, sanitizeConditions } from './gamification-engine';
import { GamificationService, parsePeriod, toRuleDto } from './gamification.service'; import { GamificationService, parsePeriod, toRuleDto } from './gamification.service';
import type { import type {
@@ -751,6 +752,7 @@ export class GamificationAdminService {
organizationId, organizationId,
status: { not: 'cancelled' }, status: { not: 'cancelled' },
scheduledFor: { gte: dayKeyToDate(fromKey), lte: dayKeyToDate(toKey) }, scheduledFor: { gte: dayKeyToDate(fromKey), lte: dayKeyToDate(toKey) },
...internalRoutineTaskWhere,
}; };
const [total, done] = await Promise.all([ const [total, done] = await Promise.all([
this.prisma.routineTask.count({ where }), this.prisma.routineTask.count({ where }),
@@ -17,7 +17,11 @@ import type {
} from '@mento/shared'; } from '@mento/shared';
import { parseBirthDate } from '../../common/excel'; import { parseBirthDate } from '../../common/excel';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { normalizeEmail, normalizePhone } from '../../contacts/normalize'; import {
normalizeEmail,
phoneIdentityKeys,
storedPhoneNumber,
} from '../../contacts/normalize';
import { carryPreviousSource } from '../../contacts/person-source'; import { carryPreviousSource } from '../../contacts/person-source';
import { ownerWhere, type Actor } from '../../common/record-scope'; import { ownerWhere, type Actor } from '../../common/record-scope';
import { MontifyConnectionsService } from './connections.service'; import { MontifyConnectionsService } from './connections.service';
@@ -100,13 +104,7 @@ function arr(v: unknown): unknown[] {
} }
function mobileVariants(mobile: string): string[] { function mobileVariants(mobile: string): string[] {
const digits = mobile.replace(/\D/g, ''); return phoneIdentityKeys(mobile);
let core = digits;
if (core.startsWith('0098')) core = core.slice(4);
else if (core.startsWith('98') && core.length === 12) core = core.slice(2);
else if (core.startsWith('0')) core = core.slice(1);
if (!core) return [mobile];
return [...new Set([mobile, core, `0${core}`, `98${core}`, `+98${core}`])];
} }
function formatAddress(a: MontifyAddress): string | null { function formatAddress(a: MontifyAddress): string | null {
@@ -417,7 +415,7 @@ export class MontifySyncService {
}> { }> {
const externalId = String(c.id); const externalId = String(c.id);
const { firstName, lastName } = splitName(c); const { firstName, lastName } = splitName(c);
const mobile = normalizePhone(c.phone ?? null); const mobile = storedPhoneNumber(c.phone ?? null);
const email = normalizeEmail(c.email ?? null); const email = normalizeEmail(c.email ?? null);
const addressLines = addresses.map(formatAddress).filter((a): a is string => Boolean(a)); const addressLines = addresses.map(formatAddress).filter((a): a is string => Boolean(a));
const profile: CustomerProfile = { const profile: CustomerProfile = {
File diff suppressed because it is too large Load Diff
@@ -43,6 +43,10 @@ export class TahesabConnectionsService {
lastHealthAt: Date | null; lastHealthAt: Date | null;
lastHealthOk: boolean | null; lastHealthOk: boolean | null;
lastError: string | null; lastError: string | null;
customerSyncHours: number[];
customerSyncLastRunAt: Date | null;
customerSyncLastSummary: string | null;
customerSyncLastError: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
}) { }) {
@@ -60,6 +64,10 @@ export class TahesabConnectionsService {
lastHealthAt: row.lastHealthAt?.toISOString() ?? null, lastHealthAt: row.lastHealthAt?.toISOString() ?? null,
lastHealthOk: row.lastHealthOk, lastHealthOk: row.lastHealthOk,
lastError: row.lastError, lastError: row.lastError,
customerSyncHours: [...row.customerSyncHours].sort((a, b) => a - b),
customerSyncLastRunAt: row.customerSyncLastRunAt?.toISOString() ?? null,
customerSyncLastSummary: row.customerSyncLastSummary,
customerSyncLastError: row.customerSyncLastError,
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(), updatedAt: row.updatedAt.toISOString(),
}; };
@@ -201,6 +209,15 @@ export class TahesabConnectionsService {
if (dto.isDefault === true) await this.clearOtherDefaults(organizationId, id); if (dto.isDefault === true) await this.clearOtherDefaults(organizationId, id);
let customerSyncHours: number[] | undefined;
if (dto.customerSyncHours !== undefined) {
const hours = dto.customerSyncHours.map((hour) => Math.trunc(hour));
if (new Set(hours).size !== hours.length) {
throw new BadRequestException('ساعت‌های بروزرسانی نباید تکراری باشند.');
}
customerSyncHours = [...hours].sort((a, b) => a - b);
}
let enc = { let enc = {
ciphertext: row.tokenCiphertext, ciphertext: row.tokenCiphertext,
iv: row.tokenIv, iv: row.tokenIv,
@@ -225,6 +242,7 @@ export class TahesabConnectionsService {
enabled: dto.enabled ?? row.enabled, enabled: dto.enabled ?? row.enabled,
isDefault: dto.isDefault === true ? true : dto.isDefault === false ? false : row.isDefault, isDefault: dto.isDefault === true ? true : dto.isDefault === false ? false : row.isDefault,
allowInsecureTls: dto.allowInsecureTls ?? row.allowInsecureTls, allowInsecureTls: dto.allowInsecureTls ?? row.allowInsecureTls,
...(customerSyncHours !== undefined ? { customerSyncHours } : {}),
}, },
}); });
return this.toDto(updated); return this.toDto(updated);
@@ -0,0 +1,99 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../../prisma/prisma.service';
import { TahesabPersonLinkService } from './person-link.service';
/** ساعت و تاریخ امروز به وقت تهران، برای نوبت یک‌بار در هر ساعت. */
export function tehranSyncSlot(now = new Date()): { hour: number; slot: string } {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
hourCycle: 'h23',
}).formatToParts(now);
const read = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? '';
let hour = Number(read('hour'));
if (!Number.isFinite(hour) || hour === 24) hour = 0;
const slot = `${read('year')}-${read('month')}-${read('day')}-${String(hour).padStart(2, '0')}`;
return { hour, slot };
}
@Injectable()
export class TahesabCustomerSyncScheduler {
private readonly logger = new Logger(TahesabCustomerSyncScheduler.name);
private busy = false;
constructor(
private readonly prisma: PrismaService,
private readonly links: TahesabPersonLinkService,
) {}
@Cron('* * * * *', { timeZone: 'Asia/Tehran' })
async tick() {
if (process.env.NODE_ENV === 'test' || this.busy) return;
this.busy = true;
try {
const { hour, slot } = tehranSyncSlot();
const connections = await this.prisma.accountingConnection.findMany({
where: {
provider: 'tahesab',
enabled: true,
customerSyncHours: { has: hour },
},
select: { id: true, organizationId: true, label: true, customerSyncLastSlot: true },
});
for (const connection of connections) {
if (connection.customerSyncLastSlot === slot) continue;
const claimed = await this.prisma.accountingConnection.updateMany({
where: {
id: connection.id,
OR: [
{ customerSyncLastSlot: null },
{ customerSyncLastSlot: { not: slot } },
],
},
data: { customerSyncLastSlot: slot },
});
if (claimed.count !== 1) continue;
await this.run(connection.organizationId, connection.id, connection.label);
}
} catch (err) {
this.logger.warn(
`بررسی زمان‌بندی مشتریان ته‌حساب ناموفق بود: ${
err instanceof Error ? err.message : err
}`,
);
} finally {
this.busy = false;
}
}
private async run(organizationId: string, connectionId: string, label: string) {
try {
const result = await this.links.syncAllCustomers(organizationId, connectionId);
await this.prisma.accountingConnection.update({
where: { id: connectionId },
data: {
customerSyncLastRunAt: new Date(),
customerSyncLastSummary: result.summary,
customerSyncLastError: result.error,
},
});
this.logger.log(`بروزرسانی خودکار «${label}»: ${result.summary}`);
} catch (err) {
const message = (err instanceof Error ? err.message : 'همگام‌سازی ناموفق بود').slice(0, 500);
await this.prisma.accountingConnection.update({
where: { id: connectionId },
data: {
customerSyncLastRunAt: new Date(),
customerSyncLastSummary: null,
customerSyncLastError: message,
},
});
this.logger.warn(`بروزرسانی خودکار «${label}» ناموفق بود: ${message}`);
}
}
}
+104
View File
@@ -1,4 +1,5 @@
import { import {
ArrayMaxSize,
IsArray, IsArray,
IsBoolean, IsBoolean,
IsIn, IsIn,
@@ -85,6 +86,16 @@ export class UpdateTahesabConnectionDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
allowInsecureTls?: boolean; allowInsecureTls?: boolean;
/** ساعت‌های ۰ تا ۲۳. آرایهٔ خالی یعنی خاموش. */
@IsOptional()
@IsArray()
@ArrayMaxSize(6)
@Type(() => Number)
@IsInt({ each: true })
@Min(0, { each: true })
@Max(23, { each: true })
customerSyncHours?: number[];
} }
export class ManualLinkDto { export class ManualLinkDto {
@@ -176,6 +187,35 @@ export class ImportBulkFromTahesabDto {
pageSize?: number; pageSize?: number;
} }
export class SyncGroupsPageDto {
@IsString()
connectionId!: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(100)
@Max(2000)
pageSize?: number;
}
export class ExportPeopleToTahesabDto {
@IsString()
connectionId!: string;
/** هر درخواست چند نفر؛ هر نفر چند تماس با ته‌حساب دارد */
@IsArray()
@ArrayMaxSize(25)
@IsString({ each: true })
personIds!: string[];
}
export class SearchTahesabDto { export class SearchTahesabDto {
@IsString() @IsString()
connectionId!: string; connectionId!: string;
@@ -403,6 +443,70 @@ export class PushSanadDto {
factorCode?: string; factorCode?: string;
} }
export class SyncAsnadReportDto {
@IsOptional()
@IsString()
connectionId?: string;
@IsString()
@MinLength(8)
@MaxLength(12)
from!: string;
@IsString()
@MinLength(8)
@MaxLength(12)
to!: string;
/** اگر باشد، فقط سندهای همین شخص گرفته می‌شود */
@IsOptional()
@IsString()
personId?: string;
/** اگر باشد، فقط مشتریان این گروه‌ها (و زیرگروه‌ها) گرفته می‌شوند */
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(200)
groupIds?: string[];
}
export class UpdateTahesabInvoiceSettingsDto {
/** null = حذف لوگو */
@IsOptional()
logoDataUrl?: string | null;
@IsOptional()
@IsString()
@MaxLength(200)
companyName?: string;
@IsOptional()
@IsString()
@MaxLength(200)
companySubtitle?: string;
@IsOptional()
@IsString()
@MaxLength(500)
address?: string;
@IsOptional()
@IsString()
@MaxLength(80)
phone?: string;
@IsOptional()
@IsString()
@MaxLength(1000)
headerText?: string;
@IsOptional()
@IsString()
@MaxLength(1000)
footerText?: string;
}
// silence unused import warnings for ValidateNested/Type if unused // silence unused import warnings for ValidateNested/Type if unused
void ValidateNested; void ValidateNested;
void Type; void Type;
@@ -0,0 +1,111 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import type {
TahesabInvoiceSettingsDto,
UpdateTahesabInvoiceSettingsRequest,
} from '@mento/shared';
import { PrismaService } from '../../prisma/prisma.service';
const MAX_LOGO_CHARS = 700_000; // ~500KB data URL
function toDto(row: {
id: string;
organizationId: string;
logoDataUrl: string | null;
companyName: string;
companySubtitle: string;
address: string;
phone: string;
headerText: string;
footerText: string;
updatedAt: Date;
}): TahesabInvoiceSettingsDto {
return {
id: row.id,
organizationId: row.organizationId,
logoDataUrl: row.logoDataUrl,
companyName: row.companyName,
companySubtitle: row.companySubtitle,
address: row.address,
phone: row.phone,
headerText: row.headerText,
footerText: row.footerText,
updatedAt: row.updatedAt.toISOString(),
};
}
@Injectable()
export class TahesabInvoiceSettingsService {
constructor(private readonly prisma: PrismaService) {}
async getOrCreate(organizationId: string): Promise<TahesabInvoiceSettingsDto> {
const existing = await this.prisma.tahesabInvoiceSettings.findUnique({
where: { organizationId },
});
if (existing) return toDto(existing);
const org = await this.prisma.organization.findUnique({
where: { id: organizationId },
select: { name: true },
});
const created = await this.prisma.tahesabInvoiceSettings.create({
data: {
organizationId,
companyName: org?.name ?? '',
},
});
return toDto(created);
}
async update(
organizationId: string,
dto: UpdateTahesabInvoiceSettingsRequest,
): Promise<TahesabInvoiceSettingsDto> {
if (dto.logoDataUrl != null && dto.logoDataUrl.length > MAX_LOGO_CHARS) {
throw new BadRequestException(
'حجم لوگو زیاد است؛ تصویر کوچک‌تری انتخاب کنید.',
);
}
if (
dto.logoDataUrl != null &&
dto.logoDataUrl !== '' &&
!/^data:image\/(png|jpe?g|webp|gif);base64,/i.test(dto.logoDataUrl)
) {
throw new BadRequestException('لوگو باید تصویر با فرمت data URL باشد.');
}
const data = {
...(dto.logoDataUrl !== undefined
? { logoDataUrl: dto.logoDataUrl?.trim() || null }
: {}),
...(dto.companyName !== undefined
? { companyName: dto.companyName.trim().slice(0, 200) }
: {}),
...(dto.companySubtitle !== undefined
? { companySubtitle: dto.companySubtitle.trim().slice(0, 200) }
: {}),
...(dto.address !== undefined
? { address: dto.address.trim().slice(0, 500) }
: {}),
...(dto.phone !== undefined
? { phone: dto.phone.trim().slice(0, 80) }
: {}),
...(dto.headerText !== undefined
? { headerText: dto.headerText.trim().slice(0, 1000) }
: {}),
...(dto.footerText !== undefined
? { footerText: dto.footerText.trim().slice(0, 1000) }
: {}),
};
const row = await this.prisma.tahesabInvoiceSettings.upsert({
where: { organizationId },
create: {
organizationId,
companyName: '',
...data,
},
update: data,
});
return toDto(row);
}
}
@@ -8,19 +8,42 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { carryPreviousSource } from '../../contacts/person-source'; import { carryPreviousSource, namesDiffer } from '../../contacts/person-source';
import {
canonicalIranMobile,
phoneIdentityKeys,
storedPhoneNumber,
} from '../../contacts/normalize';
import { applyPersonGenderTitle } from '@mento/shared';
import { TahesabConnectionsService } from './connections.service'; import { TahesabConnectionsService } from './connections.service';
import { import {
CreateInTahesabDto, CreateInTahesabDto,
ExportPeopleToTahesabDto,
ImportBulkFromTahesabDto, ImportBulkFromTahesabDto,
ImportFromTahesabDto, ImportFromTahesabDto,
ManualLinkDto, ManualLinkDto,
MatchPersonDto, MatchPersonDto,
SearchTahesabDto, SearchTahesabDto,
SyncGroupsPageDto,
} from './dto'; } from './dto';
import { parseBirthDate } from '../../common/excel'; import { formatJalaliDate, parseBirthDate } from '../../common/excel';
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods'; import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
/** موبایل ته‌حساب را به شکل 0912… ذخیره می‌کند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */
function canonicalMobileUpdate(
incoming: string | null,
current: { mobile: string | null; phone: string | null },
): { mobile?: string; phone?: string } {
const mobile =
canonicalIranMobile(incoming) ??
canonicalIranMobile(current.mobile) ??
canonicalIranMobile(current.phone);
if (!mobile) return {};
const phone = canonicalIranMobile(current.phone);
return phone ? { mobile, phone } : { mobile };
}
/** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */ /** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */
const LIST_PAGE_SIZE = 2000; const LIST_PAGE_SIZE = 2000;
const LIST_TIMEOUT_MS = 55_000; const LIST_TIMEOUT_MS = 55_000;
@@ -71,6 +94,23 @@ function pickBirthDate(row: Record<string, unknown>): Date | null {
return parseBirthDate(String(raw)); return parseBirthDate(String(raw));
} }
function firstAddress(raw: unknown): string | undefined {
if (!Array.isArray(raw)) return undefined;
const first = raw.find((a) => typeof a === 'string' && a.trim());
return typeof first === 'string' ? first.trim() : undefined;
}
/** گروه مشتری در ته‌حساب (فیلد نام در پاسخ ته‌حساب «GoroupName» غلط‌املایی دارد) */
function pickGroup(row: Record<string, unknown>): { gid: string | null; name: string } | null {
const rawGid = row.GID ?? row.Gid ?? row.GroupID ?? row.GroupId;
const rawName = row.GoroupName ?? row.GroupName ?? row.Group_Name ?? row.Goroup;
if (rawGid == null && rawName == null) return null;
const gid = rawGid == null ? null : String(rawGid).trim() || null;
const name = rawName == null ? '' : String(rawName).trim();
if (!gid && !name) return null;
return { gid, name };
}
@Injectable() @Injectable()
export class TahesabPersonLinkService { export class TahesabPersonLinkService {
private readonly logger = new Logger(TahesabPersonLinkService.name); private readonly logger = new Logger(TahesabPersonLinkService.name);
@@ -78,8 +118,37 @@ export class TahesabPersonLinkService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly connections: TahesabConnectionsService, private readonly connections: TahesabConnectionsService,
private readonly groups: CustomerGroupsService,
) {} ) {}
/**
* گروه ته‌حساب روی شخص: فقط گروهِ آمده از ته‌حساب عوض می‌شود و گروه‌های دستی می‌مانند.
* @returns آیا گروه شخص عوض شد
*/
private async applyTahesabGroup(
organizationId: string,
connectionId: string,
personId: string,
row: Record<string, unknown> | undefined,
): Promise<boolean> {
const group = row ? pickGroup(row) : null;
if (!group) return false;
try {
const groupId = await this.groups.ensureTahesabGroup(
organizationId,
connectionId,
group.gid,
group.name,
);
return await this.groups.setPersonTahesabGroup(personId, groupId);
} catch (err) {
this.logger.warn(
`گروه ته‌حساب برای شخص ${personId} ثبت نشد: ${err instanceof Error ? err.message : err}`,
);
return false;
}
}
private async getPerson(organizationId: string, personId: string) { private async getPerson(organizationId: string, personId: string) {
const person = await this.prisma.person.findFirst({ const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId }, where: { id: personId, organizationId },
@@ -305,10 +374,17 @@ export class TahesabPersonLinkService {
person.mobile ?? person.mobile ??
person.phone ?? person.phone ??
''; '';
const extra = (person.customFields ?? {}) as Record<string, unknown>;
const nationalId = typeof extra.nationalId === 'string' ? extra.nationalId.trim() : '';
const groupName =
dto.groupName?.trim() || (await this.groups.firstGroupName(personId)) || 'CRM';
const args = SanadBuilders.newMoshtari({ const args = SanadBuilders.newMoshtari({
name: `${person.firstName} ${person.lastName}`.trim(), name: `${person.firstName} ${person.lastName}`.trim(),
groupName: dto.groupName ?? 'CRM', groupName,
tel, tel,
address: firstAddress(person.addresses),
codeMeli: nationalId || undefined,
tarikhTavalod: person.birthDate ? formatJalaliDate(person.birthDate) : undefined,
jensFelez: dto.jensFelez ?? 0, jensFelez: dto.jensFelez ?? 0,
}); });
const res = await api.doNewMoshtari(args); const res = await api.doNewMoshtari(args);
@@ -482,6 +558,7 @@ export class TahesabPersonLinkService {
organizationId: string, organizationId: string,
dto: ImportFromTahesabDto, dto: ImportFromTahesabDto,
prefetched?: { api: TahesabApi; row?: Record<string, unknown> }, prefetched?: { api: TahesabApi; row?: Record<string, unknown> },
options?: { skipJob?: boolean },
) { ) {
const clash = await this.prisma.personAccountingLink.findFirst({ const clash = await this.prisma.personAccountingLink.findFirst({
where: { where: {
@@ -517,13 +594,14 @@ export class TahesabPersonLinkService {
} }
const name = pickName(row) || `مشتری ته‌حساب ${dto.moshtariCode}`; const name = pickName(row) || `مشتری ته‌حساب ${dto.moshtariCode}`;
const tel = pickTel(row); const tel = pickTel(row);
const parts = name.trim().split(/\s+/); const named = applyPersonGenderTitle({ firstName: name, lastName: '' });
const firstName = parts[0] || name; const firstName = named.firstName;
const lastName = parts.slice(1).join(' ') || '—'; const lastName = named.lastName;
const mobile = tel || null; const mobile = storedPhoneNumber(tel);
const sourceRef = String(dto.moshtariCode); const sourceRef = String(dto.moshtariCode);
let personId = dto.personId; let personId = dto.personId;
let preserveExistingLink = false;
if (personId) { if (personId) {
const existing = await this.getPerson(organizationId, personId); const existing = await this.getPerson(organizationId, personId);
const prevFirst = existing.firstName; const prevFirst = existing.firstName;
@@ -535,6 +613,8 @@ export class TahesabPersonLinkService {
data: { data: {
firstName, firstName,
lastName, lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
...(nameChanged ...(nameChanged
? { ? {
previousFirstName: prevFirst || null, previousFirstName: prevFirst || null,
@@ -545,28 +625,33 @@ export class TahesabPersonLinkService {
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, sourceRef,
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}), ...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...(mobile ...canonicalMobileUpdate(mobile, existing),
? {
mobile: existing.mobile || mobile,
phone: existing.phone || mobile,
}
: {}),
}, },
}); });
} else { } else {
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین. // اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین.
// شخصی که روی همین اتصال به کد دیگری لینک است کنار گذاشته می‌شود؛ // اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، لینک جابه‌جا نمی‌شود
// وگرنه upsert لینک، کد قبلی را جابه‌جا و آن مشتری را دوباره «واردنشده» می‌کند. // تا کد قبلی دوباره «واردنشده» نشود و مشتری تکراری ساخته نشود.
const byMobile = mobile const mobileKeys = phoneIdentityKeys(mobile);
? await this.prisma.person.findFirst({ const matches = mobileKeys.length
? await this.prisma.person.findMany({
where: { where: {
organizationId, organizationId,
OR: [{ mobile }, { phone: mobile }], OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
accountingLinks: { none: { connectionId: dto.connectionId } },
}, },
include: {
accountingLinks: {
where: { connectionId: dto.connectionId },
select: { moshtariCode: true },
},
},
orderBy: { createdAt: 'asc' },
}) })
: null; : [];
const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null;
if (byMobile) { if (byMobile) {
const linkedCode = byMobile.accountingLinks[0]?.moshtariCode;
preserveExistingLink = linkedCode != null && linkedCode !== dto.moshtariCode;
const nameChanged = const nameChanged =
`${byMobile.firstName} ${byMobile.lastName}`.trim() !== `${byMobile.firstName} ${byMobile.lastName}`.trim() !==
`${firstName} ${lastName}`.trim(); `${firstName} ${lastName}`.trim();
@@ -575,6 +660,8 @@ export class TahesabPersonLinkService {
data: { data: {
firstName, firstName,
lastName, lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
...(nameChanged ...(nameChanged
? { ? {
previousFirstName: byMobile.firstName || null, previousFirstName: byMobile.firstName || null,
@@ -583,8 +670,9 @@ export class TahesabPersonLinkService {
: {}), : {}),
...carryPreviousSource(byMobile, 'tahesab'), ...carryPreviousSource(byMobile, 'tahesab'),
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, sourceRef: preserveExistingLink ? String(linkedCode) : sourceRef,
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}), ...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...canonicalMobileUpdate(mobile, byMobile),
}, },
}); });
personId = byMobile.id; personId = byMobile.id;
@@ -594,6 +682,8 @@ export class TahesabPersonLinkService {
organizationId, organizationId,
firstName, firstName,
lastName, lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
mobile, mobile,
phone: mobile, phone: mobile,
notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`, notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`,
@@ -606,6 +696,32 @@ export class TahesabPersonLinkService {
} }
} }
if (preserveExistingLink) {
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
const existing = await this.prisma.personAccountingLink.findFirst({
where: { personId, connectionId: dto.connectionId },
include: {
connection: { select: { label: true, dbName: true, isDefault: true } },
},
});
if (!existing) {
throw new NotFoundException('لینک ته‌حساب این مشتری یافت نشد.');
}
if (!options?.skipJob) {
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId: dto.connectionId,
kind: 'import',
status: 'success',
request: dto as unknown as Prisma.InputJsonValue,
result: { personId, linkId: existing.id, keptLink: true } as Prisma.InputJsonValue,
},
});
}
return { personId, link: this.linkDto(existing) };
}
const link = await this.prisma.personAccountingLink.upsert({ const link = await this.prisma.personAccountingLink.upsert({
where: { where: {
personId_connectionId: { personId_connectionId: {
@@ -628,23 +744,176 @@ export class TahesabPersonLinkService {
connection: { select: { label: true, dbName: true, isDefault: true } }, connection: { select: { label: true, dbName: true, isDefault: true } },
}, },
}); });
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
await this.prisma.accountingSyncJob.create({ if (!options?.skipJob) {
data: { await this.prisma.accountingSyncJob.create({
organizationId, data: {
connectionId: dto.connectionId, organizationId,
kind: 'import', connectionId: dto.connectionId,
status: 'success', kind: 'import',
request: dto as unknown as Prisma.InputJsonValue, status: 'success',
result: { personId, linkId: link.id } as Prisma.InputJsonValue, request: dto as unknown as Prisma.InputJsonValue,
}, result: { personId, linkId: link.id } as Prisma.InputJsonValue,
}); },
});
}
return { personId, link: this.linkDto(link) }; return { personId, link: this.linkDto(link) };
} }
/** نام، موبایل و گروه مشتریِ از قبل لینک‌شده را با ردیف ته‌حساب تازه می‌کند. */
private async refreshLinkedCustomer(
organizationId: string,
connectionId: string,
personId: string,
moshtariCode: number,
row: Record<string, unknown>,
) {
const existing = await this.getPerson(organizationId, personId);
const picked = pickName(row).trim();
const named = picked
? applyPersonGenderTitle({ firstName: picked, lastName: '' })
: null;
const nameChanged =
named != null &&
namesDiffer(
existing.firstName,
existing.lastName,
named.firstName,
named.lastName,
);
await this.prisma.person.update({
where: { id: personId },
data: {
...(named
? {
firstName: named.firstName,
lastName: named.lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
}
: {}),
...(nameChanged
? {
previousFirstName: existing.firstName || null,
previousLastName: existing.lastName || null,
}
: {}),
...carryPreviousSource(existing, 'tahesab'),
importSource: 'tahesab',
sourceRef: String(moshtariCode),
...(!existing.birthDate && pickBirthDate(row)
? { birthDate: pickBirthDate(row) }
: {}),
...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing),
},
});
await this.applyTahesabGroup(organizationId, connectionId, personId, row);
}
/**
* همهٔ مشتریان ته‌حساب را صفحه به صفحه می‌خواند.
* کد تازه وارد می‌شود؛ کد لینک‌شده نام و شماره و گروهش تازه می‌شود.
*/
async syncAllCustomers(organizationId: string, connectionId: string) {
const { api } = await this.connections.getApi(organizationId, connectionId);
let page = 1;
let emptyStreak = 0;
let imported = 0;
let updated = 0;
let failed = 0;
const errors: string[] = [];
const maxPages = 300;
while (emptyStreak < 2 && page <= maxPages) {
let listed: Awaited<ReturnType<typeof this.listMoshtariPage>>;
try {
listed = await this.listMoshtariPage(api, page, LIST_PAGE_SIZE);
} catch (err) {
const message = err instanceof Error ? err.message : 'خواندن ته‌حساب ناموفق بود';
errors.push(message);
break;
}
if (!listed.rows.length) {
emptyStreak += 1;
page += 1;
continue;
}
emptyStreak = 0;
const rowsByCode = new Map<number, Record<string, unknown>>();
for (const row of listed.rows) {
const code = pickCode(row);
if (code != null && code >= 1) rowsByCode.set(code, row);
}
const links = rowsByCode.size
? await this.prisma.personAccountingLink.findMany({
where: {
connectionId,
moshtariCode: { in: [...rowsByCode.keys()] },
},
select: { moshtariCode: true, personId: true },
})
: [];
const linkedByCode = new Map(links.map((link) => [link.moshtariCode, link.personId]));
for (const [code, row] of rowsByCode) {
try {
const personId = linkedByCode.get(code);
if (personId) {
await this.refreshLinkedCustomer(
organizationId,
connectionId,
personId,
code,
row,
);
updated += 1;
} else {
await this.importFromTahesab(
organizationId,
{ connectionId, moshtariCode: code },
{ api, row },
{ skipJob: true },
);
imported += 1;
}
} catch (err) {
failed += 1;
if (errors.length < 5) {
errors.push(err instanceof Error ? err.message : 'خطا');
}
}
}
page += 1;
}
if (page > maxPages && emptyStreak < 2) {
errors.push('فهرست مشتریان از سقف خواندن خودکار بیشتر بود.');
}
const summary = `${imported} وارد شد، ${updated} به‌روز شد، ${failed} خطا`;
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId,
kind: 'customer-sync',
status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success',
result: { imported, updated, failed, pages: page - 1 } as Prisma.InputJsonValue,
},
});
this.logger.log(`همگام‌سازی مشتریان ته‌حساب ${connectionId}: ${summary}`);
return {
imported,
updated,
failed,
summary,
error: errors.length ? errors.join('؛ ') : null,
};
}
async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) { async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) {
let codes = (dto.moshtariCodes ?? []).filter((n) => Number.isFinite(n) && n >= 1); let codes = (dto.moshtariCodes ?? []).filter((n) => Number.isFinite(n) && n >= 1);
const rowsByCode = new Map<number, Record<string, unknown>>();
if (dto.listAll) { if (dto.listAll) {
const { api } = await this.connections.getApi( const { api } = await this.connections.getApi(
@@ -656,10 +925,11 @@ export class TahesabPersonLinkService {
dto.page ?? 1, dto.page ?? 1,
dto.pageSize, dto.pageSize,
); );
const listedCodes = listed.rows for (const row of listed.rows) {
.map((row) => pickCode(row)) const code = pickCode(row);
.filter((c): c is number => c != null); if (code != null) rowsByCode.set(code, row);
codes = [...new Set([...codes, ...listedCodes])]; }
codes = [...new Set([...codes, ...rowsByCode.keys()])];
} else if (dto.fromCode != null && dto.toCode != null) { } else if (dto.fromCode != null && dto.toCode != null) {
if (dto.toCode < dto.fromCode) { if (dto.toCode < dto.fromCode) {
throw new BadRequestException('کد پایان باید بزرگ‌تر یا مساوی کد شروع باشد.'); throw new BadRequestException('کد پایان باید بزرگ‌تر یا مساوی کد شروع باشد.');
@@ -674,10 +944,11 @@ export class TahesabPersonLinkService {
dto.connectionId, dto.connectionId,
); );
const raw = await api.doListMoshtariByCode(dto.fromCode, dto.toCode); const raw = await api.doListMoshtariByCode(dto.fromCode, dto.toCode);
const listed = asMoshtariList(raw) for (const row of asMoshtariList(raw)) {
.map((row) => pickCode(row)) const code = pickCode(row);
.filter((c): c is number => c != null); if (code != null) rowsByCode.set(code, row);
codes = [...new Set([...codes, ...listed])]; }
codes = [...new Set([...codes, ...rowsByCode.keys()])];
} }
if (!codes.length) { if (!codes.length) {
@@ -708,11 +979,13 @@ export class TahesabPersonLinkService {
const pending = codes.filter((c) => !linkedByCode.has(c)); const pending = codes.filter((c) => !linkedByCode.has(c));
let bulkApi: TahesabApi | undefined; let bulkApi: TahesabApi | undefined;
const rowsByCode = new Map<number, Record<string, unknown>>(); const missingRows = pending.filter((c) => !rowsByCode.has(c));
if (pending.length) { if (pending.length) {
bulkApi = (await this.connections.getApi(organizationId, dto.connectionId)).api; bulkApi = (await this.connections.getApi(organizationId, dto.connectionId)).api;
const minCode = Math.min(...pending); }
const maxCode = Math.max(...pending); if (bulkApi && missingRows.length) {
const minCode = Math.min(...missingRows);
const maxCode = Math.max(...missingRows);
if (maxCode - minCode <= 2000) { if (maxCode - minCode <= 2000) {
try { try {
const raw = await bulkApi.doListMoshtariByCode(minCode, maxCode); const raw = await bulkApi.doListMoshtariByCode(minCode, maxCode);
@@ -737,6 +1010,12 @@ export class TahesabPersonLinkService {
data: { birthDate }, data: { birthDate },
}); });
} }
await this.applyTahesabGroup(
organizationId,
dto.connectionId,
linkedPersonId,
rowsByCode.get(moshtariCode),
);
items.push({ items.push({
moshtariCode, moshtariCode,
ok: true, ok: true,
@@ -776,6 +1055,146 @@ export class TahesabPersonLinkService {
return { imported, skipped, failed, items }; return { imported, skipped, failed, items };
} }
/** یک صفحه (بازهٔ کد) از ته‌حساب: گروه مشتری‌های لینک‌شده به‌روز می‌شود */
async syncGroupsPage(organizationId: string, dto: SyncGroupsPageDto) {
const { api } = await this.connections.getApi(organizationId, dto.connectionId);
const listed = await this.listMoshtariPage(api, dto.page ?? 1, dto.pageSize);
const rowsByCode = new Map<number, Record<string, unknown>>();
for (const row of listed.rows) {
const code = pickCode(row);
if (code != null) rowsByCode.set(code, row);
}
const links = rowsByCode.size
? await this.prisma.personAccountingLink.findMany({
where: { connectionId: dto.connectionId, moshtariCode: { in: [...rowsByCode.keys()] } },
select: { moshtariCode: true, personId: true },
})
: [];
let updated = 0;
for (const link of links) {
const changed = await this.applyTahesabGroup(
organizationId,
dto.connectionId,
link.personId,
rowsByCode.get(link.moshtariCode),
);
if (changed) updated += 1;
}
return {
page: listed.page,
fromCode: listed.fromCode,
toCode: listed.toCode,
rows: listed.rows.length,
linked: links.length,
updated,
};
}
/** اشخاصی که روی این اتصال به ته‌حساب لینک نیستند */
async unlinkedPeople(organizationId: string, connectionId: string) {
await this.connections.getOrThrow(organizationId, connectionId);
const rows = await this.prisma.person.findMany({
where: { organizationId, accountingLinks: { none: { connectionId } } },
select: { id: true },
orderBy: { createdAt: 'asc' },
});
return { ids: rows.map((r) => r.id), total: rows.length };
}
/**
* ثبت گروهی در ته‌حساب: برای هر نفر اول تطبیق (موبایل، بعد نام)؛
* تطبیق یکتا → لینک، چند نتیجه → رد می‌شود تا دستی بررسی شود، بدون نتیجه → ساخت مشتری.
*/
async exportPeople(organizationId: string, dto: ExportPeopleToTahesabDto) {
await this.connections.getOrThrow(organizationId, dto.connectionId);
const ids = [...new Set(dto.personIds)];
const people = await this.prisma.person.findMany({
where: { organizationId, id: { in: ids } },
select: {
id: true,
firstName: true,
lastName: true,
accountingLinks: {
where: { connectionId: dto.connectionId },
select: { moshtariCode: true },
},
},
});
const byId = new Map(people.map((p) => [p.id, p]));
const items: Array<{
personId: string;
name: string;
status: 'matched' | 'created' | 'ambiguous' | 'skipped' | 'error';
moshtariCode?: number;
error?: string;
}> = [];
for (const personId of ids) {
const person = byId.get(personId);
if (!person) {
items.push({ personId, name: '', status: 'error', error: 'شخص یافت نشد.' });
continue;
}
const name = `${person.firstName} ${person.lastName}`.trim();
const existing = person.accountingLinks[0];
if (existing) {
items.push({ personId, name, status: 'skipped', moshtariCode: existing.moshtariCode });
continue;
}
try {
const matched = await this.match(organizationId, personId, {
connectionId: dto.connectionId,
autoLinkIfUnique: true,
});
if (matched.status === 'linked' && matched.link) {
items.push({
personId,
name,
status: 'matched',
moshtariCode: matched.link.moshtariCode,
});
} else if (matched.status === 'none') {
const link = await this.createInTahesab(organizationId, personId, {
connectionId: dto.connectionId,
});
items.push({ personId, name, status: 'created', moshtariCode: link.moshtariCode });
} else {
items.push({ personId, name, status: 'ambiguous' });
}
} catch (e) {
items.push({
personId,
name,
status: 'error',
error: e instanceof Error ? e.message : 'ثبت ناموفق بود',
});
}
}
const count = (s: (typeof items)[number]['status']) =>
items.filter((i) => i.status === s).length;
const summary = {
matched: count('matched'),
created: count('created'),
ambiguous: count('ambiguous'),
skipped: count('skipped'),
failed: count('error'),
};
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId: dto.connectionId,
kind: 'export',
status: summary.failed && !summary.matched && !summary.created ? 'error' : 'success',
request: { count: ids.length } as Prisma.InputJsonValue,
result: summary as Prisma.InputJsonValue,
},
});
return { ...summary, items };
}
/** مشتری‌های قبلاً لینک‌شده که تاریخ تولد ندارند را از فهرست ته‌حساب پر می‌کند. */ /** مشتری‌های قبلاً لینک‌شده که تاریخ تولد ندارند را از فهرست ته‌حساب پر می‌کند. */
async fillMissingBirthDates() { async fillMissingBirthDates() {
const connections = await this.prisma.accountingConnection.findMany({ const connections = await this.prisma.accountingConnection.findMany({
@@ -0,0 +1,220 @@
/** طبقه‌بندی و خالص‌کردن فروش/خرید بعد از مرجوعی، برای گزارش ته‌حساب. */
export const SALES_FACTOR_KEYS = [
'karsakhte',
'etiket_gold',
'etiket_jewel',
'bullion',
'other',
] as const;
export type SalesFactorKey = (typeof SALES_FACTOR_KEYS)[number];
export const SALES_FACTOR_LABELS: Record<SalesFactorKey, string> = {
karsakhte: 'کار ساخته',
etiket_gold: 'اتیکت طلا',
etiket_jewel: 'اتیکت جواهر',
bullion: 'شمش و سکه',
other: 'سایر',
};
export type DocDirection = 'in' | 'out' | 'purchase_return' | 'sale_return' | 'other';
export type SalesDoc = {
bucketKey: string;
factor: SalesFactorKey;
direction: DocDirection;
weight: number;
mali: number;
count: number;
percent: number | null;
tarikh: string;
};
export type PercentStats = {
first: number | null;
last: number | null;
min: number | null;
max: number | null;
avg: number | null;
};
export type MovementStats = {
grossWeight: number;
returnWeight: number;
netWeight: number;
grossMali: number;
returnMali: number;
netMali: number;
grossCount: number;
returnCount: number;
netCount: number;
percent: PercentStats | null;
};
const EMPTY_MOVE: MovementStats = {
grossWeight: 0,
returnWeight: 0,
netWeight: 0,
grossMali: 0,
returnMali: 0,
netMali: 0,
grossCount: 0,
returnCount: 0,
netCount: 0,
percent: null,
};
function norm(raw: string): string {
return raw.replace(/\u064A/g, '\u06CC').replace(/\u0643/g, '\u06A9').replace(/\s+/g, ' ').trim();
}
export function classifyDirection(noRaw: string): DocDirection {
const no = norm(noRaw);
if (no.includes('ورود کارساخته مرجوع')) return 'sale_return';
if (no.includes('خروج کارساخته مرجوع')) return 'purchase_return';
if (no.includes('ورود کارساخته')) return 'in';
if (no.includes('خروج کارساخته')) return 'out';
const isReturn = /مرجوع|برگشت/.test(no);
const isBuy = /خرید/.test(no);
const isSell = /فروش/.test(no);
if (isReturn && isBuy) return 'purchase_return';
if (isReturn) return 'sale_return';
if (isBuy || (/ورود/.test(no) && !isReturn)) return 'in';
if (isSell || (/خروج/.test(no) && !isReturn)) return 'out';
return 'other';
}
export function classifyFactor(noRaw: string, noKar: string | null | undefined): SalesFactorKey {
const blob = norm(`${noRaw} ${noKar ?? ''}`);
if (blob.includes('کارساخته') || blob.includes('کار ساخته')) return 'karsakhte';
if (/شمش|سکه/.test(blob)) return 'bullion';
if (/جواهر|ساچمه/.test(blob)) return 'etiket_jewel';
if (blob.includes('اتیکت')) return 'etiket_gold';
return 'other';
}
export function combinedPercent(
darsad: number | null | undefined,
talayee: number | null | undefined,
maliat: number | null | undefined,
): number | null {
const parts = [darsad, talayee, maliat].filter(
(n): n is number => n != null && Number.isFinite(n),
);
if (!parts.length) return null;
return parts.reduce((sum, n) => sum + n, 0);
}
export function positiveAmount(vazn: number | null | undefined, tabdil: number | null | undefined): number {
if (vazn != null && Number.isFinite(vazn) && vazn !== 0) return Math.abs(vazn);
if (tabdil != null && Number.isFinite(tabdil)) return Math.abs(tabdil);
return 0;
}
type Piece = {
tarikh: string;
seq: number;
weight: number;
mali: number;
count: number;
percent: number | null;
};
/** مرجوعی از آخرین سندهای همان نام کم می‌شود تا فروش/خریدِ برگشتی در آمار نماند. */
function remainingAfterReturns(gross: Piece[], returns: Piece[]): Piece[] {
const docs = gross.map((d) => ({ ...d }));
let leftW = returns.reduce((s, d) => s + d.weight, 0);
let leftM = returns.reduce((s, d) => s + d.mali, 0);
let leftC = returns.reduce((s, d) => s + d.count, 0);
const order = [...docs].sort((a, b) => {
if (a.tarikh !== b.tarikh) return b.tarikh.localeCompare(a.tarikh);
return b.seq - a.seq;
});
for (const doc of order) {
if (leftW <= 0 && leftM <= 0 && leftC <= 0) break;
const takeW = Math.min(doc.weight, Math.max(leftW, 0));
const takeM = Math.min(doc.mali, Math.max(leftM, 0));
const takeC = Math.min(doc.count, Math.max(leftC, 0));
doc.weight -= takeW;
doc.mali -= takeM;
doc.count -= takeC;
leftW -= takeW;
leftM -= takeM;
leftC -= takeC;
}
return docs.filter((d) => d.weight > 1e-9 || d.mali > 1e-6 || d.count > 1e-9);
}
function percentOf(docs: Piece[]): PercentStats | null {
const withPercent = docs.filter((d) => d.percent != null && d.weight > 1e-9);
if (!withPercent.length) return null;
const ordered = [...withPercent].sort((a, b) => {
if (a.tarikh !== b.tarikh) return a.tarikh.localeCompare(b.tarikh);
return a.seq - b.seq;
});
const values = ordered.map((d) => d.percent!);
const weight = ordered.reduce((s, d) => s + d.weight, 0);
const avg =
weight > 0
? ordered.reduce((s, d) => s + d.percent! * d.weight, 0) / weight
: null;
return {
first: ordered[0]?.percent ?? null,
last: ordered[ordered.length - 1]?.percent ?? null,
min: Math.min(...values),
max: Math.max(...values),
avg,
};
}
export function movementStats(lines: SalesDoc[], side: 'in' | 'out'): MovementStats {
const returnDir: DocDirection = side === 'in' ? 'purchase_return' : 'sale_return';
const groups = new Map<string, { gross: Piece[]; ret: Piece[] }>();
let seq = 0;
for (const line of lines) {
if (line.direction !== side && line.direction !== returnDir) continue;
const bag = groups.get(line.bucketKey) ?? { gross: [], ret: [] };
const piece: Piece = {
tarikh: line.tarikh,
seq: seq++,
weight: Math.abs(line.weight),
mali: Math.abs(line.mali),
count: Math.abs(line.count),
percent: line.percent,
};
if (line.direction === side) bag.gross.push(piece);
else bag.ret.push(piece);
groups.set(line.bucketKey, bag);
}
if (!groups.size) return { ...EMPTY_MOVE };
let grossWeight = 0;
let returnWeight = 0;
let grossMali = 0;
let returnMali = 0;
let grossCount = 0;
let returnCount = 0;
const kept: Piece[] = [];
for (const bag of groups.values()) {
grossWeight += bag.gross.reduce((s, d) => s + d.weight, 0);
returnWeight += bag.ret.reduce((s, d) => s + d.weight, 0);
grossMali += bag.gross.reduce((s, d) => s + d.mali, 0);
returnMali += bag.ret.reduce((s, d) => s + d.mali, 0);
grossCount += bag.gross.reduce((s, d) => s + d.count, 0);
returnCount += bag.ret.reduce((s, d) => s + d.count, 0);
kept.push(...remainingAfterReturns(bag.gross, bag.ret));
}
return {
grossWeight,
returnWeight,
netWeight: Math.max(0, grossWeight - returnWeight),
grossMali,
returnMali,
netMali: Math.max(0, grossMali - returnMali),
grossCount,
returnCount,
netCount: Math.max(0, grossCount - returnCount),
percent: percentOf(kept),
};
}
@@ -1,4 +1,5 @@
import { import {
BadRequestException,
Body, Body,
Controller, Controller,
Delete, Delete,
@@ -13,14 +14,19 @@ 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 { OrgModuleGuard } from '../../billing/org-module.guard';
import { RequireOrgModule } from '../../billing/org-module.decorator';
import { TahesabConnectionsService } from './connections.service'; import { TahesabConnectionsService } from './connections.service';
import { TahesabPersonLinkService } from './person-link.service'; import { TahesabPersonLinkService } from './person-link.service';
import { TahesabProductLinkService } from './product-link.service'; import { TahesabProductLinkService } from './product-link.service';
import { TahesabBalanceService } from './balance.service'; import { TahesabBalanceService } from './balance.service';
import { TahesabDocumentsService } from './documents.service'; import { TahesabDocumentsService } from './documents.service';
import { TahesabAsnadReportService } from './asnad-report.service';
import { TahesabInvoiceSettingsService } from './invoice-settings.service';
import { import {
CreateInTahesabDto, CreateInTahesabDto,
CreateTahesabConnectionDto, CreateTahesabConnectionDto,
ExportPeopleToTahesabDto,
ImportBulkFromTahesabDto, ImportBulkFromTahesabDto,
ImportBulkProductsFromTahesabDto, ImportBulkProductsFromTahesabDto,
ImportFromTahesabDto, ImportFromTahesabDto,
@@ -30,11 +36,15 @@ import {
PushSanadDto, PushSanadDto,
SearchTahesabDto, SearchTahesabDto,
SearchTahesabProductsDto, SearchTahesabProductsDto,
SyncAsnadReportDto,
SyncGroupsPageDto,
UpdateTahesabConnectionDto, UpdateTahesabConnectionDto,
UpdateTahesabInvoiceSettingsDto,
} from './dto'; } from './dto';
@Controller('integrations/tahesab') @Controller('integrations/tahesab')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@RequireOrgModule('tahesab')
export class TahesabController { export class TahesabController {
constructor( constructor(
private readonly connections: TahesabConnectionsService, private readonly connections: TahesabConnectionsService,
@@ -42,6 +52,8 @@ export class TahesabController {
private readonly products: TahesabProductLinkService, private readonly products: TahesabProductLinkService,
private readonly balance: TahesabBalanceService, private readonly balance: TahesabBalanceService,
private readonly documents: TahesabDocumentsService, private readonly documents: TahesabDocumentsService,
private readonly asnadReport: TahesabAsnadReportService,
private readonly invoiceSettings: TahesabInvoiceSettingsService,
) {} ) {}
// —— Connections —— // —— Connections ——
@@ -216,6 +228,33 @@ export class TahesabController {
return this.links.importBulk(user.organizationId, dto); return this.links.importBulk(user.organizationId, dto);
} }
@Post('sync/groups')
@Roles('owner', 'admin')
syncGroups(
@CurrentUser() user: { organizationId: string },
@Body() dto: SyncGroupsPageDto,
) {
return this.links.syncGroupsPage(user.organizationId, dto);
}
@Get('sync/unlinked-people')
@Roles('owner', 'admin')
unlinkedPeople(
@CurrentUser() user: { organizationId: string },
@Query('connectionId') connectionId: string,
) {
return this.links.unlinkedPeople(user.organizationId, connectionId);
}
@Post('sync/export-people')
@Roles('owner', 'admin')
exportPeople(
@CurrentUser() user: { organizationId: string },
@Body() dto: ExportPeopleToTahesabDto,
) {
return this.links.exportPeople(user.organizationId, dto);
}
@Post('sync/products/search') @Post('sync/products/search')
@Roles('owner', 'admin', 'sales') @Roles('owner', 'admin', 'sales')
searchProducts( searchProducts(
@@ -242,4 +281,157 @@ export class TahesabController {
) { ) {
return this.products.importBulk(user.organizationId, dto); return this.products.importBulk(user.organizationId, dto);
} }
// —— تنظیمات فاکتور PDF اسناد ته‌حساب ——
@Get('invoice-settings')
getInvoiceSettings(@CurrentUser() user: { organizationId: string }) {
return this.invoiceSettings.getOrCreate(user.organizationId);
}
@Patch('invoice-settings')
@Roles('owner', 'admin')
updateInvoiceSettings(
@CurrentUser() user: { organizationId: string },
@Body() dto: UpdateTahesabInvoiceSettingsDto,
) {
return this.invoiceSettings.update(user.organizationId, dto);
}
// —— گزارش فروش اسناد (اسنپ‌شات DoListAsnad) ——
@Post('reports/asnad/sync')
@Roles('owner', 'admin', 'sales')
syncAsnadReport(
@CurrentUser() user: { organizationId: string },
@Body() dto: SyncAsnadReportDto,
) {
return this.asnadReport.startSync(
user.organizationId,
dto.connectionId,
dto.from,
dto.to,
{ personId: dto.personId, groupIds: dto.groupIds },
);
}
@Get('reports/asnad/customers')
searchAsnadCustomers(
@CurrentUser() user: { organizationId: string },
@Query('connectionId') connectionId?: string,
@Query('q') q?: string,
) {
return this.asnadReport.searchCustomers(
user.organizationId,
connectionId,
q ?? '',
);
}
@Get('reports/asnad/sync/:jobId')
syncAsnadReportStatus(
@CurrentUser() user: { organizationId: string },
@Param('jobId') jobId: string,
) {
return this.asnadReport.getSyncJob(user.organizationId, jobId);
}
@Get('reports/asnad/overview')
asnadReportOverview(
@CurrentUser() user: { organizationId: string },
@Query('connectionId') connectionId?: string,
@Query('from') from?: string,
@Query('to') to?: string,
@Query('groupIds') groupIds?: string | string[],
@Query('personId') personId?: string,
@Query('jensFelez') jensFelez?: string,
) {
if (!from || !to) {
throw new BadRequestException('پارامتر from و to الزامی است.');
}
return this.asnadReport.overview(user.organizationId, {
connectionId,
from,
to,
...this.parseAsnadFilters(groupIds, personId, undefined, jensFelez),
});
}
@Get('reports/asnad/summary')
asnadReportSummary(
@CurrentUser() user: { organizationId: string },
@Query('connectionId') connectionId?: string,
@Query('from') from?: string,
@Query('to') to?: string,
@Query('groupIds') groupIds?: string | string[],
@Query('personId') personId?: string,
@Query('kinds') kinds?: string | string[],
@Query('jensFelez') jensFelez?: string,
) {
if (!from || !to) {
throw new BadRequestException('پارامتر from و to الزامی است.');
}
return this.asnadReport.summary(user.organizationId, {
connectionId,
from,
to,
...this.parseAsnadFilters(groupIds, personId, kinds, jensFelez),
});
}
@Get('reports/asnad')
listAsnadReport(
@CurrentUser() user: { organizationId: string },
@Query('connectionId') connectionId?: string,
@Query('from') from?: string,
@Query('to') to?: string,
@Query('groupIds') groupIds?: string | string[],
@Query('personId') personId?: string,
@Query('kinds') kinds?: string | string[],
@Query('jensFelez') jensFelez?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
if (!from || !to) {
throw new BadRequestException('پارامتر from و to الزامی است.');
}
return this.asnadReport.query(user.organizationId, {
connectionId,
from,
to,
...this.parseAsnadFilters(groupIds, personId, kinds, jensFelez),
page: page ? Number(page) : undefined,
pageSize: pageSize ? Number(pageSize) : undefined,
});
}
private parseAsnadFilters(
groupIds?: string | string[],
personId?: string,
kinds?: string | string[],
jensFelez?: string,
) {
const groups = Array.isArray(groupIds)
? groupIds
: groupIds
? groupIds
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: undefined;
const kindList = Array.isArray(kinds)
? kinds
: kinds
? kinds
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: undefined;
const jf =
jensFelez != null && jensFelez !== '' ? Number(jensFelez) : undefined;
return {
groupIds: groups,
personId,
kinds: kindList,
jensFelez: jf != null && Number.isFinite(jf) ? jf : undefined,
};
}
} }
@@ -10,10 +10,14 @@ import {
import { TahesabProductLinkService } from './product-link.service'; import { TahesabProductLinkService } from './product-link.service';
import { TahesabBalanceService } from './balance.service'; import { TahesabBalanceService } from './balance.service';
import { TahesabDocumentsService } from './documents.service'; import { TahesabDocumentsService } from './documents.service';
import { TahesabAsnadReportService } from './asnad-report.service';
import { TahesabInvoiceSettingsService } from './invoice-settings.service';
import { TahesabCustomerSyncScheduler } from './customer-sync.scheduler';
import { TahesabController } from './tahesab.controller'; import { TahesabController } from './tahesab.controller';
import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module';
@Module({ @Module({
imports: [AuthModule], imports: [AuthModule, CustomerGroupsModule],
controllers: [TahesabController], controllers: [TahesabController],
providers: [ providers: [
TahesabCryptoService, TahesabCryptoService,
@@ -24,6 +28,9 @@ import { TahesabController } from './tahesab.controller';
TahesabProductLinkService, TahesabProductLinkService,
TahesabBalanceService, TahesabBalanceService,
TahesabDocumentsService, TahesabDocumentsService,
TahesabAsnadReportService,
TahesabInvoiceSettingsService,
TahesabCustomerSyncScheduler,
], ],
exports: [ exports: [
TahesabCryptoService, TahesabCryptoService,
@@ -32,6 +39,8 @@ import { TahesabController } from './tahesab.controller';
TahesabProductLinkService, TahesabProductLinkService,
TahesabBalanceService, TahesabBalanceService,
TahesabDocumentsService, TahesabDocumentsService,
TahesabAsnadReportService,
TahesabInvoiceSettingsService,
], ],
}) })
export class TahesabModule {} export class TahesabModule {}
@@ -11,7 +11,7 @@ import type {
} from '@mento/shared'; } from '@mento/shared';
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 { normalizePhone } from '../../contacts/normalize'; import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize';
import { VoipConnectionsService } from './connections.service'; import { VoipConnectionsService } from './connections.service';
function digitsOnly(value: string | null | undefined): string { function digitsOnly(value: string | null | undefined): string {
@@ -35,6 +35,7 @@ function phoneVariants(raw: string | null | undefined): string[] {
variants.add(`0${d}`); variants.add(`0${d}`);
variants.add(`98${d}`); variants.add(`98${d}`);
} }
for (const key of iranMobileLookupKeys(raw)) variants.add(key);
return [...variants]; return [...variants];
} }
@@ -6,7 +6,11 @@ import type {
WooCommerceSyncResultDto, WooCommerceSyncResultDto,
} from '@mento/shared'; } from '@mento/shared';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { normalizeEmail, normalizePhone } from '../../contacts/normalize'; import {
normalizeEmail,
phoneIdentityKeys,
storedPhoneNumber,
} from '../../contacts/normalize';
import { WooConnectionsService } from './connections.service'; import { WooConnectionsService } from './connections.service';
import type { WooOrder } from './woo-client'; import type { WooOrder } from './woo-client';
@@ -374,7 +378,8 @@ export class WooSyncService {
): Promise<string | null> { ): Promise<string | null> {
const billing = order.billing ?? {}; const billing = order.billing ?? {};
const email = normalizeEmail(billing.email ?? null); const email = normalizeEmail(billing.email ?? null);
const mobile = normalizePhone(billing.phone ?? null); const mobile = storedPhoneNumber(billing.phone ?? null);
const mobileKeys = phoneIdentityKeys(mobile);
const firstName = (billing.first_name ?? '').trim() || 'مشتری'; const firstName = (billing.first_name ?? '').trim() || 'مشتری';
const lastName = (billing.last_name ?? '').trim() || 'ووکامرس'; const lastName = (billing.last_name ?? '').trim() || 'ووکامرس';
@@ -387,7 +392,9 @@ export class WooSyncService {
organizationId, organizationId,
OR: [ OR: [
...(email ? [{ email }] : []), ...(email ? [{ email }] : []),
...(mobile ? [{ mobile }, { phone: mobile }] : []), ...(mobileKeys.length
? [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }]
: []),
], ],
}, },
select: { id: true }, select: { id: true },
@@ -9,6 +9,7 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
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 { Roles } from '../auth/roles.decorator'; import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard'; import { RolesGuard } from '../auth/roles.guard';
@@ -18,7 +19,8 @@ import { InventoryService } from './inventory.service';
type AuthUser = { organizationId: string }; type AuthUser = { organizationId: string };
@Controller('inventory') @Controller('inventory')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard, SectionPermissionGuard)
@RequirePermission('products')
export class InventoryController { export class InventoryController {
constructor(private readonly inventory: InventoryService) {} constructor(private readonly inventory: InventoryService) {}
+3 -1
View File
@@ -10,6 +10,7 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
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 { import {
CreateInvoiceDto, CreateInvoiceDto,
@@ -22,7 +23,8 @@ import { InvoicesService } from './invoices.service';
type AuthUser = { organizationId: string; userId: string; role: string }; type AuthUser = { organizationId: string; userId: string; role: string };
@Controller('invoices') @Controller('invoices')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, SectionPermissionGuard)
@RequirePermission('billing')
export class InvoicesController { export class InvoicesController {
constructor(private readonly invoicesService: InvoicesService) {} constructor(private readonly invoicesService: InvoicesService) {}
+2
View File
@@ -32,6 +32,8 @@ const MAP_TO = [
'lead.trade', 'lead.trade',
'lead.notes', 'lead.notes',
'lead.source', 'lead.source',
'lead.gender',
'person.gender',
'notes', 'notes',
'custom', 'custom',
] as const; ] as const;
+51 -11
View File
@@ -13,15 +13,20 @@ import type {
SubmitLeadFormResponse, SubmitLeadFormResponse,
UpdateLeadFormRequest, UpdateLeadFormRequest,
} from '@mento/shared'; } from '@mento/shared';
import { isGenderQuestionLabel, parsePersonGenderAnswer } 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 { LocalFileStorage } from '../storage/local-file.storage'; import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.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 { import {
canonicalIranMobile,
emptyToNull, emptyToNull,
normalizeEmail, normalizeEmail,
normalizePhone, normalizePhone,
phoneIdentityKeys,
storedPhoneNumber,
} from '../contacts/normalize'; } from '../contacts/normalize';
import { LeadStagesService } from '../leads/lead-stages.service'; import { LeadStagesService } from '../leads/lead-stages.service';
@@ -83,6 +88,7 @@ export class LeadFormsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly files: LocalFileStorage, private readonly files: LocalFileStorage,
private readonly storage: StorageQuotaService,
private readonly automations: AutomationsService, private readonly automations: AutomationsService,
private readonly messageAutomations: MessageAutomationsService, private readonly messageAutomations: MessageAutomationsService,
private readonly leadStages: LeadStagesService, private readonly leadStages: LeadStagesService,
@@ -210,7 +216,12 @@ export class LeadFormsService {
} }
async remove(organizationId: string, id: string): Promise<void> { async remove(organizationId: string, id: string): Promise<void> {
await this.findInOrg(organizationId, id); const row = await this.findInOrg(organizationId, id);
if (row.coverStorageKey) {
const bytes = await this.files.sizeOf(row.coverStorageKey);
await this.files.remove(row.coverStorageKey);
if (bytes) await this.storage.addLocal(organizationId, -bytes);
}
await this.prisma.leadForm.delete({ where: { id } }); await this.prisma.leadForm.delete({ where: { id } });
} }
@@ -391,6 +402,7 @@ export class LeadFormsService {
sourceId: form.id, sourceId: form.id,
sourceType: 'lead_form', sourceType: 'lead_form',
leadId, leadId,
syncLeadAssignee: !ownerId && leadCreated && !!leadId,
mobile: mapped.mobile ?? mapped.phone ?? null, mobile: mapped.mobile ?? mapped.phone ?? null,
formName: form.name, formName: form.name,
formValues: values, formValues: values,
@@ -449,7 +461,10 @@ export class LeadFormsService {
? await this.prisma.person.findFirst({ ? await this.prisma.person.findFirst({
where: { where: {
organizationId: form.organizationId, organizationId: form.organizationId,
OR: [{ mobile }, { phone: mobile }], OR: [
{ mobile: { in: phoneIdentityKeys(mobile) } },
{ phone: { in: phoneIdentityKeys(mobile) } },
],
}, },
select: { id: true, firstName: true, lastName: true }, select: { id: true, firstName: true, lastName: true },
}) })
@@ -547,6 +562,7 @@ export class LeadFormsService {
trade: emptyToNull(mapped.trade) ?? existingLead.trade, trade: emptyToNull(mapped.trade) ?? existingLead.trade,
source: source ?? existingLead.source, source: source ?? existingLead.source,
notes: emptyToNull(mapped.notes) ?? existingLead.notes, notes: emptyToNull(mapped.notes) ?? existingLead.notes,
...(mapped.gender ? { gender: mapped.gender } : {}),
assigneeId: existingLead.assigneeId ?? assigneeId, assigneeId: existingLead.assigneeId ?? assigneeId,
lastContactAt: new Date(), lastContactAt: new Date(),
}, },
@@ -595,6 +611,7 @@ export class LeadFormsService {
trade: emptyToNull(mapped.trade), trade: emptyToNull(mapped.trade),
source, source,
notes: emptyToNull(mapped.notes), notes: emptyToNull(mapped.notes),
gender: mapped.gender ?? 'none',
stageId, stageId,
position: (maxPos._max.position ?? -1) + 1, position: (maxPos._max.position ?? -1) + 1,
assigneeId, assigneeId,
@@ -630,13 +647,16 @@ export class LeadFormsService {
ownerId: string, ownerId: string,
): Promise<string> { ): Promise<string> {
const email = normalizeEmail(mapped.email); const email = normalizeEmail(mapped.email);
const mobile = normalizePhone(mapped.mobile); const mobile = storedPhoneNumber(mapped.mobile);
const phone = normalizePhone(mapped.phone); const phone = storedPhoneNumber(mapped.phone);
const or: Prisma.PersonWhereInput[] = []; const or: Prisma.PersonWhereInput[] = [];
if (email) or.push({ email }); if (email) or.push({ email });
if (mobile) or.push({ mobile });
const mobileKeys = phoneIdentityKeys(mobile);
if (mobileKeys.length) {
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
}
const existing = or.length const existing = or.length
? await this.prisma.person.findFirst({ ? await this.prisma.person.findFirst({
where: { organizationId, OR: or }, where: { organizationId, OR: or },
@@ -654,6 +674,7 @@ export class LeadFormsService {
phone: phone ?? existing.phone, phone: phone ?? existing.phone,
position: emptyToNull(mapped.position) ?? existing.position, position: emptyToNull(mapped.position) ?? existing.position,
notes: emptyToNull(mapped.notes) ?? existing.notes, notes: emptyToNull(mapped.notes) ?? existing.notes,
...(mapped.gender ? { gender: mapped.gender } : {}),
ownerId: existing.ownerId ?? ownerId, ownerId: existing.ownerId ?? ownerId,
}, },
}); });
@@ -670,6 +691,7 @@ export class LeadFormsService {
phone, phone,
position: emptyToNull(mapped.position), position: emptyToNull(mapped.position),
notes: emptyToNull(mapped.notes), notes: emptyToNull(mapped.notes),
gender: mapped.gender ?? 'none',
ownerId, ownerId,
}, },
}); });
@@ -829,6 +851,9 @@ export class LeadFormsService {
throw new BadRequestException('فرمت تصویر کاور باید PNG، JPG یا WEBP باشد'); throw new BadRequestException('فرمت تصویر کاور باید PNG، JPG یا WEBP باشد');
} }
const key = `lead-forms/${id}/cover.${ext}`; const key = `lead-forms/${id}/cover.${ext}`;
const prevBytes = row.coverStorageKey ? await this.files.sizeOf(row.coverStorageKey) : 0;
const delta = file.buffer.length - prevBytes;
if (delta > 0) await this.storage.assertCanAdd(organizationId, delta);
await this.files.save(key, file.buffer); await this.files.save(key, file.buffer);
if (row.coverStorageKey && row.coverStorageKey !== key) { if (row.coverStorageKey && row.coverStorageKey !== key) {
await this.files.remove(row.coverStorageKey); await this.files.remove(row.coverStorageKey);
@@ -837,12 +862,17 @@ export class LeadFormsService {
where: { id }, where: { id },
data: { coverStorageKey: key }, data: { coverStorageKey: key },
}); });
if (delta) await this.storage.addLocal(organizationId, delta);
return this.get(organizationId, id); return this.get(organizationId, id);
} }
async removeCover(organizationId: string, id: string) { async removeCover(organizationId: string, id: string) {
const row = await this.findInOrg(organizationId, id); const row = await this.findInOrg(organizationId, id);
if (row.coverStorageKey) await this.files.remove(row.coverStorageKey); if (row.coverStorageKey) {
const bytes = await this.files.sizeOf(row.coverStorageKey);
await this.files.remove(row.coverStorageKey);
if (bytes) await this.storage.addLocal(organizationId, -bytes);
}
await this.prisma.leadForm.update({ await this.prisma.leadForm.update({
where: { id }, where: { id },
data: { coverStorageKey: null }, data: { coverStorageKey: null },
@@ -925,6 +955,7 @@ type MappedValues = {
region?: string; region?: string;
trade?: string; trade?: string;
leadSource?: string; leadSource?: string;
gender?: 'male' | 'female' | 'none';
}; };
function mapValues( function mapValues(
@@ -945,6 +976,15 @@ function mapValues(
throw new BadRequestException(`فیلد «${field.label}» الزامی است`); throw new BadRequestException(`فیلد «${field.label}» الزامی است`);
} }
if (!raw) continue; if (!raw) continue;
if (
field.mapTo === 'lead.gender' ||
field.mapTo === 'person.gender' ||
isGenderQuestionLabel(field.label)
) {
const gender = parsePersonGenderAnswer(raw);
if (gender) mapped.gender = gender;
continue;
}
switch (field.mapTo) { switch (field.mapTo) {
case 'person.fullName': { case 'person.fullName': {
const parts = raw.split(/\s+/).filter(Boolean); const parts = raw.split(/\s+/).filter(Boolean);
@@ -1068,7 +1108,8 @@ function collectExtras(
for (const field of fields) { for (const field of fields) {
if (field.fieldType === 'heading' || field.mapTo !== 'custom') continue; if (field.fieldType === 'heading' || field.mapTo !== 'custom') continue;
const raw = (values?.[field.key] ?? '').toString().trim(); const raw = (values?.[field.key] ?? '').toString().trim();
if (raw) lines.push(`${field.label}: ${raw}`); if (!raw || (isGenderQuestionLabel(field.label) && parsePersonGenderAnswer(raw))) continue;
lines.push(`${field.label}: ${raw}`);
} }
return lines.join('\n'); return lines.join('\n');
} }
@@ -1083,13 +1124,12 @@ function slugKey(label: string): string {
} }
function mobileKey(raw: string | null | undefined): string | null { function mobileKey(raw: string | null | undefined): string | null {
const canon = canonicalIranMobile(raw);
if (canon) return canon;
const n = normalizePhone(raw); const n = normalizePhone(raw);
if (!n) return null; if (!n) return null;
const d = n.replace(/\D/g, ''); const d = n.replace(/\D/g, '');
if (!d) return null; return d || null;
if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`;
if (d.length === 10 && d.startsWith('9')) return `0${d}`;
return d.startsWith('0') ? d : d;
} }
function normalizeDuplicateMode(raw?: string | null): string { function normalizeDuplicateMode(raw?: string | null): string {
+5
View File
@@ -8,6 +8,7 @@ import {
MaxLength, MaxLength,
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
import { PERSON_GENDERS } from '@mento/shared';
export class LeadSmsDto { export class LeadSmsDto {
@IsString() @IsString()
@@ -26,6 +27,8 @@ export class CreateLeadDto {
mobile!: string; mobile!: string;
@IsOptional() @IsString() contactName?: string; @IsOptional() @IsString() contactName?: string;
@IsOptional() @IsIn(PERSON_GENDERS) gender?: (typeof PERSON_GENDERS)[number];
@IsOptional() @IsBoolean() showGenderInName?: boolean;
@IsOptional() @IsString() phone?: string; @IsOptional() @IsString() phone?: string;
@IsOptional() @IsString() city?: string; @IsOptional() @IsString() city?: string;
@IsOptional() @IsString() region?: string; @IsOptional() @IsString() region?: string;
@@ -47,6 +50,8 @@ export class UpdateLeadDto {
@IsOptional() @IsString() @MinLength(1) title?: string; @IsOptional() @IsString() @MinLength(1) title?: string;
@IsOptional() @IsString() @MinLength(5) mobile?: string; @IsOptional() @IsString() @MinLength(5) mobile?: string;
@IsOptional() @IsString() contactName?: string | null; @IsOptional() @IsString() contactName?: string | null;
@IsOptional() @IsIn(PERSON_GENDERS) gender?: (typeof PERSON_GENDERS)[number];
@IsOptional() @IsBoolean() showGenderInName?: boolean;
@IsOptional() @IsString() phone?: string | null; @IsOptional() @IsString() phone?: string | null;
@IsOptional() @IsString() city?: string | null; @IsOptional() @IsString() city?: string | null;
@IsOptional() @IsString() region?: string | null; @IsOptional() @IsString() region?: string | null;
+42 -17
View File
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import type { LeadStageDto } from '@mento/shared'; import type { LeadStageDto } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
const DEFAULT_STAGES: Array<{ const DEFAULT_STAGES: Array<{
@@ -74,20 +75,24 @@ export class LeadStagesService {
where: { organizationId }, where: { organizationId },
_max: { position: true }, _max: { position: true },
}); });
const row = await this.prisma.leadStage.create({ try {
data: { const row = await this.prisma.leadStage.create({
organizationId, data: {
name: dto.name.trim(), organizationId,
color: dto.color?.trim() || 'teal', name: this.cleanName(dto.name),
showOnBoard: dto.showOnBoard !== false, color: dto.color?.trim() || 'teal',
isConverted: Boolean(dto.isConverted), showOnBoard: dto.showOnBoard !== false,
isLost: Boolean(dto.isLost), isConverted: Boolean(dto.isConverted),
isUnqualified: Boolean(dto.isUnqualified), isLost: Boolean(dto.isLost),
position: (max._max.position ?? -1) + 1, isUnqualified: Boolean(dto.isUnqualified),
}, position: (max._max.position ?? -1) + 1,
include: { _count: { select: { leads: true } } }, },
}); include: { _count: { select: { leads: true } } },
return this.toDto(row); });
return this.toDto(row);
} catch (e) {
this.rethrow(e);
}
} }
async update( async update(
@@ -103,10 +108,11 @@ export class LeadStagesService {
position?: number; position?: number;
}, },
): Promise<LeadStageDto> { ): Promise<LeadStageDto> {
try {
await this.prisma.leadStage.updateMany({ await this.prisma.leadStage.updateMany({
where: { id, organizationId }, where: { id, organizationId },
data: { data: {
...(dto.name != null ? { name: dto.name.trim() } : {}), ...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
...(dto.color != null ? { color: dto.color } : {}), ...(dto.color != null ? { color: dto.color } : {}),
...(dto.showOnBoard != null ? { showOnBoard: dto.showOnBoard } : {}), ...(dto.showOnBoard != null ? { showOnBoard: dto.showOnBoard } : {}),
...(dto.isConverted != null ? { isConverted: dto.isConverted } : {}), ...(dto.isConverted != null ? { isConverted: dto.isConverted } : {}),
@@ -122,6 +128,9 @@ export class LeadStagesService {
include: { _count: { select: { leads: true } } }, include: { _count: { select: { leads: true } } },
}); });
return this.toDto(row); return this.toDto(row);
} catch (e) {
this.rethrow(e);
}
} }
async reorder(organizationId: string, stageIds: string[]): Promise<LeadStageDto[]> { async reorder(organizationId: string, stageIds: string[]): Promise<LeadStageDto[]> {
@@ -143,11 +152,27 @@ export class LeadStagesService {
}); });
if (!stage) return; if (!stage) return;
if (stage._count.leads > 0) { if (stage._count.leads > 0) {
throw new Error('مرحله‌ای که لید دارد قابل حذف نیست'); throw new BadRequestException(
'این ستون هنوز لید دارد و حذف نمی‌شود. لیدهای فعال و بایگانی را اول به ستون دیگری ببرید.',
);
} }
await this.prisma.leadStage.delete({ where: { id } }); await this.prisma.leadStage.delete({ where: { id } });
} }
private cleanName(raw: string): string {
const name = raw?.trim() ?? '';
if (!name) throw new BadRequestException('نام ستون لازم است');
if (name.length > 80) throw new BadRequestException('نام ستون حداکثر ۸۰ کاراکتر است');
return name;
}
private rethrow(e: unknown): never {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new BadRequestException('ستونی با این نام وجود دارد');
}
throw e;
}
private toDto(row: { private toDto(row: {
id: string; id: string;
name: string; name: string;
+4 -3
View File
@@ -46,10 +46,12 @@ import {
import { LeadStagesService } from './lead-stages.service'; import { LeadStagesService } from './lead-stages.service';
import { LeadsImportService } from './leads-import.service'; import { LeadsImportService } from './leads-import.service';
import { LeadsService } from './leads.service'; import { LeadsService } from './leads.service';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
@Controller('leads') @Controller('leads')
@UseGuards(JwtAuthGuard, OrgModuleGuard) @UseGuards(JwtAuthGuard, OrgModuleGuard, SectionPermissionGuard)
@RequireOrgModule('leads') @RequireOrgModule('leads')
@RequirePermission('leads', { reads: true })
export class LeadsController { export class LeadsController {
constructor( constructor(
private readonly leads: LeadsService, private readonly leads: LeadsService,
@@ -105,6 +107,7 @@ export class LeadsController {
await this.stages.remove(user.organizationId, id); await this.stages.remove(user.organizationId, id);
return { ok: true }; return { ok: true };
} catch (e) { } catch (e) {
if (e instanceof BadRequestException) throw e;
throw new BadRequestException( throw new BadRequestException(
e instanceof Error ? e.message : 'حذف مرحله ناموفق بود', e instanceof Error ? e.message : 'حذف مرحله ناموفق بود',
); );
@@ -299,8 +302,6 @@ export class LeadsController {
} }
@Post('assign') @Post('assign')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
assign(@CurrentUser() user: Actor, @Body() dto: AssignLeadsDto) { assign(@CurrentUser() user: Actor, @Body() dto: AssignLeadsDto) {
return this.leads return this.leads
.assign(user, dto.leadIds, dto.assigneeId) .assign(user, dto.leadIds, dto.assigneeId)
+283 -71
View File
@@ -20,22 +20,32 @@ import type {
PaginatedResponse, PaginatedResponse,
} from '@mento/shared'; } from '@mento/shared';
import { FILTERED_IDS_MAX } from '@mento/shared'; import { FILTERED_IDS_MAX } from '@mento/shared';
import { applyLeadGenderName, applyPersonGenderTitle } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service'; import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { renderSmsText } from '../common/sms-text'; import { renderSmsText } from '../common/sms-text';
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service'; import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
import { leadsDeepWhere } from '../common/data-access';
import { import {
type Actor, type Actor,
elevated, elevated,
forScope, forScope,
} from '../common/record-scope'; } from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { placeId, sameIds, writePositions } from '../common/column-order';
import { import {
asAddresses, asAddresses,
collectAddresses, collectAddresses,
toJsonAddresses, toJsonAddresses,
} from '../common/custom-fields'; } from '../common/custom-fields';
import { emptyToNull, normalizePhone } from '../contacts/normalize'; import {
canonicalIranMobile,
emptyToNull,
normalizePhone,
phoneIdentityKeys,
} from '../contacts/normalize';
import { LocalFileStorage } from '../storage/local-file.storage'; import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service';
import { LeadStagesService } from './lead-stages.service'; import { LeadStagesService } from './lead-stages.service';
import { buildSearchWhere, ci } from '../common/search'; import { buildSearchWhere, ci } from '../common/search';
import { GamificationService } from '../gamification/gamification.service'; import { GamificationService } from '../gamification/gamification.service';
@@ -50,27 +60,37 @@ const LOGO_EXT: Record<string, string> = {
}; };
function mobileKey(raw: string | null | undefined): string | null { function mobileKey(raw: string | null | undefined): string | null {
const canon = canonicalIranMobile(raw);
if (canon) return canon;
const n = normalizePhone(raw); const n = normalizePhone(raw);
if (!n) return null; if (!n) return null;
const d = n.replace(/\D/g, ''); const d = n.replace(/\D/g, '');
if (!d) return null; return d || null;
if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`;
if (d.length === 10 && d.startsWith('9')) return `0${d}`;
return d.startsWith('0') ? d : d;
} }
function leadAccessWhere(baseActor: Actor): Prisma.LeadWhereInput { function personWithMobile(organizationId: string, mobile: string): Prisma.PersonWhereInput {
const actor = forScope(baseActor, 'leads'); const keys = phoneIdentityKeys(mobile);
if (elevated(actor.role)) return {}; return {
if (actor.dataVisibility === 'org') return {}; organizationId,
if ( OR: [{ mobile: { in: keys } }, { phone: { in: keys } }],
actor.dataVisibility === 'team' && };
actor.visibleUserIds && }
actor.visibleUserIds.length
) { function leadAccessWhere(actor: Actor): Prisma.LeadWhereInput {
return { assigneeId: { in: actor.visibleUserIds } }; if (actor.leadsAccess !== undefined) {
return leadsDeepWhere(actor);
} }
return { assigneeId: actor.userId }; const scoped = forScope(actor, 'leads');
if (elevated(scoped.role)) return {};
if (scoped.dataVisibility === 'org') return {};
if (
scoped.dataVisibility === 'team' &&
scoped.visibleUserIds &&
scoped.visibleUserIds.length
) {
return { assigneeId: { in: scoped.visibleUserIds } };
}
return { assigneeId: scoped.userId };
} }
const leadInclude = { const leadInclude = {
@@ -86,6 +106,7 @@ export class LeadsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly stages: LeadStagesService, private readonly stages: LeadStagesService,
private readonly files: LocalFileStorage, private readonly files: LocalFileStorage,
private readonly storage: StorageQuotaService,
private readonly game: GamificationService, private readonly game: GamificationService,
private readonly sms: SmsGatewayService, private readonly sms: SmsGatewayService,
private readonly outboundSms: OutboundSmsLogService, private readonly outboundSms: OutboundSmsLogService,
@@ -314,10 +335,7 @@ export class LeadsService {
}; };
} }
const person = await this.prisma.person.findFirst({ const person = await this.prisma.person.findFirst({
where: { where: personWithMobile(actor.organizationId, mobile),
organizationId: actor.organizationId,
OR: [{ mobile }, { phone: mobile }],
},
select: { id: true, firstName: true, lastName: true }, select: { id: true, firstName: true, lastName: true },
}); });
if (person) { if (person) {
@@ -337,6 +355,8 @@ export class LeadsService {
title: string; title: string;
mobile: string; mobile: string;
contactName?: string | null; contactName?: string | null;
gender?: string | null;
showGenderInName?: boolean | null;
phone?: string | null; phone?: string | null;
city?: string | null; city?: string | null;
region?: string | null; region?: string | null;
@@ -370,19 +390,31 @@ export class LeadsService {
const stageId = dto.stageId || stages[0]?.id; const stageId = dto.stageId || stages[0]?.id;
if (!stageId) throw new BadRequestException('مرحله‌ای تعریف نشده'); if (!stageId) throw new BadRequestException('مرحله‌ای تعریف نشده');
const allowOthers = await actorCanAssignToOthers(this.prisma, actor, 'leads');
let assigneeId: string | null = null; let assigneeId: string | null = null;
if (elevated(actor.role) && dto.assigneeId) { if (allowOthers && dto.assigneeId) {
assigneeId = dto.assigneeId; assigneeId = dto.assigneeId;
} else if (!elevated(actor.role)) { } else if (!allowOthers) {
assigneeId = actor.userId; assigneeId = actor.userId;
} }
const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null; const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null;
const named = applyLeadGenderName({
title: dto.title,
contactName: dto.contactName,
gender:
dto.gender === 'male' || dto.gender === 'female' || dto.gender === 'none'
? dto.gender
: undefined,
showGenderInName: dto.showGenderInName,
});
const row = await this.prisma.lead.create({ const row = await this.prisma.lead.create({
data: { data: {
organizationId: actor.organizationId, organizationId: actor.organizationId,
title: dto.title.trim(), title: named.title,
contactName: emptyToNull(dto.contactName), contactName: named.contactName,
gender: named.gender,
showGenderInName: named.showGenderInName,
mobile, mobile,
phone: emptyToNull(dto.phone), phone: emptyToNull(dto.phone),
city: emptyToNull(dto.city), city: emptyToNull(dto.city),
@@ -457,12 +489,41 @@ export class LeadsService {
} }
} }
const touchName =
typeof dto.title === 'string' ||
dto.contactName !== undefined ||
dto.gender !== undefined ||
dto.showGenderInName !== undefined;
const named = touchName
? applyLeadGenderName({
title: typeof dto.title === 'string' ? dto.title : existing.title,
contactName:
dto.contactName !== undefined
? (dto.contactName as string | null)
: existing.contactName,
gender:
dto.gender === 'male' || dto.gender === 'female' || dto.gender === 'none'
? dto.gender
: existing.gender === 'male' || existing.gender === 'female'
? existing.gender
: 'none',
showGenderInName:
typeof dto.showGenderInName === 'boolean'
? dto.showGenderInName
: existing.showGenderInName,
})
: null;
const data: Prisma.LeadUpdateInput = { const data: Prisma.LeadUpdateInput = {
...(typeof dto.title === 'string' ? { title: dto.title.trim() } : {}), ...(named
mobile, ? {
...(dto.contactName !== undefined title: named.title,
? { contactName: emptyToNull(dto.contactName as string) } contactName: named.contactName,
gender: named.gender,
showGenderInName: named.showGenderInName,
}
: {}), : {}),
mobile,
...(dto.phone !== undefined ...(dto.phone !== undefined
? { phone: emptyToNull(dto.phone as string) } ? { phone: emptyToNull(dto.phone as string) }
: {}), : {}),
@@ -521,8 +582,9 @@ export class LeadsService {
if (typeof dto.stageId === 'string' && dto.stageId !== existing.stageId) { if (typeof dto.stageId === 'string' && dto.stageId !== existing.stageId) {
data.stage = { connect: { id: dto.stageId } }; data.stage = { connect: { id: dto.stageId } };
} }
if (elevated(actor.role) && dto.assigneeId !== undefined) { if (dto.assigneeId !== undefined) {
const aid = dto.assigneeId ? String(dto.assigneeId) : null; const aid = dto.assigneeId ? String(dto.assigneeId) : null;
await assertAssigneeSet(this.prisma, actor, 'leads', [aid], [existing.assigneeId]);
data.assignee = aid data.assignee = aid
? { connect: { id: aid } } ? { connect: { id: aid } }
: { disconnect: true }; : { disconnect: true };
@@ -561,24 +623,43 @@ export class LeadsService {
}); });
if (!stage) throw new NotFoundException('مرحله پیدا نشد'); if (!stage) throw new NotFoundException('مرحله پیدا نشد');
let placedIds: string[] | null = null;
let nextPosition = position ?? 0;
if (position != null) {
const rows = await this.prisma.lead.findMany({
where: { organizationId: actor.organizationId, stageId, archivedAt: null },
select: { id: true },
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
});
const currentIds = rows.map((row) => row.id);
placedIds = placeId(currentIds, id, position);
nextPosition = Math.max(0, placedIds.indexOf(id));
if (sameIds(currentIds, placedIds)) placedIds = null;
}
const row = await this.prisma.lead.update({ const row = await this.prisma.lead.update({
where: { id }, where: { id },
data: { data: {
stageId, stageId,
position: position ?? 0, position: nextPosition,
...(stage.isConverted
? {}
: {}),
}, },
include: leadInclude, include: leadInclude,
}); });
await this.addEvent( if (placedIds) {
actor, await writePositions(
id, (ops) => this.prisma.$transaction(ops),
'stage_changed', placedIds,
`به مرحله «${stage.name}» منتقل شد`, (leadId, leadPosition) =>
); this.prisma.lead.update({ where: { id: leadId }, data: { position: leadPosition } }),
);
}
if (before.stageId !== stageId) { if (before.stageId !== stageId) {
await this.addEvent(
actor,
id,
'stage_changed',
`به مرحله «${stage.name}» منتقل شد`,
);
this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, { this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, {
eventKey: `${id}:${stageId}`, eventKey: `${id}:${stageId}`,
facts: { stageName: stage.name, source: row.source }, facts: { stageName: stage.name, source: row.source },
@@ -592,9 +673,7 @@ export class LeadsService {
leadIds: string[], leadIds: string[],
assigneeId: string, assigneeId: string,
): Promise<number> { ): Promise<number> {
if (!elevated(actor.role)) { await assertAssigneeSet(this.prisma, actor, 'leads', [assigneeId]);
throw new BadRequestException('فقط مدیر می‌تواند تخصیص دهد');
}
const user = await this.prisma.user.findFirst({ const user = await this.prisma.user.findFirst({
where: { where: {
id: assigneeId, id: assigneeId,
@@ -760,14 +839,14 @@ export class LeadsService {
? `تماس ثبت شد — ${dto.outcome}` ? `تماس ثبت شد — ${dto.outcome}`
: 'تماس ثبت شد'; : 'تماس ثبت شد';
await this.addEvent(actor, id, 'call', title, dto.body ?? null); await this.addEvent(actor, id, 'call', title, dto.body ?? null);
const followAt = this.parseFollowUpAt(dto.nextFollowUpAt);
if (followAt) {
await this.createLeadFollowUpActivity(actor, before, followAt, dto.body);
}
const data: Prisma.LeadUpdateInput = { const data: Prisma.LeadUpdateInput = {
lastContactAt: new Date(), lastContactAt: new Date(),
...(dto.nextFollowUpAt !== undefined ...(dto.nextFollowUpAt !== undefined
? { ? { nextFollowUpAt: followAt }
nextFollowUpAt: dto.nextFollowUpAt
? new Date(dto.nextFollowUpAt)
: null,
}
: {}), : {}),
}; };
if (dto.stageId) data.stage = { connect: { id: dto.stageId } }; if (dto.stageId) data.stage = { connect: { id: dto.stageId } };
@@ -806,8 +885,10 @@ export class LeadsService {
nextFollowUpAt: string, nextFollowUpAt: string,
body?: string, body?: string,
): Promise<LeadDto> { ): Promise<LeadDto> {
await this.findAccessible(actor, id); const lead = await this.findAccessible(actor, id);
const at = new Date(nextFollowUpAt); const at = this.parseFollowUpAt(nextFollowUpAt);
if (!at) throw new BadRequestException('زمان پیگیری نامعتبر است');
await this.createLeadFollowUpActivity(actor, lead, at, body);
await this.addEvent( await this.addEvent(
actor, actor,
id, id,
@@ -828,6 +909,39 @@ export class LeadsService {
return this.toDto(row); return this.toDto(row);
} }
/** پیگیری آینده باید خودش یک فعالیت باز و متصل به لید باشد، نه فقط یک تاریخ. */
private parseFollowUpAt(value?: string | null): Date | null {
if (!value) return null;
const at = new Date(value);
if (Number.isNaN(at.getTime())) {
throw new BadRequestException('زمان پیگیری نامعتبر است');
}
return at;
}
private async createLeadFollowUpActivity(
actor: Actor,
lead: { id: string; title: string; assigneeId: string | null },
dueAt: Date,
body?: string | null,
) {
const allowOthers = await actorCanAssignToOthers(this.prisma, actor, 'activities');
const assigneeId = allowOthers && lead.assigneeId ? lead.assigneeId : actor.userId;
await this.prisma.activity.create({
data: {
organizationId: actor.organizationId,
type: 'task',
title: `پیگیری: ${lead.title}`.slice(0, 200),
description: body?.trim() || null,
dueAt,
leadId: lead.id,
assigneeId,
assignees: { connect: [{ id: assigneeId }] },
createdById: actor.userId,
},
});
}
async archive(actor: Actor, ids: string[]): Promise<number> { async archive(actor: Actor, ids: string[]): Promise<number> {
if (!elevated(actor.role) && ids.length > 1) { if (!elevated(actor.role) && ids.length > 1) {
throw new BadRequestException('حذف گروهی فقط برای مدیر'); throw new BadRequestException('حذف گروهی فقط برای مدیر');
@@ -861,10 +975,7 @@ export class LeadsService {
} }
let person = await this.prisma.person.findFirst({ let person = await this.prisma.person.findFirst({
where: { where: personWithMobile(actor.organizationId, lead.mobile),
organizationId: actor.organizationId,
OR: [{ mobile: lead.mobile }, { phone: lead.mobile }],
},
}); });
let createdPerson = false; let createdPerson = false;
@@ -873,14 +984,25 @@ export class LeadsService {
? `${dto.firstName ?? ''} ${dto.lastName ?? ''}`.trim() ? `${dto.firstName ?? ''} ${dto.lastName ?? ''}`.trim()
: (lead.contactName || lead.title).trim(); : (lead.contactName || lead.title).trim();
const nameParts = rawName.split(/\s+/).filter(Boolean); const nameParts = rawName.split(/\s+/).filter(Boolean);
const firstName = nameParts[0] || lead.title; const bareFirst = nameParts[0] || lead.title;
const lastName = nameParts.slice(1).join(' ') || '—'; const bareLast = nameParts.slice(1).join(' ');
const named = applyPersonGenderTitle({
firstName: bareFirst,
lastName: bareLast,
gender:
lead.gender === 'male' || lead.gender === 'female' || lead.gender === 'none'
? lead.gender
: 'none',
showGenderInName: lead.showGenderInName,
});
person = await this.prisma.person.create({ person = await this.prisma.person.create({
data: { data: {
organizationId: actor.organizationId, organizationId: actor.organizationId,
firstName, firstName: named.firstName,
lastName, lastName: named.lastName,
mobile: lead.mobile, gender: named.gender,
showGenderInName: named.showGenderInName,
mobile: canonicalIranMobile(lead.mobile) ?? lead.mobile,
phone: lead.phone, phone: lead.phone,
instagram: lead.instagram, instagram: lead.instagram,
birthDate: lead.birthDate, birthDate: lead.birthDate,
@@ -975,8 +1097,10 @@ export class LeadsService {
const stages = await this.stages.list(actor.organizationId); const stages = await this.stages.list(actor.organizationId);
const convertedIds = stages.filter((s) => s.isConverted).map((s) => s.id); const convertedIds = stages.filter((s) => s.isConverted).map((s) => s.id);
const lostIds = stages.filter((s) => s.isLost).map((s) => s.id); const lostIds = stages.filter((s) => s.isLost).map((s) => s.id);
const interested = stages.find((s) => s.name.includes('علاقه‌مند')); // همان مقصد فرم و ورود از اکسل: اولین ستون باز، مستقل از اسم «جدید»
const newStage = stages.find((s) => s.name === 'جدید'); const newStage = stages.find(
(s) => !s.isConverted && !s.isLost && !s.isUnqualified,
);
const [ const [
total, total,
@@ -984,7 +1108,6 @@ export class LeadsService {
unassigned, unassigned,
converted, converted,
lost, lost,
interestedCount,
withFollowUp, withFollowUp,
callsLogged, callsLogged,
byAssigneeRaw, byAssigneeRaw,
@@ -1004,11 +1127,6 @@ export class LeadsService {
where: { ...base, stageId: { in: lostIds } }, where: { ...base, stageId: { in: lostIds } },
}) })
: Promise.resolve(0), : Promise.resolve(0),
interested
? this.prisma.lead.count({
where: { ...base, stageId: interested.id },
})
: Promise.resolve(0),
this.prisma.lead.count({ this.prisma.lead.count({
where: { ...base, nextFollowUpAt: { not: null } }, where: { ...base, nextFollowUpAt: { not: null } },
}), }),
@@ -1051,13 +1169,102 @@ export class LeadsService {
.sort((a, b) => b.count - a.count), .sort((a, b) => b.count - a.count),
callsLogged, callsLogged,
withFollowUp, withFollowUp,
interested: interestedCount, interested: 0,
converted, converted,
lost, lost,
conversionRate: Math.round((converted / denom) * 1000) / 10, conversionRate: Math.round((converted / denom) * 1000) / 10,
}; };
} }
async createFromExternal(input: {
organizationId: string;
siteName: string;
firstName?: string | null;
lastName?: string | null;
mobile: string;
storeName?: string | null;
source?: string | null;
}): Promise<{
ok: true;
created: boolean;
duplicate: boolean;
leadId: string | null;
}> {
const mobile = mobileKey(input.mobile);
if (!mobile || !/^09\d{9}$/.test(mobile)) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
const contactName =
`${input.firstName?.trim() ?? ''} ${input.lastName?.trim() ?? ''}`.trim() || null;
const storeName = input.storeName?.trim() ?? '';
const source = emptyToNull(input.source);
const title = storeName || contactName || mobile;
const siteName = input.siteName.trim() || 'سایت بیرونی';
const existing = await this.prisma.lead.findFirst({
where: { organizationId: input.organizationId, mobile, archivedAt: null },
select: { id: true, source: true, contactName: true },
});
if (existing) {
const patch: Prisma.LeadUpdateInput = {};
if (!existing.source && source) patch.source = source;
if (!existing.contactName && contactName) patch.contactName = contactName;
if (Object.keys(patch).length) {
await this.prisma.lead.update({ where: { id: existing.id }, data: patch });
}
await this.prisma.leadEvent.create({
data: {
organizationId: input.organizationId,
leadId: existing.id,
type: 'note',
title: 'درخواست دوباره از سایت',
body: [`سایت: ${siteName}`, source ? `منبع: ${source}` : null]
.filter(Boolean)
.join('\n'),
},
});
return { ok: true, created: false, duplicate: true, leadId: existing.id };
}
const person = await this.prisma.person.findFirst({
where: personWithMobile(input.organizationId, mobile),
select: { id: true },
});
if (person) {
return { ok: true, created: false, duplicate: true, leadId: null };
}
await this.stages.ensureDefaults(input.organizationId);
const stages = await this.stages.list(input.organizationId);
const stageId = stages[0]?.id;
if (!stageId) throw new BadRequestException('مرحله‌ای تعریف نشده');
const lead = await this.prisma.lead.create({
data: {
organizationId: input.organizationId,
title,
contactName,
mobile,
source,
notes: `ثبت از سایت: ${siteName}`,
stageId,
},
});
await this.prisma.leadEvent.create({
data: {
organizationId: input.organizationId,
leadId: lead.id,
type: 'created',
title: 'لید از سایت ثبت شد',
body: [`سایت: ${siteName}`, source ? `منبع: ${source}` : null]
.filter(Boolean)
.join('\n'),
},
});
return { ok: true, created: true, duplicate: false, leadId: lead.id };
}
async createFromImportRow( async createFromImportRow(
organizationId: string, organizationId: string,
createdById: string | null, createdById: string | null,
@@ -1100,10 +1307,7 @@ export class LeadsService {
} }
const existingPerson = await this.prisma.person.findFirst({ const existingPerson = await this.prisma.person.findFirst({
where: { where: personWithMobile(organizationId, mobile),
organizationId,
OR: [{ mobile }, { phone: mobile }],
},
select: { id: true }, select: { id: true },
}); });
if (existingPerson) { if (existingPerson) {
@@ -1202,6 +1406,8 @@ export class LeadsService {
id: row.id, id: row.id,
title: row.title, title: row.title,
contactName: row.contactName, contactName: row.contactName,
gender: row.gender === 'male' || row.gender === 'female' ? row.gender : 'none',
showGenderInName: row.showGenderInName,
mobile: row.mobile, mobile: row.mobile,
phone: row.phone, phone: row.phone,
city: row.city, city: row.city,
@@ -1252,6 +1458,9 @@ export class LeadsService {
const storageKey = path const storageKey = path
.join(actor.organizationId, 'leads', id, `logo.${ext}`) .join(actor.organizationId, 'leads', id, `logo.${ext}`)
.replace(/\\/g, '/'); .replace(/\\/g, '/');
const prevBytes = prevKey ? await this.files.sizeOf(prevKey) : 0;
const delta = file.size - prevBytes;
if (delta > 0) await this.storage.assertCanAdd(actor.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);
@@ -1262,13 +1471,16 @@ export class LeadsService {
include: leadInclude, include: leadInclude,
}); });
await this.addEvent(actor, id, 'note', 'لوگو به‌روز شد', null); await this.addEvent(actor, id, 'note', 'لوگو به‌روز شد', null);
if (delta) await this.storage.addLocal(actor.organizationId, delta);
return this.toDto(row); return this.toDto(row);
} }
async removeLogo(actor: Actor, id: string): Promise<LeadDto> { async removeLogo(actor: Actor, id: string): Promise<LeadDto> {
const existing = await this.findAccessible(actor, id); const existing = await this.findAccessible(actor, id);
if (existing.logoStorageKey) { if (existing.logoStorageKey) {
const bytes = await this.files.sizeOf(existing.logoStorageKey);
await this.files.remove(existing.logoStorageKey); await this.files.remove(existing.logoStorageKey);
if (bytes) await this.storage.addLocal(actor.organizationId, -bytes);
} }
const row = await this.prisma.lead.update({ const row = await this.prisma.lead.update({
where: { id }, where: { id },
+13 -1
View File
@@ -1,11 +1,23 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { json, urlencoded } from 'express';
import type { Request } from 'express';
import { brand } from '@mento/shared'; import { brand } from '@mento/shared';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule, { rawBody: true }); // bodyParser خاموش تا سقف ۲ مگ برای پیوست فشردهٔ فرم عمومی + حفظ rawBody برای وب‌هوک
const app = await NestFactory.create(AppModule, { bodyParser: false });
app.use(
json({
limit: '2mb',
verify: (req: Request, _res, buf) => {
(req as Request & { rawBody?: Buffer }).rawBody = Buffer.from(buf);
},
}),
);
app.use(urlencoded({ extended: true, limit: '2mb' }));
const config = app.get(ConfigService); const config = app.get(ConfigService);
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
@@ -98,7 +98,7 @@ export class MembershipsService {
where: { organizationId, userId }, where: { organizationId, userId },
include: { include: {
user: true, user: true,
orgRole: { select: { id: true, name: true, color: true } }, orgRole: { select: { id: true, name: true, color: true, dataAccessPolicy: true } },
}, },
}); });
} }
@@ -108,7 +108,7 @@ export class MembershipsService {
where: { organizationId }, where: { organizationId },
include: { include: {
user: true, user: true,
orgRole: { select: { id: true, name: true, color: true } }, orgRole: { select: { id: true, name: true, color: true, dataAccessPolicy: true } },
}, },
orderBy: [{ user: { lastLoginAt: 'desc' } }, { user: { fullName: 'asc' } }], orderBy: [{ user: { lastLoginAt: 'desc' } }, { user: { fullName: 'asc' } }],
}); });
@@ -56,7 +56,7 @@ function testTemplate() {
}) === 'سلام علی 0912', }) === 'سلام علی 0912',
'name phone vars', 'name phone vars',
); );
assert(clipSms('الف'.repeat(200)).length === 160, 'sms clip'); assert(clipSms('الف'.repeat(200)).length === 200, 'sms is not clipped');
} }
testDelay(); testDelay();
@@ -17,7 +17,20 @@ export const MESSAGE_AUTOMATION_TRIGGERS = [
] as const; ] as const;
export const MAX_MESSAGE_REPEAT = 10; export const MAX_MESSAGE_REPEAT = 10;
export const MAX_MESSAGE_SMS = 160;
/** شمارهٔ مشتری: اول شخص، و اگر نبود شمارهٔ خود سرنخ. */
export function customerPhone(
person: { mobile?: string | null; phone?: string | null } | null | undefined,
lead: { mobile?: string | null; phone?: string | null } | null | undefined,
): string {
return (
person?.mobile?.trim() ||
person?.phone?.trim() ||
lead?.mobile?.trim() ||
lead?.phone?.trim() ||
''
);
}
export function delayMs(value: number, unit: MessageDelayUnit): number { export function delayMs(value: number, unit: MessageDelayUnit): number {
const n = Math.max(0, Math.trunc(Number(value) || 0)); const n = Math.max(0, Math.trunc(Number(value) || 0));
@@ -92,10 +105,9 @@ export function applyMessageTemplate(
}); });
} }
/** متن کامل پیامک؛ بخش‌بندی و هزینه در درگاه حساب می‌شود. */
export function clipSms(text: string): string { export function clipSms(text: string): string {
const trimmed = text.trim(); return text.trim();
if (trimmed.length <= MAX_MESSAGE_SMS) return trimmed;
return `${trimmed.slice(0, MAX_MESSAGE_SMS - 1)}…`;
} }
/** When this event happens, cancel leftover messages of these earlier triggers. */ /** When this event happens, cancel leftover messages of these earlier triggers. */
@@ -21,6 +21,7 @@ import {
buildSendAts, buildSendAts,
cancelTriggersFor, cancelTriggersFor,
clipSms, clipSms,
customerPhone,
} from './message-automation.util'; } from './message-automation.util';
export type MessageAutomationContext = { export type MessageAutomationContext = {
@@ -354,13 +355,15 @@ export class MessageAutomationsService {
}, },
}) })
: null; : null;
const lead = await this.lookupLead(job);
const personName = person const personName = person
? [person.firstName, person.lastName].filter(Boolean).join(' ').trim() ? [person.firstName, person.lastName].filter(Boolean).join(' ').trim()
: ''; : '';
const phone = customerPhone(person, lead);
const vars = { const vars = {
title: job.entityTitle, title: job.entityTitle,
name: personName || job.entityTitle, name: personName || lead?.contactName?.trim() || lead?.title || job.entityTitle,
phone: person?.mobile || person?.phone || '', phone,
date: formatJalaliDayFa(new Date()), date: formatJalaliDayFa(new Date()),
company: person?.company?.name ?? '', company: person?.company?.name ?? '',
}; };
@@ -405,26 +408,59 @@ export class MessageAutomationsService {
} }
if (targetPerson && wantSms) { if (targetPerson && wantSms) {
await this.sendSms(person?.mobile || person?.phone, smsText, { if (!phone) {
throw new Error('شماره مشتری یا سرنخ برای پیامک پیدا نشد');
}
const sent = await this.sendSms(phone, smsText, {
organizationId: job.organizationId, organizationId: job.organizationId,
personId: job.personId, personId: job.personId,
leadId: lead?.id ?? null,
ruleId: job.ruleId || job.rule.id || '', ruleId: job.ruleId || job.rule.id || '',
}); });
if (!sent) {
throw new Error('ارسال پیامک به مشتری یا سرنخ ناموفق بود');
}
} }
} }
/** سرنخ همان پرونده، یا سرنخی که به این شخص تبدیل شده. */
private async lookupLead(job: {
organizationId: string;
entityId: string;
personId: string | null;
}) {
return this.prisma.lead.findFirst({
where: {
organizationId: job.organizationId,
OR: [
{ id: job.entityId },
...(job.personId ? [{ convertedPersonId: job.personId }] : []),
],
},
select: {
id: true,
mobile: true,
phone: true,
contactName: true,
title: true,
},
orderBy: { createdAt: 'desc' },
});
}
private async sendSms( private async sendSms(
mobile: string | null | undefined, mobile: string | null | undefined,
text: string, text: string,
log: { log: {
organizationId: string; organizationId: string;
personId?: string | null; personId?: string | null;
leadId?: string | null;
ruleId: string; ruleId: string;
}, },
) { ): Promise<boolean> {
if (!mobile) { if (!mobile?.trim()) {
this.logger.warn('Message automation SMS skipped: no mobile'); this.logger.warn('Message automation SMS skipped: no mobile');
return; return false;
} }
const result = await this.sms.send({ const result = await this.sms.send({
organizationId: log.organizationId, organizationId: log.organizationId,
@@ -432,10 +468,11 @@ export class MessageAutomationsService {
text, text,
source: 'automation', source: 'automation',
}); });
if (log.personId) { if (log.personId || log.leadId) {
await this.outboundSms.record({ await this.outboundSms.record({
organizationId: log.organizationId, organizationId: log.organizationId,
personId: log.personId, personId: log.personId,
leadId: log.leadId,
mobile, mobile,
body: text, body: text,
source: 'automation', source: 'automation',
@@ -448,7 +485,9 @@ export class MessageAutomationsService {
this.logger.warn( this.logger.warn(
`Message automation SMS failed: ${result.reason ?? 'unknown'}`, `Message automation SMS failed: ${result.reason ?? 'unknown'}`,
); );
return false;
} }
return true;
} }
private toData( private toData(
+77 -19
View File
@@ -5,6 +5,7 @@ import {
IsDateString, IsDateString,
IsIn, IsIn,
IsInt, IsInt,
IsObject,
IsOptional, IsOptional,
IsString, IsString,
Max, Max,
@@ -15,19 +16,6 @@ import {
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
const NOTE_TYPES = [
'general',
'call_result',
'meeting',
'important',
'warning',
'next_action',
'follow_up',
'idea',
'suggestion',
'personal',
] as const;
const NOTE_VISIBILITIES = ['team', 'personal'] as const; const NOTE_VISIBILITIES = ['team', 'personal'] as const;
const NOTE_SORTS = ['updatedAt', 'createdAt', 'reminderAt'] as const; const NOTE_SORTS = ['updatedAt', 'createdAt', 'reminderAt'] as const;
@@ -73,8 +61,10 @@ export class ListNotesQueryDto {
tag?: string; tag?: string;
@IsOptional() @IsOptional()
@IsIn(NOTE_TYPES) @IsString()
noteType?: (typeof NOTE_TYPES)[number]; @MinLength(1)
@MaxLength(64)
noteType?: string;
@IsOptional() @IsOptional()
@IsIn(NOTE_VISIBILITIES) @IsIn(NOTE_VISIBILITIES)
@@ -110,8 +100,10 @@ export class CreateNoteDto {
body!: string; body!: string;
@IsOptional() @IsOptional()
@IsIn(NOTE_TYPES) @IsString()
noteType?: (typeof NOTE_TYPES)[number]; @MinLength(1)
@MaxLength(64)
noteType?: string;
@IsOptional() @IsOptional()
@IsIn(NOTE_VISIBILITIES) @IsIn(NOTE_VISIBILITIES)
@@ -171,6 +163,10 @@ export class CreateNoteDto {
@ValidateIf((_, v) => v !== '' && v != null) @ValidateIf((_, v) => v !== '' && v != null)
@IsString() @IsString()
activityId?: string | null; activityId?: string | null;
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
} }
export class UpdateNoteDto { export class UpdateNoteDto {
@@ -187,8 +183,10 @@ export class UpdateNoteDto {
body?: string; body?: string;
@IsOptional() @IsOptional()
@IsIn(NOTE_TYPES) @IsString()
noteType?: (typeof NOTE_TYPES)[number]; @MinLength(1)
@MaxLength(64)
noteType?: string;
@IsOptional() @IsOptional()
@IsIn(NOTE_VISIBILITIES) @IsIn(NOTE_VISIBILITIES)
@@ -248,6 +246,10 @@ export class UpdateNoteDto {
@ValidateIf((_, v) => v !== '' && v != null) @ValidateIf((_, v) => v !== '' && v != null)
@IsString() @IsString()
activityId?: string | null; activityId?: string | null;
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
} }
class NoteAiNextActionDtoClass { class NoteAiNextActionDtoClass {
@@ -300,3 +302,59 @@ export class ApplyNoteAiSuggestionDto {
@Type(() => ApplyMemoryEditDto) @Type(() => ApplyMemoryEditDto)
memoryItems?: ApplyMemoryEditDto[]; memoryItems?: ApplyMemoryEditDto[];
} }
const NOTE_CATEGORY_TONES = [
'general',
'follow_up',
'idea',
'suggestion',
'meeting',
'personal',
] as const;
export class CreateNoteCategoryDto {
@IsString()
@MinLength(1)
@MaxLength(40)
label!: string;
@IsOptional()
@IsIn(NOTE_CATEGORY_TONES)
tone?: (typeof NOTE_CATEGORY_TONES)[number];
@IsOptional()
@IsBoolean()
showInBar?: boolean;
}
export class UpdateNoteCategoryDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(40)
label?: string;
@IsOptional()
@IsIn(NOTE_CATEGORY_TONES)
tone?: (typeof NOTE_CATEGORY_TONES)[number];
@IsOptional()
@IsBoolean()
showInBar?: boolean;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
position?: number;
}
export class ReorderNoteCategoriesDto {
@IsArray()
@IsString({ each: true })
ids!: string[];
}

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