chore(content): merge content full implementation into main

Merge feat/content-ai09 with complete content service
This commit is contained in:
SpecialX
2026-07-10 19:13:02 +08:00
74 changed files with 5256 additions and 355 deletions

View File

@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { QuestionsController } from "./questions.controller.js";
vi.mock("./questions.service.js", () => ({
QuestionsService: vi.fn(),
}));
const mockService = {
createQuestion: vi.fn(),
list: vi.fn(),
listByKnowledgePoint: vi.fn(),
getQuestion: vi.fn(),
updateQuestion: vi.fn(),
deleteQuestion: vi.fn(),
};
describe("QuestionsController", () => {
let controller: QuestionsController;
beforeEach(() => {
vi.clearAllMocks();
controller = new QuestionsController(mockService as never);
});
describe("create", () => {
it("should parse input and call service.createQuestion", async () => {
mockService.createQuestion.mockResolvedValue({ id: "q-1" });
const result = await controller.create({
knowledgePointId: "kp-1",
type: "single_choice",
content: "What is 2+2?",
answer: "4",
createdBy: "user-1",
});
expect(mockService.createQuestion).toHaveBeenCalledWith(
expect.objectContaining({
knowledgePointId: "kp-1",
type: "single_choice",
answer: "4",
}),
);
expect(result).toEqual({ success: true, data: { id: "q-1" } });
});
it("should throw on invalid body", async () => {
await expect(
controller.create({ knowledgePointId: "kp-1", type: "invalid" }),
).rejects.toThrow();
});
});
describe("list", () => {
it("should parse query and call service.list", async () => {
const data = [{ id: "q-1" }];
mockService.list.mockResolvedValue(data);
const result = await controller.list({ page: "1", pageSize: "20" });
expect(mockService.list).toHaveBeenCalledWith(
expect.objectContaining({ page: 1, pageSize: 20 }),
);
expect(result).toEqual({ success: true, data });
});
});
describe("listByKnowledgePoint", () => {
it("should call service.listByKnowledgePoint", async () => {
const data = [{ id: "q-1" }];
mockService.listByKnowledgePoint.mockResolvedValue(data);
const result = await controller.listByKnowledgePoint("kp-1");
expect(mockService.listByKnowledgePoint).toHaveBeenCalledWith("kp-1");
expect(result).toEqual({ success: true, data });
});
});
describe("getById", () => {
it("should call service.getQuestion", async () => {
const data = { id: "q-1", content: "Q" };
mockService.getQuestion.mockResolvedValue(data);
const result = await controller.getById("q-1");
expect(mockService.getQuestion).toHaveBeenCalledWith("q-1");
expect(result).toEqual({ success: true, data });
});
});
describe("update", () => {
it("should parse input and call service.updateQuestion", async () => {
const result = await controller.update("q-1", { content: "New content" });
expect(mockService.updateQuestion).toHaveBeenCalledWith("q-1", {
content: "New content",
});
expect(result).toEqual({ success: true, data: { success: true } });
});
it("should throw on invalid status", async () => {
await expect(
controller.update("q-1", { status: "invalid" }),
).rejects.toThrow();
});
});
describe("remove", () => {
it("should call service.deleteQuestion", async () => {
const result = await controller.remove("q-1");
expect(mockService.deleteQuestion).toHaveBeenCalledWith("q-1");
expect(result).toEqual({ success: true, data: { success: true } });
});
});
});

View File

@@ -6,17 +6,19 @@ import {
Param,
Post,
Put,
Query,
} from "@nestjs/common";
import {
QuestionsService,
type CreateQuestionInput,
type UpdateQuestionInput,
} from "./questions.service.js";
import { QuestionsService } from "./questions.service.js";
import type { Question } from "./questions.schema.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import {
createQuestionSchema,
updateQuestionSchema,
listQuestionsSchema,
} from "./questions.dto.js";
@Controller("questions")
export class QuestionsController {
@@ -25,12 +27,23 @@ export class QuestionsController {
@Post()
@RequirePermission(Permissions.CONTENT_QUESTION_CREATE)
async create(
@Body() body: CreateQuestionInput,
@Body() body: unknown,
): Promise<{ success: true; data: { id: string } }> {
const result = await this.service.createQuestion(body);
const input = createQuestionSchema.parse(body);
const result = await this.service.createQuestion(input);
return { success: true, data: result };
}
@Get()
@RequirePermission(Permissions.CONTENT_QUESTION_READ)
async list(
@Query() query: unknown,
): Promise<{ success: true; data: Question[] }> {
const input = listQuestionsSchema.parse(query);
const data = await this.service.list(input);
return { success: true, data };
}
@Get("knowledge-point/:knowledgePointId")
@RequirePermission(Permissions.CONTENT_QUESTION_READ)
async listByKnowledgePoint(
@@ -53,9 +66,10 @@ export class QuestionsController {
@RequirePermission(Permissions.CONTENT_QUESTION_UPDATE)
async update(
@Param("id") id: string,
@Body() body: UpdateQuestionInput,
@Body() body: unknown,
): Promise<{ success: true; data: { success: true } }> {
await this.service.updateQuestion(id, body);
const input = updateQuestionSchema.parse(body);
await this.service.updateQuestion(id, input);
return { success: true, data: { success: true } };
}

View File

@@ -0,0 +1,106 @@
import { describe, it, expect } from "vitest";
import {
createQuestionSchema,
updateQuestionSchema,
listQuestionsSchema,
questionTypeSchema,
questionSourceSchema,
} from "./questions.dto.js";
describe("questions.dto", () => {
describe("questionTypeSchema", () => {
it("should accept all valid question types", () => {
const validTypes = [
"single_choice",
"multiple_choice",
"short_answer",
"essay",
];
for (const t of validTypes) {
expect(questionTypeSchema.parse(t)).toBe(t);
}
});
it("should reject invalid question type", () => {
expect(() => questionTypeSchema.parse("invalid_type")).toThrow();
});
});
describe("questionSourceSchema", () => {
it("should accept all valid question sources", () => {
const validSources = ["manual", "ai_generated", "imported"];
for (const s of validSources) {
expect(questionSourceSchema.parse(s)).toBe(s);
}
});
});
describe("createQuestionSchema", () => {
it("should parse valid input and apply defaults", () => {
const result = createQuestionSchema.parse({
knowledgePointId: "kp-1",
type: "single_choice",
content: "What is 2+2?",
answer: "4",
createdBy: "user-1",
});
expect(result.knowledgePointId).toBe("kp-1");
expect(result.type).toBe("single_choice");
expect(result.content).toBe("What is 2+2?");
expect(result.answer).toBe("4");
expect(result.difficulty).toBe(3);
expect(result.source).toBe("manual");
});
it("should reject missing answer", () => {
expect(() =>
createQuestionSchema.parse({
knowledgePointId: "kp-1",
type: "essay",
content: "Write an essay",
createdBy: "user-1",
}),
).toThrow();
});
it("should reject invalid type", () => {
expect(() =>
createQuestionSchema.parse({
knowledgePointId: "kp-1",
type: "invalid",
content: "content",
answer: "answer",
createdBy: "user-1",
}),
).toThrow();
});
});
describe("updateQuestionSchema", () => {
it("should parse partial update with status", () => {
const result = updateQuestionSchema.parse({ status: "published" });
expect(result.status).toBe("published");
});
it("should reject invalid status value", () => {
expect(() => updateQuestionSchema.parse({ status: "invalid" })).toThrow();
});
});
describe("listQuestionsSchema", () => {
it("should default page and pageSize", () => {
const result = listQuestionsSchema.parse({});
expect(result.page).toBe(1);
expect(result.pageSize).toBe(20);
});
it("should coerce string numbers for page and pageSize", () => {
const result = listQuestionsSchema.parse({
page: "3",
pageSize: "50",
});
expect(result.page).toBe(3);
expect(result.pageSize).toBe(50);
});
});
});

View File

@@ -0,0 +1,51 @@
import { z } from "zod";
export const questionTypeSchema = z.enum([
"single_choice",
"multiple_choice",
"short_answer",
"essay",
]);
export const questionSourceSchema = z.enum([
"manual",
"ai_generated",
"imported",
]);
export const createQuestionSchema = z.object({
knowledgePointId: z.string().min(1).max(32),
type: questionTypeSchema,
content: z.string().min(1),
options: z.record(z.unknown()).nullish(),
answer: z.string().min(1),
explanation: z.string().optional(),
difficulty: z.number().int().min(1).max(5).optional().default(3),
source: questionSourceSchema.optional().default("manual"),
createdBy: z.string().min(1).max(32),
metadata: z.record(z.unknown()).nullish(),
});
export const updateQuestionSchema = z.object({
content: z.string().min(1).optional(),
options: z.record(z.unknown()).nullish(),
answer: z.string().min(1).optional(),
explanation: z.string().optional(),
difficulty: z.number().int().min(1).max(5).optional(),
status: z
.enum(["draft", "pending_review", "published", "rejected", "archived"])
.optional(),
});
export const listQuestionsSchema = z.object({
knowledgePointId: z.string().optional(),
type: questionTypeSchema.optional(),
difficulty: z.coerce.number().int().min(1).max(5).optional(),
status: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});
export type CreateQuestionDto = z.infer<typeof createQuestionSchema>;
export type UpdateQuestionDto = z.infer<typeof updateQuestionSchema>;
export type ListQuestionsDto = z.infer<typeof listQuestionsSchema>;

View File

@@ -1,8 +1,10 @@
import { Module } from "@nestjs/common";
import { QuestionsController } from "./questions.controller.js";
import { QuestionsService } from "./questions.service.js";
import { OutboxModule } from "../shared/outbox/outbox.module.js";
@Module({
imports: [OutboxModule],
controllers: [QuestionsController],
providers: [QuestionsService],
exports: [QuestionsService],

View File

@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockGetDb = vi.fn();
vi.mock("../config/database.js", () => ({
getDb: () => mockGetDb(),
}));
import { QuestionsRepository } from "./questions.repository.js";
function createMockDb(resolvedValue: unknown): unknown {
const handler: ProxyHandler<Record<PropertyKey, unknown>> = {
get: (_target, prop) => {
if (prop === "then") {
return (onFulfilled?: (v: unknown) => unknown) =>
Promise.resolve(
typeof onFulfilled === "function"
? onFulfilled(resolvedValue)
: resolvedValue,
);
}
return () => new Proxy({}, handler);
},
};
return new Proxy({}, handler);
}
describe("QuestionsRepository", () => {
let repo: QuestionsRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new QuestionsRepository();
});
it("findById should return first matching question", async () => {
const q = { id: "q-1", content: "Q" };
mockGetDb.mockReturnValue(createMockDb([q]));
const result = await repo.findById("q-1");
expect(result).toBe(q);
});
it("findById should return undefined when no match", async () => {
mockGetDb.mockReturnValue(createMockDb([]));
const result = await repo.findById("missing");
expect(result).toBeUndefined();
});
it("findByKnowledgePointId should return questions array", async () => {
const qs = [{ id: "q-1" }];
mockGetDb.mockReturnValue(createMockDb(qs));
const result = await repo.findByKnowledgePointId("kp-1");
expect(result).toBe(qs);
});
it("find should apply query filters and pagination", async () => {
const qs = [{ id: "q-1" }];
mockGetDb.mockReturnValue(createMockDb(qs));
const result = await repo.find({
knowledgePointId: "kp-1",
type: "single_choice",
difficulty: 3,
status: "published",
page: 2,
pageSize: 10,
});
expect(result).toBe(qs);
});
it("find should use defaults when query is empty", async () => {
const qs: unknown[] = [];
mockGetDb.mockReturnValue(createMockDb(qs));
const result = await repo.find();
expect(result).toBe(qs);
});
it("create should insert a new question", async () => {
mockGetDb.mockReturnValue(createMockDb(undefined));
await repo.create({
id: "q-1",
knowledgePointId: "kp-1",
type: "single_choice",
content: "Q",
answer: "A",
difficulty: 3,
status: "draft",
source: "manual",
createdBy: "user-1",
});
expect(mockGetDb).toHaveBeenCalled();
});
it("update should update a question by id", async () => {
mockGetDb.mockReturnValue(createMockDb(undefined));
await repo.update("q-1", { content: "New" });
expect(mockGetDb).toHaveBeenCalled();
});
it("delete should delete a question by id", async () => {
mockGetDb.mockReturnValue(createMockDb(undefined));
await repo.delete("q-1");
expect(mockGetDb).toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,5 @@
import { eq } from "drizzle-orm";
import { db } from "../config/database.js";
import { getDb } from "../config/database.js";
import {
questions,
type Question,
@@ -8,7 +8,7 @@ import {
export class QuestionsRepository {
async findById(id: string): Promise<Question | undefined> {
const [result] = await db
const [result] = await getDb()
.select()
.from(questions)
.where(eq(questions.id, id))
@@ -17,22 +17,49 @@ export class QuestionsRepository {
}
async findByKnowledgePointId(knowledgePointId: string): Promise<Question[]> {
return db
return getDb()
.select()
.from(questions)
.where(eq(questions.knowledgePointId, knowledgePointId));
}
async find(query?: {
knowledgePointId?: string;
type?: string;
difficulty?: number;
status?: string;
page?: number;
pageSize?: number;
}): Promise<Question[]> {
const db = getDb();
let q = db.select().from(questions).$dynamic();
if (query?.knowledgePointId) {
q = q.where(eq(questions.knowledgePointId, query.knowledgePointId));
}
if (query?.type) {
q = q.where(eq(questions.type, query.type));
}
if (query?.difficulty !== undefined) {
q = q.where(eq(questions.difficulty, query.difficulty));
}
if (query?.status) {
q = q.where(eq(questions.status, query.status));
}
const pageSize = query?.pageSize ?? 20;
const page = query?.page ?? 1;
return q.limit(pageSize).offset((page - 1) * pageSize);
}
async create(data: NewQuestion): Promise<void> {
await db.insert(questions).values(data);
await getDb().insert(questions).values(data);
}
async update(id: string, data: Partial<NewQuestion>): Promise<void> {
await db.update(questions).set(data).where(eq(questions.id, id));
await getDb().update(questions).set(data).where(eq(questions.id, id));
}
async delete(id: string): Promise<void> {
await db.delete(questions).where(eq(questions.id, id));
await getDb().delete(questions).where(eq(questions.id, id));
}
}

View File

@@ -1,23 +1,41 @@
import {
mysqlTable,
char,
varchar,
text,
int,
tinyint,
timestamp,
json,
index,
} from "drizzle-orm/mysql-core";
export const questions = mysqlTable("content_questions", {
id: char("id", { length: 36 }).notNull().primaryKey(),
knowledgePointId: char("knowledge_point_id", { length: 36 }).notNull(),
type: varchar("type", { length: 50 }).notNull(),
content: text("content").notNull(),
answer: text("answer"),
explanation: text("explanation"),
difficulty: int("difficulty").default(3),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
});
export const questions = mysqlTable(
"content_questions",
{
id: varchar("id", { length: 32 }).notNull().primaryKey(),
knowledgePointId: varchar("knowledge_point_id", { length: 32 }).notNull(),
type: varchar("type", { length: 32 }).notNull(),
content: text("content").notNull(),
options: json("options").$type<Record<string, unknown> | null>(),
answer: text("answer").notNull(),
explanation: text("explanation"),
difficulty: tinyint("difficulty").notNull().default(3),
status: varchar("status", { length: 32 }).notNull().default("draft"),
source: varchar("source", { length: 32 }).notNull().default("manual"),
createdBy: varchar("created_by", { length: 32 }).notNull(),
metadata: json("metadata").$type<Record<string, unknown> | null>(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
kpIdx: index("idx_questions_kp").on(table.knowledgePointId),
typeDifficultyIdx: index("idx_questions_type_difficulty").on(
table.type,
table.difficulty,
),
statusIdx: index("idx_questions_status").on(table.status),
sourceIdx: index("idx_questions_source").on(table.source),
}),
);
export type Question = typeof questions.$inferSelect;
export type NewQuestion = typeof questions.$inferInsert;

View File

@@ -0,0 +1,177 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { QuestionsService } from "./questions.service.js";
import { NotFoundError } from "../shared/errors/application-error.js";
vi.mock("./questions.repository.js", () => ({
questionsRepository: {
findById: vi.fn(),
findByKnowledgePointId: vi.fn(),
find: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}));
import { questionsRepository } from "./questions.repository.js";
const mockOutbox = {
publish: vi.fn().mockResolvedValue("event-id"),
};
describe("QuestionsService", () => {
let service: QuestionsService;
beforeEach(() => {
vi.clearAllMocks();
service = new QuestionsService(mockOutbox as never);
});
describe("createQuestion", () => {
it("should create a question and publish event", async () => {
const input = {
knowledgePointId: "kp-1",
type: "single_choice",
content: "What is 2+2?",
answer: "4",
createdBy: "user-1",
};
const result = await service.createQuestion(input);
expect(result.id).toBeDefined();
expect(questionsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
knowledgePointId: "kp-1",
type: "single_choice",
content: "What is 2+2?",
answer: "4",
status: "draft",
source: "manual",
createdBy: "user-1",
}),
);
expect(mockOutbox.publish).toHaveBeenCalledWith(
"question.created",
"Question",
result.id,
expect.objectContaining({
knowledge_point_id: "kp-1",
type: "single_choice",
created_by: "user-1",
}),
);
});
it("should default difficulty to 3 and source to manual", async () => {
await service.createQuestion({
knowledgePointId: "kp-1",
type: "essay",
content: "Write an essay",
answer: "Sample answer",
createdBy: "user-1",
});
expect(questionsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
difficulty: 3,
source: "manual",
}),
);
});
});
describe("getQuestion", () => {
it("should throw NotFoundError when not found", async () => {
vi.mocked(questionsRepository.findById).mockResolvedValue(undefined);
await expect(service.getQuestion("nope")).rejects.toThrow(NotFoundError);
});
});
describe("updateQuestion", () => {
it("should publish published event when status is published", async () => {
vi.mocked(questionsRepository.findById).mockResolvedValue({
id: "q-1",
knowledgePointId: "kp-1",
type: "single_choice",
content: "Q",
options: null,
answer: "A",
explanation: null,
difficulty: 3,
status: "draft",
source: "manual",
createdBy: "u",
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
});
await service.updateQuestion("q-1", { status: "published" });
expect(mockOutbox.publish).toHaveBeenCalledWith(
"question.published",
"Question",
"q-1",
expect.any(Object),
);
});
it("should publish updated event for non-status updates", async () => {
vi.mocked(questionsRepository.findById).mockResolvedValue({
id: "q-1",
knowledgePointId: "kp-1",
type: "single_choice",
content: "Q",
options: null,
answer: "A",
explanation: null,
difficulty: 3,
status: "draft",
source: "manual",
createdBy: "u",
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
});
await service.updateQuestion("q-1", { content: "New content" });
expect(mockOutbox.publish).toHaveBeenCalledWith(
"question.updated",
"Question",
"q-1",
expect.any(Object),
);
});
});
describe("deleteQuestion", () => {
it("should delete and publish deleted event", async () => {
vi.mocked(questionsRepository.findById).mockResolvedValue({
id: "q-1",
knowledgePointId: "kp-1",
type: "single_choice",
content: "Q",
options: null,
answer: "A",
explanation: null,
difficulty: 3,
status: "draft",
source: "manual",
createdBy: "u",
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
});
await service.deleteQuestion("q-1");
expect(questionsRepository.delete).toHaveBeenCalledWith("q-1");
expect(mockOutbox.publish).toHaveBeenCalledWith(
"question.deleted",
"Question",
"q-1",
{ deleted: true },
);
});
});
});

View File

@@ -1,58 +1,78 @@
import { randomUUID } from "node:crypto";
import { createId } from "@paralleldrive/cuid2";
import { Injectable } from "@nestjs/common";
import { questionsRepository } from "./questions.repository.js";
import type { Question } from "./questions.schema.js";
import {
NotFoundError,
ValidationError,
} from "../shared/errors/application-error.js";
import type { Question, NewQuestion } from "./questions.schema.js";
import { OutboxService } from "../shared/outbox/outbox.service.js";
import { AGGREGATE_TYPES, EVENT_TYPES } from "../shared/outbox/events.js";
import { NotFoundError } from "../shared/errors/application-error.js";
export interface CreateQuestionInput {
knowledgePointId: string;
type: string;
content: string;
answer?: string;
options?: Record<string, unknown> | null;
answer: string;
explanation?: string;
difficulty?: number;
source?: string;
createdBy: string;
metadata?: Record<string, unknown> | null;
}
export interface UpdateQuestionInput {
type?: string;
content?: string;
options?: Record<string, unknown> | null;
answer?: string;
explanation?: string;
difficulty?: number;
status?: string;
}
const VALID_TYPES = new Set([
"single_choice",
"multiple_choice",
"short_answer",
"essay",
]);
export interface ListQuestionsInput {
knowledgePointId?: string;
type?: string;
difficulty?: number;
status?: string;
page?: number;
pageSize?: number;
}
@Injectable()
export class QuestionsService {
async createQuestion(input: CreateQuestionInput): Promise<{ id: string }> {
if (!input.knowledgePointId || !input.type || !input.content) {
throw new ValidationError("knowledgePointId, type, content are required");
}
if (!VALID_TYPES.has(input.type)) {
throw new ValidationError(
`Invalid question type: ${input.type}. Must be one of: ${[...VALID_TYPES].join(", ")}`,
);
}
constructor(private readonly outbox: OutboxService) {}
const id = randomUUID();
await questionsRepository.create({
async createQuestion(input: CreateQuestionInput): Promise<{ id: string }> {
const id = createId();
const record: NewQuestion = {
id,
knowledgePointId: input.knowledgePointId,
type: input.type,
content: input.content,
options: input.options ?? null,
answer: input.answer,
explanation: input.explanation,
difficulty: input.difficulty,
});
difficulty: input.difficulty ?? 3,
status: "draft",
source: input.source ?? "manual",
createdBy: input.createdBy,
metadata: input.metadata ?? null,
};
await questionsRepository.create(record);
await this.outbox.publish(
EVENT_TYPES.QUESTION_CREATED,
AGGREGATE_TYPES.QUESTION,
id,
{
knowledge_point_id: record.knowledgePointId,
type: record.type,
difficulty: record.difficulty,
status: record.status,
source: record.source,
created_by: record.createdBy,
},
);
return { id };
}
@@ -68,16 +88,35 @@ export class QuestionsService {
return questionsRepository.findByKnowledgePointId(knowledgePointId);
}
async list(query: ListQuestionsInput): Promise<Question[]> {
return questionsRepository.find(query);
}
async updateQuestion(id: string, data: UpdateQuestionInput): Promise<void> {
await this.getQuestion(id);
if (data.type && !VALID_TYPES.has(data.type)) {
throw new ValidationError(`Invalid question type: ${data.type}`);
}
const existing = await this.getQuestion(id);
await questionsRepository.update(id, data);
const eventType =
data.status === "published"
? EVENT_TYPES.QUESTION_PUBLISHED
: EVENT_TYPES.QUESTION_UPDATED;
await this.outbox.publish(eventType, AGGREGATE_TYPES.QUESTION, id, {
content: data.content ?? existing.content,
difficulty: data.difficulty ?? existing.difficulty,
status: data.status ?? existing.status,
});
}
async deleteQuestion(id: string): Promise<void> {
await this.getQuestion(id);
await questionsRepository.delete(id);
await this.outbox.publish(
EVENT_TYPES.QUESTION_DELETED,
AGGREGATE_TYPES.QUESTION,
id,
{ deleted: true },
);
}
}