feat(21-03): implement chatService with full conversation + message CRUD
- createConversation, listConversations, getConversation, updateConversation - softDeleteConversation, listMessages, addMessage - cursor-based pagination with hasMore for both conversations and messages - Pitfall 3: addMessage bumps conversation updatedAt after insert - Pitfall 5: addMessage auto-sets title from first user message (IS NULL guard) - 21 vitest tests passing
This commit is contained in:
parent
cbce70a50a
commit
fb423b4d66
2 changed files with 524 additions and 23 deletions
|
|
@ -1,46 +1,386 @@
|
|||
import { describe, it } from "vitest";
|
||||
import { and, desc, eq, isNull, lt } from "drizzle-orm";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { chatConversations, chatMessages } from "@paperclipai/db";
|
||||
import { chatService } from "../services/chat.js";
|
||||
|
||||
/**
|
||||
* Build a query chain mock.
|
||||
* - `selectResult`: what `db.select()...limit()` resolves to (array)
|
||||
* - `returningResult`: what `db.insert/update()...returning()` resolves to (array)
|
||||
*/
|
||||
function makeSelectChain(rows: unknown[], opts: { terminalAtWhere?: boolean } = {}) {
|
||||
const chain: any = {};
|
||||
const methods = ["select", "from", "orderBy"];
|
||||
for (const m of methods) {
|
||||
chain[m] = vi.fn(() => chain);
|
||||
}
|
||||
chain.limit = vi.fn(() => Promise.resolve(rows));
|
||||
if (opts.terminalAtWhere) {
|
||||
// getConversation: select().from().where() — no orderBy/limit
|
||||
chain.where = vi.fn(() => Promise.resolve(rows));
|
||||
} else {
|
||||
// listConversations / listMessages: select().from().where().orderBy().limit()
|
||||
chain.where = vi.fn(() => chain);
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
function makeUpdateChain(rows: unknown[]) {
|
||||
const chain: any = {};
|
||||
chain.set = vi.fn(() => chain);
|
||||
chain.where = vi.fn(() => chain);
|
||||
chain.returning = vi.fn(() => Promise.resolve(rows));
|
||||
return chain;
|
||||
}
|
||||
|
||||
function makeInsertChain(rows: unknown[]) {
|
||||
const chain: any = {};
|
||||
chain.values = vi.fn(() => chain);
|
||||
chain.returning = vi.fn(() => Promise.resolve(rows));
|
||||
return chain;
|
||||
}
|
||||
|
||||
function makeDb() {
|
||||
return {
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("chatService", () => {
|
||||
let db: ReturnType<typeof makeDb>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
db = makeDb();
|
||||
});
|
||||
|
||||
describe("createConversation", () => {
|
||||
it.todo("creates a conversation row with companyId");
|
||||
it.todo("returns the created conversation with id and timestamps");
|
||||
it("inserts into chatConversations and returns the created row", async () => {
|
||||
const inserted = {
|
||||
id: "conv-1",
|
||||
companyId: "company-1",
|
||||
title: "Hello",
|
||||
agentId: null,
|
||||
pinnedAt: null,
|
||||
archivedAt: null,
|
||||
deletedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const chain = makeInsertChain([inserted]);
|
||||
db.insert.mockReturnValue(chain);
|
||||
|
||||
const svc = chatService(db as any);
|
||||
const result = await svc.createConversation("company-1", { title: "Hello" });
|
||||
|
||||
expect(db.insert).toHaveBeenCalledWith(chatConversations);
|
||||
expect(chain.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ companyId: "company-1", title: "Hello" }),
|
||||
);
|
||||
expect(result).toEqual(inserted);
|
||||
});
|
||||
|
||||
it("returns the created conversation with id and timestamps", async () => {
|
||||
const now = new Date();
|
||||
const inserted = {
|
||||
id: "conv-1",
|
||||
companyId: "company-1",
|
||||
title: null,
|
||||
agentId: null,
|
||||
pinnedAt: null,
|
||||
archivedAt: null,
|
||||
deletedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const chain = makeInsertChain([inserted]);
|
||||
db.insert.mockReturnValue(chain);
|
||||
|
||||
const svc = chatService(db as any);
|
||||
const result = await svc.createConversation("company-1", {});
|
||||
|
||||
expect(result.id).toBe("conv-1");
|
||||
expect(result.createdAt).toBe(now);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listConversations", () => {
|
||||
it.todo("returns conversations sorted by updatedAt DESC");
|
||||
it.todo("excludes soft-deleted conversations");
|
||||
it.todo("supports cursor-based pagination with hasMore");
|
||||
it.todo("limits results to max 100");
|
||||
it("returns conversations sorted by updatedAt DESC", async () => {
|
||||
const rows = [
|
||||
{ id: "conv-2", updatedAt: new Date("2024-01-02") },
|
||||
{ id: "conv-1", updatedAt: new Date("2024-01-01") },
|
||||
];
|
||||
|
||||
const chain = makeSelectChain(rows);
|
||||
db.select.mockReturnValue(chain);
|
||||
|
||||
const svc = chatService(db as any);
|
||||
await svc.listConversations("company-1", {});
|
||||
|
||||
expect(db.select).toHaveBeenCalled();
|
||||
expect(chain.orderBy).toHaveBeenCalledWith(desc(chatConversations.updatedAt));
|
||||
});
|
||||
|
||||
it("excludes soft-deleted conversations", async () => {
|
||||
const chain = makeSelectChain([]);
|
||||
db.select.mockReturnValue(chain);
|
||||
|
||||
const svc = chatService(db as any);
|
||||
await svc.listConversations("company-1", {});
|
||||
|
||||
// where should be called with a combined condition that includes isNull(deletedAt)
|
||||
expect(chain.where).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("supports cursor-based pagination with hasMore", async () => {
|
||||
// Return limit+1 rows to trigger hasMore=true
|
||||
const rows = Array.from({ length: 31 }, (_, i) => ({
|
||||
id: `conv-${i}`,
|
||||
updatedAt: new Date(2024, 0, 31 - i),
|
||||
}));
|
||||
|
||||
const chain = makeSelectChain(rows);
|
||||
db.select.mockReturnValue(chain);
|
||||
|
||||
const svc = chatService(db as any);
|
||||
const result = await svc.listConversations("company-1", { limit: 30 });
|
||||
|
||||
expect(result.hasMore).toBe(true);
|
||||
expect(result.items.length).toBe(30);
|
||||
});
|
||||
|
||||
it("limits results to max 100", async () => {
|
||||
const chain = makeSelectChain([]);
|
||||
db.select.mockReturnValue(chain);
|
||||
|
||||
const svc = chatService(db as any);
|
||||
await svc.listConversations("company-1", { limit: 9999 });
|
||||
|
||||
// limit should be called with 101 (100 + 1 for hasMore detection)
|
||||
expect(chain.limit).toHaveBeenCalledWith(101);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConversation", () => {
|
||||
it.todo("returns conversation by id");
|
||||
it.todo("throws notFound for non-existent conversation");
|
||||
it.todo("throws notFound for soft-deleted conversation");
|
||||
it("returns conversation by id", async () => {
|
||||
const conv = { id: "conv-1", companyId: "company-1", deletedAt: null };
|
||||
const chain = makeSelectChain([conv], { terminalAtWhere: true });
|
||||
db.select.mockReturnValue(chain);
|
||||
|
||||
const svc = chatService(db as any);
|
||||
const result = await svc.getConversation("conv-1");
|
||||
|
||||
expect(result).toEqual(conv);
|
||||
});
|
||||
|
||||
it("throws notFound for non-existent conversation", async () => {
|
||||
const chain = makeSelectChain([], { terminalAtWhere: true });
|
||||
db.select.mockReturnValue(chain);
|
||||
|
||||
const svc = chatService(db as any);
|
||||
await expect(svc.getConversation("no-such-id")).rejects.toMatchObject({
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws notFound for soft-deleted conversation", async () => {
|
||||
// When deletedAt is set, the WHERE clause won't match — returns empty
|
||||
const chain = makeSelectChain([], { terminalAtWhere: true });
|
||||
db.select.mockReturnValue(chain);
|
||||
|
||||
const svc = chatService(db as any);
|
||||
await expect(svc.getConversation("deleted-id")).rejects.toMatchObject({
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateConversation", () => {
|
||||
it.todo("updates title");
|
||||
it.todo("sets pinnedAt timestamp");
|
||||
it.todo("clears pinnedAt when set to null");
|
||||
it.todo("sets archivedAt timestamp");
|
||||
it.todo("bumps updatedAt on every update");
|
||||
it("updates title and bumps updatedAt", async () => {
|
||||
const updated = { id: "conv-1", title: "New Title", updatedAt: new Date() };
|
||||
const chain = makeUpdateChain([updated]);
|
||||
db.update.mockReturnValue(chain);
|
||||
|
||||
const svc = chatService(db as any);
|
||||
const result = await svc.updateConversation("conv-1", { title: "New Title" });
|
||||
|
||||
expect(db.update).toHaveBeenCalledWith(chatConversations);
|
||||
expect(chain.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "New Title", updatedAt: expect.any(Date) }),
|
||||
);
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
|
||||
it("sets pinnedAt timestamp when provided as string", async () => {
|
||||
const pinnedAt = "2024-06-01T00:00:00.000Z";
|
||||
const updated = { id: "conv-1", pinnedAt: new Date(pinnedAt) };
|
||||
const chain = makeUpdateChain([updated]);
|
||||
db.update.mockReturnValue(chain);
|
||||
|
||||
const svc = chatService(db as any);
|
||||
await svc.updateConversation("conv-1", { pinnedAt });
|
||||
|
||||
expect(chain.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pinnedAt: new Date(pinnedAt) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears pinnedAt when set to null", async () => {
|
||||
const updated = { id: "conv-1", pinnedAt: null };
|
||||
const chain = makeUpdateChain([updated]);
|
||||
db.update.mockReturnValue(chain);
|
||||
|
||||
const svc = chatService(db as any);
|
||||
await svc.updateConversation("conv-1", { pinnedAt: null });
|
||||
|
||||
expect(chain.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pinnedAt: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it("bumps updatedAt on every update", async () => {
|
||||
const before = new Date("2024-01-01");
|
||||
const updated = { id: "conv-1", updatedAt: new Date() };
|
||||
const chain = makeUpdateChain([updated]);
|
||||
db.update.mockReturnValue(chain);
|
||||
|
||||
const svc = chatService(db as any);
|
||||
await svc.updateConversation("conv-1", { title: "x" });
|
||||
|
||||
const setCall = chain.set.mock.calls[0][0];
|
||||
expect(setCall.updatedAt).toBeInstanceOf(Date);
|
||||
expect(setCall.updatedAt.getTime()).toBeGreaterThan(before.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("softDeleteConversation", () => {
|
||||
it.todo("sets deletedAt timestamp");
|
||||
it.todo("throws notFound if already deleted");
|
||||
it("sets deletedAt and returns the updated row", async () => {
|
||||
const deleted = { id: "conv-1", deletedAt: new Date() };
|
||||
const chain = makeUpdateChain([deleted]);
|
||||
db.update.mockReturnValue(chain);
|
||||
|
||||
const svc = chatService(db as any);
|
||||
const result = await svc.softDeleteConversation("conv-1");
|
||||
|
||||
expect(db.update).toHaveBeenCalledWith(chatConversations);
|
||||
expect(chain.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ deletedAt: expect.any(Date) }),
|
||||
);
|
||||
expect(result).toEqual(deleted);
|
||||
});
|
||||
|
||||
it("throws notFound if conversation is already deleted (no row returned)", async () => {
|
||||
const chain = makeUpdateChain([]);
|
||||
db.update.mockReturnValue(chain);
|
||||
|
||||
const svc = chatService(db as any);
|
||||
await expect(svc.softDeleteConversation("gone-id")).rejects.toMatchObject({
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("addMessage", () => {
|
||||
it.todo("inserts a message row with conversationId and role");
|
||||
it.todo("bumps conversation updatedAt after insert");
|
||||
it.todo("auto-sets title from first user message when title is null");
|
||||
it.todo("does not overwrite existing title on subsequent messages");
|
||||
it("inserts a message row with conversationId and role", async () => {
|
||||
const message = { id: "msg-1", conversationId: "conv-1", role: "user", content: "Hi" };
|
||||
const insertChain = makeInsertChain([message]);
|
||||
const updateChain = makeUpdateChain([]);
|
||||
|
||||
db.insert.mockReturnValue(insertChain);
|
||||
db.update.mockReturnValue(updateChain);
|
||||
|
||||
const svc = chatService(db as any);
|
||||
const result = await svc.addMessage("conv-1", { role: "user", content: "Hi" });
|
||||
|
||||
expect(db.insert).toHaveBeenCalledWith(chatMessages);
|
||||
expect(insertChain.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ conversationId: "conv-1", role: "user", content: "Hi" }),
|
||||
);
|
||||
expect(result).toEqual(message);
|
||||
});
|
||||
|
||||
it("bumps conversation updatedAt after insert (Pitfall 3)", async () => {
|
||||
const message = { id: "msg-1", conversationId: "conv-1", role: "user", content: "Hi" };
|
||||
const insertChain = makeInsertChain([message]);
|
||||
const updateChain = makeUpdateChain([]);
|
||||
|
||||
db.insert.mockReturnValue(insertChain);
|
||||
db.update.mockReturnValue(updateChain);
|
||||
|
||||
const svc = chatService(db as any);
|
||||
await svc.addMessage("conv-1", { role: "user", content: "Hi" });
|
||||
|
||||
expect(db.update).toHaveBeenCalledWith(chatConversations);
|
||||
});
|
||||
|
||||
it("auto-sets title from first user message when title is null (Pitfall 5)", async () => {
|
||||
const content = "This is a long message that should be truncated to 60 chars when used as title";
|
||||
const message = { id: "msg-1", conversationId: "conv-1", role: "user", content };
|
||||
const insertChain = makeInsertChain([message]);
|
||||
const updateChain = makeUpdateChain([]);
|
||||
|
||||
db.insert.mockReturnValue(insertChain);
|
||||
db.update.mockReturnValue(updateChain);
|
||||
|
||||
const svc = chatService(db as any);
|
||||
await svc.addMessage("conv-1", { role: "user", content });
|
||||
|
||||
// Should be called twice: once for updatedAt bump, once for title auto-set
|
||||
expect(db.update).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not auto-set title for assistant/system messages", async () => {
|
||||
const content = "Response from assistant";
|
||||
const message = { id: "msg-1", conversationId: "conv-1", role: "assistant", content };
|
||||
const insertChain = makeInsertChain([message]);
|
||||
const updateChain = makeUpdateChain([]);
|
||||
|
||||
db.insert.mockReturnValue(insertChain);
|
||||
db.update.mockReturnValue(updateChain);
|
||||
|
||||
const svc = chatService(db as any);
|
||||
await svc.addMessage("conv-1", { role: "assistant", content });
|
||||
|
||||
// Only one update: updatedAt bump (no auto-title for non-user messages)
|
||||
expect(db.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listMessages", () => {
|
||||
it.todo("returns messages for conversation sorted by createdAt DESC");
|
||||
it.todo("supports cursor-based pagination");
|
||||
it("returns messages for conversation sorted by createdAt DESC", async () => {
|
||||
const rows = [
|
||||
{ id: "msg-2", createdAt: new Date("2024-01-02") },
|
||||
{ id: "msg-1", createdAt: new Date("2024-01-01") },
|
||||
];
|
||||
|
||||
const chain = makeSelectChain(rows);
|
||||
db.select.mockReturnValue(chain);
|
||||
|
||||
const svc = chatService(db as any);
|
||||
await svc.listMessages("conv-1", {});
|
||||
|
||||
expect(db.select).toHaveBeenCalled();
|
||||
expect(chain.orderBy).toHaveBeenCalledWith(desc(chatMessages.createdAt));
|
||||
});
|
||||
|
||||
it("supports cursor-based pagination", async () => {
|
||||
const rows = Array.from({ length: 51 }, (_, i) => ({
|
||||
id: `msg-${i}`,
|
||||
createdAt: new Date(2024, 0, 51 - i),
|
||||
}));
|
||||
|
||||
const chain = makeSelectChain(rows);
|
||||
db.select.mockReturnValue(chain);
|
||||
|
||||
const svc = chatService(db as any);
|
||||
const result = await svc.listMessages("conv-1", { limit: 50 });
|
||||
|
||||
expect(result.hasMore).toBe(true);
|
||||
expect(result.items.length).toBe(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
161
server/src/services/chat.ts
Normal file
161
server/src/services/chat.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { and, desc, eq, isNull, lt } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { chatConversations, chatMessages } from "@paperclipai/db";
|
||||
import { notFound } from "../errors.js";
|
||||
|
||||
export function chatService(db: Db) {
|
||||
return {
|
||||
async listConversations(
|
||||
companyId: string,
|
||||
opts: { cursor?: string; limit?: number; includeArchived?: boolean },
|
||||
) {
|
||||
const limit = Math.min(opts.limit ?? 30, 100);
|
||||
const includeArchived = opts.includeArchived ?? false;
|
||||
|
||||
const conditions = [
|
||||
eq(chatConversations.companyId, companyId),
|
||||
isNull(chatConversations.deletedAt),
|
||||
];
|
||||
|
||||
if (!includeArchived) {
|
||||
conditions.push(isNull(chatConversations.archivedAt));
|
||||
}
|
||||
|
||||
if (opts.cursor) {
|
||||
conditions.push(lt(chatConversations.updatedAt, new Date(opts.cursor)));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(chatConversations)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(chatConversations.updatedAt))
|
||||
.limit(limit + 1);
|
||||
|
||||
const hasMore = rows.length > limit;
|
||||
const items = hasMore ? rows.slice(0, limit) : rows;
|
||||
const nextCursor = hasMore ? items[items.length - 1]!.updatedAt.toISOString() : null;
|
||||
|
||||
return { items, hasMore, nextCursor };
|
||||
},
|
||||
|
||||
async createConversation(companyId: string, data: { title?: string; agentId?: string }) {
|
||||
const [row] = await db
|
||||
.insert(chatConversations)
|
||||
.values({
|
||||
companyId,
|
||||
title: data.title ?? null,
|
||||
agentId: data.agentId ?? null,
|
||||
})
|
||||
.returning();
|
||||
return row!;
|
||||
},
|
||||
|
||||
async getConversation(id: string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(chatConversations)
|
||||
.where(and(eq(chatConversations.id, id), isNull(chatConversations.deletedAt)));
|
||||
if (!row) throw notFound("Conversation not found");
|
||||
return row;
|
||||
},
|
||||
|
||||
async updateConversation(
|
||||
id: string,
|
||||
data: {
|
||||
title?: string;
|
||||
agentId?: string | null;
|
||||
pinnedAt?: string | null;
|
||||
archivedAt?: string | null;
|
||||
},
|
||||
) {
|
||||
const patch: Record<string, unknown> = { updatedAt: new Date() };
|
||||
|
||||
if (data.title !== undefined) patch.title = data.title;
|
||||
if (data.agentId !== undefined) patch.agentId = data.agentId;
|
||||
if ("pinnedAt" in data) {
|
||||
patch.pinnedAt = data.pinnedAt ? new Date(data.pinnedAt) : null;
|
||||
}
|
||||
if ("archivedAt" in data) {
|
||||
patch.archivedAt = data.archivedAt ? new Date(data.archivedAt) : null;
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.update(chatConversations)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.set(patch as any)
|
||||
.where(eq(chatConversations.id, id))
|
||||
.returning();
|
||||
|
||||
return row!;
|
||||
},
|
||||
|
||||
async softDeleteConversation(id: string) {
|
||||
const now = new Date();
|
||||
const [row] = await db
|
||||
.update(chatConversations)
|
||||
.set({ deletedAt: now, updatedAt: now })
|
||||
.where(and(eq(chatConversations.id, id), isNull(chatConversations.deletedAt)))
|
||||
.returning();
|
||||
if (!row) throw notFound("Conversation not found");
|
||||
return row;
|
||||
},
|
||||
|
||||
async listMessages(
|
||||
conversationId: string,
|
||||
opts: { cursor?: string; limit?: number },
|
||||
) {
|
||||
const limit = Math.min(opts.limit ?? 50, 200);
|
||||
|
||||
const conditions = [eq(chatMessages.conversationId, conversationId)];
|
||||
|
||||
if (opts.cursor) {
|
||||
conditions.push(lt(chatMessages.createdAt, new Date(opts.cursor)));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(chatMessages)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(chatMessages.createdAt))
|
||||
.limit(limit + 1);
|
||||
|
||||
const hasMore = rows.length > limit;
|
||||
const items = hasMore ? rows.slice(0, limit) : rows;
|
||||
const nextCursor = hasMore ? items[items.length - 1]!.createdAt.toISOString() : null;
|
||||
|
||||
return { items, hasMore, nextCursor };
|
||||
},
|
||||
|
||||
async addMessage(
|
||||
conversationId: string,
|
||||
data: { role: string; content: string; agentId?: string },
|
||||
) {
|
||||
const [message] = await db
|
||||
.insert(chatMessages)
|
||||
.values({
|
||||
conversationId,
|
||||
role: data.role,
|
||||
content: data.content,
|
||||
agentId: data.agentId ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Pitfall 3: Bump conversation updatedAt after inserting a message
|
||||
await db
|
||||
.update(chatConversations)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(chatConversations.id, conversationId));
|
||||
|
||||
// Pitfall 5: Auto-title from first user message (idempotent via IS NULL guard)
|
||||
if (data.role === "user") {
|
||||
await db
|
||||
.update(chatConversations)
|
||||
.set({ title: data.content.slice(0, 60), updatedAt: new Date() })
|
||||
.where(and(eq(chatConversations.id, conversationId), isNull(chatConversations.title)));
|
||||
}
|
||||
|
||||
return message!;
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue