Compare commits
2
Commits
main
..
39c62834e7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39c62834e7 | ||
|
|
26792f9a37 |
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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}"
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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
|
|
||||||
@@ -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-*)` پاکسازی شده است؟
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"prisma:seed": "tsx prisma/seed.ts",
|
"prisma:seed": "tsx prisma/seed.ts",
|
||||||
"lint": "echo \"api lint placeholder\"",
|
"lint": "echo \"api lint placeholder\"",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
|
||||||
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts test/behavior.test.ts test/attendance-time.test.ts test/sales-factors.test.ts test/platform-admin-alert.test.ts test/person-name.test.ts"
|
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts test/behavior.test.ts test/attendance-time.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mento/shared": "*",
|
"@mento/shared": "*",
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
-- تیکهای مجوز نقش از این نسخه واقعاً اعمال میشوند.
|
|
||||||
-- تا امروز همهٔ نقشها عملاً به این بخشها دسترسی داشتند؛ همان دسترسی را برای نقشهای موجود حفظ میکنیم.
|
|
||||||
UPDATE "OrgRole"
|
|
||||||
SET "permissions" = ARRAY(
|
|
||||||
SELECT DISTINCT p
|
|
||||||
FROM unnest(
|
|
||||||
"permissions" || ARRAY['customers', 'leads', 'sales', 'billing', 'products', 'activities', 'notes', 'reports']
|
|
||||||
) AS p
|
|
||||||
);
|
|
||||||
|
|
||||||
-- لاگ فعالیت کاربران تا امروز فقط برای مالک و مدیر باز بود (مستقل از تیک)؛ همان را حفظ میکنیم.
|
|
||||||
UPDATE "OrgRole"
|
|
||||||
SET "permissions" = array_remove("permissions", 'audit')
|
|
||||||
WHERE "systemKey" IS NULL OR "systemKey" NOT IN ('owner', 'admin');
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Membership" ADD COLUMN "blockLoginAfterLogout" BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "CustomerGroup" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"organizationId" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"color" TEXT NOT NULL DEFAULT 'teal',
|
|
||||||
"position" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"tahesabConnectionId" TEXT,
|
|
||||||
"tahesabGid" TEXT,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "CustomerGroup_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "PersonGroupLink" (
|
|
||||||
"personId" TEXT NOT NULL,
|
|
||||||
"groupId" TEXT NOT NULL,
|
|
||||||
"source" TEXT NOT NULL DEFAULT 'manual',
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "PersonGroupLink_pkey" PRIMARY KEY ("personId","groupId")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "CompanyGroupLink" (
|
|
||||||
"companyId" TEXT NOT NULL,
|
|
||||||
"groupId" TEXT NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "CompanyGroupLink_pkey" PRIMARY KEY ("companyId","groupId")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "CustomerGroup_organizationId_name_key" ON "CustomerGroup"("organizationId", "name");
|
|
||||||
CREATE INDEX "CustomerGroup_organizationId_position_idx" ON "CustomerGroup"("organizationId", "position");
|
|
||||||
CREATE INDEX "CustomerGroup_tahesabConnectionId_tahesabGid_idx" ON "CustomerGroup"("tahesabConnectionId", "tahesabGid");
|
|
||||||
CREATE INDEX "PersonGroupLink_groupId_idx" ON "PersonGroupLink"("groupId");
|
|
||||||
CREATE INDEX "CompanyGroupLink_groupId_idx" ON "CompanyGroupLink"("groupId");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "CustomerGroup" ADD CONSTRAINT "CustomerGroup_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "PersonGroupLink" ADD CONSTRAINT "PersonGroupLink_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "PersonGroupLink" ADD CONSTRAINT "PersonGroupLink_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "CustomerGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "CompanyGroupLink" ADD CONSTRAINT "CompanyGroupLink_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
ALTER TABLE "CompanyGroupLink" ADD CONSTRAINT "CompanyGroupLink_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "CustomerGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- تبدیل دادههای قبلی: «گروه مشتری» اکسل (customFields.group) و «گروهبندی شرکت» (groupName).
|
|
||||||
-- مقدارهای چندتایی با «،» «,» «/» جدا میشوند. برچسبهای «وضعیت مشتری» که دیدار در همان ستون میگذارد گروه حساب نمیشوند.
|
|
||||||
CREATE TEMP TABLE "_cg_src" AS
|
|
||||||
SELECT p."organizationId" AS org, 'person' AS kind, p."id" AS ref, left(btrim(x), 80) AS name
|
|
||||||
FROM "Person" p
|
|
||||||
CROSS JOIN LATERAL regexp_split_to_table(p."customFields"->>'group', '\s*[،,/]\s*') AS x
|
|
||||||
WHERE COALESCE(p."customFields"->>'group', '') <> ''
|
|
||||||
UNION ALL
|
|
||||||
SELECT c."organizationId", 'company', c."id", left(btrim(x), 80)
|
|
||||||
FROM "Company" c
|
|
||||||
CROSS JOIN LATERAL regexp_split_to_table(c."groupName", '\s*[،,/]\s*') AS x
|
|
||||||
WHERE COALESCE(c."groupName", '') <> '';
|
|
||||||
|
|
||||||
DELETE FROM "_cg_src"
|
|
||||||
WHERE name = ''
|
|
||||||
OR name IN ('مشتری تازه وارد', 'مشتری خوشحال', 'مشتری ناراضی', 'مشتری خاص');
|
|
||||||
|
|
||||||
INSERT INTO "CustomerGroup" ("id", "organizationId", "name", "color", "position", "createdAt", "updatedAt")
|
|
||||||
SELECT 'cgrp_' || md5(d.org || '|' || d.name), d.org, d.name, 'teal', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
|
||||||
FROM (SELECT DISTINCT org, name FROM "_cg_src") AS d
|
|
||||||
ON CONFLICT ("organizationId", "name") DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO "PersonGroupLink" ("personId", "groupId", "source", "createdAt")
|
|
||||||
SELECT DISTINCT s.ref, g."id", 'manual', CURRENT_TIMESTAMP
|
|
||||||
FROM "_cg_src" s
|
|
||||||
JOIN "CustomerGroup" g ON g."organizationId" = s.org AND g."name" = s.name
|
|
||||||
WHERE s.kind = 'person'
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO "CompanyGroupLink" ("companyId", "groupId", "createdAt")
|
|
||||||
SELECT DISTINCT s.ref, g."id", CURRENT_TIMESTAMP
|
|
||||||
FROM "_cg_src" s
|
|
||||||
JOIN "CustomerGroup" g ON g."organizationId" = s.org AND g."name" = s.name
|
|
||||||
WHERE s.kind = 'company'
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
DROP TABLE "_cg_src";
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
-- Deep data access: role default + membership override (phase 1: customers)
|
|
||||||
|
|
||||||
ALTER TABLE "OrgRole" ADD COLUMN IF NOT EXISTS "dataAccessPolicy" JSONB NOT NULL DEFAULT '{}';
|
|
||||||
|
|
||||||
ALTER TABLE "Membership" ADD COLUMN IF NOT EXISTS "dataAccessOverride" JSONB NOT NULL DEFAULT '{}';
|
|
||||||
|
|
||||||
-- Migrate legacy own/team/org (+ optional contacts scope) into customers override
|
|
||||||
UPDATE "Membership" m
|
|
||||||
SET "dataAccessOverride" = jsonb_build_object(
|
|
||||||
'customers',
|
|
||||||
CASE
|
|
||||||
WHEN COALESCE(
|
|
||||||
NULLIF(m."dataVisibilityScopes"::jsonb ->> 'contacts', ''),
|
|
||||||
m."dataVisibility"
|
|
||||||
) = 'org' THEN '{"mode":"all","rules":[]}'::jsonb
|
|
||||||
WHEN COALESCE(
|
|
||||||
NULLIF(m."dataVisibilityScopes"::jsonb ->> 'contacts', ''),
|
|
||||||
m."dataVisibility"
|
|
||||||
) = 'team' THEN '{"mode":"rules","rules":[{"owners":["self","team"]}]}'::jsonb
|
|
||||||
ELSE '{"mode":"rules","rules":[{"owners":["self"]}]}'::jsonb
|
|
||||||
END
|
|
||||||
)
|
|
||||||
WHERE m."role" NOT IN ('owner', 'admin')
|
|
||||||
AND (
|
|
||||||
m."dataAccessOverride" IS NULL
|
|
||||||
OR m."dataAccessOverride"::text IN ('{}', 'null')
|
|
||||||
);
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Membership" ADD COLUMN "loginOtpOnly" BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "AccountingAsnadLine" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"organizationId" TEXT NOT NULL,
|
|
||||||
"connectionId" TEXT NOT NULL,
|
|
||||||
"personId" TEXT,
|
|
||||||
"linkId" TEXT,
|
|
||||||
"moshtariCode" INTEGER NOT NULL,
|
|
||||||
"lineKey" TEXT NOT NULL,
|
|
||||||
"tahesabId" INTEGER,
|
|
||||||
"factorCode" TEXT,
|
|
||||||
"shFactor" INTEGER,
|
|
||||||
"radif" INTEGER,
|
|
||||||
"tarikh" TEXT NOT NULL,
|
|
||||||
"noRaw" TEXT NOT NULL DEFAULT '',
|
|
||||||
"kind" TEXT NOT NULL DEFAULT 'other',
|
|
||||||
"jensFelez" INTEGER,
|
|
||||||
"noKar" TEXT,
|
|
||||||
"vazn" DOUBLE PRECISION,
|
|
||||||
"tabdilVazn" DOUBLE PRECISION,
|
|
||||||
"mali" DOUBLE PRECISION,
|
|
||||||
"count" DOUBLE PRECISION,
|
|
||||||
"ayar" DOUBLE PRECISION,
|
|
||||||
"mazaneh" DOUBLE PRECISION,
|
|
||||||
"fi" DOUBLE PRECISION,
|
|
||||||
"sharh1" TEXT,
|
|
||||||
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "AccountingAsnadLine_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "AccountingAsnadLine_connectionId_lineKey_key" ON "AccountingAsnadLine"("connectionId", "lineKey");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "AccountingAsnadLine_organizationId_connectionId_tarikh_idx" ON "AccountingAsnadLine"("organizationId", "connectionId", "tarikh");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "AccountingAsnadLine_organizationId_kind_idx" ON "AccountingAsnadLine"("organizationId", "kind");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "AccountingAsnadLine_personId_idx" ON "AccountingAsnadLine"("personId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "AccountingAsnadLine_linkId_idx" ON "AccountingAsnadLine"("linkId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "AccountingAsnadLine_moshtariCode_idx" ON "AccountingAsnadLine"("moshtariCode");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "AccountingConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_linkId_fkey" FOREIGN KEY ("linkId") REFERENCES "PersonAccountingLink"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "TahesabInvoiceSettings" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"organizationId" TEXT NOT NULL,
|
|
||||||
"logoDataUrl" TEXT,
|
|
||||||
"companyName" TEXT NOT NULL DEFAULT '',
|
|
||||||
"companySubtitle" TEXT NOT NULL DEFAULT '',
|
|
||||||
"address" TEXT NOT NULL DEFAULT '',
|
|
||||||
"phone" TEXT NOT NULL DEFAULT '',
|
|
||||||
"headerText" TEXT NOT NULL DEFAULT '',
|
|
||||||
"footerText" TEXT NOT NULL DEFAULT '',
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "TahesabInvoiceSettings_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "TahesabInvoiceSettings_organizationId_key" ON "TahesabInvoiceSettings"("organizationId");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "TahesabInvoiceSettings" ADD CONSTRAINT "TahesabInvoiceSettings_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
ALTER TABLE "CustomerGroup" ADD COLUMN IF NOT EXISTS "parentId" TEXT;
|
|
||||||
CREATE INDEX IF NOT EXISTS "CustomerGroup_parentId_idx" ON "CustomerGroup"("parentId");
|
|
||||||
DO $$ BEGIN
|
|
||||||
ALTER TABLE "CustomerGroup"
|
|
||||||
ADD CONSTRAINT "CustomerGroup_parentId_fkey"
|
|
||||||
FOREIGN KEY ("parentId") REFERENCES "CustomerGroup"("id")
|
|
||||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN NULL;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
ALTER TABLE "AccountingAsnadLine" ADD COLUMN IF NOT EXISTS "darsad" DOUBLE PRECISION;
|
|
||||||
ALTER TABLE "AccountingAsnadLine" ADD COLUMN IF NOT EXISTS "darsadTalayee" DOUBLE PRECISION;
|
|
||||||
ALTER TABLE "AccountingAsnadLine" ADD COLUMN IF NOT EXISTS "maliatDarsad" DOUBLE PRECISION;
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "ActivityImage" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"organizationId" TEXT NOT NULL,
|
|
||||||
"activityId" TEXT NOT NULL,
|
|
||||||
"fileName" TEXT NOT NULL,
|
|
||||||
"mimeType" TEXT NOT NULL,
|
|
||||||
"sizeBytes" INTEGER NOT NULL,
|
|
||||||
"storageKey" TEXT NOT NULL,
|
|
||||||
"uploadedById" TEXT,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "ActivityImage_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "ActivityImage_activityId_createdAt_idx" ON "ActivityImage"("activityId", "createdAt");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "ActivityImage_organizationId_idx" ON "ActivityImage"("organizationId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "ActivityImage_uploadedById_idx" ON "ActivityImage"("uploadedById");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_activityId_fkey" FOREIGN KEY ("activityId") REFERENCES "Activity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
-- پیامک پایان اشتراک فقط برای انقضاهای بعدی؛ حسابهای قبلاً تمامشده دوباره خبر نمیدهند
|
|
||||||
ALTER TABLE "BillingAccount" ADD COLUMN "expiryNotifiedAt" TIMESTAMP(3);
|
|
||||||
|
|
||||||
UPDATE "BillingAccount"
|
|
||||||
SET "expiryNotifiedAt" = CURRENT_TIMESTAMP
|
|
||||||
WHERE "planCode" = 'lifetime'
|
|
||||||
OR "status" = 'expired'
|
|
||||||
OR "expiresAt" < CURRENT_TIMESTAMP;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
CREATE TABLE "ActivityBoardMember" (
|
|
||||||
"boardId" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "ActivityBoardMember_pkey" PRIMARY KEY ("boardId","userId")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX "ActivityBoardMember_userId_idx" ON "ActivityBoardMember"("userId");
|
|
||||||
|
|
||||||
ALTER TABLE "ActivityBoardMember" ADD CONSTRAINT "ActivityBoardMember_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "ActivityBoard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
ALTER TABLE "ActivityBoardMember" ADD CONSTRAINT "ActivityBoardMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
ALTER TABLE "Person" ADD COLUMN "gender" TEXT NOT NULL DEFAULT 'none';
|
|
||||||
ALTER TABLE "Person" ADD COLUMN "showGenderInName" BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
|
|
||||||
-- نامهایی که از تهحساب با عنوان آمدهاند؛ خود متن نام دست نمیخورد
|
|
||||||
UPDATE "Person"
|
|
||||||
SET "gender" = 'male', "showGenderInName" = true
|
|
||||||
WHERE regexp_replace(
|
|
||||||
regexp_replace(btrim("firstName" || ' ' || "lastName"), 'ي', 'ی', 'g'),
|
|
||||||
'ك',
|
|
||||||
'ک',
|
|
||||||
'g'
|
|
||||||
) ~ '^جناب[[:space:]]*آقای([[:space:]]|$)';
|
|
||||||
|
|
||||||
UPDATE "Person"
|
|
||||||
SET "gender" = 'female', "showGenderInName" = true
|
|
||||||
WHERE "gender" = 'none'
|
|
||||||
AND regexp_replace(
|
|
||||||
regexp_replace(btrim("firstName" || ' ' || "lastName"), 'ي', 'ی', 'g'),
|
|
||||||
'ك',
|
|
||||||
'ک',
|
|
||||||
'g'
|
|
||||||
) ~ '^سرکار[[:space:]]*خانم([[:space:]]|$)';
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
ALTER TABLE "Lead" ADD COLUMN "gender" TEXT NOT NULL DEFAULT 'none';
|
|
||||||
ALTER TABLE "Lead" ADD COLUMN "showGenderInName" BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
|
|
||||||
-- عنوان داخل نام مخاطب، و اگر مخاطب خالی است داخل عنوان لید
|
|
||||||
UPDATE "Lead"
|
|
||||||
SET "gender" = 'male', "showGenderInName" = true
|
|
||||||
WHERE regexp_replace(
|
|
||||||
regexp_replace(
|
|
||||||
btrim(CASE WHEN btrim(coalesce("contactName", '')) <> '' THEN "contactName" ELSE "title" END),
|
|
||||||
'ي',
|
|
||||||
'ی',
|
|
||||||
'g'
|
|
||||||
),
|
|
||||||
'ك',
|
|
||||||
'ک',
|
|
||||||
'g'
|
|
||||||
) ~ '^جناب[[:space:]]*آقای([[:space:]]|$)';
|
|
||||||
|
|
||||||
UPDATE "Lead"
|
|
||||||
SET "gender" = 'female', "showGenderInName" = true
|
|
||||||
WHERE "gender" = 'none'
|
|
||||||
AND regexp_replace(
|
|
||||||
regexp_replace(
|
|
||||||
btrim(CASE WHEN btrim(coalesce("contactName", '')) <> '' THEN "contactName" ELSE "title" END),
|
|
||||||
'ي',
|
|
||||||
'ی',
|
|
||||||
'g'
|
|
||||||
),
|
|
||||||
'ك',
|
|
||||||
'ک',
|
|
||||||
'g'
|
|
||||||
) ~ '^سرکار[[:space:]]*خانم([[:space:]]|$)';
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
ALTER TYPE "CustomFieldEntity" ADD VALUE IF NOT EXISTS 'note';
|
|
||||||
|
|
||||||
ALTER TABLE "Note" ADD COLUMN IF NOT EXISTS "customFields" JSONB NOT NULL DEFAULT '{}';
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
CREATE TABLE "NoteCategory" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"organizationId" TEXT NOT NULL,
|
|
||||||
"key" TEXT NOT NULL,
|
|
||||||
"label" TEXT NOT NULL,
|
|
||||||
"tone" TEXT NOT NULL DEFAULT 'general',
|
|
||||||
"position" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
"showInBar" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
"isSystem" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "NoteCategory_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "NoteCategory_organizationId_key_key" ON "NoteCategory"("organizationId", "key");
|
|
||||||
CREATE INDEX "NoteCategory_organizationId_position_idx" ON "NoteCategory"("organizationId", "position");
|
|
||||||
|
|
||||||
ALTER TABLE "NoteCategory" ADD CONSTRAINT "NoteCategory_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
ALTER TABLE "Organization" ADD COLUMN "fileBytes" BIGINT NOT NULL DEFAULT 0;
|
|
||||||
ALTER TABLE "Organization" ADD COLUMN "chatFileBytes" BIGINT NOT NULL DEFAULT 0;
|
|
||||||
ALTER TABLE "Organization" ADD COLUMN "fileBytesSyncedAt" TIMESTAMP(3);
|
|
||||||
|
|
||||||
ALTER TABLE "BillingAccount" ADD COLUMN "storageQuotaBytes" BIGINT;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE "BillingAccount" ADD COLUMN "maxUsers" INTEGER;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
CREATE TABLE "PlatformMasterPassword" (
|
|
||||||
"id" TEXT NOT NULL DEFAULT 'default',
|
|
||||||
"passwordHash" TEXT NOT NULL,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "PlatformMasterPassword_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
-- دموی ۷ روزه که هنوز سقف کاربر ندارد، پیشفرض ۳ کاربر میگیرد
|
|
||||||
UPDATE "BillingAccount"
|
|
||||||
SET "maxUsers" = 3
|
|
||||||
WHERE "planCode" = 'trial' AND "maxUsers" IS NULL;
|
|
||||||
@@ -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 '[]';
|
|
||||||
@@ -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
|
||||||
@@ -63,7 +52,6 @@ enum CustomFieldEntity {
|
|||||||
person
|
person
|
||||||
company
|
company
|
||||||
deal
|
deal
|
||||||
note
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CustomFieldType {
|
enum CustomFieldType {
|
||||||
@@ -162,19 +150,9 @@ model Organization {
|
|||||||
enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
|
enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
|
||||||
/// ماژولهای فعال منو برای این محیط
|
/// ماژولهای فعال منو برای این محیط
|
||||||
enabledModules String[] @default([])
|
enabledModules String[] @default([])
|
||||||
/// برچسب/رنگ اولویت فعالیت و روتین (کلیدهای enum ثابت؛ فقط ظاهر)
|
|
||||||
activityPriorities Json @default("{}")
|
|
||||||
/// قالبهای آمادهٔ پیامک دستی ([{ id, name, body }, …])
|
|
||||||
smsTemplates Json @default("[]")
|
|
||||||
/// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخابشده)؛ null = بدون فیلتر
|
/// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخابشده)؛ null = بدون فیلتر
|
||||||
priorArchiveEnabledAt DateTime?
|
priorArchiveEnabledAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
/// حجم فایلهای محلی این محیط، به بایت. متن رکوردها (مشتری، فعالیت، فاکتور) داخلش نیست.
|
|
||||||
fileBytes BigInt @default(0)
|
|
||||||
/// حجم فایلهای گفتگو که از این محیط آپلود شدهاند
|
|
||||||
chatFileBytes BigInt @default(0)
|
|
||||||
/// آخرین باری که fileBytes از روی فایلهای واقعی محاسبه شده
|
|
||||||
fileBytesSyncedAt DateTime?
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
users User[]
|
users User[]
|
||||||
@@ -182,7 +160,6 @@ model Organization {
|
|||||||
orgRoles OrgRole[]
|
orgRoles OrgRole[]
|
||||||
teams Team[]
|
teams Team[]
|
||||||
companies Company[]
|
companies Company[]
|
||||||
customerGroups CustomerGroup[]
|
|
||||||
people Person[]
|
people Person[]
|
||||||
attendanceEvents AttendanceEvent[]
|
attendanceEvents AttendanceEvent[]
|
||||||
discAssessments DiscAssessment[]
|
discAssessments DiscAssessment[]
|
||||||
@@ -191,13 +168,11 @@ model Organization {
|
|||||||
deals Deal[]
|
deals Deal[]
|
||||||
activities Activity[]
|
activities Activity[]
|
||||||
notes Note[]
|
notes Note[]
|
||||||
noteCategories NoteCategory[]
|
|
||||||
noteRevisions NoteRevision[]
|
noteRevisions NoteRevision[]
|
||||||
customFields CustomFieldDefinition[]
|
customFields CustomFieldDefinition[]
|
||||||
products Product[]
|
products Product[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
noteAttachments NoteAttachment[]
|
noteAttachments NoteAttachment[]
|
||||||
activityImages ActivityImage[]
|
|
||||||
leadForms LeadForm[]
|
leadForms LeadForm[]
|
||||||
quotes Quote[]
|
quotes Quote[]
|
||||||
invoices Invoice[]
|
invoices Invoice[]
|
||||||
@@ -220,8 +195,6 @@ model Organization {
|
|||||||
noteAiSuggestions NoteAiSuggestion[]
|
noteAiSuggestions NoteAiSuggestion[]
|
||||||
accountingConnections AccountingConnection[]
|
accountingConnections AccountingConnection[]
|
||||||
accountingSyncJobs AccountingSyncJob[]
|
accountingSyncJobs AccountingSyncJob[]
|
||||||
accountingAsnadLines AccountingAsnadLine[]
|
|
||||||
tahesabInvoiceSettings TahesabInvoiceSettings?
|
|
||||||
warehouses Warehouse[]
|
warehouses Warehouse[]
|
||||||
stockMovements StockMovement[]
|
stockMovements StockMovement[]
|
||||||
partyOpenings PartyOpeningBalance[]
|
partyOpenings PartyOpeningBalance[]
|
||||||
@@ -310,8 +283,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")
|
||||||
@@ -319,7 +290,6 @@ model User {
|
|||||||
notes Note[] @relation("NoteAuthor")
|
notes Note[] @relation("NoteAuthor")
|
||||||
noteRevisions NoteRevision[] @relation("NoteRevisionEditor")
|
noteRevisions NoteRevision[] @relation("NoteRevisionEditor")
|
||||||
noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader")
|
noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader")
|
||||||
activityImages ActivityImage[] @relation("ActivityImageUploader")
|
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
salesGoals SalesGoal[] @relation("SalesGoalUser")
|
salesGoals SalesGoal[] @relation("SalesGoalUser")
|
||||||
commissionPercent Decimal @default(0) @db.Decimal(8, 2)
|
commissionPercent Decimal @default(0) @db.Decimal(8, 2)
|
||||||
@@ -336,7 +306,6 @@ model User {
|
|||||||
tablePreferences UserTablePreference[]
|
tablePreferences UserTablePreference[]
|
||||||
assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees")
|
assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees")
|
||||||
teamMemberships TeamMember[]
|
teamMemberships TeamMember[]
|
||||||
activityBoardMembers ActivityBoardMember[]
|
|
||||||
reservationAssignments ReservationAppointment[] @relation("ReservationAssignee")
|
reservationAssignments ReservationAppointment[] @relation("ReservationAssignee")
|
||||||
assignedLeads Lead[] @relation("LeadAssignee")
|
assignedLeads Lead[] @relation("LeadAssignee")
|
||||||
createdLeads Lead[] @relation("LeadCreator")
|
createdLeads Lead[] @relation("LeadCreator")
|
||||||
@@ -368,12 +337,6 @@ model OrgRole {
|
|||||||
description String @default("")
|
description String @default("")
|
||||||
color String @default("teal")
|
color String @default("teal")
|
||||||
permissions String[] @default([])
|
permissions String[] @default([])
|
||||||
/// عمق داده بهازای بخش، مثلاً {"customers":{"mode":"rules","rules":[{"owners":["self"]}]}}
|
|
||||||
dataAccessPolicy Json @default("{}")
|
|
||||||
/// حسابهای مالی اضافی این نقش؛ همراه با حسابهایی که خودش مسئول است
|
|
||||||
treasuryAccountIds String[] @default([])
|
|
||||||
/// اعضای نقش، حسابهایی را ببینند که نام خودشان مسئول آن است
|
|
||||||
treasuryOwnAccounts Boolean @default(false)
|
|
||||||
/// owner | admin | sales | support — نقشهای سیستمی قابل حذف نیستند
|
/// owner | admin | sales | support — نقشهای سیستمی قابل حذف نیستند
|
||||||
systemKey String?
|
systemKey String?
|
||||||
/// نقش والد برای سلسلهمراتب زیرنقشها
|
/// نقش والد برای سلسلهمراتب زیرنقشها
|
||||||
@@ -406,20 +369,12 @@ model Membership {
|
|||||||
dataVisibility String @default("own")
|
dataVisibility String @default("own")
|
||||||
/// سطح دید تفکیکی هر بخش، مثلاً {"contacts":"org","deals":"own"}؛ بخشهای بدون مقدار = dataVisibility
|
/// سطح دید تفکیکی هر بخش، مثلاً {"contacts":"org","deals":"own"}؛ بخشهای بدون مقدار = dataVisibility
|
||||||
dataVisibilityScopes Json @default("{}")
|
dataVisibilityScopes Json @default("{}")
|
||||||
/// Override عمق داده نسبت به نقش؛ کلید غایب = ارث از OrgRole.dataAccessPolicy
|
|
||||||
dataAccessOverride Json @default("{}")
|
|
||||||
/// null = دسترسی مالی از نقش؛ وگرنه جایگزین بخشها و حسابهای مالی همین کاربر
|
|
||||||
treasuryAccessOverride Json?
|
|
||||||
/// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد
|
/// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد
|
||||||
priorArchiveEnabled Boolean @default(false)
|
priorArchiveEnabled Boolean @default(false)
|
||||||
/// current | archive
|
/// current | archive
|
||||||
priorArchiveLens String @default("current")
|
priorArchiveLens String @default("current")
|
||||||
/// ساعت خروج روزانه به وقت تهران، مثل 18:00؛ خالی یعنی بدون محدودیت
|
/// ساعت خروج روزانه به وقت تهران، مثل 18:00؛ خالی یعنی بدون محدودیت
|
||||||
logoutTime String?
|
logoutTime String?
|
||||||
/// بعد از ساعت خروج تا پایان روز (تهران) اجازهٔ ورود دوباره به این محیط نیست
|
|
||||||
blockLoginAfterLogout Boolean @default(false)
|
|
||||||
/// ورود به این محیط فقط با OTP؛ رمز عبور برای این عضویت کار نمیکند
|
|
||||||
loginOtpOnly Boolean @default(false)
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -474,13 +429,6 @@ model PlatformAdmin {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
/// رمز کلی سوپرادمین: با موبایل هر کاربر و این رمز میشود وارد حساب CRM او شد
|
|
||||||
model PlatformMasterPassword {
|
|
||||||
id String @id @default("default")
|
|
||||||
passwordHash String
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
/// اشتراک SaaS روی مالک حساب (نه روی هر عضویت)
|
/// اشتراک SaaS روی مالک حساب (نه روی هر عضویت)
|
||||||
model BillingAccount {
|
model BillingAccount {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -493,15 +441,9 @@ model BillingAccount {
|
|||||||
startsAt DateTime @default(now())
|
startsAt DateTime @default(now())
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
maxOrganizations Int @default(1)
|
maxOrganizations Int @default(1)
|
||||||
/// سقف حجم فایل همهٔ محیطهای این مالک، به بایت. null = بدون سقف
|
|
||||||
storageQuotaBytes BigInt?
|
|
||||||
/// سقف کاربر فعال همهٔ محیطهای این مالک. null = هنوز تعیین نشده
|
|
||||||
maxUsers Int?
|
|
||||||
/// رزرو برای درگاه/لندینگ بعدی
|
/// رزرو برای درگاه/لندینگ بعدی
|
||||||
externalPaymentRef String?
|
externalPaymentRef String?
|
||||||
notes String?
|
notes String?
|
||||||
/// پیامک پایان اشتراک برای مدیران پنل؛ خالی یعنی هنوز اطلاع داده نشده
|
|
||||||
expiryNotifiedAt DateTime?
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -533,15 +475,12 @@ model Company {
|
|||||||
city String?
|
city String?
|
||||||
postalCode String?
|
postalCode String?
|
||||||
address String?
|
address String?
|
||||||
/// قدیمی؛ از نسخهٔ گروهبندی، گروهها در CompanyGroupLink هستند
|
|
||||||
groupName String?
|
groupName String?
|
||||||
notes String?
|
notes String?
|
||||||
registeredAt DateTime?
|
registeredAt DateTime?
|
||||||
tags String[] @default([])
|
tags String[] @default([])
|
||||||
customFields Json @default("{}")
|
customFields Json @default("{}")
|
||||||
groupLinks CompanyGroupLink[]
|
|
||||||
people Person[]
|
people Person[]
|
||||||
financialDocuments FinancialDocument[]
|
|
||||||
deals Deal[]
|
deals Deal[]
|
||||||
activities Activity[]
|
activities Activity[]
|
||||||
smartNotes Note[]
|
smartNotes Note[]
|
||||||
@@ -564,58 +503,6 @@ model Company {
|
|||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// گروه مشتری (مشترک بین اشخاص و شرکتها؛ هر مشتری چند گروه)
|
|
||||||
model CustomerGroup {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
organizationId String
|
|
||||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
|
||||||
name String
|
|
||||||
color String @default("teal")
|
|
||||||
position Int @default(0)
|
|
||||||
/// گروه متناظر در تهحساب (GID) روی همان اتصال
|
|
||||||
tahesabConnectionId String?
|
|
||||||
tahesabGid String?
|
|
||||||
/// گروه اصلی؛ فقط یک سطح (اصلی / فرعی)
|
|
||||||
parentId String?
|
|
||||||
parent CustomerGroup? @relation("CustomerGroupTree", fields: [parentId], references: [id], onDelete: SetNull)
|
|
||||||
children CustomerGroup[] @relation("CustomerGroupTree")
|
|
||||||
people PersonGroupLink[]
|
|
||||||
companies CompanyGroupLink[]
|
|
||||||
/// مسئولان گروه — اختیاری؛ اعضای گروه را مثل مشتریان خود میبینند
|
|
||||||
assignees User[] @relation("CustomerGroupAssignees")
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@unique([organizationId, name])
|
|
||||||
@@index([organizationId, position])
|
|
||||||
@@index([tahesabConnectionId, tahesabGid])
|
|
||||||
@@index([parentId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model PersonGroupLink {
|
|
||||||
personId String
|
|
||||||
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
|
|
||||||
groupId String
|
|
||||||
group CustomerGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
|
||||||
/// manual | tahesab — گروهِ تهحسابی با همگامسازی جایگزین میشود؛ دستی دست نمیخورد
|
|
||||||
source String @default("manual")
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@id([personId, groupId])
|
|
||||||
@@index([groupId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model CompanyGroupLink {
|
|
||||||
companyId String
|
|
||||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
|
||||||
groupId String
|
|
||||||
group CustomerGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@id([companyId, groupId])
|
|
||||||
@@index([groupId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Person {
|
model Person {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
organizationId String
|
organizationId String
|
||||||
@@ -624,10 +511,6 @@ model Person {
|
|||||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||||
firstName String
|
firstName String
|
||||||
lastName String
|
lastName String
|
||||||
/// male | female | none
|
|
||||||
gender String @default("none")
|
|
||||||
/// اگر روشن باشد، مرد «جناب آقای» و زن «سرکار خانم» ابتدای نام ذخیره میشود
|
|
||||||
showGenderInName Boolean @default(false)
|
|
||||||
/// نام قبل از همگامسازی مبدأ (مثلاً تهحساب)
|
/// نام قبل از همگامسازی مبدأ (مثلاً تهحساب)
|
||||||
previousFirstName String?
|
previousFirstName String?
|
||||||
previousLastName String?
|
previousLastName String?
|
||||||
@@ -656,7 +539,6 @@ model Person {
|
|||||||
mood PersonMood @default(newcomer)
|
mood PersonMood @default(newcomer)
|
||||||
tags String[] @default([])
|
tags String[] @default([])
|
||||||
customFields Json @default("{}")
|
customFields Json @default("{}")
|
||||||
groupLinks PersonGroupLink[]
|
|
||||||
loyaltyPoints Int @default(0)
|
loyaltyPoints Int @default(0)
|
||||||
loyaltyTierId String?
|
loyaltyTierId String?
|
||||||
loyaltyTier LoyaltyTier? @relation(fields: [loyaltyTierId], references: [id], onDelete: SetNull)
|
loyaltyTier LoyaltyTier? @relation(fields: [loyaltyTierId], references: [id], onDelete: SetNull)
|
||||||
@@ -675,7 +557,6 @@ model Person {
|
|||||||
memoryItems MemoryItem[]
|
memoryItems MemoryItem[]
|
||||||
accountingLinks PersonAccountingLink[]
|
accountingLinks PersonAccountingLink[]
|
||||||
accountingDocuments AccountingDocumentLog[]
|
accountingDocuments AccountingDocumentLog[]
|
||||||
asnadLines AccountingAsnadLine[]
|
|
||||||
openingBalances PartyOpeningBalance[]
|
openingBalances PartyOpeningBalance[]
|
||||||
cheques Cheque[]
|
cheques Cheque[]
|
||||||
trackingLinks TrackingLink[]
|
trackingLinks TrackingLink[]
|
||||||
@@ -816,7 +697,6 @@ model ActivityBoard {
|
|||||||
position Int @default(0)
|
position Int @default(0)
|
||||||
stages ActivityStage[]
|
stages ActivityStage[]
|
||||||
activities Activity[]
|
activities Activity[]
|
||||||
members ActivityBoardMember[]
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -824,17 +704,6 @@ model ActivityBoard {
|
|||||||
@@index([organizationId, position])
|
@@index([organizationId, position])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// عضو گروه فعالیت؛ اگر گروهی هیچ عضوی نداشته باشد برای همهٔ محیط باز است
|
|
||||||
model ActivityBoardMember {
|
|
||||||
boardId String
|
|
||||||
board ActivityBoard @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
|
||||||
userId String
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@id([boardId, userId])
|
|
||||||
@@index([userId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model ActivityStage {
|
model ActivityStage {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
organizationId String
|
organizationId String
|
||||||
@@ -901,12 +770,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 +791,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)
|
||||||
@@ -940,12 +801,10 @@ model Activity {
|
|||||||
/// لید مرتبط (مثلاً فعالیت ساختهشده از ارسال فرم)
|
/// لید مرتبط (مثلاً فعالیت ساختهشده از ارسال فرم)
|
||||||
leadId String?
|
leadId String?
|
||||||
lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull)
|
lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull)
|
||||||
images ActivityImage[]
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([stageId])
|
@@index([stageId])
|
||||||
@@index([stageId, boardPosition])
|
|
||||||
@@index([boardId])
|
@@index([boardId])
|
||||||
@@index([typeDefId])
|
@@index([typeDefId])
|
||||||
@@index([leadId])
|
@@index([leadId])
|
||||||
@@ -960,26 +819,6 @@ model Activity {
|
|||||||
@@index([dealId])
|
@@index([dealId])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// عکس فشردهشدهٔ یادداشت فعالیت (حداکثر ۲۰۰ کیلوبایت)
|
|
||||||
model ActivityImage {
|
|
||||||
id String @id
|
|
||||||
organizationId String
|
|
||||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
|
||||||
activityId String
|
|
||||||
activity Activity @relation(fields: [activityId], references: [id], onDelete: Cascade)
|
|
||||||
fileName String
|
|
||||||
mimeType String
|
|
||||||
sizeBytes Int
|
|
||||||
storageKey String
|
|
||||||
uploadedById String?
|
|
||||||
uploadedBy User? @relation("ActivityImageUploader", fields: [uploadedById], references: [id], onDelete: SetNull)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@index([activityId, createdAt])
|
|
||||||
@@index([organizationId])
|
|
||||||
@@index([uploadedById])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Note {
|
model Note {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
organizationId String
|
organizationId String
|
||||||
@@ -995,8 +834,6 @@ model Note {
|
|||||||
checklist Json?
|
checklist Json?
|
||||||
locationText String?
|
locationText String?
|
||||||
analyzeEnabled Boolean @default(true)
|
analyzeEnabled Boolean @default(true)
|
||||||
/// مقادیر فیلدهای سفارشی یادداشت
|
|
||||||
customFields Json @default("{}")
|
|
||||||
personId String?
|
personId String?
|
||||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||||
companyId String?
|
companyId String?
|
||||||
@@ -1026,26 +863,6 @@ model Note {
|
|||||||
@@index([authorId])
|
@@index([authorId])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// دستههای نوار فیلتر یادداشت؛ برای هر سازمان قابل تغییر است
|
|
||||||
model NoteCategory {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
organizationId String
|
|
||||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
|
||||||
key String
|
|
||||||
label String
|
|
||||||
tone String @default("general")
|
|
||||||
position Int @default(0)
|
|
||||||
isActive Boolean @default(true)
|
|
||||||
/// در نوار «همه / معمولی / پیگیری / …» دیده شود
|
|
||||||
showInBar Boolean @default(true)
|
|
||||||
isSystem Boolean @default(false)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@unique([organizationId, key])
|
|
||||||
@@index([organizationId, position])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// اسنپشات متن قبل از هر ویرایش معنادار یادداشت
|
/// اسنپشات متن قبل از هر ویرایش معنادار یادداشت
|
||||||
model NoteRevision {
|
model NoteRevision {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -1867,20 +1684,12 @@ model AccountingConnection {
|
|||||||
lastHealthAt DateTime?
|
lastHealthAt DateTime?
|
||||||
lastHealthOk Boolean?
|
lastHealthOk Boolean?
|
||||||
lastError String?
|
lastError String?
|
||||||
/// ساعتهای بروزرسانی خودکار مشتریان (۰ تا ۲۳، وقت تهران). خالی یعنی خاموش.
|
|
||||||
customerSyncHours Int[] @default([])
|
|
||||||
/// نوبت claimشده، مثل 2026-11-05-08، تا در همان ساعت دو بار اجرا نشود
|
|
||||||
customerSyncLastSlot String?
|
|
||||||
customerSyncLastRunAt DateTime?
|
|
||||||
customerSyncLastSummary String?
|
|
||||||
customerSyncLastError String?
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
personLinks PersonAccountingLink[]
|
personLinks PersonAccountingLink[]
|
||||||
productLinks ProductAccountingLink[]
|
productLinks ProductAccountingLink[]
|
||||||
documentLogs AccountingDocumentLog[]
|
documentLogs AccountingDocumentLog[]
|
||||||
balanceCaches AccountingBalanceCache[]
|
balanceCaches AccountingBalanceCache[]
|
||||||
asnadLines AccountingAsnadLine[]
|
|
||||||
|
|
||||||
@@unique([organizationId, baseUrl, dbName])
|
@@unique([organizationId, baseUrl, dbName])
|
||||||
@@index([organizationId, enabled])
|
@@index([organizationId, enabled])
|
||||||
@@ -1900,7 +1709,6 @@ model PersonAccountingLink {
|
|||||||
linkedAt DateTime @default(now())
|
linkedAt DateTime @default(now())
|
||||||
lastBalanceSyncedAt DateTime?
|
lastBalanceSyncedAt DateTime?
|
||||||
balanceCache AccountingBalanceCache?
|
balanceCache AccountingBalanceCache?
|
||||||
asnadLines AccountingAsnadLine[]
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -1979,55 +1787,6 @@ model AccountingBalanceCache {
|
|||||||
@@index([connectionId])
|
@@index([connectionId])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// اسنپشات ردیفهای DoListAsnad برای گزارش فروش گروهی
|
|
||||||
model AccountingAsnadLine {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
organizationId String
|
|
||||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
|
||||||
connectionId String
|
|
||||||
connection AccountingConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
|
|
||||||
personId String?
|
|
||||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
|
||||||
linkId String?
|
|
||||||
link PersonAccountingLink? @relation(fields: [linkId], references: [id], onDelete: SetNull)
|
|
||||||
moshtariCode Int
|
|
||||||
/// کلید پایدار برای upsert: ID تهحساب یا f:factor|r:radif|t:tarikh|n:no
|
|
||||||
lineKey String
|
|
||||||
tahesabId Int?
|
|
||||||
factorCode String?
|
|
||||||
shFactor Int?
|
|
||||||
radif Int?
|
|
||||||
/// شمسی YYYY-MM-DD
|
|
||||||
tarikh String
|
|
||||||
noRaw String @default("")
|
|
||||||
/// sale | return | purchase | other
|
|
||||||
kind String @default("other")
|
|
||||||
jensFelez Int?
|
|
||||||
noKar String?
|
|
||||||
vazn Float?
|
|
||||||
tabdilVazn Float?
|
|
||||||
mali Float?
|
|
||||||
count Float?
|
|
||||||
ayar Float?
|
|
||||||
/// درصد اجرت سند تهحساب؛ بعد از همگامسازی پر میشود
|
|
||||||
darsad Float?
|
|
||||||
darsadTalayee Float?
|
|
||||||
maliatDarsad Float?
|
|
||||||
mazaneh Float?
|
|
||||||
fi Float?
|
|
||||||
sharh1 String?
|
|
||||||
syncedAt DateTime @default(now())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@unique([connectionId, lineKey])
|
|
||||||
@@index([organizationId, connectionId, tarikh])
|
|
||||||
@@index([organizationId, kind])
|
|
||||||
@@index([personId])
|
|
||||||
@@index([linkId])
|
|
||||||
@@index([moshtariCode])
|
|
||||||
}
|
|
||||||
|
|
||||||
model AccountingSyncJob {
|
model AccountingSyncJob {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
organizationId String
|
organizationId String
|
||||||
@@ -2045,23 +1804,6 @@ model AccountingSyncJob {
|
|||||||
@@index([organizationId, createdAt])
|
@@index([organizationId, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// تنظیمات ظاهر فاکتور PDF اسناد تهحساب (جدا از فاکتور CRM)
|
|
||||||
model TahesabInvoiceSettings {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
organizationId String @unique
|
|
||||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
|
||||||
/// data URL تصویر لوگو (مثلاً data:image/png;base64,...)
|
|
||||||
logoDataUrl String? @db.Text
|
|
||||||
companyName String @default("")
|
|
||||||
companySubtitle String @default("")
|
|
||||||
address String @default("")
|
|
||||||
phone String @default("")
|
|
||||||
headerText String @default("")
|
|
||||||
footerText String @default("")
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
/// انبار داخلی — وقتی تهحساب وصل نیست
|
/// انبار داخلی — وقتی تهحساب وصل نیست
|
||||||
model Warehouse {
|
model Warehouse {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -2363,12 +2105,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 +2125,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 +2205,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 +2711,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 +2730,6 @@ model VoipConnection {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([organizationId, enabled])
|
@@index([organizationId, enabled])
|
||||||
@@index([organizationId, provider])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model VoipCallLog {
|
model VoipCallLog {
|
||||||
@@ -3024,8 +2744,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 +2768,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])
|
||||||
@@ -3132,10 +2848,6 @@ model Lead {
|
|||||||
title String
|
title String
|
||||||
/// نام مالک یا مسئول
|
/// نام مالک یا مسئول
|
||||||
contactName String?
|
contactName String?
|
||||||
/// male | female | none
|
|
||||||
gender String @default("none")
|
|
||||||
/// عنوان «جناب آقای» / «سرکار خانم» داخل نام مخاطب
|
|
||||||
showGenderInName Boolean @default(false)
|
|
||||||
mobile String
|
mobile String
|
||||||
phone String?
|
phone String?
|
||||||
city String?
|
city String?
|
||||||
@@ -3828,9 +3540,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,
|
||||||
@@ -19,7 +18,6 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
|||||||
import { memoryStorage } from 'multer';
|
import { memoryStorage } from 'multer';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
|
||||||
import { RolesGuard } from '../auth/roles.guard';
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
import { Roles } from '../auth/roles.decorator';
|
import { Roles } from '../auth/roles.decorator';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
@@ -41,7 +39,6 @@ import {
|
|||||||
ReorderActivityTypesDto,
|
ReorderActivityTypesDto,
|
||||||
UpdateActivityBoardDto,
|
UpdateActivityBoardDto,
|
||||||
UpdateActivityDto,
|
UpdateActivityDto,
|
||||||
UpdateActivityPrioritiesDto,
|
|
||||||
UpdateActivityStageDto,
|
UpdateActivityStageDto,
|
||||||
UpdateActivityTypeDto,
|
UpdateActivityTypeDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
@@ -53,8 +50,7 @@ const excelUpload = FileInterceptor('file', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@Controller('activities')
|
@Controller('activities')
|
||||||
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@RequirePermission('activities')
|
|
||||||
export class ActivitiesController {
|
export class ActivitiesController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly activitiesService: ActivitiesService,
|
private readonly activitiesService: ActivitiesService,
|
||||||
@@ -74,14 +70,14 @@ export class ActivitiesController {
|
|||||||
|
|
||||||
@Get('boards')
|
@Get('boards')
|
||||||
listBoards(@CurrentUser() user: Actor) {
|
listBoards(@CurrentUser() user: Actor) {
|
||||||
return this.stagesService.listBoards(user.organizationId, user);
|
return this.stagesService.listBoards(user.organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('boards')
|
@Post('boards')
|
||||||
@UseGuards(RolesGuard)
|
@UseGuards(RolesGuard)
|
||||||
@Roles('owner', 'admin')
|
@Roles('owner', 'admin')
|
||||||
createBoard(@CurrentUser() user: Actor, @Body() dto: CreateActivityBoardDto) {
|
createBoard(@CurrentUser() user: Actor, @Body() dto: CreateActivityBoardDto) {
|
||||||
return this.stagesService.createBoard(user.organizationId, dto.name, dto.memberIds);
|
return this.stagesService.createBoard(user.organizationId, dto.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('boards/:id')
|
@Patch('boards/:id')
|
||||||
@@ -105,7 +101,7 @@ export class ActivitiesController {
|
|||||||
|
|
||||||
@Get('stages')
|
@Get('stages')
|
||||||
listStages(@CurrentUser() user: Actor, @Query() query: ListActivityStagesQueryDto) {
|
listStages(@CurrentUser() user: Actor, @Query() query: ListActivityStagesQueryDto) {
|
||||||
return this.stagesService.list(user.organizationId, query.boardId, user);
|
return this.stagesService.list(user.organizationId, query.boardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('stages')
|
@Post('stages')
|
||||||
@@ -141,18 +137,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);
|
||||||
@@ -207,10 +191,6 @@ export class ActivitiesController {
|
|||||||
typeDefId: query.typeDefId,
|
typeDefId: query.typeDefId,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
assigneeId: query.assigneeId,
|
assigneeId: query.assigneeId,
|
||||||
title: query.title,
|
|
||||||
customer: query.customer,
|
|
||||||
due: query.due,
|
|
||||||
priority: query.priority,
|
|
||||||
boardId: query.boardId,
|
boardId: query.boardId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -244,44 +224,6 @@ export class ActivitiesController {
|
|||||||
typeDefId: query.typeDefId,
|
typeDefId: query.typeDefId,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
assigneeId: query.assigneeId,
|
assigneeId: query.assigneeId,
|
||||||
title: query.title,
|
|
||||||
customer: query.customer,
|
|
||||||
due: query.due,
|
|
||||||
priority: query.priority,
|
|
||||||
boardId: query.boardId,
|
|
||||||
},
|
|
||||||
query.stageId ? { stageId: query.stageId } : undefined,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ترتیب همان لیست میز، برای فعالیت قبلی/بعدی در جزئیات */
|
|
||||||
@Get('nav-ids')
|
|
||||||
navIds(@CurrentUser() user: Actor, @Query() query: ListActivitiesQueryDto) {
|
|
||||||
return this.activitiesService.orderedIds(
|
|
||||||
user.organizationId,
|
|
||||||
user,
|
|
||||||
query.filter ?? 'today',
|
|
||||||
query.q,
|
|
||||||
{
|
|
||||||
personId: query.personId,
|
|
||||||
companyId: query.companyId,
|
|
||||||
dealId: query.dealId,
|
|
||||||
leadId: query.leadId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
from: query.from,
|
|
||||||
to: query.to,
|
|
||||||
},
|
|
||||||
query.desk,
|
|
||||||
{
|
|
||||||
type: query.type,
|
|
||||||
typeDefId: query.typeDefId,
|
|
||||||
status: query.status,
|
|
||||||
assigneeId: query.assigneeId,
|
|
||||||
title: query.title,
|
|
||||||
customer: query.customer,
|
|
||||||
due: query.due,
|
|
||||||
priority: query.priority,
|
|
||||||
boardId: query.boardId,
|
boardId: query.boardId,
|
||||||
},
|
},
|
||||||
query.stageId ? { stageId: query.stageId } : undefined,
|
query.stageId ? { stageId: query.stageId } : undefined,
|
||||||
@@ -365,56 +307,6 @@ export class ActivitiesController {
|
|||||||
return this.activitiesService.get(user.organizationId, id, user);
|
return this.activitiesService.get(user.organizationId, id, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/images')
|
|
||||||
@UseInterceptors(
|
|
||||||
FileInterceptor('file', {
|
|
||||||
storage: memoryStorage(),
|
|
||||||
limits: { fileSize: 10 * 1024 * 1024 },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
addImage(
|
|
||||||
@CurrentUser() user: Actor,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@UploadedFile() file: Express.Multer.File,
|
|
||||||
) {
|
|
||||||
return this.activitiesService.addImage(
|
|
||||||
user.organizationId,
|
|
||||||
user.userId,
|
|
||||||
id,
|
|
||||||
file,
|
|
||||||
user,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':id/images/:imageId')
|
|
||||||
async downloadImage(
|
|
||||||
@CurrentUser() user: Actor,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Param('imageId') imageId: string,
|
|
||||||
): Promise<StreamableFile> {
|
|
||||||
const { stream, fileName, mimeType } = await this.activitiesService.openImage(
|
|
||||||
user.organizationId,
|
|
||||||
id,
|
|
||||||
imageId,
|
|
||||||
user,
|
|
||||||
);
|
|
||||||
const encoded = encodeURIComponent(fileName);
|
|
||||||
return new StreamableFile(stream, {
|
|
||||||
type: mimeType,
|
|
||||||
disposition: `inline; filename="${fileName.replace(/"/g, '')}"; filename*=UTF-8''${encoded}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':id/images/:imageId')
|
|
||||||
async removeImage(
|
|
||||||
@CurrentUser() user: Actor,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Param('imageId') imageId: string,
|
|
||||||
) {
|
|
||||||
await this.activitiesService.removeImage(user.organizationId, id, imageId, user);
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(':id/promote-from-prior')
|
@Post(':id/promote-from-prior')
|
||||||
promoteFromPrior(@CurrentUser() user: Actor, @Param('id') id: string) {
|
promoteFromPrior(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||||
return this.activitiesService.promoteFromPrior(user, id);
|
return this.activitiesService.promoteFromPrior(user, id);
|
||||||
@@ -445,26 +337,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')
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ import { ActivityTypesService } from './activity-types.service';
|
|||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { AutomationsModule } from '../automations/automations.module';
|
import { AutomationsModule } from '../automations/automations.module';
|
||||||
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
||||||
import { StorageModule } from '../storage/storage.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule, StorageModule],
|
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule],
|
||||||
controllers: [ActivitiesController],
|
controllers: [ActivitiesController],
|
||||||
providers: [
|
providers: [
|
||||||
ActivitiesService,
|
ActivitiesService,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
BadRequestException,
|
|
||||||
ForbiddenException,
|
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import type {
|
import type {
|
||||||
ActivityBoardDto,
|
ActivityBoardDto,
|
||||||
ActivityStageColor,
|
ActivityStageColor,
|
||||||
@@ -39,23 +34,8 @@ type BoardRow = {
|
|||||||
name: string;
|
name: string;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
position: number;
|
position: number;
|
||||||
members: Array<{ userId: string }>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function toBoardDto(row: BoardRow): ActivityBoardDto {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
name: row.name,
|
|
||||||
isDefault: row.isDefault,
|
|
||||||
position: row.position,
|
|
||||||
memberIds: row.members.map((member) => member.userId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function managesAllBoards(role: string) {
|
|
||||||
return role === 'owner' || role === 'admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
const STAGE_SELECT = {
|
const STAGE_SELECT = {
|
||||||
id: true,
|
id: true,
|
||||||
boardId: true,
|
boardId: true,
|
||||||
@@ -70,7 +50,6 @@ const BOARD_SELECT = {
|
|||||||
name: true,
|
name: true,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
position: true,
|
position: true,
|
||||||
members: { select: { userId: true } },
|
|
||||||
} satisfies Prisma.ActivityBoardSelect;
|
} satisfies Prisma.ActivityBoardSelect;
|
||||||
|
|
||||||
export type StageDefaults = Record<ActivityStatus, string | null>;
|
export type StageDefaults = Record<ActivityStatus, string | null>;
|
||||||
@@ -130,36 +109,14 @@ export class ActivityStagesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async listBoards(
|
async listBoards(organizationId: string): Promise<ActivityBoardDto[]> {
|
||||||
organizationId: string,
|
|
||||||
actor?: { userId: string; role: string },
|
|
||||||
): Promise<ActivityBoardDto[]> {
|
|
||||||
await this.ensureDefaultBoard(organizationId);
|
await this.ensureDefaultBoard(organizationId);
|
||||||
const rows = await this.prisma.activityBoard.findMany({
|
const rows = await this.prisma.activityBoard.findMany({
|
||||||
where: { organizationId },
|
where: { organizationId },
|
||||||
orderBy: [{ isDefault: 'desc' }, { position: 'asc' }, { createdAt: 'asc' }],
|
orderBy: [{ isDefault: 'desc' }, { position: 'asc' }, { createdAt: 'asc' }],
|
||||||
select: BOARD_SELECT,
|
select: BOARD_SELECT,
|
||||||
});
|
});
|
||||||
const boards = rows.map(toBoardDto);
|
return rows.map((b) => ({ ...b }));
|
||||||
if (!actor || managesAllBoards(actor.role)) return boards;
|
|
||||||
return boards.filter(
|
|
||||||
(board) => board.memberIds.length === 0 || board.memberIds.includes(actor.userId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** گروه بدون عضو برای همه باز است؛ مالک و مدیر همهٔ گروهها را میبینند */
|
|
||||||
async assertCanAccessBoard(
|
|
||||||
organizationId: string,
|
|
||||||
boardId: string,
|
|
||||||
actor: { userId: string; role: string },
|
|
||||||
) {
|
|
||||||
if (managesAllBoards(actor.role)) return;
|
|
||||||
const members = await this.prisma.activityBoardMember.findMany({
|
|
||||||
where: { boardId, board: { organizationId } },
|
|
||||||
select: { userId: true },
|
|
||||||
});
|
|
||||||
if (!members.length || members.some((member) => member.userId === actor.userId)) return;
|
|
||||||
throw new ForbiddenException('به این گروه فعالیت دسترسی ندارید');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** شناسهٔ گروه معتبر؛ خالی = گروه پیشفرض */
|
/** شناسهٔ گروه معتبر؛ خالی = گروه پیشفرض */
|
||||||
@@ -174,11 +131,7 @@ export class ActivityStagesService {
|
|||||||
return board.id;
|
return board.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createBoard(
|
async createBoard(organizationId: string, name: string): Promise<ActivityBoardDto> {
|
||||||
organizationId: string,
|
|
||||||
name: string,
|
|
||||||
memberIds?: string[],
|
|
||||||
): Promise<ActivityBoardDto> {
|
|
||||||
await this.ensureDefaultBoard(organizationId);
|
await this.ensureDefaultBoard(organizationId);
|
||||||
const max = await this.prisma.activityBoard.aggregate({
|
const max = await this.prisma.activityBoard.aggregate({
|
||||||
where: { organizationId },
|
where: { organizationId },
|
||||||
@@ -194,8 +147,7 @@ export class ActivityStagesService {
|
|||||||
select: BOARD_SELECT,
|
select: BOARD_SELECT,
|
||||||
});
|
});
|
||||||
await this.ensureBoardStages(organizationId, board.id);
|
await this.ensureBoardStages(organizationId, board.id);
|
||||||
if (memberIds !== undefined) await this.replaceMembers(organizationId, board.id, memberIds);
|
return { ...board };
|
||||||
return this.getBoardDto(organizationId, board.id);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw this.mapBoardError(e);
|
throw this.mapBoardError(e);
|
||||||
}
|
}
|
||||||
@@ -204,51 +156,21 @@ export class ActivityStagesService {
|
|||||||
async updateBoard(
|
async updateBoard(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
id: string,
|
id: string,
|
||||||
dto: { name?: string; memberIds?: string[] },
|
dto: { name?: string },
|
||||||
): Promise<ActivityBoardDto> {
|
): Promise<ActivityBoardDto> {
|
||||||
await this.getBoard(organizationId, id);
|
await this.getBoard(organizationId, id);
|
||||||
try {
|
try {
|
||||||
if (dto.name != null) {
|
const board = await this.prisma.activityBoard.update({
|
||||||
await this.prisma.activityBoard.update({
|
where: { id },
|
||||||
where: { id },
|
data: dto.name != null ? { name: this.cleanBoardName(dto.name) } : {},
|
||||||
data: { name: this.cleanBoardName(dto.name) },
|
select: BOARD_SELECT,
|
||||||
});
|
});
|
||||||
}
|
return { ...board };
|
||||||
if (dto.memberIds !== undefined) await this.replaceMembers(organizationId, id, dto.memberIds);
|
|
||||||
return this.getBoardDto(organizationId, id);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw this.mapBoardError(e);
|
throw this.mapBoardError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async replaceMembers(organizationId: string, boardId: string, memberIds: string[]) {
|
|
||||||
const ids = [...new Set(memberIds.map((id) => id.trim()).filter(Boolean))];
|
|
||||||
if (ids.length) {
|
|
||||||
const found = await this.prisma.membership.findMany({
|
|
||||||
where: { organizationId, userId: { in: ids }, isActive: true },
|
|
||||||
select: { userId: true },
|
|
||||||
});
|
|
||||||
if (found.length !== ids.length) {
|
|
||||||
throw new BadRequestException('بعضی از اعضا در این محیط نیستند');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await this.prisma.activityBoardMember.deleteMany({ where: { boardId } });
|
|
||||||
if (ids.length) {
|
|
||||||
await this.prisma.activityBoardMember.createMany({
|
|
||||||
data: ids.map((userId) => ({ boardId, userId })),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getBoardDto(organizationId: string, id: string): Promise<ActivityBoardDto> {
|
|
||||||
const board = await this.prisma.activityBoard.findFirst({
|
|
||||||
where: { id, organizationId },
|
|
||||||
select: BOARD_SELECT,
|
|
||||||
});
|
|
||||||
if (!board) throw new NotFoundException('گروه فعالیت پیدا نشد');
|
|
||||||
return toBoardDto(board);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** فعالیتهای گروه حذفشده به گروه پیشفرض (ستون اولِ هموضعیت) منتقل میشوند */
|
/** فعالیتهای گروه حذفشده به گروه پیشفرض (ستون اولِ هموضعیت) منتقل میشوند */
|
||||||
async removeBoard(organizationId: string, id: string): Promise<void> {
|
async removeBoard(organizationId: string, id: string): Promise<void> {
|
||||||
const board = await this.getBoard(organizationId, id);
|
const board = await this.getBoard(organizationId, id);
|
||||||
@@ -291,14 +213,8 @@ export class ActivityStagesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async list(
|
async list(organizationId: string, boardId?: string | null): Promise<ActivityStageDto[]> {
|
||||||
organizationId: string,
|
return (await this.rows(organizationId, boardId)).map((r) => this.toDto(r));
|
||||||
boardId?: string | null,
|
|
||||||
actor?: { userId: string; role: string },
|
|
||||||
): Promise<ActivityStageDto[]> {
|
|
||||||
const resolved = await this.resolveBoardId(organizationId, boardId);
|
|
||||||
if (actor) await this.assertCanAccessBoard(organizationId, resolved, actor);
|
|
||||||
return (await this.rows(organizationId, resolved)).map((r) => this.toDto(r));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** اولین مرحلهٔ هر نوع = مقصد فعالیتهای بدون مرحله یا با مرحلهٔ ناهمنوع */
|
/** اولین مرحلهٔ هر نوع = مقصد فعالیتهای بدون مرحله یا با مرحلهٔ ناهمنوع */
|
||||||
|
|||||||
@@ -6,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('نوع فعالیتی با این نام وجود دارد');
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -71,33 +69,11 @@ export class ListActivitiesQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
|
|
||||||
/** فیلتر عنوان فعالیت، جدا از جستجوی کلی */
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(120)
|
|
||||||
title?: string;
|
|
||||||
|
|
||||||
/** نام شخص یا شرکتِ مشتری */
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(120)
|
|
||||||
customer?: string;
|
|
||||||
|
|
||||||
/** زمان شروع: set = دارد، unset = ندارد */
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(['set', 'unset'])
|
|
||||||
due?: 'set' | 'unset';
|
|
||||||
|
|
||||||
/** نوع فعالیت (تعریفشده در سازمان) */
|
/** نوع فعالیت (تعریفشده در سازمان) */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
typeDefId?: string;
|
typeDefId?: string;
|
||||||
|
|
||||||
/** اولویت فعالیت (کلید ثابت؛ برچسب در تنظیمات سازمان) */
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(ACTIVITY_PRIORITIES)
|
|
||||||
priority?: (typeof ACTIVITY_PRIORITIES)[number];
|
|
||||||
|
|
||||||
/** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */
|
/** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -155,11 +131,6 @@ export class CreateActivityBoardDto {
|
|||||||
@MinLength(2)
|
@MinLength(2)
|
||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
@IsString({ each: true })
|
|
||||||
memberIds?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateActivityBoardDto {
|
export class UpdateActivityBoardDto {
|
||||||
@@ -168,11 +139,6 @@ export class UpdateActivityBoardDto {
|
|||||||
@MinLength(2)
|
@MinLength(2)
|
||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
@IsString({ each: true })
|
|
||||||
memberIds?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ListActivityStagesQueryDto {
|
export class ListActivityStagesQueryDto {
|
||||||
@@ -226,12 +192,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 +289,7 @@ export class CreateActivityDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(20000)
|
@MaxLength(2000)
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -377,11 +337,6 @@ export class CreateActivityDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
dealId?: string | null;
|
dealId?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
|
||||||
@IsString()
|
|
||||||
leadId?: string | null;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -391,14 +346,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 +384,7 @@ export class UpdateActivityDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(20000)
|
@MaxLength(2000)
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -485,11 +432,6 @@ export class UpdateActivityDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
dealId?: string | null;
|
dealId?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
|
||||||
@IsString()
|
|
||||||
leadId?: string | null;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -499,35 +441,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[];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type {
|
|||||||
VoicePersonDraft,
|
VoicePersonDraft,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { leadsDeepWhere } from '../common/data-access';
|
|
||||||
import {
|
import {
|
||||||
type Actor,
|
type Actor,
|
||||||
assigneeWhere,
|
assigneeWhere,
|
||||||
@@ -261,9 +260,6 @@ Rules:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private leadWhere(actor: Actor) {
|
private leadWhere(actor: Actor) {
|
||||||
if (actor.leadsAccess !== undefined) {
|
|
||||||
return leadsDeepWhere(actor);
|
|
||||||
}
|
|
||||||
const scoped = forScope(actor, 'leads');
|
const scoped = forScope(actor, 'leads');
|
||||||
if (elevated(scoped.role) || scoped.dataVisibility === 'org') return {};
|
if (elevated(scoped.role) || scoped.dataVisibility === 'org') return {};
|
||||||
if (scoped.dataVisibility === 'team' && scoped.visibleUserIds?.length) {
|
if (scoped.dataVisibility === 'team' && scoped.visibleUserIds?.length) {
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ import { DeletionsModule } from './deletions/deletions.module';
|
|||||||
import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
|
import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
|
||||||
import { BehaviorModule } from './behavior/behavior.module';
|
import { BehaviorModule } from './behavior/behavior.module';
|
||||||
import { SmsBillingModule } from './sms-billing/sms-billing.module';
|
import { SmsBillingModule } from './sms-billing/sms-billing.module';
|
||||||
import { CustomerGroupsModule } from './customer-groups/customer-groups.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -66,7 +65,6 @@ import { CustomerGroupsModule } from './customer-groups/customer-groups.module';
|
|||||||
AiModule,
|
AiModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
SmsBillingModule,
|
SmsBillingModule,
|
||||||
CustomerGroupsModule,
|
|
||||||
OutboundSmsModule,
|
OutboundSmsModule,
|
||||||
StorageModule,
|
StorageModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ForbiddenException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -13,9 +13,6 @@ import {
|
|||||||
export const WORKDAY_ENDED_MESSAGE =
|
export const WORKDAY_ENDED_MESSAGE =
|
||||||
'ساعت کاری امروز تمام شده است. برای ادامه دوباره وارد شوید.';
|
'ساعت کاری امروز تمام شده است. برای ادامه دوباره وارد شوید.';
|
||||||
|
|
||||||
export const LOGIN_BLOCKED_AFTER_HOURS_MESSAGE =
|
|
||||||
'ساعت کاری امروز شما تمام شده و ورود دوباره تا فردا مجاز نیست.';
|
|
||||||
|
|
||||||
type RecordInput = {
|
type RecordInput = {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -64,18 +61,6 @@ export class AttendanceService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** با «مسدودسازی بعد از ساعت خروج»، از آن ساعت تا پایان روز تهران ورود رد میشود. */
|
|
||||||
async assertLoginAllowed(organizationId: string, userId: string, at = new Date()) {
|
|
||||||
const membership = await this.prisma.membership.findFirst({
|
|
||||||
where: { organizationId, userId, isActive: true },
|
|
||||||
select: { role: true, logoutTime: true, blockLoginAfterLogout: true },
|
|
||||||
});
|
|
||||||
if (!membership || membership.role === 'owner' || !membership.blockLoginAfterLogout) return;
|
|
||||||
if (loggedInAfterCutoff(at, membership.logoutTime)) {
|
|
||||||
throw new ForbiddenException(LOGIN_BLOCKED_AFTER_HOURS_MESSAGE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async recordLogout(actor: Actor) {
|
async recordLogout(actor: Actor) {
|
||||||
if (actor.viewAsReadOnly) return { ok: true };
|
if (actor.viewAsReadOnly) return { ok: true };
|
||||||
const at = new Date();
|
const at = new Date();
|
||||||
@@ -96,20 +81,14 @@ export class AttendanceService {
|
|||||||
if (actor.viewAsReadOnly || actor.role === 'owner' || !actor.sessionStartedAt) return;
|
if (actor.viewAsReadOnly || actor.role === 'owner' || !actor.sessionStartedAt) return;
|
||||||
const membership = await this.prisma.membership.findFirst({
|
const membership = await this.prisma.membership.findFirst({
|
||||||
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
|
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
|
||||||
select: { logoutTime: true, role: true, blockLoginAfterLogout: true },
|
select: { logoutTime: true, role: true },
|
||||||
});
|
});
|
||||||
const logoutTime = membership?.logoutTime;
|
const logoutTime = membership?.logoutTime;
|
||||||
if (!logoutTime || membership?.role === 'owner') return;
|
if (!logoutTime || membership?.role === 'owner') return;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const cutoff = tehranCutoff(now, logoutTime);
|
const cutoff = tehranCutoff(now, logoutTime);
|
||||||
if (!cutoff || now.getTime() < cutoff.getTime()) return;
|
if (!cutoff || now.getTime() < cutoff.getTime()) return;
|
||||||
// نشستی که بعد از ساعت خروج شروع شده فقط وقتی مجاز است که ورود بعد از ساعت مسدود نباشد
|
if (actor.sessionStartedAt.getTime() >= cutoff.getTime()) return;
|
||||||
if (
|
|
||||||
actor.sessionStartedAt.getTime() >= cutoff.getTime() &&
|
|
||||||
!membership?.blockLoginAfterLogout
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.write({
|
await this.write({
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import { Roles } from '../auth/roles.decorator';
|
import { Roles } from '../auth/roles.decorator';
|
||||||
import { RolesGuard } from '../auth/roles.guard';
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
|
||||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||||
import { AuditLogsService } from './audit-logs.service';
|
import { AuditLogsService } from './audit-logs.service';
|
||||||
@@ -43,9 +42,9 @@ class ListAuditQueryDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Controller('audit-logs')
|
@Controller('audit-logs')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard, SectionPermissionGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
|
||||||
@RequireOrgModule('audit_logs')
|
@RequireOrgModule('audit_logs')
|
||||||
@RequirePermission('audit', { reads: true })
|
@Roles('owner', 'admin')
|
||||||
export class AuditLogsController {
|
export class AuditLogsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly auditLogs: AuditLogsService,
|
private readonly auditLogs: AuditLogsService,
|
||||||
@@ -53,7 +52,6 @@ export class AuditLogsController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('attendance')
|
@Get('attendance')
|
||||||
@Roles('owner', 'admin')
|
|
||||||
attendanceList(
|
attendanceList(
|
||||||
@CurrentUser() user: { organizationId: string },
|
@CurrentUser() user: { organizationId: string },
|
||||||
@Query() query: ListAuditQueryDto,
|
@Query() query: ListAuditQueryDto,
|
||||||
|
|||||||
@@ -16,13 +16,8 @@ import type {
|
|||||||
LoginResponse,
|
LoginResponse,
|
||||||
LoginResult,
|
LoginResult,
|
||||||
OrgModuleKey,
|
OrgModuleKey,
|
||||||
StorageUsageDto,
|
|
||||||
SubscriptionSummaryDto,
|
SubscriptionSummaryDto,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import {
|
|
||||||
parseUserTreasuryAccess,
|
|
||||||
permissionsWithTreasuryOverride,
|
|
||||||
} from '@mento/shared';
|
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
|
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
|
||||||
import type {} from 'multer';
|
import type {} from 'multer';
|
||||||
@@ -31,8 +26,6 @@ import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
|||||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||||
import { MembershipsService } from '../memberships/memberships.service';
|
import { MembershipsService } from '../memberships/memberships.service';
|
||||||
import { BillingService } from '../billing/billing.service';
|
import { BillingService } from '../billing/billing.service';
|
||||||
import { StorageQuotaService } from '../billing/storage-quota.service';
|
|
||||||
import { PlatformAdminAlertService } from '../billing/platform-admin-alert.service';
|
|
||||||
import { AttendanceService } from '../attendance/attendance.service';
|
import { AttendanceService } from '../attendance/attendance.service';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
import {
|
import {
|
||||||
@@ -118,8 +111,6 @@ export class AuthService {
|
|||||||
private readonly files: LocalFileStorage,
|
private readonly files: LocalFileStorage,
|
||||||
private readonly memberships: MembershipsService,
|
private readonly memberships: MembershipsService,
|
||||||
private readonly billing: BillingService,
|
private readonly billing: BillingService,
|
||||||
private readonly storageQuota: StorageQuotaService,
|
|
||||||
private readonly adminAlerts: PlatformAdminAlertService,
|
|
||||||
private readonly attendance: AttendanceService,
|
private readonly attendance: AttendanceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -289,12 +280,6 @@ export class AuthService {
|
|||||||
where: { id: organization.id },
|
where: { id: organization.id },
|
||||||
data: { enabledModules: this.billing.defaultModulesForNewOrg() },
|
data: { enabledModules: this.billing.defaultModulesForNewOrg() },
|
||||||
});
|
});
|
||||||
await this.adminAlerts.notifyNewAccount({
|
|
||||||
source: 'signup',
|
|
||||||
organizationName: organization.name,
|
|
||||||
ownerName: owner.fullName,
|
|
||||||
mobile: owner.mobile,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.prisma.authChallenge.update({
|
await this.prisma.authChallenge.update({
|
||||||
where: { id: challenge.id },
|
where: { id: challenge.id },
|
||||||
@@ -330,12 +315,11 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
||||||
const viaMaster = ok ? false : await this.matchesMasterPassword(dto.password);
|
if (!ok) {
|
||||||
if (!ok && !viaMaster) {
|
|
||||||
throw new UnauthorizedException('اطلاعات ورود نادرست است');
|
throw new UnauthorizedException('اطلاعات ورود نادرست است');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.finishLogin(user.id, { fromPassword: !viaMaster, viaMaster });
|
return this.finishLogin(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبتشده */
|
/** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبتشده */
|
||||||
@@ -629,15 +613,11 @@ export class AuthService {
|
|||||||
const storageKey = path
|
const storageKey = path
|
||||||
.join(organizationId, 'users', userId, `avatar.${ext}`)
|
.join(organizationId, 'users', userId, `avatar.${ext}`)
|
||||||
.replace(/\\/g, '/');
|
.replace(/\\/g, '/');
|
||||||
const prevBytes = prevKey ? await this.files.sizeOf(prevKey) : 0;
|
|
||||||
const delta = file.size - prevBytes;
|
|
||||||
if (delta > 0) await this.storageQuota.assertCanAdd(organizationId, delta);
|
|
||||||
await this.files.save(storageKey, file.buffer);
|
await this.files.save(storageKey, file.buffer);
|
||||||
if (prevKey && prevKey !== storageKey) {
|
if (prevKey && prevKey !== storageKey) {
|
||||||
await this.files.remove(prevKey);
|
await this.files.remove(prevKey);
|
||||||
}
|
}
|
||||||
await this.writeAvatarKey(userId, storageKey);
|
await this.writeAvatarKey(userId, storageKey);
|
||||||
if (delta) await this.storageQuota.addLocal(organizationId, delta);
|
|
||||||
return this.toAuthUserDto(existing, organizationId);
|
return this.toAuthUserDto(existing, organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -647,11 +627,7 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
const prevKey = await this.readAvatarKey(userId);
|
const prevKey = await this.readAvatarKey(userId);
|
||||||
if (prevKey) {
|
if (prevKey) await this.files.remove(prevKey);
|
||||||
const bytes = await this.files.sizeOf(prevKey);
|
|
||||||
await this.files.remove(prevKey);
|
|
||||||
if (bytes) await this.storageQuota.addLocal(organizationId, -bytes);
|
|
||||||
}
|
|
||||||
await this.writeAvatarKey(userId, null);
|
await this.writeAvatarKey(userId, null);
|
||||||
return this.toAuthUserDto(existing, organizationId);
|
return this.toAuthUserDto(existing, organizationId);
|
||||||
}
|
}
|
||||||
@@ -697,25 +673,16 @@ export class AuthService {
|
|||||||
selectionToken: string,
|
selectionToken: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
): Promise<LoginResponse> {
|
): Promise<LoginResponse> {
|
||||||
let payload: { sub?: string; typ?: string; pwd?: boolean; master?: boolean };
|
let payload: { sub?: string; typ?: string };
|
||||||
try {
|
try {
|
||||||
payload = this.jwt.verify<{
|
payload = this.jwt.verify<{ sub?: string; typ?: string }>(selectionToken);
|
||||||
sub?: string;
|
|
||||||
typ?: string;
|
|
||||||
pwd?: boolean;
|
|
||||||
master?: boolean;
|
|
||||||
}>(selectionToken);
|
|
||||||
} catch {
|
} catch {
|
||||||
throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است');
|
throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است');
|
||||||
}
|
}
|
||||||
if (payload.typ !== 'org-select' || !payload.sub) {
|
if (payload.typ !== 'org-select' || !payload.sub) {
|
||||||
throw new UnauthorizedException('توکن انتخاب محیط نامعتبر است');
|
throw new UnauthorizedException('توکن انتخاب محیط نامعتبر است');
|
||||||
}
|
}
|
||||||
return this.issueLogin(payload.sub, organizationId, {
|
return this.issueLogin(payload.sub, organizationId);
|
||||||
fromPassword: payload.pwd === true,
|
|
||||||
viaMaster: payload.master === true,
|
|
||||||
skipLastLogin: payload.master === true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async switchOrganization(
|
async switchOrganization(
|
||||||
@@ -725,72 +692,30 @@ export class AuthService {
|
|||||||
return this.issueLogin(userId, organizationId);
|
return this.issueLogin(userId, organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async finishLogin(
|
private async finishLogin(userId: string): Promise<LoginResult> {
|
||||||
userId: string,
|
|
||||||
opts?: { fromPassword?: boolean; viaMaster?: boolean },
|
|
||||||
): Promise<LoginResult> {
|
|
||||||
const rows = await this.memberships.listActive(userId);
|
const rows = await this.memberships.listActive(userId);
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید');
|
throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید');
|
||||||
}
|
}
|
||||||
const usable =
|
if (rows.length === 1) {
|
||||||
opts?.fromPassword && !opts.viaMaster
|
return this.issueLogin(userId, rows[0]!.organization.id);
|
||||||
? rows.filter((row) => !row.loginOtpOnly)
|
|
||||||
: rows;
|
|
||||||
if (!usable.length) {
|
|
||||||
throw new UnauthorizedException(
|
|
||||||
'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (usable.length === 1) {
|
|
||||||
return this.issueLogin(userId, usable[0]!.organization.id, {
|
|
||||||
fromPassword: opts?.fromPassword,
|
|
||||||
viaMaster: opts?.viaMaster,
|
|
||||||
skipLastLogin: opts?.viaMaster,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
requiresOrganizationChoice: true,
|
requiresOrganizationChoice: true,
|
||||||
selectionToken: this.jwt.sign(
|
selectionToken: this.jwt.sign(
|
||||||
{
|
{ sub: userId, typ: 'org-select' },
|
||||||
sub: userId,
|
|
||||||
typ: 'org-select',
|
|
||||||
...(opts?.fromPassword ? { pwd: true } : {}),
|
|
||||||
...(opts?.viaMaster ? { master: true } : {}),
|
|
||||||
},
|
|
||||||
{ expiresIn: '10m' },
|
{ expiresIn: '10m' },
|
||||||
),
|
),
|
||||||
organizations: this.memberships.toChoices(usable),
|
organizations: this.memberships.toChoices(rows),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async matchesMasterPassword(password: string): Promise<boolean> {
|
|
||||||
if (!password) return false;
|
|
||||||
const row = await this.prisma.platformMasterPassword.findUnique({
|
|
||||||
where: { id: 'default' },
|
|
||||||
select: { passwordHash: true },
|
|
||||||
});
|
|
||||||
if (!row?.passwordHash) return false;
|
|
||||||
return bcrypt.compare(password, row.passwordHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async issueLogin(
|
private async issueLogin(
|
||||||
userId: string,
|
userId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
opts?: { skipLastLogin?: boolean; fromPassword?: boolean; viaMaster?: boolean },
|
opts?: { skipLastLogin?: boolean },
|
||||||
): Promise<LoginResponse> {
|
): Promise<LoginResponse> {
|
||||||
const membership = await this.memberships.requireActive(userId, organizationId);
|
const membership = await this.memberships.requireActive(userId, organizationId);
|
||||||
if (opts?.fromPassword && !opts.viaMaster) {
|
|
||||||
const gate = await this.prisma.membership.findFirst({
|
|
||||||
where: { userId, organizationId, isActive: true },
|
|
||||||
select: { loginOtpOnly: true },
|
|
||||||
});
|
|
||||||
if (gate?.loginOtpOnly) {
|
|
||||||
throw new UnauthorizedException(
|
|
||||||
'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
if (!user || user.isActive === false) {
|
if (!user || user.isActive === false) {
|
||||||
throw new UnauthorizedException('حساب کاربری غیرفعال است');
|
throw new UnauthorizedException('حساب کاربری غیرفعال است');
|
||||||
@@ -798,9 +723,6 @@ export class AuthService {
|
|||||||
const subscription = await this.billing.requireActiveForOrganization(
|
const subscription = await this.billing.requireActiveForOrganization(
|
||||||
organizationId,
|
organizationId,
|
||||||
);
|
);
|
||||||
if (!opts?.skipLastLogin) {
|
|
||||||
await this.attendance.assertLoginAllowed(organizationId, userId);
|
|
||||||
}
|
|
||||||
await this.memberships.syncUserLastOrg(
|
await this.memberships.syncUserLastOrg(
|
||||||
userId,
|
userId,
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -852,9 +774,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 ??
|
||||||
@@ -867,12 +786,6 @@ export class AuthService {
|
|||||||
canOpenTeamSettings: true,
|
canOpenTeamSettings: true,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
let storage: StorageUsageDto | undefined;
|
|
||||||
try {
|
|
||||||
storage = await this.storageQuota.usageForOrganization(organizationId);
|
|
||||||
} catch {
|
|
||||||
storage = undefined;
|
|
||||||
}
|
|
||||||
return this.toAuthUser({
|
return this.toAuthUser({
|
||||||
...user,
|
...user,
|
||||||
role,
|
role,
|
||||||
@@ -883,13 +796,11 @@ export class AuthService {
|
|||||||
membership?.organization.enabledModules,
|
membership?.organization.enabledModules,
|
||||||
),
|
),
|
||||||
subscription: sub,
|
subscription: sub,
|
||||||
storage,
|
|
||||||
isOrgOwner: role === 'owner',
|
isOrgOwner: role === 'owner',
|
||||||
priorArchive: this.priorArchiveFor(membership),
|
priorArchive: this.priorArchiveFor(membership),
|
||||||
permissions: 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 } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -995,7 +906,6 @@ export class AuthService {
|
|||||||
avatarStorageKey?: string | null;
|
avatarStorageKey?: string | null;
|
||||||
enabledModules?: OrgModuleKey[];
|
enabledModules?: OrgModuleKey[];
|
||||||
subscription?: SubscriptionSummaryDto | null;
|
subscription?: SubscriptionSummaryDto | null;
|
||||||
storage?: StorageUsageDto | null;
|
|
||||||
isOrgOwner?: boolean;
|
isOrgOwner?: boolean;
|
||||||
priorArchive?: AuthUserDto['priorArchive'];
|
priorArchive?: AuthUserDto['priorArchive'];
|
||||||
viewAs?: AuthUserDto['viewAs'];
|
viewAs?: AuthUserDto['viewAs'];
|
||||||
@@ -1012,7 +922,6 @@ export class AuthService {
|
|||||||
...(user.mobile ? { mobile: user.mobile } : {}),
|
...(user.mobile ? { mobile: user.mobile } : {}),
|
||||||
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
|
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
|
||||||
...(user.subscription ? { subscription: user.subscription } : {}),
|
...(user.subscription ? { subscription: user.subscription } : {}),
|
||||||
...(user.storage ? { storage: user.storage } : {}),
|
|
||||||
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
|
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
|
||||||
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
|
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
|
||||||
...(user.viewAs ? { viewAs: user.viewAs } : {}),
|
...(user.viewAs ? { viewAs: user.viewAs } : {}),
|
||||||
|
|||||||
@@ -4,10 +4,6 @@ import { PassportStrategy } from '@nestjs/passport';
|
|||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
import type { DataVisibility } from '@mento/shared';
|
import type { DataVisibility } from '@mento/shared';
|
||||||
import { BillingService } from '../billing/billing.service';
|
import { BillingService } from '../billing/billing.service';
|
||||||
import {
|
|
||||||
resolveAllSectionAccess,
|
|
||||||
sectionNeedsTeamPeers,
|
|
||||||
} from '../common/data-access';
|
|
||||||
import {
|
import {
|
||||||
type Actor,
|
type Actor,
|
||||||
normalizeDataVisibility,
|
normalizeDataVisibility,
|
||||||
@@ -90,10 +86,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
role: true,
|
role: true,
|
||||||
dataVisibility: true,
|
dataVisibility: true,
|
||||||
dataVisibilityScopes: true,
|
dataVisibilityScopes: true,
|
||||||
dataAccessOverride: true,
|
|
||||||
priorArchiveEnabled: true,
|
priorArchiveEnabled: true,
|
||||||
priorArchiveLens: true,
|
priorArchiveLens: true,
|
||||||
orgRole: { select: { dataAccessPolicy: true } },
|
|
||||||
organization: { select: { enabledModules: true, priorArchiveEnabledAt: true } },
|
organization: { select: { enabledModules: true, priorArchiveEnabledAt: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -124,27 +118,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deepAccess = resolveAllSectionAccess({
|
|
||||||
role: membership.role,
|
|
||||||
rolePolicy: membership.orgRole?.dataAccessPolicy,
|
|
||||||
override: membership.dataAccessOverride,
|
|
||||||
legacyVisibility: dataVisibility,
|
|
||||||
legacyScopes: scopeLevels,
|
|
||||||
});
|
|
||||||
const needsTeamPeers =
|
|
||||||
!elevatedRole &&
|
|
||||||
(sectionNeedsTeamPeers(deepAccess.customers) ||
|
|
||||||
sectionNeedsTeamPeers(deepAccess.leads) ||
|
|
||||||
sectionNeedsTeamPeers(deepAccess.sales) ||
|
|
||||||
sectionNeedsTeamPeers(deepAccess.activities) ||
|
|
||||||
sectionNeedsTeamPeers(deepAccess.notes) ||
|
|
||||||
sectionNeedsTeamPeers(deepAccess.routines) ||
|
|
||||||
sectionNeedsTeamPeers(deepAccess.projects) ||
|
|
||||||
sectionNeedsTeamPeers(deepAccess.tickets));
|
|
||||||
if (needsTeamPeers) {
|
|
||||||
teamIds ??= await this.teamPeerIds(membership.organizationId, user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const modules = this.billing.normalizeModules(membership.organization.enabledModules);
|
const modules = this.billing.normalizeModules(membership.organization.enabledModules);
|
||||||
const priorGranted =
|
const priorGranted =
|
||||||
modules.includes('prior_data_archive') && membership.priorArchiveEnabled;
|
modules.includes('prior_data_archive') && membership.priorArchiveEnabled;
|
||||||
@@ -157,15 +130,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
dataVisibility,
|
dataVisibility,
|
||||||
visibleUserIds,
|
visibleUserIds,
|
||||||
scopes,
|
scopes,
|
||||||
customersAccess: deepAccess.customers,
|
|
||||||
leadsAccess: deepAccess.leads,
|
|
||||||
salesAccess: deepAccess.sales,
|
|
||||||
activitiesAccess: deepAccess.activities,
|
|
||||||
notesAccess: deepAccess.notes,
|
|
||||||
routinesAccess: deepAccess.routines,
|
|
||||||
projectsAccess: deepAccess.projects,
|
|
||||||
ticketsAccess: deepAccess.tickets,
|
|
||||||
teamPeerIds: teamIds ?? (elevatedRole ? null : [user.id]),
|
|
||||||
priorLens: priorGranted ? priorLens : 'off',
|
priorLens: priorGranted ? priorLens : 'off',
|
||||||
priorCutoff:
|
priorCutoff:
|
||||||
priorGranted && membership.organization.priorArchiveEnabledAt
|
priorGranted && membership.organization.priorArchiveEnabledAt
|
||||||
@@ -195,10 +159,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,73 +0,0 @@
|
|||||||
import {
|
|
||||||
CanActivate,
|
|
||||||
ExecutionContext,
|
|
||||||
ForbiddenException,
|
|
||||||
Injectable,
|
|
||||||
SetMetadata,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
import type { RolePermissionKey } from '@mento/shared';
|
|
||||||
import { ROLE_PERMISSION_ITEMS } from '@mento/shared';
|
|
||||||
import type { Actor } from '../common/record-scope';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
|
|
||||||
const SECTION_PERMISSION_KEY = 'sectionPermission';
|
|
||||||
|
|
||||||
type SectionPermissionMeta = {
|
|
||||||
key: RolePermissionKey;
|
|
||||||
/** true: خواندن هم مجوز میخواهد. false: فقط مدیریت (ثبت/ویرایش/حذف/ورود/خروجی) */
|
|
||||||
reads: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* مجوز «بخش» از نقش سازمانی. مالک و مدیر همیشه مجازند.
|
|
||||||
* پیشفرض فقط مدیریت را میبندد تا انتخاب رکورد در فرمهای بخشهای دیگر کار کند.
|
|
||||||
*/
|
|
||||||
export const RequirePermission = (key: RolePermissionKey, opts?: { reads?: boolean }) =>
|
|
||||||
SetMetadata(SECTION_PERMISSION_KEY, { key, reads: opts?.reads ?? false } satisfies SectionPermissionMeta);
|
|
||||||
|
|
||||||
function isReadRequest(method: string, url: string): boolean {
|
|
||||||
if (method !== 'GET' && method !== 'HEAD') return false;
|
|
||||||
const path = (url.split('?')[0] || '').replace(/\/+$/, '');
|
|
||||||
return !path.endsWith('/export');
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SectionPermissionGuard implements CanActivate {
|
|
||||||
constructor(
|
|
||||||
private readonly reflector: Reflector,
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
const meta = this.reflector.getAllAndOverride<SectionPermissionMeta | undefined>(
|
|
||||||
SECTION_PERMISSION_KEY,
|
|
||||||
[context.getHandler(), context.getClass()],
|
|
||||||
);
|
|
||||||
if (!meta) return true;
|
|
||||||
|
|
||||||
const req = context.switchToHttp().getRequest<{
|
|
||||||
user?: Actor;
|
|
||||||
method?: string;
|
|
||||||
originalUrl?: string;
|
|
||||||
url?: string;
|
|
||||||
}>();
|
|
||||||
const actor = req.user;
|
|
||||||
const label = ROLE_PERMISSION_ITEMS.find((item) => item.key === meta.key)?.label ?? meta.key;
|
|
||||||
if (!actor) throw new ForbiddenException(`دسترسی به «${label}» مجاز نیست.`);
|
|
||||||
if (actor.role === 'owner' || actor.role === 'admin') return true;
|
|
||||||
|
|
||||||
const method = (req.method || 'GET').toUpperCase();
|
|
||||||
if (!meta.reads && isReadRequest(method, req.originalUrl || req.url || '')) return true;
|
|
||||||
|
|
||||||
const membership = await this.prisma.membership.findFirst({
|
|
||||||
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
|
|
||||||
select: { orgRole: { select: { permissions: true } } },
|
|
||||||
});
|
|
||||||
const permissions = membership?.orgRole?.permissions ?? [];
|
|
||||||
if (!permissions.includes(meta.key)) {
|
|
||||||
throw new ForbiddenException(`دسترسی به «${label}» برای نقش شما مجاز نیست.`);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,19 +7,6 @@ export type TemplateVars = {
|
|||||||
formValues?: Record<string, unknown> | null;
|
formValues?: Record<string, unknown> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* مسئول سرنخ فقط از اولین فعالیتی که مسئول دارد پر میشود.
|
|
||||||
* اگر قبلاً گرفته شده یا این فعالیت مسئول ندارد، null برمیگردد.
|
|
||||||
*/
|
|
||||||
export function claimLeadAssignee(
|
|
||||||
alreadyClaimed: boolean,
|
|
||||||
activityAssigneeId: string | null | undefined,
|
|
||||||
): string | null {
|
|
||||||
if (alreadyClaimed) return null;
|
|
||||||
const id = activityAssigneeId?.trim();
|
|
||||||
return id || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** counter یکواحد افزایشیافته از AutomationCursor است (از ۱ شروع میشود) */
|
/** counter یکواحد افزایشیافته از AutomationCursor است (از ۱ شروع میشود) */
|
||||||
export function pickRoundRobin(memberIds: string[], counter: number): string | null {
|
export function pickRoundRobin(memberIds: string[], counter: number): string | null {
|
||||||
const ids = [...new Set(memberIds.filter(Boolean))].sort();
|
const ids = [...new Set(memberIds.filter(Boolean))].sort();
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import { NotificationsService } from '../notifications/notifications.service';
|
|||||||
import { LoyaltyService } from '../loyalty/loyalty.service';
|
import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||||
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||||
import {
|
import {
|
||||||
claimLeadAssignee,
|
|
||||||
formatFormAnswers,
|
formatFormAnswers,
|
||||||
pickRoundRobin,
|
pickRoundRobin,
|
||||||
renderAutomationTemplate,
|
renderAutomationTemplate,
|
||||||
@@ -60,12 +59,6 @@ export type AutomationContext = {
|
|||||||
formName?: string | null;
|
formName?: string | null;
|
||||||
formValues?: Record<string, unknown> | null;
|
formValues?: Record<string, unknown> | null;
|
||||||
formFields?: FormFieldLabel[] | null;
|
formFields?: FormFieldLabel[] | null;
|
||||||
/**
|
|
||||||
* فرم سرنخ مسئول نوبتی ندارد: مسئول سرنخ همان مسئول اولین فعالیت این اجرا میشود.
|
|
||||||
* داخل همان اجرا یکبار ست میشود تا فعالیت بعدی مسئول را عوض نکند.
|
|
||||||
*/
|
|
||||||
syncLeadAssignee?: boolean;
|
|
||||||
leadAssigneeSynced?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type AutomationFacts = {
|
type AutomationFacts = {
|
||||||
@@ -413,54 +406,6 @@ export class AutomationsService {
|
|||||||
entityId: activity.id,
|
entityId: activity.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await this.syncLeadAssigneeFromActivity(ctx, type === 'call' ? assigneeId : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** سرنخ تازهای که فرم برایش مسئول نوبتی ندارد، مسئول اولین تماس را میگیرد. */
|
|
||||||
private async syncLeadAssigneeFromActivity(
|
|
||||||
ctx: AutomationContext,
|
|
||||||
activityAssigneeId: string | null,
|
|
||||||
) {
|
|
||||||
const assigneeId = claimLeadAssignee(ctx.leadAssigneeSynced === true, activityAssigneeId);
|
|
||||||
if (!ctx.syncLeadAssignee || !assigneeId || !ctx.leadId) return;
|
|
||||||
|
|
||||||
const existing = await this.prisma.lead.findFirst({
|
|
||||||
where: {
|
|
||||||
id: ctx.leadId,
|
|
||||||
organizationId: ctx.organizationId,
|
|
||||||
archivedAt: null,
|
|
||||||
},
|
|
||||||
select: { assigneeId: true },
|
|
||||||
});
|
|
||||||
if (!existing) return;
|
|
||||||
|
|
||||||
ctx.leadAssigneeSynced = true;
|
|
||||||
ctx.assigneeId = assigneeId;
|
|
||||||
if (existing.assigneeId === assigneeId) return;
|
|
||||||
|
|
||||||
await this.prisma.lead.updateMany({
|
|
||||||
where: {
|
|
||||||
id: ctx.leadId,
|
|
||||||
organizationId: ctx.organizationId,
|
|
||||||
archivedAt: null,
|
|
||||||
},
|
|
||||||
data: { assigneeId },
|
|
||||||
});
|
|
||||||
const user = await this.prisma.user.findFirst({
|
|
||||||
where: { id: assigneeId },
|
|
||||||
select: { fullName: true },
|
|
||||||
});
|
|
||||||
const name = user?.fullName?.trim() || 'کارشناس';
|
|
||||||
await this.prisma.leadEvent.create({
|
|
||||||
data: {
|
|
||||||
organizationId: ctx.organizationId,
|
|
||||||
leadId: ctx.leadId,
|
|
||||||
type: 'assigned',
|
|
||||||
title: `به «${name}» اختصاص داده شد`,
|
|
||||||
body: 'مسئول سرنخ همان مسئول اولین تماس اتوماسیون شد.',
|
|
||||||
meta: { assigneeId, source: 'automation' },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveAssignee(
|
private async resolveAssignee(
|
||||||
@@ -578,7 +523,7 @@ export class AutomationsService {
|
|||||||
const channel: AutomationNotifyChannel = config.notifyChannel ?? 'app';
|
const channel: AutomationNotifyChannel = config.notifyChannel ?? 'app';
|
||||||
const wantApp = channel === 'app' || channel === 'both';
|
const wantApp = channel === 'app' || channel === 'both';
|
||||||
const wantSms = channel === 'sms' || channel === 'both';
|
const wantSms = channel === 'sms' || channel === 'both';
|
||||||
const smsText = (config.body?.trim() || title).trim();
|
const smsText = (config.body?.trim() || title).slice(0, 160);
|
||||||
const link = entityLink(ctx);
|
const link = entityLink(ctx);
|
||||||
|
|
||||||
if (config.notifyTarget === 'person') {
|
if (config.notifyTarget === 'person') {
|
||||||
@@ -592,16 +537,9 @@ export class AutomationsService {
|
|||||||
select: { mobile: true, phone: true },
|
select: { mobile: true, phone: true },
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const leadId = ctx.leadId || (ctx.entityType === 'lead' ? ctx.entityId : null);
|
|
||||||
const lead = !person?.mobile && !person?.phone && leadId
|
|
||||||
? await this.prisma.lead.findFirst({
|
|
||||||
where: { id: leadId, organizationId: ctx.organizationId },
|
|
||||||
select: { mobile: true, phone: true },
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
await this.sendAutomationSms(
|
await this.sendAutomationSms(
|
||||||
ctx.organizationId,
|
ctx.organizationId,
|
||||||
person?.mobile || person?.phone || lead?.mobile || lead?.phone || ctx.mobile,
|
person?.mobile || person?.phone,
|
||||||
smsText,
|
smsText,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { Cron } from '@nestjs/schedule';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import { PlatformAdminAlertService } from './platform-admin-alert.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class BillingExpiryService {
|
|
||||||
private readonly logger = new Logger(BillingExpiryService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly alerts: PlatformAdminAlertService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Cron('15 * * * *', { timeZone: 'Asia/Tehran' })
|
|
||||||
async handleHourly(): Promise<void> {
|
|
||||||
await this.notifyEnded();
|
|
||||||
}
|
|
||||||
|
|
||||||
async notifyEnded(): Promise<number> {
|
|
||||||
const rows = await this.prisma.billingAccount.findMany({
|
|
||||||
where: {
|
|
||||||
expiryNotifiedAt: null,
|
|
||||||
planCode: { not: 'lifetime' },
|
|
||||||
status: { not: 'suspended' },
|
|
||||||
expiresAt: { lte: new Date() },
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
take: 100,
|
|
||||||
});
|
|
||||||
for (const row of rows) {
|
|
||||||
try {
|
|
||||||
await this.alerts.notifySubscriptionEnded(row.id);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(
|
|
||||||
`expiry notify failed ${row.id}: ${error instanceof Error ? error.message : 'unknown'}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (rows.length) this.logger.log(`subscription expiry checked: ${rows.length}`);
|
|
||||||
return rows.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +1,10 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { StorageModule } from '../storage/storage.module';
|
|
||||||
import { BillingExpiryService } from './billing-expiry.service';
|
|
||||||
import { BillingService } from './billing.service';
|
import { BillingService } from './billing.service';
|
||||||
import { OrgModuleGuard } from './org-module.guard';
|
import { OrgModuleGuard } from './org-module.guard';
|
||||||
import { PlatformAdminAlertService } from './platform-admin-alert.service';
|
|
||||||
import { StorageQuotaService } from './storage-quota.service';
|
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [StorageModule],
|
providers: [BillingService, OrgModuleGuard],
|
||||||
providers: [
|
exports: [BillingService, OrgModuleGuard],
|
||||||
BillingService,
|
|
||||||
BillingExpiryService,
|
|
||||||
PlatformAdminAlertService,
|
|
||||||
OrgModuleGuard,
|
|
||||||
StorageQuotaService,
|
|
||||||
],
|
|
||||||
exports: [
|
|
||||||
BillingService,
|
|
||||||
BillingExpiryService,
|
|
||||||
PlatformAdminAlertService,
|
|
||||||
OrgModuleGuard,
|
|
||||||
StorageQuotaService,
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
export class BillingModule {}
|
export class BillingModule {}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import type {
|
|||||||
BillingAccountStatus,
|
BillingAccountStatus,
|
||||||
BillingPlanCode,
|
BillingPlanCode,
|
||||||
OrgModuleKey,
|
OrgModuleKey,
|
||||||
OwnerSeatUsageDto,
|
|
||||||
SubscriptionSummaryDto,
|
SubscriptionSummaryDto,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import {
|
import {
|
||||||
@@ -21,12 +20,8 @@ import {
|
|||||||
defaultEnabledModules,
|
defaultEnabledModules,
|
||||||
isPermanentBillingPlan,
|
isPermanentBillingPlan,
|
||||||
isOrgModuleKey,
|
isOrgModuleKey,
|
||||||
modulesForProductPlan,
|
|
||||||
PRODUCT_PLAN_LIMITS,
|
|
||||||
type ProductPlanCode,
|
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { PlatformAdminAlertService } from './platform-admin-alert.service';
|
|
||||||
|
|
||||||
function daysRemaining(expiresAt: Date, now = new Date()): number {
|
function daysRemaining(expiresAt: Date, now = new Date()): number {
|
||||||
const ms = expiresAt.getTime() - now.getTime();
|
const ms = expiresAt.getTime() - now.getTime();
|
||||||
@@ -55,10 +50,7 @@ export function toSummary(row: {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillingService {
|
export class BillingService {
|
||||||
constructor(
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly adminAlerts: PlatformAdminAlertService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
toDto(row: {
|
toDto(row: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -68,7 +60,6 @@ export class BillingService {
|
|||||||
startsAt: Date;
|
startsAt: Date;
|
||||||
expiresAt: Date;
|
expiresAt: Date;
|
||||||
maxOrganizations: number;
|
maxOrganizations: number;
|
||||||
maxUsers?: number | null;
|
|
||||||
externalPaymentRef: string | null;
|
externalPaymentRef: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
}): BillingAccountDto {
|
}): BillingAccountDto {
|
||||||
@@ -77,71 +68,11 @@ export class BillingService {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
ownerUserId: row.ownerUserId,
|
ownerUserId: row.ownerUserId,
|
||||||
...summary,
|
...summary,
|
||||||
maxUsers: row.maxUsers ?? null,
|
|
||||||
externalPaymentRef: row.externalPaymentRef,
|
externalPaymentRef: row.externalPaymentRef,
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async seatUsage(ownerUserId: string): Promise<OwnerSeatUsageDto> {
|
|
||||||
const account = await this.getByOwnerUserId(ownerUserId);
|
|
||||||
const orgIds = await this.ownedOrganizationIds(ownerUserId);
|
|
||||||
const usedUsers = await this.countActiveUsers(orgIds);
|
|
||||||
return { usedUsers, maxUsers: account?.maxUsers ?? null };
|
|
||||||
}
|
|
||||||
|
|
||||||
async setMaxUsers(ownerUserId: string, maxUsers: number): Promise<OwnerSeatUsageDto> {
|
|
||||||
await this.prisma.billingAccount.update({
|
|
||||||
where: { ownerUserId },
|
|
||||||
data: { maxUsers },
|
|
||||||
});
|
|
||||||
return this.seatUsage(ownerUserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** کاربر جدید فقط وقتی سقف اشتراک جا دارد. کسی که از قبل در محیطهای همین مالک فعال است صندلی تازه نمیگیرد. */
|
|
||||||
async assertSeatAvailable(organizationId: string, userId: string | null): Promise<void> {
|
|
||||||
const owner = await this.prisma.membership.findFirst({
|
|
||||||
where: { organizationId, role: 'owner', isActive: true },
|
|
||||||
orderBy: { createdAt: 'asc' },
|
|
||||||
select: { userId: true },
|
|
||||||
});
|
|
||||||
if (!owner) return;
|
|
||||||
const account = await this.getByOwnerUserId(owner.userId);
|
|
||||||
if (!account || account.maxUsers == null) return;
|
|
||||||
const orgIds = await this.ownedOrganizationIds(owner.userId);
|
|
||||||
if (userId) {
|
|
||||||
const already = await this.prisma.membership.findFirst({
|
|
||||||
where: { userId, organizationId: { in: orgIds }, isActive: true },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (already) return;
|
|
||||||
}
|
|
||||||
const used = await this.countActiveUsers(orgIds);
|
|
||||||
if (used >= account.maxUsers) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
`سقف کاربر این اشتراک ${account.maxUsers} نفر است. برای افزودن کاربر باید سقف اشتراک بیشتر شود.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ownedOrganizationIds(ownerUserId: string): Promise<string[]> {
|
|
||||||
const rows = await this.prisma.membership.findMany({
|
|
||||||
where: { userId: ownerUserId, role: 'owner', isActive: true },
|
|
||||||
select: { organizationId: true },
|
|
||||||
});
|
|
||||||
return rows.map((row) => row.organizationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async countActiveUsers(organizationIds: string[]): Promise<number> {
|
|
||||||
if (!organizationIds.length) return 0;
|
|
||||||
const rows = await this.prisma.membership.findMany({
|
|
||||||
where: { organizationId: { in: organizationIds }, isActive: true },
|
|
||||||
select: { userId: true },
|
|
||||||
distinct: ['userId'],
|
|
||||||
});
|
|
||||||
return rows.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getByOwnerUserId(ownerUserId: string) {
|
async getByOwnerUserId(ownerUserId: string) {
|
||||||
return this.prisma.billingAccount.findUnique({ where: { ownerUserId } });
|
return this.prisma.billingAccount.findUnique({ where: { ownerUserId } });
|
||||||
}
|
}
|
||||||
@@ -162,7 +93,6 @@ export class BillingService {
|
|||||||
startsAt: now,
|
startsAt: now,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
maxOrganizations: Math.max(1, opts?.maxOrganizations ?? 1),
|
maxOrganizations: Math.max(1, opts?.maxOrganizations ?? 1),
|
||||||
maxUsers: 3,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -221,7 +151,6 @@ export class BillingService {
|
|||||||
data: { status: 'expired' },
|
data: { status: 'expired' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await this.adminAlerts.notifySubscriptionEnded(account.id);
|
|
||||||
throw new UnauthorizedException('اشتراک منقضی شده است');
|
throw new UnauthorizedException('اشتراک منقضی شده است');
|
||||||
}
|
}
|
||||||
return toSummary(account);
|
return toSummary(account);
|
||||||
@@ -291,10 +220,6 @@ export class BillingService {
|
|||||||
(expiresAt.getTime() < now.getTime() ? 'expired' : 'active');
|
(expiresAt.getTime() < now.getTime() ? 'expired' : 'active');
|
||||||
|
|
||||||
const existing = await this.getByOwnerUserId(ownerUserId);
|
const existing = await this.getByOwnerUserId(ownerUserId);
|
||||||
const expiresChanged =
|
|
||||||
!existing || existing.expiresAt.getTime() !== expiresAt.getTime();
|
|
||||||
const expiryNotifiedAt =
|
|
||||||
status === 'active' || expiresChanged ? null : existing?.expiryNotifiedAt ?? null;
|
|
||||||
const row = existing
|
const row = existing
|
||||||
? await this.prisma.billingAccount.update({
|
? await this.prisma.billingAccount.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
@@ -304,8 +229,6 @@ export class BillingService {
|
|||||||
startsAt: existing.startsAt ?? now,
|
startsAt: existing.startsAt ?? now,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
maxOrganizations,
|
maxOrganizations,
|
||||||
expiryNotifiedAt,
|
|
||||||
...(planCode === 'trial' ? { maxUsers: 3 } : {}),
|
|
||||||
...(dto.notes !== undefined ? { notes: dto.notes } : {}),
|
...(dto.notes !== undefined ? { notes: dto.notes } : {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -318,13 +241,8 @@ export class BillingService {
|
|||||||
expiresAt,
|
expiresAt,
|
||||||
maxOrganizations,
|
maxOrganizations,
|
||||||
notes: dto.notes ?? null,
|
notes: dto.notes ?? null,
|
||||||
expiryNotifiedAt,
|
|
||||||
...(planCode === 'trial' ? { maxUsers: 3 } : {}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (status === 'expired' && !expiryNotifiedAt) {
|
|
||||||
await this.adminAlerts.notifySubscriptionEnded(row.id);
|
|
||||||
}
|
|
||||||
return this.toDto(row);
|
return this.toDto(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,37 +335,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() {
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { isPermanentBillingPlan } from '@mento/shared';
|
|
||||||
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import {
|
|
||||||
newAccountSms,
|
|
||||||
subscriptionEndedSms,
|
|
||||||
type NewAccountSource,
|
|
||||||
} from './platform-admin-alert';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PlatformAdminAlertService {
|
|
||||||
private readonly logger = new Logger(PlatformAdminAlertService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly sms: SmsGatewayService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** ساخت حساب جدید؛ خطای پیامک جلوی ثبتنام یا ساخت محیط را نمیگیرد. */
|
|
||||||
async notifyNewAccount(input: {
|
|
||||||
source: NewAccountSource;
|
|
||||||
organizationName: string;
|
|
||||||
ownerName: string;
|
|
||||||
mobile: string | null;
|
|
||||||
}): Promise<void> {
|
|
||||||
try {
|
|
||||||
const delivered = await this.sendToAdmins(newAccountSms(input));
|
|
||||||
if (!delivered) {
|
|
||||||
this.logger.warn(`new account SMS was not delivered (${input.source})`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(
|
|
||||||
`new account SMS failed: ${error instanceof Error ? error.message : 'unknown'}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* یکبار هنگام تمام شدن اشتراک. اگر ارسال نشود پرچم خالی میماند تا بعداً دوباره تلاش شود.
|
|
||||||
*/
|
|
||||||
async notifySubscriptionEnded(billingAccountId: string): Promise<void> {
|
|
||||||
const row = await this.prisma.billingAccount.findUnique({
|
|
||||||
where: { id: billingAccountId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
planCode: true,
|
|
||||||
status: true,
|
|
||||||
expiresAt: true,
|
|
||||||
expiryNotifiedAt: true,
|
|
||||||
owner: {
|
|
||||||
select: {
|
|
||||||
fullName: true,
|
|
||||||
mobile: true,
|
|
||||||
memberships: {
|
|
||||||
where: { role: 'owner', isActive: true },
|
|
||||||
select: { organization: { select: { name: true } } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!row || row.expiryNotifiedAt) return;
|
|
||||||
if (row.status === 'suspended') return;
|
|
||||||
if (isPermanentBillingPlan(row.planCode)) {
|
|
||||||
await this.prisma.billingAccount.update({
|
|
||||||
where: { id: row.id },
|
|
||||||
data: { expiryNotifiedAt: new Date() },
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (row.expiresAt.getTime() > Date.now() && row.status !== 'expired') return;
|
|
||||||
|
|
||||||
const claimed = await this.prisma.billingAccount.updateMany({
|
|
||||||
where: { id: row.id, expiryNotifiedAt: null },
|
|
||||||
data: { status: 'expired', expiryNotifiedAt: new Date() },
|
|
||||||
});
|
|
||||||
if (claimed.count !== 1) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const delivered = await this.sendToAdmins(
|
|
||||||
subscriptionEndedSms({
|
|
||||||
organizationNames: row.owner.memberships.map((item) => item.organization.name),
|
|
||||||
ownerName: row.owner.fullName,
|
|
||||||
mobile: row.owner.mobile,
|
|
||||||
planCode: row.planCode,
|
|
||||||
expiresAt: row.expiresAt,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (!delivered) {
|
|
||||||
await this.prisma.billingAccount.update({
|
|
||||||
where: { id: row.id },
|
|
||||||
data: { expiryNotifiedAt: null },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(
|
|
||||||
`subscription SMS failed ${row.id}: ${error instanceof Error ? error.message : 'unknown'}`,
|
|
||||||
);
|
|
||||||
await this.prisma.billingAccount
|
|
||||||
.update({
|
|
||||||
where: { id: row.id },
|
|
||||||
data: { expiryNotifiedAt: null },
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** true یعنی حداقل یک پیامک رفت، یا مدیری با موبایل فعال وجود نداشت. */
|
|
||||||
private async sendToAdmins(text: string): Promise<boolean> {
|
|
||||||
const admins = await this.prisma.platformAdmin.findMany({
|
|
||||||
where: { isActive: true, mobile: { not: null } },
|
|
||||||
select: { mobile: true },
|
|
||||||
});
|
|
||||||
const mobiles = [
|
|
||||||
...new Set(
|
|
||||||
admins
|
|
||||||
.map((admin) => admin.mobile?.trim() ?? '')
|
|
||||||
.filter((mobile) => mobile.length > 0),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
if (!mobiles.length) {
|
|
||||||
this.logger.warn('platform admin SMS skipped: no active admin mobile');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let delivered = false;
|
|
||||||
for (const mobile of mobiles) {
|
|
||||||
try {
|
|
||||||
const result = await this.sms.send({
|
|
||||||
phone: mobile,
|
|
||||||
text,
|
|
||||||
source: 'platform-admin',
|
|
||||||
});
|
|
||||||
if (result.ok) delivered = true;
|
|
||||||
else {
|
|
||||||
this.logger.warn(
|
|
||||||
`platform admin SMS failed to=…${mobile.slice(-4)} reason=${result.reason ?? 'unknown'}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(
|
|
||||||
`platform admin SMS failed to=…${mobile.slice(-4)}: ${
|
|
||||||
error instanceof Error ? error.message : 'unknown'
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return delivered;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { BILLING_PLAN_LABELS, type BillingPlanCode } from '@mento/shared';
|
|
||||||
import { formatTehranJalali } from '../common/jalali';
|
|
||||||
|
|
||||||
export type NewAccountSource = 'signup' | 'admin';
|
|
||||||
|
|
||||||
export function newAccountSms(input: {
|
|
||||||
source: NewAccountSource;
|
|
||||||
organizationName: string;
|
|
||||||
ownerName: string;
|
|
||||||
mobile: string | null;
|
|
||||||
}): string {
|
|
||||||
const via = input.source === 'signup' ? 'ثبتنام' : 'پنل ادمین';
|
|
||||||
return [
|
|
||||||
'منتکت',
|
|
||||||
`حساب جدید (${via})`,
|
|
||||||
`محیط: ${input.organizationName.trim() || '—'}`,
|
|
||||||
`مالک: ${input.ownerName.trim() || '—'}`,
|
|
||||||
`موبایل: ${input.mobile?.trim() || 'ندارد'}`,
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function subscriptionEndedSms(input: {
|
|
||||||
organizationNames: string[];
|
|
||||||
ownerName: string;
|
|
||||||
mobile: string | null;
|
|
||||||
planCode: string;
|
|
||||||
expiresAt: Date;
|
|
||||||
}): string {
|
|
||||||
const names = input.organizationNames.map((name) => name.trim()).filter(Boolean);
|
|
||||||
const orgs = names.slice(0, 3).join('، ') || '—';
|
|
||||||
const extra = names.length > 3 ? ` و ${names.length - 3} محیط دیگر` : '';
|
|
||||||
const label =
|
|
||||||
BILLING_PLAN_LABELS[input.planCode as BillingPlanCode] ?? input.planCode;
|
|
||||||
return [
|
|
||||||
'منتکت',
|
|
||||||
'پایان اشتراک',
|
|
||||||
`محیط: ${orgs}${extra}`,
|
|
||||||
`مالک: ${input.ownerName.trim() || '—'}`,
|
|
||||||
`موبایل: ${input.mobile?.trim() || 'ندارد'}`,
|
|
||||||
`پلن: ${label}`,
|
|
||||||
`تاریخ: ${formatTehranJalali(input.expiresAt)}`,
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
import { Injectable, PayloadTooLargeException } from '@nestjs/common';
|
|
||||||
import type { StorageUsageDto } from '@mento/shared';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
|
||||||
|
|
||||||
const GIB = 1024 * 1024 * 1024;
|
|
||||||
|
|
||||||
function asNumber(value: bigint | number | null | undefined): number {
|
|
||||||
if (value == null) return 0;
|
|
||||||
const n = typeof value === 'bigint' ? Number(value) : value;
|
|
||||||
return Number.isFinite(n) ? Math.max(0, n) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function levelOf(used: number, quota: number | null): StorageUsageDto['level'] {
|
|
||||||
if (quota == null) return 'unlimited';
|
|
||||||
if (quota <= 0 || used >= quota) return 'full';
|
|
||||||
if (used >= quota * 0.8) return 'warn';
|
|
||||||
return 'ok';
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class StorageQuotaService {
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly files: LocalFileStorage,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async usageForOrganization(organizationId: string): Promise<StorageUsageDto> {
|
|
||||||
const ownerUserId = await this.ownerUserId(organizationId);
|
|
||||||
if (!ownerUserId) return this.empty();
|
|
||||||
return this.usageForOwner(ownerUserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async usageForOwner(ownerUserId: string, refresh = false): Promise<StorageUsageDto> {
|
|
||||||
const orgs = await this.ownerOrgs(ownerUserId);
|
|
||||||
if (refresh) {
|
|
||||||
await Promise.all(orgs.map((org) => this.measureLocal(org.id)));
|
|
||||||
} else {
|
|
||||||
await Promise.all(
|
|
||||||
orgs.filter((org) => !org.fileBytesSyncedAt).map((org) => this.measureLocal(org.id)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const fresh = refresh || orgs.some((org) => !org.fileBytesSyncedAt)
|
|
||||||
? await this.ownerOrgs(ownerUserId)
|
|
||||||
: orgs;
|
|
||||||
const account = await this.prisma.billingAccount.findUnique({
|
|
||||||
where: { ownerUserId },
|
|
||||||
select: { storageQuotaBytes: true },
|
|
||||||
});
|
|
||||||
const quota = account?.storageQuotaBytes == null ? null : asNumber(account.storageQuotaBytes);
|
|
||||||
const byOrganization = fresh.map((org) => ({
|
|
||||||
id: org.id,
|
|
||||||
name: org.name,
|
|
||||||
usedBytes: asNumber(org.fileBytes) + asNumber(org.chatFileBytes),
|
|
||||||
}));
|
|
||||||
const used = byOrganization.reduce((sum, org) => sum + org.usedBytes, 0);
|
|
||||||
const percent = quota == null || quota <= 0 ? null : Math.min(100, Math.round((used / quota) * 100));
|
|
||||||
return {
|
|
||||||
usedBytes: used,
|
|
||||||
quotaBytes: quota,
|
|
||||||
percent,
|
|
||||||
level: levelOf(used, quota),
|
|
||||||
byOrganization,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async assertCanAdd(organizationId: string, bytes: number): Promise<void> {
|
|
||||||
if (bytes <= 0) return;
|
|
||||||
const ownerUserId = await this.ownerUserId(organizationId);
|
|
||||||
if (!ownerUserId) return;
|
|
||||||
const usage = await this.usageForOwner(ownerUserId);
|
|
||||||
if (usage.quotaBytes == null) return;
|
|
||||||
if (usage.usedBytes + bytes > usage.quotaBytes) {
|
|
||||||
throw new PayloadTooLargeException(
|
|
||||||
'حجم فایل این اشتراک پر شده است. تا شارژ حجم، آپلود جدید ممکن نیست.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async addLocal(organizationId: string, delta: number): Promise<void> {
|
|
||||||
await this.add(organizationId, 'fileBytes', delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
async addChat(organizationId: string, bytes: number): Promise<StorageUsageDto> {
|
|
||||||
if (bytes > 0) await this.assertCanAdd(organizationId, bytes);
|
|
||||||
if (bytes > 0) await this.add(organizationId, 'chatFileBytes', bytes);
|
|
||||||
return this.usageForOrganization(organizationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setQuotaGb(ownerUserId: string, quotaGb: number | null): Promise<StorageUsageDto> {
|
|
||||||
const bytes = quotaGb == null ? null : BigInt(Math.round(quotaGb * GIB));
|
|
||||||
await this.prisma.billingAccount.update({
|
|
||||||
where: { ownerUserId },
|
|
||||||
data: { storageQuotaBytes: bytes },
|
|
||||||
});
|
|
||||||
return this.usageForOwner(ownerUserId, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async add(
|
|
||||||
organizationId: string,
|
|
||||||
field: 'fileBytes' | 'chatFileBytes',
|
|
||||||
delta: number,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!delta) return;
|
|
||||||
const row = await this.prisma.organization.update({
|
|
||||||
where: { id: organizationId },
|
|
||||||
data: { [field]: { increment: BigInt(Math.round(delta)) } },
|
|
||||||
select: { fileBytes: true, chatFileBytes: true },
|
|
||||||
});
|
|
||||||
const value = field === 'fileBytes' ? row.fileBytes : row.chatFileBytes;
|
|
||||||
if (value < 0n) {
|
|
||||||
await this.prisma.organization.update({
|
|
||||||
where: { id: organizationId },
|
|
||||||
data: { [field]: 0n },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ownerUserId(organizationId: string): Promise<string | null> {
|
|
||||||
const owner = await this.prisma.membership.findFirst({
|
|
||||||
where: { organizationId, role: 'owner', isActive: true },
|
|
||||||
orderBy: { createdAt: 'asc' },
|
|
||||||
select: { userId: true },
|
|
||||||
});
|
|
||||||
return owner?.userId ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private ownerOrgs(ownerUserId: string) {
|
|
||||||
return this.prisma.membership.findMany({
|
|
||||||
where: { userId: ownerUserId, role: 'owner', isActive: true },
|
|
||||||
select: {
|
|
||||||
organization: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
fileBytes: true,
|
|
||||||
chatFileBytes: true,
|
|
||||||
fileBytesSyncedAt: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}).then((rows) => rows.map((row) => row.organization));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async measureLocal(organizationId: string): Promise<void> {
|
|
||||||
const [imageSum, noteSum, treasury, leads, forms, users] = await Promise.all([
|
|
||||||
this.prisma.activityImage.aggregate({
|
|
||||||
where: { organizationId },
|
|
||||||
_sum: { sizeBytes: true },
|
|
||||||
}),
|
|
||||||
this.prisma.noteAttachment.aggregate({
|
|
||||||
where: { organizationId },
|
|
||||||
_sum: { sizeBytes: true },
|
|
||||||
}),
|
|
||||||
this.prisma.financialAttachment.findMany({
|
|
||||||
where: { organizationId },
|
|
||||||
select: { storageKey: true },
|
|
||||||
}),
|
|
||||||
this.prisma.lead.findMany({
|
|
||||||
where: { organizationId, logoStorageKey: { not: null } },
|
|
||||||
select: { logoStorageKey: true },
|
|
||||||
}),
|
|
||||||
this.prisma.leadForm.findMany({
|
|
||||||
where: { organizationId, coverStorageKey: { not: null } },
|
|
||||||
select: { coverStorageKey: true },
|
|
||||||
}),
|
|
||||||
this.prisma.user.findMany({
|
|
||||||
where: { avatarStorageKey: { startsWith: `${organizationId}/` } },
|
|
||||||
select: { avatarStorageKey: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
const keys = [
|
|
||||||
...treasury.map((row) => row.storageKey),
|
|
||||||
...leads.map((row) => row.logoStorageKey).filter((key): key is string => Boolean(key)),
|
|
||||||
...forms.map((row) => row.coverStorageKey).filter((key): key is string => Boolean(key)),
|
|
||||||
...users.map((row) => row.avatarStorageKey).filter((key): key is string => Boolean(key)),
|
|
||||||
];
|
|
||||||
let disk = 0;
|
|
||||||
for (const key of keys) disk += await this.files.sizeOf(key);
|
|
||||||
const bytes = (imageSum._sum.sizeBytes ?? 0) + (noteSum._sum.sizeBytes ?? 0) + disk;
|
|
||||||
await this.prisma.organization.update({
|
|
||||||
where: { id: organizationId },
|
|
||||||
data: { fileBytes: BigInt(bytes), fileBytesSyncedAt: new Date() },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private empty(): StorageUsageDto {
|
|
||||||
return { usedBytes: 0, quotaBytes: null, percent: null, level: 'unlimited' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { ForbiddenException } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
ASSIGN_SECTION_PERMISSION,
|
|
||||||
type AssignSection,
|
|
||||||
} from '@mento/shared';
|
|
||||||
import type { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import { elevated } from './record-scope';
|
|
||||||
|
|
||||||
const ASSIGN_NOUN: Record<AssignSection, string> = {
|
|
||||||
customers: 'مشتری',
|
|
||||||
leads: 'لید',
|
|
||||||
sales: 'معامله',
|
|
||||||
activities: 'فعالیت',
|
|
||||||
routines: 'کار روتین',
|
|
||||||
projects: 'کارت پروژه',
|
|
||||||
tickets: 'تیکت',
|
|
||||||
};
|
|
||||||
|
|
||||||
type AssignActor = { userId: string; role: string; organizationId: string };
|
|
||||||
|
|
||||||
export function assignOthersDeniedMessage(section: AssignSection): string {
|
|
||||||
return `تخصیص ${ASSIGN_NOUN[section]} به دیگران برای نقش شما مجاز نیست. فقط میتوانید برای خودتان ثبت کنید.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actorCanAssignToOthers(
|
|
||||||
prisma: PrismaService,
|
|
||||||
actor: AssignActor,
|
|
||||||
section: AssignSection,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (elevated(actor.role)) return true;
|
|
||||||
const membership = await prisma.membership.findFirst({
|
|
||||||
where: {
|
|
||||||
userId: actor.userId,
|
|
||||||
organizationId: actor.organizationId,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
select: { orgRole: { select: { permissions: true } } },
|
|
||||||
});
|
|
||||||
const permissions = membership?.orgRole?.permissions;
|
|
||||||
const key = ASSIGN_SECTION_PERMISSION[section];
|
|
||||||
return Array.isArray(permissions) && permissions.includes(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* بدون مجوز همان بخش، مجموعهٔ مسئولانِ غیر از خود کاربر نباید عوض شود.
|
|
||||||
* ثبت تازه یعنی قبلی خالی است، پس فقط خود کاربر مجاز است.
|
|
||||||
*/
|
|
||||||
export async function assertAssigneeSet(
|
|
||||||
prisma: PrismaService,
|
|
||||||
actor: AssignActor,
|
|
||||||
section: AssignSection,
|
|
||||||
nextIds: Array<string | null | undefined>,
|
|
||||||
previousIds: Array<string | null | undefined> = [],
|
|
||||||
): Promise<void> {
|
|
||||||
if (await actorCanAssignToOthers(prisma, actor, section)) return;
|
|
||||||
const prev = new Set(
|
|
||||||
previousIds.filter((id): id is string => Boolean(id) && id !== actor.userId),
|
|
||||||
);
|
|
||||||
const next = nextIds.filter((id): id is string => Boolean(id) && id !== actor.userId);
|
|
||||||
const changed = next.length !== prev.size || next.some((id) => !prev.has(id));
|
|
||||||
if (changed) throw new ForbiddenException(assignOthersDeniedMessage(section));
|
|
||||||
}
|
|
||||||
@@ -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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,499 +0,0 @@
|
|||||||
import type {
|
|
||||||
DataAccessPolicy,
|
|
||||||
DataAccessRule,
|
|
||||||
DataAccessSection,
|
|
||||||
DataVisibility,
|
|
||||||
DataVisibilityScopes,
|
|
||||||
SectionDataAccess,
|
|
||||||
} from '@mento/shared';
|
|
||||||
import {
|
|
||||||
DATA_ACCESS_LEGACY_SCOPE,
|
|
||||||
DATA_ACCESS_SECTIONS,
|
|
||||||
migrateVisibilityToSectionAccess,
|
|
||||||
parseDataAccessPolicy,
|
|
||||||
sectionHasDataAccessLimits,
|
|
||||||
} from '@mento/shared';
|
|
||||||
import { canViewAllRecords } from '@mento/shared';
|
|
||||||
import type { Prisma } from '@prisma/client';
|
|
||||||
import type { Actor } from './record-scope';
|
|
||||||
|
|
||||||
function elevated(role: string | null | undefined): boolean {
|
|
||||||
return canViewAllRecords(role);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SectionAccessContext = {
|
|
||||||
section: SectionDataAccess;
|
|
||||||
source: 'role' | 'override' | 'default';
|
|
||||||
};
|
|
||||||
|
|
||||||
function ownerIdList(
|
|
||||||
rule: DataAccessRule,
|
|
||||||
ctx: { userId: string; teamPeerIds: string[] | null },
|
|
||||||
): string[] {
|
|
||||||
const ownerIds = new Set<string>();
|
|
||||||
if (!rule.owners?.length) return [];
|
|
||||||
if (rule.owners.includes('self')) ownerIds.add(ctx.userId);
|
|
||||||
if (rule.owners.includes('team')) {
|
|
||||||
const peers = ctx.teamPeerIds?.length ? ctx.teamPeerIds : [ctx.userId];
|
|
||||||
for (const id of peers) ownerIds.add(id);
|
|
||||||
}
|
|
||||||
return [...ownerIds];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve یک بخش: override کاربر بر نقش؛ وگرنه مهاجرت از visibility قدیمی. */
|
|
||||||
export function resolveSectionAccess(
|
|
||||||
sectionKey: DataAccessSection,
|
|
||||||
input: {
|
|
||||||
role: string;
|
|
||||||
rolePolicy?: unknown;
|
|
||||||
override?: unknown;
|
|
||||||
legacyVisibility?: DataVisibility | string | null;
|
|
||||||
legacyScopes?: DataVisibilityScopes | null;
|
|
||||||
},
|
|
||||||
): SectionAccessContext {
|
|
||||||
if (elevated(input.role)) {
|
|
||||||
return { section: { mode: 'all', rules: [] }, source: 'default' };
|
|
||||||
}
|
|
||||||
const override = parseDataAccessPolicy(input.override);
|
|
||||||
if (override[sectionKey]) {
|
|
||||||
return { section: override[sectionKey]!, source: 'override' };
|
|
||||||
}
|
|
||||||
const rolePolicy = parseDataAccessPolicy(input.rolePolicy);
|
|
||||||
if (rolePolicy[sectionKey]) {
|
|
||||||
return { section: rolePolicy[sectionKey]!, source: 'role' };
|
|
||||||
}
|
|
||||||
// فرم محدودهٔ نقش، بخشِ ننوشته را «بدون محدودیت» نشان میدهد.
|
|
||||||
// اگر نقش حتی یک بخش ذخیره کرده باشد، بقیه نباید به دید قدیمی own/team برگردند.
|
|
||||||
if (Object.keys(rolePolicy).length > 0) {
|
|
||||||
return { section: { mode: 'all', rules: [] }, source: 'role' };
|
|
||||||
}
|
|
||||||
if (input.legacyVisibility != null || input.legacyScopes) {
|
|
||||||
return {
|
|
||||||
section: migrateVisibilityToSectionAccess(
|
|
||||||
input.legacyVisibility,
|
|
||||||
input.legacyScopes,
|
|
||||||
DATA_ACCESS_LEGACY_SCOPE[sectionKey],
|
|
||||||
),
|
|
||||||
source: 'default',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { section: { mode: 'all', rules: [] }, source: 'default' };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @deprecated */
|
|
||||||
export function resolveCustomersAccess(input: {
|
|
||||||
role: string;
|
|
||||||
rolePolicy?: unknown;
|
|
||||||
override?: unknown;
|
|
||||||
legacyVisibility?: DataVisibility | string | null;
|
|
||||||
legacyScopes?: DataVisibilityScopes | null;
|
|
||||||
}): SectionAccessContext {
|
|
||||||
return resolveSectionAccess('customers', input);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveAllSectionAccess(input: {
|
|
||||||
role: string;
|
|
||||||
rolePolicy?: unknown;
|
|
||||||
override?: unknown;
|
|
||||||
legacyVisibility?: DataVisibility | string | null;
|
|
||||||
legacyScopes?: DataVisibilityScopes | null;
|
|
||||||
}): DataAccessPolicy {
|
|
||||||
const out: DataAccessPolicy = {};
|
|
||||||
for (const key of DATA_ACCESS_SECTIONS) {
|
|
||||||
out[key] = resolveSectionAccess(key, input).section;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ابعاد پرشدهٔ یک قانون با 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(
|
|
||||||
rule: DataAccessRule,
|
|
||||||
ctx: { userId: string; teamPeerIds: string[] | null },
|
|
||||||
): Prisma.PersonWhereInput | null {
|
|
||||||
const facets: Prisma.PersonWhereInput[] = [];
|
|
||||||
if (rule.owners?.length) {
|
|
||||||
const ids = ownerIdList(rule, ctx);
|
|
||||||
if (ids.length) {
|
|
||||||
facets.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// groupIds دیگر فیلتر دسترسی نیست؛ دید گروه فقط از مسئولگروه (personViaGroupAssignee)
|
|
||||||
if (rule.sources?.length) {
|
|
||||||
facets.push({
|
|
||||||
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (rule.moods?.length) {
|
|
||||||
facets.push({ mood: { in: rule.moods } });
|
|
||||||
}
|
|
||||||
return unionFacets(facets);
|
|
||||||
}
|
|
||||||
|
|
||||||
function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } {
|
|
||||||
if (!parts.length) return { id: '__no_access__' } as T & { id: string };
|
|
||||||
return parts.length === 1 ? parts[0]! : { OR: parts };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** اعضای گروههایی که کاربر مسئولشان است */
|
|
||||||
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(
|
|
||||||
actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>,
|
|
||||||
): Prisma.PersonWhereInput {
|
|
||||||
if (elevated(actor.role)) return {};
|
|
||||||
const section = actor.customersAccess ?? { mode: 'all' as const, rules: [] };
|
|
||||||
if (!sectionHasDataAccessLimits(section)) return {};
|
|
||||||
const parts: Prisma.PersonWhereInput[] = [];
|
|
||||||
for (const rule of section.rules ?? []) {
|
|
||||||
const w = buildContactRuleWhere(rule, {
|
|
||||||
userId: actor.userId,
|
|
||||||
teamPeerIds: actor.teamPeerIds ?? null,
|
|
||||||
});
|
|
||||||
if (w) parts.push(w);
|
|
||||||
}
|
|
||||||
// مسئول گروه: اتحاد با محدودیت نقش (مالک خودم یا عضو گروه تحت مسئولیت)
|
|
||||||
parts.push(personViaGroupAssignee(actor.userId));
|
|
||||||
return orParts(parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function customersCompanyWhere(
|
|
||||||
actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>,
|
|
||||||
): Prisma.CompanyWhereInput {
|
|
||||||
if (elevated(actor.role)) return {};
|
|
||||||
const section = actor.customersAccess ?? { mode: 'all' as const, rules: [] };
|
|
||||||
if (!sectionHasDataAccessLimits(section)) return {};
|
|
||||||
const parts: Prisma.CompanyWhereInput[] = [];
|
|
||||||
for (const rule of section.rules ?? []) {
|
|
||||||
// حال مشتری روی شرکت معنا ندارد؛ فقط مالک/منبع
|
|
||||||
if (rule.moods?.length && !rule.owners?.length && !rule.sources?.length) continue;
|
|
||||||
const facets: Prisma.CompanyWhereInput[] = [];
|
|
||||||
if (rule.owners?.length) {
|
|
||||||
const ids = ownerIdList(rule, {
|
|
||||||
userId: actor.userId,
|
|
||||||
teamPeerIds: actor.teamPeerIds ?? null,
|
|
||||||
});
|
|
||||||
if (ids.length) {
|
|
||||||
facets.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// groupIds دیگر فیلتر دسترسی نیست؛ دید گروه فقط از مسئولگروه (companyViaGroupAssignee)
|
|
||||||
if (rule.sources?.length) {
|
|
||||||
facets.push({
|
|
||||||
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const w = unionFacets(facets);
|
|
||||||
if (w) parts.push(w);
|
|
||||||
}
|
|
||||||
parts.push(companyViaGroupAssignee(actor.userId));
|
|
||||||
return orParts(parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function leadsDeepWhere(
|
|
||||||
actor: Pick<Actor, 'userId' | 'role' | 'leadsAccess' | 'teamPeerIds'>,
|
|
||||||
): Prisma.LeadWhereInput {
|
|
||||||
if (elevated(actor.role)) return {};
|
|
||||||
const section = actor.leadsAccess ?? { mode: 'all' as const, rules: [] };
|
|
||||||
if (!sectionHasDataAccessLimits(section)) return {};
|
|
||||||
const parts: Prisma.LeadWhereInput[] = [];
|
|
||||||
for (const rule of section.rules ?? []) {
|
|
||||||
const facets: Prisma.LeadWhereInput[] = [];
|
|
||||||
if (rule.owners?.length) {
|
|
||||||
const ids = ownerIdList(rule, {
|
|
||||||
userId: actor.userId,
|
|
||||||
teamPeerIds: actor.teamPeerIds ?? null,
|
|
||||||
});
|
|
||||||
if (ids.length) {
|
|
||||||
facets.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (rule.stageIds?.length) {
|
|
||||||
facets.push({ stageId: { in: rule.stageIds } });
|
|
||||||
}
|
|
||||||
if (rule.sources?.length) {
|
|
||||||
facets.push({
|
|
||||||
OR: rule.sources.map((s) => ({
|
|
||||||
source: { equals: s, mode: 'insensitive' as const },
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const w = unionFacets(facets);
|
|
||||||
if (w) parts.push(w);
|
|
||||||
}
|
|
||||||
return orParts(parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dealsDeepWhere(
|
|
||||||
actor: Pick<Actor, 'userId' | 'role' | 'salesAccess' | 'teamPeerIds'>,
|
|
||||||
): Prisma.DealWhereInput {
|
|
||||||
if (elevated(actor.role)) return {};
|
|
||||||
const section = actor.salesAccess ?? { mode: 'all' as const, rules: [] };
|
|
||||||
if (!sectionHasDataAccessLimits(section)) return {};
|
|
||||||
const parts: Prisma.DealWhereInput[] = [];
|
|
||||||
for (const rule of section.rules ?? []) {
|
|
||||||
const facets: Prisma.DealWhereInput[] = [];
|
|
||||||
if (rule.owners?.length) {
|
|
||||||
const ids = ownerIdList(rule, {
|
|
||||||
userId: actor.userId,
|
|
||||||
teamPeerIds: actor.teamPeerIds ?? null,
|
|
||||||
});
|
|
||||||
if (ids.length) {
|
|
||||||
facets.push({
|
|
||||||
OR: [
|
|
||||||
{ ownerId: { in: ids } },
|
|
||||||
{ assignees: { some: { id: { in: ids } } } },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (rule.pipelineIds?.length) {
|
|
||||||
facets.push({ pipelineId: { in: rule.pipelineIds } });
|
|
||||||
}
|
|
||||||
if (rule.stageIds?.length) {
|
|
||||||
facets.push({ stageId: { in: rule.stageIds } });
|
|
||||||
}
|
|
||||||
const w = unionFacets(facets);
|
|
||||||
if (w) parts.push(w);
|
|
||||||
}
|
|
||||||
return orParts(parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function activitiesDeepWhere(
|
|
||||||
actor: Pick<Actor, 'userId' | 'role' | 'activitiesAccess' | 'teamPeerIds'>,
|
|
||||||
): Prisma.ActivityWhereInput {
|
|
||||||
if (elevated(actor.role)) return {};
|
|
||||||
const section = actor.activitiesAccess ?? { mode: 'all' as const, rules: [] };
|
|
||||||
if (!sectionHasDataAccessLimits(section)) return {};
|
|
||||||
const parts: Prisma.ActivityWhereInput[] = [];
|
|
||||||
for (const rule of section.rules ?? []) {
|
|
||||||
const facets: Prisma.ActivityWhereInput[] = [];
|
|
||||||
if (rule.owners?.length) {
|
|
||||||
const ids = ownerIdList(rule, {
|
|
||||||
userId: actor.userId,
|
|
||||||
teamPeerIds: actor.teamPeerIds ?? null,
|
|
||||||
});
|
|
||||||
if (ids.length) {
|
|
||||||
facets.push({
|
|
||||||
OR: [
|
|
||||||
{ assigneeId: { in: ids } },
|
|
||||||
{ assignees: { some: { id: { in: ids } } } },
|
|
||||||
{ participantIds: { hasSome: ids } },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (rule.boardIds?.length) {
|
|
||||||
facets.push({ boardId: { in: rule.boardIds } });
|
|
||||||
}
|
|
||||||
if (rule.stageIds?.length) {
|
|
||||||
facets.push({ stageId: { in: rule.stageIds } });
|
|
||||||
}
|
|
||||||
if (rule.typeIds?.length) {
|
|
||||||
facets.push({ typeDefId: { in: rule.typeIds } });
|
|
||||||
}
|
|
||||||
const w = unionFacets(facets);
|
|
||||||
if (w) parts.push(w);
|
|
||||||
}
|
|
||||||
return orParts(parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function notesDeepWhere(
|
|
||||||
actor: Pick<Actor, 'userId' | 'role' | 'notesAccess' | 'teamPeerIds'>,
|
|
||||||
): Prisma.NoteWhereInput {
|
|
||||||
const personalGate: Prisma.NoteWhereInput = {
|
|
||||||
OR: [
|
|
||||||
{ visibility: { not: 'personal' } },
|
|
||||||
{ visibility: 'personal', authorId: actor.userId },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
if (elevated(actor.role)) return personalGate;
|
|
||||||
const section = actor.notesAccess ?? { mode: 'all' as const, rules: [] };
|
|
||||||
if (!sectionHasDataAccessLimits(section)) return personalGate;
|
|
||||||
const parts: Prisma.NoteWhereInput[] = [];
|
|
||||||
for (const rule of section.rules ?? []) {
|
|
||||||
const facets: Prisma.NoteWhereInput[] = [];
|
|
||||||
if (rule.owners?.length) {
|
|
||||||
const ids = ownerIdList(rule, {
|
|
||||||
userId: actor.userId,
|
|
||||||
teamPeerIds: actor.teamPeerIds ?? null,
|
|
||||||
});
|
|
||||||
if (ids.length) {
|
|
||||||
facets.push({
|
|
||||||
OR: [
|
|
||||||
{ authorId: { in: ids } },
|
|
||||||
{
|
|
||||||
deal: {
|
|
||||||
OR: [
|
|
||||||
{ ownerId: { in: ids } },
|
|
||||||
{ assignees: { some: { id: { in: ids } } } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ person: { ownerId: { in: ids } } },
|
|
||||||
{ company: { ownerId: { in: ids } } },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (rule.sources?.length) {
|
|
||||||
facets.push({ noteType: { in: rule.sources } });
|
|
||||||
}
|
|
||||||
const w = unionFacets(facets);
|
|
||||||
if (w) parts.push(w);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
AND: [personalGate, orParts(parts)],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ticketsDeepWhere(
|
|
||||||
actor: Pick<Actor, 'userId' | 'role' | 'ticketsAccess' | 'teamPeerIds'>,
|
|
||||||
): Prisma.TicketWhereInput {
|
|
||||||
if (elevated(actor.role)) return {};
|
|
||||||
const section = actor.ticketsAccess ?? { mode: 'all' as const, rules: [] };
|
|
||||||
if (!sectionHasDataAccessLimits(section)) return {};
|
|
||||||
const parts: Prisma.TicketWhereInput[] = [];
|
|
||||||
for (const rule of section.rules ?? []) {
|
|
||||||
const facets: Prisma.TicketWhereInput[] = [];
|
|
||||||
if (rule.owners?.length) {
|
|
||||||
const ids = ownerIdList(rule, {
|
|
||||||
userId: actor.userId,
|
|
||||||
teamPeerIds: actor.teamPeerIds ?? null,
|
|
||||||
});
|
|
||||||
if (ids.length) {
|
|
||||||
facets.push({
|
|
||||||
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (rule.sources?.length) {
|
|
||||||
facets.push({
|
|
||||||
status: {
|
|
||||||
in: rule.sources as Array<'open' | 'in_progress' | 'waiting' | 'resolved' | 'closed'>,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const w = unionFacets(facets);
|
|
||||||
if (w) parts.push(w);
|
|
||||||
}
|
|
||||||
return orParts(parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function projectsDeepWhere(
|
|
||||||
actor: Pick<Actor, 'userId' | 'role' | 'projectsAccess' | 'teamPeerIds'>,
|
|
||||||
): Prisma.ProjectCardWhereInput {
|
|
||||||
if (elevated(actor.role)) return {};
|
|
||||||
const section = actor.projectsAccess ?? { mode: 'all' as const, rules: [] };
|
|
||||||
if (!sectionHasDataAccessLimits(section)) return {};
|
|
||||||
const parts: Prisma.ProjectCardWhereInput[] = [];
|
|
||||||
for (const rule of section.rules ?? []) {
|
|
||||||
const facets: Prisma.ProjectCardWhereInput[] = [];
|
|
||||||
if (rule.owners?.length) {
|
|
||||||
const ids = ownerIdList(rule, {
|
|
||||||
userId: actor.userId,
|
|
||||||
teamPeerIds: actor.teamPeerIds ?? null,
|
|
||||||
});
|
|
||||||
if (ids.length) {
|
|
||||||
facets.push({
|
|
||||||
OR: [
|
|
||||||
{ ownerId: { in: ids } },
|
|
||||||
{ assignees: { some: { id: { in: ids } } } },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (rule.boardIds?.length) {
|
|
||||||
facets.push({ boardId: { in: rule.boardIds } });
|
|
||||||
}
|
|
||||||
if (rule.stageIds?.length) {
|
|
||||||
facets.push({ stageId: { in: rule.stageIds } });
|
|
||||||
}
|
|
||||||
const w = unionFacets(facets);
|
|
||||||
if (w) parts.push(w);
|
|
||||||
}
|
|
||||||
return orParts(parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function routinesDeepWhere(
|
|
||||||
actor: Pick<Actor, 'userId' | 'role' | 'routinesAccess' | 'teamPeerIds'>,
|
|
||||||
): Prisma.RoutineTaskWhereInput {
|
|
||||||
if (elevated(actor.role)) return {};
|
|
||||||
const section = actor.routinesAccess ?? { mode: 'all' as const, rules: [] };
|
|
||||||
if (!sectionHasDataAccessLimits(section)) return {};
|
|
||||||
const parts: Prisma.RoutineTaskWhereInput[] = [];
|
|
||||||
for (const rule of section.rules ?? []) {
|
|
||||||
const facets: Prisma.RoutineTaskWhereInput[] = [];
|
|
||||||
if (rule.owners?.length) {
|
|
||||||
const ids = ownerIdList(rule, {
|
|
||||||
userId: actor.userId,
|
|
||||||
teamPeerIds: actor.teamPeerIds ?? null,
|
|
||||||
});
|
|
||||||
if (ids.length) {
|
|
||||||
facets.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (rule.typeIds?.length) {
|
|
||||||
facets.push({ categoryId: { in: rule.typeIds } });
|
|
||||||
}
|
|
||||||
const w = unionFacets(facets);
|
|
||||||
if (w) parts.push(w);
|
|
||||||
}
|
|
||||||
return orParts(parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** اتحاد شناسهٔ owners در قوانین؛ null = بدون محدودیت مالکی (یا mode=all) */
|
|
||||||
export function sectionOwnerIdUnion(
|
|
||||||
section: SectionDataAccess | null | undefined,
|
|
||||||
ctx: { userId: string; teamPeerIds: string[] | null },
|
|
||||||
): string[] | null {
|
|
||||||
if (!sectionHasDataAccessLimits(section)) return null;
|
|
||||||
const ids = new Set<string>();
|
|
||||||
let sawOwnerRule = false;
|
|
||||||
let sawNonOwnerRule = false;
|
|
||||||
for (const rule of section?.rules ?? []) {
|
|
||||||
if (rule.owners?.length) {
|
|
||||||
sawOwnerRule = true;
|
|
||||||
for (const id of ownerIdList(rule, ctx)) ids.add(id);
|
|
||||||
} else if (
|
|
||||||
rule.sources?.length ||
|
|
||||||
rule.moods?.length ||
|
|
||||||
rule.stageIds?.length ||
|
|
||||||
rule.pipelineIds?.length ||
|
|
||||||
rule.boardIds?.length ||
|
|
||||||
rule.typeIds?.length
|
|
||||||
) {
|
|
||||||
sawNonOwnerRule = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (sawNonOwnerRule && !sawOwnerRule) return null;
|
|
||||||
if (!sawOwnerRule) return null;
|
|
||||||
return [...ids];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sectionNeedsTeamPeers(section: SectionDataAccess | null | undefined): boolean {
|
|
||||||
if (!sectionHasDataAccessLimits(section)) return false;
|
|
||||||
return (section?.rules ?? []).some((r) => r.owners?.includes('team'));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sanitizeDataAccessPolicyInput(raw: unknown): DataAccessPolicy {
|
|
||||||
return parseDataAccessPolicy(raw);
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,6 @@
|
|||||||
import type {
|
import type { DataScope, DataVisibility, DataVisibilityScopes, UserRole } from '@mento/shared';
|
||||||
DataScope,
|
import { DATA_SCOPES, canViewAllRecords } from '@mento/shared';
|
||||||
DataVisibility,
|
|
||||||
DataVisibilityScopes,
|
|
||||||
SectionDataAccess,
|
|
||||||
UserRole,
|
|
||||||
} from '@mento/shared';
|
|
||||||
import { DATA_SCOPES, canViewAllRecords, sectionHasDataAccessLimits } from '@mento/shared';
|
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
import {
|
|
||||||
activitiesDeepWhere,
|
|
||||||
dealsDeepWhere,
|
|
||||||
notesDeepWhere,
|
|
||||||
projectsDeepWhere,
|
|
||||||
routinesDeepWhere,
|
|
||||||
sectionOwnerIdUnion,
|
|
||||||
ticketsDeepWhere,
|
|
||||||
} from './data-access';
|
|
||||||
|
|
||||||
export type ScopeVisibility = {
|
export type ScopeVisibility = {
|
||||||
dataVisibility: DataVisibility;
|
dataVisibility: DataVisibility;
|
||||||
@@ -35,17 +20,6 @@ export type Actor = {
|
|||||||
visibleUserIds?: string[] | null;
|
visibleUserIds?: string[] | null;
|
||||||
/** سطح دید تفکیکی هر بخش (از Membership.dataVisibilityScopes) */
|
/** سطح دید تفکیکی هر بخش (از Membership.dataVisibilityScopes) */
|
||||||
scopes?: Partial<Record<DataScope, ScopeVisibility>>;
|
scopes?: Partial<Record<DataScope, ScopeVisibility>>;
|
||||||
/** عمق دادهٔ بخشها (resolve شده از نقش + override) */
|
|
||||||
customersAccess?: SectionDataAccess;
|
|
||||||
leadsAccess?: SectionDataAccess;
|
|
||||||
salesAccess?: SectionDataAccess;
|
|
||||||
activitiesAccess?: SectionDataAccess;
|
|
||||||
notesAccess?: SectionDataAccess;
|
|
||||||
routinesAccess?: SectionDataAccess;
|
|
||||||
projectsAccess?: SectionDataAccess;
|
|
||||||
ticketsAccess?: SectionDataAccess;
|
|
||||||
/** همتیمیها برای قوانین owners:team (شامل خود کاربر) */
|
|
||||||
teamPeerIds?: string[] | null;
|
|
||||||
/** off = فیلتر خاموش؛ current = کار جاری؛ archive = بایگانی اطلاعات قبل */
|
/** off = فیلتر خاموش؛ current = کار جاری؛ archive = بایگانی اطلاعات قبل */
|
||||||
priorLens?: 'off' | 'current' | 'archive';
|
priorLens?: 'off' | 'current' | 'archive';
|
||||||
/** لحظهٔ روشن شدن ماژول برای این محیط؛ فعالیتهای ساختهشده قبل از آن میتوانند قبلی باشند */
|
/** لحظهٔ روشن شدن ماژول برای این محیط؛ فعالیتهای ساختهشده قبل از آن میتوانند قبلی باشند */
|
||||||
@@ -61,65 +35,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,29 +84,19 @@ 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]! };
|
||||||
return { ownerId: { in: ids } };
|
return { ownerId: { in: ids } };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** معامله: عمق دادهٔ sales (JWT) یا مدل قدیمی own/team/org */
|
/** معامله: مالک اصلی یا یکی از مسئولان مشترک در محدوده دید */
|
||||||
export function dealAccessWhere(
|
export function dealAccessWhere(
|
||||||
actor: ScopedActor & Pick<Actor, 'salesAccess' | 'teamPeerIds'>,
|
actor: ScopedActor,
|
||||||
): Prisma.DealWhereInput {
|
): Prisma.DealWhereInput {
|
||||||
if (actor.salesAccess !== undefined) {
|
|
||||||
return dealsDeepWhere(actor as Actor);
|
|
||||||
}
|
|
||||||
const ids = scopedOwnerIds(actor, 'deals');
|
const ids = scopedOwnerIds(actor, 'deals');
|
||||||
if (!ids) return {};
|
if (!ids) return {};
|
||||||
return {
|
return {
|
||||||
@@ -202,13 +107,10 @@ export function dealAccessWhere(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** کارت پروژه: عمق داده یا مدل قدیمی own/team/org */
|
/** کارت پروژه: مالک یا یکی از مسئولان در محدوده دید */
|
||||||
export function projectCardAccessWhere(
|
export function projectCardAccessWhere(
|
||||||
actor: ScopedActor & Pick<Actor, 'projectsAccess' | 'teamPeerIds'>,
|
actor: ScopedActor,
|
||||||
): Prisma.ProjectCardWhereInput {
|
): Prisma.ProjectCardWhereInput {
|
||||||
if (actor.projectsAccess !== undefined) {
|
|
||||||
return projectsDeepWhere(actor as Actor);
|
|
||||||
}
|
|
||||||
const ids = scopedOwnerIds(actor, 'projects');
|
const ids = scopedOwnerIds(actor, 'projects');
|
||||||
if (!ids) return {};
|
if (!ids) return {};
|
||||||
return {
|
return {
|
||||||
@@ -220,16 +122,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 } },
|
||||||
@@ -238,13 +135,10 @@ export function assigneeWhere(actor: DeepAccessActor): Prisma.ActivityWhereInput
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** دسترسی به فعالیت: عمق دادهٔ activities (JWT) یا مدل قدیمی */
|
/** دسترسی به فعالیت: مسئولان یا شرکتکنندگان (فیلد شرکتکنندگان) در محدوده دید */
|
||||||
export function activityAccessWhere(
|
export function activityAccessWhere(
|
||||||
actor: ScopedActor & Pick<Actor, 'activitiesAccess' | 'teamPeerIds'>,
|
actor: ScopedActor,
|
||||||
): Prisma.ActivityWhereInput {
|
): Prisma.ActivityWhereInput {
|
||||||
if (actor.activitiesAccess !== undefined) {
|
|
||||||
return activitiesDeepWhere(actor as Actor);
|
|
||||||
}
|
|
||||||
const ids = scopedOwnerIds(actor, 'activities');
|
const ids = scopedOwnerIds(actor, 'activities');
|
||||||
if (!ids) return {};
|
if (!ids) return {};
|
||||||
return {
|
return {
|
||||||
@@ -256,13 +150,10 @@ export function activityAccessWhere(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** یادداشت: عمق داده یا مدل قدیمی + محرمانگی شخصی */
|
/** یادداشت: نویسنده یا پروندههای تحت مالکیت در محدوده دید + محرمانگی شخصی */
|
||||||
export function noteAccessWhere(
|
export function noteAccessWhere(
|
||||||
actor: ScopedActor & Pick<Actor, 'notesAccess' | 'teamPeerIds'>,
|
actor: ScopedActor,
|
||||||
): Prisma.NoteWhereInput {
|
): Prisma.NoteWhereInput {
|
||||||
if (actor.notesAccess !== undefined) {
|
|
||||||
return notesDeepWhere(actor as Actor);
|
|
||||||
}
|
|
||||||
const ids = scopedOwnerIds(actor, 'notes');
|
const ids = scopedOwnerIds(actor, 'notes');
|
||||||
const personalGate: Prisma.NoteWhereInput = {
|
const personalGate: Prisma.NoteWhereInput = {
|
||||||
OR: [
|
OR: [
|
||||||
@@ -297,22 +188,17 @@ export function noteAccessWhere(
|
|||||||
export function resolveOwnerId(
|
export function resolveOwnerId(
|
||||||
actor: Pick<Actor, 'userId' | 'role'>,
|
actor: Pick<Actor, 'userId' | 'role'>,
|
||||||
requested?: string | null,
|
requested?: string | null,
|
||||||
allowOthers?: boolean,
|
|
||||||
): string {
|
): string {
|
||||||
const allowed = allowOthers ?? elevated(actor.role);
|
if (elevated(actor.role) && requested?.trim()) {
|
||||||
if (allowed && requested?.trim()) {
|
|
||||||
return requested.trim();
|
return requested.trim();
|
||||||
}
|
}
|
||||||
return actor.userId;
|
return actor.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** تیکت: عمق داده یا مدل قدیمی */
|
/** تیکت: مسئول یا ثبتکننده در محدوده دید */
|
||||||
export function ticketAccessWhere(
|
export function ticketAccessWhere(
|
||||||
actor: ScopedActor & Pick<Actor, 'ticketsAccess' | 'teamPeerIds'>,
|
actor: ScopedActor,
|
||||||
): Prisma.TicketWhereInput {
|
): Prisma.TicketWhereInput {
|
||||||
if (actor.ticketsAccess !== undefined) {
|
|
||||||
return ticketsDeepWhere(actor as Actor);
|
|
||||||
}
|
|
||||||
const ids = scopedOwnerIds(actor, 'tickets');
|
const ids = scopedOwnerIds(actor, 'tickets');
|
||||||
if (!ids) return {};
|
if (!ids) return {};
|
||||||
return {
|
return {
|
||||||
@@ -320,14 +206,6 @@ export function ticketAccessWhere(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** کار روتین: عمق داده (وقتی JWT ست کرده) */
|
|
||||||
export function routineTaskAccessWhere(
|
|
||||||
actor: Pick<Actor, 'userId' | 'role' | 'routinesAccess' | 'teamPeerIds'>,
|
|
||||||
): Prisma.RoutineTaskWhereInput {
|
|
||||||
if (actor.routinesAccess === undefined) return {};
|
|
||||||
return routinesDeepWhere(actor);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canReassignOwner(role: string | null | undefined): boolean {
|
export function canReassignOwner(role: string | null | undefined): boolean {
|
||||||
return elevated(role);
|
return elevated(role);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,19 +79,6 @@ export function buildSearchWhere<W extends object>(
|
|||||||
return (parts.length === 1 ? parts[0] : { AND: parts }) as unknown as W;
|
return (parts.length === 1 ? parts[0] : { AND: parts }) as unknown as W;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** پارامتر چندتایی کوئری: «a,b,c» → ['a','b','c'] */
|
|
||||||
export function splitCsv(raw: string | null | undefined, max = 50): string[] {
|
|
||||||
if (!raw) return [];
|
|
||||||
return [
|
|
||||||
...new Set(
|
|
||||||
String(raw)
|
|
||||||
.split(',')
|
|
||||||
.map((v) => v.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
),
|
|
||||||
].slice(0, max);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** کمک برای فیلدهای متنی ساده: `{ field: { contains, mode: 'insensitive' } }` */
|
/** کمک برای فیلدهای متنی ساده: `{ field: { contains, mode: 'insensitive' } }` */
|
||||||
export function ci(term: string) {
|
export function ci(term: string) {
|
||||||
return { contains: term, mode: 'insensitive' as const };
|
return { contains: term, mode: 'insensitive' as const };
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
|||||||
import { memoryStorage } from 'multer';
|
import { memoryStorage } from 'multer';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
import { CompaniesService } from './companies.service';
|
import { CompaniesService } from './companies.service';
|
||||||
@@ -32,8 +31,7 @@ const excelUpload = FileInterceptor('file', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@Controller('companies')
|
@Controller('companies')
|
||||||
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@RequirePermission('customers')
|
|
||||||
export class CompaniesController {
|
export class CompaniesController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly companiesService: CompaniesService,
|
private readonly companiesService: CompaniesService,
|
||||||
@@ -47,15 +45,9 @@ export class CompaniesController {
|
|||||||
query.q,
|
query.q,
|
||||||
query.page ?? 1,
|
query.page ?? 1,
|
||||||
query.limit ?? 20,
|
query.limit ?? 20,
|
||||||
{ groupId: query.groupId },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('group-stats')
|
|
||||||
groupStats(@CurrentUser() user: Actor) {
|
|
||||||
return this.companiesService.groupStats(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('export')
|
@Get('export')
|
||||||
async export(
|
async export(
|
||||||
@CurrentUser() user: Actor,
|
@CurrentUser() user: Actor,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
CompanyDto,
|
CompanyDto,
|
||||||
CreateCompanyRequest,
|
CreateCompanyRequest,
|
||||||
CustomerGroupDto,
|
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
UpdateCompanyRequest,
|
UpdateCompanyRequest,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
@@ -17,21 +16,17 @@ import {
|
|||||||
asCustomFields,
|
asCustomFields,
|
||||||
toJsonCustomFields,
|
toJsonCustomFields,
|
||||||
} from '../common/custom-fields';
|
} from '../common/custom-fields';
|
||||||
import { customersCompanyWhere } from '../common/data-access';
|
|
||||||
import {
|
import {
|
||||||
type Actor,
|
type Actor,
|
||||||
|
canReassignOwner,
|
||||||
elevated,
|
elevated,
|
||||||
|
ownerWhere,
|
||||||
resolveOwnerId,
|
resolveOwnerId,
|
||||||
} from '../common/record-scope';
|
} from '../common/record-scope';
|
||||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
|
||||||
import { normalizeTags } from '../common/tags';
|
import { normalizeTags } from '../common/tags';
|
||||||
import { normalizePersonImportSource } from './person-source';
|
import { normalizePersonImportSource } from './person-source';
|
||||||
import { buildSearchWhere, ci, splitCsv } from '../common/search';
|
import { buildSearchWhere, ci } from '../common/search';
|
||||||
import { GamificationService } from '../gamification/gamification.service';
|
import { GamificationService } from '../gamification/gamification.service';
|
||||||
import {
|
|
||||||
CustomerGroupsService,
|
|
||||||
groupSummarySelect,
|
|
||||||
} from '../customer-groups/customer-groups.service';
|
|
||||||
|
|
||||||
function parseDateOrNull(raw?: string | null): Date | null {
|
function parseDateOrNull(raw?: string | null): Date | null {
|
||||||
if (!raw?.trim()) return null;
|
if (!raw?.trim()) return null;
|
||||||
@@ -42,10 +37,6 @@ function parseDateOrNull(raw?: string | null): Date | null {
|
|||||||
const companyInclude = {
|
const companyInclude = {
|
||||||
_count: { select: { people: true } },
|
_count: { select: { people: true } },
|
||||||
owner: { select: { id: true, fullName: true } },
|
owner: { select: { id: true, fullName: true } },
|
||||||
groupLinks: {
|
|
||||||
select: { group: { select: groupSummarySelect } },
|
|
||||||
orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }],
|
|
||||||
},
|
|
||||||
} satisfies Prisma.CompanyInclude;
|
} satisfies Prisma.CompanyInclude;
|
||||||
|
|
||||||
type CompanyRow = {
|
type CompanyRow = {
|
||||||
@@ -76,7 +67,6 @@ type CompanyRow = {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
_count: { people: number };
|
_count: { people: number };
|
||||||
owner: { id: string; fullName: string } | null;
|
owner: { id: string; fullName: string } | null;
|
||||||
groupLinks: Array<{ group: { id: string; name: string; color: string } }>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -84,7 +74,6 @@ export class CompaniesService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly game: GamificationService,
|
private readonly game: GamificationService,
|
||||||
private readonly groups: CustomerGroupsService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
@@ -92,7 +81,6 @@ export class CompaniesService {
|
|||||||
q?: string,
|
q?: string,
|
||||||
page = 1,
|
page = 1,
|
||||||
limit = 20,
|
limit = 20,
|
||||||
opts?: { groupId?: string },
|
|
||||||
): Promise<PaginatedResponse<CompanyDto>> {
|
): Promise<PaginatedResponse<CompanyDto>> {
|
||||||
const search = buildSearchWhere<Prisma.CompanyWhereInput>(q, (t) => [
|
const search = buildSearchWhere<Prisma.CompanyWhereInput>(q, (t) => [
|
||||||
{ name: ci(t) },
|
{ name: ci(t) },
|
||||||
@@ -106,19 +94,10 @@ export class CompaniesService {
|
|||||||
{ city: ci(t) },
|
{ city: ci(t) },
|
||||||
{ province: ci(t) },
|
{ province: ci(t) },
|
||||||
]);
|
]);
|
||||||
const groupIds = splitCsv(opts?.groupId);
|
|
||||||
const and: Prisma.CompanyWhereInput[] = [
|
|
||||||
...(search ? [search] : []),
|
|
||||||
...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []),
|
|
||||||
];
|
|
||||||
const where: Prisma.CompanyWhereInput = {
|
const where: Prisma.CompanyWhereInput = {
|
||||||
AND: [
|
organizationId: actor.organizationId,
|
||||||
{
|
...ownerWhere(actor),
|
||||||
organizationId: actor.organizationId,
|
...(search ? { AND: [search] } : {}),
|
||||||
...customersCompanyWhere(actor),
|
|
||||||
},
|
|
||||||
...and,
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const [rows, total] = await this.prisma.$transaction([
|
const [rows, total] = await this.prisma.$transaction([
|
||||||
@@ -140,42 +119,9 @@ export class CompaniesService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** آمار گروه برای فیلتر شرکتها — فقط شرکتهای در دسترس کاربر */
|
|
||||||
async groupStats(actor: Actor): Promise<CustomerGroupDto[]> {
|
|
||||||
const counts = await this.prisma.companyGroupLink.groupBy({
|
|
||||||
by: ['groupId'],
|
|
||||||
where: {
|
|
||||||
company: {
|
|
||||||
organizationId: actor.organizationId,
|
|
||||||
...customersCompanyWhere(actor),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: { _all: true },
|
|
||||||
});
|
|
||||||
if (!counts.length) return [];
|
|
||||||
const countMap = new Map(counts.map((c) => [c.groupId, c._count._all]));
|
|
||||||
const groups = await this.prisma.customerGroup.findMany({
|
|
||||||
where: {
|
|
||||||
organizationId: actor.organizationId,
|
|
||||||
id: { in: counts.map((c) => c.groupId) },
|
|
||||||
},
|
|
||||||
orderBy: [{ position: 'asc' }, { name: 'asc' }],
|
|
||||||
});
|
|
||||||
return groups.map((g) => ({
|
|
||||||
id: g.id,
|
|
||||||
name: g.name,
|
|
||||||
color: g.color,
|
|
||||||
position: g.position,
|
|
||||||
fromTahesab: Boolean(g.tahesabGid),
|
|
||||||
personCount: 0,
|
|
||||||
companyCount: countMap.get(g.id) ?? 0,
|
|
||||||
assignees: [],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async get(actor: Actor, id: string): Promise<CompanyDto> {
|
async get(actor: Actor, id: string): Promise<CompanyDto> {
|
||||||
const row = await this.prisma.company.findFirst({
|
const row = await this.prisma.company.findFirst({
|
||||||
where: { id, organizationId: actor.organizationId, ...customersCompanyWhere(actor) },
|
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
|
||||||
include: companyInclude,
|
include: companyInclude,
|
||||||
});
|
});
|
||||||
if (!row) throw new NotFoundException('شرکت پیدا نشد');
|
if (!row) throw new NotFoundException('شرکت پیدا نشد');
|
||||||
@@ -206,27 +152,15 @@ export class CompaniesService {
|
|||||||
notes: emptyToNull(dto.notes),
|
notes: emptyToNull(dto.notes),
|
||||||
registeredAt: parseDateOrNull(dto.registeredAt),
|
registeredAt: parseDateOrNull(dto.registeredAt),
|
||||||
tags: normalizeTags(dto.tags),
|
tags: normalizeTags(dto.tags),
|
||||||
ownerId: resolveOwnerId(
|
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||||
actor,
|
|
||||||
dto.ownerId,
|
|
||||||
await actorCanAssignToOthers(this.prisma, actor, 'customers'),
|
|
||||||
),
|
|
||||||
customFields: toJsonCustomFields(dto.customFields),
|
customFields: toJsonCustomFields(dto.customFields),
|
||||||
};
|
};
|
||||||
await this.assertNoDuplicate(actor.organizationId, data);
|
await this.assertNoDuplicate(actor.organizationId, data);
|
||||||
const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds);
|
|
||||||
|
|
||||||
let row = await this.prisma.company.create({
|
const row = await this.prisma.company.create({
|
||||||
data,
|
data,
|
||||||
include: companyInclude,
|
include: companyInclude,
|
||||||
});
|
});
|
||||||
if (groupIds.length) {
|
|
||||||
await this.groups.addCompanyGroups(row.id, groupIds);
|
|
||||||
row = await this.prisma.company.findUniqueOrThrow({
|
|
||||||
where: { id: row.id },
|
|
||||||
include: companyInclude,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (row.importSource === 'manual') {
|
if (row.importSource === 'manual') {
|
||||||
this.game.emit({
|
this.game.emit({
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
@@ -275,21 +209,11 @@ export class CompaniesService {
|
|||||||
if ('customFields' in dto) {
|
if ('customFields' in dto) {
|
||||||
data.customFields = toJsonCustomFields(dto.customFields);
|
data.customFields = toJsonCustomFields(dto.customFields);
|
||||||
}
|
}
|
||||||
if (dto.ownerId !== undefined) {
|
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
|
||||||
const current = await this.prisma.company.findFirst({
|
data.ownerId = resolveOwnerId(actor, dto.ownerId);
|
||||||
where: { id, organizationId: actor.organizationId },
|
|
||||||
select: { ownerId: true },
|
|
||||||
});
|
|
||||||
const nextOwner = emptyToNull(dto.ownerId ?? null);
|
|
||||||
await assertAssigneeSet(this.prisma, actor, 'customers', [nextOwner], [current?.ownerId]);
|
|
||||||
data.ownerId = nextOwner ?? actor.userId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.assertNoDuplicate(actor.organizationId, data, id);
|
await this.assertNoDuplicate(actor.organizationId, data, id);
|
||||||
if (dto.groupIds !== undefined) {
|
|
||||||
const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds);
|
|
||||||
await this.groups.setCompanyGroups(id, groupIds);
|
|
||||||
}
|
|
||||||
const row = await this.prisma.company.update({
|
const row = await this.prisma.company.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
@@ -391,7 +315,6 @@ export class CompaniesService {
|
|||||||
registeredAt: row.registeredAt ? row.registeredAt.toISOString() : null,
|
registeredAt: row.registeredAt ? row.registeredAt.toISOString() : null,
|
||||||
tags: row.tags ?? [],
|
tags: row.tags ?? [],
|
||||||
customFields: asCustomFields(row.customFields),
|
customFields: asCustomFields(row.customFields),
|
||||||
groups: (row.groupLinks ?? []).map((l) => l.group),
|
|
||||||
peopleCount: row._count.people,
|
peopleCount: row._count.people,
|
||||||
owner: row.owner,
|
owner: row.owner,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import { BadRequestException, ConflictException, Injectable } from '@nestjs/comm
|
|||||||
import type { ImportContactsResult, PersonMood } from '@mento/shared';
|
import type { ImportContactsResult, PersonMood } from '@mento/shared';
|
||||||
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@mento/shared';
|
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@mento/shared';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { customersCompanyWhere } from '../common/data-access';
|
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
import { resolveOwnerId } from '../common/record-scope';
|
import { ownerWhere, resolveOwnerId } from '../common/record-scope';
|
||||||
import {
|
import {
|
||||||
collectAddresses,
|
collectAddresses,
|
||||||
} from '../common/custom-fields';
|
} from '../common/custom-fields';
|
||||||
@@ -26,10 +25,6 @@ import { CompaniesService } from './companies.service';
|
|||||||
import { PeopleService } from './people.service';
|
import { PeopleService } from './people.service';
|
||||||
import { normalizeEmail, normalizePhone } from './normalize';
|
import { normalizeEmail, normalizePhone } from './normalize';
|
||||||
import { normalizePersonImportSource } from './person-source';
|
import { normalizePersonImportSource } from './person-source';
|
||||||
import {
|
|
||||||
CustomerGroupsService,
|
|
||||||
splitGroupNames,
|
|
||||||
} from '../customer-groups/customer-groups.service';
|
|
||||||
|
|
||||||
type ImportChunkOpts = {
|
type ImportChunkOpts = {
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
@@ -44,16 +39,6 @@ function parseMood(raw?: string): PersonMood | undefined {
|
|||||||
return PERSON_MOODS.find((mood) => PERSON_MOOD_LABELS[mood] === value);
|
return PERSON_MOODS.find((mood) => PERSON_MOOD_LABELS[mood] === value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ستونهای گروه اکسل → نام گروهها (برچسبهای وضعیت مشتری گروه حساب نمیشوند) */
|
|
||||||
function groupNamesFrom(...raws: Array<string | undefined>): string[] {
|
|
||||||
const names = raws.flatMap((raw) => splitGroupNames(raw));
|
|
||||||
return [...new Set(names.filter((name) => !parseMood(name)))];
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupNamesText(groups: Array<{ name: string }> | undefined, fallback?: string) {
|
|
||||||
return groups?.length ? groups.map((g) => g.name).join('، ') : (fallback ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitDidarName(firstName: string, lastName: string, title?: string) {
|
function splitDidarName(firstName: string, lastName: string, title?: string) {
|
||||||
const first = firstName.trim();
|
const first = firstName.trim();
|
||||||
const last = lastName.trim();
|
const last = lastName.trim();
|
||||||
@@ -384,7 +369,6 @@ export class ContactsExcelService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly people: PeopleService,
|
private readonly people: PeopleService,
|
||||||
private readonly companies: CompaniesService,
|
private readonly companies: CompaniesService,
|
||||||
private readonly groups: CustomerGroupsService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async exportPeople(actor: Actor, q?: string): Promise<Buffer> {
|
async exportPeople(actor: Actor, q?: string): Promise<Buffer> {
|
||||||
@@ -394,7 +378,6 @@ export class ContactsExcelService {
|
|||||||
CONTACT_COLUMNS,
|
CONTACT_COLUMNS,
|
||||||
items.map((p) => {
|
items.map((p) => {
|
||||||
const extra = (p.customFields ?? {}) as Record<string, string>;
|
const extra = (p.customFields ?? {}) as Record<string, string>;
|
||||||
const groupText = groupNamesText(p.groups, extra.group);
|
|
||||||
return {
|
return {
|
||||||
_type: 'Contact',
|
_type: 'Contact',
|
||||||
didarId: extra.didarId ?? p.id,
|
didarId: extra.didarId ?? p.id,
|
||||||
@@ -419,7 +402,7 @@ export class ContactsExcelService {
|
|||||||
nationalId: extra.nationalId ?? '',
|
nationalId: extra.nationalId ?? '',
|
||||||
registeredAt: formatJalaliDate(p.createdAt),
|
registeredAt: formatJalaliDate(p.createdAt),
|
||||||
birthDate: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
|
birthDate: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
|
||||||
mood: PERSON_MOOD_LABELS[p.mood || 'newcomer'],
|
mood: extra.group || PERSON_MOOD_LABELS[p.mood || 'newcomer'],
|
||||||
otherPhones: extra.otherPhones ?? '',
|
otherPhones: extra.otherPhones ?? '',
|
||||||
address: p.addresses[0] ?? extra.address ?? '',
|
address: p.addresses[0] ?? extra.address ?? '',
|
||||||
website: p.website ?? extra.website ?? '',
|
website: p.website ?? extra.website ?? '',
|
||||||
@@ -427,7 +410,7 @@ export class ContactsExcelService {
|
|||||||
position2: p.position ?? extra.position ?? '',
|
position2: p.position ?? extra.position ?? '',
|
||||||
city2: extra.city ?? '',
|
city2: extra.city ?? '',
|
||||||
code2: extra.customerCode ?? '',
|
code2: extra.customerCode ?? '',
|
||||||
group2: groupText,
|
group2: extra.group ?? '',
|
||||||
address2: p.addresses[1] ?? extra.address ?? '',
|
address2: p.addresses[1] ?? extra.address ?? '',
|
||||||
phone2: extra.phone2 ?? p.phone ?? '',
|
phone2: extra.phone2 ?? p.phone ?? '',
|
||||||
postalCode2: extra.postalCode ?? '',
|
postalCode2: extra.postalCode ?? '',
|
||||||
@@ -435,7 +418,7 @@ export class ContactsExcelService {
|
|||||||
homeAddress: p.addresses[2] ?? extra.homeAddress ?? '',
|
homeAddress: p.addresses[2] ?? extra.homeAddress ?? '',
|
||||||
birthDate2: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
|
birthDate2: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
|
||||||
instagram: p.instagram ?? extra.instagram ?? '',
|
instagram: p.instagram ?? extra.instagram ?? '',
|
||||||
group: groupText,
|
group: extra.group ?? '',
|
||||||
colleagueContact: extra.colleagueContact ?? '',
|
colleagueContact: extra.colleagueContact ?? '',
|
||||||
previousCompany: extra.previousCompany ?? '',
|
previousCompany: extra.previousCompany ?? '',
|
||||||
customerType: extra.customerType ?? '',
|
customerType: extra.customerType ?? '',
|
||||||
@@ -473,7 +456,7 @@ export class ContactsExcelService {
|
|||||||
registeredAt: formatJalaliDate(
|
registeredAt: formatJalaliDate(
|
||||||
c.registeredAt ? new Date(c.registeredAt) : new Date(c.createdAt),
|
c.registeredAt ? new Date(c.registeredAt) : new Date(c.createdAt),
|
||||||
),
|
),
|
||||||
groupName: groupNamesText(c.groups, c.groupName ?? ''),
|
groupName: c.groupName ?? '',
|
||||||
otherPhones: c.otherPhones ?? '',
|
otherPhones: c.otherPhones ?? '',
|
||||||
address: c.address ?? '',
|
address: c.address ?? '',
|
||||||
website: c.website ?? '',
|
website: c.website ?? '',
|
||||||
@@ -568,8 +551,6 @@ export class ContactsExcelService {
|
|||||||
const value = row[key]?.trim();
|
const value = row[key]?.trim();
|
||||||
if (value) customFields[key] = value;
|
if (value) customFields[key] = value;
|
||||||
}
|
}
|
||||||
const groupNames = groupNamesFrom(row.group, parseMood(row.mood) ? undefined : row.mood);
|
|
||||||
if (groupNames.length) delete customFields.group;
|
|
||||||
|
|
||||||
const addresses = collectAddresses(
|
const addresses = collectAddresses(
|
||||||
row.address,
|
row.address,
|
||||||
@@ -595,7 +576,6 @@ export class ContactsExcelService {
|
|||||||
? normalizePersonImportSource(sourceCol)
|
? normalizePersonImportSource(sourceCol)
|
||||||
: 'excel';
|
: 'excel';
|
||||||
const sourceRef = didarId || customerCode || null;
|
const sourceRef = didarId || customerCode || null;
|
||||||
const groupIds = await this.groups.idsForNames(actor.organizationId, groupNames);
|
|
||||||
|
|
||||||
await this.people.create(actor, {
|
await this.people.create(actor, {
|
||||||
firstName: names.firstName,
|
firstName: names.firstName,
|
||||||
@@ -616,7 +596,6 @@ export class ContactsExcelService {
|
|||||||
importSource,
|
importSource,
|
||||||
sourceRef,
|
sourceRef,
|
||||||
registeredAt: registeredAt?.toISOString() ?? null,
|
registeredAt: registeredAt?.toISOString() ?? null,
|
||||||
groupIds,
|
|
||||||
});
|
});
|
||||||
result.created += 1;
|
result.created += 1;
|
||||||
if (ownerMissing) {
|
if (ownerMissing) {
|
||||||
@@ -722,13 +701,8 @@ export class ContactsExcelService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const groupIds = await this.groups.idsForNames(
|
|
||||||
actor.organizationId,
|
|
||||||
groupNamesFrom(row.groupName),
|
|
||||||
);
|
|
||||||
const existing = await this.findImportedCompany(actor, didarId, name);
|
const existing = await this.findImportedCompany(actor, didarId, name);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (groupIds.length) await this.groups.addCompanyGroups(existing.id, groupIds);
|
|
||||||
const merged: Record<string, unknown> = {};
|
const merged: Record<string, unknown> = {};
|
||||||
for (const [k, v] of Object.entries(payload)) {
|
for (const [k, v] of Object.entries(payload)) {
|
||||||
if (k === 'customFields') continue;
|
if (k === 'customFields') continue;
|
||||||
@@ -745,7 +719,6 @@ export class ContactsExcelService {
|
|||||||
await this.companies.create(actor, {
|
await this.companies.create(actor, {
|
||||||
...payload,
|
...payload,
|
||||||
ownerId: resolveOwnerId(actor, ownerId ?? null),
|
ownerId: resolveOwnerId(actor, ownerId ?? null),
|
||||||
groupIds,
|
|
||||||
});
|
});
|
||||||
result.created += 1;
|
result.created += 1;
|
||||||
}
|
}
|
||||||
@@ -777,7 +750,7 @@ export class ContactsExcelService {
|
|||||||
where: {
|
where: {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
sourceRef: didarId,
|
sourceRef: didarId,
|
||||||
...customersCompanyWhere(actor),
|
...ownerWhere(actor),
|
||||||
},
|
},
|
||||||
select: { id: true, customFields: true },
|
select: { id: true, customFields: true },
|
||||||
});
|
});
|
||||||
@@ -788,7 +761,7 @@ export class ContactsExcelService {
|
|||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
name: { equals: name, mode: 'insensitive' },
|
name: { equals: name, mode: 'insensitive' },
|
||||||
...(didarId ? { sourceRef: null } : {}),
|
...(didarId ? { sourceRef: null } : {}),
|
||||||
...customersCompanyWhere(actor),
|
...ownerWhere(actor),
|
||||||
},
|
},
|
||||||
select: { id: true, customFields: true },
|
select: { id: true, customFields: true },
|
||||||
});
|
});
|
||||||
@@ -806,7 +779,7 @@ export class ContactsExcelService {
|
|||||||
where: {
|
where: {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
sourceRef: ref,
|
sourceRef: ref,
|
||||||
...customersCompanyWhere(actor),
|
...ownerWhere(actor),
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
@@ -816,7 +789,7 @@ export class ContactsExcelService {
|
|||||||
where: {
|
where: {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
name: { equals: name, mode: 'insensitive' },
|
name: { equals: name, mode: 'insensitive' },
|
||||||
...customersCompanyWhere(actor),
|
...ownerWhere(actor),
|
||||||
},
|
},
|
||||||
select: { id: true, sourceRef: true },
|
select: { id: true, sourceRef: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,15 +3,12 @@ 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';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -20,11 +17,9 @@ import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'
|
|||||||
MessageAutomationsModule,
|
MessageAutomationsModule,
|
||||||
PublicApiModule,
|
PublicApiModule,
|
||||||
BehaviorModule,
|
BehaviorModule,
|
||||||
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 {}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
ArrayMaxSize,
|
ArrayMaxSize,
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
|
||||||
IsEmail,
|
IsEmail,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
@@ -16,18 +15,13 @@ import {
|
|||||||
MinLength,
|
MinLength,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { PERSON_GENDERS, PERSON_IMPORT_SOURCES, PERSON_MOODS } from '@mento/shared';
|
import { PERSON_IMPORT_SOURCES, PERSON_MOODS } from '@mento/shared';
|
||||||
|
|
||||||
export class BirthdaySmsDto {
|
export class BirthdaySmsDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
@MaxLength(500)
|
@MaxLength(500)
|
||||||
text!: string;
|
text!: string;
|
||||||
|
|
||||||
/** تأیید ارسال مجدد وقتی قبلاً مسئول دیگری تبریک فرستاده */
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
force?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BulkDeletePeopleDto {
|
export class BulkDeletePeopleDto {
|
||||||
@@ -56,20 +50,18 @@ export class ListQueryDto {
|
|||||||
@Max(100)
|
@Max(100)
|
||||||
limit?: number = 20;
|
limit?: number = 20;
|
||||||
|
|
||||||
/** وضعیت مشتری؛ چندتایی با کاما */
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsIn([...PERSON_MOODS])
|
||||||
@MaxLength(120)
|
mood?: (typeof PERSON_MOODS)[number];
|
||||||
mood?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['name', 'newest'])
|
@IsIn(['name', 'newest'])
|
||||||
sort?: 'name' | 'newest';
|
sort?: 'name' | 'newest';
|
||||||
|
|
||||||
/** فیلتر منبع ورود شخص؛ چندتایی با کاما */
|
/** فیلتر منبع ورود شخص */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(400)
|
@MaxLength(40)
|
||||||
importSource?: string;
|
importSource?: string;
|
||||||
|
|
||||||
/** استان شرکت مرتبط */
|
/** استان شرکت مرتبط */
|
||||||
@@ -94,12 +86,6 @@ export class ListQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(12)
|
@MaxLength(12)
|
||||||
disc?: string;
|
disc?: string;
|
||||||
|
|
||||||
/** فیلتر گروه مشتری؛ چندتایی با کاما (هر کدام از گروهها) */
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(2000)
|
|
||||||
groupId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class CompanyExtraFieldsDto {
|
class CompanyExtraFieldsDto {
|
||||||
@@ -167,11 +153,6 @@ class CompanyExtraFieldsDto {
|
|||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
@IsString()
|
@IsString()
|
||||||
registeredAt?: string | null;
|
registeredAt?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
@IsString({ each: true })
|
|
||||||
groupIds?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateCompanyDto extends CompanyExtraFieldsDto {
|
export class CreateCompanyDto extends CompanyExtraFieldsDto {
|
||||||
@@ -288,14 +269,6 @@ export class CreatePersonDto {
|
|||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(PERSON_GENDERS)
|
|
||||||
gender?: (typeof PERSON_GENDERS)[number];
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
showGenderInName?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -397,11 +370,6 @@ export class CreatePersonDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
registeredAt?: string | null;
|
registeredAt?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
@IsString({ each: true })
|
|
||||||
groupIds?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdatePersonDto {
|
export class UpdatePersonDto {
|
||||||
@@ -415,14 +383,6 @@ export class UpdatePersonDto {
|
|||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(PERSON_GENDERS)
|
|
||||||
gender?: (typeof PERSON_GENDERS)[number];
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
showGenderInName?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -518,9 +478,4 @@ export class UpdatePersonDto {
|
|||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
@IsString()
|
@IsString()
|
||||||
ownerId?: string | null;
|
ownerId?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
@IsString({ each: true })
|
|
||||||
groupIds?: string[];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
|
||||||
}
|
|
||||||
@@ -9,54 +9,11 @@ export function normalizeEmail(value?: string | null): string | null {
|
|||||||
return v ? v.toLowerCase() : null;
|
return v ? v.toLowerCase() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
|
||||||
const AR_DIGITS = '٠١٢٣٤٥٦٧٨٩';
|
|
||||||
|
|
||||||
/** ارقام فارسی و عربی را به لاتین برمیگرداند تا از شماره حذف نشوند. */
|
|
||||||
function latinDigits(value: string): string {
|
|
||||||
return value.replace(/[۰-۹٠-٩]/g, (ch) => {
|
|
||||||
const fa = FA_DIGITS.indexOf(ch);
|
|
||||||
return String(fa >= 0 ? fa : AR_DIGITS.indexOf(ch));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keep digits and leading + for Iranian/international numbers. */
|
/** Keep digits and leading + for Iranian/international numbers. */
|
||||||
export function normalizePhone(value?: string | null): string | null {
|
export function normalizePhone(value?: string | null): string | null {
|
||||||
const v = emptyToNull(value);
|
const v = emptyToNull(value);
|
||||||
if (!v) return null;
|
if (!v) return null;
|
||||||
const cleaned = latinDigits(v).replace(/[^\d+]/g, '');
|
const cleaned = 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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
|||||||
import { memoryStorage } from 'multer';
|
import { memoryStorage } from 'multer';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||||
@@ -34,9 +33,8 @@ const excelUpload = FileInterceptor('file', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@Controller('people')
|
@Controller('people')
|
||||||
@UseGuards(JwtAuthGuard, OrgModuleGuard, SectionPermissionGuard)
|
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||||
@RequireOrgModule('customers')
|
@RequireOrgModule('customers')
|
||||||
@RequirePermission('customers')
|
|
||||||
export class PeopleController {
|
export class PeopleController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly peopleService: PeopleService,
|
private readonly peopleService: PeopleService,
|
||||||
@@ -62,7 +60,6 @@ export class PeopleController {
|
|||||||
city: query.city,
|
city: query.city,
|
||||||
hasMobile,
|
hasMobile,
|
||||||
disc: query.disc,
|
disc: query.disc,
|
||||||
groupId: query.groupId,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,7 +77,6 @@ export class PeopleController {
|
|||||||
city: query.city,
|
city: query.city,
|
||||||
hasMobile,
|
hasMobile,
|
||||||
disc: query.disc,
|
disc: query.disc,
|
||||||
groupId: query.groupId,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,11 +85,6 @@ export class PeopleController {
|
|||||||
return this.peopleService.sourceStats(user);
|
return this.peopleService.sourceStats(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('group-stats')
|
|
||||||
groupStats(@CurrentUser() user: Actor) {
|
|
||||||
return this.peopleService.groupStats(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('export')
|
@Get('export')
|
||||||
async export(
|
async export(
|
||||||
@CurrentUser() user: Actor,
|
@CurrentUser() user: Actor,
|
||||||
@@ -139,7 +130,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 +162,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')
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type {
|
import type {
|
||||||
CreatePersonRequest,
|
CreatePersonRequest,
|
||||||
CustomerGroupDto,
|
|
||||||
FilteredIdsResponse,
|
FilteredIdsResponse,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
PersonDto,
|
PersonDto,
|
||||||
@@ -18,57 +17,40 @@ import type {
|
|||||||
PersonMood,
|
PersonMood,
|
||||||
UpdatePersonRequest,
|
UpdatePersonRequest,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import {
|
import { FILTERED_IDS_MAX } from '@mento/shared';
|
||||||
FILTERED_IDS_MAX,
|
|
||||||
PERSON_MOODS,
|
|
||||||
applyPersonGenderTitle,
|
|
||||||
canViewAllRecords,
|
|
||||||
splitPersonHonorific,
|
|
||||||
} from '@mento/shared';
|
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
||||||
import { CompaniesService } from './companies.service';
|
import { CompaniesService } from './companies.service';
|
||||||
import {
|
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize';
|
||||||
emptyToNull,
|
|
||||||
normalizeEmail,
|
|
||||||
phoneIdentityKeys,
|
|
||||||
storedPhoneNumber,
|
|
||||||
} from './normalize';
|
|
||||||
import {
|
import {
|
||||||
asAddresses,
|
asAddresses,
|
||||||
asCustomFields,
|
asCustomFields,
|
||||||
toJsonAddresses,
|
toJsonAddresses,
|
||||||
toJsonCustomFields,
|
toJsonCustomFields,
|
||||||
} from '../common/custom-fields';
|
} from '../common/custom-fields';
|
||||||
import { customersPersonWhere } from '../common/data-access';
|
|
||||||
import {
|
import {
|
||||||
type Actor,
|
type Actor,
|
||||||
|
canReassignOwner,
|
||||||
|
ownerWhere,
|
||||||
resolveOwnerId,
|
resolveOwnerId,
|
||||||
} from '../common/record-scope';
|
} from '../common/record-scope';
|
||||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
|
||||||
import { LoyaltyService } from '../loyalty/loyalty.service';
|
import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||||
import { AutomationsService } from '../automations/automations.service';
|
import { AutomationsService } from '../automations/automations.service';
|
||||||
import { 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 } from '../common/search';
|
||||||
import {
|
import {
|
||||||
normalizePersonImportSource,
|
normalizePersonImportSource,
|
||||||
personImportSourceLabel,
|
personImportSourceLabel,
|
||||||
} from './person-source';
|
} from './person-source';
|
||||||
import { BehaviorAccessService } from '../behavior/behavior-access.service';
|
import { BehaviorAccessService } from '../behavior/behavior-access.service';
|
||||||
import {
|
|
||||||
CustomerGroupsService,
|
|
||||||
groupSummarySelect,
|
|
||||||
} from '../customer-groups/customer-groups.service';
|
|
||||||
|
|
||||||
const personInclude = {
|
const personInclude = {
|
||||||
company: { select: { id: true, name: true, province: true, city: true } },
|
company: { select: { id: true, name: true, province: true, city: true } },
|
||||||
@@ -76,18 +58,12 @@ const personInclude = {
|
|||||||
loyaltyTier: {
|
loyaltyTier: {
|
||||||
select: { id: true, name: true, minPoints: true, color: true },
|
select: { id: true, name: true, minPoints: true, color: true },
|
||||||
},
|
},
|
||||||
groupLinks: {
|
|
||||||
select: { source: true, group: { select: groupSummarySelect } },
|
|
||||||
orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }],
|
|
||||||
},
|
|
||||||
} satisfies Prisma.PersonInclude;
|
} satisfies Prisma.PersonInclude;
|
||||||
|
|
||||||
type PersonRow = {
|
type PersonRow = {
|
||||||
id: string;
|
id: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
gender: string;
|
|
||||||
showGenderInName: boolean;
|
|
||||||
previousFirstName: string | null;
|
previousFirstName: string | null;
|
||||||
previousLastName: string | null;
|
previousLastName: string | null;
|
||||||
importSource: string;
|
importSource: string;
|
||||||
@@ -126,7 +102,6 @@ type PersonRow = {
|
|||||||
discLabel: string | null;
|
discLabel: string | null;
|
||||||
discReliable: boolean;
|
discReliable: boolean;
|
||||||
discAssessedAt: Date | null;
|
discAssessedAt: Date | null;
|
||||||
groupLinks: Array<{ source: string; group: { id: string; name: string; color: string } }>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function discFilter(raw: string | undefined): Prisma.PersonWhereInput | null {
|
function discFilter(raw: string | undefined): Prisma.PersonWhereInput | null {
|
||||||
@@ -159,28 +134,23 @@ export class PeopleService implements OnModuleInit {
|
|||||||
private readonly sms: SmsGatewayService,
|
private readonly sms: SmsGatewayService,
|
||||||
private readonly outboundSms: OutboundSmsLogService,
|
private readonly outboundSms: OutboundSmsLogService,
|
||||||
private readonly behaviorAccess: BehaviorAccessService,
|
private readonly behaviorAccess: BehaviorAccessService,
|
||||||
private readonly groups: CustomerGroupsService,
|
|
||||||
private readonly notifications: NotificationsService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private personListWhere(
|
private personListWhere(
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
q?: string,
|
q?: string,
|
||||||
opts?: {
|
opts?: {
|
||||||
mood?: string;
|
mood?: PersonMood;
|
||||||
importSource?: string;
|
importSource?: string;
|
||||||
province?: string;
|
province?: string;
|
||||||
city?: string;
|
city?: string;
|
||||||
hasMobile?: boolean;
|
hasMobile?: boolean;
|
||||||
disc?: string;
|
disc?: string;
|
||||||
groupId?: string;
|
|
||||||
},
|
},
|
||||||
): Prisma.PersonWhereInput {
|
): Prisma.PersonWhereInput {
|
||||||
const sources = [...new Set(splitCsv(opts?.importSource).map(normalizePersonImportSource))];
|
const sourceFilter = opts?.importSource?.trim()
|
||||||
const moods = splitCsv(opts?.mood).filter((m): m is PersonMood =>
|
? normalizePersonImportSource(opts.importSource)
|
||||||
(PERSON_MOODS as readonly string[]).includes(m),
|
: undefined;
|
||||||
);
|
|
||||||
const groupIds = splitCsv(opts?.groupId);
|
|
||||||
const search = buildSearchWhere<Prisma.PersonWhereInput>(q, (t) => [
|
const search = buildSearchWhere<Prisma.PersonWhereInput>(q, (t) => [
|
||||||
{ firstName: ci(t) },
|
{ firstName: ci(t) },
|
||||||
{ lastName: ci(t) },
|
{ lastName: ci(t) },
|
||||||
@@ -220,27 +190,12 @@ export class PeopleService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
const disc = discFilter(opts?.disc);
|
const disc = discFilter(opts?.disc);
|
||||||
if (disc) and.push(disc);
|
if (disc) and.push(disc);
|
||||||
// گروه / منبع / حال: اتحاد (هر بُعد کافی است)؛ داخل هر بُعد چند مقدار با in = OR
|
|
||||||
const facets: Prisma.PersonWhereInput[] = [];
|
|
||||||
if (groupIds.length) {
|
|
||||||
facets.push({ groupLinks: { some: { groupId: { in: groupIds } } } });
|
|
||||||
}
|
|
||||||
if (moods.length) {
|
|
||||||
facets.push({ mood: { in: moods } });
|
|
||||||
}
|
|
||||||
if (sources.length) {
|
|
||||||
facets.push({ importSource: { in: sources } });
|
|
||||||
}
|
|
||||||
if (facets.length === 1) and.push(facets[0]!);
|
|
||||||
else if (facets.length > 1) and.push({ OR: facets });
|
|
||||||
return {
|
return {
|
||||||
AND: [
|
organizationId: actor.organizationId,
|
||||||
{
|
...ownerWhere(actor),
|
||||||
organizationId: actor.organizationId,
|
...(opts?.mood ? { mood: opts.mood } : {}),
|
||||||
...customersPersonWhere(actor),
|
...(sourceFilter ? { importSource: sourceFilter } : {}),
|
||||||
},
|
...(and.length ? { AND: and } : {}),
|
||||||
...and,
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,14 +205,13 @@ export class PeopleService implements OnModuleInit {
|
|||||||
page = 1,
|
page = 1,
|
||||||
limit = 20,
|
limit = 20,
|
||||||
opts?: {
|
opts?: {
|
||||||
mood?: string;
|
mood?: PersonMood;
|
||||||
sort?: 'name' | 'newest';
|
sort?: 'name' | 'newest';
|
||||||
importSource?: string;
|
importSource?: string;
|
||||||
province?: string;
|
province?: string;
|
||||||
city?: string;
|
city?: string;
|
||||||
hasMobile?: boolean;
|
hasMobile?: boolean;
|
||||||
disc?: string;
|
disc?: string;
|
||||||
groupId?: string;
|
|
||||||
},
|
},
|
||||||
): Promise<PaginatedResponse<PersonDto>> {
|
): Promise<PaginatedResponse<PersonDto>> {
|
||||||
const seeDisc = await this.behaviorAccess.canView(actor);
|
const seeDisc = await this.behaviorAccess.canView(actor);
|
||||||
@@ -289,13 +243,12 @@ export class PeopleService implements OnModuleInit {
|
|||||||
actor: Actor,
|
actor: Actor,
|
||||||
q?: string,
|
q?: string,
|
||||||
opts?: {
|
opts?: {
|
||||||
mood?: string;
|
mood?: PersonMood;
|
||||||
importSource?: string;
|
importSource?: string;
|
||||||
province?: string;
|
province?: string;
|
||||||
city?: string;
|
city?: string;
|
||||||
hasMobile?: boolean;
|
hasMobile?: boolean;
|
||||||
disc?: string;
|
disc?: string;
|
||||||
groupId?: string;
|
|
||||||
},
|
},
|
||||||
max = FILTERED_IDS_MAX,
|
max = FILTERED_IDS_MAX,
|
||||||
): Promise<FilteredIdsResponse> {
|
): Promise<FilteredIdsResponse> {
|
||||||
@@ -321,7 +274,7 @@ export class PeopleService implements OnModuleInit {
|
|||||||
by: ['importSource'],
|
by: ['importSource'],
|
||||||
where: {
|
where: {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
...customersPersonWhere(actor),
|
...ownerWhere(actor),
|
||||||
},
|
},
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
@@ -334,39 +287,6 @@ export class PeopleService implements OnModuleInit {
|
|||||||
.sort((a, b) => b.count - a.count);
|
.sort((a, b) => b.count - a.count);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** آمار گروه برای فیلتر اشخاص — فقط افراد در دسترس کاربر */
|
|
||||||
async groupStats(actor: Actor): Promise<CustomerGroupDto[]> {
|
|
||||||
const counts = await this.prisma.personGroupLink.groupBy({
|
|
||||||
by: ['groupId'],
|
|
||||||
where: {
|
|
||||||
person: {
|
|
||||||
organizationId: actor.organizationId,
|
|
||||||
...customersPersonWhere(actor),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: { _all: true },
|
|
||||||
});
|
|
||||||
if (!counts.length) return [];
|
|
||||||
const countMap = new Map(counts.map((c) => [c.groupId, c._count._all]));
|
|
||||||
const groups = await this.prisma.customerGroup.findMany({
|
|
||||||
where: {
|
|
||||||
organizationId: actor.organizationId,
|
|
||||||
id: { in: counts.map((c) => c.groupId) },
|
|
||||||
},
|
|
||||||
orderBy: [{ position: 'asc' }, { name: 'asc' }],
|
|
||||||
});
|
|
||||||
return groups.map((g) => ({
|
|
||||||
id: g.id,
|
|
||||||
name: g.name,
|
|
||||||
color: g.color,
|
|
||||||
position: g.position,
|
|
||||||
fromTahesab: Boolean(g.tahesabGid),
|
|
||||||
personCount: countMap.get(g.id) ?? 0,
|
|
||||||
companyCount: 0,
|
|
||||||
assignees: [],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
void this.backfillStoredBirthDates();
|
void this.backfillStoredBirthDates();
|
||||||
}
|
}
|
||||||
@@ -423,10 +343,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({
|
||||||
@@ -437,7 +354,7 @@ export class PeopleService implements OnModuleInit {
|
|||||||
|
|
||||||
async get(actor: Actor, id: string): Promise<PersonDto> {
|
async get(actor: Actor, id: string): Promise<PersonDto> {
|
||||||
const row = await this.prisma.person.findFirst({
|
const row = await this.prisma.person.findFirst({
|
||||||
where: { id, organizationId: actor.organizationId, ...customersPersonWhere(actor) },
|
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
|
||||||
include: personInclude,
|
include: personInclude,
|
||||||
});
|
});
|
||||||
if (!row) throw new NotFoundException('شخص پیدا نشد');
|
if (!row) throw new NotFoundException('شخص پیدا نشد');
|
||||||
@@ -451,25 +368,17 @@ export class PeopleService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null;
|
const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null;
|
||||||
const named = applyPersonGenderTitle({
|
|
||||||
firstName: dto.firstName ?? '',
|
|
||||||
lastName: dto.lastName ?? '',
|
|
||||||
gender: dto.gender,
|
|
||||||
showGenderInName: dto.showGenderInName,
|
|
||||||
});
|
|
||||||
const data: Prisma.PersonUncheckedCreateInput = {
|
const data: Prisma.PersonUncheckedCreateInput = {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
firstName: named.firstName,
|
firstName: (dto.firstName ?? '').trim(),
|
||||||
lastName: named.lastName,
|
lastName: (dto.lastName ?? '').trim(),
|
||||||
gender: named.gender,
|
|
||||||
showGenderInName: named.showGenderInName,
|
|
||||||
previousFirstName: emptyToNull(dto.previousFirstName),
|
previousFirstName: emptyToNull(dto.previousFirstName),
|
||||||
previousLastName: emptyToNull(dto.previousLastName),
|
previousLastName: emptyToNull(dto.previousLastName),
|
||||||
importSource: normalizePersonImportSource(dto.importSource),
|
importSource: normalizePersonImportSource(dto.importSource),
|
||||||
sourceRef: emptyToNull(dto.sourceRef),
|
sourceRef: emptyToNull(dto.sourceRef),
|
||||||
email: normalizeEmail(dto.email),
|
email: normalizeEmail(dto.email),
|
||||||
phone: 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),
|
||||||
@@ -480,24 +389,18 @@ export class PeopleService implements OnModuleInit {
|
|||||||
mood: dto.mood ?? 'newcomer',
|
mood: dto.mood ?? 'newcomer',
|
||||||
tags: normalizeTags(dto.tags),
|
tags: normalizeTags(dto.tags),
|
||||||
companyId,
|
companyId,
|
||||||
ownerId: resolveOwnerId(
|
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||||
actor,
|
|
||||||
dto.ownerId,
|
|
||||||
await actorCanAssignToOthers(this.prisma, actor, 'customers'),
|
|
||||||
),
|
|
||||||
customFields: toJsonCustomFields(dto.customFields),
|
customFields: toJsonCustomFields(dto.customFields),
|
||||||
...(registeredAt && !Number.isNaN(registeredAt.getTime())
|
...(registeredAt && !Number.isNaN(registeredAt.getTime())
|
||||||
? { createdAt: registeredAt }
|
? { createdAt: registeredAt }
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
await this.assertNoDuplicate(actor.organizationId, data);
|
await this.assertNoDuplicate(actor.organizationId, data);
|
||||||
const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds);
|
|
||||||
|
|
||||||
const row = await this.prisma.person.create({
|
const row = await this.prisma.person.create({
|
||||||
data,
|
data,
|
||||||
include: personInclude,
|
include: personInclude,
|
||||||
});
|
});
|
||||||
if (groupIds.length) await this.groups.addPersonGroups(row.id, groupIds);
|
|
||||||
await this.loyalty.applyWelcomePoints(actor.organizationId, row.id);
|
await this.loyalty.applyWelcomePoints(actor.organizationId, row.id);
|
||||||
const createdCtx = {
|
const createdCtx = {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
@@ -543,32 +446,8 @@ export class PeopleService implements OnModuleInit {
|
|||||||
await this.ensureExists(actor, id);
|
await this.ensureExists(actor, id);
|
||||||
|
|
||||||
const data: Prisma.PersonUncheckedUpdateInput = {};
|
const data: Prisma.PersonUncheckedUpdateInput = {};
|
||||||
const touchesName =
|
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim();
|
||||||
dto.firstName !== undefined ||
|
if (dto.lastName !== undefined) data.lastName = dto.lastName.trim();
|
||||||
dto.lastName !== undefined ||
|
|
||||||
dto.gender !== undefined ||
|
|
||||||
dto.showGenderInName !== undefined;
|
|
||||||
if (touchesName) {
|
|
||||||
const existing = await this.prisma.person.findFirst({
|
|
||||||
where: { id, organizationId: actor.organizationId },
|
|
||||||
select: { firstName: true, lastName: true, gender: true, showGenderInName: true },
|
|
||||||
});
|
|
||||||
if (!existing) throw new NotFoundException('شخص پیدا نشد');
|
|
||||||
const bare = splitPersonHonorific(existing.firstName, existing.lastName);
|
|
||||||
const storedGender =
|
|
||||||
existing.gender === 'male' || existing.gender === 'female' ? existing.gender : 'none';
|
|
||||||
const named = applyPersonGenderTitle({
|
|
||||||
firstName: dto.firstName !== undefined ? dto.firstName : bare.firstName,
|
|
||||||
lastName: dto.lastName !== undefined ? dto.lastName : bare.lastName,
|
|
||||||
gender: dto.gender !== undefined ? dto.gender : storedGender,
|
|
||||||
showGenderInName:
|
|
||||||
dto.showGenderInName !== undefined ? dto.showGenderInName : existing.showGenderInName,
|
|
||||||
});
|
|
||||||
data.firstName = named.firstName;
|
|
||||||
data.lastName = named.lastName;
|
|
||||||
data.gender = named.gender;
|
|
||||||
data.showGenderInName = named.showGenderInName;
|
|
||||||
}
|
|
||||||
if ('previousFirstName' in dto) {
|
if ('previousFirstName' in dto) {
|
||||||
data.previousFirstName = emptyToNull(dto.previousFirstName);
|
data.previousFirstName = emptyToNull(dto.previousFirstName);
|
||||||
}
|
}
|
||||||
@@ -580,8 +459,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);
|
||||||
@@ -605,21 +484,11 @@ export class PeopleService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
data.companyId = nextCompanyId;
|
data.companyId = nextCompanyId;
|
||||||
}
|
}
|
||||||
if (dto.ownerId !== undefined) {
|
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
|
||||||
const current = await this.prisma.person.findFirst({
|
data.ownerId = resolveOwnerId(actor, dto.ownerId);
|
||||||
where: { id, organizationId: actor.organizationId },
|
|
||||||
select: { ownerId: true },
|
|
||||||
});
|
|
||||||
const nextOwner = emptyToNull(dto.ownerId ?? null);
|
|
||||||
await assertAssigneeSet(this.prisma, actor, 'customers', [nextOwner], [current?.ownerId]);
|
|
||||||
data.ownerId = nextOwner ?? actor.userId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.assertNoDuplicate(actor.organizationId, data, id);
|
await this.assertNoDuplicate(actor.organizationId, data, id);
|
||||||
if (dto.groupIds !== undefined) {
|
|
||||||
const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds);
|
|
||||||
await this.groups.setPersonGroups(id, groupIds);
|
|
||||||
}
|
|
||||||
const row = await this.prisma.person.update({
|
const row = await this.prisma.person.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
@@ -647,7 +516,7 @@ export class PeopleService implements OnModuleInit {
|
|||||||
where: {
|
where: {
|
||||||
id: { in: unique },
|
id: { in: unique },
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
...customersPersonWhere(actor),
|
...ownerWhere(actor),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { deleted: res.count };
|
return { deleted: res.count };
|
||||||
@@ -655,7 +524,7 @@ export class PeopleService implements OnModuleInit {
|
|||||||
|
|
||||||
private async ensureExists(actor: Actor, id: string) {
|
private async ensureExists(actor: Actor, id: string) {
|
||||||
const found = await this.prisma.person.findFirst({
|
const found = await this.prisma.person.findFirst({
|
||||||
where: { id, organizationId: actor.organizationId, ...customersPersonWhere(actor) },
|
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (!found) throw new NotFoundException('شخص پیدا نشد');
|
if (!found) throw new NotFoundException('شخص پیدا نشد');
|
||||||
@@ -668,13 +537,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 +565,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 +613,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 +655,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 +674,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 +687,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 +698,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 +733,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),
|
||||||
@@ -1012,8 +754,6 @@ export class PeopleService implements OnModuleInit {
|
|||||||
previousImportSource: row.previousImportSource,
|
previousImportSource: row.previousImportSource,
|
||||||
previousSourceRef: row.previousSourceRef,
|
previousSourceRef: row.previousSourceRef,
|
||||||
fullName: `${row.firstName} ${row.lastName}`.trim(),
|
fullName: `${row.firstName} ${row.lastName}`.trim(),
|
||||||
gender: row.gender === 'male' || row.gender === 'female' ? row.gender : 'none',
|
|
||||||
showGenderInName: row.showGenderInName,
|
|
||||||
email: row.email,
|
email: row.email,
|
||||||
phone: row.phone,
|
phone: row.phone,
|
||||||
mobile: row.mobile,
|
mobile: row.mobile,
|
||||||
@@ -1036,10 +776,6 @@ export class PeopleService implements OnModuleInit {
|
|||||||
mood: row.mood,
|
mood: row.mood,
|
||||||
tags: row.tags ?? [],
|
tags: row.tags ?? [],
|
||||||
customFields: asCustomFields(row.customFields),
|
customFields: asCustomFields(row.customFields),
|
||||||
groups: (row.groupLinks ?? []).map((l) => ({
|
|
||||||
...l.group,
|
|
||||||
source: l.source === 'tahesab' ? ('tahesab' as const) : ('manual' as const),
|
|
||||||
})),
|
|
||||||
loyaltyPoints: row.loyaltyPoints ?? 0,
|
loyaltyPoints: row.loyaltyPoints ?? 0,
|
||||||
loyaltyTier: row.loyaltyTier,
|
loyaltyTier: row.loyaltyTier,
|
||||||
discLabel: includeDisc && row.discReliable ? row.discLabel : null,
|
discLabel: includeDisc && row.discReliable ? row.discLabel : null,
|
||||||
|
|||||||
@@ -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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
|||||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||||
import {
|
import {
|
||||||
ConversationFileBytesDto,
|
|
||||||
CreateConversationDmDto,
|
CreateConversationDmDto,
|
||||||
CreateConversationGroupDto,
|
CreateConversationGroupDto,
|
||||||
CreateConversationChannelDto,
|
CreateConversationChannelDto,
|
||||||
@@ -20,34 +19,12 @@ import {
|
|||||||
ToggleMessageReactionDto,
|
ToggleMessageReactionDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
import { ConversationsService } from './conversations.service';
|
import { ConversationsService } from './conversations.service';
|
||||||
import { StorageQuotaService } from '../billing/storage-quota.service';
|
|
||||||
|
|
||||||
@Controller('conversations')
|
@Controller('conversations')
|
||||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||||
@RequireOrgModule('conversations')
|
@RequireOrgModule('conversations')
|
||||||
export class ConversationsController {
|
export class ConversationsController {
|
||||||
constructor(
|
constructor(private readonly conversations: ConversationsService) {}
|
||||||
private readonly conversations: ConversationsService,
|
|
||||||
private readonly storage: StorageQuotaService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Post('files/reserve')
|
|
||||||
reserveFile(
|
|
||||||
@CurrentUser() user: { organizationId: string },
|
|
||||||
@Body() dto: ConversationFileBytesDto,
|
|
||||||
) {
|
|
||||||
return this.storage.assertCanAdd(user.organizationId, dto.bytes).then(() =>
|
|
||||||
this.storage.usageForOrganization(user.organizationId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('files/commit')
|
|
||||||
commitFile(
|
|
||||||
@CurrentUser() user: { organizationId: string },
|
|
||||||
@Body() dto: ConversationFileBytesDto,
|
|
||||||
) {
|
|
||||||
return this.storage.addChat(user.organizationId, dto.bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('access')
|
@Get('access')
|
||||||
access(@CurrentUser() user: { userId: string; organizationId: string; role: string }) {
|
access(@CurrentUser() user: { userId: string; organizationId: string; role: string }) {
|
||||||
|
|||||||
@@ -656,7 +656,7 @@ export class ConversationsService {
|
|||||||
});
|
});
|
||||||
if (!row) {
|
if (!row) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → گفتگو پیکربندی کنید.',
|
'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → محیطها → تنظیمات گفتگو پیکربندی کنید.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { Type } from 'class-transformer';
|
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsInt,
|
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Max,
|
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Min,
|
|
||||||
MinLength,
|
MinLength,
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
ArrayMaxSize,
|
ArrayMaxSize,
|
||||||
@@ -36,14 +32,6 @@ export class UpdateConversationSettingsDto {
|
|||||||
downloadUrl?: string;
|
downloadUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ConversationFileBytesDto {
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
@Max(100 * 1024 * 1024)
|
|
||||||
bytes!: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CreateConversationDmDto {
|
export class CreateConversationDmDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export enum CustomFieldEntityDto {
|
|||||||
person = 'person',
|
person = 'person',
|
||||||
company = 'company',
|
company = 'company',
|
||||||
deal = 'deal',
|
deal = 'deal',
|
||||||
note = 'note',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum CustomFieldTypeDto {
|
export enum CustomFieldTypeDto {
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
Param,
|
|
||||||
Patch,
|
|
||||||
Post,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
IsArray,
|
|
||||||
IsIn,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
MaxLength,
|
|
||||||
MinLength,
|
|
||||||
} from 'class-validator';
|
|
||||||
import { CUSTOMER_GROUP_COLORS, type CustomerGroupColor } from '@mento/shared';
|
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|
||||||
import { RolesGuard } from '../auth/roles.guard';
|
|
||||||
import { Roles } from '../auth/roles.decorator';
|
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
|
||||||
import type { Actor } from '../common/record-scope';
|
|
||||||
import { CustomerGroupsService } from './customer-groups.service';
|
|
||||||
|
|
||||||
class CreateCustomerGroupDto {
|
|
||||||
@IsString()
|
|
||||||
@MinLength(1)
|
|
||||||
@MaxLength(80)
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn([...CUSTOMER_GROUP_COLORS])
|
|
||||||
color?: CustomerGroupColor;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
@IsString({ each: true })
|
|
||||||
assigneeIds?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
class UpdateCustomerGroupDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MinLength(1)
|
|
||||||
@MaxLength(80)
|
|
||||||
name?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn([...CUSTOMER_GROUP_COLORS])
|
|
||||||
color?: CustomerGroupColor;
|
|
||||||
|
|
||||||
/** null گروه را اصلی میکند */
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
parentId?: string | null;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
@IsString({ each: true })
|
|
||||||
assigneeIds?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller('customer-groups')
|
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
||||||
export class CustomerGroupsController {
|
|
||||||
constructor(private readonly groups: CustomerGroupsService) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
list(@CurrentUser() user: Actor) {
|
|
||||||
return this.groups.list(user.organizationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post()
|
|
||||||
@Roles('owner', 'admin')
|
|
||||||
create(@CurrentUser() user: Actor, @Body() dto: CreateCustomerGroupDto) {
|
|
||||||
return this.groups.create(user.organizationId, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch(':id')
|
|
||||||
@Roles('owner', 'admin')
|
|
||||||
update(
|
|
||||||
@CurrentUser() user: Actor,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() dto: UpdateCustomerGroupDto,
|
|
||||||
) {
|
|
||||||
return this.groups.update(user.organizationId, id, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':id')
|
|
||||||
@Roles('owner', 'admin')
|
|
||||||
async remove(@CurrentUser() user: Actor, @Param('id') id: string) {
|
|
||||||
await this.groups.remove(user.organizationId, id);
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { AuthModule } from '../auth/auth.module';
|
|
||||||
import { CustomerGroupsController } from './customer-groups.controller';
|
|
||||||
import { CustomerGroupsService } from './customer-groups.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [AuthModule],
|
|
||||||
controllers: [CustomerGroupsController],
|
|
||||||
providers: [CustomerGroupsService],
|
|
||||||
exports: [CustomerGroupsService],
|
|
||||||
})
|
|
||||||
export class CustomerGroupsModule {}
|
|
||||||
@@ -1,398 +0,0 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
ConflictException,
|
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import type { CustomerGroupColor, CustomerGroupDto } from '@mento/shared';
|
|
||||||
import { CUSTOMER_GROUP_COLORS } from '@mento/shared';
|
|
||||||
import { Prisma } from '@prisma/client';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
|
|
||||||
const NAME_MAX = 80;
|
|
||||||
|
|
||||||
/** نام گروه برای مقایسه: فاصلههای اضافه و ی/ک عربی یکسان میشوند */
|
|
||||||
function normalizeGroupName(raw: string): string {
|
|
||||||
return raw
|
|
||||||
.replace(/\u064A/g, '\u06CC')
|
|
||||||
.replace(/\u0643/g, '\u06A9')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim()
|
|
||||||
.slice(0, NAME_MAX);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** «الف، ب / ج» → ['الف', 'ب', 'ج'] */
|
|
||||||
export function splitGroupNames(raw: string | null | undefined): string[] {
|
|
||||||
if (!raw) return [];
|
|
||||||
return [
|
|
||||||
...new Set(
|
|
||||||
raw
|
|
||||||
.split(/\s*[،,/]\s*/)
|
|
||||||
.map(normalizeGroupName)
|
|
||||||
.filter(Boolean),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const groupSummarySelect = {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
color: true,
|
|
||||||
} satisfies Prisma.CustomerGroupSelect;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CustomerGroupsService {
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
|
|
||||||
async list(organizationId: string): Promise<CustomerGroupDto[]> {
|
|
||||||
const rows = await this.prisma.customerGroup.findMany({
|
|
||||||
where: { organizationId },
|
|
||||||
orderBy: [{ position: 'asc' }, { name: 'asc' }],
|
|
||||||
include: {
|
|
||||||
_count: { select: { people: true, companies: true } },
|
|
||||||
parent: { select: { name: true } },
|
|
||||||
assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return rows.map((r) => this.toDto(r));
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(
|
|
||||||
organizationId: string,
|
|
||||||
dto: { name: string; color?: CustomerGroupColor; assigneeIds?: string[] },
|
|
||||||
): Promise<CustomerGroupDto> {
|
|
||||||
const name = this.cleanName(dto.name);
|
|
||||||
const assigneeIds =
|
|
||||||
dto.assigneeIds !== undefined
|
|
||||||
? await this.validAssigneeIds(organizationId, dto.assigneeIds)
|
|
||||||
: [];
|
|
||||||
const max = await this.prisma.customerGroup.aggregate({
|
|
||||||
where: { organizationId },
|
|
||||||
_max: { position: true },
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await this.prisma.customerGroup.create({
|
|
||||||
data: {
|
|
||||||
organizationId,
|
|
||||||
name,
|
|
||||||
color: this.cleanColor(dto.color),
|
|
||||||
position: (max._max.position ?? -1) + 1,
|
|
||||||
...(assigneeIds.length
|
|
||||||
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
throw this.mapError(e);
|
|
||||||
}
|
|
||||||
return (await this.list(organizationId)).find((g) => g.name === name)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(
|
|
||||||
organizationId: string,
|
|
||||||
id: string,
|
|
||||||
dto: {
|
|
||||||
name?: string;
|
|
||||||
color?: CustomerGroupColor;
|
|
||||||
parentId?: string | null;
|
|
||||||
assigneeIds?: string[];
|
|
||||||
},
|
|
||||||
): Promise<CustomerGroupDto> {
|
|
||||||
await this.require(organizationId, id);
|
|
||||||
const parentId =
|
|
||||||
dto.parentId === undefined ? undefined : dto.parentId?.trim() || null;
|
|
||||||
if (parentId !== undefined) {
|
|
||||||
await this.assertParent(organizationId, id, parentId);
|
|
||||||
}
|
|
||||||
const assigneeIds =
|
|
||||||
dto.assigneeIds !== undefined
|
|
||||||
? await this.validAssigneeIds(organizationId, dto.assigneeIds)
|
|
||||||
: undefined;
|
|
||||||
try {
|
|
||||||
await this.prisma.customerGroup.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
|
|
||||||
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
|
|
||||||
...(parentId !== undefined ? { parentId } : {}),
|
|
||||||
...(assigneeIds !== undefined
|
|
||||||
? { assignees: { set: assigneeIds.map((uid) => ({ id: uid })) } }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
throw this.mapError(e);
|
|
||||||
}
|
|
||||||
return (await this.list(organizationId)).find((g) => g.id === id)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** فقط یک سطح: گروه اصلی خودش زیرگروه نیست و گروه فرعی زیرگروه ندارد. */
|
|
||||||
private async assertParent(
|
|
||||||
organizationId: string,
|
|
||||||
id: string,
|
|
||||||
parentId: string | null,
|
|
||||||
) {
|
|
||||||
if (parentId == null) return;
|
|
||||||
if (parentId === id) {
|
|
||||||
throw new BadRequestException('گروه نمیتواند زیرمجموعهٔ خودش باشد.');
|
|
||||||
}
|
|
||||||
const parent = await this.prisma.customerGroup.findFirst({
|
|
||||||
where: { id: parentId, organizationId },
|
|
||||||
select: { id: true, parentId: true },
|
|
||||||
});
|
|
||||||
if (!parent) throw new NotFoundException('گروه اصلی پیدا نشد.');
|
|
||||||
if (parent.parentId) {
|
|
||||||
throw new BadRequestException('فقط یک سطح گروه اصلی و فرعی ممکن است.');
|
|
||||||
}
|
|
||||||
const childCount = await this.prisma.customerGroup.count({
|
|
||||||
where: { organizationId, parentId: id },
|
|
||||||
});
|
|
||||||
if (childCount > 0) {
|
|
||||||
throw new BadRequestException('این گروه خودش زیرگروه دارد و نمیتواند فرعی شود.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** مشتریها حذف نمیشوند؛ فقط این گروه از رویشان برداشته میشود */
|
|
||||||
async remove(organizationId: string, id: string): Promise<void> {
|
|
||||||
await this.require(organizationId, id);
|
|
||||||
await this.prisma.customerGroup.delete({ where: { id } });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** شناسههای معتبر همین محیط (بقیه نادیده گرفته میشوند) */
|
|
||||||
async validIds(organizationId: string, ids: string[] | undefined): Promise<string[]> {
|
|
||||||
const unique = [...new Set((ids ?? []).map((id) => id?.trim()).filter(Boolean))];
|
|
||||||
if (!unique.length) return [];
|
|
||||||
const rows = await this.prisma.customerGroup.findMany({
|
|
||||||
where: { organizationId, id: { in: unique } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (rows.length !== unique.length) {
|
|
||||||
throw new BadRequestException('یکی از گروههای انتخابشده معتبر نیست');
|
|
||||||
}
|
|
||||||
return rows.map((r) => r.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** گروهها با نام (برای اکسل)؛ نبود → ساخته میشود */
|
|
||||||
async idsForNames(organizationId: string, names: string[]): Promise<string[]> {
|
|
||||||
const clean = [...new Set(names.map(normalizeGroupName).filter(Boolean))];
|
|
||||||
if (!clean.length) return [];
|
|
||||||
const ids: string[] = [];
|
|
||||||
for (const name of clean) {
|
|
||||||
ids.push((await this.findOrCreateByName(organizationId, name)).id);
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async findOrCreateByName(organizationId: string, name: string) {
|
|
||||||
const existing = await this.prisma.customerGroup.findFirst({
|
|
||||||
where: { organizationId, name: { equals: name, mode: 'insensitive' } },
|
|
||||||
select: { id: true, tahesabGid: true },
|
|
||||||
});
|
|
||||||
if (existing) return existing;
|
|
||||||
const max = await this.prisma.customerGroup.aggregate({
|
|
||||||
where: { organizationId },
|
|
||||||
_max: { position: true },
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
return await this.prisma.customerGroup.create({
|
|
||||||
data: { organizationId, name, position: (max._max.position ?? -1) + 1 },
|
|
||||||
select: { id: true, tahesabGid: true },
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
|
||||||
return this.prisma.customerGroup.findFirstOrThrow({
|
|
||||||
where: { organizationId, name },
|
|
||||||
select: { id: true, tahesabGid: true },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* گروه متناظر یک گروه تهحساب: اول با GID روی همان اتصال، بعد با نام.
|
|
||||||
* با تغییر نام در تهحساب گروه تکراری ساخته نمیشود.
|
|
||||||
*/
|
|
||||||
async ensureTahesabGroup(
|
|
||||||
organizationId: string,
|
|
||||||
connectionId: string,
|
|
||||||
gid: string | null,
|
|
||||||
rawName: string,
|
|
||||||
): Promise<string | null> {
|
|
||||||
const name = normalizeGroupName(rawName);
|
|
||||||
const cleanGid = gid?.trim() || null;
|
|
||||||
if (!name && !cleanGid) return null;
|
|
||||||
if (cleanGid) {
|
|
||||||
const byGid = await this.prisma.customerGroup.findFirst({
|
|
||||||
where: { organizationId, tahesabConnectionId: connectionId, tahesabGid: cleanGid },
|
|
||||||
select: { id: true, name: true },
|
|
||||||
});
|
|
||||||
if (byGid) {
|
|
||||||
if (name && byGid.name !== name) {
|
|
||||||
await this.prisma.customerGroup
|
|
||||||
.update({ where: { id: byGid.id }, data: { name } })
|
|
||||||
.catch(() => undefined); // نام در منتکت تکراری است؛ نام فعلی میماند
|
|
||||||
}
|
|
||||||
return byGid.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!name) return null;
|
|
||||||
const group = await this.findOrCreateByName(organizationId, name);
|
|
||||||
if (cleanGid && !group.tahesabGid) {
|
|
||||||
await this.prisma.customerGroup.update({
|
|
||||||
where: { id: group.id },
|
|
||||||
data: { tahesabConnectionId: connectionId, tahesabGid: cleanGid },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return group.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** گروههای دستی شخص (جایگزین کامل). منبع لینکهایی که میمانند حفظ میشود. */
|
|
||||||
async setPersonGroups(personId: string, groupIds: string[]): Promise<void> {
|
|
||||||
await this.prisma.$transaction([
|
|
||||||
this.prisma.personGroupLink.deleteMany({
|
|
||||||
where: { personId, groupId: { notIn: groupIds } },
|
|
||||||
}),
|
|
||||||
this.prisma.personGroupLink.createMany({
|
|
||||||
data: groupIds.map((groupId) => ({ personId, groupId, source: 'manual' })),
|
|
||||||
skipDuplicates: true,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setCompanyGroups(companyId: string, groupIds: string[]): Promise<void> {
|
|
||||||
await this.prisma.$transaction([
|
|
||||||
this.prisma.companyGroupLink.deleteMany({
|
|
||||||
where: { companyId, groupId: { notIn: groupIds } },
|
|
||||||
}),
|
|
||||||
this.prisma.companyGroupLink.createMany({
|
|
||||||
data: groupIds.map((groupId) => ({ companyId, groupId })),
|
|
||||||
skipDuplicates: true,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** فقط اضافه میکند (ایمپورت اکسل) */
|
|
||||||
async addPersonGroups(personId: string, groupIds: string[]): Promise<void> {
|
|
||||||
if (!groupIds.length) return;
|
|
||||||
await this.prisma.personGroupLink.createMany({
|
|
||||||
data: groupIds.map((groupId) => ({ personId, groupId, source: 'manual' })),
|
|
||||||
skipDuplicates: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async addCompanyGroups(companyId: string, groupIds: string[]): Promise<void> {
|
|
||||||
if (!groupIds.length) return;
|
|
||||||
await this.prisma.companyGroupLink.createMany({
|
|
||||||
data: groupIds.map((groupId) => ({ companyId, groupId })),
|
|
||||||
skipDuplicates: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* گروهِ آمده از تهحساب: گروه تهحسابی قبلی برداشته و این یکی گذاشته میشود.
|
|
||||||
* گروههای دستی دست نمیخورند. اگر همین گروه از قبل دستی بوده، دستی میماند.
|
|
||||||
* @returns آیا چیزی عوض شد
|
|
||||||
*/
|
|
||||||
async setPersonTahesabGroup(personId: string, groupId: string | null): Promise<boolean> {
|
|
||||||
const current = await this.prisma.personGroupLink.findMany({
|
|
||||||
where: { personId },
|
|
||||||
select: { groupId: true, source: true },
|
|
||||||
});
|
|
||||||
const staleTahesab = current.filter((l) => l.source === 'tahesab' && l.groupId !== groupId);
|
|
||||||
const alreadyLinked = groupId ? current.some((l) => l.groupId === groupId) : true;
|
|
||||||
if (!staleTahesab.length && alreadyLinked) return false;
|
|
||||||
await this.prisma.$transaction([
|
|
||||||
...(staleTahesab.length
|
|
||||||
? [
|
|
||||||
this.prisma.personGroupLink.deleteMany({
|
|
||||||
where: { personId, groupId: { in: staleTahesab.map((l) => l.groupId) } },
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(groupId && !alreadyLinked
|
|
||||||
? [this.prisma.personGroupLink.create({ data: { personId, groupId, source: 'tahesab' } })]
|
|
||||||
: []),
|
|
||||||
]);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** گروهی که موقع «ثبت در تهحساب» فرستاده میشود: اولین گروه (به ترتیب گروهها) */
|
|
||||||
async firstGroupName(personId: string): Promise<string | null> {
|
|
||||||
const link = await this.prisma.personGroupLink.findFirst({
|
|
||||||
where: { personId },
|
|
||||||
orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }],
|
|
||||||
select: { group: { select: { name: true } } },
|
|
||||||
});
|
|
||||||
return link?.group.name ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private 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) {
|
|
||||||
const row = await this.prisma.customerGroup.findFirst({
|
|
||||||
where: { id, organizationId },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (!row) throw new NotFoundException('گروه پیدا نشد');
|
|
||||||
}
|
|
||||||
|
|
||||||
private cleanName(raw: string): string {
|
|
||||||
const name = normalizeGroupName(raw ?? '');
|
|
||||||
if (!name) throw new BadRequestException('نام گروه لازم است');
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
private cleanColor(raw?: string): CustomerGroupColor {
|
|
||||||
return (CUSTOMER_GROUP_COLORS as readonly string[]).includes(raw ?? '')
|
|
||||||
? (raw as CustomerGroupColor)
|
|
||||||
: 'teal';
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapError(e: unknown) {
|
|
||||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
|
||||||
return new ConflictException('گروهی با این نام وجود دارد');
|
|
||||||
}
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -30,7 +30,6 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { type Actor, elevated } from '../common/record-scope';
|
import { type Actor, elevated } from '../common/record-scope';
|
||||||
import { dayKeyToDate, tehranDayKey, addDays } from '../routines/routine-dates';
|
import { dayKeyToDate, tehranDayKey, addDays } from '../routines/routine-dates';
|
||||||
import { internalRoutineTaskWhere } from '../routines/internal-routine';
|
|
||||||
import { normalizeLevels, periodStart, previousPeriod, sanitizeConditions } from './gamification-engine';
|
import { normalizeLevels, periodStart, previousPeriod, sanitizeConditions } from './gamification-engine';
|
||||||
import { GamificationService, parsePeriod, toRuleDto } from './gamification.service';
|
import { GamificationService, parsePeriod, toRuleDto } from './gamification.service';
|
||||||
import type {
|
import type {
|
||||||
@@ -752,7 +751,6 @@ export class GamificationAdminService {
|
|||||||
organizationId,
|
organizationId,
|
||||||
status: { not: 'cancelled' },
|
status: { not: 'cancelled' },
|
||||||
scheduledFor: { gte: dayKeyToDate(fromKey), lte: dayKeyToDate(toKey) },
|
scheduledFor: { gte: dayKeyToDate(fromKey), lte: dayKeyToDate(toKey) },
|
||||||
...internalRoutineTaskWhere,
|
|
||||||
};
|
};
|
||||||
const [total, done] = await Promise.all([
|
const [total, done] = await Promise.all([
|
||||||
this.prisma.routineTask.count({ where }),
|
this.prisma.routineTask.count({ where }),
|
||||||
|
|||||||
@@ -17,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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
ArrayMaxSize,
|
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsIn,
|
IsIn,
|
||||||
@@ -86,16 +85,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 {
|
||||||
@@ -187,41 +176,6 @@ export class ImportBulkFromTahesabDto {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SyncGroupsPageDto {
|
|
||||||
@IsString()
|
|
||||||
connectionId!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
page?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsInt()
|
|
||||||
@Min(100)
|
|
||||||
@Max(2000)
|
|
||||||
pageSize?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** اجرای دستی همگامسازی همهٔ مشتریان (وارد + بهروز کردن لینکشدهها) */
|
|
||||||
export class SyncAllCustomersDto {
|
|
||||||
@IsString()
|
|
||||||
connectionId!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ExportPeopleToTahesabDto {
|
|
||||||
@IsString()
|
|
||||||
connectionId!: string;
|
|
||||||
|
|
||||||
/** هر درخواست چند نفر؛ هر نفر چند تماس با تهحساب دارد */
|
|
||||||
@IsArray()
|
|
||||||
@ArrayMaxSize(25)
|
|
||||||
@IsString({ each: true })
|
|
||||||
personIds!: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SearchTahesabDto {
|
export class SearchTahesabDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
connectionId!: string;
|
connectionId!: string;
|
||||||
@@ -449,70 +403,6 @@ export class PushSanadDto {
|
|||||||
factorCode?: string;
|
factorCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SyncAsnadReportDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
connectionId?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@MinLength(8)
|
|
||||||
@MaxLength(12)
|
|
||||||
from!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@MinLength(8)
|
|
||||||
@MaxLength(12)
|
|
||||||
to!: string;
|
|
||||||
|
|
||||||
/** اگر باشد، فقط سندهای همین شخص گرفته میشود */
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
personId?: string;
|
|
||||||
|
|
||||||
/** اگر باشد، فقط مشتریان این گروهها (و زیرگروهها) گرفته میشوند */
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
@IsString({ each: true })
|
|
||||||
@ArrayMaxSize(200)
|
|
||||||
groupIds?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UpdateTahesabInvoiceSettingsDto {
|
|
||||||
/** null = حذف لوگو */
|
|
||||||
@IsOptional()
|
|
||||||
logoDataUrl?: string | null;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(200)
|
|
||||||
companyName?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(200)
|
|
||||||
companySubtitle?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(500)
|
|
||||||
address?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(80)
|
|
||||||
phone?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(1000)
|
|
||||||
headerText?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(1000)
|
|
||||||
footerText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// silence unused import warnings for ValidateNested/Type if unused
|
// silence unused import warnings for ValidateNested/Type if unused
|
||||||
void ValidateNested;
|
void ValidateNested;
|
||||||
void Type;
|
void Type;
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
|
||||||
import type {
|
|
||||||
TahesabInvoiceSettingsDto,
|
|
||||||
UpdateTahesabInvoiceSettingsRequest,
|
|
||||||
} from '@mento/shared';
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
|
||||||
|
|
||||||
const MAX_LOGO_CHARS = 700_000; // ~500KB data URL
|
|
||||||
|
|
||||||
function toDto(row: {
|
|
||||||
id: string;
|
|
||||||
organizationId: string;
|
|
||||||
logoDataUrl: string | null;
|
|
||||||
companyName: string;
|
|
||||||
companySubtitle: string;
|
|
||||||
address: string;
|
|
||||||
phone: string;
|
|
||||||
headerText: string;
|
|
||||||
footerText: string;
|
|
||||||
updatedAt: Date;
|
|
||||||
}): TahesabInvoiceSettingsDto {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
organizationId: row.organizationId,
|
|
||||||
logoDataUrl: row.logoDataUrl,
|
|
||||||
companyName: row.companyName,
|
|
||||||
companySubtitle: row.companySubtitle,
|
|
||||||
address: row.address,
|
|
||||||
phone: row.phone,
|
|
||||||
headerText: row.headerText,
|
|
||||||
footerText: row.footerText,
|
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class TahesabInvoiceSettingsService {
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
|
|
||||||
async getOrCreate(organizationId: string): Promise<TahesabInvoiceSettingsDto> {
|
|
||||||
const existing = await this.prisma.tahesabInvoiceSettings.findUnique({
|
|
||||||
where: { organizationId },
|
|
||||||
});
|
|
||||||
if (existing) return toDto(existing);
|
|
||||||
|
|
||||||
const org = await this.prisma.organization.findUnique({
|
|
||||||
where: { id: organizationId },
|
|
||||||
select: { name: true },
|
|
||||||
});
|
|
||||||
const created = await this.prisma.tahesabInvoiceSettings.create({
|
|
||||||
data: {
|
|
||||||
organizationId,
|
|
||||||
companyName: org?.name ?? '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return toDto(created);
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(
|
|
||||||
organizationId: string,
|
|
||||||
dto: UpdateTahesabInvoiceSettingsRequest,
|
|
||||||
): Promise<TahesabInvoiceSettingsDto> {
|
|
||||||
if (dto.logoDataUrl != null && dto.logoDataUrl.length > MAX_LOGO_CHARS) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'حجم لوگو زیاد است؛ تصویر کوچکتری انتخاب کنید.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
dto.logoDataUrl != null &&
|
|
||||||
dto.logoDataUrl !== '' &&
|
|
||||||
!/^data:image\/(png|jpe?g|webp|gif);base64,/i.test(dto.logoDataUrl)
|
|
||||||
) {
|
|
||||||
throw new BadRequestException('لوگو باید تصویر با فرمت data URL باشد.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
...(dto.logoDataUrl !== undefined
|
|
||||||
? { logoDataUrl: dto.logoDataUrl?.trim() || null }
|
|
||||||
: {}),
|
|
||||||
...(dto.companyName !== undefined
|
|
||||||
? { companyName: dto.companyName.trim().slice(0, 200) }
|
|
||||||
: {}),
|
|
||||||
...(dto.companySubtitle !== undefined
|
|
||||||
? { companySubtitle: dto.companySubtitle.trim().slice(0, 200) }
|
|
||||||
: {}),
|
|
||||||
...(dto.address !== undefined
|
|
||||||
? { address: dto.address.trim().slice(0, 500) }
|
|
||||||
: {}),
|
|
||||||
...(dto.phone !== undefined
|
|
||||||
? { phone: dto.phone.trim().slice(0, 80) }
|
|
||||||
: {}),
|
|
||||||
...(dto.headerText !== undefined
|
|
||||||
? { headerText: dto.headerText.trim().slice(0, 1000) }
|
|
||||||
: {}),
|
|
||||||
...(dto.footerText !== undefined
|
|
||||||
? { footerText: dto.footerText.trim().slice(0, 1000) }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const row = await this.prisma.tahesabInvoiceSettings.upsert({
|
|
||||||
where: { organizationId },
|
|
||||||
create: {
|
|
||||||
organizationId,
|
|
||||||
companyName: '',
|
|
||||||
...data,
|
|
||||||
},
|
|
||||||
update: data,
|
|
||||||
});
|
|
||||||
return toDto(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user