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:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Organization" ADD COLUMN "businessField" TEXT;
|
||||
@@ -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())
|
||||
|
||||
@@ -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,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',
|
||||
];
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ export class AuthService {
|
||||
data: {
|
||||
name: dto.organizationName,
|
||||
slug,
|
||||
businessField: dto.businessField,
|
||||
users: {
|
||||
create: {
|
||||
email: dto.email.toLowerCase(),
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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: 'سایر',
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user