feat: 完成分类、worksheet 接口开发,资源迁移到七牛
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Allow } from 'class-validator';
|
||||
import type { WorksheetAgeBand, WorksheetSort } from '@doodle/contracts';
|
||||
|
||||
export const worksheetAgeBands = {
|
||||
'3-4': [3, 4],
|
||||
'4-5': [4, 5],
|
||||
'5-6': [5, 6],
|
||||
'6-7': [6, 7],
|
||||
'7-8': [7, 8],
|
||||
} as const;
|
||||
|
||||
/** HTTP query 先做字段白名单,随后显式解析;不把 false、空串或重复参数隐式转换。 */
|
||||
export class WorksheetQueryDto {
|
||||
@Allow()
|
||||
@ApiPropertyOptional({
|
||||
type: 'integer',
|
||||
default: 1,
|
||||
minimum: 1,
|
||||
maximum: 1000000,
|
||||
})
|
||||
declare page?: string;
|
||||
|
||||
@Allow()
|
||||
@ApiPropertyOptional({
|
||||
type: 'integer',
|
||||
default: 20,
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
})
|
||||
declare pageSize?: string;
|
||||
|
||||
@Allow()
|
||||
@ApiPropertyOptional({
|
||||
example: 'math',
|
||||
description: '精确分类 ID,不自动包含子分类',
|
||||
})
|
||||
declare categoryId?: string;
|
||||
|
||||
@Allow()
|
||||
@ApiPropertyOptional({
|
||||
type: 'integer',
|
||||
minimum: -4,
|
||||
maximum: 6,
|
||||
description: '经审核的适用年级,不能与 grades 同传',
|
||||
})
|
||||
declare grade?: string;
|
||||
|
||||
@Allow()
|
||||
@ApiPropertyOptional({
|
||||
example: '-1,0,1',
|
||||
description: '多个年级,逗号分隔,任一匹配;不重复返回题型',
|
||||
})
|
||||
declare grades?: string;
|
||||
|
||||
@Allow()
|
||||
@ApiPropertyOptional({
|
||||
enum: Object.keys(worksheetAgeBands),
|
||||
description:
|
||||
'题型适用范围完整覆盖所选年龄段:ageMin ≤ 下限,ageMax ≥ 上限',
|
||||
})
|
||||
declare ageBand?: string;
|
||||
|
||||
@Allow()
|
||||
@ApiPropertyOptional({
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: 32767,
|
||||
description: '单个年龄须被题型年龄范围覆盖,不能与 ageBand 同传',
|
||||
})
|
||||
declare age?: string;
|
||||
|
||||
@Allow()
|
||||
@ApiPropertyOptional({
|
||||
example: '描红',
|
||||
maxLength: 100,
|
||||
description: 'Tag 精确匹配,区分大小写',
|
||||
})
|
||||
declare tag?: string;
|
||||
|
||||
@Allow()
|
||||
@ApiPropertyOptional({
|
||||
enum: ['default', 'latest', 'hot'],
|
||||
default: 'default',
|
||||
})
|
||||
declare sort?: string;
|
||||
|
||||
@Allow()
|
||||
@ApiPropertyOptional({
|
||||
type: Boolean,
|
||||
description: '筛选人工运营热门标记,可与热度排序分别使用',
|
||||
})
|
||||
declare isHot?: string;
|
||||
|
||||
@Allow()
|
||||
@ApiPropertyOptional({
|
||||
type: Boolean,
|
||||
description: '按真实首次发布时间及新品窗口筛选,未知发布时间不标新品',
|
||||
})
|
||||
declare isNew?: string;
|
||||
}
|
||||
|
||||
export interface ParsedWorksheetQuery {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
categoryId: string | undefined;
|
||||
grades: number[] | undefined;
|
||||
ageBand: WorksheetAgeBand | undefined;
|
||||
age: number | undefined;
|
||||
tag: string | undefined;
|
||||
sort: WorksheetSort;
|
||||
isHot: boolean | undefined;
|
||||
isNew: boolean | undefined;
|
||||
}
|
||||
|
||||
/** service 也调用同一解析入口,确保手动调用不会绕过组合和取值校验。 */
|
||||
export function parseWorksheetQuery(
|
||||
input: WorksheetQueryDto,
|
||||
): ParsedWorksheetQuery {
|
||||
const allowed = [
|
||||
'page',
|
||||
'pageSize',
|
||||
'categoryId',
|
||||
'grade',
|
||||
'grades',
|
||||
'ageBand',
|
||||
'age',
|
||||
'tag',
|
||||
'sort',
|
||||
'isHot',
|
||||
'isNew',
|
||||
];
|
||||
if (Object.keys(input).some((key) => !allowed.includes(key)))
|
||||
throw new BadRequestException('未知题型查询参数');
|
||||
const text = (
|
||||
value: unknown,
|
||||
name: string,
|
||||
max: number,
|
||||
): string | undefined => {
|
||||
if (value === undefined) return undefined;
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!value.trim() ||
|
||||
[...value].length > max ||
|
||||
value.includes('\0')
|
||||
)
|
||||
throw new BadRequestException(`${name} 不合法`);
|
||||
return value.trim();
|
||||
};
|
||||
const integer = (
|
||||
value: unknown,
|
||||
name: string,
|
||||
min: number,
|
||||
max: number,
|
||||
): number | undefined => {
|
||||
const raw = text(value, name, 16);
|
||||
if (raw === undefined) return undefined;
|
||||
if (!/^(?:0|-?[1-9]\d*)$/.test(raw))
|
||||
throw new BadRequestException(`${name} 须为整数`);
|
||||
const result = Number(raw);
|
||||
if (!Number.isSafeInteger(result) || result < min || result > max)
|
||||
throw new BadRequestException(`${name} 越界`);
|
||||
return result;
|
||||
};
|
||||
const boolean = (value: unknown, name: string): boolean | undefined => {
|
||||
const raw = text(value, name, 5);
|
||||
if (raw === undefined) return undefined;
|
||||
if (raw !== 'true' && raw !== 'false')
|
||||
throw new BadRequestException(`${name} 须为 true 或 false`);
|
||||
return raw === 'true';
|
||||
};
|
||||
const grade = integer(input.grade, 'grade', -4, 6);
|
||||
const gradeList = text(input.grades, 'grades', 100);
|
||||
if (grade !== undefined && gradeList !== undefined)
|
||||
throw new BadRequestException('grade 和 grades 不能同时传入');
|
||||
let grades = grade === undefined ? undefined : [grade];
|
||||
if (gradeList !== undefined) {
|
||||
const values = gradeList.split(',');
|
||||
if (values.length > 11)
|
||||
throw new BadRequestException('grades 最多 11 项');
|
||||
grades = [
|
||||
...new Set(values.map((value) => integer(value, 'grades', -4, 6)!)),
|
||||
];
|
||||
}
|
||||
const ageBand = text(input.ageBand, 'ageBand', 3);
|
||||
if (ageBand !== undefined && !Object.hasOwn(worksheetAgeBands, ageBand))
|
||||
throw new BadRequestException('年龄段不合法');
|
||||
const age = integer(input.age, 'age', 0, 32767);
|
||||
if (age !== undefined && ageBand !== undefined)
|
||||
throw new BadRequestException('age 与 ageBand 不能同时传入');
|
||||
const sort = text(input.sort, 'sort', 10) ?? 'default';
|
||||
if (!['default', 'latest', 'hot'].includes(sort))
|
||||
throw new BadRequestException('排序方式不合法');
|
||||
return {
|
||||
page: integer(input.page, 'page', 1, 1000000) ?? 1,
|
||||
pageSize: integer(input.pageSize, 'pageSize', 1, 100) ?? 20,
|
||||
categoryId: text(input.categoryId, 'categoryId', 128),
|
||||
grades,
|
||||
ageBand: ageBand as WorksheetAgeBand | undefined,
|
||||
age,
|
||||
tag: text(input.tag, 'tag', 100),
|
||||
sort: sort as WorksheetSort,
|
||||
isHot: boolean(input.isHot, 'isHot'),
|
||||
isNew: boolean(input.isNew, 'isNew'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import {
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type {
|
||||
ApiSuccess,
|
||||
PublicWorksheet,
|
||||
WorksheetPage,
|
||||
} from '@doodle/contracts';
|
||||
import { Public } from '../auth/auth.decorators';
|
||||
import { WorksheetQueryDto } from './worksheet-query.dto';
|
||||
import { WorksheetService } from './worksheet.service';
|
||||
|
||||
@Controller('worksheets')
|
||||
@ApiTags('worksheets')
|
||||
@Public()
|
||||
/** 小程序与未来 Web 共用的公开只读接口,不提供管理状态或原始统计的查询开关。 */
|
||||
export class WorksheetController {
|
||||
constructor(private readonly worksheets: WorksheetService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '分页查询全部/分类/年级/年龄段/Tag,支持最新和热门排序',
|
||||
security: [],
|
||||
description:
|
||||
'条件可组合,题型及全部上级分类必须 active;sort=hot 按下载展示值降序;isHot 为运营标记筛选。预览未就绪返回 null。',
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description:
|
||||
'{code:0,message:"ok",data:{items,total,page,pageSize,hasNext}};downloads/likes 为含 seed 的十进制字符串。',
|
||||
})
|
||||
async list(
|
||||
@Query() query: WorksheetQueryDto,
|
||||
): Promise<ApiSuccess<WorksheetPage>> {
|
||||
return {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: await this.worksheets.list(query),
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({
|
||||
summary: '公开题型详情;不存在或未发布返回 404',
|
||||
security: [],
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'id',
|
||||
example: 'number-find',
|
||||
description: '使用题型列表 data.items 中的 id,例如 number-find',
|
||||
})
|
||||
@ApiNotFoundResponse({
|
||||
description: '题型不存在,或题型及所属分类链尚未全部上线',
|
||||
})
|
||||
async get(@Param('id') id: string): Promise<ApiSuccess<PublicWorksheet>> {
|
||||
return { code: 0, message: 'ok', data: await this.worksheets.get(id) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import {
|
||||
AdminWorksheetController,
|
||||
AdminResourceController,
|
||||
} from '../content/content.controller';
|
||||
import { PublicationService } from '../content/publication.service';
|
||||
import { ResourcePreviewService } from '../content/resource-preview.service';
|
||||
import { WorksheetController } from './worksheet.controller';
|
||||
import { WorksheetService } from './worksheet.service';
|
||||
|
||||
/** 公开题型查询与独立鉴权的管理预览、上下架;不注册文件上传或资源公开查询。 */
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [
|
||||
WorksheetController,
|
||||
AdminWorksheetController,
|
||||
AdminResourceController,
|
||||
],
|
||||
providers: [WorksheetService, PublicationService, ResourcePreviewService],
|
||||
})
|
||||
export class WorksheetModule {}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type {
|
||||
AdminWorksheet,
|
||||
AdminWorksheetPage,
|
||||
PublicWorksheet,
|
||||
WorksheetPage,
|
||||
} from '@doodle/contracts';
|
||||
import {
|
||||
visibleWorksheet,
|
||||
runContentQuery,
|
||||
contentStatus,
|
||||
} from '../content/content-policy';
|
||||
import { previewUrl } from '../content/preview-url';
|
||||
import {
|
||||
parseAdminWorksheetQuery,
|
||||
type AdminWorksheetQueryDto,
|
||||
} from '../content/content.dto';
|
||||
import { Prisma } from '../generated/prisma/client';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import { SERVER_CONFIG, type ServerConfig } from '../config/server-config';
|
||||
import {
|
||||
parseWorksheetQuery,
|
||||
worksheetAgeBands,
|
||||
type ParsedWorksheetQuery,
|
||||
type WorksheetQueryDto,
|
||||
} from './worksheet-query.dto';
|
||||
|
||||
interface WorksheetRow {
|
||||
status: string;
|
||||
visible: boolean;
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
category: string;
|
||||
subcategory: string | null;
|
||||
path: string;
|
||||
ageMin: number;
|
||||
ageMax: number;
|
||||
grades: number[];
|
||||
difficulty: number;
|
||||
tags: string[];
|
||||
isNew: boolean;
|
||||
isHot: boolean;
|
||||
sortOrder: number;
|
||||
downloads: bigint;
|
||||
likes: bigint;
|
||||
downloadsSeed: bigint;
|
||||
likesSeed: bigint;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
contentUpdatedAt: Date | null;
|
||||
firstPublishedAt: Date | null;
|
||||
assetStatus: string;
|
||||
assetVisibility: string;
|
||||
assetPurpose: string;
|
||||
assetOwnerId: string | null;
|
||||
assetProvider: string;
|
||||
assetKey: string | null;
|
||||
assetExternalUrl: string | null;
|
||||
assetMimeType: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
/** 公开查询按整条所属关系限制可见性;管理预览由独立的管理员路由提供。 */
|
||||
export class WorksheetService {
|
||||
constructor(
|
||||
private readonly database: PrismaService,
|
||||
@Inject(SERVER_CONFIG) private readonly config: ServerConfig,
|
||||
) {}
|
||||
|
||||
/** 公开查询不提供状态开关,固定要求题型及全部上级分类上线。 */
|
||||
async list(input: WorksheetQueryDto): Promise<WorksheetPage> {
|
||||
const query = parseWorksheetQuery(input);
|
||||
return this.readPage(query, visibleWorksheet, (row) =>
|
||||
this.toPublic(row),
|
||||
);
|
||||
}
|
||||
|
||||
/** 仅管理控制器调用;公开 DTO 与管理 DTO 分离,不能由 query 提升权限。 */
|
||||
async listForAdmin(
|
||||
input: AdminWorksheetQueryDto,
|
||||
): Promise<AdminWorksheetPage> {
|
||||
const { query, status } = parseAdminWorksheetQuery(input);
|
||||
return this.readPage(
|
||||
query,
|
||||
status === undefined
|
||||
? Prisma.sql`TRUE`
|
||||
: Prisma.sql`w.status = ${status}`,
|
||||
(row) => this.toAdmin(row),
|
||||
);
|
||||
}
|
||||
|
||||
/** 总量、数据和派生可见性在同一个数据库快照中读取。 */
|
||||
private async readPage<T extends PublicWorksheet>(
|
||||
query: ParsedWorksheetQuery,
|
||||
visibility: Prisma.Sql,
|
||||
convert: (row: WorksheetRow) => T,
|
||||
) {
|
||||
const isNew = this.isNewExpression(new Date());
|
||||
const where = this.conditions(query, isNew, visibility);
|
||||
return runContentQuery(() =>
|
||||
this.database.client.$transaction(
|
||||
async (tx) => {
|
||||
const counts = await tx.$queryRaw<Array<{ total: bigint }>>`
|
||||
SELECT COUNT(*) AS total FROM public.worksheets w WHERE ${where}
|
||||
`;
|
||||
const total = Number(counts[0]!.total);
|
||||
if (!Number.isSafeInteger(total))
|
||||
throw new Error('题型总数超出安全范围');
|
||||
const rows = await tx.$queryRaw<WorksheetRow[]>(Prisma.sql`
|
||||
SELECT ${this.columns(isNew)}
|
||||
FROM public.worksheets w JOIN public.assets a ON a.id = w.preview_asset_id
|
||||
WHERE ${where} ORDER BY ${this.order(query.sort)}
|
||||
LIMIT ${query.pageSize} OFFSET ${(query.page - 1) * query.pageSize}
|
||||
`);
|
||||
return {
|
||||
items: rows.map(convert),
|
||||
total,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
hasNext: query.page * query.pageSize < total,
|
||||
};
|
||||
},
|
||||
{ isolationLevel: 'RepeatableRead', timeout: 10_000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 未发布、祖先未发布与不存在统一 404。 */
|
||||
async get(id: string): Promise<PublicWorksheet> {
|
||||
return this.toPublic(await this.readOne(id, visibleWorksheet));
|
||||
}
|
||||
|
||||
/** 管理预览只增加发布状态和有效可见性,不泄漏源文件或任意渲染配置。 */
|
||||
async getForAdmin(id: string): Promise<AdminWorksheet> {
|
||||
return this.toAdmin(await this.readOne(id, Prisma.sql`TRUE`));
|
||||
}
|
||||
|
||||
private async readOne(
|
||||
id: string,
|
||||
visibility: Prisma.Sql,
|
||||
): Promise<WorksheetRow> {
|
||||
if (!id || [...id].length > 128 || id.includes('\0'))
|
||||
throw new NotFoundException();
|
||||
return runContentQuery(async () => {
|
||||
const rows = await this.database.client.$queryRaw<
|
||||
WorksheetRow[]
|
||||
>(Prisma.sql`
|
||||
SELECT ${this.columns(this.isNewExpression(new Date()))}
|
||||
FROM public.worksheets w JOIN public.assets a ON a.id = w.preview_asset_id
|
||||
WHERE w.id = ${id} AND (${visibility})
|
||||
`);
|
||||
if (!rows[0]) throw new NotFoundException();
|
||||
return rows[0];
|
||||
});
|
||||
}
|
||||
|
||||
private toAdmin(row: WorksheetRow): AdminWorksheet {
|
||||
return {
|
||||
...this.toPublic(row),
|
||||
status: contentStatus(row.status),
|
||||
visible: row.visible,
|
||||
};
|
||||
}
|
||||
|
||||
private isNewExpression(now: Date): Prisma.Sql {
|
||||
const cutoff = new Date(
|
||||
now.getTime() - this.config.worksheetNewWindowDays * 86400000,
|
||||
);
|
||||
// NULL 首发时间和未来时间均为 false;旧 is_new 不能代替真实发布时间。
|
||||
return Prisma.sql`COALESCE(w.first_published_at BETWEEN ${cutoff} AND ${now}, FALSE)`;
|
||||
}
|
||||
|
||||
private conditions(
|
||||
query: ParsedWorksheetQuery,
|
||||
isNew: Prisma.Sql,
|
||||
visibility: Prisma.Sql,
|
||||
): Prisma.Sql {
|
||||
const parts = [Prisma.sql`(${visibility})`];
|
||||
if (query.categoryId !== undefined)
|
||||
parts.push(Prisma.sql`w.category_id = ${query.categoryId}`);
|
||||
if (query.grades !== undefined)
|
||||
parts.push(Prisma.sql`EXISTS (
|
||||
SELECT 1 FROM public.worksheet_grades g WHERE g.worksheet_id = w.id
|
||||
AND g.grade IN (${Prisma.join(query.grades)})
|
||||
)`);
|
||||
if (query.ageBand !== undefined) {
|
||||
const [min, max] = worksheetAgeBands[query.ageBand];
|
||||
// 适用范围须完整覆盖所选年龄段;只相交不算命中,不做推荐榜补齐或去重混排。
|
||||
parts.push(Prisma.sql`w.age_min <= ${min} AND w.age_max >= ${max}`);
|
||||
}
|
||||
if (query.age !== undefined)
|
||||
parts.push(
|
||||
Prisma.sql`w.age_min <= ${query.age} AND w.age_max >= ${query.age}`,
|
||||
);
|
||||
if (query.tag !== undefined)
|
||||
parts.push(
|
||||
Prisma.sql`w.tags @> ${JSON.stringify([query.tag])}::jsonb`,
|
||||
);
|
||||
if (query.isHot !== undefined)
|
||||
parts.push(Prisma.sql`w.is_hot = ${query.isHot}`);
|
||||
if (query.isNew !== undefined)
|
||||
parts.push(Prisma.sql`${isNew} = ${query.isNew}`);
|
||||
return Prisma.join(parts, ' AND ');
|
||||
}
|
||||
|
||||
private order(sort: ParsedWorksheetQuery['sort']): Prisma.Sql {
|
||||
if (sort === 'latest')
|
||||
return Prisma.sql`COALESCE(w.first_published_at, w.created_at) DESC, w.id ASC`;
|
||||
// 转 numeric 避免两个 bigint 相加溢出;不在 JavaScript 中排序或先截断候选集。
|
||||
if (sort === 'hot')
|
||||
return Prisma.sql`
|
||||
(w.downloads::numeric + w.downloads_seed::numeric) DESC,
|
||||
(w.likes::numeric + w.likes_seed::numeric) DESC, w.updated_at DESC, w.id ASC
|
||||
`;
|
||||
return Prisma.sql`w.sort_order ASC, w.updated_at DESC, w.id ASC`;
|
||||
}
|
||||
|
||||
private columns(isNew: Prisma.Sql): Prisma.Sql {
|
||||
// 不 SELECT w.*,不把旧 grade、seed、内部资产或生成器配置混入公开响应。
|
||||
return Prisma.sql`
|
||||
w.status, (${visibleWorksheet}) AS visible,
|
||||
w.id, w.title, w.subtitle, w.category_id AS category, w.subcategory, w.path,
|
||||
w.age_min AS "ageMin", w.age_max AS "ageMax", w.difficulty, w.tags,
|
||||
array_to_json(ARRAY(SELECT grade FROM public.worksheet_grades g WHERE g.worksheet_id = w.id ORDER BY grade)) AS grades,
|
||||
${isNew} AS "isNew", w.is_hot AS "isHot", w.sort_order AS "sortOrder",
|
||||
w.downloads, w.likes, w.downloads_seed AS "downloadsSeed", w.likes_seed AS "likesSeed",
|
||||
w.created_at AS "createdAt", w.updated_at AS "updatedAt",
|
||||
w.content_updated_at AS "contentUpdatedAt", w.first_published_at AS "firstPublishedAt",
|
||||
a.status AS "assetStatus", a.visibility AS "assetVisibility", a.purpose AS "assetPurpose",
|
||||
a.owner_user_id AS "assetOwnerId", a.provider AS "assetProvider", a.object_key AS "assetKey", a.external_url AS "assetExternalUrl", a.mime_type AS "assetMimeType"
|
||||
`;
|
||||
}
|
||||
|
||||
private toPublic(row: WorksheetRow): PublicWorksheet {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
subtitle: row.subtitle,
|
||||
category: row.category,
|
||||
subcategory: row.subcategory,
|
||||
path: row.path,
|
||||
ageMin: row.ageMin,
|
||||
ageMax: row.ageMax,
|
||||
grades: row.grades,
|
||||
difficulty: row.difficulty,
|
||||
previewImg: previewUrl(row, this.config.qiniuPublicDomain),
|
||||
tags: row.tags,
|
||||
isNew: row.isNew,
|
||||
isHot: row.isHot,
|
||||
sortOrder: row.sortOrder,
|
||||
downloads: (row.downloads + row.downloadsSeed).toString(),
|
||||
likes: (row.likes + row.likesSeed).toString(),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
contentUpdatedAt: row.contentUpdatedAt?.toISOString() ?? null,
|
||||
firstPublishedAt: row.firstPublishedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user