Add defaultLocale, EN names, and dashboard activity endpoints.

Expose branding.defaultLocale, optional user EN name fields, and lightweight dual daily activity for orders/customers; update project context and rules.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-01 09:35:27 +03:30
co-authored by Cursor
parent 4598add88c
commit 43e3912662
22 changed files with 331 additions and 15 deletions
+3 -1
View File
@@ -18,7 +18,9 @@ API prefix: `/api/v1`. Package name: `meshkee-cms-api`.
- Multi-tenant: `Business` is the tenant root; routes are `businesses/:businessId/...`
- **Content Category** (`categories`) ≠ **Business Category** (`business_categories`) — do not confuse them
- **Location Cities** (`cities`) — system reference tree (country → province → city) for address forms; not business-scoped. Distinct from **Addresses** (`addresses`) which store user/business street addresses
- Blogs/portfolios exist in DB + permissions but have no Prisma models or API modules yet
- Blogs + portfolios have Prisma models and CMS modules (`blogs/`, `portfolios/`)
- Branding JSON includes `defaultLocale` (`fa` \| `en`, default `fa`) for dashboard language
- Dashboard activity charts: `GET businesses/:id/orders/activity` and `.../customers/activity` (dual daily series, not website-facing)
## Schema changes
+4
View File
@@ -0,0 +1,4 @@
-- English given/family names on users (alongside existing first_name / last_name as FA/primary).
ALTER TABLE users
ADD COLUMN IF NOT EXISTS first_name_en VARCHAR(100),
ADD COLUMN IF NOT EXISTS last_name_en VARCHAR(100);
+14 -6
View File
@@ -1,7 +1,7 @@
# Meshkee CMS API — Project Context
> Living reference for developers and AI assistants working on this codebase.
> Last updated: July 24, 2026
> Last updated: August 1, 2026
## What This Project Is
@@ -157,6 +157,7 @@ Example super admin: `+989121111111` / `password`
| `038_invoice_templates.sql` | Full invoice templates + key points/accounts on invoices |
| `039_invoice_account_holder.sql` | `account_holder_name` on invoice / template accounts |
| `040_invoice_public_id.sql` | Opaque `public_id` for unguessable public invoice links |
| `049_user_name_en.sql` | Optional `users.first_name_en` / `last_name_en` for EN display names |
Docker mounts `./database/migrations` into Postgres init — migrations run automatically only on **first** volume creation. Use `migrate.sh` for subsequent migrations.
@@ -209,14 +210,20 @@ Invoice.issuedBy → User
Media 1──* MediaAttachment (polymorphic: entityType + entityId)
```
### Schema gap: blogs & portfolios
### Branding & dashboard locale
SQL migrations create `blogs` and `portfolios` tables and seed data populates them. Permissions exist (`blogs.*`, `portfolios.*`). However:
`businesses.settings.branding.defaultLocale` is `'fa' | 'en'` (default **`fa`**). Exposed on tenant resolve and editable from super-admin. Dashboards apply it once on load.
- No Prisma models for Blog/Portfolio
- No NestJS modules or API endpoints
Optional EN name fields: `users.first_name_en` / `last_name_en` (migration `049`).
Categories and media attachments already support `blog` and `portfolio` entity types — infrastructure is ready, API is not.
### Dashboard activity (CMS only — not website-facing)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/businesses/:businessId/orders/activity?days=30` | Dual daily series: orders + cart-item creates |
| GET | `/businesses/:businessId/customers/activity?days=30` | Dual daily series: registrations + active logins (`last_login_at`) |
Lightweight `GROUP BY` counts for business home charts. Requires `orders.read`.
---
@@ -301,6 +308,7 @@ Pattern: `/businesses/:businessId/<resource>`
| Method | Path | Access | Description |
|--------|------|--------|-------------|
| GET | `/orders` | Customer (own) or `orders.read` (all) | List orders |
| GET | `/orders/activity` | Admin `orders.read` | Daily order + cart-add counts |
| GET | `/orders/:orderId` | Customer (own) or `orders.read` | Order detail |
| POST | `/orders` | `orders.create` | Admin: create order for a customer |
| PATCH | `/orders/:orderId` | `orders.update` | Admin: update status / admin notes |
+1
View File
@@ -83,6 +83,7 @@
"slug": { "type": "string" },
"domain": { "type": "string" },
"primaryColor": { "type": "string", "nullable": true },
"defaultLocale": { "type": "string", "enum": ["en", "fa"] },
"logoUrl": { "type": "string", "nullable": true },
"faviconUrl": { "type": "string", "nullable": true }
}
+2
View File
@@ -14,6 +14,8 @@ model User {
email String? @db.VarChar(255)
firstName String? @map("first_name") @db.VarChar(100)
lastName String? @map("last_name") @db.VarChar(100)
firstNameEn String? @map("first_name_en") @db.VarChar(100)
lastNameEn String? @map("last_name_en") @db.VarChar(100)
isActive Boolean @default(true) @map("is_active")
cellVerifiedAt DateTime? @map("cell_verified_at") @db.Timestamptz(6)
lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(6)
+6
View File
@@ -222,6 +222,8 @@ export class AuthService {
data: {
firstName: dto.firstName ?? user.firstName,
lastName: dto.lastName ?? user.lastName,
firstNameEn: dto.firstNameEn ?? user.firstNameEn,
lastNameEn: dto.lastNameEn ?? user.lastNameEn,
email: dto.email ?? user.email,
profile: nextProfile as object,
},
@@ -382,6 +384,8 @@ export class AuthService {
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
firstNameEn: user.firstNameEn,
lastNameEn: user.lastNameEn,
cellVerifiedAt: user.cellVerifiedAt,
roles,
dashboard: this.resolveDashboard(roles, businesses.length),
@@ -449,6 +453,8 @@ export class AuthService {
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
firstNameEn: user.firstNameEn,
lastNameEn: user.lastNameEn,
cellVerifiedAt: user.cellVerifiedAt,
roles: user.roles,
dashboard: user.dashboard,
+2
View File
@@ -35,6 +35,8 @@ export interface AuthUser {
email: string | null;
firstName: string | null;
lastName: string | null;
firstNameEn: string | null;
lastNameEn: string | null;
cellVerifiedAt: Date | null;
roles: string[];
dashboard: DashboardType;
+10
View File
@@ -11,6 +11,16 @@ export class UpdateProfileDto {
@MaxLength(100)
lastName?: string;
@IsOptional()
@IsString()
@MaxLength(100)
firstNameEn?: string;
@IsOptional()
@IsString()
@MaxLength(100)
lastNameEn?: string;
@IsOptional()
@IsEmail()
@MaxLength(255)
+2
View File
@@ -56,6 +56,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
firstNameEn: user.firstNameEn,
lastNameEn: user.lastNameEn,
cellVerifiedAt: user.cellVerifiedAt,
roles,
dashboard: this.resolveDashboard(roles, businesses.length),
+10 -1
View File
@@ -23,6 +23,10 @@ import { LegacyMigrateService } from './legacy-migrate.service';
import { LegacyPurgeService } from './legacy-purge.service';
import { normalizeBusinessPrimaryColorId } from '../business-settings/business-primary-colors';
import type { BusinessPrimaryColorId } from '../business-settings/business-primary-colors';
import {
normalizeDashboardLocale,
} from '../business-settings/business-settings.util';
import type { DashboardLocale } from '../business-settings/business-settings.types';
type BusinessRow = {
id: bigint;
@@ -40,6 +44,7 @@ type BusinessRow = {
ownerName: string | null;
ownerCellNumber: string | null;
primaryColor: string | null;
defaultLocale: string | null;
};
function slugify(value: string) {
@@ -113,7 +118,8 @@ export class BusinessAdminService {
own."ownerUserId" AS "ownerUserId",
own."ownerName" AS "ownerName",
own."ownerCellNumber" AS "ownerCellNumber",
b.settings->'branding'->>'primaryColor' AS "primaryColor"
b.settings->'branding'->>'primaryColor' AS "primaryColor",
b.settings->'branding'->>'defaultLocale' AS "defaultLocale"
FROM businesses b
LEFT JOIN LATERAL (
SELECT d.id, d.host, d.ssl_enabled
@@ -149,6 +155,9 @@ export class BusinessAdminService {
primaryColor: normalizeBusinessPrimaryColorId(
item.primaryColor,
) as BusinessPrimaryColorId,
defaultLocale: normalizeDashboardLocale(
item.defaultLocale,
) as DashboardLocale,
})),
total: totalRow[0]?.total ?? 0,
page,
@@ -66,6 +66,8 @@ export class BusinessSettingsService {
primaryColor: normalizeBusinessPrimaryColorId(
dto.branding.primaryColor ?? current.branding.primaryColor,
),
defaultLocale:
dto.branding.defaultLocale ?? current.branding.defaultLocale,
};
}
@@ -88,8 +90,11 @@ export class BusinessSettingsService {
patch.store = {
onlineSellEnabled:
dto.store.onlineSellEnabled ?? current.store.onlineSellEnabled,
orderProcessSteps:
dto.store.orderProcessSteps ?? current.store.orderProcessSteps,
orderProcessSteps: dto.store.orderProcessSteps
? normalizeBusinessSettings({
store: { orderProcessSteps: dto.store.orderProcessSteps },
}).store.orderProcessSteps
: current.store.orderProcessSteps,
};
}
@@ -1,8 +1,14 @@
import type { BusinessPrimaryColorId } from './business-primary-colors';
import { DEFAULT_BUSINESS_PRIMARY_COLOR_ID } from './business-primary-colors';
export type DashboardLocale = 'en' | 'fa';
export const DEFAULT_BUSINESS_DASHBOARD_LOCALE: DashboardLocale = 'fa';
export type BrandingSettings = {
primaryColor: BusinessPrimaryColorId;
/** Default UI language for business + customer dashboards. */
defaultLocale: DashboardLocale;
};
export type DashboardCommentsSettings = {
@@ -22,6 +28,7 @@ export type DashboardSettings = {
export type OrderProcessStep = {
id: string;
label: string;
labelFa: string;
color: string;
};
@@ -39,15 +46,36 @@ export type BusinessSettings = {
};
export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
{ id: 'processing', label: 'Under processing', color: '#3B82F6' },
{ id: 'ready-for-shipping', label: 'Ready for shipping', color: '#F59E0B' },
{ id: 'shipped', label: 'Shipped', color: '#8B5CF6' },
{ id: 'delivered', label: 'Delivered', color: '#22C55E' },
{
id: 'processing',
label: 'Under processing',
labelFa: 'در حال پردازش',
color: '#3B82F6',
},
{
id: 'ready-for-shipping',
label: 'Ready for shipping',
labelFa: 'آماده ارسال',
color: '#F59E0B',
},
{
id: 'shipped',
label: 'Shipped',
labelFa: 'ارسال‌شده',
color: '#8B5CF6',
},
{
id: 'delivered',
label: 'Delivered',
labelFa: 'تحویل‌شده',
color: '#22C55E',
},
];
export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
branding: {
primaryColor: DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
defaultLocale: DEFAULT_BUSINESS_DASHBOARD_LOCALE,
},
dashboard: {
comments: { autoApprove: false },
@@ -4,8 +4,10 @@ import {
} from './business-primary-colors';
import {
BusinessSettings,
DEFAULT_BUSINESS_DASHBOARD_LOCALE,
DEFAULT_BUSINESS_SETTINGS,
DEFAULT_ORDER_PROCESS_STEPS,
type DashboardLocale,
OrderProcessStep,
} from './business-settings.types';
import {
@@ -17,6 +19,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function normalizeDashboardLocale(value: unknown): DashboardLocale {
return value === 'en' || value === 'fa' ? value : DEFAULT_BUSINESS_DASHBOARD_LOCALE;
}
function readBoolean(value: unknown, fallback: boolean) {
return typeof value === 'boolean' ? value : fallback;
}
@@ -32,9 +38,18 @@ function readOrderProcessSteps(value: unknown): OrderProcessStep[] {
const id = typeof item.id === 'string' ? item.id.trim() : '';
const label = typeof item.label === 'string' ? item.label.trim() : '';
if (!id || !label) return null;
const defaults = DEFAULT_ORDER_PROCESS_STEPS.find((step) => step.id === id);
const byEnglishLabel = DEFAULT_ORDER_PROCESS_STEPS.find(
(step) => step.label.toLowerCase() === label.toLowerCase(),
);
const labelFaRaw =
typeof item.labelFa === 'string' ? item.labelFa.trim() : '';
return {
id,
label,
// Never fall back to the English label — that leaks EN into FA UIs.
labelFa:
labelFaRaw || defaults?.labelFa || byEnglishLabel?.labelFa || '',
color: normalizeOrderStepColor(
item.color,
defaultOrderStepColor(id, index),
@@ -59,6 +74,7 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
return {
branding: {
primaryColor: normalizeBusinessPrimaryColorId(branding.primaryColor),
defaultLocale: normalizeDashboardLocale(branding.defaultLocale),
},
dashboard: {
comments: {
@@ -92,6 +108,8 @@ export function mergeBusinessSettings(
branding: {
primaryColor:
patch.branding?.primaryColor ?? current.branding.primaryColor,
defaultLocale:
patch.branding?.defaultLocale ?? current.branding.defaultLocale,
},
dashboard: {
comments: {
@@ -16,6 +16,11 @@ class BrandingSettingsDto {
@IsString()
@IsIn([...BUSINESS_PRIMARY_COLOR_IDS])
primaryColor?: string;
@IsOptional()
@IsString()
@IsIn(['en', 'fa'])
defaultLocale?: 'en' | 'fa';
}
class DashboardCommentsSettingsDto {
@@ -51,6 +56,11 @@ class OrderProcessStepDto {
@MinLength(1)
label!: string;
@IsOptional()
@IsString()
@MinLength(1)
labelFa?: string;
@IsString()
@IsIn([...ORDER_STEP_COLOR_HEXES])
color!: string;
+80
View File
@@ -0,0 +1,80 @@
export type DailyCountRow = { day: Date | string; count: number };
export type DailyActivityPoint = {
date: string;
count: number;
};
export type DailyActivityResponse = {
days: number;
total: number;
items: DailyActivityPoint[];
};
/** Local calendar day at 00:00:00, `offsetDays` before today (0 = today). */
export function startOfLocalDay(offsetDays = 0): Date {
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() - offsetDays);
return date;
}
function pad2(n: number) {
return String(n).padStart(2, '0');
}
/** Calendar date key from a PG `date` (UTC midnight) or ISO string. */
function toDateKey(value: Date | string): string {
if (typeof value === 'string') {
return value.slice(0, 10);
}
return `${value.getUTCFullYear()}-${pad2(value.getUTCMonth() + 1)}-${pad2(value.getUTCDate())}`;
}
function toLocalDateKey(date: Date): string {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
/** Fill a continuous series of the last `days` local calendar days. */
export function buildDailyActivitySeries(
days: number,
rows: DailyCountRow[],
): DailyActivityResponse {
const byDay = new Map<string, number>();
for (const row of rows) {
byDay.set(toDateKey(row.day), Number(row.count) || 0);
}
const items: DailyActivityPoint[] = [];
let total = 0;
const end = startOfLocalDay(0);
for (let i = days - 1; i >= 0; i -= 1) {
const date = new Date(end);
date.setDate(end.getDate() - i);
const key = toLocalDateKey(date);
const count = byDay.get(key) ?? 0;
total += count;
items.push({ date: key, count });
}
return { days, total, items };
}
export type DualDailyActivityResponse = {
days: number;
primary: DailyActivityResponse;
secondary: DailyActivityResponse;
};
export function buildDualDailyActivitySeries(
days: number,
primaryRows: DailyCountRow[],
secondaryRows: DailyCountRow[],
): DualDailyActivityResponse {
return {
days,
primary: buildDailyActivitySeries(days, primaryRows),
secondary: buildDailyActivitySeries(days, secondaryRows),
};
}
@@ -0,0 +1,11 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class DailyActivityQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(90)
days?: number;
}
+11
View File
@@ -14,6 +14,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { DailyActivityQueryDto } from '../common/dto/daily-activity-query.dto';
import { CustomersService } from './customers.service';
import { CreateCustomerDto } from './dto/create-customer.dto';
import { ListCustomersDto } from './dto/list-customers.dto';
@@ -35,6 +36,16 @@ export class CustomersController {
return this.service.list(businessId, query, user);
}
@Get('activity')
@RequireBusinessPermission('orders.read')
activity(
@Param('businessId') businessId: string,
@Query() query: DailyActivityQueryDto,
@CurrentUser() user: AuthUser,
) {
return this.service.dailyActivity(businessId, query.days, user);
}
@Get('search')
@RequireBusinessPermission('orders.create')
search(
+46
View File
@@ -3,6 +3,10 @@ import { Prisma } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import {
buildDualDailyActivitySeries,
startOfLocalDay,
} from '../common/daily-activity';
import { PrismaService } from '../prisma/prisma.service';
import { CreateCustomerDto } from './dto/create-customer.dto';
import { ListCustomersDto } from './dto/list-customers.dto';
@@ -94,6 +98,48 @@ export class CustomersService {
};
}
/** Lightweight daily registration + active-login counts for dashboard charts. */
async dailyActivity(
businessIdRaw: string,
daysRaw: number | undefined,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'orders.read');
const days = Math.min(Math.max(daysRaw ?? 30, 1), 90);
const from = startOfLocalDay(days - 1);
const [registeredRows, activeRows] = await Promise.all([
this.prisma.$queryRaw<{ day: Date; count: number }[]>(
Prisma.sql`
SELECT created_at::date AS day,
COUNT(*)::int AS count
FROM business_customers
WHERE business_id = ${businessId}
AND created_at >= ${from}
GROUP BY 1
ORDER BY 1
`,
),
this.prisma.$queryRaw<{ day: Date; count: number }[]>(
Prisma.sql`
SELECT u.last_login_at::date AS day,
COUNT(*)::int AS count
FROM business_customers bc
JOIN users u ON u.id = bc.user_id
WHERE bc.business_id = ${businessId}
AND u.last_login_at IS NOT NULL
AND u.last_login_at >= ${from}
GROUP BY 1
ORDER BY 1
`,
),
]);
return buildDualDailyActivitySeries(days, registeredRows, activeRows);
}
async search(
businessIdRaw: string,
query: SearchCustomersDto,
+10
View File
@@ -19,6 +19,7 @@ import {
ListOrdersDto,
UpdateOrderDto,
} from './dto/order.dto';
import { DailyActivityQueryDto } from '../common/dto/daily-activity-query.dto';
import { OrdersService } from './orders.service';
@Controller('businesses/:businessId/orders')
@@ -35,6 +36,15 @@ export class OrdersController {
return this.service.list(businessId, query, user);
}
@Get('activity')
activity(
@Param('businessId') businessId: string,
@Query() query: DailyActivityQueryDto,
@CurrentUser() user: AuthUser,
) {
return this.service.dailyActivity(businessId, query.days, user);
}
@Get(':orderId')
getOne(
@Param('businessId') businessId: string,
+50 -1
View File
@@ -17,6 +17,10 @@ import { AuthUser } from '../auth/auth.types';
import { BusinessSettingsService } from '../business-settings/business-settings.service';
import { PermissionsService } from '../auth/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import {
buildDualDailyActivitySeries,
startOfLocalDay,
} from '../common/daily-activity';
import {
CreateAdminOrderDto,
ListOrdersDto,
@@ -168,6 +172,50 @@ export class OrdersService {
};
}
/** Lightweight daily counts for dashboard charts (admin only). */
async dailyActivity(
businessIdRaw: string,
daysRaw: number | undefined,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const scope = await this.resolveReadScope(businessId, actor);
if (scope !== 'admin') {
throw new ForbiddenException('Missing permission: orders.read for this business');
}
const days = Math.min(Math.max(daysRaw ?? 30, 1), 90);
const from = startOfLocalDay(days - 1);
const [orderRows, cartRows] = await Promise.all([
this.prisma.$queryRaw<{ day: Date; count: number }[]>(
Prisma.sql`
SELECT created_at::date AS day,
COUNT(*)::int AS count
FROM orders
WHERE business_id = ${businessId}
AND created_at >= ${from}
GROUP BY 1
ORDER BY 1
`,
),
this.prisma.$queryRaw<{ day: Date; count: number }[]>(
Prisma.sql`
SELECT ci.created_at::date AS day,
COUNT(*)::int AS count
FROM cart_items ci
JOIN carts c ON c.id = ci.cart_id
WHERE c.business_id = ${businessId}
AND ci.created_at >= ${from}
GROUP BY 1
ORDER BY 1
`,
),
]);
return buildDualDailyActivitySeries(days, orderRows, cartRows);
}
async getOne(businessIdRaw: string, orderIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const orderId = BigInt(orderIdRaw);
@@ -788,7 +836,7 @@ export class OrdersService {
private serialize(
order: OrderWithItems,
galleryByProductId: Map<string, string> = new Map(),
processSteps: { id: string; label: string; color: string }[] = [],
processSteps: { id: string; label: string; labelFa: string; color: string }[] = [],
) {
const processStep =
processSteps.find((step) => step.id === order.processStepId) ?? null;
@@ -800,6 +848,7 @@ export class OrdersService {
status: order.status,
processStepId: order.processStepId,
processStepLabel: processStep?.label ?? null,
processStepLabelFa: processStep?.labelFa?.trim() || null,
processStepColor: processStep?.color ?? null,
source: order.source,
subtotal: Number(order.subtotal),
+1
View File
@@ -55,6 +55,7 @@ export class TenantService {
slug: business.slug,
domain: normalizedHost,
primaryColor: settings.branding.primaryColor,
defaultLocale: settings.branding.defaultLocale,
logoUrl: media?.logoMedia?.publicUrl ?? null,
faviconUrl:
media?.faviconMedia?.publicUrl ?? media?.logoMedia?.publicUrl ?? null,
+1
View File
@@ -83,6 +83,7 @@
"slug": { "type": "string" },
"domain": { "type": "string" },
"primaryColor": { "type": "string", "nullable": true },
"defaultLocale": { "type": "string", "enum": ["en", "fa"] },
"logoUrl": { "type": "string", "nullable": true },
"faviconUrl": { "type": "string", "nullable": true }
}