82 Commits
Author SHA1 Message Date
MahdiandCursor 70524f4f2c Allow treasury payments when account balance goes negative.
Remove insufficient-balance blocks on document create, settle, and party collection so income/expense posting can overdraw an account.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 16:43:25 +03:30
MahdiandCursor 1b08ab1230 Show selected account balance beside the treasury account field.
Color the balance green, red, or gray on income and expense forms so users see cash position before posting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 13:48:30 +03:30
MahdiandCursor 4409ded0ab Add treasury document photos, table list, and manage permission.
Income/expense lists are table-or-card with detail, edit, void, and delete; edit/delete require owner, admin, or the new finance manage permission.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 12:42:01 +03:30
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
194 changed files with 19428 additions and 2927 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
@@ -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 '[]';
+76 -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
@@ -151,6 +162,10 @@ 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)
@@ -295,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")
@@ -353,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?
/// نقش والد برای سلسله‌مراتب زیرنقش‌ها /// نقش والد برای سلسله‌مراتب زیرنقش‌ها
@@ -387,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
@@ -518,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[]
@@ -557,6 +581,8 @@ model CustomerGroup {
children CustomerGroup[] @relation("CustomerGroupTree") 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
@@ -875,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)
@@ -896,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)
@@ -911,6 +945,7 @@ model Activity {
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])
@@ -1832,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[]
@@ -2321,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)
@@ -2341,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])
} }
/// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال. /// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال.
@@ -2421,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?
@@ -2927,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[]
@@ -2946,6 +3009,7 @@ model VoipConnection {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([organizationId, enabled]) @@index([organizationId, enabled])
@@index([organizationId, provider])
} }
model VoipCallLog { model VoipCallLog {
@@ -2960,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?
@@ -2984,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])
@@ -3760,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';
@@ -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);
@@ -196,6 +210,7 @@ export class ActivitiesController {
title: query.title, title: query.title,
customer: query.customer, customer: query.customer,
due: query.due, due: query.due,
priority: query.priority,
boardId: query.boardId, boardId: query.boardId,
}, },
); );
@@ -232,6 +247,7 @@ export class ActivitiesController {
title: query.title, title: query.title,
customer: query.customer, customer: query.customer,
due: query.due, due: query.due,
priority: query.priority,
boardId: query.boardId, boardId: query.boardId,
}, },
query.stageId ? { stageId: query.stageId } : undefined, query.stageId ? { stageId: query.stageId } : undefined,
@@ -265,6 +281,7 @@ export class ActivitiesController {
title: query.title, title: query.title,
customer: query.customer, customer: query.customer,
due: query.due, due: query.due,
priority: query.priority,
boardId: query.boardId, boardId: query.boardId,
}, },
query.stageId ? { stageId: query.stageId } : undefined, query.stageId ? { stageId: query.stageId } : undefined,
@@ -428,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')
+322 -34
View File
@@ -31,6 +31,7 @@ import {
ownerWhere, ownerWhere,
} from '../common/record-scope'; } from '../common/record-scope';
import { assertAssigneeSet } from '../common/assign-access'; import { assertAssigneeSet } from '../common/assign-access';
import { placeId, sameIds, writePositions } from '../common/column-order';
import { normalizeTags } from '../common/tags'; import { normalizeTags } from '../common/tags';
import { GamificationService } from '../gamification/gamification.service'; import { GamificationService } from '../gamification/gamification.service';
import { tehranDayKey } from '../routines/routine-dates'; import { tehranDayKey } from '../routines/routine-dates';
@@ -51,6 +52,65 @@ type ActivityActor = Pick<
'userId' | 'role' | 'priorLens' | 'priorCutoff' | 'leadsAccess' | 'teamPeerIds' 'userId' | 'role' | 'priorLens' | 'priorCutoff' | 'leadsAccess' | 'teamPeerIds'
>; >;
function assigneeIdList(row: {
assigneeId?: string | null;
assignees?: Array<{ id: string }>;
}): string[] {
const ids = row.assignees?.map((item) => item.id) ?? [];
if (ids.length) return [...new Set(ids)];
return row.assigneeId ? [row.assigneeId] : [];
}
function canForceClose(
actor: { userId: string; role: string },
createdById: string | null | undefined,
): boolean {
return actor.role === 'owner' || actor.role === 'admin' || actor.userId === createdById;
}
function canMutateActivity(
actor: { userId: string; role: string },
row: {
createdById?: string | null;
editPolicy?: 'creator' | 'assignees' | null;
assigneeId?: string | null;
assignees?: Array<{ id: string }>;
},
): boolean {
if (actor.role === 'owner' || actor.role === 'admin') return true;
if (row.createdById && actor.userId === row.createdById) return true;
if ((row.editPolicy ?? 'creator') !== 'assignees') return false;
return assigneeIdList(row).includes(actor.userId);
}
/** ثبت نظر / تعویق — برای همهٔ مسئولان، حتی وقتی ویرایش فقط با ایجادکننده است */
function canCollaborateActivity(
actor: { userId: string; role: string },
row: {
createdById?: string | null;
assigneeId?: string | null;
assignees?: Array<{ id: string }>;
},
): boolean {
if (actor.role === 'owner' || actor.role === 'admin') return true;
if (row.createdById && actor.userId === row.createdById) return true;
return assigneeIdList(row).includes(actor.userId);
}
/** فقط dueAt / description → تعویق و ثبت نظر */
function isCollaboratorPatch(dto: UpdateActivityRequest): boolean {
const keys = Object.keys(dto).filter(
(key) => (dto as Record<string, unknown>)[key] !== undefined,
);
return keys.length > 0 && keys.every((key) => key === 'dueAt' || key === 'description');
}
function reconcileAllShares(assigneeIds: string[], completedByIds: string[]) {
const kept = [...new Set(completedByIds.filter((id) => assigneeIds.includes(id)))];
const done = assigneeIds.length > 0 && assigneeIds.every((id) => kept.includes(id));
return { status: done ? ('done' as const) : ('open' as const), completedByIds: kept };
}
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */ /** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */
type StageScope = { stageId: string }; type StageScope = { stageId: string };
@@ -63,6 +123,7 @@ type ListFacets = {
customer?: string; customer?: string;
/** set = زمان شروع دارد، unset = ندارد */ /** set = زمان شروع دارد، unset = ندارد */
due?: 'set' | 'unset'; due?: 'set' | 'unset';
priority?: ActivityDto['priority'];
/** گروه فعالیت؛ خالی = همهٔ گروه‌ها */ /** گروه فعالیت؛ خالی = همهٔ گروه‌ها */
boardId?: string; boardId?: string;
}; };
@@ -81,6 +142,9 @@ type ActivityRow = {
reminderNotifySms: boolean; reminderNotifySms: boolean;
reminderSentAt: Date | null; reminderSentAt: Date | null;
completedAt: Date | null; completedAt: Date | null;
completionMode?: 'any' | 'all';
completedByIds?: string[];
editPolicy?: 'creator' | 'assignees';
importSource?: string; importSource?: string;
promotedFromPriorAt?: Date | null; promotedFromPriorAt?: Date | null;
tags: string[]; tags: string[];
@@ -185,22 +249,39 @@ export class ActivitiesService {
return { stages, types }; return { stages, types };
} }
/** کارهای بازِ «من» که سهم‌شان ثبت شده، از میز کار شخص خارج می‌شوند. */
private deskScope(
actor: ActivityActor,
desk?: 'mine' | 'team',
): Prisma.ActivityWhereInput {
if (desk !== 'mine') return activityAccessWhere(actor);
return {
AND: [
{
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
},
{
NOT: {
completionMode: 'all',
status: 'open',
completedByIds: { has: actor.userId },
},
},
],
};
}
async summary( async summary(
organizationId: string, organizationId: string,
actor: ActivityActor, actor: ActivityActor,
desk?: 'mine' | 'team', desk?: 'mine' | 'team',
): Promise<ActivitySummaryDto> { ): Promise<ActivitySummaryDto> {
const { start, end } = dayBounds(); const { start, end } = dayBounds();
const scope: Prisma.ActivityWhereInput = const scope = this.deskScope(actor, desk);
desk === 'mine'
? {
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
}
: activityAccessWhere(actor);
const prior = activityPriorWhere(actor.priorLens, actor.priorCutoff); const prior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
const [todayCount, overdueCount] = await Promise.all([ const [todayCount, overdueCount] = await Promise.all([
this.prisma.activity.count({ this.prisma.activity.count({
@@ -329,9 +410,7 @@ export class ActivitiesService {
where, where,
include, include,
// روی پروندهٔ لید، تازه‌ترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد // روی پروندهٔ لید، تازه‌ترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد
orderBy: entity?.leadId orderBy: this.listOrder(entity, stageScope),
? [{ createdAt: 'desc' }]
: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * take, skip: (page - 1) * take,
take, take,
}), }),
@@ -385,7 +464,7 @@ export class ActivitiesService {
this.prisma.activity.findMany({ this.prisma.activity.findMany({
where, where,
select: { id: true }, select: { id: true },
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }], orderBy: this.listOrder(entity, stageScope),
take: cap, take: cap,
}), }),
this.prisma.activity.count({ where }), this.prisma.activity.count({ where }),
@@ -490,18 +569,69 @@ export class ActivitiesService {
return { counts, total }; return { counts, total };
} }
private listOrder(
entity?: { leadId?: string },
stageScope?: StageScope,
): Prisma.ActivityOrderByWithRelationInput[] {
if (entity?.leadId) return [{ createdAt: 'desc' }];
if (stageScope && stageScope.stageId !== 'all') {
return [{ boardPosition: 'asc' }, { dueAt: 'asc' }, { createdAt: 'desc' }];
}
return [{ dueAt: 'asc' }, { createdAt: 'desc' }];
}
private async reorderStage(
organizationId: string,
stageId: string,
activityId: string,
index: number,
) {
const where = await this.stages.whereForStage(organizationId, stageId);
const rows = await this.prisma.activity.findMany({
where,
select: { id: true },
orderBy: [{ boardPosition: 'asc' }, { dueAt: 'asc' }, { createdAt: 'desc' }],
});
const currentIds = rows.map((row) => row.id);
const placed = placeId(currentIds, activityId, index);
if (sameIds(currentIds, placed)) return;
await writePositions(
(ops) => this.prisma.$transaction(ops),
placed,
(id, boardPosition) =>
this.prisma.activity.update({ where: { id }, data: { boardPosition } }),
);
}
async moveToStage( async moveToStage(
organizationId: string, organizationId: string,
id: string, id: string,
actor: ActivityActor, actor: ActivityActor,
stageId: string, stageId: string,
position?: number,
): Promise<ActivityDto> { ): Promise<ActivityDto> {
await this.ensureExists(organizationId, id, actor); await this.ensureExists(organizationId, id, actor);
const stage = await this.stages.get(organizationId, stageId); const stage = await this.stages.get(organizationId, stageId);
const existing = await this.prisma.activity.findFirst({ const existing = await this.prisma.activity.findFirst({
where: { id, organizationId }, where: { id, organizationId },
select: { status: true, completedAt: true }, select: {
status: true,
completedAt: true,
completionMode: true,
completedByIds: true,
createdById: true,
assigneeId: true,
assignees: { select: { id: true } },
},
}); });
if (
stage.kind === 'done' &&
existing?.completionMode === 'all' &&
assigneeIdList(existing).length > 1 &&
!canForceClose(actor, existing.createdById)
) {
return this.complete(organizationId, id, actor);
}
const row = await this.prisma.activity.update({ const row = await this.prisma.activity.update({
where: { id }, where: { id },
data: { data: {
@@ -516,9 +646,13 @@ export class ActivitiesService {
: stage.kind === 'open' : stage.kind === 'open'
? null ? null
: undefined, : undefined,
...(stage.kind === 'open' && existing?.status !== 'open'
? { completedByIds: [] }
: {}),
}, },
include, include,
}); });
if (position != null) await this.reorderStage(organizationId, stage.id, id, position);
if (stage.kind === 'done' && existing?.status !== 'done') { if (stage.kind === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row); await this.fireDone(organizationId, row);
} }
@@ -640,6 +774,9 @@ export class ActivitiesService {
assignees: assigneeIds.length assignees: assigneeIds.length
? { connect: assigneeIds.map((id) => ({ id })) } ? { connect: assigneeIds.map((id) => ({ id })) }
: undefined, : undefined,
completionMode: dto.completionMode === 'all' ? 'all' : 'any',
completedByIds: [],
editPolicy: dto.editPolicy === 'assignees' ? 'assignees' : 'creator',
createdById: userId, createdById: userId,
}, },
include, include,
@@ -708,8 +845,31 @@ export class ActivitiesService {
const existing = await this.prisma.activity.findFirst({ const existing = await this.prisma.activity.findFirst({
where: { id, organizationId }, where: { id, organizationId },
select: { status: true, type: true, typeDefId: true, fields: true, participantIds: true }, select: {
status: true,
type: true,
typeDefId: true,
fields: true,
participantIds: true,
completionMode: true,
completedByIds: true,
editPolicy: true,
createdById: true,
assigneeId: true,
assignees: { select: { id: true } },
},
}); });
if (!existing) {
throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید');
}
const collabOnly = isCollaboratorPatch(dto);
if (collabOnly) {
if (!canCollaborateActivity(actor, existing)) {
throw new ForbiddenException('شما مجوز ثبت نظر یا تعویق این فعالیت را ندارید');
}
} else if (!canMutateActivity(actor, existing)) {
throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید');
}
const data: Prisma.ActivityUncheckedUpdateInput = {}; const data: Prisma.ActivityUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim(); if (dto.title !== undefined) data.title = dto.title.trim();
if ('boardId' in dto) { if ('boardId' in dto) {
@@ -777,6 +937,7 @@ export class ActivitiesService {
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null); if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null); if ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null);
let assigneeConnect: Array<{ id: string }> | undefined; let assigneeConnect: Array<{ id: string }> | undefined;
let nextAssigneeIds: string[] | undefined;
if (dto.assigneeIds !== undefined || 'assigneeId' in dto) { if (dto.assigneeIds !== undefined || 'assigneeId' in dto) {
const current = await this.prisma.activity.findFirst({ const current = await this.prisma.activity.findFirst({
where: { id, organizationId }, where: { id, organizationId },
@@ -804,6 +965,7 @@ export class ActivitiesService {
); );
data.assigneeId = assigneeIds[0]!; data.assigneeId = assigneeIds[0]!;
assigneeConnect = assigneeIds.map((uid) => ({ id: uid })); assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
nextAssigneeIds = assigneeIds;
} else { } else {
const assigneeId = emptyToNull(dto.assigneeId ?? null); const assigneeId = emptyToNull(dto.assigneeId ?? null);
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId); if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
@@ -817,12 +979,44 @@ export class ActivitiesService {
data.assigneeId = assigneeId; data.assigneeId = assigneeId;
if (assigneeId) assigneeConnect = [{ id: assigneeId }]; if (assigneeId) assigneeConnect = [{ id: assigneeId }];
else assigneeConnect = []; else assigneeConnect = [];
nextAssigneeIds = assigneeId ? [assigneeId] : [];
} }
} }
if (dto.status !== undefined) { const modeChanged = dto.completionMode === 'any' || dto.completionMode === 'all';
data.status = dto.status; const nextMode = modeChanged ? dto.completionMode! : (existing?.completionMode ?? 'any');
data.completedAt = if (modeChanged) data.completionMode = nextMode;
dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined; if (dto.editPolicy === 'creator' || dto.editPolicy === 'assignees') {
data.editPolicy = dto.editPolicy;
}
const prevAssigneeIds = assigneeIdList(existing ?? {});
const nextIds = nextAssigneeIds ?? prevAssigneeIds;
const explicitDone = dto.status === 'done';
const explicitReopen = dto.status === 'open' && existing?.status !== 'open';
const explicitCancel = dto.status === 'canceled';
if (explicitDone) {
data.status = 'done';
data.completedAt = new Date();
} else if (explicitReopen) {
data.status = 'open';
data.completedAt = null;
data.completedByIds = [];
} else if (explicitCancel) {
data.status = 'canceled';
} else if (nextMode === 'any' && modeChanged) {
data.completedByIds = [];
} else if (nextMode === 'all' && (modeChanged || nextAssigneeIds)) {
const reconciled = reconcileAllShares(nextIds, existing?.completedByIds ?? []);
data.completedByIds = reconciled.completedByIds;
const finishedStaysFinished = existing?.status === 'done' && !nextAssigneeIds;
if (existing?.status !== 'canceled' && !finishedStaysFinished) {
if (reconciled.status === 'done' && existing?.status !== 'done') {
data.status = 'done';
data.completedAt = new Date();
} else if (reconciled.status === 'open' && existing?.status === 'done') {
data.status = 'open';
data.completedAt = null;
}
}
} }
const row = await this.prisma.activity.update({ const row = await this.prisma.activity.update({
@@ -846,7 +1040,7 @@ export class ActivitiesService {
addedParticipants.filter((uid) => !assigneeSet.has(uid)), addedParticipants.filter((uid) => !assigneeSet.has(uid)),
); );
} }
if (dto.status === 'done' && existing?.status !== 'done') { if (data.status === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row); await this.fireDone(organizationId, row);
} }
return this.toDto(row, await this.ctx(organizationId)); return this.toDto(row, await this.ctx(organizationId));
@@ -856,18 +1050,74 @@ export class ActivitiesService {
organizationId: string, organizationId: string,
id: string, id: string,
actor: ActivityActor, actor: ActivityActor,
options: { force?: boolean; undo?: boolean } = {},
): Promise<ActivityDto> { ): Promise<ActivityDto> {
await this.ensureExists(organizationId, id, actor); await this.ensureExists(organizationId, id, actor);
const existing = await this.prisma.activity.findFirst({ const existing = await this.prisma.activity.findFirst({
where: { id, organizationId }, where: { id, organizationId },
select: { status: true }, select: {
status: true,
completionMode: true,
completedByIds: true,
createdById: true,
assigneeId: true,
assignees: { select: { id: true } },
},
}); });
if (!existing) throw new NotFoundException('فعالیت پیدا نشد');
if (existing.status === 'canceled') {
throw new BadRequestException('فعالیت لغو شده است');
}
const assignees = assigneeIdList(existing);
const allowedForce = canForceClose(actor, existing.createdById);
if (options.force && !allowedForce) {
throw new ForbiddenException('بستن فعالیت برای همه فقط برای سازنده یا مدیر ممکن است');
}
if (options.undo) {
if (existing.completionMode !== 'all' || existing.status !== 'open') {
throw new BadRequestException('سهم انجام‌شده را فقط تا وقتی فعالیت باز است می‌توان لغو کرد');
}
const row = await this.prisma.activity.update({
where: { id },
data: {
completedByIds: existing.completedByIds.filter((uid) => uid !== actor.userId),
},
include,
});
return this.toDto(row, await this.ctx(organizationId));
}
const personal =
existing.completionMode === 'all' && assignees.length > 1 && !options.force;
if (!personal) {
const row = await this.prisma.activity.update({
where: { id },
data: { status: 'done', completedAt: new Date() },
include,
});
if (existing.status !== 'done') await this.fireDone(organizationId, row);
return this.toDto(row, await this.ctx(organizationId));
}
if (!assignees.includes(actor.userId)) {
throw new ForbiddenException('فقط مسئول این فعالیت می‌تواند سهم خودش را ثبت کند');
}
const reconciled = reconcileAllShares(assignees, [
...existing.completedByIds,
actor.userId,
]);
const row = await this.prisma.activity.update({ const row = await this.prisma.activity.update({
where: { id }, where: { id },
data: { status: 'done', completedAt: new Date() }, data: {
completedByIds: reconciled.completedByIds,
status: reconciled.status,
completedAt: reconciled.status === 'done' ? new Date() : null,
},
include, include,
}); });
if (existing?.status !== 'done') { if (reconciled.status === 'done' && existing.status !== 'done') {
await this.fireDone(organizationId, row); await this.fireDone(organizationId, row);
} }
return this.toDto(row, await this.ctx(organizationId)); return this.toDto(row, await this.ctx(organizationId));
@@ -911,6 +1161,18 @@ export class ActivitiesService {
actor: ActivityActor, actor: ActivityActor,
): Promise<void> { ): Promise<void> {
await this.ensureExists(organizationId, id, actor); await this.ensureExists(organizationId, id, actor);
const existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: {
createdById: true,
editPolicy: true,
assigneeId: true,
assignees: { select: { id: true } },
},
});
if (!existing || !canMutateActivity(actor, existing)) {
throw new ForbiddenException('شما مجوز حذف این فعالیت را ندارید');
}
const images = await this.prisma.activityImage.findMany({ const images = await this.prisma.activityImage.findMany({
where: { activityId: id, organizationId }, where: { activityId: id, organizationId },
select: { storageKey: true, sizeBytes: true }, select: { storageKey: true, sizeBytes: true },
@@ -931,6 +1193,19 @@ export class ActivitiesService {
actor: ActivityActor, actor: ActivityActor,
): Promise<ActivityImageDto> { ): Promise<ActivityImageDto> {
await this.ensureExists(organizationId, activityId, actor); await this.ensureExists(organizationId, activityId, actor);
const gate = await this.prisma.activity.findFirst({
where: { id: activityId, organizationId },
select: {
createdById: true,
editPolicy: true,
assigneeId: true,
assignees: { select: { id: true } },
},
});
// پیوست در مسیر ثبت نظر — مسئولان هم مجازند حتی با editPolicy=creator
if (!gate || !canCollaborateActivity(actor, gate)) {
throw new ForbiddenException('شما مجوز ثبت نظر یا پیوست برای این فعالیت را ندارید');
}
if (!file?.buffer?.length) { if (!file?.buffer?.length) {
throw new BadRequestException('فایلی ارسال نشده است'); throw new BadRequestException('فایلی ارسال نشده است');
} }
@@ -1025,10 +1300,27 @@ export class ActivitiesService {
actor: ActivityActor, actor: ActivityActor,
): Promise<void> { ): Promise<void> {
await this.ensureExists(organizationId, activityId, actor); await this.ensureExists(organizationId, activityId, actor);
const gate = await this.prisma.activity.findFirst({
where: { id: activityId, organizationId },
select: {
createdById: true,
editPolicy: true,
assigneeId: true,
assignees: { select: { id: true } },
},
});
const row = await this.prisma.activityImage.findFirst({ const row = await this.prisma.activityImage.findFirst({
where: { id: imageId, activityId, organizationId }, where: { id: imageId, activityId, organizationId },
}); });
if (!row) throw new NotFoundException('فایل پیدا نشد'); if (!row) throw new NotFoundException('فایل پیدا نشد');
const canMutate = !!gate && canMutateActivity(actor, gate);
const canRemoveOwn =
!!gate &&
canCollaborateActivity(actor, gate) &&
row.uploadedById === actor.userId;
if (!canMutate && !canRemoveOwn) {
throw new ForbiddenException('شما مجوز حذف این پیوست را ندارید');
}
await this.files.remove(row.storageKey); await this.files.remove(row.storageKey);
await this.prisma.activityImage.delete({ where: { id: row.id } }); await this.prisma.activityImage.delete({ where: { id: row.id } });
await this.storage.addLocal(organizationId, -row.sizeBytes); await this.storage.addLocal(organizationId, -row.sizeBytes);
@@ -1073,25 +1365,18 @@ export class ActivitiesService {
typeRows: TypeRow[] = [], typeRows: TypeRow[] = [],
): Prisma.ActivityWhereInput { ): Prisma.ActivityWhereInput {
const { start, end } = dayBounds(); const { start, end } = dayBounds();
const scope: Prisma.ActivityWhereInput = const scope = this.deskScope(actor, desk);
desk === 'mine'
? {
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
}
: activityAccessWhere(actor);
const facetAssignee = facets?.assigneeId?.trim(); const facetAssignee = facets?.assigneeId?.trim();
const facetTypeDef = facets?.typeDefId?.trim(); const facetTypeDef = facets?.typeDefId?.trim();
const facetTitle = facets?.title?.trim(); const facetTitle = facets?.title?.trim();
const facetCustomer = facets?.customer?.trim(); const facetCustomer = facets?.customer?.trim();
const facetPriority = facets?.priority;
const andParts: Prisma.ActivityWhereInput[] = [ const andParts: Prisma.ActivityWhereInput[] = [
scope, scope,
...(facets?.type ? [{ type: facets.type }] : []), ...(facets?.type ? [{ type: facets.type }] : []),
...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []), ...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []),
...(facets?.status ? [{ status: facets.status }] : []), ...(facets?.status ? [{ status: facets.status }] : []),
...(facetPriority ? [{ priority: facetPriority }] : []),
...(facetTitle ? [{ title: ci(facetTitle) }] : []), ...(facetTitle ? [{ title: ci(facetTitle) }] : []),
...(facetCustomer ? [this.customerWhere(facetCustomer)] : []), ...(facetCustomer ? [this.customerWhere(facetCustomer)] : []),
...(facetAssignee ...(facetAssignee
@@ -1383,6 +1668,9 @@ export class ActivitiesService {
reminderNotifySms: row.reminderNotifySms, reminderNotifySms: row.reminderNotifySms,
reminderSentAt: row.reminderSentAt?.toISOString() ?? null, reminderSentAt: row.reminderSentAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null, completedAt: row.completedAt?.toISOString() ?? null,
completionMode: row.completionMode ?? 'any',
completedByIds: row.completedByIds ?? [],
editPolicy: row.editPolicy ?? 'creator',
importSource: row.importSource ?? 'manual', importSource: row.importSource ?? 'manual',
promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null, promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null,
tags: row.tags ?? [], tags: row.tags ?? [],
@@ -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('نوع فعالیتی با این نام وجود دارد');
+54 -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';
@@ -91,6 +93,11 @@ export class ListActivitiesQueryDto {
@IsString() @IsString()
typeDefId?: string; typeDefId?: string;
/** اولویت فعالیت (کلید ثابت؛ برچسب در تنظیمات سازمان) */
@IsOptional()
@IsIn(ACTIVITY_PRIORITIES)
priority?: (typeof ACTIVITY_PRIORITIES)[number];
/** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */ /** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -219,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 در سرویس بر اساس تعریف نوع اعتبارسنجی می‌شود */
@@ -316,7 +329,7 @@ export class CreateActivityDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(2000) @MaxLength(20000)
description?: string; description?: string;
@IsOptional() @IsOptional()
@@ -378,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 {
@@ -416,7 +437,7 @@ export class UpdateActivityDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(2000) @MaxLength(20000)
description?: string | null; description?: string | null;
@IsOptional() @IsOptional()
@@ -478,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[];
} }
+11 -3
View File
@@ -19,6 +19,10 @@ import type {
StorageUsageDto, 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';
@@ -848,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 ??
@@ -879,9 +886,10 @@ export class AuthService {
storage, 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 } : {}),
}); });
} }
+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 },
}); });
+34 -1
View File
@@ -21,6 +21,9 @@ 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'; import { PlatformAdminAlertService } from './platform-admin-alert.service';
@@ -414,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() {
+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)));
}
}
+138 -109
View File
@@ -105,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 } {
@@ -135,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 {
@@ -149,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);
} }
@@ -160,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);
} }
@@ -199,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);
} }
@@ -232,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);
} }
@@ -266,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);
} }
@@ -310,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)],
@@ -352,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);
} }
@@ -384,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);
} }
@@ -418,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);
} }
@@ -449,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;
} }
} }
+78 -6
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 } },
+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,
+8 -3
View File
@@ -112,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([
@@ -165,6 +169,7 @@ export class CompaniesService {
fromTahesab: Boolean(g.tahesabGid), fromTahesab: Boolean(g.tahesabGid),
personCount: 0, personCount: 0,
companyCount: countMap.get(g.id) ?? 0, companyCount: countMap.get(g.id) ?? 0,
assignees: [],
})); }));
} }
+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 {}
+5
View File
@@ -23,6 +23,11 @@ export class BirthdaySmsDto {
@MinLength(1) @MinLength(1)
@MaxLength(500) @MaxLength(500)
text!: string; text!: string;
/** تأیید ارسال مجدد وقتی قبلاً مسئول دیگری تبریک فرستاده */
@IsOptional()
@IsBoolean()
force?: boolean;
} }
export class BulkDeletePeopleDto { export class BulkDeletePeopleDto {
+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());
}
+33
View File
@@ -27,3 +27,36 @@ export function normalizePhone(value?: string | null): string | null {
const cleaned = latinDigits(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);
}
+2 -1
View File
@@ -139,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);
} }
@@ -171,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')
+217 -63
View File
@@ -22,13 +22,19 @@ import {
FILTERED_IDS_MAX, FILTERED_IDS_MAX,
PERSON_MOODS, PERSON_MOODS,
applyPersonGenderTitle, applyPersonGenderTitle,
canViewAllRecords,
splitPersonHonorific, splitPersonHonorific,
} from '@mento/shared'; } 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,
@@ -43,13 +49,15 @@ import {
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access'; 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 {
@@ -152,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(
@@ -211,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,
],
}; };
} }
@@ -340,6 +363,7 @@ export class PeopleService implements OnModuleInit {
fromTahesab: Boolean(g.tahesabGid), fromTahesab: Boolean(g.tahesabGid),
personCount: countMap.get(g.id) ?? 0, personCount: countMap.get(g.id) ?? 0,
companyCount: 0, companyCount: 0,
assignees: [],
})); }));
} }
@@ -399,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({
@@ -441,8 +468,8 @@ export class PeopleService implements OnModuleInit {
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),
@@ -553,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);
@@ -641,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({
@@ -669,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,
@@ -717,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('برای این مشتری تاریخ تولد ثبت نشده است.');
@@ -759,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,
@@ -794,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) {
@@ -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),
);
}
}
}
@@ -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 {
@@ -43,6 +55,11 @@ class UpdateCustomerGroupDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
parentId?: string | null; parentId?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
} }
@Controller('customer-groups') @Controller('customer-groups')
@@ -51,26 +51,21 @@ export class CustomerGroupsService {
include: { include: {
_count: { select: { people: true, companies: true } }, _count: { select: { people: true, companies: true } },
parent: { select: { name: 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,
parentId: r.parentId,
parentName: r.parent?.name ?? null,
}));
} }
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 },
@@ -82,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) {
@@ -93,7 +91,12 @@ export class CustomerGroupsService {
async update( async update(
organizationId: string, organizationId: string,
id: string, id: string,
dto: { name?: string; color?: CustomerGroupColor; parentId?: string | null }, 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 = const parentId =
@@ -101,6 +104,10 @@ export class CustomerGroupsService {
if (parentId !== undefined) { if (parentId !== undefined) {
await this.assertParent(organizationId, id, parentId); 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 },
@@ -108,6 +115,9 @@ export class CustomerGroupsService {
...(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 } : {}), ...(parentId !== undefined ? { parentId } : {}),
...(assigneeIds !== undefined
? { assignees: { set: assigneeIds.map((uid) => ({ id: uid })) } }
: {}),
}, },
}); });
} catch (e) { } catch (e) {
@@ -317,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 },
@@ -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');
} }
@@ -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}`);
}
}
}
+16
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;
@@ -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,12 @@ 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 { applyPersonGenderTitle } from '@mento/shared';
import { TahesabConnectionsService } from './connections.service'; import { TahesabConnectionsService } from './connections.service';
import { import {
@@ -21,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;
@@ -64,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());
@@ -419,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,
@@ -497,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,
@@ -508,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: {
@@ -539,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: {
@@ -577,7 +674,7 @@ export class TahesabPersonLinkService {
const named = applyPersonGenderTitle({ firstName: name, lastName: '' }); const named = applyPersonGenderTitle({ firstName: name, lastName: '' });
const firstName = named.firstName; const firstName = named.firstName;
const lastName = named.lastName; 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;
@@ -603,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();
@@ -645,11 +762,17 @@ 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,
@@ -662,7 +785,9 @@ export class TahesabPersonLinkService {
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;
@@ -693,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) {
@@ -799,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,
@@ -825,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 =
@@ -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(
@@ -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 },
+16 -8
View File
@@ -21,9 +21,12 @@ 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';
@@ -458,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 },
}) })
@@ -641,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 },
@@ -1115,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 {
+16
View File
@@ -246,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,
@@ -258,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,
}); });
} }
@@ -276,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,
@@ -291,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,
}); });
} }
+235 -45
View File
@@ -32,17 +32,29 @@ import {
forScope, forScope,
} from '../common/record-scope'; } from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access'; 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 { 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> = {
@@ -54,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 {
@@ -129,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) => [
@@ -148,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 } : {}),
},
}; };
} }
@@ -181,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);
@@ -230,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> {
@@ -322,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) {
@@ -613,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 },
@@ -946,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;
@@ -976,7 +1075,7 @@ export class LeadsService {
lastName: named.lastName, lastName: named.lastName,
gender: named.gender, gender: named.gender,
showGenderInName: named.showGenderInName, showGenderInName: named.showGenderInName,
mobile: lead.mobile, mobile: canonicalIranMobile(lead.mobile) ?? lead.mobile,
phone: lead.phone, phone: lead.phone,
instagram: lead.instagram, instagram: lead.instagram,
birthDate: lead.birthDate, birthDate: lead.birthDate,
@@ -1090,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 } },
@@ -1150,6 +1249,95 @@ export class LeadsService {
}; };
} }
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,
@@ -1192,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) {
@@ -1387,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({
@@ -1,4 +1,5 @@
import { import {
BadRequestException,
ForbiddenException, ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
@@ -70,6 +71,16 @@ export class MembershipsService {
})); }));
} }
/** هر محیط یک مالک فعال دارد: همان کسی که محیط را ثبت کرده. */
async assertSoleOwner(organizationId: string, userId: string) {
const others = await this.countActiveOwners(organizationId, userId);
if (others > 0) {
throw new BadRequestException(
'هر محیط فقط یک مالک دارد و مالک همان کسی است که ثبت‌نام کرده. برای دسترسی کامل نقش مدیر را انتخاب کنید.',
);
}
}
async createMembership(data: { async createMembership(data: {
userId: string; userId: string;
organizationId: string; organizationId: string;
@@ -78,6 +89,9 @@ export class MembershipsService {
commissionPercent?: number; commissionPercent?: number;
isActive?: boolean; isActive?: boolean;
}) { }) {
if (data.role === 'owner' && data.isActive !== false) {
await this.assertSoleOwner(data.organizationId, data.userId);
}
return this.prisma.membership.create({ return this.prisma.membership.create({
data: { data: {
userId: data.userId, userId: data.userId,
@@ -56,7 +56,19 @@ function testTemplate() {
}) === 'سلام علی 0912', }) === 'سلام علی 0912',
'name phone vars', 'name phone vars',
); );
assert(clipSms('الف'.repeat(200)).length === 200, 'sms is not clipped'); assert(
applyMessageTemplate('تبریک {از طرف}', { from: 'هنراندیشه' }) ===
'تبریک هنراندیشه',
'from org var',
);
assert(
applyMessageTemplate('مانده {مانده ریالی} / {مانده فلزی}', {
rialBalance: '۱۰۰۰ بس',
metalBalance: 'طلا ۲',
}) === 'مانده ۱۰۰۰ بس / طلا ۲',
'balance vars',
);
assert(clipSms('ا'.repeat(200)).length === 200, 'sms is not clipped');
} }
testDelay(); testDelay();
@@ -77,6 +77,17 @@ export function buildSendAts(
return times; return times;
} }
/** یکسان‌سازی کلید توکن‌های قالب (فاصله/ZWNJ/ی عربی). */
export function normalizeTemplateKey(key: string): string {
return String(key ?? '')
.normalize('NFKC')
.replace(/[\u200c\u200d\u200e\u200f\ufeff]/g, '')
.replace(/ي/g, 'ی')
.replace(/ك/g, 'ک')
.replace(/\s+/g, ' ')
.trim();
}
export function applyMessageTemplate( export function applyMessageTemplate(
text: string, text: string,
vars: { vars: {
@@ -85,22 +96,44 @@ export function applyMessageTemplate(
phone?: string; phone?: string;
date?: string; date?: string;
company?: string; company?: string;
/** نام سازمان فرستنده */
from?: string;
/** مانده مالی قالب‌بندی‌شده */
rialBalance?: string;
/** مانده‌های وزنی/فلزی قالب‌بندی‌شده */
metalBalance?: string;
}, },
): string { ): string {
const map: Record<string, string> = { const from = vars.from ?? '';
title: vars.title ?? '', const rial = vars.rialBalance ?? '';
عنوان: vars.title ?? '', const metal = vars.metalBalance ?? '';
نام: vars.name ?? '', const entries: Array<[string, string]> = [
name: vars.name ?? '', ['title', vars.title ?? ''],
شماره: vars.phone ?? '', ['عنوان', vars.title ?? ''],
phone: vars.phone ?? '', ['نام', vars.name ?? ''],
تاریخ: vars.date ?? '', ['name', vars.name ?? ''],
date: vars.date ?? '', ['شماره', vars.phone ?? ''],
شرکت: vars.company ?? '', ['phone', vars.phone ?? ''],
company: vars.company ?? '', ['تاریخ', vars.date ?? ''],
}; ['date', vars.date ?? ''],
['شرکت', vars.company ?? ''],
['company', vars.company ?? ''],
['از طرف', from],
['ازطرف', from],
['from', from],
['مانده ریالی', rial],
['ماندهریالی', rial],
['rialBalance', rial],
['مانده فلزی', metal],
['ماندهفلزی', metal],
['metalBalance', metal],
];
const map: Record<string, string> = {};
for (const [key, value] of entries) {
map[normalizeTemplateKey(key)] = value;
}
return text.replace(/\{([^}]+)\}/g, (full, key: string) => { return text.replace(/\{([^}]+)\}/g, (full, key: string) => {
const next = map[key.trim()]; const next = map[normalizeTemplateKey(key)];
return next === undefined ? full : next; return next === undefined ? full : next;
}); });
} }
@@ -356,6 +356,10 @@ export class MessageAutomationsService {
}) })
: null; : null;
const lead = await this.lookupLead(job); const lead = await this.lookupLead(job);
const org = await this.prisma.organization.findUnique({
where: { id: job.organizationId },
select: { name: true },
});
const personName = person const personName = person
? [person.firstName, person.lastName].filter(Boolean).join(' ').trim() ? [person.firstName, person.lastName].filter(Boolean).join(' ').trim()
: ''; : '';
@@ -366,6 +370,7 @@ export class MessageAutomationsService {
phone, phone,
date: formatJalaliDayFa(new Date()), date: formatJalaliDayFa(new Date()),
company: person?.company?.name ?? '', company: person?.company?.name ?? '',
from: org?.name?.trim() || '',
}; };
const title = applyMessageTemplate(job.rule.title, vars).slice(0, 200); const title = applyMessageTemplate(job.rule.title, vars).slice(0, 200);
const body = applyMessageTemplate(job.rule.body, vars); const body = applyMessageTemplate(job.rule.body, vars);
+21
View File
@@ -2,6 +2,7 @@ import { Type } from 'class-transformer';
import { import {
ArrayUnique, ArrayUnique,
IsArray, IsArray,
IsBoolean,
IsIn, IsIn,
IsObject, IsObject,
IsOptional, IsOptional,
@@ -45,6 +46,16 @@ export class CreateOrgRoleDto {
@ValidateIf((_, v) => v !== null) @ValidateIf((_, v) => v !== null)
@IsString() @IsString()
parentId?: string | null; parentId?: string | null;
@IsOptional()
@IsArray()
@ArrayUnique()
@IsString({ each: true })
treasuryAccountIds?: string[];
@IsOptional()
@IsBoolean()
treasuryOwnAccounts?: boolean;
} }
export class UpdateOrgRoleDto { export class UpdateOrgRoleDto {
@@ -77,6 +88,16 @@ export class UpdateOrgRoleDto {
@ValidateIf((_, v) => v !== null) @ValidateIf((_, v) => v !== null)
@IsString() @IsString()
parentId?: string | null; parentId?: string | null;
@IsOptional()
@IsArray()
@ArrayUnique()
@IsString({ each: true })
treasuryAccountIds?: string[];
@IsOptional()
@IsBoolean()
treasuryOwnAccounts?: boolean;
} }
export class RolePermissionPatchItemDto { export class RolePermissionPatchItemDto {
@@ -32,6 +32,8 @@ type RoleRow = {
color: string; color: string;
permissions: string[]; permissions: string[];
dataAccessPolicy?: unknown; dataAccessPolicy?: unknown;
treasuryAccountIds?: string[];
treasuryOwnAccounts?: boolean;
systemKey: string | null; systemKey: string | null;
parentId: string | null; parentId: string | null;
createdAt: Date; createdAt: Date;
@@ -79,6 +81,8 @@ export class OrgRolesService {
permissions?: RolePermissionKey[]; permissions?: RolePermissionKey[];
dataAccessPolicy?: DataAccessPolicy; dataAccessPolicy?: DataAccessPolicy;
parentId?: string | null; parentId?: string | null;
treasuryAccountIds?: string[];
treasuryOwnAccounts?: boolean;
}, },
): Promise<OrgRoleDto> { ): Promise<OrgRoleDto> {
this.assertManager(actorRole); this.assertManager(actorRole);
@@ -108,6 +112,8 @@ export class OrgRolesService {
description: (dto.description ?? '').trim(), description: (dto.description ?? '').trim(),
color: dto.color ?? parent?.color ?? 'teal', color: dto.color ?? parent?.color ?? 'teal',
permissions, permissions,
treasuryAccountIds: dto.treasuryAccountIds ?? [],
treasuryOwnAccounts: dto.treasuryOwnAccounts ?? false,
parentId, parentId,
...(dto.dataAccessPolicy ...(dto.dataAccessPolicy
? { dataAccessPolicy: parseDataAccessPolicy(dto.dataAccessPolicy) } ? { dataAccessPolicy: parseDataAccessPolicy(dto.dataAccessPolicy) }
@@ -135,6 +141,8 @@ export class OrgRolesService {
permissions?: RolePermissionKey[]; permissions?: RolePermissionKey[];
dataAccessPolicy?: DataAccessPolicy; dataAccessPolicy?: DataAccessPolicy;
parentId?: string | null; parentId?: string | null;
treasuryAccountIds?: string[];
treasuryOwnAccounts?: boolean;
}, },
): Promise<OrgRoleDto> { ): Promise<OrgRoleDto> {
this.assertManager(actorRole); this.assertManager(actorRole);
@@ -159,6 +167,12 @@ export class OrgRolesService {
if (dto.dataAccessPolicy !== undefined) { if (dto.dataAccessPolicy !== undefined) {
data.dataAccessPolicy = parseDataAccessPolicy(dto.dataAccessPolicy); data.dataAccessPolicy = parseDataAccessPolicy(dto.dataAccessPolicy);
} }
if (dto.treasuryAccountIds !== undefined) {
data.treasuryAccountIds = [...new Set(dto.treasuryAccountIds.map((id) => id.trim()).filter(Boolean))];
}
if (dto.treasuryOwnAccounts !== undefined) {
data.treasuryOwnAccounts = dto.treasuryOwnAccounts;
}
if (dto.parentId !== undefined) { if (dto.parentId !== undefined) {
const nextParentId = const nextParentId =
@@ -426,6 +440,8 @@ export class OrgRolesService {
color: row.color, color: row.color,
permissions: sanitizePermissions(row.permissions), permissions: sanitizePermissions(row.permissions),
dataAccessPolicy: parseDataAccessPolicy(row.dataAccessPolicy), dataAccessPolicy: parseDataAccessPolicy(row.dataAccessPolicy),
treasuryAccountIds: row.treasuryAccountIds ?? [],
treasuryOwnAccounts: row.treasuryOwnAccounts ?? false,
systemKey, systemKey,
parentId: row.parentId, parentId: row.parentId,
parentName: row.parentId ? (nameById.get(row.parentId) ?? null) : null, parentName: row.parentId ? (nameById.get(row.parentId) ?? null) : null,
@@ -1,9 +1,12 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { OutboundSmsLogService } from './outbound-sms-log.service'; import { OutboundSmsLogService } from './outbound-sms-log.service';
import { SmsTemplatesController } from './sms-templates.controller';
import { SmsTemplatesService } from './sms-templates.service';
@Global() @Global()
@Module({ @Module({
providers: [OutboundSmsLogService], controllers: [SmsTemplatesController],
exports: [OutboundSmsLogService], providers: [OutboundSmsLogService, SmsTemplatesService],
exports: [OutboundSmsLogService, SmsTemplatesService],
}) })
export class OutboundSmsModule {} export class OutboundSmsModule {}
@@ -0,0 +1,48 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import {
CreateSmsTemplateDto,
UpdateSmsTemplateDto,
} from './sms-templates.dto';
import { SmsTemplatesService } from './sms-templates.service';
@Controller('sms-templates')
@UseGuards(JwtAuthGuard)
export class SmsTemplatesController {
constructor(private readonly templates: SmsTemplatesService) {}
@Get()
list(@CurrentUser() user: Actor) {
return this.templates.list(user.organizationId);
}
@Post()
create(@CurrentUser() user: Actor, @Body() dto: CreateSmsTemplateDto) {
return this.templates.create(user.organizationId, dto);
}
@Patch(':id')
update(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateSmsTemplateDto,
) {
return this.templates.update(user.organizationId, id, dto);
}
@Delete(':id')
remove(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.templates.remove(user.organizationId, id);
}
}
@@ -0,0 +1,28 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import { SMS_TEMPLATE_BODY_MAX, SMS_TEMPLATE_NAME_MAX } from '@mento/shared';
export class CreateSmsTemplateDto {
@IsString()
@MinLength(1)
@MaxLength(SMS_TEMPLATE_NAME_MAX)
name!: string;
@IsString()
@MinLength(2)
@MaxLength(SMS_TEMPLATE_BODY_MAX)
body!: string;
}
export class UpdateSmsTemplateDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(SMS_TEMPLATE_NAME_MAX)
name?: string;
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(SMS_TEMPLATE_BODY_MAX)
body?: string;
}
@@ -0,0 +1,92 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { randomUUID } from 'crypto';
import type { Prisma } from '@prisma/client';
import {
parseSmsTemplates,
SMS_TEMPLATE_MAX,
type SmsTemplateDto,
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import type {
CreateSmsTemplateDto,
UpdateSmsTemplateDto,
} from './sms-templates.dto';
@Injectable()
export class SmsTemplatesService {
constructor(private readonly prisma: PrismaService) {}
async list(organizationId: string): Promise<SmsTemplateDto[]> {
const row = await this.prisma.organization.findUnique({
where: { id: organizationId },
select: { smsTemplates: true },
});
return parseSmsTemplates(row?.smsTemplates);
}
async create(
organizationId: string,
dto: CreateSmsTemplateDto,
): Promise<SmsTemplateDto[]> {
const current = await this.list(organizationId);
if (current.length >= SMS_TEMPLATE_MAX) {
throw new BadRequestException(
`حداکثر ${SMS_TEMPLATE_MAX} پیامک آماده می‌توانید داشته باشید.`,
);
}
const next: SmsTemplateDto[] = [
...current,
{
id: randomUUID(),
name: dto.name.trim(),
body: dto.body.trim(),
},
];
return this.persist(organizationId, next);
}
async update(
organizationId: string,
id: string,
dto: UpdateSmsTemplateDto,
): Promise<SmsTemplateDto[]> {
const current = await this.list(organizationId);
const idx = current.findIndex((item) => item.id === id);
if (idx < 0) throw new NotFoundException('پیامک آماده پیدا نشد.');
const prev = current[idx]!;
const next = [...current];
next[idx] = {
id: prev.id,
name: dto.name !== undefined ? dto.name.trim() : prev.name,
body: dto.body !== undefined ? dto.body.trim() : prev.body,
};
return this.persist(organizationId, next);
}
async remove(organizationId: string, id: string): Promise<SmsTemplateDto[]> {
const current = await this.list(organizationId);
if (!current.some((item) => item.id === id)) {
throw new NotFoundException('پیامک آماده پیدا نشد.');
}
return this.persist(
organizationId,
current.filter((item) => item.id !== id),
);
}
private async persist(
organizationId: string,
items: SmsTemplateDto[],
): Promise<SmsTemplateDto[]> {
const stored = parseSmsTemplates(items);
await this.prisma.organization.update({
where: { id: organizationId },
data: { smsTemplates: stored as unknown as Prisma.InputJsonValue },
});
return stored;
}
}
@@ -14,7 +14,7 @@ import type { Actor } from '../common/record-scope';
import { dealAccessWhere, resolveOwnerId } from '../common/record-scope'; import { dealAccessWhere, resolveOwnerId } from '../common/record-scope';
import { dealPriorWhere } from '../common/prior-archive'; import { dealPriorWhere } from '../common/prior-archive';
import { asCustomFields, toJsonCustomFields } from '../common/custom-fields'; import { asCustomFields, toJsonCustomFields } from '../common/custom-fields';
import { normalizePhone } from '../contacts/normalize'; import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
import { import {
excelFieldsFromColumns, excelFieldsFromColumns,
formatJalaliDate, formatJalaliDate,
@@ -708,9 +708,9 @@ export class DealsExcelService {
): Promise<string | null> { ): Promise<string | null> {
const orgId = actor.organizationId; const orgId = actor.organizationId;
const code = cleanCode(row.personCode); const code = cleanCode(row.personCode);
const mobile = normalizePhone(row.personMobile); const mobile = storedPhoneNumber(row.personMobile);
const mobileCore = mobile ? mobile.replace(/^\+98|^98|^0/, '') : ''; const mobileKeys = phoneIdentityKeys(mobile);
const validMobile = mobileCore.length >= 8 ? mobile : null; const validMobile = (mobile ?? '').replace(/\D/g, '').length >= 8 ? mobile : null;
const first = (row.personFirstName ?? '').trim(); const first = (row.personFirstName ?? '').trim();
const last = (row.personName ?? '').trim(); const last = (row.personName ?? '').trim();
const fullName = [first, last].filter(Boolean).join(' '); const fullName = [first, last].filter(Boolean).join(' ');
@@ -731,7 +731,10 @@ export class DealsExcelService {
} }
if (!found && validMobile) { if (!found && validMobile) {
found = await this.prisma.person.findFirst({ found = await this.prisma.person.findFirst({
where: { organizationId: orgId, mobile: { contains: mobileCore } }, where: {
organizationId: orgId,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
select: { id: true }, select: { id: true },
}); });
} }
+23 -1
View File
@@ -27,6 +27,7 @@ import {
resolveOwnerId, resolveOwnerId,
} from '../common/record-scope'; } from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access'; import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { placeId, sameIds, writePositions } from '../common/column-order';
const dealInclude = { const dealInclude = {
person: { select: { id: true, firstName: true, lastName: true } }, person: { select: { id: true, firstName: true, lastName: true } },
@@ -379,7 +380,8 @@ export class DealsService {
} }
const status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open'; const status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open';
const position = let placedIds: string[] | null = null;
let position =
dto.position ?? dto.position ??
(( ((
await this.prisma.deal.aggregate({ await this.prisma.deal.aggregate({
@@ -387,6 +389,17 @@ export class DealsService {
_max: { position: true }, _max: { position: true },
}) })
)._max.position ?? -1) + 1; )._max.position ?? -1) + 1;
if (dto.position != null) {
const rows = await this.prisma.deal.findMany({
where: { organizationId, stageId: stage.id },
select: { id: true },
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
});
const currentIds = rows.map((row) => row.id);
placedIds = placeId(currentIds, deal.id, dto.position);
position = Math.max(0, placedIds.indexOf(deal.id));
if (sameIds(currentIds, placedIds)) placedIds = null;
}
const row = await this.prisma.deal.update({ const row = await this.prisma.deal.update({
where: { id }, where: { id },
@@ -402,6 +415,15 @@ export class DealsService {
include: dealInclude, include: dealInclude,
}); });
if (placedIds) {
await writePositions(
(ops) => this.prisma.$transaction(ops),
placedIds,
(dealId, nextPosition) =>
this.prisma.deal.update({ where: { id: dealId }, data: { position: nextPosition } }),
);
}
if (deal.ownerId && deal.stageId !== stage.id) { if (deal.ownerId && deal.stageId !== stage.id) {
await this.notifications.create({ await this.notifications.create({
organizationId, organizationId,
@@ -31,8 +31,11 @@ export class PipelinesController {
constructor(private readonly pipelinesService: PipelinesService) {} constructor(private readonly pipelinesService: PipelinesService) {}
@Get() @Get()
list(@CurrentUser() user: { organizationId: string }) { list(
return this.pipelinesService.list(user.organizationId); @CurrentUser() user: Actor,
@Query('forLens') forLens?: string,
) {
return this.pipelinesService.list(user, forLens === '1');
} }
@Get('board') @Get('board')
+154 -9
View File
@@ -4,6 +4,10 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { import type {
ActivityStatus,
ActivityType,
DealActivityCue,
DealActivitySummaryDto,
DealDto, DealDto,
MergePipelineResultDto, MergePipelineResultDto,
PipelineBoardDto, PipelineBoardDto,
@@ -102,24 +106,55 @@ export class PipelinesService {
return this.toPipelineDto(created); return this.toPipelineDto(created);
} }
async list(organizationId: string): Promise<PipelineDto[]> { async list(actorOrOrgId: Actor | string, forLens = false): Promise<PipelineDto[]> {
const organizationId =
typeof actorOrOrgId === 'string' ? actorOrOrgId : actorOrOrgId.organizationId;
const actor = typeof actorOrOrgId === 'string' ? undefined : actorOrOrgId;
await this.ensureDefaultPipeline(organizationId); await this.ensureDefaultPipeline(organizationId);
const rows = await this.prisma.pipeline.findMany({ const rows = await this.prisma.pipeline.findMany({
where: { organizationId }, where: { organizationId },
include: { stages: { orderBy: { position: 'asc' } } }, include: { stages: { orderBy: { position: 'asc' } } },
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }], orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
}); });
const counts = await this.prisma.deal.groupBy({ const lens = actor?.priorLens;
by: ['pipelineId'], const lensActive = forLens && (lens === 'current' || lens === 'archive');
where: { organizationId }, const access = actor ? dealAccessWhere(actor) : {};
_count: { _all: true }, const prior = lensActive
}); ? dealPriorWhere(actor?.priorLens, actor?.priorCutoff)
: {};
const [counts, lensCounts] = await Promise.all([
this.prisma.deal.groupBy({
by: ['pipelineId'],
where: lensActive ? { organizationId, AND: [access] } : { organizationId },
_count: { _all: true },
}),
lensActive
? this.prisma.deal.groupBy({
by: ['pipelineId'],
where: { organizationId, AND: [access, prior] },
_count: { _all: true },
})
: Promise.resolve([]),
]);
const dealCountByPipeline = new Map( const dealCountByPipeline = new Map(
counts.map((row) => [row.pipelineId, row._count._all]), counts.map((row) => [row.pipelineId, row._count._all]),
); );
return rows.map((row) => ({ const lensCountByPipeline = new Map(
lensCounts.map((row) => [row.pipelineId, row._count._all]),
);
const visible = lensActive
? rows.filter((row) => {
const inLens = lensCountByPipeline.get(row.id) ?? 0;
const total = dealCountByPipeline.get(row.id) ?? 0;
if (lens === 'archive') return inLens > 0;
return inLens > 0 || total === 0;
})
: rows;
return visible.map((row) => ({
...this.toPipelineDto(row), ...this.toPipelineDto(row),
dealCount: dealCountByPipeline.get(row.id) ?? 0, dealCount: lensActive
? (lensCountByPipeline.get(row.id) ?? 0)
: (dealCountByPipeline.get(row.id) ?? 0),
})); }));
} }
@@ -343,12 +378,23 @@ export class PipelinesService {
}); });
const rateMap = await loadOrgRates(this.prisma, organizationId); const rateMap = await loadOrgRates(this.prisma, organizationId);
const activityHints = await this.loadDealActivityHints(
organizationId,
deals.map((deal) => deal.id),
);
const byStage = new Map<string, DealDto[]>(); const byStage = new Map<string, DealDto[]>();
for (const stage of resolved.stages) byStage.set(stage.id, []); for (const stage of resolved.stages) byStage.set(stage.id, []);
for (const deal of deals) { for (const deal of deals) {
const list = byStage.get(deal.stageId); const list = byStage.get(deal.stageId);
if (list) list.push(this.toDealDto(deal)); if (list) {
const hint = activityHints.get(deal.id);
list.push({
...this.toDealDto(deal),
activityCue: hint?.cue ?? 'none',
activitySummaries: hint?.summaries ?? [],
});
}
} }
return { return {
@@ -566,6 +612,105 @@ export class PipelinesService {
}; };
} }
private async loadDealActivityHints(
organizationId: string,
dealIds: string[],
): Promise<
Map<string, { cue: DealActivityCue; summaries: DealActivitySummaryDto[] }>
> {
const hints = new Map<
string,
{ cue: DealActivityCue; summaries: DealActivitySummaryDto[] }
>();
if (!dealIds.length) return hints;
const rows = await this.prisma.activity.findMany({
where: {
organizationId,
dealId: { in: dealIds },
status: { in: ['open', 'done'] },
},
select: {
id: true,
dealId: true,
title: true,
type: true,
status: true,
dueAt: true,
completedAt: true,
},
});
type Row = {
id: string;
title: string;
type: string;
status: 'open' | 'done';
dueAt: Date | null;
completedAt: Date | null;
};
const byDeal = new Map<string, Row[]>();
for (const row of rows) {
if (!row.dealId) continue;
const list = byDeal.get(row.dealId) ?? [];
list.push({
id: row.id,
title: row.title,
type: row.type,
status: row.status === 'done' ? 'done' : 'open',
dueAt: row.dueAt,
completedAt: row.completedAt,
});
byDeal.set(row.dealId, list);
}
const now = Date.now();
const cueOf = (item: Row): Exclude<DealActivityCue, 'none'> => {
if (item.status === 'done') return 'done';
if (item.dueAt && item.dueAt.getTime() < now) return 'overdue';
return 'upcoming';
};
const rank = (cue: Exclude<DealActivityCue, 'none'>) =>
cue === 'overdue' ? 0 : cue === 'upcoming' ? 1 : 2;
for (const dealId of dealIds) {
const list = byDeal.get(dealId) ?? [];
const summaries = list
.map((item) => {
const cue = cueOf(item);
return {
id: item.id,
title: item.title,
type: item.type as ActivityType,
status: item.status as ActivityStatus,
dueAt: item.dueAt?.toISOString() ?? null,
cue,
_due: item.dueAt?.getTime() ?? Number.POSITIVE_INFINITY,
_done: item.completedAt?.getTime() ?? 0,
};
})
.sort((a, b) => {
const byRank = rank(a.cue) - rank(b.cue);
if (byRank) return byRank;
if (a.cue === 'done' && b.cue === 'done') return b._done - a._done;
return a._due - b._due;
})
.map(({ _due: _d, _done: _c, ...summary }) => summary);
const open = list.filter((item) => item.status === 'open');
let cue: DealActivityCue = 'none';
if (open.some((item) => item.dueAt && item.dueAt.getTime() < now)) {
cue = 'overdue';
} else if (open.length) {
cue = 'upcoming';
} else if (list.some((item) => item.status === 'done')) {
cue = 'done';
}
hints.set(dealId, { cue, summaries });
}
return hints;
}
toDealDto(row: DealRow): DealDto { toDealDto(row: DealRow): DealDto {
return { return {
id: row.id, id: row.id,
+6 -1
View File
@@ -13,7 +13,7 @@ import {
MinLength, MinLength,
ValidateIf, ValidateIf,
} from 'class-validator'; } from 'class-validator';
import { USER_ROLES, type UserRole } from '@mento/shared'; import { PRODUCT_PLAN_CODES, USER_ROLES, type ProductPlanCode, type UserRole } from '@mento/shared';
export class PlatformLoginDto { export class PlatformLoginDto {
@IsString() @IsString()
@@ -189,6 +189,11 @@ export class SetStorageQuotaDto {
quotaGb!: number | null; quotaGb!: number | null;
} }
export class ApplyProductPlanDto {
@IsIn(PRODUCT_PLAN_CODES)
planCode!: ProductPlanCode;
}
export class UpdateOrgModulesDto { export class UpdateOrgModulesDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
@@ -13,6 +13,7 @@ import {
import { CurrentUser } from '../auth/current-user.decorator'; import { CurrentUser } from '../auth/current-user.decorator';
import { import {
ApplyBillingPlanDto, ApplyBillingPlanDto,
ApplyProductPlanDto,
SetStorageQuotaDto, SetStorageQuotaDto,
SetUserCapDto, SetUserCapDto,
CreatePlatformAdminDto, CreatePlatformAdminDto,
@@ -168,6 +169,11 @@ export class PlatformController {
return this.platform.setUserCap(id, dto.maxUsers); return this.platform.setUserCap(id, dto.maxUsers);
} }
@Post('organizations/:id/product-plan')
applyProductPlan(@Param('id') id: string, @Body() dto: ApplyProductPlanDto) {
return this.platform.applyProductPlan(id, dto.planCode);
}
@Patch('organizations/:id/modules') @Patch('organizations/:id/modules')
updateOrganizationModules( updateOrganizationModules(
@Param('id') id: string, @Param('id') id: string,
+18 -1
View File
@@ -9,6 +9,7 @@ import type {
PlatformOrganizationDto, PlatformOrganizationDto,
PlatformStatsDto, PlatformStatsDto,
PlatformUserDto, PlatformUserDto,
ProductPlanCode,
UserRole, UserRole,
} from '@mento/shared'; } from '@mento/shared';
import { isBusinessField } from '@mento/shared'; import { isBusinessField } from '@mento/shared';
@@ -472,6 +473,16 @@ export class PlatformService {
return this.getUser(userId); return this.getUser(userId);
} }
async applyProductPlan(id: string, planCode: ProductPlanCode) {
const existing = await this.prisma.organization.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('محیط پیدا نشد');
await this.billing.applyProductPlan(id, planCode);
return this.getOrganization(id);
}
async updateOrganizationModules(id: string, dto: UpdateOrgModulesDto) { async updateOrganizationModules(id: string, dto: UpdateOrgModulesDto) {
const existing = await this.prisma.organization.findUnique({ const existing = await this.prisma.organization.findUnique({
where: { id }, where: { id },
@@ -532,6 +543,9 @@ export class PlatformService {
}); });
if (!row) throw new NotFoundException('عضویت پیدا نشد'); if (!row) throw new NotFoundException('عضویت پیدا نشد');
if (isActive && !row.isActive) { if (isActive && !row.isActive) {
if (row.role === 'owner') {
await this.memberships.assertSoleOwner(row.organizationId, row.userId);
}
await this.billing.assertSeatAvailable(row.organizationId, row.userId); await this.billing.assertSeatAvailable(row.organizationId, row.userId);
} }
if (!isActive && row.role === 'owner') { if (!isActive && row.role === 'owner') {
@@ -562,6 +576,9 @@ export class PlatformService {
}); });
if (!row) throw new NotFoundException('عضویت پیدا نشد'); if (!row) throw new NotFoundException('عضویت پیدا نشد');
if (role === 'owner' && row.role !== 'owner') {
await this.memberships.assertSoleOwner(row.organizationId, row.userId);
}
if (row.role === 'owner' && role !== 'owner') { if (row.role === 'owner' && role !== 'owner') {
const owners = await this.memberships.countActiveOwners( const owners = await this.memberships.countActiveOwners(
row.organizationId, row.organizationId,
@@ -569,7 +586,7 @@ export class PlatformService {
); );
if (owners < 1) { if (owners < 1) {
throw new BadRequestException( throw new BadRequestException(
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید', 'مالک محیط همان کسی است که ثبت‌نام کرده و نمی‌توان نقش او را عوض کرد',
); );
} }
} }
+13 -6
View File
@@ -19,6 +19,7 @@ import type {
} from '@mento/shared'; } from '@mento/shared';
import { TICKET_SLA_HOURS } from '@mento/shared'; import { TICKET_SLA_HOURS } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { canonicalIranMobile, phoneIdentityKeys } from '../contacts/normalize';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service'; import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import type { PortalActor } from './portal-auth'; import type { PortalActor } from './portal-auth';
@@ -62,7 +63,7 @@ export class PortalService {
mobileRaw: string, mobileRaw: string,
): Promise<PortalRequestOtpResponse> { ): Promise<PortalRequestOtpResponse> {
const org = await this.findOrg(slug); const org = await this.findOrg(slug);
const mobile = normalizeMobile(mobileRaw); const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw);
if (!mobile) { if (!mobile) {
throw new BadRequestException('شماره موبایل معتبر نیست'); throw new BadRequestException('شماره موبایل معتبر نیست');
} }
@@ -123,7 +124,8 @@ export class PortalService {
codeRaw: string, codeRaw: string,
): Promise<PortalLoginResponse> { ): Promise<PortalLoginResponse> {
const org = await this.findOrg(slug); const org = await this.findOrg(slug);
const mobile = normalizeMobile(mobileRaw); const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw);
const mobileKeys = phoneIdentityKeys(mobile);
const code = codeRaw.trim(); const code = codeRaw.trim();
if (!mobile || !code) { if (!mobile || !code) {
throw new BadRequestException('موبایل یا کد ناقص است'); throw new BadRequestException('موبایل یا کد ناقص است');
@@ -148,10 +150,15 @@ export class PortalService {
data: { consumedAt: new Date() }, data: { consumedAt: new Date() },
}); });
let person = await this.prisma.person.findFirst({ let person = mobileKeys.length
where: { organizationId: org.id, mobile }, ? await this.prisma.person.findFirst({
orderBy: { createdAt: 'asc' }, where: {
}); organizationId: org.id,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
orderBy: { createdAt: 'asc' },
})
: null;
if (!person) { if (!person) {
person = await this.prisma.person.create({ person = await this.prisma.person.create({
data: { data: {
@@ -3,7 +3,7 @@ import type { 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 { projectCardAccessWhere } from '../common/record-scope'; import { projectCardAccessWhere } from '../common/record-scope';
import { normalizePhone } from '../contacts/normalize'; import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
import { import {
excelFieldsFromColumns, excelFieldsFromColumns,
formatJalaliDate, formatJalaliDate,
@@ -256,11 +256,13 @@ export class ProjectCardsExcelService {
} }
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 needle = mobile.replace(/^\+98|^98|^0/, '');
const byMobile = await this.prisma.person.findFirst({ const byMobile = await this.prisma.person.findFirst({
where: { organizationId, mobile: { contains: needle } }, 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;
@@ -22,6 +22,7 @@ export class ApiKeyAuthGuard implements CanActivate {
organizationId: string; organizationId: string;
role: string; role: string;
apiKeyId?: string; apiKeyId?: string;
apiKeyName?: string;
}; };
}>(); }>();
@@ -44,6 +45,7 @@ export class ApiKeyAuthGuard implements CanActivate {
organizationId: auth.organizationId, organizationId: auth.organizationId,
role: auth.role, role: auth.role,
apiKeyId: auth.apiKeyId, apiKeyId: auth.apiKeyId,
apiKeyName: auth.name,
}; };
return true; return true;
} }
+3 -2
View File
@@ -63,7 +63,7 @@ export class ApiKeysService {
async authenticate( async authenticate(
rawKey: string, rawKey: string,
): Promise<{ organizationId: string; apiKeyId: string; role: 'owner' }> { ): Promise<{ organizationId: string; apiKeyId: string; name: string; role: 'owner' }> {
const key = rawKey.trim(); const key = rawKey.trim();
if (!key.startsWith('mk_') || key.length < 20) { if (!key.startsWith('mk_') || key.length < 20) {
throw new UnauthorizedException('کلید API نامعتبر است'); throw new UnauthorizedException('کلید API نامعتبر است');
@@ -71,7 +71,7 @@ export class ApiKeysService {
const keyHash = this.hashKey(key); const keyHash = this.hashKey(key);
const row = await this.prisma.organizationApiKey.findFirst({ const row = await this.prisma.organizationApiKey.findFirst({
where: { keyHash, revokedAt: null }, where: { keyHash, revokedAt: null },
select: { id: true, organizationId: true }, select: { id: true, organizationId: true, name: true },
}); });
if (!row) throw new UnauthorizedException('کلید API نامعتبر است'); if (!row) throw new UnauthorizedException('کلید API نامعتبر است');
@@ -83,6 +83,7 @@ export class ApiKeysService {
return { return {
organizationId: row.organizationId, organizationId: row.organizationId,
apiKeyId: row.id, apiKeyId: row.id,
name: row.name,
role: 'owner', role: 'owner',
}; };
} }
@@ -1,18 +1,55 @@
import { import {
Body,
Controller, Controller,
Get, Get,
HttpCode,
HttpException,
HttpStatus,
Param, Param,
Post,
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; import { IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { CurrentUser } from '../auth/current-user.decorator'; import { CurrentUser } from '../auth/current-user.decorator';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { buildSearchWhere, ci } from '../common/search'; import { buildSearchWhere, ci } from '../common/search';
import { LeadsService } from '../leads/leads.service';
import { ApiKeyAuthGuard } from './api-key-auth.guard'; import { ApiKeyAuthGuard } from './api-key-auth.guard';
class CreateExternalLeadDto {
@IsOptional()
@IsString()
@MaxLength(80)
firstName?: string;
@IsOptional()
@IsString()
@MaxLength(80)
lastName?: string;
@IsString()
@MinLength(8)
@MaxLength(20)
mobile!: string;
@IsOptional()
@IsString()
@MaxLength(200)
storeName?: string;
@IsOptional()
@IsString()
@MaxLength(200)
source?: string;
}
const LEAD_RATE_WINDOW_MS = 60_000;
const LEAD_RATE_MAX = 60;
const leadHits = new Map<string, number[]>();
class ListQueryDto { class ListQueryDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -35,7 +72,36 @@ class ListQueryDto {
@Controller('public/v1') @Controller('public/v1')
@UseGuards(ApiKeyAuthGuard) @UseGuards(ApiKeyAuthGuard)
export class PublicApiV1Controller { export class PublicApiV1Controller {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly leads: LeadsService,
) {}
@Post('leads')
@HttpCode(200)
createLead(
@CurrentUser()
user: { organizationId: string; apiKeyId?: string; apiKeyName?: string },
@Body() body: CreateExternalLeadDto,
) {
const bucket = user.apiKeyId || user.organizationId;
const now = Date.now();
const prev = (leadHits.get(bucket) ?? []).filter((t) => now - t < LEAD_RATE_WINDOW_MS);
if (prev.length >= LEAD_RATE_MAX) {
throw new HttpException('تعداد ارسال بیش از حد مجاز است. کمی بعد دوباره تلاش کنید.', HttpStatus.TOO_MANY_REQUESTS);
}
prev.push(now);
leadHits.set(bucket, prev);
return this.leads.createFromExternal({
organizationId: user.organizationId,
siteName: user.apiKeyName?.trim() || 'سایت بیرونی',
firstName: body.firstName,
lastName: body.lastName,
mobile: body.mobile,
storeName: body.storeName,
source: body.source,
});
}
@Get('people') @Get('people')
async listPeople( async listPeople(
+2 -1
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { BillingModule } from '../billing/billing.module'; import { BillingModule } from '../billing/billing.module';
import { LeadsModule } from '../leads/leads.module';
import { ApiKeyAuthGuard } from './api-key-auth.guard'; import { ApiKeyAuthGuard } from './api-key-auth.guard';
import { ApiKeysController } from './api-keys.controller'; import { ApiKeysController } from './api-keys.controller';
import { ApiKeysService } from './api-keys.service'; import { ApiKeysService } from './api-keys.service';
@@ -9,7 +10,7 @@ import { WebhooksController } from './webhooks.controller';
import { WebhooksService } from './webhooks.service'; import { WebhooksService } from './webhooks.service';
@Module({ @Module({
imports: [AuthModule, BillingModule], imports: [AuthModule, BillingModule, LeadsModule],
controllers: [ApiKeysController, WebhooksController, PublicApiV1Controller], controllers: [ApiKeysController, WebhooksController, PublicApiV1Controller],
providers: [ApiKeysService, ApiKeyAuthGuard, WebhooksService], providers: [ApiKeysService, ApiKeyAuthGuard, WebhooksService],
exports: [ApiKeysService, WebhooksService], exports: [ApiKeysService, WebhooksService],
+3 -2
View File
@@ -18,6 +18,7 @@ import {
} from '@mento/shared'; } 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 { customersPersonWhere } from '../common/data-access';
import { import {
type Actor, type Actor,
assigneeWhere, assigneeWhere,
@@ -54,7 +55,7 @@ export class ReportsService {
const invoiceScope = ownerWhere(actor, 'finance'); const invoiceScope = ownerWhere(actor, 'finance');
const activityScope = assigneeWhere(actor); const activityScope = assigneeWhere(actor);
const ticketScope = ticketAccessWhere(actor); const ticketScope = ticketAccessWhere(actor);
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId }; const personScope = customersPersonWhere(actor);
const durationMs = end.getTime() - start.getTime(); const durationMs = end.getTime() - start.getTime();
const prevEnd = new Date(start.getTime() - 1); const prevEnd = new Date(start.getTime() - 1);
@@ -1534,7 +1535,7 @@ export class ReportsService {
const orgId = actor.organizationId; const orgId = actor.organizationId;
const dealScope = ownerWhere(actor, 'deals'); const dealScope = ownerWhere(actor, 'deals');
const activityScope = assigneeWhere(actor); const activityScope = assigneeWhere(actor);
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId }; const personScope = customersPersonWhere(actor);
const rates = await loadOrgRates(this.prisma, orgId); const rates = await loadOrgRates(this.prisma, orgId);
const now = new Date(); const now = new Date();
const { jy, jm } = dateToJalali(now); const { jy, jm } = dateToJalali(now);
+3
View File
@@ -41,6 +41,7 @@ export type RoutineTaskRow = Prisma.RoutineTaskGetPayload<{
export const routineInclude = { export const routineInclude = {
category: true, category: true,
assignee: userSelect, assignee: userSelect,
createdBy: userSelect,
team: { select: { id: true, name: true } }, team: { select: { id: true, name: true } },
} satisfies Prisma.RoutineInclude; } satisfies Prisma.RoutineInclude;
@@ -185,6 +186,8 @@ export function toRoutineDto(
entityLink: normalizeEntityLink(row.entityLink), entityLink: normalizeEntityLink(row.entityLink),
reminderMinutesBefore: row.reminderMinutesBefore, reminderMinutesBefore: row.reminderMinutesBefore,
isActive: row.isActive, isActive: row.isActive,
createdById: row.createdById,
createdBy: row.createdBy,
recurrenceLabel: describeRecurrence(rule), recurrenceLabel: describeRecurrence(rule),
executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null, executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null,
executionTotal: execution.total, executionTotal: execution.total,
+29 -13
View File
@@ -200,8 +200,7 @@ export class RoutinesService {
async create(actor: Actor, dto: SaveRoutineDto): Promise<RoutineDto> { async create(actor: Actor, dto: SaveRoutineDto): Promise<RoutineDto> {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope); const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
const data = await this.buildData(actor, scope, dto);
const row = await this.prisma.routine.create({ const row = await this.prisma.routine.create({
data: { data: {
...data, ...data,
@@ -216,10 +215,9 @@ export class RoutinesService {
async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise<RoutineDto> { async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise<RoutineDto> {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id); const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing); this.assertCanWriteRoutine(scope, existing);
const data = await this.buildData(actor, scope, dto); const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
const row = await this.prisma.routine.update({ const row = await this.prisma.routine.update({
where: { id }, where: { id },
data: data as Prisma.RoutineUncheckedUpdateInput, data: data as Prisma.RoutineUncheckedUpdateInput,
@@ -232,9 +230,8 @@ export class RoutinesService {
async toggle(actor: Actor, id: string, isActive: boolean): Promise<RoutineDto> { async toggle(actor: Actor, id: string, isActive: boolean): Promise<RoutineDto> {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id); const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing); this.assertCanWriteRoutine(scope, existing);
const row = await this.prisma.routine.update({ const row = await this.prisma.routine.update({
where: { id }, where: { id },
data: { isActive }, data: { isActive },
@@ -251,9 +248,8 @@ export class RoutinesService {
dto: ChangeRoutineAssigneeDto, dto: ChangeRoutineAssigneeDto,
): Promise<RoutineDto> { ): Promise<RoutineDto> {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id); const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing); this.assertCanWriteRoutine(scope, existing);
const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId); const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId);
await this.cancelTodayPending(id, 'تغییر مسئول روتین'); await this.cancelTodayPending(id, 'تغییر مسئول روتین');
const row = await this.prisma.routine.update({ const row = await this.prisma.routine.update({
@@ -268,9 +264,8 @@ export class RoutinesService {
async duplicate(actor: Actor, id: string): Promise<RoutineDto> { async duplicate(actor: Actor, id: string): Promise<RoutineDto> {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope);
const src = await this.findRoutine(actor.organizationId, id); const src = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, src); this.assertCanWriteRoutine(scope, src);
const { const {
id: _id, id: _id,
createdAt: _c, createdAt: _c,
@@ -278,6 +273,7 @@ export class RoutinesService {
category: _cat, category: _cat,
assignee: _a, assignee: _a,
team: _t, team: _t,
createdBy: _cb,
deletedAt: _d, deletedAt: _d,
...rest ...rest
} = src; } = src;
@@ -295,9 +291,8 @@ export class RoutinesService {
async remove(actor: Actor, id: string) { async remove(actor: Actor, id: string) {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id); const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing); this.assertCanWriteRoutine(scope, existing);
await this.prisma.routine.update({ await this.prisma.routine.update({
where: { id }, where: { id },
data: { deletedAt: new Date(), isActive: false }, data: { deletedAt: new Date(), isActive: false },
@@ -345,6 +340,27 @@ export class RoutinesService {
return row; return row;
} }
/** عضو عادی فقط روتینی را می‌نویسد که خودش برای خودش ساخته است. */
private ownRoutineInput(actor: Actor, scope: RoutineScope, dto: SaveRoutineDto): SaveRoutineDto {
if (scope.isManager) return dto;
if (dto.teamId || (dto.assigneeId && dto.assigneeId !== actor.userId)) {
throw new ForbiddenException('فقط می‌توانید برای خودتان روتین بسازید');
}
return { ...dto, assigneeId: actor.userId, teamId: null };
}
private assertCanWriteRoutine(
scope: RoutineScope,
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
) {
if (scope.isManager) {
this.assertCanManageRoutine(scope, row);
return;
}
if (row.createdById === scope.userId && row.assigneeId === scope.userId && !row.teamId) return;
throw new ForbiddenException('فقط روتینی که برای خودتان ساخته‌اید را می‌توانید تغییر دهید');
}
private assertCanManageRoutine( private assertCanManageRoutine(
scope: RoutineScope, scope: RoutineScope,
row: { assigneeId: string | null; teamId: string | null; createdById: string | null }, row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
@@ -511,7 +511,7 @@ export class SmsCampaignsService {
const leadIds = batch const leadIds = batch
.map((r) => r.leadId) .map((r) => r.leadId)
.filter((id): id is string => Boolean(id)); .filter((id): id is string => Boolean(id));
const [people, leads] = await Promise.all([ const [people, leads, org] = await Promise.all([
personIds.length personIds.length
? this.prisma.person.findMany({ ? this.prisma.person.findMany({
where: { id: { in: personIds } }, where: { id: { in: personIds } },
@@ -535,9 +535,14 @@ export class SmsCampaignsService {
}, },
}) })
: Promise.resolve([]), : Promise.resolve([]),
this.prisma.organization.findUnique({
where: { id: campaign.organizationId },
select: { name: true },
}),
]); ]);
const personMap = new Map(people.map((p) => [p.id, p])); const personMap = new Map(people.map((p) => [p.id, p]));
const leadMap = new Map(leads.map((l) => [l.id, l])); const leadMap = new Map(leads.map((l) => [l.id, l]));
const fromOrg = org?.name?.trim() || '';
let sent = 0; let sent = 0;
let failed = 0; let failed = 0;
@@ -572,6 +577,7 @@ export class SmsCampaignsService {
phone: recipient.mobile, phone: recipient.mobile,
title, title,
company, company,
from: fromOrg,
}); });
const personalized = await this.tracking.personalizeSmsBody({ const personalized = await this.tracking.personalizeSmsBody({
+212
View File
@@ -0,0 +1,212 @@
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
const OPEN_INVOICE_STATUSES = ['issued', 'partially_paid', 'paid'] as const;
export type InvoicePartyPosition = {
key: string;
partyKind: 'person' | 'company';
partyName: string;
/** فاکتورهای عادی به‌علاوهٔ ماندهٔ اول دورهٔ مثبت */
billed: Prisma.Decimal;
/** برگشتی‌ها و اضافه‌دریافت، برای ستون تسویه‌شدهٔ بدهکار */
offset: Prisma.Decimal;
/** مثبت = طلب ما، منفی = بدهی ما */
net: Prisma.Decimal;
};
type Bucket = {
key: string;
partyKind: 'person' | 'company';
partyName: string;
sales: Prisma.Decimal;
credits: Prisma.Decimal;
paid: Prisma.Decimal;
opening: Prisma.Decimal;
};
function zero() {
return new Prisma.Decimal(0);
}
function round2(value: Prisma.Decimal) {
return value.toDecimalPlaces(2);
}
/** همان فرمول ماندهٔ فاکتور: جمع ردیف‌ها − تخفیف + مالیات، برگشتی منفی. */
export function signedInvoicePayable(invoice: {
kind: string;
discountAmount: Prisma.Decimal;
taxPercent: Prisma.Decimal;
lineItems: Array<{ quantity: Prisma.Decimal; unitPrice: Prisma.Decimal }>;
}) {
const subtotal = invoice.lineItems.reduce(
(sum, line) => sum.plus(line.quantity.mul(line.unitPrice)),
zero(),
);
const discount = invoice.discountAmount ?? zero();
const taxable = Prisma.Decimal.max(subtotal.minus(discount), 0);
const tax = taxable.mul(invoice.taxPercent ?? 0).div(100);
const signed = round2(subtotal.minus(discount).plus(tax));
return invoice.kind === 'credit' ? signed.neg() : signed;
}
export async function internalBooksEnabled(prisma: PrismaService, organizationId: string) {
const connections = await prisma.accountingConnection.count({
where: { organizationId, enabled: true },
});
return connections === 0;
}
/**
* ماندهٔ طرف‌حساب از فاکتور صادرشده و ماندهٔ اول دوره.
* سند درآمد جدا ساخته نمی‌شود تا همان فروش دو بار طلب نشود.
* اگر حسابداری بیرونی وصل باشد، خالی برمی‌گردد.
*/
export async function loadInvoicePartyPositions(
prisma: PrismaService,
organizationId: string,
): Promise<InvoicePartyPosition[]> {
if (!(await internalBooksEnabled(prisma, organizationId))) return [];
const [invoices, openings] = await Promise.all([
prisma.invoice.findMany({
where: { organizationId, status: { in: [...OPEN_INVOICE_STATUSES] } },
select: {
kind: true,
discountAmount: true,
taxPercent: true,
personId: true,
companyId: true,
person: { select: { firstName: true, lastName: true } },
company: { select: { name: true } },
lineItems: { select: { quantity: true, unitPrice: true } },
payments: { select: { amount: true } },
},
}),
prisma.partyOpeningBalance.findMany({
where: { organizationId },
select: {
personId: true,
amount: true,
person: { select: { firstName: true, lastName: true } },
},
}),
]);
const buckets = new Map<string, Bucket>();
const ensure = (
key: string,
partyKind: 'person' | 'company',
partyName: string,
) => {
const current = buckets.get(key);
if (current) return current;
const created: Bucket = {
key,
partyKind,
partyName,
sales: zero(),
credits: zero(),
paid: zero(),
opening: zero(),
};
buckets.set(key, created);
return created;
};
for (const invoice of invoices) {
const personId = invoice.personId;
const companyId = invoice.companyId;
const party = personId
? {
key: `person:${personId}`,
partyKind: 'person' as const,
partyName:
`${invoice.person?.firstName ?? ''} ${invoice.person?.lastName ?? ''}`.trim() ||
'مشتری',
}
: companyId
? {
key: `company:${companyId}`,
partyKind: 'company' as const,
partyName: invoice.company?.name?.trim() || 'شرکت',
}
: null;
if (!party) continue;
const bucket = ensure(party.key, party.partyKind, party.partyName);
const payable = signedInvoicePayable(invoice);
if (payable.greaterThan(0)) bucket.sales = bucket.sales.plus(payable);
else if (payable.lessThan(0)) bucket.credits = bucket.credits.plus(payable.abs());
const paid = invoice.payments.reduce((sum, row) => sum.plus(row.amount), zero());
bucket.paid = bucket.paid.plus(paid);
}
for (const opening of openings) {
if (opening.amount.equals(0)) continue;
const bucket = ensure(
`person:${opening.personId}`,
'person',
`${opening.person.firstName} ${opening.person.lastName}`.trim() || 'مشتری',
);
bucket.opening = bucket.opening.plus(opening.amount);
}
const positions: InvoicePartyPosition[] = [];
for (const bucket of buckets.values()) {
const net = round2(
bucket.opening.plus(bucket.sales).minus(bucket.credits).minus(bucket.paid),
);
if (net.equals(0)) continue;
const billed = round2(bucket.sales.plus(Prisma.Decimal.max(bucket.opening, 0)));
const offset = round2(billed.minus(net));
positions.push({
key: bucket.key,
partyKind: bucket.partyKind,
partyName: bucket.partyName,
billed,
offset: Prisma.Decimal.max(offset, 0),
net,
});
}
return positions;
}
/** فروش و برگشتیِ فاکتورهای صادرشده در بازه. وجه نقد را دوباره جمع نمی‌کند. */
export async function invoiceSalesInPeriod(
prisma: PrismaService,
organizationId: string,
from: Date | null,
to: Date | null,
) {
const empty = { sales: '0.00', credits: '0.00', net: '0.00' };
if (!(await internalBooksEnabled(prisma, organizationId))) return empty;
const invoices = await prisma.invoice.findMany({
where: { organizationId, status: { in: [...OPEN_INVOICE_STATUSES] } },
select: {
kind: true,
discountAmount: true,
taxPercent: true,
issuedAt: true,
createdAt: true,
lineItems: { select: { quantity: true, unitPrice: true } },
},
});
let sales = zero();
let credits = zero();
for (const invoice of invoices) {
const when = invoice.issuedAt ?? invoice.createdAt;
if (from && when < from) continue;
if (to && when > to) continue;
const payable = signedInvoicePayable(invoice);
if (payable.greaterThan(0)) sales = sales.plus(payable);
else if (payable.lessThan(0)) credits = credits.plus(payable.abs());
}
return {
sales: round2(sales).toFixed(2),
credits: round2(credits).toFixed(2),
net: round2(sales.minus(credits)).toFixed(2),
};
}
+65 -10
View File
@@ -1,29 +1,84 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import {
parseUserTreasuryAccess,
permissionsWithTreasuryOverride,
treasurySectionAllowed,
type TreasurySectionKey,
} from '@mento/shared';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { PrismaService } from '../prisma/prisma.service';
/**
* حقوق فقط با treasury_payroll.
* بقیه مسیرها با treasury باز می‌شوند؛ اگر بخش ریز انتخاب شده باشد فقط همان‌ها.
*/
@Injectable() @Injectable()
export class TreasuryAccessGuard implements CanActivate { export class TreasuryAccessGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<{ user?: Actor; originalUrl?: string; url?: string }>(); const req = context.switchToHttp().getRequest<{
user?: Actor;
method?: string;
originalUrl?: string;
url?: string;
}>();
const actor = req.user; const actor = req.user;
if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.'); if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.');
const url = req.originalUrl || req.url || '';
const payroll = url.includes('/treasury/payroll');
if (actor.role === 'owner' || actor.role === 'admin') return true; if (actor.role === 'owner' || actor.role === 'admin') return true;
const membership = await this.prisma.membership.findFirst({ const membership = await this.prisma.membership.findFirst({
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true }, where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
select: { orgRole: { select: { permissions: true } } }, select: {
orgRole: { select: { permissions: true } },
treasuryAccessOverride: true,
},
}); });
const permissions = Array.isArray(membership?.orgRole?.permissions) const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.map(String) ? membership.orgRole.permissions.map(String)
: []; : [];
const allowed = payroll const permissions = permissionsWithTreasuryOverride(
? permissions.includes('treasury_payroll') rolePermissions,
: permissions.includes('treasury') || permissions.includes('treasury_payroll'); parseUserTreasuryAccess(membership?.treasuryAccessOverride),
if (!allowed) throw new ForbiddenException('دسترسی مالی برای نقش شما مجاز نیست.'); );
const method = (req.method || 'GET').toUpperCase();
const url = req.originalUrl || req.url || '';
if (
isLookupForDocuments(method, url) &&
treasurySectionAllowed(actor.role, permissions, 'treasury_documents')
) {
return true;
}
if (!treasurySectionAllowed(actor.role, permissions, sectionForUrl(url))) {
throw new ForbiddenException('به این بخش مدیریت مالی دسترسی ندارید.');
}
return true; return true;
} }
} }
/** فهرست حساب و دسته برای ثبت سند لازم است؛ صفحهٔ مدیریت‌شان جدا می‌ماند. */
function isLookupForDocuments(method: string, url: string): boolean {
if (method !== 'GET' && method !== 'HEAD') return false;
const path = (url.split('?')[0] || '').replace(/\/+$/, '');
return path.endsWith('/treasury/accounts') || path.endsWith('/treasury/categories');
}
function sectionForUrl(url: string): TreasurySectionKey | 'treasury_documents' {
if (url.includes('/treasury/payroll')) return 'treasury_payroll';
if (url.includes('/treasury/receivables') || url.includes('/treasury/installments')) {
return 'treasury_receivables';
}
if (url.includes('/treasury/recurring')) return 'treasury_recurring';
if (url.includes('/treasury/reports')) return 'treasury_reports';
if (url.includes('/treasury/audit')) return 'treasury_audit';
if (url.includes('/treasury/categories')) return 'treasury_categories';
if (
url.includes('/treasury/accounts') ||
url.includes('/treasury/transfers') ||
url.includes('/treasury/reconciliations') ||
url.includes('/treasury/adjustments')
) {
return 'treasury_accounts';
}
if (url.includes('/treasury/dashboard')) return 'treasury_dashboard';
return 'treasury_documents';
}
+218 -7
View File
@@ -2,8 +2,10 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes
import { Cron } from '@nestjs/schedule'; import { Cron } from '@nestjs/schedule';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs'; import ExcelJS from 'exceljs';
import type { TreasuryPartyBalanceDto } 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 { invoiceSalesInPeriod } from './invoice-ledger';
function dec(value: Prisma.Decimal | number | string | null | undefined) { function dec(value: Prisma.Decimal | number | string | null | undefined) {
return new Prisma.Decimal(value ?? 0); return new Prisma.Decimal(value ?? 0);
@@ -11,11 +13,20 @@ function dec(value: Prisma.Decimal | number | string | null | undefined) {
function money(value: Prisma.Decimal | number | string) { function money(value: Prisma.Decimal | number | string) {
return new Prisma.Decimal(value).toFixed(2); return new Prisma.Decimal(value).toFixed(2);
} }
function reasonText(value?: string | null) {
const text = value?.trim() ?? '';
return text ? text.slice(0, 500) : null;
}
function parseDate(iso?: string | null) { function parseDate(iso?: string | null) {
if (!iso) return null; if (!iso) return null;
const d = new Date(iso); const d = new Date(iso);
return Number.isNaN(d.getTime()) ? null : d; return Number.isNaN(d.getTime()) ? null : d;
} }
function payrollStatus(payable: Prisma.Decimal, paid: Prisma.Decimal) {
if (paid.lessThanOrEqualTo(0)) return 'open';
if (paid.greaterThanOrEqualTo(payable)) return 'settled';
return 'partial';
}
function periodKey(date: Date) { function periodKey(date: Date) {
return date.toISOString().slice(0, 10); return date.toISOString().slice(0, 10);
} }
@@ -35,9 +46,15 @@ export class TreasuryOpsService {
async listPayroll(actor: Actor) { async listPayroll(actor: Actor) {
const rows = await this.prisma.financialPayroll.findMany({ const rows = await this.prisma.financialPayroll.findMany({
where: { organizationId: actor.organizationId }, where: { organizationId: actor.organizationId },
include: { movements: true }, include: {
movements: {
where: { voidedAt: null },
include: { account: { select: { name: true } } },
orderBy: { occurredAt: 'desc' },
},
},
orderBy: { dueDate: 'desc' }, orderBy: { dueDate: 'desc' },
take: 100, take: 500,
}); });
const users = await this.prisma.user.findMany({ const users = await this.prisma.user.findMany({
where: { id: { in: rows.map((row) => row.userId) } }, where: { id: { in: rows.map((row) => row.userId) } },
@@ -54,8 +71,11 @@ export class TreasuryOpsService {
period: string; period: string;
baseAmount: string | number; baseAmount: string | number;
benefits?: string | number; benefits?: string | number;
benefitsReason?: string | null;
bonus?: string | number; bonus?: string | number;
bonusReason?: string | null;
deductions?: string | number; deductions?: string | number;
deductionsReason?: string | null;
dueDate?: string; dueDate?: string;
notes?: string | null; notes?: string | null;
}, },
@@ -80,14 +100,23 @@ export class TreasuryOpsService {
period: input.period.trim(), period: input.period.trim(),
baseAmount: base, baseAmount: base,
benefits, benefits,
benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null,
bonus, bonus,
bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null,
deductions, deductions,
deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null,
payable, payable,
dueDate: parseDate(input.dueDate) ?? new Date(), dueDate: parseDate(input.dueDate) ?? new Date(),
notes: input.notes?.trim() || null, notes: input.notes?.trim() || null,
createdById: actor.userId, createdById: actor.userId,
}, },
include: { movements: true }, include: {
movements: {
where: { voidedAt: null },
include: { account: { select: { name: true } } },
orderBy: { occurredAt: 'desc' },
},
},
}); });
const user = await this.prisma.user.findUnique({ where: { id: row.userId }, select: { fullName: true } }); const user = await this.prisma.user.findUnique({ where: { id: row.userId }, select: { fullName: true } });
return this.payrollDto(row, user?.fullName ?? ''); return this.payrollDto(row, user?.fullName ?? '');
@@ -123,7 +152,7 @@ export class TreasuryOpsService {
if (!account) throw new BadRequestException('حساب پرداخت پیدا نشد.'); if (!account) throw new BadRequestException('حساب پرداخت پیدا نشد.');
const balance = await this.balance(account.id); const balance = await this.balance(account.id);
if (balance.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.'); if (balance.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.');
const status = paid.plus(amount).equals(row.payable) ? 'settled' : 'partial'; const status = payrollStatus(row.payable, paid.plus(amount));
await this.prisma.$transaction([ await this.prisma.$transaction([
this.prisma.financialMovement.create({ this.prisma.financialMovement.create({
data: { data: {
@@ -144,6 +173,123 @@ export class TreasuryOpsService {
return this.listPayroll(actor); return this.listPayroll(actor);
} }
async updatePayroll(
actor: Actor,
id: string,
input: {
userId: string;
period: string;
baseAmount: string | number;
benefits?: string | number;
benefitsReason?: string | null;
bonus?: string | number;
bonusReason?: string | null;
deductions?: string | number;
deductionsReason?: string | null;
},
) {
const row = await this.prisma.financialPayroll.findFirst({
where: { id, organizationId: actor.organizationId },
include: { movements: true },
});
if (!row || row.voidedAt || row.status === 'void') {
throw new NotFoundException('رکورد حقوق برای ویرایش پیدا نشد.');
}
const base = dec(input.baseAmount);
const benefits = dec(input.benefits ?? 0);
const bonus = dec(input.bonus ?? 0);
const deductions = dec(input.deductions ?? 0);
const payable = base.plus(benefits).plus(bonus).minus(deductions);
if (!input.userId || !input.period?.trim()) {
throw new BadRequestException('پرسنل و دوره حقوق لازم است.');
}
if (!payable.greaterThan(0)) throw new BadRequestException('مبلغ قابل پرداخت باید بزرگ‌تر از صفر باشد.');
const paid = this.paidOf(row.movements);
if (payable.lessThan(paid)) {
throw new BadRequestException('مبلغ قابل پرداخت از پرداخت‌های ثبت‌شده کمتر است.');
}
const member = await this.prisma.membership.findFirst({
where: { organizationId: actor.organizationId, userId: input.userId, isActive: true },
});
if (!member) throw new BadRequestException('این کاربر عضو محیط نیست.');
try {
await this.prisma.financialPayroll.update({
where: { id: row.id },
data: {
userId: input.userId,
period: input.period.trim(),
baseAmount: base,
benefits,
benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null,
bonus,
bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null,
deductions,
deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null,
payable,
status: payrollStatus(payable, paid),
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
throw new BadRequestException('برای این پرسنل در این دوره حقوق ثبت شده است.');
}
throw error;
}
return this.listPayroll(actor);
}
async updatePayrollPayment(
actor: Actor,
payrollId: string,
movementId: string,
input: { amount: string | number; accountId: string; occurredAt?: string },
) {
const movement = await this.prisma.financialMovement.findFirst({
where: {
id: movementId,
payrollId,
organizationId: actor.organizationId,
voidedAt: null,
},
});
const payroll = await this.prisma.financialPayroll.findFirst({
where: { id: payrollId, organizationId: actor.organizationId },
include: { movements: true },
});
if (!movement || !payroll || payroll.voidedAt || payroll.status === 'void') {
throw new NotFoundException('پرداخت حقوق برای ویرایش پیدا نشد.');
}
const amount = dec(input.amount);
const otherPaid = this.paidOf(payroll.movements.filter((item) => item.id !== movement.id));
if (!amount.greaterThan(0) || otherPaid.plus(amount).greaterThan(payroll.payable)) {
throw new BadRequestException('مبلغ پرداخت حقوق نامعتبر است.');
}
const account = await this.prisma.financialAccount.findFirst({
where: { id: input.accountId, organizationId: actor.organizationId },
});
if (!account || (!account.isActive && account.id !== movement.accountId)) {
throw new BadRequestException('حساب پرداخت پیدا نشد.');
}
const available = await this.balance(account.id);
const room = account.id === movement.accountId ? available.plus(movement.amount) : available;
if (room.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.');
await this.prisma.$transaction([
this.prisma.financialMovement.update({
where: { id: movement.id },
data: {
accountId: account.id,
amount,
occurredAt: parseDate(input.occurredAt) ?? movement.occurredAt,
},
}),
this.prisma.financialPayroll.update({
where: { id: payroll.id },
data: { status: payrollStatus(payroll.payable, otherPaid.plus(amount)) },
}),
]);
return this.listPayroll(actor);
}
async listRecurring(actor: Actor) { async listRecurring(actor: Actor) {
const rows = await this.prisma.financialRecurringItem.findMany({ const rows = await this.prisma.financialRecurringItem.findMany({
where: { organizationId: actor.organizationId }, where: { organizationId: actor.organizationId },
@@ -314,13 +460,20 @@ export class TreasuryOpsService {
return { from: from.toISOString(), to: to.toISOString(), current, previous, ...breakdown }; return { from: from.toISOString(), to: to.toISOString(), current, previous, ...breakdown };
} }
async reportExcel(actor: Actor, from?: string, to?: string) { async reportExcel(
actor: Actor,
from?: string,
to?: string,
parties?: { debtors: TreasuryPartyBalanceDto[]; creditors: TreasuryPartyBalanceDto[] },
) {
const data = await this.report(actor, from, to); const data = await this.report(actor, from, to);
const book = new ExcelJS.Workbook(); const book = new ExcelJS.Workbook();
const sheet = book.addWorksheet('گزارش مالی'); const sheet = book.addWorksheet('گزارش مالی');
sheet.addRow(['شاخص', 'این دوره', 'دوره قبل']); sheet.addRow(['شاخص', 'این دوره', 'دوره قبل']);
const rows: Array<[string, string, string]> = [ const rows: Array<[string, string, string]> = [
['درآمد ثبت‌شده', data.current.bookedIncome, data.previous.bookedIncome], ['درآمد ثبت‌شده', data.current.bookedIncome, data.previous.bookedIncome],
['فروش فاکتور', data.current.invoiceSales, data.previous.invoiceSales],
['فاکتور برگشتی', data.current.invoiceCredits, data.previous.invoiceCredits],
['هزینه ثبت‌شده', data.current.bookedExpense, data.previous.bookedExpense], ['هزینه ثبت‌شده', data.current.bookedExpense, data.previous.bookedExpense],
['سود عملیاتی', data.current.operatingProfit, data.previous.operatingProfit], ['سود عملیاتی', data.current.operatingProfit, data.previous.operatingProfit],
['ورود وجه', data.current.cashIn, data.previous.cashIn], ['ورود وجه', data.current.cashIn, data.previous.cashIn],
@@ -331,10 +484,41 @@ export class TreasuryOpsService {
sheet.addRow([]); sheet.addRow([]);
sheet.addRow(['دسته هزینه', 'مبلغ', 'سهم']); sheet.addRow(['دسته هزینه', 'مبلغ', 'سهم']);
data.current.expenseCategories.forEach((row) => sheet.addRow([row.name, row.amount, row.share])); data.current.expenseCategories.forEach((row) => sheet.addRow([row.name, row.amount, row.share]));
if (parties) {
this.addPartySheet(book, 'بدهکاران', parties.debtors, 'دریافت‌شده');
this.addPartySheet(book, 'بستانکاران', parties.creditors, 'پرداخت‌شده');
}
const buffer = await book.xlsx.writeBuffer(); const buffer = await book.xlsx.writeBuffer();
return Buffer.from(buffer); return Buffer.from(buffer);
} }
private addPartySheet(
book: ExcelJS.Workbook,
title: string,
rows: TreasuryPartyBalanceDto[],
settledLabel: string,
) {
const sheet = book.addWorksheet(title);
const kindLabel: Record<string, string> = { person: 'مشتری', company: 'شرکت', user: 'کاربر' };
sheet.addRow(['طرف حساب', 'نوع', 'مبلغ کل', settledLabel, 'مانده']);
let amount = new Prisma.Decimal(0);
let settled = new Prisma.Decimal(0);
let remaining = new Prisma.Decimal(0);
for (const row of rows) {
sheet.addRow([
row.partyName,
(row.partyKind && kindLabel[row.partyKind]) || 'ثبت قبلی',
row.amount,
row.settledAmount,
row.remaining,
]);
amount = amount.plus(row.amount);
settled = settled.plus(row.settledAmount);
remaining = remaining.plus(row.remaining);
}
sheet.addRow(['جمع', '', money(amount), money(settled), money(remaining)]);
}
private async period(organizationId: string, from: Date, to: Date) { private async period(organizationId: string, from: Date, to: Date) {
const docs = await this.prisma.financialDocument.findMany({ const docs = await this.prisma.financialDocument.findMany({
where: { where: {
@@ -374,10 +558,15 @@ export class TreasuryOpsService {
amount: money(amount), amount: money(amount),
share: bookedExpense.equals(0) ? '0' : amount.div(bookedExpense).mul(100).toFixed(1), share: bookedExpense.equals(0) ? '0' : amount.div(bookedExpense).mul(100).toFixed(1),
})); }));
const invoiceSales = await invoiceSalesInPeriod(this.prisma, organizationId, from, to);
return { return {
bookedIncome: money(bookedIncome), bookedIncome: money(bookedIncome),
invoiceSales: invoiceSales.sales,
invoiceCredits: invoiceSales.credits,
bookedExpense: money(bookedExpense), bookedExpense: money(bookedExpense),
operatingProfit: money(bookedIncome.minus(bookedExpense)), operatingProfit: money(
bookedIncome.plus(invoiceSales.net).minus(bookedExpense),
),
cashIn: money(cashIn), cashIn: money(cashIn),
cashOut: money(cashOut), cashOut: money(cashOut),
cashNet: money(cashIn.minus(cashOut)), cashNet: money(cashIn.minus(cashOut)),
@@ -438,12 +627,22 @@ export class TreasuryOpsService {
period: string; period: string;
baseAmount: Prisma.Decimal; baseAmount: Prisma.Decimal;
benefits: Prisma.Decimal; benefits: Prisma.Decimal;
benefitsReason?: string | null;
bonus: Prisma.Decimal; bonus: Prisma.Decimal;
bonusReason?: string | null;
deductions: Prisma.Decimal; deductions: Prisma.Decimal;
deductionsReason?: string | null;
payable: Prisma.Decimal; payable: Prisma.Decimal;
dueDate: Date; dueDate: Date;
status: string; status: string;
movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>; movements: Array<{
id: string;
amount: Prisma.Decimal;
voidedAt: Date | null;
accountId: string;
occurredAt: Date;
account?: { name: string } | null;
}>;
}, },
fullName: string, fullName: string,
) { ) {
@@ -456,13 +655,25 @@ export class TreasuryOpsService {
period: row.period, period: row.period,
baseAmount: money(row.baseAmount), baseAmount: money(row.baseAmount),
benefits: money(row.benefits), benefits: money(row.benefits),
benefitsReason: row.benefitsReason?.trim() || null,
bonus: money(row.bonus), bonus: money(row.bonus),
bonusReason: row.bonusReason?.trim() || null,
deductions: money(row.deductions), deductions: money(row.deductions),
deductionsReason: row.deductionsReason?.trim() || null,
payable: money(row.payable), payable: money(row.payable),
paidAmount: money(paid), paidAmount: money(paid),
remaining: money(left.isNegative() ? 0 : left), remaining: money(left.isNegative() ? 0 : left),
dueDate: row.dueDate.toISOString(), dueDate: row.dueDate.toISOString(),
status: row.status, status: row.status,
payments: row.movements
.filter((item) => !item.voidedAt)
.map((item) => ({
id: item.id,
amount: money(item.amount),
occurredAt: item.occurredAt.toISOString(),
accountId: item.accountId,
accountName: item.account?.name ?? '',
})),
}; };
} }
+124 -3
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer'; import { memoryStorage } from 'multer';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -43,6 +43,20 @@ export class TreasuryController {
return this.treasury.createCategory(user, body); return this.treasury.createCategory(user, body);
} }
@Patch('categories/:id')
updateCategory(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() body: { name?: string; isActive?: boolean },
) {
return this.treasury.updateCategory(user, id, body);
}
@Delete('categories/:id')
deleteCategory(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.treasury.deleteCategory(user, id);
}
@Get('accounts') @Get('accounts')
accounts( accounts(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
@@ -61,7 +75,18 @@ export class TreasuryController {
updateAccount( updateAccount(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
@Param('id') id: string, @Param('id') id: string,
@Body() body: { name?: string; notes?: string | null; isActive?: boolean; responsibleName?: string | null }, @Body()
body: {
name?: string;
type?: 'bank' | 'cash' | 'petty';
bankName?: string | null;
accountRef?: string | null;
notes?: string | null;
isActive?: boolean;
responsibleName?: string | null;
openingBalance?: string | number;
openingDate?: string;
},
) { ) {
return this.treasury.updateAccount(user, id, body); return this.treasury.updateAccount(user, id, body);
} }
@@ -81,6 +106,52 @@ export class TreasuryController {
return this.treasury.transfer(user, body); return this.treasury.transfer(user, body);
} }
@Get('parties')
parties(@CurrentUser() user: Actor, @Query('q') q?: string) {
return this.treasury.searchParties(user, q);
}
@Post('parties/person')
createPartyPerson(
@CurrentUser() user: Actor,
@Body() body: { firstName?: string; lastName?: string },
) {
return this.treasury.createPartyPerson(user, body);
}
@Post('parties/company')
createPartyCompany(@CurrentUser() user: Actor, @Body() body: { name?: string }) {
return this.treasury.createPartyCompany(user, body);
}
@Get('party-balances')
partyBalances(@CurrentUser() user: Actor, @Query('kind') kind?: string) {
return this.treasury.partyBalances(user, kind === 'expense' ? 'expense' : 'income');
}
@Post('party-balances/collect')
collectPartyBalance(
@CurrentUser() user: Actor,
@Body()
body: {
kind?: string;
partyKind?: 'person' | 'company' | 'user';
partyId?: string;
amount?: string | number;
accountId?: string;
occurredAt?: string;
},
) {
return this.treasury.collectPartyBalance(user, {
kind: body.kind === 'expense' ? 'expense' : 'income',
partyKind: body.partyKind ?? 'person',
partyId: body.partyId ?? '',
amount: body.amount ?? 0,
accountId: body.accountId ?? '',
occurredAt: body.occurredAt,
});
}
@Get('documents') @Get('documents')
documents( documents(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
@@ -110,6 +181,33 @@ export class TreasuryController {
}); });
} }
@Get('documents/:id')
getDocument(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.treasury.getDocument(user, id);
}
@Patch('documents/:id')
updateDocument(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body()
body: {
title?: string;
notes?: string | null;
categoryId?: string | null;
method?: string | null;
bookedAt?: string;
amount?: string | number;
},
) {
return this.treasury.updateDocument(user, id, body);
}
@Delete('documents/:id')
deleteDocument(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.treasury.deleteDocument(user, id);
}
@Get('attachments/:id') @Get('attachments/:id')
async downloadAttachment(@CurrentUser() user: Actor, @Param('id') id: string) { async downloadAttachment(@CurrentUser() user: Actor, @Param('id') id: string) {
const file = await this.treasury.openAttachment(user, id); const file = await this.treasury.openAttachment(user, id);
@@ -184,6 +282,25 @@ export class TreasuryController {
return this.ops.createPayroll(user, body); return this.ops.createPayroll(user, body);
} }
@Patch('payroll/:id')
updatePayroll(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() body: Parameters<TreasuryOpsService['updatePayroll']>[2],
) {
return this.ops.updatePayroll(user, id, body);
}
@Patch('payroll/:id/payments/:movementId')
updatePayrollPayment(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Param('movementId') movementId: string,
@Body() body: Parameters<TreasuryOpsService['updatePayrollPayment']>[3],
) {
return this.ops.updatePayrollPayment(user, id, movementId, body);
}
@Post('payroll/:id/pay') @Post('payroll/:id/pay')
payPayroll( payPayroll(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
@@ -221,7 +338,11 @@ export class TreasuryController {
@Query('from') from?: string, @Query('from') from?: string,
@Query('to') to?: string, @Query('to') to?: string,
) { ) {
const buffer = await this.ops.reportExcel(user, from, to); const [debtors, creditors] = await Promise.all([
this.treasury.partyBalances(user, 'income'),
this.treasury.partyBalances(user, 'expense'),
]);
const buffer = await this.ops.reportExcel(user, from, to, { debtors, creditors });
return new StreamableFile(buffer, { return new StreamableFile(buffer, {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
disposition: 'attachment; filename="treasury-report.xlsx"', disposition: 'attachment; filename="treasury-report.xlsx"',
+2 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { ContactsModule } from '../contacts/contacts.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { TreasuryController } from './treasury.controller'; import { TreasuryController } from './treasury.controller';
import { TreasuryAccessGuard } from './treasury-access.guard'; import { TreasuryAccessGuard } from './treasury-access.guard';
@@ -9,7 +10,7 @@ import { TreasuryReceivablesService } from './treasury-receivables.service';
import { TreasuryService } from './treasury.service'; import { TreasuryService } from './treasury.service';
@Module({ @Module({
imports: [AuthModule, NotificationsModule], imports: [AuthModule, NotificationsModule, ContactsModule],
controllers: [TreasuryController], controllers: [TreasuryController],
providers: [ providers: [
TreasuryService, TreasuryService,
File diff suppressed because it is too large Load Diff
+6
View File
@@ -128,4 +128,10 @@ export class UpdateUserDataAccessDto {
@IsOptional() @IsOptional()
@IsObject() @IsObject()
dataAccessOverride?: Record<string, unknown> | null; dataAccessOverride?: Record<string, unknown> | null;
/** null = ارث از نقش */
@ValidateIf((_, v) => v !== null)
@IsOptional()
@IsObject()
treasuryAccessOverride?: Record<string, unknown> | null;
} }

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