4 Commits
Author SHA1 Message Date
MahdiandCursor b11affbdfd fix(web): load IRANSans outside the Tailwind pipeline
Tailwind rewrites url() values that contain parentheses, so the FaNum font files never resolve.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 18:32:30 +03:30
Mahdi 7aaa15fad2 merge: bring latest origin/main into dev (keep local UI)
Resolved UI conflicts in favor of local Ant Design screens; kept non-UI changes from main.
2026-11-03 16:55:42 +03:30
Mahdi 39c62834e7 fix: resolve Ant switch and unused board menu type errors blocking push 2026-11-03 16:53:49 +03:30
Mahdi 26792f9a37 feat: migrate CRM screens to Ant Design Vue and expand home dashboard
Preserve the local Ant UI work across views/components and add the new home dashboard chart panels before merging latest main.
2026-11-03 16:51:51 +03:30
256 changed files with 22810 additions and 45763 deletions
+11 -3
View File
@@ -41,11 +41,19 @@ Legacy CRM UI (`components/ui/*`, `.crm-*`, `.btn` / `.field` in `styles/`) rema
6. **24-column grid only** 6. **24-column grid only**
Page layout on migrated screens uses Ant Grid: `a-row` / `a-col` with the **24-column** system (`:span`, `:xs`, `:sm`, `:md`, `:lg`, `:xl`). Do not invent parallel flex/grid CSS layouts for page chrome. Page layout on migrated screens uses Ant Grid: `a-row` / `a-col` with the **24-column** system (`:span`, `:xs`, `:sm`, `:md`, `:lg`, `:xl`). Do not invent parallel flex/grid CSS layouts for page chrome.
7. **RTL + locale** 7. **RTL + locale + StyleProvider**
App root is wrapped with `<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">`. Do not override direction to LTR on whole pages; use local `direction="ltr"` only for inherently LTR fragments (e.g. phone numbers, codes). App root in `App.vue` **must** wrap ConfigProvider with `<a-style-provider hash-priority="high">` so legacy `main.css` cannot override Ant controls via higher specificity:
```vue
<a-style-provider hash-priority="high">
<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">
<RouterView />
</a-config-provider>
</a-style-provider>
```
Do not override direction to LTR on whole pages; use local `direction="ltr"` only for inherently LTR fragments (e.g. phone numbers, codes).
8. **Auto-import** 8. **Auto-import**
`<a-*>` components are resolved via `unplugin-vue-components` + `AntDesignVueResolver({ importStyle: false })`. Do not add blanket `app.use(antd)` unless there is a documented exception. Do not per-component CSS imports (`importStyle` stays `false`; CSS-in-JS + theme). `<a-*>` components are resolved via `unplugin-vue-components` + `AntDesignVueResolver({ importStyle: false })`. Do not add blanket `app.use(antd)` unless there is a documented exception. Do not per-component CSS imports (`importStyle` stays `false`; CSS-in-JS + theme). Prefer **explicit** `import { Button as AButton, … } from 'ant-design-vue'` in migrated files when the Vite resolver is missing or unreliable.
9. **Theme is central** 9. **Theme is central**
Visual tokens (primary, radius, `controlHeight`, padding/margin scale, shadows, Form/Card overrides) live only in `antdTheme.ts`. Do not fork theme objects inside views. Visual tokens (primary, radius, `controlHeight`, padding/margin scale, shadows, Form/Card overrides) live only in `antdTheme.ts`. Do not fork theme objects inside views.
+7
View File
@@ -0,0 +1,7 @@
{
"plugins": {
"hex": {
"enabled": true
}
}
}
@@ -38,9 +38,13 @@ Also read and obey [`.cursor/rules/antd-strict-governance.mdc`](../../rules/antd
| Dependency | `ant-design-vue` ^4, `@ant-design/icons-vue`, `unplugin-vue-components` in `@mento/web` | | Dependency | `ant-design-vue` ^4, `@ant-design/icons-vue`, `unplugin-vue-components` in `@mento/web` |
| Vite plugin | `apps/web/vite.config.ts` — `Components` + `AntDesignVueResolver` | | Vite plugin | `apps/web/vite.config.ts` — `Components` + `AntDesignVueResolver` |
| Theme | `apps/web/src/theme/antdTheme.ts` (`controlHeight: 36`) | | Theme | `apps/web/src/theme/antdTheme.ts` (`controlHeight: 36`) |
| RTL + locale + theme | `apps/web/src/App.vue` — `<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">` | | RTL + locale + theme + StyleProvider | `apps/web/src/App.vue` — `<a-style-provider hash-priority="high">` wrapping `<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">` (mandatory so legacy `main.css` cannot override Ant) |
| Legacy kit (do not touch) | `apps/web/src/components/ui/*`, `styles/main.css` utilities | | Legacy kit (do not touch) | `apps/web/src/components/ui/*`, `styles/main.css` utilities |
### Mandatory child-control conversion (same pass)
هنگام بازنویسی یک صفحه (View)، ایجنت موظف است علاوه بر خود فایل View، تمام کامپوننت‌های فرزندی را که در آن View ایمپورت شده‌اند و حاوی اینپوت/سلکت/دکمه هستند نیز بازبینی کرده و درون آن‌ها را به کامپوننت‌های رسمی `ant-design-vue` تبدیل کند. هیچ تگ `<input>`, `<select>`, `<textarea>`, `<button>` خامی نباید در صفحه یا کامپوننت‌های فرزندِ مصرفی آن باقی بماند.
## Migration workflow ## Migration workflow
1. Confirm the **single** route/view the user named. 1. Confirm the **single** route/view the user named.
-15
View File
@@ -1,15 +0,0 @@
.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
@@ -1,129 +0,0 @@
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
@@ -1,29 +0,0 @@
# 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
@@ -1,15 +0,0 @@
# 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
@@ -1,16 +0,0 @@
# 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
+65
View File
@@ -0,0 +1,65 @@
---
description: حاکمیت مطلق معماری رابط کاربری بر پایه Ant Design Vue v4، قفل مهاجرت صفحه به صفحه، ممنوعیت کامل CSS دستی و استاندارد ریاضی چیدمان
globs: apps/web/src/**/*.{vue,ts,tsx,css}
alwaysApply: true
---
# قرارداد حاکمیتی و غیرقابل‌مذاکرهٔ رابط کاربری (Ant Design Vue v4 Strict Contract)
تنها منبع مجاز برای ساخت و بازنویسی عناصر رابط کاربری در این پروژه `ant-design-vue` (نسخه ۴) است. هرگونه تخطی از قوانین زیر خطای معماری محسوب می‌شود:
## ۱. قفل ایزولاسیون و مهاجرت صفحه به صفحه (Single-Page Blast Radius Lock)
1. **ممنوعیت ویرایش دسته‌جمعی:** تو فقط و فقط مجاز به ویرایش صفحه (View) یا کامپوننتی هستی که کاربر صراحتاً در همان نوبت نام برده است. دست زدن به سایر صفحات پروژه مطلقاً ممنوع است.
2. **حفظ لایهٔ قدیمی برای صفحات مهاجرت‌نکرده:** فایل‌های داخل `apps/web/src/components/ui/Ui*.vue` و استایل‌های سراسری `main.css` / `tokens.css` نباید حذف یا ویرایش شوند تا صفحات قدیمی پروژه بدون خطا به کار خود ادامه دهند.
3. **قطع کامل وابستگی در صفحهٔ هدف:** در صفحه‌ای که کاربر دستور بازنویسی آن را می‌دهد، تمام ایمپورت‌های `@/components/ui` و کلاس‌های قدیمی باید از آن فایل حذف شوند و ۱۰۰٪ با کامپوننت‌های رسمی `<a-...>` جایگزین گردند.
4. **کامپوننت‌های تخصصی تاریخ شمسی و مبالغ:** در صفحات بازنویسی‌شده، اگر از `JalaliDateInput` یا `MoneyInput` استفاده شده است، منطق آن‌ها را حفظ کن اما مطمئن شو ظاهر ورودی آن‌ها با ارتفاع `40px`، ردیوس `10px` و عرض `style="width: 100%"` کاملاً هم‌تراز با فیلدهای Ant Design نمایش داده می‌شود.
## ۲. لیست سیاه سینتکس و تگ‌های ممنوعه در صفحات بازنویسی‌شده (Banned Syntax)
در هر فایلی که بازنویسی یا ایجاد می‌کنی، وجود موارد زیر خطای بحرانی است و باید فوراً حذف شوند:
- **تگ‌های خام HTML برای فرم و جدول:** `<button>`, `<input>`, `<select>`, `<textarea>`, `<label>`, `<form>`, `<table>`, `<thead>`, `<tbody>`, `<tr>`, `<td>`.
- **کلاس‌های لگسی پروژه:** `.btn`, `.btn-primary`, `.btn-ghost`, `.btn-danger`, `.btn-save`, `.field`, `.crm-page`, `.crm-page-header`, `.crm-card`, `.crm-table`, `.crm-empty-state`.
- **کلاس‌های Tailwind (پروژه فاقد Tailwind است):** استفاده از کلاس‌های ناموجود مانند `w-full`, `flex`, `gap-4`, `p-4`, `mb-4` مطلقاً ممنوع است. برای عرض کامل کنترل‌ها فقط از `style="width: 100%"` یا پراپ `block` استفاده کن.
## ۳. قوانین ریاضی چیدمان، گرید و ریتم فضایی (Spatial & Layout Mathematics)
1. **ممنوعیت تکه‌تکه کردن فرم به کارت‌های متعدد (Anti-Card-Fragmentation):**
- خرد کردن یک فرم به چندین باکس یا کارت مجزا زیر هم ممنوع است.
- کل فیلدهای یک فرم اصلی باید در **حداکثر ۱ `<a-card :bordered="false">`** تجمیع شوند.
- برای تفکیک بخش‌های منطقی در داخل آن کارت واحد، فقط از `<a-divider orientation="right">عنوان بخش</a-divider>` استفاده کن.
2. **چیدمان استاندارد دو ستونه (فرم اصلی + پنل خلاصه):**
- ردیف اصلی صفحه: `<a-row :gutter="[24, 24]">`
- ستون اصلی (فرم/محتوا): `<a-col :xs="24" :lg="17" :xl="18">`
- ستون کناری (خلاصه وضعیت): `<a-col :xs="24" :lg="7" :xl="6">` همراه با `<a-affix :offset-top="24">`.
- محتوای پنل خلاصه باید الزاماً با `<a-descriptions :column="1" size="small" bordered>` داخل `<a-card title="..." :bordered="false">` پیاده‌سازی شود، نه با `div` و `span` دستی.
3. **قانون عرض فیلدها در فرم (Form Grid Allocation):**
- تمام فرم‌ها باید `<a-form layout="vertical" :model="...">` باشند و تمام فیلدها بدون استثنا داخل `<a-form-item label="..." name="...">` قرار گیرند.
- رها کردن فیلدهای کوتاه در عرض ۱۰۰٪ (`span="24"`) ممنوع است. چیدمان درون فرم باید با `<a-row :gutter="[16, 0]">` به شکل زیر باشد:
- **فیلدهای کوتاه** (تاریخ، ساعت، مدت زمان، مبلغ، وضعیت، نوع، کد): `<a-col :xs="24" :sm="12" :md="8">` (۳ ستونه).
- **فیلدهای متوسط** (انتخاب مشتری، مسئول انجام، فرصت مرتبط، دسته‌بندی، عنوان): `<a-col :xs="24" :md="12">` (۲ ستونه).
- **فیلدهای تمام‌عرض** (توضیحات، یادداشت، چک‌لیست، برچسب‌ها): `<a-col :span="24">`.
- **سوییچ‌ها و چک‌باکس‌های هم‌خانواده:** قرارگیری در یک ردیف با `<a-space :size="24" wrap>`.
4. **مدیریت Margin و Padding بدون CSS دستی:**
- فاصلهٔ عمودی فیلدها فقط توسط `itemMarginBottom` در کانفیگ تم `<a-form-item>` کنترل می‌شود.
- فاصلهٔ افقی/عمودی دکمه‌ها و المان‌ها فقط با `<a-space :size="8 | 12 | 16">` یا `<a-flex gap="small | middle | large" align="center" justify="space-between">` کنترل می‌شود.
## ۴. قفل سینتکس اختصاصی Ant Design Vue v4
- **بایندینگ مقدار (`v-model`):** استفاده از `v-model="..."` بدون آرگومان روی کامپوننت‌های Ant Design Vue خطاست:
- برای `a-input`, `a-textarea`, `a-input-number`, `a-select`, `a-radio-group`, `a-segmented`, `a-date-picker`, `a-time-picker`: الزاماً `v-model:value="..."`.
- برای `a-checkbox`, `a-switch`: الزاماً `v-model:checked="..."`.
- برای `a-modal`, `a-drawer`, `a-dropdown`, `a-popover`, `a-tooltip`: الزاماً `v-model:open="..."` (پراپ `:visible` منسوخ و ممنوع است).
- **الزام عرض ۱۰۰٪ روی کنترل‌های خاص:** کامپوننت‌های `<a-input-number>`, `<a-date-picker>`, `<a-time-picker>` و `<a-select>` در داخل `<a-form-item>` باید دارای `style="width: 100%"` باشند.
- **فیدبک و پیام‌ها:** برای نمایش پیام از `import { message, notification, Modal } from 'ant-design-vue'` استفاده کن.
## ۵. حاکمیت مطلق توکن‌ها و ممنوعیت CSS دستی (Zero Manual Styling & RTL Lock)
1. نوشتن کد رنگ (`#hex`, `rgb`, `rgba`, `hsl`)، سایه (`box-shadow`)، ردیوس (`border-radius`) و فاصله‌های دستی (`margin`, `padding` با `px`/`rem`) در `<style scoped>` ممنوع است.
2. تمام تنظیمات ظاهری (ردیوس، شدو، ارتفاع کنترل‌ها، پدینگ و رنگ‌ها) فقط در فایل مرکزی `apps/web/src/theme/antdTheme.ts` تعریف می‌شوند.
3. در صورت نیاز ضروری به خواندن توکن در یک کانتینر، فقط از هوک `const { token } = theme.useToken()` در `ant-design-vue` استفاده کن.
4. استفاده از `:deep(.ant-...)` برای دست‌کاری ظاهر کامپوننت‌های انت ممنوع است.
5. در RTL، استفاده از `left`, `right`, `margin-left`, `margin-right`, `padding-left`, `padding-right` ممنوع است؛ فقط از خواص منطقی (`inset-inline-start`, `inset-inline-end`, `margin-inline`, `padding-inline`) استفاده شود.
## ۶. گیت خودارزیابی پیش از تحویل کد (Self-Verification Gate)
قبل از اتمام کار روی فایلِ درخواستی، این ۵ شرط را بررسی کن:
- [ ] آیا فقط فایل درخواستی کاربر ویرایش شده و سایر صفحات دست‌نخورده مانده‌اند؟
- [ ] آیا تمام تگ‌های خام فرم، کامپوننت‌های `Ui*`، کلاس‌های `.btn`/`.field`/`.crm-*` و کلاس‌های ناموجود مثل `w-full` از این صفحه حذف شده‌اند؟
- [ ] آیا فرم در ۱ کارت واحد با `<a-divider>` و گرید `<a-row :gutter="[16, 0]">` چیده شده و فیلدهای کوتاه از حالت تمام‌عرض خارج شده‌اند؟
- [ ] آیا تمام بایندینگ‌ها از `v-model:value`، `v-model:checked` و `v-model:open` به همراه `style="width: 100%"` روی سلکت‌ها و شمارنده‌ها استفاده می‌کنند؟
- [ ] آیا `<style scoped>` کاملاً از رنگ‌های Hex، مارجین/پدینگ دستی، ردیوس، شدو و `:deep(.ant-*)` پاک‌سازی شده است؟
@@ -1,3 +0,0 @@
ALTER TABLE "Activity" ADD COLUMN "boardPosition" INTEGER NOT NULL DEFAULT 0;
CREATE INDEX "Activity_stageId_boardPosition_idx" ON "Activity"("stageId", "boardPosition");
@@ -1 +0,0 @@
ALTER TABLE "OrgRole" ADD COLUMN "treasuryAccountIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
@@ -1 +0,0 @@
ALTER TABLE "OrgRole" ADD COLUMN "treasuryOwnAccounts" BOOLEAN NOT NULL DEFAULT false;
@@ -1 +0,0 @@
ALTER TABLE "Membership" ADD COLUMN "treasuryAccessOverride" JSONB;
@@ -1,9 +0,0 @@
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;
@@ -1,6 +0,0 @@
CREATE TABLE "DataFix" (
"key" TEXT NOT NULL,
"doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DataFix_pkey" PRIMARY KEY ("key")
);
@@ -1,6 +0,0 @@
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;
@@ -1,10 +0,0 @@
-- 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");
@@ -1,8 +0,0 @@
-- 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");
@@ -1,4 +0,0 @@
-- AlterTable
ALTER TABLE "FinancialPayroll" ADD COLUMN "benefitsReason" TEXT;
ALTER TABLE "FinancialPayroll" ADD COLUMN "bonusReason" TEXT;
ALTER TABLE "FinancialPayroll" ADD COLUMN "deductionsReason" TEXT;
@@ -1,6 +0,0 @@
-- 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[];
@@ -1,5 +0,0 @@
-- CreateEnum
CREATE TYPE "ActivityEditPolicy" AS ENUM ('creator', 'assignees');
-- AlterTable
ALTER TABLE "Activity" ADD COLUMN "editPolicy" "ActivityEditPolicy" NOT NULL DEFAULT 'creator';
@@ -1,28 +0,0 @@
-- مسئولان اختیاری روی گروه‌های مشتری
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 $$;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Organization" ADD COLUMN "activityPriorities" JSONB NOT NULL DEFAULT '{}';
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Organization" ADD COLUMN "smsTemplates" JSONB NOT NULL DEFAULT '[]';
+2 -76
View File
@@ -33,17 +33,6 @@ enum ActivityStatus {
canceled canceled
} }
enum ActivityCompletionMode {
any
all
}
/// چه کسانی می‌توانند فعالیت را ویرایش/حذف کنند (علاوه بر owner/admin)
enum ActivityEditPolicy {
creator
assignees
}
enum ActivityPriority { enum ActivityPriority {
low low
medium medium
@@ -162,10 +151,6 @@ 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)
@@ -310,8 +295,6 @@ 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")
@@ -370,10 +353,6 @@ 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?
/// نقش والد برای سلسله‌مراتب زیرنقش‌ها /// نقش والد برای سلسله‌مراتب زیرنقش‌ها
@@ -408,8 +387,6 @@ 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
@@ -541,7 +518,6 @@ 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[]
@@ -581,8 +557,6 @@ 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
@@ -901,12 +875,6 @@ 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)
@@ -928,8 +896,6 @@ 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)
@@ -945,7 +911,6 @@ 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])
@@ -1867,13 +1832,6 @@ 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[]
@@ -2363,12 +2321,6 @@ 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)
@@ -2389,8 +2341,6 @@ model FinancialDocument {
@@index([organizationId, kind, bookedAt]) @@index([organizationId, kind, bookedAt])
@@index([organizationId, status]) @@index([organizationId, status])
@@index([personId]) @@index([personId])
@@index([companyId])
@@index([userId])
} }
/// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال. /// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال.
@@ -2471,11 +2421,8 @@ 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?
@@ -2980,28 +2927,18 @@ 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} شماره می‌نشیند (generic) /// قالب URL شروع تماس؛ جای {number} شماره می‌نشیند
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[]
@@ -3009,7 +2946,6 @@ model VoipConnection {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([organizationId, enabled]) @@index([organizationId, enabled])
@@index([organizationId, provider])
} }
model VoipCallLog { model VoipCallLog {
@@ -3024,8 +2960,6 @@ 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?
@@ -3050,8 +2984,6 @@ 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])
@@ -3828,9 +3760,3 @@ 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 { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize'; import { normalizePhone } 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 = storedPhoneNumber(row.personMobile); const mobile = normalizePhone(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,13 +478,10 @@ export class ActivitiesExcelService {
} }
private async findPersonId(organizationId: string, row: Record<string, string>) { private async findPersonId(organizationId: string, row: Record<string, string>) {
const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile)); const mobile = normalizePhone(row.personMobile);
if (mobileKeys.length) { if (mobile) {
const byMobile = await this.prisma.person.findFirst({ const byMobile = await this.prisma.person.findFirst({
where: { where: { organizationId, mobile: { contains: mobile.replace(/^\+98|^98|^0/, '') } },
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,7 +7,6 @@ import {
Param, Param,
Patch, Patch,
Post, Post,
Put,
Query, Query,
Res, Res,
StreamableFile, StreamableFile,
@@ -41,7 +40,6 @@ import {
ReorderActivityTypesDto, ReorderActivityTypesDto,
UpdateActivityBoardDto, UpdateActivityBoardDto,
UpdateActivityDto, UpdateActivityDto,
UpdateActivityPrioritiesDto,
UpdateActivityStageDto, UpdateActivityStageDto,
UpdateActivityTypeDto, UpdateActivityTypeDto,
} from './dto'; } from './dto';
@@ -141,18 +139,6 @@ 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);
@@ -210,7 +196,6 @@ 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,
}, },
); );
@@ -247,7 +232,6 @@ 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,
@@ -281,7 +265,6 @@ 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,
@@ -445,26 +428,12 @@ export class ActivitiesController {
@Param('id') id: string, @Param('id') id: string,
@Body() dto: MoveActivityStageDto, @Body() dto: MoveActivityStageDto,
) { ) {
return this.activitiesService.moveToStage( return this.activitiesService.moveToStage(user.organizationId, id, user, dto.stageId);
user.organizationId,
id,
user,
dto.stageId,
dto.position,
);
} }
@Patch(':id/complete') @Patch(':id/complete')
complete( complete(@CurrentUser() user: Actor, @Param('id') id: string) {
@CurrentUser() user: Actor, return this.activitiesService.complete(user.organizationId, id, user);
@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')
+34 -322
View File
@@ -31,7 +31,6 @@ 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';
@@ -52,65 +51,6 @@ 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 };
@@ -123,7 +63,6 @@ type ListFacets = {
customer?: string; customer?: string;
/** set = زمان شروع دارد، unset = ندارد */ /** set = زمان شروع دارد، unset = ندارد */
due?: 'set' | 'unset'; due?: 'set' | 'unset';
priority?: ActivityDto['priority'];
/** گروه فعالیت؛ خالی = همهٔ گروه‌ها */ /** گروه فعالیت؛ خالی = همهٔ گروه‌ها */
boardId?: string; boardId?: string;
}; };
@@ -142,9 +81,6 @@ 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[];
@@ -249,39 +185,22 @@ 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 = this.deskScope(actor, desk); const scope: Prisma.ActivityWhereInput =
desk === 'mine'
? {
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
}
: activityAccessWhere(actor);
const prior = activityPriorWhere(actor.priorLens, actor.priorCutoff); const 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({
@@ -410,7 +329,9 @@ export class ActivitiesService {
where, where,
include, include,
// روی پروندهٔ لید، تازه‌ترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد // روی پروندهٔ لید، تازه‌ترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد
orderBy: this.listOrder(entity, stageScope), orderBy: entity?.leadId
? [{ createdAt: 'desc' }]
: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * take, skip: (page - 1) * take,
take, take,
}), }),
@@ -464,7 +385,7 @@ export class ActivitiesService {
this.prisma.activity.findMany({ this.prisma.activity.findMany({
where, where,
select: { id: true }, select: { id: true },
orderBy: this.listOrder(entity, stageScope), orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
take: cap, take: cap,
}), }),
this.prisma.activity.count({ where }), this.prisma.activity.count({ where }),
@@ -569,69 +490,18 @@ 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: { select: { status: true, completedAt: true },
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: {
@@ -646,13 +516,9 @@ 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);
} }
@@ -774,9 +640,6 @@ 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,
@@ -845,31 +708,8 @@ export class ActivitiesService {
const existing = await this.prisma.activity.findFirst({ const existing = await this.prisma.activity.findFirst({
where: { id, organizationId }, where: { id, organizationId },
select: { select: { status: true, type: true, typeDefId: true, fields: true, participantIds: true },
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) {
@@ -937,7 +777,6 @@ 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 },
@@ -965,7 +804,6 @@ 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);
@@ -979,44 +817,12 @@ 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] : [];
} }
} }
const modeChanged = dto.completionMode === 'any' || dto.completionMode === 'all'; if (dto.status !== undefined) {
const nextMode = modeChanged ? dto.completionMode! : (existing?.completionMode ?? 'any'); data.status = dto.status;
if (modeChanged) data.completionMode = nextMode; data.completedAt =
if (dto.editPolicy === 'creator' || dto.editPolicy === 'assignees') { dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined;
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({
@@ -1040,7 +846,7 @@ export class ActivitiesService {
addedParticipants.filter((uid) => !assigneeSet.has(uid)), addedParticipants.filter((uid) => !assigneeSet.has(uid)),
); );
} }
if (data.status === 'done' && existing?.status !== 'done') { if (dto.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));
@@ -1050,74 +856,18 @@ 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: { select: { status: true },
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: { data: { status: 'done', completedAt: new Date() },
completedByIds: reconciled.completedByIds,
status: reconciled.status,
completedAt: reconciled.status === 'done' ? new Date() : null,
},
include, include,
}); });
if (reconciled.status === 'done' && existing.status !== 'done') { if (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));
@@ -1161,18 +911,6 @@ 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 },
@@ -1193,19 +931,6 @@ 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('فایلی ارسال نشده است');
} }
@@ -1300,27 +1025,10 @@ 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);
@@ -1365,18 +1073,25 @@ export class ActivitiesService {
typeRows: TypeRow[] = [], typeRows: TypeRow[] = [],
): Prisma.ActivityWhereInput { ): Prisma.ActivityWhereInput {
const { start, end } = dayBounds(); const { start, end } = dayBounds();
const scope = this.deskScope(actor, desk); const scope: Prisma.ActivityWhereInput =
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
@@ -1668,9 +1383,6 @@ 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,8 +6,6 @@ import type {
ActivityFieldValue, ActivityFieldValue,
ActivityFieldValues, ActivityFieldValues,
ActivityParticipantsValue, ActivityParticipantsValue,
ActivityPriority,
ActivityPrioritySetting,
ActivityStageColor, ActivityStageColor,
ActivityType, ActivityType,
ActivityTypeDefDto, ActivityTypeDefDto,
@@ -19,11 +17,8 @@ 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';
@@ -624,43 +619,6 @@ 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('نوع فعالیتی با این نام وجود دارد');
+2 -54
View File
@@ -9,13 +9,11 @@ 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';
@@ -93,11 +91,6 @@ export class ListActivitiesQueryDto {
@IsString() @IsString()
typeDefId?: string; typeDefId?: string;
/** اولویت فعالیت (کلید ثابت؛ برچسب در تنظیمات سازمان) */
@IsOptional()
@IsIn(ACTIVITY_PRIORITIES)
priority?: (typeof ACTIVITY_PRIORITIES)[number];
/** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */ /** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -226,12 +219,6 @@ 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 در سرویس بر اساس تعریف نوع اعتبارسنجی می‌شود */
@@ -329,7 +316,7 @@ export class CreateActivityDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(20000) @MaxLength(2000)
description?: string; description?: string;
@IsOptional() @IsOptional()
@@ -391,14 +378,6 @@ 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 {
@@ -437,7 +416,7 @@ export class UpdateActivityDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(20000) @MaxLength(2000)
description?: string | null; description?: string | null;
@IsOptional() @IsOptional()
@@ -499,35 +478,4 @@ 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[];
} }
+3 -11
View File
@@ -19,10 +19,6 @@ 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';
@@ -852,9 +848,6 @@ 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 ??
@@ -886,10 +879,9 @@ export class AuthService {
storage, storage,
isOrgOwner: role === 'owner', isOrgOwner: role === 'owner',
priorArchive: this.priorArchiveFor(membership), priorArchive: this.priorArchiveFor(membership),
permissions: permissionsWithTreasuryOverride( permissions: (Array.isArray(membership?.orgRole?.permissions)
rolePermissions, ? membership.orgRole.permissions.filter((item) => typeof item === 'string')
parseUserTreasuryAccess(membership?.treasuryAccessOverride), : []) as AuthUserDto['permissions'],
) as AuthUserDto['permissions'],
...(viewAs ? { viewAs } : {}), ...(viewAs ? { viewAs } : {}),
}); });
} }
-4
View File
@@ -195,10 +195,6 @@ 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 },
}); });
+1 -34
View File
@@ -21,9 +21,6 @@ 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';
@@ -417,37 +414,7 @@ export class BillingService {
} }
defaultModulesForNewOrg(): OrgModuleKey[] { defaultModulesForNewOrg(): OrgModuleKey[] {
return modulesForProductPlan('core'); return defaultEnabledModules();
}
/** سطح امکانات را روی همهٔ محیط‌های مالک می‌گذارد و سقف هسته/تیم/سازمان را هم تنظیم می‌کند. */
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
@@ -1,28 +0,0 @@
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)));
}
}
+109 -138
View File
@@ -105,33 +105,29 @@ 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 facets: Prisma.PersonWhereInput[] = []; const and: Prisma.PersonWhereInput[] = [];
const ids = ownerIdList(rule, ctx);
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, ctx); if (!ids.length) return null;
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 دیگر فیلتر دسترسی نیست؛ دید گروه فقط از مسئول‌گروه (personViaGroupAssignee)
if (rule.sources?.length) { if (rule.sources?.length) {
facets.push({ and.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) {
facets.push({ mood: { in: rule.moods } }); and.push({ mood: { in: rule.moods } });
} }
return unionFacets(facets); if (!and.length) return null;
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 } {
@@ -139,23 +135,6 @@ 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 {
@@ -170,8 +149,6 @@ export function customersPersonWhere(
}); });
if (w) parts.push(w); if (w) parts.push(w);
} }
// مسئول گروه: اتحاد با محدودیت نقش (مالک خودم یا عضو گروه تحت مسئولیت)
parts.push(personViaGroupAssignee(actor.userId));
return orParts(parts); return orParts(parts);
} }
@@ -183,28 +160,34 @@ 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) { if (!ids.length) continue;
facets.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } }); and.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) {
facets.push({ and.push({
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }], OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
}); });
} }
const w = unionFacets(facets); if (
if (w) parts.push(w); rule.moods?.length &&
!rule.owners?.length &&
!rule.groupIds?.length &&
!rule.sources?.length
) {
continue;
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
} }
parts.push(companyViaGroupAssignee(actor.userId));
return orParts(parts); return orParts(parts);
} }
@@ -216,28 +199,27 @@ 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 facets: Prisma.LeadWhereInput[] = []; const and: 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) { if (!ids.length) continue;
facets.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } }); and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
}
} }
if (rule.stageIds?.length) { if (rule.stageIds?.length) {
facets.push({ stageId: { in: rule.stageIds } }); and.push({ stageId: { in: rule.stageIds } });
} }
if (rule.sources?.length) { if (rule.sources?.length) {
facets.push({ and.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 },
})), })),
}); });
} }
const w = unionFacets(facets); if (!and.length) continue;
if (w) parts.push(w); parts.push(and.length === 1 ? and[0]! : { AND: and });
} }
return orParts(parts); return orParts(parts);
} }
@@ -250,29 +232,28 @@ 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 facets: Prisma.DealWhereInput[] = []; const and: 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) { if (!ids.length) continue;
facets.push({ and.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) {
facets.push({ pipelineId: { in: rule.pipelineIds } }); and.push({ pipelineId: { in: rule.pipelineIds } });
} }
if (rule.stageIds?.length) { if (rule.stageIds?.length) {
facets.push({ stageId: { in: rule.stageIds } }); and.push({ stageId: { in: rule.stageIds } });
} }
const w = unionFacets(facets); if (!and.length) continue;
if (w) parts.push(w); parts.push(and.length === 1 ? and[0]! : { AND: and });
} }
return orParts(parts); return orParts(parts);
} }
@@ -285,33 +266,32 @@ 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 facets: Prisma.ActivityWhereInput[] = []; const and: 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) { if (!ids.length) continue;
facets.push({ and.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) {
facets.push({ boardId: { in: rule.boardIds } }); and.push({ boardId: { in: rule.boardIds } });
} }
if (rule.stageIds?.length) { if (rule.stageIds?.length) {
facets.push({ stageId: { in: rule.stageIds } }); and.push({ stageId: { in: rule.stageIds } });
} }
if (rule.typeIds?.length) { if (rule.typeIds?.length) {
facets.push({ typeDefId: { in: rule.typeIds } }); and.push({ typeDefId: { in: rule.typeIds } });
} }
const w = unionFacets(facets); if (!and.length) continue;
if (w) parts.push(w); parts.push(and.length === 1 ? and[0]! : { AND: and });
} }
return orParts(parts); return orParts(parts);
} }
@@ -330,35 +310,34 @@ 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 facets: Prisma.NoteWhereInput[] = []; const and: 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) { if (!ids.length) continue;
facets.push({ and.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 } } }, },
{ company: { ownerId: { in: ids } } }, { person: { ownerId: { in: ids } } },
], { company: { ownerId: { in: ids } } },
}); ],
} });
} }
if (rule.sources?.length) { if (rule.sources?.length) {
facets.push({ noteType: { in: rule.sources } }); and.push({ noteType: { in: rule.sources } });
} }
const w = unionFacets(facets); if (!and.length) continue;
if (w) parts.push(w); parts.push(and.length === 1 ? and[0]! : { AND: and });
} }
return { return {
AND: [personalGate, orParts(parts)], AND: [personalGate, orParts(parts)],
@@ -373,27 +352,26 @@ 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 facets: Prisma.TicketWhereInput[] = []; const and: 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) { if (!ids.length) continue;
facets.push({ and.push({
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }], OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
}); });
}
} }
if (rule.sources?.length) { if (rule.sources?.length) {
facets.push({ and.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'>,
}, },
}); });
} }
const w = unionFacets(facets); if (!and.length) continue;
if (w) parts.push(w); parts.push(and.length === 1 ? and[0]! : { AND: and });
} }
return orParts(parts); return orParts(parts);
} }
@@ -406,29 +384,28 @@ 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 facets: Prisma.ProjectCardWhereInput[] = []; const and: 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) { if (!ids.length) continue;
facets.push({ and.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) {
facets.push({ boardId: { in: rule.boardIds } }); and.push({ boardId: { in: rule.boardIds } });
} }
if (rule.stageIds?.length) { if (rule.stageIds?.length) {
facets.push({ stageId: { in: rule.stageIds } }); and.push({ stageId: { in: rule.stageIds } });
} }
const w = unionFacets(facets); if (!and.length) continue;
if (w) parts.push(w); parts.push(and.length === 1 ? and[0]! : { AND: and });
} }
return orParts(parts); return orParts(parts);
} }
@@ -441,21 +418,20 @@ 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 facets: Prisma.RoutineTaskWhereInput[] = []; const and: 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) { if (!ids.length) continue;
facets.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } }); and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
}
} }
if (rule.typeIds?.length) { if (rule.typeIds?.length) {
facets.push({ categoryId: { in: rule.typeIds } }); and.push({ categoryId: { in: rule.typeIds } });
} }
const w = unionFacets(facets); if (!and.length) continue;
if (w) parts.push(w); parts.push(and.length === 1 ? and[0]! : { AND: and });
} }
return orParts(parts); return orParts(parts);
} }
@@ -473,14 +449,9 @@ 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 ( } else if (rule.groupIds?.length || rule.sources?.length || rule.moods?.length ||
rule.sources?.length || rule.stageIds?.length || rule.pipelineIds?.length || rule.boardIds?.length ||
rule.moods?.length || rule.typeIds?.length) {
rule.stageIds?.length ||
rule.pipelineIds?.length ||
rule.boardIds?.length ||
rule.typeIds?.length
) {
sawNonOwnerRule = true; sawNonOwnerRule = true;
} }
} }
+6 -78
View File
@@ -5,7 +5,7 @@ import type {
SectionDataAccess, SectionDataAccess,
UserRole, UserRole,
} from '@mento/shared'; } from '@mento/shared';
import { DATA_SCOPES, canViewAllRecords, sectionHasDataAccessLimits } from '@mento/shared'; import { DATA_SCOPES, canViewAllRecords } from '@mento/shared';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { import {
activitiesDeepWhere, activitiesDeepWhere,
@@ -13,7 +13,6 @@ import {
notesDeepWhere, notesDeepWhere,
projectsDeepWhere, projectsDeepWhere,
routinesDeepWhere, routinesDeepWhere,
sectionOwnerIdUnion,
ticketsDeepWhere, ticketsDeepWhere,
} from './data-access'; } from './data-access';
@@ -61,65 +60,6 @@ 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];
@@ -169,16 +109,9 @@ export function scopedOwnerIds(
/** فیلتر مالکیت؛ پیش‌فرض بخش «مشتریان» (Person / Company). برای فاکتور/پیش‌فاکتور scope=finance. */ /** فیلتر مالکیت؛ پیش‌فرض بخش «مشتریان» (Person / Company). برای فاکتور/پیش‌فاکتور scope=finance. */
export function ownerWhere( export function ownerWhere(
actor: DeepAccessActor, actor: ScopedActor,
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]! };
@@ -220,16 +153,11 @@ export function projectCardAccessWhere(
} }
/** فعالیت‌ها بر اساس مسئول اصلی یا مسئولان مشترک */ /** فعالیت‌ها بر اساس مسئول اصلی یا مسئولان مشترک */
export function assigneeWhere(actor: DeepAccessActor): Prisma.ActivityWhereInput { export function assigneeWhere(
const deep = deepOwnerIds(actor, 'activities'); actor: ScopedActor,
const ids = ): Prisma.ActivityWhereInput {
deep.kind === 'open' const ids = scopedOwnerIds(actor, 'activities');
? 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 } },
+1 -68
View File
@@ -21,82 +21,15 @@ 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: { vars: { name?: string; phone?: string; title?: string; company?: string },
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,7 +60,6 @@ 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,
+3 -8
View File
@@ -112,13 +112,9 @@ 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 = {
AND: [ organizationId: actor.organizationId,
{ ...customersCompanyWhere(actor),
organizationId: actor.organizationId, ...(and.length ? { AND: and } : {}),
...customersCompanyWhere(actor),
},
...and,
],
}; };
const [rows, total] = await this.prisma.$transaction([ const [rows, total] = await this.prisma.$transaction([
@@ -169,7 +165,6 @@ 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: [],
})); }));
} }
+1 -4
View File
@@ -3,13 +3,11 @@ 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';
@@ -21,10 +19,9 @@ 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, SameMobileMergeService], providers: [PeopleService, CompaniesService, ContactsExcelService],
exports: [PeopleService, CompaniesService], exports: [PeopleService, CompaniesService],
}) })
export class ContactsModule {} export class ContactsModule {}
-5
View File
@@ -23,11 +23,6 @@ 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
@@ -1,367 +0,0 @@
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,36 +27,3 @@ 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);
}
+1 -2
View File
@@ -139,7 +139,6 @@ 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);
} }
@@ -172,7 +171,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, Boolean(dto.force)); return this.peopleService.sendBirthdaySms(user, id, dto.text);
} }
@Get(':id') @Get(':id')
+31 -185
View File
@@ -22,19 +22,13 @@ 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 { import { emptyToNull, normalizeEmail, normalizePhone } from './normalize';
emptyToNull,
normalizeEmail,
phoneIdentityKeys,
storedPhoneNumber,
} from './normalize';
import { import {
asAddresses, asAddresses,
asCustomFields, asCustomFields,
@@ -49,15 +43,13 @@ 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, smsBalanceTexts } from '../common/sms-text'; import { renderSmsText } 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 {
@@ -160,7 +152,6 @@ 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(
@@ -220,27 +211,13 @@ 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);
// گروه / منبع / حال: اتحاد (هر بُعد کافی است)؛ داخل هر بُعد چند مقدار با in = OR if (groupIds.length) and.push({ groupLinks: { some: { groupId: { in: groupIds } } } });
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 {
AND: [ organizationId: actor.organizationId,
{ ...customersPersonWhere(actor),
organizationId: actor.organizationId, ...(moods.length ? { mood: { in: moods } } : {}),
...customersPersonWhere(actor), ...(sources.length ? { importSource: { in: sources } } : {}),
}, ...(and.length ? { AND: and } : {}),
...and,
],
}; };
} }
@@ -363,7 +340,6 @@ 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: [],
})); }));
} }
@@ -423,10 +399,7 @@ 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[] = [];
const mobileKeys = phoneIdentityKeys(input.mobile); if (input.mobile) or.push({ mobile: 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({
@@ -468,8 +441,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: storedPhoneNumber(dto.phone), phone: normalizePhone(dto.phone),
mobile: storedPhoneNumber(dto.mobile), mobile: normalizePhone(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),
@@ -580,8 +553,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 = storedPhoneNumber(dto.phone); if ('phone' in dto) data.phone = normalizePhone(dto.phone);
if ('mobile' in dto) data.mobile = storedPhoneNumber(dto.mobile); if ('mobile' in dto) data.mobile = normalizePhone(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);
@@ -668,13 +641,10 @@ 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 (mobileKeys.length) { if (mobile) or.push({ mobile });
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({
@@ -699,8 +669,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,
@@ -747,50 +717,27 @@ export class PeopleService implements OnModuleInit {
}; };
} }
async sendBirthdaySms(actor: Actor, personId: string, text: string, force = false) { async sendBirthdaySms(actor: Actor, personId: string, text: string) {
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: { where: { id: personId, organizationId: actor.organizationId },
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('مشتری پیدا نشد');
const assigneeIds = new Set( if (person.ownerId !== actor.userId) {
person.groupLinks.flatMap((link) => link.group.assignees.map((a) => a.id)), throw new ForbiddenException('فقط مسئول این مشتری می‌تواند پیامک تبریک بفرستد.');
);
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('برای این مشتری تاریخ تولد ثبت نشده است.');
@@ -812,41 +759,12 @@ 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 birthdayKey = `birthday:${inMonth.year}-${inMonth.month}`; const message = renderSmsText(body, {
if (!force) { name: fullName,
const prior = await this.prisma.outboundSmsLog.findFirst({ phone: mobile,
where: { title: fullName,
organizationId: actor.organizationId, company: person.company?.name ?? '',
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('متن پیامک خالی است.'); if (!message) throw new BadRequestException('متن پیامک خالی است.');
const result = await this.sms.send({ const result = await this.sms.send({
organizationId: actor.organizationId, organizationId: actor.organizationId,
@@ -860,7 +778,6 @@ export class PeopleService implements OnModuleInit {
mobile, mobile,
body: message, body: message,
source: 'manual', source: 'manual',
sourceId: birthdayKey,
status: result.ok ? 'sent' : 'failed', status: result.ok ? 'sent' : 'failed',
error: result.ok ? null : result.reason, error: result.ok ? null : result.reason,
actorUserId: actor.userId, actorUserId: actor.userId,
@@ -874,28 +791,6 @@ export class PeopleService implements OnModuleInit {
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.', : 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
); );
} }
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 }; return { ok: true };
} }
@@ -907,15 +802,12 @@ export class PeopleService implements OnModuleInit {
const person = await this.get(actor, personId); const person = await this.get(actor, personId);
const mobile = person.mobile || person.phone; const mobile = person.mobile || person.phone;
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.'); if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
const message = renderSmsText( const message = renderSmsText(body, {
body, name: person.fullName,
await this.personSmsVars(actor.organizationId, person.id, { phone: mobile,
name: person.fullName, title: person.fullName,
phone: mobile, company: person.company?.name ?? '',
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,
@@ -945,52 +837,6 @@ export class PeopleService implements OnModuleInit {
return { ok: true }; return { ok: true };
} }
/** نام سازمان + مانده‌های کش‌شدهٔ ته‌حساب برای توکن‌های پیامک */
private async personSmsVars(
organizationId: string,
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;
}
return {
...base,
from: org?.name?.trim() || '',
rialBalance,
metalBalance,
};
}
listSmsHistory(actor: Actor, personId: string, limit?: number) { listSmsHistory(actor: Actor, personId: string, limit?: number) {
return this.get(actor, personId).then(() => return this.get(actor, personId).then(() =>
this.outboundSms.historyForPerson(actor.organizationId, personId, limit), this.outboundSms.historyForPerson(actor.organizationId, personId, limit),
@@ -1,85 +0,0 @@
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,14 +8,7 @@ import {
Post, Post,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
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';
@@ -33,11 +26,6 @@ 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 {
@@ -55,11 +43,6 @@ 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,21 +51,26 @@ 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) => this.toDto(r)); return rows.map((r) => ({
id: r.id,
name: r.name,
color: r.color,
position: r.position,
fromTahesab: Boolean(r.tahesabGid),
personCount: r._count.people,
companyCount: r._count.companies,
parentId: r.parentId,
parentName: r.parent?.name ?? null,
}));
} }
async create( async create(
organizationId: string, organizationId: string,
dto: { name: string; color?: CustomerGroupColor; assigneeIds?: string[] }, dto: { name: string; color?: CustomerGroupColor },
): Promise<CustomerGroupDto> { ): Promise<CustomerGroupDto> {
const name = this.cleanName(dto.name); const name = this.cleanName(dto.name);
const assigneeIds =
dto.assigneeIds !== undefined
? await this.validAssigneeIds(organizationId, dto.assigneeIds)
: [];
const max = await this.prisma.customerGroup.aggregate({ const max = await this.prisma.customerGroup.aggregate({
where: { organizationId }, where: { organizationId },
_max: { position: true }, _max: { position: true },
@@ -77,9 +82,6 @@ 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) {
@@ -91,12 +93,7 @@ export class CustomerGroupsService {
async update( async update(
organizationId: string, organizationId: string,
id: string, id: string,
dto: { dto: { name?: string; color?: CustomerGroupColor; parentId?: string | null },
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 =
@@ -104,10 +101,6 @@ 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 },
@@ -115,9 +108,6 @@ 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) {
@@ -327,48 +317,6 @@ 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,11 +17,7 @@ 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 { import { normalizeEmail, normalizePhone } from '../../contacts/normalize';
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';
@@ -104,7 +100,13 @@ function arr(v: unknown): unknown[] {
} }
function mobileVariants(mobile: string): string[] { function mobileVariants(mobile: string): string[] {
return phoneIdentityKeys(mobile); const digits = mobile.replace(/\D/g, '');
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 {
@@ -415,7 +417,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 = storedPhoneNumber(c.phone ?? null); const mobile = normalizePhone(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,11 +1,4 @@
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,
@@ -123,52 +116,12 @@ 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,10 +43,6 @@ 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;
}) { }) {
@@ -64,10 +60,6 @@ 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(),
}; };
@@ -209,15 +201,6 @@ 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,
@@ -242,7 +225,6 @@ 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);
@@ -1,99 +0,0 @@
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,16 +86,6 @@ 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 {
@@ -205,12 +195,6 @@ 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;
@@ -1,113 +0,0 @@
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,12 +8,7 @@ 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, namesDiffer } from '../../contacts/person-source'; import { carryPreviousSource } 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 {
@@ -26,25 +21,10 @@ import {
SearchTahesabDto, SearchTahesabDto,
SyncGroupsPageDto, SyncGroupsPageDto,
} from './dto'; } from './dto';
import { formatJalaliDate } from '../../common/excel'; import { formatJalaliDate, parseBirthDate } 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;
@@ -84,6 +64,17 @@ 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());
@@ -428,77 +419,6 @@ 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,
@@ -577,7 +497,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 linked = mapped.map((row) => { const results = 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,
@@ -588,22 +508,6 @@ 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: {
@@ -635,7 +539,6 @@ 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: {
@@ -674,7 +577,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 = storedPhoneNumber(tel); const mobile = tel || null;
const sourceRef = String(dto.moshtariCode); const sourceRef = String(dto.moshtariCode);
let personId = dto.personId; let personId = dto.personId;
@@ -700,49 +603,29 @@ export class TahesabPersonLinkService {
...carryPreviousSource(existing, 'tahesab'), ...carryPreviousSource(existing, 'tahesab'),
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, sourceRef,
...canonicalMobileUpdate(mobile, existing), ...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...tahesabProfilePatch(row, existing), ...(mobile
? {
mobile: existing.mobile || mobile,
phone: existing.phone || mobile,
}
: {}),
}, },
}); });
} else { } else {
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین. // اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین.
// اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، نام و لینک دست نمی‌خورند // شخصی که روی همین اتصال به کد دیگری لینک است کنار گذاشته می‌شود؛
// تا کد قبلی جابه‌جا نشود و این کد هم مشتری تکراری نسازد. // وگرنه upsert لینک، کد قبلی را جابه‌جا و آن مشتری را دوباره «واردنشده» می‌کند.
const mobileKeys = phoneIdentityKeys(mobile); const byMobile = mobile
const matches = mobileKeys.length ? await this.prisma.person.findFirst({
? await this.prisma.person.findMany({
where: { where: {
organizationId, organizationId,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }], OR: [{ mobile }, { phone: mobile }],
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();
@@ -762,17 +645,11 @@ export class TahesabPersonLinkService {
...carryPreviousSource(byMobile, 'tahesab'), ...carryPreviousSource(byMobile, 'tahesab'),
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, sourceRef,
...canonicalMobileUpdate(mobile, byMobile), ...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...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,
@@ -785,9 +662,7 @@ export class TahesabPersonLinkService {
notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`, notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`,
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, sourceRef,
birthDate: (profile.birthDate as Date | undefined) ?? null, birthDate: pickBirthDate(row),
addresses: (profile.addresses as Prisma.InputJsonValue) ?? [],
customFields: (profile.customFields as Prisma.InputJsonValue) ?? {},
}, },
}); });
personId = created.id; personId = created.id;
@@ -818,184 +693,18 @@ 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, connectionId: dto.connectionId,
kind: 'customer-sync', kind: 'import',
status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success', status: 'success',
result: { imported, updated, duplicate, failed, pages: page - 1 } as Prisma.InputJsonValue, request: dto as unknown as Prisma.InputJsonValue,
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
}, },
}); });
await this.prisma.accountingConnection.update({
where: { id: connectionId }, return { personId, link: this.linkDto(link) };
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) {
@@ -1090,16 +799,19 @@ export class TahesabPersonLinkService {
try { try {
const linkedPersonId = linkedByCode.get(moshtariCode); const linkedPersonId = linkedByCode.get(moshtariCode);
if (linkedPersonId) { if (linkedPersonId) {
const row = rowsByCode.get(moshtariCode); const birthDate = pickBirthDate(rowsByCode.get(moshtariCode) ?? {});
if (row) { if (birthDate) {
await this.refreshLinkedCustomer( await this.prisma.person.updateMany({
organizationId, where: { id: linkedPersonId, birthDate: null },
dto.connectionId, data: { birthDate },
linkedPersonId, });
moshtariCode,
row,
);
} }
await this.applyTahesabGroup(
organizationId,
dto.connectionId,
linkedPersonId,
rowsByCode.get(moshtariCode),
);
items.push({ items.push({
moshtariCode, moshtariCode,
ok: true, ok: true,
@@ -1113,16 +825,6 @@ 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,7 +37,6 @@ import {
SearchTahesabDto, SearchTahesabDto,
SearchTahesabProductsDto, SearchTahesabProductsDto,
SyncAsnadReportDto, SyncAsnadReportDto,
SyncAllCustomersDto,
SyncGroupsPageDto, SyncGroupsPageDto,
UpdateTahesabConnectionDto, UpdateTahesabConnectionDto,
UpdateTahesabInvoiceSettingsDto, UpdateTahesabInvoiceSettingsDto,
@@ -238,16 +237,6 @@ 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,7 +12,6 @@ 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';
@@ -30,7 +29,6 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu
TahesabDocumentsService, TahesabDocumentsService,
TahesabAsnadReportService, TahesabAsnadReportService,
TahesabInvoiceSettingsService, TahesabInvoiceSettingsService,
TahesabCustomerSyncScheduler,
], ],
exports: [ exports: [
TahesabCryptoService, TahesabCryptoService,
@@ -3,9 +3,6 @@ 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;
@@ -20,8 +17,6 @@ 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 * * * *')
@@ -79,31 +74,7 @@ export class CallSummaryService {
); );
return; return;
} }
const daftId = parseDaftaresomaRecordingRef(row.recordingUrl); transcript = await this.stt.downloadAndTranscribe(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 },
+5 -161
View File
@@ -11,13 +11,8 @@ 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 { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize'; import { 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, '');
@@ -40,7 +35,6 @@ 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];
} }
@@ -62,32 +56,19 @@ 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,
opts?: { connectionId?: string,
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,
...(opts?.connectionId ? { connectionId: opts.connectionId } : {}), ...(connectionId ? { 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: 100, take: 50,
}); });
const personIds = rows const personIds = rows
.map((r) => r.personId) .map((r) => r.personId)
@@ -109,7 +90,6 @@ 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,
@@ -146,9 +126,6 @@ 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,
@@ -215,7 +192,6 @@ 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,
@@ -236,7 +212,6 @@ export class VoipCallsService {
status, status,
fromNumber, fromNumber,
toNumber, toNumber,
lineExtension,
personId, personId,
activityId, activityId,
durationSec, durationSec,
@@ -264,17 +239,6 @@ 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: {
@@ -296,67 +260,10 @@ export class VoipCallsService {
userId: string, userId: string,
number: string, number: string,
personId?: string, personId?: string,
): Promise<{ ok: true; mode: 'url' | 'tel' | 'daftareshoma'; dialUrl: string }> { ): Promise<{ ok: true; mode: 'url' | 'tel'; 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,
@@ -410,69 +317,6 @@ 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,17 +7,12 @@ 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(
@@ -48,35 +43,19 @@ 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 token = dto.apiToken?.trim(); const clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl);
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);
@@ -98,12 +77,6 @@ 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) {
@@ -117,19 +90,6 @@ 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,
@@ -164,21 +124,6 @@ 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,
@@ -208,13 +153,9 @@ 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;
@@ -225,13 +166,9 @@ 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,
@@ -1,126 +0,0 @@
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 };
}
}
@@ -1,168 +0,0 @@
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,6 +1,5 @@
import { import {
IsBoolean, IsBoolean,
IsIn,
IsOptional, IsOptional,
IsString, IsString,
MaxLength, MaxLength,
@@ -13,10 +12,6 @@ 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)
@@ -27,21 +22,6 @@ 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;
@@ -64,21 +44,6 @@ 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,31 +18,17 @@ 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 waitMs = timeoutMs ?? Number( const 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,
@@ -53,7 +39,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(), waitMs); const timer = setTimeout(() => controller.abort(), timeoutMs);
try { try {
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/transcribe`, { const res = await fetch(`${baseUrl.replace(/\/$/, '')}/transcribe`, {
method: 'POST', method: 'POST',
@@ -28,10 +28,6 @@ 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)
@@ -82,16 +78,8 @@ 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, { return this.calls.listRecent(user.organizationId, connectionId);
connectionId,
direction,
status,
lineExtension,
});
} }
@Post('click-to-call') @Post('click-to-call')
@@ -141,21 +129,11 @@ export class VoipWebhookController {
if (!ok) throw new UnauthorizedException('امضای وب‌هوک نامعتبر است'); if (!ok) throw new UnauthorizedException('امضای وب‌هوک نامعتبر است');
} }
let event: VoipWebhookEvent | null = null; if (!body?.callId) {
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, event); const result = await this.calls.ingestWebhook(connectionId, body);
return { ok: true, ...result }; return { ok: true, ...result };
} }
} }
@@ -7,7 +7,6 @@ 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';
@@ -18,7 +17,6 @@ import { VoipController, VoipWebhookController } from './voip.controller';
TahesabCryptoService, TahesabCryptoService,
VoipConnectionsService, VoipConnectionsService,
VoipCallsService, VoipCallsService,
DaftaresomaClient,
SttClientService, SttClientService,
CallSummaryService, CallSummaryService,
], ],
@@ -6,11 +6,7 @@ import type {
WooCommerceSyncResultDto, WooCommerceSyncResultDto,
} from '@mento/shared'; } from '@mento/shared';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { import { normalizeEmail, normalizePhone } from '../../contacts/normalize';
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';
@@ -378,8 +374,7 @@ 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 = storedPhoneNumber(billing.phone ?? null); const mobile = normalizePhone(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() || 'ووکامرس';
@@ -392,9 +387,7 @@ export class WooSyncService {
organizationId, organizationId,
OR: [ OR: [
...(email ? [{ email }] : []), ...(email ? [{ email }] : []),
...(mobileKeys.length ...(mobile ? [{ mobile }, { phone: mobile }] : []),
? [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }]
: []),
], ],
}, },
select: { id: true }, select: { id: true },
+8 -16
View File
@@ -21,12 +21,9 @@ 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';
@@ -461,10 +458,7 @@ export class LeadFormsService {
? await this.prisma.person.findFirst({ ? await this.prisma.person.findFirst({
where: { where: {
organizationId: form.organizationId, organizationId: form.organizationId,
OR: [ OR: [{ mobile }, { phone: mobile }],
{ mobile: { in: phoneIdentityKeys(mobile) } },
{ phone: { in: phoneIdentityKeys(mobile) } },
],
}, },
select: { id: true, firstName: true, lastName: true }, select: { id: true, firstName: true, lastName: true },
}) })
@@ -647,16 +641,13 @@ export class LeadFormsService {
ownerId: string, ownerId: string,
): Promise<string> { ): Promise<string> {
const email = normalizeEmail(mapped.email); const email = normalizeEmail(mapped.email);
const mobile = storedPhoneNumber(mapped.mobile); const mobile = normalizePhone(mapped.mobile);
const phone = storedPhoneNumber(mapped.phone); const phone = normalizePhone(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 },
@@ -1124,12 +1115,13 @@ 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, '');
return d || null; if (!d) return 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,11 +246,7 @@ 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,
@@ -262,11 +258,7 @@ 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,
}); });
} }
@@ -284,12 +276,8 @@ 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,
@@ -303,12 +291,8 @@ 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,
}); });
} }
+45 -235
View File
@@ -32,29 +32,17 @@ 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 { import { emptyToNull, normalizePhone } from '../contacts/normalize';
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> = {
@@ -66,20 +54,13 @@ 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, '');
return d || null; if (!d) return null;
} if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`;
if (d.length === 10 && d.startsWith('9')) return `0${d}`;
function personWithMobile(organizationId: string, mobile: string): Prisma.PersonWhereInput { return d.startsWith('0') ? d : d;
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 {
@@ -148,11 +129,7 @@ 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) => [
@@ -171,77 +148,22 @@ 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 {
AND: [ organizationId: actor.organizationId,
{ ...leadAccessWhere(actor),
organizationId: actor.organizationId, archivedAt: opts.archived ? { not: null } : null,
...leadAccessWhere(actor), ...(opts.stageId ? { stageId: opts.stageId } : {}),
archivedAt: opts.archived ? { not: null } : null, ...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}),
...(opts.converted ? { convertedPersonId: { not: null } } : {}), ...(opts.unassigned ? { assigneeId: null } : {}),
...(opts.stageId ? { stageId: opts.stageId } : {}), ...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}), ...(opts.region
...(opts.region ? { region: { contains: opts.region, mode: 'insensitive' } }
? { region: { contains: opts.region, mode: 'insensitive' } } : {}),
: {}), ...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}),
...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}), ...(opts.source
...(opts.source ? { source: { contains: opts.source, mode: 'insensitive' } }
? { 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 } : {}),
},
}; };
} }
@@ -259,12 +181,8 @@ 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);
@@ -312,11 +230,7 @@ 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> {
@@ -408,7 +322,10 @@ export class LeadsService {
}; };
} }
const person = await this.prisma.person.findFirst({ const person = await this.prisma.person.findFirst({
where: personWithMobile(actor.organizationId, mobile), where: {
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) {
@@ -696,43 +613,24 @@ 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: nextPosition, position: position ?? 0,
...(stage.isConverted
? {}
: {}),
}, },
include: leadInclude, include: leadInclude,
}); });
if (placedIds) { await this.addEvent(
await writePositions( actor,
(ops) => this.prisma.$transaction(ops), id,
placedIds, 'stage_changed',
(leadId, leadPosition) => `به مرحله «${stage.name}» منتقل شد`,
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 },
@@ -1048,7 +946,10 @@ export class LeadsService {
} }
let person = await this.prisma.person.findFirst({ let person = await this.prisma.person.findFirst({
where: personWithMobile(actor.organizationId, lead.mobile), where: {
organizationId: actor.organizationId,
OR: [{ mobile: lead.mobile }, { phone: lead.mobile }],
},
}); });
let createdPerson = false; let createdPerson = false;
@@ -1075,7 +976,7 @@ export class LeadsService {
lastName: named.lastName, lastName: named.lastName,
gender: named.gender, gender: named.gender,
showGenderInName: named.showGenderInName, showGenderInName: named.showGenderInName,
mobile: canonicalIranMobile(lead.mobile) ?? lead.mobile, mobile: lead.mobile,
phone: lead.phone, phone: lead.phone,
instagram: lead.instagram, instagram: lead.instagram,
birthDate: lead.birthDate, birthDate: lead.birthDate,
@@ -1189,7 +1090,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: { AND: [base, { assigneeId: null }] } }), this.prisma.lead.count({ where: { ...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 } },
@@ -1249,95 +1150,6 @@ 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,
@@ -1380,7 +1192,10 @@ export class LeadsService {
} }
const existingPerson = await this.prisma.person.findFirst({ const existingPerson = await this.prisma.person.findFirst({
where: personWithMobile(organizationId, mobile), where: {
organizationId,
OR: [{ mobile }, { phone: mobile }],
},
select: { id: true }, select: { id: true },
}); });
if (existingPerson) { if (existingPerson) {
@@ -1572,16 +1387,11 @@ 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.trade?.trim() || '', company: lead.contactName ? lead.title : '',
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,5 +1,4 @@
import { import {
BadRequestException,
ForbiddenException, ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
@@ -71,16 +70,6 @@ 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;
@@ -89,9 +78,6 @@ 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,19 +56,7 @@ function testTemplate() {
}) === 'سلام علی 0912', }) === 'سلام علی 0912',
'name phone vars', 'name phone vars',
); );
assert( assert(clipSms('الف'.repeat(200)).length === 200, 'sms is not clipped');
applyMessageTemplate('تبریک {از طرف}', { from: 'هنراندیشه' }) ===
'تبریک هنراندیشه',
'from org var',
);
assert(
applyMessageTemplate('مانده {مانده ریالی} / {مانده فلزی}', {
rialBalance: '۱۰۰۰ بس',
metalBalance: 'طلا ۲',
}) === 'مانده ۱۰۰۰ بس / طلا ۲',
'balance vars',
);
assert(clipSms('ا'.repeat(200)).length === 200, 'sms is not clipped');
} }
testDelay(); testDelay();
@@ -77,17 +77,6 @@ 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: {
@@ -96,44 +85,22 @@ export function applyMessageTemplate(
phone?: string; phone?: string;
date?: string; date?: string;
company?: string; company?: string;
/** نام سازمان فرستنده */
from?: string;
/** مانده مالی قالب‌بندی‌شده */
rialBalance?: string;
/** مانده‌های وزنی/فلزی قالب‌بندی‌شده */
metalBalance?: string;
}, },
): string { ): string {
const from = vars.from ?? ''; const map: Record<string, string> = {
const rial = vars.rialBalance ?? ''; title: vars.title ?? '',
const metal = vars.metalBalance ?? ''; عنوان: vars.title ?? '',
const entries: Array<[string, string]> = [ نام: vars.name ?? '',
['title', vars.title ?? ''], name: vars.name ?? '',
['عنوان', vars.title ?? ''], شماره: vars.phone ?? '',
['نام', vars.name ?? ''], phone: vars.phone ?? '',
['name', vars.name ?? ''], تاریخ: vars.date ?? '',
['شماره', vars.phone ?? ''], date: vars.date ?? '',
['phone', vars.phone ?? ''], شرکت: vars.company ?? '',
['تاریخ', vars.date ?? ''], company: vars.company ?? '',
['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[normalizeTemplateKey(key)]; const next = map[key.trim()];
return next === undefined ? full : next; return next === undefined ? full : next;
}); });
} }
@@ -356,10 +356,6 @@ 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()
: ''; : '';
@@ -370,7 +366,6 @@ 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,7 +2,6 @@ import { Type } from 'class-transformer';
import { import {
ArrayUnique, ArrayUnique,
IsArray, IsArray,
IsBoolean,
IsIn, IsIn,
IsObject, IsObject,
IsOptional, IsOptional,
@@ -46,16 +45,6 @@ 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 {
@@ -88,16 +77,6 @@ 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,8 +32,6 @@ 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;
@@ -81,8 +79,6 @@ 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);
@@ -112,8 +108,6 @@ 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) }
@@ -141,8 +135,6 @@ 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);
@@ -167,12 +159,6 @@ 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 =
@@ -440,8 +426,6 @@ 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,12 +1,9 @@
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({
controllers: [SmsTemplatesController], providers: [OutboundSmsLogService],
providers: [OutboundSmsLogService, SmsTemplatesService], exports: [OutboundSmsLogService],
exports: [OutboundSmsLogService, SmsTemplatesService],
}) })
export class OutboundSmsModule {} export class OutboundSmsModule {}
@@ -1,48 +0,0 @@
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);
}
}
@@ -1,28 +0,0 @@
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;
}
@@ -1,92 +0,0 @@
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 { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize'; import { normalizePhone } 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 = storedPhoneNumber(row.personMobile); const mobile = normalizePhone(row.personMobile);
const mobileKeys = phoneIdentityKeys(mobile); const mobileCore = mobile ? mobile.replace(/^\+98|^98|^0/, '') : '';
const validMobile = (mobile ?? '').replace(/\D/g, '').length >= 8 ? mobile : null; const validMobile = mobileCore.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,10 +731,7 @@ export class DealsExcelService {
} }
if (!found && validMobile) { if (!found && validMobile) {
found = await this.prisma.person.findFirst({ found = await this.prisma.person.findFirst({
where: { where: { organizationId: orgId, mobile: { contains: mobileCore } },
organizationId: orgId,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
select: { id: true }, select: { id: true },
}); });
} }
+1 -23
View File
@@ -27,7 +27,6 @@ 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 } },
@@ -380,8 +379,7 @@ export class DealsService {
} }
const status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open'; const status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open';
let placedIds: string[] | null = null; const position =
let position =
dto.position ?? dto.position ??
(( ((
await this.prisma.deal.aggregate({ await this.prisma.deal.aggregate({
@@ -389,17 +387,6 @@ 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 },
@@ -415,15 +402,6 @@ 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,11 +31,8 @@ export class PipelinesController {
constructor(private readonly pipelinesService: PipelinesService) {} constructor(private readonly pipelinesService: PipelinesService) {}
@Get() @Get()
list( list(@CurrentUser() user: { organizationId: string }) {
@CurrentUser() user: Actor, return this.pipelinesService.list(user.organizationId);
@Query('forLens') forLens?: string,
) {
return this.pipelinesService.list(user, forLens === '1');
} }
@Get('board') @Get('board')
+9 -154
View File
@@ -4,10 +4,6 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { import type {
ActivityStatus,
ActivityType,
DealActivityCue,
DealActivitySummaryDto,
DealDto, DealDto,
MergePipelineResultDto, MergePipelineResultDto,
PipelineBoardDto, PipelineBoardDto,
@@ -106,55 +102,24 @@ export class PipelinesService {
return this.toPipelineDto(created); return this.toPipelineDto(created);
} }
async list(actorOrOrgId: Actor | string, forLens = false): Promise<PipelineDto[]> { async list(organizationId: string): 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 lens = actor?.priorLens; const counts = await this.prisma.deal.groupBy({
const lensActive = forLens && (lens === 'current' || lens === 'archive'); by: ['pipelineId'],
const access = actor ? dealAccessWhere(actor) : {}; where: { organizationId },
const prior = lensActive _count: { _all: true },
? 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]),
); );
const lensCountByPipeline = new Map( return rows.map((row) => ({
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: lensActive dealCount: dealCountByPipeline.get(row.id) ?? 0,
? (lensCountByPipeline.get(row.id) ?? 0)
: (dealCountByPipeline.get(row.id) ?? 0),
})); }));
} }
@@ -378,23 +343,12 @@ 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) { if (list) list.push(this.toDealDto(deal));
const hint = activityHints.get(deal.id);
list.push({
...this.toDealDto(deal),
activityCue: hint?.cue ?? 'none',
activitySummaries: hint?.summaries ?? [],
});
}
} }
return { return {
@@ -612,105 +566,6 @@ 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,
+1 -6
View File
@@ -13,7 +13,7 @@ import {
MinLength, MinLength,
ValidateIf, ValidateIf,
} from 'class-validator'; } from 'class-validator';
import { PRODUCT_PLAN_CODES, USER_ROLES, type ProductPlanCode, type UserRole } from '@mento/shared'; import { USER_ROLES, type UserRole } from '@mento/shared';
export class PlatformLoginDto { export class PlatformLoginDto {
@IsString() @IsString()
@@ -189,11 +189,6 @@ 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,7 +13,6 @@ 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,
@@ -169,11 +168,6 @@ 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,
+1 -18
View File
@@ -9,7 +9,6 @@ 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';
@@ -473,16 +472,6 @@ 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 },
@@ -543,9 +532,6 @@ 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') {
@@ -576,9 +562,6 @@ 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,
@@ -586,7 +569,7 @@ export class PlatformService {
); );
if (owners < 1) { if (owners < 1) {
throw new BadRequestException( throw new BadRequestException(
'مالک محیط همان کسی است که ثبت‌نام کرده و نمی‌توان نقش او را عوض کرد', 'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
); );
} }
} }
+6 -13
View File
@@ -19,7 +19,6 @@ 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';
@@ -63,7 +62,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 = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw); const mobile = normalizeMobile(mobileRaw);
if (!mobile) { if (!mobile) {
throw new BadRequestException('شماره موبایل معتبر نیست'); throw new BadRequestException('شماره موبایل معتبر نیست');
} }
@@ -124,8 +123,7 @@ 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 = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw); const mobile = 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('موبایل یا کد ناقص است');
@@ -150,15 +148,10 @@ export class PortalService {
data: { consumedAt: new Date() }, data: { consumedAt: new Date() },
}); });
let person = mobileKeys.length let person = await this.prisma.person.findFirst({
? await this.prisma.person.findFirst({ where: { organizationId: org.id, mobile },
where: { orderBy: { createdAt: 'asc' },
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 { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize'; import { normalizePhone } from '../contacts/normalize';
import { import {
excelFieldsFromColumns, excelFieldsFromColumns,
formatJalaliDate, formatJalaliDate,
@@ -256,13 +256,11 @@ export class ProjectCardsExcelService {
} }
private async findPersonId(organizationId: string, row: Record<string, string>) { private async findPersonId(organizationId: string, row: Record<string, string>) {
const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile)); const mobile = normalizePhone(row.personMobile);
if (mobileKeys.length) { if (mobile) {
const needle = mobile.replace(/^\+98|^98|^0/, '');
const byMobile = await this.prisma.person.findFirst({ const byMobile = await this.prisma.person.findFirst({
where: { where: { organizationId, mobile: { contains: needle } },
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,7 +22,6 @@ export class ApiKeyAuthGuard implements CanActivate {
organizationId: string; organizationId: string;
role: string; role: string;
apiKeyId?: string; apiKeyId?: string;
apiKeyName?: string;
}; };
}>(); }>();
@@ -45,7 +44,6 @@ 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;
} }
+2 -3
View File
@@ -63,7 +63,7 @@ export class ApiKeysService {
async authenticate( async authenticate(
rawKey: string, rawKey: string,
): Promise<{ organizationId: string; apiKeyId: string; name: string; role: 'owner' }> { ): Promise<{ organizationId: string; apiKeyId: 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, name: true }, select: { id: true, organizationId: true },
}); });
if (!row) throw new UnauthorizedException('کلید API نامعتبر است'); if (!row) throw new UnauthorizedException('کلید API نامعتبر است');
@@ -83,7 +83,6 @@ 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,55 +1,18 @@
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, MaxLength, Min, MinLength } from 'class-validator'; import { IsInt, IsOptional, IsString, Max, Min } 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()
@@ -72,36 +35,7 @@ class ListQueryDto {
@Controller('public/v1') @Controller('public/v1')
@UseGuards(ApiKeyAuthGuard) @UseGuards(ApiKeyAuthGuard)
export class PublicApiV1Controller { export class PublicApiV1Controller {
constructor( constructor(private readonly prisma: PrismaService) {}
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(
+1 -2
View File
@@ -1,7 +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 { 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';
@@ -10,7 +9,7 @@ import { WebhooksController } from './webhooks.controller';
import { WebhooksService } from './webhooks.service'; import { WebhooksService } from './webhooks.service';
@Module({ @Module({
imports: [AuthModule, BillingModule, LeadsModule], imports: [AuthModule, BillingModule],
controllers: [ApiKeysController, WebhooksController, PublicApiV1Controller], controllers: [ApiKeysController, WebhooksController, PublicApiV1Controller],
providers: [ApiKeysService, ApiKeyAuthGuard, WebhooksService], providers: [ApiKeysService, ApiKeyAuthGuard, WebhooksService],
exports: [ApiKeysService, WebhooksService], exports: [ApiKeysService, WebhooksService],
+266 -23
View File
@@ -18,7 +18,6 @@ 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,
@@ -55,7 +54,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 = customersPersonWhere(actor); const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
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,8 +1533,9 @@ export class ReportsService {
async home(actor: Actor): Promise<HomeDashboardDto> { async home(actor: Actor): Promise<HomeDashboardDto> {
const orgId = actor.organizationId; const orgId = actor.organizationId;
const dealScope = ownerWhere(actor, 'deals'); const dealScope = ownerWhere(actor, 'deals');
const invoiceScope = ownerWhere(actor, 'finance');
const activityScope = assigneeWhere(actor); const activityScope = assigneeWhere(actor);
const personScope = customersPersonWhere(actor); const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
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);
@@ -1544,6 +1544,7 @@ export class ReportsService {
const prev = addJalaliMonths(jy, jm, -1); const prev = addJalaliMonths(jy, jm, -1);
const prevMonthStart = startOfJalaliMonth(prev.jy, prev.jm); const prevMonthStart = startOfJalaliMonth(prev.jy, prev.jm);
const prevMonthEnd = endOfJalaliMonth(prev.jy, prev.jm); const prevMonthEnd = endOfJalaliMonth(prev.jy, prev.jm);
const trendWindowStart = addMonthsStart(now, -12);
const todayStart = startOfDay(now); const todayStart = startOfDay(now);
const todayEnd = endOfDay(now); const todayEnd = endOfDay(now);
@@ -1556,17 +1557,25 @@ export class ReportsService {
const dealsPrior = dealPriorWhere(actor.priorLens, actor.priorCutoff); const dealsPrior = dealPriorWhere(actor.priorLens, actor.priorCutoff);
const activitiesPrior = activityPriorWhere(actor.priorLens, actor.priorCutoff); const activitiesPrior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
const invoiceStatuses: Array<'issued' | 'partially_paid' | 'paid'> = [
'issued',
'partially_paid',
'paid',
];
const [ const [
wonDeals, wonDeals,
openDeals, openDealRows,
lostDeals,
staleDeals, staleDeals,
peopleCur, peopleRecent,
peoplePrev,
openActivitiesToday, openActivitiesToday,
openActivitiesYesterday, openActivitiesYesterday,
openDealsWeekAgo, openDealsWeekAgo,
goals, goals,
overdueCount, overdueCount,
invoices,
activityTypeGroups,
] = await Promise.all([ ] = await Promise.all([
this.prisma.deal.findMany({ this.prisma.deal.findMany({
where: { where: {
@@ -1574,7 +1583,7 @@ export class ReportsService {
{ {
organizationId: orgId, organizationId: orgId,
status: 'won', status: 'won',
updatedAt: { gte: addMonthsStart(now, -5) }, updatedAt: { gte: trendWindowStart },
}, },
dealScope, dealScope,
dealsPrior, dealsPrior,
@@ -1591,7 +1600,7 @@ export class ReportsService {
person: { select: { id: true, firstName: true, lastName: true } }, person: { select: { id: true, firstName: true, lastName: true } },
}, },
}), }),
this.prisma.deal.count({ this.prisma.deal.findMany({
where: { where: {
AND: [ AND: [
{ organizationId: orgId, status: 'open' }, { organizationId: orgId, status: 'open' },
@@ -1599,6 +1608,30 @@ export class ReportsService {
dealsPrior, dealsPrior,
], ],
}, },
select: {
amount: true,
currency: true,
stageId: true,
stage: { select: { id: true, name: true, position: true } },
},
}),
this.prisma.deal.findMany({
where: {
AND: [
{
organizationId: orgId,
status: 'lost',
updatedAt: { gte: trendWindowStart },
},
dealScope,
dealsPrior,
],
},
select: {
amount: true,
currency: true,
lostReason: true,
},
}), }),
this.prisma.deal.findMany({ this.prisma.deal.findMany({
where: { where: {
@@ -1625,19 +1658,13 @@ export class ReportsService {
orderBy: { updatedAt: 'asc' }, orderBy: { updatedAt: 'asc' },
take: 5, take: 5,
}), }),
this.prisma.person.count({ this.prisma.person.findMany({
where: { where: {
organizationId: orgId, organizationId: orgId,
...personScope, ...personScope,
createdAt: { gte: curMonthStart, lte: curMonthEnd }, createdAt: { gte: trendWindowStart, lte: curMonthEnd },
},
}),
this.prisma.person.count({
where: {
organizationId: orgId,
...personScope,
createdAt: { gte: prevMonthStart, lte: prevMonthEnd },
}, },
select: { createdAt: true, importSource: true },
}), }),
this.prisma.activity.count({ this.prisma.activity.count({
where: { where: {
@@ -1657,6 +1684,7 @@ export class ReportsService {
AND: [ AND: [
{ {
organizationId: orgId, organizationId: orgId,
status: 'open',
dueAt: { gte: yesterdayStart, lte: yesterdayEnd }, dueAt: { gte: yesterdayStart, lte: yesterdayEnd },
}, },
activityScope, activityScope,
@@ -1698,8 +1726,67 @@ export class ReportsService {
], ],
}, },
}), }),
this.prisma.invoice.findMany({
where: {
organizationId: orgId,
...invoiceScope,
status: { in: invoiceStatuses },
OR: [
{ issuedAt: { gte: trendWindowStart, lte: curMonthEnd } },
{ createdAt: { gte: trendWindowStart, lte: curMonthEnd } },
],
},
select: {
issuedAt: true,
createdAt: true,
lineItems: {
select: {
quantity: true,
unitPrice: true,
currency: true,
},
},
},
}),
this.prisma.activity.groupBy({
by: ['type'],
where: {
AND: [
{
organizationId: orgId,
status: 'open',
},
activityScope,
activitiesPrior,
],
},
_count: { _all: true },
}),
]); ]);
const openDeals = openDealRows.length;
const invoiceInRange = (
inv: { issuedAt: Date | null; createdAt: Date },
from: Date,
to: Date,
) => {
const anchor = inv.issuedAt ?? inv.createdAt;
return anchor >= from && anchor <= to;
};
const sumInvoiceInRange = (from: Date, to: Date) => {
let amount = 0;
for (const inv of invoices) {
if (!invoiceInRange(inv, from, to)) continue;
for (const line of inv.lineItems) {
const lineTotal = Number(line.quantity) * Number(line.unitPrice);
amount += toBaseAmount(lineTotal, line.currency, rates);
}
}
return amount;
};
const sumWonInRange = (start: Date, end: Date) => const sumWonInRange = (start: Date, end: Date) =>
wonDeals wonDeals
.filter((d) => d.updatedAt >= start && d.updatedAt <= end) .filter((d) => d.updatedAt >= start && d.updatedAt <= end)
@@ -1708,8 +1795,21 @@ export class ReportsService {
0, 0,
); );
const salesCur = sumWonInRange(curMonthStart, curMonthEnd); const salesInRange = (start: Date, end: Date) => {
const salesPrev = sumWonInRange(prevMonthStart, prevMonthEnd); const inv = sumInvoiceInRange(start, end);
const won = sumWonInRange(start, end);
return Math.round(inv > 0 ? inv : won);
};
const peopleCur = peopleRecent.filter(
(p) => p.createdAt >= curMonthStart && p.createdAt <= curMonthEnd,
).length;
const peoplePrev = peopleRecent.filter(
(p) => p.createdAt >= prevMonthStart && p.createdAt <= prevMonthEnd,
).length;
const salesCur = salesInRange(curMonthStart, curMonthEnd);
const salesPrev = salesInRange(prevMonthStart, prevMonthEnd);
const targetAmount = goals.reduce( const targetAmount = goals.reduce(
(acc, g) => acc + Number(g.targetAmount), (acc, g) => acc + Number(g.targetAmount),
0, 0,
@@ -1717,7 +1817,7 @@ export class ReportsService {
const trendMonths: Array<{ key: string; label: string; start: Date; end: Date }> = const trendMonths: Array<{ key: string; label: string; start: Date; end: Date }> =
[]; [];
for (let i = 4; i >= 0; i -= 1) { for (let i = 11; i >= 0; i -= 1) {
const m = addJalaliMonths(jy, jm, -i); const m = addJalaliMonths(jy, jm, -i);
trendMonths.push({ trendMonths.push({
key: `${m.jy}-${String(m.jm).padStart(2, '0')}`, key: `${m.jy}-${String(m.jm).padStart(2, '0')}`,
@@ -1730,14 +1830,28 @@ export class ReportsService {
const salesTrend = trendMonths.map((m) => ({ const salesTrend = trendMonths.map((m) => ({
monthKey: m.key, monthKey: m.key,
monthLabel: m.label, monthLabel: m.label,
amount: Math.round(sumWonInRange(m.start, m.end)), amount: salesInRange(m.start, m.end),
}));
const customerGrowth = trendMonths.map((m) => ({
monthKey: m.key,
monthLabel: m.label,
value: peopleRecent.filter(
(p) => p.createdAt >= m.start && p.createdAt <= m.end,
).length,
})); }));
const sourceMap = new Map<string, number>(); const sourceMap = new Map<string, number>();
for (const d of wonDeals) { for (const p of peopleRecent) {
const label = normalizeSource(d.source); const label = normalizeImportSource(p.importSource);
sourceMap.set(label, (sourceMap.get(label) ?? 0) + 1); sourceMap.set(label, (sourceMap.get(label) ?? 0) + 1);
} }
if (sourceMap.size === 0) {
for (const d of wonDeals) {
const label = normalizeSource(d.source);
sourceMap.set(label, (sourceMap.get(label) ?? 0) + 1);
}
}
const sourceTotal = [...sourceMap.values()].reduce((a, b) => a + b, 0) || 1; const sourceTotal = [...sourceMap.values()].reduce((a, b) => a + b, 0) || 1;
const sources = [...sourceMap.entries()] const sources = [...sourceMap.entries()]
.map(([label, count]) => ({ .map(([label, count]) => ({
@@ -1781,6 +1895,101 @@ export class ReportsService {
.slice(0, 5) .slice(0, 5)
.map((row, index) => ({ ...row, rank: index + 1 })); .map((row, index) => ({ ...row, rank: index + 1 }));
const wonAmountAll = Math.round(
wonDeals.reduce(
(acc, d) => acc + decimalToBase(d.amount, d.currency, rates),
0,
),
);
const lostAmountAll = Math.round(
lostDeals.reduce(
(acc, d) => acc + decimalToBase(d.amount, d.currency, rates),
0,
),
);
const openAmountAll = Math.round(
openDealRows.reduce(
(acc, d) => acc + decimalToBase(d.amount, d.currency, rates),
0,
),
);
const closedCount = wonDeals.length + lostDeals.length;
const winRate = closedCount
? Math.round((wonDeals.length / closedCount) * 1000) / 10
: 0;
const salesSummary: HomeDashboardDto['salesSummary'] = {
openCount: openDeals,
wonCount: wonDeals.length,
lostCount: lostDeals.length,
openAmount: openAmountAll,
wonAmount: wonAmountAll,
lostAmount: lostAmountAll,
winRate,
};
const funnelMap = new Map<
string,
{ stageId: string; stageName: string; position: number; count: number; amount: number }
>();
for (const d of openDealRows) {
const stageId = d.stage?.id ?? d.stageId ?? 'unknown';
const stageName = d.stage?.name ?? 'بدون مرحله';
const position = d.stage?.position ?? 999;
const cur = funnelMap.get(stageId) ?? {
stageId,
stageName,
position,
count: 0,
amount: 0,
};
cur.count += 1;
cur.amount += decimalToBase(d.amount, d.currency, rates);
funnelMap.set(stageId, cur);
}
const funnel = [...funnelMap.values()]
.sort((a, b) => a.position - b.position)
.slice(0, 8)
.map((row) => ({
stageId: row.stageId,
stageName: row.stageName,
count: row.count,
amount: Math.round(row.amount),
}));
const lostReasonMap = new Map<string, { count: number; amount: number }>();
for (const d of lostDeals) {
const label = normalizeLostReason(d.lostReason);
const prevRow = lostReasonMap.get(label) ?? { count: 0, amount: 0 };
prevRow.count += 1;
prevRow.amount += decimalToBase(d.amount, d.currency, rates);
lostReasonMap.set(label, prevRow);
}
const lostReasonTotal =
[...lostReasonMap.values()].reduce((a, b) => a + b.count, 0) || 1;
const lostReasons = [...lostReasonMap.entries()]
.map(([label, row]) => ({
label,
count: row.count,
amount: Math.round(row.amount),
percent: Math.round((row.count / lostReasonTotal) * 1000) / 10,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 6);
const activityTypeLabel: Record<string, string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'کار',
note: 'یادداشت',
};
const activityByType = activityTypeGroups
.map((row) => ({
type: row.type,
label: activityTypeLabel[row.type] ?? row.type,
count: row._count._all,
}))
.sort((a, b) => b.count - a.count);
const alerts: HomeDashboardDto['alerts'] = []; const alerts: HomeDashboardDto['alerts'] = [];
if (staleDeals.length) { if (staleDeals.length) {
const first = staleDeals[0]!; const first = staleDeals[0]!;
@@ -1858,11 +2067,30 @@ export class ReportsService {
changePercent: pctChange(openActivitiesToday, openActivitiesYesterday), changePercent: pctChange(openActivitiesToday, openActivitiesYesterday),
changeLabel: 'نسبت به دیروز', changeLabel: 'نسبت به دیروز',
}, },
{
key: 'win-rate',
label: 'نرخ برد',
value: winRate,
changePercent: null,
changeLabel: '۱۲ ماه اخیر',
},
{
key: 'overdue',
label: 'عقب‌افتاده',
value: overdueCount,
changePercent: null,
changeLabel: 'فعالیت باز',
},
], ],
alerts, alerts,
salesTrend, salesTrend,
customerGrowth,
sources, sources,
topCustomers, topCustomers,
salesSummary,
funnel,
lostReasons,
activityByType,
}; };
} }
} }
@@ -1920,6 +2148,21 @@ function normalizeSource(raw: string | null | undefined): string {
return map[value.toLowerCase()] ?? value; return map[value.toLowerCase()] ?? value;
} }
function normalizeImportSource(raw: string | null | undefined): string {
const value = raw?.trim().toLowerCase();
if (!value) return 'سایر';
const map: Record<string, string> = {
manual: 'دستی',
tahesab: 'ته‌حساب',
didar: 'دیدار',
excel: 'اکسل',
phone: 'تلفن',
lead_form: 'فرم لید',
other: 'سایر',
};
return map[value] ?? normalizeSource(raw);
}
function normalizeLostReason(raw: string | null | undefined): string { function normalizeLostReason(raw: string | null | undefined): string {
const value = raw?.trim(); const value = raw?.trim();
if (!value) return 'بدون دلیل ثبت‌شده'; if (!value) return 'بدون دلیل ثبت‌شده';
-3
View File
@@ -41,7 +41,6 @@ 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;
@@ -186,8 +185,6 @@ 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,
+13 -29
View File
@@ -200,7 +200,8 @@ 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);
const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto)); assertManager(scope);
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,
@@ -215,9 +216,10 @@ 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.assertCanWriteRoutine(scope, existing); this.assertCanManageRoutine(scope, existing);
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.update({ const row = await this.prisma.routine.update({
where: { id }, where: { id },
data: data as Prisma.RoutineUncheckedUpdateInput, data: data as Prisma.RoutineUncheckedUpdateInput,
@@ -230,8 +232,9 @@ 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.assertCanWriteRoutine(scope, existing); this.assertCanManageRoutine(scope, existing);
const row = await this.prisma.routine.update({ const row = await this.prisma.routine.update({
where: { id }, where: { id },
data: { isActive }, data: { isActive },
@@ -248,8 +251,9 @@ 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.assertCanWriteRoutine(scope, existing); this.assertCanManageRoutine(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({
@@ -264,8 +268,9 @@ 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.assertCanWriteRoutine(scope, src); this.assertCanManageRoutine(scope, src);
const { const {
id: _id, id: _id,
createdAt: _c, createdAt: _c,
@@ -273,7 +278,6 @@ 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;
@@ -291,8 +295,9 @@ 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.assertCanWriteRoutine(scope, existing); this.assertCanManageRoutine(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 },
@@ -340,27 +345,6 @@ 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, org] = await Promise.all([ const [people, leads] = await Promise.all([
personIds.length personIds.length
? this.prisma.person.findMany({ ? this.prisma.person.findMany({
where: { id: { in: personIds } }, where: { id: { in: personIds } },
@@ -535,14 +535,9 @@ 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;
@@ -577,7 +572,6 @@ 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
@@ -1,212 +0,0 @@
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),
};
}
+10 -65
View File
@@ -1,84 +1,29 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import {
parseUserTreasuryAccess,
permissionsWithTreasuryOverride,
treasurySectionAllowed,
type TreasurySectionKey,
} from '@mento/shared';
import type { Actor } from '../common/record-scope';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
/**
* حقوق فقط با 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<{ const req = context.switchToHttp().getRequest<{ user?: Actor; originalUrl?: string; url?: string }>();
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: { select: { orgRole: { select: { permissions: true } } },
orgRole: { select: { permissions: true } },
treasuryAccessOverride: true,
},
}); });
const rolePermissions = Array.isArray(membership?.orgRole?.permissions) const permissions = Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.map(String) ? membership.orgRole.permissions.map(String)
: []; : [];
const permissions = permissionsWithTreasuryOverride( const allowed = payroll
rolePermissions, ? permissions.includes('treasury_payroll')
parseUserTreasuryAccess(membership?.treasuryAccessOverride), : permissions.includes('treasury') || permissions.includes('treasury_payroll');
); 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';
}
+7 -218
View File
@@ -2,10 +2,8 @@ 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);
@@ -13,20 +11,11 @@ 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);
} }
@@ -46,15 +35,9 @@ 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: { include: { movements: true },
movements: {
where: { voidedAt: null },
include: { account: { select: { name: true } } },
orderBy: { occurredAt: 'desc' },
},
},
orderBy: { dueDate: 'desc' }, orderBy: { dueDate: 'desc' },
take: 500, take: 100,
}); });
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) } },
@@ -71,11 +54,8 @@ 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;
}, },
@@ -100,23 +80,14 @@ 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: { include: { movements: true },
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 ?? '');
@@ -152,7 +123,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 = payrollStatus(row.payable, paid.plus(amount)); const status = paid.plus(amount).equals(row.payable) ? 'settled' : 'partial';
await this.prisma.$transaction([ await this.prisma.$transaction([
this.prisma.financialMovement.create({ this.prisma.financialMovement.create({
data: { data: {
@@ -173,123 +144,6 @@ 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 },
@@ -460,20 +314,13 @@ 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( async reportExcel(actor: Actor, from?: string, to?: string) {
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],
@@ -484,41 +331,10 @@ 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: {
@@ -558,15 +374,10 @@ 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( operatingProfit: money(bookedIncome.minus(bookedExpense)),
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)),
@@ -627,22 +438,12 @@ 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<{ movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>;
id: string;
amount: Prisma.Decimal;
voidedAt: Date | null;
accountId: string;
occurredAt: Date;
account?: { name: string } | null;
}>;
}, },
fullName: string, fullName: string,
) { ) {
@@ -655,25 +456,13 @@ 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 ?? '',
})),
}; };
} }

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