2 Commits
Author SHA1 Message Date
Mahdi 39c62834e7 fix: resolve Ant switch and unused board menu type errors blocking push 2026-11-03 16:53:49 +03:30
Mahdi 26792f9a37 feat: migrate CRM screens to Ant Design Vue and expand home dashboard
Preserve the local Ant UI work across views/components and add the new home dashboard chart panels before merging latest main.
2026-11-03 16:51:51 +03:30
370 changed files with 23918 additions and 64466 deletions
+11 -3
View File
@@ -41,11 +41,19 @@ Legacy CRM UI (`components/ui/*`, `.crm-*`, `.btn` / `.field` in `styles/`) rema
6. **24-column grid only** 6. **24-column grid only**
Page layout on migrated screens uses Ant Grid: `a-row` / `a-col` with the **24-column** system (`:span`, `:xs`, `:sm`, `:md`, `:lg`, `:xl`). Do not invent parallel flex/grid CSS layouts for page chrome. Page layout on migrated screens uses Ant Grid: `a-row` / `a-col` with the **24-column** system (`:span`, `:xs`, `:sm`, `:md`, `:lg`, `:xl`). Do not invent parallel flex/grid CSS layouts for page chrome.
7. **RTL + locale** 7. **RTL + locale + StyleProvider**
App root is wrapped with `<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">`. Do not override direction to LTR on whole pages; use local `direction="ltr"` only for inherently LTR fragments (e.g. phone numbers, codes). App root in `App.vue` **must** wrap ConfigProvider with `<a-style-provider hash-priority="high">` so legacy `main.css` cannot override Ant controls via higher specificity:
```vue
<a-style-provider hash-priority="high">
<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">
<RouterView />
</a-config-provider>
</a-style-provider>
```
Do not override direction to LTR on whole pages; use local `direction="ltr"` only for inherently LTR fragments (e.g. phone numbers, codes).
8. **Auto-import** 8. **Auto-import**
`<a-*>` components are resolved via `unplugin-vue-components` + `AntDesignVueResolver({ importStyle: false })`. Do not add blanket `app.use(antd)` unless there is a documented exception. Do not per-component CSS imports (`importStyle` stays `false`; CSS-in-JS + theme). `<a-*>` components are resolved via `unplugin-vue-components` + `AntDesignVueResolver({ importStyle: false })`. Do not add blanket `app.use(antd)` unless there is a documented exception. Do not per-component CSS imports (`importStyle` stays `false`; CSS-in-JS + theme). Prefer **explicit** `import { Button as AButton, … } from 'ant-design-vue'` in migrated files when the Vite resolver is missing or unreliable.
9. **Theme is central** 9. **Theme is central**
Visual tokens (primary, radius, `controlHeight`, padding/margin scale, shadows, Form/Card overrides) live only in `antdTheme.ts`. Do not fork theme objects inside views. Visual tokens (primary, radius, `controlHeight`, padding/margin scale, shadows, Form/Card overrides) live only in `antdTheme.ts`. Do not fork theme objects inside views.
+7
View File
@@ -0,0 +1,7 @@
{
"plugins": {
"hex": {
"enabled": true
}
}
}
@@ -38,9 +38,13 @@ Also read and obey [`.cursor/rules/antd-strict-governance.mdc`](../../rules/antd
| Dependency | `ant-design-vue` ^4, `@ant-design/icons-vue`, `unplugin-vue-components` in `@mento/web` | | Dependency | `ant-design-vue` ^4, `@ant-design/icons-vue`, `unplugin-vue-components` in `@mento/web` |
| Vite plugin | `apps/web/vite.config.ts` — `Components` + `AntDesignVueResolver` | | Vite plugin | `apps/web/vite.config.ts` — `Components` + `AntDesignVueResolver` |
| Theme | `apps/web/src/theme/antdTheme.ts` (`controlHeight: 36`) | | Theme | `apps/web/src/theme/antdTheme.ts` (`controlHeight: 36`) |
| RTL + locale + theme | `apps/web/src/App.vue` — `<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">` | | RTL + locale + theme + StyleProvider | `apps/web/src/App.vue` — `<a-style-provider hash-priority="high">` wrapping `<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">` (mandatory so legacy `main.css` cannot override Ant) |
| Legacy kit (do not touch) | `apps/web/src/components/ui/*`, `styles/main.css` utilities | | Legacy kit (do not touch) | `apps/web/src/components/ui/*`, `styles/main.css` utilities |
### Mandatory child-control conversion (same pass)
هنگام بازنویسی یک صفحه (View)، ایجنت موظف است علاوه بر خود فایل View، تمام کامپوننت‌های فرزندی را که در آن View ایمپورت شده‌اند و حاوی اینپوت/سلکت/دکمه هستند نیز بازبینی کرده و درون آن‌ها را به کامپوننت‌های رسمی `ant-design-vue` تبدیل کند. هیچ تگ `<input>`, `<select>`, `<textarea>`, `<button>` خامی نباید در صفحه یا کامپوننت‌های فرزندِ مصرفی آن باقی بماند.
## Migration workflow ## Migration workflow
1. Confirm the **single** route/view the user named. 1. Confirm the **single** route/view the user named.
-15
View File
@@ -1,15 +0,0 @@
.git
node_modules
**/dist
**/.env
**/.env.*
apps/stt/.venv
uploads
docs
e2e
test-results
playwright-report
graphify-out
.worktrees
*.md
crm-node_modules.tgz
-129
View File
@@ -1,129 +0,0 @@
name: Build & Push Docker Image
on:
push:
tags:
- "*"
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Start time
run: echo "BUILD_STARTED=$(date +%s)" >> "$GITHUB_ENV"
- name: Free disk space
id: prune
run: |
df -h / /workspace /var/lib/containerd 2>/dev/null || df -h /
docker builder prune -af || true
docker image prune -af || true
docker system prune -af --volumes || true
df -h / /workspace /var/lib/containerd 2>/dev/null || df -h /
- name: Checkout code
id: checkout
uses: actions/checkout@v4
- name: Resolve image tag
id: meta
run: |
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
TAG="${GITHUB_REF_NAME}"
else
TAG="$(git rev-parse --short HEAD)"
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "registry=dock.monsys.ir" >> "$GITHUB_OUTPUT"
- name: Build Docker images
id: build
run: |
set -eu
tag="${{ steps.meta.outputs.tag }}"
reg="${{ steps.meta.outputs.registry }}"
docker build -f Dockerfile.api -t "$reg/montact-api:$tag" -t "$reg/montact-api:latest" .
docker build -f Dockerfile.web -t "$reg/montact-web:$tag" -t "$reg/montact-web:latest" .
docker build -f Dockerfile.stt -t "$reg/montact-stt:$tag" -t "$reg/montact-stt:latest" .
# Registry login is unsolved. These two steps stay off so a tag run
# only proves the three images build. Remove `if: false` on both when
# dock.monsys.ir accepts the runner.
- name: Login to registry
id: login
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
USER_NAME="${REGISTRY_USERNAME:-admin}"
printf '%s\n' "$REGISTRY_PASSWORD" | docker login "dock.monsys.ir" -u "$USER_NAME" --password-stdin
- name: Push Docker images
id: push
run: |
set -eu
tag="${{ steps.meta.outputs.tag }}"
reg="${{ steps.meta.outputs.registry }}"
for name in montact-api montact-web montact-stt; do
docker push "$reg/$name:$tag"
docker push "$reg/$name:latest"
done
- name: Notify Bale
if: always()
continue-on-error: true
env:
BALE_TOKEN: ${{ secrets.BALE_BOT_TOKEN || vars.BALE_BOT_TOKEN }}
BALE_CHAT_ID: ${{ vars.BALE_CHAT_ID }}
TAG: ${{ steps.meta.outputs.tag }}
REG: ${{ steps.meta.outputs.registry }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}
STEP_OUTCOMES: |
Checkout code=${{ steps.checkout.outcome }}
Resolve image tag=${{ steps.meta.outcome }}
Build Docker images=${{ steps.build.outcome }}
Login to registry=${{ steps.login.outcome }}
Push Docker images=${{ steps.push.outcome }}
run: |
if [ -z "$BALE_TOKEN" ] || [ -z "$BALE_CHAT_ID" ]; then
echo "BALE_BOT_TOKEN / BALE_CHAT_ID not set in Gitea — skipping notification"
exit 0
fi
failed_step=$(printf '%s\n' "$STEP_OUTCOMES" | grep -E '=(failure|cancelled)$' | head -n1 | cut -d= -f1)
if [ -n "$failed_step" ]; then
title="❌ بیلد ناموفق"
elif printf '%s\n' "$STEP_OUTCOMES" | grep -q '^Push Docker images=success$'; then
title="✅ بیلد موفق"
elif printf '%s\n' "$STEP_OUTCOMES" | grep -q '^Build Docker images=success$'; then
title="✅ بیلد موفق؛ پوش رجیستری هنوز خاموش است"
else
title="⚠️ بیلد کامل نشد"
fi
commit=$(git log -1 --pretty='%h — %s' 2>/dev/null || echo "${GITHUB_SHA:0:7}")
elapsed=$(( $(date +%s) - ${BUILD_STARTED:-$(date +%s)} ))
text="${title}
پروژه: ${GITHUB_REPOSITORY}
تگ: ${TAG:-${GITHUB_REF_NAME}}"
if [ -z "$failed_step" ] && [ -n "$TAG" ]; then
text="${text}
image: ${REG}/montact-api:${TAG}
image: ${REG}/montact-web:${TAG}
image: ${REG}/montact-stt:${TAG}"
fi
[ -n "$failed_step" ] && text="${text}
مرحلهٔ خطا: ${failed_step}"
text="${text}
کامیت: ${commit}
توسط: ${GITHUB_ACTOR}
مدت: $((elapsed / 60))m$((elapsed % 60))s
لاگ: ${RUN_URL}"
msg_file=$(mktemp)
printf '%s\n' "$text" | sed 's/^[[:space:]]*//' > "$msg_file"
curl -sS -m 20 -X POST "https://tapi.bale.ai/bot${BALE_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${BALE_CHAT_ID}" \
--data-urlencode "text@${msg_file}"
-29
View File
@@ -1,29 +0,0 @@
# NestJS API + Prisma. Build context is the monorepo root.
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/shared/package.json packages/shared/package.json
RUN npm ci
COPY apps/api apps/api
COPY packages/shared packages/shared
RUN npm run build -w @mento/shared \
&& npm run prisma:generate -w @mento/api \
&& npm run build -w @mento/api
FROM node:20-bookworm-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/package.json /app/package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/shared ./packages/shared
COPY --from=build /app/apps/api/package.json ./apps/api/package.json
COPY --from=build /app/apps/api/dist ./apps/api/dist
COPY --from=build /app/apps/api/prisma ./apps/api/prisma
WORKDIR /app/apps/api
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/src/main.js"]
-15
View File
@@ -1,15 +0,0 @@
# Local Faster-Whisper. Not published on Ingress.
FROM python:3.11-slim-bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY apps/stt/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY apps/stt/main.py ./main.py
ENV STT_MODEL=small \
STT_DEVICE=cpu \
STT_COMPUTE_TYPE=int8 \
HF_HOME=/models
EXPOSE 3040
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3040"]
-16
View File
@@ -1,16 +0,0 @@
# Vue panel. The built app calls /api on the same host; Traefik sends that to the API.
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/shared/package.json packages/shared/package.json
RUN npm ci
COPY apps/web apps/web
COPY packages/shared packages/shared
RUN npm run build -w @mento/shared && npm run build -w @mento/web
FROM nginx:1.27-alpine
COPY docker/web.nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
EXPOSE 80
+65
View File
@@ -0,0 +1,65 @@
---
description: حاکمیت مطلق معماری رابط کاربری بر پایه Ant Design Vue v4، قفل مهاجرت صفحه به صفحه، ممنوعیت کامل CSS دستی و استاندارد ریاضی چیدمان
globs: apps/web/src/**/*.{vue,ts,tsx,css}
alwaysApply: true
---
# قرارداد حاکمیتی و غیرقابل‌مذاکرهٔ رابط کاربری (Ant Design Vue v4 Strict Contract)
تنها منبع مجاز برای ساخت و بازنویسی عناصر رابط کاربری در این پروژه `ant-design-vue` (نسخه ۴) است. هرگونه تخطی از قوانین زیر خطای معماری محسوب می‌شود:
## ۱. قفل ایزولاسیون و مهاجرت صفحه به صفحه (Single-Page Blast Radius Lock)
1. **ممنوعیت ویرایش دسته‌جمعی:** تو فقط و فقط مجاز به ویرایش صفحه (View) یا کامپوننتی هستی که کاربر صراحتاً در همان نوبت نام برده است. دست زدن به سایر صفحات پروژه مطلقاً ممنوع است.
2. **حفظ لایهٔ قدیمی برای صفحات مهاجرت‌نکرده:** فایل‌های داخل `apps/web/src/components/ui/Ui*.vue` و استایل‌های سراسری `main.css` / `tokens.css` نباید حذف یا ویرایش شوند تا صفحات قدیمی پروژه بدون خطا به کار خود ادامه دهند.
3. **قطع کامل وابستگی در صفحهٔ هدف:** در صفحه‌ای که کاربر دستور بازنویسی آن را می‌دهد، تمام ایمپورت‌های `@/components/ui` و کلاس‌های قدیمی باید از آن فایل حذف شوند و ۱۰۰٪ با کامپوننت‌های رسمی `<a-...>` جایگزین گردند.
4. **کامپوننت‌های تخصصی تاریخ شمسی و مبالغ:** در صفحات بازنویسی‌شده، اگر از `JalaliDateInput` یا `MoneyInput` استفاده شده است، منطق آن‌ها را حفظ کن اما مطمئن شو ظاهر ورودی آن‌ها با ارتفاع `40px`، ردیوس `10px` و عرض `style="width: 100%"` کاملاً هم‌تراز با فیلدهای Ant Design نمایش داده می‌شود.
## ۲. لیست سیاه سینتکس و تگ‌های ممنوعه در صفحات بازنویسی‌شده (Banned Syntax)
در هر فایلی که بازنویسی یا ایجاد می‌کنی، وجود موارد زیر خطای بحرانی است و باید فوراً حذف شوند:
- **تگ‌های خام HTML برای فرم و جدول:** `<button>`, `<input>`, `<select>`, `<textarea>`, `<label>`, `<form>`, `<table>`, `<thead>`, `<tbody>`, `<tr>`, `<td>`.
- **کلاس‌های لگسی پروژه:** `.btn`, `.btn-primary`, `.btn-ghost`, `.btn-danger`, `.btn-save`, `.field`, `.crm-page`, `.crm-page-header`, `.crm-card`, `.crm-table`, `.crm-empty-state`.
- **کلاس‌های Tailwind (پروژه فاقد Tailwind است):** استفاده از کلاس‌های ناموجود مانند `w-full`, `flex`, `gap-4`, `p-4`, `mb-4` مطلقاً ممنوع است. برای عرض کامل کنترل‌ها فقط از `style="width: 100%"` یا پراپ `block` استفاده کن.
## ۳. قوانین ریاضی چیدمان، گرید و ریتم فضایی (Spatial & Layout Mathematics)
1. **ممنوعیت تکه‌تکه کردن فرم به کارت‌های متعدد (Anti-Card-Fragmentation):**
- خرد کردن یک فرم به چندین باکس یا کارت مجزا زیر هم ممنوع است.
- کل فیلدهای یک فرم اصلی باید در **حداکثر ۱ `<a-card :bordered="false">`** تجمیع شوند.
- برای تفکیک بخش‌های منطقی در داخل آن کارت واحد، فقط از `<a-divider orientation="right">عنوان بخش</a-divider>` استفاده کن.
2. **چیدمان استاندارد دو ستونه (فرم اصلی + پنل خلاصه):**
- ردیف اصلی صفحه: `<a-row :gutter="[24, 24]">`
- ستون اصلی (فرم/محتوا): `<a-col :xs="24" :lg="17" :xl="18">`
- ستون کناری (خلاصه وضعیت): `<a-col :xs="24" :lg="7" :xl="6">` همراه با `<a-affix :offset-top="24">`.
- محتوای پنل خلاصه باید الزاماً با `<a-descriptions :column="1" size="small" bordered>` داخل `<a-card title="..." :bordered="false">` پیاده‌سازی شود، نه با `div` و `span` دستی.
3. **قانون عرض فیلدها در فرم (Form Grid Allocation):**
- تمام فرم‌ها باید `<a-form layout="vertical" :model="...">` باشند و تمام فیلدها بدون استثنا داخل `<a-form-item label="..." name="...">` قرار گیرند.
- رها کردن فیلدهای کوتاه در عرض ۱۰۰٪ (`span="24"`) ممنوع است. چیدمان درون فرم باید با `<a-row :gutter="[16, 0]">` به شکل زیر باشد:
- **فیلدهای کوتاه** (تاریخ، ساعت، مدت زمان، مبلغ، وضعیت، نوع، کد): `<a-col :xs="24" :sm="12" :md="8">` (۳ ستونه).
- **فیلدهای متوسط** (انتخاب مشتری، مسئول انجام، فرصت مرتبط، دسته‌بندی، عنوان): `<a-col :xs="24" :md="12">` (۲ ستونه).
- **فیلدهای تمام‌عرض** (توضیحات، یادداشت، چک‌لیست، برچسب‌ها): `<a-col :span="24">`.
- **سوییچ‌ها و چک‌باکس‌های هم‌خانواده:** قرارگیری در یک ردیف با `<a-space :size="24" wrap>`.
4. **مدیریت Margin و Padding بدون CSS دستی:**
- فاصلهٔ عمودی فیلدها فقط توسط `itemMarginBottom` در کانفیگ تم `<a-form-item>` کنترل می‌شود.
- فاصلهٔ افقی/عمودی دکمه‌ها و المان‌ها فقط با `<a-space :size="8 | 12 | 16">` یا `<a-flex gap="small | middle | large" align="center" justify="space-between">` کنترل می‌شود.
## ۴. قفل سینتکس اختصاصی Ant Design Vue v4
- **بایندینگ مقدار (`v-model`):** استفاده از `v-model="..."` بدون آرگومان روی کامپوننت‌های Ant Design Vue خطاست:
- برای `a-input`, `a-textarea`, `a-input-number`, `a-select`, `a-radio-group`, `a-segmented`, `a-date-picker`, `a-time-picker`: الزاماً `v-model:value="..."`.
- برای `a-checkbox`, `a-switch`: الزاماً `v-model:checked="..."`.
- برای `a-modal`, `a-drawer`, `a-dropdown`, `a-popover`, `a-tooltip`: الزاماً `v-model:open="..."` (پراپ `:visible` منسوخ و ممنوع است).
- **الزام عرض ۱۰۰٪ روی کنترل‌های خاص:** کامپوننت‌های `<a-input-number>`, `<a-date-picker>`, `<a-time-picker>` و `<a-select>` در داخل `<a-form-item>` باید دارای `style="width: 100%"` باشند.
- **فیدبک و پیام‌ها:** برای نمایش پیام از `import { message, notification, Modal } from 'ant-design-vue'` استفاده کن.
## ۵. حاکمیت مطلق توکن‌ها و ممنوعیت CSS دستی (Zero Manual Styling & RTL Lock)
1. نوشتن کد رنگ (`#hex`, `rgb`, `rgba`, `hsl`)، سایه (`box-shadow`)، ردیوس (`border-radius`) و فاصله‌های دستی (`margin`, `padding` با `px`/`rem`) در `<style scoped>` ممنوع است.
2. تمام تنظیمات ظاهری (ردیوس، شدو، ارتفاع کنترل‌ها، پدینگ و رنگ‌ها) فقط در فایل مرکزی `apps/web/src/theme/antdTheme.ts` تعریف می‌شوند.
3. در صورت نیاز ضروری به خواندن توکن در یک کانتینر، فقط از هوک `const { token } = theme.useToken()` در `ant-design-vue` استفاده کن.
4. استفاده از `:deep(.ant-...)` برای دست‌کاری ظاهر کامپوننت‌های انت ممنوع است.
5. در RTL، استفاده از `left`, `right`, `margin-left`, `margin-right`, `padding-left`, `padding-right` ممنوع است؛ فقط از خواص منطقی (`inset-inline-start`, `inset-inline-end`, `margin-inline`, `padding-inline`) استفاده شود.
## ۶. گیت خودارزیابی پیش از تحویل کد (Self-Verification Gate)
قبل از اتمام کار روی فایلِ درخواستی، این ۵ شرط را بررسی کن:
- [ ] آیا فقط فایل درخواستی کاربر ویرایش شده و سایر صفحات دست‌نخورده مانده‌اند؟
- [ ] آیا تمام تگ‌های خام فرم، کامپوننت‌های `Ui*`، کلاس‌های `.btn`/`.field`/`.crm-*` و کلاس‌های ناموجود مثل `w-full` از این صفحه حذف شده‌اند؟
- [ ] آیا فرم در ۱ کارت واحد با `<a-divider>` و گرید `<a-row :gutter="[16, 0]">` چیده شده و فیلدهای کوتاه از حالت تمام‌عرض خارج شده‌اند؟
- [ ] آیا تمام بایندینگ‌ها از `v-model:value`، `v-model:checked` و `v-model:open` به همراه `style="width: 100%"` روی سلکت‌ها و شمارنده‌ها استفاده می‌کنند؟
- [ ] آیا `<style scoped>` کاملاً از رنگ‌های Hex، مارجین/پدینگ دستی، ردیوس، شدو و `:deep(.ant-*)` پاک‌سازی شده است؟
+1 -1
View File
@@ -12,7 +12,7 @@
"prisma:seed": "tsx prisma/seed.ts", "prisma:seed": "tsx prisma/seed.ts",
"lint": "echo \"api lint placeholder\"", "lint": "echo \"api lint placeholder\"",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts test/behavior.test.ts test/attendance-time.test.ts test/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 '[]';
+2 -296
View File
@@ -33,17 +33,6 @@ enum ActivityStatus {
canceled canceled
} }
enum ActivityCompletionMode {
any
all
}
/// چه کسانی می‌توانند فعالیت را ویرایش/حذف کنند (علاوه بر owner/admin)
enum ActivityEditPolicy {
creator
assignees
}
enum ActivityPriority { enum ActivityPriority {
low low
medium medium
@@ -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')
+1 -2
View File
@@ -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('نوع فعالیتی با این نام وجود دارد');
+2 -91
View File
@@ -9,13 +9,11 @@ import {
IsObject, IsObject,
IsOptional, IsOptional,
IsString, IsString,
Matches,
Max, Max,
MaxLength, MaxLength,
Min, Min,
MinLength, MinLength,
ValidateIf, ValidateIf,
ValidateNested,
} from 'class-validator'; } from 'class-validator';
import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared'; import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared';
import { ACTIVITY_TYPE_ICONS } from '@mento/shared'; import { ACTIVITY_TYPE_ICONS } from '@mento/shared';
@@ -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[];
} }
-4
View File
@@ -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) {
-2
View File
@@ -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,
+3 -24
View File
@@ -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,
+15 -106
View File
@@ -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 } : {}),
-40
View File
@@ -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;
}
}
+2 -19
View File
@@ -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 {}
+2 -114
View File
@@ -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' };
}
}
-62
View File
@@ -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));
}
-28
View File
@@ -1,28 +0,0 @@
import type { Prisma } from '@prisma/client';
type PositionUpdate = Prisma.PrismaPromise<unknown>;
/** کارت را در ایندکس خواسته‌شده می‌گذارد. ایندکس نسبت به فهرست بدون خود کارت است. */
export function placeId(ids: string[], movingId: string, index: number): string[] {
const next = ids.filter((id) => id !== movingId);
const at = Math.max(0, Math.min(Math.trunc(index), next.length));
next.splice(at, 0, movingId);
return next;
}
export function sameIds(left: string[], right: string[]): boolean {
return left.length === right.length && left.every((id, index) => id === right[index]);
}
/** ترتیب ستون را ۰ تا n ذخیره می‌کند. */
export async function writePositions(
transaction: (ops: PositionUpdate[]) => Promise<unknown>,
ids: string[],
update: (id: string, position: number) => PositionUpdate,
) {
const size = 60;
for (let start = 0; start < ids.length; start += size) {
const slice = ids.slice(start, start + size);
await transaction(slice.map((id, offset) => update(id, start + offset)));
}
}
-499
View File
@@ -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);
}
+18 -140
View File
@@ -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);
} }
-13
View File
@@ -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 };
+1 -68
View File
@@ -21,82 +21,15 @@ function tehranJalaliLong(d: Date) {
return `${toFaDigits(j.jd)} ${jalaliMonthName(j.jm)} ${toFaDigits(j.jy)}`; return `${toFaDigits(j.jd)} ${jalaliMonthName(j.jm)} ${toFaDigits(j.jy)}`;
} }
/** قالب بس/بد مثل UI ته‌حساب — برای توکن‌های مانده در پیامک */
export function formatSmsBedBes(v: unknown, maximumFractionDigits = 3): string {
if (v == null) return '';
const s = String(v).trim();
if (!s || s === 'null' || s === 'undefined' || s === 'NaN') return '';
const n = Number(s.replace(/,/g, ''));
if (!Number.isFinite(n)) return toFaDigits(s);
const abs = toFaDigits(
new Intl.NumberFormat('en-US', { maximumFractionDigits }).format(Math.abs(n)),
);
if (n === 0) return abs;
return `${abs} ${n < 0 ? 'بد' : 'بس'}`;
}
const METAL_FIELD_LABELS: Record<string, string> = {
MandeyeVazni: 'طلا',
MandeyeVazniNoghre: 'نقره',
MandeyeKolBeTALA: 'کل به طلا',
MandeMenhayeSeke: 'منهای سکه',
};
function isMetalMandehKey(key: string) {
if (METAL_FIELD_LABELS[key]) return true;
if (key.startsWith('Seke_')) return true;
return /Vazni|طلا|نقره|فلز|Gold|Silver/i.test(key);
}
function metalFieldLabel(key: string) {
if (METAL_FIELD_LABELS[key]) return METAL_FIELD_LABELS[key]!;
if (key.startsWith('Seke_')) return `سکه ${key.slice(5)}`;
return key;
}
/** از فیلدهای مانده ته‌حساب، متن مانده ریالی و فلزی می‌سازد */
export function smsBalanceTexts(input: {
mandehFields?: Array<{ key: string; value: string | null }> | null;
rialBalance?: number | null;
goldBalance?: number | null;
}): { rialBalance: string; metalBalance: string } {
const fields = input.mandehFields ?? [];
const mali = fields.find((f) => f.key === 'MandeyeMali' || f.key === 'MandeyeKolBePool');
const rial =
formatSmsBedBes(mali?.value ?? input.rialBalance) ||
formatSmsBedBes(input.rialBalance);
const metalParts = fields
.filter((f) => isMetalMandehKey(f.key) && f.value != null && String(f.value).trim() !== '')
.map((f) => `${metalFieldLabel(f.key)} ${formatSmsBedBes(f.value)}`.trim())
.filter(Boolean);
const metal =
metalParts.join('، ') ||
(input.goldBalance != null && formatSmsBedBes(input.goldBalance)
? `طلا ${formatSmsBedBes(input.goldBalance)}`
: '');
return { rialBalance: rial, metalBalance: metal };
}
export function renderSmsText( export function renderSmsText(
text: string, text: string,
vars: { vars: { name?: string; phone?: string; title?: string; company?: string },
name?: string;
phone?: string;
title?: string;
company?: string;
from?: string;
rialBalance?: string;
metalBalance?: string;
},
) { ) {
return applyMessageTemplate(text, { return applyMessageTemplate(text, {
name: vars.name, name: vars.name,
phone: vars.phone, phone: vars.phone,
title: vars.title, title: vars.title,
company: vars.company, company: vars.company,
from: vars.from,
rialBalance: vars.rialBalance,
metalBalance: vars.metalBalance,
date: tehranJalaliLong(new Date()), date: tehranJalaliLong(new Date()),
}).trim(); }).trim();
} }
-1
View File
@@ -60,7 +60,6 @@ export function selectMonthBirthdays(people: BirthdayRow[], now = new Date(), pr
const upcoming = matched.filter((row) => row.day >= today.jd).sort(byDay); const upcoming = matched.filter((row) => row.day >= today.jd).sort(byDay);
const passed = matched.filter((row) => row.day < today.jd).sort(byDay); const passed = matched.filter((row) => row.day < today.jd).sort(byDay);
return { return {
year: today.jy,
month: today.jm, month: today.jm,
monthName: jalaliMonthName(today.jm), monthName: jalaliMonthName(today.jm),
today: today.jd, today: today.jd,
@@ -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,
+11 -88
View File
@@ -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 },
}); });
+1 -6
View File
@@ -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 {}
+5 -50
View File
@@ -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[];
} }
-367
View File
@@ -1,367 +0,0 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { Prisma, PrismaClient } from '@prisma/client';
import { canonicalIranMobile, storedPhoneNumber } from './normalize';
const personSelect = {
id: true,
createdAt: true,
firstName: true,
lastName: true,
gender: true,
showGenderInName: true,
previousFirstName: true,
previousLastName: true,
email: true,
phone: true,
mobile: true,
position: true,
website: true,
instagram: true,
birthDate: true,
referrer: true,
addresses: true,
notes: true,
tags: true,
companyId: true,
customFields: true,
importSource: true,
sourceRef: true,
previousImportSource: true,
previousSourceRef: true,
loyaltyPoints: true,
discLabel: true,
discReliable: true,
discAssessedAt: true,
} satisfies Prisma.PersonSelect;
type PersonRow = Prisma.PersonGetPayload<{ select: typeof personSelect }>;
function blank(value: string | null | undefined) {
return !value?.trim();
}
function asStrings(value: Prisma.JsonValue): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => String(item)).filter(Boolean);
}
function asObject(value: Prisma.JsonValue): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return { ...(value as Record<string, unknown>) };
}
function mergedFields(keeper: PersonRow, extras: PersonRow[], canon: string): Prisma.PersonUncheckedUpdateInput {
const notes = [keeper.notes?.trim() || ''];
let email = keeper.email;
let phone = storedPhoneNumber(keeper.phone);
let position = keeper.position;
let website = keeper.website;
let instagram = keeper.instagram;
let birthDate = keeper.birthDate;
let referrer = keeper.referrer;
let companyId = keeper.companyId;
let gender = keeper.gender;
let showGenderInName = keeper.showGenderInName;
let previousFirstName = keeper.previousFirstName;
let previousLastName = keeper.previousLastName;
let previousImportSource = keeper.previousImportSource;
let previousSourceRef = keeper.previousSourceRef;
let discLabel = keeper.discLabel;
let discReliable = keeper.discReliable;
let discAssessedAt = keeper.discAssessedAt;
let loyaltyPoints = keeper.loyaltyPoints;
const addresses = asStrings(keeper.addresses);
const tags = new Set(keeper.tags);
const customFields = asObject(keeper.customFields);
for (const extra of extras) {
const name = `${extra.firstName} ${extra.lastName}`.trim();
notes.push(
`ادغام شماره تکراری: ${name || 'بدون نام'} (${extra.mobile || 'بدون موبایل'})`,
);
if (blank(email) && extra.email) email = extra.email;
if (blank(phone)) phone = storedPhoneNumber(extra.phone);
if (blank(position) && extra.position) position = extra.position;
if (blank(website) && extra.website) website = extra.website;
if (blank(instagram) && extra.instagram) instagram = extra.instagram;
if (!birthDate && extra.birthDate) birthDate = extra.birthDate;
if (blank(referrer) && extra.referrer) referrer = extra.referrer;
if (!companyId && extra.companyId) companyId = extra.companyId;
if (gender === 'none' && extra.gender !== 'none') {
gender = extra.gender;
showGenderInName = extra.showGenderInName;
}
if (blank(previousFirstName) && extra.previousFirstName) {
previousFirstName = extra.previousFirstName;
}
if (blank(previousLastName) && extra.previousLastName) {
previousLastName = extra.previousLastName;
}
if (
blank(previousImportSource) &&
extra.importSource &&
extra.importSource !== keeper.importSource
) {
previousImportSource = extra.importSource;
previousSourceRef = extra.sourceRef;
}
if (
keeper.importSource === 'tahesab' &&
blank(previousFirstName) &&
blank(previousLastName)
) {
const extraName = `${extra.firstName} ${extra.lastName}`.trim();
const keeperName = `${keeper.firstName} ${keeper.lastName}`.trim();
if (extraName && extraName !== keeperName) {
previousFirstName = extra.firstName || null;
previousLastName = extra.lastName || null;
}
}
if (!discLabel && extra.discLabel) {
discLabel = extra.discLabel;
discReliable = extra.discReliable;
discAssessedAt = extra.discAssessedAt;
}
loyaltyPoints = Math.max(loyaltyPoints, extra.loyaltyPoints);
for (const address of asStrings(extra.addresses)) {
if (!addresses.includes(address)) addresses.push(address);
}
for (const tag of extra.tags) tags.add(tag);
for (const [key, value] of Object.entries(asObject(extra.customFields))) {
if (customFields[key] == null || customFields[key] === '') customFields[key] = value;
}
}
return {
mobile: canon,
email,
phone,
position,
website,
instagram,
birthDate,
referrer,
companyId,
gender,
showGenderInName,
previousFirstName,
previousLastName,
previousImportSource,
previousSourceRef,
discLabel,
discReliable,
discAssessedAt,
loyaltyPoints,
addresses,
tags: [...tags],
customFields: customFields as Prisma.InputJsonValue,
notes: notes.filter(Boolean).join('\n') || null,
};
}
async function personIdTables(prisma: PrismaClient | Prisma.TransactionClient) {
const rows = await prisma.$queryRaw<{ table_name: string }[]>`
SELECT table_name
FROM information_schema.columns
WHERE table_schema = 'public' AND column_name = 'personId'
`;
return rows
.map((row) => row.table_name)
.filter((name) => /^[A-Za-z0-9_]+$/.test(name) && name !== 'Person');
}
async function moveLinks(
tx: Prisma.TransactionClient,
tables: string[],
keeperId: string,
dupId: string,
) {
await tx.$executeRaw`
DELETE FROM "PersonGroupLink" AS d
USING "PersonGroupLink" AS k
WHERE d."personId" = ${dupId}
AND k."personId" = ${keeperId}
AND d."groupId" = k."groupId"
`;
await tx.$executeRaw`
DELETE FROM "PersonAccountingLink" AS d
USING "PersonAccountingLink" AS k
WHERE d."personId" = ${dupId}
AND k."personId" = ${keeperId}
AND d."connectionId" = k."connectionId"
`;
await tx.$executeRaw`
DELETE FROM "PartyOpeningBalance"
WHERE "personId" = ${dupId}
AND EXISTS (
SELECT 1 FROM "PartyOpeningBalance" AS k WHERE k."personId" = ${keeperId}
)
`;
for (const table of tables) {
await tx.$executeRawUnsafe(
`UPDATE "${table}" SET "personId" = $1 WHERE "personId" = $2`,
keeperId,
dupId,
);
}
await tx.$executeRaw`
UPDATE "Lead" SET "convertedPersonId" = ${keeperId} WHERE "convertedPersonId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "MontifyLink"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'customer' AND "entityId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "Notification"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "GamificationTransaction"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "FinancialAuditLog"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
`;
}
export async function mergeSameMobilePeople(
prisma: PrismaClient,
organizationId: string,
apply: boolean,
) {
const people = await prisma.person.findMany({
where: { organizationId },
select: personSelect,
orderBy: { createdAt: 'asc' },
});
const groups = new Map<string, PersonRow[]>();
for (const person of people) {
const key = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
if (!key) continue;
const list = groups.get(key) ?? [];
list.push(person);
groups.set(key, list);
}
const duplicateGroups = [...groups.entries()].filter(([, list]) => list.length > 1);
const preview = duplicateGroups.map(([mobile, list]) => ({
mobile,
people: list.map((person) => ({
id: person.id,
stored: person.mobile,
name: `${person.firstName} ${person.lastName}`.trim(),
})),
}));
if (!apply) {
const needsRewrite = people.filter((person) => {
const canon = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
return Boolean(canon && (person.mobile !== canon || canonicalIranMobile(person.phone)));
}).length;
return { merged: 0, rewritten: 0, needsRewrite, failed: 0, groups: preview };
}
const tables = await personIdTables(prisma);
let merged = 0;
let rewritten = 0;
let failed = 0;
for (const [canon, list] of groups) {
const ordered = [...list].sort((a, b) => {
const aTahesab = a.importSource === 'tahesab' ? 0 : 1;
const bTahesab = b.importSource === 'tahesab' ? 0 : 1;
if (aTahesab !== bTahesab) return aTahesab - bTahesab;
return a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id);
});
const [keeper, ...extras] = ordered;
if (!keeper) continue;
if (!extras.length) {
const phoneCanon = canonicalIranMobile(keeper.phone);
const data: Prisma.PersonUpdateInput = {};
if (keeper.mobile !== canon) data.mobile = canon;
if (phoneCanon && keeper.phone !== phoneCanon) data.phone = phoneCanon;
if (Object.keys(data).length) {
await prisma.person.update({ where: { id: keeper.id }, data });
rewritten += 1;
}
continue;
}
try {
await prisma.$transaction(
async (tx) => {
await tx.person.update({
where: { id: keeper.id },
data: mergedFields(keeper, extras, canon),
});
for (const extra of extras) {
await moveLinks(tx, tables, keeper.id, extra.id);
await tx.person.delete({ where: { id: extra.id } });
merged += 1;
}
},
{ timeout: 60_000 },
);
} catch (error) {
failed += 1;
console.error(
`merge mobile ${canon} failed`,
error instanceof Error ? error.message : error,
);
}
}
return { merged, rewritten, needsRewrite: 0, failed, groups: preview };
}
function loadEnv(file: string) {
const text = readFileSync(file, 'utf8');
for (const line of text.split(/\r?\n/)) {
const match = line.match(/^([A-Za-z0-9_]+)=(.*)$/);
if (!match || process.env[match[1]]) continue;
let value = match[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[match[1]] = value;
}
}
const direct = process.argv[1]?.replace(/\\/g, '/').includes('merge-same-mobile');
if (direct) {
loadEnv(resolve(__dirname, '../../.env'));
const orgId = process.argv.find((arg) => arg.startsWith('--org='))?.slice(6);
const apply = process.argv.includes('--apply');
if (!orgId) {
console.error('org id required: --org=<id>');
process.exit(1);
}
const prisma = new PrismaClient();
mergeSameMobilePeople(prisma, orgId, apply)
.then((result) => {
console.log(JSON.stringify({
apply,
merged: result.merged,
rewritten: result.rewritten,
needsRewrite: result.needsRewrite,
groupCount: result.groups.length,
groups: result.groups,
}, null, 2));
})
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
}
+19 -62
View File
@@ -1,62 +1,19 @@
export function emptyToNull(value?: string | null): string | null { export function emptyToNull(value?: string | null): string | null {
if (value == null) return null; if (value == null) return null;
const trimmed = value.trim(); const trimmed = value.trim();
return trimmed.length ? trimmed : null; return trimmed.length ? trimmed : null;
} }
export function normalizeEmail(value?: string | null): string | null { export function normalizeEmail(value?: string | null): string | null {
const v = emptyToNull(value); const v = emptyToNull(value);
return v ? v.toLowerCase() : null; return v ? v.toLowerCase() : null;
} }
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹'; /** Keep digits and leading + for Iranian/international numbers. */
const AR_DIGITS = '٠١٢٣٤٥٦٧٨٩'; export function normalizePhone(value?: string | null): string | null {
const v = emptyToNull(value);
/** ارقام فارسی و عربی را به لاتین برمی‌گرداند تا از شماره حذف نشوند. */ if (!v) return null;
function latinDigits(value: string): string { const cleaned = v.replace(/[^\d+]/g, '');
return value.replace(/[۰-۹٠-٩]/g, (ch) => { return cleaned.length ? cleaned : null;
const fa = FA_DIGITS.indexOf(ch); }
return String(fa >= 0 ? fa : AR_DIGITS.indexOf(ch));
});
}
/** Keep digits and leading + for Iranian/international numbers. */
export function normalizePhone(value?: string | null): string | null {
const v = emptyToNull(value);
if (!v) return null;
const cleaned = latinDigits(v).replace(/[^\d+]/g, '');
return cleaned.length ? cleaned : null;
}
/** موبایل ایران به شکل 09xxxxxxxxx. 912… و 98912… و +98912… همان شماره‌اند. */
export function canonicalIranMobile(value?: string | null): string | null {
const n = normalizePhone(value);
if (!n) return null;
let d = n.replace(/\D/g, '');
if (!d) return null;
if (d.startsWith('0098') && d.length >= 14) d = d.slice(4);
else if (d.startsWith('98') && d.length >= 12) d = d.slice(2);
if (d.length === 10 && d.startsWith('9')) d = `0${d}`;
return /^09\d{9}$/.test(d) ? d : null;
}
/** شکل‌هایی که ممکن است همین موبایل با آن‌ها ذخیره شده باشد. */
export function iranMobileLookupKeys(value?: string | null): string[] {
const canon = canonicalIranMobile(value);
if (!canon) return [];
const core = canon.slice(1);
return [canon, core, `98${core}`, `+98${core}`, `0098${core}`];
}
/** برای تطبیق: اگر موبایل ایران است همه شکل‌ها، وگرنه همان متن نرمال‌شده. */
export function phoneIdentityKeys(value?: string | null): string[] {
const keys = iranMobileLookupKeys(value);
if (keys.length) return keys;
const n = normalizePhone(value);
return n ? [n] : [];
}
/** ذخیره: موبایل ایران همیشه 09… ؛ بقیه شماره‌ها فقط تمیز می‌شوند. */
export function storedPhoneNumber(value?: string | null): string | null {
return canonicalIranMobile(value) ?? normalizePhone(value);
}
+2 -12
View File
@@ -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')
+51 -315
View File
@@ -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 {
-12
View File
@@ -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)
-1
View File
@@ -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}`);
}
}
}
-110
View File
@@ -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