Compare commits
126
Commits
23f8da2c65
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0998a1ec37 | ||
|
|
4979aa8b75 | ||
|
|
34d3689448 | ||
|
|
1e0b638a19 | ||
|
|
3db9c1c0fe | ||
|
|
6c6e32e099 | ||
|
|
1ef7f91771 | ||
|
|
94f0383c79 | ||
|
|
c976c0bcd6 | ||
|
|
57a0b379f6 | ||
|
|
7f6d9a84d6 | ||
|
|
6abbf8e8fd | ||
|
|
39cb5c2548 | ||
|
|
004d1dff93 | ||
|
|
78a7d6a68a | ||
|
|
5b18749005 | ||
|
|
08a278e1c8 | ||
|
|
d71c2dbef4 | ||
|
|
41f310f3a2 | ||
|
|
754cc43c98 | ||
|
|
a319085dca | ||
|
|
cb3a488b21 | ||
|
|
1ccbefa7a3 | ||
|
|
14f4e9d7d8 | ||
|
|
52edccf74e | ||
|
|
b11affbdfd | ||
|
|
bffe174162 | ||
|
|
35d74d33ed | ||
|
|
7aaa15fad2 | ||
|
|
39c62834e7 | ||
|
|
26792f9a37 | ||
|
|
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 |
@@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
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 + StyleProvider**
|
||||||
|
App root in `App.vue` **must** wrap ConfigProvider with `<a-style-provider hash-priority="high">` so legacy `main.css` cannot override Ant controls via higher specificity:
|
||||||
|
```vue
|
||||||
|
<a-style-provider hash-priority="high">
|
||||||
|
<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">
|
||||||
|
<RouterView />
|
||||||
|
</a-config-provider>
|
||||||
|
</a-style-provider>
|
||||||
|
```
|
||||||
|
Do not override direction to LTR on whole pages; use local `direction="ltr"` only for inherently LTR fragments (e.g. phone numbers, codes).
|
||||||
|
|
||||||
|
8. **Auto-import**
|
||||||
|
`<a-*>` components are resolved via `unplugin-vue-components` + `AntDesignVueResolver({ importStyle: false })`. Do not add blanket `app.use(antd)` unless there is a documented exception. Do not per-component CSS imports (`importStyle` stays `false`; CSS-in-JS + theme). Prefer **explicit** `import { Button as AButton, … } from 'ant-design-vue'` in migrated files when the Vite resolver is missing or unreliable.
|
||||||
|
|
||||||
|
9. **Theme is central**
|
||||||
|
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,7 @@
|
|||||||
|
{
|
||||||
|
"plugins": {
|
||||||
|
"hex": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
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 + StyleProvider | `apps/web/src/App.vue` — `<a-style-provider hash-priority="high">` wrapping `<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">` (mandatory so legacy `main.css` cannot override Ant) |
|
||||||
|
| Legacy kit (do not touch) | `apps/web/src/components/ui/*`, `styles/main.css` utilities |
|
||||||
|
|
||||||
|
### Mandatory child-control conversion (same pass)
|
||||||
|
|
||||||
|
هنگام بازنویسی یک صفحه (View)، ایجنت موظف است علاوه بر خود فایل View، تمام کامپوننتهای فرزندی را که در آن View ایمپورت شدهاند و حاوی اینپوت/سلکت/دکمه هستند نیز بازبینی کرده و درون آنها را به کامپوننتهای رسمی `ant-design-vue` تبدیل کند. هیچ تگ `<input>`, `<select>`, `<textarea>`, `<button>` خامی نباید در صفحه یا کامپوننتهای فرزندِ مصرفی آن باقی بماند.
|
||||||
|
|
||||||
|
## 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,37 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 246.7 46.59">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #10897d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #16c1a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: #43f7dd;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<g>
|
||||||
|
<path class="cls-1" d="M199.15,23.8v19.86c0,1.48-1.2,2.67-2.67,2.67h-8.09c-1.48,0-2.67-1.2-2.67-2.67V15.92c0-.91.47-1.76,1.23-2.25l12.85-8.23c1.13-.72,2.63-.5,3.49.53l11.02,13.14c.18.21.49.22.68.03l8.04-8.21c1.04-1.06,2.75-1.07,3.8-.02l5.99,5.99c1.05,1.05,1.05,2.74,0,3.78l-16.98,16.98c-1.1,1.1-2.92,1.03-3.93-.15l-11.96-14.01c-.28-.33-.81-.13-.81.3h0Z"/>
|
||||||
|
<circle class="cls-3" cx="237.24" cy="6.48" r="6.48"/>
|
||||||
|
<path class="cls-3" d="M242.84,16.26h0c2.13,0,3.86,1.73,3.86,3.86v22.61c0,2.13-1.73,3.86-3.86,3.86h0c-2.13,0-3.86-1.73-3.86-3.86v-22.61c0-2.13,1.73-3.86,3.86-3.86Z"/>
|
||||||
|
<path class="cls-2" d="M233.49,25.66h0c2.13,0,3.86,1.73,3.86,3.86v13.21c0,2.13-1.73,3.86-3.86,3.86h0c-2.13,0-3.86-1.73-3.86-3.86v-13.21c0-2.13,1.73-3.86,3.86-3.86Z"/>
|
||||||
|
<rect class="cls-1" x="220.08" y="35.24" width="7.72" height="11.35" rx="3.86" ry="3.86"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="cls-1" d="M132.19,17.19v2.66c0,1.27,1.03,2.29,2.29,2.29h2.66c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-2.66c-1.27,0-2.29,1.03-2.29,2.29Z"/>
|
||||||
|
<path class="cls-1" d="M102.53,17.19v2.66c0,1.27,1.03,2.29,2.29,2.29h12.91c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-12.91c-1.27,0-2.29,1.03-2.29,2.29Z"/>
|
||||||
|
<path class="cls-1" d="M13.73,20.2v2.66c0,1.27,1.03,2.29,2.29,2.29h12.91c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-12.91c-1.27,0-2.29,1.03-2.29,2.29Z"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-1" d="M173.05,30.25c-.52-1.2-1.24-2.25-2.14-3.16-.91-.9-1.96-1.62-3.16-2.14-1.2-.52-2.49-.78-3.88-.78h-3.25c-1.12,0-2.2.19-3.24.58-1.04.39-1.98.92-2.81,1.59-.83.68-1.54,1.49-2.11,2.43-.58.95-.97,1.98-1.16,3.1l-1.16,5.97c-.16.66-.47,1.18-.96,1.56-.48.39-1.05.58-1.71.58h-6.49c-.74,0-1.31-.26-1.74-.78-.43-.52-.56-1.15-.41-1.88l1.35-6.87c.25-1.25-.58-2.47-1.84-2.69l-2.52-.46c-1.22-.22-2.4.57-2.65,1.79l-1.81,8.7c-.12.66-.43,1.19-.93,1.59-.5.4-1.08.61-1.74.61h-10.08c-.74,0-1.31-.26-1.74-.78-.43-.52-.56-1.15-.41-1.88l1.35-6.87c.25-1.25-.58-2.47-1.84-2.69l-2.52-.46c-1.22-.22-2.4.57-2.65,1.79l-1.81,8.7c-.12.66-.43,1.19-.93,1.59-.5.4-1.08.61-1.74.61h-9.5c-.66,0-1.22-.22-1.68-.67-.46-.44-.69-.99-.69-1.65v-3.53c0-1.39-.26-2.69-.78-3.91-.52-1.22-1.24-2.28-2.14-3.19-.91-.91-1.96-1.62-3.16-2.14-1.2-.52-2.49-.78-3.88-.78h-21.78l9.58-11.3c.79-.94.71-2.33-.19-3.17l-1.79-1.65c-.96-.88-2.45-.79-3.29.2l-14.66,17.17c-.79.92-.72,2.3.16,3.14l3.59,3.43h29.14c.54,0,1.02.2,1.45.61.43.4.64.9.64,1.48v4.11c0,.54-.19.99-.58,1.33-.39.35-.79.52-1.22.52h-38.23c-.66,0-1.21-.22-1.65-.67-.44-.44-.67-.99-.67-1.65v-8.54c0-1.27-1.03-2.29-2.29-2.29h-2.6c-1.27,0-2.29,1.03-2.29,2.29v8.6c0,.66-.22,1.2-.67,1.62-.44.43-.99.64-1.65.64H11.53c-1.2,0-2.22-.4-3.07-1.21-.85-.81-1.27-1.83-1.27-3.07v-8.25c0-1.27-1.03-2.29-2.29-2.29h-2.6c-1.27,0-2.29,1.03-2.29,2.29v6.47c0,1.66.32,3.24.96,4.72.64,1.49,1.51,2.79,2.61,3.91,1.1,1.12,2.39,2,3.88,2.63,1.49.64,3.06.96,4.72.96h22.01c1.39,0,2.69-.25,3.91-.75,1.22-.5,2.33-1.33,3.33-2.49,1.93,2.16,4.29,3.24,7.07,3.24h35.8c1.24,0,2.44-.23,3.62-.69,1.18-.46,2.25-1.08,3.22-1.85.93.77,1.98,1.39,3.16,1.85,1.18.46,2.44.69,3.79.69h7.59c1.23,0,2.48-.29,3.74-.87,1.25-.58,2.25-1.37,2.98-2.38.58,1,1.46,1.8,2.64,2.38,1.18.58,2.36.87,3.56.87h9.44c1.23,0,2.48-.29,3.74-.87,1.25-.58,2.25-1.37,2.98-2.38.58,1,1.46,1.8,2.64,2.38,1.18.58,2.37.87,3.56.87h6.03c1.24,0,2.39-.23,3.47-.69,1.08-.46,2.07-1.29,2.95-2.49.89,1.24,1.86,2.08,2.93,2.52,1.06.44,2.15.67,3.27.67h6.6c1.04,0,2.03-.2,2.95-.61.93-.4,1.74-.96,2.43-1.65s1.24-1.51,1.65-2.43c.4-.93.61-1.91.61-2.95v-4.36c0-1.39-.26-2.68-.78-3.88ZM166.65,39.98h-7.99c-.43,0-.77-.15-1.04-.46-.27-.31-.37-.67-.29-1.1l.93-4.64c.08-.42.27-.77.58-1.04.31-.27.67-.4,1.1-.4h4.98c.5,0,.92.17,1.24.52.33.35.49.75.49,1.22v5.91Z"/>
|
||||||
|
<path class="cls-3" d="M166.7,16.1c-.65.12-1.24.25-1.77.37-.53.12-1.12.26-1.79.4-.67.14-1.34.29-2.02.44-1.3.29-2.73.61-4.28.94-.68.15-1.34-.31-1.45-1l-.24-1.57c-.1-.63.31-1.23.94-1.36l4.01-.88c-.09-.34-.16-.61-.21-.81-.05-.2-.09-.38-.12-.54-.03-.15-.07-.33-.12-.54-.05-.2-.09-.47-.12-.81-.06-.59,0-1.13.21-1.63.2-.5.48-.95.84-1.35.36-.4.78-.73,1.26-.98.48-.25.95-.4,1.42-.47l2.42-.42c.56-.09,1.11-.05,1.65.14.54.19,1.02.46,1.44.81s.78.78,1.07,1.28c.29.5.49,1.01.58,1.54l.23,1.4c.12.62.09,1.21-.09,1.77-.19.56-.47,1.05-.84,1.49-.37.43-.82.81-1.35,1.12-.53.31-1.09.53-1.68.65ZM166.79,11.54s-.02-.1-.05-.21c-.03-.11-.06-.24-.09-.4-.06-.16-.12-.34-.19-.56-.16-.31-.39-.42-.7-.33l-1.44.37c-.37.06-.53.26-.47.6l.51,1.63,1.86-.47c.5-.09.68-.31.56-.65Z"/>
|
||||||
|
<path class="cls-3" d="M105.49,6.57l11.59-2.48c.64-.14,1.28.27,1.43.91l.27,1.15c.15.66-.26,1.31-.93,1.45l-11.66,2.46c-.67.14-1.32-.3-1.44-.97l-.2-1.13c-.11-.64.3-1.25.94-1.39Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 197.7 151.02">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #10897d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #16c1a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: #43f7dd;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<path class="cls-1" d="M43.57,77.15v64.37c0,4.79-3.88,8.67-8.67,8.67H8.67c-4.79,0-8.67-3.88-8.67-8.67V51.62c0-2.96,1.51-5.71,3.99-7.3l41.67-26.69c3.67-2.35,8.52-1.61,11.32,1.73l35.74,42.59c.57.68,1.6.72,2.22.09l26.07-26.6c3.38-3.45,8.92-3.47,12.33-.06l19.42,19.42c3.39,3.39,3.39,8.88,0,12.26l-55.06,55.06c-3.58,3.58-9.45,3.35-12.73-.5l-38.76-45.42c-.9-1.06-2.64-.42-2.64.97Z"/>
|
||||||
|
<circle class="cls-3" cx="167.06" cy="21.02" r="21.02"/>
|
||||||
|
<rect class="cls-3" x="172.68" y="52.7" width="25.02" height="98.32" rx="12.51" ry="12.51"/>
|
||||||
|
<rect class="cls-2" x="142.38" y="83.17" width="25.02" height="67.85" rx="12.51" ry="12.51"/>
|
||||||
|
<rect class="cls-1" x="111.4" y="114.24" width="25.02" height="36.78" rx="12.51" ry="12.51"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
description: حاکمیت مطلق معماری رابط کاربری بر پایه Ant Design Vue v4، قفل مهاجرت صفحه به صفحه، ممنوعیت کامل CSS دستی و استاندارد ریاضی چیدمان
|
||||||
|
globs: apps/web/src/**/*.{vue,ts,tsx,css}
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# قرارداد حاکمیتی و غیرقابلمذاکرهٔ رابط کاربری (Ant Design Vue v4 Strict Contract)
|
||||||
|
|
||||||
|
تنها منبع مجاز برای ساخت و بازنویسی عناصر رابط کاربری در این پروژه `ant-design-vue` (نسخه ۴) است. هرگونه تخطی از قوانین زیر خطای معماری محسوب میشود:
|
||||||
|
|
||||||
|
## ۱. قفل ایزولاسیون و مهاجرت صفحه به صفحه (Single-Page Blast Radius Lock)
|
||||||
|
1. **ممنوعیت ویرایش دستهجمعی:** تو فقط و فقط مجاز به ویرایش صفحه (View) یا کامپوننتی هستی که کاربر صراحتاً در همان نوبت نام برده است. دست زدن به سایر صفحات پروژه مطلقاً ممنوع است.
|
||||||
|
2. **حفظ لایهٔ قدیمی برای صفحات مهاجرتنکرده:** فایلهای داخل `apps/web/src/components/ui/Ui*.vue` و استایلهای سراسری `main.css` / `tokens.css` نباید حذف یا ویرایش شوند تا صفحات قدیمی پروژه بدون خطا به کار خود ادامه دهند.
|
||||||
|
3. **قطع کامل وابستگی در صفحهٔ هدف:** در صفحهای که کاربر دستور بازنویسی آن را میدهد، تمام ایمپورتهای `@/components/ui` و کلاسهای قدیمی باید از آن فایل حذف شوند و ۱۰۰٪ با کامپوننتهای رسمی `<a-...>` جایگزین گردند.
|
||||||
|
4. **کامپوننتهای تخصصی تاریخ شمسی و مبالغ:** در صفحات بازنویسیشده، اگر از `JalaliDateInput` یا `MoneyInput` استفاده شده است، منطق آنها را حفظ کن اما مطمئن شو ظاهر ورودی آنها با ارتفاع `40px`، ردیوس `10px` و عرض `style="width: 100%"` کاملاً همتراز با فیلدهای Ant Design نمایش داده میشود.
|
||||||
|
|
||||||
|
## ۲. لیست سیاه سینتکس و تگهای ممنوعه در صفحات بازنویسیشده (Banned Syntax)
|
||||||
|
در هر فایلی که بازنویسی یا ایجاد میکنی، وجود موارد زیر خطای بحرانی است و باید فوراً حذف شوند:
|
||||||
|
- **تگهای خام HTML برای فرم و جدول:** `<button>`, `<input>`, `<select>`, `<textarea>`, `<label>`, `<form>`, `<table>`, `<thead>`, `<tbody>`, `<tr>`, `<td>`.
|
||||||
|
- **کلاسهای لگسی پروژه:** `.btn`, `.btn-primary`, `.btn-ghost`, `.btn-danger`, `.btn-save`, `.field`, `.crm-page`, `.crm-page-header`, `.crm-card`, `.crm-table`, `.crm-empty-state`.
|
||||||
|
- **کلاسهای Tailwind (پروژه فاقد Tailwind است):** استفاده از کلاسهای ناموجود مانند `w-full`, `flex`, `gap-4`, `p-4`, `mb-4` مطلقاً ممنوع است. برای عرض کامل کنترلها فقط از `style="width: 100%"` یا پراپ `block` استفاده کن.
|
||||||
|
|
||||||
|
## ۳. قوانین ریاضی چیدمان، گرید و ریتم فضایی (Spatial & Layout Mathematics)
|
||||||
|
1. **ممنوعیت تکهتکه کردن فرم به کارتهای متعدد (Anti-Card-Fragmentation):**
|
||||||
|
- خرد کردن یک فرم به چندین باکس یا کارت مجزا زیر هم ممنوع است.
|
||||||
|
- کل فیلدهای یک فرم اصلی باید در **حداکثر ۱ `<a-card :bordered="false">`** تجمیع شوند.
|
||||||
|
- برای تفکیک بخشهای منطقی در داخل آن کارت واحد، فقط از `<a-divider orientation="right">عنوان بخش</a-divider>` استفاده کن.
|
||||||
|
2. **چیدمان استاندارد دو ستونه (فرم اصلی + پنل خلاصه):**
|
||||||
|
- ردیف اصلی صفحه: `<a-row :gutter="[24, 24]">`
|
||||||
|
- ستون اصلی (فرم/محتوا): `<a-col :xs="24" :lg="17" :xl="18">`
|
||||||
|
- ستون کناری (خلاصه وضعیت): `<a-col :xs="24" :lg="7" :xl="6">` همراه با `<a-affix :offset-top="24">`.
|
||||||
|
- محتوای پنل خلاصه باید الزاماً با `<a-descriptions :column="1" size="small" bordered>` داخل `<a-card title="..." :bordered="false">` پیادهسازی شود، نه با `div` و `span` دستی.
|
||||||
|
3. **قانون عرض فیلدها در فرم (Form Grid Allocation):**
|
||||||
|
- تمام فرمها باید `<a-form layout="vertical" :model="...">` باشند و تمام فیلدها بدون استثنا داخل `<a-form-item label="..." name="...">` قرار گیرند.
|
||||||
|
- رها کردن فیلدهای کوتاه در عرض ۱۰۰٪ (`span="24"`) ممنوع است. چیدمان درون فرم باید با `<a-row :gutter="[16, 0]">` به شکل زیر باشد:
|
||||||
|
- **فیلدهای کوتاه** (تاریخ، ساعت، مدت زمان، مبلغ، وضعیت، نوع، کد): `<a-col :xs="24" :sm="12" :md="8">` (۳ ستونه).
|
||||||
|
- **فیلدهای متوسط** (انتخاب مشتری، مسئول انجام، فرصت مرتبط، دستهبندی، عنوان): `<a-col :xs="24" :md="12">` (۲ ستونه).
|
||||||
|
- **فیلدهای تمامعرض** (توضیحات، یادداشت، چکلیست، برچسبها): `<a-col :span="24">`.
|
||||||
|
- **سوییچها و چکباکسهای همخانواده:** قرارگیری در یک ردیف با `<a-space :size="24" wrap>`.
|
||||||
|
4. **مدیریت Margin و Padding بدون CSS دستی:**
|
||||||
|
- فاصلهٔ عمودی فیلدها فقط توسط `itemMarginBottom` در کانفیگ تم `<a-form-item>` کنترل میشود.
|
||||||
|
- فاصلهٔ افقی/عمودی دکمهها و المانها فقط با `<a-space :size="8 | 12 | 16">` یا `<a-flex gap="small | middle | large" align="center" justify="space-between">` کنترل میشود.
|
||||||
|
|
||||||
|
## ۴. قفل سینتکس اختصاصی Ant Design Vue v4
|
||||||
|
- **بایندینگ مقدار (`v-model`):** استفاده از `v-model="..."` بدون آرگومان روی کامپوننتهای Ant Design Vue خطاست:
|
||||||
|
- برای `a-input`, `a-textarea`, `a-input-number`, `a-select`, `a-radio-group`, `a-segmented`, `a-date-picker`, `a-time-picker`: الزاماً `v-model:value="..."`.
|
||||||
|
- برای `a-checkbox`, `a-switch`: الزاماً `v-model:checked="..."`.
|
||||||
|
- برای `a-modal`, `a-drawer`, `a-dropdown`, `a-popover`, `a-tooltip`: الزاماً `v-model:open="..."` (پراپ `:visible` منسوخ و ممنوع است).
|
||||||
|
- **الزام عرض ۱۰۰٪ روی کنترلهای خاص:** کامپوننتهای `<a-input-number>`, `<a-date-picker>`, `<a-time-picker>` و `<a-select>` در داخل `<a-form-item>` باید دارای `style="width: 100%"` باشند.
|
||||||
|
- **فیدبک و پیامها:** برای نمایش پیام از `import { message, notification, Modal } from 'ant-design-vue'` استفاده کن.
|
||||||
|
|
||||||
|
## ۵. حاکمیت مطلق توکنها و ممنوعیت CSS دستی (Zero Manual Styling & RTL Lock)
|
||||||
|
1. نوشتن کد رنگ (`#hex`, `rgb`, `rgba`, `hsl`)، سایه (`box-shadow`)، ردیوس (`border-radius`) و فاصلههای دستی (`margin`, `padding` با `px`/`rem`) در `<style scoped>` ممنوع است.
|
||||||
|
2. تمام تنظیمات ظاهری (ردیوس، شدو، ارتفاع کنترلها، پدینگ و رنگها) فقط در فایل مرکزی `apps/web/src/theme/antdTheme.ts` تعریف میشوند.
|
||||||
|
3. در صورت نیاز ضروری به خواندن توکن در یک کانتینر، فقط از هوک `const { token } = theme.useToken()` در `ant-design-vue` استفاده کن.
|
||||||
|
4. استفاده از `:deep(.ant-...)` برای دستکاری ظاهر کامپوننتهای انت ممنوع است.
|
||||||
|
5. در RTL، استفاده از `left`, `right`, `margin-left`, `margin-right`, `padding-left`, `padding-right` ممنوع است؛ فقط از خواص منطقی (`inset-inline-start`, `inset-inline-end`, `margin-inline`, `padding-inline`) استفاده شود.
|
||||||
|
|
||||||
|
## ۶. گیت خودارزیابی پیش از تحویل کد (Self-Verification Gate)
|
||||||
|
قبل از اتمام کار روی فایلِ درخواستی، این ۵ شرط را بررسی کن:
|
||||||
|
- [ ] آیا فقط فایل درخواستی کاربر ویرایش شده و سایر صفحات دستنخورده ماندهاند؟
|
||||||
|
- [ ] آیا تمام تگهای خام فرم، کامپوننتهای `Ui*`، کلاسهای `.btn`/`.field`/`.crm-*` و کلاسهای ناموجود مثل `w-full` از این صفحه حذف شدهاند؟
|
||||||
|
- [ ] آیا فرم در ۱ کارت واحد با `<a-divider>` و گرید `<a-row :gutter="[16, 0]">` چیده شده و فیلدهای کوتاه از حالت تمامعرض خارج شدهاند؟
|
||||||
|
- [ ] آیا تمام بایندینگها از `v-model:value`، `v-model:checked` و `v-model:open` به همراه `style="width: 100%"` روی سلکتها و شمارندهها استفاده میکنند؟
|
||||||
|
- [ ] آیا `<style scoped>` کاملاً از رنگهای Hex، مارجین/پدینگ دستی، ردیوس، شدو و `:deep(.ant-*)` پاکسازی شده است؟
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"prisma:seed": "tsx prisma/seed.ts",
|
"prisma:seed": "tsx prisma/seed.ts",
|
||||||
"lint": "echo \"api lint placeholder\"",
|
"lint": "echo \"api lint placeholder\"",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
|
||||||
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts"
|
"test": "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": {
|
"dependencies": {
|
||||||
"@mento/shared": "*",
|
"@mento/shared": "*",
|
||||||
|
|||||||
@@ -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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
import { assigneeWhere } from '../common/record-scope';
|
import { assigneeWhere } from '../common/record-scope';
|
||||||
import { normalizePhone } from '../contacts/normalize';
|
import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
|
||||||
import {
|
import {
|
||||||
excelFieldsFromColumns,
|
excelFieldsFromColumns,
|
||||||
formatClock,
|
formatClock,
|
||||||
@@ -357,7 +357,7 @@ export class ActivitiesExcelService {
|
|||||||
return id;
|
return id;
|
||||||
};
|
};
|
||||||
const resolvePerson = async (row: Record<string, string>) => {
|
const resolvePerson = async (row: Record<string, string>) => {
|
||||||
const mobile = normalizePhone(row.personMobile);
|
const mobile = storedPhoneNumber(row.personMobile);
|
||||||
const name = (row.personName ?? '').trim().toLowerCase();
|
const name = (row.personName ?? '').trim().toLowerCase();
|
||||||
const key = mobile ? `m:${mobile}` : name ? `n:${name}` : '';
|
const key = mobile ? `m:${mobile}` : name ? `n:${name}` : '';
|
||||||
if (!key) return null;
|
if (!key) return null;
|
||||||
@@ -478,10 +478,13 @@ export class ActivitiesExcelService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async findPersonId(organizationId: string, row: Record<string, string>) {
|
private async findPersonId(organizationId: string, row: Record<string, string>) {
|
||||||
const mobile = normalizePhone(row.personMobile);
|
const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile));
|
||||||
if (mobile) {
|
if (mobileKeys.length) {
|
||||||
const byMobile = await this.prisma.person.findFirst({
|
const byMobile = await this.prisma.person.findFirst({
|
||||||
where: { organizationId, mobile: { contains: mobile.replace(/^\+98|^98|^0/, '') } },
|
where: {
|
||||||
|
organizationId,
|
||||||
|
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
||||||
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (byMobile) return byMobile.id;
|
if (byMobile) return byMobile.id;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
|||||||
import { memoryStorage } from 'multer';
|
import { memoryStorage } from 'multer';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||||
import { RolesGuard } from '../auth/roles.guard';
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
import { Roles } from '../auth/roles.decorator';
|
import { Roles } from '../auth/roles.decorator';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
@@ -27,14 +28,17 @@ import { ActivitiesExcelService } from './activities-excel.service';
|
|||||||
import { ActivityStagesService } from './activity-stages.service';
|
import { ActivityStagesService } from './activity-stages.service';
|
||||||
import { ActivityTypesService } from './activity-types.service';
|
import { ActivityTypesService } from './activity-types.service';
|
||||||
import {
|
import {
|
||||||
|
CreateActivityBoardDto,
|
||||||
CreateActivityDto,
|
CreateActivityDto,
|
||||||
CreateActivityStageDto,
|
CreateActivityStageDto,
|
||||||
CreateActivityTypeDto,
|
CreateActivityTypeDto,
|
||||||
ImportActivityRowsDto,
|
ImportActivityRowsDto,
|
||||||
ListActivitiesQueryDto,
|
ListActivitiesQueryDto,
|
||||||
|
ListActivityStagesQueryDto,
|
||||||
MoveActivityStageDto,
|
MoveActivityStageDto,
|
||||||
ReorderActivityStagesDto,
|
ReorderActivityStagesDto,
|
||||||
ReorderActivityTypesDto,
|
ReorderActivityTypesDto,
|
||||||
|
UpdateActivityBoardDto,
|
||||||
UpdateActivityDto,
|
UpdateActivityDto,
|
||||||
UpdateActivityStageDto,
|
UpdateActivityStageDto,
|
||||||
UpdateActivityTypeDto,
|
UpdateActivityTypeDto,
|
||||||
@@ -47,7 +51,8 @@ const excelUpload = FileInterceptor('file', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@Controller('activities')
|
@Controller('activities')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
|
||||||
|
@RequirePermission('activities')
|
||||||
export class ActivitiesController {
|
export class ActivitiesController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly activitiesService: ActivitiesService,
|
private readonly activitiesService: ActivitiesService,
|
||||||
@@ -65,9 +70,40 @@ export class ActivitiesController {
|
|||||||
return this.activitiesService.summary(user.organizationId, user, scope);
|
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')
|
@Get('stages')
|
||||||
listStages(@CurrentUser() user: Actor) {
|
listStages(@CurrentUser() user: Actor, @Query() query: ListActivityStagesQueryDto) {
|
||||||
return this.stagesService.list(user.organizationId);
|
return this.stagesService.list(user.organizationId, query.boardId, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('stages')
|
@Post('stages')
|
||||||
@@ -157,6 +193,10 @@ export class ActivitiesController {
|
|||||||
typeDefId: query.typeDefId,
|
typeDefId: query.typeDefId,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
assigneeId: query.assigneeId,
|
assigneeId: query.assigneeId,
|
||||||
|
title: query.title,
|
||||||
|
customer: query.customer,
|
||||||
|
due: query.due,
|
||||||
|
boardId: query.boardId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -189,6 +229,43 @@ export class ActivitiesController {
|
|||||||
typeDefId: query.typeDefId,
|
typeDefId: query.typeDefId,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
assigneeId: query.assigneeId,
|
assigneeId: query.assigneeId,
|
||||||
|
title: query.title,
|
||||||
|
customer: query.customer,
|
||||||
|
due: query.due,
|
||||||
|
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,
|
query.stageId ? { stageId: query.stageId } : undefined,
|
||||||
);
|
);
|
||||||
@@ -271,6 +348,56 @@ export class ActivitiesController {
|
|||||||
return this.activitiesService.get(user.organizationId, id, user);
|
return this.activitiesService.get(user.organizationId, id, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/images')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', {
|
||||||
|
storage: memoryStorage(),
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
addImage(
|
||||||
|
@CurrentUser() user: Actor,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.activitiesService.addImage(
|
||||||
|
user.organizationId,
|
||||||
|
user.userId,
|
||||||
|
id,
|
||||||
|
file,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/images/:imageId')
|
||||||
|
async downloadImage(
|
||||||
|
@CurrentUser() user: Actor,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('imageId') imageId: string,
|
||||||
|
): Promise<StreamableFile> {
|
||||||
|
const { stream, fileName, mimeType } = await this.activitiesService.openImage(
|
||||||
|
user.organizationId,
|
||||||
|
id,
|
||||||
|
imageId,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
const encoded = encodeURIComponent(fileName);
|
||||||
|
return new StreamableFile(stream, {
|
||||||
|
type: mimeType,
|
||||||
|
disposition: `inline; filename="${fileName.replace(/"/g, '')}"; filename*=UTF-8''${encoded}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/images/:imageId')
|
||||||
|
async removeImage(
|
||||||
|
@CurrentUser() user: Actor,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('imageId') imageId: string,
|
||||||
|
) {
|
||||||
|
await this.activitiesService.removeImage(user.organizationId, id, imageId, user);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/promote-from-prior')
|
@Post(':id/promote-from-prior')
|
||||||
promoteFromPrior(@CurrentUser() user: Actor, @Param('id') id: string) {
|
promoteFromPrior(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||||
return this.activitiesService.promoteFromPrior(user, id);
|
return this.activitiesService.promoteFromPrior(user, id);
|
||||||
@@ -301,7 +428,13 @@ export class ActivitiesController {
|
|||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: MoveActivityStageDto,
|
@Body() dto: MoveActivityStageDto,
|
||||||
) {
|
) {
|
||||||
return this.activitiesService.moveToStage(user.organizationId, id, user, dto.stageId);
|
return this.activitiesService.moveToStage(
|
||||||
|
user.organizationId,
|
||||||
|
id,
|
||||||
|
user,
|
||||||
|
dto.stageId,
|
||||||
|
dto.position,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/complete')
|
@Patch(':id/complete')
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import { ActivityTypesService } from './activity-types.service';
|
|||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { AutomationsModule } from '../automations/automations.module';
|
import { AutomationsModule } from '../automations/automations.module';
|
||||||
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
||||||
|
import { StorageModule } from '../storage/storage.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule],
|
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule, StorageModule],
|
||||||
controllers: [ActivitiesController],
|
controllers: [ActivitiesController],
|
||||||
providers: [
|
providers: [
|
||||||
ActivitiesService,
|
ActivitiesService,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
ActivityDto,
|
ActivityDto,
|
||||||
ActivityFieldValues,
|
ActivityFieldValues,
|
||||||
ActivityFilter,
|
ActivityFilter,
|
||||||
|
ActivityImageDto,
|
||||||
ActivitySummaryDto,
|
ActivitySummaryDto,
|
||||||
ActivityStageCountsDto,
|
ActivityStageCountsDto,
|
||||||
CreateActivityRequest,
|
CreateActivityRequest,
|
||||||
@@ -15,6 +16,10 @@ import type {
|
|||||||
UpdateActivityRequest,
|
UpdateActivityRequest,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||||
|
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { AutomationsService } from '../automations/automations.service';
|
import { AutomationsService } from '../automations/automations.service';
|
||||||
@@ -25,21 +30,27 @@ import {
|
|||||||
dealAccessWhere,
|
dealAccessWhere,
|
||||||
ownerWhere,
|
ownerWhere,
|
||||||
} from '../common/record-scope';
|
} from '../common/record-scope';
|
||||||
|
import { assertAssigneeSet } from '../common/assign-access';
|
||||||
|
import { placeId, sameIds, writePositions } from '../common/column-order';
|
||||||
import { normalizeTags } from '../common/tags';
|
import { normalizeTags } from '../common/tags';
|
||||||
import { GamificationService } from '../gamification/gamification.service';
|
import { GamificationService } from '../gamification/gamification.service';
|
||||||
import { tehranDayKey } from '../routines/routine-dates';
|
import { tehranDayKey } from '../routines/routine-dates';
|
||||||
import { buildSearchWhere, ci } from '../common/search';
|
import { buildSearchWhere, ci } from '../common/search';
|
||||||
import { activityPriorWhere } from '../common/prior-archive';
|
import { activityPriorWhere } from '../common/prior-archive';
|
||||||
import { ActivityStagesService, type StageDefaults } from './activity-stages.service';
|
import { leadsDeepWhere } from '../common/data-access';
|
||||||
|
import { ActivityStagesService, type BoardStageContext } from './activity-stages.service';
|
||||||
import {
|
import {
|
||||||
ActivityTypesService,
|
ActivityTypesService,
|
||||||
type SystemTypes,
|
type SystemTypes,
|
||||||
type TypeRow,
|
type TypeRow,
|
||||||
} from './activity-types.service';
|
} from './activity-types.service';
|
||||||
|
|
||||||
type DtoContext = { stages?: StageDefaults; types?: SystemTypes };
|
type DtoContext = { stages?: BoardStageContext; types?: SystemTypes };
|
||||||
|
|
||||||
type ActivityActor = Pick<Actor, 'userId' | 'role' | 'priorLens' | 'priorCutoff'>;
|
type ActivityActor = Pick<
|
||||||
|
Actor,
|
||||||
|
'userId' | 'role' | 'priorLens' | 'priorCutoff' | 'leadsAccess' | 'teamPeerIds'
|
||||||
|
>;
|
||||||
|
|
||||||
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمیکند (جز عقبافتاده) */
|
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمیکند (جز عقبافتاده) */
|
||||||
type StageScope = { stageId: string };
|
type StageScope = { stageId: string };
|
||||||
@@ -49,6 +60,12 @@ type ListFacets = {
|
|||||||
typeDefId?: string;
|
typeDefId?: string;
|
||||||
status?: ActivityDto['status'];
|
status?: ActivityDto['status'];
|
||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
|
title?: string;
|
||||||
|
customer?: string;
|
||||||
|
/** set = زمان شروع دارد، unset = ندارد */
|
||||||
|
due?: 'set' | 'unset';
|
||||||
|
/** گروه فعالیت؛ خالی = همهٔ گروهها */
|
||||||
|
boardId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ActivityRow = {
|
type ActivityRow = {
|
||||||
@@ -77,14 +94,66 @@ type ActivityRow = {
|
|||||||
assignee: { id: string; fullName: string } | null;
|
assignee: { id: string; fullName: string } | null;
|
||||||
assignees: Array<{ id: string; fullName: string }>;
|
assignees: Array<{ id: string; fullName: string }>;
|
||||||
createdBy: { id: string; fullName: string } | null;
|
createdBy: { id: string; fullName: string } | null;
|
||||||
stage?: { id: string; kind: ActivityDto['status'] } | null;
|
boardId?: string | null;
|
||||||
|
stage?: { id: string; kind: ActivityDto['status']; boardId: string } | null;
|
||||||
typeDef?: { id: string; name: string; icon: string; color: string } | null;
|
typeDef?: { id: string; name: string; icon: string; color: string } | null;
|
||||||
fields?: Prisma.JsonValue | null;
|
fields?: Prisma.JsonValue | null;
|
||||||
participantIds?: string[];
|
participantIds?: string[];
|
||||||
|
images?: Array<{
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
createdAt: Date;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTIVITY_IMAGE_MAX_BYTES = 200 * 1024;
|
||||||
|
const ACTIVITY_IMAGE_MAX_COUNT = 8;
|
||||||
|
const ACTIVITY_IMAGE_MIME = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']);
|
||||||
|
const ACTIVITY_VOICE_MAX_BYTES = 2 * 1024 * 1024;
|
||||||
|
const ACTIVITY_VOICE_MAX_COUNT = 5;
|
||||||
|
const ACTIVITY_VOICE_MIME = new Set(['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/mpeg', 'audio/mp3', 'audio/aac', 'audio/x-m4a', 'audio/m4a', 'audio/wav', 'audio/x-wav']);
|
||||||
|
const ACTIVITY_FILE_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
|
const ACTIVITY_FILE_MAX_COUNT = 8;
|
||||||
|
const ACTIVITY_FILE_MIME = new Set([
|
||||||
|
'application/pdf',
|
||||||
|
'text/plain',
|
||||||
|
'text/csv',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'application/vnd.ms-powerpoint',
|
||||||
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
'application/zip',
|
||||||
|
'application/x-zip-compressed',
|
||||||
|
]);
|
||||||
|
const ACTIVITY_EXT_MIME: Record<string, string> = {
|
||||||
|
pdf: 'application/pdf',
|
||||||
|
txt: 'text/plain',
|
||||||
|
csv: 'text/csv',
|
||||||
|
doc: 'application/msword',
|
||||||
|
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
xls: 'application/vnd.ms-excel',
|
||||||
|
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
ppt: 'application/vnd.ms-powerpoint',
|
||||||
|
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
zip: 'application/zip',
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
png: 'image/png',
|
||||||
|
gif: 'image/gif',
|
||||||
|
webp: 'image/webp',
|
||||||
|
webm: 'audio/webm',
|
||||||
|
ogg: 'audio/ogg',
|
||||||
|
mp3: 'audio/mpeg',
|
||||||
|
m4a: 'audio/mp4',
|
||||||
|
wav: 'audio/wav',
|
||||||
};
|
};
|
||||||
|
|
||||||
const include = {
|
const include = {
|
||||||
stage: { select: { id: true, kind: true } },
|
stage: { select: { id: true, kind: true, boardId: true } },
|
||||||
typeDef: { select: { id: true, name: true, icon: true, color: true } },
|
typeDef: { select: { id: true, name: true, icon: true, color: true } },
|
||||||
person: { select: { id: true, firstName: true, lastName: true } },
|
person: { select: { id: true, firstName: true, lastName: true } },
|
||||||
company: { select: { id: true, name: true } },
|
company: { select: { id: true, name: true } },
|
||||||
@@ -105,11 +174,13 @@ export class ActivitiesService {
|
|||||||
private readonly stages: ActivityStagesService,
|
private readonly stages: ActivityStagesService,
|
||||||
private readonly types: ActivityTypesService,
|
private readonly types: ActivityTypesService,
|
||||||
private readonly game: GamificationService,
|
private readonly game: GamificationService,
|
||||||
|
private readonly files: LocalFileStorage,
|
||||||
|
private readonly storage: StorageQuotaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async ctx(organizationId: string): Promise<DtoContext> {
|
private async ctx(organizationId: string): Promise<DtoContext> {
|
||||||
const [stages, types] = await Promise.all([
|
const [stages, types] = await Promise.all([
|
||||||
this.stages.defaults(organizationId),
|
this.stages.boardContext(organizationId),
|
||||||
this.types.system(organizationId),
|
this.types.system(organizationId),
|
||||||
]);
|
]);
|
||||||
return { stages, types };
|
return { stages, types };
|
||||||
@@ -242,8 +313,7 @@ export class ActivitiesService {
|
|||||||
facets?: ListFacets,
|
facets?: ListFacets,
|
||||||
stageScope?: StageScope,
|
stageScope?: StageScope,
|
||||||
): Promise<PaginatedResponse<ActivityDto>> {
|
): Promise<PaginatedResponse<ActivityDto>> {
|
||||||
const typeRows = await this.types.rows(organizationId);
|
const { where, ctx } = await this.listScope(
|
||||||
const where = this.buildWhere(
|
|
||||||
organizationId,
|
organizationId,
|
||||||
actor,
|
actor,
|
||||||
filter,
|
filter,
|
||||||
@@ -253,23 +323,14 @@ export class ActivitiesService {
|
|||||||
desk,
|
desk,
|
||||||
facets,
|
facets,
|
||||||
stageScope,
|
stageScope,
|
||||||
typeRows,
|
|
||||||
);
|
);
|
||||||
if (stageScope && stageScope.stageId !== 'all') {
|
|
||||||
(where.AND as Prisma.ActivityWhereInput[]).push(
|
|
||||||
await this.stages.whereForStage(organizationId, stageScope.stageId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const ctx: DtoContext = {
|
|
||||||
stages: await this.stages.defaults(organizationId),
|
|
||||||
types: this.types.systemOf(typeRows),
|
|
||||||
};
|
|
||||||
const take = Math.min(Math.max(limit, 1), 200);
|
const take = Math.min(Math.max(limit, 1), 200);
|
||||||
const [rows, total] = await this.prisma.$transaction([
|
const [rows, total] = await this.prisma.$transaction([
|
||||||
this.prisma.activity.findMany({
|
this.prisma.activity.findMany({
|
||||||
where,
|
where,
|
||||||
include,
|
include,
|
||||||
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
// روی پروندهٔ لید، تازهترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد
|
||||||
|
orderBy: this.listOrder(entity, stageScope),
|
||||||
skip: (page - 1) * take,
|
skip: (page - 1) * take,
|
||||||
take,
|
take,
|
||||||
}),
|
}),
|
||||||
@@ -284,6 +345,103 @@ export class ActivitiesService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* شناسهها با همان فیلتر و ترتیب لیست (زمان شروع صعودی).
|
||||||
|
* برای ورقزدن جزئیات؛ سقف دارد تا یک زبانهٔ خیلی شلوغ کل پاسخ را بزرگ نکند.
|
||||||
|
*/
|
||||||
|
async orderedIds(
|
||||||
|
organizationId: string,
|
||||||
|
actor: ActivityActor,
|
||||||
|
filter: ActivityFilter = 'today',
|
||||||
|
q?: string,
|
||||||
|
entity?: {
|
||||||
|
personId?: string;
|
||||||
|
companyId?: string;
|
||||||
|
dealId?: string;
|
||||||
|
leadId?: string;
|
||||||
|
},
|
||||||
|
range?: {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
},
|
||||||
|
desk?: 'mine' | 'team',
|
||||||
|
facets?: ListFacets,
|
||||||
|
stageScope?: StageScope,
|
||||||
|
): Promise<{ ids: string[]; total: number; truncated: boolean }> {
|
||||||
|
const cap = 2000;
|
||||||
|
const { where } = await this.listScope(
|
||||||
|
organizationId,
|
||||||
|
actor,
|
||||||
|
filter,
|
||||||
|
q,
|
||||||
|
entity,
|
||||||
|
range,
|
||||||
|
desk,
|
||||||
|
facets,
|
||||||
|
stageScope,
|
||||||
|
);
|
||||||
|
const [rows, total] = await this.prisma.$transaction([
|
||||||
|
this.prisma.activity.findMany({
|
||||||
|
where,
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: this.listOrder(entity, stageScope),
|
||||||
|
take: cap,
|
||||||
|
}),
|
||||||
|
this.prisma.activity.count({ where }),
|
||||||
|
]);
|
||||||
|
return { ids: rows.map((row) => row.id), total, truncated: total > rows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listScope(
|
||||||
|
organizationId: string,
|
||||||
|
actor: ActivityActor,
|
||||||
|
filter: ActivityFilter,
|
||||||
|
q: string | undefined,
|
||||||
|
entity:
|
||||||
|
| {
|
||||||
|
personId?: string;
|
||||||
|
companyId?: string;
|
||||||
|
dealId?: string;
|
||||||
|
leadId?: string;
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
range: { from?: string; to?: string } | undefined,
|
||||||
|
desk: 'mine' | 'team' | undefined,
|
||||||
|
facets: ListFacets | undefined,
|
||||||
|
stageScope: StageScope | undefined,
|
||||||
|
): Promise<{ where: Prisma.ActivityWhereInput; ctx: DtoContext }> {
|
||||||
|
const typeRows = await this.types.rows(organizationId);
|
||||||
|
const where = this.buildWhere(
|
||||||
|
organizationId,
|
||||||
|
actor,
|
||||||
|
filter,
|
||||||
|
q,
|
||||||
|
entity,
|
||||||
|
range,
|
||||||
|
desk,
|
||||||
|
facets,
|
||||||
|
stageScope,
|
||||||
|
typeRows,
|
||||||
|
);
|
||||||
|
const boardCtx = await this.stages.boardContext(organizationId);
|
||||||
|
if (facets?.boardId) {
|
||||||
|
const boardId = await this.stages.resolveBoardId(organizationId, facets.boardId);
|
||||||
|
await this.stages.assertCanAccessBoard(organizationId, boardId, actor);
|
||||||
|
(where.AND as Prisma.ActivityWhereInput[]).push(
|
||||||
|
this.stages.boardWhere(boardId, boardCtx.defaultBoardId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (stageScope && stageScope.stageId !== 'all') {
|
||||||
|
(where.AND as Prisma.ActivityWhereInput[]).push(
|
||||||
|
await this.stages.whereForStage(organizationId, stageScope.stageId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
where,
|
||||||
|
ctx: { stages: boardCtx, types: this.types.systemOf(typeRows) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** تعداد فعالیت هر مرحله با همان فیلترهای لیست (زمان، جستجو، میز، نوع، کارشناس) */
|
/** تعداد فعالیت هر مرحله با همان فیلترهای لیست (زمان، جستجو، میز، نوع، کارشناس) */
|
||||||
async stageCounts(
|
async stageCounts(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
@@ -293,7 +451,10 @@ export class ActivitiesService {
|
|||||||
desk?: 'mine' | 'team',
|
desk?: 'mine' | 'team',
|
||||||
facets?: ListFacets,
|
facets?: ListFacets,
|
||||||
): Promise<ActivityStageCountsDto> {
|
): Promise<ActivityStageCountsDto> {
|
||||||
const rows = await this.stages.rows(organizationId);
|
const boardId = await this.stages.resolveBoardId(organizationId, facets?.boardId);
|
||||||
|
await this.stages.assertCanAccessBoard(organizationId, boardId, actor);
|
||||||
|
const defaultBoard = await this.stages.ensureDefaultBoard(organizationId);
|
||||||
|
const rows = await this.stages.rows(organizationId, boardId);
|
||||||
const defaults = this.stages.defaultsOf(rows);
|
const defaults = this.stages.defaultsOf(rows);
|
||||||
const kindOf = new Map(rows.map((r) => [r.id, r.kind]));
|
const kindOf = new Map(rows.map((r) => [r.id, r.kind]));
|
||||||
const where = this.buildWhere(
|
const where = this.buildWhere(
|
||||||
@@ -308,6 +469,9 @@ export class ActivitiesService {
|
|||||||
{ stageId: 'all' },
|
{ stageId: 'all' },
|
||||||
await this.types.rows(organizationId),
|
await this.types.rows(organizationId),
|
||||||
);
|
);
|
||||||
|
(where.AND as Prisma.ActivityWhereInput[]).push(
|
||||||
|
this.stages.boardWhere(boardId, defaultBoard.id),
|
||||||
|
);
|
||||||
const groups = await this.prisma.activity.groupBy({
|
const groups = await this.prisma.activity.groupBy({
|
||||||
by: ['status', 'stageId'],
|
by: ['status', 'stageId'],
|
||||||
where,
|
where,
|
||||||
@@ -325,11 +489,46 @@ export class ActivitiesService {
|
|||||||
return { counts, total };
|
return { counts, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private listOrder(
|
||||||
|
entity?: { leadId?: string },
|
||||||
|
stageScope?: StageScope,
|
||||||
|
): Prisma.ActivityOrderByWithRelationInput[] {
|
||||||
|
if (entity?.leadId) return [{ createdAt: 'desc' }];
|
||||||
|
if (stageScope && stageScope.stageId !== 'all') {
|
||||||
|
return [{ boardPosition: 'asc' }, { dueAt: 'asc' }, { createdAt: 'desc' }];
|
||||||
|
}
|
||||||
|
return [{ dueAt: 'asc' }, { createdAt: 'desc' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reorderStage(
|
||||||
|
organizationId: string,
|
||||||
|
stageId: string,
|
||||||
|
activityId: string,
|
||||||
|
index: number,
|
||||||
|
) {
|
||||||
|
const where = await this.stages.whereForStage(organizationId, stageId);
|
||||||
|
const rows = await this.prisma.activity.findMany({
|
||||||
|
where,
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: [{ boardPosition: 'asc' }, { dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||||
|
});
|
||||||
|
const currentIds = rows.map((row) => row.id);
|
||||||
|
const placed = placeId(currentIds, activityId, index);
|
||||||
|
if (sameIds(currentIds, placed)) return;
|
||||||
|
await writePositions(
|
||||||
|
(ops) => this.prisma.$transaction(ops),
|
||||||
|
placed,
|
||||||
|
(id, boardPosition) =>
|
||||||
|
this.prisma.activity.update({ where: { id }, data: { boardPosition } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async moveToStage(
|
async moveToStage(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
id: string,
|
id: string,
|
||||||
actor: ActivityActor,
|
actor: ActivityActor,
|
||||||
stageId: string,
|
stageId: string,
|
||||||
|
position?: number,
|
||||||
): Promise<ActivityDto> {
|
): Promise<ActivityDto> {
|
||||||
await this.ensureExists(organizationId, id, actor);
|
await this.ensureExists(organizationId, id, actor);
|
||||||
const stage = await this.stages.get(organizationId, stageId);
|
const stage = await this.stages.get(organizationId, stageId);
|
||||||
@@ -341,6 +540,7 @@ export class ActivitiesService {
|
|||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
stageId: stage.id,
|
stageId: stage.id,
|
||||||
|
boardId: stage.boardId,
|
||||||
status: stage.kind,
|
status: stage.kind,
|
||||||
completedAt:
|
completedAt:
|
||||||
stage.kind === 'done'
|
stage.kind === 'done'
|
||||||
@@ -353,6 +553,7 @@ export class ActivitiesService {
|
|||||||
},
|
},
|
||||||
include,
|
include,
|
||||||
});
|
});
|
||||||
|
if (position != null) await this.reorderStage(organizationId, stage.id, id, position);
|
||||||
if (stage.kind === 'done' && existing?.status !== 'done') {
|
if (stage.kind === 'done' && existing?.status !== 'done') {
|
||||||
await this.fireDone(organizationId, row);
|
await this.fireDone(organizationId, row);
|
||||||
}
|
}
|
||||||
@@ -366,7 +567,19 @@ export class ActivitiesService {
|
|||||||
): Promise<ActivityDto> {
|
): Promise<ActivityDto> {
|
||||||
const row = await this.prisma.activity.findFirst({
|
const row = await this.prisma.activity.findFirst({
|
||||||
where: { id, organizationId, ...activityAccessWhere(actor) },
|
where: { id, organizationId, ...activityAccessWhere(actor) },
|
||||||
include,
|
include: {
|
||||||
|
...include,
|
||||||
|
images: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
fileName: true,
|
||||||
|
mimeType: true,
|
||||||
|
sizeBytes: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
|
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
|
||||||
const dto = this.toDto(row, await this.ctx(organizationId));
|
const dto = this.toDto(row, await this.ctx(organizationId));
|
||||||
@@ -396,11 +609,24 @@ export class ActivitiesService {
|
|||||||
? [dto.assigneeId]
|
? [dto.assigneeId]
|
||||||
: [userId],
|
: [userId],
|
||||||
);
|
);
|
||||||
|
await assertAssigneeSet(
|
||||||
|
this.prisma,
|
||||||
|
{ userId, role: linkActor.role, organizationId },
|
||||||
|
'activities',
|
||||||
|
assigneeIds,
|
||||||
|
);
|
||||||
const assigneeId = assigneeIds[0] ?? userId;
|
const assigneeId = assigneeIds[0] ?? userId;
|
||||||
const initialStageId = emptyToNull(dto.stageId ?? null);
|
const initialStageId = emptyToNull(dto.stageId ?? null);
|
||||||
const initialStage = initialStageId
|
const initialStage = initialStageId
|
||||||
? await this.stages.get(organizationId, initialStageId)
|
? await this.stages.get(organizationId, initialStageId)
|
||||||
: null;
|
: null;
|
||||||
|
const requestedBoardId = emptyToNull(dto.boardId ?? null);
|
||||||
|
const boardId = initialStage
|
||||||
|
? initialStage.boardId
|
||||||
|
: requestedBoardId
|
||||||
|
? await this.stages.resolveBoardId(organizationId, requestedBoardId)
|
||||||
|
: null;
|
||||||
|
if (boardId) await this.stages.assertCanAccessBoard(organizationId, boardId, linkActor);
|
||||||
const resolved = await this.types.resolve(organizationId, {
|
const resolved = await this.types.resolve(organizationId, {
|
||||||
typeDefId: dto.typeDefId,
|
typeDefId: dto.typeDefId,
|
||||||
type: dto.type,
|
type: dto.type,
|
||||||
@@ -410,10 +636,17 @@ export class ActivitiesService {
|
|||||||
resolved.fields,
|
resolved.fields,
|
||||||
dto.fields,
|
dto.fields,
|
||||||
);
|
);
|
||||||
|
await assertAssigneeSet(
|
||||||
|
this.prisma,
|
||||||
|
{ userId, role: linkActor.role, organizationId },
|
||||||
|
'activities',
|
||||||
|
participantIds,
|
||||||
|
);
|
||||||
|
|
||||||
const row = await this.prisma.activity.create({
|
const row = await this.prisma.activity.create({
|
||||||
data: {
|
data: {
|
||||||
organizationId,
|
organizationId,
|
||||||
|
boardId,
|
||||||
...(initialStage
|
...(initialStage
|
||||||
? {
|
? {
|
||||||
stageId: initialStage.id,
|
stageId: initialStage.id,
|
||||||
@@ -437,6 +670,7 @@ export class ActivitiesService {
|
|||||||
personId: emptyToNull(dto.personId ?? null),
|
personId: emptyToNull(dto.personId ?? null),
|
||||||
companyId: emptyToNull(dto.companyId ?? null),
|
companyId: emptyToNull(dto.companyId ?? null),
|
||||||
dealId: emptyToNull(dto.dealId ?? null),
|
dealId: emptyToNull(dto.dealId ?? null),
|
||||||
|
leadId: emptyToNull(dto.leadId ?? null),
|
||||||
assigneeId,
|
assigneeId,
|
||||||
assignees: assigneeIds.length
|
assignees: assigneeIds.length
|
||||||
? { connect: assigneeIds.map((id) => ({ id })) }
|
? { connect: assigneeIds.map((id) => ({ id })) }
|
||||||
@@ -513,6 +747,20 @@ export class ActivitiesService {
|
|||||||
});
|
});
|
||||||
const data: Prisma.ActivityUncheckedUpdateInput = {};
|
const data: Prisma.ActivityUncheckedUpdateInput = {};
|
||||||
if (dto.title !== undefined) data.title = dto.title.trim();
|
if (dto.title !== undefined) data.title = dto.title.trim();
|
||||||
|
if ('boardId' in dto) {
|
||||||
|
const nextBoardId = await this.stages.resolveBoardId(organizationId, dto.boardId);
|
||||||
|
const current = await this.prisma.activity.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { boardId: true },
|
||||||
|
});
|
||||||
|
const defaultBoard = await this.stages.ensureDefaultBoard(organizationId);
|
||||||
|
await this.stages.assertCanAccessBoard(organizationId, nextBoardId, actor);
|
||||||
|
if (nextBoardId !== (current?.boardId ?? defaultBoard.id)) {
|
||||||
|
// ستون قبلی مال گروه دیگر است؛ فعالیت به ستون اولِ هموضعیت گروه جدید میرود
|
||||||
|
data.boardId = nextBoardId;
|
||||||
|
data.stageId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
let addedParticipants: string[] = [];
|
let addedParticipants: string[] = [];
|
||||||
const typeChanged = 'typeDefId' in dto || dto.type !== undefined;
|
const typeChanged = 'typeDefId' in dto || dto.type !== undefined;
|
||||||
if (typeChanged || dto.fields !== undefined) {
|
if (typeChanged || dto.fields !== undefined) {
|
||||||
@@ -531,6 +779,13 @@ export class ActivitiesService {
|
|||||||
data.typeDefId = resolved.typeDefId;
|
data.typeDefId = resolved.typeDefId;
|
||||||
data.fields = fields as Prisma.InputJsonValue;
|
data.fields = fields as Prisma.InputJsonValue;
|
||||||
data.participantIds = participantIds;
|
data.participantIds = participantIds;
|
||||||
|
await assertAssigneeSet(
|
||||||
|
this.prisma,
|
||||||
|
{ userId: actor.userId, role: actor.role, organizationId },
|
||||||
|
'activities',
|
||||||
|
participantIds,
|
||||||
|
existing?.participantIds ?? [],
|
||||||
|
);
|
||||||
const before = new Set(existing?.participantIds ?? []);
|
const before = new Set(existing?.participantIds ?? []);
|
||||||
addedParticipants = participantIds.filter((uid) => !before.has(uid) && uid !== actor.userId);
|
addedParticipants = participantIds.filter((uid) => !before.has(uid) && uid !== actor.userId);
|
||||||
}
|
}
|
||||||
@@ -555,23 +810,49 @@ export class ActivitiesService {
|
|||||||
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
|
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
|
||||||
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
|
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
|
||||||
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
|
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
|
||||||
|
if ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null);
|
||||||
let assigneeConnect: Array<{ id: string }> | undefined;
|
let assigneeConnect: Array<{ id: string }> | undefined;
|
||||||
if (dto.assigneeIds !== undefined) {
|
if (dto.assigneeIds !== undefined || 'assigneeId' in dto) {
|
||||||
const assigneeIds = await this.normalizeAssigneeIds(
|
const current = await this.prisma.activity.findFirst({
|
||||||
organizationId,
|
where: { id, organizationId },
|
||||||
dto.assigneeIds,
|
select: { assigneeId: true, assignees: { select: { id: true } } },
|
||||||
);
|
});
|
||||||
if (!assigneeIds.length) {
|
const previous = current?.assignees.length
|
||||||
throw new BadRequestException('حداقل یک مسئول لازم است');
|
? current.assignees.map((item) => item.id)
|
||||||
|
: current?.assigneeId
|
||||||
|
? [current.assigneeId]
|
||||||
|
: [];
|
||||||
|
if (dto.assigneeIds !== undefined) {
|
||||||
|
const assigneeIds = await this.normalizeAssigneeIds(
|
||||||
|
organizationId,
|
||||||
|
dto.assigneeIds,
|
||||||
|
);
|
||||||
|
if (!assigneeIds.length) {
|
||||||
|
throw new BadRequestException('حداقل یک مسئول لازم است');
|
||||||
|
}
|
||||||
|
await assertAssigneeSet(
|
||||||
|
this.prisma,
|
||||||
|
{ userId: actor.userId, role: actor.role, organizationId },
|
||||||
|
'activities',
|
||||||
|
assigneeIds,
|
||||||
|
previous,
|
||||||
|
);
|
||||||
|
data.assigneeId = assigneeIds[0]!;
|
||||||
|
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
|
||||||
|
} else {
|
||||||
|
const assigneeId = emptyToNull(dto.assigneeId ?? null);
|
||||||
|
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
|
||||||
|
await assertAssigneeSet(
|
||||||
|
this.prisma,
|
||||||
|
{ userId: actor.userId, role: actor.role, organizationId },
|
||||||
|
'activities',
|
||||||
|
[assigneeId],
|
||||||
|
previous,
|
||||||
|
);
|
||||||
|
data.assigneeId = assigneeId;
|
||||||
|
if (assigneeId) assigneeConnect = [{ id: assigneeId }];
|
||||||
|
else assigneeConnect = [];
|
||||||
}
|
}
|
||||||
data.assigneeId = assigneeIds[0]!;
|
|
||||||
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
|
|
||||||
} else if ('assigneeId' in dto) {
|
|
||||||
const assigneeId = emptyToNull(dto.assigneeId ?? null);
|
|
||||||
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
|
|
||||||
data.assigneeId = assigneeId;
|
|
||||||
if (assigneeId) assigneeConnect = [{ id: assigneeId }];
|
|
||||||
else assigneeConnect = [];
|
|
||||||
}
|
}
|
||||||
if (dto.status !== undefined) {
|
if (dto.status !== undefined) {
|
||||||
data.status = dto.status;
|
data.status = dto.status;
|
||||||
@@ -665,7 +946,145 @@ export class ActivitiesService {
|
|||||||
actor: ActivityActor,
|
actor: ActivityActor,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.ensureExists(organizationId, id, actor);
|
await this.ensureExists(organizationId, id, actor);
|
||||||
|
const images = await this.prisma.activityImage.findMany({
|
||||||
|
where: { activityId: id, organizationId },
|
||||||
|
select: { storageKey: true, sizeBytes: true },
|
||||||
|
});
|
||||||
|
const bytes = images.reduce((sum, image) => sum + image.sizeBytes, 0);
|
||||||
|
if (bytes) await this.storage.addLocal(organizationId, -bytes);
|
||||||
await this.prisma.activity.delete({ where: { id } });
|
await this.prisma.activity.delete({ where: { id } });
|
||||||
|
for (const image of images) {
|
||||||
|
await this.files.remove(image.storageKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addImage(
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
activityId: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
actor: ActivityActor,
|
||||||
|
): Promise<ActivityImageDto> {
|
||||||
|
await this.ensureExists(organizationId, activityId, actor);
|
||||||
|
if (!file?.buffer?.length) {
|
||||||
|
throw new BadRequestException('فایلی ارسال نشده است');
|
||||||
|
}
|
||||||
|
const namedExt = path.extname(file.originalname || '').slice(1).toLowerCase();
|
||||||
|
let mimeType = (file.mimetype || '').toLowerCase().split(';')[0]?.trim() || '';
|
||||||
|
if (!mimeType || mimeType === 'application/octet-stream') {
|
||||||
|
mimeType = ACTIVITY_EXT_MIME[namedExt] || mimeType;
|
||||||
|
}
|
||||||
|
const isVoice = mimeType.startsWith('audio/') || ACTIVITY_VOICE_MIME.has(mimeType);
|
||||||
|
const isImage = ACTIVITY_IMAGE_MIME.has(mimeType);
|
||||||
|
const isFile = ACTIVITY_FILE_MIME.has(mimeType);
|
||||||
|
if (!isVoice && !isImage && !isFile) {
|
||||||
|
throw new BadRequestException('این نوع فایل مجاز نیست.');
|
||||||
|
}
|
||||||
|
if (isVoice) {
|
||||||
|
if (file.size > ACTIVITY_VOICE_MAX_BYTES) {
|
||||||
|
throw new BadRequestException('حجم صوت باید زیر ۲ مگابایت باشد');
|
||||||
|
}
|
||||||
|
const voiceCount = await this.prisma.activityImage.count({
|
||||||
|
where: { activityId, mimeType: { startsWith: 'audio/' } },
|
||||||
|
});
|
||||||
|
if (voiceCount >= ACTIVITY_VOICE_MAX_COUNT) {
|
||||||
|
throw new BadRequestException(`حداکثر ${ACTIVITY_VOICE_MAX_COUNT} صوت برای هر فعالیت مجاز است`);
|
||||||
|
}
|
||||||
|
} else if (isImage) {
|
||||||
|
if (file.size > ACTIVITY_IMAGE_MAX_BYTES) {
|
||||||
|
throw new BadRequestException('حجم عکس باید زیر 200 کیلوبایت باشد');
|
||||||
|
}
|
||||||
|
const count = await this.prisma.activityImage.count({
|
||||||
|
where: { activityId, mimeType: { startsWith: 'image/' } },
|
||||||
|
});
|
||||||
|
if (count >= ACTIVITY_IMAGE_MAX_COUNT) {
|
||||||
|
throw new BadRequestException(`حداکثر ${ACTIVITY_IMAGE_MAX_COUNT} عکس برای هر فعالیت مجاز است`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (file.size > ACTIVITY_FILE_MAX_BYTES) {
|
||||||
|
throw new BadRequestException('حجم فایل باید زیر ۱۰ مگابایت باشد');
|
||||||
|
}
|
||||||
|
const fileCount = await this.prisma.activityImage.count({
|
||||||
|
where: {
|
||||||
|
activityId,
|
||||||
|
NOT: {
|
||||||
|
OR: [{ mimeType: { startsWith: 'image/' } }, { mimeType: { startsWith: 'audio/' } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (fileCount >= ACTIVITY_FILE_MAX_COUNT) {
|
||||||
|
throw new BadRequestException(`حداکثر ${ACTIVITY_FILE_MAX_COUNT} فایل برای هر فعالیت مجاز است`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fileName = sanitizeActivityImageName(file.originalname);
|
||||||
|
const ext =
|
||||||
|
path.extname(fileName).slice(0, 12) ||
|
||||||
|
(mimeType === 'audio/ogg' ? '.ogg' : mimeType === 'audio/mp4' ? '.m4a' : isVoice ? '.webm' : '.jpg');
|
||||||
|
const imageId = randomUUID().replace(/-/g, '').slice(0, 24);
|
||||||
|
const storageKey = path
|
||||||
|
.join(organizationId, 'activities', activityId, `${imageId}${ext}`)
|
||||||
|
.replace(/\\/g, '/');
|
||||||
|
await this.storage.assertCanAdd(organizationId, file.size);
|
||||||
|
await this.files.save(storageKey, file.buffer);
|
||||||
|
try {
|
||||||
|
const row = await this.prisma.activityImage.create({
|
||||||
|
data: {
|
||||||
|
id: imageId,
|
||||||
|
organizationId,
|
||||||
|
activityId,
|
||||||
|
fileName,
|
||||||
|
mimeType: mimeType === 'image/jpg' ? 'image/jpeg' : mimeType,
|
||||||
|
sizeBytes: file.size,
|
||||||
|
storageKey,
|
||||||
|
uploadedById: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.storage.addLocal(organizationId, file.size);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
fileName: row.fileName,
|
||||||
|
mimeType: row.mimeType,
|
||||||
|
sizeBytes: row.sizeBytes,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
await this.files.remove(storageKey);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeImage(
|
||||||
|
organizationId: string,
|
||||||
|
activityId: string,
|
||||||
|
imageId: string,
|
||||||
|
actor: ActivityActor,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.ensureExists(organizationId, activityId, actor);
|
||||||
|
const row = await this.prisma.activityImage.findFirst({
|
||||||
|
where: { id: imageId, activityId, organizationId },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('فایل پیدا نشد');
|
||||||
|
await this.files.remove(row.storageKey);
|
||||||
|
await this.prisma.activityImage.delete({ where: { id: row.id } });
|
||||||
|
await this.storage.addLocal(organizationId, -row.sizeBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
async openImage(
|
||||||
|
organizationId: string,
|
||||||
|
activityId: string,
|
||||||
|
imageId: string,
|
||||||
|
actor: ActivityActor,
|
||||||
|
): Promise<{ stream: ReturnType<LocalFileStorage['openReadStream']>; fileName: string; mimeType: string }> {
|
||||||
|
await this.ensureExists(organizationId, activityId, actor);
|
||||||
|
const row = await this.prisma.activityImage.findFirst({
|
||||||
|
where: { id: imageId, activityId, organizationId },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('فایل پیدا نشد');
|
||||||
|
return {
|
||||||
|
stream: this.files.openReadStream(row.storageKey),
|
||||||
|
fileName: row.fileName,
|
||||||
|
mimeType: row.mimeType,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildWhere(
|
private buildWhere(
|
||||||
@@ -701,11 +1120,15 @@ export class ActivitiesService {
|
|||||||
: activityAccessWhere(actor);
|
: activityAccessWhere(actor);
|
||||||
const facetAssignee = facets?.assigneeId?.trim();
|
const facetAssignee = facets?.assigneeId?.trim();
|
||||||
const facetTypeDef = facets?.typeDefId?.trim();
|
const facetTypeDef = facets?.typeDefId?.trim();
|
||||||
|
const facetTitle = facets?.title?.trim();
|
||||||
|
const facetCustomer = facets?.customer?.trim();
|
||||||
const andParts: Prisma.ActivityWhereInput[] = [
|
const andParts: Prisma.ActivityWhereInput[] = [
|
||||||
scope,
|
scope,
|
||||||
...(facets?.type ? [{ type: facets.type }] : []),
|
...(facets?.type ? [{ type: facets.type }] : []),
|
||||||
...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []),
|
...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []),
|
||||||
...(facets?.status ? [{ status: facets.status }] : []),
|
...(facets?.status ? [{ status: facets.status }] : []),
|
||||||
|
...(facetTitle ? [{ title: ci(facetTitle) }] : []),
|
||||||
|
...(facetCustomer ? [this.customerWhere(facetCustomer)] : []),
|
||||||
...(facetAssignee
|
...(facetAssignee
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -752,15 +1175,18 @@ export class ActivitiesService {
|
|||||||
const fromDate = range?.from ? new Date(range.from) : null;
|
const fromDate = range?.from ? new Date(range.from) : null;
|
||||||
const toDate = range?.to ? new Date(range.to) : null;
|
const toDate = range?.to ? new Date(range.to) : null;
|
||||||
if (fromDate || toDate) {
|
if (fromDate || toDate) {
|
||||||
return {
|
return this.withDue(
|
||||||
...base,
|
{
|
||||||
dueAt: {
|
...base,
|
||||||
...(fromDate && !Number.isNaN(fromDate.getTime())
|
dueAt: {
|
||||||
? { gte: fromDate }
|
...(fromDate && !Number.isNaN(fromDate.getTime())
|
||||||
: {}),
|
? { gte: fromDate }
|
||||||
...(toDate && !Number.isNaN(toDate.getTime()) ? { lt: toDate } : {}),
|
: {}),
|
||||||
|
...(toDate && !Number.isNaN(toDate.getTime()) ? { lt: toDate } : {}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
facets?.due,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Explicit status facet overrides the status implied by time tabs.
|
// Explicit status facet overrides the status implied by time tabs.
|
||||||
@@ -774,28 +1200,62 @@ export class ActivitiesService {
|
|||||||
|
|
||||||
switch (filter) {
|
switch (filter) {
|
||||||
case 'today':
|
case 'today':
|
||||||
return { ...base, ...impliedOpen, dueAt: { gte: start, lt: end } };
|
return this.withDue(
|
||||||
|
{ ...base, ...impliedOpen, dueAt: { gte: start, lt: end } },
|
||||||
|
facets?.due,
|
||||||
|
);
|
||||||
case 'overdue':
|
case 'overdue':
|
||||||
// عقبافتاده ذاتاً یعنی باز و موعدگذشته
|
// عقبافتاده ذاتاً یعنی باز و موعدگذشته
|
||||||
return {
|
return this.withDue(
|
||||||
...base,
|
{
|
||||||
...(facets?.status ? { status: facets.status } : { status: 'open' as const }),
|
...base,
|
||||||
dueAt: { lt: start },
|
...(facets?.status ? { status: facets.status } : { status: 'open' as const }),
|
||||||
};
|
dueAt: { lt: start },
|
||||||
|
},
|
||||||
|
facets?.due,
|
||||||
|
);
|
||||||
case 'upcoming':
|
case 'upcoming':
|
||||||
return {
|
return this.withDue(
|
||||||
...base,
|
{
|
||||||
...impliedOpen,
|
...base,
|
||||||
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
|
...impliedOpen,
|
||||||
};
|
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
|
||||||
|
},
|
||||||
|
facets?.due,
|
||||||
|
);
|
||||||
case 'done':
|
case 'done':
|
||||||
return { ...base, ...impliedDone };
|
return this.withDue({ ...base, ...impliedDone }, facets?.due);
|
||||||
case 'all':
|
case 'all':
|
||||||
default:
|
default:
|
||||||
return base;
|
return this.withDue(base, facets?.due);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** نام شخص (چندکلمهای) یا نام شرکت */
|
||||||
|
private customerWhere(term: string): Prisma.ActivityWhereInput {
|
||||||
|
const parts = term.split(/\s+/).filter(Boolean);
|
||||||
|
const person: Prisma.PersonWhereInput =
|
||||||
|
parts.length <= 1
|
||||||
|
? { OR: [{ firstName: ci(term) }, { lastName: ci(term) }] }
|
||||||
|
: {
|
||||||
|
AND: parts.map((part) => ({
|
||||||
|
OR: [{ firstName: ci(part) }, { lastName: ci(part) }],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
OR: [{ person }, { company: { name: ci(term) } }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** وجود یا نبود زمان شروع، روی هر فیلتر زمانی */
|
||||||
|
private withDue(
|
||||||
|
where: Prisma.ActivityWhereInput,
|
||||||
|
due?: 'set' | 'unset',
|
||||||
|
): Prisma.ActivityWhereInput {
|
||||||
|
if (due !== 'set' && due !== 'unset') return where;
|
||||||
|
return { AND: [where, due === 'set' ? { dueAt: { not: null } } : { dueAt: null }] };
|
||||||
|
}
|
||||||
|
|
||||||
private async fireDone(
|
private async fireDone(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
row: {
|
row: {
|
||||||
@@ -867,11 +1327,13 @@ export class ActivitiesService {
|
|||||||
personId?: string | null;
|
personId?: string | null;
|
||||||
companyId?: string | null;
|
companyId?: string | null;
|
||||||
dealId?: string | null;
|
dealId?: string | null;
|
||||||
|
leadId?: string | null;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const personId = emptyToNull(dto.personId ?? null);
|
const personId = emptyToNull(dto.personId ?? null);
|
||||||
const companyId = emptyToNull(dto.companyId ?? null);
|
const companyId = emptyToNull(dto.companyId ?? null);
|
||||||
const dealId = emptyToNull(dto.dealId ?? null);
|
const dealId = emptyToNull(dto.dealId ?? null);
|
||||||
|
const leadId = emptyToNull(dto.leadId ?? null);
|
||||||
const scope = ownerWhere(actor);
|
const scope = ownerWhere(actor);
|
||||||
|
|
||||||
if (personId) {
|
if (personId) {
|
||||||
@@ -895,6 +1357,13 @@ export class ActivitiesService {
|
|||||||
});
|
});
|
||||||
if (!deal) throw new BadRequestException('معامله انتخابشده معتبر نیست');
|
if (!deal) throw new BadRequestException('معامله انتخابشده معتبر نیست');
|
||||||
}
|
}
|
||||||
|
if (leadId) {
|
||||||
|
const lead = await this.prisma.lead.findFirst({
|
||||||
|
where: { id: leadId, organizationId, ...leadsDeepWhere(actor) },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!lead) throw new BadRequestException('لید انتخابشده معتبر نیست');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async notifyParticipants(
|
private async notifyParticipants(
|
||||||
@@ -922,6 +1391,7 @@ export class ActivitiesService {
|
|||||||
? (row.fields as unknown as ActivityFieldValues)
|
? (row.fields as unknown as ActivityFieldValues)
|
||||||
: {};
|
: {};
|
||||||
return {
|
return {
|
||||||
|
boardId: this.stages.effectiveBoardId(row, ctx?.stages),
|
||||||
stageId: this.stages.effectiveStageId(row, ctx?.stages),
|
stageId: this.stages.effectiveStageId(row, ctx?.stages),
|
||||||
typeDef: row.typeDef
|
typeDef: row.typeDef
|
||||||
? this.types.toSummary(row.typeDef)
|
? this.types.toSummary(row.typeDef)
|
||||||
@@ -934,6 +1404,13 @@ export class ActivitiesService {
|
|||||||
priority: row.priority,
|
priority: row.priority,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
|
images: row.images?.map((image) => ({
|
||||||
|
id: image.id,
|
||||||
|
fileName: image.fileName,
|
||||||
|
mimeType: image.mimeType,
|
||||||
|
sizeBytes: image.sizeBytes,
|
||||||
|
createdAt: image.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
dueAt: row.dueAt?.toISOString() ?? null,
|
dueAt: row.dueAt?.toISOString() ?? null,
|
||||||
durationMinutes: row.durationMinutes,
|
durationMinutes: row.durationMinutes,
|
||||||
reminderAt: row.reminderAt?.toISOString() ?? null,
|
reminderAt: row.reminderAt?.toISOString() ?? null,
|
||||||
@@ -976,7 +1453,11 @@ export class ActivitiesService {
|
|||||||
];
|
];
|
||||||
if (!ids.length) return [];
|
if (!ids.length) return [];
|
||||||
const count = await this.prisma.user.count({
|
const count = await this.prisma.user.count({
|
||||||
where: { organizationId, id: { in: ids }, isActive: true },
|
where: {
|
||||||
|
id: { in: ids },
|
||||||
|
isActive: true,
|
||||||
|
memberships: { some: { organizationId, isActive: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (count !== ids.length) {
|
if (count !== ids.length) {
|
||||||
throw new BadRequestException('یکی از مسئولان انتخابشده معتبر نیست');
|
throw new BadRequestException('یکی از مسئولان انتخابشده معتبر نیست');
|
||||||
@@ -1001,6 +1482,12 @@ function typesMatching(term: string): ActivityDto['type'][] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeActivityImageName(raw: string): string {
|
||||||
|
const base = path.basename(raw || 'photo.jpg').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
|
||||||
|
const trimmed = base.trim() || 'photo.jpg';
|
||||||
|
return trimmed.slice(0, 180);
|
||||||
|
}
|
||||||
|
|
||||||
function emptyToNull(value?: string | null): string | null {
|
function emptyToNull(value?: string | null): string | null {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import type {
|
import type {
|
||||||
|
ActivityBoardDto,
|
||||||
ActivityStageColor,
|
ActivityStageColor,
|
||||||
ActivityStageDto,
|
ActivityStageDto,
|
||||||
ActivityStatus,
|
ActivityStatus,
|
||||||
@@ -8,6 +14,8 @@ import { ACTIVITY_STAGE_COLORS } from '@mento/shared';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const DEFAULT_BOARD_NAME = 'کارهای اصلی';
|
||||||
|
|
||||||
const DEFAULT_STAGES: Array<{ name: string; color: ActivityStageColor; kind: ActivityStatus }> = [
|
const DEFAULT_STAGES: Array<{ name: string; color: ActivityStageColor; kind: ActivityStatus }> = [
|
||||||
{ name: 'در صف', color: 'orange', kind: 'open' },
|
{ name: 'در صف', color: 'orange', kind: 'open' },
|
||||||
{ name: 'در حال انجام', color: 'blue', kind: 'open' },
|
{ name: 'در حال انجام', color: 'blue', kind: 'open' },
|
||||||
@@ -19,24 +27,100 @@ const KINDS: ActivityStatus[] = ['open', 'done', 'canceled'];
|
|||||||
|
|
||||||
type StageRow = {
|
type StageRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
boardId: string;
|
||||||
name: string;
|
name: string;
|
||||||
position: number;
|
position: number;
|
||||||
color: string;
|
color: string;
|
||||||
kind: ActivityStatus;
|
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 StageDefaults = Record<ActivityStatus, string | null>;
|
||||||
|
|
||||||
|
/** پیشفرضهای هر گروه برای محاسبهٔ «ستون مؤثر» فعالیتها */
|
||||||
|
export type BoardStageContext = {
|
||||||
|
defaultBoardId: string;
|
||||||
|
defaults: Map<string, StageDefaults>;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ActivityStagesService {
|
export class ActivityStagesService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
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;
|
if (count > 0) return;
|
||||||
await this.prisma.activityStage.createMany({
|
await this.prisma.activityStage.createMany({
|
||||||
data: DEFAULT_STAGES.map((s, i) => ({
|
data: DEFAULT_STAGES.map((s, i) => ({
|
||||||
organizationId,
|
organizationId,
|
||||||
|
boardId,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
color: s.color,
|
color: s.color,
|
||||||
kind: s.kind,
|
kind: s.kind,
|
||||||
@@ -46,17 +130,175 @@ export class ActivityStagesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async rows(organizationId: string): Promise<StageRow[]> {
|
async listBoards(
|
||||||
await this.ensureDefaults(organizationId);
|
organizationId: string,
|
||||||
return this.prisma.activityStage.findMany({
|
actor?: { userId: string; role: string },
|
||||||
|
): Promise<ActivityBoardDto[]> {
|
||||||
|
await this.ensureDefaultBoard(organizationId);
|
||||||
|
const rows = await this.prisma.activityBoard.findMany({
|
||||||
where: { organizationId },
|
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' }],
|
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[]> {
|
async list(
|
||||||
return (await this.rows(organizationId)).map((r) => this.toDto(r));
|
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;
|
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 برای فعالیتهایی که «مؤثراً» در این مرحله هستند */
|
/** شرط Prisma برای فعالیتهایی که «مؤثراً» در این مرحله هستند */
|
||||||
@@ -77,33 +334,54 @@ export class ActivityStagesService {
|
|||||||
organizationId: string,
|
organizationId: string,
|
||||||
stageId: string,
|
stageId: string,
|
||||||
): Promise<Prisma.ActivityWhereInput> {
|
): Promise<Prisma.ActivityWhereInput> {
|
||||||
const rows = await this.rows(organizationId);
|
const stage = await this.get(organizationId, stageId);
|
||||||
const stage = rows.find((r) => r.id === stageId);
|
const defaultBoard = await this.ensureDefaultBoard(organizationId);
|
||||||
if (!stage) throw new BadRequestException('مرحلهٔ انتخابشده معتبر نیست');
|
const boardRows = await this.rows(organizationId, stage.boardId);
|
||||||
const isDefault = this.defaultsOf(rows)[stage.kind] === stage.id;
|
const inBoard = this.boardWhere(stage.boardId, defaultBoard.id);
|
||||||
if (!isDefault) return { status: stage.kind, stageId: stage.id };
|
const isDefault = this.defaultsOf(boardRows)[stage.kind] === stage.id;
|
||||||
|
if (!isDefault) {
|
||||||
|
return { AND: [inBoard, { status: stage.kind, stageId: stage.id }] };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
status: stage.kind,
|
AND: [
|
||||||
OR: [
|
inBoard,
|
||||||
{ stageId: stage.id },
|
{ status: stage.kind },
|
||||||
{ stageId: null },
|
{
|
||||||
{ stage: { kind: { not: 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(
|
effectiveStageId(
|
||||||
row: { status: ActivityStatus; stage?: { id: string; kind: ActivityStatus } | null },
|
row: {
|
||||||
defaults?: StageDefaults,
|
status: ActivityStatus;
|
||||||
|
boardId?: string | null;
|
||||||
|
stage?: { id: string; kind: ActivityStatus; boardId: string } | null;
|
||||||
|
},
|
||||||
|
ctx?: BoardStageContext,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (row.stage && row.stage.kind === row.status) return row.stage.id;
|
const boardId = this.effectiveBoardId(row, ctx);
|
||||||
return defaults?.[row.status] ?? null;
|
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> {
|
async get(organizationId: string, id: string): Promise<StageRow> {
|
||||||
const row = await this.prisma.activityStage.findFirst({
|
const row = await this.prisma.activityStage.findFirst({
|
||||||
where: { id, organizationId },
|
where: { id, organizationId },
|
||||||
select: { id: true, name: true, position: true, color: true, kind: true },
|
select: STAGE_SELECT,
|
||||||
});
|
});
|
||||||
if (!row) throw new NotFoundException('مرحله پیدا نشد');
|
if (!row) throw new NotFoundException('مرحله پیدا نشد');
|
||||||
return row;
|
return row;
|
||||||
@@ -111,22 +389,25 @@ export class ActivityStagesService {
|
|||||||
|
|
||||||
async create(
|
async create(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
dto: { name: string; color?: string; kind?: ActivityStatus },
|
dto: { boardId?: string; name: string; color?: string; kind?: ActivityStatus },
|
||||||
): Promise<ActivityStageDto> {
|
): 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({
|
const max = await this.prisma.activityStage.aggregate({
|
||||||
where: { organizationId },
|
where: { boardId },
|
||||||
_max: { position: true },
|
_max: { position: true },
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const row = await this.prisma.activityStage.create({
|
const row = await this.prisma.activityStage.create({
|
||||||
data: {
|
data: {
|
||||||
organizationId,
|
organizationId,
|
||||||
|
boardId,
|
||||||
name: this.cleanName(dto.name),
|
name: this.cleanName(dto.name),
|
||||||
color: this.cleanColor(dto.color),
|
color: this.cleanColor(dto.color),
|
||||||
kind: dto.kind && KINDS.includes(dto.kind) ? dto.kind : 'open',
|
kind: dto.kind && KINDS.includes(dto.kind) ? dto.kind : 'open',
|
||||||
position: (max._max.position ?? -1) + 1,
|
position: (max._max.position ?? -1) + 1,
|
||||||
},
|
},
|
||||||
|
select: STAGE_SELECT,
|
||||||
});
|
});
|
||||||
return this.toDto(row);
|
return this.toDto(row);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -142,7 +423,7 @@ export class ActivityStagesService {
|
|||||||
const stage = await this.get(organizationId, id);
|
const stage = await this.get(organizationId, id);
|
||||||
const nextKind = dto.kind && KINDS.includes(dto.kind) ? dto.kind : stage.kind;
|
const nextKind = dto.kind && KINDS.includes(dto.kind) ? dto.kind : stage.kind;
|
||||||
if (nextKind !== stage.kind) {
|
if (nextKind !== stage.kind) {
|
||||||
await this.assertNotLastOfKind(organizationId, stage);
|
await this.assertNotLastOfKind(stage);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const row = await this.prisma.$transaction(async (tx) => {
|
const row = await this.prisma.$transaction(async (tx) => {
|
||||||
@@ -160,6 +441,7 @@ export class ActivityStagesService {
|
|||||||
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
|
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
|
||||||
kind: nextKind,
|
kind: nextKind,
|
||||||
},
|
},
|
||||||
|
select: STAGE_SELECT,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return this.toDto(row);
|
return this.toDto(row);
|
||||||
@@ -168,34 +450,45 @@ export class ActivityStagesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ترتیب ستونهای یک گروه؛ همهٔ شناسهها باید از همان گروه باشند */
|
||||||
async reorder(organizationId: string, stageIds: string[]): Promise<ActivityStageDto[]> {
|
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(
|
await this.prisma.$transaction(
|
||||||
stageIds.map((id, position) =>
|
stageIds.map((id, position) =>
|
||||||
this.prisma.activityStage.updateMany({
|
this.prisma.activityStage.updateMany({
|
||||||
where: { id, organizationId },
|
where: { id, organizationId, boardId },
|
||||||
data: { position },
|
data: { position },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return this.list(organizationId);
|
return this.list(organizationId, boardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(organizationId: string, id: string): Promise<void> {
|
async remove(organizationId: string, id: string): Promise<void> {
|
||||||
const stage = await this.get(organizationId, id);
|
const stage = await this.get(organizationId, id);
|
||||||
await this.assertNotLastOfKind(organizationId, stage);
|
await this.assertNotLastOfKind(stage);
|
||||||
// onDelete: SetNull → فعالیتها به اولین مرحلهٔ همنوع وضعیتشان منتقل میشوند
|
// onDelete: SetNull → فعالیتها به اولین مرحلهٔ همنوع وضعیتشان در همان گروه منتقل میشوند
|
||||||
await this.prisma.activityStage.delete({ where: { id } });
|
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({
|
const sameKind = await this.prisma.activityStage.count({
|
||||||
where: { organizationId, kind: stage.kind },
|
where: { boardId: stage.boardId, kind: stage.kind },
|
||||||
});
|
});
|
||||||
if (sameKind <= 1) {
|
if (sameKind <= 1) {
|
||||||
const label =
|
const label =
|
||||||
stage.kind === 'done' ? 'انجامشده' : stage.kind === 'canceled' ? 'لغو شده' : 'باز';
|
stage.kind === 'done' ? 'انجامشده' : stage.kind === 'canceled' ? 'لغو شده' : 'باز';
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`حداقل یک ستون از نوع «${label}» لازم است؛ اول یک ستون دیگر از همین نوع بسازید`,
|
`حداقل یک ستون از نوع «${label}» در هر گروه لازم است؛ اول یک ستون دیگر از همین نوع بسازید`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,6 +500,13 @@ export class ActivityStagesService {
|
|||||||
return name;
|
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 {
|
private cleanColor(raw?: string): ActivityStageColor {
|
||||||
return ACTIVITY_STAGE_COLORS.includes(raw as ActivityStageColor)
|
return ACTIVITY_STAGE_COLORS.includes(raw as ActivityStageColor)
|
||||||
? (raw as ActivityStageColor)
|
? (raw as ActivityStageColor)
|
||||||
@@ -215,7 +515,14 @@ export class ActivityStagesService {
|
|||||||
|
|
||||||
private mapError(e: unknown) {
|
private mapError(e: unknown) {
|
||||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
return new BadRequestException('ستونی با این نام وجود دارد');
|
return new BadRequestException('ستونی با این نام در این گروه وجود دارد');
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapBoardError(e: unknown) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
return new BadRequestException('گروهی با این نام وجود دارد');
|
||||||
}
|
}
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
@@ -223,6 +530,7 @@ export class ActivityStagesService {
|
|||||||
private toDto(row: StageRow): ActivityStageDto {
|
private toDto(row: StageRow): ActivityStageDto {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
boardId: row.boardId,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
position: row.position,
|
position: row.position,
|
||||||
color: this.cleanColor(row.color),
|
color: this.cleanColor(row.color),
|
||||||
|
|||||||
@@ -69,6 +69,23 @@ export class ListActivitiesQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
|
|
||||||
|
/** فیلتر عنوان فعالیت، جدا از جستجوی کلی */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
/** نام شخص یا شرکتِ مشتری */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
customer?: string;
|
||||||
|
|
||||||
|
/** زمان شروع: set = دارد، unset = ندارد */
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['set', 'unset'])
|
||||||
|
due?: 'set' | 'unset';
|
||||||
|
|
||||||
/** نوع فعالیت (تعریفشده در سازمان) */
|
/** نوع فعالیت (تعریفشده در سازمان) */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -79,6 +96,11 @@ export class ListActivitiesQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
stageId?: string;
|
stageId?: string;
|
||||||
|
|
||||||
|
/** گروه فعالیت؛ خالی = همهٔ گروهها (در شمارش ستونها = گروه پیشفرض) */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
boardId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
personId?: string;
|
personId?: string;
|
||||||
@@ -121,7 +143,43 @@ export class ListActivitiesQueryDto {
|
|||||||
|
|
||||||
const STAGE_COLORS = ['green', 'orange', 'purple', 'blue', 'rose', 'slate'] as const;
|
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 {
|
export class CreateActivityStageDto {
|
||||||
|
/** خالی = گروه پیشفرض */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
boardId?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
@@ -161,6 +219,12 @@ export class ReorderActivityStagesDto {
|
|||||||
export class MoveActivityStageDto {
|
export class MoveActivityStageDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
stageId!: string;
|
stageId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
position?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی میشود */
|
/** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی میشود */
|
||||||
@@ -229,6 +293,11 @@ export class CreateActivityDto {
|
|||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
title!: string;
|
title!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
|
@IsString()
|
||||||
|
boardId?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -253,7 +322,7 @@ export class CreateActivityDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(2000)
|
@MaxLength(20000)
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -301,6 +370,11 @@ export class CreateActivityDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
dealId?: string | null;
|
dealId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
|
@IsString()
|
||||||
|
leadId?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -319,6 +393,12 @@ export class UpdateActivityDto {
|
|||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
||||||
|
/** null/خالی = گروه پیشفرض */
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
|
@IsString()
|
||||||
|
boardId?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(ACTIVITY_TYPES)
|
@IsIn(ACTIVITY_TYPES)
|
||||||
type?: (typeof ACTIVITY_TYPES)[number];
|
type?: (typeof ACTIVITY_TYPES)[number];
|
||||||
@@ -342,7 +422,7 @@ export class UpdateActivityDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(2000)
|
@MaxLength(20000)
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -390,6 +470,11 @@ export class UpdateActivityDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
dealId?: string | null;
|
dealId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
|
@IsString()
|
||||||
|
leadId?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
VoicePersonDraft,
|
VoicePersonDraft,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { leadsDeepWhere } from '../common/data-access';
|
||||||
import {
|
import {
|
||||||
type Actor,
|
type Actor,
|
||||||
assigneeWhere,
|
assigneeWhere,
|
||||||
@@ -260,6 +261,9 @@ Rules:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private leadWhere(actor: Actor) {
|
private leadWhere(actor: Actor) {
|
||||||
|
if (actor.leadsAccess !== undefined) {
|
||||||
|
return leadsDeepWhere(actor);
|
||||||
|
}
|
||||||
const scoped = forScope(actor, 'leads');
|
const scoped = forScope(actor, 'leads');
|
||||||
if (elevated(scoped.role) || scoped.dataVisibility === 'org') return {};
|
if (elevated(scoped.role) || scoped.dataVisibility === 'org') return {};
|
||||||
if (scoped.dataVisibility === 'team' && scoped.visibleUserIds?.length) {
|
if (scoped.dataVisibility === 'team' && scoped.visibleUserIds?.length) {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { MemoryModule } from './memory/memory.module';
|
|||||||
import { TahesabModule } from './integrations/tahesab/tahesab.module';
|
import { TahesabModule } from './integrations/tahesab/tahesab.module';
|
||||||
import { InventoryModule } from './inventory/inventory.module';
|
import { InventoryModule } from './inventory/inventory.module';
|
||||||
import { FinanceModule } from './finance/finance.module';
|
import { FinanceModule } from './finance/finance.module';
|
||||||
|
import { TreasuryModule } from './treasury/treasury.module';
|
||||||
import { PlatformModule } from './platform/platform.module';
|
import { PlatformModule } from './platform/platform.module';
|
||||||
import { BillingModule } from './billing/billing.module';
|
import { BillingModule } from './billing/billing.module';
|
||||||
import { AuditLogsModule } from './audit-logs/audit-logs.module';
|
import { AuditLogsModule } from './audit-logs/audit-logs.module';
|
||||||
@@ -51,6 +52,9 @@ import { RoutinesModule } from './routines/routines.module';
|
|||||||
import { GamificationModule } from './gamification/gamification.module';
|
import { GamificationModule } from './gamification/gamification.module';
|
||||||
import { DeletionsModule } from './deletions/deletions.module';
|
import { DeletionsModule } from './deletions/deletions.module';
|
||||||
import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
|
import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
|
||||||
|
import { BehaviorModule } from './behavior/behavior.module';
|
||||||
|
import { SmsBillingModule } from './sms-billing/sms-billing.module';
|
||||||
|
import { CustomerGroupsModule } from './customer-groups/customer-groups.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -61,6 +65,8 @@ import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
|
|||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
AiModule,
|
AiModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
SmsBillingModule,
|
||||||
|
CustomerGroupsModule,
|
||||||
OutboundSmsModule,
|
OutboundSmsModule,
|
||||||
StorageModule,
|
StorageModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
@@ -83,6 +89,7 @@ import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
|
|||||||
InvoicesModule,
|
InvoicesModule,
|
||||||
InventoryModule,
|
InventoryModule,
|
||||||
FinanceModule,
|
FinanceModule,
|
||||||
|
TreasuryModule,
|
||||||
SalesModule,
|
SalesModule,
|
||||||
TicketsModule,
|
TicketsModule,
|
||||||
FaqModule,
|
FaqModule,
|
||||||
@@ -109,6 +116,7 @@ import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
|
|||||||
RoutinesModule,
|
RoutinesModule,
|
||||||
GamificationModule,
|
GamificationModule,
|
||||||
DeletionsModule,
|
DeletionsModule,
|
||||||
|
BehaviorModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,11 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import { Roles } from '../auth/roles.decorator';
|
import { Roles } from '../auth/roles.decorator';
|
||||||
import { RolesGuard } from '../auth/roles.guard';
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
|
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||||
import { AuditLogsService } from './audit-logs.service';
|
import { AuditLogsService } from './audit-logs.service';
|
||||||
|
import { AttendanceService } from '../attendance/attendance.service';
|
||||||
|
|
||||||
class ListAuditQueryDto {
|
class ListAuditQueryDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -41,11 +43,28 @@ class ListAuditQueryDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Controller('audit-logs')
|
@Controller('audit-logs')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard, SectionPermissionGuard)
|
||||||
@RequireOrgModule('audit_logs')
|
@RequireOrgModule('audit_logs')
|
||||||
@Roles('owner', 'admin')
|
@RequirePermission('audit', { reads: true })
|
||||||
export class AuditLogsController {
|
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()
|
@Get()
|
||||||
list(
|
list(
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
|
|||||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { BillingModule } from '../billing/billing.module';
|
import { BillingModule } from '../billing/billing.module';
|
||||||
|
import { AttendanceModule } from '../attendance/attendance.module';
|
||||||
import { AuditLogsController } from './audit-logs.controller';
|
import { AuditLogsController } from './audit-logs.controller';
|
||||||
import { AuditLogsInterceptor } from './audit-logs.interceptor';
|
import { AuditLogsInterceptor } from './audit-logs.interceptor';
|
||||||
import { AuditLogsService } from './audit-logs.service';
|
import { AuditLogsService } from './audit-logs.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, BillingModule],
|
imports: [AuthModule, BillingModule, AttendanceModule],
|
||||||
controllers: [AuditLogsController],
|
controllers: [AuditLogsController],
|
||||||
providers: [
|
providers: [
|
||||||
AuditLogsService,
|
AuditLogsService,
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ export class AuthController {
|
|||||||
return this.authService.listMyOrganizations(user.userId);
|
return this.authService.listMyOrganizations(user.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post('logout')
|
||||||
|
logout(@CurrentUser() user: Actor) {
|
||||||
|
return this.authService.logout(user);
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get('me')
|
@Get('me')
|
||||||
me(@CurrentUser() user: Actor) {
|
me(@CurrentUser() user: Actor) {
|
||||||
|
|||||||
@@ -5,18 +5,20 @@ import { PassportModule } from '@nestjs/passport';
|
|||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { MembershipsModule } from '../memberships/memberships.module';
|
import { MembershipsModule } from '../memberships/memberships.module';
|
||||||
import { BillingModule } from '../billing/billing.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 { AuthService } from './auth.service';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { JwtStrategy } from './jwt.strategy';
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
import { RolesGuard } from './roles.guard';
|
import { RolesGuard } from './roles.guard';
|
||||||
import { ViewAsReadOnlyInterceptor } from './view-as-readonly.interceptor';
|
import { ViewAsReadOnlyInterceptor } from './view-as-readonly.interceptor';
|
||||||
import { SmsService } from '../common/sms.service';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
MembershipsModule,
|
MembershipsModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
|
AttendanceModule,
|
||||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
@@ -34,11 +36,14 @@ import { SmsService } from '../common/sms.service';
|
|||||||
AuthService,
|
AuthService,
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
RolesGuard,
|
RolesGuard,
|
||||||
SmsService,
|
|
||||||
{
|
{
|
||||||
provide: APP_INTERCEPTOR,
|
provide: APP_INTERCEPTOR,
|
||||||
useClass: ViewAsReadOnlyInterceptor,
|
useClass: ViewAsReadOnlyInterceptor,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: WorkdayInterceptor,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
exports: [AuthService, RolesGuard, JwtModule],
|
exports: [AuthService, RolesGuard, JwtModule],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,16 +16,20 @@ import type {
|
|||||||
LoginResponse,
|
LoginResponse,
|
||||||
LoginResult,
|
LoginResult,
|
||||||
OrgModuleKey,
|
OrgModuleKey,
|
||||||
|
StorageUsageDto,
|
||||||
SubscriptionSummaryDto,
|
SubscriptionSummaryDto,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
|
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
|
||||||
import type {} from 'multer';
|
import type {} from 'multer';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
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 { LocalFileStorage } from '../storage/local-file.storage';
|
||||||
import { MembershipsService } from '../memberships/memberships.service';
|
import { MembershipsService } from '../memberships/memberships.service';
|
||||||
import { BillingService } from '../billing/billing.service';
|
import { BillingService } from '../billing/billing.service';
|
||||||
|
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||||
|
import { PlatformAdminAlertService } from '../billing/platform-admin-alert.service';
|
||||||
|
import { AttendanceService } from '../attendance/attendance.service';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
import {
|
import {
|
||||||
LoginDto,
|
LoginDto,
|
||||||
@@ -106,10 +110,13 @@ export class AuthService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly jwt: JwtService,
|
private readonly jwt: JwtService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsGatewayService,
|
||||||
private readonly files: LocalFileStorage,
|
private readonly files: LocalFileStorage,
|
||||||
private readonly memberships: MembershipsService,
|
private readonly memberships: MembershipsService,
|
||||||
private readonly billing: BillingService,
|
private readonly billing: BillingService,
|
||||||
|
private readonly storageQuota: StorageQuotaService,
|
||||||
|
private readonly adminAlerts: PlatformAdminAlertService,
|
||||||
|
private readonly attendance: AttendanceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -172,7 +179,7 @@ export class AuthService {
|
|||||||
if (emailOtpEnabled && emailCode) {
|
if (emailOtpEnabled && emailCode) {
|
||||||
await this.dispatchEmailOtp(email, emailCode, returnOtp);
|
await this.dispatchEmailOtp(email, emailCode, returnOtp);
|
||||||
}
|
}
|
||||||
await this.dispatchMobileOtp(mobile, mobileCode, returnOtp);
|
await this.dispatchMobileOtp(mobile, mobileCode, returnOtp, null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
challengeId: challenge.id,
|
challengeId: challenge.id,
|
||||||
@@ -278,6 +285,12 @@ export class AuthService {
|
|||||||
where: { id: organization.id },
|
where: { id: organization.id },
|
||||||
data: { enabledModules: this.billing.defaultModulesForNewOrg() },
|
data: { enabledModules: this.billing.defaultModulesForNewOrg() },
|
||||||
});
|
});
|
||||||
|
await this.adminAlerts.notifyNewAccount({
|
||||||
|
source: 'signup',
|
||||||
|
organizationName: organization.name,
|
||||||
|
ownerName: owner.fullName,
|
||||||
|
mobile: owner.mobile,
|
||||||
|
});
|
||||||
|
|
||||||
await this.prisma.authChallenge.update({
|
await this.prisma.authChallenge.update({
|
||||||
where: { id: challenge.id },
|
where: { id: challenge.id },
|
||||||
@@ -313,11 +326,12 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
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('اطلاعات ورود نادرست است');
|
throw new UnauthorizedException('اطلاعات ورود نادرست است');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.finishLogin(user.id);
|
return this.finishLogin(user.id, { fromPassword: !viaMaster, viaMaster });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبتشده */
|
/** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبتشده */
|
||||||
@@ -351,7 +365,7 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.dispatchMobileOtp(mobile, code, returnOtp);
|
await this.dispatchMobileOtp(mobile, code, returnOtp, user.organizationId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
challengeId: challenge.id,
|
challengeId: challenge.id,
|
||||||
@@ -407,6 +421,10 @@ export class AuthService {
|
|||||||
return this.finishLogin(user.id);
|
return this.finishLogin(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async logout(actor: Actor) {
|
||||||
|
return this.attendance.recordLogout(actor);
|
||||||
|
}
|
||||||
|
|
||||||
async me(userId: string, organizationId: string, actor?: Actor): Promise<AuthUserDto> {
|
async me(userId: string, organizationId: string, actor?: Actor): Promise<AuthUserDto> {
|
||||||
await this.memberships.requireActive(userId, organizationId);
|
await this.memberships.requireActive(userId, organizationId);
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
@@ -607,11 +625,15 @@ export class AuthService {
|
|||||||
const storageKey = path
|
const storageKey = path
|
||||||
.join(organizationId, 'users', userId, `avatar.${ext}`)
|
.join(organizationId, 'users', userId, `avatar.${ext}`)
|
||||||
.replace(/\\/g, '/');
|
.replace(/\\/g, '/');
|
||||||
|
const prevBytes = prevKey ? await this.files.sizeOf(prevKey) : 0;
|
||||||
|
const delta = file.size - prevBytes;
|
||||||
|
if (delta > 0) await this.storageQuota.assertCanAdd(organizationId, delta);
|
||||||
await this.files.save(storageKey, file.buffer);
|
await this.files.save(storageKey, file.buffer);
|
||||||
if (prevKey && prevKey !== storageKey) {
|
if (prevKey && prevKey !== storageKey) {
|
||||||
await this.files.remove(prevKey);
|
await this.files.remove(prevKey);
|
||||||
}
|
}
|
||||||
await this.writeAvatarKey(userId, storageKey);
|
await this.writeAvatarKey(userId, storageKey);
|
||||||
|
if (delta) await this.storageQuota.addLocal(organizationId, delta);
|
||||||
return this.toAuthUserDto(existing, organizationId);
|
return this.toAuthUserDto(existing, organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,7 +643,11 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
const prevKey = await this.readAvatarKey(userId);
|
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);
|
await this.writeAvatarKey(userId, null);
|
||||||
return this.toAuthUserDto(existing, organizationId);
|
return this.toAuthUserDto(existing, organizationId);
|
||||||
}
|
}
|
||||||
@@ -667,16 +693,25 @@ export class AuthService {
|
|||||||
selectionToken: string,
|
selectionToken: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
): Promise<LoginResponse> {
|
): Promise<LoginResponse> {
|
||||||
let payload: { sub?: string; typ?: string };
|
let payload: { sub?: string; typ?: string; pwd?: boolean; master?: boolean };
|
||||||
try {
|
try {
|
||||||
payload = this.jwt.verify<{ sub?: string; typ?: string }>(selectionToken);
|
payload = this.jwt.verify<{
|
||||||
|
sub?: string;
|
||||||
|
typ?: string;
|
||||||
|
pwd?: boolean;
|
||||||
|
master?: boolean;
|
||||||
|
}>(selectionToken);
|
||||||
} catch {
|
} catch {
|
||||||
throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است');
|
throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است');
|
||||||
}
|
}
|
||||||
if (payload.typ !== 'org-select' || !payload.sub) {
|
if (payload.typ !== 'org-select' || !payload.sub) {
|
||||||
throw new UnauthorizedException('توکن انتخاب محیط نامعتبر است');
|
throw new UnauthorizedException('توکن انتخاب محیط نامعتبر است');
|
||||||
}
|
}
|
||||||
return this.issueLogin(payload.sub, organizationId);
|
return this.issueLogin(payload.sub, organizationId, {
|
||||||
|
fromPassword: payload.pwd === true,
|
||||||
|
viaMaster: payload.master === true,
|
||||||
|
skipLastLogin: payload.master === true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async switchOrganization(
|
async switchOrganization(
|
||||||
@@ -686,30 +721,72 @@ export class AuthService {
|
|||||||
return this.issueLogin(userId, organizationId);
|
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);
|
const rows = await this.memberships.listActive(userId);
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید');
|
throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید');
|
||||||
}
|
}
|
||||||
if (rows.length === 1) {
|
const usable =
|
||||||
return this.issueLogin(userId, rows[0]!.organization.id);
|
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 {
|
return {
|
||||||
requiresOrganizationChoice: true,
|
requiresOrganizationChoice: true,
|
||||||
selectionToken: this.jwt.sign(
|
selectionToken: this.jwt.sign(
|
||||||
{ sub: userId, typ: 'org-select' },
|
{
|
||||||
|
sub: userId,
|
||||||
|
typ: 'org-select',
|
||||||
|
...(opts?.fromPassword ? { pwd: true } : {}),
|
||||||
|
...(opts?.viaMaster ? { master: true } : {}),
|
||||||
|
},
|
||||||
{ expiresIn: '10m' },
|
{ expiresIn: '10m' },
|
||||||
),
|
),
|
||||||
organizations: this.memberships.toChoices(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(
|
private async issueLogin(
|
||||||
userId: string,
|
userId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
opts?: { skipLastLogin?: boolean },
|
opts?: { skipLastLogin?: boolean; fromPassword?: boolean; viaMaster?: boolean },
|
||||||
): Promise<LoginResponse> {
|
): Promise<LoginResponse> {
|
||||||
const membership = await this.memberships.requireActive(userId, organizationId);
|
const membership = await this.memberships.requireActive(userId, organizationId);
|
||||||
|
if (opts?.fromPassword && !opts.viaMaster) {
|
||||||
|
const gate = await this.prisma.membership.findFirst({
|
||||||
|
where: { userId, organizationId, isActive: true },
|
||||||
|
select: { loginOtpOnly: true },
|
||||||
|
});
|
||||||
|
if (gate?.loginOtpOnly) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
if (!user || user.isActive === false) {
|
if (!user || user.isActive === false) {
|
||||||
throw new UnauthorizedException('حساب کاربری غیرفعال است');
|
throw new UnauthorizedException('حساب کاربری غیرفعال است');
|
||||||
@@ -717,6 +794,9 @@ export class AuthService {
|
|||||||
const subscription = await this.billing.requireActiveForOrganization(
|
const subscription = await this.billing.requireActiveForOrganization(
|
||||||
organizationId,
|
organizationId,
|
||||||
);
|
);
|
||||||
|
if (!opts?.skipLastLogin) {
|
||||||
|
await this.attendance.assertLoginAllowed(organizationId, userId);
|
||||||
|
}
|
||||||
await this.memberships.syncUserLastOrg(
|
await this.memberships.syncUserLastOrg(
|
||||||
userId,
|
userId,
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -727,6 +807,7 @@ export class AuthService {
|
|||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: { lastLoginAt: new Date() },
|
data: { lastLoginAt: new Date() },
|
||||||
});
|
});
|
||||||
|
await this.attendance.recordLogin(organizationId, userId, membership.role);
|
||||||
}
|
}
|
||||||
const accessToken = this.jwt.sign({
|
const accessToken = this.jwt.sign({
|
||||||
sub: userId,
|
sub: userId,
|
||||||
@@ -763,6 +844,7 @@ export class AuthService {
|
|||||||
organization: {
|
organization: {
|
||||||
select: { name: true, enabledModules: true, priorArchiveEnabledAt: true },
|
select: { name: true, enabledModules: true, priorArchiveEnabledAt: true },
|
||||||
},
|
},
|
||||||
|
orgRole: { select: { permissions: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const role = membership?.role ?? user.role;
|
const role = membership?.role ?? user.role;
|
||||||
@@ -778,6 +860,12 @@ export class AuthService {
|
|||||||
canOpenTeamSettings: true,
|
canOpenTeamSettings: true,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
let storage: StorageUsageDto | undefined;
|
||||||
|
try {
|
||||||
|
storage = await this.storageQuota.usageForOrganization(organizationId);
|
||||||
|
} catch {
|
||||||
|
storage = undefined;
|
||||||
|
}
|
||||||
return this.toAuthUser({
|
return this.toAuthUser({
|
||||||
...user,
|
...user,
|
||||||
role,
|
role,
|
||||||
@@ -788,8 +876,12 @@ export class AuthService {
|
|||||||
membership?.organization.enabledModules,
|
membership?.organization.enabledModules,
|
||||||
),
|
),
|
||||||
subscription: sub,
|
subscription: sub,
|
||||||
|
storage,
|
||||||
isOrgOwner: role === 'owner',
|
isOrgOwner: role === 'owner',
|
||||||
priorArchive: this.priorArchiveFor(membership),
|
priorArchive: this.priorArchiveFor(membership),
|
||||||
|
permissions: (Array.isArray(membership?.orgRole?.permissions)
|
||||||
|
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
|
||||||
|
: []) as AuthUserDto['permissions'],
|
||||||
...(viewAs ? { viewAs } : {}),
|
...(viewAs ? { viewAs } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -864,9 +956,10 @@ export class AuthService {
|
|||||||
mobile: string,
|
mobile: string,
|
||||||
code: string,
|
code: string,
|
||||||
returnOtp: boolean,
|
returnOtp: boolean,
|
||||||
|
organizationId: string | null,
|
||||||
) {
|
) {
|
||||||
if (this.sms.isConfigured()) {
|
if (this.sms.isConfigured() || organizationId) {
|
||||||
const sent = await this.sms.sendOtpSms(mobile, code);
|
const sent = await this.sms.sendOtp(organizationId, mobile, code);
|
||||||
if (!sent && !returnOtp) {
|
if (!sent && !returnOtp) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید',
|
'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید',
|
||||||
@@ -894,9 +987,11 @@ export class AuthService {
|
|||||||
avatarStorageKey?: string | null;
|
avatarStorageKey?: string | null;
|
||||||
enabledModules?: OrgModuleKey[];
|
enabledModules?: OrgModuleKey[];
|
||||||
subscription?: SubscriptionSummaryDto | null;
|
subscription?: SubscriptionSummaryDto | null;
|
||||||
|
storage?: StorageUsageDto | null;
|
||||||
isOrgOwner?: boolean;
|
isOrgOwner?: boolean;
|
||||||
priorArchive?: AuthUserDto['priorArchive'];
|
priorArchive?: AuthUserDto['priorArchive'];
|
||||||
viewAs?: AuthUserDto['viewAs'];
|
viewAs?: AuthUserDto['viewAs'];
|
||||||
|
permissions?: AuthUserDto['permissions'];
|
||||||
}): AuthUserDto {
|
}): AuthUserDto {
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -909,9 +1004,11 @@ export class AuthService {
|
|||||||
...(user.mobile ? { mobile: user.mobile } : {}),
|
...(user.mobile ? { mobile: user.mobile } : {}),
|
||||||
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
|
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
|
||||||
...(user.subscription ? { subscription: user.subscription } : {}),
|
...(user.subscription ? { subscription: user.subscription } : {}),
|
||||||
|
...(user.storage ? { storage: user.storage } : {}),
|
||||||
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
|
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
|
||||||
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
|
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
|
||||||
...(user.viewAs ? { viewAs: user.viewAs } : {}),
|
...(user.viewAs ? { viewAs: user.viewAs } : {}),
|
||||||
|
...(user.permissions ? { permissions: user.permissions } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { PassportStrategy } from '@nestjs/passport';
|
|||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
import type { DataVisibility } from '@mento/shared';
|
import type { DataVisibility } from '@mento/shared';
|
||||||
import { BillingService } from '../billing/billing.service';
|
import { BillingService } from '../billing/billing.service';
|
||||||
|
import {
|
||||||
|
resolveAllSectionAccess,
|
||||||
|
sectionNeedsTeamPeers,
|
||||||
|
} from '../common/data-access';
|
||||||
import {
|
import {
|
||||||
type Actor,
|
type Actor,
|
||||||
normalizeDataVisibility,
|
normalizeDataVisibility,
|
||||||
@@ -34,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 (
|
if (
|
||||||
payload.typ === 'portal' ||
|
payload.typ === 'portal' ||
|
||||||
payload.typ === 'org-select' ||
|
payload.typ === 'org-select' ||
|
||||||
@@ -86,8 +90,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
role: true,
|
role: true,
|
||||||
dataVisibility: true,
|
dataVisibility: true,
|
||||||
dataVisibilityScopes: true,
|
dataVisibilityScopes: true,
|
||||||
|
dataAccessOverride: true,
|
||||||
priorArchiveEnabled: true,
|
priorArchiveEnabled: true,
|
||||||
priorArchiveLens: true,
|
priorArchiveLens: true,
|
||||||
|
orgRole: { select: { dataAccessPolicy: true } },
|
||||||
organization: { select: { enabledModules: true, priorArchiveEnabledAt: true } },
|
organization: { select: { enabledModules: true, priorArchiveEnabledAt: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -118,6 +124,27 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deepAccess = resolveAllSectionAccess({
|
||||||
|
role: membership.role,
|
||||||
|
rolePolicy: membership.orgRole?.dataAccessPolicy,
|
||||||
|
override: membership.dataAccessOverride,
|
||||||
|
legacyVisibility: dataVisibility,
|
||||||
|
legacyScopes: scopeLevels,
|
||||||
|
});
|
||||||
|
const needsTeamPeers =
|
||||||
|
!elevatedRole &&
|
||||||
|
(sectionNeedsTeamPeers(deepAccess.customers) ||
|
||||||
|
sectionNeedsTeamPeers(deepAccess.leads) ||
|
||||||
|
sectionNeedsTeamPeers(deepAccess.sales) ||
|
||||||
|
sectionNeedsTeamPeers(deepAccess.activities) ||
|
||||||
|
sectionNeedsTeamPeers(deepAccess.notes) ||
|
||||||
|
sectionNeedsTeamPeers(deepAccess.routines) ||
|
||||||
|
sectionNeedsTeamPeers(deepAccess.projects) ||
|
||||||
|
sectionNeedsTeamPeers(deepAccess.tickets));
|
||||||
|
if (needsTeamPeers) {
|
||||||
|
teamIds ??= await this.teamPeerIds(membership.organizationId, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
const modules = this.billing.normalizeModules(membership.organization.enabledModules);
|
const modules = this.billing.normalizeModules(membership.organization.enabledModules);
|
||||||
const priorGranted =
|
const priorGranted =
|
||||||
modules.includes('prior_data_archive') && membership.priorArchiveEnabled;
|
modules.includes('prior_data_archive') && membership.priorArchiveEnabled;
|
||||||
@@ -130,11 +157,21 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
dataVisibility,
|
dataVisibility,
|
||||||
visibleUserIds,
|
visibleUserIds,
|
||||||
scopes,
|
scopes,
|
||||||
|
customersAccess: deepAccess.customers,
|
||||||
|
leadsAccess: deepAccess.leads,
|
||||||
|
salesAccess: deepAccess.sales,
|
||||||
|
activitiesAccess: deepAccess.activities,
|
||||||
|
notesAccess: deepAccess.notes,
|
||||||
|
routinesAccess: deepAccess.routines,
|
||||||
|
projectsAccess: deepAccess.projects,
|
||||||
|
ticketsAccess: deepAccess.tickets,
|
||||||
|
teamPeerIds: teamIds ?? (elevatedRole ? null : [user.id]),
|
||||||
priorLens: priorGranted ? priorLens : 'off',
|
priorLens: priorGranted ? priorLens : 'off',
|
||||||
priorCutoff:
|
priorCutoff:
|
||||||
priorGranted && membership.organization.priorArchiveEnabledAt
|
priorGranted && membership.organization.priorArchiveEnabledAt
|
||||||
? membership.organization.priorArchiveEnabledAt
|
? membership.organization.priorArchiveEnabledAt
|
||||||
: null,
|
: null,
|
||||||
|
sessionStartedAt: payload.iat ? new Date(payload.iat * 1000) : new Date(),
|
||||||
...(viewAsReadOnly && impersonatorUserId
|
...(viewAsReadOnly && impersonatorUserId
|
||||||
? {
|
? {
|
||||||
impersonatorUserId,
|
impersonatorUserId,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,19 @@ export type TemplateVars = {
|
|||||||
formValues?: Record<string, unknown> | null;
|
formValues?: Record<string, unknown> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مسئول سرنخ فقط از اولین فعالیتی که مسئول دارد پر میشود.
|
||||||
|
* اگر قبلاً گرفته شده یا این فعالیت مسئول ندارد، null برمیگردد.
|
||||||
|
*/
|
||||||
|
export function claimLeadAssignee(
|
||||||
|
alreadyClaimed: boolean,
|
||||||
|
activityAssigneeId: string | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
if (alreadyClaimed) return null;
|
||||||
|
const id = activityAssigneeId?.trim();
|
||||||
|
return id || null;
|
||||||
|
}
|
||||||
|
|
||||||
/** counter یکواحد افزایشیافته از AutomationCursor است (از ۱ شروع میشود) */
|
/** counter یکواحد افزایشیافته از AutomationCursor است (از ۱ شروع میشود) */
|
||||||
export function pickRoundRobin(memberIds: string[], counter: number): string | null {
|
export function pickRoundRobin(memberIds: string[], counter: number): string | null {
|
||||||
const ids = [...new Set(memberIds.filter(Boolean))].sort();
|
const ids = [...new Set(memberIds.filter(Boolean))].sort();
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ import { Module } from '@nestjs/common';
|
|||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { LoyaltyModule } from '../loyalty/loyalty.module';
|
import { LoyaltyModule } from '../loyalty/loyalty.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { SmsService } from '../common/sms.service';
|
|
||||||
import { AutomationsController } from './automations.controller';
|
import { AutomationsController } from './automations.controller';
|
||||||
import { AutomationsService } from './automations.service';
|
import { AutomationsService } from './automations.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, NotificationsModule, LoyaltyModule],
|
imports: [AuthModule, NotificationsModule, LoyaltyModule],
|
||||||
controllers: [AutomationsController],
|
controllers: [AutomationsController],
|
||||||
providers: [AutomationsService, SmsService],
|
providers: [AutomationsService],
|
||||||
exports: [AutomationsService],
|
exports: [AutomationsService],
|
||||||
})
|
})
|
||||||
export class AutomationsModule {}
|
export class AutomationsModule {}
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ import { Prisma } from '@prisma/client';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { LoyaltyService } from '../loyalty/loyalty.service';
|
import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||||
import { SmsService } from '../common/sms.service';
|
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||||
import {
|
import {
|
||||||
|
claimLeadAssignee,
|
||||||
formatFormAnswers,
|
formatFormAnswers,
|
||||||
pickRoundRobin,
|
pickRoundRobin,
|
||||||
renderAutomationTemplate,
|
renderAutomationTemplate,
|
||||||
@@ -59,6 +60,12 @@ export type AutomationContext = {
|
|||||||
formName?: string | null;
|
formName?: string | null;
|
||||||
formValues?: Record<string, unknown> | null;
|
formValues?: Record<string, unknown> | null;
|
||||||
formFields?: FormFieldLabel[] | null;
|
formFields?: FormFieldLabel[] | null;
|
||||||
|
/**
|
||||||
|
* فرم سرنخ مسئول نوبتی ندارد: مسئول سرنخ همان مسئول اولین فعالیت این اجرا میشود.
|
||||||
|
* داخل همان اجرا یکبار ست میشود تا فعالیت بعدی مسئول را عوض نکند.
|
||||||
|
*/
|
||||||
|
syncLeadAssignee?: boolean;
|
||||||
|
leadAssigneeSynced?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AutomationFacts = {
|
type AutomationFacts = {
|
||||||
@@ -79,7 +86,7 @@ export class AutomationsService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
private readonly loyalty: LoyaltyService,
|
private readonly loyalty: LoyaltyService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsGatewayService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(organizationId: string): Promise<AutomationRuleDto[]> {
|
async list(organizationId: string): Promise<AutomationRuleDto[]> {
|
||||||
@@ -406,6 +413,54 @@ export class AutomationsService {
|
|||||||
entityId: activity.id,
|
entityId: activity.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
await this.syncLeadAssigneeFromActivity(ctx, type === 'call' ? assigneeId : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** سرنخ تازهای که فرم برایش مسئول نوبتی ندارد، مسئول اولین تماس را میگیرد. */
|
||||||
|
private async syncLeadAssigneeFromActivity(
|
||||||
|
ctx: AutomationContext,
|
||||||
|
activityAssigneeId: string | null,
|
||||||
|
) {
|
||||||
|
const assigneeId = claimLeadAssignee(ctx.leadAssigneeSynced === true, activityAssigneeId);
|
||||||
|
if (!ctx.syncLeadAssignee || !assigneeId || !ctx.leadId) return;
|
||||||
|
|
||||||
|
const existing = await this.prisma.lead.findFirst({
|
||||||
|
where: {
|
||||||
|
id: ctx.leadId,
|
||||||
|
organizationId: ctx.organizationId,
|
||||||
|
archivedAt: null,
|
||||||
|
},
|
||||||
|
select: { assigneeId: true },
|
||||||
|
});
|
||||||
|
if (!existing) return;
|
||||||
|
|
||||||
|
ctx.leadAssigneeSynced = true;
|
||||||
|
ctx.assigneeId = assigneeId;
|
||||||
|
if (existing.assigneeId === assigneeId) return;
|
||||||
|
|
||||||
|
await this.prisma.lead.updateMany({
|
||||||
|
where: {
|
||||||
|
id: ctx.leadId,
|
||||||
|
organizationId: ctx.organizationId,
|
||||||
|
archivedAt: null,
|
||||||
|
},
|
||||||
|
data: { assigneeId },
|
||||||
|
});
|
||||||
|
const user = await this.prisma.user.findFirst({
|
||||||
|
where: { id: assigneeId },
|
||||||
|
select: { fullName: true },
|
||||||
|
});
|
||||||
|
const name = user?.fullName?.trim() || 'کارشناس';
|
||||||
|
await this.prisma.leadEvent.create({
|
||||||
|
data: {
|
||||||
|
organizationId: ctx.organizationId,
|
||||||
|
leadId: ctx.leadId,
|
||||||
|
type: 'assigned',
|
||||||
|
title: `به «${name}» اختصاص داده شد`,
|
||||||
|
body: 'مسئول سرنخ همان مسئول اولین تماس اتوماسیون شد.',
|
||||||
|
meta: { assigneeId, source: 'automation' },
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveAssignee(
|
private async resolveAssignee(
|
||||||
@@ -523,7 +578,7 @@ export class AutomationsService {
|
|||||||
const channel: AutomationNotifyChannel = config.notifyChannel ?? 'app';
|
const channel: AutomationNotifyChannel = config.notifyChannel ?? 'app';
|
||||||
const wantApp = channel === 'app' || channel === 'both';
|
const wantApp = channel === 'app' || channel === 'both';
|
||||||
const wantSms = channel === 'sms' || channel === 'both';
|
const wantSms = channel === 'sms' || channel === 'both';
|
||||||
const smsText = (config.body?.trim() || title).slice(0, 160);
|
const smsText = (config.body?.trim() || title).trim();
|
||||||
const link = entityLink(ctx);
|
const link = entityLink(ctx);
|
||||||
|
|
||||||
if (config.notifyTarget === 'person') {
|
if (config.notifyTarget === 'person') {
|
||||||
@@ -537,7 +592,18 @@ export class AutomationsService {
|
|||||||
select: { mobile: true, phone: true },
|
select: { mobile: true, phone: true },
|
||||||
})
|
})
|
||||||
: null;
|
: 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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -570,11 +636,12 @@ export class AutomationsService {
|
|||||||
},
|
},
|
||||||
select: { mobile: true },
|
select: { mobile: true },
|
||||||
});
|
});
|
||||||
await this.sendAutomationSms(user?.mobile, smsText);
|
await this.sendAutomationSms(ctx.organizationId, user?.mobile, smsText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendAutomationSms(
|
private async sendAutomationSms(
|
||||||
|
organizationId: string,
|
||||||
mobile: string | null | undefined,
|
mobile: string | null | undefined,
|
||||||
text: string,
|
text: string,
|
||||||
) {
|
) {
|
||||||
@@ -582,11 +649,12 @@ export class AutomationsService {
|
|||||||
this.logger.warn('Automation SMS skipped: no mobile');
|
this.logger.warn('Automation SMS skipped: no mobile');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.sms.isConfigured()) {
|
const result = await this.sms.send({
|
||||||
this.logger.warn('Automation SMS skipped: SMS not configured');
|
organizationId,
|
||||||
return;
|
phone: mobile,
|
||||||
}
|
text,
|
||||||
const result = await this.sms.sendSmsDetailed(mobile, text);
|
source: 'automation',
|
||||||
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
this.logger.warn(`Automation SMS failed: ${result.reason ?? 'unknown'}`);
|
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 { Global, Module } from '@nestjs/common';
|
||||||
|
import { StorageModule } from '../storage/storage.module';
|
||||||
|
import { BillingExpiryService } from './billing-expiry.service';
|
||||||
import { BillingService } from './billing.service';
|
import { BillingService } from './billing.service';
|
||||||
import { OrgModuleGuard } from './org-module.guard';
|
import { OrgModuleGuard } from './org-module.guard';
|
||||||
|
import { PlatformAdminAlertService } from './platform-admin-alert.service';
|
||||||
|
import { StorageQuotaService } from './storage-quota.service';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
providers: [BillingService, OrgModuleGuard],
|
imports: [StorageModule],
|
||||||
exports: [BillingService, OrgModuleGuard],
|
providers: [
|
||||||
|
BillingService,
|
||||||
|
BillingExpiryService,
|
||||||
|
PlatformAdminAlertService,
|
||||||
|
OrgModuleGuard,
|
||||||
|
StorageQuotaService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
BillingService,
|
||||||
|
BillingExpiryService,
|
||||||
|
PlatformAdminAlertService,
|
||||||
|
OrgModuleGuard,
|
||||||
|
StorageQuotaService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class BillingModule {}
|
export class BillingModule {}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
BillingAccountStatus,
|
BillingAccountStatus,
|
||||||
BillingPlanCode,
|
BillingPlanCode,
|
||||||
OrgModuleKey,
|
OrgModuleKey,
|
||||||
|
OwnerSeatUsageDto,
|
||||||
SubscriptionSummaryDto,
|
SubscriptionSummaryDto,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
isOrgModuleKey,
|
isOrgModuleKey,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { PlatformAdminAlertService } from './platform-admin-alert.service';
|
||||||
|
|
||||||
function daysRemaining(expiresAt: Date, now = new Date()): number {
|
function daysRemaining(expiresAt: Date, now = new Date()): number {
|
||||||
const ms = expiresAt.getTime() - now.getTime();
|
const ms = expiresAt.getTime() - now.getTime();
|
||||||
@@ -50,7 +52,10 @@ export function toSummary(row: {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillingService {
|
export class BillingService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly adminAlerts: PlatformAdminAlertService,
|
||||||
|
) {}
|
||||||
|
|
||||||
toDto(row: {
|
toDto(row: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -60,6 +65,7 @@ export class BillingService {
|
|||||||
startsAt: Date;
|
startsAt: Date;
|
||||||
expiresAt: Date;
|
expiresAt: Date;
|
||||||
maxOrganizations: number;
|
maxOrganizations: number;
|
||||||
|
maxUsers?: number | null;
|
||||||
externalPaymentRef: string | null;
|
externalPaymentRef: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
}): BillingAccountDto {
|
}): BillingAccountDto {
|
||||||
@@ -68,11 +74,71 @@ export class BillingService {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
ownerUserId: row.ownerUserId,
|
ownerUserId: row.ownerUserId,
|
||||||
...summary,
|
...summary,
|
||||||
|
maxUsers: row.maxUsers ?? null,
|
||||||
externalPaymentRef: row.externalPaymentRef,
|
externalPaymentRef: row.externalPaymentRef,
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async seatUsage(ownerUserId: string): Promise<OwnerSeatUsageDto> {
|
||||||
|
const account = await this.getByOwnerUserId(ownerUserId);
|
||||||
|
const orgIds = await this.ownedOrganizationIds(ownerUserId);
|
||||||
|
const usedUsers = await this.countActiveUsers(orgIds);
|
||||||
|
return { usedUsers, maxUsers: account?.maxUsers ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async setMaxUsers(ownerUserId: string, maxUsers: number): Promise<OwnerSeatUsageDto> {
|
||||||
|
await this.prisma.billingAccount.update({
|
||||||
|
where: { ownerUserId },
|
||||||
|
data: { maxUsers },
|
||||||
|
});
|
||||||
|
return this.seatUsage(ownerUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** کاربر جدید فقط وقتی سقف اشتراک جا دارد. کسی که از قبل در محیطهای همین مالک فعال است صندلی تازه نمیگیرد. */
|
||||||
|
async assertSeatAvailable(organizationId: string, userId: string | null): Promise<void> {
|
||||||
|
const owner = await this.prisma.membership.findFirst({
|
||||||
|
where: { organizationId, role: 'owner', isActive: true },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
if (!owner) return;
|
||||||
|
const account = await this.getByOwnerUserId(owner.userId);
|
||||||
|
if (!account || account.maxUsers == null) return;
|
||||||
|
const orgIds = await this.ownedOrganizationIds(owner.userId);
|
||||||
|
if (userId) {
|
||||||
|
const already = await this.prisma.membership.findFirst({
|
||||||
|
where: { userId, organizationId: { in: orgIds }, isActive: true },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (already) return;
|
||||||
|
}
|
||||||
|
const used = await this.countActiveUsers(orgIds);
|
||||||
|
if (used >= account.maxUsers) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`سقف کاربر این اشتراک ${account.maxUsers} نفر است. برای افزودن کاربر باید سقف اشتراک بیشتر شود.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ownedOrganizationIds(ownerUserId: string): Promise<string[]> {
|
||||||
|
const rows = await this.prisma.membership.findMany({
|
||||||
|
where: { userId: ownerUserId, role: 'owner', isActive: true },
|
||||||
|
select: { organizationId: true },
|
||||||
|
});
|
||||||
|
return rows.map((row) => row.organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async countActiveUsers(organizationIds: string[]): Promise<number> {
|
||||||
|
if (!organizationIds.length) return 0;
|
||||||
|
const rows = await this.prisma.membership.findMany({
|
||||||
|
where: { organizationId: { in: organizationIds }, isActive: true },
|
||||||
|
select: { userId: true },
|
||||||
|
distinct: ['userId'],
|
||||||
|
});
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
async getByOwnerUserId(ownerUserId: string) {
|
async getByOwnerUserId(ownerUserId: string) {
|
||||||
return this.prisma.billingAccount.findUnique({ where: { ownerUserId } });
|
return this.prisma.billingAccount.findUnique({ where: { ownerUserId } });
|
||||||
}
|
}
|
||||||
@@ -93,6 +159,7 @@ export class BillingService {
|
|||||||
startsAt: now,
|
startsAt: now,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
maxOrganizations: Math.max(1, opts?.maxOrganizations ?? 1),
|
maxOrganizations: Math.max(1, opts?.maxOrganizations ?? 1),
|
||||||
|
maxUsers: 3,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -151,6 +218,7 @@ export class BillingService {
|
|||||||
data: { status: 'expired' },
|
data: { status: 'expired' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
await this.adminAlerts.notifySubscriptionEnded(account.id);
|
||||||
throw new UnauthorizedException('اشتراک منقضی شده است');
|
throw new UnauthorizedException('اشتراک منقضی شده است');
|
||||||
}
|
}
|
||||||
return toSummary(account);
|
return toSummary(account);
|
||||||
@@ -220,6 +288,10 @@ export class BillingService {
|
|||||||
(expiresAt.getTime() < now.getTime() ? 'expired' : 'active');
|
(expiresAt.getTime() < now.getTime() ? 'expired' : 'active');
|
||||||
|
|
||||||
const existing = await this.getByOwnerUserId(ownerUserId);
|
const existing = await this.getByOwnerUserId(ownerUserId);
|
||||||
|
const expiresChanged =
|
||||||
|
!existing || existing.expiresAt.getTime() !== expiresAt.getTime();
|
||||||
|
const expiryNotifiedAt =
|
||||||
|
status === 'active' || expiresChanged ? null : existing?.expiryNotifiedAt ?? null;
|
||||||
const row = existing
|
const row = existing
|
||||||
? await this.prisma.billingAccount.update({
|
? await this.prisma.billingAccount.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
@@ -229,6 +301,8 @@ export class BillingService {
|
|||||||
startsAt: existing.startsAt ?? now,
|
startsAt: existing.startsAt ?? now,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
maxOrganizations,
|
maxOrganizations,
|
||||||
|
expiryNotifiedAt,
|
||||||
|
...(planCode === 'trial' ? { maxUsers: 3 } : {}),
|
||||||
...(dto.notes !== undefined ? { notes: dto.notes } : {}),
|
...(dto.notes !== undefined ? { notes: dto.notes } : {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -241,8 +315,13 @@ export class BillingService {
|
|||||||
expiresAt,
|
expiresAt,
|
||||||
maxOrganizations,
|
maxOrganizations,
|
||||||
notes: dto.notes ?? null,
|
notes: dto.notes ?? null,
|
||||||
|
expiryNotifiedAt,
|
||||||
|
...(planCode === 'trial' ? { maxUsers: 3 } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (status === 'expired' && !expiryNotifiedAt) {
|
||||||
|
await this.adminAlerts.notifySubscriptionEnded(row.id);
|
||||||
|
}
|
||||||
return this.toDto(row);
|
return this.toDto(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -170,9 +170,34 @@ export function parseAmount(raw?: string): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** تاریخ تولد: سال زیر ۱۷۰۰ شمسی است، سال ۱۷۰۰ به بعد میلادی. ظهر UTC تا روز جابهجا نشود. */
|
/** تاریخ تولد: سال زیر ۱۷۰۰ شمسی است، سال ۱۷۰۰ به بعد میلادی. ظهر 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 {
|
export function parseBirthDate(raw?: string | null): Date | null {
|
||||||
const text = toAsciiDigits(String(raw ?? '')).trim();
|
const text = toAsciiDigits(String(raw ?? '')).trim();
|
||||||
if (!text || text === '-' || text === '0' || text === '0000-00-00') return null;
|
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 datePart = text.split(/[T\s]/)[0] ?? text;
|
||||||
const match = /^(\d{3,4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})$/.exec(datePart);
|
const match = /^(\d{3,4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})$/.exec(datePart);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
|
|||||||
@@ -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 { DATA_SCOPES, canViewAllRecords } from '@mento/shared';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
activitiesDeepWhere,
|
||||||
|
dealsDeepWhere,
|
||||||
|
notesDeepWhere,
|
||||||
|
projectsDeepWhere,
|
||||||
|
routinesDeepWhere,
|
||||||
|
ticketsDeepWhere,
|
||||||
|
} from './data-access';
|
||||||
|
|
||||||
export type ScopeVisibility = {
|
export type ScopeVisibility = {
|
||||||
dataVisibility: DataVisibility;
|
dataVisibility: DataVisibility;
|
||||||
@@ -20,6 +34,17 @@ export type Actor = {
|
|||||||
visibleUserIds?: string[] | null;
|
visibleUserIds?: string[] | null;
|
||||||
/** سطح دید تفکیکی هر بخش (از Membership.dataVisibilityScopes) */
|
/** سطح دید تفکیکی هر بخش (از Membership.dataVisibilityScopes) */
|
||||||
scopes?: Partial<Record<DataScope, ScopeVisibility>>;
|
scopes?: Partial<Record<DataScope, ScopeVisibility>>;
|
||||||
|
/** عمق دادهٔ بخشها (resolve شده از نقش + override) */
|
||||||
|
customersAccess?: SectionDataAccess;
|
||||||
|
leadsAccess?: SectionDataAccess;
|
||||||
|
salesAccess?: SectionDataAccess;
|
||||||
|
activitiesAccess?: SectionDataAccess;
|
||||||
|
notesAccess?: SectionDataAccess;
|
||||||
|
routinesAccess?: SectionDataAccess;
|
||||||
|
projectsAccess?: SectionDataAccess;
|
||||||
|
ticketsAccess?: SectionDataAccess;
|
||||||
|
/** همتیمیها برای قوانین owners:team (شامل خود کاربر) */
|
||||||
|
teamPeerIds?: string[] | null;
|
||||||
/** off = فیلتر خاموش؛ current = کار جاری؛ archive = بایگانی اطلاعات قبل */
|
/** off = فیلتر خاموش؛ current = کار جاری؛ archive = بایگانی اطلاعات قبل */
|
||||||
priorLens?: 'off' | 'current' | 'archive';
|
priorLens?: 'off' | 'current' | 'archive';
|
||||||
/** لحظهٔ روشن شدن ماژول برای این محیط؛ فعالیتهای ساختهشده قبل از آن میتوانند قبلی باشند */
|
/** لحظهٔ روشن شدن ماژول برای این محیط؛ فعالیتهای ساختهشده قبل از آن میتوانند قبلی باشند */
|
||||||
@@ -27,6 +52,8 @@ export type Actor = {
|
|||||||
/** مالک واقعی وقتی در حالت مشاهده بهجای کاربر دیگر است */
|
/** مالک واقعی وقتی در حالت مشاهده بهجای کاربر دیگر است */
|
||||||
impersonatorUserId?: string;
|
impersonatorUserId?: string;
|
||||||
impersonatorRole?: string;
|
impersonatorRole?: string;
|
||||||
|
/** شروع این نشست؛ برای ساعت خروج روزانه */
|
||||||
|
sessionStartedAt?: Date;
|
||||||
/** در حالت مشاهده، سرور تغییر را رد میکند */
|
/** در حالت مشاهده، سرور تغییر را رد میکند */
|
||||||
viewAsReadOnly?: boolean;
|
viewAsReadOnly?: boolean;
|
||||||
};
|
};
|
||||||
@@ -91,10 +118,13 @@ export function ownerWhere(
|
|||||||
return { ownerId: { in: ids } };
|
return { ownerId: { in: ids } };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** معامله: مالک اصلی یا یکی از مسئولان مشترک در محدوده دید */
|
/** معامله: عمق دادهٔ sales (JWT) یا مدل قدیمی own/team/org */
|
||||||
export function dealAccessWhere(
|
export function dealAccessWhere(
|
||||||
actor: ScopedActor,
|
actor: ScopedActor & Pick<Actor, 'salesAccess' | 'teamPeerIds'>,
|
||||||
): Prisma.DealWhereInput {
|
): Prisma.DealWhereInput {
|
||||||
|
if (actor.salesAccess !== undefined) {
|
||||||
|
return dealsDeepWhere(actor as Actor);
|
||||||
|
}
|
||||||
const ids = scopedOwnerIds(actor, 'deals');
|
const ids = scopedOwnerIds(actor, 'deals');
|
||||||
if (!ids) return {};
|
if (!ids) return {};
|
||||||
return {
|
return {
|
||||||
@@ -105,10 +135,13 @@ export function dealAccessWhere(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** کارت پروژه: مالک یا یکی از مسئولان در محدوده دید */
|
/** کارت پروژه: عمق داده یا مدل قدیمی own/team/org */
|
||||||
export function projectCardAccessWhere(
|
export function projectCardAccessWhere(
|
||||||
actor: ScopedActor,
|
actor: ScopedActor & Pick<Actor, 'projectsAccess' | 'teamPeerIds'>,
|
||||||
): Prisma.ProjectCardWhereInput {
|
): Prisma.ProjectCardWhereInput {
|
||||||
|
if (actor.projectsAccess !== undefined) {
|
||||||
|
return projectsDeepWhere(actor as Actor);
|
||||||
|
}
|
||||||
const ids = scopedOwnerIds(actor, 'projects');
|
const ids = scopedOwnerIds(actor, 'projects');
|
||||||
if (!ids) return {};
|
if (!ids) return {};
|
||||||
return {
|
return {
|
||||||
@@ -133,10 +166,13 @@ export function assigneeWhere(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** دسترسی به فعالیت: مسئولان یا شرکتکنندگان (فیلد شرکتکنندگان) در محدوده دید */
|
/** دسترسی به فعالیت: عمق دادهٔ activities (JWT) یا مدل قدیمی */
|
||||||
export function activityAccessWhere(
|
export function activityAccessWhere(
|
||||||
actor: ScopedActor,
|
actor: ScopedActor & Pick<Actor, 'activitiesAccess' | 'teamPeerIds'>,
|
||||||
): Prisma.ActivityWhereInput {
|
): Prisma.ActivityWhereInput {
|
||||||
|
if (actor.activitiesAccess !== undefined) {
|
||||||
|
return activitiesDeepWhere(actor as Actor);
|
||||||
|
}
|
||||||
const ids = scopedOwnerIds(actor, 'activities');
|
const ids = scopedOwnerIds(actor, 'activities');
|
||||||
if (!ids) return {};
|
if (!ids) return {};
|
||||||
return {
|
return {
|
||||||
@@ -148,10 +184,13 @@ export function activityAccessWhere(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** یادداشت: نویسنده یا پروندههای تحت مالکیت در محدوده دید + محرمانگی شخصی */
|
/** یادداشت: عمق داده یا مدل قدیمی + محرمانگی شخصی */
|
||||||
export function noteAccessWhere(
|
export function noteAccessWhere(
|
||||||
actor: ScopedActor,
|
actor: ScopedActor & Pick<Actor, 'notesAccess' | 'teamPeerIds'>,
|
||||||
): Prisma.NoteWhereInput {
|
): Prisma.NoteWhereInput {
|
||||||
|
if (actor.notesAccess !== undefined) {
|
||||||
|
return notesDeepWhere(actor as Actor);
|
||||||
|
}
|
||||||
const ids = scopedOwnerIds(actor, 'notes');
|
const ids = scopedOwnerIds(actor, 'notes');
|
||||||
const personalGate: Prisma.NoteWhereInput = {
|
const personalGate: Prisma.NoteWhereInput = {
|
||||||
OR: [
|
OR: [
|
||||||
@@ -186,17 +225,22 @@ export function noteAccessWhere(
|
|||||||
export function resolveOwnerId(
|
export function resolveOwnerId(
|
||||||
actor: Pick<Actor, 'userId' | 'role'>,
|
actor: Pick<Actor, 'userId' | 'role'>,
|
||||||
requested?: string | null,
|
requested?: string | null,
|
||||||
|
allowOthers?: boolean,
|
||||||
): string {
|
): string {
|
||||||
if (elevated(actor.role) && requested?.trim()) {
|
const allowed = allowOthers ?? elevated(actor.role);
|
||||||
|
if (allowed && requested?.trim()) {
|
||||||
return requested.trim();
|
return requested.trim();
|
||||||
}
|
}
|
||||||
return actor.userId;
|
return actor.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** تیکت: مسئول یا ثبتکننده در محدوده دید */
|
/** تیکت: عمق داده یا مدل قدیمی */
|
||||||
export function ticketAccessWhere(
|
export function ticketAccessWhere(
|
||||||
actor: ScopedActor,
|
actor: ScopedActor & Pick<Actor, 'ticketsAccess' | 'teamPeerIds'>,
|
||||||
): Prisma.TicketWhereInput {
|
): Prisma.TicketWhereInput {
|
||||||
|
if (actor.ticketsAccess !== undefined) {
|
||||||
|
return ticketsDeepWhere(actor as Actor);
|
||||||
|
}
|
||||||
const ids = scopedOwnerIds(actor, 'tickets');
|
const ids = scopedOwnerIds(actor, 'tickets');
|
||||||
if (!ids) return {};
|
if (!ids) return {};
|
||||||
return {
|
return {
|
||||||
@@ -204,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 {
|
export function canReassignOwner(role: string | null | undefined): boolean {
|
||||||
return elevated(role);
|
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;
|
return (parts.length === 1 ? parts[0] : { AND: parts }) as unknown as W;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** پارامتر چندتایی کوئری: «a,b,c» → ['a','b','c'] */
|
||||||
|
export function splitCsv(raw: string | null | undefined, max = 50): string[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
String(raw)
|
||||||
|
.split(',')
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
].slice(0, max);
|
||||||
|
}
|
||||||
|
|
||||||
/** کمک برای فیلدهای متنی ساده: `{ field: { contains, mode: 'insensitive' } }` */
|
/** کمک برای فیلدهای متنی ساده: `{ field: { contains, mode: 'insensitive' } }` */
|
||||||
export function ci(term: string) {
|
export function ci(term: string) {
|
||||||
return { contains: term, mode: 'insensitive' as const };
|
return { contains: term, mode: 'insensitive' as const };
|
||||||
|
|||||||
@@ -10,8 +10,19 @@ const SMS_0098_SOAP_URL = 'https://webservice.0098sms.com/service.asmx';
|
|||||||
|
|
||||||
export type SmsSendResult = {
|
export type SmsSendResult = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
transport: 'trez' | 'soap' | 'link';
|
blocked?: boolean;
|
||||||
|
transport: 'trez' | 'soap' | 'link' | 'none';
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
parts?: number;
|
||||||
|
costRials?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SmsTransportOverride = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
sender: string;
|
||||||
|
/** trez | 0098 — default 0098 for own panels */
|
||||||
|
provider?: 'trez' | '0098';
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -50,17 +61,29 @@ export class SmsService {
|
|||||||
return result.ok;
|
return result.ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendSmsDetailed(phone: string, text: string): Promise<SmsSendResult> {
|
buildOtpText(code: string): string {
|
||||||
const username = this.config.get<string>('SMS_USERNAME');
|
const template =
|
||||||
const password = this.getPassword();
|
this.config.get<string>('SMS_OTP_TEMPLATE')?.trim() ||
|
||||||
const sender = this.config.get<string>('SMS_SENDER');
|
`کد ورود منتکت: {code}`;
|
||||||
|
return template.replace('{code}', code);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendSmsDetailed(
|
||||||
|
phone: string,
|
||||||
|
text: string,
|
||||||
|
override?: SmsTransportOverride,
|
||||||
|
): Promise<SmsSendResult> {
|
||||||
|
const username = override?.username || this.config.get<string>('SMS_USERNAME');
|
||||||
|
const password = override?.password || this.getPassword();
|
||||||
|
const sender = override?.sender || this.config.get<string>('SMS_SENDER');
|
||||||
|
|
||||||
if (!username || !password || !sender) {
|
if (!username || !password || !sender) {
|
||||||
return { ok: false, transport: 'trez', reason: 'SMS not configured' };
|
return { ok: false, transport: 'none', reason: 'SMS not configured' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const to = this.normalizePhoneForSms(phone);
|
const to = this.normalizePhoneForSms(phone);
|
||||||
const provider = this.resolveProvider();
|
const provider =
|
||||||
|
override?.provider ?? this.resolveProvider();
|
||||||
|
|
||||||
if (provider === 'trez') {
|
if (provider === 'trez') {
|
||||||
return this.sendViaTrez(username, password, sender, to, text);
|
return this.sendViaTrez(username, password, sender, to, text);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
|||||||
import { memoryStorage } from 'multer';
|
import { memoryStorage } from 'multer';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
import { CompaniesService } from './companies.service';
|
import { CompaniesService } from './companies.service';
|
||||||
@@ -31,7 +32,8 @@ const excelUpload = FileInterceptor('file', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@Controller('companies')
|
@Controller('companies')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
|
||||||
|
@RequirePermission('customers')
|
||||||
export class CompaniesController {
|
export class CompaniesController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly companiesService: CompaniesService,
|
private readonly companiesService: CompaniesService,
|
||||||
@@ -45,9 +47,15 @@ export class CompaniesController {
|
|||||||
query.q,
|
query.q,
|
||||||
query.page ?? 1,
|
query.page ?? 1,
|
||||||
query.limit ?? 20,
|
query.limit ?? 20,
|
||||||
|
{ groupId: query.groupId },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('group-stats')
|
||||||
|
groupStats(@CurrentUser() user: Actor) {
|
||||||
|
return this.companiesService.groupStats(user);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('export')
|
@Get('export')
|
||||||
async export(
|
async export(
|
||||||
@CurrentUser() user: Actor,
|
@CurrentUser() user: Actor,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
CompanyDto,
|
CompanyDto,
|
||||||
CreateCompanyRequest,
|
CreateCompanyRequest,
|
||||||
|
CustomerGroupDto,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
UpdateCompanyRequest,
|
UpdateCompanyRequest,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
@@ -16,17 +17,21 @@ import {
|
|||||||
asCustomFields,
|
asCustomFields,
|
||||||
toJsonCustomFields,
|
toJsonCustomFields,
|
||||||
} from '../common/custom-fields';
|
} from '../common/custom-fields';
|
||||||
|
import { customersCompanyWhere } from '../common/data-access';
|
||||||
import {
|
import {
|
||||||
type Actor,
|
type Actor,
|
||||||
canReassignOwner,
|
|
||||||
elevated,
|
elevated,
|
||||||
ownerWhere,
|
|
||||||
resolveOwnerId,
|
resolveOwnerId,
|
||||||
} from '../common/record-scope';
|
} from '../common/record-scope';
|
||||||
|
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||||
import { normalizeTags } from '../common/tags';
|
import { normalizeTags } from '../common/tags';
|
||||||
import { normalizePersonImportSource } from './person-source';
|
import { normalizePersonImportSource } from './person-source';
|
||||||
import { buildSearchWhere, ci } from '../common/search';
|
import { buildSearchWhere, ci, splitCsv } from '../common/search';
|
||||||
import { GamificationService } from '../gamification/gamification.service';
|
import { GamificationService } from '../gamification/gamification.service';
|
||||||
|
import {
|
||||||
|
CustomerGroupsService,
|
||||||
|
groupSummarySelect,
|
||||||
|
} from '../customer-groups/customer-groups.service';
|
||||||
|
|
||||||
function parseDateOrNull(raw?: string | null): Date | null {
|
function parseDateOrNull(raw?: string | null): Date | null {
|
||||||
if (!raw?.trim()) return null;
|
if (!raw?.trim()) return null;
|
||||||
@@ -37,6 +42,10 @@ function parseDateOrNull(raw?: string | null): Date | null {
|
|||||||
const companyInclude = {
|
const companyInclude = {
|
||||||
_count: { select: { people: true } },
|
_count: { select: { people: true } },
|
||||||
owner: { select: { id: true, fullName: true } },
|
owner: { select: { id: true, fullName: true } },
|
||||||
|
groupLinks: {
|
||||||
|
select: { group: { select: groupSummarySelect } },
|
||||||
|
orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }],
|
||||||
|
},
|
||||||
} satisfies Prisma.CompanyInclude;
|
} satisfies Prisma.CompanyInclude;
|
||||||
|
|
||||||
type CompanyRow = {
|
type CompanyRow = {
|
||||||
@@ -67,6 +76,7 @@ type CompanyRow = {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
_count: { people: number };
|
_count: { people: number };
|
||||||
owner: { id: string; fullName: string } | null;
|
owner: { id: string; fullName: string } | null;
|
||||||
|
groupLinks: Array<{ group: { id: string; name: string; color: string } }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -74,6 +84,7 @@ export class CompaniesService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly game: GamificationService,
|
private readonly game: GamificationService,
|
||||||
|
private readonly groups: CustomerGroupsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
@@ -81,6 +92,7 @@ export class CompaniesService {
|
|||||||
q?: string,
|
q?: string,
|
||||||
page = 1,
|
page = 1,
|
||||||
limit = 20,
|
limit = 20,
|
||||||
|
opts?: { groupId?: string },
|
||||||
): Promise<PaginatedResponse<CompanyDto>> {
|
): Promise<PaginatedResponse<CompanyDto>> {
|
||||||
const search = buildSearchWhere<Prisma.CompanyWhereInput>(q, (t) => [
|
const search = buildSearchWhere<Prisma.CompanyWhereInput>(q, (t) => [
|
||||||
{ name: ci(t) },
|
{ name: ci(t) },
|
||||||
@@ -94,10 +106,15 @@ export class CompaniesService {
|
|||||||
{ city: ci(t) },
|
{ city: ci(t) },
|
||||||
{ province: ci(t) },
|
{ province: ci(t) },
|
||||||
]);
|
]);
|
||||||
|
const groupIds = splitCsv(opts?.groupId);
|
||||||
|
const and: Prisma.CompanyWhereInput[] = [
|
||||||
|
...(search ? [search] : []),
|
||||||
|
...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []),
|
||||||
|
];
|
||||||
const where: Prisma.CompanyWhereInput = {
|
const where: Prisma.CompanyWhereInput = {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
...ownerWhere(actor),
|
...customersCompanyWhere(actor),
|
||||||
...(search ? { AND: [search] } : {}),
|
...(and.length ? { AND: and } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const [rows, total] = await this.prisma.$transaction([
|
const [rows, total] = await this.prisma.$transaction([
|
||||||
@@ -119,9 +136,41 @@ export class CompaniesService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** آمار گروه برای فیلتر شرکتها — فقط شرکتهای در دسترس کاربر */
|
||||||
|
async groupStats(actor: Actor): Promise<CustomerGroupDto[]> {
|
||||||
|
const counts = await this.prisma.companyGroupLink.groupBy({
|
||||||
|
by: ['groupId'],
|
||||||
|
where: {
|
||||||
|
company: {
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
...customersCompanyWhere(actor),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
if (!counts.length) return [];
|
||||||
|
const countMap = new Map(counts.map((c) => [c.groupId, c._count._all]));
|
||||||
|
const groups = await this.prisma.customerGroup.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
id: { in: counts.map((c) => c.groupId) },
|
||||||
|
},
|
||||||
|
orderBy: [{ position: 'asc' }, { name: 'asc' }],
|
||||||
|
});
|
||||||
|
return groups.map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
name: g.name,
|
||||||
|
color: g.color,
|
||||||
|
position: g.position,
|
||||||
|
fromTahesab: Boolean(g.tahesabGid),
|
||||||
|
personCount: 0,
|
||||||
|
companyCount: countMap.get(g.id) ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async get(actor: Actor, id: string): Promise<CompanyDto> {
|
async get(actor: Actor, id: string): Promise<CompanyDto> {
|
||||||
const row = await this.prisma.company.findFirst({
|
const row = await this.prisma.company.findFirst({
|
||||||
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
|
where: { id, organizationId: actor.organizationId, ...customersCompanyWhere(actor) },
|
||||||
include: companyInclude,
|
include: companyInclude,
|
||||||
});
|
});
|
||||||
if (!row) throw new NotFoundException('شرکت پیدا نشد');
|
if (!row) throw new NotFoundException('شرکت پیدا نشد');
|
||||||
@@ -152,15 +201,27 @@ export class CompaniesService {
|
|||||||
notes: emptyToNull(dto.notes),
|
notes: emptyToNull(dto.notes),
|
||||||
registeredAt: parseDateOrNull(dto.registeredAt),
|
registeredAt: parseDateOrNull(dto.registeredAt),
|
||||||
tags: normalizeTags(dto.tags),
|
tags: normalizeTags(dto.tags),
|
||||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
ownerId: resolveOwnerId(
|
||||||
|
actor,
|
||||||
|
dto.ownerId,
|
||||||
|
await actorCanAssignToOthers(this.prisma, actor, 'customers'),
|
||||||
|
),
|
||||||
customFields: toJsonCustomFields(dto.customFields),
|
customFields: toJsonCustomFields(dto.customFields),
|
||||||
};
|
};
|
||||||
await this.assertNoDuplicate(actor.organizationId, data);
|
await this.assertNoDuplicate(actor.organizationId, data);
|
||||||
|
const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds);
|
||||||
|
|
||||||
const row = await this.prisma.company.create({
|
let row = await this.prisma.company.create({
|
||||||
data,
|
data,
|
||||||
include: companyInclude,
|
include: companyInclude,
|
||||||
});
|
});
|
||||||
|
if (groupIds.length) {
|
||||||
|
await this.groups.addCompanyGroups(row.id, groupIds);
|
||||||
|
row = await this.prisma.company.findUniqueOrThrow({
|
||||||
|
where: { id: row.id },
|
||||||
|
include: companyInclude,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (row.importSource === 'manual') {
|
if (row.importSource === 'manual') {
|
||||||
this.game.emit({
|
this.game.emit({
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
@@ -209,11 +270,21 @@ export class CompaniesService {
|
|||||||
if ('customFields' in dto) {
|
if ('customFields' in dto) {
|
||||||
data.customFields = toJsonCustomFields(dto.customFields);
|
data.customFields = toJsonCustomFields(dto.customFields);
|
||||||
}
|
}
|
||||||
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
|
if (dto.ownerId !== undefined) {
|
||||||
data.ownerId = resolveOwnerId(actor, dto.ownerId);
|
const current = await this.prisma.company.findFirst({
|
||||||
|
where: { id, organizationId: actor.organizationId },
|
||||||
|
select: { ownerId: true },
|
||||||
|
});
|
||||||
|
const nextOwner = emptyToNull(dto.ownerId ?? null);
|
||||||
|
await assertAssigneeSet(this.prisma, actor, 'customers', [nextOwner], [current?.ownerId]);
|
||||||
|
data.ownerId = nextOwner ?? actor.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.assertNoDuplicate(actor.organizationId, data, id);
|
await this.assertNoDuplicate(actor.organizationId, data, id);
|
||||||
|
if (dto.groupIds !== undefined) {
|
||||||
|
const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds);
|
||||||
|
await this.groups.setCompanyGroups(id, groupIds);
|
||||||
|
}
|
||||||
const row = await this.prisma.company.update({
|
const row = await this.prisma.company.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
@@ -315,6 +386,7 @@ export class CompaniesService {
|
|||||||
registeredAt: row.registeredAt ? row.registeredAt.toISOString() : null,
|
registeredAt: row.registeredAt ? row.registeredAt.toISOString() : null,
|
||||||
tags: row.tags ?? [],
|
tags: row.tags ?? [],
|
||||||
customFields: asCustomFields(row.customFields),
|
customFields: asCustomFields(row.customFields),
|
||||||
|
groups: (row.groupLinks ?? []).map((l) => l.group),
|
||||||
peopleCount: row._count.people,
|
peopleCount: row._count.people,
|
||||||
owner: row.owner,
|
owner: row.owner,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { BadRequestException, ConflictException, Injectable } from '@nestjs/comm
|
|||||||
import type { ImportContactsResult, PersonMood } from '@mento/shared';
|
import type { ImportContactsResult, PersonMood } from '@mento/shared';
|
||||||
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@mento/shared';
|
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@mento/shared';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { customersCompanyWhere } from '../common/data-access';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
import { ownerWhere, resolveOwnerId } from '../common/record-scope';
|
import { resolveOwnerId } from '../common/record-scope';
|
||||||
import {
|
import {
|
||||||
collectAddresses,
|
collectAddresses,
|
||||||
} from '../common/custom-fields';
|
} from '../common/custom-fields';
|
||||||
@@ -25,6 +26,10 @@ import { CompaniesService } from './companies.service';
|
|||||||
import { PeopleService } from './people.service';
|
import { PeopleService } from './people.service';
|
||||||
import { normalizeEmail, normalizePhone } from './normalize';
|
import { normalizeEmail, normalizePhone } from './normalize';
|
||||||
import { normalizePersonImportSource } from './person-source';
|
import { normalizePersonImportSource } from './person-source';
|
||||||
|
import {
|
||||||
|
CustomerGroupsService,
|
||||||
|
splitGroupNames,
|
||||||
|
} from '../customer-groups/customer-groups.service';
|
||||||
|
|
||||||
type ImportChunkOpts = {
|
type ImportChunkOpts = {
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
@@ -39,6 +44,16 @@ function parseMood(raw?: string): PersonMood | undefined {
|
|||||||
return PERSON_MOODS.find((mood) => PERSON_MOOD_LABELS[mood] === value);
|
return PERSON_MOODS.find((mood) => PERSON_MOOD_LABELS[mood] === value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ستونهای گروه اکسل → نام گروهها (برچسبهای وضعیت مشتری گروه حساب نمیشوند) */
|
||||||
|
function groupNamesFrom(...raws: Array<string | undefined>): string[] {
|
||||||
|
const names = raws.flatMap((raw) => splitGroupNames(raw));
|
||||||
|
return [...new Set(names.filter((name) => !parseMood(name)))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupNamesText(groups: Array<{ name: string }> | undefined, fallback?: string) {
|
||||||
|
return groups?.length ? groups.map((g) => g.name).join('، ') : (fallback ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
function splitDidarName(firstName: string, lastName: string, title?: string) {
|
function splitDidarName(firstName: string, lastName: string, title?: string) {
|
||||||
const first = firstName.trim();
|
const first = firstName.trim();
|
||||||
const last = lastName.trim();
|
const last = lastName.trim();
|
||||||
@@ -369,6 +384,7 @@ export class ContactsExcelService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly people: PeopleService,
|
private readonly people: PeopleService,
|
||||||
private readonly companies: CompaniesService,
|
private readonly companies: CompaniesService,
|
||||||
|
private readonly groups: CustomerGroupsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async exportPeople(actor: Actor, q?: string): Promise<Buffer> {
|
async exportPeople(actor: Actor, q?: string): Promise<Buffer> {
|
||||||
@@ -378,6 +394,7 @@ export class ContactsExcelService {
|
|||||||
CONTACT_COLUMNS,
|
CONTACT_COLUMNS,
|
||||||
items.map((p) => {
|
items.map((p) => {
|
||||||
const extra = (p.customFields ?? {}) as Record<string, string>;
|
const extra = (p.customFields ?? {}) as Record<string, string>;
|
||||||
|
const groupText = groupNamesText(p.groups, extra.group);
|
||||||
return {
|
return {
|
||||||
_type: 'Contact',
|
_type: 'Contact',
|
||||||
didarId: extra.didarId ?? p.id,
|
didarId: extra.didarId ?? p.id,
|
||||||
@@ -402,7 +419,7 @@ export class ContactsExcelService {
|
|||||||
nationalId: extra.nationalId ?? '',
|
nationalId: extra.nationalId ?? '',
|
||||||
registeredAt: formatJalaliDate(p.createdAt),
|
registeredAt: formatJalaliDate(p.createdAt),
|
||||||
birthDate: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
|
birthDate: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
|
||||||
mood: extra.group || PERSON_MOOD_LABELS[p.mood || 'newcomer'],
|
mood: PERSON_MOOD_LABELS[p.mood || 'newcomer'],
|
||||||
otherPhones: extra.otherPhones ?? '',
|
otherPhones: extra.otherPhones ?? '',
|
||||||
address: p.addresses[0] ?? extra.address ?? '',
|
address: p.addresses[0] ?? extra.address ?? '',
|
||||||
website: p.website ?? extra.website ?? '',
|
website: p.website ?? extra.website ?? '',
|
||||||
@@ -410,7 +427,7 @@ export class ContactsExcelService {
|
|||||||
position2: p.position ?? extra.position ?? '',
|
position2: p.position ?? extra.position ?? '',
|
||||||
city2: extra.city ?? '',
|
city2: extra.city ?? '',
|
||||||
code2: extra.customerCode ?? '',
|
code2: extra.customerCode ?? '',
|
||||||
group2: extra.group ?? '',
|
group2: groupText,
|
||||||
address2: p.addresses[1] ?? extra.address ?? '',
|
address2: p.addresses[1] ?? extra.address ?? '',
|
||||||
phone2: extra.phone2 ?? p.phone ?? '',
|
phone2: extra.phone2 ?? p.phone ?? '',
|
||||||
postalCode2: extra.postalCode ?? '',
|
postalCode2: extra.postalCode ?? '',
|
||||||
@@ -418,7 +435,7 @@ export class ContactsExcelService {
|
|||||||
homeAddress: p.addresses[2] ?? extra.homeAddress ?? '',
|
homeAddress: p.addresses[2] ?? extra.homeAddress ?? '',
|
||||||
birthDate2: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
|
birthDate2: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
|
||||||
instagram: p.instagram ?? extra.instagram ?? '',
|
instagram: p.instagram ?? extra.instagram ?? '',
|
||||||
group: extra.group ?? '',
|
group: groupText,
|
||||||
colleagueContact: extra.colleagueContact ?? '',
|
colleagueContact: extra.colleagueContact ?? '',
|
||||||
previousCompany: extra.previousCompany ?? '',
|
previousCompany: extra.previousCompany ?? '',
|
||||||
customerType: extra.customerType ?? '',
|
customerType: extra.customerType ?? '',
|
||||||
@@ -456,7 +473,7 @@ export class ContactsExcelService {
|
|||||||
registeredAt: formatJalaliDate(
|
registeredAt: formatJalaliDate(
|
||||||
c.registeredAt ? new Date(c.registeredAt) : new Date(c.createdAt),
|
c.registeredAt ? new Date(c.registeredAt) : new Date(c.createdAt),
|
||||||
),
|
),
|
||||||
groupName: c.groupName ?? '',
|
groupName: groupNamesText(c.groups, c.groupName ?? ''),
|
||||||
otherPhones: c.otherPhones ?? '',
|
otherPhones: c.otherPhones ?? '',
|
||||||
address: c.address ?? '',
|
address: c.address ?? '',
|
||||||
website: c.website ?? '',
|
website: c.website ?? '',
|
||||||
@@ -551,6 +568,8 @@ export class ContactsExcelService {
|
|||||||
const value = row[key]?.trim();
|
const value = row[key]?.trim();
|
||||||
if (value) customFields[key] = value;
|
if (value) customFields[key] = value;
|
||||||
}
|
}
|
||||||
|
const groupNames = groupNamesFrom(row.group, parseMood(row.mood) ? undefined : row.mood);
|
||||||
|
if (groupNames.length) delete customFields.group;
|
||||||
|
|
||||||
const addresses = collectAddresses(
|
const addresses = collectAddresses(
|
||||||
row.address,
|
row.address,
|
||||||
@@ -576,6 +595,7 @@ export class ContactsExcelService {
|
|||||||
? normalizePersonImportSource(sourceCol)
|
? normalizePersonImportSource(sourceCol)
|
||||||
: 'excel';
|
: 'excel';
|
||||||
const sourceRef = didarId || customerCode || null;
|
const sourceRef = didarId || customerCode || null;
|
||||||
|
const groupIds = await this.groups.idsForNames(actor.organizationId, groupNames);
|
||||||
|
|
||||||
await this.people.create(actor, {
|
await this.people.create(actor, {
|
||||||
firstName: names.firstName,
|
firstName: names.firstName,
|
||||||
@@ -596,6 +616,7 @@ export class ContactsExcelService {
|
|||||||
importSource,
|
importSource,
|
||||||
sourceRef,
|
sourceRef,
|
||||||
registeredAt: registeredAt?.toISOString() ?? null,
|
registeredAt: registeredAt?.toISOString() ?? null,
|
||||||
|
groupIds,
|
||||||
});
|
});
|
||||||
result.created += 1;
|
result.created += 1;
|
||||||
if (ownerMissing) {
|
if (ownerMissing) {
|
||||||
@@ -701,8 +722,13 @@ export class ContactsExcelService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const groupIds = await this.groups.idsForNames(
|
||||||
|
actor.organizationId,
|
||||||
|
groupNamesFrom(row.groupName),
|
||||||
|
);
|
||||||
const existing = await this.findImportedCompany(actor, didarId, name);
|
const existing = await this.findImportedCompany(actor, didarId, name);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
if (groupIds.length) await this.groups.addCompanyGroups(existing.id, groupIds);
|
||||||
const merged: Record<string, unknown> = {};
|
const merged: Record<string, unknown> = {};
|
||||||
for (const [k, v] of Object.entries(payload)) {
|
for (const [k, v] of Object.entries(payload)) {
|
||||||
if (k === 'customFields') continue;
|
if (k === 'customFields') continue;
|
||||||
@@ -719,6 +745,7 @@ export class ContactsExcelService {
|
|||||||
await this.companies.create(actor, {
|
await this.companies.create(actor, {
|
||||||
...payload,
|
...payload,
|
||||||
ownerId: resolveOwnerId(actor, ownerId ?? null),
|
ownerId: resolveOwnerId(actor, ownerId ?? null),
|
||||||
|
groupIds,
|
||||||
});
|
});
|
||||||
result.created += 1;
|
result.created += 1;
|
||||||
}
|
}
|
||||||
@@ -750,7 +777,7 @@ export class ContactsExcelService {
|
|||||||
where: {
|
where: {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
sourceRef: didarId,
|
sourceRef: didarId,
|
||||||
...ownerWhere(actor),
|
...customersCompanyWhere(actor),
|
||||||
},
|
},
|
||||||
select: { id: true, customFields: true },
|
select: { id: true, customFields: true },
|
||||||
});
|
});
|
||||||
@@ -761,7 +788,7 @@ export class ContactsExcelService {
|
|||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
name: { equals: name, mode: 'insensitive' },
|
name: { equals: name, mode: 'insensitive' },
|
||||||
...(didarId ? { sourceRef: null } : {}),
|
...(didarId ? { sourceRef: null } : {}),
|
||||||
...ownerWhere(actor),
|
...customersCompanyWhere(actor),
|
||||||
},
|
},
|
||||||
select: { id: true, customFields: true },
|
select: { id: true, customFields: true },
|
||||||
});
|
});
|
||||||
@@ -779,7 +806,7 @@ export class ContactsExcelService {
|
|||||||
where: {
|
where: {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
sourceRef: ref,
|
sourceRef: ref,
|
||||||
...ownerWhere(actor),
|
...customersCompanyWhere(actor),
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
@@ -789,7 +816,7 @@ export class ContactsExcelService {
|
|||||||
where: {
|
where: {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
name: { equals: name, mode: 'insensitive' },
|
name: { equals: name, mode: 'insensitive' },
|
||||||
...ownerWhere(actor),
|
...customersCompanyWhere(actor),
|
||||||
},
|
},
|
||||||
select: { id: true, sourceRef: true },
|
select: { id: true, sourceRef: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import { CompaniesService } from './companies.service';
|
|||||||
import { PeopleController } from './people.controller';
|
import { PeopleController } from './people.controller';
|
||||||
import { PeopleService } from './people.service';
|
import { PeopleService } from './people.service';
|
||||||
import { ContactsExcelService } from './contacts-excel.service';
|
import { ContactsExcelService } from './contacts-excel.service';
|
||||||
import { SmsService } from '../common/sms.service';
|
import { SameMobileMergeService } from './same-mobile-merge.service';
|
||||||
|
import { BehaviorModule } from '../behavior/behavior.module';
|
||||||
|
import { CustomerGroupsModule } from '../customer-groups/customer-groups.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -16,9 +18,11 @@ import { SmsService } from '../common/sms.service';
|
|||||||
AutomationsModule,
|
AutomationsModule,
|
||||||
MessageAutomationsModule,
|
MessageAutomationsModule,
|
||||||
PublicApiModule,
|
PublicApiModule,
|
||||||
|
BehaviorModule,
|
||||||
|
CustomerGroupsModule,
|
||||||
],
|
],
|
||||||
controllers: [PeopleController, CompaniesController],
|
controllers: [PeopleController, CompaniesController],
|
||||||
providers: [PeopleService, CompaniesService, ContactsExcelService, SmsService],
|
providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService],
|
||||||
exports: [PeopleService, CompaniesService],
|
exports: [PeopleService, CompaniesService],
|
||||||
})
|
})
|
||||||
export class ContactsModule {}
|
export class ContactsModule {}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ArrayMaxSize,
|
ArrayMaxSize,
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
IsEmail,
|
IsEmail,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
@@ -15,7 +16,7 @@ import {
|
|||||||
MinLength,
|
MinLength,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { PERSON_IMPORT_SOURCES, PERSON_MOODS } from '@mento/shared';
|
import { PERSON_GENDERS, PERSON_IMPORT_SOURCES, PERSON_MOODS } from '@mento/shared';
|
||||||
|
|
||||||
export class BirthdaySmsDto {
|
export class BirthdaySmsDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -50,18 +51,20 @@ export class ListQueryDto {
|
|||||||
@Max(100)
|
@Max(100)
|
||||||
limit?: number = 20;
|
limit?: number = 20;
|
||||||
|
|
||||||
|
/** وضعیت مشتری؛ چندتایی با کاما */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn([...PERSON_MOODS])
|
@IsString()
|
||||||
mood?: (typeof PERSON_MOODS)[number];
|
@MaxLength(120)
|
||||||
|
mood?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['name', 'newest'])
|
@IsIn(['name', 'newest'])
|
||||||
sort?: 'name' | 'newest';
|
sort?: 'name' | 'newest';
|
||||||
|
|
||||||
/** فیلتر منبع ورود شخص */
|
/** فیلتر منبع ورود شخص؛ چندتایی با کاما */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(40)
|
@MaxLength(400)
|
||||||
importSource?: string;
|
importSource?: string;
|
||||||
|
|
||||||
/** استان شرکت مرتبط */
|
/** استان شرکت مرتبط */
|
||||||
@@ -80,6 +83,18 @@ export class ListQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['1', 'true', '0', 'false'])
|
@IsIn(['1', 'true', '0', 'false'])
|
||||||
hasMobile?: string;
|
hasMobile?: string;
|
||||||
|
|
||||||
|
/** فیلتر تیپ DISC غالب یا ترکیبی، مثل D یا D/C */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(12)
|
||||||
|
disc?: string;
|
||||||
|
|
||||||
|
/** فیلتر گروه مشتری؛ چندتایی با کاما (هر کدام از گروهها) */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
groupId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class CompanyExtraFieldsDto {
|
class CompanyExtraFieldsDto {
|
||||||
@@ -147,6 +162,11 @@ class CompanyExtraFieldsDto {
|
|||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
@IsString()
|
@IsString()
|
||||||
registeredAt?: string | null;
|
registeredAt?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
groupIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateCompanyDto extends CompanyExtraFieldsDto {
|
export class CreateCompanyDto extends CompanyExtraFieldsDto {
|
||||||
@@ -263,6 +283,14 @@ export class CreatePersonDto {
|
|||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(PERSON_GENDERS)
|
||||||
|
gender?: (typeof PERSON_GENDERS)[number];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
showGenderInName?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -364,6 +392,11 @@ export class CreatePersonDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
registeredAt?: string | null;
|
registeredAt?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
groupIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdatePersonDto {
|
export class UpdatePersonDto {
|
||||||
@@ -377,6 +410,14 @@ export class UpdatePersonDto {
|
|||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(PERSON_GENDERS)
|
||||||
|
gender?: (typeof PERSON_GENDERS)[number];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
showGenderInName?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -472,4 +513,9 @@ export class UpdatePersonDto {
|
|||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
@IsString()
|
@IsString()
|
||||||
ownerId?: string | null;
|
ownerId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
groupIds?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { Prisma, PrismaClient } from '@prisma/client';
|
||||||
|
import { canonicalIranMobile, storedPhoneNumber } from './normalize';
|
||||||
|
|
||||||
|
const personSelect = {
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
gender: true,
|
||||||
|
showGenderInName: true,
|
||||||
|
previousFirstName: true,
|
||||||
|
previousLastName: true,
|
||||||
|
email: true,
|
||||||
|
phone: true,
|
||||||
|
mobile: true,
|
||||||
|
position: true,
|
||||||
|
website: true,
|
||||||
|
instagram: true,
|
||||||
|
birthDate: true,
|
||||||
|
referrer: true,
|
||||||
|
addresses: true,
|
||||||
|
notes: true,
|
||||||
|
tags: true,
|
||||||
|
companyId: true,
|
||||||
|
customFields: true,
|
||||||
|
importSource: true,
|
||||||
|
sourceRef: true,
|
||||||
|
previousImportSource: true,
|
||||||
|
previousSourceRef: true,
|
||||||
|
loyaltyPoints: true,
|
||||||
|
discLabel: true,
|
||||||
|
discReliable: true,
|
||||||
|
discAssessedAt: true,
|
||||||
|
} satisfies Prisma.PersonSelect;
|
||||||
|
|
||||||
|
type PersonRow = Prisma.PersonGetPayload<{ select: typeof personSelect }>;
|
||||||
|
|
||||||
|
function blank(value: string | null | undefined) {
|
||||||
|
return !value?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function asStrings(value: Prisma.JsonValue): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.map((item) => String(item)).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asObject(value: Prisma.JsonValue): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||||
|
return { ...(value as Record<string, unknown>) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergedFields(keeper: PersonRow, extras: PersonRow[], canon: string): Prisma.PersonUncheckedUpdateInput {
|
||||||
|
const notes = [keeper.notes?.trim() || ''];
|
||||||
|
let email = keeper.email;
|
||||||
|
let phone = storedPhoneNumber(keeper.phone);
|
||||||
|
let position = keeper.position;
|
||||||
|
let website = keeper.website;
|
||||||
|
let instagram = keeper.instagram;
|
||||||
|
let birthDate = keeper.birthDate;
|
||||||
|
let referrer = keeper.referrer;
|
||||||
|
let companyId = keeper.companyId;
|
||||||
|
let gender = keeper.gender;
|
||||||
|
let showGenderInName = keeper.showGenderInName;
|
||||||
|
let previousFirstName = keeper.previousFirstName;
|
||||||
|
let previousLastName = keeper.previousLastName;
|
||||||
|
let previousImportSource = keeper.previousImportSource;
|
||||||
|
let previousSourceRef = keeper.previousSourceRef;
|
||||||
|
let discLabel = keeper.discLabel;
|
||||||
|
let discReliable = keeper.discReliable;
|
||||||
|
let discAssessedAt = keeper.discAssessedAt;
|
||||||
|
let loyaltyPoints = keeper.loyaltyPoints;
|
||||||
|
const addresses = asStrings(keeper.addresses);
|
||||||
|
const tags = new Set(keeper.tags);
|
||||||
|
const customFields = asObject(keeper.customFields);
|
||||||
|
|
||||||
|
for (const extra of extras) {
|
||||||
|
const name = `${extra.firstName} ${extra.lastName}`.trim();
|
||||||
|
notes.push(
|
||||||
|
`ادغام شماره تکراری: ${name || 'بدون نام'} (${extra.mobile || 'بدون موبایل'})`,
|
||||||
|
);
|
||||||
|
if (blank(email) && extra.email) email = extra.email;
|
||||||
|
if (blank(phone)) phone = storedPhoneNumber(extra.phone);
|
||||||
|
if (blank(position) && extra.position) position = extra.position;
|
||||||
|
if (blank(website) && extra.website) website = extra.website;
|
||||||
|
if (blank(instagram) && extra.instagram) instagram = extra.instagram;
|
||||||
|
if (!birthDate && extra.birthDate) birthDate = extra.birthDate;
|
||||||
|
if (blank(referrer) && extra.referrer) referrer = extra.referrer;
|
||||||
|
if (!companyId && extra.companyId) companyId = extra.companyId;
|
||||||
|
if (gender === 'none' && extra.gender !== 'none') {
|
||||||
|
gender = extra.gender;
|
||||||
|
showGenderInName = extra.showGenderInName;
|
||||||
|
}
|
||||||
|
if (blank(previousFirstName) && extra.previousFirstName) {
|
||||||
|
previousFirstName = extra.previousFirstName;
|
||||||
|
}
|
||||||
|
if (blank(previousLastName) && extra.previousLastName) {
|
||||||
|
previousLastName = extra.previousLastName;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
blank(previousImportSource) &&
|
||||||
|
extra.importSource &&
|
||||||
|
extra.importSource !== keeper.importSource
|
||||||
|
) {
|
||||||
|
previousImportSource = extra.importSource;
|
||||||
|
previousSourceRef = extra.sourceRef;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
keeper.importSource === 'tahesab' &&
|
||||||
|
blank(previousFirstName) &&
|
||||||
|
blank(previousLastName)
|
||||||
|
) {
|
||||||
|
const extraName = `${extra.firstName} ${extra.lastName}`.trim();
|
||||||
|
const keeperName = `${keeper.firstName} ${keeper.lastName}`.trim();
|
||||||
|
if (extraName && extraName !== keeperName) {
|
||||||
|
previousFirstName = extra.firstName || null;
|
||||||
|
previousLastName = extra.lastName || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!discLabel && extra.discLabel) {
|
||||||
|
discLabel = extra.discLabel;
|
||||||
|
discReliable = extra.discReliable;
|
||||||
|
discAssessedAt = extra.discAssessedAt;
|
||||||
|
}
|
||||||
|
loyaltyPoints = Math.max(loyaltyPoints, extra.loyaltyPoints);
|
||||||
|
for (const address of asStrings(extra.addresses)) {
|
||||||
|
if (!addresses.includes(address)) addresses.push(address);
|
||||||
|
}
|
||||||
|
for (const tag of extra.tags) tags.add(tag);
|
||||||
|
for (const [key, value] of Object.entries(asObject(extra.customFields))) {
|
||||||
|
if (customFields[key] == null || customFields[key] === '') customFields[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mobile: canon,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
position,
|
||||||
|
website,
|
||||||
|
instagram,
|
||||||
|
birthDate,
|
||||||
|
referrer,
|
||||||
|
companyId,
|
||||||
|
gender,
|
||||||
|
showGenderInName,
|
||||||
|
previousFirstName,
|
||||||
|
previousLastName,
|
||||||
|
previousImportSource,
|
||||||
|
previousSourceRef,
|
||||||
|
discLabel,
|
||||||
|
discReliable,
|
||||||
|
discAssessedAt,
|
||||||
|
loyaltyPoints,
|
||||||
|
addresses,
|
||||||
|
tags: [...tags],
|
||||||
|
customFields: customFields as Prisma.InputJsonValue,
|
||||||
|
notes: notes.filter(Boolean).join('\n') || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function personIdTables(prisma: PrismaClient | Prisma.TransactionClient) {
|
||||||
|
const rows = await prisma.$queryRaw<{ table_name: string }[]>`
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND column_name = 'personId'
|
||||||
|
`;
|
||||||
|
return rows
|
||||||
|
.map((row) => row.table_name)
|
||||||
|
.filter((name) => /^[A-Za-z0-9_]+$/.test(name) && name !== 'Person');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveLinks(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
tables: string[],
|
||||||
|
keeperId: string,
|
||||||
|
dupId: string,
|
||||||
|
) {
|
||||||
|
await tx.$executeRaw`
|
||||||
|
DELETE FROM "PersonGroupLink" AS d
|
||||||
|
USING "PersonGroupLink" AS k
|
||||||
|
WHERE d."personId" = ${dupId}
|
||||||
|
AND k."personId" = ${keeperId}
|
||||||
|
AND d."groupId" = k."groupId"
|
||||||
|
`;
|
||||||
|
await tx.$executeRaw`
|
||||||
|
DELETE FROM "PersonAccountingLink" AS d
|
||||||
|
USING "PersonAccountingLink" AS k
|
||||||
|
WHERE d."personId" = ${dupId}
|
||||||
|
AND k."personId" = ${keeperId}
|
||||||
|
AND d."connectionId" = k."connectionId"
|
||||||
|
`;
|
||||||
|
await tx.$executeRaw`
|
||||||
|
DELETE FROM "PartyOpeningBalance"
|
||||||
|
WHERE "personId" = ${dupId}
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM "PartyOpeningBalance" AS k WHERE k."personId" = ${keeperId}
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
for (const table of tables) {
|
||||||
|
await tx.$executeRawUnsafe(
|
||||||
|
`UPDATE "${table}" SET "personId" = $1 WHERE "personId" = $2`,
|
||||||
|
keeperId,
|
||||||
|
dupId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.$executeRaw`
|
||||||
|
UPDATE "Lead" SET "convertedPersonId" = ${keeperId} WHERE "convertedPersonId" = ${dupId}
|
||||||
|
`;
|
||||||
|
await tx.$executeRaw`
|
||||||
|
UPDATE "MontifyLink"
|
||||||
|
SET "entityId" = ${keeperId}
|
||||||
|
WHERE "entityType" = 'customer' AND "entityId" = ${dupId}
|
||||||
|
`;
|
||||||
|
await tx.$executeRaw`
|
||||||
|
UPDATE "Notification"
|
||||||
|
SET "entityId" = ${keeperId}
|
||||||
|
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
|
||||||
|
`;
|
||||||
|
await tx.$executeRaw`
|
||||||
|
UPDATE "GamificationTransaction"
|
||||||
|
SET "entityId" = ${keeperId}
|
||||||
|
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
|
||||||
|
`;
|
||||||
|
await tx.$executeRaw`
|
||||||
|
UPDATE "FinancialAuditLog"
|
||||||
|
SET "entityId" = ${keeperId}
|
||||||
|
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mergeSameMobilePeople(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
organizationId: string,
|
||||||
|
apply: boolean,
|
||||||
|
) {
|
||||||
|
const people = await prisma.person.findMany({
|
||||||
|
where: { organizationId },
|
||||||
|
select: personSelect,
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
const groups = new Map<string, PersonRow[]>();
|
||||||
|
for (const person of people) {
|
||||||
|
const key = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
|
||||||
|
if (!key) continue;
|
||||||
|
const list = groups.get(key) ?? [];
|
||||||
|
list.push(person);
|
||||||
|
groups.set(key, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateGroups = [...groups.entries()].filter(([, list]) => list.length > 1);
|
||||||
|
const preview = duplicateGroups.map(([mobile, list]) => ({
|
||||||
|
mobile,
|
||||||
|
people: list.map((person) => ({
|
||||||
|
id: person.id,
|
||||||
|
stored: person.mobile,
|
||||||
|
name: `${person.firstName} ${person.lastName}`.trim(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!apply) {
|
||||||
|
const needsRewrite = people.filter((person) => {
|
||||||
|
const canon = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
|
||||||
|
return Boolean(canon && (person.mobile !== canon || canonicalIranMobile(person.phone)));
|
||||||
|
}).length;
|
||||||
|
return { merged: 0, rewritten: 0, needsRewrite, failed: 0, groups: preview };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tables = await personIdTables(prisma);
|
||||||
|
let merged = 0;
|
||||||
|
let rewritten = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const [canon, list] of groups) {
|
||||||
|
const ordered = [...list].sort((a, b) => {
|
||||||
|
const aTahesab = a.importSource === 'tahesab' ? 0 : 1;
|
||||||
|
const bTahesab = b.importSource === 'tahesab' ? 0 : 1;
|
||||||
|
if (aTahesab !== bTahesab) return aTahesab - bTahesab;
|
||||||
|
return a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id);
|
||||||
|
});
|
||||||
|
const [keeper, ...extras] = ordered;
|
||||||
|
if (!keeper) continue;
|
||||||
|
if (!extras.length) {
|
||||||
|
const phoneCanon = canonicalIranMobile(keeper.phone);
|
||||||
|
const data: Prisma.PersonUpdateInput = {};
|
||||||
|
if (keeper.mobile !== canon) data.mobile = canon;
|
||||||
|
if (phoneCanon && keeper.phone !== phoneCanon) data.phone = phoneCanon;
|
||||||
|
if (Object.keys(data).length) {
|
||||||
|
await prisma.person.update({ where: { id: keeper.id }, data });
|
||||||
|
rewritten += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
await tx.person.update({
|
||||||
|
where: { id: keeper.id },
|
||||||
|
data: mergedFields(keeper, extras, canon),
|
||||||
|
});
|
||||||
|
for (const extra of extras) {
|
||||||
|
await moveLinks(tx, tables, keeper.id, extra.id);
|
||||||
|
await tx.person.delete({ where: { id: extra.id } });
|
||||||
|
merged += 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
failed += 1;
|
||||||
|
console.error(
|
||||||
|
`merge mobile ${canon} failed`,
|
||||||
|
error instanceof Error ? error.message : error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { merged, rewritten, needsRewrite: 0, failed, groups: preview };
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEnv(file: string) {
|
||||||
|
const text = readFileSync(file, 'utf8');
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
const match = line.match(/^([A-Za-z0-9_]+)=(.*)$/);
|
||||||
|
if (!match || process.env[match[1]]) continue;
|
||||||
|
let value = match[2].trim();
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
process.env[match[1]] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const direct = process.argv[1]?.replace(/\\/g, '/').includes('merge-same-mobile');
|
||||||
|
if (direct) {
|
||||||
|
loadEnv(resolve(__dirname, '../../.env'));
|
||||||
|
const orgId = process.argv.find((arg) => arg.startsWith('--org='))?.slice(6);
|
||||||
|
const apply = process.argv.includes('--apply');
|
||||||
|
if (!orgId) {
|
||||||
|
console.error('org id required: --org=<id>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
mergeSameMobilePeople(prisma, orgId, apply)
|
||||||
|
.then((result) => {
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
apply,
|
||||||
|
merged: result.merged,
|
||||||
|
rewritten: result.rewritten,
|
||||||
|
needsRewrite: result.needsRewrite,
|
||||||
|
groupCount: result.groups.length,
|
||||||
|
groups: result.groups,
|
||||||
|
}, null, 2));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
|
}
|
||||||
@@ -9,11 +9,54 @@ export function normalizeEmail(value?: string | null): string | null {
|
|||||||
return v ? v.toLowerCase() : null;
|
return v ? v.toLowerCase() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
||||||
|
const AR_DIGITS = '٠١٢٣٤٥٦٧٨٩';
|
||||||
|
|
||||||
|
/** ارقام فارسی و عربی را به لاتین برمیگرداند تا از شماره حذف نشوند. */
|
||||||
|
function latinDigits(value: string): string {
|
||||||
|
return value.replace(/[۰-۹٠-٩]/g, (ch) => {
|
||||||
|
const fa = FA_DIGITS.indexOf(ch);
|
||||||
|
return String(fa >= 0 ? fa : AR_DIGITS.indexOf(ch));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Keep digits and leading + for Iranian/international numbers. */
|
/** Keep digits and leading + for Iranian/international numbers. */
|
||||||
export function normalizePhone(value?: string | null): string | null {
|
export function normalizePhone(value?: string | null): string | null {
|
||||||
const v = emptyToNull(value);
|
const v = emptyToNull(value);
|
||||||
if (!v) return null;
|
if (!v) return null;
|
||||||
const cleaned = v.replace(/[^\d+]/g, '');
|
const cleaned = latinDigits(v).replace(/[^\d+]/g, '');
|
||||||
return cleaned.length ? cleaned : null;
|
return cleaned.length ? cleaned : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** موبایل ایران به شکل 09xxxxxxxxx. 912… و 98912… و +98912… همان شمارهاند. */
|
||||||
|
export function canonicalIranMobile(value?: string | null): string | null {
|
||||||
|
const n = normalizePhone(value);
|
||||||
|
if (!n) return null;
|
||||||
|
let d = n.replace(/\D/g, '');
|
||||||
|
if (!d) return null;
|
||||||
|
if (d.startsWith('0098') && d.length >= 14) d = d.slice(4);
|
||||||
|
else if (d.startsWith('98') && d.length >= 12) d = d.slice(2);
|
||||||
|
if (d.length === 10 && d.startsWith('9')) d = `0${d}`;
|
||||||
|
return /^09\d{9}$/.test(d) ? d : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** شکلهایی که ممکن است همین موبایل با آنها ذخیره شده باشد. */
|
||||||
|
export function iranMobileLookupKeys(value?: string | null): string[] {
|
||||||
|
const canon = canonicalIranMobile(value);
|
||||||
|
if (!canon) return [];
|
||||||
|
const core = canon.slice(1);
|
||||||
|
return [canon, core, `98${core}`, `+98${core}`, `0098${core}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** برای تطبیق: اگر موبایل ایران است همه شکلها، وگرنه همان متن نرمالشده. */
|
||||||
|
export function phoneIdentityKeys(value?: string | null): string[] {
|
||||||
|
const keys = iranMobileLookupKeys(value);
|
||||||
|
if (keys.length) return keys;
|
||||||
|
const n = normalizePhone(value);
|
||||||
|
return n ? [n] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ذخیره: موبایل ایران همیشه 09… ؛ بقیه شمارهها فقط تمیز میشوند. */
|
||||||
|
export function storedPhoneNumber(value?: string | null): string | null {
|
||||||
|
return canonicalIranMobile(value) ?? normalizePhone(value);
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
|||||||
import { memoryStorage } from 'multer';
|
import { memoryStorage } from 'multer';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||||
@@ -33,8 +34,9 @@ const excelUpload = FileInterceptor('file', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@Controller('people')
|
@Controller('people')
|
||||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
@UseGuards(JwtAuthGuard, OrgModuleGuard, SectionPermissionGuard)
|
||||||
@RequireOrgModule('customers')
|
@RequireOrgModule('customers')
|
||||||
|
@RequirePermission('customers')
|
||||||
export class PeopleController {
|
export class PeopleController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly peopleService: PeopleService,
|
private readonly peopleService: PeopleService,
|
||||||
@@ -59,6 +61,8 @@ export class PeopleController {
|
|||||||
province: query.province,
|
province: query.province,
|
||||||
city: query.city,
|
city: query.city,
|
||||||
hasMobile,
|
hasMobile,
|
||||||
|
disc: query.disc,
|
||||||
|
groupId: query.groupId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -75,6 +79,8 @@ export class PeopleController {
|
|||||||
province: query.province,
|
province: query.province,
|
||||||
city: query.city,
|
city: query.city,
|
||||||
hasMobile,
|
hasMobile,
|
||||||
|
disc: query.disc,
|
||||||
|
groupId: query.groupId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +89,11 @@ export class PeopleController {
|
|||||||
return this.peopleService.sourceStats(user);
|
return this.peopleService.sourceStats(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('group-stats')
|
||||||
|
groupStats(@CurrentUser() user: Actor) {
|
||||||
|
return this.peopleService.groupStats(user);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('export')
|
@Get('export')
|
||||||
async export(
|
async export(
|
||||||
@CurrentUser() user: Actor,
|
@CurrentUser() user: Actor,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type {
|
import type {
|
||||||
CreatePersonRequest,
|
CreatePersonRequest,
|
||||||
|
CustomerGroupDto,
|
||||||
FilteredIdsResponse,
|
FilteredIdsResponse,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
PersonDto,
|
PersonDto,
|
||||||
@@ -17,39 +18,54 @@ import type {
|
|||||||
PersonMood,
|
PersonMood,
|
||||||
UpdatePersonRequest,
|
UpdatePersonRequest,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { FILTERED_IDS_MAX } from '@mento/shared';
|
import {
|
||||||
|
FILTERED_IDS_MAX,
|
||||||
|
PERSON_MOODS,
|
||||||
|
applyPersonGenderTitle,
|
||||||
|
splitPersonHonorific,
|
||||||
|
} from '@mento/shared';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
||||||
import { CompaniesService } from './companies.service';
|
import { CompaniesService } from './companies.service';
|
||||||
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize';
|
import {
|
||||||
|
emptyToNull,
|
||||||
|
normalizeEmail,
|
||||||
|
phoneIdentityKeys,
|
||||||
|
storedPhoneNumber,
|
||||||
|
} from './normalize';
|
||||||
import {
|
import {
|
||||||
asAddresses,
|
asAddresses,
|
||||||
asCustomFields,
|
asCustomFields,
|
||||||
toJsonAddresses,
|
toJsonAddresses,
|
||||||
toJsonCustomFields,
|
toJsonCustomFields,
|
||||||
} from '../common/custom-fields';
|
} from '../common/custom-fields';
|
||||||
|
import { customersPersonWhere } from '../common/data-access';
|
||||||
import {
|
import {
|
||||||
type Actor,
|
type Actor,
|
||||||
canReassignOwner,
|
|
||||||
ownerWhere,
|
|
||||||
resolveOwnerId,
|
resolveOwnerId,
|
||||||
} from '../common/record-scope';
|
} from '../common/record-scope';
|
||||||
|
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||||
import { LoyaltyService } from '../loyalty/loyalty.service';
|
import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||||
import { AutomationsService } from '../automations/automations.service';
|
import { AutomationsService } from '../automations/automations.service';
|
||||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||||
import { WebhooksService } from '../public-api/webhooks.service';
|
import { WebhooksService } from '../public-api/webhooks.service';
|
||||||
import { GamificationService } from '../gamification/gamification.service';
|
import { GamificationService } from '../gamification/gamification.service';
|
||||||
import { parseBirthDate } from '../common/excel';
|
import { parseBirthDate } from '../common/excel';
|
||||||
import { SmsService } from '../common/sms.service';
|
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||||
import { selectMonthBirthdays } from './birthday-month';
|
import { selectMonthBirthdays } from './birthday-month';
|
||||||
import { renderSmsText } from '../common/sms-text';
|
import { renderSmsText } from '../common/sms-text';
|
||||||
import { normalizeTags } from '../common/tags';
|
import { normalizeTags } from '../common/tags';
|
||||||
import { buildSearchWhere, ci } from '../common/search';
|
import { buildSearchWhere, ci, splitCsv } from '../common/search';
|
||||||
import {
|
import {
|
||||||
normalizePersonImportSource,
|
normalizePersonImportSource,
|
||||||
personImportSourceLabel,
|
personImportSourceLabel,
|
||||||
} from './person-source';
|
} from './person-source';
|
||||||
|
import { BehaviorAccessService } from '../behavior/behavior-access.service';
|
||||||
|
import {
|
||||||
|
CustomerGroupsService,
|
||||||
|
groupSummarySelect,
|
||||||
|
} from '../customer-groups/customer-groups.service';
|
||||||
|
|
||||||
const personInclude = {
|
const personInclude = {
|
||||||
company: { select: { id: true, name: true, province: true, city: true } },
|
company: { select: { id: true, name: true, province: true, city: true } },
|
||||||
@@ -57,12 +73,18 @@ const personInclude = {
|
|||||||
loyaltyTier: {
|
loyaltyTier: {
|
||||||
select: { id: true, name: true, minPoints: true, color: true },
|
select: { id: true, name: true, minPoints: true, color: true },
|
||||||
},
|
},
|
||||||
|
groupLinks: {
|
||||||
|
select: { source: true, group: { select: groupSummarySelect } },
|
||||||
|
orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }],
|
||||||
|
},
|
||||||
} satisfies Prisma.PersonInclude;
|
} satisfies Prisma.PersonInclude;
|
||||||
|
|
||||||
type PersonRow = {
|
type PersonRow = {
|
||||||
id: string;
|
id: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
gender: string;
|
||||||
|
showGenderInName: boolean;
|
||||||
previousFirstName: string | null;
|
previousFirstName: string | null;
|
||||||
previousLastName: string | null;
|
previousLastName: string | null;
|
||||||
importSource: string;
|
importSource: string;
|
||||||
@@ -98,8 +120,28 @@ type PersonRow = {
|
|||||||
minPoints: number;
|
minPoints: number;
|
||||||
color: string | null;
|
color: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
discLabel: string | null;
|
||||||
|
discReliable: boolean;
|
||||||
|
discAssessedAt: Date | null;
|
||||||
|
groupLinks: Array<{ source: string; group: { id: string; name: string; color: string } }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function discFilter(raw: string | undefined): Prisma.PersonWhereInput | null {
|
||||||
|
const value = raw?.trim().toUpperCase();
|
||||||
|
if (!value || !/^[DISC](\/[DISC]){0,3}$/.test(value)) return null;
|
||||||
|
if (value.includes('/')) {
|
||||||
|
return { discReliable: true, discLabel: value };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
discReliable: true,
|
||||||
|
OR: [
|
||||||
|
{ discLabel: value },
|
||||||
|
{ discLabel: { startsWith: `${value}/` } },
|
||||||
|
{ discLabel: { contains: `/${value}` } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PeopleService implements OnModuleInit {
|
export class PeopleService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(PeopleService.name);
|
private readonly logger = new Logger(PeopleService.name);
|
||||||
@@ -111,24 +153,30 @@ export class PeopleService implements OnModuleInit {
|
|||||||
private readonly messageAutomations: MessageAutomationsService,
|
private readonly messageAutomations: MessageAutomationsService,
|
||||||
private readonly webhooks: WebhooksService,
|
private readonly webhooks: WebhooksService,
|
||||||
private readonly game: GamificationService,
|
private readonly game: GamificationService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsGatewayService,
|
||||||
private readonly outboundSms: OutboundSmsLogService,
|
private readonly outboundSms: OutboundSmsLogService,
|
||||||
|
private readonly behaviorAccess: BehaviorAccessService,
|
||||||
|
private readonly groups: CustomerGroupsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private personListWhere(
|
private personListWhere(
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
q?: string,
|
q?: string,
|
||||||
opts?: {
|
opts?: {
|
||||||
mood?: PersonMood;
|
mood?: string;
|
||||||
importSource?: string;
|
importSource?: string;
|
||||||
province?: string;
|
province?: string;
|
||||||
city?: string;
|
city?: string;
|
||||||
hasMobile?: boolean;
|
hasMobile?: boolean;
|
||||||
|
disc?: string;
|
||||||
|
groupId?: string;
|
||||||
},
|
},
|
||||||
): Prisma.PersonWhereInput {
|
): Prisma.PersonWhereInput {
|
||||||
const sourceFilter = opts?.importSource?.trim()
|
const sources = [...new Set(splitCsv(opts?.importSource).map(normalizePersonImportSource))];
|
||||||
? normalizePersonImportSource(opts.importSource)
|
const moods = splitCsv(opts?.mood).filter((m): m is PersonMood =>
|
||||||
: undefined;
|
(PERSON_MOODS as readonly string[]).includes(m),
|
||||||
|
);
|
||||||
|
const groupIds = splitCsv(opts?.groupId);
|
||||||
const search = buildSearchWhere<Prisma.PersonWhereInput>(q, (t) => [
|
const search = buildSearchWhere<Prisma.PersonWhereInput>(q, (t) => [
|
||||||
{ firstName: ci(t) },
|
{ firstName: ci(t) },
|
||||||
{ lastName: ci(t) },
|
{ lastName: ci(t) },
|
||||||
@@ -166,11 +214,14 @@ export class PeopleService implements OnModuleInit {
|
|||||||
if (opts?.hasMobile) {
|
if (opts?.hasMobile) {
|
||||||
and.push({ mobile: { not: null } }, { NOT: { mobile: '' } });
|
and.push({ mobile: { not: null } }, { NOT: { mobile: '' } });
|
||||||
}
|
}
|
||||||
|
const disc = discFilter(opts?.disc);
|
||||||
|
if (disc) and.push(disc);
|
||||||
|
if (groupIds.length) and.push({ groupLinks: { some: { groupId: { in: groupIds } } } });
|
||||||
return {
|
return {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
...ownerWhere(actor),
|
...customersPersonWhere(actor),
|
||||||
...(opts?.mood ? { mood: opts.mood } : {}),
|
...(moods.length ? { mood: { in: moods } } : {}),
|
||||||
...(sourceFilter ? { importSource: sourceFilter } : {}),
|
...(sources.length ? { importSource: { in: sources } } : {}),
|
||||||
...(and.length ? { AND: and } : {}),
|
...(and.length ? { AND: and } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -181,15 +232,18 @@ export class PeopleService implements OnModuleInit {
|
|||||||
page = 1,
|
page = 1,
|
||||||
limit = 20,
|
limit = 20,
|
||||||
opts?: {
|
opts?: {
|
||||||
mood?: PersonMood;
|
mood?: string;
|
||||||
sort?: 'name' | 'newest';
|
sort?: 'name' | 'newest';
|
||||||
importSource?: string;
|
importSource?: string;
|
||||||
province?: string;
|
province?: string;
|
||||||
city?: string;
|
city?: string;
|
||||||
hasMobile?: boolean;
|
hasMobile?: boolean;
|
||||||
|
disc?: string;
|
||||||
|
groupId?: string;
|
||||||
},
|
},
|
||||||
): Promise<PaginatedResponse<PersonDto>> {
|
): Promise<PaginatedResponse<PersonDto>> {
|
||||||
const where = this.personListWhere(actor, q, opts);
|
const seeDisc = await this.behaviorAccess.canView(actor);
|
||||||
|
const where = this.personListWhere(actor, q, seeDisc ? opts : { ...opts, disc: undefined });
|
||||||
|
|
||||||
const [rows, total] = await this.prisma.$transaction([
|
const [rows, total] = await this.prisma.$transaction([
|
||||||
this.prisma.person.findMany({
|
this.prisma.person.findMany({
|
||||||
@@ -206,7 +260,7 @@ export class PeopleService implements OnModuleInit {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: rows.map((row) => this.toDto(row)),
|
items: rows.map((row) => this.toDto(row, seeDisc)),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
@@ -217,15 +271,18 @@ export class PeopleService implements OnModuleInit {
|
|||||||
actor: Actor,
|
actor: Actor,
|
||||||
q?: string,
|
q?: string,
|
||||||
opts?: {
|
opts?: {
|
||||||
mood?: PersonMood;
|
mood?: string;
|
||||||
importSource?: string;
|
importSource?: string;
|
||||||
province?: string;
|
province?: string;
|
||||||
city?: string;
|
city?: string;
|
||||||
hasMobile?: boolean;
|
hasMobile?: boolean;
|
||||||
|
disc?: string;
|
||||||
|
groupId?: string;
|
||||||
},
|
},
|
||||||
max = FILTERED_IDS_MAX,
|
max = FILTERED_IDS_MAX,
|
||||||
): Promise<FilteredIdsResponse> {
|
): Promise<FilteredIdsResponse> {
|
||||||
const where = this.personListWhere(actor, q, opts);
|
const seeDisc = await this.behaviorAccess.canView(actor);
|
||||||
|
const where = this.personListWhere(actor, q, seeDisc ? opts : { ...opts, disc: undefined });
|
||||||
const total = await this.prisma.person.count({ where });
|
const total = await this.prisma.person.count({ where });
|
||||||
if (total > max) {
|
if (total > max) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -246,7 +303,7 @@ export class PeopleService implements OnModuleInit {
|
|||||||
by: ['importSource'],
|
by: ['importSource'],
|
||||||
where: {
|
where: {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
...ownerWhere(actor),
|
...customersPersonWhere(actor),
|
||||||
},
|
},
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
@@ -259,6 +316,38 @@ export class PeopleService implements OnModuleInit {
|
|||||||
.sort((a, b) => b.count - a.count);
|
.sort((a, b) => b.count - a.count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** آمار گروه برای فیلتر اشخاص — فقط افراد در دسترس کاربر */
|
||||||
|
async groupStats(actor: Actor): Promise<CustomerGroupDto[]> {
|
||||||
|
const counts = await this.prisma.personGroupLink.groupBy({
|
||||||
|
by: ['groupId'],
|
||||||
|
where: {
|
||||||
|
person: {
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
...customersPersonWhere(actor),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
if (!counts.length) return [];
|
||||||
|
const countMap = new Map(counts.map((c) => [c.groupId, c._count._all]));
|
||||||
|
const groups = await this.prisma.customerGroup.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
id: { in: counts.map((c) => c.groupId) },
|
||||||
|
},
|
||||||
|
orderBy: [{ position: 'asc' }, { name: 'asc' }],
|
||||||
|
});
|
||||||
|
return groups.map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
name: g.name,
|
||||||
|
color: g.color,
|
||||||
|
position: g.position,
|
||||||
|
fromTahesab: Boolean(g.tahesabGid),
|
||||||
|
personCount: countMap.get(g.id) ?? 0,
|
||||||
|
companyCount: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
void this.backfillStoredBirthDates();
|
void this.backfillStoredBirthDates();
|
||||||
}
|
}
|
||||||
@@ -315,7 +404,10 @@ export class PeopleService implements OnModuleInit {
|
|||||||
input: { mobile?: string | null; email?: string | null; birthDate: Date },
|
input: { mobile?: string | null; email?: string | null; birthDate: Date },
|
||||||
) {
|
) {
|
||||||
const or: Prisma.PersonWhereInput[] = [];
|
const or: Prisma.PersonWhereInput[] = [];
|
||||||
if (input.mobile) or.push({ mobile: input.mobile });
|
const mobileKeys = phoneIdentityKeys(input.mobile);
|
||||||
|
if (mobileKeys.length) {
|
||||||
|
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
|
||||||
|
}
|
||||||
if (input.email) or.push({ email: input.email });
|
if (input.email) or.push({ email: input.email });
|
||||||
if (!or.length) return;
|
if (!or.length) return;
|
||||||
await this.prisma.person.updateMany({
|
await this.prisma.person.updateMany({
|
||||||
@@ -326,11 +418,11 @@ export class PeopleService implements OnModuleInit {
|
|||||||
|
|
||||||
async get(actor: Actor, id: string): Promise<PersonDto> {
|
async get(actor: Actor, id: string): Promise<PersonDto> {
|
||||||
const row = await this.prisma.person.findFirst({
|
const row = await this.prisma.person.findFirst({
|
||||||
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
|
where: { id, organizationId: actor.organizationId, ...customersPersonWhere(actor) },
|
||||||
include: personInclude,
|
include: personInclude,
|
||||||
});
|
});
|
||||||
if (!row) throw new NotFoundException('شخص پیدا نشد');
|
if (!row) throw new NotFoundException('شخص پیدا نشد');
|
||||||
return this.toDto(row);
|
return this.toDto(row, await this.behaviorAccess.canView(actor));
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(actor: Actor, dto: CreatePersonRequest): Promise<PersonDto> {
|
async create(actor: Actor, dto: CreatePersonRequest): Promise<PersonDto> {
|
||||||
@@ -340,17 +432,25 @@ export class PeopleService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null;
|
const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null;
|
||||||
|
const named = applyPersonGenderTitle({
|
||||||
|
firstName: dto.firstName ?? '',
|
||||||
|
lastName: dto.lastName ?? '',
|
||||||
|
gender: dto.gender,
|
||||||
|
showGenderInName: dto.showGenderInName,
|
||||||
|
});
|
||||||
const data: Prisma.PersonUncheckedCreateInput = {
|
const data: Prisma.PersonUncheckedCreateInput = {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
firstName: (dto.firstName ?? '').trim(),
|
firstName: named.firstName,
|
||||||
lastName: (dto.lastName ?? '').trim(),
|
lastName: named.lastName,
|
||||||
|
gender: named.gender,
|
||||||
|
showGenderInName: named.showGenderInName,
|
||||||
previousFirstName: emptyToNull(dto.previousFirstName),
|
previousFirstName: emptyToNull(dto.previousFirstName),
|
||||||
previousLastName: emptyToNull(dto.previousLastName),
|
previousLastName: emptyToNull(dto.previousLastName),
|
||||||
importSource: normalizePersonImportSource(dto.importSource),
|
importSource: normalizePersonImportSource(dto.importSource),
|
||||||
sourceRef: emptyToNull(dto.sourceRef),
|
sourceRef: emptyToNull(dto.sourceRef),
|
||||||
email: normalizeEmail(dto.email),
|
email: normalizeEmail(dto.email),
|
||||||
phone: normalizePhone(dto.phone),
|
phone: storedPhoneNumber(dto.phone),
|
||||||
mobile: normalizePhone(dto.mobile),
|
mobile: storedPhoneNumber(dto.mobile),
|
||||||
position: emptyToNull(dto.position),
|
position: emptyToNull(dto.position),
|
||||||
website: emptyToNull(dto.website),
|
website: emptyToNull(dto.website),
|
||||||
instagram: emptyToNull(dto.instagram),
|
instagram: emptyToNull(dto.instagram),
|
||||||
@@ -361,18 +461,24 @@ export class PeopleService implements OnModuleInit {
|
|||||||
mood: dto.mood ?? 'newcomer',
|
mood: dto.mood ?? 'newcomer',
|
||||||
tags: normalizeTags(dto.tags),
|
tags: normalizeTags(dto.tags),
|
||||||
companyId,
|
companyId,
|
||||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
ownerId: resolveOwnerId(
|
||||||
|
actor,
|
||||||
|
dto.ownerId,
|
||||||
|
await actorCanAssignToOthers(this.prisma, actor, 'customers'),
|
||||||
|
),
|
||||||
customFields: toJsonCustomFields(dto.customFields),
|
customFields: toJsonCustomFields(dto.customFields),
|
||||||
...(registeredAt && !Number.isNaN(registeredAt.getTime())
|
...(registeredAt && !Number.isNaN(registeredAt.getTime())
|
||||||
? { createdAt: registeredAt }
|
? { createdAt: registeredAt }
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
await this.assertNoDuplicate(actor.organizationId, data);
|
await this.assertNoDuplicate(actor.organizationId, data);
|
||||||
|
const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds);
|
||||||
|
|
||||||
const row = await this.prisma.person.create({
|
const row = await this.prisma.person.create({
|
||||||
data,
|
data,
|
||||||
include: personInclude,
|
include: personInclude,
|
||||||
});
|
});
|
||||||
|
if (groupIds.length) await this.groups.addPersonGroups(row.id, groupIds);
|
||||||
await this.loyalty.applyWelcomePoints(actor.organizationId, row.id);
|
await this.loyalty.applyWelcomePoints(actor.organizationId, row.id);
|
||||||
const createdCtx = {
|
const createdCtx = {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
@@ -407,7 +513,7 @@ export class PeopleService implements OnModuleInit {
|
|||||||
where: { id: row.id },
|
where: { id: row.id },
|
||||||
include: personInclude,
|
include: personInclude,
|
||||||
});
|
});
|
||||||
return this.toDto(fresh ?? row);
|
return this.toDto(fresh ?? row, await this.behaviorAccess.canView(actor));
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
@@ -418,8 +524,32 @@ export class PeopleService implements OnModuleInit {
|
|||||||
await this.ensureExists(actor, id);
|
await this.ensureExists(actor, id);
|
||||||
|
|
||||||
const data: Prisma.PersonUncheckedUpdateInput = {};
|
const data: Prisma.PersonUncheckedUpdateInput = {};
|
||||||
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim();
|
const touchesName =
|
||||||
if (dto.lastName !== undefined) data.lastName = dto.lastName.trim();
|
dto.firstName !== undefined ||
|
||||||
|
dto.lastName !== undefined ||
|
||||||
|
dto.gender !== undefined ||
|
||||||
|
dto.showGenderInName !== undefined;
|
||||||
|
if (touchesName) {
|
||||||
|
const existing = await this.prisma.person.findFirst({
|
||||||
|
where: { id, organizationId: actor.organizationId },
|
||||||
|
select: { firstName: true, lastName: true, gender: true, showGenderInName: true },
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundException('شخص پیدا نشد');
|
||||||
|
const bare = splitPersonHonorific(existing.firstName, existing.lastName);
|
||||||
|
const storedGender =
|
||||||
|
existing.gender === 'male' || existing.gender === 'female' ? existing.gender : 'none';
|
||||||
|
const named = applyPersonGenderTitle({
|
||||||
|
firstName: dto.firstName !== undefined ? dto.firstName : bare.firstName,
|
||||||
|
lastName: dto.lastName !== undefined ? dto.lastName : bare.lastName,
|
||||||
|
gender: dto.gender !== undefined ? dto.gender : storedGender,
|
||||||
|
showGenderInName:
|
||||||
|
dto.showGenderInName !== undefined ? dto.showGenderInName : existing.showGenderInName,
|
||||||
|
});
|
||||||
|
data.firstName = named.firstName;
|
||||||
|
data.lastName = named.lastName;
|
||||||
|
data.gender = named.gender;
|
||||||
|
data.showGenderInName = named.showGenderInName;
|
||||||
|
}
|
||||||
if ('previousFirstName' in dto) {
|
if ('previousFirstName' in dto) {
|
||||||
data.previousFirstName = emptyToNull(dto.previousFirstName);
|
data.previousFirstName = emptyToNull(dto.previousFirstName);
|
||||||
}
|
}
|
||||||
@@ -431,8 +561,8 @@ export class PeopleService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef);
|
if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef);
|
||||||
if ('email' in dto) data.email = normalizeEmail(dto.email);
|
if ('email' in dto) data.email = normalizeEmail(dto.email);
|
||||||
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
|
if ('phone' in dto) data.phone = storedPhoneNumber(dto.phone);
|
||||||
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile);
|
if ('mobile' in dto) data.mobile = storedPhoneNumber(dto.mobile);
|
||||||
if ('position' in dto) data.position = emptyToNull(dto.position);
|
if ('position' in dto) data.position = emptyToNull(dto.position);
|
||||||
if ('website' in dto) data.website = emptyToNull(dto.website);
|
if ('website' in dto) data.website = emptyToNull(dto.website);
|
||||||
if ('instagram' in dto) data.instagram = emptyToNull(dto.instagram);
|
if ('instagram' in dto) data.instagram = emptyToNull(dto.instagram);
|
||||||
@@ -456,11 +586,21 @@ export class PeopleService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
data.companyId = nextCompanyId;
|
data.companyId = nextCompanyId;
|
||||||
}
|
}
|
||||||
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
|
if (dto.ownerId !== undefined) {
|
||||||
data.ownerId = resolveOwnerId(actor, dto.ownerId);
|
const current = await this.prisma.person.findFirst({
|
||||||
|
where: { id, organizationId: actor.organizationId },
|
||||||
|
select: { ownerId: true },
|
||||||
|
});
|
||||||
|
const nextOwner = emptyToNull(dto.ownerId ?? null);
|
||||||
|
await assertAssigneeSet(this.prisma, actor, 'customers', [nextOwner], [current?.ownerId]);
|
||||||
|
data.ownerId = nextOwner ?? actor.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.assertNoDuplicate(actor.organizationId, data, id);
|
await this.assertNoDuplicate(actor.organizationId, data, id);
|
||||||
|
if (dto.groupIds !== undefined) {
|
||||||
|
const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds);
|
||||||
|
await this.groups.setPersonGroups(id, groupIds);
|
||||||
|
}
|
||||||
const row = await this.prisma.person.update({
|
const row = await this.prisma.person.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
@@ -473,7 +613,7 @@ export class PeopleService implements OnModuleInit {
|
|||||||
email: row.email,
|
email: row.email,
|
||||||
companyId: row.companyId,
|
companyId: row.companyId,
|
||||||
});
|
});
|
||||||
return this.toDto(row);
|
return this.toDto(row, await this.behaviorAccess.canView(actor));
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(actor: Actor, id: string): Promise<void> {
|
async remove(actor: Actor, id: string): Promise<void> {
|
||||||
@@ -488,7 +628,7 @@ export class PeopleService implements OnModuleInit {
|
|||||||
where: {
|
where: {
|
||||||
id: { in: unique },
|
id: { in: unique },
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
...ownerWhere(actor),
|
...customersPersonWhere(actor),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { deleted: res.count };
|
return { deleted: res.count };
|
||||||
@@ -496,7 +636,7 @@ export class PeopleService implements OnModuleInit {
|
|||||||
|
|
||||||
private async ensureExists(actor: Actor, id: string) {
|
private async ensureExists(actor: Actor, id: string) {
|
||||||
const found = await this.prisma.person.findFirst({
|
const found = await this.prisma.person.findFirst({
|
||||||
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
|
where: { id, organizationId: actor.organizationId, ...customersPersonWhere(actor) },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (!found) throw new NotFoundException('شخص پیدا نشد');
|
if (!found) throw new NotFoundException('شخص پیدا نشد');
|
||||||
@@ -509,10 +649,13 @@ export class PeopleService implements OnModuleInit {
|
|||||||
) {
|
) {
|
||||||
const email = typeof data.email === 'string' ? data.email : null;
|
const email = typeof data.email === 'string' ? data.email : null;
|
||||||
const mobile = typeof data.mobile === 'string' ? data.mobile : null;
|
const mobile = typeof data.mobile === 'string' ? data.mobile : null;
|
||||||
|
const mobileKeys = phoneIdentityKeys(mobile);
|
||||||
|
|
||||||
const or: Prisma.PersonWhereInput[] = [];
|
const or: Prisma.PersonWhereInput[] = [];
|
||||||
if (email) or.push({ email });
|
if (email) or.push({ email });
|
||||||
if (mobile) or.push({ mobile });
|
if (mobileKeys.length) {
|
||||||
|
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
|
||||||
|
}
|
||||||
if (!or.length) return;
|
if (!or.length) return;
|
||||||
|
|
||||||
const clash = await this.prisma.person.findFirst({
|
const clash = await this.prisma.person.findFirst({
|
||||||
@@ -634,7 +777,12 @@ export class PeopleService implements OnModuleInit {
|
|||||||
company: person.company?.name ?? '',
|
company: person.company?.name ?? '',
|
||||||
});
|
});
|
||||||
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
||||||
const result = await this.sms.sendSmsDetailed(mobile, message);
|
const result = await this.sms.send({
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
phone: mobile,
|
||||||
|
text: message,
|
||||||
|
source: 'manual',
|
||||||
|
});
|
||||||
await this.outboundSms.record({
|
await this.outboundSms.record({
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
personId: person.id,
|
personId: person.id,
|
||||||
@@ -647,9 +795,11 @@ export class PeopleService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
result.reason === 'SMS not configured'
|
result.blocked
|
||||||
? 'ارسال پیامک روی سرور فعال نیست.'
|
? 'ارسال پیامک بهخاطر محدودیت اعتبار مسدود است.'
|
||||||
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
|
: result.reason === 'SMS not configured'
|
||||||
|
? 'ارسال پیامک روی سرور فعال نیست.'
|
||||||
|
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -670,7 +820,12 @@ export class PeopleService implements OnModuleInit {
|
|||||||
company: person.company?.name ?? '',
|
company: person.company?.name ?? '',
|
||||||
});
|
});
|
||||||
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
||||||
const result = await this.sms.sendSmsDetailed(mobile, message);
|
const result = await this.sms.send({
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
phone: mobile,
|
||||||
|
text: message,
|
||||||
|
source: 'manual',
|
||||||
|
});
|
||||||
await this.outboundSms.record({
|
await this.outboundSms.record({
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
personId: person.id,
|
personId: person.id,
|
||||||
@@ -683,9 +838,11 @@ export class PeopleService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
result.reason === 'SMS not configured'
|
result.blocked
|
||||||
? 'ارسال پیامک روی سرور فعال نیست.'
|
? 'ارسال پیامک بهخاطر محدودیت اعتبار مسدود است.'
|
||||||
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
|
: result.reason === 'SMS not configured'
|
||||||
|
? 'ارسال پیامک روی سرور فعال نیست.'
|
||||||
|
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -697,7 +854,7 @@ export class PeopleService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private toDto(row: PersonRow): PersonDto {
|
private toDto(row: PersonRow, includeDisc: boolean): PersonDto {
|
||||||
const previousFullName = `${row.previousFirstName ?? ''} ${row.previousLastName ?? ''}`.trim();
|
const previousFullName = `${row.previousFirstName ?? ''} ${row.previousLastName ?? ''}`.trim();
|
||||||
const importSource = row.importSource || 'manual';
|
const importSource = row.importSource || 'manual';
|
||||||
return {
|
return {
|
||||||
@@ -712,6 +869,8 @@ export class PeopleService implements OnModuleInit {
|
|||||||
previousImportSource: row.previousImportSource,
|
previousImportSource: row.previousImportSource,
|
||||||
previousSourceRef: row.previousSourceRef,
|
previousSourceRef: row.previousSourceRef,
|
||||||
fullName: `${row.firstName} ${row.lastName}`.trim(),
|
fullName: `${row.firstName} ${row.lastName}`.trim(),
|
||||||
|
gender: row.gender === 'male' || row.gender === 'female' ? row.gender : 'none',
|
||||||
|
showGenderInName: row.showGenderInName,
|
||||||
email: row.email,
|
email: row.email,
|
||||||
phone: row.phone,
|
phone: row.phone,
|
||||||
mobile: row.mobile,
|
mobile: row.mobile,
|
||||||
@@ -734,8 +893,16 @@ export class PeopleService implements OnModuleInit {
|
|||||||
mood: row.mood,
|
mood: row.mood,
|
||||||
tags: row.tags ?? [],
|
tags: row.tags ?? [],
|
||||||
customFields: asCustomFields(row.customFields),
|
customFields: asCustomFields(row.customFields),
|
||||||
|
groups: (row.groupLinks ?? []).map((l) => ({
|
||||||
|
...l.group,
|
||||||
|
source: l.source === 'tahesab' ? ('tahesab' as const) : ('manual' as const),
|
||||||
|
})),
|
||||||
loyaltyPoints: row.loyaltyPoints ?? 0,
|
loyaltyPoints: row.loyaltyPoints ?? 0,
|
||||||
loyaltyTier: row.loyaltyTier,
|
loyaltyTier: row.loyaltyTier,
|
||||||
|
discLabel: includeDisc && row.discReliable ? row.discLabel : null,
|
||||||
|
discReliable: includeDisc ? row.discReliable : false,
|
||||||
|
discAssessedAt:
|
||||||
|
includeDisc && row.discAssessedAt ? row.discAssessedAt.toISOString() : null,
|
||||||
company: row.company,
|
company: row.company,
|
||||||
owner: row.owner,
|
owner: row.owner,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { mergeSameMobilePeople } from './merge-same-mobile';
|
||||||
|
|
||||||
|
const FIX_KEY = 'person-mobile-canonical-v3';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SameMobileMergeService implements OnApplicationBootstrap {
|
||||||
|
private readonly logger = new Logger(SameMobileMergeService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
onApplicationBootstrap() {
|
||||||
|
if (process.env.NODE_ENV === 'test') return;
|
||||||
|
void this.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async run() {
|
||||||
|
try {
|
||||||
|
const ready = await this.prisma.$queryRaw<{ ok: number }[]>`
|
||||||
|
SELECT 1 AS ok
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND lower(table_name) = 'person'
|
||||||
|
AND lower(column_name) = 'gender'
|
||||||
|
`;
|
||||||
|
if (!ready.length) {
|
||||||
|
this.logger.warn('یکسانسازی موبایل رد شد: ستون gender هنوز نیست');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$executeRaw`
|
||||||
|
CREATE TABLE IF NOT EXISTS "DataFix" (
|
||||||
|
"key" TEXT PRIMARY KEY,
|
||||||
|
"doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const done = await this.prisma.$queryRaw<{ key: string }[]>`
|
||||||
|
SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY}
|
||||||
|
`;
|
||||||
|
if (done.length) return;
|
||||||
|
|
||||||
|
await this.prisma.$executeRaw`SELECT pg_advisory_lock(86051205)`;
|
||||||
|
try {
|
||||||
|
const again = await this.prisma.$queryRaw<{ key: string }[]>`
|
||||||
|
SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY}
|
||||||
|
`;
|
||||||
|
if (again.length) return;
|
||||||
|
|
||||||
|
const orgs = await this.prisma.organization.findMany({ select: { id: true } });
|
||||||
|
let merged = 0;
|
||||||
|
let rewritten = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const org of orgs) {
|
||||||
|
try {
|
||||||
|
const result = await mergeSameMobilePeople(this.prisma, org.id, true);
|
||||||
|
merged += result.merged;
|
||||||
|
rewritten += result.rewritten;
|
||||||
|
failed += result.failed;
|
||||||
|
} catch (error) {
|
||||||
|
failed += 1;
|
||||||
|
this.logger.error(
|
||||||
|
`یکسانسازی موبایل برای محیط ${org.id} انجام نشد`,
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed) return;
|
||||||
|
await this.prisma.$executeRaw`
|
||||||
|
INSERT INTO "DataFix" ("key") VALUES (${FIX_KEY})
|
||||||
|
ON CONFLICT ("key") DO NOTHING
|
||||||
|
`;
|
||||||
|
this.logger.log(`شمارههای تکراری یکی شد: ${merged} ادغام، ${rewritten} بازنویسی`);
|
||||||
|
} finally {
|
||||||
|
await this.prisma.$executeRaw`SELECT pg_advisory_unlock(86051205)`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
'یکسانسازی موبایل مشتریها انجام نشد',
|
||||||
|
error instanceof Error ? error.stack : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
|||||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||||
import {
|
import {
|
||||||
|
ConversationFileBytesDto,
|
||||||
CreateConversationDmDto,
|
CreateConversationDmDto,
|
||||||
CreateConversationGroupDto,
|
CreateConversationGroupDto,
|
||||||
CreateConversationChannelDto,
|
CreateConversationChannelDto,
|
||||||
@@ -19,12 +20,34 @@ import {
|
|||||||
ToggleMessageReactionDto,
|
ToggleMessageReactionDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
import { ConversationsService } from './conversations.service';
|
import { ConversationsService } from './conversations.service';
|
||||||
|
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||||
|
|
||||||
@Controller('conversations')
|
@Controller('conversations')
|
||||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||||
@RequireOrgModule('conversations')
|
@RequireOrgModule('conversations')
|
||||||
export class ConversationsController {
|
export class ConversationsController {
|
||||||
constructor(private readonly conversations: ConversationsService) {}
|
constructor(
|
||||||
|
private readonly conversations: ConversationsService,
|
||||||
|
private readonly storage: StorageQuotaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('files/reserve')
|
||||||
|
reserveFile(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Body() dto: ConversationFileBytesDto,
|
||||||
|
) {
|
||||||
|
return this.storage.assertCanAdd(user.organizationId, dto.bytes).then(() =>
|
||||||
|
this.storage.usageForOrganization(user.organizationId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('files/commit')
|
||||||
|
commitFile(
|
||||||
|
@CurrentUser() user: { organizationId: string },
|
||||||
|
@Body() dto: ConversationFileBytesDto,
|
||||||
|
) {
|
||||||
|
return this.storage.addChat(user.organizationId, dto.bytes);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('access')
|
@Get('access')
|
||||||
access(@CurrentUser() user: { userId: string; organizationId: string; role: string }) {
|
access(@CurrentUser() user: { userId: string; organizationId: string; role: string }) {
|
||||||
|
|||||||
@@ -656,7 +656,7 @@ export class ConversationsService {
|
|||||||
});
|
});
|
||||||
if (!row) {
|
if (!row) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → محیطها → تنظیمات گفتگو پیکربندی کنید.',
|
'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → گفتگو پیکربندی کنید.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
Max,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
ArrayMaxSize,
|
ArrayMaxSize,
|
||||||
@@ -32,6 +36,14 @@ export class UpdateConversationSettingsDto {
|
|||||||
downloadUrl?: string;
|
downloadUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ConversationFileBytesDto {
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100 * 1024 * 1024)
|
||||||
|
bytes!: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class CreateConversationDmDto {
|
export class CreateConversationDmDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export enum CustomFieldEntityDto {
|
|||||||
person = 'person',
|
person = 'person',
|
||||||
company = 'company',
|
company = 'company',
|
||||||
deal = 'deal',
|
deal = 'deal',
|
||||||
|
note = 'note',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum CustomFieldTypeDto {
|
export enum CustomFieldTypeDto {
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
import { CUSTOMER_GROUP_COLORS, type CustomerGroupColor } from '@mento/shared';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
|
import { Roles } from '../auth/roles.decorator';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
|
import type { Actor } from '../common/record-scope';
|
||||||
|
import { CustomerGroupsService } from './customer-groups.service';
|
||||||
|
|
||||||
|
class CreateCustomerGroupDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(80)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...CUSTOMER_GROUP_COLORS])
|
||||||
|
color?: CustomerGroupColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateCustomerGroupDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(80)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...CUSTOMER_GROUP_COLORS])
|
||||||
|
color?: CustomerGroupColor;
|
||||||
|
|
||||||
|
/** null گروه را اصلی میکند */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
parentId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('customer-groups')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
export class CustomerGroupsController {
|
||||||
|
constructor(private readonly groups: CustomerGroupsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@CurrentUser() user: Actor) {
|
||||||
|
return this.groups.list(user.organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
create(@CurrentUser() user: Actor, @Body() dto: CreateCustomerGroupDto) {
|
||||||
|
return this.groups.create(user.organizationId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
update(
|
||||||
|
@CurrentUser() user: Actor,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateCustomerGroupDto,
|
||||||
|
) {
|
||||||
|
return this.groups.update(user.organizationId, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
async remove(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||||
|
await this.groups.remove(user.organizationId, id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { CustomerGroupsController } from './customer-groups.controller';
|
||||||
|
import { CustomerGroupsService } from './customer-groups.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [CustomerGroupsController],
|
||||||
|
providers: [CustomerGroupsService],
|
||||||
|
exports: [CustomerGroupsService],
|
||||||
|
})
|
||||||
|
export class CustomerGroupsModule {}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { CustomerGroupColor, CustomerGroupDto } from '@mento/shared';
|
||||||
|
import { CUSTOMER_GROUP_COLORS } from '@mento/shared';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const NAME_MAX = 80;
|
||||||
|
|
||||||
|
/** نام گروه برای مقایسه: فاصلههای اضافه و ی/ک عربی یکسان میشوند */
|
||||||
|
function normalizeGroupName(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.replace(/\u064A/g, '\u06CC')
|
||||||
|
.replace(/\u0643/g, '\u06A9')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, NAME_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «الف، ب / ج» → ['الف', 'ب', 'ج'] */
|
||||||
|
export function splitGroupNames(raw: string | null | undefined): string[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
raw
|
||||||
|
.split(/\s*[،,/]\s*/)
|
||||||
|
.map(normalizeGroupName)
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const groupSummarySelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
color: true,
|
||||||
|
} satisfies Prisma.CustomerGroupSelect;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CustomerGroupsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(organizationId: string): Promise<CustomerGroupDto[]> {
|
||||||
|
const rows = await this.prisma.customerGroup.findMany({
|
||||||
|
where: { organizationId },
|
||||||
|
orderBy: [{ position: 'asc' }, { name: 'asc' }],
|
||||||
|
include: {
|
||||||
|
_count: { select: { people: true, companies: true } },
|
||||||
|
parent: { select: { name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
color: r.color,
|
||||||
|
position: r.position,
|
||||||
|
fromTahesab: Boolean(r.tahesabGid),
|
||||||
|
personCount: r._count.people,
|
||||||
|
companyCount: r._count.companies,
|
||||||
|
parentId: r.parentId,
|
||||||
|
parentName: r.parent?.name ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
organizationId: string,
|
||||||
|
dto: { name: string; color?: CustomerGroupColor },
|
||||||
|
): Promise<CustomerGroupDto> {
|
||||||
|
const name = this.cleanName(dto.name);
|
||||||
|
const max = await this.prisma.customerGroup.aggregate({
|
||||||
|
where: { organizationId },
|
||||||
|
_max: { position: true },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await this.prisma.customerGroup.create({
|
||||||
|
data: {
|
||||||
|
organizationId,
|
||||||
|
name,
|
||||||
|
color: this.cleanColor(dto.color),
|
||||||
|
position: (max._max.position ?? -1) + 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw this.mapError(e);
|
||||||
|
}
|
||||||
|
return (await this.list(organizationId)).find((g) => g.name === name)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
organizationId: string,
|
||||||
|
id: string,
|
||||||
|
dto: { name?: string; color?: CustomerGroupColor; parentId?: string | null },
|
||||||
|
): Promise<CustomerGroupDto> {
|
||||||
|
await this.require(organizationId, id);
|
||||||
|
const parentId =
|
||||||
|
dto.parentId === undefined ? undefined : dto.parentId?.trim() || null;
|
||||||
|
if (parentId !== undefined) {
|
||||||
|
await this.assertParent(organizationId, id, parentId);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.prisma.customerGroup.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
|
||||||
|
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
|
||||||
|
...(parentId !== undefined ? { parentId } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw this.mapError(e);
|
||||||
|
}
|
||||||
|
return (await this.list(organizationId)).find((g) => g.id === id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** فقط یک سطح: گروه اصلی خودش زیرگروه نیست و گروه فرعی زیرگروه ندارد. */
|
||||||
|
private async assertParent(
|
||||||
|
organizationId: string,
|
||||||
|
id: string,
|
||||||
|
parentId: string | null,
|
||||||
|
) {
|
||||||
|
if (parentId == null) return;
|
||||||
|
if (parentId === id) {
|
||||||
|
throw new BadRequestException('گروه نمیتواند زیرمجموعهٔ خودش باشد.');
|
||||||
|
}
|
||||||
|
const parent = await this.prisma.customerGroup.findFirst({
|
||||||
|
where: { id: parentId, organizationId },
|
||||||
|
select: { id: true, parentId: true },
|
||||||
|
});
|
||||||
|
if (!parent) throw new NotFoundException('گروه اصلی پیدا نشد.');
|
||||||
|
if (parent.parentId) {
|
||||||
|
throw new BadRequestException('فقط یک سطح گروه اصلی و فرعی ممکن است.');
|
||||||
|
}
|
||||||
|
const childCount = await this.prisma.customerGroup.count({
|
||||||
|
where: { organizationId, parentId: id },
|
||||||
|
});
|
||||||
|
if (childCount > 0) {
|
||||||
|
throw new BadRequestException('این گروه خودش زیرگروه دارد و نمیتواند فرعی شود.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** مشتریها حذف نمیشوند؛ فقط این گروه از رویشان برداشته میشود */
|
||||||
|
async remove(organizationId: string, id: string): Promise<void> {
|
||||||
|
await this.require(organizationId, id);
|
||||||
|
await this.prisma.customerGroup.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** شناسههای معتبر همین محیط (بقیه نادیده گرفته میشوند) */
|
||||||
|
async validIds(organizationId: string, ids: string[] | undefined): Promise<string[]> {
|
||||||
|
const unique = [...new Set((ids ?? []).map((id) => id?.trim()).filter(Boolean))];
|
||||||
|
if (!unique.length) return [];
|
||||||
|
const rows = await this.prisma.customerGroup.findMany({
|
||||||
|
where: { organizationId, id: { in: unique } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (rows.length !== unique.length) {
|
||||||
|
throw new BadRequestException('یکی از گروههای انتخابشده معتبر نیست');
|
||||||
|
}
|
||||||
|
return rows.map((r) => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** گروهها با نام (برای اکسل)؛ نبود → ساخته میشود */
|
||||||
|
async idsForNames(organizationId: string, names: string[]): Promise<string[]> {
|
||||||
|
const clean = [...new Set(names.map(normalizeGroupName).filter(Boolean))];
|
||||||
|
if (!clean.length) return [];
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const name of clean) {
|
||||||
|
ids.push((await this.findOrCreateByName(organizationId, name)).id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findOrCreateByName(organizationId: string, name: string) {
|
||||||
|
const existing = await this.prisma.customerGroup.findFirst({
|
||||||
|
where: { organizationId, name: { equals: name, mode: 'insensitive' } },
|
||||||
|
select: { id: true, tahesabGid: true },
|
||||||
|
});
|
||||||
|
if (existing) return existing;
|
||||||
|
const max = await this.prisma.customerGroup.aggregate({
|
||||||
|
where: { organizationId },
|
||||||
|
_max: { position: true },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await this.prisma.customerGroup.create({
|
||||||
|
data: { organizationId, name, position: (max._max.position ?? -1) + 1 },
|
||||||
|
select: { id: true, tahesabGid: true },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
return this.prisma.customerGroup.findFirstOrThrow({
|
||||||
|
where: { organizationId, name },
|
||||||
|
select: { id: true, tahesabGid: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* گروه متناظر یک گروه تهحساب: اول با GID روی همان اتصال، بعد با نام.
|
||||||
|
* با تغییر نام در تهحساب گروه تکراری ساخته نمیشود.
|
||||||
|
*/
|
||||||
|
async ensureTahesabGroup(
|
||||||
|
organizationId: string,
|
||||||
|
connectionId: string,
|
||||||
|
gid: string | null,
|
||||||
|
rawName: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const name = normalizeGroupName(rawName);
|
||||||
|
const cleanGid = gid?.trim() || null;
|
||||||
|
if (!name && !cleanGid) return null;
|
||||||
|
if (cleanGid) {
|
||||||
|
const byGid = await this.prisma.customerGroup.findFirst({
|
||||||
|
where: { organizationId, tahesabConnectionId: connectionId, tahesabGid: cleanGid },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
if (byGid) {
|
||||||
|
if (name && byGid.name !== name) {
|
||||||
|
await this.prisma.customerGroup
|
||||||
|
.update({ where: { id: byGid.id }, data: { name } })
|
||||||
|
.catch(() => undefined); // نام در منتکت تکراری است؛ نام فعلی میماند
|
||||||
|
}
|
||||||
|
return byGid.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!name) return null;
|
||||||
|
const group = await this.findOrCreateByName(organizationId, name);
|
||||||
|
if (cleanGid && !group.tahesabGid) {
|
||||||
|
await this.prisma.customerGroup.update({
|
||||||
|
where: { id: group.id },
|
||||||
|
data: { tahesabConnectionId: connectionId, tahesabGid: cleanGid },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return group.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** گروههای دستی شخص (جایگزین کامل). منبع لینکهایی که میمانند حفظ میشود. */
|
||||||
|
async setPersonGroups(personId: string, groupIds: string[]): Promise<void> {
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.personGroupLink.deleteMany({
|
||||||
|
where: { personId, groupId: { notIn: groupIds } },
|
||||||
|
}),
|
||||||
|
this.prisma.personGroupLink.createMany({
|
||||||
|
data: groupIds.map((groupId) => ({ personId, groupId, source: 'manual' })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setCompanyGroups(companyId: string, groupIds: string[]): Promise<void> {
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.companyGroupLink.deleteMany({
|
||||||
|
where: { companyId, groupId: { notIn: groupIds } },
|
||||||
|
}),
|
||||||
|
this.prisma.companyGroupLink.createMany({
|
||||||
|
data: groupIds.map((groupId) => ({ companyId, groupId })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** فقط اضافه میکند (ایمپورت اکسل) */
|
||||||
|
async addPersonGroups(personId: string, groupIds: string[]): Promise<void> {
|
||||||
|
if (!groupIds.length) return;
|
||||||
|
await this.prisma.personGroupLink.createMany({
|
||||||
|
data: groupIds.map((groupId) => ({ personId, groupId, source: 'manual' })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addCompanyGroups(companyId: string, groupIds: string[]): Promise<void> {
|
||||||
|
if (!groupIds.length) return;
|
||||||
|
await this.prisma.companyGroupLink.createMany({
|
||||||
|
data: groupIds.map((groupId) => ({ companyId, groupId })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* گروهِ آمده از تهحساب: گروه تهحسابی قبلی برداشته و این یکی گذاشته میشود.
|
||||||
|
* گروههای دستی دست نمیخورند. اگر همین گروه از قبل دستی بوده، دستی میماند.
|
||||||
|
* @returns آیا چیزی عوض شد
|
||||||
|
*/
|
||||||
|
async setPersonTahesabGroup(personId: string, groupId: string | null): Promise<boolean> {
|
||||||
|
const current = await this.prisma.personGroupLink.findMany({
|
||||||
|
where: { personId },
|
||||||
|
select: { groupId: true, source: true },
|
||||||
|
});
|
||||||
|
const staleTahesab = current.filter((l) => l.source === 'tahesab' && l.groupId !== groupId);
|
||||||
|
const alreadyLinked = groupId ? current.some((l) => l.groupId === groupId) : true;
|
||||||
|
if (!staleTahesab.length && alreadyLinked) return false;
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
...(staleTahesab.length
|
||||||
|
? [
|
||||||
|
this.prisma.personGroupLink.deleteMany({
|
||||||
|
where: { personId, groupId: { in: staleTahesab.map((l) => l.groupId) } },
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(groupId && !alreadyLinked
|
||||||
|
? [this.prisma.personGroupLink.create({ data: { personId, groupId, source: 'tahesab' } })]
|
||||||
|
: []),
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** گروهی که موقع «ثبت در تهحساب» فرستاده میشود: اولین گروه (به ترتیب گروهها) */
|
||||||
|
async firstGroupName(personId: string): Promise<string | null> {
|
||||||
|
const link = await this.prisma.personGroupLink.findFirst({
|
||||||
|
where: { personId },
|
||||||
|
orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }],
|
||||||
|
select: { group: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
return link?.group.name ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async require(organizationId: string, id: string) {
|
||||||
|
const row = await this.prisma.customerGroup.findFirst({
|
||||||
|
where: { id, organizationId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('گروه پیدا نشد');
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanName(raw: string): string {
|
||||||
|
const name = normalizeGroupName(raw ?? '');
|
||||||
|
if (!name) throw new BadRequestException('نام گروه لازم است');
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanColor(raw?: string): CustomerGroupColor {
|
||||||
|
return (CUSTOMER_GROUP_COLORS as readonly string[]).includes(raw ?? '')
|
||||||
|
? (raw as CustomerGroupColor)
|
||||||
|
: 'teal';
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapError(e: unknown) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
return new ConflictException('گروهی با این نام وجود دارد');
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user