Add website view analytics for dashboard traffic charts.
Track storefront views via POST /tenants/{domain}/analytics/views, expose business activity API, and add product/website/portfolio/blog 30-day chart types for super-admin selection.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
b8f60c5141
commit
3f266a2dcb
@@ -0,0 +1,18 @@
|
|||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'website_view_kind') THEN
|
||||||
|
CREATE TYPE website_view_kind AS ENUM ('website', 'product', 'portfolio', 'blog');
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS website_content_views (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
business_id BIGINT NOT NULL REFERENCES businesses (id) ON DELETE CASCADE,
|
||||||
|
view_kind website_view_kind NOT NULL,
|
||||||
|
entity_id BIGINT,
|
||||||
|
page_path TEXT,
|
||||||
|
viewed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_website_content_views_business_kind_time
|
||||||
|
ON website_content_views (business_id, view_kind, viewed_at DESC);
|
||||||
@@ -54,6 +54,11 @@ Public marketplace listings owned by customers — not catalog `products`.
|
|||||||
- `GET /tenants/{domain}/user-products/{slug}/technical-info` — category form + values
|
- `GET /tenants/{domain}/user-products/{slug}/technical-info` — category form + values
|
||||||
Use product categories from `GET /tenants/{domain}/categories?entityType=product` for filters. Creating/editing listings is customer-dashboard only (`/businesses/.../my-user-products`), not website-facing.
|
Use product categories from `GET /tenants/{domain}/categories?entityType=product` for filters. Creating/editing listings is customer-dashboard only (`/businesses/.../my-user-products`), not website-facing.
|
||||||
|
|
||||||
|
### Analytics (dashboard charts)
|
||||||
|
Record storefront views so the business home charts can show traffic:
|
||||||
|
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "website"|"product"|"portfolio"|"blog", "entityId"?: "...", "path"?: "/about" }`
|
||||||
|
- Call once per page view from the website (product detail, portfolio, blog post, or general pages with `kind: "website"`).
|
||||||
|
|
||||||
### Static images
|
### Static images
|
||||||
Named slots the business dashboard can replace. Fetch once per page:
|
Named slots the business dashboard can replace. Fetch once per page:
|
||||||
- `GET /tenants/{domain}/website/static-images` — all slots
|
- `GET /tenants/{domain}/website/static-images` — all slots
|
||||||
|
|||||||
@@ -430,6 +430,61 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/tenants/{domain}/analytics/views": {
|
||||||
|
"post": {
|
||||||
|
"tags": ["Analytics"],
|
||||||
|
"summary": "Record a storefront page view",
|
||||||
|
"description": "Fire-and-forget view counter for business dashboard charts. Call once per page impression from the website (product detail, portfolio, blog post, or general site page). No auth required.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/domain"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["kind"],
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["website", "product", "portfolio", "blog"]
|
||||||
|
},
|
||||||
|
"entityId": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Required when kind is product, portfolio, or blog."
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional page path for website visits (e.g. `/`, `/about`)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "View recorded",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": { "type": "boolean", "enum": [true] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Unknown domain or entity"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/tenants/{domain}/website/sliders": {
|
"/tenants/{domain}/website/sliders": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ model Business {
|
|||||||
website_category_groups website_category_groups[]
|
website_category_groups website_category_groups[]
|
||||||
website_image_slots website_image_slots[]
|
website_image_slots website_image_slots[]
|
||||||
website_sliders website_sliders[]
|
website_sliders website_sliders[]
|
||||||
|
websiteContentViews WebsiteContentView[]
|
||||||
|
|
||||||
@@map("businesses")
|
@@map("businesses")
|
||||||
}
|
}
|
||||||
@@ -1636,3 +1637,25 @@ enum UserProductCondition {
|
|||||||
|
|
||||||
@@map("user_product_condition")
|
@@map("user_product_condition")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum WebsiteViewKind {
|
||||||
|
website
|
||||||
|
product
|
||||||
|
portfolio
|
||||||
|
blog
|
||||||
|
|
||||||
|
@@map("website_view_kind")
|
||||||
|
}
|
||||||
|
|
||||||
|
model WebsiteContentView {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
businessId BigInt @map("business_id")
|
||||||
|
viewKind WebsiteViewKind @map("view_kind")
|
||||||
|
entityId BigInt? @map("entity_id")
|
||||||
|
pagePath String? @map("page_path")
|
||||||
|
viewedAt DateTime @default(now()) @map("viewed_at") @db.Timestamptz(6)
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([businessId, viewKind, viewedAt(sort: Desc)], map: "idx_website_content_views_business_kind_time")
|
||||||
|
@@map("website_content_views")
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { BrandsModule } from './brands/brands.module';
|
|||||||
import { WebsiteModule } from './website/website.module';
|
import { WebsiteModule } from './website/website.module';
|
||||||
import { InternalSslModule } from './internal-ssl/internal-ssl.module';
|
import { InternalSslModule } from './internal-ssl/internal-ssl.module';
|
||||||
import { WebsiteDocsModule } from './website-docs/website-docs.module';
|
import { WebsiteDocsModule } from './website-docs/website-docs.module';
|
||||||
|
import { WebsiteAnalyticsModule } from './website-analytics/website-analytics.module';
|
||||||
import { InvoicesModule } from './invoices/invoices.module';
|
import { InvoicesModule } from './invoices/invoices.module';
|
||||||
import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module';
|
import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module';
|
||||||
import { PublicSmsModule } from './public-sms/public-sms.module';
|
import { PublicSmsModule } from './public-sms/public-sms.module';
|
||||||
@@ -82,6 +83,7 @@ import { SitemapModule } from './sitemap/sitemap.module';
|
|||||||
UserProductsModule,
|
UserProductsModule,
|
||||||
BrandsModule,
|
BrandsModule,
|
||||||
WebsiteModule,
|
WebsiteModule,
|
||||||
|
WebsiteAnalyticsModule,
|
||||||
WebsiteDocsModule,
|
WebsiteDocsModule,
|
||||||
InvoicesModule,
|
InvoicesModule,
|
||||||
PublicSmsModule,
|
PublicSmsModule,
|
||||||
|
|||||||
@@ -179,6 +179,9 @@ export const HOME_CHART_IDS = [
|
|||||||
'customers_joined_1y',
|
'customers_joined_1y',
|
||||||
'blog_views_30d',
|
'blog_views_30d',
|
||||||
'products_added_1y',
|
'products_added_1y',
|
||||||
|
'product_views_30d',
|
||||||
|
'website_views_30d',
|
||||||
|
'portfolio_views_30d',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type HomeChartId = (typeof HOME_CHART_IDS)[number];
|
export type HomeChartId = (typeof HOME_CHART_IDS)[number];
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
import { WEBSITE_VIEW_KINDS, type WebsiteViewKind } from '../website-analytics.types';
|
||||||
|
|
||||||
|
export class RecordWebsiteViewDto {
|
||||||
|
@IsIn([...WEBSITE_VIEW_KINDS])
|
||||||
|
kind!: WebsiteViewKind;
|
||||||
|
|
||||||
|
/** Product / portfolio / blog id when kind is not `website`. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
entityId?: string;
|
||||||
|
|
||||||
|
/** Page path for storefront visits (e.g. `/`, `/about`, `/products/12/slug`). */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||||
|
import { WEBSITE_VIEW_KINDS, type WebsiteViewKind } from '../website-analytics.types';
|
||||||
|
|
||||||
|
export class ViewActivityQueryDto {
|
||||||
|
@IsIn([...WEBSITE_VIEW_KINDS])
|
||||||
|
kind!: WebsiteViewKind;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(90)
|
||||||
|
days?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Body, Controller, Param, Post } from '@nestjs/common';
|
||||||
|
import { RecordWebsiteViewDto } from './dto/record-view.dto';
|
||||||
|
import { WebsiteAnalyticsService } from './website-analytics.service';
|
||||||
|
|
||||||
|
@Controller('tenants/:host/analytics/views')
|
||||||
|
export class PublicWebsiteAnalyticsController {
|
||||||
|
constructor(private readonly service: WebsiteAnalyticsService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
record(@Param('host') host: string, @Body() dto: RecordWebsiteViewDto) {
|
||||||
|
return this.service.recordPublicView(host, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { ViewActivityQueryDto } from './dto/view-activity-query.dto';
|
||||||
|
import { WebsiteAnalyticsService } from './website-analytics.service';
|
||||||
|
|
||||||
|
@Controller('businesses/:businessId/analytics')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class WebsiteAnalyticsController {
|
||||||
|
constructor(private readonly service: WebsiteAnalyticsService) {}
|
||||||
|
|
||||||
|
@Get('views/activity')
|
||||||
|
viewActivity(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Query() query: ViewActivityQueryDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.dailyViewActivity(
|
||||||
|
businessId,
|
||||||
|
query.kind,
|
||||||
|
query.days,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
import { PublicWebsiteAnalyticsController } from './public-website-analytics.controller';
|
||||||
|
import { WebsiteAnalyticsController } from './website-analytics.controller';
|
||||||
|
import { WebsiteAnalyticsService } from './website-analytics.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, TenantModule],
|
||||||
|
controllers: [PublicWebsiteAnalyticsController, WebsiteAnalyticsController],
|
||||||
|
providers: [WebsiteAnalyticsService],
|
||||||
|
})
|
||||||
|
export class WebsiteAnalyticsModule {}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { buildDailyActivitySeries, startOfLocalDay } from '../common/daily-activity';
|
||||||
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
|
import type { RecordWebsiteViewDto } from './dto/record-view.dto';
|
||||||
|
import type { WebsiteViewKind } from './website-analytics.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WebsiteAnalyticsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly tenant: TenantService,
|
||||||
|
private readonly permissions: PermissionsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async recordPublicView(host: string, dto: RecordWebsiteViewDto) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
const businessId = business.id;
|
||||||
|
|
||||||
|
const entityId = await this.resolveEntityId(businessId, dto.kind, dto.entityId);
|
||||||
|
|
||||||
|
await this.prisma.websiteContentView.create({
|
||||||
|
data: {
|
||||||
|
businessId,
|
||||||
|
viewKind: dto.kind,
|
||||||
|
entityId,
|
||||||
|
pagePath: this.normalizePath(dto.path),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
async dailyViewActivity(
|
||||||
|
businessIdRaw: string,
|
||||||
|
kind: WebsiteViewKind,
|
||||||
|
daysRaw: number | undefined,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'business.read');
|
||||||
|
|
||||||
|
const days = Math.min(Math.max(daysRaw ?? 30, 1), 90);
|
||||||
|
const from = startOfLocalDay(days - 1);
|
||||||
|
|
||||||
|
const rows = await this.prisma.$queryRaw<{ day: Date; count: number }[]>(
|
||||||
|
Prisma.sql`
|
||||||
|
SELECT viewed_at::date AS day,
|
||||||
|
COUNT(*)::int AS count
|
||||||
|
FROM website_content_views
|
||||||
|
WHERE business_id = ${businessId}
|
||||||
|
AND view_kind = ${kind}::website_view_kind
|
||||||
|
AND viewed_at >= ${from}
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY 1
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const primary = buildDailyActivitySeries(days, rows);
|
||||||
|
return {
|
||||||
|
days,
|
||||||
|
primary,
|
||||||
|
secondary: { days, total: 0, items: primary.items.map((item) => ({ ...item, count: 0 })) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizePath(path: string | undefined): string | null {
|
||||||
|
if (!path) return null;
|
||||||
|
const trimmed = path.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
return trimmed.startsWith('/') ? trimmed.slice(0, 500) : `/${trimmed}`.slice(0, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveEntityId(
|
||||||
|
businessId: bigint,
|
||||||
|
kind: WebsiteViewKind,
|
||||||
|
entityIdRaw: string | undefined,
|
||||||
|
): Promise<bigint | null> {
|
||||||
|
if (kind === 'website') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entityIdRaw?.trim()) {
|
||||||
|
throw new BadRequestException(`entityId is required for ${kind} views`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityId = BigInt(entityIdRaw);
|
||||||
|
|
||||||
|
if (kind === 'product') {
|
||||||
|
const product = await this.prisma.product.findFirst({
|
||||||
|
where: { id: entityId, businessId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!product) throw new NotFoundException('Product not found');
|
||||||
|
return entityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'portfolio') {
|
||||||
|
const portfolio = await this.prisma.portfolios.findFirst({
|
||||||
|
where: { id: entityId, business_id: businessId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!portfolio) throw new NotFoundException('Portfolio not found');
|
||||||
|
return entityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blog = await this.prisma.blogs.findFirst({
|
||||||
|
where: { id: entityId, business_id: businessId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!blog) throw new NotFoundException('Blog not found');
|
||||||
|
return entityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertPermission(
|
||||||
|
businessId: bigint,
|
||||||
|
userId: bigint,
|
||||||
|
permission: string,
|
||||||
|
) {
|
||||||
|
const allowed = await this.permissions.hasBusinessPermission(
|
||||||
|
userId,
|
||||||
|
businessId,
|
||||||
|
permission,
|
||||||
|
);
|
||||||
|
if (!allowed) {
|
||||||
|
throw new ForbiddenException(`Insufficient permissions (${permission})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export const WEBSITE_VIEW_KINDS = [
|
||||||
|
'website',
|
||||||
|
'product',
|
||||||
|
'portfolio',
|
||||||
|
'blog',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type WebsiteViewKind = (typeof WEBSITE_VIEW_KINDS)[number];
|
||||||
|
|
||||||
|
export function isWebsiteViewKind(value: unknown): value is WebsiteViewKind {
|
||||||
|
return (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
(WEBSITE_VIEW_KINDS as readonly string[]).includes(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps home dashboard chart ids to analytics view kinds. */
|
||||||
|
export const HOME_CHART_VIEW_KIND: Partial<
|
||||||
|
Record<string, WebsiteViewKind>
|
||||||
|
> = {
|
||||||
|
website_views_30d: 'website',
|
||||||
|
product_views_30d: 'product',
|
||||||
|
portfolio_views_30d: 'portfolio',
|
||||||
|
blog_views_30d: 'blog',
|
||||||
|
};
|
||||||
@@ -54,6 +54,11 @@ Public marketplace listings owned by customers — not catalog `products`.
|
|||||||
- `GET /tenants/{domain}/user-products/{slug}/technical-info` — category form + values
|
- `GET /tenants/{domain}/user-products/{slug}/technical-info` — category form + values
|
||||||
Use product categories from `GET /tenants/{domain}/categories?entityType=product` for filters. Creating/editing listings is customer-dashboard only (`/businesses/.../my-user-products`), not website-facing.
|
Use product categories from `GET /tenants/{domain}/categories?entityType=product` for filters. Creating/editing listings is customer-dashboard only (`/businesses/.../my-user-products`), not website-facing.
|
||||||
|
|
||||||
|
### Analytics (dashboard charts)
|
||||||
|
Record storefront views so the business home charts can show traffic:
|
||||||
|
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "website"|"product"|"portfolio"|"blog", "entityId"?: "...", "path"?: "/about" }`
|
||||||
|
- Call once per page view from the website (product detail, portfolio, blog post, or general pages with `kind: "website"`).
|
||||||
|
|
||||||
### Static images
|
### Static images
|
||||||
Named slots the business dashboard can replace. Fetch once per page:
|
Named slots the business dashboard can replace. Fetch once per page:
|
||||||
- `GET /tenants/{domain}/website/static-images` — all slots
|
- `GET /tenants/{domain}/website/static-images` — all slots
|
||||||
|
|||||||
@@ -430,6 +430,61 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/tenants/{domain}/analytics/views": {
|
||||||
|
"post": {
|
||||||
|
"tags": ["Analytics"],
|
||||||
|
"summary": "Record a storefront page view",
|
||||||
|
"description": "Fire-and-forget view counter for business dashboard charts. Call once per page impression from the website (product detail, portfolio, blog post, or general site page). No auth required.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/domain"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["kind"],
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["website", "product", "portfolio", "blog"]
|
||||||
|
},
|
||||||
|
"entityId": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Required when kind is product, portfolio, or blog."
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional page path for website visits (e.g. `/`, `/about`)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "View recorded",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": { "type": "boolean", "enum": [true] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Unknown domain or entity"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/tenants/{domain}/website/sliders": {
|
"/tenants/{domain}/website/sliders": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
Reference in New Issue
Block a user