Ask for organization business field at signup, including jewelry.

Store Didar-style industry on the org so registration, settings, and Ask Mento share the same catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-03 16:45:59 +03:30
co-authored by Cursor
parent ce94658fcd
commit d2f66d11fc
14 changed files with 286 additions and 8 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Organization" ADD COLUMN "businessField" TEXT;
+2
View File
@@ -134,6 +134,8 @@ model Organization {
id String @id @default(cuid())
name String
slug String @unique
/// زمینه فعالیت سازمان — فهرست هم‌تراز صنایع دیدار
businessField String?
baseCurrency String @default("IRR")
enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
createdAt DateTime @default(now())
+2
View File
@@ -34,6 +34,7 @@ async function main() {
slug: 'demo-mento',
baseCurrency: 'IRR',
enabledCurrencies: ['IRR', 'USD', 'EUR'],
businessField: 'tech',
users: {
create: {
email,
@@ -60,6 +61,7 @@ async function main() {
data: {
baseCurrency: 'IRR',
enabledCurrencies: ['IRR', 'USD', 'EUR'],
businessField: 'tech',
},
});
for (const row of [
+11 -1
View File
@@ -11,6 +11,7 @@ import {
noteAccessWhere,
ownerWhere,
} from '../common/record-scope';
import { BUSINESS_FIELD_LABELS, isBusinessField } from '@mento/shared';
import { AiService } from './ai.service';
type CatalogItem = AskMentoSourceDto;
@@ -101,7 +102,11 @@ Rules:
if (!person) throw new NotFoundException('شخص پیدا نشد');
}
const [notes, activities, deals, memories] = await Promise.all([
const [org, notes, activities, deals, memories] = await Promise.all([
this.prisma.organization.findUnique({
where: { id: actor.organizationId },
select: { name: true, businessField: true },
}),
this.prisma.note.findMany({
where: {
organizationId: actor.organizationId,
@@ -167,8 +172,13 @@ Rules:
]);
const catalog: CatalogItem[] = [];
const field =
org?.businessField && isBusinessField(org.businessField)
? BUSINESS_FIELD_LABELS[org.businessField]
: 'نامشخص';
const lines: string[] = [
`Today: ${new Date().toISOString()}`,
`Organization: ${org?.name ?? '—'} | business field: ${field}`,
personId ? `Focus personId: ${personId}` : 'Scope: current user records',
];
+1
View File
@@ -45,6 +45,7 @@ export class AuthService {
data: {
name: dto.organizationName,
slug,
businessField: dto.businessField,
users: {
create: {
email: dto.email.toLowerCase(),
+10 -1
View File
@@ -1,4 +1,10 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
import {
IsEmail,
IsIn,
IsString,
MinLength,
} from 'class-validator';
import { BUSINESS_FIELDS } from '@mento/shared';
export class LoginDto {
@IsEmail()
@@ -24,4 +30,7 @@ export class RegisterOrganizationDto {
@IsString()
@MinLength(6)
password!: string;
@IsIn([...BUSINESS_FIELDS])
businessField!: (typeof BUSINESS_FIELDS)[number];
}
+14
View File
@@ -1,6 +1,7 @@
import {
ArrayMinSize,
IsArray,
IsIn,
IsNumber,
IsOptional,
IsString,
@@ -10,6 +11,7 @@ import {
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { BUSINESS_FIELDS } from '@mento/shared';
export class ExchangeRateInputDto {
@IsString()
@@ -42,3 +44,15 @@ export class UpdateOrgCurrenciesDto {
@Type(() => ExchangeRateInputDto)
rates?: ExchangeRateInputDto[];
}
export class UpdateOrganizationDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(120)
name?: string;
@IsOptional()
@IsIn([...BUSINESS_FIELDS])
businessField?: (typeof BUSINESS_FIELDS)[number];
}
@@ -3,7 +3,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
import { UpdateOrgCurrenciesDto } from './dto';
import { UpdateOrgCurrenciesDto, UpdateOrganizationDto } from './dto';
import { OrganizationsService } from './organizations.service';
@Controller('organizations')
@@ -21,6 +21,15 @@ export class OrganizationsController {
return this.organizationsService.catalog();
}
@Patch('current')
@Roles('owner', 'admin')
updateCurrent(
@CurrentUser() user: { organizationId: string },
@Body() dto: UpdateOrganizationDto,
) {
return this.organizationsService.updateProfile(user.organizationId, dto);
}
@Patch('current/currencies')
@Roles('owner', 'admin')
updateCurrencies(
@@ -7,7 +7,7 @@ import type {
OrganizationDto,
UpdateOrgCurrenciesRequest,
} from '@mento/shared';
import { CURRENCY_CATALOG } from '@mento/shared';
import { CURRENCY_CATALOG, isBusinessField } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { isKnownCurrency } from '../common/currency';
@@ -29,6 +29,27 @@ export class OrganizationsService {
return this.toDto(org);
}
async updateProfile(
organizationId: string,
dto: { name?: string; businessField?: string },
): Promise<OrganizationDto> {
if (dto.businessField && !isBusinessField(dto.businessField)) {
throw new BadRequestException('زمینه فعالیت معتبر نیست');
}
const name = dto.name?.trim();
if (dto.name !== undefined && (!name || name.length < 2)) {
throw new BadRequestException('نام سازمان معتبر نیست');
}
await this.prisma.organization.update({
where: { id: organizationId },
data: {
...(name ? { name } : {}),
...(dto.businessField ? { businessField: dto.businessField } : {}),
},
});
return this.getCurrent(organizationId);
}
async updateCurrencies(
organizationId: string,
dto: UpdateOrgCurrenciesRequest,
@@ -130,6 +151,7 @@ export class OrganizationsService {
id: string;
name: string;
slug: string;
businessField: string | null;
baseCurrency: string;
enabledCurrencies: string[];
exchangeRates: Array<{
@@ -142,6 +164,10 @@ export class OrganizationsService {
id: org.id,
name: org.name,
slug: org.slug,
businessField:
org.businessField && isBusinessField(org.businessField)
? org.businessField
: null,
baseCurrency: org.baseCurrency,
enabledCurrencies: org.enabledCurrencies,
exchangeRates: org.exchangeRates.map((r) => ({
+47
View File
@@ -0,0 +1,47 @@
import type { BusinessField } from '@mento/shared';
export const BUSINESS_FIELDS: BusinessField[] = [
'education',
'beauty',
'real_estate',
'construction',
'advertising',
'insurance',
'consulting',
'startup',
'medical_devices',
'travel',
'contractor',
'trading',
'manufacturing',
'personal',
'tech',
'legal',
'repair',
'retail',
'jewelry',
'other',
];
export const BUSINESS_FIELD_LABELS: Record<BusinessField, string> = {
education: 'آموزشگاه و مؤسسات آموزشی',
beauty: 'کلینیک زیبایی و آرایشگاه',
real_estate: 'املاک و مشاورین مسکن',
construction: 'صنعت ساختمان',
advertising: 'تبلیغات و مشاوره کسب‌وکار',
insurance: 'بیمه',
consulting: 'شرکت‌های مشاور',
startup: 'استارتاپ و کسب‌وکار آنلاین',
medical_devices: 'تجهیزات پزشکی',
travel: 'گردشگری و مهاجرت',
contractor: 'پیمانکاری و پروژه‌ای',
trading: 'بازرگانی',
manufacturing: 'تولیدی و صنعتی',
personal: 'کسب‌وکار شخصی و خانگی',
tech: 'نرم‌افزار و سخت‌افزار',
legal: 'وکلا و مشاورین حقوقی',
repair: 'خدمات و تعمیرات',
retail: 'خرده‌فروشی',
jewelry: 'طلا و جواهر',
other: 'سایر',
};
+1
View File
@@ -49,6 +49,7 @@ export const useAuthStore = defineStore('auth', () => {
fullName: string;
email: string;
password: string;
businessField: string;
}) {
const { data } = await api.post<LoginResponse>('/auth/register', payload);
persist(data);
+13 -1
View File
@@ -2,12 +2,14 @@
import { ref } from 'vue';
import { useRouter, RouterLink } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
import axios from 'axios';
const auth = useAuthStore();
const router = useRouter();
const organizationName = ref('');
const businessField = ref('');
const fullName = ref('');
const email = ref('');
const password = ref('');
@@ -20,6 +22,7 @@ async function submit() {
try {
await auth.register({
organizationName: organizationName.value,
businessField: businessField.value,
fullName: fullName.value,
email: email.value,
password: password.value,
@@ -47,13 +50,22 @@ async function submit() {
<section class="panel">
<p class="eyebrow">شروع با منتو</p>
<h1>ساخت سازمان</h1>
<p class="lead">اولین کاربر به‌عنوان مالک سازمان ثبت می‌شود.</p>
<p class="lead">اولین کاربر به‌عنوان مالک سازمان ثبت می‌شود. زمینه فعالیت کمک می‌کند منتو فضای کاری را با صنعت شما هم‌تراز کند.</p>
<form class="form" @submit.prevent="submit">
<div class="field">
<label for="org">نام سازمان</label>
<input id="org" v-model="organizationName" required />
</div>
<div class="field">
<label for="field">زمینه فعالیت</label>
<select id="field" v-model="businessField" required>
<option value="" disabled>انتخاب کنید</option>
<option v-for="id in BUSINESS_FIELDS" :key="id" :value="id">
{{ BUSINESS_FIELD_LABELS[id] }}
</option>
</select>
</div>
<div class="field">
<label for="name">نام شما</label>
<input id="name" v-model="fullName" required />
+79 -3
View File
@@ -5,16 +5,24 @@ import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import type {
InviteUserRequest,
OrganizationDto,
OrgUserDto,
UpdateOrganizationRequest,
UserRole,
} from '@mento/shared';
import { USER_ROLE_LABELS, canManageUsers } from '@/lib/roles';
import { USER_ROLE_LABELS, canManageSettings, canManageUsers } from '@/lib/roles';
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
const auth = useAuthStore();
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const users = ref<OrgUserDto[]>([]);
const org = ref<OrganizationDto | null>(null);
const orgName = ref('');
const businessField = ref('');
const orgSaving = ref(false);
const orgOk = ref('');
const email = ref('');
const fullName = ref('');
@@ -29,6 +37,7 @@ const inviteRoles: Array<{ value: Exclude<UserRole, 'owner'>; label: string }> =
const isOwner = computed(() => auth.user?.role === 'owner');
const canInvite = computed(() => canManageUsers(auth.user?.role));
const canEditOrg = computed(() => canManageSettings(auth.user?.role));
function roleOptionsFor(user: OrgUserDto): UserRole[] {
if (isOwner.value) return ['owner', 'admin', 'sales', 'support'];
@@ -40,7 +49,14 @@ async function load() {
loading.value = true;
error.value = '';
try {
users.value = (await api.get<OrgUserDto[]>('/users')).data;
const [usersRes, orgRes] = await Promise.all([
api.get<OrgUserDto[]>('/users'),
api.get<OrganizationDto>('/organizations/current'),
]);
users.value = usersRes.data;
org.value = orgRes.data;
orgName.value = orgRes.data.name;
businessField.value = orgRes.data.businessField ?? '';
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
@@ -52,6 +68,37 @@ async function load() {
}
}
async function saveOrg() {
if (!canEditOrg.value) return;
if (!businessField.value) {
error.value = 'زمینه فعالیت را انتخاب کنید.';
return;
}
orgSaving.value = true;
error.value = '';
orgOk.value = '';
try {
const payload: UpdateOrganizationRequest = {
name: orgName.value.trim(),
businessField: businessField.value as UpdateOrganizationRequest['businessField'],
};
const { data } = await api.patch<OrganizationDto>('/organizations/current', payload);
org.value = data;
orgName.value = data.name;
businessField.value = data.businessField ?? '';
orgOk.value = 'مشخصات سازمان ذخیره شد.';
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ذخیره سازمان ناموفق بود');
} else {
error.value = 'ذخیره سازمان ناموفق بود';
}
} finally {
orgSaving.value = false;
}
}
async function invite() {
if (!email.value.trim() || !fullName.value.trim() || password.value.length < 8) {
error.value = 'نام، ایمیل و رمز حداقل ۸ کاراکتر لازم است.';
@@ -110,10 +157,33 @@ onMounted(load);
<p class="eyebrow">تنظیمات</p>
<h1>تیم و نقش‌ها</h1>
<p class="lead">
کاربران سازمان را اضافه کنید و نقش بدهید. تنظیمات سازمانی فقط برای مالک و مدیر است.
زمینه فعالیت سازمان و کاربران را اینجا مدیریت کنید. تنظیمات سازمانی فقط برای مالک و مدیر است.
</p>
</header>
<form v-if="org" class="invite" @submit.prevent="saveOrg">
<h2>سازمان</h2>
<div class="grid">
<label>
نام سازمان
<input v-model="orgName" :disabled="!canEditOrg" required minlength="2" />
</label>
<label>
زمینه فعالیت
<select v-model="businessField" :disabled="!canEditOrg" required>
<option value="" disabled>انتخاب کنید</option>
<option v-for="id in BUSINESS_FIELDS" :key="id" :value="id">
{{ BUSINESS_FIELD_LABELS[id] }}
</option>
</select>
</label>
</div>
<p v-if="orgOk" class="ok">{{ orgOk }}</p>
<button v-if="canEditOrg" class="btn" type="submit" :disabled="orgSaving">
{{ orgSaving ? 'در حال ذخیره…' : 'ذخیره مشخصات سازمان' }}
</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
@@ -202,6 +272,12 @@ h1 {
color: #b42318;
}
.ok {
margin: 0;
color: #0f766e;
font-size: 0.9rem;
}
.invite,
.row {
background: var(--surface);
+67
View File
@@ -68,6 +68,14 @@ export interface LoginRequest {
password: string;
}
export interface RegisterOrganizationRequest {
organizationName: string;
fullName: string;
email: string;
password: string;
businessField: BusinessField;
}
export interface LoginResponse {
accessToken: string;
user: AuthUserDto;
@@ -77,11 +85,70 @@ export interface OrganizationDto {
id: string;
name: string;
slug: string;
businessField: BusinessField | null;
baseCurrency: string;
enabledCurrencies: string[];
exchangeRates: ExchangeRateDto[];
}
export interface UpdateOrganizationRequest {
name?: string;
businessField?: BusinessField;
}
/** زمینه فعالیت سازمان — هم‌تراز صنایع دیدار */
export const BUSINESS_FIELDS = [
"education",
"beauty",
"real_estate",
"construction",
"advertising",
"insurance",
"consulting",
"startup",
"medical_devices",
"travel",
"contractor",
"trading",
"manufacturing",
"personal",
"tech",
"legal",
"repair",
"retail",
"jewelry",
"other",
] as const;
export type BusinessField = (typeof BUSINESS_FIELDS)[number];
export const BUSINESS_FIELD_LABELS: Record<BusinessField, string> = {
education: "آموزشگاه و مؤسسات آموزشی",
beauty: "کلینیک زیبایی و آرایشگاه",
real_estate: "املاک و مشاورین مسکن",
construction: "صنعت ساختمان",
advertising: "تبلیغات و مشاوره کسب‌وکار",
insurance: "بیمه",
consulting: "شرکت‌های مشاور",
startup: "استارتاپ و کسب‌وکار آنلاین",
medical_devices: "تجهیزات پزشکی",
travel: "گردشگری و مهاجرت",
contractor: "پیمانکاری و پروژه‌ای",
trading: "بازرگانی",
manufacturing: "تولیدی و صنعتی",
personal: "کسب‌وکار شخصی و خانگی",
tech: "نرم‌افزار و سخت‌افزار",
legal: "وکلا و مشاورین حقوقی",
repair: "خدمات و تعمیرات",
retail: "خرده‌فروشی",
jewelry: "طلا و جواهر",
other: "سایر",
};
export function isBusinessField(value: string): value is BusinessField {
return (BUSINESS_FIELDS as readonly string[]).includes(value);
}
export interface ExchangeRateDto {
currency: string;
rateToBase: number;