121 Commits
Author SHA1 Message Date
MahdiandCursor 3696713443 Fix routines manage select popup typing for vue-tsc.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 20:48:48 +03:30
MahdiandCursor d282627af7 Add searchable assignee and creator filters on the routines manage toolbar.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 20:45:37 +03:30
MahdiandCursor fb6aa77465 Omit createdBy relation when duplicating a routine so API types still compile.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 20:16:12 +03:30
MahdiandCursor 54d360ded8 Show routine creator in the manage list so managers can see who defined each routine.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 20:07:41 +03:30
MahdiandCursor 717a41e94b Substitute org name and balance tokens in outbound SMS so templates no longer leave {از طرف} literal.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 17:12:31 +03:30
MahdiandCursor f36fddc23b Allow inserting dynamic field tokens while creating SMS templates.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 14:55:25 +03:30
MahdiandCursor e04aa14b84 Add org-scoped reusable SMS templates in the compose modal.
Users can save, pick, and delete prepared messages shared across the organization.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 14:15:42 +03:30
MahdiandCursor 3c27fae626 Apply overdue/today/upcoming date colors on sales deal cards as well.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 13:13:40 +03:30
MahdiandCursor 33eeb3242a Add activity priority desk filter and keep detail browsing inside the same column after stage changes.
When done/reopen/stage moves leave a column, advance to the next activity there or return to the list if empty.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 12:02:13 +03:30
MahdiandCursor ae16e43013 Clarify activity card due tones: overdue as عقب افتاده, done as انجام شده, and today in warning yellow.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 21:52:56 +03:30
MahdiandCursor 7a55b84b81 Add org-scoped activity priority labels/colors and apply them across forms, boards, and routines.
Also fix activity leave-without-save confirm, kanban due-date labels with tone-colored peek chevron, and rename the types manager button.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 21:39:15 +03:30
MahdiandCursor a1ac677b3a Combine data-access and contact list facets with OR so each selected dimension grants visibility.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 20:15:21 +03:30
MahdiandCursor b00465439e Drop groupIds from customer data-access filters; keep visibility via group assignees only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:50:02 +03:30
MahdiandCursor 9af0e9e94e Fix customer groups modal scroll nesting and make assignees removable via chips.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:07:36 +03:30
MahdiandCursor b745b6d460 Tighten team users table with a compact row-number column and fixed layout.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 18:30:49 +03:30
MahdiandCursor fec5ece255 Improve deal board activity cues with relative dates and a quick activity list popup.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 16:27:32 +03:30
MahdiandCursor f4f2311ae4 Improve team settings responsive layout, narrower role panel, and mobile pagination.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 15:31:58 +03:30
MahdiandCursor 57e0fdd600 Improve team settings search, user sorting, and role badge layout.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 14:01:08 +03:30
MahdiandCursor b583858088 feat(customers): add optional group assignees with OR visibility for people, companies, and birthdays
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 12:59:49 +03:30
MahdiandCursor 453ff1a5f6 fix(activities): allow assignees to attach files when commenting under creator-only edit
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 09:49:22 +03:30
MahdiandCursor 68fcece743 fix(activities): keep comment and postpone open for assignees under creator-only edit
Allow dueAt/description patches for collaborators when editPolicy is creator-only, and show the no-edit warning only when they try to change locked fields—not as a permanent banner.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 09:23:10 +03:30
MahdiandCursor de77066cb0 feat(tahesab): add manual sync to refresh linked customer profiles
Expose sync/customers-now and a settings button so existing linked people get national id, address, city, and rasteh without waiting for the schedule.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 18:54:59 +03:30
MahdiandCursor d23dd20237 feat(tahesab): sync full customer profile fields into CRM people
Pull national id, address, city, and rasteh on import/refresh so person records match Tahesab, and surface them on the person form and profile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 18:31:47 +03:30
MahdiandCursor 8a4bd70ad4 fix(boards): keep kanban card drag from locking after a few moves
Reset hung move queues, defer drag visuals past dragstart, and prefer card drop identity so HTML5 DnD no longer dies mid-drag.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 16:58:19 +03:30
MahdiandCursor d87321c7bf fix(activities): type ActivityRow with editPolicy for API builds
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 16:01:16 +03:30
MahdiandCursor 1a05eee46d feat(activities): let creators lock edit and delete to themselves
Add editPolicy (default creator-only), enforce it in the API, surface clear permission messages, and drop the redundant edit menu now that detail is editable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 15:56:36 +03:30
MahdiandCursor ec9a7cf9c9 feat(deals): plan activities on the deal and create them from the board
Show related activities on deal detail, prefill dealId on activity create, and add a side-panel quick create from pipeline cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 15:06:20 +03:30
MahdiandCursor 4f08ffae36 fix(web): use nowrap for StagePathStrip flex wrap prop
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 13:59:06 +03:30
MahdiandCursor d7cb92bf81 feat(web): add stage path strip with Didar-style move confirm
Let users jump stages from activity detail and deal form only after confirming انجام بده / انجام نده.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 13:55:43 +03:30
MahdiandCursor 871d7bba1a fix(activities): keep board filters and make card drops reliable
Return to the filtered desk after marking an activity done, widen the detail title, and stop a stuck column-drag id from blocking card moves.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 13:05:26 +03:30
MahdiandCursor f13a1249af feat(activities): edit detail in place and tighten the board peek
Let people change activity fields and their own notes with photos from the detail page, open full detail from the card title, and toggle the side peek with a chevron.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 21:31:21 +03:30
MahdiandCursor fdf1fbe4aa feat(activities): peek at a card from the board
Let people read notes, postpone, and mark an activity done beside the card instead of opening the full page.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 20:10:16 +03:30
MahdiandCursor 6df198321a feat(activities): remove an assignee from the share list
A team, role, or person can be taken off the activity from the progress chips, and ungrouped people use the same chip as grouped members.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 18:43:02 +03:30
MahdiandCursor 4c8604d346 feat(activities): pick assignees by team or role
A team or role adds every member in one step, while each person's completion share stays unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 17:39:51 +03:30
MahdiandCursor 25a24772fc feat(access): keep a single owner per organization
The person who registered stays the only owner. Full day-to-day access is granted through the manager role instead of a second owner.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 14:59:32 +03:30
MahdiandCursor 932dc3f1c1 feat(billing): start demos on the core plan and apply tariff levels
New signups get the core module set with the 7-day demo. Superadmin can apply core, team, organization, or dedicated and the owner's environments take that level's access.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 14:08:14 +03:30
MahdiandCursor 5b26b9156d fix(treasury): restore the money parser import on expenses
The phone layout used the amount parser without importing it, so the web type check blocked the push.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 12:33:17 +03:30
MahdiandCursor d12d7b91cb feat(treasury): separate receipts from income and fit finance on a phone
A receipt or payment only reduces the party balance, while an income or expense keeps both amounts. Finance screens stack into cards on a phone so the forms and lists stay readable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 12:28:27 +03:30
MahdiandCursor 49459707cb fix(treasury): show a party's net balance as debtor or creditor
Income and expense forms now use the same receivable-minus-payable figure beside the party.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 11:09:51 +03:30
MahdiandCursor ba7f942059 fix(treasury): apply a full receipt or payment to the party's open balance
A settled document of the same amount no longer leaves an existing remaining unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 10:45:59 +03:30
MahdiandCursor 96105468d4 fix(activities): keep each assignee status attached to that person
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 21:45:32 +03:30
MahdiandCursor 7a8007fff0 feat(activities): keep multi-assignee completion readable for large teams
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 21:30:05 +03:30
MahdiandCursor da851bd9e5 feat(activities): let every assignee finish their own share
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 20:48:43 +03:30
MahdiandCursor dae3ae3f7e fix(notes): keep card titles visible when the square card is crowded
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 18:34:23 +03:30
MahdiandCursor b03d97dd9e fix(activities): show each note as its own dated message
The colon inside the clock was splitting the stamp from the comment, so date, author, text, and attachments now stay together.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 16:39:06 +03:30
MahdiandCursor 8b8d01976d fix(pipeline): hide prior-only sales paths from current work
The board lists a pipeline in current work only when it has a deal in that period, and keeps archive-only paths for the prior-data view.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 15:09:49 +03:30
MahdiandCursor 4764562bd4 fix(tahesab): stop treating shared phone numbers as new customers
A second Tahesab code with the same phone stays linked to the existing person, so the import list no longer counts it as unimported and sync no longer overwrites that person's name.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 14:42:46 +03:30
MahdiandCursor 0e1bc4a1fe fix(access): honor finance sections and team-member data scope
Role treasury keys now open the first allowed finance page, and team-member ownership limits lists, dashboard, and reports instead of the whole organization.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 11:20:20 +03:30
MahdiandCursor 3c9b906bd6 fix(leads): keep the filter panel from shifting the page
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 20:58:18 +03:30
MahdiandCursor ef58922e13 feat(leads): gather filters in one popover like activities
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 20:24:45 +03:30
MahdiandCursor 06036b00d3 feat(finance): open payroll details from mobile cards
Show each payroll as a card on a phone and slide its details up from the bottom so a short sheet leaves the top of the screen clear.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 18:13:38 +03:30
MahdiandCursor d33d88e76d feat(finance): show payroll details and search payment periods
Keep long reasons out of the report table, open them with payments in a details view, and let a payment period be found by name or month.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 17:34:12 +03:30
MahdiandCursor 5ef693990c feat(finance): edit payroll with month ranges, reasons, and toman
Let salary and payments be corrected after they are saved, pick a Jalali period as a month range, and show toman on finance amounts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 16:53:45 +03:30
MahdiandCursor 6ebd45f2cb feat(finance): show invoice balances in treasury and split payroll
Issued invoices feed debtors, creditors, and period profit without a second income document. Payroll is split into setup, staged payment, and a filtered report.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 14:20:00 +03:30
MahdiandCursor 33f33f9521 fix(notes): keep the card menu above the grid
The menu was clipped by the card, so the lower actions disappeared under the next row.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 14:19:35 +03:30
MahdiandCursor 97f00d7e6d fix(products): open one row menu so edit and other actions run
Desktop and mobile dropdowns shared an id, so both opened and the extra menu closed the click before the action.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 11:54:38 +03:30
m.lotfi b8c49293f7 Update .gitea/workflows/build.yml
Fix build.yml for push to registery
2026-11-08 06:37:08 +00:00
MahdiandCursor f36711174e feat(voip): make Daftaresoma setup inbound-first and filter calls by line
Drop required from_number for webhook logging, persist CalleeExtension as lineExtension, and add direction/status/line filters on the calls list.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-07 13:58:42 +03:30
MahdiandCursor abb1a3a18e feat(voip): add Daftaresoma provider tab with webhook and click-to-call
Map portal call events into CRM logs, store the API token for OutgoingCall, and surface a dedicated settings tab for wiring the webhook URL.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-07 12:31:59 +03:30
MahdiandCursor 21d2c69227 feat(leads): expand filters and tidy the list chrome on mobile
Owners can filter by assignee and follow-up status, with extra filters in a drawer, while mobile keeps a compact search-and-filter bar without the stage chip grid.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-07 11:07:34 +03:30
mahmood 0ca9540524 Fix change build level for test docker build
Build & Push Docker Image / build (push) Successful in 6m11s
2026-11-05 19:53:52 +03:30
mahmood dc691c5192 Preparation for MONSYS
Build & Push Docker Image / build (push) Failing after 7s
2026-11-05 19:48:41 +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 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 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
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
275 changed files with 30118 additions and 4091 deletions
+15
View File
@@ -0,0 +1,15 @@
.git
node_modules
**/dist
**/.env
**/.env.*
apps/stt/.venv
uploads
docs
e2e
test-results
playwright-report
graphify-out
.worktrees
*.md
crm-node_modules.tgz
+129
View File
@@ -0,0 +1,129 @@
name: Build & Push Docker Image
on:
push:
tags:
- "*"
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Start time
run: echo "BUILD_STARTED=$(date +%s)" >> "$GITHUB_ENV"
- name: Free disk space
id: prune
run: |
df -h / /workspace /var/lib/containerd 2>/dev/null || df -h /
docker builder prune -af || true
docker image prune -af || true
docker system prune -af --volumes || true
df -h / /workspace /var/lib/containerd 2>/dev/null || df -h /
- name: Checkout code
id: checkout
uses: actions/checkout@v4
- name: Resolve image tag
id: meta
run: |
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
TAG="${GITHUB_REF_NAME}"
else
TAG="$(git rev-parse --short HEAD)"
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "registry=dock.monsys.ir" >> "$GITHUB_OUTPUT"
- name: Build Docker images
id: build
run: |
set -eu
tag="${{ steps.meta.outputs.tag }}"
reg="${{ steps.meta.outputs.registry }}"
docker build -f Dockerfile.api -t "$reg/montact-api:$tag" -t "$reg/montact-api:latest" .
docker build -f Dockerfile.web -t "$reg/montact-web:$tag" -t "$reg/montact-web:latest" .
docker build -f Dockerfile.stt -t "$reg/montact-stt:$tag" -t "$reg/montact-stt:latest" .
# Registry login is unsolved. These two steps stay off so a tag run
# only proves the three images build. Remove `if: false` on both when
# dock.monsys.ir accepts the runner.
- name: Login to registry
id: login
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
USER_NAME="${REGISTRY_USERNAME:-admin}"
printf '%s\n' "$REGISTRY_PASSWORD" | docker login "dock.monsys.ir" -u "$USER_NAME" --password-stdin
- name: Push Docker images
id: push
run: |
set -eu
tag="${{ steps.meta.outputs.tag }}"
reg="${{ steps.meta.outputs.registry }}"
for name in montact-api montact-web montact-stt; do
docker push "$reg/$name:$tag"
docker push "$reg/$name:latest"
done
- name: Notify Bale
if: always()
continue-on-error: true
env:
BALE_TOKEN: ${{ secrets.BALE_BOT_TOKEN || vars.BALE_BOT_TOKEN }}
BALE_CHAT_ID: ${{ vars.BALE_CHAT_ID }}
TAG: ${{ steps.meta.outputs.tag }}
REG: ${{ steps.meta.outputs.registry }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}
STEP_OUTCOMES: |
Checkout code=${{ steps.checkout.outcome }}
Resolve image tag=${{ steps.meta.outcome }}
Build Docker images=${{ steps.build.outcome }}
Login to registry=${{ steps.login.outcome }}
Push Docker images=${{ steps.push.outcome }}
run: |
if [ -z "$BALE_TOKEN" ] || [ -z "$BALE_CHAT_ID" ]; then
echo "BALE_BOT_TOKEN / BALE_CHAT_ID not set in Gitea — skipping notification"
exit 0
fi
failed_step=$(printf '%s\n' "$STEP_OUTCOMES" | grep -E '=(failure|cancelled)$' | head -n1 | cut -d= -f1)
if [ -n "$failed_step" ]; then
title="❌ بیلد ناموفق"
elif printf '%s\n' "$STEP_OUTCOMES" | grep -q '^Push Docker images=success$'; then
title="✅ بیلد موفق"
elif printf '%s\n' "$STEP_OUTCOMES" | grep -q '^Build Docker images=success$'; then
title="✅ بیلد موفق؛ پوش رجیستری هنوز خاموش است"
else
title="⚠️ بیلد کامل نشد"
fi
commit=$(git log -1 --pretty='%h — %s' 2>/dev/null || echo "${GITHUB_SHA:0:7}")
elapsed=$(( $(date +%s) - ${BUILD_STARTED:-$(date +%s)} ))
text="${title}
پروژه: ${GITHUB_REPOSITORY}
تگ: ${TAG:-${GITHUB_REF_NAME}}"
if [ -z "$failed_step" ] && [ -n "$TAG" ]; then
text="${text}
image: ${REG}/montact-api:${TAG}
image: ${REG}/montact-web:${TAG}
image: ${REG}/montact-stt:${TAG}"
fi
[ -n "$failed_step" ] && text="${text}
مرحلهٔ خطا: ${failed_step}"
text="${text}
کامیت: ${commit}
توسط: ${GITHUB_ACTOR}
مدت: $((elapsed / 60))m$((elapsed % 60))s
لاگ: ${RUN_URL}"
msg_file=$(mktemp)
printf '%s\n' "$text" | sed 's/^[[:space:]]*//' > "$msg_file"
curl -sS -m 20 -X POST "https://tapi.bale.ai/bot${BALE_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${BALE_CHAT_ID}" \
--data-urlencode "text@${msg_file}"
+29
View File
@@ -0,0 +1,29 @@
# NestJS API + Prisma. Build context is the monorepo root.
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/shared/package.json packages/shared/package.json
RUN npm ci
COPY apps/api apps/api
COPY packages/shared packages/shared
RUN npm run build -w @mento/shared \
&& npm run prisma:generate -w @mento/api \
&& npm run build -w @mento/api
FROM node:20-bookworm-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/package.json /app/package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/shared ./packages/shared
COPY --from=build /app/apps/api/package.json ./apps/api/package.json
COPY --from=build /app/apps/api/dist ./apps/api/dist
COPY --from=build /app/apps/api/prisma ./apps/api/prisma
WORKDIR /app/apps/api
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/src/main.js"]
+15
View File
@@ -0,0 +1,15 @@
# Local Faster-Whisper. Not published on Ingress.
FROM python:3.11-slim-bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY apps/stt/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY apps/stt/main.py ./main.py
ENV STT_MODEL=small \
STT_DEVICE=cpu \
STT_COMPUTE_TYPE=int8 \
HF_HOME=/models
EXPOSE 3040
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3040"]
+16
View File
@@ -0,0 +1,16 @@
# Vue panel. The built app calls /api on the same host; Traefik sends that to the API.
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/shared/package.json packages/shared/package.json
RUN npm ci
COPY apps/web apps/web
COPY packages/shared packages/shared
RUN npm run build -w @mento/shared && npm run build -w @mento/web
FROM nginx:1.27-alpine
COPY docker/web.nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
EXPOSE 80
+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 @@
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;
@@ -0,0 +1,10 @@
-- AlterTable
ALTER TABLE "VoipConnection" ADD COLUMN "provider" TEXT NOT NULL DEFAULT 'generic';
ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenCiphertext" TEXT;
ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenIv" TEXT;
ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenTag" TEXT;
ALTER TABLE "VoipConnection" ADD COLUMN "fromNumber" TEXT;
ALTER TABLE "VoipConnection" ADD COLUMN "callerExtension" TEXT;
-- CreateIndex
CREATE INDEX "VoipConnection_organizationId_provider_idx" ON "VoipConnection"("organizationId", "provider");
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "VoipCallLog" ADD COLUMN "lineExtension" TEXT;
-- CreateIndex
CREATE INDEX "VoipCallLog_organizationId_lineExtension_idx" ON "VoipCallLog"("organizationId", "lineExtension");
-- CreateIndex
CREATE INDEX "VoipCallLog_organizationId_status_idx" ON "VoipCallLog"("organizationId", "status");
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "FinancialPayroll" ADD COLUMN "benefitsReason" TEXT;
ALTER TABLE "FinancialPayroll" ADD COLUMN "bonusReason" TEXT;
ALTER TABLE "FinancialPayroll" ADD COLUMN "deductionsReason" TEXT;
@@ -0,0 +1,6 @@
-- CreateEnum
CREATE TYPE "ActivityCompletionMode" AS ENUM ('any', 'all');
-- AlterTable
ALTER TABLE "Activity" ADD COLUMN "completionMode" "ActivityCompletionMode" NOT NULL DEFAULT 'any',
ADD COLUMN "completedByIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "ActivityEditPolicy" AS ENUM ('creator', 'assignees');
-- AlterTable
ALTER TABLE "Activity" ADD COLUMN "editPolicy" "ActivityEditPolicy" NOT NULL DEFAULT 'creator';
@@ -0,0 +1,28 @@
-- مسئولان اختیاری روی گروه‌های مشتری
CREATE TABLE IF NOT EXISTS "_CustomerGroupAssignees" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "_CustomerGroupAssignees_AB_unique" ON "_CustomerGroupAssignees"("A", "B");
CREATE INDEX IF NOT EXISTS "_CustomerGroupAssignees_B_index" ON "_CustomerGroupAssignees"("B");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = '_CustomerGroupAssignees_A_fkey'
) THEN
ALTER TABLE "_CustomerGroupAssignees"
ADD CONSTRAINT "_CustomerGroupAssignees_A_fkey"
FOREIGN KEY ("A") REFERENCES "CustomerGroup"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = '_CustomerGroupAssignees_B_fkey'
) THEN
ALTER TABLE "_CustomerGroupAssignees"
ADD CONSTRAINT "_CustomerGroupAssignees_B_fkey"
FOREIGN KEY ("B") REFERENCES "User"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Organization" ADD COLUMN "activityPriorities" JSONB NOT NULL DEFAULT '{}';
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Organization" ADD COLUMN "smsTemplates" JSONB NOT NULL DEFAULT '[]';
+172 -2
View File
@@ -33,6 +33,17 @@ enum ActivityStatus {
canceled canceled
} }
enum ActivityCompletionMode {
any
all
}
/// چه کسانی می‌توانند فعالیت را ویرایش/حذف کنند (علاوه بر owner/admin)
enum ActivityEditPolicy {
creator
assignees
}
enum ActivityPriority { enum ActivityPriority {
low low
medium medium
@@ -52,6 +63,7 @@ enum CustomFieldEntity {
person person
company company
deal deal
note
} }
enum CustomFieldType { enum CustomFieldType {
@@ -150,9 +162,19 @@ model Organization {
enabledCurrencies String[] @default(["IRR", "USD", "EUR"]) enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
/// ماژول‌های فعال منو برای این محیط /// ماژول‌های فعال منو برای این محیط
enabledModules String[] @default([]) enabledModules String[] @default([])
/// برچسب/رنگ اولویت فعالیت و روتین (کلیدهای enum ثابت؛ فقط ظاهر)
activityPriorities Json @default("{}")
/// قالب‌های آمادهٔ پیامک دستی ([{ id, name, body }, …])
smsTemplates Json @default("[]")
/// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخاب‌شده)؛ 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[]
@@ -169,11 +191,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[]
@@ -286,6 +310,8 @@ 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")
assignedCustomerGroups CustomerGroup[] @relation("CustomerGroupAssignees")
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")
@@ -293,6 +319,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)
@@ -309,6 +336,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")
@@ -342,6 +370,10 @@ model OrgRole {
permissions String[] @default([]) permissions String[] @default([])
/// عمق داده به‌ازای بخش، مثلاً {"customers":{"mode":"rules","rules":[{"owners":["self"]}]}} /// عمق داده به‌ازای بخش، مثلاً {"customers":{"mode":"rules","rules":[{"owners":["self"]}]}}
dataAccessPolicy Json @default("{}") dataAccessPolicy Json @default("{}")
/// حساب‌های مالی اضافی این نقش؛ همراه با حساب‌هایی که خودش مسئول است
treasuryAccountIds String[] @default([])
/// اعضای نقش، حساب‌هایی را ببینند که نام خودشان مسئول آن است
treasuryOwnAccounts Boolean @default(false)
/// owner | admin | sales | support — نقش‌های سیستمی قابل حذف نیستند /// owner | admin | sales | support — نقش‌های سیستمی قابل حذف نیستند
systemKey String? systemKey String?
/// نقش والد برای سلسله‌مراتب زیرنقش‌ها /// نقش والد برای سلسله‌مراتب زیرنقش‌ها
@@ -376,6 +408,8 @@ model Membership {
dataVisibilityScopes Json @default("{}") dataVisibilityScopes Json @default("{}")
/// Override عمق داده نسبت به نقش؛ کلید غایب = ارث از OrgRole.dataAccessPolicy /// Override عمق داده نسبت به نقش؛ کلید غایب = ارث از OrgRole.dataAccessPolicy
dataAccessOverride Json @default("{}") dataAccessOverride Json @default("{}")
/// null = دسترسی مالی از نقش؛ وگرنه جایگزین بخش‌ها و حساب‌های مالی همین کاربر
treasuryAccessOverride Json?
/// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد /// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد
priorArchiveEnabled Boolean @default(false) priorArchiveEnabled Boolean @default(false)
/// current | archive /// current | archive
@@ -440,6 +474,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())
@@ -452,9 +493,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
@@ -494,6 +541,7 @@ model Company {
customFields Json @default("{}") customFields Json @default("{}")
groupLinks CompanyGroupLink[] groupLinks CompanyGroupLink[]
people Person[] people Person[]
financialDocuments FinancialDocument[]
deals Deal[] deals Deal[]
activities Activity[] activities Activity[]
smartNotes Note[] smartNotes Note[]
@@ -527,14 +575,21 @@ model CustomerGroup {
/// گروه متناظر در ته‌حساب (GID) روی همان اتصال /// گروه متناظر در ته‌حساب (GID) روی همان اتصال
tahesabConnectionId String? tahesabConnectionId String?
tahesabGid String? tahesabGid String?
/// گروه اصلی؛ فقط یک سطح (اصلی / فرعی)
parentId String?
parent CustomerGroup? @relation("CustomerGroupTree", fields: [parentId], references: [id], onDelete: SetNull)
children CustomerGroup[] @relation("CustomerGroupTree")
people PersonGroupLink[] people PersonGroupLink[]
companies CompanyGroupLink[] companies CompanyGroupLink[]
/// مسئولان گروه — اختیاری؛ اعضای گروه را مثل مشتریان خود می‌بینند
assignees User[] @relation("CustomerGroupAssignees")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([organizationId, name]) @@unique([organizationId, name])
@@index([organizationId, position]) @@index([organizationId, position])
@@index([tahesabConnectionId, tahesabGid]) @@index([tahesabConnectionId, tahesabGid])
@@index([parentId])
} }
model PersonGroupLink { model PersonGroupLink {
@@ -569,6 +624,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?
@@ -757,6 +816,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
@@ -764,6 +824,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
@@ -830,6 +901,12 @@ model Activity {
/// زمان ارسال یادآور؛ با تغییر reminderAt پاک می‌شود /// زمان ارسال یادآور؛ با تغییر reminderAt پاک می‌شود
reminderSentAt DateTime? reminderSentAt DateTime?
completedAt DateTime? completedAt DateTime?
/// any = با انجام یکی بسته می‌شود؛ all = هر مسئول باید سهم خودش را بزند
completionMode ActivityCompletionMode @default(any)
/// مسئولانی که سهم خود را در حالت all ثبت کرده‌اند
completedByIds String[] @default([])
/// creator = فقط ایجادکننده؛ assignees = ایجادکننده + همهٔ مسئولان
editPolicy ActivityEditPolicy @default(creator)
tags String[] @default([]) tags String[] @default([])
personId String? personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
@@ -851,6 +928,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)
@@ -861,10 +940,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])
@@ -879,6 +960,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
@@ -894,6 +995,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?
@@ -923,6 +1026,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())
@@ -1744,6 +1867,13 @@ 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[]
@@ -1879,6 +2009,10 @@ model AccountingAsnadLine {
mali Float? mali Float?
count Float? count Float?
ayar Float? ayar Float?
/// درصد اجرت سند ته‌حساب؛ بعد از همگام‌سازی پر می‌شود
darsad Float?
darsadTalayee Float?
maliatDarsad Float?
mazaneh Float? mazaneh Float?
fi Float? fi Float?
sharh1 String? sharh1 String?
@@ -2229,6 +2363,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)
@@ -2249,6 +2389,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])
} }
/// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال. /// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال.
@@ -2329,8 +2471,11 @@ model FinancialPayroll {
period String period String
baseAmount Decimal @db.Decimal(18, 2) baseAmount Decimal @db.Decimal(18, 2)
benefits Decimal @default(0) @db.Decimal(18, 2) benefits Decimal @default(0) @db.Decimal(18, 2)
benefitsReason String?
bonus Decimal @default(0) @db.Decimal(18, 2) bonus Decimal @default(0) @db.Decimal(18, 2)
bonusReason String?
deductions Decimal @default(0) @db.Decimal(18, 2) deductions Decimal @default(0) @db.Decimal(18, 2)
deductionsReason String?
payable Decimal @db.Decimal(18, 2) payable Decimal @db.Decimal(18, 2)
dueDate DateTime dueDate DateTime
notes String? notes String?
@@ -2835,18 +2980,28 @@ model MontifyWebhookDelivery {
@@index([connectionId, createdAt]) @@index([connectionId, createdAt])
} }
/// اتصال تلفن سازمانی / سانترال (وب‌هوک عمومی) /// اتصال تلفن سازمانی / سانترال (وب‌هوک عمومی یا ارائه‌دهنده)
model VoipConnection { model VoipConnection {
id String @id @default(cuid()) id String @id @default(cuid())
organizationId String organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
label String label String
/// generic | daftareshoma
provider String @default("generic")
enabled Boolean @default(true) enabled Boolean @default(true)
webhookSecretCiphertext String? webhookSecretCiphertext String?
webhookSecretIv String? webhookSecretIv String?
webhookSecretTag String? webhookSecretTag String?
/// قالب URL شروع تماس؛ جای {number} شماره می‌نشیند /// قالب URL شروع تماس؛ جای {number} شماره می‌نشیند (generic)
clickToCallUrl String? clickToCallUrl String?
/// توکن Bearer API دفتر شما (رمزنگاری‌شده)
apiTokenCiphertext String?
apiTokenIv String?
apiTokenTag String?
/// شماره مبدأ در تماس خروجی دفتر شما (from_number)
fromNumber String?
/// خط/داخلی سازمان در دفتر شما (caller_extension)
callerExtension String?
lastEventAt DateTime? lastEventAt DateTime?
lastError String? lastError String?
calls VoipCallLog[] calls VoipCallLog[]
@@ -2854,6 +3009,7 @@ model VoipConnection {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([organizationId, enabled]) @@index([organizationId, enabled])
@@index([organizationId, provider])
} }
model VoipCallLog { model VoipCallLog {
@@ -2868,6 +3024,8 @@ model VoipCallLog {
status String @default("completed") status String @default("completed")
fromNumber String? fromNumber String?
toNumber String? toNumber String?
/// خط/داخلی سازمان که تماس روی آن بوده (مثلاً CalleeExtension دفتر شما)
lineExtension String?
personId String? personId String?
activityId String? activityId String?
durationSec Int? durationSec Int?
@@ -2892,6 +3050,8 @@ model VoipCallLog {
@@unique([connectionId, externalCallId]) @@unique([connectionId, externalCallId])
@@index([organizationId, syncedAt]) @@index([organizationId, syncedAt])
@@index([organizationId, lineExtension])
@@index([organizationId, status])
@@index([personId]) @@index([personId])
@@index([activityId]) @@index([activityId])
@@index([summaryStatus, summaryNextAttemptAt]) @@index([summaryStatus, summaryNextAttemptAt])
@@ -2972,6 +3132,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?
@@ -3664,3 +3828,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;
@@ -7,6 +7,7 @@ import {
Param, Param,
Patch, Patch,
Post, Post,
Put,
Query, Query,
Res, Res,
StreamableFile, StreamableFile,
@@ -40,6 +41,7 @@ import {
ReorderActivityTypesDto, ReorderActivityTypesDto,
UpdateActivityBoardDto, UpdateActivityBoardDto,
UpdateActivityDto, UpdateActivityDto,
UpdateActivityPrioritiesDto,
UpdateActivityStageDto, UpdateActivityStageDto,
UpdateActivityTypeDto, UpdateActivityTypeDto,
} from './dto'; } from './dto';
@@ -72,14 +74,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')
@@ -103,7 +105,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')
@@ -139,6 +141,18 @@ export class ActivitiesController {
return { ok: true }; return { ok: true };
} }
@Get('priorities')
listPriorities(@CurrentUser() user: Actor) {
return this.typesService.listPriorities(user.organizationId);
}
@Put('priorities')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
updatePriorities(@CurrentUser() user: Actor, @Body() dto: UpdateActivityPrioritiesDto) {
return this.typesService.updatePriorities(user.organizationId, dto.items);
}
@Get('types') @Get('types')
listTypes(@CurrentUser() user: Actor) { listTypes(@CurrentUser() user: Actor) {
return this.typesService.list(user.organizationId); return this.typesService.list(user.organizationId);
@@ -193,6 +207,10 @@ export class ActivitiesController {
typeDefId: query.typeDefId, typeDefId: query.typeDefId,
status: query.status, status: query.status,
assigneeId: query.assigneeId, assigneeId: query.assigneeId,
title: query.title,
customer: query.customer,
due: query.due,
priority: query.priority,
boardId: query.boardId, boardId: query.boardId,
}, },
); );
@@ -226,6 +244,44 @@ 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,
priority: query.priority,
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,
priority: query.priority,
boardId: query.boardId, boardId: query.boardId,
}, },
query.stageId ? { stageId: query.stageId } : undefined, query.stageId ? { stageId: query.stageId } : undefined,
@@ -309,6 +365,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);
@@ -339,12 +445,26 @@ 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')
complete(@CurrentUser() user: Actor, @Param('id') id: string) { complete(
return this.activitiesService.complete(user.organizationId, id, user); @CurrentUser() user: Actor,
@Param('id') id: string,
@Query('force') force?: string,
@Query('undo') undo?: string,
) {
return this.activitiesService.complete(user.organizationId, id, user, {
force: force === '1' || force === 'true',
undo: undo === '1' || undo === 'true',
});
} }
@Delete(':id') @Delete(':id')
+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,
File diff suppressed because it is too large Load Diff
@@ -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));
} }
/** اولین مرحلهٔ هر نوع = مقصد فعالیت‌های بدون مرحله یا با مرحلهٔ ناهم‌نوع */ /** اولین مرحلهٔ هر نوع = مقصد فعالیت‌های بدون مرحله یا با مرحلهٔ ناهم‌نوع */
@@ -6,6 +6,8 @@ import type {
ActivityFieldValue, ActivityFieldValue,
ActivityFieldValues, ActivityFieldValues,
ActivityParticipantsValue, ActivityParticipantsValue,
ActivityPriority,
ActivityPrioritySetting,
ActivityStageColor, ActivityStageColor,
ActivityType, ActivityType,
ActivityTypeDefDto, ActivityTypeDefDto,
@@ -17,8 +19,11 @@ import type {
import { import {
ACTIVITY_FIELD_TYPES, ACTIVITY_FIELD_TYPES,
ACTIVITY_FIELD_TYPES_WITH_OPTIONS, ACTIVITY_FIELD_TYPES_WITH_OPTIONS,
ACTIVITY_PRIORITY_KEYS,
ACTIVITY_STAGE_COLORS, ACTIVITY_STAGE_COLORS,
ACTIVITY_TYPE_ICONS, ACTIVITY_TYPE_ICONS,
DEFAULT_ACTIVITY_PRIORITY_SETTINGS,
parseActivityPrioritySettings,
} from '@mento/shared'; } from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@@ -619,6 +624,43 @@ export class ActivityTypesService {
: 'green'; : 'green';
} }
async listPriorities(organizationId: string): Promise<ActivityPrioritySetting[]> {
const row = await this.prisma.organization.findUnique({
where: { id: organizationId },
select: { activityPriorities: true },
});
return parseActivityPrioritySettings(row?.activityPriorities);
}
async updatePriorities(
organizationId: string,
items: Array<{ key: ActivityPriority; label: string; color: string }>,
): Promise<ActivityPrioritySetting[]> {
const byKey = new Map(items.map((i) => [i.key, i]));
if (ACTIVITY_PRIORITY_KEYS.some((k) => !byKey.has(k))) {
throw new BadRequestException('هر پنج سطح اولویت باید ارسال شوند');
}
const hex = /^#[0-9a-fA-F]{6}$/;
const stored: Record<string, { label: string; color: string }> = {};
for (const key of ACTIVITY_PRIORITY_KEYS) {
const item = byKey.get(key)!;
const label = item.label.trim().slice(0, 40);
const color = item.color.trim();
if (!label) throw new BadRequestException(`عنوان اولویت «${key}» خالی است`);
if (!hex.test(color)) throw new BadRequestException(`رنگ اولویت «${key}» نامعتبر است`);
const def = DEFAULT_ACTIVITY_PRIORITY_SETTINGS.find((d) => d.key === key)!;
// فقط تفاوت با پیش‌فرض را نگه می‌داریم تا JSON جمع‌وجور بماند
if (label !== def.label || color.toLowerCase() !== def.color.toLowerCase()) {
stored[key] = { label, color };
}
}
await this.prisma.organization.update({
where: { id: organizationId },
data: { activityPriorities: stored as Prisma.InputJsonValue },
});
return parseActivityPrioritySettings(stored);
}
private mapError(e: unknown) { private mapError(e: unknown) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return new BadRequestException('نوع فعالیتی با این نام وجود دارد'); return new BadRequestException('نوع فعالیتی با این نام وجود دارد');
+91 -2
View File
@@ -9,11 +9,13 @@ import {
IsObject, IsObject,
IsOptional, IsOptional,
IsString, IsString,
Matches,
Max, Max,
MaxLength, MaxLength,
Min, Min,
MinLength, MinLength,
ValidateIf, ValidateIf,
ValidateNested,
} from 'class-validator'; } from 'class-validator';
import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared'; import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared';
import { ACTIVITY_TYPE_ICONS } from '@mento/shared'; import { ACTIVITY_TYPE_ICONS } from '@mento/shared';
@@ -69,11 +71,33 @@ 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()
typeDefId?: string; typeDefId?: string;
/** اولویت فعالیت (کلید ثابت؛ برچسب در تنظیمات سازمان) */
@IsOptional()
@IsIn(ACTIVITY_PRIORITIES)
priority?: (typeof ACTIVITY_PRIORITIES)[number];
/** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */ /** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -131,6 +155,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 +168,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 +226,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 +329,7 @@ export class CreateActivityDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(2000) @MaxLength(20000)
description?: string; description?: string;
@IsOptional() @IsOptional()
@@ -337,6 +377,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()
@@ -346,6 +391,14 @@ export class CreateActivityDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
assigneeIds?: string[]; assigneeIds?: string[];
@IsOptional()
@IsIn(['any', 'all'])
completionMode?: 'any' | 'all';
@IsOptional()
@IsIn(['creator', 'assignees'])
editPolicy?: 'creator' | 'assignees';
} }
export class UpdateActivityDto { export class UpdateActivityDto {
@@ -384,7 +437,7 @@ export class UpdateActivityDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(2000) @MaxLength(20000)
description?: string | null; description?: string | null;
@IsOptional() @IsOptional()
@@ -432,6 +485,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()
@@ -441,4 +499,35 @@ export class UpdateActivityDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
assigneeIds?: string[]; assigneeIds?: string[];
@IsOptional()
@IsIn(['any', 'all'])
completionMode?: 'any' | 'all';
@IsOptional()
@IsIn(['creator', 'assignees'])
editPolicy?: 'creator' | 'assignees';
}
const PRIORITY_KEYS = ['very_low', 'low', 'medium', 'high', 'very_high'] as const;
export class ActivityPriorityItemDto {
@IsIn(PRIORITY_KEYS)
key!: (typeof PRIORITY_KEYS)[number];
@IsString()
@MinLength(1)
@MaxLength(40)
label!: string;
@IsString()
@Matches(/^#[0-9a-fA-F]{6}$/)
color!: string;
}
export class UpdateActivityPrioritiesDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => ActivityPriorityItemDto)
items!: ActivityPriorityItemDto[];
} }
+72 -16
View File
@@ -16,8 +16,13 @@ import type {
LoginResponse, LoginResponse,
LoginResult, LoginResult,
OrgModuleKey, OrgModuleKey,
StorageUsageDto,
SubscriptionSummaryDto, SubscriptionSummaryDto,
} from '@mento/shared'; } from '@mento/shared';
import {
parseUserTreasuryAccess,
permissionsWithTreasuryOverride,
} from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */ /** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
import type {} from 'multer'; import type {} from 'multer';
@@ -26,6 +31,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 +118,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 +289,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 +330,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, { fromPassword: true }); return this.finishLogin(user.id, { fromPassword: !viaMaster, viaMaster });
} }
/** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبت‌شده */ /** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبت‌شده */
@@ -613,11 +629,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 +647,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,11 +697,14 @@ export class AuthService {
selectionToken: string, selectionToken: string,
organizationId: string, organizationId: string,
): Promise<LoginResponse> { ): Promise<LoginResponse> {
let payload: { sub?: string; typ?: string; pwd?: boolean }; let payload: { sub?: string; typ?: string; pwd?: boolean; master?: boolean };
try { try {
payload = this.jwt.verify<{ sub?: string; typ?: string; pwd?: boolean }>( payload = this.jwt.verify<{
selectionToken, sub?: string;
); typ?: string;
pwd?: boolean;
master?: boolean;
}>(selectionToken);
} catch { } catch {
throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است'); throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است');
} }
@@ -686,6 +713,8 @@ export class AuthService {
} }
return this.issueLogin(payload.sub, organizationId, { return this.issueLogin(payload.sub, organizationId, {
fromPassword: payload.pwd === true, fromPassword: payload.pwd === true,
viaMaster: payload.master === true,
skipLastLogin: payload.master === true,
}); });
} }
@@ -698,15 +727,16 @@ export class AuthService {
private async finishLogin( private async finishLogin(
userId: string, userId: string,
opts?: { fromPassword?: boolean }, opts?: { fromPassword?: boolean; viaMaster?: boolean },
): Promise<LoginResult> { ): 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('به هیچ محیطی دسترسی ندارید');
} }
const usable = opts?.fromPassword const usable =
? rows.filter((row) => !row.loginOtpOnly) opts?.fromPassword && !opts.viaMaster
: rows; ? rows.filter((row) => !row.loginOtpOnly)
: rows;
if (!usable.length) { if (!usable.length) {
throw new UnauthorizedException( throw new UnauthorizedException(
'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.', 'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.',
@@ -715,6 +745,8 @@ export class AuthService {
if (usable.length === 1) { if (usable.length === 1) {
return this.issueLogin(userId, usable[0]!.organization.id, { return this.issueLogin(userId, usable[0]!.organization.id, {
fromPassword: opts?.fromPassword, fromPassword: opts?.fromPassword,
viaMaster: opts?.viaMaster,
skipLastLogin: opts?.viaMaster,
}); });
} }
return { return {
@@ -724,6 +756,7 @@ export class AuthService {
sub: userId, sub: userId,
typ: 'org-select', typ: 'org-select',
...(opts?.fromPassword ? { pwd: true } : {}), ...(opts?.fromPassword ? { pwd: true } : {}),
...(opts?.viaMaster ? { master: true } : {}),
}, },
{ expiresIn: '10m' }, { expiresIn: '10m' },
), ),
@@ -731,13 +764,23 @@ export class AuthService {
}; };
} }
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; fromPassword?: 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) { if (opts?.fromPassword && !opts.viaMaster) {
const gate = await this.prisma.membership.findFirst({ const gate = await this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true }, where: { userId, organizationId, isActive: true },
select: { loginOtpOnly: true }, select: { loginOtpOnly: true },
@@ -809,6 +852,9 @@ export class AuthService {
}, },
}); });
const role = membership?.role ?? user.role; const role = membership?.role ?? user.role;
const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
: [];
const orgName = membership?.organization.name; const orgName = membership?.organization.name;
const sub = const sub =
subscription ?? subscription ??
@@ -821,6 +867,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,
@@ -831,11 +883,13 @@ 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: permissionsWithTreasuryOverride(
? membership.orgRole.permissions.filter((item) => typeof item === 'string') rolePermissions,
: []) as AuthUserDto['permissions'], parseUserTreasuryAccess(membership?.treasuryAccessOverride),
) as AuthUserDto['permissions'],
...(viewAs ? { viewAs } : {}), ...(viewAs ? { viewAs } : {}),
}); });
} }
@@ -941,6 +995,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'];
@@ -957,6 +1012,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 } : {}),
+4
View File
@@ -195,6 +195,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
const peers = await this.prisma.teamMember.findMany({ const peers = await this.prisma.teamMember.findMany({
where: { where: {
teamId: { in: memberships.map((m) => m.teamId) }, teamId: { in: memberships.map((m) => m.teamId) },
team: { organizationId },
user: {
memberships: { some: { organizationId, isActive: true } },
},
}, },
select: { userId: true }, select: { userId: 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 {}
+114 -2
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 {
@@ -20,8 +21,12 @@ import {
defaultEnabledModules, defaultEnabledModules,
isPermanentBillingPlan, isPermanentBillingPlan,
isOrgModuleKey, isOrgModuleKey,
modulesForProductPlan,
PRODUCT_PLAN_LIMITS,
type ProductPlanCode,
} 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 +55,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 +68,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 +77,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 +162,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 +221,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 +291,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 +304,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 +318,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);
} }
@@ -335,7 +417,37 @@ export class BillingService {
} }
defaultModulesForNewOrg(): OrgModuleKey[] { defaultModulesForNewOrg(): OrgModuleKey[] {
return defaultEnabledModules(); return modulesForProductPlan('core');
}
/** سطح امکانات را روی همهٔ محیط‌های مالک می‌گذارد و سقف هسته/تیم/سازمان را هم تنظیم می‌کند. */
async applyProductPlan(organizationId: string, code: ProductPlanCode) {
const modules = modulesForProductPlan(code);
const owner = await this.prisma.membership.findFirst({
where: { organizationId, role: 'owner', isActive: true },
orderBy: { createdAt: 'asc' },
select: { userId: true },
});
const orgIds = owner ? await this.ownedOrganizationIds(owner.userId) : [organizationId];
for (const id of orgIds) {
await this.setOrgModules(id, modules);
}
const limits = PRODUCT_PLAN_LIMITS[code];
if (owner && (limits.maxUsers != null || limits.maxOrganizations != null)) {
const account = await this.getByOwnerUserId(owner.userId);
if (account) {
await this.prisma.billingAccount.update({
where: { id: account.id },
data: {
...(limits.maxUsers != null ? { maxUsers: limits.maxUsers } : {}),
...(limits.maxOrganizations != null
? { maxOrganizations: limits.maxOrganizations }
: {}),
},
});
}
}
return modules;
} }
async countExpiringSoon() { async countExpiringSoon() {
@@ -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)));
}
}
+143 -109
View File
@@ -62,6 +62,11 @@ export function resolveSectionAccess(
if (rolePolicy[sectionKey]) { if (rolePolicy[sectionKey]) {
return { section: rolePolicy[sectionKey]!, source: 'role' }; 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) { if (input.legacyVisibility != null || input.legacyScopes) {
return { return {
section: migrateVisibilityToSectionAccess( section: migrateVisibilityToSectionAccess(
@@ -100,29 +105,33 @@ export function resolveAllSectionAccess(input: {
return out; return out;
} }
/** ابعاد پرشدهٔ یک قانون با OR؛ خالی = null */
function unionFacets<T>(facets: T[]): T | { OR: T[] } | null {
if (!facets.length) return null;
return facets.length === 1 ? facets[0]! : { OR: facets };
}
export function buildContactRuleWhere( export function buildContactRuleWhere(
rule: DataAccessRule, rule: DataAccessRule,
ctx: { userId: string; teamPeerIds: string[] | null }, ctx: { userId: string; teamPeerIds: string[] | null },
): Prisma.PersonWhereInput | null { ): Prisma.PersonWhereInput | null {
const and: Prisma.PersonWhereInput[] = []; const facets: Prisma.PersonWhereInput[] = [];
const ids = ownerIdList(rule, ctx);
if (rule.owners?.length) { if (rule.owners?.length) {
if (!ids.length) return null; const ids = ownerIdList(rule, ctx);
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } }); if (ids.length) {
} facets.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
if (rule.groupIds?.length) { }
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
} }
// groupIds دیگر فیلتر دسترسی نیست؛ دید گروه فقط از مسئول‌گروه (personViaGroupAssignee)
if (rule.sources?.length) { if (rule.sources?.length) {
and.push({ facets.push({
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }], OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
}); });
} }
if (rule.moods?.length) { if (rule.moods?.length) {
and.push({ mood: { in: rule.moods } }); facets.push({ mood: { in: rule.moods } });
} }
if (!and.length) return null; return unionFacets(facets);
return and.length === 1 ? and[0]! : { AND: and };
} }
function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } { function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } {
@@ -130,6 +139,23 @@ function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } {
return parts.length === 1 ? parts[0]! : { OR: parts }; return parts.length === 1 ? parts[0]! : { OR: parts };
} }
/** اعضای گروه‌هایی که کاربر مسئولشان است */
function personViaGroupAssignee(userId: string): Prisma.PersonWhereInput {
return {
groupLinks: {
some: { group: { assignees: { some: { id: userId } } } },
},
};
}
function companyViaGroupAssignee(userId: string): Prisma.CompanyWhereInput {
return {
groupLinks: {
some: { group: { assignees: { some: { id: userId } } } },
},
};
}
export function customersPersonWhere( export function customersPersonWhere(
actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>, actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>,
): Prisma.PersonWhereInput { ): Prisma.PersonWhereInput {
@@ -144,6 +170,8 @@ export function customersPersonWhere(
}); });
if (w) parts.push(w); if (w) parts.push(w);
} }
// مسئول گروه: اتحاد با محدودیت نقش (مالک خودم یا عضو گروه تحت مسئولیت)
parts.push(personViaGroupAssignee(actor.userId));
return orParts(parts); return orParts(parts);
} }
@@ -155,34 +183,28 @@ export function customersCompanyWhere(
if (!sectionHasDataAccessLimits(section)) return {}; if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.CompanyWhereInput[] = []; const parts: Prisma.CompanyWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.CompanyWhereInput[] = []; // حال مشتری روی شرکت معنا ندارد؛ فقط مالک/منبع
if (rule.moods?.length && !rule.owners?.length && !rule.sources?.length) continue;
const facets: Prisma.CompanyWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } }); facets.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
} }
if (rule.groupIds?.length) {
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
} }
// groupIds دیگر فیلتر دسترسی نیست؛ دید گروه فقط از مسئول‌گروه (companyViaGroupAssignee)
if (rule.sources?.length) { if (rule.sources?.length) {
and.push({ facets.push({
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }], OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
}); });
} }
if ( const w = unionFacets(facets);
rule.moods?.length && if (w) parts.push(w);
!rule.owners?.length &&
!rule.groupIds?.length &&
!rule.sources?.length
) {
continue;
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
} }
parts.push(companyViaGroupAssignee(actor.userId));
return orParts(parts); return orParts(parts);
} }
@@ -194,27 +216,28 @@ export function leadsDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {}; if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.LeadWhereInput[] = []; const parts: Prisma.LeadWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.LeadWhereInput[] = []; const facets: Prisma.LeadWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } }); facets.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
}
} }
if (rule.stageIds?.length) { if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } }); facets.push({ stageId: { in: rule.stageIds } });
} }
if (rule.sources?.length) { if (rule.sources?.length) {
and.push({ facets.push({
OR: rule.sources.map((s) => ({ OR: rule.sources.map((s) => ({
source: { equals: s, mode: 'insensitive' as const }, source: { equals: s, mode: 'insensitive' as const },
})), })),
}); });
} }
if (!and.length) continue; const w = unionFacets(facets);
parts.push(and.length === 1 ? and[0]! : { AND: and }); if (w) parts.push(w);
} }
return orParts(parts); return orParts(parts);
} }
@@ -227,28 +250,29 @@ export function dealsDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {}; if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.DealWhereInput[] = []; const parts: Prisma.DealWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.DealWhereInput[] = []; const facets: Prisma.DealWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push({ facets.push({
OR: [ OR: [
{ ownerId: { in: ids } }, { ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } }, { assignees: { some: { id: { in: ids } } } },
], ],
}); });
}
} }
if (rule.pipelineIds?.length) { if (rule.pipelineIds?.length) {
and.push({ pipelineId: { in: rule.pipelineIds } }); facets.push({ pipelineId: { in: rule.pipelineIds } });
} }
if (rule.stageIds?.length) { if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } }); facets.push({ stageId: { in: rule.stageIds } });
} }
if (!and.length) continue; const w = unionFacets(facets);
parts.push(and.length === 1 ? and[0]! : { AND: and }); if (w) parts.push(w);
} }
return orParts(parts); return orParts(parts);
} }
@@ -261,32 +285,33 @@ export function activitiesDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {}; if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.ActivityWhereInput[] = []; const parts: Prisma.ActivityWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.ActivityWhereInput[] = []; const facets: Prisma.ActivityWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push({ facets.push({
OR: [ OR: [
{ assigneeId: { in: ids } }, { assigneeId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } }, { assignees: { some: { id: { in: ids } } } },
{ participantIds: { hasSome: ids } }, { participantIds: { hasSome: ids } },
], ],
}); });
}
} }
if (rule.boardIds?.length) { if (rule.boardIds?.length) {
and.push({ boardId: { in: rule.boardIds } }); facets.push({ boardId: { in: rule.boardIds } });
} }
if (rule.stageIds?.length) { if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } }); facets.push({ stageId: { in: rule.stageIds } });
} }
if (rule.typeIds?.length) { if (rule.typeIds?.length) {
and.push({ typeDefId: { in: rule.typeIds } }); facets.push({ typeDefId: { in: rule.typeIds } });
} }
if (!and.length) continue; const w = unionFacets(facets);
parts.push(and.length === 1 ? and[0]! : { AND: and }); if (w) parts.push(w);
} }
return orParts(parts); return orParts(parts);
} }
@@ -305,34 +330,35 @@ export function notesDeepWhere(
if (!sectionHasDataAccessLimits(section)) return personalGate; if (!sectionHasDataAccessLimits(section)) return personalGate;
const parts: Prisma.NoteWhereInput[] = []; const parts: Prisma.NoteWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.NoteWhereInput[] = []; const facets: Prisma.NoteWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push({ facets.push({
OR: [ OR: [
{ authorId: { in: ids } }, { authorId: { in: ids } },
{ {
deal: { deal: {
OR: [ OR: [
{ ownerId: { in: ids } }, { ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } }, { assignees: { some: { id: { in: ids } } } },
], ],
},
}, },
}, { person: { ownerId: { in: ids } } },
{ person: { ownerId: { in: ids } } }, { company: { ownerId: { in: ids } } },
{ company: { ownerId: { in: ids } } }, ],
], });
}); }
} }
if (rule.sources?.length) { if (rule.sources?.length) {
and.push({ noteType: { in: rule.sources } }); facets.push({ noteType: { in: rule.sources } });
} }
if (!and.length) continue; const w = unionFacets(facets);
parts.push(and.length === 1 ? and[0]! : { AND: and }); if (w) parts.push(w);
} }
return { return {
AND: [personalGate, orParts(parts)], AND: [personalGate, orParts(parts)],
@@ -347,26 +373,27 @@ export function ticketsDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {}; if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.TicketWhereInput[] = []; const parts: Prisma.TicketWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.TicketWhereInput[] = []; const facets: Prisma.TicketWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push({ facets.push({
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }], OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
}); });
}
} }
if (rule.sources?.length) { if (rule.sources?.length) {
and.push({ facets.push({
status: { status: {
in: rule.sources as Array<'open' | 'in_progress' | 'waiting' | 'resolved' | 'closed'>, in: rule.sources as Array<'open' | 'in_progress' | 'waiting' | 'resolved' | 'closed'>,
}, },
}); });
} }
if (!and.length) continue; const w = unionFacets(facets);
parts.push(and.length === 1 ? and[0]! : { AND: and }); if (w) parts.push(w);
} }
return orParts(parts); return orParts(parts);
} }
@@ -379,28 +406,29 @@ export function projectsDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {}; if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.ProjectCardWhereInput[] = []; const parts: Prisma.ProjectCardWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.ProjectCardWhereInput[] = []; const facets: Prisma.ProjectCardWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push({ facets.push({
OR: [ OR: [
{ ownerId: { in: ids } }, { ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } }, { assignees: { some: { id: { in: ids } } } },
], ],
}); });
}
} }
if (rule.boardIds?.length) { if (rule.boardIds?.length) {
and.push({ boardId: { in: rule.boardIds } }); facets.push({ boardId: { in: rule.boardIds } });
} }
if (rule.stageIds?.length) { if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } }); facets.push({ stageId: { in: rule.stageIds } });
} }
if (!and.length) continue; const w = unionFacets(facets);
parts.push(and.length === 1 ? and[0]! : { AND: and }); if (w) parts.push(w);
} }
return orParts(parts); return orParts(parts);
} }
@@ -413,20 +441,21 @@ export function routinesDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {}; if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.RoutineTaskWhereInput[] = []; const parts: Prisma.RoutineTaskWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.RoutineTaskWhereInput[] = []; const facets: Prisma.RoutineTaskWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } }); facets.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
}
} }
if (rule.typeIds?.length) { if (rule.typeIds?.length) {
and.push({ categoryId: { in: rule.typeIds } }); facets.push({ categoryId: { in: rule.typeIds } });
} }
if (!and.length) continue; const w = unionFacets(facets);
parts.push(and.length === 1 ? and[0]! : { AND: and }); if (w) parts.push(w);
} }
return orParts(parts); return orParts(parts);
} }
@@ -444,9 +473,14 @@ export function sectionOwnerIdUnion(
if (rule.owners?.length) { if (rule.owners?.length) {
sawOwnerRule = true; sawOwnerRule = true;
for (const id of ownerIdList(rule, ctx)) ids.add(id); for (const id of ownerIdList(rule, ctx)) ids.add(id);
} else if (rule.groupIds?.length || rule.sources?.length || rule.moods?.length || } else if (
rule.stageIds?.length || rule.pipelineIds?.length || rule.boardIds?.length || rule.sources?.length ||
rule.typeIds?.length) { rule.moods?.length ||
rule.stageIds?.length ||
rule.pipelineIds?.length ||
rule.boardIds?.length ||
rule.typeIds?.length
) {
sawNonOwnerRule = true; sawNonOwnerRule = true;
} }
} }
+81 -7
View File
@@ -5,7 +5,7 @@ import type {
SectionDataAccess, SectionDataAccess,
UserRole, UserRole,
} from '@mento/shared'; } from '@mento/shared';
import { DATA_SCOPES, canViewAllRecords } from '@mento/shared'; import { DATA_SCOPES, canViewAllRecords, sectionHasDataAccessLimits } from '@mento/shared';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { import {
activitiesDeepWhere, activitiesDeepWhere,
@@ -13,6 +13,7 @@ import {
notesDeepWhere, notesDeepWhere,
projectsDeepWhere, projectsDeepWhere,
routinesDeepWhere, routinesDeepWhere,
sectionOwnerIdUnion,
ticketsDeepWhere, ticketsDeepWhere,
} from './data-access'; } from './data-access';
@@ -60,6 +61,65 @@ export type Actor = {
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>; export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>;
type DeepAccessActor = ScopedActor &
Partial<
Pick<
Actor,
| 'customersAccess'
| 'leadsAccess'
| 'salesAccess'
| 'activitiesAccess'
| 'notesAccess'
| 'routinesAccess'
| 'projectsAccess'
| 'ticketsAccess'
| 'teamPeerIds'
>
>;
function sectionForScope(actor: DeepAccessActor, scope: DataScope): SectionDataAccess | undefined {
switch (scope) {
case 'contacts':
return actor.customersAccess;
case 'leads':
return actor.leadsAccess;
case 'deals':
return actor.salesAccess;
case 'activities':
return actor.activitiesAccess;
case 'notes':
return actor.notesAccess;
case 'routines':
return actor.routinesAccess;
case 'projects':
return actor.projectsAccess;
case 'tickets':
return actor.ticketsAccess;
default:
return undefined;
}
}
/**
* شناسه‌های مالک از قانون عمق داده.
* legacy = بخش عمیق روی این Actor نیست.
* open = محدودیت مالکی ندارد (همه، یا فقط فیلترهای غیرمالک).
*/
function deepOwnerIds(
actor: DeepAccessActor,
scope: DataScope,
): { kind: 'legacy' } | { kind: 'open' } | { kind: 'ids'; ids: string[] } {
const section = sectionForScope(actor, scope);
if (!section) return { kind: 'legacy' };
if (!sectionHasDataAccessLimits(section)) return { kind: 'open' };
const ids = sectionOwnerIdUnion(section, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (ids === null) return { kind: 'open' };
return { kind: 'ids', ids };
}
/** Actor با سطح دید مخصوص یک بخش (اگر تنظیم شده باشد). */ /** Actor با سطح دید مخصوص یک بخش (اگر تنظیم شده باشد). */
export function forScope<T extends ScopedActor>(actor: T, scope: DataScope): T { export function forScope<T extends ScopedActor>(actor: T, scope: DataScope): T {
const s = actor.scopes?.[scope]; const s = actor.scopes?.[scope];
@@ -109,9 +169,16 @@ export function scopedOwnerIds(
/** فیلتر مالکیت؛ پیش‌فرض بخش «مشتریان» (Person / Company). برای فاکتور/پیش‌فاکتور scope=finance. */ /** فیلتر مالکیت؛ پیش‌فرض بخش «مشتریان» (Person / Company). برای فاکتور/پیش‌فاکتور scope=finance. */
export function ownerWhere( export function ownerWhere(
actor: ScopedActor, actor: DeepAccessActor,
scope: DataScope = 'contacts', scope: DataScope = 'contacts',
): { ownerId: string } | { ownerId: { in: string[] } } | Record<string, never> { ): { ownerId: string } | { ownerId: { in: string[] } } | Record<string, never> {
const deep = deepOwnerIds(actor, scope);
if (deep.kind === 'open') return {};
if (deep.kind === 'ids') {
if (deep.ids.length === 1) return { ownerId: deep.ids[0]! };
if (!deep.ids.length) return { ownerId: '__no_access__' };
return { ownerId: { in: deep.ids } };
}
const ids = scopedOwnerIds(actor, scope); const ids = scopedOwnerIds(actor, scope);
if (!ids) return {}; if (!ids) return {};
if (ids.length === 1) return { ownerId: ids[0]! }; if (ids.length === 1) return { ownerId: ids[0]! };
@@ -153,11 +220,16 @@ export function projectCardAccessWhere(
} }
/** فعالیت‌ها بر اساس مسئول اصلی یا مسئولان مشترک */ /** فعالیت‌ها بر اساس مسئول اصلی یا مسئولان مشترک */
export function assigneeWhere( export function assigneeWhere(actor: DeepAccessActor): Prisma.ActivityWhereInput {
actor: ScopedActor, const deep = deepOwnerIds(actor, 'activities');
): Prisma.ActivityWhereInput { const ids =
const ids = scopedOwnerIds(actor, 'activities'); deep.kind === 'open'
? null
: deep.kind === 'ids'
? deep.ids
: scopedOwnerIds(actor, 'activities');
if (!ids) return {}; if (!ids) return {};
if (!ids.length) return { assigneeId: '__no_access__' };
return { return {
OR: [ OR: [
{ assigneeId: { in: ids } }, { assigneeId: { in: ids } },
@@ -225,8 +297,10 @@ 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;
+68 -1
View File
@@ -21,15 +21,82 @@ function tehranJalaliLong(d: Date) {
return `${toFaDigits(j.jd)} ${jalaliMonthName(j.jm)} ${toFaDigits(j.jy)}`; return `${toFaDigits(j.jd)} ${jalaliMonthName(j.jm)} ${toFaDigits(j.jy)}`;
} }
/** قالب بس/بد مثل UI ته‌حساب — برای توکن‌های مانده در پیامک */
export function formatSmsBedBes(v: unknown, maximumFractionDigits = 3): string {
if (v == null) return '';
const s = String(v).trim();
if (!s || s === 'null' || s === 'undefined' || s === 'NaN') return '';
const n = Number(s.replace(/,/g, ''));
if (!Number.isFinite(n)) return toFaDigits(s);
const abs = toFaDigits(
new Intl.NumberFormat('en-US', { maximumFractionDigits }).format(Math.abs(n)),
);
if (n === 0) return abs;
return `${abs} ${n < 0 ? 'بد' : 'بس'}`;
}
const METAL_FIELD_LABELS: Record<string, string> = {
MandeyeVazni: 'طلا',
MandeyeVazniNoghre: 'نقره',
MandeyeKolBeTALA: 'کل به طلا',
MandeMenhayeSeke: 'منهای سکه',
};
function isMetalMandehKey(key: string) {
if (METAL_FIELD_LABELS[key]) return true;
if (key.startsWith('Seke_')) return true;
return /Vazni|طلا|نقره|فلز|Gold|Silver/i.test(key);
}
function metalFieldLabel(key: string) {
if (METAL_FIELD_LABELS[key]) return METAL_FIELD_LABELS[key]!;
if (key.startsWith('Seke_')) return `سکه ${key.slice(5)}`;
return key;
}
/** از فیلدهای مانده ته‌حساب، متن مانده ریالی و فلزی می‌سازد */
export function smsBalanceTexts(input: {
mandehFields?: Array<{ key: string; value: string | null }> | null;
rialBalance?: number | null;
goldBalance?: number | null;
}): { rialBalance: string; metalBalance: string } {
const fields = input.mandehFields ?? [];
const mali = fields.find((f) => f.key === 'MandeyeMali' || f.key === 'MandeyeKolBePool');
const rial =
formatSmsBedBes(mali?.value ?? input.rialBalance) ||
formatSmsBedBes(input.rialBalance);
const metalParts = fields
.filter((f) => isMetalMandehKey(f.key) && f.value != null && String(f.value).trim() !== '')
.map((f) => `${metalFieldLabel(f.key)} ${formatSmsBedBes(f.value)}`.trim())
.filter(Boolean);
const metal =
metalParts.join('، ') ||
(input.goldBalance != null && formatSmsBedBes(input.goldBalance)
? `طلا ${formatSmsBedBes(input.goldBalance)}`
: '');
return { rialBalance: rial, metalBalance: metal };
}
export function renderSmsText( export function renderSmsText(
text: string, text: string,
vars: { name?: string; phone?: string; title?: string; company?: string }, vars: {
name?: string;
phone?: string;
title?: string;
company?: string;
from?: string;
rialBalance?: string;
metalBalance?: string;
},
) { ) {
return applyMessageTemplate(text, { return applyMessageTemplate(text, {
name: vars.name, name: vars.name,
phone: vars.phone, phone: vars.phone,
title: vars.title, title: vars.title,
company: vars.company, company: vars.company,
from: vars.from,
rialBalance: vars.rialBalance,
metalBalance: vars.metalBalance,
date: tehranJalaliLong(new Date()), date: tehranJalaliLong(new Date()),
}).trim(); }).trim();
} }
+1
View File
@@ -60,6 +60,7 @@ export function selectMonthBirthdays(people: BirthdayRow[], now = new Date(), pr
const upcoming = matched.filter((row) => row.day >= today.jd).sort(byDay); const upcoming = matched.filter((row) => row.day >= today.jd).sort(byDay);
const passed = matched.filter((row) => row.day < today.jd).sort(byDay); const passed = matched.filter((row) => row.day < today.jd).sort(byDay);
return { return {
year: today.jy,
month: today.jm, month: today.jm,
monthName: jalaliMonthName(today.jm), monthName: jalaliMonthName(today.jm),
today: today.jd, today: today.jd,
@@ -51,6 +51,11 @@ export class CompaniesController {
); );
} }
@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,
+55 -7
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';
@@ -19,10 +20,10 @@ import {
import { customersCompanyWhere } from '../common/data-access'; import { customersCompanyWhere } from '../common/data-access';
import { import {
type Actor, type Actor,
canReassignOwner,
elevated, elevated,
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, splitCsv } from '../common/search'; import { buildSearchWhere, ci, splitCsv } from '../common/search';
@@ -111,9 +112,13 @@ export class CompaniesService {
...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []), ...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []),
]; ];
const where: Prisma.CompanyWhereInput = { const where: Prisma.CompanyWhereInput = {
organizationId: actor.organizationId, AND: [
...customersCompanyWhere(actor), {
...(and.length ? { AND: and } : {}), organizationId: actor.organizationId,
...customersCompanyWhere(actor),
},
...and,
],
}; };
const [rows, total] = await this.prisma.$transaction([ const [rows, total] = await this.prisma.$transaction([
@@ -135,6 +140,39 @@ 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,
assignees: [],
}));
}
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, ...customersCompanyWhere(actor) }, where: { id, organizationId: actor.organizationId, ...customersCompanyWhere(actor) },
@@ -168,7 +206,11 @@ 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);
@@ -233,8 +275,14 @@ 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);
+4 -1
View File
@@ -3,11 +3,13 @@ import { LoyaltyModule } from '../loyalty/loyalty.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 { PublicApiModule } from '../public-api/public-api.module'; import { PublicApiModule } from '../public-api/public-api.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { CompaniesController } from './companies.controller'; import { CompaniesController } from './companies.controller';
import { CompaniesService } from './companies.service'; 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'; import { CustomerGroupsModule } from '../customer-groups/customer-groups.module';
@@ -19,9 +21,10 @@ import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'
PublicApiModule, PublicApiModule,
BehaviorModule, BehaviorModule,
CustomerGroupsModule, CustomerGroupsModule,
NotificationsModule,
], ],
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 {}
+23 -1
View File
@@ -3,6 +3,7 @@ import {
ArrayMaxSize, ArrayMaxSize,
ArrayMinSize, ArrayMinSize,
IsArray, IsArray,
IsBoolean,
IsEmail, IsEmail,
IsIn, IsIn,
IsInt, IsInt,
@@ -15,13 +16,18 @@ 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()
@MinLength(1) @MinLength(1)
@MaxLength(500) @MaxLength(500)
text!: string; text!: string;
/** تأیید ارسال مجدد وقتی قبلاً مسئول دیگری تبریک فرستاده */
@IsOptional()
@IsBoolean()
force?: boolean;
} }
export class BulkDeletePeopleDto { export class BulkDeletePeopleDto {
@@ -282,6 +288,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()
@@ -401,6 +415,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()
+367
View File
@@ -0,0 +1,367 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { Prisma, PrismaClient } from '@prisma/client';
import { canonicalIranMobile, storedPhoneNumber } from './normalize';
const personSelect = {
id: true,
createdAt: true,
firstName: true,
lastName: true,
gender: true,
showGenderInName: true,
previousFirstName: true,
previousLastName: true,
email: true,
phone: true,
mobile: true,
position: true,
website: true,
instagram: true,
birthDate: true,
referrer: true,
addresses: true,
notes: true,
tags: true,
companyId: true,
customFields: true,
importSource: true,
sourceRef: true,
previousImportSource: true,
previousSourceRef: true,
loyaltyPoints: true,
discLabel: true,
discReliable: true,
discAssessedAt: true,
} satisfies Prisma.PersonSelect;
type PersonRow = Prisma.PersonGetPayload<{ select: typeof personSelect }>;
function blank(value: string | null | undefined) {
return !value?.trim();
}
function asStrings(value: Prisma.JsonValue): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => String(item)).filter(Boolean);
}
function asObject(value: Prisma.JsonValue): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return { ...(value as Record<string, unknown>) };
}
function mergedFields(keeper: PersonRow, extras: PersonRow[], canon: string): Prisma.PersonUncheckedUpdateInput {
const notes = [keeper.notes?.trim() || ''];
let email = keeper.email;
let phone = storedPhoneNumber(keeper.phone);
let position = keeper.position;
let website = keeper.website;
let instagram = keeper.instagram;
let birthDate = keeper.birthDate;
let referrer = keeper.referrer;
let companyId = keeper.companyId;
let gender = keeper.gender;
let showGenderInName = keeper.showGenderInName;
let previousFirstName = keeper.previousFirstName;
let previousLastName = keeper.previousLastName;
let previousImportSource = keeper.previousImportSource;
let previousSourceRef = keeper.previousSourceRef;
let discLabel = keeper.discLabel;
let discReliable = keeper.discReliable;
let discAssessedAt = keeper.discAssessedAt;
let loyaltyPoints = keeper.loyaltyPoints;
const addresses = asStrings(keeper.addresses);
const tags = new Set(keeper.tags);
const customFields = asObject(keeper.customFields);
for (const extra of extras) {
const name = `${extra.firstName} ${extra.lastName}`.trim();
notes.push(
`ادغام شماره تکراری: ${name || 'بدون نام'} (${extra.mobile || 'بدون موبایل'})`,
);
if (blank(email) && extra.email) email = extra.email;
if (blank(phone)) phone = storedPhoneNumber(extra.phone);
if (blank(position) && extra.position) position = extra.position;
if (blank(website) && extra.website) website = extra.website;
if (blank(instagram) && extra.instagram) instagram = extra.instagram;
if (!birthDate && extra.birthDate) birthDate = extra.birthDate;
if (blank(referrer) && extra.referrer) referrer = extra.referrer;
if (!companyId && extra.companyId) companyId = extra.companyId;
if (gender === 'none' && extra.gender !== 'none') {
gender = extra.gender;
showGenderInName = extra.showGenderInName;
}
if (blank(previousFirstName) && extra.previousFirstName) {
previousFirstName = extra.previousFirstName;
}
if (blank(previousLastName) && extra.previousLastName) {
previousLastName = extra.previousLastName;
}
if (
blank(previousImportSource) &&
extra.importSource &&
extra.importSource !== keeper.importSource
) {
previousImportSource = extra.importSource;
previousSourceRef = extra.sourceRef;
}
if (
keeper.importSource === 'tahesab' &&
blank(previousFirstName) &&
blank(previousLastName)
) {
const extraName = `${extra.firstName} ${extra.lastName}`.trim();
const keeperName = `${keeper.firstName} ${keeper.lastName}`.trim();
if (extraName && extraName !== keeperName) {
previousFirstName = extra.firstName || null;
previousLastName = extra.lastName || null;
}
}
if (!discLabel && extra.discLabel) {
discLabel = extra.discLabel;
discReliable = extra.discReliable;
discAssessedAt = extra.discAssessedAt;
}
loyaltyPoints = Math.max(loyaltyPoints, extra.loyaltyPoints);
for (const address of asStrings(extra.addresses)) {
if (!addresses.includes(address)) addresses.push(address);
}
for (const tag of extra.tags) tags.add(tag);
for (const [key, value] of Object.entries(asObject(extra.customFields))) {
if (customFields[key] == null || customFields[key] === '') customFields[key] = value;
}
}
return {
mobile: canon,
email,
phone,
position,
website,
instagram,
birthDate,
referrer,
companyId,
gender,
showGenderInName,
previousFirstName,
previousLastName,
previousImportSource,
previousSourceRef,
discLabel,
discReliable,
discAssessedAt,
loyaltyPoints,
addresses,
tags: [...tags],
customFields: customFields as Prisma.InputJsonValue,
notes: notes.filter(Boolean).join('\n') || null,
};
}
async function personIdTables(prisma: PrismaClient | Prisma.TransactionClient) {
const rows = await prisma.$queryRaw<{ table_name: string }[]>`
SELECT table_name
FROM information_schema.columns
WHERE table_schema = 'public' AND column_name = 'personId'
`;
return rows
.map((row) => row.table_name)
.filter((name) => /^[A-Za-z0-9_]+$/.test(name) && name !== 'Person');
}
async function moveLinks(
tx: Prisma.TransactionClient,
tables: string[],
keeperId: string,
dupId: string,
) {
await tx.$executeRaw`
DELETE FROM "PersonGroupLink" AS d
USING "PersonGroupLink" AS k
WHERE d."personId" = ${dupId}
AND k."personId" = ${keeperId}
AND d."groupId" = k."groupId"
`;
await tx.$executeRaw`
DELETE FROM "PersonAccountingLink" AS d
USING "PersonAccountingLink" AS k
WHERE d."personId" = ${dupId}
AND k."personId" = ${keeperId}
AND d."connectionId" = k."connectionId"
`;
await tx.$executeRaw`
DELETE FROM "PartyOpeningBalance"
WHERE "personId" = ${dupId}
AND EXISTS (
SELECT 1 FROM "PartyOpeningBalance" AS k WHERE k."personId" = ${keeperId}
)
`;
for (const table of tables) {
await tx.$executeRawUnsafe(
`UPDATE "${table}" SET "personId" = $1 WHERE "personId" = $2`,
keeperId,
dupId,
);
}
await tx.$executeRaw`
UPDATE "Lead" SET "convertedPersonId" = ${keeperId} WHERE "convertedPersonId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "MontifyLink"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'customer' AND "entityId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "Notification"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "GamificationTransaction"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "FinancialAuditLog"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
`;
}
export async function mergeSameMobilePeople(
prisma: PrismaClient,
organizationId: string,
apply: boolean,
) {
const people = await prisma.person.findMany({
where: { organizationId },
select: personSelect,
orderBy: { createdAt: 'asc' },
});
const groups = new Map<string, PersonRow[]>();
for (const person of people) {
const key = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
if (!key) continue;
const list = groups.get(key) ?? [];
list.push(person);
groups.set(key, list);
}
const duplicateGroups = [...groups.entries()].filter(([, list]) => list.length > 1);
const preview = duplicateGroups.map(([mobile, list]) => ({
mobile,
people: list.map((person) => ({
id: person.id,
stored: person.mobile,
name: `${person.firstName} ${person.lastName}`.trim(),
})),
}));
if (!apply) {
const needsRewrite = people.filter((person) => {
const canon = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
return Boolean(canon && (person.mobile !== canon || canonicalIranMobile(person.phone)));
}).length;
return { merged: 0, rewritten: 0, needsRewrite, failed: 0, groups: preview };
}
const tables = await personIdTables(prisma);
let merged = 0;
let rewritten = 0;
let failed = 0;
for (const [canon, list] of groups) {
const ordered = [...list].sort((a, b) => {
const aTahesab = a.importSource === 'tahesab' ? 0 : 1;
const bTahesab = b.importSource === 'tahesab' ? 0 : 1;
if (aTahesab !== bTahesab) return aTahesab - bTahesab;
return a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id);
});
const [keeper, ...extras] = ordered;
if (!keeper) continue;
if (!extras.length) {
const phoneCanon = canonicalIranMobile(keeper.phone);
const data: Prisma.PersonUpdateInput = {};
if (keeper.mobile !== canon) data.mobile = canon;
if (phoneCanon && keeper.phone !== phoneCanon) data.phone = phoneCanon;
if (Object.keys(data).length) {
await prisma.person.update({ where: { id: keeper.id }, data });
rewritten += 1;
}
continue;
}
try {
await prisma.$transaction(
async (tx) => {
await tx.person.update({
where: { id: keeper.id },
data: mergedFields(keeper, extras, canon),
});
for (const extra of extras) {
await moveLinks(tx, tables, keeper.id, extra.id);
await tx.person.delete({ where: { id: extra.id } });
merged += 1;
}
},
{ timeout: 60_000 },
);
} catch (error) {
failed += 1;
console.error(
`merge mobile ${canon} failed`,
error instanceof Error ? error.message : error,
);
}
}
return { merged, rewritten, needsRewrite: 0, failed, groups: preview };
}
function loadEnv(file: string) {
const text = readFileSync(file, 'utf8');
for (const line of text.split(/\r?\n/)) {
const match = line.match(/^([A-Za-z0-9_]+)=(.*)$/);
if (!match || process.env[match[1]]) continue;
let value = match[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[match[1]] = value;
}
}
const direct = process.argv[1]?.replace(/\\/g, '/').includes('merge-same-mobile');
if (direct) {
loadEnv(resolve(__dirname, '../../.env'));
const orgId = process.argv.find((arg) => arg.startsWith('--org='))?.slice(6);
const apply = process.argv.includes('--apply');
if (!orgId) {
console.error('org id required: --org=<id>');
process.exit(1);
}
const prisma = new PrismaClient();
mergeSameMobilePeople(prisma, orgId, apply)
.then((result) => {
console.log(JSON.stringify({
apply,
merged: result.merged,
rewritten: result.rewritten,
needsRewrite: result.needsRewrite,
groupCount: result.groups.length,
groups: result.groups,
}, null, 2));
})
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
}
+44 -1
View File
@@ -9,11 +9,54 @@ export function normalizeEmail(value?: string | null): string | null {
return v ? v.toLowerCase() : null; return v ? v.toLowerCase() : null;
} }
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
const AR_DIGITS = '٠١٢٣٤٥٦٧٨٩';
/** ارقام فارسی و عربی را به لاتین برمی‌گرداند تا از شماره حذف نشوند. */
function latinDigits(value: string): string {
return value.replace(/[۰-۹٠-٩]/g, (ch) => {
const fa = FA_DIGITS.indexOf(ch);
return String(fa >= 0 ? fa : AR_DIGITS.indexOf(ch));
});
}
/** Keep digits and leading + for Iranian/international numbers. */ /** Keep digits and leading + for Iranian/international numbers. */
export function normalizePhone(value?: string | null): string | null { export function normalizePhone(value?: string | null): string | null {
const v = emptyToNull(value); const v = emptyToNull(value);
if (!v) return null; if (!v) return null;
const cleaned = v.replace(/[^\d+]/g, '');␍ const cleaned = latinDigits(v).replace(/[^\d+]/g, '');
return cleaned.length ? cleaned : null; return cleaned.length ? cleaned : null;
} }
/** موبایل ایران به شکل 09xxxxxxxxx. 912… و 98912… و +98912… همان شماره‌اند. */
export function canonicalIranMobile(value?: string | null): string | null {
const n = normalizePhone(value);
if (!n) return null;
let d = n.replace(/\D/g, '');
if (!d) return null;
if (d.startsWith('0098') && d.length >= 14) d = d.slice(4);
else if (d.startsWith('98') && d.length >= 12) d = d.slice(2);
if (d.length === 10 && d.startsWith('9')) d = `0${d}`;
return /^09\d{9}$/.test(d) ? d : null;
}
/** شکل‌هایی که ممکن است همین موبایل با آن‌ها ذخیره شده باشد. */
export function iranMobileLookupKeys(value?: string | null): string[] {
const canon = canonicalIranMobile(value);
if (!canon) return [];
const core = canon.slice(1);
return [canon, core, `98${core}`, `+98${core}`, `0098${core}`];
}
/** برای تطبیق: اگر موبایل ایران است همه شکل‌ها، وگرنه همان متن نرمال‌شده. */
export function phoneIdentityKeys(value?: string | null): string[] {
const keys = iranMobileLookupKeys(value);
if (keys.length) return keys;
const n = normalizePhone(value);
return n ? [n] : [];
}
/** ذخیره: موبایل ایران همیشه 09… ؛ بقیه شماره‌ها فقط تمیز می‌شوند. */
export function storedPhoneNumber(value?: string | null): string | null {
return canonicalIranMobile(value) ?? normalizePhone(value);
}
+7 -1
View File
@@ -89,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,
@@ -134,6 +139,7 @@ export class PeopleController {
} }
@Get('birthdays') @Get('birthdays')
@RequirePermission('customers', { reads: true })
birthdays(@CurrentUser() user: Actor) { birthdays(@CurrentUser() user: Actor) {
return this.peopleService.birthdaysThisMonth(user); return this.peopleService.birthdaysThisMonth(user);
} }
@@ -166,7 +172,7 @@ export class PeopleController {
@Param('id') id: string, @Param('id') id: string,
@Body() dto: BirthdaySmsDto, @Body() dto: BirthdaySmsDto,
) { ) {
return this.peopleService.sendBirthdaySms(user, id, dto.text); return this.peopleService.sendBirthdaySms(user, id, dto.text, Boolean(dto.force));
} }
@Get(':id') @Get(':id')
+310 -72
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,12 +18,23 @@ import type {
PersonMood, PersonMood,
UpdatePersonRequest, UpdatePersonRequest,
} from '@mento/shared'; } from '@mento/shared';
import { FILTERED_IDS_MAX, PERSON_MOODS } from '@mento/shared'; import {
FILTERED_IDS_MAX,
PERSON_MOODS,
applyPersonGenderTitle,
canViewAllRecords,
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,
@@ -32,18 +44,20 @@ import {
import { customersPersonWhere } from '../common/data-access'; import { customersPersonWhere } from '../common/data-access';
import { import {
type Actor, type Actor,
canReassignOwner,
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 { NotificationsService } from '../notifications/notifications.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service'; import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { WebhooksService } from '../public-api/webhooks.service'; import { WebhooksService } from '../public-api/webhooks.service';
import { GamificationService } from '../gamification/gamification.service'; import { GamificationService } from '../gamification/gamification.service';
import { parseBirthDate } from '../common/excel'; import { parseBirthDate } from '../common/excel';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service'; import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { selectMonthBirthdays } from './birthday-month'; import { selectMonthBirthdays } from './birthday-month';
import { renderSmsText } from '../common/sms-text'; import { renderSmsText, smsBalanceTexts } from '../common/sms-text';
import { extractMandehRaw } from '../integrations/tahesab/tahesab-methods';
import { normalizeTags } from '../common/tags'; import { normalizeTags } from '../common/tags';
import { buildSearchWhere, ci, splitCsv } from '../common/search'; import { buildSearchWhere, ci, splitCsv } from '../common/search';
import { import {
@@ -72,6 +86,8 @@ 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;
@@ -144,6 +160,7 @@ export class PeopleService implements OnModuleInit {
private readonly outboundSms: OutboundSmsLogService, private readonly outboundSms: OutboundSmsLogService,
private readonly behaviorAccess: BehaviorAccessService, private readonly behaviorAccess: BehaviorAccessService,
private readonly groups: CustomerGroupsService, private readonly groups: CustomerGroupsService,
private readonly notifications: NotificationsService,
) {} ) {}
private personListWhere( private personListWhere(
@@ -203,13 +220,27 @@ 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 } } } }); // گروه / منبع / حال: اتحاد (هر بُعد کافی است)؛ داخل هر بُعد چند مقدار با in = OR
const facets: Prisma.PersonWhereInput[] = [];
if (groupIds.length) {
facets.push({ groupLinks: { some: { groupId: { in: groupIds } } } });
}
if (moods.length) {
facets.push({ mood: { in: moods } });
}
if (sources.length) {
facets.push({ importSource: { in: sources } });
}
if (facets.length === 1) and.push(facets[0]!);
else if (facets.length > 1) and.push({ OR: facets });
return { return {
organizationId: actor.organizationId, AND: [
...customersPersonWhere(actor), {
...(moods.length ? { mood: { in: moods } } : {}), organizationId: actor.organizationId,
...(sources.length ? { importSource: { in: sources } } : {}), ...customersPersonWhere(actor),
...(and.length ? { AND: and } : {}), },
...and,
],
}; };
} }
@@ -303,6 +334,39 @@ 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,
assignees: [],
}));
}
onModuleInit() { onModuleInit() {
void this.backfillStoredBirthDates(); void this.backfillStoredBirthDates();
} }
@@ -359,7 +423,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({
@@ -384,17 +451,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),
@@ -405,7 +480,11 @@ 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 }
@@ -464,8 +543,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);
} }
@@ -477,8 +580,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);
@@ -502,8 +605,14 @@ 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);
@@ -559,10 +668,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({
@@ -587,8 +699,8 @@ export class PeopleService implements OnModuleInit {
const rows = await this.prisma.person.findMany({ const rows = await this.prisma.person.findMany({
where: { where: {
organizationId: actor.organizationId, organizationId: actor.organizationId,
ownerId: actor.userId,
birthDate: { not: null }, birthDate: { not: null },
...customersPersonWhere(actor),
}, },
select: { select: {
id: true, id: true,
@@ -635,27 +747,50 @@ export class PeopleService implements OnModuleInit {
}; };
} }
async sendBirthdaySms(actor: Actor, personId: string, text: string) { async sendBirthdaySms(actor: Actor, personId: string, text: string, force = false) {
const body = text.trim(); const body = text.trim();
if (!body || body.length > 500) { if (!body || body.length > 500) {
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.'); throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.');
} }
const person = await this.prisma.person.findFirst({ const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId: actor.organizationId }, where: {
id: personId,
organizationId: actor.organizationId,
...customersPersonWhere(actor),
},
select: { select: {
id: true, id: true,
firstName: true, firstName: true,
lastName: true, lastName: true,
mobile: true, mobile: true,
phone: true, phone: true,
position: true,
ownerId: true, ownerId: true,
birthDate: true, birthDate: true,
company: { select: { name: true } }, company: { select: { name: true } },
groupLinks: {
select: {
group: {
select: {
assignees: { select: { id: true, fullName: true } },
},
},
},
},
}, },
}); });
if (!person) throw new NotFoundException('مشتری پیدا نشد'); if (!person) throw new NotFoundException('مشتری پیدا نشد');
if (person.ownerId !== actor.userId) { const assigneeIds = new Set(
throw new ForbiddenException('فقط مسئول این مشتری می‌تواند پیامک تبریک بفرستد.'); person.groupLinks.flatMap((link) => link.group.assignees.map((a) => a.id)),
);
const canSend =
canViewAllRecords(actor.role) ||
person.ownerId === actor.userId ||
assigneeIds.has(actor.userId);
if (!canSend) {
throw new ForbiddenException(
'فقط مالک مشتری یا مسئول گروه او می‌تواند پیامک تبریک بفرستد.',
);
} }
if (!person.birthDate) { if (!person.birthDate) {
throw new BadRequestException('برای این مشتری تاریخ تولد ثبت نشده است.'); throw new BadRequestException('برای این مشتری تاریخ تولد ثبت نشده است.');
@@ -677,12 +812,110 @@ export class PeopleService implements OnModuleInit {
const mobile = person.mobile || person.phone; const mobile = person.mobile || person.phone;
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.'); if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
const fullName = `${person.firstName} ${person.lastName}`.trim(); const fullName = `${person.firstName} ${person.lastName}`.trim();
const message = renderSmsText(body, { const birthdayKey = `birthday:${inMonth.year}-${inMonth.month}`;
name: fullName, if (!force) {
const prior = await this.prisma.outboundSmsLog.findFirst({
where: {
organizationId: actor.organizationId,
personId: person.id,
status: 'sent',
sourceId: birthdayKey,
NOT: { actorUserId: actor.userId },
},
orderBy: { createdAt: 'desc' },
select: { actorUserId: true },
});
if (prior) {
const priorUser = prior.actorUserId
? await this.prisma.user.findFirst({
where: { id: prior.actorUserId },
select: { fullName: true },
})
: null;
const who = priorUser?.fullName?.trim() || 'مسئول دیگری';
throw new ConflictException(
`${who} قبلاً پیامک تبریک این مشتری را فرستاده است. آیا می‌خواهید دوباره بفرستید؟`,
);
}
}
const message = renderSmsText(
body,
await this.personSmsVars(actor.organizationId, person.id, {
name: fullName,
phone: mobile,
title: person.position?.trim() || '',
company: person.company?.name ?? '',
}),
);
if (!message) throw new BadRequestException('متن پیامک خالی است.');
const result = await this.sms.send({
organizationId: actor.organizationId,
phone: mobile, phone: mobile,
title: fullName, text: message,
company: person.company?.name ?? '', source: 'manual',
}); });
await this.outboundSms.record({
organizationId: actor.organizationId,
personId: person.id,
mobile,
body: message,
source: 'manual',
sourceId: birthdayKey,
status: result.ok ? 'sent' : 'failed',
error: result.ok ? null : result.reason,
actorUserId: actor.userId,
});
if (!result.ok) {
throw new ServiceUnavailableException(
result.blocked
? 'ارسال پیامک به‌خاطر محدودیت اعتبار مسدود است.'
: result.reason === 'SMS not configured'
? 'ارسال پیامک روی سرور فعال نیست.'
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
);
}
const senderName = (
await this.prisma.user.findFirst({
where: { id: actor.userId },
select: { fullName: true },
})
)?.fullName?.trim() || 'یکی از مسئولان';
const peers = [...assigneeIds].filter((id) => id !== actor.userId);
await Promise.all(
peers.map((userId) =>
this.notifications.create({
organizationId: actor.organizationId,
userId,
type: 'birthday_sms_sent',
title: 'پیامک تبریک تولد ارسال شد',
body: `${senderName} برای «${fullName}» پیامک تبریک فرستاد. اگر لازم است می‌توانید دوباره بفرستید.`,
link: `/contacts/people/${person.id}`,
entityType: 'person',
entityId: person.id,
dedupe: false,
}),
),
);
return { ok: true };
}
async sendSms(actor: Actor, personId: string, text: string) {
const body = text.trim();
if (!body || body.length > 500) {
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.');
}
const person = await this.get(actor, personId);
const mobile = person.mobile || person.phone;
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
const message = renderSmsText(
body,
await this.personSmsVars(actor.organizationId, person.id, {
name: person.fullName,
phone: mobile,
title: person.position?.trim() || '',
company: person.company?.name ?? '',
}),
);
if (!message) throw new BadRequestException('متن پیامک خالی است.'); if (!message) throw new BadRequestException('متن پیامک خالی است.');
const result = await this.sms.send({ const result = await this.sms.send({
organizationId: actor.organizationId, organizationId: actor.organizationId,
@@ -712,47 +945,50 @@ export class PeopleService implements OnModuleInit {
return { ok: true }; return { ok: true };
} }
async sendSms(actor: Actor, personId: string, text: string) { /** نام سازمان + مانده‌های کش‌شدهٔ ته‌حساب برای توکن‌های پیامک */
const body = text.trim(); private async personSmsVars(
if (!body || body.length > 500) { organizationId: string,
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.'); personId: string,
base: {
name: string;
phone: string;
title: string;
company: string;
},
) {
const [org, link] = await Promise.all([
this.prisma.organization.findUnique({
where: { id: organizationId },
select: { name: true },
}),
this.prisma.personAccountingLink.findFirst({
where: { personId, connection: { organizationId } },
orderBy: [
{ connection: { isDefault: 'desc' } },
{ linkedAt: 'desc' },
],
include: { balanceCache: true },
}),
]);
let rialBalance = '';
let metalBalance = '';
const cache = link?.balanceCache;
if (cache) {
const rawView = extractMandehRaw(cache.rawResponse);
const bal = smsBalanceTexts({
mandehFields: rawView.fields,
rialBalance: cache.rialBalance,
goldBalance: cache.goldBalance,
});
rialBalance = bal.rialBalance;
metalBalance = bal.metalBalance;
} }
const person = await this.get(actor, personId); return {
const mobile = person.mobile || person.phone; ...base,
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.'); from: org?.name?.trim() || '',
const message = renderSmsText(body, { rialBalance,
name: person.fullName, metalBalance,
phone: mobile, };
title: person.fullName,
company: person.company?.name ?? '',
});
if (!message) throw new BadRequestException('متن پیامک خالی است.');
const result = await this.sms.send({
organizationId: actor.organizationId,
phone: mobile,
text: message,
source: 'manual',
});
await this.outboundSms.record({
organizationId: actor.organizationId,
personId: person.id,
mobile,
body: message,
source: 'manual',
status: result.ok ? 'sent' : 'failed',
error: result.ok ? null : result.reason,
actorUserId: actor.userId,
});
if (!result.ok) {
throw new ServiceUnavailableException(
result.blocked
? 'ارسال پیامک به‌خاطر محدودیت اعتبار مسدود است.'
: result.reason === 'SMS not configured'
? 'ارسال پیامک روی سرور فعال نیست.'
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
);
}
return { ok: true };
} }
listSmsHistory(actor: Actor, personId: string, limit?: number) { listSmsHistory(actor: Actor, personId: string, limit?: number) {
@@ -776,6 +1012,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,
@@ -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 {
@@ -8,7 +8,14 @@ import {
Post, Post,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; import {
IsArray,
IsIn,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { CUSTOMER_GROUP_COLORS, type CustomerGroupColor } from '@mento/shared'; import { CUSTOMER_GROUP_COLORS, type CustomerGroupColor } from '@mento/shared';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard'; import { RolesGuard } from '../auth/roles.guard';
@@ -26,6 +33,11 @@ class CreateCustomerGroupDto {
@IsOptional() @IsOptional()
@IsIn([...CUSTOMER_GROUP_COLORS]) @IsIn([...CUSTOMER_GROUP_COLORS])
color?: CustomerGroupColor; color?: CustomerGroupColor;
@IsOptional()
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
} }
class UpdateCustomerGroupDto { class UpdateCustomerGroupDto {
@@ -38,6 +50,16 @@ class UpdateCustomerGroupDto {
@IsOptional() @IsOptional()
@IsIn([...CUSTOMER_GROUP_COLORS]) @IsIn([...CUSTOMER_GROUP_COLORS])
color?: CustomerGroupColor; color?: CustomerGroupColor;
/** null گروه را اصلی می‌کند */
@IsOptional()
@IsString()
parentId?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
} }
@Controller('customer-groups') @Controller('customer-groups')
@@ -48,24 +48,24 @@ export class CustomerGroupsService {
const rows = await this.prisma.customerGroup.findMany({ const rows = await this.prisma.customerGroup.findMany({
where: { organizationId }, where: { organizationId },
orderBy: [{ position: 'asc' }, { name: 'asc' }], orderBy: [{ position: 'asc' }, { name: 'asc' }],
include: { _count: { select: { people: true, companies: true } } }, include: {
_count: { select: { people: true, companies: true } },
parent: { select: { name: true } },
assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' } },
},
}); });
return rows.map((r) => ({ return rows.map((r) => this.toDto(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,
}));
} }
async create( async create(
organizationId: string, organizationId: string,
dto: { name: string; color?: CustomerGroupColor }, dto: { name: string; color?: CustomerGroupColor; assigneeIds?: string[] },
): Promise<CustomerGroupDto> { ): Promise<CustomerGroupDto> {
const name = this.cleanName(dto.name); const name = this.cleanName(dto.name);
const assigneeIds =
dto.assigneeIds !== undefined
? await this.validAssigneeIds(organizationId, dto.assigneeIds)
: [];
const max = await this.prisma.customerGroup.aggregate({ const max = await this.prisma.customerGroup.aggregate({
where: { organizationId }, where: { organizationId },
_max: { position: true }, _max: { position: true },
@@ -77,6 +77,9 @@ export class CustomerGroupsService {
name, name,
color: this.cleanColor(dto.color), color: this.cleanColor(dto.color),
position: (max._max.position ?? -1) + 1, position: (max._max.position ?? -1) + 1,
...(assigneeIds.length
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
: {}),
}, },
}); });
} catch (e) { } catch (e) {
@@ -88,15 +91,33 @@ export class CustomerGroupsService {
async update( async update(
organizationId: string, organizationId: string,
id: string, id: string,
dto: { name?: string; color?: CustomerGroupColor }, dto: {
name?: string;
color?: CustomerGroupColor;
parentId?: string | null;
assigneeIds?: string[];
},
): Promise<CustomerGroupDto> { ): Promise<CustomerGroupDto> {
await this.require(organizationId, id); await this.require(organizationId, id);
const parentId =
dto.parentId === undefined ? undefined : dto.parentId?.trim() || null;
if (parentId !== undefined) {
await this.assertParent(organizationId, id, parentId);
}
const assigneeIds =
dto.assigneeIds !== undefined
? await this.validAssigneeIds(organizationId, dto.assigneeIds)
: undefined;
try { try {
await this.prisma.customerGroup.update({ await this.prisma.customerGroup.update({
where: { id }, where: { id },
data: { data: {
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}), ...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}), ...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
...(parentId !== undefined ? { parentId } : {}),
...(assigneeIds !== undefined
? { assignees: { set: assigneeIds.map((uid) => ({ id: uid })) } }
: {}),
}, },
}); });
} catch (e) { } catch (e) {
@@ -105,6 +126,32 @@ export class CustomerGroupsService {
return (await this.list(organizationId)).find((g) => g.id === id)!; 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> { async remove(organizationId: string, id: string): Promise<void> {
await this.require(organizationId, id); await this.require(organizationId, id);
@@ -280,6 +327,48 @@ export class CustomerGroupsService {
return link?.group.name ?? null; return link?.group.name ?? null;
} }
private toDto(r: {
id: string;
name: string;
color: string;
position: number;
tahesabGid: string | null;
parentId: string | null;
parent?: { name: string } | null;
_count: { people: number; companies: number };
assignees: Array<{ id: string; fullName: string }>;
}): CustomerGroupDto {
return {
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,
assignees: r.assignees.map((a) => ({ id: a.id, fullName: a.fullName })),
};
}
private async validAssigneeIds(organizationId: string, ids: string[]): Promise<string[]> {
const unique = [...new Set((ids ?? []).map((id) => id?.trim()).filter(Boolean))];
if (!unique.length) return [];
const rows = await this.prisma.user.findMany({
where: {
id: { in: unique },
isActive: true,
memberships: { some: { organizationId, isActive: true } },
},
select: { id: true },
});
if (rows.length !== unique.length) {
throw new BadRequestException('یکی از مسئولان انتخاب‌شده معتبر نیست');
}
return rows.map((r) => r.id);
}
private async require(organizationId: string, id: string) { private async require(organizationId: string, id: string) {
const row = await this.prisma.customerGroup.findFirst({ const row = await this.prisma.customerGroup.findFirst({
where: { id, organizationId }, where: { id, organizationId },
@@ -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 = {
@@ -1,4 +1,11 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
import {
pickAddress,
pickCity,
pickNationalId,
pickRasteh,
tahesabProfilePatch,
} from '../moshtari-profile';
import { import {
assertFactorCode, assertFactorCode,
buildFactorCode, buildFactorCode,
@@ -116,12 +123,52 @@ function testSanadBuilders() {
assert(bank[12] === fc, 'bank factor index'); assert(bank[12] === fc, 'bank factor index');
} }
function testMoshtariProfile() {
const row = {
CodeMelli: '00112312311',
Address: 'خیابان ولیعصر',
City: 'تهران',
Rasteh: 2,
BDate: null,
};
assert(pickNationalId(row) === '00112312311', 'national id');
assert(pickAddress(row) === 'خیابان ولیعصر', 'address');
assert(pickCity(row) === 'تهران', 'city');
assert(pickRasteh(row) === 2, 'rasteh');
assert(pickNationalId({ CodeMelli: '-1' }) == null, 'skip -1 national');
const patch = tahesabProfilePatch(row, {
birthDate: null,
addresses: [],
customFields: { keep: true },
});
assert(Array.isArray(patch.addresses) && patch.addresses[0] === 'خیابان ولیعصر، تهران', 'addr+city');
const fields = patch.customFields as Record<string, unknown>;
assert(fields.nationalId === '00112312311', 'cf national');
assert(fields.city === 'تهران', 'cf city');
assert(fields.rasteh === 2, 'cf rasteh');
assert(fields.keep === true, 'cf merge');
const emptyKeep = tahesabProfilePatch(
{ CodeMelli: '', Address: '', City: '', Rasteh: '' },
{
birthDate: new Date('2000-01-01'),
addresses: ['قدیمی'],
customFields: { nationalId: '111' },
},
);
assert(emptyKeep.addresses == null, 'empty does not wipe address');
assert(emptyKeep.customFields == null, 'empty does not wipe custom');
assert(emptyKeep.birthDate == null, 'existing birth kept');
}
function main() { function main() {
testCryptoRoundtrip(); testCryptoRoundtrip();
testFactorCode(); testFactorCode();
testMobile(); testMobile();
testMandehNormalize(); testMandehNormalize();
testSanadBuilders(); testSanadBuilders();
testMoshtariProfile();
console.log('tahesab unit checks: OK'); console.log('tahesab unit checks: OK');
} }
@@ -6,21 +6,44 @@ import {
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import type { import type {
CustomerGroupSummaryDto, CustomerGroupSummaryDto,
SalesExpertOverviewDto,
SalesFactorOverviewDto,
SalesGroupOverviewDto,
SalesInternetOverviewDto,
SalesNamedMovementDto,
TahesabAsnadKind, TahesabAsnadKind,
TahesabAsnadLineDto, TahesabAsnadLineDto,
TahesabAsnadReportPageDto, TahesabAsnadReportPageDto,
TahesabAsnadSummaryDto, TahesabAsnadSummaryDto,
TahesabAsnadSyncJobDto, TahesabAsnadSyncJobDto,
TahesabSalesOverviewDto,
} from '@mento/shared'; } from '@mento/shared';
import { import {
TAHESAB_ASNAD_KIND_LABELS, TAHESAB_ASNAD_KIND_LABELS,
TAHESAB_JENS_FELEZ_LABELS, TAHESAB_JENS_FELEZ_LABELS,
} from '@mento/shared'; } from '@mento/shared';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { toGregorian } from '../../common/jalali';
import { TahesabConnectionsService } from './connections.service'; import { TahesabConnectionsService } from './connections.service';
import {
SALES_FACTOR_KEYS,
SALES_FACTOR_LABELS,
classifyDirection,
classifyFactor,
combinedPercent,
movementStats,
positiveAmount,
type SalesDoc,
} from './sales-factors';
const ASNAD_COUNT_LAST = 200; const ASNAD_COUNT_LAST = 5000;
const SYNC_CONCURRENCY = 3; const SYNC_CONCURRENCY = 3;
function latinDigits(value: string): string {
return value
.replace(/[۰-۹]/g, (ch) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(ch)))
.replace(/[٠-٩]/g, (ch) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(ch)));
}
const ASNAD_KINDS = new Set<string>(['sale', 'return', 'purchase', 'other']); const ASNAD_KINDS = new Set<string>(['sale', 'return', 'purchase', 'other']);
function normalizeShamsiYmd(input: string): string { function normalizeShamsiYmd(input: string): string {
@@ -167,6 +190,7 @@ export class TahesabAsnadReportService {
connectionId: string | undefined, connectionId: string | undefined,
from: string, from: string,
to: string, to: string,
scope?: { personId?: string; groupIds?: string[] },
): Promise<TahesabAsnadSyncJobDto> { ): Promise<TahesabAsnadSyncJobDto> {
const fromNorm = normalizeShamsiYmd(from); const fromNorm = normalizeShamsiYmd(from);
const toNorm = normalizeShamsiYmd(to); const toNorm = normalizeShamsiYmd(to);
@@ -177,6 +201,22 @@ export class TahesabAsnadReportService {
organizationId, organizationId,
connectionId, connectionId,
); );
const scopedPersonIds = await this.resolveSyncPersonIds(organizationId, scope);
if (scopedPersonIds && scopedPersonIds.length === 0) {
throw new BadRequestException(
'برای گروه یا شخص انتخاب‌شده مشتری‌ای پیدا نشد.',
);
}
if (scopedPersonIds) {
const linked = await this.prisma.personAccountingLink.count({
where: { connectionId: resolvedId, personId: { in: scopedPersonIds } },
});
if (linked === 0) {
throw new BadRequestException(
'مشتریان این فیلتر به ته‌حساب وصل نیستند. به‌روزرسانی همه را بزنید یا اول اتصال مشتری را انجام دهید.',
);
}
}
const job = await this.prisma.accountingSyncJob.create({ const job = await this.prisma.accountingSyncJob.create({
data: { data: {
@@ -184,7 +224,13 @@ export class TahesabAsnadReportService {
connectionId: resolvedId, connectionId: resolvedId,
kind: 'asnad-sync', kind: 'asnad-sync',
status: 'pending', status: 'pending',
request: { from: fromNorm, to: toNorm, connectionId: resolvedId }, request: {
from: fromNorm,
to: toNorm,
connectionId: resolvedId,
personId: scope?.personId ?? null,
groupIds: scope?.groupIds ?? [],
},
result: { result: {
connectionId: resolvedId, connectionId: resolvedId,
from: fromNorm, from: fromNorm,
@@ -197,9 +243,14 @@ export class TahesabAsnadReportService {
}, },
}); });
void this.runSync(job.id, organizationId, resolvedId, fromNorm, toNorm).catch( void this.runSync(
() => undefined, job.id,
); organizationId,
resolvedId,
fromNorm,
toNorm,
scopedPersonIds,
).catch(() => undefined);
return this.mapJob(job); return this.mapJob(job);
} }
@@ -221,6 +272,7 @@ export class TahesabAsnadReportService {
connectionId: string, connectionId: string,
from: string, from: string,
to: string, to: string,
personIds: string[] | null,
) { ) {
const progress: SyncProgress = { const progress: SyncProgress = {
connectionId, connectionId,
@@ -240,7 +292,10 @@ export class TahesabAsnadReportService {
const { api } = await this.connections.getApi(organizationId, connectionId); const { api } = await this.connections.getApi(organizationId, connectionId);
const links = await this.prisma.personAccountingLink.findMany({ const links = await this.prisma.personAccountingLink.findMany({
where: { connectionId }, where: {
connectionId,
...(personIds ? { personId: { in: personIds } } : {}),
},
select: { select: {
id: true, id: true,
personId: true, personId: true,
@@ -259,6 +314,7 @@ export class TahesabAsnadReportService {
organizationId, organizationId,
connectionId, connectionId,
tarikh: { gte: from, lte: to }, tarikh: { gte: from, lte: to },
...(personIds ? { personId: { in: personIds } } : {}),
}, },
}); });
@@ -307,6 +363,11 @@ export class TahesabAsnadReportService {
mali: pickNum(item.Mali), mali: pickNum(item.Mali),
count: pickNum(item.Count), count: pickNum(item.Count),
ayar: pickNum(item.Ayar), ayar: pickNum(item.Ayar),
darsad: pickNum(item.Darsad ?? item.darsad),
darsadTalayee: pickNum(
item.Darsad_Talayee ?? item.DarsadTalayee ?? item.darsadTalayee,
),
maliatDarsad: pickNum(item.MaliatDarsad ?? item.Maliat_Darsad),
mazaneh: pickNum(item.Mazaneh), mazaneh: pickNum(item.Mazaneh),
fi: pickNum(item.Fi), fi: pickNum(item.Fi),
sharh1: pickStr(item.Sharh1), sharh1: pickStr(item.Sharh1),
@@ -383,11 +444,12 @@ export class TahesabAsnadReportService {
where.jensFelez = params.jensFelez; where.jensFelez = params.jensFelez;
} }
if (params.groupIds?.length) { if (params.groupIds?.length) {
const groupIds = await this.expandGroupIds(params.organizationId, params.groupIds);
where.personId = { where.personId = {
in: ( in: (
await this.prisma.personGroupLink.findMany({ await this.prisma.personGroupLink.findMany({
where: { where: {
groupId: { in: params.groupIds }, groupId: { in: groupIds },
person: { organizationId: params.organizationId }, person: { organizationId: params.organizationId },
}, },
select: { personId: true }, select: { personId: true },
@@ -614,4 +676,553 @@ export class TahesabAsnadReportService {
lastSyncedAt: lastSyncedAt?.toISOString() ?? null, lastSyncedAt: lastSyncedAt?.toISOString() ?? null,
}; };
} }
/** null یعنی همهٔ مشتریان متصل. آرایه یعنی فقط همین اشخاص. */
private async resolveSyncPersonIds(
organizationId: string,
scope?: { personId?: string; groupIds?: string[] },
): Promise<string[] | null> {
const groupIds = (scope?.groupIds ?? []).map((id) => id.trim()).filter(Boolean);
const personId = scope?.personId?.trim() || undefined;
if (!personId && groupIds.length === 0) return null;
let ids: string[] | null = null;
if (groupIds.length) {
const expanded = await this.expandGroupIds(organizationId, groupIds);
const rows = await this.prisma.personGroupLink.findMany({
where: {
groupId: { in: expanded },
person: { organizationId },
},
select: { personId: true },
distinct: ['personId'],
});
ids = rows.map((row) => row.personId);
}
if (personId) ids = ids ? ids.filter((id) => id === personId) : [personId];
return ids ?? [];
}
async searchCustomers(
organizationId: string,
connectionId: string | undefined,
q: string,
) {
const resolvedId = await this.connections.resolveConnectionId(
organizationId,
connectionId,
);
const query = q.trim();
const tokens = query.split(/\s+/).filter(Boolean);
const code = Number(latinDigits(query));
const or: Prisma.PersonAccountingLinkWhereInput[] = [];
if (tokens.length) {
or.push({
person: {
organizationId,
AND: tokens.map((token) => ({
OR: [
{ firstName: { contains: token } },
{ lastName: { contains: token } },
],
})),
},
});
}
if (Number.isInteger(code) && code > 0 && latinDigits(query) === String(code)) {
or.push({ moshtariCode: code, person: { organizationId } });
}
const rows = await this.prisma.personAccountingLink.findMany({
where: {
connectionId: resolvedId,
...(or.length ? { OR: or } : { person: { organizationId } }),
},
take: 30,
orderBy: [{ person: { lastName: 'asc' } }, { person: { firstName: 'asc' } }],
select: {
personId: true,
moshtariCode: true,
person: { select: { firstName: true, lastName: true } },
},
});
return {
items: rows.map((row) => ({
personId: row.personId,
moshtariCode: row.moshtariCode,
name: `${row.person.firstName} ${row.person.lastName}`.trim(),
})),
};
}
/** گروه اصلی، زیرگروه‌ها را هم داخل فیلتر می‌آورد. */
private async expandGroupIds(organizationId: string, ids: string[]): Promise<string[]> {
const rows = await this.prisma.customerGroup.findMany({
where: { organizationId },
select: { id: true, parentId: true },
});
const children = new Map<string, string[]>();
for (const row of rows) {
if (!row.parentId) continue;
const list = children.get(row.parentId) ?? [];
list.push(row.id);
children.set(row.parentId, list);
}
const out = new Set(ids);
const walk = (id: string) => {
for (const child of children.get(id) ?? []) {
if (out.has(child)) continue;
out.add(child);
walk(child);
}
};
for (const id of ids) walk(id);
return [...out];
}
async overview(
organizationId: string,
opts: {
connectionId?: string;
from: string;
to: string;
groupIds?: string[];
personId?: string;
jensFelez?: number;
},
): Promise<TahesabSalesOverviewDto> {
const where = await this.buildWhere({ organizationId, ...opts });
const rows = await this.prisma.accountingAsnadLine.findMany({
where,
select: {
id: true,
tarikh: true,
noRaw: true,
noKar: true,
jensFelez: true,
vazn: true,
tabdilVazn: true,
mali: true,
count: true,
darsad: true,
darsadTalayee: true,
maliatDarsad: true,
factorCode: true,
personId: true,
person: {
select: {
ownerId: true,
owner: { select: { id: true, fullName: true } },
groupLinks: {
include: {
group: {
select: {
id: true,
name: true,
parentId: true,
parent: { select: { id: true, name: true } },
},
},
},
},
},
},
},
});
const docs: SalesDoc[] = rows.map((row) => ({
bucketKey: (row.noKar ?? '').trim() || 'بدون نام',
factor: classifyFactor(row.noRaw, row.noKar),
direction: classifyDirection(row.noRaw),
weight: positiveAmount(row.vazn, row.tabdilVazn),
mali: Math.abs(row.mali ?? 0),
count: Math.abs(row.count ?? 0),
percent: combinedPercent(row.darsad, row.darsadTalayee, row.maliatDarsad),
tarikh: row.tarikh,
}));
const factors: SalesFactorOverviewDto[] = SALES_FACTOR_KEYS.map((key) => {
const slice = docs.filter((d) => d.factor === key);
return {
key,
label: SALES_FACTOR_LABELS[key],
sale: movementStats(slice, 'out'),
purchase: movementStats(slice, 'in'),
};
});
const names = [...new Set(docs.filter((d) => d.factor === 'karsakhte').map((d) => d.bucketKey))];
names.sort((a, b) => a.localeCompare(b, 'fa'));
const karsakhte: SalesNamedMovementDto[] = names.map((name) => {
const slice = docs.filter((d) => d.factor === 'karsakhte' && d.bucketKey === name);
return {
name,
sale: movementStats(slice, 'out'),
purchase: movementStats(slice, 'in'),
};
});
const groups = this.groupOverview(rows, docs);
const experts = await this.expertOverview(organizationId, rows, docs);
const internetPack = await this.internetOverview(
organizationId,
opts.from,
opts.to,
rows,
opts.personId,
opts.groupIds,
);
await this.addInternetCommission(organizationId, experts, internetPack.owners);
const internet = internetPack.public;
const nos = new Set<string>();
const otherNos = new Set<string>();
for (const row of rows) {
if (!row.noRaw) continue;
nos.add(row.noRaw);
if (classifyFactor(row.noRaw, row.noKar) === 'other') otherNos.add(row.noRaw);
}
return {
factors,
karsakhte,
groups,
experts,
internet,
distinctNos: [...nos].sort((a, b) => a.localeCompare(b, 'fa')),
otherNos: [...otherNos].sort((a, b) => a.localeCompare(b, 'fa')),
percentReady: rows.some((r) => r.darsad != null || r.darsadTalayee != null || r.maliatDarsad != null),
};
}
private groupOverview(
rows: Array<{
id: string;
person: {
groupLinks: Array<{
group: { id: string; name: string; parentId: string | null; parent: { id: string; name: string } | null };
}>;
} | null;
}>,
docs: SalesDoc[],
): SalesGroupOverviewDto[] {
const byId = new Map(docs.map((d, i) => [rows[i]!.id, d]));
type Acc = { label: string; parentId: string | null; parentName: string | null; gross: number; ret: number; mali: number; retMali: number };
const acc = new Map<string, Acc>();
rows.forEach((row, index) => {
const doc = byId.get(row.id) ?? docs[index];
if (!doc || (doc.direction !== 'out' && doc.direction !== 'sale_return')) return;
const links = row.person?.groupLinks ?? [];
const targets = links.length
? links
: [{ group: { id: '__none__', name: 'بدون گروه', parentId: null, parent: null } }];
for (const link of targets) {
const g = link.group;
const bag = acc.get(g.id) ?? {
label: g.name,
parentId: g.parentId,
parentName: g.parent?.name ?? null,
gross: 0,
ret: 0,
mali: 0,
retMali: 0,
};
if (doc.direction === 'out') {
bag.gross += doc.weight;
bag.mali += doc.mali;
} else {
bag.ret += doc.weight;
bag.retMali += doc.mali;
}
acc.set(g.id, bag);
}
});
const parents = new Map<string, SalesGroupOverviewDto>();
const ensure = (id: string, label: string) => {
const found = parents.get(id);
if (found) return found;
const created: SalesGroupOverviewDto = {
id,
label,
netSaleWeight: 0,
netSaleMali: 0,
saleReturnWeight: 0,
children: [],
};
parents.set(id, created);
return created;
};
for (const [id, bag] of acc) {
const netW = Math.max(0, bag.gross - bag.ret);
const netM = Math.max(0, bag.mali - bag.retMali);
if (bag.parentId) {
const parent = ensure(bag.parentId, bag.parentName || 'گروه اصلی');
parent.netSaleWeight += netW;
parent.netSaleMali += netM;
parent.saleReturnWeight += bag.ret;
parent.children.push({
id,
label: bag.label,
netSaleWeight: netW,
netSaleMali: netM,
saleReturnWeight: bag.ret,
});
} else {
const parent = ensure(id, bag.label);
parent.netSaleWeight += netW;
parent.netSaleMali += netM;
parent.saleReturnWeight += bag.ret;
}
}
return [...parents.values()].sort((a, b) => b.netSaleWeight - a.netSaleWeight);
}
private async expertOverview(
organizationId: string,
rows: Array<{
id: string;
jensFelez: number | null;
person: { ownerId: string | null; owner: { id: string; fullName: string } | null } | null;
}>,
docs: SalesDoc[],
): Promise<SalesExpertOverviewDto[]> {
type Bag = { name: string; silverW: number; silverM: number; silverRetW: number; silverRetM: number; investW: number; investM: number; investRetW: number; investRetM: number };
const bags = new Map<string, Bag>();
const touch = (id: string, name: string) => {
const bag = bags.get(id) ?? {
name,
silverW: 0,
silverM: 0,
silverRetW: 0,
silverRetM: 0,
investW: 0,
investM: 0,
investRetW: 0,
investRetM: 0,
};
bags.set(id, bag);
return bag;
};
rows.forEach((row, index) => {
const doc = docs[index];
if (!doc || (doc.direction !== 'out' && doc.direction !== 'sale_return')) return;
const ownerId = row.person?.owner?.id ?? row.person?.ownerId ?? '__none__';
const name = row.person?.owner?.fullName || 'بدون کارشناس';
const bag = touch(ownerId, name);
const isReturn = doc.direction === 'sale_return';
const silver = doc.factor === 'karsakhte' && row.jensFelez === 2;
const invest = doc.factor === 'etiket_jewel';
if (silver) {
if (isReturn) {
bag.silverRetW += doc.weight;
bag.silverRetM += doc.mali;
} else {
bag.silverW += doc.weight;
bag.silverM += doc.mali;
}
}
if (invest) {
if (isReturn) {
bag.investRetW += doc.weight;
bag.investRetM += doc.mali;
} else {
bag.investW += doc.weight;
bag.investM += doc.mali;
}
}
});
const ownerIds = [...bags.keys()].filter((id) => id !== '__none__');
const memberships = ownerIds.length
? await this.prisma.membership.findMany({
where: { organizationId, userId: { in: ownerIds } },
select: { userId: true, commissionPercent: true },
})
: [];
const percentOf = new Map(memberships.map((m) => [m.userId, Number(m.commissionPercent ?? 0)]));
return [...bags.entries()]
.map(([userId, bag]) => {
const percent = percentOf.get(userId) ?? 0;
const silverMali = Math.max(0, bag.silverM - bag.silverRetM);
const investMali = Math.max(0, bag.investM - bag.investRetM);
return {
userId,
name: bag.name,
percent,
silverWeight: Math.max(0, bag.silverW - bag.silverRetW),
silverMali,
silverCommission: (silverMali * percent) / 100,
investWeight: Math.max(0, bag.investW - bag.investRetW),
investMali,
investCommission: (investMali * percent) / 100,
};
})
.sort((a, b) => b.silverMali + b.investMali - (a.silverMali + a.investMali));
}
private async addInternetCommission(
organizationId: string,
experts: SalesExpertOverviewDto[],
owners: Map<string, { name: string; weight: number; mali: number }>,
) {
const missing = [...owners.keys()].filter((id) => id !== '__none__' && !experts.some((e) => e.userId === id));
const memberships = missing.length
? await this.prisma.membership.findMany({
where: { organizationId, userId: { in: missing } },
select: { userId: true, commissionPercent: true },
})
: [];
const percentOf = new Map(memberships.map((m) => [m.userId, Number(m.commissionPercent ?? 0)]));
for (const [userId, extra] of owners) {
let row = experts.find((e) => e.userId === userId);
if (!row) {
row = {
userId,
name: extra.name,
percent: percentOf.get(userId) ?? 0,
silverWeight: 0,
silverMali: 0,
silverCommission: 0,
investWeight: 0,
investMali: 0,
investCommission: 0,
};
experts.push(row);
}
row.investWeight += extra.weight;
row.investMali += extra.mali;
row.investCommission = (row.investMali * row.percent) / 100;
}
experts.sort((a, b) => b.silverMali + b.investMali - (a.silverMali + a.investMali));
const active = experts.filter(
(e) => e.silverWeight > 0 || e.investWeight > 0 || e.silverMali > 0 || e.investMali > 0,
);
experts.splice(0, experts.length, ...active);
}
/** null یعنی گروه و شخص اشتراکی ندارند و سفارشی نباید بیاید. */
private async internetPersonWhere(
organizationId: string,
personId?: string,
groupIds?: string[],
): Promise<{ personId: string | { in: string[] } } | Record<string, never> | null> {
if (!groupIds?.length) {
return personId ? { personId } : {};
}
const expanded = await this.expandGroupIds(organizationId, groupIds);
const links = await this.prisma.personGroupLink.findMany({
where: {
groupId: { in: expanded },
person: { organizationId },
},
select: { personId: true },
distinct: ['personId'],
});
let ids = links.map((link) => link.personId);
if (personId) ids = ids.filter((id) => id === personId);
if (!ids.length) return null;
return { personId: { in: ids } };
}
private async internetOverview(
organizationId: string,
from: string,
to: string,
rows: Array<{ factorCode: string | null }>,
personId?: string,
groupIds?: string[],
): Promise<{
public: SalesInternetOverviewDto;
owners: Map<string, { name: string; weight: number; mali: number }>;
}> {
const empty: SalesInternetOverviewDto = {
orderCount: 0,
returnCount: 0,
netGold: 0,
netSilver: 0,
returnGold: 0,
returnSilver: 0,
netMali: 0,
returnMali: 0,
linkedSkipped: 0,
};
const start = shamsiStart(from);
const end = shamsiEnd(to);
const owners = new Map<string, { name: string; weight: number; mali: number }>();
if (!start || !end) return { public: empty, owners };
const personWhere = await this.internetPersonWhere(organizationId, personId, groupIds);
if (!personWhere) return { public: empty, owners };
const orders = await this.prisma.montifyOrder.findMany({
where: {
organizationId,
deletedAt: null,
...personWhere,
externalCreatedAt: { gte: start, lte: end },
},
select: {
externalId: true,
orderReference: true,
orderStatus: true,
cancelledAt: true,
totalPrice: true,
discountTotalPrice: true,
sumGold: true,
sumSilver: true,
personId: true,
},
});
const personIds = [...new Set(orders.map((order) => order.personId).filter((id): id is string => Boolean(id)))];
const people = personIds.length
? await this.prisma.person.findMany({
where: { organizationId, id: { in: personIds } },
select: { id: true, ownerId: true, owner: { select: { id: true, fullName: true } } },
})
: [];
const personById = new Map(people.map((person) => [person.id, person]));
const linked = new Set(rows.map((r) => r.factorCode).filter((v): v is string => Boolean(v)));
const result = { ...empty };
for (const order of orders) {
const ref = (order.orderReference || order.externalId || '').trim();
if (ref && linked.has(ref)) {
result.linkedSkipped += 1;
continue;
}
const cancelled =
Boolean(order.cancelledAt) ||
/cancel|لغو|مرجوع/i.test(order.orderStatus ?? '');
const gold = Number(order.sumGold ?? 0);
const silver = Number(order.sumSilver ?? 0);
const mali = Number(order.discountTotalPrice ?? order.totalPrice ?? 0);
if (cancelled) {
result.returnCount += 1;
result.returnGold += gold;
result.returnSilver += silver;
result.returnMali += mali;
} else {
result.orderCount += 1;
result.netGold += gold;
result.netSilver += silver;
result.netMali += mali;
const person = order.personId ? personById.get(order.personId) : undefined;
const ownerId = person?.owner?.id ?? person?.ownerId ?? '__none__';
const name = person?.owner?.fullName || 'بدون کارشناس';
const bag = owners.get(ownerId) ?? { name, weight: 0, mali: 0 };
bag.weight += gold + silver;
bag.mali += mali;
owners.set(ownerId, bag);
}
}
return { public: result, owners };
}
}
function shamsiStart(ymd: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
if (!m) return null;
const g = toGregorian(Number(m[1]), Number(m[2]), Number(m[3]));
return new Date(g.gy, g.gm - 1, g.gd, 0, 0, 0, 0);
}
function shamsiEnd(ymd: string): Date | null {
const start = shamsiStart(ymd);
if (!start) return null;
return new Date(start.getFullYear(), start.getMonth(), start.getDate(), 23, 59, 59, 999);
} }
@@ -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}`);
}
}
}
+28
View File
@@ -86,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 {
@@ -195,6 +205,12 @@ export class SyncGroupsPageDto {
pageSize?: number; pageSize?: number;
} }
/** اجرای دستی همگام‌سازی همهٔ مشتریان (وارد + به‌روز کردن لینک‌شده‌ها) */
export class SyncAllCustomersDto {
@IsString()
connectionId!: string;
}
export class ExportPeopleToTahesabDto { export class ExportPeopleToTahesabDto {
@IsString() @IsString()
connectionId!: string; connectionId!: string;
@@ -447,6 +463,18 @@ export class SyncAsnadReportDto {
@MinLength(8) @MinLength(8)
@MaxLength(12) @MaxLength(12)
to!: string; to!: string;
/** اگر باشد، فقط سندهای همین شخص گرفته می‌شود */
@IsOptional()
@IsString()
personId?: string;
/** اگر باشد، فقط مشتریان این گروه‌ها (و زیرگروه‌ها) گرفته می‌شوند */
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(200)
groupIds?: string[];
} }
export class UpdateTahesabInvoiceSettingsDto { export class UpdateTahesabInvoiceSettingsDto {
@@ -0,0 +1,113 @@
import { Prisma } from '@prisma/client';
import { parseBirthDate } from '../../common/excel';
export function pickBirthDate(row: Record<string, unknown>): Date | null {
const raw =
row.BDate ??
row.bDate ??
row.Tarikh_Tavalod ??
row.tarikh_tavalod ??
row.BirthDate;
if (raw == null) return null;
return parseBirthDate(String(raw));
}
function trimStr(raw: unknown): string | null {
if (raw == null) return null;
const s = String(raw).trim();
if (!s || s === '-1') return null;
return s;
}
/** کد ملی از پاسخ لیست مشتری ته‌حساب */
export function pickNationalId(row: Record<string, unknown>): string | null {
return (
trimStr(row.CodeMelli) ??
trimStr(row.CodeMeli) ??
trimStr(row.codeMeli) ??
trimStr(row.NationalId) ??
null
);
}
export function pickAddress(row: Record<string, unknown>): string | null {
return trimStr(row.Address) ?? trimStr(row.address) ?? null;
}
export function pickCity(row: Record<string, unknown>): string | null {
return trimStr(row.City) ?? trimStr(row.city) ?? trimStr(row.Shahr) ?? null;
}
/** رسته حساب در ته‌حساب (عدد یا رشته) */
export function pickRasteh(row: Record<string, unknown>): string | number | null {
const raw = row.Rasteh ?? row.rasteh ?? row.Raste;
if (raw == null || raw === '') return null;
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
const s = String(raw).trim();
if (!s || s === '-1') return null;
const n = Number(s);
return Number.isFinite(n) && String(n) === s ? n : s;
}
function asCustomFieldRecord(raw: unknown): Record<string, unknown> {
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
return { ...(raw as Record<string, unknown>) };
}
return {};
}
function asAddressList(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
return raw
.map((a) => (typeof a === 'string' ? a.trim() : ''))
.filter(Boolean);
}
/**
* فیلدهای پروفایل قابل‌دریافت از DoListMoshtari را روی شخص می‌نشاند.
* مقدار خالی ته‌حساب، دادهٔ موجود CRM را پاک نمی‌کند.
*/
export function tahesabProfilePatch(
row: Record<string, unknown>,
existing: {
birthDate: Date | null;
addresses: unknown;
customFields: unknown;
},
): Prisma.PersonUpdateInput {
const data: Prisma.PersonUpdateInput = {};
const birthDate = pickBirthDate(row);
if (!existing.birthDate && birthDate) data.birthDate = birthDate;
const address = pickAddress(row);
const city = pickCity(row);
if (address) {
const line = city ? `${address}، ${city}` : address;
data.addresses = [line] as Prisma.InputJsonValue;
} else if (city && asAddressList(existing.addresses).length === 0) {
data.addresses = [city] as Prisma.InputJsonValue;
}
const fields = asCustomFieldRecord(existing.customFields);
let fieldsChanged = false;
const nationalId = pickNationalId(row);
if (nationalId) {
fields.nationalId = nationalId;
fieldsChanged = true;
}
if (city) {
fields.city = city;
fieldsChanged = true;
}
if (address) {
fields.address = address;
fieldsChanged = true;
}
const rasteh = pickRasteh(row);
if (rasteh != null) {
fields.rasteh = rasteh;
fieldsChanged = true;
}
if (fieldsChanged) data.customFields = fields as Prisma.InputJsonValue;
return data;
}
@@ -8,7 +8,13 @@ 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,
@@ -20,10 +26,25 @@ import {
SearchTahesabDto, SearchTahesabDto,
SyncGroupsPageDto, SyncGroupsPageDto,
} from './dto'; } from './dto';
import { formatJalaliDate, parseBirthDate } from '../../common/excel'; import { formatJalaliDate } from '../../common/excel';
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service'; import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
import { pickBirthDate, tahesabProfilePatch } from './moshtari-profile';
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;
@@ -63,17 +84,6 @@ function pickTel(row: Record<string, unknown>): string {
return String(row.Tel ?? row.Phone ?? row.Mobile ?? row.tel ?? ''); return String(row.Tel ?? row.Phone ?? row.Mobile ?? row.tel ?? '');
} }
function pickBirthDate(row: Record<string, unknown>): Date | null {
const raw =
row.BDate ??
row.bDate ??
row.Tarikh_Tavalod ??
row.tarikh_tavalod ??
row.BirthDate;
if (raw == null) return null;
return parseBirthDate(String(raw));
}
function firstAddress(raw: unknown): string | undefined { function firstAddress(raw: unknown): string | undefined {
if (!Array.isArray(raw)) return undefined; if (!Array.isArray(raw)) return undefined;
const first = raw.find((a) => typeof a === 'string' && a.trim()); const first = raw.find((a) => typeof a === 'string' && a.trim());
@@ -418,6 +428,77 @@ export class TahesabPersonLinkService {
}; };
} }
/**
* کدهایی که لینک ندارند ولی شماره‌شان مال مشتریِ از قبل لینک‌شدهٔ همین اتصال است.
* یک شخص فقط یک کد ته‌حساب دارد؛ این کدها مشتری تازه نیستند.
*/
private async duplicateOwners(
organizationId: string,
connectionId: string,
rows: Array<{ moshtariCode: number | null; tel: string }>,
) {
const pending = rows.filter(
(row): row is { moshtariCode: number; tel: string } => row.moshtariCode != null,
);
const keys = [
...new Set(pending.flatMap((row) => phoneIdentityKeys(storedPhoneNumber(row.tel)))),
];
const out = new Map<
number,
{ moshtariCode: number; personId: string; personName: string }
>();
if (!keys.length) return out;
const people = await this.prisma.person.findMany({
where: {
organizationId,
OR: [{ mobile: { in: keys } }, { phone: { in: keys } }],
accountingLinks: { some: { connectionId } },
},
select: {
id: true,
firstName: true,
lastName: true,
mobile: true,
phone: true,
accountingLinks: {
where: { connectionId },
select: { moshtariCode: true },
take: 1,
},
},
});
const byKey = new Map<
string,
{ moshtariCode: number; personId: string; personName: string }
>();
for (const person of people) {
const link = person.accountingLinks[0];
if (!link) continue;
const info = {
moshtariCode: link.moshtariCode,
personId: person.id,
personName: `${person.firstName} ${person.lastName}`.trim(),
};
for (const key of [
...phoneIdentityKeys(person.mobile),
...phoneIdentityKeys(person.phone),
]) {
if (!byKey.has(key)) byKey.set(key, info);
}
}
for (const row of pending) {
for (const key of phoneIdentityKeys(storedPhoneNumber(row.tel))) {
const hit = byKey.get(key);
if (hit && hit.moshtariCode !== row.moshtariCode) {
out.set(row.moshtariCode, hit);
break;
}
}
}
return out;
}
async search(organizationId: string, dto: SearchTahesabDto) { async search(organizationId: string, dto: SearchTahesabDto) {
const { api } = await this.connections.getApi( const { api } = await this.connections.getApi(
organizationId, organizationId,
@@ -496,7 +577,7 @@ export class TahesabPersonLinkService {
}); });
const byCode = new Map(existing.map((e) => [e.moshtariCode, e])); const byCode = new Map(existing.map((e) => [e.moshtariCode, e]));
const results = mapped.map((row) => { const linked = mapped.map((row) => {
const link = row.moshtariCode != null ? byCode.get(row.moshtariCode) : undefined; const link = row.moshtariCode != null ? byCode.get(row.moshtariCode) : undefined;
return { return {
...row, ...row,
@@ -507,6 +588,22 @@ export class TahesabPersonLinkService {
: null, : null,
}; };
}); });
const duplicates = await this.duplicateOwners(
organizationId,
dto.connectionId,
linked.filter((row) => !row.alreadyLinked),
);
const results = linked.map((row) => {
if (row.alreadyLinked || row.moshtariCode == null) return row;
const dup = duplicates.get(row.moshtariCode);
if (!dup) return row;
return {
...row,
duplicateOfCode: dup.moshtariCode,
duplicatePersonId: dup.personId,
duplicatePersonName: dup.personName,
};
});
await this.prisma.accountingSyncJob.create({ await this.prisma.accountingSyncJob.create({
data: { data: {
@@ -538,6 +635,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: {
@@ -573,10 +671,10 @@ 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;
@@ -591,6 +689,8 @@ export class TahesabPersonLinkService {
data: { data: {
firstName, firstName,
lastName, lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
...(nameChanged ...(nameChanged
? { ? {
previousFirstName: prevFirst || null, previousFirstName: prevFirst || null,
@@ -600,29 +700,49 @@ export class TahesabPersonLinkService {
...carryPreviousSource(existing, 'tahesab'), ...carryPreviousSource(existing, 'tahesab'),
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, sourceRef,
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}), ...canonicalMobileUpdate(mobile, existing),
...(mobile ...tahesabProfilePatch(row, 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;
if (linkedCode != null && linkedCode !== dto.moshtariCode) {
const existing = await this.prisma.personAccountingLink.findFirst({
where: { personId: byMobile.id, connectionId: dto.connectionId },
include: {
connection: { select: { label: true, dbName: true, isDefault: true } },
},
});
if (!existing) {
throw new NotFoundException('لینک ته‌حساب این مشتری یافت نشد.');
}
return {
personId: byMobile.id,
link: this.linkDto(existing),
duplicateOf: linkedCode,
};
}
const nameChanged = const nameChanged =
`${byMobile.firstName} ${byMobile.lastName}`.trim() !== `${byMobile.firstName} ${byMobile.lastName}`.trim() !==
`${firstName} ${lastName}`.trim(); `${firstName} ${lastName}`.trim();
@@ -631,6 +751,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,
@@ -640,22 +762,32 @@ export class TahesabPersonLinkService {
...carryPreviousSource(byMobile, 'tahesab'), ...carryPreviousSource(byMobile, 'tahesab'),
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, sourceRef,
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}), ...canonicalMobileUpdate(mobile, byMobile),
...tahesabProfilePatch(row, byMobile),
}, },
}); });
personId = byMobile.id; personId = byMobile.id;
} else { } else {
const profile = tahesabProfilePatch(row, {
birthDate: null,
addresses: [],
customFields: {},
});
const created = await this.prisma.person.create({ const created = await this.prisma.person.create({
data: { data: {
organizationId, organizationId,
firstName, firstName,
lastName, lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
mobile, mobile,
phone: mobile, phone: mobile,
notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`, notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`,
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, sourceRef,
birthDate: pickBirthDate(row), birthDate: (profile.birthDate as Date | undefined) ?? null,
addresses: (profile.addresses as Prisma.InputJsonValue) ?? [],
customFields: (profile.customFields as Prisma.InputJsonValue) ?? {},
}, },
}); });
personId = created.id; personId = created.id;
@@ -686,18 +818,184 @@ export class TahesabPersonLinkService {
}); });
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row); await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
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: link.id } as Prisma.InputJsonValue,
},
});
}
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),
...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing),
...tahesabProfilePatch(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 duplicate = 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 {
const created = await this.importFromTahesab(
organizationId,
{ connectionId, moshtariCode: code },
{ api, row },
{ skipJob: true },
);
if (created.duplicateOf) duplicate += 1;
else 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} به‌روز شد`,
...(duplicate ? [`${duplicate} شماره تکراری`] : []),
`${failed} خطا`,
].join('، ');
const error = errors.length ? errors.join('؛ ') : null;
await this.prisma.accountingSyncJob.create({ await this.prisma.accountingSyncJob.create({
data: { data: {
organizationId, organizationId,
connectionId: dto.connectionId, connectionId,
kind: 'import', kind: 'customer-sync',
status: 'success', status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success',
request: dto as unknown as Prisma.InputJsonValue, result: { imported, updated, duplicate, failed, pages: page - 1 } as Prisma.InputJsonValue,
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
}, },
}); });
await this.prisma.accountingConnection.update({
return { personId, link: this.linkDto(link) }; where: { id: connectionId },
data: {
customerSyncLastRunAt: new Date(),
customerSyncLastSummary: summary,
customerSyncLastError: error,
},
});
this.logger.log(`همگام‌سازی مشتریان ته‌حساب ${connectionId}: ${summary}`);
return {
imported,
updated,
duplicate,
failed,
summary,
error,
};
} }
async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) { async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) {
@@ -792,19 +1090,16 @@ export class TahesabPersonLinkService {
try { try {
const linkedPersonId = linkedByCode.get(moshtariCode); const linkedPersonId = linkedByCode.get(moshtariCode);
if (linkedPersonId) { if (linkedPersonId) {
const birthDate = pickBirthDate(rowsByCode.get(moshtariCode) ?? {}); const row = rowsByCode.get(moshtariCode);
if (birthDate) { if (row) {
await this.prisma.person.updateMany({ await this.refreshLinkedCustomer(
where: { id: linkedPersonId, birthDate: null }, organizationId,
data: { birthDate }, dto.connectionId,
}); linkedPersonId,
moshtariCode,
row,
);
} }
await this.applyTahesabGroup(
organizationId,
dto.connectionId,
linkedPersonId,
rowsByCode.get(moshtariCode),
);
items.push({ items.push({
moshtariCode, moshtariCode,
ok: true, ok: true,
@@ -818,6 +1113,16 @@ export class TahesabPersonLinkService {
{ connectionId: dto.connectionId, moshtariCode }, { connectionId: dto.connectionId, moshtariCode },
bulkApi ? { api: bulkApi, row: rowsByCode.get(moshtariCode) } : undefined, bulkApi ? { api: bulkApi, row: rowsByCode.get(moshtariCode) } : undefined,
); );
if (r.duplicateOf) {
items.push({
moshtariCode,
ok: true,
skipped: true,
personId: r.personId,
error: `شماره تکراری است و قبلاً با کد ${r.duplicateOf} لینک شده`,
});
continue;
}
items.push({ moshtariCode, ok: true, personId: r.personId }); items.push({ moshtariCode, ok: true, personId: r.personId });
} catch (e) { } catch (e) {
const message = const message =
@@ -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),
};
}
@@ -37,6 +37,7 @@ import {
SearchTahesabDto, SearchTahesabDto,
SearchTahesabProductsDto, SearchTahesabProductsDto,
SyncAsnadReportDto, SyncAsnadReportDto,
SyncAllCustomersDto,
SyncGroupsPageDto, SyncGroupsPageDto,
UpdateTahesabConnectionDto, UpdateTahesabConnectionDto,
UpdateTahesabInvoiceSettingsDto, UpdateTahesabInvoiceSettingsDto,
@@ -237,6 +238,16 @@ export class TahesabController {
return this.links.syncGroupsPage(user.organizationId, dto); return this.links.syncGroupsPage(user.organizationId, dto);
} }
/** همهٔ مشتریان ته‌حساب: تازه‌ها وارد، لینک‌شده‌ها (نام/موبایل/کدملی/آدرس/شهر/رسته/گروه) به‌روز */
@Post('sync/customers-now')
@Roles('owner', 'admin')
syncCustomersNow(
@CurrentUser() user: { organizationId: string },
@Body() dto: SyncAllCustomersDto,
) {
return this.links.syncAllCustomers(user.organizationId, dto.connectionId);
}
@Get('sync/unlinked-people') @Get('sync/unlinked-people')
@Roles('owner', 'admin') @Roles('owner', 'admin')
unlinkedPeople( unlinkedPeople(
@@ -309,6 +320,20 @@ export class TahesabController {
dto.connectionId, dto.connectionId,
dto.from, dto.from,
dto.to, 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 ?? '',
); );
} }
@@ -320,6 +345,27 @@ export class TahesabController {
return this.asnadReport.getSyncJob(user.organizationId, jobId); 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') @Get('reports/asnad/summary')
asnadReportSummary( asnadReportSummary(
@CurrentUser() user: { organizationId: string }, @CurrentUser() user: { organizationId: string },
@@ -12,6 +12,7 @@ import { TahesabBalanceService } from './balance.service';
import { TahesabDocumentsService } from './documents.service'; import { TahesabDocumentsService } from './documents.service';
import { TahesabAsnadReportService } from './asnad-report.service'; import { TahesabAsnadReportService } from './asnad-report.service';
import { TahesabInvoiceSettingsService } from './invoice-settings.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'; import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module';
@@ -29,6 +30,7 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu
TahesabDocumentsService, TahesabDocumentsService,
TahesabAsnadReportService, TahesabAsnadReportService,
TahesabInvoiceSettingsService, TahesabInvoiceSettingsService,
TahesabCustomerSyncScheduler,
], ],
exports: [ exports: [
TahesabCryptoService, TahesabCryptoService,
@@ -3,6 +3,9 @@ import { Cron } from '@nestjs/schedule';
import { AiService } from '../../ai/ai.service'; import { AiService } from '../../ai/ai.service';
import { NotesService } from '../../notes/notes.service'; import { NotesService } from '../../notes/notes.service';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { VoipConnectionsService } from './connections.service';
import { DaftaresomaClient } from './daftareshoma-client';
import { parseDaftaresomaRecordingRef } from './daftareshoma-webhook';
import { SttClientService } from './stt-client.service'; import { SttClientService } from './stt-client.service';
const MAX_ATTEMPTS = 3; const MAX_ATTEMPTS = 3;
@@ -17,6 +20,8 @@ export class CallSummaryService {
private readonly stt: SttClientService, private readonly stt: SttClientService,
private readonly ai: AiService, private readonly ai: AiService,
private readonly notes: NotesService, private readonly notes: NotesService,
private readonly connections: VoipConnectionsService,
private readonly daftareshoma: DaftaresomaClient,
) {} ) {}
@Cron('*/1 * * * *') @Cron('*/1 * * * *')
@@ -74,7 +79,31 @@ export class CallSummaryService {
); );
return; return;
} }
transcript = await this.stt.downloadAndTranscribe(row.recordingUrl); const daftId = parseDaftaresomaRecordingRef(row.recordingUrl);
if (daftId) {
const conn = await this.prisma.voipConnection.findFirst({
where: { id: row.connectionId },
});
const token = conn
? await this.connections.decryptApiToken(conn)
: null;
if (!token) {
await this.markFailed(
id,
row.summaryAttempts,
'توکن API دفتر شما برای دانلود ضبط موجود نیست',
true,
);
return;
}
const audio = await this.daftareshoma.downloadRecording(
token,
daftId,
);
transcript = await this.stt.transcribeAudio(audio);
} else {
transcript = await this.stt.downloadAndTranscribe(row.recordingUrl);
}
await this.prisma.voipCallLog.update({ await this.prisma.voipCallLog.update({
where: { id }, where: { id },
data: { transcript }, data: { transcript },
+161 -5
View File
@@ -11,8 +11,13 @@ 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';
import { DaftaresomaClient } from './daftareshoma-client';
import {
daftareshomaRecordingRef,
parseDaftaresomaRecordingRef,
} from './daftareshoma-webhook';
function digitsOnly(value: string | null | undefined): string { function digitsOnly(value: string | null | undefined): string {
return (value ?? '').replace(/\D/g, ''); return (value ?? '').replace(/\D/g, '');
@@ -35,6 +40,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];
} }
@@ -56,19 +62,32 @@ export class VoipCallsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly connections: VoipConnectionsService, private readonly connections: VoipConnectionsService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
private readonly daftareshoma: DaftaresomaClient,
) {} ) {}
async listRecent( async listRecent(
organizationId: string, organizationId: string,
connectionId?: string, opts?: {
connectionId?: string;
direction?: string;
status?: string;
lineExtension?: string;
},
): Promise<VoipCallLogDto[]> { ): Promise<VoipCallLogDto[]> {
const rows = await this.prisma.voipCallLog.findMany({ const rows = await this.prisma.voipCallLog.findMany({
where: { where: {
organizationId, organizationId,
...(connectionId ? { connectionId } : {}), ...(opts?.connectionId ? { connectionId: opts.connectionId } : {}),
...(opts?.direction === 'inbound' || opts?.direction === 'outbound'
? { direction: opts.direction }
: {}),
...(opts?.status?.trim() ? { status: opts.status.trim() } : {}),
...(opts?.lineExtension?.trim()
? { lineExtension: opts.lineExtension.trim() }
: {}),
}, },
orderBy: { syncedAt: 'desc' }, orderBy: { syncedAt: 'desc' },
take: 50, take: 100,
}); });
const personIds = rows const personIds = rows
.map((r) => r.personId) .map((r) => r.personId)
@@ -90,6 +109,7 @@ export class VoipCallsService {
status: r.status, status: r.status,
fromNumber: r.fromNumber, fromNumber: r.fromNumber,
toNumber: r.toNumber, toNumber: r.toNumber,
lineExtension: r.lineExtension ?? null,
personId: r.personId, personId: r.personId,
personName: r.personId ? (nameById.get(r.personId) ?? null) : null, personName: r.personId ? (nameById.get(r.personId) ?? null) : null,
activityId: r.activityId, activityId: r.activityId,
@@ -126,6 +146,9 @@ export class VoipCallsService {
const toDigits = digitsOnly(event.to ?? event.callee); const toDigits = digitsOnly(event.to ?? event.callee);
const fromNumber = fromNormalized || fromDigits || null; const fromNumber = fromNormalized || fromDigits || null;
const toNumber = toNormalized || toDigits || null; const toNumber = toNormalized || toDigits || null;
const lineRaw = String(event.lineExtension ?? '').trim();
const lineExtension =
normalizePhone(lineRaw) || digitsOnly(lineRaw) || lineRaw || null;
const matchNumber = direction === 'inbound' ? fromNumber : toNumber; const matchNumber = direction === 'inbound' ? fromNumber : toNumber;
const personId = await this.findPersonId( const personId = await this.findPersonId(
conn.organizationId, conn.organizationId,
@@ -192,6 +215,7 @@ export class VoipCallsService {
status, status,
fromNumber, fromNumber,
toNumber, toNumber,
lineExtension: lineExtension ?? existing.lineExtension,
personId: personId ?? existing.personId, personId: personId ?? existing.personId,
activityId: activityId ?? existing.activityId, activityId: activityId ?? existing.activityId,
durationSec, durationSec,
@@ -212,6 +236,7 @@ export class VoipCallsService {
status, status,
fromNumber, fromNumber,
toNumber, toNumber,
lineExtension,
personId, personId,
activityId, activityId,
durationSec, durationSec,
@@ -239,6 +264,17 @@ export class VoipCallsService {
await this.notifyIncoming(conn.organizationId, personId, fromNumber); await this.notifyIncoming(conn.organizationId, personId, fromNumber);
} }
if (
conn.provider === 'daftareshoma' &&
['completed', 'answered', 'missed'].includes(status)
) {
void this.enrichDaftaresomaRecording(connectionId, callId).catch((e) => {
this.logger.warn(
`daftareshoma recording enrich failed: ${e instanceof Error ? e.message : e}`,
);
});
}
const link = await this.prisma.voipCallLog.findUnique({ const link = await this.prisma.voipCallLog.findUnique({
where: { where: {
connectionId_externalCallId: { connectionId_externalCallId: {
@@ -260,10 +296,67 @@ export class VoipCallsService {
userId: string, userId: string,
number: string, number: string,
personId?: string, personId?: string,
): Promise<{ ok: true; mode: 'url' | 'tel'; dialUrl: string }> { ): Promise<{ ok: true; mode: 'url' | 'tel' | 'daftareshoma'; dialUrl: string }> {
const phone = normalizePhone(number) ?? digitsOnly(number); const phone = normalizePhone(number) ?? digitsOnly(number);
if (!phone) throw new BadRequestException('شماره معتبر نیست'); if (!phone) throw new BadRequestException('شماره معتبر نیست');
const daftareshomaConn = await this.prisma.voipConnection.findFirst({
where: {
organizationId,
enabled: true,
provider: 'daftareshoma',
},
orderBy: { createdAt: 'asc' },
});
if (daftareshomaConn) {
const token = await this.connections.decryptApiToken(daftareshomaConn);
const fromNumber = daftareshomaConn.fromNumber?.trim();
const callerExtension = daftareshomaConn.callerExtension?.trim();
if (!token || !fromNumber || !callerExtension) {
throw new BadRequestException(
'اتصال دفتر شما ناقص است؛ توکن، شماره مبدأ و خط را تکمیل کنید',
);
}
const toE164 = this.toPlusDigits(phone);
const fromE164 = this.toPlusDigits(fromNumber);
const extE164 = this.toPlusDigits(callerExtension);
try {
await this.daftareshoma.outgoingCall(token, {
fromNumber: fromE164,
toNumber: toE164,
callerExtension: extE164,
});
} catch (e) {
const msg = e instanceof Error ? e.message : 'خطا';
await this.prisma.voipConnection.update({
where: { id: daftareshomaConn.id },
data: { lastError: msg.slice(0, 400) },
});
throw new BadRequestException(msg);
}
await this.prisma.voipCallLog.create({
data: {
organizationId,
connectionId: daftareshomaConn.id,
externalCallId: `ctc-${Date.now()}-${userId.slice(0, 6)}`,
direction: 'outbound',
status: 'ringing',
fromNumber: fromE164,
toNumber: toE164,
personId: personId ?? null,
startedAt: new Date(),
},
});
return {
ok: true,
mode: 'daftareshoma',
dialUrl: `daftareshoma:${toE164}`,
};
}
const conn = await this.prisma.voipConnection.findFirst({ const conn = await this.prisma.voipConnection.findFirst({
where: { where: {
organizationId, organizationId,
@@ -317,6 +410,69 @@ export class VoipCallsService {
return { ok: true, mode: 'tel', dialUrl: `tel:${phone}` }; return { ok: true, mode: 'tel', dialUrl: `tel:${phone}` };
} }
private toPlusDigits(raw: string): string {
const d = digitsOnly(raw);
if (!d) return raw.trim();
if (raw.trim().startsWith('+')) return `+${d}`;
if (d.startsWith('98')) return `+${d}`;
if (d.startsWith('0') && d.length >= 10) return `+98${d.slice(1)}`;
if (d.length === 10 && d.startsWith('9')) return `+98${d}`;
return `+${d}`;
}
private async enrichDaftaresomaRecording(
connectionId: string,
callId: string,
): Promise<void> {
const conn = await this.prisma.voipConnection.findFirst({
where: { id: connectionId, provider: 'daftareshoma', enabled: true },
});
if (!conn) return;
const log = await this.prisma.voipCallLog.findUnique({
where: {
connectionId_externalCallId: { connectionId, externalCallId: callId },
},
});
if (!log) return;
if (parseDaftaresomaRecordingRef(log.recordingUrl) || log.recordingUrl) {
if (
parseDaftaresomaRecordingRef(log.recordingUrl) &&
!['done', 'pending', 'processing'].includes(log.summaryStatus)
) {
await this.prisma.voipCallLog.update({
where: { id: log.id },
data: {
summaryStatus: 'pending',
summaryError: null,
summaryNextAttemptAt: new Date(),
},
});
}
return;
}
const token = await this.connections.decryptApiToken(conn);
if (!token) return;
const row = await this.daftareshoma.findCallById(token, callId);
const recordingId = row?.recordingId?.trim();
if (!recordingId) return;
const ref = daftareshomaRecordingRef(recordingId);
await this.prisma.voipCallLog.update({
where: { id: log.id },
data: {
recordingUrl: ref,
summaryStatus: ['done', 'pending', 'processing'].includes(
log.summaryStatus,
)
? log.summaryStatus
: 'pending',
summaryError: null,
summaryNextAttemptAt: new Date(),
},
});
}
private shouldCreateActivity(status: string): boolean { private shouldCreateActivity(status: string): boolean {
return ['answered', 'completed', 'missed', 'failed'].includes(status); return ['answered', 'completed', 'missed', 'failed'].includes(status);
} }
@@ -7,12 +7,17 @@ import type {
CreateVoipConnectionRequest, CreateVoipConnectionRequest,
UpdateVoipConnectionRequest, UpdateVoipConnectionRequest,
VoipConnectionDto, VoipConnectionDto,
VoipProvider,
} from '@mento/shared'; } from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { createHmac } from 'crypto'; import { createHmac } from 'crypto';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service'; import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
function asProvider(value: string | null | undefined): VoipProvider {
return value === 'daftareshoma' ? 'daftareshoma' : 'generic';
}
@Injectable() @Injectable()
export class VoipConnectionsService { export class VoipConnectionsService {
constructor( constructor(
@@ -43,19 +48,35 @@ export class VoipConnectionsService {
): Promise<VoipConnectionDto> { ): Promise<VoipConnectionDto> {
const label = dto.label?.trim(); const label = dto.label?.trim();
if (!label) throw new BadRequestException('نام اتصال الزامی است'); if (!label) throw new BadRequestException('نام اتصال الزامی است');
const provider = asProvider(dto.provider);
if (provider === 'daftareshoma') {
const token = dto.apiToken?.trim();
if (!token) throw new BadRequestException('توکن API دفتر شما الزامی است');
// fromNumber / callerExtension فقط برای تماس خروجی از CRM لازم‌اند؛ ثبت وب‌هوک بدون آن‌ها ممکن است
}
const secret = dto.webhookSecret?.trim(); const secret = dto.webhookSecret?.trim();
const secretEnc = secret ? this.crypto.encrypt(secret) : null; const secretEnc = secret ? this.crypto.encrypt(secret) : null;
const clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl); const token = dto.apiToken?.trim();
const tokenEnc = token ? this.crypto.encrypt(token) : null;
const clickToCallUrl =
provider === 'generic' ? this.normalizeClickUrl(dto.clickToCallUrl) : null;
const row = await this.prisma.voipConnection.create({ const row = await this.prisma.voipConnection.create({
data: { data: {
organizationId, organizationId,
label, label,
provider,
enabled: dto.enabled ?? true, enabled: dto.enabled ?? true,
clickToCallUrl, clickToCallUrl,
fromNumber: dto.fromNumber?.trim() || null,
callerExtension: dto.callerExtension?.trim() || null,
webhookSecretCiphertext: secretEnc?.ciphertext, webhookSecretCiphertext: secretEnc?.ciphertext,
webhookSecretIv: secretEnc?.iv, webhookSecretIv: secretEnc?.iv,
webhookSecretTag: secretEnc?.tag, webhookSecretTag: secretEnc?.tag,
apiTokenCiphertext: tokenEnc?.ciphertext,
apiTokenIv: tokenEnc?.iv,
apiTokenTag: tokenEnc?.tag,
}, },
}); });
return this.toDto(row, 0); return this.toDto(row, 0);
@@ -77,6 +98,12 @@ export class VoipConnectionsService {
if (dto.clickToCallUrl !== undefined) { if (dto.clickToCallUrl !== undefined) {
data.clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl); data.clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl);
} }
if (dto.fromNumber !== undefined) {
data.fromNumber = dto.fromNumber?.trim() || null;
}
if (dto.callerExtension !== undefined) {
data.callerExtension = dto.callerExtension?.trim() || null;
}
if (dto.webhookSecret !== undefined) { if (dto.webhookSecret !== undefined) {
const wh = dto.webhookSecret?.trim(); const wh = dto.webhookSecret?.trim();
if (!wh) { if (!wh) {
@@ -90,6 +117,19 @@ export class VoipConnectionsService {
data.webhookSecretTag = enc.tag; data.webhookSecretTag = enc.tag;
} }
} }
if (dto.apiToken !== undefined) {
const tok = dto.apiToken?.trim();
if (!tok) {
data.apiTokenCiphertext = null;
data.apiTokenIv = null;
data.apiTokenTag = null;
} else {
const enc = this.crypto.encrypt(tok);
data.apiTokenCiphertext = enc.ciphertext;
data.apiTokenIv = enc.iv;
data.apiTokenTag = enc.tag;
}
}
const row = await this.prisma.voipConnection.update({ const row = await this.prisma.voipConnection.update({
where: { id: existing.id }, where: { id: existing.id },
data, data,
@@ -124,6 +164,21 @@ export class VoipConnectionsService {
}); });
} }
async decryptApiToken(row: {
apiTokenCiphertext: string | null;
apiTokenIv: string | null;
apiTokenTag: string | null;
}): Promise<string | null> {
if (!row.apiTokenCiphertext || !row.apiTokenIv || !row.apiTokenTag) {
return null;
}
return this.crypto.decrypt({
ciphertext: row.apiTokenCiphertext,
iv: row.apiTokenIv,
tag: row.apiTokenTag,
});
}
verifySignature( verifySignature(
rawBody: string | Buffer, rawBody: string | Buffer,
signature: string | undefined, signature: string | undefined,
@@ -153,9 +208,13 @@ export class VoipConnectionsService {
row: { row: {
id: string; id: string;
label: string; label: string;
provider?: string | null;
enabled: boolean; enabled: boolean;
webhookSecretCiphertext: string | null; webhookSecretCiphertext: string | null;
apiTokenCiphertext?: string | null;
clickToCallUrl: string | null; clickToCallUrl: string | null;
fromNumber?: string | null;
callerExtension?: string | null;
lastEventAt: Date | null; lastEventAt: Date | null;
lastError: string | null; lastError: string | null;
createdAt: Date; createdAt: Date;
@@ -166,9 +225,13 @@ export class VoipConnectionsService {
return { return {
id: row.id, id: row.id,
label: row.label, label: row.label,
provider: asProvider(row.provider),
enabled: row.enabled, enabled: row.enabled,
webhookSecretSet: Boolean(row.webhookSecretCiphertext), webhookSecretSet: Boolean(row.webhookSecretCiphertext),
apiTokenSet: Boolean(row.apiTokenCiphertext),
clickToCallUrl: row.clickToCallUrl, clickToCallUrl: row.clickToCallUrl,
fromNumber: row.fromNumber ?? null,
callerExtension: row.callerExtension ?? null,
lastEventAt: row.lastEventAt?.toISOString() ?? null, lastEventAt: row.lastEventAt?.toISOString() ?? null,
lastError: row.lastError, lastError: row.lastError,
callCount, callCount,
@@ -0,0 +1,126 @@
import { Injectable, Logger } from '@nestjs/common';
const BASE_URL = 'https://coreapi.daftareshoma.com/api/Customize';
const MAX_AUDIO_BYTES = 8 * 1024 * 1024;
export type DaftaresomaOutgoingCallInput = {
fromNumber: string;
toNumber: string;
callerExtension: string;
};
export type DaftaresomaCallSearchRow = {
id?: string;
fromNumber?: string;
toNumber?: string;
recordingId?: string | null;
direction?: string;
reportDispositionType?: string;
startTime?: string;
endTime?: string;
duration?: string;
extension?: string;
};
@Injectable()
export class DaftaresomaClient {
private readonly logger = new Logger(DaftaresomaClient.name);
async outgoingCall(
apiToken: string,
input: DaftaresomaOutgoingCallInput,
): Promise<void> {
const res = await fetch(`${BASE_URL}/OutgoingCall`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
from_number: input.fromNumber,
to_number: input.toNumber,
caller_extension: input.callerExtension,
}),
signal: AbortSignal.timeout(15000),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
this.logger.warn(
`OutgoingCall failed (${res.status}): ${text.slice(0, 300)}`,
);
throw new Error(
text.trim()
? `تماس خروجی دفتر شما ناموفق بود (${res.status}): ${text.slice(0, 200)}`
: `تماس خروجی دفتر شما ناموفق بود (${res.status})`,
);
}
}
async findCallById(
apiToken: string,
callId: string,
): Promise<DaftaresomaCallSearchRow | null> {
const res = await fetch(`${BASE_URL}/CustomerCallSearch`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ id: callId, limit: 1, pagination: 1 }),
signal: AbortSignal.timeout(20000),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
this.logger.warn(
`CustomerCallSearch failed (${res.status}): ${text.slice(0, 200)}`,
);
return null;
}
const json = (await res.json()) as {
data?: DaftaresomaCallSearchRow[];
};
const row = Array.isArray(json.data) ? json.data[0] : null;
return row ?? null;
}
async downloadRecording(
apiToken: string,
recordingId: string,
): Promise<{ buffer: ArrayBuffer; contentType: string; filename: string }> {
const url = new URL(`${BASE_URL}/GetCallRecording`);
url.searchParams.set('recordingId', recordingId);
const res = await fetch(url.toString(), {
method: 'GET',
headers: {
Authorization: `Bearer ${apiToken}`,
Accept: '*/*',
'User-Agent': 'MontactCRM-VoIP/1.0',
},
signal: AbortSignal.timeout(60000),
redirect: 'follow',
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(
`دانلود ضبط دفتر شما ناموفق بود (${res.status})${text ? `: ${text.slice(0, 160)}` : ''}`,
);
}
const contentLength = Number(res.headers.get('content-length') || 0);
if (contentLength > MAX_AUDIO_BYTES) {
throw new Error('حجم فایل ضبط بیش از حد مجاز است');
}
const buffer = await res.arrayBuffer();
if (buffer.byteLength > MAX_AUDIO_BYTES) {
throw new Error('حجم فایل ضبط بیش از حد مجاز است');
}
if (!buffer.byteLength) {
throw new Error('فایل ضبط خالی است');
}
const contentType =
res.headers.get('content-type')?.split(';')[0]?.trim() || 'audio/wav';
const filename = `daftareshoma-${recordingId}.wav`;
return { buffer, contentType, filename };
}
}
@@ -0,0 +1,168 @@
import type { VoipWebhookEvent } from '@mento/shared';
/** پیشوند ذخیره‌شده در recordingUrl برای واکشی بعدی با توکن API */
export const DAFTARESHOMA_RECORDING_PREFIX = 'daftareshoma-recording:';
export function daftareshomaRecordingRef(recordingId: string): string {
return `${DAFTARESHOMA_RECORDING_PREFIX}${recordingId}`;
}
export function parseDaftaresomaRecordingRef(
value: string | null | undefined,
): string | null {
const raw = String(value ?? '').trim();
if (!raw.startsWith(DAFTARESHOMA_RECORDING_PREFIX)) return null;
const id = raw.slice(DAFTARESHOMA_RECORDING_PREFIX.length).trim();
return id || null;
}
type Loose = Record<string, unknown>;
function str(v: unknown): string {
return v == null ? '' : String(v).trim();
}
function pick(obj: Loose, ...keys: string[]): string {
for (const k of keys) {
const v = str(obj[k]);
if (v) return v;
}
return '';
}
function parseDurationSec(raw: unknown): number | undefined {
if (raw == null || raw === '') return undefined;
if (typeof raw === 'number' && Number.isFinite(raw)) {
return Math.max(0, Math.round(raw));
}
const s = String(raw).trim();
if (!s) return undefined;
if (/^\d+(\.\d+)?$/.test(s)) return Math.max(0, Math.round(Number(s)));
const parts = s.split(':').map((p) => Number(p));
if (parts.some((n) => !Number.isFinite(n))) return undefined;
if (parts.length === 3) {
return Math.max(0, Math.round(parts[0]! * 3600 + parts[1]! * 60 + parts[2]!));
}
if (parts.length === 2) {
return Math.max(0, Math.round(parts[0]! * 60 + parts[1]!));
}
return undefined;
}
/**
* رویداد وب‌هوک دفتر شما را به VoipWebhookEvent تبدیل می‌کند.
* اگر payload از قبل فرمت Montact باشد، همان را برمی‌گرداند.
*/
export function mapDaftaresomaWebhook(
body: unknown,
): VoipWebhookEvent | null {
if (!body || typeof body !== 'object') return null;
const obj = body as Loose;
const legacyCallId = pick(obj, 'callId');
if (legacyCallId && (obj.direction || obj.status || obj.from || obj.to)) {
return body as VoipWebhookEvent;
}
const eventType = pick(obj, 'EventType', 'eventType', 'event');
const callId = pick(obj, 'CallId', 'callId', 'Id', 'id');
if (!callId) return null;
const caller = pick(
obj,
'CallerNumber',
'callerNumber',
'from',
'From',
'fromNumber',
);
/** خط سازمانی که تماس روی آن آمده/رفته */
const lineExtension =
pick(obj, 'CalleeExtension', 'calleeExtension', 'Extension', 'extension') ||
undefined;
/** مقصد انتقال / شماره طرف مقابل روی خط */
const transferred = pick(
obj,
'TransferredToNumber',
'transferredToNumber',
);
const startedAt =
pick(obj, 'StartDateTime', 'startDateTime', 'startedAt') || undefined;
const endedAt =
pick(obj, 'EndDateTime', 'endDateTime', 'endedAt', 'DateTime') ||
undefined;
const durationSec = parseDurationSec(
obj.DurationInSeconds ?? obj.durationInSeconds ?? obj.durationSec,
);
const lower = eventType.toLowerCase();
const isOutbound = lower.includes('outgoing') || lower.includes('outbound');
const isStarted =
lower.includes('started') ||
lower.includes('ringing') ||
lower.includes('transferstarted');
const isEnded =
lower.includes('ended') ||
lower.includes('completed') ||
lower.includes('hangup');
let status: string = 'completed';
if (isStarted && !isEnded) {
status = 'ringing';
} else if (isEnded) {
const result = obj.Result ?? obj.result;
if (result === false || result === 'false' || result === 0) {
status = durationSec && durationSec > 0 ? 'completed' : 'missed';
} else if (result === true || result === 'true' || result === 1) {
status = 'completed';
} else {
status = 'completed';
}
}
const recordingId = pick(
obj,
'RecordingId',
'recordingId',
'RecordId',
'recordId',
);
// inbound: from=مشتری، to=همکار/مقصد انتقال یا خود خط
// outbound: from=همکار، to=مشتری (TransferredToNumber)
let from = caller || undefined;
let to: string | undefined;
if (isOutbound) {
to = transferred || undefined;
} else {
to = transferred || lineExtension || undefined;
}
const event: VoipWebhookEvent = {
callId,
direction: isOutbound ? 'outbound' : 'inbound',
status,
from,
to,
lineExtension,
startedAt,
endedAt,
durationSec,
caller: from,
callee: to,
};
if (recordingId) {
event.recordingUrl = daftareshomaRecordingRef(recordingId);
}
return event;
}
export function isDaftaresomaWebhookPayload(body: unknown): boolean {
if (!body || typeof body !== 'object') return false;
const obj = body as Loose;
return Boolean(
pick(obj, 'EventType', 'eventType') ||
(pick(obj, 'CallId') && pick(obj, 'CallerNumber', 'callerNumber')),
);
}
+35
View File
@@ -1,5 +1,6 @@
import { import {
IsBoolean, IsBoolean,
IsIn,
IsOptional, IsOptional,
IsString, IsString,
MaxLength, MaxLength,
@@ -12,6 +13,10 @@ export class CreateVoipConnectionDto {
@MaxLength(80) @MaxLength(80)
label!: string; label!: string;
@IsOptional()
@IsIn(['generic', 'daftareshoma'])
provider?: 'generic' | 'daftareshoma';
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(200) @MaxLength(200)
@@ -22,6 +27,21 @@ export class CreateVoipConnectionDto {
@MaxLength(500) @MaxLength(500)
clickToCallUrl?: string | null; clickToCallUrl?: string | null;
@IsOptional()
@IsString()
@MaxLength(500)
apiToken?: string;
@IsOptional()
@IsString()
@MaxLength(40)
fromNumber?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
callerExtension?: string | null;
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
enabled?: boolean; enabled?: boolean;
@@ -44,6 +64,21 @@ export class UpdateVoipConnectionDto {
@MaxLength(500) @MaxLength(500)
clickToCallUrl?: string | null; clickToCallUrl?: string | null;
@IsOptional()
@IsString()
@MaxLength(500)
apiToken?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
fromNumber?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
callerExtension?: string | null;
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
enabled?: boolean; enabled?: boolean;
@@ -18,17 +18,31 @@ export class SttClientService {
throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست'); throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست');
} }
const timeoutMs = Number(
this.config.get<string>('STT_TIMEOUT_MS') || 180000,
);
const audio = await this.downloadAudio(recordingUrl, timeoutMs);
return this.transcribeAudio(audio, timeoutMs);
}
async transcribeAudio(
audio: { buffer: ArrayBuffer; contentType: string; filename: string },
timeoutMs?: number,
): Promise<string> {
if (!this.isEnabled()) {
throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست');
}
const baseUrl = this.config.get<string>('STT_URL')?.trim(); const baseUrl = this.config.get<string>('STT_URL')?.trim();
if (!baseUrl) { if (!baseUrl) {
throw new ServiceUnavailableException('آدرس سرویس STT تنظیم نشده'); throw new ServiceUnavailableException('آدرس سرویس STT تنظیم نشده');
} }
const timeoutMs = Number( const waitMs = timeoutMs ?? Number(
this.config.get<string>('STT_TIMEOUT_MS') || 180000, this.config.get<string>('STT_TIMEOUT_MS') || 180000,
); );
const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim() || ''; const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim() || '';
const audio = await this.downloadAudio(recordingUrl, timeoutMs);
const form = new FormData(); const form = new FormData();
const blob = new Blob([new Uint8Array(audio.buffer)], { const blob = new Blob([new Uint8Array(audio.buffer)], {
type: audio.contentType, type: audio.contentType,
@@ -39,7 +53,7 @@ export class SttClientService {
if (token) headers['X-STT-Token'] = token; if (token) headers['X-STT-Token'] = token;
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs); const timer = setTimeout(() => controller.abort(), waitMs);
try { try {
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/transcribe`, { const res = await fetch(`${baseUrl.replace(/\/$/, '')}/transcribe`, {
method: 'POST', method: 'POST',
@@ -28,6 +28,10 @@ import {
UpdateVoipConnectionDto, UpdateVoipConnectionDto,
VoipClickToCallDto, VoipClickToCallDto,
} from './dto'; } from './dto';
import {
isDaftaresomaWebhookPayload,
mapDaftaresomaWebhook,
} from './daftareshoma-webhook';
@Controller('integrations/voip') @Controller('integrations/voip')
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard) @UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@@ -78,8 +82,16 @@ export class VoipController {
recent( recent(
@CurrentUser() user: { organizationId: string }, @CurrentUser() user: { organizationId: string },
@Query('connectionId') connectionId?: string, @Query('connectionId') connectionId?: string,
@Query('direction') direction?: string,
@Query('status') status?: string,
@Query('lineExtension') lineExtension?: string,
) { ) {
return this.calls.listRecent(user.organizationId, connectionId); return this.calls.listRecent(user.organizationId, {
connectionId,
direction,
status,
lineExtension,
});
} }
@Post('click-to-call') @Post('click-to-call')
@@ -129,11 +141,21 @@ export class VoipWebhookController {
if (!ok) throw new UnauthorizedException('امضای وب‌هوک نامعتبر است'); if (!ok) throw new UnauthorizedException('امضای وب‌هوک نامعتبر است');
} }
if (!body?.callId) { let event: VoipWebhookEvent | null = null;
if (
row.provider === 'daftareshoma' ||
isDaftaresomaWebhookPayload(body)
) {
event = mapDaftaresomaWebhook(body);
} else if (body?.callId) {
event = body;
}
if (!event?.callId) {
return { ok: false, error: 'callId required' }; return { ok: false, error: 'callId required' };
} }
const result = await this.calls.ingestWebhook(connectionId, body); const result = await this.calls.ingestWebhook(connectionId, event);
return { ok: true, ...result }; return { ok: true, ...result };
} }
} }
@@ -7,6 +7,7 @@ import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
import { CallSummaryService } from './call-summary.service'; import { CallSummaryService } from './call-summary.service';
import { VoipConnectionsService } from './connections.service'; import { VoipConnectionsService } from './connections.service';
import { VoipCallsService } from './calls.service'; import { VoipCallsService } from './calls.service';
import { DaftaresomaClient } from './daftareshoma-client';
import { SttClientService } from './stt-client.service'; import { SttClientService } from './stt-client.service';
import { VoipController, VoipWebhookController } from './voip.controller'; import { VoipController, VoipWebhookController } from './voip.controller';
@@ -17,6 +18,7 @@ import { VoipController, VoipWebhookController } from './voip.controller';
TahesabCryptoService, TahesabCryptoService,
VoipConnectionsService, VoipConnectionsService,
VoipCallsService, VoipCallsService,
DaftaresomaClient,
SttClientService, SttClientService,
CallSummaryService, CallSummaryService,
], ],
@@ -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 },
+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;
+17 -2
View File
@@ -107,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 : 'حذف مرحله ناموفق بود',
); );
@@ -245,7 +246,11 @@ export class LeadsController {
@Query('source') source?: string, @Query('source') source?: string,
@Query('unassigned') unassigned?: string, @Query('unassigned') unassigned?: string,
@Query('archived') archived?: string, @Query('archived') archived?: string,
@Query('converted') converted?: string,
@Query('hasMobile') hasMobile?: string, @Query('hasMobile') hasMobile?: string,
@Query('followUp') followUp?: string,
@Query('createdFrom') createdFrom?: string,
@Query('createdTo') createdTo?: string,
) { ) {
return this.leads.listIds(user, { return this.leads.listIds(user, {
q, q,
@@ -257,7 +262,11 @@ export class LeadsController {
source, source,
unassigned: unassigned === '1' || unassigned === 'true', unassigned: unassigned === '1' || unassigned === 'true',
archived: archived === '1' || archived === 'true', archived: archived === '1' || archived === 'true',
converted: converted === '1' || converted === 'true',
hasMobile: hasMobile === '1' || hasMobile === 'true', hasMobile: hasMobile === '1' || hasMobile === 'true',
followUp,
createdFrom,
createdTo,
}); });
} }
@@ -275,8 +284,12 @@ export class LeadsController {
@Query('source') source?: string, @Query('source') source?: string,
@Query('unassigned') unassigned?: string, @Query('unassigned') unassigned?: string,
@Query('archived') archived?: string, @Query('archived') archived?: string,
@Query('converted') converted?: string,
@Query('sort') sort?: string, @Query('sort') sort?: string,
@Query('hasMobile') hasMobile?: string, @Query('hasMobile') hasMobile?: string,
@Query('followUp') followUp?: string,
@Query('createdFrom') createdFrom?: string,
@Query('createdTo') createdTo?: string,
) { ) {
return this.leads.list(user, { return this.leads.list(user, {
q, q,
@@ -290,8 +303,12 @@ export class LeadsController {
source, source,
unassigned: unassigned === '1' || unassigned === 'true', unassigned: unassigned === '1' || unassigned === 'true',
archived: archived === '1' || archived === 'true', archived: archived === '1' || archived === 'true',
converted: converted === '1' || converted === 'true',
sort, sort,
hasMobile: hasMobile === '1' || hasMobile === 'true', hasMobile: hasMobile === '1' || hasMobile === 'true',
followUp,
createdFrom,
createdTo,
}); });
} }
@@ -301,8 +318,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)
+363 -77
View File
@@ -20,6 +20,7 @@ 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';
@@ -30,16 +31,30 @@ import {
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';
import {
addDays,
isDayKey,
tehranDayKey,
tehranDayStart,
} from '../routines/routine-dates';
const LOGO_MAX_BYTES = 2 * 1024 * 1024; const LOGO_MAX_BYTES = 2 * 1024 * 1024;
const LOGO_EXT: Record<string, string> = { const LOGO_EXT: Record<string, string> = {
@@ -51,13 +66,20 @@ 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 personWithMobile(organizationId: string, mobile: string): Prisma.PersonWhereInput {
const keys = phoneIdentityKeys(mobile);
return {
organizationId,
OR: [{ mobile: { in: keys } }, { phone: { in: keys } }],
};
} }
function leadAccessWhere(actor: Actor): Prisma.LeadWhereInput { function leadAccessWhere(actor: Actor): Prisma.LeadWhereInput {
@@ -90,6 +112,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,
@@ -125,7 +148,11 @@ export class LeadsService {
source?: string; source?: string;
unassigned?: boolean; unassigned?: boolean;
archived?: boolean; archived?: boolean;
converted?: boolean;
hasMobile?: boolean; hasMobile?: boolean;
followUp?: string;
createdFrom?: string;
createdTo?: string;
}, },
): Prisma.LeadWhereInput { ): Prisma.LeadWhereInput {
const search = buildSearchWhere<Prisma.LeadWhereInput>(opts.q, (t) => [ const search = buildSearchWhere<Prisma.LeadWhereInput>(opts.q, (t) => [
@@ -144,22 +171,77 @@ export class LeadsService {
if (opts.hasMobile) { if (opts.hasMobile) {
and.push({ mobile: { not: '' } }); and.push({ mobile: { not: '' } });
} }
const follow = this.followUpWhere(opts.followUp);
if (follow) and.push(follow);
const createdRange = this.createdAtWhere(opts.createdFrom, opts.createdTo);
if (createdRange) and.push(createdRange);
return { return {
organizationId: actor.organizationId, AND: [
...leadAccessWhere(actor), {
archivedAt: opts.archived ? { not: null } : null, organizationId: actor.organizationId,
...(opts.stageId ? { stageId: opts.stageId } : {}), ...leadAccessWhere(actor),
...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}), archivedAt: opts.archived ? { not: null } : null,
...(opts.unassigned ? { assigneeId: null } : {}), ...(opts.converted ? { convertedPersonId: { not: null } } : {}),
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}), ...(opts.stageId ? { stageId: opts.stageId } : {}),
...(opts.region ...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
? { region: { contains: opts.region, mode: 'insensitive' } } ...(opts.region
: {}), ? { region: { contains: opts.region, mode: 'insensitive' } }
...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}), : {}),
...(opts.source ...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}),
? { source: { contains: opts.source, mode: 'insensitive' } } ...(opts.source
: {}), ? { source: { contains: opts.source, mode: 'insensitive' } }
...(and.length ? { AND: and } : {}), : {}),
},
...(opts.unassigned ? [{ assigneeId: null }] : []),
...(opts.assigneeId && !opts.unassigned ? [{ assigneeId: opts.assigneeId }] : []),
...and,
],
};
}
/** مرز روز به وقت تهران برای فیلترهای «امروز». */
private tehranDayBounds(day = tehranDayKey()) {
const start = tehranDayStart(day);
const end = tehranDayStart(addDays(day, 1));
return { start, end };
}
private followUpWhere(followUp?: string): Prisma.LeadWhereInput | null {
if (!followUp) return null;
const { start, end } = this.tehranDayBounds();
switch (followUp) {
case 'due_today':
return { nextFollowUpAt: { gte: start, lt: end } };
case 'overdue':
return { nextFollowUpAt: { not: null, lt: start } };
case 'called_today':
return { lastContactAt: { gte: start, lt: end } };
case 'missed_today':
return {
nextFollowUpAt: { not: null, lt: end },
OR: [{ lastContactAt: null }, { lastContactAt: { lt: start } }],
};
case 'no_follow_up':
return { nextFollowUpAt: null };
default:
return null;
}
}
private createdAtWhere(
createdFrom?: string,
createdTo?: string,
): Prisma.LeadWhereInput | null {
const from = isDayKey(createdFrom) ? tehranDayStart(createdFrom) : null;
const to = isDayKey(createdTo)
? tehranDayStart(addDays(createdTo, 1))
: null;
if (!from && !to) return null;
return {
createdAt: {
...(from ? { gte: from } : {}),
...(to ? { lt: to } : {}),
},
}; };
} }
@@ -177,8 +259,12 @@ export class LeadsService {
source?: string; source?: string;
unassigned?: boolean; unassigned?: boolean;
archived?: boolean; archived?: boolean;
converted?: boolean;
sort?: string; sort?: string;
hasMobile?: boolean; hasMobile?: boolean;
followUp?: string;
createdFrom?: string;
createdTo?: string;
}, },
): Promise<PaginatedResponse<LeadDto>> { ): Promise<PaginatedResponse<LeadDto>> {
await this.stages.ensureDefaults(actor.organizationId); await this.stages.ensureDefaults(actor.organizationId);
@@ -226,7 +312,11 @@ export class LeadsService {
source?: string; source?: string;
unassigned?: boolean; unassigned?: boolean;
archived?: boolean; archived?: boolean;
converted?: boolean;
hasMobile?: boolean; hasMobile?: boolean;
followUp?: string;
createdFrom?: string;
createdTo?: string;
}, },
max = FILTERED_IDS_MAX, max = FILTERED_IDS_MAX,
): Promise<FilteredIdsResponse> { ): Promise<FilteredIdsResponse> {
@@ -318,10 +408,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) {
@@ -341,6 +428,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;
@@ -374,19 +463,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),
@@ -461,12 +562,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) }
: {}), : {}),
@@ -525,8 +655,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 };
@@ -565,24 +696,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 },
@@ -596,9 +746,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,
@@ -764,14 +912,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 } };
@@ -810,8 +958,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,
@@ -832,6 +982,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('حذف گروهی فقط برای مدیر');
@@ -865,10 +1048,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;
@@ -877,14 +1057,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,
@@ -979,8 +1170,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,
@@ -988,7 +1181,6 @@ export class LeadsService {
unassigned, unassigned,
converted, converted,
lost, lost,
interestedCount,
withFollowUp, withFollowUp,
callsLogged, callsLogged,
byAssigneeRaw, byAssigneeRaw,
@@ -997,7 +1189,7 @@ export class LeadsService {
newStage newStage
? this.prisma.lead.count({ where: { ...base, stageId: newStage.id } }) ? this.prisma.lead.count({ where: { ...base, stageId: newStage.id } })
: Promise.resolve(0), : Promise.resolve(0),
this.prisma.lead.count({ where: { ...base, assigneeId: null } }), this.prisma.lead.count({ where: { AND: [base, { assigneeId: null }] } }),
convertedIds.length convertedIds.length
? this.prisma.lead.count({ ? this.prisma.lead.count({
where: { ...base, stageId: { in: convertedIds } }, where: { ...base, stageId: { in: convertedIds } },
@@ -1008,11 +1200,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 } },
}), }),
@@ -1055,13 +1242,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,
@@ -1104,10 +1380,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) {
@@ -1206,6 +1479,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,
@@ -1256,6 +1531,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);
@@ -1266,13 +1544,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 },
@@ -1291,11 +1572,16 @@ export class LeadsService {
const mobile = lead.mobile || lead.phone; const mobile = lead.mobile || lead.phone;
if (!mobile) throw new BadRequestException('برای این لید شماره موبایل ثبت نشده است.'); if (!mobile) throw new BadRequestException('برای این لید شماره موبایل ثبت نشده است.');
const name = (lead.contactName || lead.title).trim(); const name = (lead.contactName || lead.title).trim();
const org = await this.prisma.organization.findUnique({
where: { id: actor.organizationId },
select: { name: true },
});
const message = renderSmsText(body, { const message = renderSmsText(body, {
name, name,
phone: mobile, phone: mobile,
title: lead.title, title: lead.title,
company: lead.contactName ? lead.title : '', company: lead.trade?.trim() || '',
from: org?.name?.trim() || '',
}); });
if (!message) throw new BadRequestException('متن پیامک خالی است.'); if (!message) throw new BadRequestException('متن پیامک خالی است.');
const result = await this.sms.send({ const result = await this.sms.send({

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