Compare commits
322
Commits
| 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 | ||
|
|
23f8da2c65 | ||
|
|
9325d879a5 | ||
|
|
d62e004e90 | ||
|
|
32c722f42a | ||
|
|
f7b07f6b7a | ||
|
|
52c7306e78 | ||
|
|
960e01938d | ||
|
|
3fb5f32ac3 | ||
|
|
0ce2533066 | ||
|
|
bc68582fe2 | ||
|
|
e86841fc60 | ||
|
|
5069647f79 | ||
|
|
008733d338 | ||
|
|
c5708ed59b | ||
|
|
65d66bb331 | ||
|
|
79ba146118 | ||
|
|
02841a5b8c | ||
|
|
a04743c6df | ||
|
|
27635c0752 | ||
|
|
03e1c01023 | ||
|
|
177e5cc31b | ||
|
|
2c7c943802 | ||
|
|
9611803567 | ||
|
|
a494b5308e | ||
|
|
582733e2ea | ||
|
|
6bff9d2888 | ||
|
|
6bc5e1bec7 | ||
|
|
3a8a684aa4 | ||
|
|
831c682eb3 | ||
|
|
2cc81bb596 | ||
|
|
c04582072d | ||
|
|
42f0573a5f | ||
|
|
13ee70764b | ||
|
|
cf326d1ed9 | ||
|
|
5f4cc47c41 | ||
|
|
0e420a443f | ||
|
|
6b4bd0b03e | ||
|
|
910c7f51b0 | ||
|
|
609ce3d006 | ||
|
|
9bed6cac03 | ||
|
|
fd6e79c15d | ||
|
|
9b42fec0a8 | ||
|
|
d00f362376 | ||
|
|
fd8966e13f | ||
|
|
91eb9be1d4 | ||
|
|
ffae63edbf | ||
|
|
cba4ad6a49 | ||
|
|
faac26d07d | ||
|
|
a2b01f787e | ||
|
|
8af78047d9 | ||
|
|
b43ed68e82 | ||
|
|
f4398cdb3e | ||
|
|
17638786a8 | ||
|
|
b3b92abae9 | ||
|
|
b30f68563d | ||
|
|
51ca23b398 | ||
|
|
60d797f184 | ||
|
|
b87ebb22e0 | ||
|
|
792b3d29fa | ||
|
|
0802c2f436 | ||
|
|
37157620e2 | ||
|
|
f27b4c264a | ||
|
|
e82bd2b2df | ||
|
|
fa3fe0d364 | ||
|
|
4e26658092 | ||
|
|
287f88abc0 | ||
|
|
09f05d4ecc | ||
|
|
46a7693ea0 | ||
|
|
e6755d0c2e | ||
|
|
2f3646d085 | ||
|
|
f5b6a47f3c | ||
|
|
678cb87de7 | ||
|
|
d683932d1b | ||
|
|
13668c4ba4 | ||
|
|
aa2f39256a | ||
|
|
b4ffef9b6f | ||
|
|
b3784818fc | ||
|
|
3de2d0fa67 | ||
|
|
2a2728e5ee | ||
|
|
e08f1452ef | ||
|
|
43717392b2 | ||
|
|
bc01384551 | ||
|
|
5074c6d791 | ||
|
|
0d7555f219 | ||
|
|
f71f09c85d | ||
|
|
f88786ef61 | ||
|
|
46dcf296d3 | ||
|
|
c8753a4c4e | ||
|
|
48de552f36 | ||
|
|
71b3a7255a | ||
|
|
df8fac187e | ||
|
|
3296b1c209 | ||
|
|
23c83ffbee | ||
|
|
75ff2c36c4 | ||
|
|
39e2eb24a6 | ||
|
|
8b37525f2f | ||
|
|
e144b2277a | ||
|
|
236f73de93 | ||
|
|
60c16053a4 | ||
|
|
cbfe470f33 | ||
|
|
ac57b8ac33 | ||
|
|
d3fb92a5e2 | ||
|
|
07bdc4a3b6 | ||
|
|
c629729a9a | ||
|
|
beae16437b | ||
|
|
17628722d3 | ||
|
|
c322a24424 | ||
|
|
2e315c9cfd | ||
|
|
f8313c0ea5 | ||
|
|
3b128bd980 | ||
|
|
9f97371391 | ||
|
|
1309da2281 | ||
|
|
989c03b381 | ||
|
|
5784b09232 | ||
|
|
5d5b3a3213 | ||
|
|
ae21d9bbc7 | ||
|
|
e94600a02e | ||
|
|
75b83b7f1b | ||
|
|
d3986da28b | ||
|
|
c7a60c524d | ||
|
|
3c0ed3369e | ||
|
|
a3ed6187e9 | ||
|
|
e1d456ad11 | ||
|
|
b978708487 | ||
|
|
4c8731122b | ||
|
|
f71e78f3a7 | ||
|
|
7c4b24fad6 | ||
|
|
e6fb5c3700 | ||
|
|
298b3316b4 | ||
|
|
58e89246d9 | ||
|
|
5bb8e68521 | ||
|
|
88dc7c5a1c | ||
|
|
83504816c5 | ||
|
|
c20dc0a7ca | ||
|
|
e20b904bbe | ||
|
|
7996fc7f73 | ||
|
|
70bcd15847 | ||
|
|
b0f05b6a27 | ||
|
|
d4972891f1 | ||
|
|
032eb70524 | ||
|
|
2309e2c5ab | ||
|
|
66af7e577c | ||
|
|
125976f3eb | ||
|
|
0670067e7f | ||
|
|
a1655432fc | ||
|
|
589c763ef8 | ||
|
|
d2c82f2115 | ||
|
|
319493171f | ||
|
|
f20206b045 | ||
|
|
32e27d45f7 | ||
|
|
8effd4cbc6 | ||
|
|
7e73ba9936 | ||
|
|
647503810d | ||
|
|
ec5c7639e3 | ||
|
|
3833788aa8 | ||
|
|
9edbb92b13 | ||
|
|
05f205cadf | ||
|
|
ac3ffb8ab2 | ||
|
|
c079295ff4 | ||
|
|
22980e5152 | ||
|
|
c5c903d4b4 | ||
|
|
c06c92b333 | ||
|
|
00f8b91d78 | ||
|
|
55a287563d | ||
|
|
a16c2f0a9b | ||
|
|
6a9e864b07 | ||
|
|
d133c8ca34 | ||
|
|
db65b55660 | ||
|
|
26085b54a9 | ||
|
|
1b160c8cd3 | ||
|
|
499d13d1df | ||
|
|
ef600d149d | ||
|
|
ccddd009e3 | ||
|
|
6463275c3d | ||
|
|
35a618d0af | ||
|
|
011b316b5b | ||
|
|
b7e3d1e290 | ||
|
|
7dc4344e0e | ||
|
|
99df327ded | ||
|
|
230d7b3bf6 | ||
|
|
58c3e25db6 | ||
|
|
75a73559b5 | ||
|
|
41f0025883 | ||
|
|
42db5919e1 | ||
|
|
d53136d91c | ||
|
|
dac0454e69 | ||
|
|
4b335aa7ff | ||
|
|
17fb33b3e7 | ||
|
|
be97a067f9 | ||
|
|
9249302eac | ||
|
|
5d9a572ff8 | ||
|
|
f4cb41d96f | ||
|
|
7a58b9d469 | ||
|
|
1adc217375 | ||
|
|
d7d82102af | ||
|
|
f5a4eb577b |
@@ -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
|
||||||
+1
-1
@@ -17,7 +17,7 @@ SMS_PASSWORD=
|
|||||||
SMS_SENDER=
|
SMS_SENDER=
|
||||||
# SMS_BASE_URL=http://smspanel.Trez.ir
|
# SMS_BASE_URL=http://smspanel.Trez.ir
|
||||||
# SMS_SEND_PATH=/SendMessageWithCode.ashx
|
# SMS_SEND_PATH=/SendMessageWithCode.ashx
|
||||||
# SMS_OTP_TEMPLATE=کد ورود پرتال منتکت: {code}
|
# SMS_OTP_TEMPLATE=کد ورود منتکت: {code}
|
||||||
# SMS_TIMEOUT_MS=20000
|
# SMS_TIMEOUT_MS=20000
|
||||||
|
|
||||||
# رمزنگاری توکنهای اتصال حسابداری (تهحساب) — ۳۲ بایت بهصورت hex (۶۴ کاراکتر)
|
# رمزنگاری توکنهای اتصال حسابداری (تهحساب) — ۳۲ بایت بهصورت hex (۶۴ کاراکتر)
|
||||||
|
|||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# فعالسازی: npm install (اسکریپت prepare) یا git config core.hooksPath .githooks
|
||||||
|
npm run check
|
||||||
+15
@@ -21,3 +21,18 @@ docs/~$*
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
apps/stt/.venv/
|
apps/stt/.venv/
|
||||||
crm-node_modules.tgz
|
crm-node_modules.tgz
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
e2e/.auth/
|
||||||
|
e2e/.env
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
|
# Graphify (local architecture graph — do not commit)
|
||||||
|
graphify-out/
|
||||||
|
**/graphify-out/
|
||||||
|
.cursor/rules/graphify.mdc
|
||||||
|
|
||||||
|
# Local git worktrees + SDD scratch
|
||||||
|
.worktrees/
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -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 |
@@ -80,7 +80,7 @@ $env:MENTO_DEV_API="http://127.0.0.1:3000"; npm run dev
|
|||||||
|
|
||||||
## فاز فعلی
|
## فاز فعلی
|
||||||
|
|
||||||
فاز ۱ هسته فروش/پشتیبانی و Smart Notes عملاً کامل است. جزئیات تیکخورده و باقیمانده:
|
فاز ۱ هسته فروش/پشتیبانی، Smart Notes، مالی سبک و اتصالها (API، ووکامرس، VoIP، پیامک گروهی) عملاً کامل است. جزئیات تیکخورده و باقیمانده:
|
||||||
|
|
||||||
- [`docs/وضعیت-پیشرفت.md`](docs/وضعیت-پیشرفت.md) ← **چکلیست زنده**
|
- [`docs/وضعیت-پیشرفت.md`](docs/وضعیت-پیشرفت.md) ← **چکلیست زنده**
|
||||||
- [`docs/قابلیتهای-منتکت.md`](docs/قابلیتهای-منتکت.md) ← فهرست کوتاه قابلیتها
|
- [`docs/قابلیتهای-منتکت.md`](docs/قابلیتهای-منتکت.md) ← فهرست کوتاه قابلیتها
|
||||||
|
|||||||
@@ -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-*)` پاکسازی شده است؟
|
||||||
@@ -10,7 +10,9 @@
|
|||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"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",
|
||||||
|
"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,33 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ProductAccountingLink" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
"connectionId" TEXT NOT NULL,
|
||||||
|
"externalName" TEXT NOT NULL,
|
||||||
|
"ayar" INTEGER,
|
||||||
|
"vaznSabet" DOUBLE PRECISION,
|
||||||
|
"matchMethod" TEXT NOT NULL DEFAULT 'import',
|
||||||
|
"linkedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ProductAccountingLink_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProductAccountingLink_productId_idx" ON "ProductAccountingLink"("productId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProductAccountingLink_connectionId_idx" ON "ProductAccountingLink"("connectionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ProductAccountingLink_productId_connectionId_key" ON "ProductAccountingLink"("productId", "connectionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ProductAccountingLink_connectionId_externalName_key" ON "ProductAccountingLink"("connectionId", "externalName");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProductAccountingLink" ADD CONSTRAINT "ProductAccountingLink_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProductAccountingLink" ADD CONSTRAINT "ProductAccountingLink_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "AccountingConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Person" ADD COLUMN "website" TEXT;
|
||||||
|
ALTER TABLE "Person" ADD COLUMN "referrer" TEXT;
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Activity" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ProjectCard" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE IF NOT EXISTS "AuditLog" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"actorUserId" TEXT,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"method" TEXT NOT NULL,
|
||||||
|
"path" TEXT NOT NULL,
|
||||||
|
"entityType" TEXT,
|
||||||
|
"entityId" TEXT,
|
||||||
|
"summary" TEXT NOT NULL,
|
||||||
|
"meta" JSONB,
|
||||||
|
"ip" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE IF NOT EXISTS "SavedView" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"ownerUserId" TEXT,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"scope" TEXT NOT NULL,
|
||||||
|
"filters" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"isShared" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "SavedView_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable (implicit many-to-many ProjectCard <-> User assignees)
|
||||||
|
CREATE TABLE IF NOT EXISTS "_ProjectCardAssignees" (
|
||||||
|
"A" TEXT NOT NULL,
|
||||||
|
"B" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX IF NOT EXISTS "AuditLog_organizationId_createdAt_idx" ON "AuditLog"("organizationId", "createdAt");
|
||||||
|
CREATE INDEX IF NOT EXISTS "AuditLog_organizationId_actorUserId_createdAt_idx" ON "AuditLog"("organizationId", "actorUserId", "createdAt");
|
||||||
|
CREATE INDEX IF NOT EXISTS "AuditLog_organizationId_entityType_createdAt_idx" ON "AuditLog"("organizationId", "entityType", "createdAt");
|
||||||
|
CREATE INDEX IF NOT EXISTS "SavedView_organizationId_scope_idx" ON "SavedView"("organizationId", "scope");
|
||||||
|
CREATE INDEX IF NOT EXISTS "SavedView_organizationId_ownerUserId_idx" ON "SavedView"("organizationId", "ownerUserId");
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "_ProjectCardAssignees_AB_unique" ON "_ProjectCardAssignees"("A", "B");
|
||||||
|
CREATE INDEX IF NOT EXISTS "_ProjectCardAssignees_B_index" ON "_ProjectCardAssignees"("B");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SavedView" ADD CONSTRAINT "SavedView_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "SavedView" ADD CONSTRAINT "SavedView_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "_ProjectCardAssignees" ADD CONSTRAINT "_ProjectCardAssignees_A_fkey" FOREIGN KEY ("A") REFERENCES "ProjectCard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "_ProjectCardAssignees" ADD CONSTRAINT "_ProjectCardAssignees_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "OrganizationApiKey" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"prefix" TEXT NOT NULL,
|
||||||
|
"keyHash" TEXT NOT NULL,
|
||||||
|
"lastUsedAt" TIMESTAMP(3),
|
||||||
|
"revokedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "OrganizationApiKey_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "WebhookEndpoint" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"secret" TEXT NOT NULL,
|
||||||
|
"events" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "WebhookEndpoint_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "WebhookDelivery" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"endpointId" TEXT NOT NULL,
|
||||||
|
"event" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"statusCode" INTEGER,
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lastError" TEXT,
|
||||||
|
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"deliveredAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "WebhookDelivery_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "SmsCampaign" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
"scheduledAt" TIMESTAMP(3),
|
||||||
|
"totalCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"sentCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"failedCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"skippedCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdById" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "SmsCampaign_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "SmsCampaignRecipient" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"campaignId" TEXT NOT NULL,
|
||||||
|
"personId" TEXT,
|
||||||
|
"mobile" TEXT,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"error" TEXT,
|
||||||
|
"sentAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "SmsCampaignRecipient_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "OrganizationApiKey_keyHash_key" ON "OrganizationApiKey"("keyHash");
|
||||||
|
CREATE INDEX "OrganizationApiKey_organizationId_revokedAt_idx" ON "OrganizationApiKey"("organizationId", "revokedAt");
|
||||||
|
CREATE INDEX "OrganizationApiKey_prefix_idx" ON "OrganizationApiKey"("prefix");
|
||||||
|
CREATE INDEX "WebhookEndpoint_organizationId_isActive_idx" ON "WebhookEndpoint"("organizationId", "isActive");
|
||||||
|
CREATE INDEX "WebhookDelivery_organizationId_createdAt_idx" ON "WebhookDelivery"("organizationId", "createdAt");
|
||||||
|
CREATE INDEX "WebhookDelivery_status_nextAttemptAt_idx" ON "WebhookDelivery"("status", "nextAttemptAt");
|
||||||
|
CREATE INDEX "WebhookDelivery_endpointId_createdAt_idx" ON "WebhookDelivery"("endpointId", "createdAt");
|
||||||
|
CREATE INDEX "SmsCampaign_organizationId_status_scheduledAt_idx" ON "SmsCampaign"("organizationId", "status", "scheduledAt");
|
||||||
|
CREATE INDEX "SmsCampaignRecipient_campaignId_status_idx" ON "SmsCampaignRecipient"("campaignId", "status");
|
||||||
|
|
||||||
|
ALTER TABLE "OrganizationApiKey" ADD CONSTRAINT "OrganizationApiKey_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "WebhookEndpoint" ADD CONSTRAINT "WebhookEndpoint_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_endpointId_fkey" FOREIGN KEY ("endpointId") REFERENCES "WebhookEndpoint"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "SmsCampaign" ADD CONSTRAINT "SmsCampaign_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "SmsCampaignRecipient" ADD CONSTRAINT "SmsCampaignRecipient_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "SmsCampaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Membership" ADD COLUMN "dataVisibility" TEXT NOT NULL DEFAULT 'own';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Team" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL DEFAULT '',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TeamMember" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"teamId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"role" TEXT NOT NULL DEFAULT 'member',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Team_organizationId_idx" ON "Team"("organizationId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Team_organizationId_name_key" ON "Team"("organizationId", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TeamMember_userId_idx" ON "TeamMember"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TeamMember_teamId_idx" ON "TeamMember"("teamId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TeamMember_teamId_userId_key" ON "TeamMember"("teamId", "userId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Team" ADD CONSTRAINT "Team_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WooCommerceConnection" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"storeUrl" TEXT NOT NULL,
|
||||||
|
"consumerKeyCiphertext" TEXT NOT NULL,
|
||||||
|
"consumerKeyIv" TEXT NOT NULL,
|
||||||
|
"consumerKeyTag" TEXT NOT NULL,
|
||||||
|
"consumerKeyLast4" TEXT NOT NULL DEFAULT '',
|
||||||
|
"consumerSecretCiphertext" TEXT NOT NULL,
|
||||||
|
"consumerSecretIv" TEXT NOT NULL,
|
||||||
|
"consumerSecretTag" TEXT NOT NULL,
|
||||||
|
"webhookSecretCiphertext" TEXT,
|
||||||
|
"webhookSecretIv" TEXT,
|
||||||
|
"webhookSecretTag" TEXT,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"lastSyncAt" TIMESTAMP(3),
|
||||||
|
"lastHealthAt" TIMESTAMP(3),
|
||||||
|
"lastHealthOk" BOOLEAN,
|
||||||
|
"lastError" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "WooCommerceConnection_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WooCommerceOrderLink" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"connectionId" TEXT NOT NULL,
|
||||||
|
"wooOrderId" TEXT NOT NULL,
|
||||||
|
"wooOrderNumber" TEXT,
|
||||||
|
"invoiceId" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT '',
|
||||||
|
"total" DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'IRR',
|
||||||
|
"rawPayload" JSONB,
|
||||||
|
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "WooCommerceOrderLink_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WooCommerceConnection_organizationId_enabled_idx" ON "WooCommerceConnection"("organizationId", "enabled");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WooCommerceOrderLink_organizationId_syncedAt_idx" ON "WooCommerceOrderLink"("organizationId", "syncedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WooCommerceOrderLink_invoiceId_idx" ON "WooCommerceOrderLink"("invoiceId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "WooCommerceOrderLink_connectionId_wooOrderId_key" ON "WooCommerceOrderLink"("connectionId", "wooOrderId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WooCommerceConnection" ADD CONSTRAINT "WooCommerceConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WooCommerceOrderLink" ADD CONSTRAINT "WooCommerceOrderLink_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "WooCommerceConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VoipConnection" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"webhookSecretCiphertext" TEXT,
|
||||||
|
"webhookSecretIv" TEXT,
|
||||||
|
"webhookSecretTag" TEXT,
|
||||||
|
"clickToCallUrl" TEXT,
|
||||||
|
"lastEventAt" TIMESTAMP(3),
|
||||||
|
"lastError" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "VoipConnection_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VoipCallLog" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"connectionId" TEXT NOT NULL,
|
||||||
|
"externalCallId" TEXT NOT NULL,
|
||||||
|
"direction" TEXT NOT NULL DEFAULT 'inbound',
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'completed',
|
||||||
|
"fromNumber" TEXT,
|
||||||
|
"toNumber" TEXT,
|
||||||
|
"personId" TEXT,
|
||||||
|
"activityId" TEXT,
|
||||||
|
"durationSec" INTEGER,
|
||||||
|
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"endedAt" TIMESTAMP(3),
|
||||||
|
"rawPayload" JSONB,
|
||||||
|
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "VoipCallLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VoipConnection_organizationId_enabled_idx" ON "VoipConnection"("organizationId", "enabled");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VoipCallLog_organizationId_syncedAt_idx" ON "VoipCallLog"("organizationId", "syncedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VoipCallLog_personId_idx" ON "VoipCallLog"("personId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VoipCallLog_activityId_idx" ON "VoipCallLog"("activityId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "VoipCallLog_connectionId_externalCallId_key" ON "VoipCallLog"("connectionId", "externalCallId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VoipConnection" ADD CONSTRAINT "VoipConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VoipCallLog" ADD CONSTRAINT "VoipCallLog_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "VoipConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ReservationService" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL DEFAULT '',
|
||||||
|
"durationMinutes" INTEGER NOT NULL DEFAULT 30,
|
||||||
|
"workDays" INTEGER[] DEFAULT ARRAY[0, 1, 2, 3, 4, 5, 6]::INTEGER[],
|
||||||
|
"dayStartHour" INTEGER NOT NULL DEFAULT 9,
|
||||||
|
"dayEndHour" INTEGER NOT NULL DEFAULT 17,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ReservationService_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ReservationAppointment" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"serviceId" TEXT NOT NULL,
|
||||||
|
"personId" TEXT NOT NULL,
|
||||||
|
"assigneeId" TEXT,
|
||||||
|
"startsAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"endsAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ReservationAppointment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ReservationService_organizationId_isActive_idx" ON "ReservationService"("organizationId", "isActive");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ReservationAppointment_organizationId_startsAt_idx" ON "ReservationAppointment"("organizationId", "startsAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ReservationAppointment_organizationId_status_idx" ON "ReservationAppointment"("organizationId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ReservationAppointment_serviceId_startsAt_idx" ON "ReservationAppointment"("serviceId", "startsAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ReservationAppointment_personId_idx" ON "ReservationAppointment"("personId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ReservationAppointment_assigneeId_startsAt_idx" ON "ReservationAppointment"("assigneeId", "startsAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ReservationService" ADD CONSTRAINT "ReservationService_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "ReservationService"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Note" ADD COLUMN "visibility" TEXT NOT NULL DEFAULT 'team';
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Note_organizationId_visibility_idx" ON "Note"("organizationId", "visibility");
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "NoteRevision" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"noteId" TEXT NOT NULL,
|
||||||
|
"editorId" TEXT,
|
||||||
|
"title" TEXT,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"noteType" TEXT NOT NULL,
|
||||||
|
"visibility" TEXT NOT NULL DEFAULT 'team',
|
||||||
|
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"checklist" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "NoteRevision_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NoteRevision_noteId_createdAt_idx" ON "NoteRevision"("noteId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NoteRevision_organizationId_idx" ON "NoteRevision"("organizationId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NoteRevision_editorId_idx" ON "NoteRevision"("editorId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NoteRevision" ADD CONSTRAINT "NoteRevision_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NoteRevision" ADD CONSTRAINT "NoteRevision_noteId_fkey" FOREIGN KEY ("noteId") REFERENCES "Note"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NoteRevision" ADD CONSTRAINT "NoteRevision_editorId_fkey" FOREIGN KEY ("editorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "_DealAssignees" (
|
||||||
|
"A" TEXT NOT NULL,
|
||||||
|
"B" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "_DealAssignees_AB_pkey" PRIMARY KEY ("A","B")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "_DealAssignees_B_index" ON "_DealAssignees"("B");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_DealAssignees" ADD CONSTRAINT "_DealAssignees_A_fkey" FOREIGN KEY ("A") REFERENCES "Deal"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_DealAssignees" ADD CONSTRAINT "_DealAssignees_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "VoipCallLog" ADD COLUMN "recordingUrl" TEXT;
|
||||||
|
ALTER TABLE "VoipCallLog" ADD COLUMN "transcript" TEXT;
|
||||||
|
ALTER TABLE "VoipCallLog" ADD COLUMN "summary" TEXT;
|
||||||
|
ALTER TABLE "VoipCallLog" ADD COLUMN "summaryStatus" TEXT NOT NULL DEFAULT 'none';
|
||||||
|
ALTER TABLE "VoipCallLog" ADD COLUMN "summaryNoteId" TEXT;
|
||||||
|
ALTER TABLE "VoipCallLog" ADD COLUMN "summaryError" TEXT;
|
||||||
|
ALTER TABLE "VoipCallLog" ADD COLUMN "summaryAttempts" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "VoipCallLog" ADD COLUMN "summaryNextAttemptAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VoipCallLog_summaryStatus_summaryNextAttemptAt_idx" ON "VoipCallLog"("summaryStatus", "summaryNextAttemptAt");
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "LeadStage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"position" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"color" TEXT NOT NULL DEFAULT 'teal',
|
||||||
|
"showOnBoard" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"isConverted" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"isLost" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"isUnqualified" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "LeadStage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Lead" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"contactName" TEXT,
|
||||||
|
"mobile" TEXT NOT NULL,
|
||||||
|
"phone" TEXT,
|
||||||
|
"city" TEXT,
|
||||||
|
"region" TEXT,
|
||||||
|
"trade" TEXT,
|
||||||
|
"source" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"estimatedValue" DECIMAL(18,2),
|
||||||
|
"stageId" TEXT NOT NULL,
|
||||||
|
"position" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"assigneeId" TEXT,
|
||||||
|
"createdById" TEXT,
|
||||||
|
"lastContactAt" TIMESTAMP(3),
|
||||||
|
"nextFollowUpAt" TIMESTAMP(3),
|
||||||
|
"convertedPersonId" TEXT,
|
||||||
|
"convertedAt" TIMESTAMP(3),
|
||||||
|
"archivedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Lead_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "LeadEvent" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"leadId" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"body" TEXT,
|
||||||
|
"meta" JSONB,
|
||||||
|
"actorId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "LeadEvent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "LeadImportJob" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"createdById" TEXT,
|
||||||
|
"fileName" TEXT,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"totalCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"processedCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"successCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"duplicateCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"errorCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"rows" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"errorSamples" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"startedAt" TIMESTAMP(3),
|
||||||
|
"finishedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "LeadImportJob_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "LeadStage_organizationId_position_idx" ON "LeadStage"("organizationId", "position");
|
||||||
|
CREATE UNIQUE INDEX "LeadStage_organizationId_name_key" ON "LeadStage"("organizationId", "name");
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "Lead_organizationId_mobile_key" ON "Lead"("organizationId", "mobile");
|
||||||
|
CREATE INDEX "Lead_organizationId_stageId_position_idx" ON "Lead"("organizationId", "stageId", "position");
|
||||||
|
CREATE INDEX "Lead_organizationId_assigneeId_idx" ON "Lead"("organizationId", "assigneeId");
|
||||||
|
CREATE INDEX "Lead_organizationId_nextFollowUpAt_idx" ON "Lead"("organizationId", "nextFollowUpAt");
|
||||||
|
CREATE INDEX "Lead_organizationId_lastContactAt_idx" ON "Lead"("organizationId", "lastContactAt");
|
||||||
|
CREATE INDEX "Lead_organizationId_archivedAt_idx" ON "Lead"("organizationId", "archivedAt");
|
||||||
|
CREATE INDEX "Lead_organizationId_createdAt_idx" ON "Lead"("organizationId", "createdAt");
|
||||||
|
CREATE INDEX "Lead_organizationId_city_idx" ON "Lead"("organizationId", "city");
|
||||||
|
CREATE INDEX "Lead_organizationId_trade_idx" ON "Lead"("organizationId", "trade");
|
||||||
|
CREATE INDEX "Lead_organizationId_source_idx" ON "Lead"("organizationId", "source");
|
||||||
|
|
||||||
|
CREATE INDEX "LeadEvent_leadId_createdAt_idx" ON "LeadEvent"("leadId", "createdAt");
|
||||||
|
CREATE INDEX "LeadEvent_organizationId_createdAt_idx" ON "LeadEvent"("organizationId", "createdAt");
|
||||||
|
|
||||||
|
CREATE INDEX "LeadImportJob_organizationId_status_createdAt_idx" ON "LeadImportJob"("organizationId", "status", "createdAt");
|
||||||
|
CREATE INDEX "LeadImportJob_status_createdAt_idx" ON "LeadImportJob"("status", "createdAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "LeadStage" ADD CONSTRAINT "LeadStage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Lead" ADD CONSTRAINT "Lead_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Lead" ADD CONSTRAINT "Lead_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "LeadStage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Lead" ADD CONSTRAINT "Lead_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Lead" ADD CONSTRAINT "Lead_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "LeadEvent" ADD CONSTRAINT "LeadEvent_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "LeadEvent" ADD CONSTRAINT "LeadEvent_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "LeadImportJob" ADD CONSTRAINT "LeadImportJob_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Lead" ADD COLUMN IF NOT EXISTS "logoStorageKey" TEXT;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- AlterTable LeadForm: publish → Lead connection
|
||||||
|
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "createLead" BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "leadStageId" TEXT;
|
||||||
|
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "duplicateMode" TEXT NOT NULL DEFAULT 'update';
|
||||||
|
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "viewCount" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "startCount" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "submitCount" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "leadCreateCount" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "duplicateCount" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Existing forms: keep person/deal behavior unless already lead-oriented
|
||||||
|
ALTER TABLE "LeadForm" ALTER COLUMN "createPerson" SET DEFAULT false;
|
||||||
|
ALTER TABLE "LeadForm" ALTER COLUMN "createDeal" SET DEFAULT false;
|
||||||
|
|
||||||
|
-- AlterTable LeadFormSubmission
|
||||||
|
ALTER TABLE "LeadFormSubmission" ADD COLUMN IF NOT EXISTS "leadId" TEXT;
|
||||||
|
ALTER TABLE "LeadFormSubmission" ADD COLUMN IF NOT EXISTS "status" TEXT NOT NULL DEFAULT 'created';
|
||||||
|
ALTER TABLE "LeadFormSubmission" ADD COLUMN IF NOT EXISTS "utm" JSONB;
|
||||||
|
ALTER TABLE "LeadFormSubmission" ADD COLUMN IF NOT EXISTS "meta" JSONB;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "LeadFormSubmission_leadId_idx" ON "LeadFormSubmission"("leadId");
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ConversationSettings" (
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"chatHost" TEXT NOT NULL,
|
||||||
|
"apiKeyCipher" TEXT NOT NULL,
|
||||||
|
"apiKeyIv" TEXT NOT NULL,
|
||||||
|
"apiKeyTag" TEXT NOT NULL,
|
||||||
|
"uploadUrl" TEXT NOT NULL DEFAULT '',
|
||||||
|
"downloadUrl" TEXT NOT NULL DEFAULT '',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ConversationSettings_pkey" PRIMARY KEY ("organizationId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ConversationRoom" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"externalRoomId" TEXT NOT NULL,
|
||||||
|
"kind" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"dmUserMinId" TEXT,
|
||||||
|
"dmUserMaxId" TEXT,
|
||||||
|
"createdByUserId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ConversationRoom_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ConversationRoom_organizationId_externalRoomId_key" ON "ConversationRoom"("organizationId", "externalRoomId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ConversationRoom_organizationId_dmUserMinId_dmUserMaxId_key" ON "ConversationRoom"("organizationId", "dmUserMinId", "dmUserMaxId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ConversationRoom_organizationId_kind_idx" ON "ConversationRoom"("organizationId", "kind");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ConversationSettings" ADD CONSTRAINT "ConversationSettings_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ConversationRoom" ADD CONSTRAINT "ConversationRoom_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ConversationHiddenMessage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"externalRoomId" TEXT NOT NULL,
|
||||||
|
"messageId" TEXT NOT NULL,
|
||||||
|
"hiddenByUserId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ConversationHiddenMessage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ConversationHiddenMessage_organizationId_externalRoomId_idx" ON "ConversationHiddenMessage"("organizationId", "externalRoomId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ConversationHiddenMessage_organizationId_externalRoomId_messageId_key" ON "ConversationHiddenMessage"("organizationId", "externalRoomId", "messageId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ConversationHiddenMessage" ADD CONSTRAINT "ConversationHiddenMessage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ConversationHiddenMessage" ADD COLUMN "scope" TEXT NOT NULL DEFAULT 'all';
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "ConversationHiddenMessage_organizationId_externalRoomId_messageId_key";
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ConversationHiddenMessage_organizationId_externalRoomId_messageId_scope_hiddenByUserId_key" ON "ConversationHiddenMessage"("organizationId", "externalRoomId", "messageId", "scope", "hiddenByUserId");
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ConversationPinnedMessage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"externalRoomId" TEXT NOT NULL,
|
||||||
|
"messageId" TEXT NOT NULL,
|
||||||
|
"preview" TEXT NOT NULL DEFAULT '',
|
||||||
|
"pinnedByUserId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ConversationPinnedMessage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ConversationRoomUserPin" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"externalRoomId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ConversationRoomUserPin_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ConversationPinnedMessage_organizationId_externalRoomId_key" ON "ConversationPinnedMessage"("organizationId", "externalRoomId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ConversationPinnedMessage_organizationId_externalRoomId_idx" ON "ConversationPinnedMessage"("organizationId", "externalRoomId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ConversationRoomUserPin_organizationId_userId_externalRoomId_key" ON "ConversationRoomUserPin"("organizationId", "userId", "externalRoomId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ConversationRoomUserPin_organizationId_userId_idx" ON "ConversationRoomUserPin"("organizationId", "userId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ConversationPinnedMessage" ADD CONSTRAINT "ConversationPinnedMessage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ConversationRoomUserPin" ADD CONSTRAINT "ConversationRoomUserPin_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ConversationRoomMember" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"externalRoomId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"role" TEXT NOT NULL DEFAULT 'member',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ConversationRoomMember_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ConversationRoomMember_organizationId_externalRoomId_userId_key" ON "ConversationRoomMember"("organizationId", "externalRoomId", "userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ConversationRoomMember_organizationId_externalRoomId_idx" ON "ConversationRoomMember"("organizationId", "externalRoomId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ConversationRoomMember" ADD CONSTRAINT "ConversationRoomMember_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- Mute per-user (smart: mentions can still notify)
|
||||||
|
CREATE TABLE "ConversationRoomUserMute" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"externalRoomId" TEXT NOT NULL,
|
||||||
|
"allowMentions" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ConversationRoomUserMute_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "ConversationRoomUserMute_organizationId_userId_externalRoomId_key" ON "ConversationRoomUserMute"("organizationId", "userId", "externalRoomId");
|
||||||
|
CREATE INDEX "ConversationRoomUserMute_organizationId_userId_idx" ON "ConversationRoomUserMute"("organizationId", "userId");
|
||||||
|
ALTER TABLE "ConversationRoomUserMute" ADD CONSTRAINT "ConversationRoomUserMute_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Message emoji reactions
|
||||||
|
CREATE TABLE "ConversationMessageReaction" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"externalRoomId" TEXT NOT NULL,
|
||||||
|
"messageId" TEXT NOT NULL,
|
||||||
|
"emoji" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ConversationMessageReaction_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "ConversationMessageReaction_organizationId_externalRoomId_messageId_userId_emoji_key" ON "ConversationMessageReaction"("organizationId", "externalRoomId", "messageId", "userId", "emoji");
|
||||||
|
CREATE INDEX "ConversationMessageReaction_organizationId_externalRoomId_idx" ON "ConversationMessageReaction"("organizationId", "externalRoomId");
|
||||||
|
ALTER TABLE "ConversationMessageReaction" ADD CONSTRAINT "ConversationMessageReaction_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
-- Extend AutomationTrigger
|
||||||
|
CREATE TYPE "AutomationTrigger_new" AS ENUM (
|
||||||
|
'person_created',
|
||||||
|
'lead_submitted',
|
||||||
|
'deal_created',
|
||||||
|
'deal_stage_changed',
|
||||||
|
'deal_won',
|
||||||
|
'deal_lost',
|
||||||
|
'ticket_created',
|
||||||
|
'ticket_resolved',
|
||||||
|
'invoice_issued',
|
||||||
|
'invoice_paid',
|
||||||
|
'activity_done',
|
||||||
|
'sms_link_clicked'
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "AutomationRule"
|
||||||
|
ALTER COLUMN "trigger" TYPE "AutomationTrigger_new"
|
||||||
|
USING ("trigger"::text::"AutomationTrigger_new");
|
||||||
|
|
||||||
|
ALTER TYPE "AutomationTrigger" RENAME TO "AutomationTrigger_old";
|
||||||
|
ALTER TYPE "AutomationTrigger_new" RENAME TO "AutomationTrigger";
|
||||||
|
DROP TYPE "AutomationTrigger_old";
|
||||||
|
|
||||||
|
-- Extend MessageAutomationTrigger
|
||||||
|
CREATE TYPE "MessageAutomationTrigger_new" AS ENUM (
|
||||||
|
'person_created',
|
||||||
|
'lead_submitted',
|
||||||
|
'deal_created',
|
||||||
|
'deal_stage_changed',
|
||||||
|
'deal_won',
|
||||||
|
'deal_lost',
|
||||||
|
'ticket_created',
|
||||||
|
'ticket_resolved',
|
||||||
|
'invoice_issued',
|
||||||
|
'invoice_paid',
|
||||||
|
'activity_created',
|
||||||
|
'activity_done',
|
||||||
|
'sms_link_clicked'
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "MessageAutomationRule"
|
||||||
|
ALTER COLUMN "trigger" TYPE "MessageAutomationTrigger_new"
|
||||||
|
USING ("trigger"::text::"MessageAutomationTrigger_new");
|
||||||
|
|
||||||
|
ALTER TABLE "MessageAutomationJob"
|
||||||
|
ALTER COLUMN "trigger" TYPE "MessageAutomationTrigger_new"
|
||||||
|
USING ("trigger"::text::"MessageAutomationTrigger_new");
|
||||||
|
|
||||||
|
ALTER TYPE "MessageAutomationTrigger" RENAME TO "MessageAutomationTrigger_old";
|
||||||
|
ALTER TYPE "MessageAutomationTrigger_new" RENAME TO "MessageAutomationTrigger";
|
||||||
|
DROP TYPE "MessageAutomationTrigger_old";
|
||||||
|
|
||||||
|
-- Recipient: optional leadId for future Lead campaigns
|
||||||
|
ALTER TABLE "SmsCampaignRecipient" ADD COLUMN "leadId" TEXT;
|
||||||
|
CREATE INDEX "SmsCampaignRecipient_personId_idx" ON "SmsCampaignRecipient"("personId");
|
||||||
|
CREATE INDEX "SmsCampaignRecipient_leadId_idx" ON "SmsCampaignRecipient"("leadId");
|
||||||
|
|
||||||
|
-- TrackingLink
|
||||||
|
CREATE TABLE "TrackingLink" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"originalUrl" TEXT NOT NULL,
|
||||||
|
"channel" TEXT NOT NULL DEFAULT 'sms',
|
||||||
|
"sourceType" TEXT NOT NULL,
|
||||||
|
"sourceId" TEXT NOT NULL,
|
||||||
|
"recipientType" TEXT,
|
||||||
|
"recipientId" TEXT,
|
||||||
|
"smsRecipientId" TEXT,
|
||||||
|
"personId" TEXT,
|
||||||
|
"leadId" TEXT,
|
||||||
|
"clickCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"firstClickedAt" TIMESTAMP(3),
|
||||||
|
"lastClickedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "TrackingLink_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "TrackingLink_token_key" ON "TrackingLink"("token");
|
||||||
|
CREATE INDEX "TrackingLink_organizationId_sourceType_sourceId_idx" ON "TrackingLink"("organizationId", "sourceType", "sourceId");
|
||||||
|
CREATE INDEX "TrackingLink_organizationId_personId_idx" ON "TrackingLink"("organizationId", "personId");
|
||||||
|
CREATE INDEX "TrackingLink_organizationId_leadId_idx" ON "TrackingLink"("organizationId", "leadId");
|
||||||
|
CREATE INDEX "TrackingLink_smsRecipientId_idx" ON "TrackingLink"("smsRecipientId");
|
||||||
|
CREATE INDEX "TrackingLink_token_idx" ON "TrackingLink"("token");
|
||||||
|
|
||||||
|
ALTER TABLE "TrackingLink" ADD CONSTRAINT "TrackingLink_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "TrackingLink" ADD CONSTRAINT "TrackingLink_smsRecipientId_fkey" FOREIGN KEY ("smsRecipientId") REFERENCES "SmsCampaignRecipient"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "TrackingLink" ADD CONSTRAINT "TrackingLink_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "TrackingLink" ADD CONSTRAINT "TrackingLink_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- TrackingEvent
|
||||||
|
CREATE TABLE "TrackingEvent" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"trackingLinkId" TEXT,
|
||||||
|
"eventType" TEXT NOT NULL,
|
||||||
|
"channel" TEXT NOT NULL DEFAULT 'sms',
|
||||||
|
"sourceType" TEXT,
|
||||||
|
"sourceId" TEXT,
|
||||||
|
"recipientType" TEXT,
|
||||||
|
"recipientId" TEXT,
|
||||||
|
"personId" TEXT,
|
||||||
|
"leadId" TEXT,
|
||||||
|
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"metadata" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "TrackingEvent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "TrackingEvent_organizationId_eventType_occurredAt_idx" ON "TrackingEvent"("organizationId", "eventType", "occurredAt");
|
||||||
|
CREATE INDEX "TrackingEvent_organizationId_sourceType_sourceId_eventType_idx" ON "TrackingEvent"("organizationId", "sourceType", "sourceId", "eventType");
|
||||||
|
CREATE INDEX "TrackingEvent_organizationId_personId_eventType_idx" ON "TrackingEvent"("organizationId", "personId", "eventType");
|
||||||
|
CREATE INDEX "TrackingEvent_organizationId_leadId_eventType_idx" ON "TrackingEvent"("organizationId", "leadId", "eventType");
|
||||||
|
CREATE INDEX "TrackingEvent_trackingLinkId_occurredAt_idx" ON "TrackingEvent"("trackingLinkId", "occurredAt");
|
||||||
|
|
||||||
|
ALTER TABLE "TrackingEvent" ADD CONSTRAINT "TrackingEvent_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "TrackingEvent" ADD CONSTRAINT "TrackingEvent_trackingLinkId_fkey" FOREIGN KEY ("trackingLinkId") REFERENCES "TrackingLink"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "TrackingEvent" ADD CONSTRAINT "TrackingEvent_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "TrackingEvent" ADD CONSTRAINT "TrackingEvent_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "instagram" TEXT;
|
||||||
|
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "addresses" JSONB NOT NULL DEFAULT '[]';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Lead" ADD COLUMN IF NOT EXISTS "instagram" TEXT;
|
||||||
|
ALTER TABLE "Lead" ADD COLUMN IF NOT EXISTS "addresses" JSONB NOT NULL DEFAULT '[]';
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- OrgRole hierarchy (parent / child sub-roles)
|
||||||
|
ALTER TABLE "OrgRole" ADD COLUMN IF NOT EXISTS "parentId" TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "OrgRole_parentId_idx" ON "OrgRole"("parentId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "OrgRole_organizationId_parentId_idx" ON "OrgRole"("organizationId", "parentId");
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'OrgRole_parentId_fkey'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "OrgRole"
|
||||||
|
ADD CONSTRAINT "OrgRole_parentId_fkey"
|
||||||
|
FOREIGN KEY ("parentId") REFERENCES "OrgRole"("id")
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- Multi-assignee support for activities (implicit many-to-many)
|
||||||
|
CREATE TABLE IF NOT EXISTS "_ActivityAssignees" (
|
||||||
|
"A" TEXT NOT NULL,
|
||||||
|
"B" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "_ActivityAssignees_AB_unique" ON "_ActivityAssignees"("A", "B");
|
||||||
|
CREATE INDEX IF NOT EXISTS "_ActivityAssignees_B_index" ON "_ActivityAssignees"("B");
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = '_ActivityAssignees_A_fkey'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "_ActivityAssignees"
|
||||||
|
ADD CONSTRAINT "_ActivityAssignees_A_fkey"
|
||||||
|
FOREIGN KEY ("A") REFERENCES "Activity"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = '_ActivityAssignees_B_fkey'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "_ActivityAssignees"
|
||||||
|
ADD CONSTRAINT "_ActivityAssignees_B_fkey"
|
||||||
|
FOREIGN KEY ("B") REFERENCES "User"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Backfill from legacy single assigneeId
|
||||||
|
INSERT INTO "_ActivityAssignees" ("A", "B")
|
||||||
|
SELECT a."id", a."assigneeId"
|
||||||
|
FROM "Activity" a
|
||||||
|
WHERE a."assigneeId" IS NOT NULL
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "previousFirstName" TEXT;
|
||||||
|
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "previousLastName" TEXT;
|
||||||
|
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "importSource" TEXT NOT NULL DEFAULT 'manual';
|
||||||
|
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "sourceRef" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX IF NOT EXISTS "Person_organizationId_importSource_idx" ON "Person"("organizationId", "importSource");
|
||||||
|
CREATE INDEX IF NOT EXISTS "Person_organizationId_sourceRef_idx" ON "Person"("organizationId", "sourceRef");
|
||||||
|
|
||||||
|
-- Backfill from legacy customFields / notes
|
||||||
|
UPDATE "Person"
|
||||||
|
SET
|
||||||
|
"importSource" = 'didar',
|
||||||
|
"sourceRef" = COALESCE(NULLIF(BTRIM("sourceRef"), ''), NULLIF(BTRIM("customFields"->>'didarId'), ''))
|
||||||
|
WHERE COALESCE(BTRIM("customFields"->>'didarId'), '') <> '';
|
||||||
|
|
||||||
|
UPDATE "Person"
|
||||||
|
SET "importSource" = 'tahesab'
|
||||||
|
WHERE "importSource" = 'manual'
|
||||||
|
AND notes ILIKE '%تهحساب%';
|
||||||
|
|
||||||
|
UPDATE "Person"
|
||||||
|
SET "importSource" = 'excel'
|
||||||
|
WHERE "importSource" = 'manual'
|
||||||
|
AND COALESCE(BTRIM("customFields"->>'customerCode'), '') <> ''
|
||||||
|
AND COALESCE(BTRIM("customFields"->>'didarId'), '') = '';
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE IF NOT EXISTS "UserTablePreference" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"tableKey" TEXT NOT NULL,
|
||||||
|
"hiddenColumns" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "UserTablePreference_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UserTablePreference_userId_organizationId_tableKey_key" ON "UserTablePreference"("userId", "organizationId", "tableKey");
|
||||||
|
CREATE INDEX IF NOT EXISTS "UserTablePreference_userId_organizationId_idx" ON "UserTablePreference"("userId", "organizationId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "UserTablePreference" ADD CONSTRAINT "UserTablePreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "previousImportSource" TEXT;
|
||||||
|
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "previousSourceRef" TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "Person_organizationId_previousSourceRef_idx" ON "Person"("organizationId", "previousSourceRef");
|
||||||
|
|
||||||
|
-- اشخاصی که قبلاً از دیدار آمدهاند و بعد با تهحساب بازنویسی شدهاند: کد دیدار را بهعنوان کد قبلی بازیابی کن
|
||||||
|
UPDATE "Person"
|
||||||
|
SET "previousImportSource" = 'didar',
|
||||||
|
"previousSourceRef" = TRIM("customFields"->>'didarId')
|
||||||
|
WHERE "importSource" = 'tahesab'
|
||||||
|
AND "previousSourceRef" IS NULL
|
||||||
|
AND "customFields" IS NOT NULL
|
||||||
|
AND jsonb_typeof("customFields") = 'object'
|
||||||
|
AND COALESCE(TRIM("customFields"->>'didarId'), '') <> '';
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "code" TEXT;
|
||||||
|
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "importSource" TEXT NOT NULL DEFAULT 'manual';
|
||||||
|
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "sourceRef" TEXT;
|
||||||
|
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "economicCode" TEXT;
|
||||||
|
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "registrationNumber" TEXT;
|
||||||
|
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "otherPhones" TEXT;
|
||||||
|
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "fax" TEXT;
|
||||||
|
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "otherEmails" TEXT;
|
||||||
|
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "province" TEXT;
|
||||||
|
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "city" TEXT;
|
||||||
|
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "postalCode" TEXT;
|
||||||
|
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "groupName" TEXT;
|
||||||
|
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "registeredAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "Company_organizationId_sourceRef_idx" ON "Company"("organizationId", "sourceRef");
|
||||||
|
CREATE INDEX IF NOT EXISTS "Company_organizationId_code_idx" ON "Company"("organizationId", "code");
|
||||||
|
|
||||||
|
-- شرکتهایی که از ورود اشخاص دیدار ساخته شدهاند: کد دیدار شرکت را از customFields اشخاص بازیابی کن
|
||||||
|
UPDATE "Company" c
|
||||||
|
SET "sourceRef" = sub.didar_code,
|
||||||
|
"importSource" = 'didar'
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (p."companyId")
|
||||||
|
p."companyId" AS company_id,
|
||||||
|
TRIM(p."customFields"->>'companyDidarId') AS didar_code
|
||||||
|
FROM "Person" p
|
||||||
|
WHERE p."companyId" IS NOT NULL
|
||||||
|
AND jsonb_typeof(p."customFields") = 'object'
|
||||||
|
AND COALESCE(TRIM(p."customFields"->>'companyDidarId'), '') <> ''
|
||||||
|
ORDER BY p."companyId", p."createdAt"
|
||||||
|
) sub
|
||||||
|
WHERE c."id" = sub.company_id
|
||||||
|
AND c."sourceRef" IS NULL;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "Activity" ADD COLUMN IF NOT EXISTS "reminderSentAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "Activity_reminderAt_reminderSentAt_idx" ON "Activity"("reminderAt", "reminderSentAt");
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "Membership" ADD COLUMN IF NOT EXISTS "dataVisibilityScopes" JSONB NOT NULL DEFAULT '{}';
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "code" TEXT;
|
||||||
|
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "importSource" TEXT NOT NULL DEFAULT 'manual';
|
||||||
|
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "sourceRef" TEXT;
|
||||||
|
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "probability" INTEGER;
|
||||||
|
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "sourceDetail" TEXT;
|
||||||
|
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "referrer" TEXT;
|
||||||
|
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "lostNote" TEXT;
|
||||||
|
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "closedAt" TIMESTAMP(3);
|
||||||
|
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "createdById" TEXT;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Deal" ADD CONSTRAINT "Deal_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "Deal_organizationId_sourceRef_idx" ON "Deal"("organizationId", "sourceRef");
|
||||||
|
|
||||||
|
UPDATE "Deal"
|
||||||
|
SET "importSource" = 'didar', "sourceRef" = "customFields"->>'didarId'
|
||||||
|
WHERE "sourceRef" IS NULL AND COALESCE("customFields"->>'didarId', '') <> '';
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE IF NOT EXISTS "MontifyConnection" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"baseUrl" TEXT NOT NULL,
|
||||||
|
"storeId" TEXT NOT NULL DEFAULT '',
|
||||||
|
"tokenCiphertext" TEXT NOT NULL,
|
||||||
|
"tokenIv" TEXT NOT NULL,
|
||||||
|
"tokenTag" TEXT NOT NULL,
|
||||||
|
"tokenLast4" TEXT NOT NULL DEFAULT '',
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"webhookRemoteId" TEXT,
|
||||||
|
"webhookSecretCiphertext" TEXT,
|
||||||
|
"webhookSecretIv" TEXT,
|
||||||
|
"webhookSecretTag" TEXT,
|
||||||
|
"customersCursor" TIMESTAMP(3),
|
||||||
|
"syncStatus" TEXT NOT NULL DEFAULT 'idle',
|
||||||
|
"syncStartedAt" TIMESTAMP(3),
|
||||||
|
"lastSyncAt" TIMESTAMP(3),
|
||||||
|
"lastSyncResult" JSONB,
|
||||||
|
"lastHealthAt" TIMESTAMP(3),
|
||||||
|
"lastHealthOk" BOOLEAN,
|
||||||
|
"lastError" TEXT,
|
||||||
|
"lastWebhookAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "MontifyConnection_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE IF NOT EXISTS "MontifyLink" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"connectionId" TEXT NOT NULL,
|
||||||
|
"entityType" TEXT NOT NULL,
|
||||||
|
"externalId" TEXT NOT NULL,
|
||||||
|
"entityId" TEXT NOT NULL,
|
||||||
|
"externalUpdatedAt" TIMESTAMP(3),
|
||||||
|
"ordersSyncedAt" TIMESTAMP(3),
|
||||||
|
"data" JSONB,
|
||||||
|
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "MontifyLink_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE IF NOT EXISTS "MontifyOrder" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"connectionId" TEXT NOT NULL,
|
||||||
|
"externalId" TEXT NOT NULL,
|
||||||
|
"customerExternalId" TEXT,
|
||||||
|
"personId" TEXT,
|
||||||
|
"orderReference" TEXT,
|
||||||
|
"orderStatus" TEXT,
|
||||||
|
"paymentStatus" TEXT,
|
||||||
|
"paymentType" TEXT,
|
||||||
|
"totalPrice" DECIMAL(20,2),
|
||||||
|
"discountTotalPrice" DECIMAL(20,2),
|
||||||
|
"totalDiscounts" DECIMAL(20,2),
|
||||||
|
"totalTax" DECIMAL(20,2),
|
||||||
|
"totalQty" DECIMAL(14,3),
|
||||||
|
"profit" DECIMAL(20,2),
|
||||||
|
"percentProfit" DECIMAL(10,3),
|
||||||
|
"sumGold" DECIMAL(14,3),
|
||||||
|
"sumSilver" DECIMAL(14,3),
|
||||||
|
"address" JSONB,
|
||||||
|
"note" TEXT,
|
||||||
|
"cancelReason" TEXT,
|
||||||
|
"cancelledAt" TIMESTAMP(3),
|
||||||
|
"closedAt" TIMESTAMP(3),
|
||||||
|
"codeDiscount" TEXT,
|
||||||
|
"items" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"externalCreatedAt" TIMESTAMP(3),
|
||||||
|
"externalUpdatedAt" TIMESTAMP(3),
|
||||||
|
"deletedAt" TIMESTAMP(3),
|
||||||
|
"raw" JSONB,
|
||||||
|
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "MontifyOrder_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE IF NOT EXISTS "MontifyTransaction" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"connectionId" TEXT NOT NULL,
|
||||||
|
"externalId" TEXT NOT NULL,
|
||||||
|
"customerExternalId" TEXT,
|
||||||
|
"personId" TEXT,
|
||||||
|
"type" TEXT,
|
||||||
|
"amount" DECIMAL(24,4),
|
||||||
|
"currency" TEXT,
|
||||||
|
"status" TEXT,
|
||||||
|
"referenceCode" TEXT,
|
||||||
|
"externalCreatedAt" TIMESTAMP(3),
|
||||||
|
"raw" JSONB,
|
||||||
|
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "MontifyTransaction_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE IF NOT EXISTS "MontifyWebhookDelivery" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"connectionId" TEXT NOT NULL,
|
||||||
|
"deliveryId" TEXT NOT NULL,
|
||||||
|
"event" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'received',
|
||||||
|
"error" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "MontifyWebhookDelivery_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX IF NOT EXISTS "MontifyConnection_organizationId_enabled_idx" ON "MontifyConnection"("organizationId", "enabled");
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "MontifyLink_connectionId_entityType_externalId_key" ON "MontifyLink"("connectionId", "entityType", "externalId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "MontifyLink_organizationId_entityType_entityId_idx" ON "MontifyLink"("organizationId", "entityType", "entityId");
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "MontifyOrder_connectionId_externalId_key" ON "MontifyOrder"("connectionId", "externalId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "MontifyOrder_organizationId_personId_idx" ON "MontifyOrder"("organizationId", "personId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "MontifyOrder_organizationId_externalCreatedAt_idx" ON "MontifyOrder"("organizationId", "externalCreatedAt");
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "MontifyTransaction_connectionId_externalId_key" ON "MontifyTransaction"("connectionId", "externalId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "MontifyTransaction_organizationId_personId_idx" ON "MontifyTransaction"("organizationId", "personId");
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "MontifyWebhookDelivery_connectionId_deliveryId_key" ON "MontifyWebhookDelivery"("connectionId", "deliveryId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "MontifyWebhookDelivery_connectionId_createdAt_idx" ON "MontifyWebhookDelivery"("connectionId", "createdAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MontifyConnection" ADD CONSTRAINT "MontifyConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MontifyLink" ADD CONSTRAINT "MontifyLink_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MontifyConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MontifyOrder" ADD CONSTRAINT "MontifyOrder_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MontifyConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MontifyTransaction" ADD CONSTRAINT "MontifyTransaction_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MontifyConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "MontifyWebhookDelivery" ADD CONSTRAINT "MontifyWebhookDelivery_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MontifyConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
-- ماژول جدید برای محیطهایی که فهرست ماژول ذخیرهشده دارند و ووکامرس فعال است
|
||||||
|
UPDATE "Organization"
|
||||||
|
SET "enabledModules" = array_append("enabledModules", 'montify')
|
||||||
|
WHERE cardinality("enabledModules") > 0
|
||||||
|
AND 'woocommerce' = ANY("enabledModules")
|
||||||
|
AND NOT ('montify' = ANY("enabledModules"));
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RoutineTaskStatus" AS ENUM ('todo', 'in_progress', 'completed', 'overdue', 'skipped', 'cancelled');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RoutineRecurrence" AS ENUM ('daily', 'weekdays', 'weekly', 'biweekly', 'monthly', 'every_n_days', 'specific_dates');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RoutineTaskSource" AS ENUM ('routine', 'manual', 'follow_up', 'lead', 'customer', 'deal');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RoutineCategory" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"color" TEXT NOT NULL DEFAULT '#14b8a6',
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "RoutineCategory_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Routine" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"assigneeId" TEXT,
|
||||||
|
"teamId" TEXT,
|
||||||
|
"categoryId" TEXT,
|
||||||
|
"priority" "ActivityPriority" NOT NULL DEFAULT 'medium',
|
||||||
|
"timeOfDay" TEXT NOT NULL DEFAULT '09:00',
|
||||||
|
"durationMinutes" INTEGER,
|
||||||
|
"startDate" DATE NOT NULL,
|
||||||
|
"endDate" DATE,
|
||||||
|
"recurrence" "RoutineRecurrence" NOT NULL DEFAULT 'daily',
|
||||||
|
"weekdays" INTEGER[] DEFAULT ARRAY[]::INTEGER[],
|
||||||
|
"intervalDays" INTEGER,
|
||||||
|
"monthDay" INTEGER,
|
||||||
|
"specificDates" DATE[],
|
||||||
|
"isMandatory" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"allowSkip" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"skipReasonRequired" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"reportRequired" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"allowAfterDeadline" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"carryOverOverdue" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"entityLink" TEXT NOT NULL DEFAULT 'none',
|
||||||
|
"reminderMinutesBefore" INTEGER,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdById" TEXT,
|
||||||
|
"deletedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Routine_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RoutineTask" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"routineId" TEXT,
|
||||||
|
"assigneeId" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"categoryId" TEXT,
|
||||||
|
"priority" "ActivityPriority" NOT NULL DEFAULT 'medium',
|
||||||
|
"status" "RoutineTaskStatus" NOT NULL DEFAULT 'todo',
|
||||||
|
"scheduledFor" DATE NOT NULL,
|
||||||
|
"dueAt" TIMESTAMP(3),
|
||||||
|
"startedAt" TIMESTAMP(3),
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"resultNote" TEXT,
|
||||||
|
"skipReason" TEXT,
|
||||||
|
"source" "RoutineTaskSource" NOT NULL DEFAULT 'manual',
|
||||||
|
"isMandatory" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"leadId" TEXT,
|
||||||
|
"personId" TEXT,
|
||||||
|
"companyId" TEXT,
|
||||||
|
"dealId" TEXT,
|
||||||
|
"invoiceId" TEXT,
|
||||||
|
"activityId" TEXT,
|
||||||
|
"reminderAt" TIMESTAMP(3),
|
||||||
|
"reminderSentAt" TIMESTAMP(3),
|
||||||
|
"overdueNotifiedAt" TIMESTAMP(3),
|
||||||
|
"createdById" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "RoutineTask_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RoutineTaskEvent" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"taskId" TEXT NOT NULL,
|
||||||
|
"actorId" TEXT,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"fromStatus" "RoutineTaskStatus",
|
||||||
|
"toStatus" "RoutineTaskStatus",
|
||||||
|
"note" TEXT,
|
||||||
|
"meta" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "RoutineTaskEvent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RoutineUserSettings" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"remindBeforeMinutes" INTEGER DEFAULT 30,
|
||||||
|
"endOfDaySummary" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"overdueAlert" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"summaryHour" INTEGER NOT NULL DEFAULT 16,
|
||||||
|
"lastSummaryOn" DATE,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "RoutineUserSettings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RoutineCategory_organizationId_sortOrder_idx" ON "RoutineCategory"("organizationId", "sortOrder");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "RoutineCategory_organizationId_name_key" ON "RoutineCategory"("organizationId", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Routine_organizationId_isActive_deletedAt_idx" ON "Routine"("organizationId", "isActive", "deletedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Routine_assigneeId_idx" ON "Routine"("assigneeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Routine_teamId_idx" ON "Routine"("teamId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RoutineTask_organizationId_assigneeId_scheduledFor_idx" ON "RoutineTask"("organizationId", "assigneeId", "scheduledFor");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RoutineTask_organizationId_status_dueAt_idx" ON "RoutineTask"("organizationId", "status", "dueAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RoutineTask_reminderAt_reminderSentAt_idx" ON "RoutineTask"("reminderAt", "reminderSentAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RoutineTask_leadId_idx" ON "RoutineTask"("leadId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RoutineTask_personId_idx" ON "RoutineTask"("personId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RoutineTask_companyId_idx" ON "RoutineTask"("companyId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RoutineTask_dealId_idx" ON "RoutineTask"("dealId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RoutineTask_activityId_idx" ON "RoutineTask"("activityId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "RoutineTask_routineId_assigneeId_scheduledFor_key" ON "RoutineTask"("routineId", "assigneeId", "scheduledFor");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RoutineTaskEvent_taskId_createdAt_idx" ON "RoutineTaskEvent"("taskId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RoutineTaskEvent_organizationId_createdAt_idx" ON "RoutineTaskEvent"("organizationId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "RoutineUserSettings_organizationId_userId_key" ON "RoutineUserSettings"("organizationId", "userId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RoutineCategory" ADD CONSTRAINT "RoutineCategory_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Routine" ADD CONSTRAINT "Routine_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Routine" ADD CONSTRAINT "Routine_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Routine" ADD CONSTRAINT "Routine_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Routine" ADD CONSTRAINT "Routine_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "RoutineCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Routine" ADD CONSTRAINT "Routine_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_routineId_fkey" FOREIGN KEY ("routineId") REFERENCES "Routine"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "RoutineCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_activityId_fkey" FOREIGN KEY ("activityId") REFERENCES "Activity"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RoutineTaskEvent" ADD CONSTRAINT "RoutineTaskEvent_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RoutineTaskEvent" ADD CONSTRAINT "RoutineTaskEvent_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "RoutineTask"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RoutineTaskEvent" ADD CONSTRAINT "RoutineTaskEvent_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RoutineUserSettings" ADD CONSTRAINT "RoutineUserSettings_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RoutineUserSettings" ADD CONSTRAINT "RoutineUserSettings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
-- Enable the new routines module for organizations with an explicit module list
|
||||||
|
UPDATE "Organization"
|
||||||
|
SET "enabledModules" = array_append("enabledModules", 'routines')
|
||||||
|
WHERE cardinality("enabledModules") > 0 AND NOT ('routines' = ANY("enabledModules"));
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Rename the default sales pipeline: «کاریز فروش» → «مسیر فروش»
|
||||||
|
UPDATE "Pipeline" SET "name" = 'مسیر فروش' WHERE "name" = 'کاریز فروش';
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ActivityStage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"position" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"color" TEXT NOT NULL DEFAULT 'green',
|
||||||
|
"kind" "ActivityStatus" NOT NULL DEFAULT 'open',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ActivityStage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Activity" ADD COLUMN "stageId" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ActivityStage_organizationId_name_key" ON "ActivityStage"("organizationId", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ActivityStage_organizationId_position_idx" ON "ActivityStage"("organizationId", "position");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Activity_stageId_idx" ON "Activity"("stageId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ActivityStage" ADD CONSTRAINT "ActivityStage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "ActivityStage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Activity" ADD COLUMN "fields" JSONB,
|
||||||
|
ADD COLUMN "participantIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
ADD COLUMN "typeDefId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ActivityTypeDef" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"baseType" "ActivityType" NOT NULL DEFAULT 'task',
|
||||||
|
"icon" TEXT NOT NULL DEFAULT 'check',
|
||||||
|
"color" TEXT NOT NULL DEFAULT 'green',
|
||||||
|
"fields" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"position" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"isActive" 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 "ActivityTypeDef_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ActivityTypeDef_organizationId_position_idx" ON "ActivityTypeDef"("organizationId", "position");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ActivityTypeDef_organizationId_name_key" ON "ActivityTypeDef"("organizationId", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Activity_typeDefId_idx" ON "Activity"("typeDefId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ActivityTypeDef" ADD CONSTRAINT "ActivityTypeDef_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_typeDefId_fkey" FOREIGN KEY ("typeDefId") REFERENCES "ActivityTypeDef"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GamificationSettings" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"levels" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"monthlyGoal" INTEGER NOT NULL DEFAULT 1500,
|
||||||
|
"showFullLeaderboard" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"enabledAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "GamificationSettings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GamificationRule" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"event" TEXT NOT NULL,
|
||||||
|
"score" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"coins" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"conditions" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"maxPerDay" INTEGER,
|
||||||
|
"maxCoinsPerDay" INTEGER,
|
||||||
|
"maxPerUser" INTEGER,
|
||||||
|
"cooldownMinutes" INTEGER,
|
||||||
|
"oncePerEntity" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"firstTimeOnly" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"requiresApproval" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"roles" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"teamIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"position" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "GamificationRule_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GamificationTransaction" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"kind" TEXT NOT NULL,
|
||||||
|
"event" TEXT,
|
||||||
|
"ruleId" TEXT,
|
||||||
|
"bonusId" TEXT,
|
||||||
|
"badgeId" TEXT,
|
||||||
|
"score" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"coins" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'approved',
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"entityType" TEXT,
|
||||||
|
"entityId" TEXT,
|
||||||
|
"entityTitle" TEXT,
|
||||||
|
"idempotencyKey" TEXT,
|
||||||
|
"reason" TEXT,
|
||||||
|
"createdById" TEXT,
|
||||||
|
"decidedById" TEXT,
|
||||||
|
"decidedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "GamificationTransaction_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GamificationWallet" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"score" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"coins" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"coinsEarned" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"bestMonthScore" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "GamificationWallet_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GamificationReward" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"cost" INTEGER NOT NULL,
|
||||||
|
"icon" TEXT NOT NULL DEFAULT 'gift',
|
||||||
|
"color" TEXT NOT NULL DEFAULT '#14b8a6',
|
||||||
|
"stock" INTEGER,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"requiresApproval" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"position" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "GamificationReward_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GamificationRedemption" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"rewardId" TEXT NOT NULL,
|
||||||
|
"cost" INTEGER NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"note" TEXT,
|
||||||
|
"decisionNote" TEXT,
|
||||||
|
"decidedById" TEXT,
|
||||||
|
"decidedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "GamificationRedemption_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GamificationBadge" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"icon" TEXT NOT NULL DEFAULT 'medal',
|
||||||
|
"color" TEXT NOT NULL DEFAULT '#f59e0b',
|
||||||
|
"metric" TEXT NOT NULL,
|
||||||
|
"event" TEXT,
|
||||||
|
"threshold" INTEGER NOT NULL,
|
||||||
|
"coins" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"position" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "GamificationBadge_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GamificationUserBadge" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"badgeId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"earnedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "GamificationUserBadge_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GamificationBonus" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"kind" TEXT NOT NULL,
|
||||||
|
"event" TEXT,
|
||||||
|
"threshold" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"period" TEXT NOT NULL DEFAULT 'month',
|
||||||
|
"score" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"coins" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "GamificationBonus_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "GamificationSettings_organizationId_key" ON "GamificationSettings"("organizationId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "GamificationRule_organizationId_event_isActive_idx" ON "GamificationRule"("organizationId", "event", "isActive");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "GamificationTransaction_organizationId_createdAt_idx" ON "GamificationTransaction"("organizationId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "GamificationTransaction_organizationId_userId_createdAt_idx" ON "GamificationTransaction"("organizationId", "userId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "GamificationTransaction_organizationId_ruleId_userId_create_idx" ON "GamificationTransaction"("organizationId", "ruleId", "userId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "GamificationTransaction_organizationId_status_idx" ON "GamificationTransaction"("organizationId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "GamificationTransaction_organizationId_idempotencyKey_key" ON "GamificationTransaction"("organizationId", "idempotencyKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "GamificationWallet_organizationId_score_idx" ON "GamificationWallet"("organizationId", "score");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "GamificationWallet_organizationId_userId_key" ON "GamificationWallet"("organizationId", "userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "GamificationReward_organizationId_isActive_idx" ON "GamificationReward"("organizationId", "isActive");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "GamificationRedemption_organizationId_status_idx" ON "GamificationRedemption"("organizationId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "GamificationRedemption_organizationId_userId_idx" ON "GamificationRedemption"("organizationId", "userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "GamificationBadge_organizationId_isActive_idx" ON "GamificationBadge"("organizationId", "isActive");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "GamificationUserBadge_organizationId_userId_idx" ON "GamificationUserBadge"("organizationId", "userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "GamificationUserBadge_badgeId_userId_key" ON "GamificationUserBadge"("badgeId", "userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "GamificationBonus_organizationId_isActive_idx" ON "GamificationBonus"("organizationId", "isActive");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GamificationSettings" ADD CONSTRAINT "GamificationSettings_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GamificationRule" ADD CONSTRAINT "GamificationRule_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GamificationTransaction" ADD CONSTRAINT "GamificationTransaction_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GamificationWallet" ADD CONSTRAINT "GamificationWallet_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GamificationReward" ADD CONSTRAINT "GamificationReward_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GamificationRedemption" ADD CONSTRAINT "GamificationRedemption_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GamificationRedemption" ADD CONSTRAINT "GamificationRedemption_rewardId_fkey" FOREIGN KEY ("rewardId") REFERENCES "GamificationReward"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GamificationBadge" ADD CONSTRAINT "GamificationBadge_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GamificationUserBadge" ADD CONSTRAINT "GamificationUserBadge_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GamificationUserBadge" ADD CONSTRAINT "GamificationUserBadge_badgeId_fkey" FOREIGN KEY ("badgeId") REFERENCES "GamificationBadge"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GamificationBonus" ADD CONSTRAINT "GamificationBonus_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Enable the new module for orgs with an explicit module list
|
||||||
|
UPDATE "Organization"
|
||||||
|
SET "enabledModules" = array_append("enabledModules", 'gamification')
|
||||||
|
WHERE cardinality("enabledModules") > 0 AND NOT ('gamification' = ANY("enabledModules"));
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Activity" ADD COLUMN "leadId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AutomationCursor" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"ruleId" TEXT NOT NULL,
|
||||||
|
"stepId" TEXT NOT NULL,
|
||||||
|
"counter" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AutomationCursor_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Activity_leadId_idx" ON "Activity"("leadId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AutomationCursor_ruleId_stepId_key" ON "AutomationCursor"("ruleId", "stepId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AutomationCursor" ADD CONSTRAINT "AutomationCursor_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "AutomationRule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Person" ADD COLUMN "birthDate" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Lead" ADD COLUMN "birthDate" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
ALTER TABLE "Organization" ADD COLUMN "deletionRetention" TEXT NOT NULL DEFAULT 'never';
|
||||||
|
|
||||||
|
CREATE TABLE "DeletionArchive" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"model" TEXT NOT NULL,
|
||||||
|
"entityId" TEXT NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"snapshot" JSONB NOT NULL,
|
||||||
|
"deletedById" TEXT,
|
||||||
|
"deletedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "DeletionArchive_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "DeletionArchive_organizationId_deletedAt_idx" ON "DeletionArchive"("organizationId", "deletedAt");
|
||||||
|
CREATE INDEX "DeletionArchive_organizationId_model_idx" ON "DeletionArchive"("organizationId", "model");
|
||||||
|
|
||||||
|
ALTER TABLE "DeletionArchive" ADD CONSTRAINT "DeletionArchive_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "DeletionArchive" ADD CONSTRAINT "DeletionArchive_deletedById_fkey" FOREIGN KEY ("deletedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- بایگانی اطلاعات قبل: مرز محیط، مجوز عضو، و منبع فعالیت
|
||||||
|
ALTER TABLE "Organization" ADD COLUMN "priorArchiveEnabledAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
ALTER TABLE "Membership" ADD COLUMN "priorArchiveEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE "Membership" ADD COLUMN "priorArchiveLens" TEXT NOT NULL DEFAULT 'current';
|
||||||
|
|
||||||
|
ALTER TABLE "Activity" ADD COLUMN "importSource" TEXT NOT NULL DEFAULT 'manual';
|
||||||
|
|
||||||
|
CREATE INDEX "Activity_organizationId_importSource_idx" ON "Activity"("organizationId", "importSource");
|
||||||
|
|
||||||
|
-- فعالیت وصل به معاملهٔ دیدار از همین حالا قبلی است. بقیه بعد از روشن شدن ماژول و با تاریخ مرز پر میشوند.
|
||||||
|
UPDATE "Activity" AS a
|
||||||
|
SET "importSource" = 'didar'
|
||||||
|
WHERE a."importSource" = 'manual'
|
||||||
|
AND a."dealId" IS NOT NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM "Deal" AS d
|
||||||
|
WHERE d."id" = a."dealId" AND d."importSource" = 'didar'
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Prior archive: promote tag + cutoff query indexes
|
||||||
|
ALTER TABLE "Deal" ADD COLUMN "promotedFromPriorAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
ALTER TABLE "Activity" ADD COLUMN "promotedFromPriorAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
CREATE INDEX "Deal_organizationId_createdAt_idx" ON "Deal"("organizationId", "createdAt");
|
||||||
|
|
||||||
|
CREATE INDEX "Activity_organizationId_createdAt_idx" ON "Activity"("organizationId", "createdAt");
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "OutboundSmsLog" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"personId" TEXT,
|
||||||
|
"leadId" TEXT,
|
||||||
|
"mobile" TEXT NOT NULL,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"source" TEXT NOT NULL,
|
||||||
|
"sourceId" TEXT,
|
||||||
|
"status" TEXT NOT NULL,
|
||||||
|
"error" TEXT,
|
||||||
|
"actorUserId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "OutboundSmsLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OutboundSmsLog_organizationId_personId_createdAt_idx" ON "OutboundSmsLog"("organizationId", "personId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OutboundSmsLog_organizationId_leadId_createdAt_idx" ON "OutboundSmsLog"("organizationId", "leadId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OutboundSmsLog_organizationId_createdAt_idx" ON "OutboundSmsLog"("organizationId", "createdAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "OutboundSmsLog" ADD CONSTRAINT "OutboundSmsLog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "OutboundSmsLog" ADD CONSTRAINT "OutboundSmsLog_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "OutboundSmsLog" ADD CONSTRAINT "OutboundSmsLog_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FinancialAccount" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"bankName" TEXT,
|
||||||
|
"accountRef" TEXT,
|
||||||
|
"responsibleName" TEXT,
|
||||||
|
"openingBalance" DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
"openingDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"notes" TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'IRR',
|
||||||
|
"createdById" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "FinancialAccount_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "FinancialCategory" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"kind" TEXT NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "FinancialCategory_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "FinancialDocument" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"kind" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"amount" DECIMAL(18,2) NOT NULL,
|
||||||
|
"bookedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"dueAt" TIMESTAMP(3),
|
||||||
|
"categoryId" TEXT,
|
||||||
|
"personId" TEXT,
|
||||||
|
"partyName" TEXT,
|
||||||
|
"accountId" TEXT,
|
||||||
|
"method" TEXT,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'open',
|
||||||
|
"reference" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdById" TEXT,
|
||||||
|
"voidedAt" TIMESTAMP(3),
|
||||||
|
"voidReason" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "FinancialDocument_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "FinancialMovement" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"documentId" TEXT,
|
||||||
|
"direction" TEXT NOT NULL,
|
||||||
|
"amount" DECIMAL(18,2) NOT NULL,
|
||||||
|
"kind" TEXT NOT NULL,
|
||||||
|
"transferGroupId" TEXT,
|
||||||
|
"occurredAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"memo" TEXT,
|
||||||
|
"reference" TEXT,
|
||||||
|
"createdById" TEXT,
|
||||||
|
"voidedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "FinancialMovement_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "FinancialAccount_organizationId_isActive_idx" ON "FinancialAccount"("organizationId", "isActive");
|
||||||
|
CREATE UNIQUE INDEX "FinancialCategory_organizationId_kind_name_key" ON "FinancialCategory"("organizationId", "kind", "name");
|
||||||
|
CREATE INDEX "FinancialCategory_organizationId_kind_idx" ON "FinancialCategory"("organizationId", "kind");
|
||||||
|
CREATE INDEX "FinancialDocument_organizationId_kind_bookedAt_idx" ON "FinancialDocument"("organizationId", "kind", "bookedAt");
|
||||||
|
CREATE INDEX "FinancialDocument_organizationId_status_idx" ON "FinancialDocument"("organizationId", "status");
|
||||||
|
CREATE INDEX "FinancialDocument_personId_idx" ON "FinancialDocument"("personId");
|
||||||
|
CREATE INDEX "FinancialMovement_organizationId_accountId_occurredAt_idx" ON "FinancialMovement"("organizationId", "accountId", "occurredAt");
|
||||||
|
CREATE INDEX "FinancialMovement_transferGroupId_idx" ON "FinancialMovement"("transferGroupId");
|
||||||
|
CREATE INDEX "FinancialMovement_documentId_idx" ON "FinancialMovement"("documentId");
|
||||||
|
|
||||||
|
ALTER TABLE "FinancialAccount" ADD CONSTRAINT "FinancialAccount_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "FinancialCategory" ADD CONSTRAINT "FinancialCategory_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FinancialCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "FinancialAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "FinancialAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "FinancialDocument"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Person" ADD COLUMN "discLabel" TEXT,
|
||||||
|
ADD COLUMN "discReliable" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "discAssessedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Person_organizationId_discLabel_idx" ON "Person"("organizationId", "discLabel");
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DiscAssessment" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"personId" TEXT NOT NULL,
|
||||||
|
"assessorId" TEXT NOT NULL,
|
||||||
|
"answers" JSONB NOT NULL,
|
||||||
|
"scoreD" INTEGER NOT NULL,
|
||||||
|
"scoreI" INTEGER NOT NULL,
|
||||||
|
"scoreS" INTEGER NOT NULL,
|
||||||
|
"scoreC" INTEGER NOT NULL,
|
||||||
|
"percentD" INTEGER NOT NULL,
|
||||||
|
"percentI" INTEGER NOT NULL,
|
||||||
|
"percentS" INTEGER NOT NULL,
|
||||||
|
"percentC" INTEGER NOT NULL,
|
||||||
|
"validCount" INTEGER NOT NULL,
|
||||||
|
"primaryTypes" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"secondaryTypes" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"secondaryLabel" TEXT,
|
||||||
|
"reliable" BOOLEAN NOT NULL,
|
||||||
|
"confidence" TEXT NOT NULL,
|
||||||
|
"note" TEXT,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'confirmed',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "DiscAssessment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SalesCopilotSession" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"personId" TEXT,
|
||||||
|
"leadId" TEXT,
|
||||||
|
"dealId" TEXT,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"topic" TEXT NOT NULL,
|
||||||
|
"goal" TEXT NOT NULL,
|
||||||
|
"customerStatement" TEXT NOT NULL,
|
||||||
|
"behaviorNotes" TEXT,
|
||||||
|
"analysis" JSONB NOT NULL,
|
||||||
|
"confirmedResponse" TEXT,
|
||||||
|
"nextAction" TEXT,
|
||||||
|
"followUpAt" TIMESTAMP(3),
|
||||||
|
"callOutcome" TEXT,
|
||||||
|
"agentNote" TEXT,
|
||||||
|
"status" TEXT NOT NULL,
|
||||||
|
"messageDraft" TEXT,
|
||||||
|
"activityId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "SalesCopilotSession_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DiscAssessment_organizationId_personId_createdAt_idx" ON "DiscAssessment"("organizationId", "personId", "createdAt");
|
||||||
|
CREATE INDEX "DiscAssessment_personId_idx" ON "DiscAssessment"("personId");
|
||||||
|
CREATE INDEX "SalesCopilotSession_organizationId_personId_createdAt_idx" ON "SalesCopilotSession"("organizationId", "personId", "createdAt");
|
||||||
|
CREATE INDEX "SalesCopilotSession_organizationId_leadId_idx" ON "SalesCopilotSession"("organizationId", "leadId");
|
||||||
|
CREATE INDEX "SalesCopilotSession_dealId_idx" ON "SalesCopilotSession"("dealId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DiscAssessment" ADD CONSTRAINT "DiscAssessment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "DiscAssessment" ADD CONSTRAINT "DiscAssessment_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "DiscAssessment" ADD CONSTRAINT "DiscAssessment_assessorId_fkey" FOREIGN KEY ("assessorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- ماژول برای محیطهایی که از قبل ماژول دارند روشن میشود و در سوپرادمین قابل خاموش کردن است
|
||||||
|
UPDATE "Organization"
|
||||||
|
SET "enabledModules" = array_append("enabledModules", 'behavior')
|
||||||
|
WHERE cardinality("enabledModules") > 0
|
||||||
|
AND NOT ('behavior' = ANY("enabledModules"));
|
||||||
|
|
||||||
|
-- مجوز رفتار برای نقشهایی که فروش دارند؛ پشتیبانی بدون این کلید میماند
|
||||||
|
UPDATE "OrgRole"
|
||||||
|
SET "permissions" = array_append("permissions", 'behavior')
|
||||||
|
WHERE NOT ('behavior' = ANY("permissions"))
|
||||||
|
AND (
|
||||||
|
"systemKey" IN ('owner', 'admin', 'sales')
|
||||||
|
OR 'sales' = ANY("permissions")
|
||||||
|
);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
ALTER TABLE "FinancialMovement" ADD COLUMN "receivableId" TEXT;
|
||||||
|
ALTER TABLE "FinancialMovement" ADD COLUMN "installmentId" TEXT;
|
||||||
|
|
||||||
|
CREATE TABLE "FinancialReceivable" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"personId" TEXT,
|
||||||
|
"partyName" TEXT,
|
||||||
|
"totalAmount" DECIMAL(18,2) NOT NULL,
|
||||||
|
"notes" TEXT,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'open',
|
||||||
|
"createdById" TEXT,
|
||||||
|
"voidedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "FinancialReceivable_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "FinancialInstallment" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"receivableId" TEXT NOT NULL,
|
||||||
|
"position" INTEGER NOT NULL,
|
||||||
|
"dueDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"amount" DECIMAL(18,2) NOT NULL,
|
||||||
|
"reminderNotifiedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "FinancialInstallment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "FinancialMovement_receivableId_idx" ON "FinancialMovement"("receivableId");
|
||||||
|
CREATE INDEX "FinancialMovement_installmentId_idx" ON "FinancialMovement"("installmentId");
|
||||||
|
CREATE INDEX "FinancialReceivable_organizationId_status_idx" ON "FinancialReceivable"("organizationId", "status");
|
||||||
|
CREATE INDEX "FinancialReceivable_personId_idx" ON "FinancialReceivable"("personId");
|
||||||
|
CREATE UNIQUE INDEX "FinancialInstallment_receivableId_position_key" ON "FinancialInstallment"("receivableId", "position");
|
||||||
|
CREATE INDEX "FinancialInstallment_dueDate_idx" ON "FinancialInstallment"("dueDate");
|
||||||
|
|
||||||
|
ALTER TABLE "FinancialReceivable" ADD CONSTRAINT "FinancialReceivable_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "FinancialReceivable" ADD CONSTRAINT "FinancialReceivable_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "FinancialInstallment" ADD CONSTRAINT "FinancialInstallment_receivableId_fkey" FOREIGN KEY ("receivableId") REFERENCES "FinancialReceivable"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_receivableId_fkey" FOREIGN KEY ("receivableId") REFERENCES "FinancialReceivable"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_installmentId_fkey" FOREIGN KEY ("installmentId") REFERENCES "FinancialInstallment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
ALTER TABLE "FinancialMovement" ADD COLUMN "payrollId" TEXT;
|
||||||
|
CREATE INDEX "FinancialMovement_payrollId_idx" ON "FinancialMovement"("payrollId");
|
||||||
|
|
||||||
|
CREATE TABLE "FinancialPayroll" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"period" TEXT NOT NULL,
|
||||||
|
"baseAmount" DECIMAL(18,2) NOT NULL,
|
||||||
|
"benefits" DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
"bonus" DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
"deductions" DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
"payable" DECIMAL(18,2) NOT NULL,
|
||||||
|
"dueDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"notes" TEXT,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'open',
|
||||||
|
"createdById" TEXT,
|
||||||
|
"voidedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "FinancialPayroll_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "FinancialRecurringItem" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"kind" TEXT NOT NULL,
|
||||||
|
"amount" DECIMAL(18,2) NOT NULL,
|
||||||
|
"interval" TEXT NOT NULL,
|
||||||
|
"startDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"endDate" TIMESTAMP(3),
|
||||||
|
"nextDue" TIMESTAMP(3) NOT NULL,
|
||||||
|
"accountId" TEXT,
|
||||||
|
"categoryId" TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdById" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "FinancialRecurringItem_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "FinancialRecurringOccurrence" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"itemId" TEXT NOT NULL,
|
||||||
|
"periodKey" TEXT NOT NULL,
|
||||||
|
"dueDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"documentId" TEXT,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "FinancialRecurringOccurrence_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "FinancialReconciliation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"reconciledAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"statedBalance" DECIMAL(18,2) NOT NULL,
|
||||||
|
"systemBalance" DECIMAL(18,2) NOT NULL,
|
||||||
|
"difference" DECIMAL(18,2) NOT NULL,
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdById" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "FinancialReconciliation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "FinancialPayroll_organizationId_userId_period_key" ON "FinancialPayroll"("organizationId", "userId", "period");
|
||||||
|
CREATE INDEX "FinancialPayroll_organizationId_status_idx" ON "FinancialPayroll"("organizationId", "status");
|
||||||
|
CREATE INDEX "FinancialRecurringItem_organizationId_isActive_nextDue_idx" ON "FinancialRecurringItem"("organizationId", "isActive", "nextDue");
|
||||||
|
CREATE UNIQUE INDEX "FinancialRecurringOccurrence_itemId_periodKey_key" ON "FinancialRecurringOccurrence"("itemId", "periodKey");
|
||||||
|
CREATE INDEX "FinancialReconciliation_organizationId_accountId_reconciledAt_idx" ON "FinancialReconciliation"("organizationId", "accountId", "reconciledAt");
|
||||||
|
|
||||||
|
ALTER TABLE "FinancialPayroll" ADD CONSTRAINT "FinancialPayroll_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "FinancialMovement" ADD CONSTRAINT "FinancialMovement_payrollId_fkey" FOREIGN KEY ("payrollId") REFERENCES "FinancialPayroll"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "FinancialRecurringItem" ADD CONSTRAINT "FinancialRecurringItem_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "FinancialRecurringOccurrence" ADD CONSTRAINT "FinancialRecurringOccurrence_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "FinancialRecurringItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "FinancialReconciliation" ADD CONSTRAINT "FinancialReconciliation_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "FinancialReconciliation" ADD CONSTRAINT "FinancialReconciliation_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "FinancialAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
ALTER TABLE "Payment" ADD COLUMN "financialAccountId" TEXT;
|
||||||
|
ALTER TABLE "Cheque" ADD COLUMN "financialAccountId" TEXT;
|
||||||
|
ALTER TABLE "FinancialMovement" ADD COLUMN "paymentId" TEXT;
|
||||||
|
ALTER TABLE "FinancialMovement" ADD COLUMN "chequeId" TEXT;
|
||||||
|
CREATE INDEX "FinancialMovement_paymentId_idx" ON "FinancialMovement"("paymentId");
|
||||||
|
CREATE INDEX "FinancialMovement_chequeId_idx" ON "FinancialMovement"("chequeId");
|
||||||
|
|
||||||
|
CREATE TABLE "FinancialAuditLog" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"entityType" TEXT NOT NULL,
|
||||||
|
"entityId" TEXT NOT NULL,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"before" JSONB,
|
||||||
|
"after" JSONB,
|
||||||
|
"actorUserId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "FinancialAuditLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE INDEX "FinancialAuditLog_organizationId_entityType_entityId_createdAt_idx" ON "FinancialAuditLog"("organizationId", "entityType", "entityId", "createdAt");
|
||||||
|
|
||||||
|
CREATE TABLE "FinancialAttachment" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"documentId" TEXT NOT NULL,
|
||||||
|
"fileName" TEXT NOT NULL,
|
||||||
|
"mimeType" TEXT NOT NULL,
|
||||||
|
"storageKey" TEXT NOT NULL,
|
||||||
|
"createdById" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "FinancialAttachment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE INDEX "FinancialAttachment_documentId_idx" ON "FinancialAttachment"("documentId");
|
||||||
|
|
||||||
|
ALTER TABLE "FinancialAuditLog" ADD CONSTRAINT "FinancialAuditLog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "FinancialAttachment" ADD CONSTRAINT "FinancialAttachment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "FinancialAttachment" ADD CONSTRAINT "FinancialAttachment_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "FinancialDocument"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Membership" ADD COLUMN "logoutTime" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AttendanceEvent" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"kind" TEXT NOT NULL,
|
||||||
|
"at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"afterHours" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"sessionStartedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "AttendanceEvent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AttendanceEvent_organizationId_at_idx" ON "AttendanceEvent"("organizationId", "at");
|
||||||
|
CREATE INDEX "AttendanceEvent_organizationId_userId_at_idx" ON "AttendanceEvent"("organizationId", "userId", "at");
|
||||||
|
CREATE UNIQUE INDEX "AttendanceEvent_organizationId_userId_kind_sessionStartedAt_key" ON "AttendanceEvent"("organizationId", "userId", "kind", "sessionStartedAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AttendanceEvent" ADD CONSTRAINT "AttendanceEvent_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "AttendanceEvent" ADD CONSTRAINT "AttendanceEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SmsAccount" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"balanceRials" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"pricePerPartRials" INTEGER NOT NULL DEFAULT 2500,
|
||||||
|
"isBlocked" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"autoBlockBelowRials" INTEGER,
|
||||||
|
"alertCriticalRials" INTEGER NOT NULL DEFAULT 10000,
|
||||||
|
"alertWarningRials" INTEGER NOT NULL DEFAULT 100000,
|
||||||
|
"panelMode" TEXT NOT NULL DEFAULT 'platform',
|
||||||
|
"ownPanelType" TEXT NOT NULL DEFAULT '0098',
|
||||||
|
"ownPanelUsername" TEXT,
|
||||||
|
"ownPanelPassword" TEXT,
|
||||||
|
"ownPanelFrom" TEXT,
|
||||||
|
"chargeCard" TEXT,
|
||||||
|
"chargeIban" TEXT,
|
||||||
|
"chargeHolder" TEXT,
|
||||||
|
"chargeInstruction" TEXT,
|
||||||
|
"alertDaysSent" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lastAlertDate" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "SmsAccount_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SmsLedger" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"smsAccountId" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"amountRials" INTEGER NOT NULL,
|
||||||
|
"balanceAfterRials" INTEGER NOT NULL,
|
||||||
|
"parts" INTEGER,
|
||||||
|
"pricePerPartRials" INTEGER,
|
||||||
|
"note" TEXT,
|
||||||
|
"smsSendLogId" TEXT,
|
||||||
|
"createdById" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "SmsLedger_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SmsBillingSendLog" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"smsAccountId" TEXT NOT NULL,
|
||||||
|
"receiver" TEXT NOT NULL,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"parts" INTEGER NOT NULL,
|
||||||
|
"pricePerPartRials" INTEGER NOT NULL,
|
||||||
|
"totalAmountRials" INTEGER NOT NULL,
|
||||||
|
"provider" TEXT,
|
||||||
|
"source" TEXT,
|
||||||
|
"isAlert" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "SmsBillingSendLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "SmsAccount_organizationId_key" ON "SmsAccount"("organizationId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SmsAccount_isBlocked_idx" ON "SmsAccount"("isBlocked");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SmsAccount_balanceRials_idx" ON "SmsAccount"("balanceRials");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SmsLedger_smsAccountId_createdAt_idx" ON "SmsLedger"("smsAccountId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SmsLedger_type_idx" ON "SmsLedger"("type");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SmsBillingSendLog_smsAccountId_createdAt_idx" ON "SmsBillingSendLog"("smsAccountId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SmsBillingSendLog_source_idx" ON "SmsBillingSendLog"("source");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SmsAccount" ADD CONSTRAINT "SmsAccount_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SmsLedger" ADD CONSTRAINT "SmsLedger_smsAccountId_fkey" FOREIGN KEY ("smsAccountId") REFERENCES "SmsAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SmsBillingSendLog" ADD CONSTRAINT "SmsBillingSendLog_smsAccountId_fkey" FOREIGN KEY ("smsAccountId") REFERENCES "SmsAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ActivityBoard" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"position" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ActivityBoard_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ActivityBoard_organizationId_name_key" ON "ActivityBoard"("organizationId", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ActivityBoard_organizationId_position_idx" ON "ActivityBoard"("organizationId", "position");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ActivityBoard" ADD CONSTRAINT "ActivityBoard_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Backfill: one default board per organization that already has activity stages
|
||||||
|
INSERT INTO "ActivityBoard" ("id", "organizationId", "name", "isDefault", "position", "createdAt", "updatedAt")
|
||||||
|
SELECT 'actb_' || md5(s."organizationId" || clock_timestamp()::text), s."organizationId", 'کارهای اصلی', true, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||||
|
FROM (SELECT DISTINCT "organizationId" FROM "ActivityStage") AS s;
|
||||||
|
|
||||||
|
-- AlterTable: stages belong to a board
|
||||||
|
ALTER TABLE "ActivityStage" ADD COLUMN "boardId" TEXT;
|
||||||
|
|
||||||
|
UPDATE "ActivityStage" AS st
|
||||||
|
SET "boardId" = b."id"
|
||||||
|
FROM "ActivityBoard" AS b
|
||||||
|
WHERE b."organizationId" = st."organizationId" AND b."isDefault" = true;
|
||||||
|
|
||||||
|
ALTER TABLE "ActivityStage" ALTER COLUMN "boardId" SET NOT NULL;
|
||||||
|
|
||||||
|
-- Stage names are unique per board (not per organization)
|
||||||
|
DROP INDEX "ActivityStage_organizationId_name_key";
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ActivityStage_boardId_name_key" ON "ActivityStage"("boardId", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ActivityStage_boardId_position_idx" ON "ActivityStage"("boardId", "position");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ActivityStage" ADD CONSTRAINT "ActivityStage_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "ActivityBoard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AlterTable: activities optionally point at a board (null = default board)
|
||||||
|
ALTER TABLE "Activity" ADD COLUMN "boardId" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Activity_boardId_idx" ON "Activity"("boardId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "ActivityBoard"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- تیکهای مجوز نقش از این نسخه واقعاً اعمال میشوند.
|
||||||
|
-- تا امروز همهٔ نقشها عملاً به این بخشها دسترسی داشتند؛ همان دسترسی را برای نقشهای موجود حفظ میکنیم.
|
||||||
|
UPDATE "OrgRole"
|
||||||
|
SET "permissions" = ARRAY(
|
||||||
|
SELECT DISTINCT p
|
||||||
|
FROM unnest(
|
||||||
|
"permissions" || ARRAY['customers', 'leads', 'sales', 'billing', 'products', 'activities', 'notes', 'reports']
|
||||||
|
) AS p
|
||||||
|
);
|
||||||
|
|
||||||
|
-- لاگ فعالیت کاربران تا امروز فقط برای مالک و مدیر باز بود (مستقل از تیک)؛ همان را حفظ میکنیم.
|
||||||
|
UPDATE "OrgRole"
|
||||||
|
SET "permissions" = array_remove("permissions", 'audit')
|
||||||
|
WHERE "systemKey" IS NULL OR "systemKey" NOT IN ('owner', 'admin');
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Membership" ADD COLUMN "blockLoginAfterLogout" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CustomerGroup" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"color" TEXT NOT NULL DEFAULT 'teal',
|
||||||
|
"position" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"tahesabConnectionId" TEXT,
|
||||||
|
"tahesabGid" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "CustomerGroup_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PersonGroupLink" (
|
||||||
|
"personId" TEXT NOT NULL,
|
||||||
|
"groupId" TEXT NOT NULL,
|
||||||
|
"source" TEXT NOT NULL DEFAULT 'manual',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "PersonGroupLink_pkey" PRIMARY KEY ("personId","groupId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CompanyGroupLink" (
|
||||||
|
"companyId" TEXT NOT NULL,
|
||||||
|
"groupId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "CompanyGroupLink_pkey" PRIMARY KEY ("companyId","groupId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CustomerGroup_organizationId_name_key" ON "CustomerGroup"("organizationId", "name");
|
||||||
|
CREATE INDEX "CustomerGroup_organizationId_position_idx" ON "CustomerGroup"("organizationId", "position");
|
||||||
|
CREATE INDEX "CustomerGroup_tahesabConnectionId_tahesabGid_idx" ON "CustomerGroup"("tahesabConnectionId", "tahesabGid");
|
||||||
|
CREATE INDEX "PersonGroupLink_groupId_idx" ON "PersonGroupLink"("groupId");
|
||||||
|
CREATE INDEX "CompanyGroupLink_groupId_idx" ON "CompanyGroupLink"("groupId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CustomerGroup" ADD CONSTRAINT "CustomerGroup_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "PersonGroupLink" ADD CONSTRAINT "PersonGroupLink_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "PersonGroupLink" ADD CONSTRAINT "PersonGroupLink_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "CustomerGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "CompanyGroupLink" ADD CONSTRAINT "CompanyGroupLink_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "CompanyGroupLink" ADD CONSTRAINT "CompanyGroupLink_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "CustomerGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- تبدیل دادههای قبلی: «گروه مشتری» اکسل (customFields.group) و «گروهبندی شرکت» (groupName).
|
||||||
|
-- مقدارهای چندتایی با «،» «,» «/» جدا میشوند. برچسبهای «وضعیت مشتری» که دیدار در همان ستون میگذارد گروه حساب نمیشوند.
|
||||||
|
CREATE TEMP TABLE "_cg_src" AS
|
||||||
|
SELECT p."organizationId" AS org, 'person' AS kind, p."id" AS ref, left(btrim(x), 80) AS name
|
||||||
|
FROM "Person" p
|
||||||
|
CROSS JOIN LATERAL regexp_split_to_table(p."customFields"->>'group', '\s*[،,/]\s*') AS x
|
||||||
|
WHERE COALESCE(p."customFields"->>'group', '') <> ''
|
||||||
|
UNION ALL
|
||||||
|
SELECT c."organizationId", 'company', c."id", left(btrim(x), 80)
|
||||||
|
FROM "Company" c
|
||||||
|
CROSS JOIN LATERAL regexp_split_to_table(c."groupName", '\s*[،,/]\s*') AS x
|
||||||
|
WHERE COALESCE(c."groupName", '') <> '';
|
||||||
|
|
||||||
|
DELETE FROM "_cg_src"
|
||||||
|
WHERE name = ''
|
||||||
|
OR name IN ('مشتری تازه وارد', 'مشتری خوشحال', 'مشتری ناراضی', 'مشتری خاص');
|
||||||
|
|
||||||
|
INSERT INTO "CustomerGroup" ("id", "organizationId", "name", "color", "position", "createdAt", "updatedAt")
|
||||||
|
SELECT 'cgrp_' || md5(d.org || '|' || d.name), d.org, d.name, 'teal', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||||
|
FROM (SELECT DISTINCT org, name FROM "_cg_src") AS d
|
||||||
|
ON CONFLICT ("organizationId", "name") DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO "PersonGroupLink" ("personId", "groupId", "source", "createdAt")
|
||||||
|
SELECT DISTINCT s.ref, g."id", 'manual', CURRENT_TIMESTAMP
|
||||||
|
FROM "_cg_src" s
|
||||||
|
JOIN "CustomerGroup" g ON g."organizationId" = s.org AND g."name" = s.name
|
||||||
|
WHERE s.kind = 'person'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO "CompanyGroupLink" ("companyId", "groupId", "createdAt")
|
||||||
|
SELECT DISTINCT s.ref, g."id", CURRENT_TIMESTAMP
|
||||||
|
FROM "_cg_src" s
|
||||||
|
JOIN "CustomerGroup" g ON g."organizationId" = s.org AND g."name" = s.name
|
||||||
|
WHERE s.kind = 'company'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
DROP TABLE "_cg_src";
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Deep data access: role default + membership override (phase 1: customers)
|
||||||
|
|
||||||
|
ALTER TABLE "OrgRole" ADD COLUMN IF NOT EXISTS "dataAccessPolicy" JSONB NOT NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
ALTER TABLE "Membership" ADD COLUMN IF NOT EXISTS "dataAccessOverride" JSONB NOT NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
-- Migrate legacy own/team/org (+ optional contacts scope) into customers override
|
||||||
|
UPDATE "Membership" m
|
||||||
|
SET "dataAccessOverride" = jsonb_build_object(
|
||||||
|
'customers',
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(
|
||||||
|
NULLIF(m."dataVisibilityScopes"::jsonb ->> 'contacts', ''),
|
||||||
|
m."dataVisibility"
|
||||||
|
) = 'org' THEN '{"mode":"all","rules":[]}'::jsonb
|
||||||
|
WHEN COALESCE(
|
||||||
|
NULLIF(m."dataVisibilityScopes"::jsonb ->> 'contacts', ''),
|
||||||
|
m."dataVisibility"
|
||||||
|
) = 'team' THEN '{"mode":"rules","rules":[{"owners":["self","team"]}]}'::jsonb
|
||||||
|
ELSE '{"mode":"rules","rules":[{"owners":["self"]}]}'::jsonb
|
||||||
|
END
|
||||||
|
)
|
||||||
|
WHERE m."role" NOT IN ('owner', 'admin')
|
||||||
|
AND (
|
||||||
|
m."dataAccessOverride" IS NULL
|
||||||
|
OR m."dataAccessOverride"::text IN ('{}', 'null')
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Membership" ADD COLUMN "loginOtpOnly" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AccountingAsnadLine" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"connectionId" TEXT NOT NULL,
|
||||||
|
"personId" TEXT,
|
||||||
|
"linkId" TEXT,
|
||||||
|
"moshtariCode" INTEGER NOT NULL,
|
||||||
|
"lineKey" TEXT NOT NULL,
|
||||||
|
"tahesabId" INTEGER,
|
||||||
|
"factorCode" TEXT,
|
||||||
|
"shFactor" INTEGER,
|
||||||
|
"radif" INTEGER,
|
||||||
|
"tarikh" TEXT NOT NULL,
|
||||||
|
"noRaw" TEXT NOT NULL DEFAULT '',
|
||||||
|
"kind" TEXT NOT NULL DEFAULT 'other',
|
||||||
|
"jensFelez" INTEGER,
|
||||||
|
"noKar" TEXT,
|
||||||
|
"vazn" DOUBLE PRECISION,
|
||||||
|
"tabdilVazn" DOUBLE PRECISION,
|
||||||
|
"mali" DOUBLE PRECISION,
|
||||||
|
"count" DOUBLE PRECISION,
|
||||||
|
"ayar" DOUBLE PRECISION,
|
||||||
|
"mazaneh" DOUBLE PRECISION,
|
||||||
|
"fi" DOUBLE PRECISION,
|
||||||
|
"sharh1" TEXT,
|
||||||
|
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AccountingAsnadLine_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AccountingAsnadLine_connectionId_lineKey_key" ON "AccountingAsnadLine"("connectionId", "lineKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AccountingAsnadLine_organizationId_connectionId_tarikh_idx" ON "AccountingAsnadLine"("organizationId", "connectionId", "tarikh");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AccountingAsnadLine_organizationId_kind_idx" ON "AccountingAsnadLine"("organizationId", "kind");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AccountingAsnadLine_personId_idx" ON "AccountingAsnadLine"("personId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AccountingAsnadLine_linkId_idx" ON "AccountingAsnadLine"("linkId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AccountingAsnadLine_moshtariCode_idx" ON "AccountingAsnadLine"("moshtariCode");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "AccountingConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_linkId_fkey" FOREIGN KEY ("linkId") REFERENCES "PersonAccountingLink"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TahesabInvoiceSettings" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"logoDataUrl" TEXT,
|
||||||
|
"companyName" TEXT NOT NULL DEFAULT '',
|
||||||
|
"companySubtitle" TEXT NOT NULL DEFAULT '',
|
||||||
|
"address" TEXT NOT NULL DEFAULT '',
|
||||||
|
"phone" TEXT NOT NULL DEFAULT '',
|
||||||
|
"headerText" TEXT NOT NULL DEFAULT '',
|
||||||
|
"footerText" TEXT NOT NULL DEFAULT '',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "TahesabInvoiceSettings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TahesabInvoiceSettings_organizationId_key" ON "TahesabInvoiceSettings"("organizationId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TahesabInvoiceSettings" ADD CONSTRAINT "TahesabInvoiceSettings_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
ALTER TABLE "CustomerGroup" ADD COLUMN IF NOT EXISTS "parentId" TEXT;
|
||||||
|
CREATE INDEX IF NOT EXISTS "CustomerGroup_parentId_idx" ON "CustomerGroup"("parentId");
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "CustomerGroup"
|
||||||
|
ADD CONSTRAINT "CustomerGroup_parentId_fkey"
|
||||||
|
FOREIGN KEY ("parentId") REFERENCES "CustomerGroup"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE "AccountingAsnadLine" ADD COLUMN IF NOT EXISTS "darsad" DOUBLE PRECISION;
|
||||||
|
ALTER TABLE "AccountingAsnadLine" ADD COLUMN IF NOT EXISTS "darsadTalayee" DOUBLE PRECISION;
|
||||||
|
ALTER TABLE "AccountingAsnadLine" ADD COLUMN IF NOT EXISTS "maliatDarsad" DOUBLE PRECISION;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ActivityImage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"activityId" TEXT NOT NULL,
|
||||||
|
"fileName" TEXT NOT NULL,
|
||||||
|
"mimeType" TEXT NOT NULL,
|
||||||
|
"sizeBytes" INTEGER NOT NULL,
|
||||||
|
"storageKey" TEXT NOT NULL,
|
||||||
|
"uploadedById" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ActivityImage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ActivityImage_activityId_createdAt_idx" ON "ActivityImage"("activityId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ActivityImage_organizationId_idx" ON "ActivityImage"("organizationId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ActivityImage_uploadedById_idx" ON "ActivityImage"("uploadedById");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_activityId_fkey" FOREIGN KEY ("activityId") REFERENCES "Activity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- پیامک پایان اشتراک فقط برای انقضاهای بعدی؛ حسابهای قبلاً تمامشده دوباره خبر نمیدهند
|
||||||
|
ALTER TABLE "BillingAccount" ADD COLUMN "expiryNotifiedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
UPDATE "BillingAccount"
|
||||||
|
SET "expiryNotifiedAt" = CURRENT_TIMESTAMP
|
||||||
|
WHERE "planCode" = 'lifetime'
|
||||||
|
OR "status" = 'expired'
|
||||||
|
OR "expiresAt" < CURRENT_TIMESTAMP;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE "ActivityBoardMember" (
|
||||||
|
"boardId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ActivityBoardMember_pkey" PRIMARY KEY ("boardId","userId")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "ActivityBoardMember_userId_idx" ON "ActivityBoardMember"("userId");
|
||||||
|
|
||||||
|
ALTER TABLE "ActivityBoardMember" ADD CONSTRAINT "ActivityBoardMember_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "ActivityBoard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "ActivityBoardMember" ADD CONSTRAINT "ActivityBoardMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
ALTER TABLE "Person" ADD COLUMN "gender" TEXT NOT NULL DEFAULT 'none';
|
||||||
|
ALTER TABLE "Person" ADD COLUMN "showGenderInName" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- نامهایی که از تهحساب با عنوان آمدهاند؛ خود متن نام دست نمیخورد
|
||||||
|
UPDATE "Person"
|
||||||
|
SET "gender" = 'male', "showGenderInName" = true
|
||||||
|
WHERE regexp_replace(
|
||||||
|
regexp_replace(btrim("firstName" || ' ' || "lastName"), 'ي', 'ی', 'g'),
|
||||||
|
'ك',
|
||||||
|
'ک',
|
||||||
|
'g'
|
||||||
|
) ~ '^جناب[[:space:]]*آقای([[:space:]]|$)';
|
||||||
|
|
||||||
|
UPDATE "Person"
|
||||||
|
SET "gender" = 'female', "showGenderInName" = true
|
||||||
|
WHERE "gender" = 'none'
|
||||||
|
AND regexp_replace(
|
||||||
|
regexp_replace(btrim("firstName" || ' ' || "lastName"), 'ي', 'ی', 'g'),
|
||||||
|
'ك',
|
||||||
|
'ک',
|
||||||
|
'g'
|
||||||
|
) ~ '^سرکار[[:space:]]*خانم([[:space:]]|$)';
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
ALTER TABLE "Lead" ADD COLUMN "gender" TEXT NOT NULL DEFAULT 'none';
|
||||||
|
ALTER TABLE "Lead" ADD COLUMN "showGenderInName" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- عنوان داخل نام مخاطب، و اگر مخاطب خالی است داخل عنوان لید
|
||||||
|
UPDATE "Lead"
|
||||||
|
SET "gender" = 'male', "showGenderInName" = true
|
||||||
|
WHERE regexp_replace(
|
||||||
|
regexp_replace(
|
||||||
|
btrim(CASE WHEN btrim(coalesce("contactName", '')) <> '' THEN "contactName" ELSE "title" END),
|
||||||
|
'ي',
|
||||||
|
'ی',
|
||||||
|
'g'
|
||||||
|
),
|
||||||
|
'ك',
|
||||||
|
'ک',
|
||||||
|
'g'
|
||||||
|
) ~ '^جناب[[:space:]]*آقای([[:space:]]|$)';
|
||||||
|
|
||||||
|
UPDATE "Lead"
|
||||||
|
SET "gender" = 'female', "showGenderInName" = true
|
||||||
|
WHERE "gender" = 'none'
|
||||||
|
AND regexp_replace(
|
||||||
|
regexp_replace(
|
||||||
|
btrim(CASE WHEN btrim(coalesce("contactName", '')) <> '' THEN "contactName" ELSE "title" END),
|
||||||
|
'ي',
|
||||||
|
'ی',
|
||||||
|
'g'
|
||||||
|
),
|
||||||
|
'ك',
|
||||||
|
'ک',
|
||||||
|
'g'
|
||||||
|
) ~ '^سرکار[[:space:]]*خانم([[:space:]]|$)';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TYPE "CustomFieldEntity" ADD VALUE IF NOT EXISTS 'note';
|
||||||
|
|
||||||
|
ALTER TABLE "Note" ADD COLUMN IF NOT EXISTS "customFields" JSONB NOT NULL DEFAULT '{}';
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE "NoteCategory" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"tone" TEXT NOT NULL DEFAULT 'general',
|
||||||
|
"position" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"showInBar" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"isSystem" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "NoteCategory_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "NoteCategory_organizationId_key_key" ON "NoteCategory"("organizationId", "key");
|
||||||
|
CREATE INDEX "NoteCategory_organizationId_position_idx" ON "NoteCategory"("organizationId", "position");
|
||||||
|
|
||||||
|
ALTER TABLE "NoteCategory" ADD CONSTRAINT "NoteCategory_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE "Organization" ADD COLUMN "fileBytes" BIGINT NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "Organization" ADD COLUMN "chatFileBytes" BIGINT NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "Organization" ADD COLUMN "fileBytesSyncedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
ALTER TABLE "BillingAccount" ADD COLUMN "storageQuotaBytes" BIGINT;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "BillingAccount" ADD COLUMN "maxUsers" INTEGER;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE "PlatformMasterPassword" (
|
||||||
|
"id" TEXT NOT NULL DEFAULT 'default',
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PlatformMasterPassword_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- دموی ۷ روزه که هنوز سقف کاربر ندارد، پیشفرض ۳ کاربر میگیرد
|
||||||
|
UPDATE "BillingAccount"
|
||||||
|
SET "maxUsers" = 3
|
||||||
|
WHERE "planCode" = 'trial' AND "maxUsers" IS NULL;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "Activity" ADD COLUMN "boardPosition" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE INDEX "Activity_stageId_boardPosition_idx" ON "Activity"("stageId", "boardPosition");
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "OrgRole" ADD COLUMN "treasuryAccountIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "OrgRole" ADD COLUMN "treasuryOwnAccounts" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "Membership" ADD COLUMN "treasuryAccessOverride" JSONB;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE "FinancialDocument" ADD COLUMN "companyId" TEXT;
|
||||||
|
ALTER TABLE "FinancialDocument" ADD COLUMN "userId" TEXT;
|
||||||
|
ALTER TABLE "FinancialDocument" ADD COLUMN "partyKind" TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX "FinancialDocument_companyId_idx" ON "FinancialDocument"("companyId");
|
||||||
|
CREATE INDEX "FinancialDocument_userId_idx" ON "FinancialDocument"("userId");
|
||||||
|
|
||||||
|
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE "DataFix" (
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "DataFix_pkey" PRIMARY KEY ("key")
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE "AccountingConnection"
|
||||||
|
ADD COLUMN "customerSyncHours" INTEGER[] NOT NULL DEFAULT ARRAY[]::INTEGER[],
|
||||||
|
ADD COLUMN "customerSyncLastSlot" TEXT,
|
||||||
|
ADD COLUMN "customerSyncLastRunAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "customerSyncLastSummary" TEXT,
|
||||||
|
ADD COLUMN "customerSyncLastError" TEXT;
|
||||||
+2710
-435
File diff suppressed because it is too large
Load Diff
+10
-10
@@ -335,7 +335,7 @@ async function main() {
|
|||||||
pipeline = await prisma.pipeline.create({
|
pipeline = await prisma.pipeline.create({
|
||||||
data: {
|
data: {
|
||||||
organizationId: org.id,
|
organizationId: org.id,
|
||||||
name: 'کاریز فروش',
|
name: 'مسیر فروش',
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
stages: {
|
stages: {
|
||||||
create: DEFAULT_STAGES.map((s, index) => ({
|
create: DEFAULT_STAGES.map((s, index) => ({
|
||||||
@@ -354,13 +354,13 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const servicesPipeline = await prisma.pipeline.findFirst({
|
const servicesPipeline = await prisma.pipeline.findFirst({
|
||||||
where: { organizationId: org.id, name: 'کاریز خدمات' },
|
where: { organizationId: org.id, name: 'مسیر خدمات' },
|
||||||
});
|
});
|
||||||
if (!servicesPipeline) {
|
if (!servicesPipeline) {
|
||||||
await prisma.pipeline.create({
|
await prisma.pipeline.create({
|
||||||
data: {
|
data: {
|
||||||
organizationId: org.id,
|
organizationId: org.id,
|
||||||
name: 'کاریز خدمات',
|
name: 'مسیر خدمات',
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
stages: {
|
stages: {
|
||||||
create: DEFAULT_STAGES.map((s, index) => ({
|
create: DEFAULT_STAGES.map((s, index) => ({
|
||||||
@@ -377,7 +377,7 @@ async function main() {
|
|||||||
console.log('Services pipeline already seeded');
|
console.log('Services pipeline already seeded');
|
||||||
}
|
}
|
||||||
|
|
||||||
// کاریز منتظر — مطابق طراحی UI (۳ مرحله + معاملات نمونه)
|
// مسیر منتظر — مطابق طراحی UI (۳ مرحله + معاملات نمونه)
|
||||||
const WAITING_STAGES = [
|
const WAITING_STAGES = [
|
||||||
{ name: 'پیگیری', isWon: false, isLost: false },
|
{ name: 'پیگیری', isWon: false, isLost: false },
|
||||||
{ name: 'مذاکرات اولیه', isWon: false, isLost: false },
|
{ name: 'مذاکرات اولیه', isWon: false, isLost: false },
|
||||||
@@ -483,7 +483,7 @@ async function main() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
let waitingPipeline = await prisma.pipeline.findFirst({
|
let waitingPipeline = await prisma.pipeline.findFirst({
|
||||||
where: { organizationId: org.id, name: 'کاریز منتظر' },
|
where: { organizationId: org.id, name: 'مسیر منتظر' },
|
||||||
include: { stages: { orderBy: { position: 'asc' } } },
|
include: { stages: { orderBy: { position: 'asc' } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -491,7 +491,7 @@ async function main() {
|
|||||||
waitingPipeline = await prisma.pipeline.create({
|
waitingPipeline = await prisma.pipeline.create({
|
||||||
data: {
|
data: {
|
||||||
organizationId: org.id,
|
organizationId: org.id,
|
||||||
name: 'کاریز منتظر',
|
name: 'مسیر منتظر',
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
stages: {
|
stages: {
|
||||||
create: WAITING_STAGES.map((s, index) => ({
|
create: WAITING_STAGES.map((s, index) => ({
|
||||||
@@ -504,7 +504,7 @@ async function main() {
|
|||||||
},
|
},
|
||||||
include: { stages: { orderBy: { position: 'asc' } } },
|
include: { stages: { orderBy: { position: 'asc' } } },
|
||||||
});
|
});
|
||||||
console.log('Seeded waiting pipeline (کاریز منتظر)');
|
console.log('Seeded waiting pipeline (مسیر منتظر)');
|
||||||
} else {
|
} else {
|
||||||
console.log('Waiting pipeline already seeded');
|
console.log('Waiting pipeline already seeded');
|
||||||
}
|
}
|
||||||
@@ -534,7 +534,7 @@ async function main() {
|
|||||||
expectedCloseAt: new Date(d.expectedCloseAt),
|
expectedCloseAt: new Date(d.expectedCloseAt),
|
||||||
ownerId: owner?.id,
|
ownerId: owner?.id,
|
||||||
position: d.position,
|
position: d.position,
|
||||||
tags: ['دمو-کاریز'],
|
tags: ['دمو-مسیر'],
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -763,7 +763,7 @@ async function main() {
|
|||||||
{
|
{
|
||||||
organizationId: org.id,
|
organizationId: org.id,
|
||||||
authorId: owner?.id,
|
authorId: owner?.id,
|
||||||
body: 'در دمو نوا روی گزارش کاریز خیلی حساس بودند. نسخه چاپی PDF را آماده کنیم.',
|
body: 'در دمو نوا روی گزارش مسیر فروش خیلی حساس بودند. نسخه چاپی PDF را آماده کنیم.',
|
||||||
noteType: 'call_result',
|
noteType: 'call_result',
|
||||||
color: 'sky',
|
color: 'sky',
|
||||||
tags: ['دمو'],
|
tags: ['دمو'],
|
||||||
@@ -971,7 +971,7 @@ async function main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await ensure('deal_moved', {
|
await ensure('deal_moved', {
|
||||||
title: 'جابهجایی معامله در کاریز',
|
title: 'جابهجایی معامله در مسیر فروش',
|
||||||
body: sampleDeal
|
body: sampleDeal
|
||||||
? `«${sampleDeal.title}» به مرحله جدید منتقل شد`
|
? `«${sampleDeal.title}» به مرحله جدید منتقل شد`
|
||||||
: 'یک معامله جابهجا شد',
|
: 'یک معامله جابهجا شد',
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import type { ActivityType, ImportContactsResult } from '@mento/shared';
|
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,
|
||||||
@@ -16,35 +16,60 @@ import {
|
|||||||
writeXlsx,
|
writeXlsx,
|
||||||
type ExcelPreviewResult,
|
type ExcelPreviewResult,
|
||||||
} from '../common/excel';
|
} from '../common/excel';
|
||||||
|
import {
|
||||||
|
beginExcelImportPrepare,
|
||||||
|
chunkImportMeta,
|
||||||
|
createExcelImportCacheSession,
|
||||||
|
destroyExcelImportSession,
|
||||||
|
getExcelImportPrepareStatus,
|
||||||
|
getExcelImportSessionCaches,
|
||||||
|
resolveExcelImportChunk,
|
||||||
|
type ExcelImportLookupCaches,
|
||||||
|
type ExcelImportSessionStatus,
|
||||||
|
} from '../common/excel-import-session';
|
||||||
|
import type { ImportActivityRowsDto } from './dto';
|
||||||
|
|
||||||
const ACTIVITY_COLUMNS = [
|
/** ترتیب دقیق خروجی فعالیت دیدار — برای فایلهای بدون ردیف هدر */
|
||||||
{ header: '_type', key: '_type', width: 12 },
|
const DIDAR_ACTIVITY_POSITIONAL = [
|
||||||
{ header: 'نوع فعالیت', key: 'kind', width: 16 },
|
{ key: '_type', label: '_type' },
|
||||||
{ header: 'عنوان فعالیت', key: 'title', width: 24 },
|
{ key: 'kind', label: 'نوع فعالیت' },
|
||||||
{ header: 'متن فعالیت', key: 'body', width: 36 },
|
{ key: 'title', label: 'عنوان فعالیت' },
|
||||||
{ header: 'نتیجه فعالیت', key: 'result', width: 20 },
|
{ key: 'body', label: 'متن فعالیت' },
|
||||||
{ header: 'مسئول اجرا فعالیت', key: 'assignee', width: 18 },
|
{ key: 'result', label: 'نتیجه فعالیت' },
|
||||||
{ header: 'ثبت کننده فعالیت', key: 'creator', width: 18 },
|
{ key: 'assignee', label: 'مسئول اجرا فعالیت' },
|
||||||
{ header: 'تاریخ برنامه ریزی شده اجرا فعالیت', key: 'dueDate', width: 18 },
|
{ key: 'creator', label: 'ثبت کننده فعالیت' },
|
||||||
{ header: 'زمان برنامه ریزی شده اجرا فعالیت', key: 'dueTime', width: 14 },
|
{ key: 'dueDate', label: 'تاریخ برنامه ریزی شده اجرا فعالیت' },
|
||||||
{ header: 'وضعیت اجرای فعالیت', key: 'status', width: 16 },
|
{ key: 'dueTime', label: 'زمان برنامه ریزی شده اجرا فعالیت' },
|
||||||
{ header: 'تاریخ انجام شدن فعالیت', key: 'doneDate', width: 16 },
|
{ key: 'status', label: 'وضعیت اجرای فعالیت' },
|
||||||
{ header: 'کد معامله فعالیت', key: 'dealCode', width: 14 },
|
{ key: 'doneDate', label: 'تاریخ انجام شدن فعالیت' },
|
||||||
{ header: 'کد کارت فعالیت', key: 'cardCode', width: 14 },
|
{ key: 'dealCode', label: 'کد معامله فعالیت' },
|
||||||
{ header: 'زمان انجام شدن فعالیت', key: 'doneTime', width: 14 },
|
{ key: 'cardCode', label: 'کد کارت فعالیت' },
|
||||||
{ header: 'اشخاص فعالیت', key: 'personName', width: 18 },
|
{ key: 'doneTime', label: 'زمان انجام شدن فعالیت' },
|
||||||
{ header: 'کد اشخاص فعالیت', key: 'personCode', width: 14 },
|
{ key: 'personName', label: 'اشخاص فعالیت' },
|
||||||
{ header: 'موبایل اشخاص فعالیت', key: 'personMobile', width: 16 },
|
{ key: 'personCode', label: 'کد اشخاص فعالیت' },
|
||||||
{ header: 'شرکت های فعالیت', key: 'companyName', width: 20 },
|
{ key: 'personMobile', label: 'موبایل اشخاص فعالیت' },
|
||||||
{ header: 'کد شرکت های فعالیت', key: 'companyCode', width: 14 },
|
{ key: 'companyName', label: 'شرکت های فعالیت' },
|
||||||
{ header: 'معامله فعالیت_1', key: 'dealTitle', width: 24 },
|
{ key: 'companyCode', label: 'کد شرکت های فعالیت' },
|
||||||
{ header: 'کارت فعالیت_1', key: 'cardTitle', width: 20 },
|
{ key: 'dealTitle', label: 'معامله فعالیت_1' },
|
||||||
{ header: 'هشتگ', key: 'hashtag', width: 16 },
|
{ key: 'cardTitle', label: 'کارت فعالیت_1' },
|
||||||
];
|
{ key: 'hashtag', label: 'هشتگ' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ACTIVITY_COLUMNS = DIDAR_ACTIVITY_POSITIONAL.map((c) => ({
|
||||||
|
header: c.label,
|
||||||
|
key: c.key,
|
||||||
|
width:
|
||||||
|
c.key === 'body' || c.key === 'result'
|
||||||
|
? 36
|
||||||
|
: c.key === 'title' || c.key === 'dealTitle'
|
||||||
|
? 24
|
||||||
|
: 16,
|
||||||
|
}));
|
||||||
|
|
||||||
const ACTIVITY_ALIASES: Record<string, string> = {
|
const ACTIVITY_ALIASES: Record<string, string> = {
|
||||||
_type: '_type',
|
_type: '_type',
|
||||||
type: '_type',
|
type: '_type',
|
||||||
|
activity: '_type',
|
||||||
نوعفعالیت: 'kind',
|
نوعفعالیت: 'kind',
|
||||||
عنوانفعالیت: 'title',
|
عنوانفعالیت: 'title',
|
||||||
عنوان: 'title',
|
عنوان: 'title',
|
||||||
@@ -65,10 +90,27 @@ const ACTIVITY_ALIASES: Record<string, string> = {
|
|||||||
شرکتهایفعالیت: 'companyName',
|
شرکتهایفعالیت: 'companyName',
|
||||||
کدشرکتهایفعالیت: 'companyCode',
|
کدشرکتهایفعالیت: 'companyCode',
|
||||||
معاملهفعالیت1: 'dealTitle',
|
معاملهفعالیت1: 'dealTitle',
|
||||||
|
معاملهفعالیت_1: 'dealTitle',
|
||||||
کارتفعالیت1: 'cardTitle',
|
کارتفعالیت1: 'cardTitle',
|
||||||
|
کارتفعالیت_1: 'cardTitle',
|
||||||
هشتگ: 'hashtag',
|
هشتگ: 'hashtag',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function looksLikeDidarActivityHeaderless(firstRow: string[]) {
|
||||||
|
const first = (firstRow[0] ?? '').trim().toLowerCase();
|
||||||
|
if (first !== 'activity' && first !== '_type') return false;
|
||||||
|
const kind = (firstRow[1] ?? '').trim();
|
||||||
|
return /تماس|پیگیری|جلسه|یادداشت|ملاقات|call|task|meeting|note|پیام/i.test(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIVITY_EXCEL_OPTIONS = {
|
||||||
|
positionalColumns: DIDAR_ACTIVITY_POSITIONAL.map((c) => ({
|
||||||
|
key: c.key,
|
||||||
|
label: c.label,
|
||||||
|
})),
|
||||||
|
detectHeaderless: looksLikeDidarActivityHeaderless,
|
||||||
|
};
|
||||||
|
|
||||||
const TYPE_LABEL: Record<ActivityType, string> = {
|
const TYPE_LABEL: Record<ActivityType, string> = {
|
||||||
call: 'تماس با مشتری',
|
call: 'تماس با مشتری',
|
||||||
meeting: 'جلسه',
|
meeting: 'جلسه',
|
||||||
@@ -109,6 +151,7 @@ export class ActivitiesExcelService {
|
|||||||
company: { select: { id: true, name: true } },
|
company: { select: { id: true, name: true } },
|
||||||
deal: { select: { id: true, title: true } },
|
deal: { select: { id: true, title: true } },
|
||||||
assignee: { select: { fullName: true } },
|
assignee: { select: { fullName: true } },
|
||||||
|
assignees: { select: { fullName: true } },
|
||||||
createdBy: { select: { fullName: true } },
|
createdBy: { select: { fullName: true } },
|
||||||
},
|
},
|
||||||
orderBy: [{ dueAt: 'desc' }, { createdAt: 'desc' }],
|
orderBy: [{ dueAt: 'desc' }, { createdAt: 'desc' }],
|
||||||
@@ -124,7 +167,9 @@ export class ActivitiesExcelService {
|
|||||||
title: row.title,
|
title: row.title,
|
||||||
body: row.description ?? '',
|
body: row.description ?? '',
|
||||||
result: '',
|
result: '',
|
||||||
assignee: row.assignee?.fullName ?? '',
|
assignee: row.assignees?.length
|
||||||
|
? row.assignees.map((a) => a.fullName).join('، ')
|
||||||
|
: (row.assignee?.fullName ?? ''),
|
||||||
creator: row.createdBy?.fullName ?? '',
|
creator: row.createdBy?.fullName ?? '',
|
||||||
dueDate: formatJalaliDate(row.dueAt),
|
dueDate: formatJalaliDate(row.dueAt),
|
||||||
dueTime: formatClock(row.dueAt),
|
dueTime: formatClock(row.dueAt),
|
||||||
@@ -148,20 +193,199 @@ export class ActivitiesExcelService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
||||||
return previewExcel(file, ACTIVITY_ALIASES, excelFieldsFromColumns(ACTIVITY_COLUMNS, ['title']));
|
return previewExcel(
|
||||||
|
file,
|
||||||
|
ACTIVITY_ALIASES,
|
||||||
|
excelFieldsFromColumns(ACTIVITY_COLUMNS, ['title']),
|
||||||
|
ACTIVITY_EXCEL_OPTIONS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* آپلود فوری + پارس در پسزمینه (جلوگیری از ۵۰۲ nginx روی فایلهای بزرگ).
|
||||||
|
* وضعیت را با prepareStatus پول کنید.
|
||||||
|
*/
|
||||||
|
prepare(
|
||||||
|
actor: Actor,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
mappingRaw?: string,
|
||||||
|
): ImportContactsResult {
|
||||||
|
if (!file?.buffer?.length) {
|
||||||
|
throw new BadRequestException('فایل ارسال نشده');
|
||||||
|
}
|
||||||
|
const mapping = parseExcelMapping(mappingRaw);
|
||||||
|
const sessionId = beginExcelImportPrepare(
|
||||||
|
actor.organizationId,
|
||||||
|
file.buffer,
|
||||||
|
(f) =>
|
||||||
|
readMappedRows(f, ACTIVITY_ALIASES, mapping, ACTIVITY_EXCEL_OPTIONS),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
created: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errors: [],
|
||||||
|
sessionId,
|
||||||
|
status: 'parsing',
|
||||||
|
total: 0,
|
||||||
|
parsed: 0,
|
||||||
|
processed: 0,
|
||||||
|
hasMore: true,
|
||||||
|
nextOffset: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareStatus(
|
||||||
|
actor: Actor,
|
||||||
|
sessionId: string,
|
||||||
|
): ExcelImportSessionStatus {
|
||||||
|
return getExcelImportPrepareStatus(sessionId, actor.organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async import(
|
async import(
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
file: Express.Multer.File,
|
file: Express.Multer.File | undefined,
|
||||||
mappingRaw?: string,
|
mappingRaw?: string,
|
||||||
|
chunk?: { sessionId?: string; offset?: string | number; limit?: string | number },
|
||||||
): Promise<ImportContactsResult> {
|
): Promise<ImportContactsResult> {
|
||||||
const rows = await readMappedRows(file, ACTIVITY_ALIASES, parseExcelMapping(mappingRaw));
|
|
||||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await resolveExcelImportChunk({
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
file,
|
||||||
|
sessionId: chunk?.sessionId,
|
||||||
|
offset: chunk?.offset,
|
||||||
|
limit: chunk?.limit,
|
||||||
|
loadRows: () =>
|
||||||
|
readMappedRows(
|
||||||
|
file!,
|
||||||
|
ACTIVITY_ALIASES,
|
||||||
|
parseExcelMapping(mappingRaw),
|
||||||
|
ACTIVITY_EXCEL_OPTIONS,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof BadRequestException) throw e;
|
||||||
|
const message =
|
||||||
|
e instanceof Error ? e.message : 'خواندن فایل اکسل ناموفق بود';
|
||||||
|
result.errors.push({ row: 1, message });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < rows.length; i++) {
|
const MAX_ROWS = 100_000;
|
||||||
const row = rows[i]!;
|
if (ctx.total > MAX_ROWS) {
|
||||||
const rowNum = i + 2;
|
destroyExcelImportSession(ctx.sessionId);
|
||||||
|
result.errors.push({
|
||||||
|
row: 1,
|
||||||
|
message: `فایل بیش از ${MAX_ROWS} ردیف دارد. فایل را تقسیم کنید.`,
|
||||||
|
});
|
||||||
|
return { ...result, hasMore: false, total: ctx.total, processed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const caches = getExcelImportSessionCaches(ctx.sessionId, actor.organizationId);
|
||||||
|
await this.processActivityRows(
|
||||||
|
actor,
|
||||||
|
ctx.slice,
|
||||||
|
ctx.offset,
|
||||||
|
result,
|
||||||
|
caches,
|
||||||
|
{ skipDuplicateCheck: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...result, ...chunkImportMeta(ctx) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ایمپورت تکهای JSON از مرورگر — فایل اکسل روی سرور آپلود/پارس نمیشود.
|
||||||
|
*/
|
||||||
|
async importRows(
|
||||||
|
actor: Actor,
|
||||||
|
dto: ImportActivityRowsDto,
|
||||||
|
): Promise<ImportContactsResult> {
|
||||||
|
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||||
|
const rows = Array.isArray(dto.rows) ? dto.rows : [];
|
||||||
|
if (!rows.length) {
|
||||||
|
result.errors.push({ row: 1, message: 'ردیف ارسالشده خالی است' });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (rows.length > 80) {
|
||||||
|
result.errors.push({ row: 1, message: 'حداکثر ۸۰ ردیف در هر درخواست' });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = Math.max(0, Number(dto.total) || 0);
|
||||||
|
const offset = Math.max(0, Number(dto.offset) || 0);
|
||||||
|
let sessionId = (dto.sessionId ?? '').trim();
|
||||||
|
if (!sessionId) {
|
||||||
|
sessionId = createExcelImportCacheSession(actor.organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const caches = getExcelImportSessionCaches(sessionId, actor.organizationId);
|
||||||
|
await this.processActivityRows(actor, rows, offset, result, caches, {
|
||||||
|
// برای فایلهای خیلی بزرگ، بررسی تکراری هر ردیف درخواست را طولانی و ۵۰۲ میکند
|
||||||
|
skipDuplicateCheck: total > 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const processed = offset + rows.length;
|
||||||
|
const hasMore = total > 0 ? processed < total : false;
|
||||||
|
if (!hasMore) destroyExcelImportSession(sessionId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
sessionId: hasMore ? sessionId : undefined,
|
||||||
|
total,
|
||||||
|
processed,
|
||||||
|
hasMore,
|
||||||
|
nextOffset: hasMore ? processed : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processActivityRows(
|
||||||
|
actor: Actor,
|
||||||
|
slice: Array<Record<string, string>>,
|
||||||
|
offset: number,
|
||||||
|
result: ImportContactsResult,
|
||||||
|
caches: ExcelImportLookupCaches,
|
||||||
|
opts: { skipDuplicateCheck: boolean },
|
||||||
|
) {
|
||||||
|
const resolveUser = async (name?: string) => {
|
||||||
|
const key = (name ?? '').trim().toLowerCase();
|
||||||
|
if (!key) return null;
|
||||||
|
if (caches.user.has(key)) return caches.user.get(key) ?? null;
|
||||||
|
const id = await this.findUserId(actor.organizationId, name);
|
||||||
|
caches.user.set(key, id);
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
const resolvePerson = async (row: Record<string, string>) => {
|
||||||
|
const mobile = storedPhoneNumber(row.personMobile);
|
||||||
|
const name = (row.personName ?? '').trim().toLowerCase();
|
||||||
|
const key = mobile ? `m:${mobile}` : name ? `n:${name}` : '';
|
||||||
|
if (!key) return null;
|
||||||
|
if (caches.person.has(key)) return caches.person.get(key) ?? null;
|
||||||
|
const id = await this.findPersonId(actor.organizationId, row);
|
||||||
|
caches.person.set(key, id);
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
const resolveCompany = async (name?: string) => {
|
||||||
|
const key = (name ?? '').trim().toLowerCase();
|
||||||
|
if (!key) return null;
|
||||||
|
if (caches.company.has(key)) return caches.company.get(key) ?? null;
|
||||||
|
const id = await this.findCompanyId(actor.organizationId, name);
|
||||||
|
caches.company.set(key, id);
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
const resolveDeal = async (title?: string) => {
|
||||||
|
const key = (title ?? '').trim().toLowerCase();
|
||||||
|
if (!key) return null;
|
||||||
|
if (caches.deal.has(key)) return caches.deal.get(key) ?? null;
|
||||||
|
const id = await this.findDealId(actor.organizationId, title);
|
||||||
|
caches.deal.set(key, id);
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < slice.length; i++) {
|
||||||
|
const row = slice[i]!;
|
||||||
|
const rowNum = offset + i + 2;
|
||||||
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'activity') {
|
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'activity') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -179,31 +403,40 @@ export class ActivitiesExcelService {
|
|||||||
const status = parseStatus(row.status);
|
const status = parseStatus(row.status);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const personId = await this.findPersonId(actor.organizationId, row);
|
const personId = await resolvePerson(row);
|
||||||
const companyId = await this.findCompanyId(actor.organizationId, row.companyName);
|
const companyId = await resolveCompany(row.companyName);
|
||||||
const dealId = await this.findDealId(actor.organizationId, row.dealTitle);
|
const dealId = await resolveDeal(row.dealTitle);
|
||||||
const assigneeId =
|
const assigneeNames = String(row.assignee ?? '')
|
||||||
(await this.findUserId(actor.organizationId, row.assignee)) ?? actor.userId;
|
.split(/[,،]/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const assigneeIds: string[] = [];
|
||||||
|
for (const name of assigneeNames) {
|
||||||
|
const uid = await resolveUser(name);
|
||||||
|
if (uid && !assigneeIds.includes(uid)) assigneeIds.push(uid);
|
||||||
|
}
|
||||||
|
if (!assigneeIds.length) assigneeIds.push(actor.userId);
|
||||||
|
const assigneeId = assigneeIds[0]!;
|
||||||
const creatorId =
|
const creatorId =
|
||||||
(await this.findUserId(actor.organizationId, row.creator)) ?? actor.userId;
|
(await resolveUser(row.creator)) ?? actor.userId;
|
||||||
|
|
||||||
const duplicate = dueAt
|
if (!opts.skipDuplicateCheck && dueAt) {
|
||||||
? await this.prisma.activity.findFirst({
|
const duplicate = await this.prisma.activity.findFirst({
|
||||||
where: {
|
where: {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
title,
|
title,
|
||||||
dueAt,
|
dueAt,
|
||||||
personId: personId ?? null,
|
personId: personId ?? null,
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
});
|
||||||
: null;
|
if (duplicate) {
|
||||||
if (duplicate) {
|
result.skipped += 1;
|
||||||
result.skipped += 1;
|
continue;
|
||||||
continue;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.activity.create({
|
const created = await this.prisma.activity.create({
|
||||||
data: {
|
data: {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
title,
|
title,
|
||||||
@@ -220,7 +453,20 @@ export class ActivitiesExcelService {
|
|||||||
assigneeId,
|
assigneeId,
|
||||||
createdById: creatorId,
|
createdById: creatorId,
|
||||||
},
|
},
|
||||||
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
if (assigneeIds.length > 1) {
|
||||||
|
try {
|
||||||
|
await this.prisma.activity.update({
|
||||||
|
where: { id: created.id },
|
||||||
|
data: {
|
||||||
|
assignees: { set: assigneeIds.map((id) => ({ id })) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* assigneeId بهتنهایی کافی است */
|
||||||
|
}
|
||||||
|
}
|
||||||
result.created += 1;
|
result.created += 1;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
result.errors.push({
|
result.errors.push({
|
||||||
@@ -229,15 +475,16 @@ export class ActivitiesExcelService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
@@ -17,28 +18,187 @@ 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 { Roles } from '../auth/roles.decorator';
|
||||||
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 { ActivitiesService } from './activities.service';
|
import { ActivitiesService } from './activities.service';
|
||||||
import { ActivitiesExcelService } from './activities-excel.service';
|
import { ActivitiesExcelService } from './activities-excel.service';
|
||||||
|
import { ActivityStagesService } from './activity-stages.service';
|
||||||
|
import { ActivityTypesService } from './activity-types.service';
|
||||||
import {
|
import {
|
||||||
|
CreateActivityBoardDto,
|
||||||
CreateActivityDto,
|
CreateActivityDto,
|
||||||
|
CreateActivityStageDto,
|
||||||
|
CreateActivityTypeDto,
|
||||||
|
ImportActivityRowsDto,
|
||||||
ListActivitiesQueryDto,
|
ListActivitiesQueryDto,
|
||||||
|
ListActivityStagesQueryDto,
|
||||||
|
MoveActivityStageDto,
|
||||||
|
ReorderActivityStagesDto,
|
||||||
|
ReorderActivityTypesDto,
|
||||||
|
UpdateActivityBoardDto,
|
||||||
UpdateActivityDto,
|
UpdateActivityDto,
|
||||||
|
UpdateActivityStageDto,
|
||||||
|
UpdateActivityTypeDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
import { brand } from '@mento/shared';
|
import { brand } from '@mento/shared';
|
||||||
|
|
||||||
|
const excelUpload = FileInterceptor('file', {
|
||||||
|
storage: memoryStorage(),
|
||||||
|
limits: { fileSize: 15_000_000 },
|
||||||
|
});
|
||||||
|
|
||||||
@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,
|
||||||
private readonly excelService: ActivitiesExcelService,
|
private readonly excelService: ActivitiesExcelService,
|
||||||
|
private readonly stagesService: ActivityStagesService,
|
||||||
|
private readonly typesService: ActivityTypesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('summary')
|
@Get('summary')
|
||||||
summary(@CurrentUser() user: Actor) {
|
summary(
|
||||||
return this.activitiesService.summary(user.organizationId, user);
|
@CurrentUser() user: Actor,
|
||||||
|
@Query('desk') desk?: string,
|
||||||
|
) {
|
||||||
|
const scope = desk === 'mine' || desk === 'team' ? desk : undefined;
|
||||||
|
return this.activitiesService.summary(user.organizationId, user, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('boards')
|
||||||
|
listBoards(@CurrentUser() user: Actor) {
|
||||||
|
return this.stagesService.listBoards(user.organizationId, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('boards')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
createBoard(@CurrentUser() user: Actor, @Body() dto: CreateActivityBoardDto) {
|
||||||
|
return this.stagesService.createBoard(user.organizationId, dto.name, dto.memberIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('boards/:id')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
updateBoard(
|
||||||
|
@CurrentUser() user: Actor,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateActivityBoardDto,
|
||||||
|
) {
|
||||||
|
return this.stagesService.updateBoard(user.organizationId, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('boards/:id')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
async removeBoard(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||||
|
await this.stagesService.removeBoard(user.organizationId, id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('stages')
|
||||||
|
listStages(@CurrentUser() user: Actor, @Query() query: ListActivityStagesQueryDto) {
|
||||||
|
return this.stagesService.list(user.organizationId, query.boardId, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('stages')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
createStage(@CurrentUser() user: Actor, @Body() dto: CreateActivityStageDto) {
|
||||||
|
return this.stagesService.create(user.organizationId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('stages/reorder')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
reorderStages(@CurrentUser() user: Actor, @Body() dto: ReorderActivityStagesDto) {
|
||||||
|
return this.stagesService.reorder(user.organizationId, dto.stageIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('stages/:id')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
updateStage(
|
||||||
|
@CurrentUser() user: Actor,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateActivityStageDto,
|
||||||
|
) {
|
||||||
|
return this.stagesService.update(user.organizationId, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('stages/:id')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
async removeStage(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||||
|
await this.stagesService.remove(user.organizationId, id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('types')
|
||||||
|
listTypes(@CurrentUser() user: Actor) {
|
||||||
|
return this.typesService.list(user.organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('types')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
createType(@CurrentUser() user: Actor, @Body() dto: CreateActivityTypeDto) {
|
||||||
|
return this.typesService.create(user.organizationId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('types/reorder')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
reorderTypes(@CurrentUser() user: Actor, @Body() dto: ReorderActivityTypesDto) {
|
||||||
|
return this.typesService.reorder(user.organizationId, dto.typeIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('types/:id')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
updateType(
|
||||||
|
@CurrentUser() user: Actor,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateActivityTypeDto,
|
||||||
|
) {
|
||||||
|
return this.typesService.update(user.organizationId, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('types/:id')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
async removeType(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||||
|
await this.typesService.remove(user.organizationId, id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('stage-counts')
|
||||||
|
stageCounts(
|
||||||
|
@CurrentUser() user: Actor,
|
||||||
|
@Query() query: ListActivitiesQueryDto,
|
||||||
|
) {
|
||||||
|
return this.activitiesService.stageCounts(
|
||||||
|
user.organizationId,
|
||||||
|
user,
|
||||||
|
query.filter ?? 'all',
|
||||||
|
query.q,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -57,11 +217,57 @@ export class ActivitiesController {
|
|||||||
personId: query.personId,
|
personId: query.personId,
|
||||||
companyId: query.companyId,
|
companyId: query.companyId,
|
||||||
dealId: query.dealId,
|
dealId: query.dealId,
|
||||||
|
leadId: query.leadId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: query.from,
|
from: query.from,
|
||||||
to: query.to,
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ترتیب همان لیست میز، برای فعالیت قبلی/بعدی در جزئیات */
|
||||||
|
@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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,29 +286,61 @@ export class ActivitiesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('import/preview')
|
@Post('import/preview')
|
||||||
@UseInterceptors(
|
@UseInterceptors(excelUpload)
|
||||||
FileInterceptor('file', {
|
importPreview(@UploadedFile() file?: Express.Multer.File) {
|
||||||
storage: memoryStorage(),
|
if (!file?.buffer?.length) {
|
||||||
limits: { fileSize: 15_000_000 },
|
throw new BadRequestException('فایل ارسال نشده');
|
||||||
}),
|
}
|
||||||
)
|
|
||||||
importPreview(@UploadedFile() file: Express.Multer.File) {
|
|
||||||
return this.excelService.preview(file);
|
return this.excelService.preview(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('import')
|
@Post('import/prepare')
|
||||||
@UseInterceptors(
|
@UseInterceptors(excelUpload)
|
||||||
FileInterceptor('file', {
|
importPrepare(
|
||||||
storage: memoryStorage(),
|
|
||||||
limits: { fileSize: 15_000_000 },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
import(
|
|
||||||
@CurrentUser() user: Actor,
|
@CurrentUser() user: Actor,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file?: Express.Multer.File,
|
||||||
@Body('mapping') mapping?: string,
|
@Body('mapping') mapping?: string,
|
||||||
) {
|
) {
|
||||||
return this.excelService.import(user, file, mapping);
|
if (!file?.buffer?.length) {
|
||||||
|
throw new BadRequestException('فایل ارسال نشده');
|
||||||
|
}
|
||||||
|
return this.excelService.prepare(user, file, mapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('import/prepare/:sessionId')
|
||||||
|
importPrepareStatus(
|
||||||
|
@CurrentUser() user: Actor,
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
) {
|
||||||
|
return this.excelService.prepareStatus(user, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('import/rows')
|
||||||
|
importRows(
|
||||||
|
@CurrentUser() user: Actor,
|
||||||
|
@Body() dto: ImportActivityRowsDto,
|
||||||
|
) {
|
||||||
|
return this.excelService.importRows(user, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('import')
|
||||||
|
@UseInterceptors(excelUpload)
|
||||||
|
import(
|
||||||
|
@CurrentUser() user: Actor,
|
||||||
|
@UploadedFile() file?: Express.Multer.File,
|
||||||
|
@Body('mapping') mapping?: string,
|
||||||
|
@Body('sessionId') sessionId?: string,
|
||||||
|
@Body('offset') offset?: string,
|
||||||
|
@Body('limit') limit?: string,
|
||||||
|
) {
|
||||||
|
if (!file?.buffer?.length && !sessionId) {
|
||||||
|
throw new BadRequestException('فایل ارسال نشده');
|
||||||
|
}
|
||||||
|
return this.excelService.import(user, file, mapping, {
|
||||||
|
sessionId,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@@ -110,6 +348,61 @@ 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')
|
||||||
|
promoteFromPrior(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||||
|
return this.activitiesService.promoteFromPrior(user, id);
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
create(@CurrentUser() user: Actor, @Body() dto: CreateActivityDto) {
|
create(@CurrentUser() user: Actor, @Body() dto: CreateActivityDto) {
|
||||||
return this.activitiesService.create(
|
return this.activitiesService.create(
|
||||||
@@ -129,6 +422,21 @@ export class ActivitiesController {
|
|||||||
return this.activitiesService.update(user.organizationId, id, user, dto);
|
return this.activitiesService.update(user.organizationId, id, user, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':id/stage')
|
||||||
|
moveStage(
|
||||||
|
@CurrentUser() user: Actor,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: MoveActivityStageDto,
|
||||||
|
) {
|
||||||
|
return this.activitiesService.moveToStage(
|
||||||
|
user.organizationId,
|
||||||
|
id,
|
||||||
|
user,
|
||||||
|
dto.stageId,
|
||||||
|
dto.position,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':id/complete')
|
@Patch(':id/complete')
|
||||||
complete(@CurrentUser() user: Actor, @Param('id') id: string) {
|
complete(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||||
return this.activitiesService.complete(user.organizationId, id, user);
|
return this.activitiesService.complete(user.organizationId, id, user);
|
||||||
|
|||||||
@@ -2,14 +2,22 @@ import { Module } from '@nestjs/common';
|
|||||||
import { ActivitiesController } from './activities.controller';
|
import { ActivitiesController } from './activities.controller';
|
||||||
import { ActivitiesService } from './activities.service';
|
import { ActivitiesService } from './activities.service';
|
||||||
import { ActivitiesExcelService } from './activities-excel.service';
|
import { ActivitiesExcelService } from './activities-excel.service';
|
||||||
|
import { ActivityStagesService } from './activity-stages.service';
|
||||||
|
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: [ActivitiesService, ActivitiesExcelService],
|
providers: [
|
||||||
exports: [ActivitiesService],
|
ActivitiesService,
|
||||||
|
ActivitiesExcelService,
|
||||||
|
ActivityStagesService,
|
||||||
|
ActivityTypesService,
|
||||||
|
],
|
||||||
|
exports: [ActivitiesService, ActivityStagesService, ActivityTypesService],
|
||||||
})
|
})
|
||||||
export class ActivitiesModule {}
|
export class ActivitiesModule {}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,540 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
ActivityBoardDto,
|
||||||
|
ActivityStageColor,
|
||||||
|
ActivityStageDto,
|
||||||
|
ActivityStatus,
|
||||||
|
} from '@mento/shared';
|
||||||
|
import { ACTIVITY_STAGE_COLORS } from '@mento/shared';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const DEFAULT_BOARD_NAME = 'کارهای اصلی';
|
||||||
|
|
||||||
|
const DEFAULT_STAGES: Array<{ name: string; color: ActivityStageColor; kind: ActivityStatus }> = [
|
||||||
|
{ name: 'در صف', color: 'orange', kind: 'open' },
|
||||||
|
{ name: 'در حال انجام', color: 'blue', kind: 'open' },
|
||||||
|
{ name: 'انجامشده', color: 'green', kind: 'done' },
|
||||||
|
{ name: 'لغو شده', color: 'rose', kind: 'canceled' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const KINDS: ActivityStatus[] = ['open', 'done', 'canceled'];
|
||||||
|
|
||||||
|
type StageRow = {
|
||||||
|
id: string;
|
||||||
|
boardId: string;
|
||||||
|
name: string;
|
||||||
|
position: number;
|
||||||
|
color: string;
|
||||||
|
kind: ActivityStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BoardRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
position: number;
|
||||||
|
members: Array<{ userId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toBoardDto(row: BoardRow): ActivityBoardDto {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
isDefault: row.isDefault,
|
||||||
|
position: row.position,
|
||||||
|
memberIds: row.members.map((member) => member.userId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function managesAllBoards(role: string) {
|
||||||
|
return role === 'owner' || role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGE_SELECT = {
|
||||||
|
id: true,
|
||||||
|
boardId: true,
|
||||||
|
name: true,
|
||||||
|
position: true,
|
||||||
|
color: true,
|
||||||
|
kind: true,
|
||||||
|
} satisfies Prisma.ActivityStageSelect;
|
||||||
|
|
||||||
|
const BOARD_SELECT = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
isDefault: true,
|
||||||
|
position: true,
|
||||||
|
members: { select: { userId: true } },
|
||||||
|
} satisfies Prisma.ActivityBoardSelect;
|
||||||
|
|
||||||
|
export type StageDefaults = Record<ActivityStatus, string | null>;
|
||||||
|
|
||||||
|
/** پیشفرضهای هر گروه برای محاسبهٔ «ستون مؤثر» فعالیتها */
|
||||||
|
export type BoardStageContext = {
|
||||||
|
defaultBoardId: string;
|
||||||
|
defaults: Map<string, StageDefaults>;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ActivityStagesService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
// —— گروهها ——
|
||||||
|
|
||||||
|
/** گروه پیشفرض محیط را (با ستونهای اولیه) تضمین میکند */
|
||||||
|
async ensureDefaultBoard(organizationId: string): Promise<BoardRow> {
|
||||||
|
let board = await this.prisma.activityBoard.findFirst({
|
||||||
|
where: { organizationId, isDefault: true },
|
||||||
|
select: BOARD_SELECT,
|
||||||
|
});
|
||||||
|
if (!board) {
|
||||||
|
board = await this.prisma.activityBoard
|
||||||
|
.create({
|
||||||
|
data: { organizationId, name: DEFAULT_BOARD_NAME, isDefault: true, position: 0 },
|
||||||
|
select: BOARD_SELECT,
|
||||||
|
})
|
||||||
|
.catch(async (e) => {
|
||||||
|
// ساخت همزمان در دو درخواست
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
return this.prisma.activityBoard.findFirstOrThrow({
|
||||||
|
where: { organizationId, isDefault: true },
|
||||||
|
select: BOARD_SELECT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.ensureBoardStages(organizationId, board.id);
|
||||||
|
return board;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureBoardStages(organizationId: string, boardId: string) {
|
||||||
|
const count = await this.prisma.activityStage.count({ where: { boardId } });
|
||||||
|
if (count > 0) return;
|
||||||
|
await this.prisma.activityStage.createMany({
|
||||||
|
data: DEFAULT_STAGES.map((s, i) => ({
|
||||||
|
organizationId,
|
||||||
|
boardId,
|
||||||
|
name: s.name,
|
||||||
|
color: s.color,
|
||||||
|
kind: s.kind,
|
||||||
|
position: i,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listBoards(
|
||||||
|
organizationId: string,
|
||||||
|
actor?: { userId: string; role: string },
|
||||||
|
): Promise<ActivityBoardDto[]> {
|
||||||
|
await this.ensureDefaultBoard(organizationId);
|
||||||
|
const rows = await this.prisma.activityBoard.findMany({
|
||||||
|
where: { organizationId },
|
||||||
|
orderBy: [{ isDefault: 'desc' }, { position: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
select: BOARD_SELECT,
|
||||||
|
});
|
||||||
|
const boards = rows.map(toBoardDto);
|
||||||
|
if (!actor || managesAllBoards(actor.role)) return boards;
|
||||||
|
return boards.filter(
|
||||||
|
(board) => board.memberIds.length === 0 || board.memberIds.includes(actor.userId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** گروه بدون عضو برای همه باز است؛ مالک و مدیر همهٔ گروهها را میبینند */
|
||||||
|
async assertCanAccessBoard(
|
||||||
|
organizationId: string,
|
||||||
|
boardId: string,
|
||||||
|
actor: { userId: string; role: string },
|
||||||
|
) {
|
||||||
|
if (managesAllBoards(actor.role)) return;
|
||||||
|
const members = await this.prisma.activityBoardMember.findMany({
|
||||||
|
where: { boardId, board: { organizationId } },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
if (!members.length || members.some((member) => member.userId === actor.userId)) return;
|
||||||
|
throw new ForbiddenException('به این گروه فعالیت دسترسی ندارید');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** شناسهٔ گروه معتبر؛ خالی = گروه پیشفرض */
|
||||||
|
async resolveBoardId(organizationId: string, boardId?: string | null): Promise<string> {
|
||||||
|
const id = boardId?.trim();
|
||||||
|
if (!id) return (await this.ensureDefaultBoard(organizationId)).id;
|
||||||
|
const board = await this.prisma.activityBoard.findFirst({
|
||||||
|
where: { id, organizationId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!board) throw new BadRequestException('گروه فعالیت انتخابشده معتبر نیست');
|
||||||
|
return board.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBoard(
|
||||||
|
organizationId: string,
|
||||||
|
name: string,
|
||||||
|
memberIds?: string[],
|
||||||
|
): Promise<ActivityBoardDto> {
|
||||||
|
await this.ensureDefaultBoard(organizationId);
|
||||||
|
const max = await this.prisma.activityBoard.aggregate({
|
||||||
|
where: { organizationId },
|
||||||
|
_max: { position: true },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const board = await this.prisma.activityBoard.create({
|
||||||
|
data: {
|
||||||
|
organizationId,
|
||||||
|
name: this.cleanBoardName(name),
|
||||||
|
position: (max._max.position ?? 0) + 1,
|
||||||
|
},
|
||||||
|
select: BOARD_SELECT,
|
||||||
|
});
|
||||||
|
await this.ensureBoardStages(organizationId, board.id);
|
||||||
|
if (memberIds !== undefined) await this.replaceMembers(organizationId, board.id, memberIds);
|
||||||
|
return this.getBoardDto(organizationId, board.id);
|
||||||
|
} catch (e) {
|
||||||
|
throw this.mapBoardError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBoard(
|
||||||
|
organizationId: string,
|
||||||
|
id: string,
|
||||||
|
dto: { name?: string; memberIds?: string[] },
|
||||||
|
): Promise<ActivityBoardDto> {
|
||||||
|
await this.getBoard(organizationId, id);
|
||||||
|
try {
|
||||||
|
if (dto.name != null) {
|
||||||
|
await this.prisma.activityBoard.update({
|
||||||
|
where: { id },
|
||||||
|
data: { name: this.cleanBoardName(dto.name) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (dto.memberIds !== undefined) await this.replaceMembers(organizationId, id, dto.memberIds);
|
||||||
|
return this.getBoardDto(organizationId, id);
|
||||||
|
} catch (e) {
|
||||||
|
throw this.mapBoardError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async replaceMembers(organizationId: string, boardId: string, memberIds: string[]) {
|
||||||
|
const ids = [...new Set(memberIds.map((id) => id.trim()).filter(Boolean))];
|
||||||
|
if (ids.length) {
|
||||||
|
const found = await this.prisma.membership.findMany({
|
||||||
|
where: { organizationId, userId: { in: ids }, isActive: true },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
if (found.length !== ids.length) {
|
||||||
|
throw new BadRequestException('بعضی از اعضا در این محیط نیستند');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.prisma.activityBoardMember.deleteMany({ where: { boardId } });
|
||||||
|
if (ids.length) {
|
||||||
|
await this.prisma.activityBoardMember.createMany({
|
||||||
|
data: ids.map((userId) => ({ boardId, userId })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getBoardDto(organizationId: string, id: string): Promise<ActivityBoardDto> {
|
||||||
|
const board = await this.prisma.activityBoard.findFirst({
|
||||||
|
where: { id, organizationId },
|
||||||
|
select: BOARD_SELECT,
|
||||||
|
});
|
||||||
|
if (!board) throw new NotFoundException('گروه فعالیت پیدا نشد');
|
||||||
|
return toBoardDto(board);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** فعالیتهای گروه حذفشده به گروه پیشفرض (ستون اولِ هموضعیت) منتقل میشوند */
|
||||||
|
async removeBoard(organizationId: string, id: string): Promise<void> {
|
||||||
|
const board = await this.getBoard(organizationId, id);
|
||||||
|
if (board.isDefault) {
|
||||||
|
throw new BadRequestException('گروه پیشفرض قابل حذف نیست');
|
||||||
|
}
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.activity.updateMany({
|
||||||
|
where: { organizationId, boardId: id },
|
||||||
|
data: { boardId: null, stageId: null },
|
||||||
|
}),
|
||||||
|
this.prisma.activityBoard.delete({ where: { id } }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getBoard(organizationId: string, id: string): Promise<BoardRow> {
|
||||||
|
const board = await this.prisma.activityBoard.findFirst({
|
||||||
|
where: { id, organizationId },
|
||||||
|
select: BOARD_SELECT,
|
||||||
|
});
|
||||||
|
if (!board) throw new NotFoundException('گروه فعالیت پیدا نشد');
|
||||||
|
return board;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** شرط Prisma برای فعالیتهای یک گروه (فعالیت بدون گروه = گروه پیشفرض) */
|
||||||
|
boardWhere(boardId: string, defaultBoardId: string): Prisma.ActivityWhereInput {
|
||||||
|
return boardId === defaultBoardId
|
||||||
|
? { OR: [{ boardId }, { boardId: null }] }
|
||||||
|
: { boardId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— ستونها ——
|
||||||
|
|
||||||
|
async rows(organizationId: string, boardId?: string | null): Promise<StageRow[]> {
|
||||||
|
const resolved = await this.resolveBoardId(organizationId, boardId);
|
||||||
|
return this.prisma.activityStage.findMany({
|
||||||
|
where: { organizationId, boardId: resolved },
|
||||||
|
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
select: STAGE_SELECT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
organizationId: string,
|
||||||
|
boardId?: string | null,
|
||||||
|
actor?: { userId: string; role: string },
|
||||||
|
): Promise<ActivityStageDto[]> {
|
||||||
|
const resolved = await this.resolveBoardId(organizationId, boardId);
|
||||||
|
if (actor) await this.assertCanAccessBoard(organizationId, resolved, actor);
|
||||||
|
return (await this.rows(organizationId, resolved)).map((r) => this.toDto(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** اولین مرحلهٔ هر نوع = مقصد فعالیتهای بدون مرحله یا با مرحلهٔ ناهمنوع */
|
||||||
|
defaultsOf(rows: StageRow[]): StageDefaults {
|
||||||
|
const out: StageDefaults = { open: null, done: null, canceled: null };
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!out[r.kind]) out[r.kind] = r.id;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** پیشفرضهای ستون برای همهٔ گروههای محیط */
|
||||||
|
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 برای فعالیتهایی که «مؤثراً» در این مرحله هستند */
|
||||||
|
async whereForStage(
|
||||||
|
organizationId: string,
|
||||||
|
stageId: string,
|
||||||
|
): Promise<Prisma.ActivityWhereInput> {
|
||||||
|
const stage = await this.get(organizationId, stageId);
|
||||||
|
const defaultBoard = await this.ensureDefaultBoard(organizationId);
|
||||||
|
const boardRows = await this.rows(organizationId, stage.boardId);
|
||||||
|
const inBoard = this.boardWhere(stage.boardId, defaultBoard.id);
|
||||||
|
const isDefault = this.defaultsOf(boardRows)[stage.kind] === stage.id;
|
||||||
|
if (!isDefault) {
|
||||||
|
return { AND: [inBoard, { status: stage.kind, stageId: stage.id }] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
AND: [
|
||||||
|
inBoard,
|
||||||
|
{ status: stage.kind },
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ stageId: stage.id },
|
||||||
|
{ stageId: null },
|
||||||
|
{ stage: { kind: { not: stage.kind } } },
|
||||||
|
{ stage: { boardId: { not: stage.boardId } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
effectiveBoardId(row: { boardId?: string | null }, ctx?: BoardStageContext): string | null {
|
||||||
|
return row.boardId ?? ctx?.defaultBoardId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
effectiveStageId(
|
||||||
|
row: {
|
||||||
|
status: ActivityStatus;
|
||||||
|
boardId?: string | null;
|
||||||
|
stage?: { id: string; kind: ActivityStatus; boardId: string } | null;
|
||||||
|
},
|
||||||
|
ctx?: BoardStageContext,
|
||||||
|
): string | null {
|
||||||
|
const boardId = this.effectiveBoardId(row, ctx);
|
||||||
|
if (row.stage && row.stage.kind === row.status && (!boardId || row.stage.boardId === boardId)) {
|
||||||
|
return row.stage.id;
|
||||||
|
}
|
||||||
|
if (!ctx || !boardId) return null;
|
||||||
|
return ctx.defaults.get(boardId)?.[row.status] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(organizationId: string, id: string): Promise<StageRow> {
|
||||||
|
const row = await this.prisma.activityStage.findFirst({
|
||||||
|
where: { id, organizationId },
|
||||||
|
select: STAGE_SELECT,
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('مرحله پیدا نشد');
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
organizationId: string,
|
||||||
|
dto: { boardId?: string; name: string; color?: string; kind?: ActivityStatus },
|
||||||
|
): Promise<ActivityStageDto> {
|
||||||
|
const boardId = await this.resolveBoardId(organizationId, dto.boardId);
|
||||||
|
await this.ensureBoardStages(organizationId, boardId);
|
||||||
|
const max = await this.prisma.activityStage.aggregate({
|
||||||
|
where: { boardId },
|
||||||
|
_max: { position: true },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const row = await this.prisma.activityStage.create({
|
||||||
|
data: {
|
||||||
|
organizationId,
|
||||||
|
boardId,
|
||||||
|
name: this.cleanName(dto.name),
|
||||||
|
color: this.cleanColor(dto.color),
|
||||||
|
kind: dto.kind && KINDS.includes(dto.kind) ? dto.kind : 'open',
|
||||||
|
position: (max._max.position ?? -1) + 1,
|
||||||
|
},
|
||||||
|
select: STAGE_SELECT,
|
||||||
|
});
|
||||||
|
return this.toDto(row);
|
||||||
|
} catch (e) {
|
||||||
|
throw this.mapError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
organizationId: string,
|
||||||
|
id: string,
|
||||||
|
dto: { name?: string; color?: string; kind?: ActivityStatus },
|
||||||
|
): Promise<ActivityStageDto> {
|
||||||
|
const stage = await this.get(organizationId, id);
|
||||||
|
const nextKind = dto.kind && KINDS.includes(dto.kind) ? dto.kind : stage.kind;
|
||||||
|
if (nextKind !== stage.kind) {
|
||||||
|
await this.assertNotLastOfKind(stage);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const row = await this.prisma.$transaction(async (tx) => {
|
||||||
|
if (nextKind !== stage.kind) {
|
||||||
|
// فعالیتها وضعیتشان را نگه میدارند و به اولین مرحلهٔ همنوع خودشان برمیگردند
|
||||||
|
await tx.activity.updateMany({
|
||||||
|
where: { organizationId, stageId: id },
|
||||||
|
data: { stageId: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tx.activityStage.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
|
||||||
|
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
|
||||||
|
kind: nextKind,
|
||||||
|
},
|
||||||
|
select: STAGE_SELECT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return this.toDto(row);
|
||||||
|
} catch (e) {
|
||||||
|
throw this.mapError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ترتیب ستونهای یک گروه؛ همهٔ شناسهها باید از همان گروه باشند */
|
||||||
|
async reorder(organizationId: string, stageIds: string[]): Promise<ActivityStageDto[]> {
|
||||||
|
if (!stageIds.length) return [];
|
||||||
|
const rows = await this.prisma.activityStage.findMany({
|
||||||
|
where: { organizationId, id: { in: stageIds } },
|
||||||
|
select: { id: true, boardId: true },
|
||||||
|
});
|
||||||
|
const boardIds = new Set(rows.map((r) => r.boardId));
|
||||||
|
if (rows.length !== new Set(stageIds).size || boardIds.size !== 1) {
|
||||||
|
throw new BadRequestException('ستونهای ارسالی باید همه از یک گروه باشند');
|
||||||
|
}
|
||||||
|
const [boardId] = [...boardIds];
|
||||||
|
await this.prisma.$transaction(
|
||||||
|
stageIds.map((id, position) =>
|
||||||
|
this.prisma.activityStage.updateMany({
|
||||||
|
where: { id, organizationId, boardId },
|
||||||
|
data: { position },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return this.list(organizationId, boardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(organizationId: string, id: string): Promise<void> {
|
||||||
|
const stage = await this.get(organizationId, id);
|
||||||
|
await this.assertNotLastOfKind(stage);
|
||||||
|
// onDelete: SetNull → فعالیتها به اولین مرحلهٔ همنوع وضعیتشان در همان گروه منتقل میشوند
|
||||||
|
await this.prisma.activityStage.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertNotLastOfKind(stage: StageRow) {
|
||||||
|
const sameKind = await this.prisma.activityStage.count({
|
||||||
|
where: { boardId: stage.boardId, kind: stage.kind },
|
||||||
|
});
|
||||||
|
if (sameKind <= 1) {
|
||||||
|
const label =
|
||||||
|
stage.kind === 'done' ? 'انجامشده' : stage.kind === 'canceled' ? 'لغو شده' : 'باز';
|
||||||
|
throw new BadRequestException(
|
||||||
|
`حداقل یک ستون از نوع «${label}» در هر گروه لازم است؛ اول یک ستون دیگر از همین نوع بسازید`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanName(raw: string): string {
|
||||||
|
const name = raw?.trim() ?? '';
|
||||||
|
if (!name) throw new BadRequestException('نام ستون لازم است');
|
||||||
|
if (name.length > 80) throw new BadRequestException('نام ستون حداکثر ۸۰ کاراکتر است');
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanBoardName(raw: string): string {
|
||||||
|
const name = raw?.trim() ?? '';
|
||||||
|
if (name.length < 2) throw new BadRequestException('نام گروه حداقل ۲ کاراکتر است');
|
||||||
|
if (name.length > 80) throw new BadRequestException('نام گروه حداکثر ۸۰ کاراکتر است');
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanColor(raw?: string): ActivityStageColor {
|
||||||
|
return ACTIVITY_STAGE_COLORS.includes(raw as ActivityStageColor)
|
||||||
|
? (raw as ActivityStageColor)
|
||||||
|
: 'green';
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapError(e: unknown) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
return new BadRequestException('ستونی با این نام در این گروه وجود دارد');
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapBoardError(e: unknown) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
return new BadRequestException('گروهی با این نام وجود دارد');
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: StageRow): ActivityStageDto {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
boardId: row.boardId,
|
||||||
|
name: row.name,
|
||||||
|
position: row.position,
|
||||||
|
color: this.cleanColor(row.color),
|
||||||
|
kind: row.kind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,649 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
ActivityChecklistItem,
|
||||||
|
ActivityFieldDef,
|
||||||
|
ActivityFieldType,
|
||||||
|
ActivityFieldValue,
|
||||||
|
ActivityFieldValues,
|
||||||
|
ActivityParticipantsValue,
|
||||||
|
ActivityStageColor,
|
||||||
|
ActivityType,
|
||||||
|
ActivityTypeDefDto,
|
||||||
|
ActivityTypeIcon,
|
||||||
|
ActivityTypeSummaryDto,
|
||||||
|
CreateActivityTypeRequest,
|
||||||
|
UpdateActivityTypeRequest,
|
||||||
|
} from '@mento/shared';
|
||||||
|
import {
|
||||||
|
ACTIVITY_FIELD_TYPES,
|
||||||
|
ACTIVITY_FIELD_TYPES_WITH_OPTIONS,
|
||||||
|
ACTIVITY_STAGE_COLORS,
|
||||||
|
ACTIVITY_TYPE_ICONS,
|
||||||
|
} from '@mento/shared';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const BASE_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
|
||||||
|
const MAX_FIELDS = 30;
|
||||||
|
const MAX_OPTIONS = 50;
|
||||||
|
const MAX_CHECKLIST = 100;
|
||||||
|
const MAX_PARTICIPANTS = 100;
|
||||||
|
|
||||||
|
type Seed = {
|
||||||
|
name: string;
|
||||||
|
baseType: ActivityType;
|
||||||
|
icon: ActivityTypeIcon;
|
||||||
|
color: ActivityStageColor;
|
||||||
|
isSystem?: boolean;
|
||||||
|
fields: ActivityFieldDef[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_TYPES: Seed[] = [
|
||||||
|
{
|
||||||
|
name: 'تماس',
|
||||||
|
baseType: 'call',
|
||||||
|
icon: 'phone',
|
||||||
|
color: 'green',
|
||||||
|
isSystem: true,
|
||||||
|
fields: [
|
||||||
|
{ key: 'call_direction', label: 'جهت تماس', type: 'select', options: ['خروجی', 'ورودی'] },
|
||||||
|
{
|
||||||
|
key: 'call_result',
|
||||||
|
label: 'نتیجه تماس',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
'پاسخ داد',
|
||||||
|
'پاسخ نداد',
|
||||||
|
'مشغول بود',
|
||||||
|
'خاموش / در دسترس نبود',
|
||||||
|
'شماره اشتباه',
|
||||||
|
'نیاز به پیگیری مجدد',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'جلسه',
|
||||||
|
baseType: 'meeting',
|
||||||
|
icon: 'users',
|
||||||
|
color: 'purple',
|
||||||
|
isSystem: true,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'meeting_subject',
|
||||||
|
label: 'موضوع جلسه',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'مثلاً: بررسی پیشفاکتور و شرایط پرداخت',
|
||||||
|
},
|
||||||
|
{ key: 'meeting_participants', label: 'شرکتکنندگان', type: 'participants' },
|
||||||
|
{
|
||||||
|
key: 'meeting_location',
|
||||||
|
label: 'محل برگزاری / لینک جلسه',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'آدرس یا لینک جلسهٔ آنلاین',
|
||||||
|
},
|
||||||
|
{ key: 'meeting_minutes', label: 'صورتجلسه', type: 'textarea' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'وظیفه',
|
||||||
|
baseType: 'task',
|
||||||
|
icon: 'check',
|
||||||
|
color: 'orange',
|
||||||
|
isSystem: true,
|
||||||
|
fields: [{ key: 'task_checklist', label: 'لیست کارها', type: 'checklist' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'پیام',
|
||||||
|
baseType: 'note',
|
||||||
|
icon: 'chat',
|
||||||
|
color: 'blue',
|
||||||
|
isSystem: true,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'message_channel',
|
||||||
|
label: 'کانال ارسال',
|
||||||
|
type: 'select',
|
||||||
|
options: ['پیامک', 'واتساپ', 'تلگرام', 'ایتا', 'بله', 'ایمیل', 'دایرکت اینستاگرام'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'بازدید حضوری',
|
||||||
|
baseType: 'meeting',
|
||||||
|
icon: 'pin',
|
||||||
|
color: 'rose',
|
||||||
|
fields: [
|
||||||
|
{ key: 'visit_address', label: 'آدرس محل بازدید', type: 'text' },
|
||||||
|
{ key: 'visit_participants', label: 'همراهان بازدید', type: 'participants' },
|
||||||
|
{ key: 'visit_result', label: 'نتیجهٔ بازدید', type: 'textarea' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ارائه / دمو محصول',
|
||||||
|
baseType: 'meeting',
|
||||||
|
icon: 'star',
|
||||||
|
color: 'purple',
|
||||||
|
fields: [
|
||||||
|
{ key: 'demo_product', label: 'محصول / خدمت', type: 'text' },
|
||||||
|
{ key: 'demo_participants', label: 'شرکتکنندگان', type: 'participants' },
|
||||||
|
{ key: 'demo_link', label: 'لینک جلسه', type: 'url' },
|
||||||
|
{
|
||||||
|
key: 'demo_result',
|
||||||
|
label: 'نتیجهٔ دمو',
|
||||||
|
type: 'select',
|
||||||
|
options: ['علاقهمند', 'نیاز به بررسی بیشتر', 'درخواست پیشفاکتور', 'عدم علاقه'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'پیگیری',
|
||||||
|
baseType: 'call',
|
||||||
|
icon: 'bell',
|
||||||
|
color: 'orange',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'followup_reason',
|
||||||
|
label: 'دلیل پیگیری',
|
||||||
|
type: 'select',
|
||||||
|
options: ['پیشفاکتور', 'پرداخت', 'ارسال کالا', 'پشتیبانی', 'تمدید قرارداد', 'سایر'],
|
||||||
|
},
|
||||||
|
{ key: 'followup_result', label: 'نتیجهٔ پیگیری', type: 'textarea' },
|
||||||
|
{ key: 'followup_next', label: 'تاریخ پیگیری بعدی', type: 'date' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ایمیل',
|
||||||
|
baseType: 'note',
|
||||||
|
icon: 'mail',
|
||||||
|
color: 'slate',
|
||||||
|
fields: [
|
||||||
|
{ key: 'email_subject', label: 'موضوع ایمیل', type: 'text' },
|
||||||
|
{ key: 'email_to', label: 'گیرنده', type: 'text', placeholder: 'example@domain.com' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export type TypeRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
baseType: ActivityType;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
fields: Prisma.JsonValue;
|
||||||
|
position: number;
|
||||||
|
isActive: boolean;
|
||||||
|
isSystem: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** نوع اصلی هر baseType — مقصد فعالیتهای بدون typeDefId */
|
||||||
|
export type SystemTypes = Record<ActivityType, ActivityTypeSummaryDto | null>;
|
||||||
|
|
||||||
|
export type ResolvedType = {
|
||||||
|
/** مقداری که در Activity.typeDefId ذخیره میشود (نوع اصلی → null) */
|
||||||
|
typeDefId: string | null;
|
||||||
|
baseType: ActivityType;
|
||||||
|
fields: ActivityFieldDef[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const select = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
baseType: true,
|
||||||
|
icon: true,
|
||||||
|
color: true,
|
||||||
|
fields: true,
|
||||||
|
position: true,
|
||||||
|
isActive: true,
|
||||||
|
isSystem: true,
|
||||||
|
} satisfies Prisma.ActivityTypeDefSelect;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ActivityTypesService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async ensureDefaults(organizationId: string): Promise<void> {
|
||||||
|
const count = await this.prisma.activityTypeDef.count({ where: { organizationId } });
|
||||||
|
if (count > 0) return;
|
||||||
|
await this.prisma.activityTypeDef.createMany({
|
||||||
|
data: DEFAULT_TYPES.map((t, i) => ({
|
||||||
|
organizationId,
|
||||||
|
name: t.name,
|
||||||
|
baseType: t.baseType,
|
||||||
|
icon: t.icon,
|
||||||
|
color: t.color,
|
||||||
|
isSystem: Boolean(t.isSystem),
|
||||||
|
fields: t.fields as unknown as Prisma.InputJsonValue,
|
||||||
|
position: i,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async rows(organizationId: string): Promise<TypeRow[]> {
|
||||||
|
await this.ensureDefaults(organizationId);
|
||||||
|
return this.prisma.activityTypeDef.findMany({
|
||||||
|
where: { organizationId },
|
||||||
|
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(organizationId: string): Promise<ActivityTypeDefDto[]> {
|
||||||
|
return (await this.rows(organizationId)).map((r) => this.toDto(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
systemOf(rows: TypeRow[]): SystemTypes {
|
||||||
|
const out: SystemTypes = { call: null, meeting: null, task: null, note: null };
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.isSystem && !out[r.baseType]) out[r.baseType] = this.toSummary(r);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async system(organizationId: string): Promise<SystemTypes> {
|
||||||
|
return this.systemOf(await this.rows(organizationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** فیلتر Prisma برای فعالیتهایی که «مؤثراً» از این نوع هستند */
|
||||||
|
whereForType(rows: TypeRow[], typeDefId: string): Prisma.ActivityWhereInput {
|
||||||
|
const def = rows.find((r) => r.id === typeDefId);
|
||||||
|
if (!def) return { id: '__none__' };
|
||||||
|
if (!def.isSystem) return { typeDefId: def.id };
|
||||||
|
return { OR: [{ typeDefId: def.id }, { typeDefId: null, type: def.baseType }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** جستجوی نام نوع → شرطهای OR */
|
||||||
|
searchByName(rows: TypeRow[], term: string): Prisma.ActivityWhereInput[] {
|
||||||
|
const t = term.trim();
|
||||||
|
if (t.length < 2) return [];
|
||||||
|
return rows
|
||||||
|
.filter((r) => r.name.includes(t))
|
||||||
|
.map((r) => this.whereForType(rows, r.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نوع مؤثر برای ذخیره: typeDefId صریح، یا نوع اصلیِ type.
|
||||||
|
* نوع اصلی با typeDefId=null ذخیره میشود تا با دادههای قدیمی یکسان باشد.
|
||||||
|
*/
|
||||||
|
async resolve(
|
||||||
|
organizationId: string,
|
||||||
|
input: { typeDefId?: string | null; type?: ActivityType | null },
|
||||||
|
): Promise<ResolvedType> {
|
||||||
|
const rows = await this.rows(organizationId);
|
||||||
|
const requested = input.typeDefId?.trim();
|
||||||
|
if (requested) {
|
||||||
|
const def = rows.find((r) => r.id === requested);
|
||||||
|
if (!def) throw new BadRequestException('نوع فعالیت انتخابشده معتبر نیست');
|
||||||
|
return {
|
||||||
|
typeDefId: def.isSystem ? null : def.id,
|
||||||
|
baseType: def.baseType,
|
||||||
|
fields: this.parseFields(def.fields),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const baseType = input.type && BASE_TYPES.includes(input.type) ? input.type : 'task';
|
||||||
|
const sys = rows.find((r) => r.isSystem && r.baseType === baseType);
|
||||||
|
return { typeDefId: null, baseType, fields: sys ? this.parseFields(sys.fields) : [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* پاکسازی مقادیر بر اساس تعریف فیلدها؛ فقط کلیدهای همین نوع نگه داشته میشوند.
|
||||||
|
* participantIds = کاربران معتبر همهٔ فیلدهای شرکتکنندگان.
|
||||||
|
*/
|
||||||
|
async sanitizeValues(
|
||||||
|
organizationId: string,
|
||||||
|
defs: ActivityFieldDef[],
|
||||||
|
raw: unknown,
|
||||||
|
): Promise<{ fields: ActivityFieldValues; participantIds: string[] }> {
|
||||||
|
const input =
|
||||||
|
raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
|
||||||
|
const out: ActivityFieldValues = {};
|
||||||
|
const participantFields: Array<{ key: string; value: ActivityParticipantsValue }> = [];
|
||||||
|
|
||||||
|
for (const def of defs) {
|
||||||
|
const value = this.cleanValue(def, input[def.key]);
|
||||||
|
if (value === null) {
|
||||||
|
if (def.required && def.type !== 'checkbox') {
|
||||||
|
throw new BadRequestException(`فیلد «${def.label}» را تکمیل کنید`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (def.type === 'participants') {
|
||||||
|
participantFields.push({ key: def.key, value: value as ActivityParticipantsValue });
|
||||||
|
}
|
||||||
|
out[def.key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIds = [...new Set(participantFields.flatMap((p) => p.value.userIds))];
|
||||||
|
const personIds = [...new Set(participantFields.flatMap((p) => p.value.people.map((x) => x.id)))];
|
||||||
|
const [validUsers, validPeople] = await Promise.all([
|
||||||
|
userIds.length
|
||||||
|
? this.prisma.user.findMany({
|
||||||
|
where: { id: { in: userIds }, memberships: { some: { organizationId } } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
personIds.length
|
||||||
|
? this.prisma.person.findMany({
|
||||||
|
where: { id: { in: personIds }, organizationId },
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
const userSet = new Set(validUsers.map((u) => u.id));
|
||||||
|
const personNames = new Map(
|
||||||
|
validPeople.map((p) => [p.id, `${p.firstName} ${p.lastName}`.trim()]),
|
||||||
|
);
|
||||||
|
for (const p of participantFields) {
|
||||||
|
const next: ActivityParticipantsValue = {
|
||||||
|
userIds: p.value.userIds.filter((id) => userSet.has(id)),
|
||||||
|
people: p.value.people
|
||||||
|
.filter((x) => personNames.has(x.id))
|
||||||
|
.map((x) => ({ id: x.id, name: personNames.get(x.id) || x.name })),
|
||||||
|
guests: p.value.guests,
|
||||||
|
};
|
||||||
|
const def = defs.find((d) => d.key === p.key)!;
|
||||||
|
if (!next.userIds.length && !next.people.length && !next.guests.length) {
|
||||||
|
if (def.required) throw new BadRequestException(`فیلد «${def.label}» را تکمیل کنید`);
|
||||||
|
delete out[p.key];
|
||||||
|
} else {
|
||||||
|
out[p.key] = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const participantIds = [
|
||||||
|
...new Set(
|
||||||
|
Object.values(out)
|
||||||
|
.filter((v): v is ActivityParticipantsValue => isParticipants(v))
|
||||||
|
.flatMap((v) => v.userIds),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return { fields: out, participantIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
organizationId: string,
|
||||||
|
dto: CreateActivityTypeRequest,
|
||||||
|
): Promise<ActivityTypeDefDto> {
|
||||||
|
await this.ensureDefaults(organizationId);
|
||||||
|
const max = await this.prisma.activityTypeDef.aggregate({
|
||||||
|
where: { organizationId },
|
||||||
|
_max: { position: true },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const row = await this.prisma.activityTypeDef.create({
|
||||||
|
data: {
|
||||||
|
organizationId,
|
||||||
|
name: this.cleanName(dto.name),
|
||||||
|
baseType: this.cleanBase(dto.baseType),
|
||||||
|
icon: this.cleanIcon(dto.icon),
|
||||||
|
color: this.cleanColor(dto.color),
|
||||||
|
fields: this.cleanFieldDefs(dto.fields ?? []) as unknown as Prisma.InputJsonValue,
|
||||||
|
position: (max._max.position ?? -1) + 1,
|
||||||
|
},
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
return this.toDto(row);
|
||||||
|
} catch (e) {
|
||||||
|
throw this.mapError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
organizationId: string,
|
||||||
|
id: string,
|
||||||
|
dto: UpdateActivityTypeRequest,
|
||||||
|
): Promise<ActivityTypeDefDto> {
|
||||||
|
const current = await this.get(organizationId, id);
|
||||||
|
if (current.isSystem && dto.baseType && dto.baseType !== current.baseType) {
|
||||||
|
throw new BadRequestException('نوع پایهٔ انواع اصلی قابل تغییر نیست');
|
||||||
|
}
|
||||||
|
if (current.isSystem && dto.isActive === false) {
|
||||||
|
throw new BadRequestException('انواع اصلی را نمیتوان غیرفعال کرد');
|
||||||
|
}
|
||||||
|
const nextBase = dto.baseType ? this.cleanBase(dto.baseType) : current.baseType;
|
||||||
|
try {
|
||||||
|
const row = await this.prisma.$transaction(async (tx) => {
|
||||||
|
if (nextBase !== current.baseType) {
|
||||||
|
await tx.activity.updateMany({
|
||||||
|
where: { organizationId, typeDefId: id },
|
||||||
|
data: { type: nextBase },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tx.activityTypeDef.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
|
||||||
|
...(dto.icon != null ? { icon: this.cleanIcon(dto.icon) } : {}),
|
||||||
|
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
|
||||||
|
...(dto.fields != null
|
||||||
|
? { fields: this.cleanFieldDefs(dto.fields) as unknown as Prisma.InputJsonValue }
|
||||||
|
: {}),
|
||||||
|
...(dto.isActive != null ? { isActive: dto.isActive } : {}),
|
||||||
|
baseType: nextBase,
|
||||||
|
},
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return this.toDto(row);
|
||||||
|
} catch (e) {
|
||||||
|
throw this.mapError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reorder(organizationId: string, typeIds: string[]): Promise<ActivityTypeDefDto[]> {
|
||||||
|
await this.prisma.$transaction(
|
||||||
|
typeIds.map((id, position) =>
|
||||||
|
this.prisma.activityTypeDef.updateMany({
|
||||||
|
where: { id, organizationId },
|
||||||
|
data: { position },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return this.list(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(organizationId: string, id: string): Promise<void> {
|
||||||
|
const def = await this.get(organizationId, id);
|
||||||
|
if (def.isSystem) throw new BadRequestException('انواع اصلی قابل حذف نیستند');
|
||||||
|
// onDelete: SetNull → فعالیتها به نوع اصلیِ نوع پایهٔ خود برمیگردند
|
||||||
|
await this.prisma.activityTypeDef.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
toSummary(row: Pick<TypeRow, 'id' | 'name' | 'icon' | 'color'>): ActivityTypeSummaryDto {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
icon: this.cleanIcon(row.icon),
|
||||||
|
color: this.cleanColor(row.color),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
parseFields(raw: Prisma.JsonValue): ActivityFieldDef[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.filter(
|
||||||
|
(f): f is ActivityFieldDef & Prisma.JsonObject =>
|
||||||
|
Boolean(f) && typeof f === 'object' && !Array.isArray(f) && typeof (f as { key?: unknown }).key === 'string',
|
||||||
|
) as ActivityFieldDef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async get(organizationId: string, id: string): Promise<TypeRow> {
|
||||||
|
const row = await this.prisma.activityTypeDef.findFirst({
|
||||||
|
where: { id, organizationId },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('نوع فعالیت پیدا نشد');
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanValue(def: ActivityFieldDef, raw: unknown): ActivityFieldValue {
|
||||||
|
switch (def.type) {
|
||||||
|
case 'text':
|
||||||
|
return cleanText(raw, 500);
|
||||||
|
case 'textarea':
|
||||||
|
return cleanText(raw, 5000);
|
||||||
|
case 'url':
|
||||||
|
return cleanText(raw, 1000);
|
||||||
|
case 'number': {
|
||||||
|
if (raw === '' || raw == null) return null;
|
||||||
|
const n = typeof raw === 'number' ? raw : Number(String(raw).replace(/,/g, ''));
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
case 'date': {
|
||||||
|
const s = cleanText(raw, 40);
|
||||||
|
return s && /^\d{4}-\d{2}-\d{2}/.test(s) && !Number.isNaN(new Date(s).getTime())
|
||||||
|
? s
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
case 'select': {
|
||||||
|
const s = cleanText(raw, 200);
|
||||||
|
return s && (def.options ?? []).includes(s) ? s : null;
|
||||||
|
}
|
||||||
|
case 'multiselect': {
|
||||||
|
if (!Array.isArray(raw)) return null;
|
||||||
|
const opts = new Set(def.options ?? []);
|
||||||
|
const list = [...new Set(raw.filter((v): v is string => typeof v === 'string' && opts.has(v)))];
|
||||||
|
return list.length ? list : null;
|
||||||
|
}
|
||||||
|
case 'checkbox':
|
||||||
|
return raw === true ? true : raw === false ? false : null;
|
||||||
|
case 'checklist': {
|
||||||
|
if (!Array.isArray(raw)) return null;
|
||||||
|
const items: ActivityChecklistItem[] = [];
|
||||||
|
for (const it of raw.slice(0, MAX_CHECKLIST)) {
|
||||||
|
if (!it || typeof it !== 'object') continue;
|
||||||
|
const o = it as Record<string, unknown>;
|
||||||
|
const text = cleanText(o.text, 300);
|
||||||
|
if (!text) continue;
|
||||||
|
const id = typeof o.id === 'string' && o.id.trim() ? o.id.trim().slice(0, 64) : randomUUID();
|
||||||
|
items.push({ id, text, done: o.done === true });
|
||||||
|
}
|
||||||
|
return items.length ? items : null;
|
||||||
|
}
|
||||||
|
case 'participants': {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
const userIds = Array.isArray(o.userIds)
|
||||||
|
? [...new Set(o.userIds.filter((v): v is string => typeof v === 'string' && Boolean(v.trim())))]
|
||||||
|
: [];
|
||||||
|
const people = Array.isArray(o.people)
|
||||||
|
? o.people
|
||||||
|
.filter(
|
||||||
|
(p): p is { id: string; name?: unknown } =>
|
||||||
|
Boolean(p) && typeof p === 'object' && typeof (p as { id?: unknown }).id === 'string',
|
||||||
|
)
|
||||||
|
.map((p) => ({ id: p.id, name: cleanText(p.name, 200) ?? '' }))
|
||||||
|
: [];
|
||||||
|
const guests = Array.isArray(o.guests)
|
||||||
|
? [
|
||||||
|
...new Set(
|
||||||
|
o.guests
|
||||||
|
.map((g) => cleanText(g, 120))
|
||||||
|
.filter((g): g is string => Boolean(g)),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
if (!userIds.length && !people.length && !guests.length) return null;
|
||||||
|
return {
|
||||||
|
userIds: userIds.slice(0, MAX_PARTICIPANTS),
|
||||||
|
people: people.slice(0, MAX_PARTICIPANTS),
|
||||||
|
guests: guests.slice(0, MAX_PARTICIPANTS),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanFieldDefs(raw: ActivityFieldDef[]): ActivityFieldDef[] {
|
||||||
|
if (!Array.isArray(raw)) throw new BadRequestException('تعریف فیلدها نامعتبر است');
|
||||||
|
if (raw.length > MAX_FIELDS) {
|
||||||
|
throw new BadRequestException(`حداکثر ${MAX_FIELDS} فیلد برای هر نوع مجاز است`);
|
||||||
|
}
|
||||||
|
const keys = new Set<string>();
|
||||||
|
return raw.map((f, i) => {
|
||||||
|
const label = typeof f?.label === 'string' ? f.label.trim() : '';
|
||||||
|
if (!label) throw new BadRequestException(`عنوان فیلد ${i + 1} لازم است`);
|
||||||
|
if (label.length > 80) throw new BadRequestException('عنوان فیلد حداکثر ۸۰ کاراکتر است');
|
||||||
|
const type = ACTIVITY_FIELD_TYPES.includes(f.type as ActivityFieldType)
|
||||||
|
? (f.type as ActivityFieldType)
|
||||||
|
: null;
|
||||||
|
if (!type) throw new BadRequestException(`نوع فیلد «${label}» معتبر نیست`);
|
||||||
|
let key = typeof f.key === 'string' ? f.key.trim().toLowerCase() : '';
|
||||||
|
if (!/^[a-z][a-z0-9_]{0,39}$/.test(key)) key = `f_${randomUUID().slice(0, 8)}`;
|
||||||
|
if (keys.has(key)) throw new BadRequestException(`کلید فیلد «${label}» تکراری است`);
|
||||||
|
keys.add(key);
|
||||||
|
const def: ActivityFieldDef = { key, label, type };
|
||||||
|
if (ACTIVITY_FIELD_TYPES_WITH_OPTIONS.includes(type)) {
|
||||||
|
const options = [
|
||||||
|
...new Set(
|
||||||
|
(Array.isArray(f.options) ? f.options : [])
|
||||||
|
.map((o) => (typeof o === 'string' ? o.trim() : ''))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((o) => o.slice(0, 200)),
|
||||||
|
),
|
||||||
|
].slice(0, MAX_OPTIONS);
|
||||||
|
if (!options.length) {
|
||||||
|
throw new BadRequestException(`برای فیلد «${label}» حداقل یک گزینه تعریف کنید`);
|
||||||
|
}
|
||||||
|
def.options = options;
|
||||||
|
}
|
||||||
|
if (f.required) def.required = true;
|
||||||
|
const placeholder = typeof f.placeholder === 'string' ? f.placeholder.trim().slice(0, 200) : '';
|
||||||
|
if (placeholder) def.placeholder = placeholder;
|
||||||
|
return def;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanName(raw: string): string {
|
||||||
|
const name = raw?.trim() ?? '';
|
||||||
|
if (!name) throw new BadRequestException('نام نوع فعالیت لازم است');
|
||||||
|
if (name.length > 60) throw new BadRequestException('نام نوع فعالیت حداکثر ۶۰ کاراکتر است');
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanBase(raw?: ActivityType): ActivityType {
|
||||||
|
return raw && BASE_TYPES.includes(raw) ? raw : 'task';
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanIcon(raw?: string): ActivityTypeIcon {
|
||||||
|
return (ACTIVITY_TYPE_ICONS as readonly string[]).includes(raw ?? '')
|
||||||
|
? (raw as ActivityTypeIcon)
|
||||||
|
: 'check';
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanColor(raw?: string): ActivityStageColor {
|
||||||
|
return ACTIVITY_STAGE_COLORS.includes(raw as ActivityStageColor)
|
||||||
|
? (raw as ActivityStageColor)
|
||||||
|
: 'green';
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapError(e: unknown) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
return new BadRequestException('نوع فعالیتی با این نام وجود دارد');
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: TypeRow): ActivityTypeDefDto {
|
||||||
|
return {
|
||||||
|
...this.toSummary(row),
|
||||||
|
baseType: row.baseType,
|
||||||
|
fields: this.parseFields(row.fields),
|
||||||
|
position: row.position,
|
||||||
|
isActive: row.isActive,
|
||||||
|
isSystem: row.isSystem,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanText(raw: unknown, max: number): string | null {
|
||||||
|
if (typeof raw !== 'string' && typeof raw !== 'number') return null;
|
||||||
|
const s = String(raw).trim();
|
||||||
|
return s ? s.slice(0, max) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isParticipants(v: unknown): v is ActivityParticipantsValue {
|
||||||
|
return Boolean(v) && typeof v === 'object' && !Array.isArray(v) && Array.isArray((v as ActivityParticipantsValue).userIds);
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
|
Allow,
|
||||||
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
|
IsObject,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Max,
|
Max,
|
||||||
@@ -12,6 +15,29 @@ import {
|
|||||||
MinLength,
|
MinLength,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared';
|
||||||
|
import { ACTIVITY_TYPE_ICONS } from '@mento/shared';
|
||||||
|
|
||||||
|
/** تکههای JSON از ایمپورت مرورگرگرا */
|
||||||
|
export class ImportActivityRowsDto {
|
||||||
|
@IsArray()
|
||||||
|
@Allow()
|
||||||
|
rows!: Array<Record<string, string>>;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
offset!: number;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
total!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sessionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const ACTIVITY_TYPES = ['call', 'meeting', 'task', 'note'] as const;
|
const ACTIVITY_TYPES = ['call', 'meeting', 'task', 'note'] as const;
|
||||||
const ACTIVITY_STATUSES = ['open', 'done', 'canceled'] as const;
|
const ACTIVITY_STATUSES = ['open', 'done', 'canceled'] as const;
|
||||||
@@ -27,6 +53,54 @@ export class ListActivitiesQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
q?: string;
|
q?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['mine', 'team'])
|
||||||
|
desk?: 'mine' | 'team';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(ACTIVITY_TYPES)
|
||||||
|
type?: (typeof ACTIVITY_TYPES)[number];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(ACTIVITY_STATUSES)
|
||||||
|
status?: (typeof ACTIVITY_STATUSES)[number];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
assigneeId?: string;
|
||||||
|
|
||||||
|
/** فیلتر عنوان فعالیت، جدا از جستجوی کلی */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
/** نام شخص یا شرکتِ مشتری */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
customer?: string;
|
||||||
|
|
||||||
|
/** زمان شروع: set = دارد، unset = ندارد */
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['set', 'unset'])
|
||||||
|
due?: 'set' | 'unset';
|
||||||
|
|
||||||
|
/** نوع فعالیت (تعریفشده در سازمان) */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
typeDefId?: string;
|
||||||
|
|
||||||
|
/** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
stageId?: string;
|
||||||
|
|
||||||
|
/** گروه فعالیت؛ خالی = همهٔ گروهها (در شمارش ستونها = گروه پیشفرض) */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
boardId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
personId?: string;
|
personId?: string;
|
||||||
@@ -39,6 +113,10 @@ export class ListActivitiesQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
dealId?: string;
|
dealId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
leadId?: string;
|
||||||
|
|
||||||
/** Inclusive start (ISO). When set with/without filter, restricts dueAt. */
|
/** Inclusive start (ISO). When set with/without filter, restricts dueAt. */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
@@ -63,23 +141,188 @@ export class ListActivitiesQueryDto {
|
|||||||
limit?: number = 50;
|
limit?: number = 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STAGE_COLORS = ['green', 'orange', 'purple', 'blue', 'rose', 'slate'] as const;
|
||||||
|
|
||||||
|
export class CreateActivityBoardDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(80)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
memberIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateActivityBoardDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(80)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
memberIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListActivityStagesQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
boardId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateActivityStageDto {
|
||||||
|
/** خالی = گروه پیشفرض */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
boardId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(80)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(STAGE_COLORS)
|
||||||
|
color?: (typeof STAGE_COLORS)[number];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(ACTIVITY_STATUSES)
|
||||||
|
kind?: (typeof ACTIVITY_STATUSES)[number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateActivityStageDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(80)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(STAGE_COLORS)
|
||||||
|
color?: (typeof STAGE_COLORS)[number];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(ACTIVITY_STATUSES)
|
||||||
|
kind?: (typeof ACTIVITY_STATUSES)[number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReorderActivityStagesDto {
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
stageIds!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MoveActivityStageDto {
|
||||||
|
@IsString()
|
||||||
|
stageId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
position?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی میشود */
|
||||||
|
export class CreateActivityTypeDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(60)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(ACTIVITY_TYPES)
|
||||||
|
baseType?: (typeof ACTIVITY_TYPES)[number];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(ACTIVITY_TYPE_ICONS)
|
||||||
|
icon?: ActivityTypeIcon;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(STAGE_COLORS)
|
||||||
|
color?: (typeof STAGE_COLORS)[number];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@Allow()
|
||||||
|
fields?: ActivityFieldDef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateActivityTypeDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(60)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(ACTIVITY_TYPES)
|
||||||
|
baseType?: (typeof ACTIVITY_TYPES)[number];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(ACTIVITY_TYPE_ICONS)
|
||||||
|
icon?: ActivityTypeIcon;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(STAGE_COLORS)
|
||||||
|
color?: (typeof STAGE_COLORS)[number];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@Allow()
|
||||||
|
fields?: ActivityFieldDef[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReorderActivityTypesDto {
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
typeIds!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export class CreateActivityDto {
|
export class CreateActivityDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(2)
|
@MinLength(2)
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
title!: string;
|
title!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
|
@IsString()
|
||||||
|
boardId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
|
@IsString()
|
||||||
|
stageId?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(ACTIVITY_TYPES)
|
@IsIn(ACTIVITY_TYPES)
|
||||||
type?: (typeof ACTIVITY_TYPES)[number];
|
type?: (typeof ACTIVITY_TYPES)[number];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
|
@IsString()
|
||||||
|
typeDefId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
fields?: ActivityFieldValues;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(ACTIVITY_PRIORITIES)
|
@IsIn(ACTIVITY_PRIORITIES)
|
||||||
priority?: (typeof ACTIVITY_PRIORITIES)[number];
|
priority?: (typeof ACTIVITY_PRIORITIES)[number];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(2000)
|
@MaxLength(20000)
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -107,6 +350,11 @@ export class CreateActivityDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
reminderNotifySms?: boolean;
|
reminderNotifySms?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -122,10 +370,20 @@ 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()
|
||||||
assigneeId?: string | null;
|
assigneeId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
assigneeIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateActivityDto {
|
export class UpdateActivityDto {
|
||||||
@@ -135,10 +393,25 @@ 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];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
|
@IsString()
|
||||||
|
typeDefId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
fields?: ActivityFieldValues;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(ACTIVITY_STATUSES)
|
@IsIn(ACTIVITY_STATUSES)
|
||||||
status?: (typeof ACTIVITY_STATUSES)[number];
|
status?: (typeof ACTIVITY_STATUSES)[number];
|
||||||
@@ -149,7 +422,7 @@ export class UpdateActivityDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(2000)
|
@MaxLength(20000)
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -177,6 +450,11 @@ export class UpdateActivityDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
reminderNotifySms?: boolean;
|
reminderNotifySms?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateIf((_, v) => v !== '' && v != null)
|
@ValidateIf((_, v) => v !== '' && v != null)
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -192,8 +470,18 @@ 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()
|
||||||
assigneeId?: string | null;
|
assigneeId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
assigneeIds?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
+1037
-24
File diff suppressed because it is too large
Load Diff
@@ -1,80 +0,0 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
Injectable,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
|
|
||||||
const MAX_AUDIO_BYTES = 8 * 1024 * 1024;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AiSttService {
|
|
||||||
constructor(private readonly config: ConfigService) {}
|
|
||||||
|
|
||||||
isEnabled(): boolean {
|
|
||||||
return (
|
|
||||||
this.config.get<string>('STT_ENABLED') === 'true' &&
|
|
||||||
Boolean(this.sttUrl())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async transcribe(file: Express.Multer.File | undefined) {
|
|
||||||
if (!this.isEnabled()) {
|
|
||||||
throw new ServiceUnavailableException('تبدیل صوت به متن فعال نیست');
|
|
||||||
}
|
|
||||||
if (!file?.buffer?.length) {
|
|
||||||
throw new BadRequestException('فایل صوتی ارسال نشده است');
|
|
||||||
}
|
|
||||||
if (file.size > MAX_AUDIO_BYTES) {
|
|
||||||
throw new BadRequestException('حجم فایل صوتی بیش از حد مجاز است');
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = new FormData();
|
|
||||||
form.append(
|
|
||||||
'audio',
|
|
||||||
new Blob([new Uint8Array(file.buffer)], {
|
|
||||||
type: file.mimetype || 'audio/wav',
|
|
||||||
}),
|
|
||||||
file.originalname || 'audio.wav',
|
|
||||||
);
|
|
||||||
|
|
||||||
const timeoutMs = Number(this.config.get<string>('STT_TIMEOUT_MS') || 60000);
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim();
|
|
||||||
if (token) headers['X-STT-Token'] = token;
|
|
||||||
|
|
||||||
let res: Response;
|
|
||||||
try {
|
|
||||||
res = await fetch(`${this.sttUrl()}/transcribe`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: form,
|
|
||||||
signal: AbortSignal.timeout(timeoutMs),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
throw new ServiceUnavailableException(
|
|
||||||
'سرویس تبدیل صوت در دسترس نیست. Pipecat STT را اجرا کنید.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = (await res.json().catch(() => ({}))) as {
|
|
||||||
text?: string;
|
|
||||||
detail?: string | Array<{ msg?: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const detail = Array.isArray(payload.detail)
|
|
||||||
? payload.detail.map((item) => item.msg).filter(Boolean).join('، ')
|
|
||||||
: payload.detail;
|
|
||||||
throw new ServiceUnavailableException(
|
|
||||||
detail || 'تبدیل صوت به متن ناموفق بود',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { text: (payload.text ?? '').trim() };
|
|
||||||
}
|
|
||||||
|
|
||||||
private sttUrl(): string {
|
|
||||||
return (this.config.get<string>('STT_URL') ?? '').trim().replace(/\/$/, '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,10 @@
|
|||||||
import {
|
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
UploadedFile,
|
|
||||||
UseGuards,
|
|
||||||
UseInterceptors,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
|
||||||
import { memoryStorage } from 'multer';
|
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.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 { AiAskService } from './ai-ask.service';
|
import { AiAskService } from './ai-ask.service';
|
||||||
import { AskMentoDto } from './dto';
|
import { AskMentoDto, VoiceCommandDto } from './dto';
|
||||||
import { AiService } from './ai.service';
|
import { AiService } from './ai.service';
|
||||||
import { AiSttService } from './ai-stt.service';
|
|
||||||
|
|
||||||
@Controller('ai')
|
@Controller('ai')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -24,28 +12,21 @@ export class AiController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly ai: AiService,
|
private readonly ai: AiService,
|
||||||
private readonly ask: AiAskService,
|
private readonly ask: AiAskService,
|
||||||
private readonly stt: AiSttService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('status')
|
@Get('status')
|
||||||
status() {
|
status() {
|
||||||
return { enabled: this.ai.isEnabled(), stt: this.stt.isEnabled() };
|
return { enabled: this.ai.isEnabled() };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('transcribe')
|
@Post('voice-command')
|
||||||
@UseInterceptors(
|
voiceCommand(@CurrentUser() user: Actor, @Body() dto: VoiceCommandDto) {
|
||||||
FileInterceptor('audio', {
|
return this.ask.parseVoiceCommand(user, dto.text);
|
||||||
storage: memoryStorage(),
|
|
||||||
limits: { fileSize: 8 * 1024 * 1024 },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
transcribe(@UploadedFile() file: Express.Multer.File) {
|
|
||||||
return this.stt.transcribe(file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('ask')
|
@Post('ask')
|
||||||
askMento(@CurrentUser() user: Actor, @Body() dto: AskMentoDto) {
|
askMento(@CurrentUser() user: Actor, @Body() dto: AskMentoDto) {
|
||||||
return this.ask.ask(user, dto.question, dto.personId);
|
return this.ask.ask(user, dto.question, dto.personId, dto.chatContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('people/:personId/summary')
|
@Get('people/:personId/summary')
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user