diff --git a/.cursor/rules/meshkee-project.mdc b/.cursor/rules/meshkee-project.mdc index a274407..c3c5b0f 100644 --- a/.cursor/rules/meshkee-project.mdc +++ b/.cursor/rules/meshkee-project.mdc @@ -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 diff --git a/database/migrations/049_user_name_en.sql b/database/migrations/049_user_name_en.sql new file mode 100644 index 0000000..1bb3b87 --- /dev/null +++ b/database/migrations/049_user_name_en.sql @@ -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); diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 2376573..d13b6a4 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -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/` | 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 | diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index d0eedc0..1a2db0c 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -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 } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 72966d2..4cd33c0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 95fd33d..789829d 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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, diff --git a/src/auth/auth.types.ts b/src/auth/auth.types.ts index 0684037..a0f5dcd 100644 --- a/src/auth/auth.types.ts +++ b/src/auth/auth.types.ts @@ -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; diff --git a/src/auth/dto/update-profile.dto.ts b/src/auth/dto/update-profile.dto.ts index 3f31f71..3356eee 100644 --- a/src/auth/dto/update-profile.dto.ts +++ b/src/auth/dto/update-profile.dto.ts @@ -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) diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts index 7e5deed..423d874 100644 --- a/src/auth/strategies/jwt.strategy.ts +++ b/src/auth/strategies/jwt.strategy.ts @@ -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), diff --git a/src/business-admin/business-admin.service.ts b/src/business-admin/business-admin.service.ts index 507201f..f5d76b7 100644 --- a/src/business-admin/business-admin.service.ts +++ b/src/business-admin/business-admin.service.ts @@ -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, diff --git a/src/business-settings/business-settings.service.ts b/src/business-settings/business-settings.service.ts index 00b8a56..bf5bd22 100644 --- a/src/business-settings/business-settings.service.ts +++ b/src/business-settings/business-settings.service.ts @@ -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, }; } diff --git a/src/business-settings/business-settings.types.ts b/src/business-settings/business-settings.types.ts index c6a7609..2baa493 100644 --- a/src/business-settings/business-settings.types.ts +++ b/src/business-settings/business-settings.types.ts @@ -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 }, diff --git a/src/business-settings/business-settings.util.ts b/src/business-settings/business-settings.util.ts index 7327e28..8b90f2e 100644 --- a/src/business-settings/business-settings.util.ts +++ b/src/business-settings/business-settings.util.ts @@ -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 { 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: { diff --git a/src/business-settings/dto/update-business-settings.dto.ts b/src/business-settings/dto/update-business-settings.dto.ts index c8c4ac2..37e5edd 100644 --- a/src/business-settings/dto/update-business-settings.dto.ts +++ b/src/business-settings/dto/update-business-settings.dto.ts @@ -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; diff --git a/src/common/daily-activity.ts b/src/common/daily-activity.ts new file mode 100644 index 0000000..2bd1803 --- /dev/null +++ b/src/common/daily-activity.ts @@ -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(); + 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), + }; +} diff --git a/src/common/dto/daily-activity-query.dto.ts b/src/common/dto/daily-activity-query.dto.ts new file mode 100644 index 0000000..f131dd1 --- /dev/null +++ b/src/common/dto/daily-activity-query.dto.ts @@ -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; +} diff --git a/src/customers/customers.controller.ts b/src/customers/customers.controller.ts index 407bfbf..e84db56 100644 --- a/src/customers/customers.controller.ts +++ b/src/customers/customers.controller.ts @@ -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( diff --git a/src/customers/customers.service.ts b/src/customers/customers.service.ts index efdaa0b..433f964 100644 --- a/src/customers/customers.service.ts +++ b/src/customers/customers.service.ts @@ -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, diff --git a/src/orders/orders.controller.ts b/src/orders/orders.controller.ts index 15d49a4..1f7b29d 100644 --- a/src/orders/orders.controller.ts +++ b/src/orders/orders.controller.ts @@ -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, diff --git a/src/orders/orders.service.ts b/src/orders/orders.service.ts index abc02c3..2893e63 100644 --- a/src/orders/orders.service.ts +++ b/src/orders/orders.service.ts @@ -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 = 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), diff --git a/src/tenant/tenant.service.ts b/src/tenant/tenant.service.ts index d2ec3a7..3b3e140 100644 --- a/src/tenant/tenant.service.ts +++ b/src/tenant/tenant.service.ts @@ -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, diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index d0eedc0..1a2db0c 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -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 } }