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/...`
|
||||
- **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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
> 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 |
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 { 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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user