Compare commits
150
Commits
6b4bd0b03e
...
v0.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ca9540524 | ||
|
|
dc691c5192 | ||
|
|
1ef7f91771 | ||
|
|
94f0383c79 | ||
|
|
c976c0bcd6 | ||
|
|
57a0b379f6 | ||
|
|
7f6d9a84d6 | ||
|
|
6abbf8e8fd | ||
|
|
39cb5c2548 | ||
|
|
004d1dff93 | ||
|
|
78a7d6a68a | ||
|
|
5b18749005 | ||
|
|
08a278e1c8 | ||
|
|
d71c2dbef4 | ||
|
|
1ccbefa7a3 | ||
|
|
14f4e9d7d8 | ||
|
|
52edccf74e | ||
|
|
bffe174162 | ||
|
|
35d74d33ed | ||
|
|
c337b0b4c7 | ||
|
|
c26353624e | ||
|
|
534dae45b2 | ||
|
|
79733e6c96 | ||
|
|
fdabdc54a3 | ||
|
|
1b10ee30df | ||
|
|
c273dd6c58 | ||
|
|
acb5d51f24 | ||
|
|
06977d27ed | ||
|
|
43437b0280 | ||
|
|
28938e8f77 | ||
|
|
e6e90aabf3 | ||
|
|
a0936bf19f | ||
|
|
18a7cd3b5a | ||
|
|
4af248b06a | ||
|
|
d5dd13863a | ||
|
|
81edf61065 | ||
|
|
8e1e94a439 | ||
|
|
590f8114cc | ||
|
|
8d01bce0c2 | ||
|
|
8b2777be4e | ||
|
|
d7b5085096 | ||
|
|
cbc2e4ee8a | ||
|
|
a17c7dbabc | ||
|
|
99d686fefb | ||
|
|
b9bb2ff9fa | ||
|
|
0d5e1034e5 | ||
|
|
6f42440b3b | ||
|
|
63a1803adc | ||
|
|
2cc2d358e4 | ||
|
|
463a1652a6 | ||
|
|
f15c5b9141 | ||
|
|
1d3588a69d | ||
|
|
9812e4e5ba | ||
|
|
8ae6a928f4 | ||
|
|
2ff35e55fc | ||
|
|
15f89aec04 | ||
|
|
bebd1be29b | ||
|
|
0daaef0b39 | ||
|
|
91637cb3a6 | ||
|
|
546a7a7e46 | ||
|
|
48815852fc | ||
|
|
f8128ebe3a | ||
|
|
6ec7a1a942 | ||
|
|
2d690b4c04 | ||
|
|
1ba352f127 | ||
|
|
f50246dc46 | ||
|
|
7d9fc8447f | ||
|
|
5be0bc6f5e | ||
|
|
2ca2650b69 | ||
|
|
42b3fc72cc | ||
|
|
6d8ede8e2a | ||
|
|
ade43fea0f | ||
|
|
65cf4e9a66 | ||
|
|
80fb5bf1bb | ||
|
|
31f45a6a31 | ||
|
|
73c87b8be4 | ||
|
|
09a60b3b5a | ||
|
|
cb5944e767 | ||
|
|
23ae273232 | ||
|
|
1c2da34983 | ||
|
|
50d7cad66e | ||
|
|
29d99cbded | ||
|
|
ce27df20ca | ||
|
|
7ae6a5837a | ||
|
|
631a3267d1 | ||
|
|
b731fe43aa | ||
|
|
15cbd62997 | ||
|
|
e6aa4c64f0 | ||
|
|
9127fa334b | ||
|
|
9a86f63f48 | ||
|
|
29398a445b | ||
|
|
bb8fd489de | ||
|
|
86e9ee6c2c | ||
|
|
adb63447ca | ||
|
|
c1b38e1737 | ||
|
|
be7ae0a1d8 | ||
|
|
bbbedc6059 | ||
|
|
f5d68d83fc | ||
|
|
f15ebb72ea | ||
|
|
8590579605 | ||
|
|
3863c5e7d1 | ||
|
|
e6f9876177 | ||
|
|
fddf22d7fe | ||
|
|
ada9a1f21f | ||
|
|
2f11105628 | ||
|
|
36eda8653e | ||
|
|
8fdd5147a5 | ||
|
|
002f697dfe | ||
|
|
96befd3867 | ||
|
|
b25b5b125a | ||
|
|
4abab55a6b | ||
|
|
8981a552fc | ||
|
|
ab538b8304 | ||
|
|
61d17c5cea | ||
|
|
23f8da2c65 | ||
|
|
9325d879a5 | ||
|
|
d62e004e90 | ||
|
|
32c722f42a | ||
|
|
f7b07f6b7a | ||
|
|
52c7306e78 | ||
|
|
960e01938d | ||
|
|
3fb5f32ac3 | ||
|
|
0ce2533066 | ||
|
|
bc68582fe2 | ||
|
|
e86841fc60 | ||
|
|
5069647f79 | ||
|
|
008733d338 | ||
|
|
c5708ed59b | ||
|
|
65d66bb331 | ||
|
|
79ba146118 | ||
|
|
02841a5b8c | ||
|
|
a04743c6df | ||
|
|
27635c0752 | ||
|
|
03e1c01023 | ||
|
|
177e5cc31b | ||
|
|
2c7c943802 | ||
|
|
9611803567 | ||
|
|
a494b5308e | ||
|
|
582733e2ea | ||
|
|
6bff9d2888 | ||
|
|
6bc5e1bec7 | ||
|
|
3a8a684aa4 | ||
|
|
831c682eb3 | ||
|
|
2cc81bb596 | ||
|
|
c04582072d | ||
|
|
42f0573a5f | ||
|
|
13ee70764b | ||
|
|
cf326d1ed9 | ||
|
|
5f4cc47c41 | ||
|
|
0e420a443f |
@@ -0,0 +1,85 @@
|
||||
---
|
||||
description: Hard governance for Ant Design Vue v4 migration — isolated page-by-page, no manual CSS, 24-col grid, default controlHeight 36
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Ant Design Vue v4 — Strict Governance (locked)
|
||||
|
||||
Source of truth for migrated UI: **Ant Design Vue 4** + central theme [`apps/web/src/theme/antdTheme.ts`](apps/web/src/theme/antdTheme.ts).
|
||||
|
||||
Legacy CRM UI (`components/ui/*`, `.crm-*`, `.btn` / `.field` in `styles/`) remains for **unmigrated** screens only. Never mix systems inside one page.
|
||||
|
||||
## Hard rules
|
||||
|
||||
1. **Single-page migration lock**
|
||||
Migrate **exactly one** route/view (or one clearly named page folder) per change set. Do not “clean up” neighboring pages, shared legacy primitives, or global CSS while migrating a page.
|
||||
|
||||
2. **No edits to legacy kit during Ant work**
|
||||
Do **not** modify `apps/web/src/components/ui/**` or broaden changes across `views/` beyond the single page under migration, unless the user explicitly names that page.
|
||||
|
||||
2b. **Child components of a migrated View (mandatory)**
|
||||
هنگام بازنویسی یک صفحه (View)، ایجنت موظف است علاوه بر خود فایل View، تمام کامپوننتهای فرزندی را که در آن View ایمپورت شدهاند و حاوی اینپوت/سلکت/دکمه هستند نیز بازبینی کرده و درون آنها را به کامپوننتهای رسمی `ant-design-vue` تبدیل کند. هیچ تگ `<input>`, `<select>`, `<textarea>`, `<button>` خامی نباید در صفحه یا کامپوننتهای فرزندِ مصرفی آن باقی بماند.
|
||||
When rewriting a View, the agent **must** also review every component imported by that View that contains inputs/selects/buttons, and convert their internals to official `ant-design-vue` controls.
|
||||
**No raw** `<input>`, `<select>`, `<textarea>`, or `<button>` may remain in the View **or** in those consumed child components. Prefer native Ant equivalents (`a-select` / `mode="multiple|tags"` / `a-time-picker` / …) over custom pickers when logic allows; if domain logic is required (e.g. Jalali), convert that child file to Ant chrome (`a-input` + `a-popover`, etc.) instead of leaving raw HTML. Do **not** leave legacy `UiInput` / `UiSelect` / `UiButton` / `UiField` / `UiTextarea` in the View or those children.
|
||||
|
||||
3. **Ant-only UI on migrated pages**
|
||||
On a migrated page, build controls with Ant Design Vue (`a-button`, `a-form`, `a-table`, `a-card`, …) and `@ant-design/icons-vue`. Do **not** introduce new usage of `UiButton`, `UiInput`, `.btn`, `.field`, or ad-hoc HTML form controls for the same job.
|
||||
|
||||
4. **Ban manual CSS for layout/look**
|
||||
On migrated pages:
|
||||
- No new `<style scoped>` rules for color, spacing, radius, shadow, typography, or layout that Ant already covers.
|
||||
- No hardcoded hex / `rgb()` for chrome UI (charts/data palettes may be excepted when not chrome).
|
||||
- Prefer ConfigProvider theme tokens, component props, and Ant space/grid utilities.
|
||||
- Inline `style` only for rare one-off values that tokens cannot express; prefer tokens first.
|
||||
|
||||
5. **Default Ant control size (`controlHeight: 36`) — no manual `size`**
|
||||
- Global default comes from theme token `controlHeight: 36` in `antdTheme.ts` (Ant derives SM/LG from this).
|
||||
- Do **not** set `size="small"`, `size="middle"`, `size="large"`, or `:size` on `a-button`, `a-input`, `a-select`, `a-input-number`, `a-date-picker`, `a-radio-group`, `a-pagination`, or similar controls on migrated pages.
|
||||
- Do **not** override `controlHeight` / `controlHeightSM` / `controlHeightLG` inside `components.*` of the theme — inherit the token default.
|
||||
- Exception: only if the user explicitly requests a one-off non-default size for a named control.
|
||||
|
||||
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.
|
||||
|
||||
7. **RTL + locale**
|
||||
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).
|
||||
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
10. **Icons**
|
||||
Use `@ant-design/icons-vue` on migrated pages. Do not expand `UiIcon` for Ant pages.
|
||||
|
||||
11. **Isolation guarantee**
|
||||
Infrastructure PRs (deps, vite plugin, theme, App `ConfigProvider`) must not change behavior of unmigrated pages. No `ant-design-vue/dist/reset.css` (or similar global reset) unless explicitly approved — it can break legacy CSS.
|
||||
|
||||
## Allowed on migrated pages
|
||||
|
||||
- Ant Design Vue 4 components and their props/slots (except banned manual `size` on controls)
|
||||
- `a-row` / `a-col` (24 columns), `a-space`, `a-flex` where appropriate
|
||||
- Theme tokens via ConfigProvider
|
||||
- Minimal page-local script logic (data fetching, forms) unchanged in spirit
|
||||
|
||||
## Forbidden on migrated pages
|
||||
|
||||
- New legacy `components/ui` usage
|
||||
- Parallel button/input CSS classes (`.btn`, `.field`, `.crm-*` for chrome that Ant replaces)
|
||||
- Custom CSS that re-skins Ant components
|
||||
- Manual `size` props on form/action controls (use theme default 36px)
|
||||
- Multi-page refactors “while we’re here”
|
||||
- Adding Tailwind/Bootstrap/other UI kits
|
||||
|
||||
## Agent checklist before finishing a migration PR
|
||||
|
||||
- [ ] Only one page/route touched for UI migration
|
||||
- [ ] Imported child components with controls also converted to Ant (no raw HTML left)
|
||||
- [ ] Grep verification: `<input|<select|<textarea|<button|UiInput|UiSelect|UiField|UiTextarea|UiButton` count is **0** in the View + those children
|
||||
- [ ] No changes under `components/ui/`
|
||||
- [ ] No new scoped CSS for look-and-feel
|
||||
- [ ] No manual `size` on Ant controls
|
||||
- [ ] Layout uses 24-col Ant grid
|
||||
- [ ] Uses `antdTheme` / ConfigProvider / StyleProvider `hash-priority="high"` — no local theme fork
|
||||
- [ ] `npm run build -w @mento/web` (or `vue-tsc`) passes
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: antd-system-architect
|
||||
description: >-
|
||||
Architect and migrate Montact web UI to Ant Design Vue v4 under strict
|
||||
governance: single-page lock, no manual CSS, no manual size props, default
|
||||
controlHeight 36, 24-column grid, central antdTheme, isolated from legacy
|
||||
components/ui. Use when migrating a page to Ant Design, adding Ant components,
|
||||
editing antdTheme, or reviewing Ant PRs.
|
||||
---
|
||||
|
||||
# Ant Design Vue System Architect (Montact)
|
||||
|
||||
## When to use
|
||||
|
||||
- Migrating any `apps/web` view/page to Ant Design Vue 4
|
||||
- Extending [`apps/web/src/theme/antdTheme.ts`](../../../apps/web/src/theme/antdTheme.ts)
|
||||
- Touching Ant ConfigProvider / vite Ant auto-import
|
||||
- Reviewing whether a change violates isolation from legacy UI
|
||||
|
||||
Also read and obey [`.cursor/rules/antd-strict-governance.mdc`](../../rules/antd-strict-governance.mdc).
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
1. **One page per migration** — never batch unrelated views.
|
||||
2. **Do not edit** `apps/web/src/components/ui/**` during Ant migration work.
|
||||
3. **Child components of the View are in scope (mandatory)** — هنگام بازنویسی یک صفحه (View)، ایجنت موظف است علاوه بر خود فایل View، تمام کامپوننتهای فرزندی را که در آن View ایمپورت شدهاند و حاوی اینپوت/سلکت/دکمه هستند نیز بازبینی کرده و درون آنها را به کامپوننتهای رسمی `ant-design-vue` تبدیل کند. هیچ تگ `<input>`, `<select>`, `<textarea>`, `<button>` خامی نباید در صفحه یا کامپوننتهای فرزندِ مصرفی آن باقی بماند. When rewriting a View, also open every imported child that renders inputs/selects/buttons and convert them to official `ant-design-vue` controls. No raw `<input>`, `<select>`, `<textarea>`, or `<button>` (and no `UiInput` / `UiSelect` / `UiButton` / `UiField` / `UiTextarea`) may remain in the View or those children. Prefer Ant natives (`a-select` multiple/tags, `a-time-picker`, …); for Jalali/domain pickers, convert the child file to Ant chrome (`a-input` + `a-popover`, etc.).
|
||||
4. **No manual look CSS** on migrated pages (no new scoped colors/spacing/radius/shadows for chrome).
|
||||
5. **24-column Ant grid** (`a-row` / `a-col`) for page layout.
|
||||
6. **Theme only in** `apps/web/src/theme/antdTheme.ts`.
|
||||
7. **Default control height is 36** via token `controlHeight: 36`. Do **not** pass `size` / `:size` on buttons, inputs, selects, or similar controls; do **not** set per-component `controlHeight*` in the theme `components` map.
|
||||
8. **Auto-import** `<a-*>` via `AntDesignVueResolver({ importStyle: false })` — no per-file style imports. Root uses `StyleProvider` with `hash-priority="high"` so legacy `main.css` cannot override Ant.
|
||||
9. **No global Ant reset CSS** that would restyle legacy pages.
|
||||
|
||||
## Stack facts (this repo)
|
||||
|
||||
| Piece | Location |
|
||||
|---|---|
|
||||
| Dependency | `ant-design-vue` ^4, `@ant-design/icons-vue`, `unplugin-vue-components` in `@mento/web` |
|
||||
| Vite plugin | `apps/web/vite.config.ts` — `Components` + `AntDesignVueResolver` |
|
||||
| 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">` |
|
||||
| Legacy kit (do not touch) | `apps/web/src/components/ui/*`, `styles/main.css` utilities |
|
||||
|
||||
## Migration workflow
|
||||
|
||||
1. Confirm the **single** route/view the user named.
|
||||
2. Inventory current UI: legacy `Ui*`, `.btn`/`.field`, `.crm-*`, custom tables.
|
||||
3. List all imported children that contain controls; convert them in the same pass (except `components/ui/**`).
|
||||
4. Replace with Ant equivalents:
|
||||
- Button → `a-button` (**no** `size` prop)
|
||||
- Input/Select/Form field → `a-form` + `a-form-item` + `a-input` / `a-select` / … (**no** `size`)
|
||||
- Modal/Drawer → `a-modal` / `a-drawer`
|
||||
- Table → `a-table` (columns via Ant API)
|
||||
- Page chrome → `a-card`, title + `a-space`, layout via `a-row`/`a-col`
|
||||
- Icons → `@ant-design/icons-vue`
|
||||
5. Remove page-local CSS that only existed to skin the old controls.
|
||||
6. Keep business logic, API calls, and router behavior intact.
|
||||
7. Grep-verify zero hits for `<input|<select|<textarea|<button|UiInput|UiSelect|UiField|UiTextarea|UiButton` in the View + converted children.
|
||||
8. Run `npm run build -w @mento/web` (or at least `vue-tsc -b` in `apps/web`).
|
||||
9. Smoke-check that **other** routes still render with legacy UI unchanged.
|
||||
|
||||
## Theme extension rules
|
||||
|
||||
- Change tokens in `antdTheme.ts` only when the user asks or when a migrated page proves a missing token.
|
||||
- Keep `controlHeight: 36` as the single source for control sizing; let Ant derive SM/LG.
|
||||
- Prefer `token` and limited `components.Card` / `components.Form` overrides — never per-control `controlHeight` forks.
|
||||
- Do not duplicate `ThemeConfig` objects inside views.
|
||||
|
||||
## Anti-patterns (reject)
|
||||
|
||||
- Importing both `UiButton` and `a-button` on the same migrated page
|
||||
- Adding `ant-design-vue/dist/reset.css` “to fix spacing”
|
||||
- Editing `components/ui` to “look more like Ant”
|
||||
- `size="small|middle|large"` on Ant controls
|
||||
- Multi-page visual refactors in one PR
|
||||
- Custom CSS overriding `.ant-btn` / `.ant-input` selectors
|
||||
|
||||
## Done criteria
|
||||
|
||||
- Migrated page uses Ant components + 24-col grid
|
||||
- Imported control-bearing children also Ant-pure (grep = 0)
|
||||
- Controls use theme default height (no manual `size`)
|
||||
- No new manual chrome CSS
|
||||
- Legacy kit untouched
|
||||
- Build/typecheck clean
|
||||
- Unmigrated pages untouched in the diff
|
||||
@@ -0,0 +1,15 @@
|
||||
.git
|
||||
node_modules
|
||||
**/dist
|
||||
**/.env
|
||||
**/.env.*
|
||||
apps/stt/.venv
|
||||
uploads
|
||||
docs
|
||||
e2e
|
||||
test-results
|
||||
playwright-report
|
||||
graphify-out
|
||||
.worktrees
|
||||
*.md
|
||||
crm-node_modules.tgz
|
||||
@@ -0,0 +1,131 @@
|
||||
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
|
||||
if: false
|
||||
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
|
||||
if: false
|
||||
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}"
|
||||
@@ -32,3 +32,7 @@ playwright-report/
|
||||
graphify-out/
|
||||
**/graphify-out/
|
||||
.cursor/rules/graphify.mdc
|
||||
|
||||
# Local git worktrees + SDD scratch
|
||||
.worktrees/
|
||||
.superpowers/
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# Task 5 report
|
||||
Status: DONE
|
||||
- shared build: OK
|
||||
- pipeline-merge.test.ts: 5/5 pass
|
||||
- api typecheck: OK
|
||||
- vue-tsc: see VUE_EXIT in controller log
|
||||
- graphify: attempted (may crash)
|
||||
No commit required for verification-only task.
|
||||
|
||||
## Follow-up fix: final review important findings
|
||||
Status: DONE
|
||||
- I-1: Added in-transaction unmoved-deal count guard before deleting the source pipeline.
|
||||
- I-3: Added 30000ms timeout to the interactive merge transaction.
|
||||
- M-1: Added `@IsIn(['retarget_all'])` validation to `MergePipelineDto.leadFormMode`.
|
||||
- graphify update: OK
|
||||
- `cd apps/api; npx tsx --test test/pipeline-merge.test.ts`: OK, 5/5 pass
|
||||
- `npm run typecheck -w @mento/api`: OK
|
||||
@@ -0,0 +1,29 @@
|
||||
# NestJS API + Prisma. Build context is the monorepo root.
|
||||
FROM node:20-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY apps/api/package.json apps/api/package.json
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/shared/package.json packages/shared/package.json
|
||||
RUN npm ci
|
||||
COPY apps/api apps/api
|
||||
COPY packages/shared packages/shared
|
||||
RUN npm run build -w @mento/shared \
|
||||
&& npm run prisma:generate -w @mento/api \
|
||||
&& npm run build -w @mento/api
|
||||
|
||||
FROM node:20-bookworm-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /app/package.json /app/package-lock.json ./
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/packages/shared ./packages/shared
|
||||
COPY --from=build /app/apps/api/package.json ./apps/api/package.json
|
||||
COPY --from=build /app/apps/api/dist ./apps/api/dist
|
||||
COPY --from=build /app/apps/api/prisma ./apps/api/prisma
|
||||
WORKDIR /app/apps/api
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/src/main.js"]
|
||||
@@ -0,0 +1,15 @@
|
||||
# Local Faster-Whisper. Not published on Ingress.
|
||||
FROM python:3.11-slim-bookworm
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY apps/stt/requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY apps/stt/main.py ./main.py
|
||||
ENV STT_MODEL=small \
|
||||
STT_DEVICE=cpu \
|
||||
STT_COMPUTE_TYPE=int8 \
|
||||
HF_HOME=/models
|
||||
EXPOSE 3040
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3040"]
|
||||
@@ -0,0 +1,16 @@
|
||||
# Vue panel. The built app calls /api on the same host; Traefik sends that to the API.
|
||||
FROM node:20-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY apps/api/package.json apps/api/package.json
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/shared/package.json packages/shared/package.json
|
||||
RUN npm ci
|
||||
COPY apps/web apps/web
|
||||
COPY packages/shared packages/shared
|
||||
RUN npm run build -w @mento/shared && npm run build -w @mento/web
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY docker/web.nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
@@ -12,7 +12,7 @@
|
||||
"prisma:seed": "tsx prisma/seed.ts",
|
||||
"lint": "echo \"api lint placeholder\"",
|
||||
"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/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": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts test/behavior.test.ts test/attendance-time.test.ts test/sales-factors.test.ts test/platform-admin-alert.test.ts test/person-name.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mento/shared": "*",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Person" ADD COLUMN "birthDate" TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Lead" ADD COLUMN "birthDate" TIMESTAMP(3);
|
||||
@@ -0,0 +1,20 @@
|
||||
ALTER TABLE "Organization" ADD COLUMN "deletionRetention" TEXT NOT NULL DEFAULT 'never';
|
||||
|
||||
CREATE TABLE "DeletionArchive" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"model" TEXT NOT NULL,
|
||||
"entityId" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"snapshot" JSONB NOT NULL,
|
||||
"deletedById" TEXT,
|
||||
"deletedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "DeletionArchive_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "DeletionArchive_organizationId_deletedAt_idx" ON "DeletionArchive"("organizationId", "deletedAt");
|
||||
CREATE INDEX "DeletionArchive_organizationId_model_idx" ON "DeletionArchive"("organizationId", "model");
|
||||
|
||||
ALTER TABLE "DeletionArchive" ADD CONSTRAINT "DeletionArchive_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "DeletionArchive" ADD CONSTRAINT "DeletionArchive_deletedById_fkey" FOREIGN KEY ("deletedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- بایگانی اطلاعات قبل: مرز محیط، مجوز عضو، و منبع فعالیت
|
||||
ALTER TABLE "Organization" ADD COLUMN "priorArchiveEnabledAt" TIMESTAMP(3);
|
||||
|
||||
ALTER TABLE "Membership" ADD COLUMN "priorArchiveEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Membership" ADD COLUMN "priorArchiveLens" TEXT NOT NULL DEFAULT 'current';
|
||||
|
||||
ALTER TABLE "Activity" ADD COLUMN "importSource" TEXT NOT NULL DEFAULT 'manual';
|
||||
|
||||
CREATE INDEX "Activity_organizationId_importSource_idx" ON "Activity"("organizationId", "importSource");
|
||||
|
||||
-- فعالیت وصل به معاملهٔ دیدار از همین حالا قبلی است. بقیه بعد از روشن شدن ماژول و با تاریخ مرز پر میشوند.
|
||||
UPDATE "Activity" AS a
|
||||
SET "importSource" = 'didar'
|
||||
WHERE a."importSource" = 'manual'
|
||||
AND a."dealId" IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "Deal" AS d
|
||||
WHERE d."id" = a."dealId" AND d."importSource" = 'didar'
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Prior archive: promote tag + cutoff query indexes
|
||||
ALTER TABLE "Deal" ADD COLUMN "promotedFromPriorAt" TIMESTAMP(3);
|
||||
|
||||
ALTER TABLE "Activity" ADD COLUMN "promotedFromPriorAt" TIMESTAMP(3);
|
||||
|
||||
CREATE INDEX "Deal_organizationId_createdAt_idx" ON "Deal"("organizationId", "createdAt");
|
||||
|
||||
CREATE INDEX "Activity_organizationId_createdAt_idx" ON "Activity"("organizationId", "createdAt");
|
||||
@@ -0,0 +1,35 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "OutboundSmsLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"personId" TEXT,
|
||||
"leadId" TEXT,
|
||||
"mobile" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"sourceId" TEXT,
|
||||
"status" TEXT NOT NULL,
|
||||
"error" TEXT,
|
||||
"actorUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "OutboundSmsLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OutboundSmsLog_organizationId_personId_createdAt_idx" ON "OutboundSmsLog"("organizationId", "personId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OutboundSmsLog_organizationId_leadId_createdAt_idx" ON "OutboundSmsLog"("organizationId", "leadId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OutboundSmsLog_organizationId_createdAt_idx" ON "OutboundSmsLog"("organizationId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OutboundSmsLog" ADD CONSTRAINT "OutboundSmsLog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OutboundSmsLog" ADD CONSTRAINT "OutboundSmsLog_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OutboundSmsLog" ADD CONSTRAINT "OutboundSmsLog_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,96 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "FinancialAccount" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"bankName" TEXT,
|
||||
"accountRef" TEXT,
|
||||
"responsibleName" TEXT,
|
||||
"openingBalance" DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||
"openingDate" TIMESTAMP(3) NOT NULL,
|
||||
"notes" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"currency" TEXT NOT NULL DEFAULT 'IRR',
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "FinancialAccount_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "FinancialCategory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "FinancialCategory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "FinancialDocument" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"amount" DECIMAL(18,2) NOT NULL,
|
||||
"bookedAt" TIMESTAMP(3) NOT NULL,
|
||||
"dueAt" TIMESTAMP(3),
|
||||
"categoryId" TEXT,
|
||||
"personId" TEXT,
|
||||
"partyName" TEXT,
|
||||
"accountId" TEXT,
|
||||
"method" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'open',
|
||||
"reference" TEXT,
|
||||
"notes" TEXT,
|
||||
"createdById" TEXT,
|
||||
"voidedAt" TIMESTAMP(3),
|
||||
"voidReason" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "FinancialDocument_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "FinancialMovement" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"documentId" TEXT,
|
||||
"direction" TEXT NOT NULL,
|
||||
"amount" DECIMAL(18,2) NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"transferGroupId" TEXT,
|
||||
"occurredAt" TIMESTAMP(3) NOT NULL,
|
||||
"memo" TEXT,
|
||||
"reference" TEXT,
|
||||
"createdById" TEXT,
|
||||
"voidedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "FinancialMovement_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "FinancialAccount_organizationId_isActive_idx" ON "FinancialAccount"("organizationId", "isActive");
|
||||
CREATE UNIQUE INDEX "FinancialCategory_organizationId_kind_name_key" ON "FinancialCategory"("organizationId", "kind", "name");
|
||||
CREATE INDEX "FinancialCategory_organizationId_kind_idx" ON "FinancialCategory"("organizationId", "kind");
|
||||
CREATE INDEX "FinancialDocument_organizationId_kind_bookedAt_idx" ON "FinancialDocument"("organizationId", "kind", "bookedAt");
|
||||
CREATE INDEX "FinancialDocument_organizationId_status_idx" ON "FinancialDocument"("organizationId", "status");
|
||||
CREATE INDEX "FinancialDocument_personId_idx" ON "FinancialDocument"("personId");
|
||||
CREATE INDEX "FinancialMovement_organizationId_accountId_occurredAt_idx" ON "FinancialMovement"("organizationId", "accountId", "occurredAt");
|
||||
CREATE INDEX "FinancialMovement_transferGroupId_idx" ON "FinancialMovement"("transferGroupId");
|
||||
CREATE INDEX "FinancialMovement_documentId_idx" ON "FinancialMovement"("documentId");
|
||||
|
||||
ALTER TABLE "FinancialAccount" ADD CONSTRAINT "FinancialAccount_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialCategory" ADD CONSTRAINT "FinancialCategory_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FinancialCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "FinancialAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "FinancialAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "FinancialDocument"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,97 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Person" ADD COLUMN "discLabel" TEXT,
|
||||
ADD COLUMN "discReliable" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "discAssessedAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Person_organizationId_discLabel_idx" ON "Person"("organizationId", "discLabel");
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DiscAssessment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"personId" TEXT NOT NULL,
|
||||
"assessorId" TEXT NOT NULL,
|
||||
"answers" JSONB NOT NULL,
|
||||
"scoreD" INTEGER NOT NULL,
|
||||
"scoreI" INTEGER NOT NULL,
|
||||
"scoreS" INTEGER NOT NULL,
|
||||
"scoreC" INTEGER NOT NULL,
|
||||
"percentD" INTEGER NOT NULL,
|
||||
"percentI" INTEGER NOT NULL,
|
||||
"percentS" INTEGER NOT NULL,
|
||||
"percentC" INTEGER NOT NULL,
|
||||
"validCount" INTEGER NOT NULL,
|
||||
"primaryTypes" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"secondaryTypes" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"label" TEXT NOT NULL,
|
||||
"secondaryLabel" TEXT,
|
||||
"reliable" BOOLEAN NOT NULL,
|
||||
"confidence" TEXT NOT NULL,
|
||||
"note" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'confirmed',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "DiscAssessment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SalesCopilotSession" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"personId" TEXT,
|
||||
"leadId" TEXT,
|
||||
"dealId" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
"topic" TEXT NOT NULL,
|
||||
"goal" TEXT NOT NULL,
|
||||
"customerStatement" TEXT NOT NULL,
|
||||
"behaviorNotes" TEXT,
|
||||
"analysis" JSONB NOT NULL,
|
||||
"confirmedResponse" TEXT,
|
||||
"nextAction" TEXT,
|
||||
"followUpAt" TIMESTAMP(3),
|
||||
"callOutcome" TEXT,
|
||||
"agentNote" TEXT,
|
||||
"status" TEXT NOT NULL,
|
||||
"messageDraft" TEXT,
|
||||
"activityId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SalesCopilotSession_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DiscAssessment_organizationId_personId_createdAt_idx" ON "DiscAssessment"("organizationId", "personId", "createdAt");
|
||||
CREATE INDEX "DiscAssessment_personId_idx" ON "DiscAssessment"("personId");
|
||||
CREATE INDEX "SalesCopilotSession_organizationId_personId_createdAt_idx" ON "SalesCopilotSession"("organizationId", "personId", "createdAt");
|
||||
CREATE INDEX "SalesCopilotSession_organizationId_leadId_idx" ON "SalesCopilotSession"("organizationId", "leadId");
|
||||
CREATE INDEX "SalesCopilotSession_dealId_idx" ON "SalesCopilotSession"("dealId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DiscAssessment" ADD CONSTRAINT "DiscAssessment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "DiscAssessment" ADD CONSTRAINT "DiscAssessment_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "DiscAssessment" ADD CONSTRAINT "DiscAssessment_assessorId_fkey" FOREIGN KEY ("assessorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- ماژول برای محیطهایی که از قبل ماژول دارند روشن میشود و در سوپرادمین قابل خاموش کردن است
|
||||
UPDATE "Organization"
|
||||
SET "enabledModules" = array_append("enabledModules", 'behavior')
|
||||
WHERE cardinality("enabledModules") > 0
|
||||
AND NOT ('behavior' = ANY("enabledModules"));
|
||||
|
||||
-- مجوز رفتار برای نقشهایی که فروش دارند؛ پشتیبانی بدون این کلید میماند
|
||||
UPDATE "OrgRole"
|
||||
SET "permissions" = array_append("permissions", 'behavior')
|
||||
WHERE NOT ('behavior' = ANY("permissions"))
|
||||
AND (
|
||||
"systemKey" IN ('owner', 'admin', 'sales')
|
||||
OR 'sales' = ANY("permissions")
|
||||
);
|
||||
@@ -0,0 +1,42 @@
|
||||
ALTER TABLE "FinancialMovement" ADD COLUMN "receivableId" TEXT;
|
||||
ALTER TABLE "FinancialMovement" ADD COLUMN "installmentId" TEXT;
|
||||
|
||||
CREATE TABLE "FinancialReceivable" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"personId" TEXT,
|
||||
"partyName" TEXT,
|
||||
"totalAmount" DECIMAL(18,2) NOT NULL,
|
||||
"notes" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'open',
|
||||
"createdById" TEXT,
|
||||
"voidedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "FinancialReceivable_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "FinancialInstallment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"receivableId" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL,
|
||||
"dueDate" TIMESTAMP(3) NOT NULL,
|
||||
"amount" DECIMAL(18,2) NOT NULL,
|
||||
"reminderNotifiedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "FinancialInstallment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "FinancialMovement_receivableId_idx" ON "FinancialMovement"("receivableId");
|
||||
CREATE INDEX "FinancialMovement_installmentId_idx" ON "FinancialMovement"("installmentId");
|
||||
CREATE INDEX "FinancialReceivable_organizationId_status_idx" ON "FinancialReceivable"("organizationId", "status");
|
||||
CREATE INDEX "FinancialReceivable_personId_idx" ON "FinancialReceivable"("personId");
|
||||
CREATE UNIQUE INDEX "FinancialInstallment_receivableId_position_key" ON "FinancialInstallment"("receivableId", "position");
|
||||
CREATE INDEX "FinancialInstallment_dueDate_idx" ON "FinancialInstallment"("dueDate");
|
||||
|
||||
ALTER TABLE "FinancialReceivable" ADD CONSTRAINT "FinancialReceivable_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialReceivable" ADD CONSTRAINT "FinancialReceivable_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialInstallment" ADD CONSTRAINT "FinancialInstallment_receivableId_fkey" FOREIGN KEY ("receivableId") REFERENCES "FinancialReceivable"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_receivableId_fkey" FOREIGN KEY ("receivableId") REFERENCES "FinancialReceivable"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_installmentId_fkey" FOREIGN KEY ("installmentId") REFERENCES "FinancialInstallment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,79 @@
|
||||
ALTER TABLE "FinancialMovement" ADD COLUMN "payrollId" TEXT;
|
||||
CREATE INDEX "FinancialMovement_payrollId_idx" ON "FinancialMovement"("payrollId");
|
||||
|
||||
CREATE TABLE "FinancialPayroll" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"period" TEXT NOT NULL,
|
||||
"baseAmount" DECIMAL(18,2) NOT NULL,
|
||||
"benefits" DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||
"bonus" DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||
"deductions" DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||
"payable" DECIMAL(18,2) NOT NULL,
|
||||
"dueDate" TIMESTAMP(3) NOT NULL,
|
||||
"notes" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'open',
|
||||
"createdById" TEXT,
|
||||
"voidedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "FinancialPayroll_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "FinancialRecurringItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"amount" DECIMAL(18,2) NOT NULL,
|
||||
"interval" TEXT NOT NULL,
|
||||
"startDate" TIMESTAMP(3) NOT NULL,
|
||||
"endDate" TIMESTAMP(3),
|
||||
"nextDue" TIMESTAMP(3) NOT NULL,
|
||||
"accountId" TEXT,
|
||||
"categoryId" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "FinancialRecurringItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "FinancialRecurringOccurrence" (
|
||||
"id" TEXT NOT NULL,
|
||||
"itemId" TEXT NOT NULL,
|
||||
"periodKey" TEXT NOT NULL,
|
||||
"dueDate" TIMESTAMP(3) NOT NULL,
|
||||
"documentId" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "FinancialRecurringOccurrence_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "FinancialReconciliation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"reconciledAt" TIMESTAMP(3) NOT NULL,
|
||||
"statedBalance" DECIMAL(18,2) NOT NULL,
|
||||
"systemBalance" DECIMAL(18,2) NOT NULL,
|
||||
"difference" DECIMAL(18,2) NOT NULL,
|
||||
"notes" TEXT,
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "FinancialReconciliation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "FinancialPayroll_organizationId_userId_period_key" ON "FinancialPayroll"("organizationId", "userId", "period");
|
||||
CREATE INDEX "FinancialPayroll_organizationId_status_idx" ON "FinancialPayroll"("organizationId", "status");
|
||||
CREATE INDEX "FinancialRecurringItem_organizationId_isActive_nextDue_idx" ON "FinancialRecurringItem"("organizationId", "isActive", "nextDue");
|
||||
CREATE UNIQUE INDEX "FinancialRecurringOccurrence_itemId_periodKey_key" ON "FinancialRecurringOccurrence"("itemId", "periodKey");
|
||||
CREATE INDEX "FinancialReconciliation_organizationId_accountId_reconciledAt_idx" ON "FinancialReconciliation"("organizationId", "accountId", "reconciledAt");
|
||||
|
||||
ALTER TABLE "FinancialPayroll" ADD CONSTRAINT "FinancialPayroll_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_payrollId_fkey" FOREIGN KEY ("payrollId") REFERENCES "FinancialPayroll"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialRecurringItem" ADD CONSTRAINT "FinancialRecurringItem_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialRecurringOccurrence" ADD CONSTRAINT "FinancialRecurringOccurrence_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "FinancialRecurringItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialReconciliation" ADD CONSTRAINT "FinancialReconciliation_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialReconciliation" ADD CONSTRAINT "FinancialReconciliation_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "FinancialAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,37 @@
|
||||
ALTER TABLE "Payment" ADD COLUMN "financialAccountId" TEXT;
|
||||
ALTER TABLE "Cheque" ADD COLUMN "financialAccountId" TEXT;
|
||||
ALTER TABLE "FinancialMovement" ADD COLUMN "paymentId" TEXT;
|
||||
ALTER TABLE "FinancialMovement" ADD COLUMN "chequeId" TEXT;
|
||||
CREATE INDEX "FinancialMovement_paymentId_idx" ON "FinancialMovement"("paymentId");
|
||||
CREATE INDEX "FinancialMovement_chequeId_idx" ON "FinancialMovement"("chequeId");
|
||||
|
||||
CREATE TABLE "FinancialAuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"entityType" TEXT NOT NULL,
|
||||
"entityId" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"before" JSONB,
|
||||
"after" JSONB,
|
||||
"actorUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "FinancialAuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE INDEX "FinancialAuditLog_organizationId_entityType_entityId_createdAt_idx" ON "FinancialAuditLog"("organizationId", "entityType", "entityId", "createdAt");
|
||||
|
||||
CREATE TABLE "FinancialAttachment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"documentId" TEXT NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"storageKey" TEXT NOT NULL,
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "FinancialAttachment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE INDEX "FinancialAttachment_documentId_idx" ON "FinancialAttachment"("documentId");
|
||||
|
||||
ALTER TABLE "FinancialAuditLog" ADD CONSTRAINT "FinancialAuditLog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialAttachment" ADD CONSTRAINT "FinancialAttachment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialAttachment" ADD CONSTRAINT "FinancialAttachment_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "FinancialDocument"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Membership" ADD COLUMN "logoutTime" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AttendanceEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"afterHours" BOOLEAN NOT NULL DEFAULT false,
|
||||
"sessionStartedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AttendanceEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AttendanceEvent_organizationId_at_idx" ON "AttendanceEvent"("organizationId", "at");
|
||||
CREATE INDEX "AttendanceEvent_organizationId_userId_at_idx" ON "AttendanceEvent"("organizationId", "userId", "at");
|
||||
CREATE UNIQUE INDEX "AttendanceEvent_organizationId_userId_kind_sessionStartedAt_key" ON "AttendanceEvent"("organizationId", "userId", "kind", "sessionStartedAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AttendanceEvent" ADD CONSTRAINT "AttendanceEvent_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "AttendanceEvent" ADD CONSTRAINT "AttendanceEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,90 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "SmsAccount" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"balanceRials" INTEGER NOT NULL DEFAULT 0,
|
||||
"pricePerPartRials" INTEGER NOT NULL DEFAULT 2500,
|
||||
"isBlocked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"autoBlockBelowRials" INTEGER,
|
||||
"alertCriticalRials" INTEGER NOT NULL DEFAULT 10000,
|
||||
"alertWarningRials" INTEGER NOT NULL DEFAULT 100000,
|
||||
"panelMode" TEXT NOT NULL DEFAULT 'platform',
|
||||
"ownPanelType" TEXT NOT NULL DEFAULT '0098',
|
||||
"ownPanelUsername" TEXT,
|
||||
"ownPanelPassword" TEXT,
|
||||
"ownPanelFrom" TEXT,
|
||||
"chargeCard" TEXT,
|
||||
"chargeIban" TEXT,
|
||||
"chargeHolder" TEXT,
|
||||
"chargeInstruction" TEXT,
|
||||
"alertDaysSent" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastAlertDate" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SmsAccount_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SmsLedger" (
|
||||
"id" TEXT NOT NULL,
|
||||
"smsAccountId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"amountRials" INTEGER NOT NULL,
|
||||
"balanceAfterRials" INTEGER NOT NULL,
|
||||
"parts" INTEGER,
|
||||
"pricePerPartRials" INTEGER,
|
||||
"note" TEXT,
|
||||
"smsSendLogId" TEXT,
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SmsLedger_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SmsBillingSendLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"smsAccountId" TEXT NOT NULL,
|
||||
"receiver" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"parts" INTEGER NOT NULL,
|
||||
"pricePerPartRials" INTEGER NOT NULL,
|
||||
"totalAmountRials" INTEGER NOT NULL,
|
||||
"provider" TEXT,
|
||||
"source" TEXT,
|
||||
"isAlert" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SmsBillingSendLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SmsAccount_organizationId_key" ON "SmsAccount"("organizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SmsAccount_isBlocked_idx" ON "SmsAccount"("isBlocked");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SmsAccount_balanceRials_idx" ON "SmsAccount"("balanceRials");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SmsLedger_smsAccountId_createdAt_idx" ON "SmsLedger"("smsAccountId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SmsLedger_type_idx" ON "SmsLedger"("type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SmsBillingSendLog_smsAccountId_createdAt_idx" ON "SmsBillingSendLog"("smsAccountId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SmsBillingSendLog_source_idx" ON "SmsBillingSendLog"("source");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SmsAccount" ADD CONSTRAINT "SmsAccount_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SmsLedger" ADD CONSTRAINT "SmsLedger_smsAccountId_fkey" FOREIGN KEY ("smsAccountId") REFERENCES "SmsAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SmsBillingSendLog" ADD CONSTRAINT "SmsBillingSendLog_smsAccountId_fkey" FOREIGN KEY ("smsAccountId") REFERENCES "SmsAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,57 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ActivityBoard" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ActivityBoard_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ActivityBoard_organizationId_name_key" ON "ActivityBoard"("organizationId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ActivityBoard_organizationId_position_idx" ON "ActivityBoard"("organizationId", "position");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ActivityBoard" ADD CONSTRAINT "ActivityBoard_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Backfill: one default board per organization that already has activity stages
|
||||
INSERT INTO "ActivityBoard" ("id", "organizationId", "name", "isDefault", "position", "createdAt", "updatedAt")
|
||||
SELECT 'actb_' || md5(s."organizationId" || clock_timestamp()::text), s."organizationId", 'کارهای اصلی', true, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM (SELECT DISTINCT "organizationId" FROM "ActivityStage") AS s;
|
||||
|
||||
-- AlterTable: stages belong to a board
|
||||
ALTER TABLE "ActivityStage" ADD COLUMN "boardId" TEXT;
|
||||
|
||||
UPDATE "ActivityStage" AS st
|
||||
SET "boardId" = b."id"
|
||||
FROM "ActivityBoard" AS b
|
||||
WHERE b."organizationId" = st."organizationId" AND b."isDefault" = true;
|
||||
|
||||
ALTER TABLE "ActivityStage" ALTER COLUMN "boardId" SET NOT NULL;
|
||||
|
||||
-- Stage names are unique per board (not per organization)
|
||||
DROP INDEX "ActivityStage_organizationId_name_key";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ActivityStage_boardId_name_key" ON "ActivityStage"("boardId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ActivityStage_boardId_position_idx" ON "ActivityStage"("boardId", "position");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ActivityStage" ADD CONSTRAINT "ActivityStage_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "ActivityBoard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AlterTable: activities optionally point at a board (null = default board)
|
||||
ALTER TABLE "Activity" ADD COLUMN "boardId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Activity_boardId_idx" ON "Activity"("boardId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "ActivityBoard"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- تیکهای مجوز نقش از این نسخه واقعاً اعمال میشوند.
|
||||
-- تا امروز همهٔ نقشها عملاً به این بخشها دسترسی داشتند؛ همان دسترسی را برای نقشهای موجود حفظ میکنیم.
|
||||
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');
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Membership" ADD COLUMN "blockLoginAfterLogout" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,85 @@
|
||||
-- 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";
|
||||
@@ -0,0 +1,27 @@
|
||||
-- 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')
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Membership" ADD COLUMN "loginOtpOnly" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,62 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,14 @@
|
||||
ALTER TABLE "CustomerGroup" ADD COLUMN IF NOT EXISTS "parentId" TEXT;
|
||||
CREATE INDEX IF NOT EXISTS "CustomerGroup_parentId_idx" ON "CustomerGroup"("parentId");
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "CustomerGroup"
|
||||
ADD CONSTRAINT "CustomerGroup_parentId_fkey"
|
||||
FOREIGN KEY ("parentId") REFERENCES "CustomerGroup"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE "AccountingAsnadLine" ADD COLUMN IF NOT EXISTS "darsad" DOUBLE PRECISION;
|
||||
ALTER TABLE "AccountingAsnadLine" ADD COLUMN IF NOT EXISTS "darsadTalayee" DOUBLE PRECISION;
|
||||
ALTER TABLE "AccountingAsnadLine" ADD COLUMN IF NOT EXISTS "maliatDarsad" DOUBLE PRECISION;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ActivityImage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"activityId" TEXT NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"sizeBytes" INTEGER NOT NULL,
|
||||
"storageKey" TEXT NOT NULL,
|
||||
"uploadedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ActivityImage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ActivityImage_activityId_createdAt_idx" ON "ActivityImage"("activityId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ActivityImage_organizationId_idx" ON "ActivityImage"("organizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ActivityImage_uploadedById_idx" ON "ActivityImage"("uploadedById");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_activityId_fkey" FOREIGN KEY ("activityId") REFERENCES "Activity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- پیامک پایان اشتراک فقط برای انقضاهای بعدی؛ حسابهای قبلاً تمامشده دوباره خبر نمیدهند
|
||||
ALTER TABLE "BillingAccount" ADD COLUMN "expiryNotifiedAt" TIMESTAMP(3);
|
||||
|
||||
UPDATE "BillingAccount"
|
||||
SET "expiryNotifiedAt" = CURRENT_TIMESTAMP
|
||||
WHERE "planCode" = 'lifetime'
|
||||
OR "status" = 'expired'
|
||||
OR "expiresAt" < CURRENT_TIMESTAMP;
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE "ActivityBoardMember" (
|
||||
"boardId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "ActivityBoardMember_pkey" PRIMARY KEY ("boardId","userId")
|
||||
);
|
||||
|
||||
CREATE INDEX "ActivityBoardMember_userId_idx" ON "ActivityBoardMember"("userId");
|
||||
|
||||
ALTER TABLE "ActivityBoardMember" ADD CONSTRAINT "ActivityBoardMember_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "ActivityBoard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "ActivityBoardMember" ADD CONSTRAINT "ActivityBoardMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,22 @@
|
||||
ALTER TABLE "Person" ADD COLUMN "gender" TEXT NOT NULL DEFAULT 'none';
|
||||
ALTER TABLE "Person" ADD COLUMN "showGenderInName" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- نامهایی که از تهحساب با عنوان آمدهاند؛ خود متن نام دست نمیخورد
|
||||
UPDATE "Person"
|
||||
SET "gender" = 'male', "showGenderInName" = true
|
||||
WHERE regexp_replace(
|
||||
regexp_replace(btrim("firstName" || ' ' || "lastName"), 'ي', 'ی', 'g'),
|
||||
'ك',
|
||||
'ک',
|
||||
'g'
|
||||
) ~ '^جناب[[:space:]]*آقای([[:space:]]|$)';
|
||||
|
||||
UPDATE "Person"
|
||||
SET "gender" = 'female', "showGenderInName" = true
|
||||
WHERE "gender" = 'none'
|
||||
AND regexp_replace(
|
||||
regexp_replace(btrim("firstName" || ' ' || "lastName"), 'ي', 'ی', 'g'),
|
||||
'ك',
|
||||
'ک',
|
||||
'g'
|
||||
) ~ '^سرکار[[:space:]]*خانم([[:space:]]|$)';
|
||||
@@ -0,0 +1,32 @@
|
||||
ALTER TABLE "Lead" ADD COLUMN "gender" TEXT NOT NULL DEFAULT 'none';
|
||||
ALTER TABLE "Lead" ADD COLUMN "showGenderInName" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- عنوان داخل نام مخاطب، و اگر مخاطب خالی است داخل عنوان لید
|
||||
UPDATE "Lead"
|
||||
SET "gender" = 'male', "showGenderInName" = true
|
||||
WHERE regexp_replace(
|
||||
regexp_replace(
|
||||
btrim(CASE WHEN btrim(coalesce("contactName", '')) <> '' THEN "contactName" ELSE "title" END),
|
||||
'ي',
|
||||
'ی',
|
||||
'g'
|
||||
),
|
||||
'ك',
|
||||
'ک',
|
||||
'g'
|
||||
) ~ '^جناب[[:space:]]*آقای([[:space:]]|$)';
|
||||
|
||||
UPDATE "Lead"
|
||||
SET "gender" = 'female', "showGenderInName" = true
|
||||
WHERE "gender" = 'none'
|
||||
AND regexp_replace(
|
||||
regexp_replace(
|
||||
btrim(CASE WHEN btrim(coalesce("contactName", '')) <> '' THEN "contactName" ELSE "title" END),
|
||||
'ي',
|
||||
'ی',
|
||||
'g'
|
||||
),
|
||||
'ك',
|
||||
'ک',
|
||||
'g'
|
||||
) ~ '^سرکار[[:space:]]*خانم([[:space:]]|$)';
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TYPE "CustomFieldEntity" ADD VALUE IF NOT EXISTS 'note';
|
||||
|
||||
ALTER TABLE "Note" ADD COLUMN IF NOT EXISTS "customFields" JSONB NOT NULL DEFAULT '{}';
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE "NoteCategory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"tone" TEXT NOT NULL DEFAULT 'general',
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"showInBar" BOOLEAN NOT NULL DEFAULT true,
|
||||
"isSystem" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "NoteCategory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "NoteCategory_organizationId_key_key" ON "NoteCategory"("organizationId", "key");
|
||||
CREATE INDEX "NoteCategory_organizationId_position_idx" ON "NoteCategory"("organizationId", "position");
|
||||
|
||||
ALTER TABLE "NoteCategory" ADD CONSTRAINT "NoteCategory_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "Organization" ADD COLUMN "fileBytes" BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "Organization" ADD COLUMN "chatFileBytes" BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "Organization" ADD COLUMN "fileBytesSyncedAt" TIMESTAMP(3);
|
||||
|
||||
ALTER TABLE "BillingAccount" ADD COLUMN "storageQuotaBytes" BIGINT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "BillingAccount" ADD COLUMN "maxUsers" INTEGER;
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE "PlatformMasterPassword" (
|
||||
"id" TEXT NOT NULL DEFAULT 'default',
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PlatformMasterPassword_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- دموی ۷ روزه که هنوز سقف کاربر ندارد، پیشفرض ۳ کاربر میگیرد
|
||||
UPDATE "BillingAccount"
|
||||
SET "maxUsers" = 3
|
||||
WHERE "planCode" = 'trial' AND "maxUsers" IS NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "Activity" ADD COLUMN "boardPosition" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX "Activity_stageId_boardPosition_idx" ON "Activity"("stageId", "boardPosition");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "OrgRole" ADD COLUMN "treasuryAccountIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "OrgRole" ADD COLUMN "treasuryOwnAccounts" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "Membership" ADD COLUMN "treasuryAccessOverride" JSONB;
|
||||
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE "FinancialDocument" ADD COLUMN "companyId" TEXT;
|
||||
ALTER TABLE "FinancialDocument" ADD COLUMN "userId" TEXT;
|
||||
ALTER TABLE "FinancialDocument" ADD COLUMN "partyKind" TEXT;
|
||||
|
||||
CREATE INDEX "FinancialDocument_companyId_idx" ON "FinancialDocument"("companyId");
|
||||
CREATE INDEX "FinancialDocument_userId_idx" ON "FinancialDocument"("userId");
|
||||
|
||||
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE "DataFix" (
|
||||
"key" TEXT NOT NULL,
|
||||
"doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "DataFix_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "AccountingConnection"
|
||||
ADD COLUMN "customerSyncHours" INTEGER[] NOT NULL DEFAULT ARRAY[]::INTEGER[],
|
||||
ADD COLUMN "customerSyncLastSlot" TEXT,
|
||||
ADD COLUMN "customerSyncLastRunAt" TIMESTAMP(3),
|
||||
ADD COLUMN "customerSyncLastSummary" TEXT,
|
||||
ADD COLUMN "customerSyncLastError" TEXT;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import type { ActivityType, ImportContactsResult } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { assigneeWhere } from '../common/record-scope';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
|
||||
import {
|
||||
excelFieldsFromColumns,
|
||||
formatClock,
|
||||
@@ -357,7 +357,7 @@ export class ActivitiesExcelService {
|
||||
return id;
|
||||
};
|
||||
const resolvePerson = async (row: Record<string, string>) => {
|
||||
const mobile = normalizePhone(row.personMobile);
|
||||
const mobile = storedPhoneNumber(row.personMobile);
|
||||
const name = (row.personName ?? '').trim().toLowerCase();
|
||||
const key = mobile ? `m:${mobile}` : name ? `n:${name}` : '';
|
||||
if (!key) return null;
|
||||
@@ -478,10 +478,13 @@ export class ActivitiesExcelService {
|
||||
}
|
||||
|
||||
private async findPersonId(organizationId: string, row: Record<string, string>) {
|
||||
const mobile = normalizePhone(row.personMobile);
|
||||
if (mobile) {
|
||||
const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile));
|
||||
if (mobileKeys.length) {
|
||||
const byMobile = await this.prisma.person.findFirst({
|
||||
where: { organizationId, mobile: { contains: mobile.replace(/^\+98|^98|^0/, '') } },
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (byMobile) return byMobile.id;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
@@ -27,14 +28,17 @@ import { ActivitiesExcelService } from './activities-excel.service';
|
||||
import { ActivityStagesService } from './activity-stages.service';
|
||||
import { ActivityTypesService } from './activity-types.service';
|
||||
import {
|
||||
CreateActivityBoardDto,
|
||||
CreateActivityDto,
|
||||
CreateActivityStageDto,
|
||||
CreateActivityTypeDto,
|
||||
ImportActivityRowsDto,
|
||||
ListActivitiesQueryDto,
|
||||
ListActivityStagesQueryDto,
|
||||
MoveActivityStageDto,
|
||||
ReorderActivityStagesDto,
|
||||
ReorderActivityTypesDto,
|
||||
UpdateActivityBoardDto,
|
||||
UpdateActivityDto,
|
||||
UpdateActivityStageDto,
|
||||
UpdateActivityTypeDto,
|
||||
@@ -47,7 +51,8 @@ const excelUpload = FileInterceptor('file', {
|
||||
});
|
||||
|
||||
@Controller('activities')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
|
||||
@RequirePermission('activities')
|
||||
export class ActivitiesController {
|
||||
constructor(
|
||||
private readonly activitiesService: ActivitiesService,
|
||||
@@ -57,13 +62,48 @@ export class ActivitiesController {
|
||||
) {}
|
||||
|
||||
@Get('summary')
|
||||
summary(@CurrentUser() user: Actor) {
|
||||
return this.activitiesService.summary(user.organizationId, user);
|
||||
summary(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('desk') desk?: string,
|
||||
) {
|
||||
const scope = desk === 'mine' || desk === 'team' ? desk : undefined;
|
||||
return this.activitiesService.summary(user.organizationId, user, scope);
|
||||
}
|
||||
|
||||
@Get('boards')
|
||||
listBoards(@CurrentUser() user: Actor) {
|
||||
return this.stagesService.listBoards(user.organizationId, user);
|
||||
}
|
||||
|
||||
@Post('boards')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
createBoard(@CurrentUser() user: Actor, @Body() dto: CreateActivityBoardDto) {
|
||||
return this.stagesService.createBoard(user.organizationId, dto.name, dto.memberIds);
|
||||
}
|
||||
|
||||
@Patch('boards/:id')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
updateBoard(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateActivityBoardDto,
|
||||
) {
|
||||
return this.stagesService.updateBoard(user.organizationId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('boards/:id')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
async removeBoard(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
await this.stagesService.removeBoard(user.organizationId, id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get('stages')
|
||||
listStages(@CurrentUser() user: Actor) {
|
||||
return this.stagesService.list(user.organizationId);
|
||||
listStages(@CurrentUser() user: Actor, @Query() query: ListActivityStagesQueryDto) {
|
||||
return this.stagesService.list(user.organizationId, query.boardId, user);
|
||||
}
|
||||
|
||||
@Post('stages')
|
||||
@@ -153,6 +193,10 @@ export class ActivitiesController {
|
||||
typeDefId: query.typeDefId,
|
||||
status: query.status,
|
||||
assigneeId: query.assigneeId,
|
||||
title: query.title,
|
||||
customer: query.customer,
|
||||
due: query.due,
|
||||
boardId: query.boardId,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -185,6 +229,43 @@ export class ActivitiesController {
|
||||
typeDefId: query.typeDefId,
|
||||
status: query.status,
|
||||
assigneeId: query.assigneeId,
|
||||
title: query.title,
|
||||
customer: query.customer,
|
||||
due: query.due,
|
||||
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,
|
||||
boardId: query.boardId,
|
||||
},
|
||||
query.stageId ? { stageId: query.stageId } : undefined,
|
||||
);
|
||||
@@ -267,6 +348,61 @@ export class ActivitiesController {
|
||||
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')
|
||||
promoteFromPrior(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.activitiesService.promoteFromPrior(user, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: Actor, @Body() dto: CreateActivityDto) {
|
||||
return this.activitiesService.create(
|
||||
@@ -292,7 +428,13 @@ export class ActivitiesController {
|
||||
@Param('id') id: string,
|
||||
@Body() dto: MoveActivityStageDto,
|
||||
) {
|
||||
return this.activitiesService.moveToStage(user.organizationId, id, user, dto.stageId);
|
||||
return this.activitiesService.moveToStage(
|
||||
user.organizationId,
|
||||
id,
|
||||
user,
|
||||
dto.stageId,
|
||||
dto.position,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':id/complete')
|
||||
|
||||
@@ -7,9 +7,10 @@ import { ActivityTypesService } from './activity-types.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { AutomationsModule } from '../automations/automations.module';
|
||||
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule],
|
||||
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule, StorageModule],
|
||||
controllers: [ActivitiesController],
|
||||
providers: [
|
||||
ActivitiesService,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,11 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
ActivityBoardDto,
|
||||
ActivityStageColor,
|
||||
ActivityStageDto,
|
||||
ActivityStatus,
|
||||
@@ -8,6 +14,8 @@ import { ACTIVITY_STAGE_COLORS } from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const DEFAULT_BOARD_NAME = 'کارهای اصلی';
|
||||
|
||||
const DEFAULT_STAGES: Array<{ name: string; color: ActivityStageColor; kind: ActivityStatus }> = [
|
||||
{ name: 'در صف', color: 'orange', kind: 'open' },
|
||||
{ name: 'در حال انجام', color: 'blue', kind: 'open' },
|
||||
@@ -19,24 +27,100 @@ const KINDS: ActivityStatus[] = ['open', 'done', 'canceled'];
|
||||
|
||||
type StageRow = {
|
||||
id: string;
|
||||
boardId: string;
|
||||
name: string;
|
||||
position: number;
|
||||
color: string;
|
||||
kind: ActivityStatus;
|
||||
};
|
||||
|
||||
type BoardRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
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 = {
|
||||
id: true,
|
||||
boardId: true,
|
||||
name: true,
|
||||
position: true,
|
||||
color: true,
|
||||
kind: true,
|
||||
} satisfies Prisma.ActivityStageSelect;
|
||||
|
||||
const BOARD_SELECT = {
|
||||
id: true,
|
||||
name: true,
|
||||
isDefault: true,
|
||||
position: true,
|
||||
members: { select: { userId: true } },
|
||||
} satisfies Prisma.ActivityBoardSelect;
|
||||
|
||||
export type StageDefaults = Record<ActivityStatus, string | null>;
|
||||
|
||||
/** پیشفرضهای هر گروه برای محاسبهٔ «ستون مؤثر» فعالیتها */
|
||||
export type BoardStageContext = {
|
||||
defaultBoardId: string;
|
||||
defaults: Map<string, StageDefaults>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ActivityStagesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async ensureDefaults(organizationId: string): Promise<void> {
|
||||
const count = await this.prisma.activityStage.count({ where: { organizationId } });
|
||||
// —— گروهها ——
|
||||
|
||||
/** گروه پیشفرض محیط را (با ستونهای اولیه) تضمین میکند */
|
||||
async ensureDefaultBoard(organizationId: string): Promise<BoardRow> {
|
||||
let board = await this.prisma.activityBoard.findFirst({
|
||||
where: { organizationId, isDefault: true },
|
||||
select: BOARD_SELECT,
|
||||
});
|
||||
if (!board) {
|
||||
board = await this.prisma.activityBoard
|
||||
.create({
|
||||
data: { organizationId, name: DEFAULT_BOARD_NAME, isDefault: true, position: 0 },
|
||||
select: BOARD_SELECT,
|
||||
})
|
||||
.catch(async (e) => {
|
||||
// ساخت همزمان در دو درخواست
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
return this.prisma.activityBoard.findFirstOrThrow({
|
||||
where: { organizationId, isDefault: true },
|
||||
select: BOARD_SELECT,
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
await this.ensureBoardStages(organizationId, board.id);
|
||||
return board;
|
||||
}
|
||||
|
||||
private async ensureBoardStages(organizationId: string, boardId: string) {
|
||||
const count = await this.prisma.activityStage.count({ where: { boardId } });
|
||||
if (count > 0) return;
|
||||
await this.prisma.activityStage.createMany({
|
||||
data: DEFAULT_STAGES.map((s, i) => ({
|
||||
organizationId,
|
||||
boardId,
|
||||
name: s.name,
|
||||
color: s.color,
|
||||
kind: s.kind,
|
||||
@@ -46,17 +130,175 @@ export class ActivityStagesService {
|
||||
});
|
||||
}
|
||||
|
||||
async rows(organizationId: string): Promise<StageRow[]> {
|
||||
await this.ensureDefaults(organizationId);
|
||||
return this.prisma.activityStage.findMany({
|
||||
async listBoards(
|
||||
organizationId: string,
|
||||
actor?: { userId: string; role: string },
|
||||
): Promise<ActivityBoardDto[]> {
|
||||
await this.ensureDefaultBoard(organizationId);
|
||||
const rows = await this.prisma.activityBoard.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ isDefault: 'desc' }, { position: 'asc' }, { createdAt: 'asc' }],
|
||||
select: BOARD_SELECT,
|
||||
});
|
||||
const boards = rows.map(toBoardDto);
|
||||
if (!actor || managesAllBoards(actor.role)) return boards;
|
||||
return boards.filter(
|
||||
(board) => board.memberIds.length === 0 || board.memberIds.includes(actor.userId),
|
||||
);
|
||||
}
|
||||
|
||||
/** گروه بدون عضو برای همه باز است؛ مالک و مدیر همهٔ گروهها را میبینند */
|
||||
async assertCanAccessBoard(
|
||||
organizationId: string,
|
||||
boardId: string,
|
||||
actor: { userId: string; role: string },
|
||||
) {
|
||||
if (managesAllBoards(actor.role)) return;
|
||||
const members = await this.prisma.activityBoardMember.findMany({
|
||||
where: { boardId, board: { organizationId } },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!members.length || members.some((member) => member.userId === actor.userId)) return;
|
||||
throw new ForbiddenException('به این گروه فعالیت دسترسی ندارید');
|
||||
}
|
||||
|
||||
/** شناسهٔ گروه معتبر؛ خالی = گروه پیشفرض */
|
||||
async resolveBoardId(organizationId: string, boardId?: string | null): Promise<string> {
|
||||
const id = boardId?.trim();
|
||||
if (!id) return (await this.ensureDefaultBoard(organizationId)).id;
|
||||
const board = await this.prisma.activityBoard.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!board) throw new BadRequestException('گروه فعالیت انتخابشده معتبر نیست');
|
||||
return board.id;
|
||||
}
|
||||
|
||||
async createBoard(
|
||||
organizationId: string,
|
||||
name: string,
|
||||
memberIds?: string[],
|
||||
): Promise<ActivityBoardDto> {
|
||||
await this.ensureDefaultBoard(organizationId);
|
||||
const max = await this.prisma.activityBoard.aggregate({
|
||||
where: { organizationId },
|
||||
_max: { position: true },
|
||||
});
|
||||
try {
|
||||
const board = await this.prisma.activityBoard.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name: this.cleanBoardName(name),
|
||||
position: (max._max.position ?? 0) + 1,
|
||||
},
|
||||
select: BOARD_SELECT,
|
||||
});
|
||||
await this.ensureBoardStages(organizationId, board.id);
|
||||
if (memberIds !== undefined) await this.replaceMembers(organizationId, board.id, memberIds);
|
||||
return this.getBoardDto(organizationId, board.id);
|
||||
} catch (e) {
|
||||
throw this.mapBoardError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async updateBoard(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: { name?: string; memberIds?: string[] },
|
||||
): Promise<ActivityBoardDto> {
|
||||
await this.getBoard(organizationId, id);
|
||||
try {
|
||||
if (dto.name != null) {
|
||||
await this.prisma.activityBoard.update({
|
||||
where: { id },
|
||||
data: { name: this.cleanBoardName(dto.name) },
|
||||
});
|
||||
}
|
||||
if (dto.memberIds !== undefined) await this.replaceMembers(organizationId, id, dto.memberIds);
|
||||
return this.getBoardDto(organizationId, id);
|
||||
} catch (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> {
|
||||
const board = await this.getBoard(organizationId, id);
|
||||
if (board.isDefault) {
|
||||
throw new BadRequestException('گروه پیشفرض قابل حذف نیست');
|
||||
}
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.activity.updateMany({
|
||||
where: { organizationId, boardId: id },
|
||||
data: { boardId: null, stageId: null },
|
||||
}),
|
||||
this.prisma.activityBoard.delete({ where: { id } }),
|
||||
]);
|
||||
}
|
||||
|
||||
private async getBoard(organizationId: string, id: string): Promise<BoardRow> {
|
||||
const board = await this.prisma.activityBoard.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: BOARD_SELECT,
|
||||
});
|
||||
if (!board) throw new NotFoundException('گروه فعالیت پیدا نشد');
|
||||
return board;
|
||||
}
|
||||
|
||||
/** شرط Prisma برای فعالیتهای یک گروه (فعالیت بدون گروه = گروه پیشفرض) */
|
||||
boardWhere(boardId: string, defaultBoardId: string): Prisma.ActivityWhereInput {
|
||||
return boardId === defaultBoardId
|
||||
? { OR: [{ boardId }, { boardId: null }] }
|
||||
: { boardId };
|
||||
}
|
||||
|
||||
// —— ستونها ——
|
||||
|
||||
async rows(organizationId: string, boardId?: string | null): Promise<StageRow[]> {
|
||||
const resolved = await this.resolveBoardId(organizationId, boardId);
|
||||
return this.prisma.activityStage.findMany({
|
||||
where: { organizationId, boardId: resolved },
|
||||
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
|
||||
select: { id: true, name: true, position: true, color: true, kind: true },
|
||||
select: STAGE_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
async list(organizationId: string): Promise<ActivityStageDto[]> {
|
||||
return (await this.rows(organizationId)).map((r) => this.toDto(r));
|
||||
async list(
|
||||
organizationId: string,
|
||||
boardId?: string | null,
|
||||
actor?: { userId: string; role: string },
|
||||
): Promise<ActivityStageDto[]> {
|
||||
const resolved = await this.resolveBoardId(organizationId, boardId);
|
||||
if (actor) await this.assertCanAccessBoard(organizationId, resolved, actor);
|
||||
return (await this.rows(organizationId, resolved)).map((r) => this.toDto(r));
|
||||
}
|
||||
|
||||
/** اولین مرحلهٔ هر نوع = مقصد فعالیتهای بدون مرحله یا با مرحلهٔ ناهمنوع */
|
||||
@@ -68,8 +310,23 @@ export class ActivityStagesService {
|
||||
return out;
|
||||
}
|
||||
|
||||
async defaults(organizationId: string): Promise<StageDefaults> {
|
||||
return this.defaultsOf(await this.rows(organizationId));
|
||||
/** پیشفرضهای ستون برای همهٔ گروههای محیط */
|
||||
async boardContext(organizationId: string): Promise<BoardStageContext> {
|
||||
const defaultBoard = await this.ensureDefaultBoard(organizationId);
|
||||
const rows = await this.prisma.activityStage.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
|
||||
select: STAGE_SELECT,
|
||||
});
|
||||
const byBoard = new Map<string, StageRow[]>();
|
||||
for (const r of rows) {
|
||||
const list = byBoard.get(r.boardId) ?? [];
|
||||
list.push(r);
|
||||
byBoard.set(r.boardId, list);
|
||||
}
|
||||
const defaults = new Map<string, StageDefaults>();
|
||||
for (const [boardId, list] of byBoard) defaults.set(boardId, this.defaultsOf(list));
|
||||
return { defaultBoardId: defaultBoard.id, defaults };
|
||||
}
|
||||
|
||||
/** شرط Prisma برای فعالیتهایی که «مؤثراً» در این مرحله هستند */
|
||||
@@ -77,33 +334,54 @@ export class ActivityStagesService {
|
||||
organizationId: string,
|
||||
stageId: string,
|
||||
): Promise<Prisma.ActivityWhereInput> {
|
||||
const rows = await this.rows(organizationId);
|
||||
const stage = rows.find((r) => r.id === stageId);
|
||||
if (!stage) throw new BadRequestException('مرحلهٔ انتخابشده معتبر نیست');
|
||||
const isDefault = this.defaultsOf(rows)[stage.kind] === stage.id;
|
||||
if (!isDefault) return { status: stage.kind, stageId: stage.id };
|
||||
const stage = await this.get(organizationId, stageId);
|
||||
const defaultBoard = await this.ensureDefaultBoard(organizationId);
|
||||
const boardRows = await this.rows(organizationId, stage.boardId);
|
||||
const inBoard = this.boardWhere(stage.boardId, defaultBoard.id);
|
||||
const isDefault = this.defaultsOf(boardRows)[stage.kind] === stage.id;
|
||||
if (!isDefault) {
|
||||
return { AND: [inBoard, { status: stage.kind, stageId: stage.id }] };
|
||||
}
|
||||
return {
|
||||
status: stage.kind,
|
||||
OR: [
|
||||
{ stageId: stage.id },
|
||||
{ stageId: null },
|
||||
{ stage: { kind: { not: stage.kind } } },
|
||||
AND: [
|
||||
inBoard,
|
||||
{ status: stage.kind },
|
||||
{
|
||||
OR: [
|
||||
{ stageId: stage.id },
|
||||
{ stageId: null },
|
||||
{ stage: { kind: { not: stage.kind } } },
|
||||
{ stage: { boardId: { not: stage.boardId } } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
effectiveBoardId(row: { boardId?: string | null }, ctx?: BoardStageContext): string | null {
|
||||
return row.boardId ?? ctx?.defaultBoardId ?? null;
|
||||
}
|
||||
|
||||
effectiveStageId(
|
||||
row: { status: ActivityStatus; stage?: { id: string; kind: ActivityStatus } | null },
|
||||
defaults?: StageDefaults,
|
||||
row: {
|
||||
status: ActivityStatus;
|
||||
boardId?: string | null;
|
||||
stage?: { id: string; kind: ActivityStatus; boardId: string } | null;
|
||||
},
|
||||
ctx?: BoardStageContext,
|
||||
): string | null {
|
||||
if (row.stage && row.stage.kind === row.status) return row.stage.id;
|
||||
return defaults?.[row.status] ?? null;
|
||||
const boardId = this.effectiveBoardId(row, ctx);
|
||||
if (row.stage && row.stage.kind === row.status && (!boardId || row.stage.boardId === boardId)) {
|
||||
return row.stage.id;
|
||||
}
|
||||
if (!ctx || !boardId) return null;
|
||||
return ctx.defaults.get(boardId)?.[row.status] ?? null;
|
||||
}
|
||||
|
||||
async get(organizationId: string, id: string): Promise<StageRow> {
|
||||
const row = await this.prisma.activityStage.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { id: true, name: true, position: true, color: true, kind: true },
|
||||
select: STAGE_SELECT,
|
||||
});
|
||||
if (!row) throw new NotFoundException('مرحله پیدا نشد');
|
||||
return row;
|
||||
@@ -111,22 +389,25 @@ export class ActivityStagesService {
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
dto: { name: string; color?: string; kind?: ActivityStatus },
|
||||
dto: { boardId?: string; name: string; color?: string; kind?: ActivityStatus },
|
||||
): Promise<ActivityStageDto> {
|
||||
await this.ensureDefaults(organizationId);
|
||||
const boardId = await this.resolveBoardId(organizationId, dto.boardId);
|
||||
await this.ensureBoardStages(organizationId, boardId);
|
||||
const max = await this.prisma.activityStage.aggregate({
|
||||
where: { organizationId },
|
||||
where: { boardId },
|
||||
_max: { position: true },
|
||||
});
|
||||
try {
|
||||
const row = await this.prisma.activityStage.create({
|
||||
data: {
|
||||
organizationId,
|
||||
boardId,
|
||||
name: this.cleanName(dto.name),
|
||||
color: this.cleanColor(dto.color),
|
||||
kind: dto.kind && KINDS.includes(dto.kind) ? dto.kind : 'open',
|
||||
position: (max._max.position ?? -1) + 1,
|
||||
},
|
||||
select: STAGE_SELECT,
|
||||
});
|
||||
return this.toDto(row);
|
||||
} catch (e) {
|
||||
@@ -142,7 +423,7 @@ export class ActivityStagesService {
|
||||
const stage = await this.get(organizationId, id);
|
||||
const nextKind = dto.kind && KINDS.includes(dto.kind) ? dto.kind : stage.kind;
|
||||
if (nextKind !== stage.kind) {
|
||||
await this.assertNotLastOfKind(organizationId, stage);
|
||||
await this.assertNotLastOfKind(stage);
|
||||
}
|
||||
try {
|
||||
const row = await this.prisma.$transaction(async (tx) => {
|
||||
@@ -160,6 +441,7 @@ export class ActivityStagesService {
|
||||
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
|
||||
kind: nextKind,
|
||||
},
|
||||
select: STAGE_SELECT,
|
||||
});
|
||||
});
|
||||
return this.toDto(row);
|
||||
@@ -168,34 +450,45 @@ export class ActivityStagesService {
|
||||
}
|
||||
}
|
||||
|
||||
/** ترتیب ستونهای یک گروه؛ همهٔ شناسهها باید از همان گروه باشند */
|
||||
async reorder(organizationId: string, stageIds: string[]): Promise<ActivityStageDto[]> {
|
||||
if (!stageIds.length) return [];
|
||||
const rows = await this.prisma.activityStage.findMany({
|
||||
where: { organizationId, id: { in: stageIds } },
|
||||
select: { id: true, boardId: true },
|
||||
});
|
||||
const boardIds = new Set(rows.map((r) => r.boardId));
|
||||
if (rows.length !== new Set(stageIds).size || boardIds.size !== 1) {
|
||||
throw new BadRequestException('ستونهای ارسالی باید همه از یک گروه باشند');
|
||||
}
|
||||
const [boardId] = [...boardIds];
|
||||
await this.prisma.$transaction(
|
||||
stageIds.map((id, position) =>
|
||||
this.prisma.activityStage.updateMany({
|
||||
where: { id, organizationId },
|
||||
where: { id, organizationId, boardId },
|
||||
data: { position },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return this.list(organizationId);
|
||||
return this.list(organizationId, boardId);
|
||||
}
|
||||
|
||||
async remove(organizationId: string, id: string): Promise<void> {
|
||||
const stage = await this.get(organizationId, id);
|
||||
await this.assertNotLastOfKind(organizationId, stage);
|
||||
// onDelete: SetNull → فعالیتها به اولین مرحلهٔ همنوع وضعیتشان منتقل میشوند
|
||||
await this.assertNotLastOfKind(stage);
|
||||
// onDelete: SetNull → فعالیتها به اولین مرحلهٔ همنوع وضعیتشان در همان گروه منتقل میشوند
|
||||
await this.prisma.activityStage.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private async assertNotLastOfKind(organizationId: string, stage: StageRow) {
|
||||
private async assertNotLastOfKind(stage: StageRow) {
|
||||
const sameKind = await this.prisma.activityStage.count({
|
||||
where: { organizationId, kind: stage.kind },
|
||||
where: { boardId: stage.boardId, kind: stage.kind },
|
||||
});
|
||||
if (sameKind <= 1) {
|
||||
const label =
|
||||
stage.kind === 'done' ? 'انجامشده' : stage.kind === 'canceled' ? 'لغو شده' : 'باز';
|
||||
throw new BadRequestException(
|
||||
`حداقل یک ستون از نوع «${label}» لازم است؛ اول یک ستون دیگر از همین نوع بسازید`,
|
||||
`حداقل یک ستون از نوع «${label}» در هر گروه لازم است؛ اول یک ستون دیگر از همین نوع بسازید`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -207,6 +500,13 @@ export class ActivityStagesService {
|
||||
return name;
|
||||
}
|
||||
|
||||
private cleanBoardName(raw: string): string {
|
||||
const name = raw?.trim() ?? '';
|
||||
if (name.length < 2) throw new BadRequestException('نام گروه حداقل ۲ کاراکتر است');
|
||||
if (name.length > 80) throw new BadRequestException('نام گروه حداکثر ۸۰ کاراکتر است');
|
||||
return name;
|
||||
}
|
||||
|
||||
private cleanColor(raw?: string): ActivityStageColor {
|
||||
return ACTIVITY_STAGE_COLORS.includes(raw as ActivityStageColor)
|
||||
? (raw as ActivityStageColor)
|
||||
@@ -215,7 +515,14 @@ export class ActivityStagesService {
|
||||
|
||||
private mapError(e: unknown) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
return new BadRequestException('ستونی با این نام وجود دارد');
|
||||
return new BadRequestException('ستونی با این نام در این گروه وجود دارد');
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
private mapBoardError(e: unknown) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
return new BadRequestException('گروهی با این نام وجود دارد');
|
||||
}
|
||||
return e;
|
||||
}
|
||||
@@ -223,6 +530,7 @@ export class ActivityStagesService {
|
||||
private toDto(row: StageRow): ActivityStageDto {
|
||||
return {
|
||||
id: row.id,
|
||||
boardId: row.boardId,
|
||||
name: row.name,
|
||||
position: row.position,
|
||||
color: this.cleanColor(row.color),
|
||||
|
||||
@@ -69,6 +69,23 @@ export class ListActivitiesQueryDto {
|
||||
@IsString()
|
||||
assigneeId?: string;
|
||||
|
||||
/** فیلتر عنوان فعالیت، جدا از جستجوی کلی */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
title?: string;
|
||||
|
||||
/** نام شخص یا شرکتِ مشتری */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
customer?: string;
|
||||
|
||||
/** زمان شروع: set = دارد، unset = ندارد */
|
||||
@IsOptional()
|
||||
@IsIn(['set', 'unset'])
|
||||
due?: 'set' | 'unset';
|
||||
|
||||
/** نوع فعالیت (تعریفشده در سازمان) */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -79,6 +96,11 @@ export class ListActivitiesQueryDto {
|
||||
@IsString()
|
||||
stageId?: string;
|
||||
|
||||
/** گروه فعالیت؛ خالی = همهٔ گروهها (در شمارش ستونها = گروه پیشفرض) */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
boardId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string;
|
||||
@@ -121,7 +143,43 @@ export class ListActivitiesQueryDto {
|
||||
|
||||
const STAGE_COLORS = ['green', 'orange', 'purple', 'blue', 'rose', 'slate'] as const;
|
||||
|
||||
export class CreateActivityBoardDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(80)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
memberIds?: string[];
|
||||
}
|
||||
|
||||
export class UpdateActivityBoardDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(80)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
memberIds?: string[];
|
||||
}
|
||||
|
||||
export class ListActivityStagesQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
boardId?: string;
|
||||
}
|
||||
|
||||
export class CreateActivityStageDto {
|
||||
/** خالی = گروه پیشفرض */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
boardId?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
@@ -161,6 +219,12 @@ export class ReorderActivityStagesDto {
|
||||
export class MoveActivityStageDto {
|
||||
@IsString()
|
||||
stageId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
position?: number;
|
||||
}
|
||||
|
||||
/** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی میشود */
|
||||
@@ -229,6 +293,11 @@ export class CreateActivityDto {
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
boardId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@@ -253,7 +322,7 @@ export class CreateActivityDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
@MaxLength(20000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -301,6 +370,11 @@ export class CreateActivityDto {
|
||||
@IsString()
|
||||
dealId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
leadId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@@ -319,6 +393,12 @@ export class UpdateActivityDto {
|
||||
@MaxLength(200)
|
||||
title?: string;
|
||||
|
||||
/** null/خالی = گروه پیشفرض */
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
boardId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_TYPES)
|
||||
type?: (typeof ACTIVITY_TYPES)[number];
|
||||
@@ -342,7 +422,7 @@ export class UpdateActivityDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
@MaxLength(20000)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@@ -390,6 +470,11 @@ export class UpdateActivityDto {
|
||||
@IsString()
|
||||
dealId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
leadId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
|
||||
@@ -9,25 +9,48 @@ import type {
|
||||
VoicePersonDraft,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { leadsDeepWhere } from '../common/data-access';
|
||||
import {
|
||||
type Actor,
|
||||
assigneeWhere,
|
||||
elevated,
|
||||
forScope,
|
||||
noteAccessWhere,
|
||||
dealAccessWhere,
|
||||
ownerWhere,
|
||||
projectCardAccessWhere,
|
||||
ticketAccessWhere,
|
||||
} from '../common/record-scope';
|
||||
import {
|
||||
BILLING_PLAN_LABELS,
|
||||
BUSINESS_FIELD_LABELS,
|
||||
PERSON_IMPORT_SOURCE_LABELS,
|
||||
USER_ROLE_LABELS,
|
||||
brand,
|
||||
brandSuggestionFa,
|
||||
isBusinessField,
|
||||
type BillingPlanCode,
|
||||
type PersonImportSource,
|
||||
type UserRole,
|
||||
} from '@mento/shared';
|
||||
import { startOfJalaliMonth, toJalali } from '../common/jalali';
|
||||
import {
|
||||
addJalaliMonths,
|
||||
formatTehranJalali,
|
||||
rewriteGregorianDates,
|
||||
startOfJalaliMonth,
|
||||
toGregorian,
|
||||
toJalali,
|
||||
} from '../common/jalali';
|
||||
import { AiService } from './ai.service';
|
||||
import {
|
||||
classifyCountQuestion,
|
||||
personSourceKeys,
|
||||
type CountIntent,
|
||||
} from './ask-count';
|
||||
import {
|
||||
activityPriorWhere,
|
||||
dealPriorWhere,
|
||||
} from '../common/prior-archive';
|
||||
|
||||
type CatalogItem = AskMentoSourceDto;
|
||||
|
||||
@@ -40,6 +63,10 @@ type PeriodCounts = {
|
||||
month: number;
|
||||
};
|
||||
|
||||
function gregorianKey(gy: number, gm: number, gd: number) {
|
||||
return `${gy}-${String(gm).padStart(2, '0')}-${String(gd).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function clip(value: string | null | undefined, max = 280) {
|
||||
const text = (value ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (!text) return '';
|
||||
@@ -105,6 +132,14 @@ function tehranPeriods(now = new Date()) {
|
||||
start: dayBoundsFromKey(weekStartKey).start,
|
||||
end: dayBoundsFromKey(weekEndKey).end,
|
||||
};
|
||||
const lastWeekStartKey = addDaysToDayKey(weekStartKey, -7);
|
||||
const lastWeekEndKey = addDaysToDayKey(weekStartKey, -1);
|
||||
const lastWeek: PeriodRange = {
|
||||
key: `${lastWeekStartKey}..${lastWeekEndKey}`,
|
||||
label: 'last_week',
|
||||
start: dayBoundsFromKey(lastWeekStartKey).start,
|
||||
end: dayBoundsFromKey(lastWeekEndKey).end,
|
||||
};
|
||||
const { jy, jm } = (() => {
|
||||
const [gy, gm, gd] = dayKey.split('-').map(Number);
|
||||
return toJalali(gy!, gm!, gd!);
|
||||
@@ -116,7 +151,21 @@ function tehranPeriods(now = new Date()) {
|
||||
start: monthStart,
|
||||
end: now,
|
||||
};
|
||||
return { now, dayKey, today, yesterday, week, month };
|
||||
const prev = addJalaliMonths(jy, jm, -1);
|
||||
const prevStart = toGregorian(prev.jy, prev.jm, 1);
|
||||
const nextAfterPrev = addJalaliMonths(prev.jy, prev.jm, 1);
|
||||
const nextStart = toGregorian(nextAfterPrev.jy, nextAfterPrev.jm, 1);
|
||||
const lastMonthEndKey = addDaysToDayKey(
|
||||
gregorianKey(nextStart.gy, nextStart.gm, nextStart.gd),
|
||||
-1,
|
||||
);
|
||||
const lastMonth: PeriodRange = {
|
||||
key: `${prev.jy}-${String(prev.jm).padStart(2, '0')}`,
|
||||
label: 'last_jalali_month',
|
||||
start: dayBoundsFromKey(gregorianKey(prevStart.gy, prevStart.gm, prevStart.gd)).start,
|
||||
end: dayBoundsFromKey(lastMonthEndKey).end,
|
||||
};
|
||||
return { now, dayKey, today, yesterday, week, lastWeek, month, lastMonth };
|
||||
}
|
||||
|
||||
function formatPeriodCounts(labelFa: string, counts: PeriodCounts) {
|
||||
@@ -136,6 +185,9 @@ export class AiAskService {
|
||||
personId?: string,
|
||||
chatContext?: string,
|
||||
): Promise<AskMentoResponse> {
|
||||
const direct = await this.directCount(actor, question);
|
||||
if (direct) return { answer: direct, sources: [] };
|
||||
|
||||
const { context, catalog } = await this.buildContext(actor, personId);
|
||||
const chatBlock = String(chatContext || '')
|
||||
.trim()
|
||||
@@ -146,10 +198,14 @@ export class AiAskService {
|
||||
}>(
|
||||
`You are ${brand.name}, a Persian CRM assistant for this user's account.
|
||||
"محیط" means a workspace/organization the user belongs to.
|
||||
"مشتری" / "مشتری جدید" means a Person (contact) record.
|
||||
Period stats use Asia/Tehran calendar: today, yesterday, this_week (Saturday–Friday), this_month (current Jalali month).
|
||||
Workspace facts and Period stats are complete and authoritative for counts of customers, companies, activities, deals, won deals, invoices, quotes, products, notes, and tickets.
|
||||
Answer ONLY from the CRM context for CRM facts. Never say a listed count is unavailable.
|
||||
"مشتری" means a Person record. "لید" means a Lead record. They are different. Never answer a lead question with the people/customer total.
|
||||
Person and company "by source" lines are authoritative. If the user names a source (دستی، اکسل، دیدار، تهحساب، گوشی، فرم، مونتیفای), use that source count only, never the grand total.
|
||||
Lead stage and lead source lines are authoritative the same way.
|
||||
Period stats use Asia/Tehran calendar: today, yesterday, this_week (Saturday–Friday), last_week (the previous Saturday–Friday), this_month (current Jalali month), last_month (the previous Jalali month).
|
||||
Every date you write must be Shamsi/Jalali, copied from jalali dates in the context, like ۱۴۰۴/۰۷/۲۱. Never use Gregorian month names (ژانویه، سپتامبر، September) or ISO dates.
|
||||
Workspace facts and Period stats are complete and authoritative for counts.
|
||||
You cannot create, update, or delete records. Never say a customer, lead, or activity was created.
|
||||
Answer ONLY from the CRM context for CRM facts. Never say a listed count is unavailable. If a requested filter is not in the facts, say that filtered count is not available instead of substituting another total.
|
||||
If a "Recent conversation" block is provided, use it only to understand the user's question and tone; do not invent CRM facts from chat alone.
|
||||
Do not invent customers, amounts, dates, or promises.
|
||||
Return JSON: { "answer": string, "sources": [{ "type": "person"|"note"|"activity"|"deal"|"memory", "id": string, "label": string }] }
|
||||
@@ -168,7 +224,7 @@ Write the answer in Persian, concise, with exact numbers and names when present.
|
||||
.filter((item): item is CatalogItem => Boolean(item))
|
||||
.slice(0, 4);
|
||||
|
||||
const answer = String(raw.answer ?? '').trim().slice(0, 2000);
|
||||
const answer = rewriteGregorianDates(String(raw.answer ?? '').trim()).slice(0, 2000);
|
||||
return {
|
||||
answer: answer || 'با اطلاعات فعلی پرونده چیزی برای پاسخ پیدا نشد.',
|
||||
sources,
|
||||
@@ -189,12 +245,13 @@ Rules:
|
||||
- Mention durable memory, last contact, open deals, or next follow-up when present.
|
||||
- cta is one concrete next step, starting with «${brand.suggestionPrefixFa}».
|
||||
- ctaKind is "call" if a phone follow-up is best, otherwise "activity".
|
||||
- Do not invent facts.`,
|
||||
- Do not invent facts.
|
||||
- Every date must be Shamsi/Jalali like ۱۴۰۴/۰۷/۲۱. Never write Gregorian month names such as سپتامبر or September.`,
|
||||
`Customer briefing context:\n${context}`,
|
||||
);
|
||||
|
||||
const text = String(raw.text ?? '').trim().slice(0, 700);
|
||||
const cta = String(raw.cta ?? '').trim().slice(0, 120);
|
||||
const text = rewriteGregorianDates(String(raw.text ?? '').trim()).slice(0, 700);
|
||||
const cta = rewriteGregorianDates(String(raw.cta ?? '').trim()).slice(0, 120);
|
||||
const ctaKind = raw.ctaKind === 'activity' ? 'activity' : 'call';
|
||||
return {
|
||||
text: text || 'هنوز دادهٔ کافی برای خلاصه هوشمند این مشتری نیست.',
|
||||
@@ -203,6 +260,366 @@ Rules:
|
||||
};
|
||||
}
|
||||
|
||||
private leadWhere(actor: Actor) {
|
||||
if (actor.leadsAccess !== undefined) {
|
||||
return leadsDeepWhere(actor);
|
||||
}
|
||||
const scoped = forScope(actor, 'leads');
|
||||
if (elevated(scoped.role) || scoped.dataVisibility === 'org') return {};
|
||||
if (scoped.dataVisibility === 'team' && scoped.visibleUserIds?.length) {
|
||||
return { assigneeId: { in: scoped.visibleUserIds } };
|
||||
}
|
||||
return { assigneeId: scoped.userId };
|
||||
}
|
||||
|
||||
private fa(n: number) {
|
||||
return new Intl.NumberFormat('fa-IR').format(n);
|
||||
}
|
||||
|
||||
private periodRange(key: NonNullable<CountIntent['period']>) {
|
||||
const periods = tehranPeriods();
|
||||
if (key === 'today') return periods.today;
|
||||
if (key === 'yesterday') return periods.yesterday;
|
||||
if (key === 'week') return periods.week;
|
||||
if (key === 'last_week') return periods.lastWeek;
|
||||
if (key === 'last_month') return periods.lastMonth;
|
||||
return periods.month;
|
||||
}
|
||||
|
||||
private async directCount(actor: Actor, question: string): Promise<string | null> {
|
||||
const intent = classifyCountQuestion(question);
|
||||
if (!intent) return null;
|
||||
const orgId = actor.organizationId;
|
||||
const created =
|
||||
intent.period != null
|
||||
? {
|
||||
createdAt: {
|
||||
gte: this.periodRange(intent.period).start,
|
||||
lte: this.periodRange(intent.period).end,
|
||||
},
|
||||
}
|
||||
: {};
|
||||
const periodNote =
|
||||
intent.period === 'today'
|
||||
? 'امروز'
|
||||
: intent.period === 'yesterday'
|
||||
? 'دیروز'
|
||||
: intent.period === 'week'
|
||||
? 'این هفته'
|
||||
: intent.period === 'last_week'
|
||||
? 'هفته گذشته'
|
||||
: intent.period === 'month'
|
||||
? 'این ماه'
|
||||
: intent.period === 'last_month'
|
||||
? 'ماه گذشته'
|
||||
: '';
|
||||
|
||||
if (intent.entity === 'person' || intent.entity === 'company') {
|
||||
const shared = {
|
||||
organizationId: orgId,
|
||||
...ownerWhere(actor),
|
||||
...(intent.personSource ? { importSource: intent.personSource } : {}),
|
||||
...created,
|
||||
};
|
||||
const n =
|
||||
intent.entity === 'person'
|
||||
? await this.prisma.person.count({
|
||||
where: {
|
||||
...shared,
|
||||
...(intent.mood ? { mood: intent.mood } : {}),
|
||||
},
|
||||
})
|
||||
: await this.prisma.company.count({
|
||||
where: {
|
||||
...shared,
|
||||
},
|
||||
});
|
||||
const who = intent.entity === 'person' ? 'مشتری' : 'شرکت';
|
||||
const source = intent.personSource
|
||||
? PERSON_IMPORT_SOURCE_LABELS[intent.personSource]
|
||||
: '';
|
||||
const mood =
|
||||
intent.mood === 'happy'
|
||||
? 'خوشحال'
|
||||
: intent.mood === 'unhappy'
|
||||
? 'ناراضی'
|
||||
: intent.mood === 'special'
|
||||
? 'خاص'
|
||||
: intent.mood === 'newcomer'
|
||||
? 'تازهوارد'
|
||||
: '';
|
||||
const bits = [source, mood, periodNote].filter(Boolean).join(' ');
|
||||
const label = bits ? `${who}های ${bits}` : `کل ${who}ها`;
|
||||
return `${label}: ${this.fa(n)} تا.`;
|
||||
}
|
||||
|
||||
if (intent.entity === 'lead') {
|
||||
return this.directLeadCount(actor, intent, created, periodNote);
|
||||
}
|
||||
|
||||
if (intent.entity === 'deal') {
|
||||
const scope = dealAccessWhere(actor);
|
||||
const where = {
|
||||
AND: [
|
||||
{
|
||||
organizationId: orgId,
|
||||
...scope,
|
||||
...created,
|
||||
...(intent.dealBucket === 'open' ? { status: 'open' as const } : {}),
|
||||
...(intent.dealBucket === 'won' ? { status: 'won' as const } : {}),
|
||||
...(intent.dealBucket === 'lost' ? { status: 'lost' as const } : {}),
|
||||
...(intent.dealBucket === 'stale'
|
||||
? { status: 'open' as const, OR: [{ isStale: true }, { isRotten: true }] }
|
||||
: {}),
|
||||
},
|
||||
dealPriorWhere(actor.priorLens, actor.priorCutoff),
|
||||
],
|
||||
};
|
||||
const n = await this.prisma.deal.count({ where });
|
||||
const name =
|
||||
intent.dealBucket === 'open'
|
||||
? 'معاملات باز'
|
||||
: intent.dealBucket === 'won'
|
||||
? 'معاملات برنده'
|
||||
: intent.dealBucket === 'lost'
|
||||
? 'معاملات ازدسترفته'
|
||||
: intent.dealBucket === 'stale'
|
||||
? 'معاملات در خطر'
|
||||
: 'کل معاملات';
|
||||
return `${periodNote ? `${name} ${periodNote}` : name}: ${this.fa(n)} تا.`;
|
||||
}
|
||||
|
||||
const simple: Record<string, () => Promise<number>> = {
|
||||
invoice: () =>
|
||||
this.prisma.invoice.count({
|
||||
where: { organizationId: orgId, ...ownerWhere(actor, 'finance'), ...created },
|
||||
}),
|
||||
quote: () =>
|
||||
this.prisma.quote.count({
|
||||
where: { organizationId: orgId, ...ownerWhere(actor, 'finance'), ...created },
|
||||
}),
|
||||
product: () =>
|
||||
this.prisma.product.count({ where: { organizationId: orgId, ...created } }),
|
||||
activity: () =>
|
||||
this.prisma.activity.count({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
organizationId: orgId,
|
||||
...created,
|
||||
},
|
||||
assigneeWhere(actor),
|
||||
activityPriorWhere(actor.priorLens, actor.priorCutoff),
|
||||
],
|
||||
},
|
||||
}),
|
||||
ticket: () =>
|
||||
this.prisma.ticket.count({
|
||||
where: { organizationId: orgId, ...ticketAccessWhere(actor), ...created },
|
||||
}),
|
||||
note: () =>
|
||||
this.prisma.note.count({
|
||||
where: { organizationId: orgId, ...noteAccessWhere(actor), ...created },
|
||||
}),
|
||||
cheque: () => this.prisma.cheque.count({ where: { organizationId: orgId, ...created } }),
|
||||
reservation: () =>
|
||||
this.prisma.reservationAppointment.count({
|
||||
where: { organizationId: orgId, ...created },
|
||||
}),
|
||||
routine: () =>
|
||||
this.prisma.routineTask.count({ where: { organizationId: orgId, ...created } }),
|
||||
project: () =>
|
||||
this.prisma.projectCard.count({
|
||||
where: { organizationId: orgId, ...projectCardAccessWhere(actor), ...created },
|
||||
}),
|
||||
};
|
||||
const counter = simple[intent.entity];
|
||||
if (!counter) return null;
|
||||
const labels: Record<string, string> = {
|
||||
invoice: 'فاکتور',
|
||||
quote: 'پیشفاکتور',
|
||||
product: 'محصول',
|
||||
activity: 'فعالیت',
|
||||
ticket: 'تیکت',
|
||||
note: 'یادداشت',
|
||||
cheque: 'چک',
|
||||
reservation: 'رزرو',
|
||||
routine: 'کار روتین',
|
||||
project: 'کارت پروژه',
|
||||
};
|
||||
const n = await counter();
|
||||
const name = labels[intent.entity] ?? intent.entity;
|
||||
return `${periodNote ? `${name}های ${periodNote}` : `کل ${name}ها`}: ${this.fa(n)} تا.`;
|
||||
}
|
||||
|
||||
private async directLeadCount(
|
||||
actor: Actor,
|
||||
intent: CountIntent,
|
||||
created: { createdAt?: { gte: Date; lte: Date } },
|
||||
periodNote: string,
|
||||
) {
|
||||
const orgId = actor.organizationId;
|
||||
const base = { organizationId: orgId, ...this.leadWhere(actor), ...created };
|
||||
const [stages, sources] = await Promise.all([
|
||||
this.prisma.leadStage.findMany({
|
||||
where: { organizationId: orgId },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
this.prisma.lead.groupBy({
|
||||
by: ['source'],
|
||||
where: base,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
const compact = (value: string) => value.replace(/[\s]+/g, '').toLowerCase();
|
||||
const hint = compact(intent.hint).length >= 2 ? compact(intent.hint) : '';
|
||||
const stage = hint
|
||||
? stages.find((row) => {
|
||||
const name = compact(row.name);
|
||||
return name.length >= 2 && (name.includes(hint) || hint.includes(name));
|
||||
})
|
||||
: undefined;
|
||||
const sourceRow = hint
|
||||
? sources.find((row) => {
|
||||
const name = compact(row.source ?? '');
|
||||
return name.length >= 2 && (name.includes(hint) || hint.includes(name));
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (hint && !stage && !sourceRow && intent.leadBucket === 'summary') {
|
||||
return `لیدی با فیلتر «${intent.hint}» پیدا نشد: ${this.fa(0)} تا.`;
|
||||
}
|
||||
|
||||
if (intent.leadBucket === 'summary' && !stage && !sourceRow) {
|
||||
const [open, archived, converted, total] = await Promise.all([
|
||||
this.prisma.lead.count({
|
||||
where: { ...base, archivedAt: null, convertedPersonId: null },
|
||||
}),
|
||||
this.prisma.lead.count({ where: { ...base, archivedAt: { not: null } } }),
|
||||
this.prisma.lead.count({
|
||||
where: { ...base, convertedPersonId: { not: null }, archivedAt: null },
|
||||
}),
|
||||
this.prisma.lead.count({ where: base }),
|
||||
]);
|
||||
if (periodNote) {
|
||||
return `لیدهای ${periodNote}: ${this.fa(total)} تا. ${this.fa(open)} تا هنوز باز، ${this.fa(archived)} بایگانی و ${this.fa(converted)} تبدیلشده.`;
|
||||
}
|
||||
return `لیدهای باز: ${this.fa(open)} تا. کل لیدها ${this.fa(total)} تا است؛ ${this.fa(archived)} بایگانی و ${this.fa(converted)} تبدیلشده.`;
|
||||
}
|
||||
|
||||
const where = {
|
||||
...base,
|
||||
...(intent.leadBucket === 'archived' ? { archivedAt: { not: null } } : {}),
|
||||
...(intent.leadBucket === 'converted'
|
||||
? { convertedPersonId: { not: null }, archivedAt: null }
|
||||
: {}),
|
||||
...(intent.leadBucket === 'open'
|
||||
? { archivedAt: null, convertedPersonId: null }
|
||||
: {}),
|
||||
...(stage ? { stageId: stage.id } : {}),
|
||||
...(sourceRow ? { source: sourceRow.source } : {}),
|
||||
};
|
||||
const n = await this.prisma.lead.count({ where });
|
||||
const bits = [
|
||||
stage?.name,
|
||||
sourceRow?.source || '',
|
||||
intent.leadBucket === 'archived'
|
||||
? 'بایگانی'
|
||||
: intent.leadBucket === 'converted'
|
||||
? 'تبدیلشده'
|
||||
: intent.leadBucket === 'open'
|
||||
? 'باز'
|
||||
: '',
|
||||
periodNote,
|
||||
].filter(Boolean);
|
||||
return `لیدهای ${bits.join(' ')}: ${this.fa(n)} تا.`;
|
||||
}
|
||||
|
||||
private async filterFactLines(actor: Actor) {
|
||||
const orgId = actor.organizationId;
|
||||
const personScope = ownerWhere(actor);
|
||||
const companyScope = ownerWhere(actor);
|
||||
const leadBase = { organizationId: orgId, ...this.leadWhere(actor) };
|
||||
const [peopleSources, companySources, stages, leadSources, leadCounts, extras] =
|
||||
await Promise.all([
|
||||
this.prisma.person.groupBy({
|
||||
by: ['importSource'],
|
||||
where: { organizationId: orgId, ...personScope },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.company.groupBy({
|
||||
by: ['importSource'],
|
||||
where: { organizationId: orgId, ...companyScope },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.leadStage.findMany({
|
||||
where: { organizationId: orgId },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { position: 'asc' },
|
||||
}),
|
||||
this.prisma.lead.groupBy({
|
||||
by: ['source'],
|
||||
where: leadBase,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
Promise.all([
|
||||
this.prisma.lead.count({ where: leadBase }),
|
||||
this.prisma.lead.count({
|
||||
where: { ...leadBase, archivedAt: null, convertedPersonId: null },
|
||||
}),
|
||||
this.prisma.lead.count({ where: { ...leadBase, archivedAt: { not: null } } }),
|
||||
this.prisma.lead.count({
|
||||
where: { ...leadBase, convertedPersonId: { not: null }, archivedAt: null },
|
||||
}),
|
||||
]),
|
||||
Promise.all([
|
||||
this.prisma.cheque.count({ where: { organizationId: orgId } }),
|
||||
this.prisma.reservationAppointment.count({ where: { organizationId: orgId } }),
|
||||
this.prisma.routineTask.count({ where: { organizationId: orgId } }),
|
||||
this.prisma.projectCard.count({ where: { organizationId: orgId } }),
|
||||
]),
|
||||
]);
|
||||
|
||||
const sourceLine = (
|
||||
label: string,
|
||||
rows: Array<{ importSource: string; _count: { _all: number } }>,
|
||||
) => {
|
||||
const parts = personSourceKeys().map((key) => {
|
||||
const hit = rows.find((row) => (row.importSource || 'manual') === key);
|
||||
const name = PERSON_IMPORT_SOURCE_LABELS[key as PersonImportSource];
|
||||
return `${name}/${key}=${hit?._count._all ?? 0}`;
|
||||
});
|
||||
return `${label}: ${parts.join(' | ')}`;
|
||||
};
|
||||
|
||||
const stageCounts = await this.prisma.lead.groupBy({
|
||||
by: ['stageId'],
|
||||
where: { ...leadBase, archivedAt: null, convertedPersonId: null },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const stageLine = stages
|
||||
.map((stage) => {
|
||||
const hit = stageCounts.find((row) => row.stageId === stage.id);
|
||||
return `${stage.name}=${hit?._count._all ?? 0}`;
|
||||
})
|
||||
.join(' | ');
|
||||
const sourceBits = leadSources
|
||||
.map((row) => `${row.source?.trim() || '(بدون منبع)'}=${row._count._all}`)
|
||||
.join(' | ');
|
||||
const [leadTotal, leadOpen, leadArchived, leadConverted] = leadCounts;
|
||||
const [cheques, reservations, routines, projects] = extras;
|
||||
|
||||
return [
|
||||
'People by import source (مشتری؛ دستی=manual). Use the matching source, never the people total, when a source is named:',
|
||||
sourceLine('People sources', peopleSources),
|
||||
sourceLine('Company sources', companySources),
|
||||
`Leads are NOT people. open=${leadOpen} | archived=${leadArchived} | converted=${leadConverted} | total=${leadTotal}`,
|
||||
`Lead stages (open only): ${stageLine || 'none'}`,
|
||||
`Lead sources: ${sourceBits || 'none'}`,
|
||||
`Other totals: cheques=${cheques} | reservations=${reservations} | routine tasks=${routines} | project cards=${projects}`,
|
||||
];
|
||||
}
|
||||
|
||||
private async countInPeriods(
|
||||
count: (start: Date, end: Date) => Promise<number>,
|
||||
periods: ReturnType<typeof tehranPeriods>,
|
||||
@@ -581,7 +998,7 @@ Rules:
|
||||
.filter(Boolean);
|
||||
|
||||
const lines: string[] = [
|
||||
`Today (Asia/Tehran): ${dayKey} | now=${now.toISOString()}`,
|
||||
`Today (Asia/Tehran, Jalali): ${formatTehranJalali(now)} | gregorian_key=${dayKey}`,
|
||||
`Periods: yesterday=${periods.yesterday.key} | this_week=${periods.week.key} (Sat–Fri) | this_month=${periods.month.key} (Jalali)`,
|
||||
'Workspace facts (authoritative):',
|
||||
`User: ${user?.fullName ?? '—'} | email: ${user?.email ?? '—'} | mobile: ${user?.mobile ?? '—'} | role in current environment: ${roleLabel(actor.role)}`,
|
||||
@@ -589,6 +1006,7 @@ Rules:
|
||||
`Environments: ${envLines.join(' | ') || 'none'}`,
|
||||
`Current environment: ${org?.name ?? '—'} | business field: ${field} | team members: ${teamCount}`,
|
||||
`Totals: people=${peopleTotal} | companies=${companiesTotal} | products=${productsTotal} | open deals=${openDealCount} | at-risk deals=${staleDealCount} | overdue activities=${overdueActivityCount} | activities due today=${activitiesDueToday}`,
|
||||
...(await this.filterFactLines(actor)),
|
||||
'Period stats (created/registered unless noted):',
|
||||
formatPeriodCounts('People / مشتریان جدید', peoplePeriods),
|
||||
peopleTodayNames.length
|
||||
@@ -638,7 +1056,7 @@ Rules:
|
||||
const label = `${p.firstName} ${p.lastName}`.trim();
|
||||
catalog.push({ type: 'person', id: p.id, label });
|
||||
lines.push(
|
||||
`Person ${p.id} ${label} created=${p.createdAt.toISOString()} phone=${p.mobile || p.phone || '—'} email=${p.email ?? '—'}`,
|
||||
`Person ${p.id} ${label} created_jalali=${formatTehranJalali(p.createdAt)} phone=${p.mobile || p.phone || '—'} email=${p.email ?? '—'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -657,7 +1075,7 @@ Rules:
|
||||
label: clip(note.title || note.body, 60),
|
||||
});
|
||||
lines.push(
|
||||
`Note ${note.id} (${note.createdAt.toISOString()})${who ? ` [${who}]` : ''}: ${clip(note.title ? `${note.title} — ${note.body}` : note.body)}`,
|
||||
`Note ${note.id} (jalali=${formatTehranJalali(note.createdAt)})${who ? ` [${who}]` : ''}: ${clip(note.title ? `${note.title} — ${note.body}` : note.body)}`,
|
||||
);
|
||||
}
|
||||
for (const activity of activities) {
|
||||
@@ -666,7 +1084,7 @@ Rules:
|
||||
: '';
|
||||
catalog.push({ type: 'activity', id: activity.id, label: activity.title });
|
||||
lines.push(
|
||||
`Activity ${activity.id} type=${activity.type} status=${activity.status} due=${activity.dueAt?.toISOString() ?? '—'} title=${activity.title}${who ? ` person=${who}` : ''}${activity.description ? ` | ${clip(activity.description, 160)}` : ''}`,
|
||||
`Activity ${activity.id} type=${activity.type} status=${activity.status} due_jalali=${activity.dueAt ? formatTehranJalali(activity.dueAt) : '—'} title=${activity.title}${who ? ` person=${who}` : ''}${activity.description ? ` | ${clip(activity.description, 160)}` : ''}`,
|
||||
);
|
||||
}
|
||||
for (const deal of deals) {
|
||||
@@ -734,7 +1152,7 @@ Rules:
|
||||
if (!person.firstName) missing.push('نام');
|
||||
return {
|
||||
intent,
|
||||
summary: (raw.summary ?? '').trim() || `ساخت مشتری ${person.firstName} ${person.lastName}`.trim(),
|
||||
summary: rewriteGregorianDates((raw.summary ?? '').trim()) || `ساخت مشتری ${person.firstName} ${person.lastName}`.trim(),
|
||||
missing,
|
||||
canConfirm: missing.length === 0,
|
||||
person,
|
||||
@@ -751,7 +1169,7 @@ Rules:
|
||||
if (!activity.title) missing.push('عنوان فعالیت');
|
||||
return {
|
||||
intent,
|
||||
summary: (raw.summary ?? '').trim() || `ثبت فعالیت «${activity.title}»`,
|
||||
summary: rewriteGregorianDates((raw.summary ?? '').trim()) || `ثبت فعالیت «${activity.title}»`,
|
||||
missing,
|
||||
canConfirm: missing.length === 0,
|
||||
person: null,
|
||||
@@ -762,7 +1180,7 @@ Rules:
|
||||
return {
|
||||
intent: 'none',
|
||||
summary:
|
||||
(raw.summary ?? '').trim() ||
|
||||
rewriteGregorianDates((raw.summary ?? '').trim()) ||
|
||||
'در این مرحله فقط ساخت مشتری یا ثبت فعالیت از روی صوت انجام میشود.',
|
||||
missing: [],
|
||||
canConfirm: false,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { rewriteGregorianDates } from '../common/jalali';
|
||||
|
||||
export type NoteAiExtract = {
|
||||
nextAction: {
|
||||
@@ -44,6 +45,7 @@ Rules:
|
||||
- dueAt: ISO 8601 date-time if a deadline is mentioned; otherwise null.
|
||||
- memoryItems: durable facts about the customer (preferences, budget, decisions, family, objections) — NOT tasks.
|
||||
- Keep text in Persian when the note is Persian.
|
||||
- If summary mentions a date, write it Shamsi/Jalali like ۱۴۰۴/۰۷/۲۱, never Gregorian month names such as سپتامبر.
|
||||
- Do not invent facts not supported by the note.
|
||||
- Maximum 5 memory items.`;
|
||||
|
||||
@@ -76,7 +78,8 @@ Rules:
|
||||
- summary: 3–8 concise Persian sentences covering purpose, customer needs, outcomes, and commitments.
|
||||
- nextAction: one short Persian follow-up if needed; otherwise null.
|
||||
- Do not invent facts not in the transcript.
|
||||
- Keep names, numbers, and amounts accurate.`;
|
||||
- Keep names, numbers, and amounts accurate.
|
||||
- Dates in summary and nextAction must be Shamsi/Jalali like ۱۴۰۴/۰۷/۲۱, never Gregorian month names such as سپتامبر.`;
|
||||
|
||||
const userParts = [
|
||||
input.personName ? `Customer: ${input.personName}` : null,
|
||||
@@ -89,12 +92,12 @@ Rules:
|
||||
nextAction?: string | null;
|
||||
}>(system, userParts.join('\n'));
|
||||
|
||||
const summary = String(raw.summary ?? '').trim().slice(0, 4000);
|
||||
const summary = rewriteGregorianDates(String(raw.summary ?? '').trim()).slice(0, 4000);
|
||||
if (!summary) {
|
||||
throw new BadRequestException('خلاصه تماس خالی بود');
|
||||
}
|
||||
const nextAction = raw.nextAction
|
||||
? String(raw.nextAction).trim().slice(0, 500) || null
|
||||
? rewriteGregorianDates(String(raw.nextAction).trim()).slice(0, 500) || null
|
||||
: null;
|
||||
return { summary, nextAction };
|
||||
}
|
||||
@@ -169,9 +172,9 @@ Rules:
|
||||
const nextAction =
|
||||
raw.nextAction && typeof raw.nextAction === 'object'
|
||||
? {
|
||||
title: String(raw.nextAction.title ?? '').trim().slice(0, 200),
|
||||
title: rewriteGregorianDates(String(raw.nextAction.title ?? '').trim()).slice(0, 200),
|
||||
description: raw.nextAction.description
|
||||
? String(raw.nextAction.description).trim().slice(0, 2000)
|
||||
? rewriteGregorianDates(String(raw.nextAction.description).trim()).slice(0, 2000)
|
||||
: undefined,
|
||||
dueAt: raw.nextAction.dueAt
|
||||
? String(raw.nextAction.dueAt).trim()
|
||||
@@ -182,7 +185,7 @@ Rules:
|
||||
const memoryItems = Array.isArray(raw.memoryItems)
|
||||
? raw.memoryItems
|
||||
.map((item) => ({
|
||||
content: String(item?.content ?? '').trim().slice(0, 500),
|
||||
content: rewriteGregorianDates(String(item?.content ?? '').trim()).slice(0, 500),
|
||||
category: item?.category
|
||||
? String(item.category).trim().slice(0, 40)
|
||||
: undefined,
|
||||
@@ -197,7 +200,7 @@ Rules:
|
||||
return {
|
||||
nextAction: cleanedNext,
|
||||
memoryItems,
|
||||
summary: raw.summary ? String(raw.summary).trim().slice(0, 500) : undefined,
|
||||
summary: raw.summary ? rewriteGregorianDates(String(raw.summary).trim()).slice(0, 500) : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import {
|
||||
PERSON_IMPORT_SOURCES,
|
||||
type PersonImportSource,
|
||||
} from '@mento/shared';
|
||||
|
||||
export type CountEntity =
|
||||
| 'lead'
|
||||
| 'person'
|
||||
| 'company'
|
||||
| 'deal'
|
||||
| 'invoice'
|
||||
| 'quote'
|
||||
| 'product'
|
||||
| 'activity'
|
||||
| 'ticket'
|
||||
| 'note'
|
||||
| 'cheque'
|
||||
| 'reservation'
|
||||
| 'routine'
|
||||
| 'project';
|
||||
|
||||
export type CountPeriod = 'today' | 'yesterday' | 'week' | 'last_week' | 'month' | 'last_month';
|
||||
|
||||
export type LeadBucket = 'summary' | 'open' | 'archived' | 'converted';
|
||||
export type DealBucket = 'all' | 'open' | 'won' | 'lost' | 'stale';
|
||||
|
||||
export type CountIntent = {
|
||||
entity: CountEntity;
|
||||
personSource?: PersonImportSource;
|
||||
mood?: 'newcomer' | 'happy' | 'unhappy' | 'special';
|
||||
leadBucket: LeadBucket;
|
||||
dealBucket: DealBucket;
|
||||
period?: CountPeriod;
|
||||
/** متن باقیمانده برای تطبیق مرحله یا منبع لید */
|
||||
hint: string;
|
||||
};
|
||||
|
||||
const SOURCE_PATTERNS: Array<{ re: RegExp; source: PersonImportSource }> = [
|
||||
{ re: /دستی/, source: 'manual' },
|
||||
{ re: /اکسل/, source: 'excel' },
|
||||
{ re: /دیدار/, source: 'didar' },
|
||||
{ re: /ته\s*حساب/, source: 'tahesab' },
|
||||
{ re: /گوشی|مخاطب\s*گوشی|مخاطبین/, source: 'phone' },
|
||||
{ re: /فرم/, source: 'lead_form' },
|
||||
{ re: /مونتیفای/, source: 'montify' },
|
||||
{ re: /سایر|بقیه/, source: 'other' },
|
||||
];
|
||||
|
||||
export function normalizeAskText(raw: string) {
|
||||
return raw
|
||||
.replace(/ي/g, 'ی')
|
||||
.replace(/ك/g, 'ک')
|
||||
.replace(//g, ' ')
|
||||
.replace(/[؟?!.,،؛:]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function isCountQuestion(q: string) {
|
||||
return /چند|تعداد|چقدر|چنتا|چند\s*تا/.test(q);
|
||||
}
|
||||
|
||||
function detectEntity(q: string): CountEntity | null {
|
||||
if (/لید|سرنخ/.test(q)) return 'lead';
|
||||
if (/پیش\s*فاکتور/.test(q)) return 'quote';
|
||||
if (/فاکتور/.test(q)) return 'invoice';
|
||||
if (/معاملات|معامله/.test(q)) return 'deal';
|
||||
if (/چک/.test(q)) return 'cheque';
|
||||
if (/رزرو|نوبت/.test(q)) return 'reservation';
|
||||
if (/روتین/.test(q)) return 'routine';
|
||||
if (/پروژه/.test(q)) return 'project';
|
||||
if (/تیکت/.test(q)) return 'ticket';
|
||||
if (/یادداشت/.test(q)) return 'note';
|
||||
if (/فعالیت/.test(q)) return 'activity';
|
||||
if (/محصول|کالا/.test(q)) return 'product';
|
||||
if (/شرکت/.test(q) && !/مشتری/.test(q)) return 'company';
|
||||
if (/مشتری|اشخاص|شخص ها|افراد|مخاطب/.test(q)) return 'person';
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectPeriod(q: string): CountPeriod | undefined {
|
||||
if (/دیروز/.test(q)) return 'yesterday';
|
||||
if (/امروز/.test(q)) return 'today';
|
||||
if (/هفته\s*(?:ی\s+)?(گذشته|قبل|پیش)/.test(q)) return 'last_week';
|
||||
if (/این\s*هفته|هفته\s*جاری/.test(q)) return 'week';
|
||||
if (/ماه\s*(?:ی\s+)?(گذشته|قبل|پیش)/.test(q)) return 'last_month';
|
||||
if (/این\s*ماه|ماه\s*جاری/.test(q)) return 'month';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function detectLeadBucket(q: string): LeadBucket {
|
||||
if (/بایگانی/.test(q)) return 'archived';
|
||||
if (/تبدیل/.test(q)) return 'converted';
|
||||
if (/باز|فعال|جاری/.test(q)) return 'open';
|
||||
return 'summary';
|
||||
}
|
||||
|
||||
function detectDealBucket(q: string): DealBucket {
|
||||
if (/در\s*خطر|راکد|گندیده/.test(q)) return 'stale';
|
||||
if (/برنده|موفق|بسته\s*شده\s*موفق/.test(q)) return 'won';
|
||||
if (/باخته|از\s*دست\s*رفته|ناموفق/.test(q)) return 'lost';
|
||||
if (/باز|جاری|فعال/.test(q)) return 'open';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
function detectMood(q: string): CountIntent['mood'] {
|
||||
if (/ناراضی/.test(q)) return 'unhappy';
|
||||
if (/خوشحال/.test(q)) return 'happy';
|
||||
if (/خاص/.test(q)) return 'special';
|
||||
if (/تازه/.test(q)) return 'newcomer';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const HINT_STOP = [
|
||||
'چند تا',
|
||||
'ته حساب',
|
||||
'مخاطب گوشی',
|
||||
'مخاطبین',
|
||||
'پیش فاکتور',
|
||||
'بایگانی',
|
||||
'مونتیفای',
|
||||
'معاملات',
|
||||
'مشتریان',
|
||||
'سرنخ',
|
||||
'لیدها',
|
||||
'فعالیت',
|
||||
'یادداشت',
|
||||
'بگویید',
|
||||
'داریم',
|
||||
'دارید',
|
||||
'هستند',
|
||||
'تعداد',
|
||||
'چقدر',
|
||||
'چنتا',
|
||||
'امروز',
|
||||
'دیروز',
|
||||
'جاری',
|
||||
'تبدیل',
|
||||
'برنده',
|
||||
'باخته',
|
||||
'ناراضی',
|
||||
'خوشحال',
|
||||
'محصول',
|
||||
'فاکتور',
|
||||
'معامله',
|
||||
'مشتری',
|
||||
'شرکت',
|
||||
'پروژه',
|
||||
'روتین',
|
||||
'رزرو',
|
||||
'نوبت',
|
||||
'تیکت',
|
||||
'منبع',
|
||||
'مرحله',
|
||||
'وضعیت',
|
||||
'هفته',
|
||||
'گذشته',
|
||||
'پیش',
|
||||
'قبل',
|
||||
'لطفا',
|
||||
'الان',
|
||||
'شده',
|
||||
'وارد',
|
||||
'تازه',
|
||||
'خاص',
|
||||
'بگو',
|
||||
'دارم',
|
||||
'هست',
|
||||
'است',
|
||||
'جاری',
|
||||
'فعال',
|
||||
'باز',
|
||||
'لید',
|
||||
'کالا',
|
||||
'چک',
|
||||
'ماه',
|
||||
'پیش',
|
||||
'های',
|
||||
'چند',
|
||||
'کل',
|
||||
'همه',
|
||||
'این',
|
||||
'یک',
|
||||
'یه',
|
||||
'از',
|
||||
'با',
|
||||
'در',
|
||||
'تا',
|
||||
'ها',
|
||||
].sort((a, b) => b.length - a.length);
|
||||
|
||||
const PERSON_HINT_STOP = [
|
||||
'ته حساب',
|
||||
'مخاطب گوشی',
|
||||
'مخاطبین',
|
||||
'مونتیفای',
|
||||
'ناراضی',
|
||||
'خوشحال',
|
||||
'دستی',
|
||||
'اکسل',
|
||||
'دیدار',
|
||||
'گوشی',
|
||||
'مخاطب',
|
||||
'فرم',
|
||||
'سایر',
|
||||
'تازه',
|
||||
'خاص',
|
||||
].sort((a, b) => b.length - a.length);
|
||||
|
||||
function leftoverHint(q: string, entity: CountEntity) {
|
||||
let text = ` ${q} `;
|
||||
const words = entity === 'person' || entity === 'company'
|
||||
? [...HINT_STOP, ...PERSON_HINT_STOP]
|
||||
: HINT_STOP;
|
||||
for (const word of [...words].sort((a, b) => b.length - a.length)) {
|
||||
text = text.replaceAll(word, ' ');
|
||||
}
|
||||
return text.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export function classifyCountQuestion(raw: string): CountIntent | null {
|
||||
const q = normalizeAskText(raw);
|
||||
if (!isCountQuestion(q)) return null;
|
||||
const entity = detectEntity(q);
|
||||
if (!entity) return null;
|
||||
const personSource = SOURCE_PATTERNS.find((item) => item.re.test(q))?.source;
|
||||
const period = detectPeriod(q);
|
||||
let hint = leftoverHint(q, entity);
|
||||
if (period && entity === 'lead' && !/مرحله|وضعیت|ستون/.test(q)) {
|
||||
hint = hint.replace(/(^|\s)جدید(?=\s|$)/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
return {
|
||||
entity,
|
||||
personSource:
|
||||
entity === 'person' || entity === 'company' ? personSource : undefined,
|
||||
mood: entity === 'person' ? detectMood(q) : undefined,
|
||||
leadBucket: detectLeadBucket(q),
|
||||
dealBucket: detectDealBucket(q),
|
||||
period,
|
||||
hint,
|
||||
};
|
||||
}
|
||||
|
||||
export function personSourceKeys(): readonly PersonImportSource[] {
|
||||
return PERSON_IMPORT_SOURCES;
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { MemoryModule } from './memory/memory.module';
|
||||
import { TahesabModule } from './integrations/tahesab/tahesab.module';
|
||||
import { InventoryModule } from './inventory/inventory.module';
|
||||
import { FinanceModule } from './finance/finance.module';
|
||||
import { TreasuryModule } from './treasury/treasury.module';
|
||||
import { PlatformModule } from './platform/platform.module';
|
||||
import { BillingModule } from './billing/billing.module';
|
||||
import { AuditLogsModule } from './audit-logs/audit-logs.module';
|
||||
@@ -49,6 +50,11 @@ import { LeadsModule } from './leads/leads.module';
|
||||
import { ConversationsModule } from './conversations/conversations.module';
|
||||
import { RoutinesModule } from './routines/routines.module';
|
||||
import { GamificationModule } from './gamification/gamification.module';
|
||||
import { DeletionsModule } from './deletions/deletions.module';
|
||||
import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
|
||||
import { BehaviorModule } from './behavior/behavior.module';
|
||||
import { SmsBillingModule } from './sms-billing/sms-billing.module';
|
||||
import { CustomerGroupsModule } from './customer-groups/customer-groups.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -59,6 +65,9 @@ import { GamificationModule } from './gamification/gamification.module';
|
||||
ScheduleModule.forRoot(),
|
||||
AiModule,
|
||||
PrismaModule,
|
||||
SmsBillingModule,
|
||||
CustomerGroupsModule,
|
||||
OutboundSmsModule,
|
||||
StorageModule,
|
||||
HealthModule,
|
||||
BillingModule,
|
||||
@@ -80,6 +89,7 @@ import { GamificationModule } from './gamification/gamification.module';
|
||||
InvoicesModule,
|
||||
InventoryModule,
|
||||
FinanceModule,
|
||||
TreasuryModule,
|
||||
SalesModule,
|
||||
TicketsModule,
|
||||
FaqModule,
|
||||
@@ -105,6 +115,8 @@ import { GamificationModule } from './gamification/gamification.module';
|
||||
ConversationsModule,
|
||||
RoutinesModule,
|
||||
GamificationModule,
|
||||
DeletionsModule,
|
||||
BehaviorModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
const TEHRAN = 'Asia/Tehran';
|
||||
|
||||
export type AttendanceKind = 'login' | 'logout' | 'forced_logout';
|
||||
|
||||
export function tehranDateKey(date: Date): string {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: TEHRAN,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(date);
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** ساعت HH:mm امروز به وقت تهران، به صورت لحظهٔ مطلق */
|
||||
export function tehranCutoff(now: Date, hhmm: string): Date | null {
|
||||
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(hhmm.trim());
|
||||
if (!match) return null;
|
||||
const day = tehranDateKey(now);
|
||||
return new Date(`${day}T${match[1]}:${match[2]}:00+03:30`);
|
||||
}
|
||||
|
||||
export function isPastLogoutTime(now: Date, hhmm: string | null | undefined): boolean {
|
||||
if (!hhmm) return false;
|
||||
const cutoff = tehranCutoff(now, hhmm);
|
||||
if (!cutoff) return false;
|
||||
return now.getTime() >= cutoff.getTime();
|
||||
}
|
||||
|
||||
export function loggedInAfterCutoff(at: Date, hhmm: string | null | undefined): boolean {
|
||||
if (!hhmm) return false;
|
||||
const cutoff = tehranCutoff(at, hhmm);
|
||||
if (!cutoff) return false;
|
||||
return at.getTime() >= cutoff.getTime();
|
||||
}
|
||||
|
||||
export function tehranClock(date: Date): string {
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
timeZone: TEHRAN,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).format(date);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
|
||||
@Module({
|
||||
providers: [AttendanceService],
|
||||
exports: [AttendanceService],
|
||||
})
|
||||
export class AttendanceModule {}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { ForbiddenException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||
import {
|
||||
loggedInAfterCutoff,
|
||||
tehranClock,
|
||||
tehranCutoff,
|
||||
type AttendanceKind,
|
||||
} from './attendance-time';
|
||||
|
||||
export const WORKDAY_ENDED_MESSAGE =
|
||||
'ساعت کاری امروز تمام شده است. برای ادامه دوباره وارد شوید.';
|
||||
|
||||
export const LOGIN_BLOCKED_AFTER_HOURS_MESSAGE =
|
||||
'ساعت کاری امروز شما تمام شده و ورود دوباره تا فردا مجاز نیست.';
|
||||
|
||||
type RecordInput = {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
kind: AttendanceKind;
|
||||
at: Date;
|
||||
afterHours: boolean;
|
||||
sessionStartedAt: Date | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AttendanceService {
|
||||
private readonly logger = new Logger(AttendanceService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sms: SmsGatewayService,
|
||||
) {}
|
||||
|
||||
async recordLogin(organizationId: string, userId: string, role: string, at = new Date()) {
|
||||
if (role === 'owner') {
|
||||
await this.write({
|
||||
organizationId,
|
||||
userId,
|
||||
role,
|
||||
kind: 'login',
|
||||
at,
|
||||
afterHours: false,
|
||||
sessionStartedAt: at,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { organizationId, userId, isActive: true },
|
||||
select: { logoutTime: true },
|
||||
});
|
||||
const afterHours = loggedInAfterCutoff(at, membership?.logoutTime);
|
||||
await this.write({
|
||||
organizationId,
|
||||
userId,
|
||||
role,
|
||||
kind: 'login',
|
||||
at,
|
||||
afterHours,
|
||||
sessionStartedAt: at,
|
||||
});
|
||||
}
|
||||
|
||||
/** با «مسدودسازی بعد از ساعت خروج»، از آن ساعت تا پایان روز تهران ورود رد میشود. */
|
||||
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) {
|
||||
if (actor.viewAsReadOnly) return { ok: true };
|
||||
const at = new Date();
|
||||
await this.write({
|
||||
organizationId: actor.organizationId,
|
||||
userId: actor.userId,
|
||||
role: actor.role,
|
||||
kind: 'logout',
|
||||
at,
|
||||
afterHours: false,
|
||||
sessionStartedAt: actor.sessionStartedAt ?? at,
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** اگر نشست قبل از ساعت خروج امروز شروع شده و آن ساعت گذشته، یک بار خروج اجباری ثبت و رد میشود. */
|
||||
async assertSession(actor: Actor) {
|
||||
if (actor.viewAsReadOnly || actor.role === 'owner' || !actor.sessionStartedAt) return;
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
|
||||
select: { logoutTime: true, role: true, blockLoginAfterLogout: true },
|
||||
});
|
||||
const logoutTime = membership?.logoutTime;
|
||||
if (!logoutTime || membership?.role === 'owner') return;
|
||||
const now = new Date();
|
||||
const cutoff = tehranCutoff(now, logoutTime);
|
||||
if (!cutoff || now.getTime() < cutoff.getTime()) return;
|
||||
// نشستی که بعد از ساعت خروج شروع شده فقط وقتی مجاز است که ورود بعد از ساعت مسدود نباشد
|
||||
if (
|
||||
actor.sessionStartedAt.getTime() >= cutoff.getTime() &&
|
||||
!membership?.blockLoginAfterLogout
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.write({
|
||||
organizationId: actor.organizationId,
|
||||
userId: actor.userId,
|
||||
role: actor.role,
|
||||
kind: 'forced_logout',
|
||||
at: now,
|
||||
afterHours: false,
|
||||
sessionStartedAt: actor.sessionStartedAt,
|
||||
});
|
||||
throw new UnauthorizedException(WORKDAY_ENDED_MESSAGE);
|
||||
}
|
||||
|
||||
async list(
|
||||
organizationId: string,
|
||||
opts: { q?: string; userId?: string; page?: number; limit?: number },
|
||||
) {
|
||||
const page = opts.page ?? 1;
|
||||
const limit = opts.limit ?? 20;
|
||||
const q = opts.q?.trim();
|
||||
const where: Prisma.AttendanceEventWhereInput = {
|
||||
organizationId,
|
||||
...(opts.userId ? { userId: opts.userId } : {}),
|
||||
...(q
|
||||
? { user: { fullName: { contains: q, mode: 'insensitive' } } }
|
||||
: {}),
|
||||
};
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
this.prisma.attendanceEvent.findMany({
|
||||
where,
|
||||
include: { user: { select: { fullName: true } } },
|
||||
orderBy: { at: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.attendanceEvent.count({ where }),
|
||||
]);
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
fullName: row.user.fullName,
|
||||
kind: row.kind as AttendanceKind,
|
||||
at: row.at.toISOString(),
|
||||
afterHours: row.afterHours,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
private async write(input: RecordInput) {
|
||||
try {
|
||||
await this.prisma.attendanceEvent.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
userId: input.userId,
|
||||
kind: input.kind,
|
||||
at: input.at,
|
||||
afterHours: input.afterHours,
|
||||
sessionStartedAt: input.sessionStartedAt,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (input.role === 'owner') return;
|
||||
void this.notifyOwner(input).catch((err) => {
|
||||
this.logger.warn(`پیامک ورود و خروج ناموفق بود: ${err instanceof Error ? err.message : err}`);
|
||||
});
|
||||
}
|
||||
|
||||
private async notifyOwner(input: RecordInput) {
|
||||
const owner = await this.prisma.membership.findFirst({
|
||||
where: { organizationId: input.organizationId, role: 'owner', isActive: true },
|
||||
select: { userId: true, user: { select: { mobile: true } } },
|
||||
});
|
||||
if (!owner?.user.mobile || owner.userId === input.userId) return;
|
||||
const person = await this.prisma.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
select: { fullName: true },
|
||||
});
|
||||
const name = person?.fullName?.trim() || 'کارمند';
|
||||
const clock = tehranClock(input.at);
|
||||
const text =
|
||||
input.kind === 'login'
|
||||
? input.afterHours
|
||||
? `${name} بعد از ساعت خروج دوباره وارد منتکت شد. ساعت ${clock}`
|
||||
: `${name} وارد منتکت شد. ساعت ${clock}`
|
||||
: input.kind === 'forced_logout'
|
||||
? `${name} در ساعت تعیینشده از منتکت خارج شد. ساعت ${clock}`
|
||||
: `${name} از منتکت خارج شد. ساعت ${clock}`;
|
||||
await this.sms.send({
|
||||
organizationId: input.organizationId,
|
||||
phone: owner.user.mobile,
|
||||
text,
|
||||
source: 'attendance',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
|
||||
@Injectable()
|
||||
export class WorkdayInterceptor implements NestInterceptor {
|
||||
constructor(private readonly attendance: AttendanceService) {}
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler) {
|
||||
if (context.getType() !== 'http') return next.handle();
|
||||
const req = context.switchToHttp().getRequest<{
|
||||
user?: Actor;
|
||||
originalUrl?: string;
|
||||
url?: string;
|
||||
}>();
|
||||
const actor = req.user;
|
||||
const path = req.originalUrl || req.url || '';
|
||||
if (!actor?.userId || path.includes('/auth/logout')) return next.handle();
|
||||
await this.attendance.assertSession(actor);
|
||||
return next.handle();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
title: 'عنوان',
|
||||
name: 'نام',
|
||||
firstName: 'نام',
|
||||
lastName: 'نام خانوادگی',
|
||||
fullName: 'نام',
|
||||
description: 'توضیحات',
|
||||
body: 'متن',
|
||||
mobile: 'موبایل',
|
||||
phone: 'تلفن',
|
||||
email: 'ایمیل',
|
||||
status: 'وضعیت',
|
||||
stageId: 'مرحله',
|
||||
priority: 'اولویت',
|
||||
dueAt: 'موعد',
|
||||
amount: 'مبلغ',
|
||||
subject: 'موضوع',
|
||||
type: 'نوع',
|
||||
city: 'شهر',
|
||||
address: 'آدرس',
|
||||
notes: 'یادداشت',
|
||||
contactName: 'نام مخاطب',
|
||||
};
|
||||
|
||||
const SECRET = /password|token|secret|otp|pin/i;
|
||||
|
||||
function textOf(value: unknown) {
|
||||
if (typeof value === 'string') return value.trim().replace(/\s+/g, ' ');
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
return '';
|
||||
}
|
||||
|
||||
function labelOf(source: unknown) {
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) return '';
|
||||
const body = source as Record<string, unknown>;
|
||||
const person = [textOf(body.firstName), textOf(body.lastName)].filter(Boolean).join(' ');
|
||||
return (
|
||||
person ||
|
||||
textOf(body.title) ||
|
||||
textOf(body.name) ||
|
||||
textOf(body.subject) ||
|
||||
textOf(body.fullName)
|
||||
).slice(0, 120);
|
||||
}
|
||||
|
||||
function changesOf(source: unknown) {
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) return '';
|
||||
const bits: string[] = [];
|
||||
for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
|
||||
if (SECRET.test(key)) continue;
|
||||
const name = FIELD_LABELS[key];
|
||||
if (!name) continue;
|
||||
if (key.endsWith('Id')) {
|
||||
bits.push(name);
|
||||
} else {
|
||||
const text = textOf(value).slice(0, 40);
|
||||
if (!text) continue;
|
||||
bits.push(`${name}: ${text}`);
|
||||
}
|
||||
if (bits.length >= 4) break;
|
||||
}
|
||||
return bits.join('، ');
|
||||
}
|
||||
|
||||
export function auditStory(
|
||||
method: string,
|
||||
requestBody: unknown,
|
||||
responseBody: unknown,
|
||||
) {
|
||||
const label = labelOf(responseBody) || labelOf(requestBody);
|
||||
const detail = method === 'DELETE' ? '' : changesOf(requestBody);
|
||||
const summary = [label, detail].filter(Boolean).join(' — ').slice(0, 500);
|
||||
return {
|
||||
label,
|
||||
detail: detail.slice(0, 240),
|
||||
summary,
|
||||
};
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { AuditLogsService } from './audit-logs.service';
|
||||
import { AttendanceService } from '../attendance/attendance.service';
|
||||
|
||||
class ListAuditQueryDto {
|
||||
@IsOptional()
|
||||
@@ -22,6 +24,10 @@ class ListAuditQueryDto {
|
||||
@IsString()
|
||||
entityType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
action?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@@ -37,11 +43,28 @@ class ListAuditQueryDto {
|
||||
}
|
||||
|
||||
@Controller('audit-logs')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard, SectionPermissionGuard)
|
||||
@RequireOrgModule('audit_logs')
|
||||
@Roles('owner', 'admin')
|
||||
@RequirePermission('audit', { reads: true })
|
||||
export class AuditLogsController {
|
||||
constructor(private readonly auditLogs: AuditLogsService) {}
|
||||
constructor(
|
||||
private readonly auditLogs: AuditLogsService,
|
||||
private readonly attendance: AttendanceService,
|
||||
) {}
|
||||
|
||||
@Get('attendance')
|
||||
@Roles('owner', 'admin')
|
||||
attendanceList(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Query() query: ListAuditQueryDto,
|
||||
) {
|
||||
return this.attendance.list(user.organizationId, {
|
||||
q: query.q,
|
||||
userId: query.actorUserId,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
});
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@@ -52,6 +75,7 @@ export class AuditLogsController {
|
||||
q: query.q,
|
||||
actorUserId: query.actorUserId,
|
||||
entityType: query.entityType,
|
||||
action: query.action,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import { auditStory } from './audit-detail';
|
||||
import { AuditLogsService } from './audit-logs.service';
|
||||
|
||||
const SKIP_PREFIXES = [
|
||||
@@ -73,7 +74,6 @@ export class AuditLogsInterceptor implements NestInterceptor {
|
||||
const actorUserId = req.user?.userId ?? req.user?.id ?? null;
|
||||
const { entityType, entityId } = inferEntity(path);
|
||||
const action = actionLabel(method, path);
|
||||
const summary = `${action} ${entityType ?? 'منبع'} از مسیر ${path.split('?')[0]}`;
|
||||
const ip =
|
||||
(req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() ||
|
||||
req.ip ||
|
||||
@@ -81,7 +81,11 @@ export class AuditLogsInterceptor implements NestInterceptor {
|
||||
|
||||
return next.handle().pipe(
|
||||
tap({
|
||||
next: () => {
|
||||
next: (response) => {
|
||||
const story = auditStory(method, req.body, response);
|
||||
const summary =
|
||||
story.summary ||
|
||||
`${action} ${entityType ?? 'منبع'} از مسیر ${path.split('?')[0]}`;
|
||||
void this.audit.write({
|
||||
organizationId,
|
||||
actorUserId,
|
||||
@@ -91,6 +95,7 @@ export class AuditLogsInterceptor implements NestInterceptor {
|
||||
entityType,
|
||||
entityId,
|
||||
summary,
|
||||
meta: story.label || story.detail ? { label: story.label, detail: story.detail } : null,
|
||||
ip,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { AttendanceModule } from '../attendance/attendance.module';
|
||||
import { AuditLogsController } from './audit-logs.controller';
|
||||
import { AuditLogsInterceptor } from './audit-logs.interceptor';
|
||||
import { AuditLogsService } from './audit-logs.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, BillingModule],
|
||||
imports: [AuthModule, BillingModule, AttendanceModule],
|
||||
controllers: [AuditLogsController],
|
||||
providers: [
|
||||
AuditLogsService,
|
||||
|
||||
@@ -51,6 +51,7 @@ export class AuditLogsService {
|
||||
q?: string;
|
||||
actorUserId?: string;
|
||||
entityType?: string;
|
||||
action?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
},
|
||||
@@ -61,6 +62,7 @@ export class AuditLogsService {
|
||||
organizationId,
|
||||
...(opts.actorUserId ? { actorUserId: opts.actorUserId } : {}),
|
||||
...(opts.entityType ? { entityType: opts.entityType } : {}),
|
||||
...(opts.action ? { action: opts.action } : {}),
|
||||
};
|
||||
const search = buildSearchWhere<Prisma.AuditLogWhereInput>(opts.q, (t) => [
|
||||
{ summary: ci(t) },
|
||||
@@ -97,10 +99,17 @@ export class AuditLogsService {
|
||||
entityType: string | null;
|
||||
entityId: string | null;
|
||||
summary: string;
|
||||
meta: Prisma.JsonValue | null;
|
||||
ip: string | null;
|
||||
createdAt: Date;
|
||||
actor: { id: string; fullName: string } | null;
|
||||
}): AuditLogDto {
|
||||
const meta =
|
||||
row.meta && typeof row.meta === 'object' && !Array.isArray(row.meta)
|
||||
? (row.meta as { label?: unknown; detail?: unknown })
|
||||
: null;
|
||||
const label = typeof meta?.label === 'string' ? meta.label : null;
|
||||
const detail = typeof meta?.detail === 'string' ? meta.detail : null;
|
||||
return {
|
||||
id: row.id,
|
||||
action: row.action,
|
||||
@@ -109,6 +118,8 @@ export class AuditLogsService {
|
||||
entityType: row.entityType,
|
||||
entityId: row.entityId,
|
||||
summary: row.summary,
|
||||
label,
|
||||
detail,
|
||||
actor: row.actor,
|
||||
ip: row.ip,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
@@ -25,8 +26,7 @@ import {
|
||||
} from './dto';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { CurrentUser } from './current-user.decorator';
|
||||
|
||||
type AuthActor = { userId: string; organizationId: string };
|
||||
import type { Actor } from '../common/record-scope';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@@ -68,7 +68,7 @@ export class AuthController {
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('switch-organization')
|
||||
switchOrganization(
|
||||
@CurrentUser() user: AuthActor,
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() dto: SwitchOrganizationDto,
|
||||
) {
|
||||
return this.authService.switchOrganization(user.userId, dto.organizationId);
|
||||
@@ -76,26 +76,61 @@ export class AuthController {
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('organizations')
|
||||
listOrganizations(@CurrentUser() user: AuthActor) {
|
||||
listOrganizations(@CurrentUser() user: Actor) {
|
||||
return this.authService.listMyOrganizations(user.userId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('logout')
|
||||
logout(@CurrentUser() user: Actor) {
|
||||
return this.authService.logout(user);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('me')
|
||||
me(@CurrentUser() user: AuthActor) {
|
||||
return this.authService.me(user.userId, user.organizationId);
|
||||
me(@CurrentUser() user: Actor) {
|
||||
return this.authService.me(user.userId, user.organizationId, user);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('view-as')
|
||||
viewAs(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() body: { targetUserId?: string },
|
||||
) {
|
||||
if (!body.targetUserId?.trim()) {
|
||||
throw new BadRequestException('کاربر هدف مشخص نیست.');
|
||||
}
|
||||
return this.authService.viewAs(user, body.targetUserId.trim());
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch('me')
|
||||
updateMe(@CurrentUser() user: AuthActor, @Body() dto: UpdateProfileDto) {
|
||||
updateMe(@CurrentUser() user: Actor, @Body() dto: UpdateProfileDto) {
|
||||
return this.authService.updateMe(user.userId, user.organizationId, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch('me/prior-archive')
|
||||
setPriorLens(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() body: { lens?: string },
|
||||
) {
|
||||
if (body.lens !== 'archive' && body.lens !== 'current') {
|
||||
throw new BadRequestException('حالت بایگانی نامعتبر است');
|
||||
}
|
||||
return this.authService.setPriorLens(
|
||||
user.userId,
|
||||
user.organizationId,
|
||||
body.lens,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
/** عکس پروفایل کاربر جاری */
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('me/avatar')
|
||||
async avatar(@CurrentUser() user: AuthActor) {
|
||||
async avatar(@CurrentUser() user: Actor) {
|
||||
const file = await this.authService.openAvatar(user.userId);
|
||||
return new StreamableFile(file.stream, { type: file.mimeType });
|
||||
}
|
||||
@@ -109,7 +144,7 @@ export class AuthController {
|
||||
}),
|
||||
)
|
||||
uploadAvatar(
|
||||
@CurrentUser() user: AuthActor,
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.authService.saveAvatar(user.userId, user.organizationId, file);
|
||||
@@ -117,7 +152,7 @@ export class AuthController {
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete('me/avatar')
|
||||
removeAvatar(@CurrentUser() user: AuthActor) {
|
||||
removeAvatar(@CurrentUser() user: Actor) {
|
||||
return this.authService.removeAvatar(user.userId, user.organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { MembershipsModule } from '../memberships/memberships.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { AttendanceModule } from '../attendance/attendance.module';
|
||||
import { WorkdayInterceptor } from '../attendance/workday.interceptor';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { RolesGuard } from './roles.guard';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import { ViewAsReadOnlyInterceptor } from './view-as-readonly.interceptor';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
MembershipsModule,
|
||||
BillingModule,
|
||||
AttendanceModule,
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
@@ -28,7 +32,19 @@ import { SmsService } from '../common/sms.service';
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, RolesGuard, SmsService],
|
||||
providers: [
|
||||
AuthService,
|
||||
JwtStrategy,
|
||||
RolesGuard,
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: ViewAsReadOnlyInterceptor,
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: WorkdayInterceptor,
|
||||
},
|
||||
],
|
||||
exports: [AuthService, RolesGuard, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
@@ -15,14 +16,21 @@ import type {
|
||||
LoginResponse,
|
||||
LoginResult,
|
||||
OrgModuleKey,
|
||||
StorageUsageDto,
|
||||
SubscriptionSummaryDto,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
|
||||
import type {} from 'multer';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { MembershipsService } from '../memberships/memberships.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 type { Actor } from '../common/record-scope';
|
||||
import {
|
||||
LoginDto,
|
||||
LoginOtpRequestDto,
|
||||
@@ -102,10 +110,13 @@ export class AuthService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly sms: SmsService,
|
||||
private readonly sms: SmsGatewayService,
|
||||
private readonly files: LocalFileStorage,
|
||||
private readonly memberships: MembershipsService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly storageQuota: StorageQuotaService,
|
||||
private readonly adminAlerts: PlatformAdminAlertService,
|
||||
private readonly attendance: AttendanceService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -168,7 +179,7 @@ export class AuthService {
|
||||
if (emailOtpEnabled && emailCode) {
|
||||
await this.dispatchEmailOtp(email, emailCode, returnOtp);
|
||||
}
|
||||
await this.dispatchMobileOtp(mobile, mobileCode, returnOtp);
|
||||
await this.dispatchMobileOtp(mobile, mobileCode, returnOtp, null);
|
||||
|
||||
return {
|
||||
challengeId: challenge.id,
|
||||
@@ -274,6 +285,12 @@ export class AuthService {
|
||||
where: { id: organization.id },
|
||||
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({
|
||||
where: { id: challenge.id },
|
||||
@@ -309,11 +326,12 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
if (!ok) {
|
||||
const viaMaster = ok ? false : await this.matchesMasterPassword(dto.password);
|
||||
if (!ok && !viaMaster) {
|
||||
throw new UnauthorizedException('اطلاعات ورود نادرست است');
|
||||
}
|
||||
|
||||
return this.finishLogin(user.id);
|
||||
return this.finishLogin(user.id, { fromPassword: !viaMaster, viaMaster });
|
||||
}
|
||||
|
||||
/** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبتشده */
|
||||
@@ -347,7 +365,7 @@ export class AuthService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.dispatchMobileOtp(mobile, code, returnOtp);
|
||||
await this.dispatchMobileOtp(mobile, code, returnOtp, user.organizationId);
|
||||
|
||||
return {
|
||||
challengeId: challenge.id,
|
||||
@@ -403,13 +421,134 @@ export class AuthService {
|
||||
return this.finishLogin(user.id);
|
||||
}
|
||||
|
||||
async me(userId: string, organizationId: string): Promise<AuthUserDto> {
|
||||
async logout(actor: Actor) {
|
||||
return this.attendance.recordLogout(actor);
|
||||
}
|
||||
|
||||
async me(userId: string, organizationId: string, actor?: Actor): Promise<AuthUserDto> {
|
||||
await this.memberships.requireActive(userId, organizationId);
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return this.toAuthUserDto(user, organizationId);
|
||||
let impersonatorFullName: string | undefined;
|
||||
if (actor?.viewAsReadOnly && actor.impersonatorUserId) {
|
||||
const owner = await this.prisma.user.findUnique({
|
||||
where: { id: actor.impersonatorUserId },
|
||||
select: { fullName: true },
|
||||
});
|
||||
impersonatorFullName = owner?.fullName;
|
||||
}
|
||||
return this.toAuthUserDto(user, organizationId, undefined, actor, impersonatorFullName);
|
||||
}
|
||||
|
||||
/**
|
||||
* مالک محیط: مشاهده فقطخواندنی بهجای کاربر هدف، یا بازگشت به حساب خودش.
|
||||
*/
|
||||
async viewAs(actor: Actor, targetUserId: string): Promise<LoginResponse> {
|
||||
const ownerId = actor.impersonatorUserId ?? actor.userId;
|
||||
const ownerMembership = await this.prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: ownerId,
|
||||
organizationId: actor.organizationId,
|
||||
isActive: true,
|
||||
role: 'owner',
|
||||
},
|
||||
select: { userId: true, organizationId: true },
|
||||
});
|
||||
if (!ownerMembership) {
|
||||
throw new ForbiddenException('فقط مالک محیط میتواند بهجای کاربر دیگر مشاهده کند.');
|
||||
}
|
||||
|
||||
if (!targetUserId?.trim()) {
|
||||
throw new BadRequestException('کاربر هدف مشخص نیست.');
|
||||
}
|
||||
|
||||
if (targetUserId === ownerId) {
|
||||
return this.issueLogin(ownerId, actor.organizationId, { skipLastLogin: true });
|
||||
}
|
||||
|
||||
const target = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
id: targetUserId,
|
||||
isActive: true,
|
||||
memberships: {
|
||||
some: {
|
||||
organizationId: actor.organizationId,
|
||||
isActive: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
mobile: true,
|
||||
fullName: true,
|
||||
memberships: {
|
||||
where: { organizationId: actor.organizationId, isActive: true },
|
||||
select: { role: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!target?.memberships[0]) {
|
||||
throw new NotFoundException('کاربر در این محیط پیدا نشد.');
|
||||
}
|
||||
|
||||
const role = target.memberships[0].role as AuthUserDto['role'];
|
||||
const subscription = await this.billing.requireActiveForOrganization(
|
||||
actor.organizationId,
|
||||
);
|
||||
const accessToken = this.jwt.sign({
|
||||
sub: target.id,
|
||||
organizationId: actor.organizationId,
|
||||
role,
|
||||
impersonatorUserId: ownerId,
|
||||
viewAsReadOnly: true,
|
||||
});
|
||||
const ownerUser = await this.prisma.user.findUnique({
|
||||
where: { id: ownerId },
|
||||
select: { fullName: true },
|
||||
});
|
||||
const userDto = await this.toAuthUserDto(
|
||||
{ ...target, role, organizationId: actor.organizationId },
|
||||
actor.organizationId,
|
||||
subscription,
|
||||
{
|
||||
userId: target.id,
|
||||
role,
|
||||
organizationId: actor.organizationId,
|
||||
impersonatorUserId: ownerId,
|
||||
impersonatorRole: 'owner',
|
||||
viewAsReadOnly: true,
|
||||
},
|
||||
ownerUser?.fullName ?? 'مالک',
|
||||
);
|
||||
return { accessToken, user: userDto };
|
||||
}
|
||||
|
||||
async setPriorLens(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
lens: 'current' | 'archive',
|
||||
actor?: Actor,
|
||||
): Promise<AuthUserDto> {
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { userId, organizationId, isActive: true },
|
||||
include: { organization: { select: { enabledModules: true } } },
|
||||
});
|
||||
if (!membership?.priorArchiveEnabled) {
|
||||
throw new ForbiddenException('بایگانی اطلاعات قبل برای شما فعال نیست');
|
||||
}
|
||||
const modules = this.billing.normalizeModules(membership.organization.enabledModules);
|
||||
if (!modules.includes('prior_data_archive')) {
|
||||
throw new ForbiddenException('بایگانی اطلاعات قبل برای این محیط فعال نیست');
|
||||
}
|
||||
await this.prisma.membership.update({
|
||||
where: { id: membership.id },
|
||||
data: { priorArchiveLens: lens },
|
||||
});
|
||||
return this.me(userId, organizationId, actor);
|
||||
}
|
||||
|
||||
async updateMe(userId: string, organizationId: string, dto: UpdateProfileDto): Promise<AuthUserDto> {
|
||||
@@ -486,11 +625,15 @@ export class AuthService {
|
||||
const storageKey = path
|
||||
.join(organizationId, 'users', userId, `avatar.${ext}`)
|
||||
.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);
|
||||
if (prevKey && prevKey !== storageKey) {
|
||||
await this.files.remove(prevKey);
|
||||
}
|
||||
await this.writeAvatarKey(userId, storageKey);
|
||||
if (delta) await this.storageQuota.addLocal(organizationId, delta);
|
||||
return this.toAuthUserDto(existing, organizationId);
|
||||
}
|
||||
|
||||
@@ -500,7 +643,11 @@ export class AuthService {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
const prevKey = await this.readAvatarKey(userId);
|
||||
if (prevKey) await this.files.remove(prevKey);
|
||||
if (prevKey) {
|
||||
const bytes = await this.files.sizeOf(prevKey);
|
||||
await this.files.remove(prevKey);
|
||||
if (bytes) await this.storageQuota.addLocal(organizationId, -bytes);
|
||||
}
|
||||
await this.writeAvatarKey(userId, null);
|
||||
return this.toAuthUserDto(existing, organizationId);
|
||||
}
|
||||
@@ -546,16 +693,25 @@ export class AuthService {
|
||||
selectionToken: string,
|
||||
organizationId: string,
|
||||
): Promise<LoginResponse> {
|
||||
let payload: { sub?: string; typ?: string };
|
||||
let payload: { sub?: string; typ?: string; pwd?: boolean; master?: boolean };
|
||||
try {
|
||||
payload = this.jwt.verify<{ sub?: string; typ?: string }>(selectionToken);
|
||||
payload = this.jwt.verify<{
|
||||
sub?: string;
|
||||
typ?: string;
|
||||
pwd?: boolean;
|
||||
master?: boolean;
|
||||
}>(selectionToken);
|
||||
} catch {
|
||||
throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است');
|
||||
}
|
||||
if (payload.typ !== 'org-select' || !payload.sub) {
|
||||
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(
|
||||
@@ -565,29 +721,72 @@ export class AuthService {
|
||||
return this.issueLogin(userId, organizationId);
|
||||
}
|
||||
|
||||
private async finishLogin(userId: string): Promise<LoginResult> {
|
||||
private async finishLogin(
|
||||
userId: string,
|
||||
opts?: { fromPassword?: boolean; viaMaster?: boolean },
|
||||
): Promise<LoginResult> {
|
||||
const rows = await this.memberships.listActive(userId);
|
||||
if (!rows.length) {
|
||||
throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید');
|
||||
}
|
||||
if (rows.length === 1) {
|
||||
return this.issueLogin(userId, rows[0]!.organization.id);
|
||||
const usable =
|
||||
opts?.fromPassword && !opts.viaMaster
|
||||
? 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 {
|
||||
requiresOrganizationChoice: true,
|
||||
selectionToken: this.jwt.sign(
|
||||
{ sub: userId, typ: 'org-select' },
|
||||
{
|
||||
sub: userId,
|
||||
typ: 'org-select',
|
||||
...(opts?.fromPassword ? { pwd: true } : {}),
|
||||
...(opts?.viaMaster ? { master: true } : {}),
|
||||
},
|
||||
{ expiresIn: '10m' },
|
||||
),
|
||||
organizations: this.memberships.toChoices(rows),
|
||||
organizations: this.memberships.toChoices(usable),
|
||||
};
|
||||
}
|
||||
|
||||
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(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
opts?: { skipLastLogin?: boolean; fromPassword?: boolean; viaMaster?: boolean },
|
||||
): Promise<LoginResponse> {
|
||||
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 } });
|
||||
if (!user || user.isActive === false) {
|
||||
throw new UnauthorizedException('حساب کاربری غیرفعال است');
|
||||
@@ -595,15 +794,21 @@ export class AuthService {
|
||||
const subscription = await this.billing.requireActiveForOrganization(
|
||||
organizationId,
|
||||
);
|
||||
if (!opts?.skipLastLogin) {
|
||||
await this.attendance.assertLoginAllowed(organizationId, userId);
|
||||
}
|
||||
await this.memberships.syncUserLastOrg(
|
||||
userId,
|
||||
organizationId,
|
||||
membership.role,
|
||||
);
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
if (!opts?.skipLastLogin) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
await this.attendance.recordLogin(organizationId, userId, membership.role);
|
||||
}
|
||||
const accessToken = this.jwt.sign({
|
||||
sub: userId,
|
||||
organizationId,
|
||||
@@ -630,11 +835,16 @@ export class AuthService {
|
||||
},
|
||||
organizationId: string,
|
||||
subscription?: SubscriptionSummaryDto | null,
|
||||
actor?: Actor,
|
||||
impersonatorFullName?: string,
|
||||
): Promise<AuthUserDto> {
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { userId: user.id, organizationId, isActive: true },
|
||||
include: {
|
||||
organization: { select: { name: true, enabledModules: true } },
|
||||
organization: {
|
||||
select: { name: true, enabledModules: true, priorArchiveEnabledAt: true },
|
||||
},
|
||||
orgRole: { select: { permissions: true } },
|
||||
},
|
||||
});
|
||||
const role = membership?.role ?? user.role;
|
||||
@@ -642,6 +852,20 @@ export class AuthService {
|
||||
const sub =
|
||||
subscription ??
|
||||
(await this.billing.requireActiveForOrganization(organizationId));
|
||||
const viewAs =
|
||||
actor?.viewAsReadOnly && actor.impersonatorUserId
|
||||
? {
|
||||
impersonatorId: actor.impersonatorUserId,
|
||||
impersonatorFullName: impersonatorFullName?.trim() || 'مالک',
|
||||
canOpenTeamSettings: true,
|
||||
}
|
||||
: undefined;
|
||||
let storage: StorageUsageDto | undefined;
|
||||
try {
|
||||
storage = await this.storageQuota.usageForOrganization(organizationId);
|
||||
} catch {
|
||||
storage = undefined;
|
||||
}
|
||||
return this.toAuthUser({
|
||||
...user,
|
||||
role,
|
||||
@@ -652,10 +876,33 @@ export class AuthService {
|
||||
membership?.organization.enabledModules,
|
||||
),
|
||||
subscription: sub,
|
||||
storage,
|
||||
isOrgOwner: role === 'owner',
|
||||
priorArchive: this.priorArchiveFor(membership),
|
||||
permissions: (Array.isArray(membership?.orgRole?.permissions)
|
||||
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
|
||||
: []) as AuthUserDto['permissions'],
|
||||
...(viewAs ? { viewAs } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
private priorArchiveFor(
|
||||
membership: {
|
||||
priorArchiveEnabled?: boolean;
|
||||
priorArchiveLens?: string;
|
||||
organization?: { enabledModules?: string[]; priorArchiveEnabledAt?: Date | null };
|
||||
} | null,
|
||||
): AuthUserDto['priorArchive'] {
|
||||
if (!membership?.priorArchiveEnabled) return undefined;
|
||||
const modules = this.billing.normalizeModules(membership.organization?.enabledModules);
|
||||
if (!modules.includes('prior_data_archive')) return undefined;
|
||||
const orgCutoff = membership.organization?.priorArchiveEnabledAt ?? null;
|
||||
return {
|
||||
lens: membership.priorArchiveLens === 'archive' ? 'archive' : 'current',
|
||||
cutoffAt: orgCutoff ? orgCutoff.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
private async throttleChallenges(
|
||||
kind: string,
|
||||
email: string | null | undefined,
|
||||
@@ -709,9 +956,10 @@ export class AuthService {
|
||||
mobile: string,
|
||||
code: string,
|
||||
returnOtp: boolean,
|
||||
organizationId: string | null,
|
||||
) {
|
||||
if (this.sms.isConfigured()) {
|
||||
const sent = await this.sms.sendOtpSms(mobile, code);
|
||||
if (this.sms.isConfigured() || organizationId) {
|
||||
const sent = await this.sms.sendOtp(organizationId, mobile, code);
|
||||
if (!sent && !returnOtp) {
|
||||
throw new BadRequestException(
|
||||
'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید',
|
||||
@@ -739,7 +987,11 @@ export class AuthService {
|
||||
avatarStorageKey?: string | null;
|
||||
enabledModules?: OrgModuleKey[];
|
||||
subscription?: SubscriptionSummaryDto | null;
|
||||
storage?: StorageUsageDto | null;
|
||||
isOrgOwner?: boolean;
|
||||
priorArchive?: AuthUserDto['priorArchive'];
|
||||
viewAs?: AuthUserDto['viewAs'];
|
||||
permissions?: AuthUserDto['permissions'];
|
||||
}): AuthUserDto {
|
||||
return {
|
||||
id: user.id,
|
||||
@@ -752,7 +1004,11 @@ export class AuthService {
|
||||
...(user.mobile ? { mobile: user.mobile } : {}),
|
||||
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
|
||||
...(user.subscription ? { subscription: user.subscription } : {}),
|
||||
...(user.storage ? { storage: user.storage } : {}),
|
||||
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
|
||||
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
|
||||
...(user.viewAs ? { viewAs: user.viewAs } : {}),
|
||||
...(user.permissions ? { permissions: user.permissions } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import type { DataVisibility } from '@mento/shared';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import {
|
||||
resolveAllSectionAccess,
|
||||
sectionNeedsTeamPeers,
|
||||
} from '../common/data-access';
|
||||
import {
|
||||
type Actor,
|
||||
normalizeDataVisibility,
|
||||
@@ -16,6 +20,8 @@ export type JwtPayload = {
|
||||
sub: string;
|
||||
organizationId: string;
|
||||
role: string;
|
||||
impersonatorUserId?: string;
|
||||
viewAsReadOnly?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -32,7 +38,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload & { typ?: string; mobile?: string }) {
|
||||
async validate(payload: JwtPayload & { typ?: string; mobile?: string; iat?: number }) {
|
||||
if (
|
||||
payload.typ === 'portal' ||
|
||||
payload.typ === 'org-select' ||
|
||||
@@ -43,6 +49,28 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
if (!payload.sub || !payload.organizationId) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
let impersonatorUserId: string | undefined;
|
||||
let impersonatorRole: string | undefined;
|
||||
const viewAsReadOnly = Boolean(payload.viewAsReadOnly && payload.impersonatorUserId);
|
||||
if (viewAsReadOnly && payload.impersonatorUserId) {
|
||||
const ownerMembership = await this.prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: payload.impersonatorUserId,
|
||||
organizationId: payload.organizationId,
|
||||
isActive: true,
|
||||
role: 'owner',
|
||||
organization: { isActive: true },
|
||||
},
|
||||
select: { userId: true, role: true },
|
||||
});
|
||||
if (!ownerMembership) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
impersonatorUserId = ownerMembership.userId;
|
||||
impersonatorRole = ownerMembership.role;
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: { id: true, isActive: true },
|
||||
@@ -62,6 +90,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
role: true,
|
||||
dataVisibility: true,
|
||||
dataVisibilityScopes: true,
|
||||
dataAccessOverride: true,
|
||||
priorArchiveEnabled: true,
|
||||
priorArchiveLens: true,
|
||||
orgRole: { select: { dataAccessPolicy: true } },
|
||||
organization: { select: { enabledModules: true, priorArchiveEnabledAt: true } },
|
||||
},
|
||||
});
|
||||
if (!membership) {
|
||||
@@ -91,6 +124,32 @@ 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 priorGranted =
|
||||
modules.includes('prior_data_archive') && membership.priorArchiveEnabled;
|
||||
const priorLens = membership.priorArchiveLens === 'archive' ? 'archive' : 'current';
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
organizationId: membership.organizationId,
|
||||
@@ -98,6 +157,28 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
dataVisibility,
|
||||
visibleUserIds,
|
||||
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',
|
||||
priorCutoff:
|
||||
priorGranted && membership.organization.priorArchiveEnabledAt
|
||||
? membership.organization.priorArchiveEnabledAt
|
||||
: null,
|
||||
sessionStartedAt: payload.iat ? new Date(payload.iat * 1000) : new Date(),
|
||||
...(viewAsReadOnly && impersonatorUserId
|
||||
? {
|
||||
impersonatorUserId,
|
||||
impersonatorRole: impersonatorRole ?? 'owner',
|
||||
viewAsReadOnly: true,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,26 +6,79 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { UserRole } from '@mento/shared';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { ROLES_KEY } from './roles.decorator';
|
||||
|
||||
const VIEW_AS_MESSAGE =
|
||||
'در حالت مشاهده بهجای کاربر دیگر امکان تغییر وجود ندارد.';
|
||||
|
||||
/** خواندنهای صفحهٔ تیم وقتی مالک در حالت مشاهده است. */
|
||||
function isTeamSettingsRead(method: string, url: string): boolean {
|
||||
if (method.toUpperCase() !== 'GET') return false;
|
||||
const path = (url.split('?')[0] || '').replace(/\/+$/, '');
|
||||
return (
|
||||
/\/users(\/|$)/.test(path) ||
|
||||
/\/org-roles(\/|$)/.test(path) ||
|
||||
/\/teams(\/|$)/.test(path) ||
|
||||
/\/organizations\/current/.test(path)
|
||||
);
|
||||
}
|
||||
|
||||
function isViewAsAllowedMutation(method: string, url: string): boolean {
|
||||
const path = (url.split('?')[0] || '').replace(/\/+$/, '') || '/';
|
||||
const m = method.toUpperCase();
|
||||
if (m === 'POST' && (path.endsWith('/auth/view-as') || path.endsWith('/auth/view-as/stop'))) {
|
||||
return true;
|
||||
}
|
||||
if (m === 'PATCH' && path.endsWith('/auth/me/prior-archive')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
user?: Actor;
|
||||
method?: string;
|
||||
originalUrl?: string;
|
||||
url?: string;
|
||||
}>();
|
||||
const method = (request.method || 'GET').toUpperCase();
|
||||
const url = request.originalUrl || request.url || '';
|
||||
const actor = request.user;
|
||||
|
||||
if (
|
||||
actor?.viewAsReadOnly &&
|
||||
method !== 'GET' &&
|
||||
method !== 'HEAD' &&
|
||||
method !== 'OPTIONS' &&
|
||||
!isViewAsAllowedMutation(method, url)
|
||||
) {
|
||||
throw new ForbiddenException(VIEW_AS_MESSAGE);
|
||||
}
|
||||
|
||||
const roles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!roles?.length) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
user?: { role?: string };
|
||||
}>();
|
||||
const role = request.user?.role as UserRole | undefined;
|
||||
if (!role || !roles.includes(role)) {
|
||||
throw new ForbiddenException('دسترسی به این بخش برای نقش شما مجاز نیست');
|
||||
const role = actor?.role as UserRole | undefined;
|
||||
if (role && roles.includes(role)) return true;
|
||||
|
||||
if (
|
||||
actor?.viewAsReadOnly &&
|
||||
actor.impersonatorRole === 'owner' &&
|
||||
roles.includes('owner') &&
|
||||
isTeamSettingsRead(method, url)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
|
||||
throw new ForbiddenException('دسترسی به این بخش برای نقش شما مجاز نیست');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import type { Observable } from 'rxjs';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
|
||||
const VIEW_AS_MESSAGE =
|
||||
'در حالت مشاهده بهجای کاربر دیگر امکان تغییر وجود ندارد.';
|
||||
|
||||
function normalizePath(url: string): string {
|
||||
const path = (url.split('?')[0] || '').replace(/\/+$/, '') || '/';
|
||||
return path;
|
||||
}
|
||||
|
||||
/** مسیرهایی که در حالت مشاهده مجاز به تغییرند (خروج از مشاهده و لنز بایگانی). */
|
||||
function isAllowedWhileViewing(method: string, url: string): boolean {
|
||||
const path = normalizePath(url);
|
||||
const m = method.toUpperCase();
|
||||
if (m === 'POST' && (path.endsWith('/auth/view-as') || path.endsWith('/auth/view-as/stop'))) {
|
||||
return true;
|
||||
}
|
||||
if (m === 'PATCH' && path.endsWith('/auth/me/prior-archive')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ViewAsReadOnlyInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const req = context.switchToHttp().getRequest<Request & { user?: Actor }>();
|
||||
const method = (req.method || 'GET').toUpperCase();
|
||||
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
|
||||
return next.handle();
|
||||
}
|
||||
const actor = req.user;
|
||||
if (!actor?.viewAsReadOnly) {
|
||||
return next.handle();
|
||||
}
|
||||
const url = req.originalUrl || req.url || '';
|
||||
if (isAllowedWhileViewing(method, url)) {
|
||||
return next.handle();
|
||||
}
|
||||
throw new ForbiddenException(VIEW_AS_MESSAGE);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,19 @@ export type TemplateVars = {
|
||||
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 است (از ۱ شروع میشود) */
|
||||
export function pickRoundRobin(memberIds: string[], counter: number): string | null {
|
||||
const ids = [...new Set(memberIds.filter(Boolean))].sort();
|
||||
|
||||
@@ -2,14 +2,13 @@ import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { LoyaltyModule } from '../loyalty/loyalty.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import { AutomationsController } from './automations.controller';
|
||||
import { AutomationsService } from './automations.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, NotificationsModule, LoyaltyModule],
|
||||
controllers: [AutomationsController],
|
||||
providers: [AutomationsService, SmsService],
|
||||
providers: [AutomationsService],
|
||||
exports: [AutomationsService],
|
||||
})
|
||||
export class AutomationsModule {}
|
||||
|
||||
@@ -32,8 +32,9 @@ import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||
import {
|
||||
claimLeadAssignee,
|
||||
formatFormAnswers,
|
||||
pickRoundRobin,
|
||||
renderAutomationTemplate,
|
||||
@@ -59,6 +60,12 @@ export type AutomationContext = {
|
||||
formName?: string | null;
|
||||
formValues?: Record<string, unknown> | null;
|
||||
formFields?: FormFieldLabel[] | null;
|
||||
/**
|
||||
* فرم سرنخ مسئول نوبتی ندارد: مسئول سرنخ همان مسئول اولین فعالیت این اجرا میشود.
|
||||
* داخل همان اجرا یکبار ست میشود تا فعالیت بعدی مسئول را عوض نکند.
|
||||
*/
|
||||
syncLeadAssignee?: boolean;
|
||||
leadAssigneeSynced?: boolean;
|
||||
};
|
||||
|
||||
type AutomationFacts = {
|
||||
@@ -79,7 +86,7 @@ export class AutomationsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly loyalty: LoyaltyService,
|
||||
private readonly sms: SmsService,
|
||||
private readonly sms: SmsGatewayService,
|
||||
) {}
|
||||
|
||||
async list(organizationId: string): Promise<AutomationRuleDto[]> {
|
||||
@@ -406,6 +413,54 @@ export class AutomationsService {
|
||||
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(
|
||||
@@ -523,7 +578,7 @@ export class AutomationsService {
|
||||
const channel: AutomationNotifyChannel = config.notifyChannel ?? 'app';
|
||||
const wantApp = channel === 'app' || channel === 'both';
|
||||
const wantSms = channel === 'sms' || channel === 'both';
|
||||
const smsText = (config.body?.trim() || title).slice(0, 160);
|
||||
const smsText = (config.body?.trim() || title).trim();
|
||||
const link = entityLink(ctx);
|
||||
|
||||
if (config.notifyTarget === 'person') {
|
||||
@@ -537,7 +592,18 @@ export class AutomationsService {
|
||||
select: { mobile: true, phone: true },
|
||||
})
|
||||
: null;
|
||||
await this.sendAutomationSms(person?.mobile || person?.phone, smsText);
|
||||
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(
|
||||
ctx.organizationId,
|
||||
person?.mobile || person?.phone || lead?.mobile || lead?.phone || ctx.mobile,
|
||||
smsText,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -570,11 +636,12 @@ export class AutomationsService {
|
||||
},
|
||||
select: { mobile: true },
|
||||
});
|
||||
await this.sendAutomationSms(user?.mobile, smsText);
|
||||
await this.sendAutomationSms(ctx.organizationId, user?.mobile, smsText);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendAutomationSms(
|
||||
organizationId: string,
|
||||
mobile: string | null | undefined,
|
||||
text: string,
|
||||
) {
|
||||
@@ -582,11 +649,12 @@ export class AutomationsService {
|
||||
this.logger.warn('Automation SMS skipped: no mobile');
|
||||
return;
|
||||
}
|
||||
if (!this.sms.isConfigured()) {
|
||||
this.logger.warn('Automation SMS skipped: SMS not configured');
|
||||
return;
|
||||
}
|
||||
const result = await this.sms.sendSmsDetailed(mobile, text);
|
||||
const result = await this.sms.send({
|
||||
organizationId,
|
||||
phone: mobile,
|
||||
text,
|
||||
source: 'automation',
|
||||
});
|
||||
if (!result.ok) {
|
||||
this.logger.warn(`Automation SMS failed: ${result.reason ?? 'unknown'}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class BehaviorAccessService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
) {}
|
||||
|
||||
async canView(actor: Actor): Promise<boolean> {
|
||||
const org = await this.prisma.organization.findUnique({
|
||||
where: { id: actor.organizationId },
|
||||
select: { enabledModules: true },
|
||||
});
|
||||
if (!this.billing.normalizeModules(org?.enabledModules).includes('behavior')) {
|
||||
return false;
|
||||
}
|
||||
if (actor.role === 'owner' || actor.role === 'admin') 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 ?? [];
|
||||
return permissions.includes('behavior') || permissions.includes('sales');
|
||||
}
|
||||
|
||||
async assert(actor: Actor): Promise<void> {
|
||||
if (!(await this.canView(actor))) {
|
||||
throw new ForbiddenException('دسترسی به تحلیل رفتاری و دستیار فروش ندارید');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Header,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
StreamableFile,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { DiscService } from './disc.service';
|
||||
import { CopilotService } from './copilot.service';
|
||||
import { AnalyzeCopilotDto, CopilotFollowUpDto, SaveCopilotDto, SaveDiscDto } from './dto';
|
||||
|
||||
@Controller('behavior')
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||
@RequireOrgModule('behavior')
|
||||
export class BehaviorController {
|
||||
constructor(
|
||||
private readonly disc: DiscService,
|
||||
private readonly copilot: CopilotService,
|
||||
) {}
|
||||
|
||||
@Get('disc/distribution')
|
||||
distribution(@CurrentUser() user: Actor) {
|
||||
return this.disc.distribution(user);
|
||||
}
|
||||
|
||||
@Get('disc/export')
|
||||
@Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
@Header('Content-Disposition', 'attachment; filename="disc.xlsx"')
|
||||
async exportDisc(@CurrentUser() user: Actor) {
|
||||
const buf = await this.disc.exportXlsx(user);
|
||||
return new StreamableFile(buf);
|
||||
}
|
||||
|
||||
@Get('people/:personId/disc')
|
||||
listDisc(@CurrentUser() user: Actor, @Param('personId') personId: string) {
|
||||
return this.disc.list(user, personId);
|
||||
}
|
||||
|
||||
@Post('people/:personId/disc')
|
||||
saveDisc(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('personId') personId: string,
|
||||
@Body() dto: SaveDiscDto,
|
||||
) {
|
||||
return this.disc.save(user, personId, dto);
|
||||
}
|
||||
|
||||
@Post('copilot/analyze')
|
||||
analyze(@CurrentUser() user: Actor, @Body() dto: AnalyzeCopilotDto) {
|
||||
return this.copilot.analyze(user, dto);
|
||||
}
|
||||
|
||||
@Get('copilot/sessions')
|
||||
sessions(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('personId') personId?: string,
|
||||
@Query('leadId') leadId?: string,
|
||||
@Query('dealId') dealId?: string,
|
||||
) {
|
||||
return this.copilot.list(user, { personId, leadId, dealId });
|
||||
}
|
||||
|
||||
@Post('copilot/sessions')
|
||||
saveSession(@CurrentUser() user: Actor, @Body() dto: SaveCopilotDto) {
|
||||
return this.copilot.save(user, dto);
|
||||
}
|
||||
|
||||
@Post('copilot/sessions/:id/follow-up')
|
||||
followUp(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CopilotFollowUpDto,
|
||||
) {
|
||||
return this.copilot.createFollowUp(user, id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ActivitiesModule } from '../activities/activities.module';
|
||||
import { BehaviorAccessService } from './behavior-access.service';
|
||||
import { BehaviorController } from './behavior.controller';
|
||||
import { CopilotService } from './copilot.service';
|
||||
import { DiscService } from './disc.service';
|
||||
|
||||
@Module({
|
||||
imports: [ActivitiesModule],
|
||||
controllers: [BehaviorController],
|
||||
providers: [BehaviorAccessService, DiscService, CopilotService],
|
||||
exports: [BehaviorAccessService],
|
||||
})
|
||||
export class BehaviorModule {}
|
||||
@@ -0,0 +1,345 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
analyzeCopilot,
|
||||
isDiscType,
|
||||
type CopilotAnalysis,
|
||||
type CopilotFacts,
|
||||
type SalesCopilotSessionDto,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { dealAccessWhere, ownerWhere, scopedOwnerIds, seesAllRecords } from '../common/record-scope';
|
||||
import { ActivitiesService } from '../activities/activities.service';
|
||||
import { AiService } from '../ai/ai.service';
|
||||
import { BehaviorAccessService } from './behavior-access.service';
|
||||
import type { AnalyzeCopilotDto, CopilotFollowUpDto, SaveCopilotDto } from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class CopilotService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly access: BehaviorAccessService,
|
||||
private readonly activities: ActivitiesService,
|
||||
private readonly ai: AiService,
|
||||
) {}
|
||||
|
||||
async analyze(actor: Actor, dto: AnalyzeCopilotDto): Promise<CopilotAnalysis> {
|
||||
await this.access.assert(actor);
|
||||
const facts = await this.facts(actor, dto);
|
||||
const analysis = analyzeCopilot({
|
||||
topic: dto.topic,
|
||||
goal: dto.goal,
|
||||
customerStatement: dto.customerStatement,
|
||||
behaviorNotes: dto.behaviorNotes,
|
||||
facts,
|
||||
});
|
||||
return this.polish(analysis, facts, dto.customerStatement);
|
||||
}
|
||||
|
||||
async list(
|
||||
actor: Actor,
|
||||
filter: { personId?: string; leadId?: string; dealId?: string },
|
||||
): Promise<SalesCopilotSessionDto[]> {
|
||||
await this.access.assert(actor);
|
||||
if (!filter.personId && !filter.leadId && !filter.dealId) {
|
||||
throw new BadRequestException('پرونده، سرنخ یا معامله را مشخص کنید');
|
||||
}
|
||||
const rows = await this.prisma.salesCopilotSession.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
...(filter.personId ? { personId: filter.personId } : {}),
|
||||
...(filter.leadId ? { leadId: filter.leadId } : {}),
|
||||
...(filter.dealId ? { dealId: filter.dealId } : {}),
|
||||
},
|
||||
include: { user: { select: { fullName: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 30,
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async save(actor: Actor, dto: SaveCopilotDto): Promise<SalesCopilotSessionDto> {
|
||||
await this.access.assert(actor);
|
||||
const facts = await this.facts(actor, dto);
|
||||
const analysis = await this.polish(
|
||||
analyzeCopilot({
|
||||
topic: dto.topic,
|
||||
goal: dto.goal,
|
||||
customerStatement: dto.customerStatement,
|
||||
behaviorNotes: dto.behaviorNotes,
|
||||
facts,
|
||||
}),
|
||||
facts,
|
||||
dto.customerStatement,
|
||||
);
|
||||
const row = await this.prisma.salesCopilotSession.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
personId: facts.personId,
|
||||
leadId: facts.leadId,
|
||||
dealId: facts.dealId,
|
||||
userId: actor.userId,
|
||||
topic: dto.topic,
|
||||
goal: dto.goal,
|
||||
customerStatement: dto.customerStatement.trim(),
|
||||
behaviorNotes: dto.behaviorNotes?.trim() || null,
|
||||
analysis: analysis as unknown as Prisma.InputJsonValue,
|
||||
confirmedResponse: dto.confirmedResponse?.trim() || null,
|
||||
nextAction: dto.nextAction?.trim() || analysis.next_best_action,
|
||||
followUpAt: dto.followUpAt ? new Date(dto.followUpAt) : null,
|
||||
callOutcome: dto.callOutcome?.trim() || null,
|
||||
agentNote: dto.agentNote?.trim() || null,
|
||||
status: dto.status,
|
||||
messageDraft: dto.messageDraft?.trim() || null,
|
||||
},
|
||||
include: { user: { select: { fullName: true } } },
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async createFollowUp(actor: Actor, sessionId: string, dto: CopilotFollowUpDto) {
|
||||
await this.access.assert(actor);
|
||||
const session = await this.prisma.salesCopilotSession.findFirst({
|
||||
where: { id: sessionId, organizationId: actor.organizationId, status: 'confirmed' },
|
||||
});
|
||||
if (!session) throw new NotFoundException('نتیجهٔ تأییدشده پیدا نشد');
|
||||
if (!session.personId && !session.dealId && !session.leadId) {
|
||||
throw new BadRequestException('این نتیجه به پروندهای وصل نیست');
|
||||
}
|
||||
const due = dto.dueAt || (session.followUpAt ? session.followUpAt.toISOString() : null);
|
||||
const activity = await this.activities.create(
|
||||
actor.organizationId,
|
||||
actor.userId,
|
||||
{
|
||||
title: session.nextAction?.trim() || 'پیگیری مذاکره',
|
||||
type: 'task',
|
||||
description: session.confirmedResponse || session.customerStatement,
|
||||
dueAt: due,
|
||||
personId: session.personId,
|
||||
dealId: session.dealId,
|
||||
assigneeId: dto.assigneeId || actor.userId,
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await this.prisma.salesCopilotSession.update({
|
||||
where: { id: session.id },
|
||||
data: { activityId: activity.id },
|
||||
});
|
||||
return { activityId: activity.id };
|
||||
}
|
||||
|
||||
private async facts(
|
||||
actor: Actor,
|
||||
dto: { personId?: string; leadId?: string; dealId?: string },
|
||||
): Promise<CopilotFacts & { personId: string | null; leadId: string | null; dealId: string | null }> {
|
||||
if (!dto.personId && !dto.leadId && !dto.dealId) {
|
||||
throw new BadRequestException('پرونده، سرنخ یا معامله را مشخص کنید');
|
||||
}
|
||||
const deal = dto.dealId
|
||||
? await this.prisma.deal.findFirst({
|
||||
where: { id: dto.dealId, organizationId: actor.organizationId, ...dealAccessWhere(actor) },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
amount: true,
|
||||
currency: true,
|
||||
personId: true,
|
||||
stage: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
: null;
|
||||
if (dto.dealId && !deal) throw new NotFoundException('معامله پیدا نشد');
|
||||
|
||||
const lead = dto.leadId
|
||||
? await this.prisma.lead.findFirst({
|
||||
where: { id: dto.leadId, organizationId: actor.organizationId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
trade: true,
|
||||
estimatedValue: true,
|
||||
convertedPersonId: true,
|
||||
assigneeId: true,
|
||||
stage: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
: null;
|
||||
if (dto.leadId && !lead) throw new NotFoundException('سرنخ پیدا نشد');
|
||||
if (lead && !seesAllRecords(actor, 'leads')) {
|
||||
const ids = scopedOwnerIds(actor, 'leads') ?? [actor.userId];
|
||||
if (lead.assigneeId && !ids.includes(lead.assigneeId)) {
|
||||
throw new NotFoundException('سرنخ پیدا نشد');
|
||||
}
|
||||
}
|
||||
|
||||
const personId = dto.personId || deal?.personId || lead?.convertedPersonId || null;
|
||||
const person = personId
|
||||
? await this.prisma.person.findFirst({
|
||||
where: { id: personId, organizationId: actor.organizationId, ...ownerWhere(actor) },
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
discLabel: true,
|
||||
discReliable: true,
|
||||
discAssessedAt: true,
|
||||
company: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
: null;
|
||||
if (personId && !person) throw new NotFoundException('شخص پیدا نشد');
|
||||
|
||||
const recent = person
|
||||
? await this.prisma.activity.findMany({
|
||||
where: { organizationId: actor.organizationId, personId: person.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 3,
|
||||
select: { title: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const price = deal?.amount
|
||||
? `${deal.amount.toString()} ${deal.currency}`
|
||||
: lead?.estimatedValue
|
||||
? `${lead.estimatedValue.toString()}`
|
||||
: null;
|
||||
const primaryTypes = (person?.discLabel ?? '')
|
||||
.split('/')
|
||||
.map((part) => part.trim())
|
||||
.filter(isDiscType);
|
||||
|
||||
return {
|
||||
personId: person?.id ?? null,
|
||||
leadId: lead?.id ?? null,
|
||||
dealId: deal?.id ?? null,
|
||||
customerName: person ? `${person.firstName} ${person.lastName}`.trim() : lead?.title ?? null,
|
||||
businessType: person?.company?.name || lead?.trade || null,
|
||||
productName: deal?.title || lead?.title || null,
|
||||
stageName: deal?.stage.name || lead?.stage.name || null,
|
||||
priceOffered: price,
|
||||
lastAction: recent.map((row) => row.title).join('؛ ') || null,
|
||||
disc:
|
||||
person && person.discLabel
|
||||
? {
|
||||
label: person.discLabel,
|
||||
primaryTypes,
|
||||
reliable: person.discReliable,
|
||||
assessedAt: person.discAssessedAt?.toISOString() ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async polish(
|
||||
analysis: CopilotAnalysis,
|
||||
facts: CopilotFacts,
|
||||
statement: string,
|
||||
): Promise<CopilotAnalysis> {
|
||||
if (!this.ai.isEnabled() || analysis.needs_more_input || !analysis.suggested_response) {
|
||||
return analysis;
|
||||
}
|
||||
const factText = [
|
||||
statement,
|
||||
facts.customerName,
|
||||
facts.businessType,
|
||||
facts.productName,
|
||||
facts.stageName,
|
||||
facts.priceOffered,
|
||||
facts.lastAction,
|
||||
analysis.objection_label,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
try {
|
||||
const raw = await this.ai.completeJson<{
|
||||
recommended_question?: string;
|
||||
suggested_response?: string;
|
||||
}>(
|
||||
`تو مربی فروش فارسیزبان هستی. فقط JSON برگردان:
|
||||
{"recommended_question": string, "suggested_response": string}
|
||||
قواعد:
|
||||
- چیزی را که در اطلاعات داده نشده نساز؛ بهخصوص قیمت، تخفیف، قابلیت محصول و تعهد پشتیبانی.
|
||||
- اگر عددی در اطلاعات نیست، عدد جدید ننویس.
|
||||
- DISC را تشخیص قطعی شخصیت ندان.
|
||||
- پاسخ کوتاه، محترمانه و قابل گفتن پای تلفن باشد.`,
|
||||
factText,
|
||||
);
|
||||
const question = keepGrounded(raw.recommended_question, factText);
|
||||
const response = keepGrounded(raw.suggested_response, factText);
|
||||
if (!question && !response) return analysis;
|
||||
return {
|
||||
...analysis,
|
||||
recommended_question: question || analysis.recommended_question,
|
||||
suggested_response: response || analysis.suggested_response,
|
||||
source: 'rules+ai',
|
||||
confidence_note: `${analysis.confidence_note} متن با مدل زبانی بازنویسی شد و هنوز باید توسط کارشناس تأیید شود.`,
|
||||
};
|
||||
} catch {
|
||||
return analysis;
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: string;
|
||||
personId: string | null;
|
||||
leadId: string | null;
|
||||
dealId: string | null;
|
||||
userId: string;
|
||||
user: { fullName: string };
|
||||
topic: string;
|
||||
goal: string;
|
||||
customerStatement: string;
|
||||
behaviorNotes: string | null;
|
||||
analysis: unknown;
|
||||
confirmedResponse: string | null;
|
||||
nextAction: string | null;
|
||||
followUpAt: Date | null;
|
||||
callOutcome: string | null;
|
||||
agentNote: string | null;
|
||||
status: string;
|
||||
messageDraft: string | null;
|
||||
activityId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): SalesCopilotSessionDto {
|
||||
return {
|
||||
id: row.id,
|
||||
personId: row.personId,
|
||||
leadId: row.leadId,
|
||||
dealId: row.dealId,
|
||||
userId: row.userId,
|
||||
userName: row.user.fullName,
|
||||
topic: row.topic as SalesCopilotSessionDto['topic'],
|
||||
goal: row.goal as SalesCopilotSessionDto['goal'],
|
||||
customerStatement: row.customerStatement,
|
||||
behaviorNotes: row.behaviorNotes,
|
||||
analysis: row.analysis as CopilotAnalysis,
|
||||
confirmedResponse: row.confirmedResponse,
|
||||
nextAction: row.nextAction,
|
||||
followUpAt: row.followUpAt?.toISOString() ?? null,
|
||||
callOutcome: row.callOutcome,
|
||||
agentNote: row.agentNote,
|
||||
status: row.status === 'dismissed' ? 'dismissed' : 'confirmed',
|
||||
messageDraft: row.messageDraft,
|
||||
activityId: row.activityId,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function keepGrounded(text: string | undefined, facts: string): string {
|
||||
const trimmed = text?.trim() ?? '';
|
||||
if (!trimmed || trimmed.length > 900) return '';
|
||||
const numbers = trimmed.match(/\d[\d۰-۹,،.]*/g) ?? [];
|
||||
for (const token of numbers) {
|
||||
const digits = token.replace(/\D/g, '');
|
||||
if (digits.length >= 2 && !facts.includes(digits)) return '';
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
type DiscAnswers,
|
||||
type DiscAssessmentDto,
|
||||
type DiscDistributionRow,
|
||||
type DiscType,
|
||||
isDiscType,
|
||||
scoreDisc,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { ownerWhere } from '../common/record-scope';
|
||||
import { writeXlsx } from '../common/excel';
|
||||
import { BehaviorAccessService } from './behavior-access.service';
|
||||
import type { SaveDiscDto } from './dto';
|
||||
|
||||
function asAnswers(value: unknown): DiscAnswers {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
const out: DiscAnswers = {};
|
||||
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (raw == null || raw === '') out[key] = null;
|
||||
else if (isDiscType(raw)) out[key] = raw;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function asTypes(values: string[]): DiscType[] {
|
||||
return values.filter(isDiscType);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DiscService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly access: BehaviorAccessService,
|
||||
) {}
|
||||
|
||||
async list(actor: Actor, personId: string): Promise<DiscAssessmentDto[]> {
|
||||
await this.access.assert(actor);
|
||||
await this.ensurePerson(actor, personId);
|
||||
const rows = await this.prisma.discAssessment.findMany({
|
||||
where: { organizationId: actor.organizationId, personId },
|
||||
include: { assessor: { select: { id: true, fullName: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async save(actor: Actor, personId: string, dto: SaveDiscDto): Promise<DiscAssessmentDto> {
|
||||
await this.access.assert(actor);
|
||||
await this.ensurePerson(actor, personId);
|
||||
const answers = asAnswers(dto.answers);
|
||||
const scored = scoreDisc(answers);
|
||||
if (scored.validCount === 0) {
|
||||
throw new BadRequestException('حداقل یک پاسخ معتبر لازم است');
|
||||
}
|
||||
const note = dto.note?.trim() || null;
|
||||
const row = await this.prisma.discAssessment.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
personId,
|
||||
assessorId: actor.userId,
|
||||
answers: answers as Prisma.InputJsonValue,
|
||||
scoreD: scored.scores.D,
|
||||
scoreI: scored.scores.I,
|
||||
scoreS: scored.scores.S,
|
||||
scoreC: scored.scores.C,
|
||||
percentD: scored.percents.D,
|
||||
percentI: scored.percents.I,
|
||||
percentS: scored.percents.S,
|
||||
percentC: scored.percents.C,
|
||||
validCount: scored.validCount,
|
||||
primaryTypes: scored.primaryTypes,
|
||||
secondaryTypes: scored.secondaryTypes,
|
||||
label: scored.label,
|
||||
secondaryLabel: scored.secondaryLabel,
|
||||
reliable: scored.reliable,
|
||||
confidence: scored.confidence,
|
||||
note,
|
||||
status: 'confirmed',
|
||||
},
|
||||
include: { assessor: { select: { id: true, fullName: true } } },
|
||||
});
|
||||
if (scored.reliable) {
|
||||
await this.prisma.person.update({
|
||||
where: { id: personId },
|
||||
data: {
|
||||
discLabel: scored.label,
|
||||
discReliable: true,
|
||||
discAssessedAt: row.createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async distribution(actor: Actor): Promise<DiscDistributionRow[]> {
|
||||
await this.access.assert(actor);
|
||||
const rows = await this.prisma.person.groupBy({
|
||||
by: ['discLabel'],
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
discReliable: true,
|
||||
discLabel: { not: null },
|
||||
...ownerWhere(actor),
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
return rows
|
||||
.flatMap((row) =>
|
||||
row.discLabel
|
||||
? [{ label: row.discLabel, count: row._count._all }]
|
||||
: [],
|
||||
)
|
||||
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
async exportXlsx(actor: Actor): Promise<Buffer> {
|
||||
await this.access.assert(actor);
|
||||
const people = await this.prisma.person.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
discReliable: true,
|
||||
...ownerWhere(actor),
|
||||
},
|
||||
select: { id: true, firstName: true, lastName: true, mobile: true, discLabel: true },
|
||||
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
|
||||
});
|
||||
const latest = await this.prisma.discAssessment.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
personId: { in: people.map((p) => p.id) },
|
||||
reliable: true,
|
||||
},
|
||||
include: { assessor: { select: { fullName: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const byPerson = new Map<string, (typeof latest)[number]>();
|
||||
for (const row of latest) {
|
||||
if (!byPerson.has(row.personId)) byPerson.set(row.personId, row);
|
||||
}
|
||||
return writeXlsx(
|
||||
'DISC',
|
||||
[
|
||||
{ header: 'مشتری', key: 'name', width: 28 },
|
||||
{ header: 'موبایل', key: 'mobile', width: 16 },
|
||||
{ header: 'تیپ غالب', key: 'label', width: 16 },
|
||||
{ header: 'تیپ دوم', key: 'secondary', width: 16 },
|
||||
{ header: 'D', key: 'd', width: 8 },
|
||||
{ header: 'I', key: 'i', width: 8 },
|
||||
{ header: 'S', key: 's', width: 8 },
|
||||
{ header: 'C', key: 'c', width: 8 },
|
||||
{ header: 'اطمینان', key: 'confidence', width: 14 },
|
||||
{ header: 'کارشناس', key: 'assessor', width: 22 },
|
||||
{ header: 'تاریخ', key: 'at', width: 22 },
|
||||
{ header: 'یادداشت', key: 'note', width: 40 },
|
||||
],
|
||||
people.map((person) => {
|
||||
const row = byPerson.get(person.id);
|
||||
return {
|
||||
name: `${person.firstName} ${person.lastName}`.trim(),
|
||||
mobile: person.mobile,
|
||||
label: person.discLabel,
|
||||
secondary: row?.secondaryLabel,
|
||||
d: row?.scoreD,
|
||||
i: row?.scoreI,
|
||||
s: row?.scoreS,
|
||||
c: row?.scoreC,
|
||||
confidence: row?.confidence,
|
||||
assessor: row?.assessor.fullName,
|
||||
at: row?.createdAt.toISOString(),
|
||||
note: row?.note,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async ensurePerson(actor: Actor, personId: string) {
|
||||
const found = await this.prisma.person.findFirst({
|
||||
where: { id: personId, organizationId: actor.organizationId, ...ownerWhere(actor) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!found) throw new NotFoundException('شخص پیدا نشد');
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: string;
|
||||
personId: string;
|
||||
assessorId: string;
|
||||
assessor: { fullName: string };
|
||||
answers: unknown;
|
||||
scoreD: number;
|
||||
scoreI: number;
|
||||
scoreS: number;
|
||||
scoreC: number;
|
||||
percentD: number;
|
||||
percentI: number;
|
||||
percentS: number;
|
||||
percentC: number;
|
||||
validCount: number;
|
||||
primaryTypes: string[];
|
||||
secondaryTypes: string[];
|
||||
label: string;
|
||||
secondaryLabel: string | null;
|
||||
reliable: boolean;
|
||||
confidence: string;
|
||||
note: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): DiscAssessmentDto {
|
||||
const confidence = (['insufficient', 'low', 'medium', 'high'] as const).includes(
|
||||
row.confidence as 'insufficient',
|
||||
)
|
||||
? (row.confidence as DiscAssessmentDto['confidence'])
|
||||
: 'low';
|
||||
return {
|
||||
id: row.id,
|
||||
personId: row.personId,
|
||||
assessorId: row.assessorId,
|
||||
assessorName: row.assessor.fullName,
|
||||
answers: asAnswers(row.answers),
|
||||
scores: { D: row.scoreD, I: row.scoreI, S: row.scoreS, C: row.scoreC },
|
||||
percents: { D: row.percentD, I: row.percentI, S: row.percentS, C: row.percentC },
|
||||
validCount: row.validCount,
|
||||
primaryTypes: asTypes(row.primaryTypes),
|
||||
secondaryTypes: asTypes(row.secondaryTypes),
|
||||
label: row.label,
|
||||
secondaryLabel: row.secondaryLabel,
|
||||
reliable: row.reliable,
|
||||
confidence,
|
||||
note: row.note,
|
||||
status: 'confirmed',
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
COPILOT_GOALS,
|
||||
COPILOT_TOPICS,
|
||||
type CopilotGoal,
|
||||
type CopilotTopic,
|
||||
} from '@mento/shared';
|
||||
|
||||
export class SaveDiscDto {
|
||||
@IsObject()
|
||||
answers!: Record<string, string | null>;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class AnalyzeCopilotDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
leadId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dealId?: string;
|
||||
|
||||
@IsIn([...COPILOT_TOPICS])
|
||||
topic!: CopilotTopic;
|
||||
|
||||
@IsIn([...COPILOT_GOALS])
|
||||
goal!: CopilotGoal;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
customerStatement!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
behaviorNotes?: string;
|
||||
}
|
||||
|
||||
export class SaveCopilotDto extends AnalyzeCopilotDto {
|
||||
@IsIn(['confirmed', 'dismissed'])
|
||||
status!: 'confirmed' | 'dismissed';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
confirmedResponse?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
nextAction?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value) => value != null && value !== '')
|
||||
@IsDateString()
|
||||
followUpAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
callOutcome?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
agentNote?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
messageDraft?: string;
|
||||
}
|
||||
|
||||
export class CopilotFollowUpDto {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assigneeId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Type(() => Boolean)
|
||||
createTask?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PlatformAdminAlertService } from './platform-admin-alert.service';
|
||||
|
||||
@Injectable()
|
||||
export class BillingExpiryService {
|
||||
private readonly logger = new Logger(BillingExpiryService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly alerts: PlatformAdminAlertService,
|
||||
) {}
|
||||
|
||||
@Cron('15 * * * *', { timeZone: 'Asia/Tehran' })
|
||||
async handleHourly(): Promise<void> {
|
||||
await this.notifyEnded();
|
||||
}
|
||||
|
||||
async notifyEnded(): Promise<number> {
|
||||
const rows = await this.prisma.billingAccount.findMany({
|
||||
where: {
|
||||
expiryNotifiedAt: null,
|
||||
planCode: { not: 'lifetime' },
|
||||
status: { not: 'suspended' },
|
||||
expiresAt: { lte: new Date() },
|
||||
},
|
||||
select: { id: true },
|
||||
take: 100,
|
||||
});
|
||||
for (const row of rows) {
|
||||
try {
|
||||
await this.alerts.notifySubscriptionEnded(row.id);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`expiry notify failed ${row.id}: ${error instanceof Error ? error.message : 'unknown'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (rows.length) this.logger.log(`subscription expiry checked: ${rows.length}`);
|
||||
return rows.length;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,27 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { BillingExpiryService } from './billing-expiry.service';
|
||||
import { BillingService } from './billing.service';
|
||||
import { OrgModuleGuard } from './org-module.guard';
|
||||
import { PlatformAdminAlertService } from './platform-admin-alert.service';
|
||||
import { StorageQuotaService } from './storage-quota.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [BillingService, OrgModuleGuard],
|
||||
exports: [BillingService, OrgModuleGuard],
|
||||
imports: [StorageModule],
|
||||
providers: [
|
||||
BillingService,
|
||||
BillingExpiryService,
|
||||
PlatformAdminAlertService,
|
||||
OrgModuleGuard,
|
||||
StorageQuotaService,
|
||||
],
|
||||
exports: [
|
||||
BillingService,
|
||||
BillingExpiryService,
|
||||
PlatformAdminAlertService,
|
||||
OrgModuleGuard,
|
||||
StorageQuotaService,
|
||||
],
|
||||
})
|
||||
export class BillingModule {}
|
||||
|
||||
@@ -4,12 +4,14 @@ import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
ApplyBillingPlanRequest,
|
||||
BillingAccountDto,
|
||||
BillingAccountStatus,
|
||||
BillingPlanCode,
|
||||
OrgModuleKey,
|
||||
OwnerSeatUsageDto,
|
||||
SubscriptionSummaryDto,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
isOrgModuleKey,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PlatformAdminAlertService } from './platform-admin-alert.service';
|
||||
|
||||
function daysRemaining(expiresAt: Date, now = new Date()): number {
|
||||
const ms = expiresAt.getTime() - now.getTime();
|
||||
@@ -49,7 +52,10 @@ export function toSummary(row: {
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly adminAlerts: PlatformAdminAlertService,
|
||||
) {}
|
||||
|
||||
toDto(row: {
|
||||
id: string;
|
||||
@@ -59,6 +65,7 @@ export class BillingService {
|
||||
startsAt: Date;
|
||||
expiresAt: Date;
|
||||
maxOrganizations: number;
|
||||
maxUsers?: number | null;
|
||||
externalPaymentRef: string | null;
|
||||
notes: string | null;
|
||||
}): BillingAccountDto {
|
||||
@@ -67,11 +74,71 @@ export class BillingService {
|
||||
id: row.id,
|
||||
ownerUserId: row.ownerUserId,
|
||||
...summary,
|
||||
maxUsers: row.maxUsers ?? null,
|
||||
externalPaymentRef: row.externalPaymentRef,
|
||||
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) {
|
||||
return this.prisma.billingAccount.findUnique({ where: { ownerUserId } });
|
||||
}
|
||||
@@ -92,6 +159,7 @@ export class BillingService {
|
||||
startsAt: now,
|
||||
expiresAt,
|
||||
maxOrganizations: Math.max(1, opts?.maxOrganizations ?? 1),
|
||||
maxUsers: 3,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -150,6 +218,7 @@ export class BillingService {
|
||||
data: { status: 'expired' },
|
||||
});
|
||||
}
|
||||
await this.adminAlerts.notifySubscriptionEnded(account.id);
|
||||
throw new UnauthorizedException('اشتراک منقضی شده است');
|
||||
}
|
||||
return toSummary(account);
|
||||
@@ -219,6 +288,10 @@ export class BillingService {
|
||||
(expiresAt.getTime() < now.getTime() ? 'expired' : 'active');
|
||||
|
||||
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
|
||||
? await this.prisma.billingAccount.update({
|
||||
where: { id: existing.id },
|
||||
@@ -228,6 +301,8 @@ export class BillingService {
|
||||
startsAt: existing.startsAt ?? now,
|
||||
expiresAt,
|
||||
maxOrganizations,
|
||||
expiryNotifiedAt,
|
||||
...(planCode === 'trial' ? { maxUsers: 3 } : {}),
|
||||
...(dto.notes !== undefined ? { notes: dto.notes } : {}),
|
||||
},
|
||||
})
|
||||
@@ -240,8 +315,13 @@ export class BillingService {
|
||||
expiresAt,
|
||||
maxOrganizations,
|
||||
notes: dto.notes ?? null,
|
||||
expiryNotifiedAt,
|
||||
...(planCode === 'trial' ? { maxUsers: 3 } : {}),
|
||||
},
|
||||
});
|
||||
if (status === 'expired' && !expiryNotifiedAt) {
|
||||
await this.adminAlerts.notifySubscriptionEnded(row.id);
|
||||
}
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
@@ -251,13 +331,70 @@ export class BillingService {
|
||||
modules.filter(isOrgModuleKey),
|
||||
),
|
||||
] as OrgModuleKey[];
|
||||
const prev = await this.prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
select: { priorArchiveEnabledAt: true },
|
||||
});
|
||||
const enablePrior = cleaned.includes('prior_data_archive');
|
||||
await this.prisma.organization.update({
|
||||
where: { id: organizationId },
|
||||
data: { enabledModules: cleaned },
|
||||
data: {
|
||||
enabledModules: cleaned,
|
||||
},
|
||||
});
|
||||
if (enablePrior && prev?.priorArchiveEnabledAt) {
|
||||
await this.backfillPriorActivities(organizationId, prev.priorArchiveEnabledAt);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/** فعالیتهای منتقلشده را یکبار (و تکرار بیاثر) بهعنوان اطلاعات قبل علامت میزند. */
|
||||
private async backfillPriorActivities(organizationId: string, cutoff: Date) {
|
||||
await this.prisma.$executeRaw(Prisma.sql`
|
||||
UPDATE "Activity" AS a
|
||||
SET "importSource" = 'didar'
|
||||
WHERE a."organizationId" = ${organizationId}
|
||||
AND a."importSource" = 'manual'
|
||||
AND (
|
||||
(
|
||||
a."dealId" IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "Deal" AS d
|
||||
WHERE d."id" = a."dealId" AND d."importSource" = 'didar'
|
||||
)
|
||||
)
|
||||
OR (
|
||||
a."createdAt" < ${cutoff}
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1 FROM "Person" AS p
|
||||
WHERE p."id" = a."personId"
|
||||
AND (
|
||||
p."importSource" = 'didar'
|
||||
OR p."previousImportSource" = 'didar'
|
||||
)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM "Company" AS c
|
||||
WHERE c."id" = a."companyId" AND c."importSource" = 'didar'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM "Deal" AS d2
|
||||
WHERE d2."id" = a."dealId" AND d2."importSource" = 'didar'
|
||||
)
|
||||
OR (
|
||||
a."personId" IS NULL
|
||||
AND a."companyId" IS NULL
|
||||
AND a."dealId" IS NULL
|
||||
AND a."dueAt" IS NOT NULL
|
||||
AND a."dueAt" < ${cutoff}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
normalizeModules(raw: string[] | null | undefined): OrgModuleKey[] {
|
||||
if (!raw?.length) return defaultEnabledModules();
|
||||
const cleaned = raw.filter(isOrgModuleKey) as OrgModuleKey[];
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { isPermanentBillingPlan } from '@mento/shared';
|
||||
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
newAccountSms,
|
||||
subscriptionEndedSms,
|
||||
type NewAccountSource,
|
||||
} from './platform-admin-alert';
|
||||
|
||||
@Injectable()
|
||||
export class PlatformAdminAlertService {
|
||||
private readonly logger = new Logger(PlatformAdminAlertService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sms: SmsGatewayService,
|
||||
) {}
|
||||
|
||||
/** ساخت حساب جدید؛ خطای پیامک جلوی ثبتنام یا ساخت محیط را نمیگیرد. */
|
||||
async notifyNewAccount(input: {
|
||||
source: NewAccountSource;
|
||||
organizationName: string;
|
||||
ownerName: string;
|
||||
mobile: string | null;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const delivered = await this.sendToAdmins(newAccountSms(input));
|
||||
if (!delivered) {
|
||||
this.logger.warn(`new account SMS was not delivered (${input.source})`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`new account SMS failed: ${error instanceof Error ? error.message : 'unknown'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* یکبار هنگام تمام شدن اشتراک. اگر ارسال نشود پرچم خالی میماند تا بعداً دوباره تلاش شود.
|
||||
*/
|
||||
async notifySubscriptionEnded(billingAccountId: string): Promise<void> {
|
||||
const row = await this.prisma.billingAccount.findUnique({
|
||||
where: { id: billingAccountId },
|
||||
select: {
|
||||
id: true,
|
||||
planCode: true,
|
||||
status: true,
|
||||
expiresAt: true,
|
||||
expiryNotifiedAt: true,
|
||||
owner: {
|
||||
select: {
|
||||
fullName: true,
|
||||
mobile: true,
|
||||
memberships: {
|
||||
where: { role: 'owner', isActive: true },
|
||||
select: { organization: { select: { name: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!row || row.expiryNotifiedAt) return;
|
||||
if (row.status === 'suspended') return;
|
||||
if (isPermanentBillingPlan(row.planCode)) {
|
||||
await this.prisma.billingAccount.update({
|
||||
where: { id: row.id },
|
||||
data: { expiryNotifiedAt: new Date() },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (row.expiresAt.getTime() > Date.now() && row.status !== 'expired') return;
|
||||
|
||||
const claimed = await this.prisma.billingAccount.updateMany({
|
||||
where: { id: row.id, expiryNotifiedAt: null },
|
||||
data: { status: 'expired', expiryNotifiedAt: new Date() },
|
||||
});
|
||||
if (claimed.count !== 1) return;
|
||||
|
||||
try {
|
||||
const delivered = await this.sendToAdmins(
|
||||
subscriptionEndedSms({
|
||||
organizationNames: row.owner.memberships.map((item) => item.organization.name),
|
||||
ownerName: row.owner.fullName,
|
||||
mobile: row.owner.mobile,
|
||||
planCode: row.planCode,
|
||||
expiresAt: row.expiresAt,
|
||||
}),
|
||||
);
|
||||
if (!delivered) {
|
||||
await this.prisma.billingAccount.update({
|
||||
where: { id: row.id },
|
||||
data: { expiryNotifiedAt: null },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`subscription SMS failed ${row.id}: ${error instanceof Error ? error.message : 'unknown'}`,
|
||||
);
|
||||
await this.prisma.billingAccount
|
||||
.update({
|
||||
where: { id: row.id },
|
||||
data: { expiryNotifiedAt: null },
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** true یعنی حداقل یک پیامک رفت، یا مدیری با موبایل فعال وجود نداشت. */
|
||||
private async sendToAdmins(text: string): Promise<boolean> {
|
||||
const admins = await this.prisma.platformAdmin.findMany({
|
||||
where: { isActive: true, mobile: { not: null } },
|
||||
select: { mobile: true },
|
||||
});
|
||||
const mobiles = [
|
||||
...new Set(
|
||||
admins
|
||||
.map((admin) => admin.mobile?.trim() ?? '')
|
||||
.filter((mobile) => mobile.length > 0),
|
||||
),
|
||||
];
|
||||
if (!mobiles.length) {
|
||||
this.logger.warn('platform admin SMS skipped: no active admin mobile');
|
||||
return true;
|
||||
}
|
||||
|
||||
let delivered = false;
|
||||
for (const mobile of mobiles) {
|
||||
try {
|
||||
const result = await this.sms.send({
|
||||
phone: mobile,
|
||||
text,
|
||||
source: 'platform-admin',
|
||||
});
|
||||
if (result.ok) delivered = true;
|
||||
else {
|
||||
this.logger.warn(
|
||||
`platform admin SMS failed to=…${mobile.slice(-4)} reason=${result.reason ?? 'unknown'}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`platform admin SMS failed to=…${mobile.slice(-4)}: ${
|
||||
error instanceof Error ? error.message : 'unknown'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return delivered;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { BILLING_PLAN_LABELS, type BillingPlanCode } from '@mento/shared';
|
||||
import { formatTehranJalali } from '../common/jalali';
|
||||
|
||||
export type NewAccountSource = 'signup' | 'admin';
|
||||
|
||||
export function newAccountSms(input: {
|
||||
source: NewAccountSource;
|
||||
organizationName: string;
|
||||
ownerName: string;
|
||||
mobile: string | null;
|
||||
}): string {
|
||||
const via = input.source === 'signup' ? 'ثبتنام' : 'پنل ادمین';
|
||||
return [
|
||||
'منتکت',
|
||||
`حساب جدید (${via})`,
|
||||
`محیط: ${input.organizationName.trim() || '—'}`,
|
||||
`مالک: ${input.ownerName.trim() || '—'}`,
|
||||
`موبایل: ${input.mobile?.trim() || 'ندارد'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function subscriptionEndedSms(input: {
|
||||
organizationNames: string[];
|
||||
ownerName: string;
|
||||
mobile: string | null;
|
||||
planCode: string;
|
||||
expiresAt: Date;
|
||||
}): string {
|
||||
const names = input.organizationNames.map((name) => name.trim()).filter(Boolean);
|
||||
const orgs = names.slice(0, 3).join('، ') || '—';
|
||||
const extra = names.length > 3 ? ` و ${names.length - 3} محیط دیگر` : '';
|
||||
const label =
|
||||
BILLING_PLAN_LABELS[input.planCode as BillingPlanCode] ?? input.planCode;
|
||||
return [
|
||||
'منتکت',
|
||||
'پایان اشتراک',
|
||||
`محیط: ${orgs}${extra}`,
|
||||
`مالک: ${input.ownerName.trim() || '—'}`,
|
||||
`موبایل: ${input.mobile?.trim() || 'ندارد'}`,
|
||||
`پلن: ${label}`,
|
||||
`تاریخ: ${formatTehranJalali(input.expiresAt)}`,
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Injectable, PayloadTooLargeException } from '@nestjs/common';
|
||||
import type { StorageUsageDto } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
|
||||
const GIB = 1024 * 1024 * 1024;
|
||||
|
||||
function asNumber(value: bigint | number | null | undefined): number {
|
||||
if (value == null) return 0;
|
||||
const n = typeof value === 'bigint' ? Number(value) : value;
|
||||
return Number.isFinite(n) ? Math.max(0, n) : 0;
|
||||
}
|
||||
|
||||
function levelOf(used: number, quota: number | null): StorageUsageDto['level'] {
|
||||
if (quota == null) return 'unlimited';
|
||||
if (quota <= 0 || used >= quota) return 'full';
|
||||
if (used >= quota * 0.8) return 'warn';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StorageQuotaService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly files: LocalFileStorage,
|
||||
) {}
|
||||
|
||||
async usageForOrganization(organizationId: string): Promise<StorageUsageDto> {
|
||||
const ownerUserId = await this.ownerUserId(organizationId);
|
||||
if (!ownerUserId) return this.empty();
|
||||
return this.usageForOwner(ownerUserId);
|
||||
}
|
||||
|
||||
async usageForOwner(ownerUserId: string, refresh = false): Promise<StorageUsageDto> {
|
||||
const orgs = await this.ownerOrgs(ownerUserId);
|
||||
if (refresh) {
|
||||
await Promise.all(orgs.map((org) => this.measureLocal(org.id)));
|
||||
} else {
|
||||
await Promise.all(
|
||||
orgs.filter((org) => !org.fileBytesSyncedAt).map((org) => this.measureLocal(org.id)),
|
||||
);
|
||||
}
|
||||
const fresh = refresh || orgs.some((org) => !org.fileBytesSyncedAt)
|
||||
? await this.ownerOrgs(ownerUserId)
|
||||
: orgs;
|
||||
const account = await this.prisma.billingAccount.findUnique({
|
||||
where: { ownerUserId },
|
||||
select: { storageQuotaBytes: true },
|
||||
});
|
||||
const quota = account?.storageQuotaBytes == null ? null : asNumber(account.storageQuotaBytes);
|
||||
const byOrganization = fresh.map((org) => ({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
usedBytes: asNumber(org.fileBytes) + asNumber(org.chatFileBytes),
|
||||
}));
|
||||
const used = byOrganization.reduce((sum, org) => sum + org.usedBytes, 0);
|
||||
const percent = quota == null || quota <= 0 ? null : Math.min(100, Math.round((used / quota) * 100));
|
||||
return {
|
||||
usedBytes: used,
|
||||
quotaBytes: quota,
|
||||
percent,
|
||||
level: levelOf(used, quota),
|
||||
byOrganization,
|
||||
};
|
||||
}
|
||||
|
||||
async assertCanAdd(organizationId: string, bytes: number): Promise<void> {
|
||||
if (bytes <= 0) return;
|
||||
const ownerUserId = await this.ownerUserId(organizationId);
|
||||
if (!ownerUserId) return;
|
||||
const usage = await this.usageForOwner(ownerUserId);
|
||||
if (usage.quotaBytes == null) return;
|
||||
if (usage.usedBytes + bytes > usage.quotaBytes) {
|
||||
throw new PayloadTooLargeException(
|
||||
'حجم فایل این اشتراک پر شده است. تا شارژ حجم، آپلود جدید ممکن نیست.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async addLocal(organizationId: string, delta: number): Promise<void> {
|
||||
await this.add(organizationId, 'fileBytes', delta);
|
||||
}
|
||||
|
||||
async addChat(organizationId: string, bytes: number): Promise<StorageUsageDto> {
|
||||
if (bytes > 0) await this.assertCanAdd(organizationId, bytes);
|
||||
if (bytes > 0) await this.add(organizationId, 'chatFileBytes', bytes);
|
||||
return this.usageForOrganization(organizationId);
|
||||
}
|
||||
|
||||
async setQuotaGb(ownerUserId: string, quotaGb: number | null): Promise<StorageUsageDto> {
|
||||
const bytes = quotaGb == null ? null : BigInt(Math.round(quotaGb * GIB));
|
||||
await this.prisma.billingAccount.update({
|
||||
where: { ownerUserId },
|
||||
data: { storageQuotaBytes: bytes },
|
||||
});
|
||||
return this.usageForOwner(ownerUserId, true);
|
||||
}
|
||||
|
||||
private async add(
|
||||
organizationId: string,
|
||||
field: 'fileBytes' | 'chatFileBytes',
|
||||
delta: number,
|
||||
): Promise<void> {
|
||||
if (!delta) return;
|
||||
const row = await this.prisma.organization.update({
|
||||
where: { id: organizationId },
|
||||
data: { [field]: { increment: BigInt(Math.round(delta)) } },
|
||||
select: { fileBytes: true, chatFileBytes: true },
|
||||
});
|
||||
const value = field === 'fileBytes' ? row.fileBytes : row.chatFileBytes;
|
||||
if (value < 0n) {
|
||||
await this.prisma.organization.update({
|
||||
where: { id: organizationId },
|
||||
data: { [field]: 0n },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async ownerUserId(organizationId: string): Promise<string | null> {
|
||||
const owner = await this.prisma.membership.findFirst({
|
||||
where: { organizationId, role: 'owner', isActive: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { userId: true },
|
||||
});
|
||||
return owner?.userId ?? null;
|
||||
}
|
||||
|
||||
private ownerOrgs(ownerUserId: string) {
|
||||
return this.prisma.membership.findMany({
|
||||
where: { userId: ownerUserId, role: 'owner', isActive: true },
|
||||
select: {
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
fileBytes: true,
|
||||
chatFileBytes: true,
|
||||
fileBytesSyncedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}).then((rows) => rows.map((row) => row.organization));
|
||||
}
|
||||
|
||||
private async measureLocal(organizationId: string): Promise<void> {
|
||||
const [imageSum, noteSum, treasury, leads, forms, users] = await Promise.all([
|
||||
this.prisma.activityImage.aggregate({
|
||||
where: { organizationId },
|
||||
_sum: { sizeBytes: true },
|
||||
}),
|
||||
this.prisma.noteAttachment.aggregate({
|
||||
where: { organizationId },
|
||||
_sum: { sizeBytes: true },
|
||||
}),
|
||||
this.prisma.financialAttachment.findMany({
|
||||
where: { organizationId },
|
||||
select: { storageKey: true },
|
||||
}),
|
||||
this.prisma.lead.findMany({
|
||||
where: { organizationId, logoStorageKey: { not: null } },
|
||||
select: { logoStorageKey: true },
|
||||
}),
|
||||
this.prisma.leadForm.findMany({
|
||||
where: { organizationId, coverStorageKey: { not: null } },
|
||||
select: { coverStorageKey: true },
|
||||
}),
|
||||
this.prisma.user.findMany({
|
||||
where: { avatarStorageKey: { startsWith: `${organizationId}/` } },
|
||||
select: { avatarStorageKey: true },
|
||||
}),
|
||||
]);
|
||||
const keys = [
|
||||
...treasury.map((row) => row.storageKey),
|
||||
...leads.map((row) => row.logoStorageKey).filter((key): key is string => Boolean(key)),
|
||||
...forms.map((row) => row.coverStorageKey).filter((key): key is string => Boolean(key)),
|
||||
...users.map((row) => row.avatarStorageKey).filter((key): key is string => Boolean(key)),
|
||||
];
|
||||
let disk = 0;
|
||||
for (const key of keys) disk += await this.files.sizeOf(key);
|
||||
const bytes = (imageSum._sum.sizeBytes ?? 0) + (noteSum._sum.sizeBytes ?? 0) + disk;
|
||||
await this.prisma.organization.update({
|
||||
where: { id: organizationId },
|
||||
data: { fileBytes: BigInt(bytes), fileBytesSyncedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
private empty(): StorageUsageDto {
|
||||
return { usedBytes: 0, quotaBytes: null, percent: null, level: 'unlimited' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import {
|
||||
ASSIGN_SECTION_PERMISSION,
|
||||
type AssignSection,
|
||||
} from '@mento/shared';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import { elevated } from './record-scope';
|
||||
|
||||
const ASSIGN_NOUN: Record<AssignSection, string> = {
|
||||
customers: 'مشتری',
|
||||
leads: 'لید',
|
||||
sales: 'معامله',
|
||||
activities: 'فعالیت',
|
||||
routines: 'کار روتین',
|
||||
projects: 'کارت پروژه',
|
||||
tickets: 'تیکت',
|
||||
};
|
||||
|
||||
type AssignActor = { userId: string; role: string; organizationId: string };
|
||||
|
||||
export function assignOthersDeniedMessage(section: AssignSection): string {
|
||||
return `تخصیص ${ASSIGN_NOUN[section]} به دیگران برای نقش شما مجاز نیست. فقط میتوانید برای خودتان ثبت کنید.`;
|
||||
}
|
||||
|
||||
export async function actorCanAssignToOthers(
|
||||
prisma: PrismaService,
|
||||
actor: AssignActor,
|
||||
section: AssignSection,
|
||||
): Promise<boolean> {
|
||||
if (elevated(actor.role)) return true;
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: actor.userId,
|
||||
organizationId: actor.organizationId,
|
||||
isActive: true,
|
||||
},
|
||||
select: { orgRole: { select: { permissions: true } } },
|
||||
});
|
||||
const permissions = membership?.orgRole?.permissions;
|
||||
const key = ASSIGN_SECTION_PERMISSION[section];
|
||||
return Array.isArray(permissions) && permissions.includes(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* بدون مجوز همان بخش، مجموعهٔ مسئولانِ غیر از خود کاربر نباید عوض شود.
|
||||
* ثبت تازه یعنی قبلی خالی است، پس فقط خود کاربر مجاز است.
|
||||
*/
|
||||
export async function assertAssigneeSet(
|
||||
prisma: PrismaService,
|
||||
actor: AssignActor,
|
||||
section: AssignSection,
|
||||
nextIds: Array<string | null | undefined>,
|
||||
previousIds: Array<string | null | undefined> = [],
|
||||
): Promise<void> {
|
||||
if (await actorCanAssignToOthers(prisma, actor, section)) return;
|
||||
const prev = new Set(
|
||||
previousIds.filter((id): id is string => Boolean(id) && id !== actor.userId),
|
||||
);
|
||||
const next = nextIds.filter((id): id is string => Boolean(id) && id !== actor.userId);
|
||||
const changed = next.length !== prev.size || next.some((id) => !prev.has(id));
|
||||
if (changed) throw new ForbiddenException(assignOthersDeniedMessage(section));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
type PositionUpdate = Prisma.PrismaPromise<unknown>;
|
||||
|
||||
/** کارت را در ایندکس خواستهشده میگذارد. ایندکس نسبت به فهرست بدون خود کارت است. */
|
||||
export function placeId(ids: string[], movingId: string, index: number): string[] {
|
||||
const next = ids.filter((id) => id !== movingId);
|
||||
const at = Math.max(0, Math.min(Math.trunc(index), next.length));
|
||||
next.splice(at, 0, movingId);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function sameIds(left: string[], right: string[]): boolean {
|
||||
return left.length === right.length && left.every((id, index) => id === right[index]);
|
||||
}
|
||||
|
||||
/** ترتیب ستون را ۰ تا n ذخیره میکند. */
|
||||
export async function writePositions(
|
||||
transaction: (ops: PositionUpdate[]) => Promise<unknown>,
|
||||
ids: string[],
|
||||
update: (id: string, position: number) => PositionUpdate,
|
||||
) {
|
||||
const size = 60;
|
||||
for (let start = 0; start < ids.length; start += size) {
|
||||
const slice = ids.slice(start, start + size);
|
||||
await transaction(slice.map((id, offset) => update(id, start + offset)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export function buildContactRuleWhere(
|
||||
rule: DataAccessRule,
|
||||
ctx: { userId: string; teamPeerIds: string[] | null },
|
||||
): Prisma.PersonWhereInput | null {
|
||||
const and: Prisma.PersonWhereInput[] = [];
|
||||
const ids = ownerIdList(rule, ctx);
|
||||
if (rule.owners?.length) {
|
||||
if (!ids.length) return null;
|
||||
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
|
||||
}
|
||||
if (rule.groupIds?.length) {
|
||||
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
|
||||
}
|
||||
if (rule.sources?.length) {
|
||||
and.push({
|
||||
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
|
||||
});
|
||||
}
|
||||
if (rule.moods?.length) {
|
||||
and.push({ mood: { in: rule.moods } });
|
||||
}
|
||||
if (!and.length) return null;
|
||||
return and.length === 1 ? and[0]! : { AND: and };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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 ?? []) {
|
||||
const and: Prisma.CompanyWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
|
||||
}
|
||||
if (rule.groupIds?.length) {
|
||||
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
|
||||
}
|
||||
if (rule.sources?.length) {
|
||||
and.push({
|
||||
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
|
||||
});
|
||||
}
|
||||
if (
|
||||
rule.moods?.length &&
|
||||
!rule.owners?.length &&
|
||||
!rule.groupIds?.length &&
|
||||
!rule.sources?.length
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
}
|
||||
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 and: Prisma.LeadWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
|
||||
}
|
||||
if (rule.stageIds?.length) {
|
||||
and.push({ stageId: { in: rule.stageIds } });
|
||||
}
|
||||
if (rule.sources?.length) {
|
||||
and.push({
|
||||
OR: rule.sources.map((s) => ({
|
||||
source: { equals: s, mode: 'insensitive' as const },
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
}
|
||||
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 and: Prisma.DealWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push({
|
||||
OR: [
|
||||
{ ownerId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (rule.pipelineIds?.length) {
|
||||
and.push({ pipelineId: { in: rule.pipelineIds } });
|
||||
}
|
||||
if (rule.stageIds?.length) {
|
||||
and.push({ stageId: { in: rule.stageIds } });
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
}
|
||||
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 and: Prisma.ActivityWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push({
|
||||
OR: [
|
||||
{ assigneeId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
{ participantIds: { hasSome: ids } },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (rule.boardIds?.length) {
|
||||
and.push({ boardId: { in: rule.boardIds } });
|
||||
}
|
||||
if (rule.stageIds?.length) {
|
||||
and.push({ stageId: { in: rule.stageIds } });
|
||||
}
|
||||
if (rule.typeIds?.length) {
|
||||
and.push({ typeDefId: { in: rule.typeIds } });
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
}
|
||||
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 and: Prisma.NoteWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.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) {
|
||||
and.push({ noteType: { in: rule.sources } });
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
}
|
||||
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 and: Prisma.TicketWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push({
|
||||
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
|
||||
});
|
||||
}
|
||||
if (rule.sources?.length) {
|
||||
and.push({
|
||||
status: {
|
||||
in: rule.sources as Array<'open' | 'in_progress' | 'waiting' | 'resolved' | 'closed'>,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
}
|
||||
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 and: Prisma.ProjectCardWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push({
|
||||
OR: [
|
||||
{ ownerId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (rule.boardIds?.length) {
|
||||
and.push({ boardId: { in: rule.boardIds } });
|
||||
}
|
||||
if (rule.stageIds?.length) {
|
||||
and.push({ stageId: { in: rule.stageIds } });
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
}
|
||||
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 and: Prisma.RoutineTaskWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
|
||||
}
|
||||
if (rule.typeIds?.length) {
|
||||
and.push({ categoryId: { in: rule.typeIds } });
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
}
|
||||
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.groupIds?.length || 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);
|
||||
}
|
||||
@@ -169,6 +169,53 @@ export function parseAmount(raw?: string): number | null {
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** تاریخ تولد: سال زیر ۱۷۰۰ شمسی است، سال ۱۷۰۰ به بعد میلادی. ظهر UTC تا روز جابهجا نشود. */
|
||||
/** «۲۰۲۴-۰۵-۰۱T۱۹:۳۰:۰۰Z» و مشابه: لحظهٔ دقیق با منطقهٔ زمانی */
|
||||
const ZONED_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})$/i;
|
||||
|
||||
function tehranCalendarDay(d: Date): { y: number; m: number; d: number } {
|
||||
const [y, m, day] = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
.format(d)
|
||||
.split('-')
|
||||
.map(Number);
|
||||
return { y, m, d: day };
|
||||
}
|
||||
|
||||
export function parseBirthDate(raw?: string | null): Date | null {
|
||||
const text = toAsciiDigits(String(raw ?? '')).trim();
|
||||
if (!text || text === '-' || text === '0' || text === '0000-00-00') return null;
|
||||
// تاریخ تولد فقط «روز» است. لحظهٔ UTC (مثلاً نیمهشب تهران که به روز قبلِ UTC میافتد)
|
||||
// باید با تقویم تهران به روز تبدیل شود، نه با بریدن بخش تاریخِ UTC.
|
||||
if (ZONED_TIMESTAMP.test(text)) {
|
||||
const instant = new Date(text);
|
||||
if (!Number.isNaN(instant.getTime())) {
|
||||
const day = tehranCalendarDay(instant);
|
||||
return new Date(Date.UTC(day.y, day.m - 1, day.d, 12, 0, 0));
|
||||
}
|
||||
}
|
||||
const datePart = text.split(/[T\s]/)[0] ?? text;
|
||||
const match = /^(\d{3,4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})$/.exec(datePart);
|
||||
if (!match) {
|
||||
const fallback = new Date(text);
|
||||
return Number.isNaN(fallback.getTime()) ? null : fallback;
|
||||
}
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
|
||||
if (year >= 1700) {
|
||||
return new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
|
||||
}
|
||||
if (year < 1200) return null;
|
||||
const { gy, gm, gd } = toGregorian(year, month, day);
|
||||
return new Date(Date.UTC(gy, gm - 1, gd, 12, 0, 0));
|
||||
}
|
||||
|
||||
export function parseJalaliDateTime(dateRaw?: string, timeRaw?: string): Date | null {
|
||||
const dateText = toAsciiDigits(dateRaw ?? '').trim();
|
||||
if (!dateText) return null;
|
||||
|
||||
@@ -103,6 +103,121 @@ export function jalaliMonthKey(d: Date): string {
|
||||
return `${jy}-${String(jm).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
||||
|
||||
function toAsciiDigits(value: string) {
|
||||
return value
|
||||
.replace(/[۰-۹]/g, (d) => String(FA_DIGITS.indexOf(d)))
|
||||
.replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)));
|
||||
}
|
||||
|
||||
function toFaDigits(value: string | number) {
|
||||
return String(value).replace(/\d/g, (d) => FA_DIGITS[Number(d)] ?? d);
|
||||
}
|
||||
|
||||
function tehranYmd(d: Date) {
|
||||
const [gy, gm, gd] = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
.format(d)
|
||||
.split('-')
|
||||
.map(Number);
|
||||
return { gy, gm, gd };
|
||||
}
|
||||
|
||||
/** تاریخ شمسی تهران، مثل ۱۴۰۲/۰۶/۱۲ */
|
||||
export function formatTehranJalali(d: Date): string {
|
||||
const { gy, gm, gd } = tehranYmd(d);
|
||||
const j = toJalali(gy, gm, gd);
|
||||
return `${toFaDigits(j.jy)}/${toFaDigits(String(j.jm).padStart(2, '0'))}/${toFaDigits(String(j.jd).padStart(2, '0'))}`;
|
||||
}
|
||||
|
||||
const GREG_MONTHS: Array<[string, number]> = [
|
||||
['ژانویه', 1],
|
||||
['january', 1],
|
||||
['فوریه', 2],
|
||||
['february', 2],
|
||||
['مارس', 3],
|
||||
['march', 3],
|
||||
['آوریل', 4],
|
||||
['april', 4],
|
||||
['ژوئن', 6],
|
||||
['june', 6],
|
||||
['ژوئیه', 7],
|
||||
['جولای', 7],
|
||||
['july', 7],
|
||||
['اوت', 8],
|
||||
['آگوست', 8],
|
||||
['آگست', 8],
|
||||
['august', 8],
|
||||
['سپتامبر', 9],
|
||||
['september', 9],
|
||||
['اکتبر', 10],
|
||||
['october', 10],
|
||||
['نوامبر', 11],
|
||||
['november', 11],
|
||||
['دسامبر', 12],
|
||||
['december', 12],
|
||||
['مه', 5],
|
||||
['می', 5],
|
||||
['may', 5],
|
||||
];
|
||||
|
||||
function gregMonthNumber(name: string): number | null {
|
||||
const key = name.trim().toLowerCase();
|
||||
return GREG_MONTHS.find(([label]) => label === key)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function inferGregorianYear(month: number, day: number, now = new Date()): number {
|
||||
const { gy, gm, gd } = tehranYmd(now);
|
||||
if (month > gm || (month === gm && day > gd)) return gy - 1;
|
||||
return gy;
|
||||
}
|
||||
|
||||
function jalaliFromGregorian(year: number, month: number, day: number): string | null {
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31 || year < 1700 || year > 2500) return null;
|
||||
const j = toJalali(year, month, day);
|
||||
return `${toFaDigits(j.jy)}/${toFaDigits(String(j.jm).padStart(2, '0'))}/${toFaDigits(String(j.jd).padStart(2, '0'))}`;
|
||||
}
|
||||
|
||||
/** «21 سپتامبر» و تاریخ میلادی عددی را در متن کاربر به شمسی تبدیل میکند. */
|
||||
export function rewriteGregorianDates(text: string, now = new Date()): string {
|
||||
const monthAlt = GREG_MONTHS.map(([name]) => name).join('|');
|
||||
const dayMonth = new RegExp(
|
||||
`(^|[^\\p{L}\\d])([0-9۰-۹]{1,2})\\s*(${monthAlt})(?![\\p{L}\\u200c])(?:\\s+([0-9۰-۹]{4}))?`,
|
||||
'giu',
|
||||
);
|
||||
const monthDay = new RegExp(
|
||||
`(^|[^\\p{L}\\d])(${monthAlt})(?![\\p{L}\\u200c])\\s+([0-9۰-۹]{1,2})(?:\\s*,?\\s*([0-9۰-۹]{4}))?`,
|
||||
'giu',
|
||||
);
|
||||
const iso = /\b((?:19|20)\d{2})-(\d{2})-(\d{2})\b/g;
|
||||
|
||||
const fromParts = (dayRaw: string, monthName: string, yearRaw?: string) => {
|
||||
const day = Number(toAsciiDigits(dayRaw));
|
||||
const month = gregMonthNumber(monthName);
|
||||
if (!month) return null;
|
||||
const year = yearRaw ? Number(toAsciiDigits(yearRaw)) : inferGregorianYear(month, day, now);
|
||||
return jalaliFromGregorian(year, month, day);
|
||||
};
|
||||
|
||||
let out = text.replace(dayMonth, (full, lead: string, day: string, month: string, year?: string) => {
|
||||
const next = fromParts(day, month, year);
|
||||
return next ? `${lead}${next}` : full;
|
||||
});
|
||||
out = out.replace(monthDay, (full, lead: string, month: string, day: string, year?: string) => {
|
||||
const next = fromParts(day, month, year);
|
||||
return next ? `${lead}${next}` : full;
|
||||
});
|
||||
out = out.replace(iso, (full, year: string, month: string, day: string) => {
|
||||
return jalaliFromGregorian(Number(year), Number(month), Number(day)) ?? full;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function startOfJalaliMonth(jy: number, jm: number): Date {
|
||||
return jalaliToDate(jy, jm, 1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import type { Actor } from './record-scope';
|
||||
|
||||
export type PriorLens = 'off' | 'current' | 'archive';
|
||||
|
||||
export function actorPriorLens(actor: Pick<Actor, 'priorLens'> | undefined): PriorLens {
|
||||
return actor?.priorLens ?? 'off';
|
||||
}
|
||||
|
||||
function hasCutoff(cutoff?: Date | null): cutoff is Date {
|
||||
return cutoff instanceof Date && !Number.isNaN(cutoff.getTime());
|
||||
}
|
||||
|
||||
/** شخص/شرکت دیگر با این لنز فیلتر نمیشوند. */
|
||||
export function personPriorWhere(_lens?: PriorLens): Prisma.PersonWhereInput {
|
||||
return {};
|
||||
}
|
||||
|
||||
export function companyPriorWhere(_lens?: PriorLens): Prisma.CompanyWhereInput {
|
||||
return {};
|
||||
}
|
||||
|
||||
export function dealPriorWhere(
|
||||
lens: PriorLens | undefined,
|
||||
cutoff?: Date | null,
|
||||
): Prisma.DealWhereInput {
|
||||
if (lens !== 'current' && lens !== 'archive') return {};
|
||||
if (!hasCutoff(cutoff)) return {};
|
||||
if (lens === 'current') {
|
||||
return {
|
||||
OR: [
|
||||
{ createdAt: { gte: cutoff } },
|
||||
{ promotedFromPriorAt: { not: null } },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { createdAt: { lt: cutoff }, promotedFromPriorAt: null };
|
||||
}
|
||||
|
||||
export function activityPriorWhere(
|
||||
lens: PriorLens | undefined,
|
||||
cutoff?: Date | null,
|
||||
): Prisma.ActivityWhereInput {
|
||||
if (lens !== 'current' && lens !== 'archive') return {};
|
||||
if (!hasCutoff(cutoff)) return {};
|
||||
if (lens === 'current') {
|
||||
return {
|
||||
OR: [
|
||||
{ createdAt: { gte: cutoff } },
|
||||
{ promotedFromPriorAt: { not: null } },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { createdAt: { lt: cutoff }, promotedFromPriorAt: null };
|
||||
}
|
||||
|
||||
/** فهرست شخص/شرکت: همیشه بدون فیلتر لنز (سازگاری با call-siteهای قدیمی). */
|
||||
export function listPriorLens(
|
||||
_lens: PriorLens | undefined,
|
||||
_q: string | undefined,
|
||||
_explicitSource: boolean,
|
||||
): PriorLens {
|
||||
return 'off';
|
||||
}
|
||||
@@ -1,6 +1,20 @@
|
||||
import type { DataScope, DataVisibility, DataVisibilityScopes, UserRole } from '@mento/shared';
|
||||
import type {
|
||||
DataScope,
|
||||
DataVisibility,
|
||||
DataVisibilityScopes,
|
||||
SectionDataAccess,
|
||||
UserRole,
|
||||
} from '@mento/shared';
|
||||
import { DATA_SCOPES, canViewAllRecords } from '@mento/shared';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import {
|
||||
activitiesDeepWhere,
|
||||
dealsDeepWhere,
|
||||
notesDeepWhere,
|
||||
projectsDeepWhere,
|
||||
routinesDeepWhere,
|
||||
ticketsDeepWhere,
|
||||
} from './data-access';
|
||||
|
||||
export type ScopeVisibility = {
|
||||
dataVisibility: DataVisibility;
|
||||
@@ -20,6 +34,28 @@ export type Actor = {
|
||||
visibleUserIds?: string[] | null;
|
||||
/** سطح دید تفکیکی هر بخش (از Membership.dataVisibilityScopes) */
|
||||
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 = بایگانی اطلاعات قبل */
|
||||
priorLens?: 'off' | 'current' | 'archive';
|
||||
/** لحظهٔ روشن شدن ماژول برای این محیط؛ فعالیتهای ساختهشده قبل از آن میتوانند قبلی باشند */
|
||||
priorCutoff?: Date | null;
|
||||
/** مالک واقعی وقتی در حالت مشاهده بهجای کاربر دیگر است */
|
||||
impersonatorUserId?: string;
|
||||
impersonatorRole?: string;
|
||||
/** شروع این نشست؛ برای ساعت خروج روزانه */
|
||||
sessionStartedAt?: Date;
|
||||
/** در حالت مشاهده، سرور تغییر را رد میکند */
|
||||
viewAsReadOnly?: boolean;
|
||||
};
|
||||
|
||||
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>;
|
||||
@@ -82,10 +118,13 @@ export function ownerWhere(
|
||||
return { ownerId: { in: ids } };
|
||||
}
|
||||
|
||||
/** معامله: مالک اصلی یا یکی از مسئولان مشترک در محدوده دید */
|
||||
/** معامله: عمق دادهٔ sales (JWT) یا مدل قدیمی own/team/org */
|
||||
export function dealAccessWhere(
|
||||
actor: ScopedActor,
|
||||
actor: ScopedActor & Pick<Actor, 'salesAccess' | 'teamPeerIds'>,
|
||||
): Prisma.DealWhereInput {
|
||||
if (actor.salesAccess !== undefined) {
|
||||
return dealsDeepWhere(actor as Actor);
|
||||
}
|
||||
const ids = scopedOwnerIds(actor, 'deals');
|
||||
if (!ids) return {};
|
||||
return {
|
||||
@@ -96,10 +135,13 @@ export function dealAccessWhere(
|
||||
};
|
||||
}
|
||||
|
||||
/** کارت پروژه: مالک یا یکی از مسئولان در محدوده دید */
|
||||
/** کارت پروژه: عمق داده یا مدل قدیمی own/team/org */
|
||||
export function projectCardAccessWhere(
|
||||
actor: ScopedActor,
|
||||
actor: ScopedActor & Pick<Actor, 'projectsAccess' | 'teamPeerIds'>,
|
||||
): Prisma.ProjectCardWhereInput {
|
||||
if (actor.projectsAccess !== undefined) {
|
||||
return projectsDeepWhere(actor as Actor);
|
||||
}
|
||||
const ids = scopedOwnerIds(actor, 'projects');
|
||||
if (!ids) return {};
|
||||
return {
|
||||
@@ -124,10 +166,13 @@ export function assigneeWhere(
|
||||
};
|
||||
}
|
||||
|
||||
/** دسترسی به فعالیت: مسئولان یا شرکتکنندگان (فیلد شرکتکنندگان) در محدوده دید */
|
||||
/** دسترسی به فعالیت: عمق دادهٔ activities (JWT) یا مدل قدیمی */
|
||||
export function activityAccessWhere(
|
||||
actor: ScopedActor,
|
||||
actor: ScopedActor & Pick<Actor, 'activitiesAccess' | 'teamPeerIds'>,
|
||||
): Prisma.ActivityWhereInput {
|
||||
if (actor.activitiesAccess !== undefined) {
|
||||
return activitiesDeepWhere(actor as Actor);
|
||||
}
|
||||
const ids = scopedOwnerIds(actor, 'activities');
|
||||
if (!ids) return {};
|
||||
return {
|
||||
@@ -139,10 +184,13 @@ export function activityAccessWhere(
|
||||
};
|
||||
}
|
||||
|
||||
/** یادداشت: نویسنده یا پروندههای تحت مالکیت در محدوده دید + محرمانگی شخصی */
|
||||
/** یادداشت: عمق داده یا مدل قدیمی + محرمانگی شخصی */
|
||||
export function noteAccessWhere(
|
||||
actor: ScopedActor,
|
||||
actor: ScopedActor & Pick<Actor, 'notesAccess' | 'teamPeerIds'>,
|
||||
): Prisma.NoteWhereInput {
|
||||
if (actor.notesAccess !== undefined) {
|
||||
return notesDeepWhere(actor as Actor);
|
||||
}
|
||||
const ids = scopedOwnerIds(actor, 'notes');
|
||||
const personalGate: Prisma.NoteWhereInput = {
|
||||
OR: [
|
||||
@@ -177,17 +225,22 @@ export function noteAccessWhere(
|
||||
export function resolveOwnerId(
|
||||
actor: Pick<Actor, 'userId' | 'role'>,
|
||||
requested?: string | null,
|
||||
allowOthers?: boolean,
|
||||
): string {
|
||||
if (elevated(actor.role) && requested?.trim()) {
|
||||
const allowed = allowOthers ?? elevated(actor.role);
|
||||
if (allowed && requested?.trim()) {
|
||||
return requested.trim();
|
||||
}
|
||||
return actor.userId;
|
||||
}
|
||||
|
||||
/** تیکت: مسئول یا ثبتکننده در محدوده دید */
|
||||
/** تیکت: عمق داده یا مدل قدیمی */
|
||||
export function ticketAccessWhere(
|
||||
actor: ScopedActor,
|
||||
actor: ScopedActor & Pick<Actor, 'ticketsAccess' | 'teamPeerIds'>,
|
||||
): Prisma.TicketWhereInput {
|
||||
if (actor.ticketsAccess !== undefined) {
|
||||
return ticketsDeepWhere(actor as Actor);
|
||||
}
|
||||
const ids = scopedOwnerIds(actor, 'tickets');
|
||||
if (!ids) return {};
|
||||
return {
|
||||
@@ -195,6 +248,14 @@ 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 {
|
||||
return elevated(role);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,19 @@ export function buildSearchWhere<W extends object>(
|
||||
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' } }` */
|
||||
export function ci(term: string) {
|
||||
return { contains: term, mode: 'insensitive' as const };
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { jalaliMonthName, toJalali } from './jalali';
|
||||
import { applyMessageTemplate } from '../message-automations/message-automation.util';
|
||||
|
||||
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
||||
|
||||
function toFaDigits(value: string | number) {
|
||||
return String(value).replace(/\d/g, (d) => FA_DIGITS[Number(d)] ?? d);
|
||||
}
|
||||
|
||||
function tehranJalaliLong(d: Date) {
|
||||
const [gy, gm, gd] = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
.format(d)
|
||||
.split('-')
|
||||
.map(Number);
|
||||
const j = toJalali(gy, gm, gd);
|
||||
return `${toFaDigits(j.jd)} ${jalaliMonthName(j.jm)} ${toFaDigits(j.jy)}`;
|
||||
}
|
||||
|
||||
export function renderSmsText(
|
||||
text: string,
|
||||
vars: { name?: string; phone?: string; title?: string; company?: string },
|
||||
) {
|
||||
return applyMessageTemplate(text, {
|
||||
name: vars.name,
|
||||
phone: vars.phone,
|
||||
title: vars.title,
|
||||
company: vars.company,
|
||||
date: tehranJalaliLong(new Date()),
|
||||
}).trim();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user