mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
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:
co-authored by
Cursor
parent
4598add88c
commit
43e3912662
@@ -18,7 +18,9 @@ API prefix: `/api/v1`. Package name: `meshkee-cms-api`.
|
|||||||
- Multi-tenant: `Business` is the tenant root; routes are `businesses/:businessId/...`
|
- Multi-tenant: `Business` is the tenant root; routes are `businesses/:businessId/...`
|
||||||
- **Content Category** (`categories`) ≠ **Business Category** (`business_categories`) — do not confuse them
|
- **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
|
- **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
|
## Schema changes
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
# Meshkee CMS API — Project Context
|
# Meshkee CMS API — Project Context
|
||||||
|
|
||||||
> Living reference for developers and AI assistants working on this codebase.
|
> 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
|
## 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 |
|
| `038_invoice_templates.sql` | Full invoice templates + key points/accounts on invoices |
|
||||||
| `039_invoice_account_holder.sql` | `account_holder_name` on invoice / template accounts |
|
| `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 |
|
| `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.
|
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)
|
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
|
Optional EN name fields: `users.first_name_en` / `last_name_en` (migration `049`).
|
||||||
- No NestJS modules or API endpoints
|
|
||||||
|
|
||||||
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 |
|
| Method | Path | Access | Description |
|
||||||
|--------|------|--------|-------------|
|
|--------|------|--------|-------------|
|
||||||
| GET | `/orders` | Customer (own) or `orders.read` (all) | List orders |
|
| 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 |
|
| GET | `/orders/:orderId` | Customer (own) or `orders.read` | Order detail |
|
||||||
| POST | `/orders` | `orders.create` | Admin: create order for a customer |
|
| POST | `/orders` | `orders.create` | Admin: create order for a customer |
|
||||||
| PATCH | `/orders/:orderId` | `orders.update` | Admin: update status / admin notes |
|
| PATCH | `/orders/:orderId` | `orders.update` | Admin: update status / admin notes |
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
"slug": { "type": "string" },
|
"slug": { "type": "string" },
|
||||||
"domain": { "type": "string" },
|
"domain": { "type": "string" },
|
||||||
"primaryColor": { "type": "string", "nullable": true },
|
"primaryColor": { "type": "string", "nullable": true },
|
||||||
|
"defaultLocale": { "type": "string", "enum": ["en", "fa"] },
|
||||||
"logoUrl": { "type": "string", "nullable": true },
|
"logoUrl": { "type": "string", "nullable": true },
|
||||||
"faviconUrl": { "type": "string", "nullable": true }
|
"faviconUrl": { "type": "string", "nullable": true }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ model User {
|
|||||||
email String? @db.VarChar(255)
|
email String? @db.VarChar(255)
|
||||||
firstName String? @map("first_name") @db.VarChar(100)
|
firstName String? @map("first_name") @db.VarChar(100)
|
||||||
lastName String? @map("last_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")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
cellVerifiedAt DateTime? @map("cell_verified_at") @db.Timestamptz(6)
|
cellVerifiedAt DateTime? @map("cell_verified_at") @db.Timestamptz(6)
|
||||||
lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(6)
|
lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(6)
|
||||||
|
|||||||
@@ -222,6 +222,8 @@ export class AuthService {
|
|||||||
data: {
|
data: {
|
||||||
firstName: dto.firstName ?? user.firstName,
|
firstName: dto.firstName ?? user.firstName,
|
||||||
lastName: dto.lastName ?? user.lastName,
|
lastName: dto.lastName ?? user.lastName,
|
||||||
|
firstNameEn: dto.firstNameEn ?? user.firstNameEn,
|
||||||
|
lastNameEn: dto.lastNameEn ?? user.lastNameEn,
|
||||||
email: dto.email ?? user.email,
|
email: dto.email ?? user.email,
|
||||||
profile: nextProfile as object,
|
profile: nextProfile as object,
|
||||||
},
|
},
|
||||||
@@ -382,6 +384,8 @@ export class AuthService {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
|
firstNameEn: user.firstNameEn,
|
||||||
|
lastNameEn: user.lastNameEn,
|
||||||
cellVerifiedAt: user.cellVerifiedAt,
|
cellVerifiedAt: user.cellVerifiedAt,
|
||||||
roles,
|
roles,
|
||||||
dashboard: this.resolveDashboard(roles, businesses.length),
|
dashboard: this.resolveDashboard(roles, businesses.length),
|
||||||
@@ -449,6 +453,8 @@ export class AuthService {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
|
firstNameEn: user.firstNameEn,
|
||||||
|
lastNameEn: user.lastNameEn,
|
||||||
cellVerifiedAt: user.cellVerifiedAt,
|
cellVerifiedAt: user.cellVerifiedAt,
|
||||||
roles: user.roles,
|
roles: user.roles,
|
||||||
dashboard: user.dashboard,
|
dashboard: user.dashboard,
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export interface AuthUser {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
firstName: string | null;
|
firstName: string | null;
|
||||||
lastName: string | null;
|
lastName: string | null;
|
||||||
|
firstNameEn: string | null;
|
||||||
|
lastNameEn: string | null;
|
||||||
cellVerifiedAt: Date | null;
|
cellVerifiedAt: Date | null;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
dashboard: DashboardType;
|
dashboard: DashboardType;
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ export class UpdateProfileDto {
|
|||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
firstNameEn?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
lastNameEn?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
@MaxLength(255)
|
@MaxLength(255)
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
|
firstNameEn: user.firstNameEn,
|
||||||
|
lastNameEn: user.lastNameEn,
|
||||||
cellVerifiedAt: user.cellVerifiedAt,
|
cellVerifiedAt: user.cellVerifiedAt,
|
||||||
roles,
|
roles,
|
||||||
dashboard: this.resolveDashboard(roles, businesses.length),
|
dashboard: this.resolveDashboard(roles, businesses.length),
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ import { LegacyMigrateService } from './legacy-migrate.service';
|
|||||||
import { LegacyPurgeService } from './legacy-purge.service';
|
import { LegacyPurgeService } from './legacy-purge.service';
|
||||||
import { normalizeBusinessPrimaryColorId } from '../business-settings/business-primary-colors';
|
import { normalizeBusinessPrimaryColorId } from '../business-settings/business-primary-colors';
|
||||||
import type { BusinessPrimaryColorId } 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 = {
|
type BusinessRow = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
@@ -40,6 +44,7 @@ type BusinessRow = {
|
|||||||
ownerName: string | null;
|
ownerName: string | null;
|
||||||
ownerCellNumber: string | null;
|
ownerCellNumber: string | null;
|
||||||
primaryColor: string | null;
|
primaryColor: string | null;
|
||||||
|
defaultLocale: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function slugify(value: string) {
|
function slugify(value: string) {
|
||||||
@@ -113,7 +118,8 @@ export class BusinessAdminService {
|
|||||||
own."ownerUserId" AS "ownerUserId",
|
own."ownerUserId" AS "ownerUserId",
|
||||||
own."ownerName" AS "ownerName",
|
own."ownerName" AS "ownerName",
|
||||||
own."ownerCellNumber" AS "ownerCellNumber",
|
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
|
FROM businesses b
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT d.id, d.host, d.ssl_enabled
|
SELECT d.id, d.host, d.ssl_enabled
|
||||||
@@ -149,6 +155,9 @@ export class BusinessAdminService {
|
|||||||
primaryColor: normalizeBusinessPrimaryColorId(
|
primaryColor: normalizeBusinessPrimaryColorId(
|
||||||
item.primaryColor,
|
item.primaryColor,
|
||||||
) as BusinessPrimaryColorId,
|
) as BusinessPrimaryColorId,
|
||||||
|
defaultLocale: normalizeDashboardLocale(
|
||||||
|
item.defaultLocale,
|
||||||
|
) as DashboardLocale,
|
||||||
})),
|
})),
|
||||||
total: totalRow[0]?.total ?? 0,
|
total: totalRow[0]?.total ?? 0,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export class BusinessSettingsService {
|
|||||||
primaryColor: normalizeBusinessPrimaryColorId(
|
primaryColor: normalizeBusinessPrimaryColorId(
|
||||||
dto.branding.primaryColor ?? current.branding.primaryColor,
|
dto.branding.primaryColor ?? current.branding.primaryColor,
|
||||||
),
|
),
|
||||||
|
defaultLocale:
|
||||||
|
dto.branding.defaultLocale ?? current.branding.defaultLocale,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,8 +90,11 @@ export class BusinessSettingsService {
|
|||||||
patch.store = {
|
patch.store = {
|
||||||
onlineSellEnabled:
|
onlineSellEnabled:
|
||||||
dto.store.onlineSellEnabled ?? current.store.onlineSellEnabled,
|
dto.store.onlineSellEnabled ?? current.store.onlineSellEnabled,
|
||||||
orderProcessSteps:
|
orderProcessSteps: dto.store.orderProcessSteps
|
||||||
dto.store.orderProcessSteps ?? current.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 type { BusinessPrimaryColorId } from './business-primary-colors';
|
||||||
import { DEFAULT_BUSINESS_PRIMARY_COLOR_ID } 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 = {
|
export type BrandingSettings = {
|
||||||
primaryColor: BusinessPrimaryColorId;
|
primaryColor: BusinessPrimaryColorId;
|
||||||
|
/** Default UI language for business + customer dashboards. */
|
||||||
|
defaultLocale: DashboardLocale;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DashboardCommentsSettings = {
|
export type DashboardCommentsSettings = {
|
||||||
@@ -22,6 +28,7 @@ export type DashboardSettings = {
|
|||||||
export type OrderProcessStep = {
|
export type OrderProcessStep = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
labelFa: string;
|
||||||
color: string;
|
color: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,15 +46,36 @@ export type BusinessSettings = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
|
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: 'processing',
|
||||||
{ id: 'shipped', label: 'Shipped', color: '#8B5CF6' },
|
label: 'Under processing',
|
||||||
{ id: 'delivered', label: 'Delivered', color: '#22C55E' },
|
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 = {
|
export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
|
||||||
branding: {
|
branding: {
|
||||||
primaryColor: DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
|
primaryColor: DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
|
||||||
|
defaultLocale: DEFAULT_BUSINESS_DASHBOARD_LOCALE,
|
||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
comments: { autoApprove: false },
|
comments: { autoApprove: false },
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
} from './business-primary-colors';
|
} from './business-primary-colors';
|
||||||
import {
|
import {
|
||||||
BusinessSettings,
|
BusinessSettings,
|
||||||
|
DEFAULT_BUSINESS_DASHBOARD_LOCALE,
|
||||||
DEFAULT_BUSINESS_SETTINGS,
|
DEFAULT_BUSINESS_SETTINGS,
|
||||||
DEFAULT_ORDER_PROCESS_STEPS,
|
DEFAULT_ORDER_PROCESS_STEPS,
|
||||||
|
type DashboardLocale,
|
||||||
OrderProcessStep,
|
OrderProcessStep,
|
||||||
} from './business-settings.types';
|
} from './business-settings.types';
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +19,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
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) {
|
function readBoolean(value: unknown, fallback: boolean) {
|
||||||
return typeof value === 'boolean' ? value : fallback;
|
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 id = typeof item.id === 'string' ? item.id.trim() : '';
|
||||||
const label = typeof item.label === 'string' ? item.label.trim() : '';
|
const label = typeof item.label === 'string' ? item.label.trim() : '';
|
||||||
if (!id || !label) return null;
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
|
// Never fall back to the English label — that leaks EN into FA UIs.
|
||||||
|
labelFa:
|
||||||
|
labelFaRaw || defaults?.labelFa || byEnglishLabel?.labelFa || '',
|
||||||
color: normalizeOrderStepColor(
|
color: normalizeOrderStepColor(
|
||||||
item.color,
|
item.color,
|
||||||
defaultOrderStepColor(id, index),
|
defaultOrderStepColor(id, index),
|
||||||
@@ -59,6 +74,7 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
|
|||||||
return {
|
return {
|
||||||
branding: {
|
branding: {
|
||||||
primaryColor: normalizeBusinessPrimaryColorId(branding.primaryColor),
|
primaryColor: normalizeBusinessPrimaryColorId(branding.primaryColor),
|
||||||
|
defaultLocale: normalizeDashboardLocale(branding.defaultLocale),
|
||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
comments: {
|
comments: {
|
||||||
@@ -92,6 +108,8 @@ export function mergeBusinessSettings(
|
|||||||
branding: {
|
branding: {
|
||||||
primaryColor:
|
primaryColor:
|
||||||
patch.branding?.primaryColor ?? current.branding.primaryColor,
|
patch.branding?.primaryColor ?? current.branding.primaryColor,
|
||||||
|
defaultLocale:
|
||||||
|
patch.branding?.defaultLocale ?? current.branding.defaultLocale,
|
||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
comments: {
|
comments: {
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ class BrandingSettingsDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@IsIn([...BUSINESS_PRIMARY_COLOR_IDS])
|
@IsIn([...BUSINESS_PRIMARY_COLOR_IDS])
|
||||||
primaryColor?: string;
|
primaryColor?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['en', 'fa'])
|
||||||
|
defaultLocale?: 'en' | 'fa';
|
||||||
}
|
}
|
||||||
|
|
||||||
class DashboardCommentsSettingsDto {
|
class DashboardCommentsSettingsDto {
|
||||||
@@ -51,6 +56,11 @@ class OrderProcessStepDto {
|
|||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
label!: string;
|
label!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
labelFa?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsIn([...ORDER_STEP_COLOR_HEXES])
|
@IsIn([...ORDER_STEP_COLOR_HEXES])
|
||||||
color!: string;
|
color!: string;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
|||||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { DailyActivityQueryDto } from '../common/dto/daily-activity-query.dto';
|
||||||
import { CustomersService } from './customers.service';
|
import { CustomersService } from './customers.service';
|
||||||
import { CreateCustomerDto } from './dto/create-customer.dto';
|
import { CreateCustomerDto } from './dto/create-customer.dto';
|
||||||
import { ListCustomersDto } from './dto/list-customers.dto';
|
import { ListCustomersDto } from './dto/list-customers.dto';
|
||||||
@@ -35,6 +36,16 @@ export class CustomersController {
|
|||||||
return this.service.list(businessId, query, user);
|
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')
|
@Get('search')
|
||||||
@RequireBusinessPermission('orders.create')
|
@RequireBusinessPermission('orders.create')
|
||||||
search(
|
search(
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { Prisma } from '@prisma/client';
|
|||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
|
import {
|
||||||
|
buildDualDailyActivitySeries,
|
||||||
|
startOfLocalDay,
|
||||||
|
} from '../common/daily-activity';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateCustomerDto } from './dto/create-customer.dto';
|
import { CreateCustomerDto } from './dto/create-customer.dto';
|
||||||
import { ListCustomersDto } from './dto/list-customers.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(
|
async search(
|
||||||
businessIdRaw: string,
|
businessIdRaw: string,
|
||||||
query: SearchCustomersDto,
|
query: SearchCustomersDto,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ListOrdersDto,
|
ListOrdersDto,
|
||||||
UpdateOrderDto,
|
UpdateOrderDto,
|
||||||
} from './dto/order.dto';
|
} from './dto/order.dto';
|
||||||
|
import { DailyActivityQueryDto } from '../common/dto/daily-activity-query.dto';
|
||||||
import { OrdersService } from './orders.service';
|
import { OrdersService } from './orders.service';
|
||||||
|
|
||||||
@Controller('businesses/:businessId/orders')
|
@Controller('businesses/:businessId/orders')
|
||||||
@@ -35,6 +36,15 @@ export class OrdersController {
|
|||||||
return this.service.list(businessId, query, user);
|
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')
|
@Get(':orderId')
|
||||||
getOne(
|
getOne(
|
||||||
@Param('businessId') businessId: string,
|
@Param('businessId') businessId: string,
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import { AuthUser } from '../auth/auth.types';
|
|||||||
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
buildDualDailyActivitySeries,
|
||||||
|
startOfLocalDay,
|
||||||
|
} from '../common/daily-activity';
|
||||||
import {
|
import {
|
||||||
CreateAdminOrderDto,
|
CreateAdminOrderDto,
|
||||||
ListOrdersDto,
|
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) {
|
async getOne(businessIdRaw: string, orderIdRaw: string, actor: AuthUser) {
|
||||||
const businessId = BigInt(businessIdRaw);
|
const businessId = BigInt(businessIdRaw);
|
||||||
const orderId = BigInt(orderIdRaw);
|
const orderId = BigInt(orderIdRaw);
|
||||||
@@ -788,7 +836,7 @@ export class OrdersService {
|
|||||||
private serialize(
|
private serialize(
|
||||||
order: OrderWithItems,
|
order: OrderWithItems,
|
||||||
galleryByProductId: Map<string, string> = new Map(),
|
galleryByProductId: Map<string, string> = new Map(),
|
||||||
processSteps: { id: string; label: string; color: string }[] = [],
|
processSteps: { id: string; label: string; labelFa: string; color: string }[] = [],
|
||||||
) {
|
) {
|
||||||
const processStep =
|
const processStep =
|
||||||
processSteps.find((step) => step.id === order.processStepId) ?? null;
|
processSteps.find((step) => step.id === order.processStepId) ?? null;
|
||||||
@@ -800,6 +848,7 @@ export class OrdersService {
|
|||||||
status: order.status,
|
status: order.status,
|
||||||
processStepId: order.processStepId,
|
processStepId: order.processStepId,
|
||||||
processStepLabel: processStep?.label ?? null,
|
processStepLabel: processStep?.label ?? null,
|
||||||
|
processStepLabelFa: processStep?.labelFa?.trim() || null,
|
||||||
processStepColor: processStep?.color ?? null,
|
processStepColor: processStep?.color ?? null,
|
||||||
source: order.source,
|
source: order.source,
|
||||||
subtotal: Number(order.subtotal),
|
subtotal: Number(order.subtotal),
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export class TenantService {
|
|||||||
slug: business.slug,
|
slug: business.slug,
|
||||||
domain: normalizedHost,
|
domain: normalizedHost,
|
||||||
primaryColor: settings.branding.primaryColor,
|
primaryColor: settings.branding.primaryColor,
|
||||||
|
defaultLocale: settings.branding.defaultLocale,
|
||||||
logoUrl: media?.logoMedia?.publicUrl ?? null,
|
logoUrl: media?.logoMedia?.publicUrl ?? null,
|
||||||
faviconUrl:
|
faviconUrl:
|
||||||
media?.faviconMedia?.publicUrl ?? media?.logoMedia?.publicUrl ?? null,
|
media?.faviconMedia?.publicUrl ?? media?.logoMedia?.publicUrl ?? null,
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
"slug": { "type": "string" },
|
"slug": { "type": "string" },
|
||||||
"domain": { "type": "string" },
|
"domain": { "type": "string" },
|
||||||
"primaryColor": { "type": "string", "nullable": true },
|
"primaryColor": { "type": "string", "nullable": true },
|
||||||
|
"defaultLocale": { "type": "string", "enum": ["en", "fa"] },
|
||||||
"logoUrl": { "type": "string", "nullable": true },
|
"logoUrl": { "type": "string", "nullable": true },
|
||||||
"faviconUrl": { "type": "string", "nullable": true }
|
"faviconUrl": { "type": "string", "nullable": true }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user