mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-18 17:30:16 +01:00
🏪 feat: Agent Marketplace
bugfix: Enhance Agent and AgentCategory schemas with new fields for category, support contact, and promotion status refactored and moved agent category methods and schema to data-schema package
This commit is contained in:
parent
658480d7cd
commit
f59ef0ecb0
10 changed files with 291 additions and 100 deletions
|
|
@ -5,7 +5,6 @@ const { SystemRoles, Tools, actionDelimiter } = require('librechat-data-provider
|
||||||
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } =
|
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } =
|
||||||
require('librechat-data-provider').Constants;
|
require('librechat-data-provider').Constants;
|
||||||
// Default category value for new agents
|
// Default category value for new agents
|
||||||
const AgentCategory = require('./AgentCategory');
|
|
||||||
const {
|
const {
|
||||||
getProjectByName,
|
getProjectByName,
|
||||||
addAgentIdsToProject,
|
addAgentIdsToProject,
|
||||||
|
|
@ -15,80 +14,7 @@ const {
|
||||||
const { getCachedTools } = require('~/server/services/Config');
|
const { getCachedTools } = require('~/server/services/Config');
|
||||||
|
|
||||||
// Category values are now imported from shared constants
|
// Category values are now imported from shared constants
|
||||||
|
// Schema fields (category, support_contact, is_promoted) are defined in @librechat/data-schemas
|
||||||
// Add category field to the Agent schema if it doesn't already exist
|
|
||||||
if (!agentSchema.paths.category) {
|
|
||||||
agentSchema.add({
|
|
||||||
category: {
|
|
||||||
type: String,
|
|
||||||
trim: true,
|
|
||||||
validate: {
|
|
||||||
validator: async function (value) {
|
|
||||||
if (!value) return true; // Allow empty values (will use default)
|
|
||||||
|
|
||||||
// Check if category exists in database
|
|
||||||
const validCategories = await AgentCategory.getValidCategoryValues();
|
|
||||||
return validCategories.includes(value);
|
|
||||||
},
|
|
||||||
message: function (props) {
|
|
||||||
return `"${props.value}" is not a valid agent category. Please check available categories.`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
index: true,
|
|
||||||
default: 'general',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add support_contact field to the Agent schema if it doesn't already exist
|
|
||||||
if (!agentSchema.paths.support_contact) {
|
|
||||||
agentSchema.add({
|
|
||||||
support_contact: {
|
|
||||||
type: Object,
|
|
||||||
default: {},
|
|
||||||
name: {
|
|
||||||
type: String,
|
|
||||||
minlength: [3, 'Support contact name must be at least 3 characters.'],
|
|
||||||
trim: true,
|
|
||||||
},
|
|
||||||
email: {
|
|
||||||
type: String,
|
|
||||||
match: [
|
|
||||||
/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/,
|
|
||||||
'Please enter a valid email address.',
|
|
||||||
],
|
|
||||||
trim: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add promotion field to the Agent schema if it doesn't already exist
|
|
||||||
if (!agentSchema.paths.is_promoted) {
|
|
||||||
agentSchema.add({
|
|
||||||
is_promoted: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
index: true, // Index for efficient promoted agent queries
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add additional indexes for marketplace functionality
|
|
||||||
agentSchema.index({ projectIds: 1, is_promoted: 1, updatedAt: -1 }); // Optimize promoted agents query
|
|
||||||
agentSchema.index({ category: 1, projectIds: 1, updatedAt: -1 }); // Optimize category filtering
|
|
||||||
agentSchema.index({ projectIds: 1, category: 1 }); // Optimize aggregation pipeline
|
|
||||||
|
|
||||||
// Text indexes for search functionality
|
|
||||||
agentSchema.index(
|
|
||||||
{ name: 'text', description: 'text' },
|
|
||||||
{
|
|
||||||
weights: {
|
|
||||||
name: 3, // Name matches are 3x more important than description matches
|
|
||||||
description: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const { getActions } = require('./Action');
|
const { getActions } = require('./Action');
|
||||||
const { Agent } = require('~/db/models');
|
const { Agent } = require('~/db/models');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ const agentCategorySchema = new mongoose.Schema(
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timestamps: true,
|
timestamps: true,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Indexes for performance
|
// Indexes for performance
|
||||||
|
|
@ -57,9 +57,7 @@ agentCategorySchema.index({ isActive: 1, order: 1 });
|
||||||
* @returns {Promise<AgentCategory[]>} Array of active categories
|
* @returns {Promise<AgentCategory[]>} Array of active categories
|
||||||
*/
|
*/
|
||||||
agentCategorySchema.statics.getActiveCategories = function () {
|
agentCategorySchema.statics.getActiveCategories = function () {
|
||||||
return this.find({ isActive: true })
|
return this.find({ isActive: true }).sort({ order: 1, label: 1 }).lean();
|
||||||
.sort({ order: 1, label: 1 })
|
|
||||||
.lean();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -67,7 +65,7 @@ agentCategorySchema.statics.getActiveCategories = function() {
|
||||||
* @returns {Promise<AgentCategory[]>} Categories with agent counts
|
* @returns {Promise<AgentCategory[]>} Categories with agent counts
|
||||||
*/
|
*/
|
||||||
agentCategorySchema.statics.getCategoriesWithCounts = async function () {
|
agentCategorySchema.statics.getCategoriesWithCounts = async function () {
|
||||||
const Agent = mongoose.model('agent');
|
const Agent = mongoose.model('Agent');
|
||||||
|
|
||||||
// Aggregate to get agent counts per category
|
// Aggregate to get agent counts per category
|
||||||
const categoryCounts = await Agent.aggregate([
|
const categoryCounts = await Agent.aggregate([
|
||||||
|
|
@ -76,12 +74,12 @@ agentCategorySchema.statics.getCategoriesWithCounts = async function() {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Create a map for quick lookup
|
// Create a map for quick lookup
|
||||||
const countMap = new Map(categoryCounts.map(c => [c._id, c.count]));
|
const countMap = new Map(categoryCounts.map((c) => [c._id, c.count]));
|
||||||
|
|
||||||
// Get all active categories and add counts
|
// Get all active categories and add counts
|
||||||
const categories = await this.getActiveCategories();
|
const categories = await this.getActiveCategories();
|
||||||
|
|
||||||
return categories.map(category => ({
|
return categories.map((category) => ({
|
||||||
...category,
|
...category,
|
||||||
agentCount: countMap.get(category.value) || 0,
|
agentCount: countMap.get(category.value) || 0,
|
||||||
}));
|
}));
|
||||||
|
|
@ -92,9 +90,7 @@ agentCategorySchema.statics.getCategoriesWithCounts = async function() {
|
||||||
* @returns {Promise<string[]>} Array of valid category values
|
* @returns {Promise<string[]>} Array of valid category values
|
||||||
*/
|
*/
|
||||||
agentCategorySchema.statics.getValidCategoryValues = function () {
|
agentCategorySchema.statics.getValidCategoryValues = function () {
|
||||||
return this.find({ isActive: true })
|
return this.find({ isActive: true }).distinct('value').lean();
|
||||||
.distinct('value')
|
|
||||||
.lean();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ const mongoose = require('mongoose');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
// Get the Agent model
|
// Get the Agent model
|
||||||
const Agent = mongoose.model('agent');
|
const Agent = mongoose.model('Agent');
|
||||||
|
|
||||||
// Default page size for agent browsing
|
// Default page size for agent browsing
|
||||||
const DEFAULT_PAGE_SIZE = 6;
|
const DEFAULT_PAGE_SIZE = 6;
|
||||||
|
|
|
||||||
161
packages/data-schemas/src/methods/agentCategory.ts
Normal file
161
packages/data-schemas/src/methods/agentCategory.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
import type { Model, Types, DeleteResult } from 'mongoose';
|
||||||
|
import type { IAgentCategory, AgentCategory } from '../types/agentCategory';
|
||||||
|
|
||||||
|
export function createAgentCategoryMethods(mongoose: typeof import('mongoose')) {
|
||||||
|
/**
|
||||||
|
* Get all active categories sorted by order
|
||||||
|
* @returns Array of active categories
|
||||||
|
*/
|
||||||
|
async function getActiveCategories(): Promise<IAgentCategory[]> {
|
||||||
|
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
|
||||||
|
return await AgentCategory.find({ isActive: true }).sort({ order: 1, label: 1 }).lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get categories with agent counts
|
||||||
|
* @returns Categories with agent counts
|
||||||
|
*/
|
||||||
|
async function getCategoriesWithCounts(): Promise<(IAgentCategory & { agentCount: number })[]> {
|
||||||
|
const Agent = mongoose.models.Agent;
|
||||||
|
|
||||||
|
const categoryCounts = await Agent.aggregate([
|
||||||
|
{ $match: { category: { $exists: true, $ne: null } } },
|
||||||
|
{ $group: { _id: '$category', count: { $sum: 1 } } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const countMap = new Map(categoryCounts.map((c) => [c._id, c.count]));
|
||||||
|
const categories = await getActiveCategories();
|
||||||
|
|
||||||
|
return categories.map((category) => ({
|
||||||
|
...category,
|
||||||
|
agentCount: countMap.get(category.value) || (0 as number),
|
||||||
|
})) as (IAgentCategory & { agentCount: number })[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get valid category values for Agent model validation
|
||||||
|
* @returns Array of valid category values
|
||||||
|
*/
|
||||||
|
async function getValidCategoryValues(): Promise<string[]> {
|
||||||
|
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
|
||||||
|
return await AgentCategory.find({ isActive: true }).distinct('value').lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed initial categories from existing constants
|
||||||
|
* @param categories - Array of category data to seed
|
||||||
|
* @returns Bulk write result
|
||||||
|
*/
|
||||||
|
async function seedCategories(
|
||||||
|
categories: Array<{
|
||||||
|
value: string;
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
order?: number;
|
||||||
|
}>,
|
||||||
|
): Promise<any> {
|
||||||
|
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
|
||||||
|
|
||||||
|
const operations = categories.map((category, index) => ({
|
||||||
|
updateOne: {
|
||||||
|
filter: { value: category.value },
|
||||||
|
update: {
|
||||||
|
$setOnInsert: {
|
||||||
|
value: category.value,
|
||||||
|
label: category.label || category.value,
|
||||||
|
description: category.description || '',
|
||||||
|
order: category.order || index,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
upsert: true,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return await AgentCategory.bulkWrite(operations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a category by value
|
||||||
|
* @param value - The category value to search for
|
||||||
|
* @returns The category document or null
|
||||||
|
*/
|
||||||
|
async function findCategoryByValue(value: string): Promise<IAgentCategory | null> {
|
||||||
|
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
|
||||||
|
return await AgentCategory.findOne({ value }).lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new category
|
||||||
|
* @param categoryData - The category data to create
|
||||||
|
* @returns The created category
|
||||||
|
*/
|
||||||
|
async function createCategory(categoryData: Partial<IAgentCategory>): Promise<IAgentCategory> {
|
||||||
|
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
|
||||||
|
const category = await AgentCategory.create(categoryData);
|
||||||
|
return category.toObject() as IAgentCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a category by value
|
||||||
|
* @param value - The category value to update
|
||||||
|
* @param updateData - The data to update
|
||||||
|
* @returns The updated category or null
|
||||||
|
*/
|
||||||
|
async function updateCategory(
|
||||||
|
value: string,
|
||||||
|
updateData: Partial<IAgentCategory>,
|
||||||
|
): Promise<IAgentCategory | null> {
|
||||||
|
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
|
||||||
|
return await AgentCategory.findOneAndUpdate(
|
||||||
|
{ value },
|
||||||
|
{ $set: updateData },
|
||||||
|
{ new: true, runValidators: true },
|
||||||
|
).lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a category by value
|
||||||
|
* @param value - The category value to delete
|
||||||
|
* @returns Whether the deletion was successful
|
||||||
|
*/
|
||||||
|
async function deleteCategory(value: string): Promise<boolean> {
|
||||||
|
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
|
||||||
|
const result = await AgentCategory.deleteOne({ value });
|
||||||
|
return result.deletedCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a category by ID
|
||||||
|
* @param id - The category ID to search for
|
||||||
|
* @returns The category document or null
|
||||||
|
*/
|
||||||
|
async function findCategoryById(id: string | Types.ObjectId): Promise<IAgentCategory | null> {
|
||||||
|
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
|
||||||
|
return await AgentCategory.findById(id).lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all categories (active and inactive)
|
||||||
|
* @returns Array of all categories
|
||||||
|
*/
|
||||||
|
async function getAllCategories(): Promise<IAgentCategory[]> {
|
||||||
|
const AgentCategory = mongoose.models.AgentCategory as Model<IAgentCategory>;
|
||||||
|
return await AgentCategory.find({}).sort({ order: 1, label: 1 }).lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getActiveCategories,
|
||||||
|
getCategoriesWithCounts,
|
||||||
|
getValidCategoryValues,
|
||||||
|
seedCategories,
|
||||||
|
findCategoryByValue,
|
||||||
|
createCategory,
|
||||||
|
updateCategory,
|
||||||
|
deleteCategory,
|
||||||
|
findCategoryById,
|
||||||
|
getAllCategories,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AgentCategoryMethods = ReturnType<typeof createAgentCategoryMethods>;
|
||||||
|
|
@ -4,6 +4,8 @@ import { createTokenMethods, type TokenMethods } from './token';
|
||||||
import { createRoleMethods, type RoleMethods } from './role';
|
import { createRoleMethods, type RoleMethods } from './role';
|
||||||
/* Memories */
|
/* Memories */
|
||||||
import { createMemoryMethods, type MemoryMethods } from './memory';
|
import { createMemoryMethods, type MemoryMethods } from './memory';
|
||||||
|
/* Agent Categories */
|
||||||
|
import { createAgentCategoryMethods, type AgentCategoryMethods } from './agentCategory';
|
||||||
/* Permissions */
|
/* Permissions */
|
||||||
import { createAccessRoleMethods, type AccessRoleMethods } from './accessRole';
|
import { createAccessRoleMethods, type AccessRoleMethods } from './accessRole';
|
||||||
import { createUserGroupMethods, type UserGroupMethods } from './userGroup';
|
import { createUserGroupMethods, type UserGroupMethods } from './userGroup';
|
||||||
|
|
@ -22,6 +24,7 @@ export function createMethods(mongoose: typeof import('mongoose')) {
|
||||||
...createTokenMethods(mongoose),
|
...createTokenMethods(mongoose),
|
||||||
...createRoleMethods(mongoose),
|
...createRoleMethods(mongoose),
|
||||||
...createMemoryMethods(mongoose),
|
...createMemoryMethods(mongoose),
|
||||||
|
...createAgentCategoryMethods(mongoose),
|
||||||
...createAccessRoleMethods(mongoose),
|
...createAccessRoleMethods(mongoose),
|
||||||
...createUserGroupMethods(mongoose),
|
...createUserGroupMethods(mongoose),
|
||||||
...createAclEntryMethods(mongoose),
|
...createAclEntryMethods(mongoose),
|
||||||
|
|
@ -37,6 +40,7 @@ export type AllMethods = UserMethods &
|
||||||
TokenMethods &
|
TokenMethods &
|
||||||
RoleMethods &
|
RoleMethods &
|
||||||
MemoryMethods &
|
MemoryMethods &
|
||||||
|
AgentCategoryMethods &
|
||||||
AccessRoleMethods &
|
AccessRoleMethods &
|
||||||
UserGroupMethods &
|
UserGroupMethods &
|
||||||
AclEntryMethods &
|
AclEntryMethods &
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,35 @@ const agentSchema = new Schema<IAgent>(
|
||||||
type: [Schema.Types.Mixed],
|
type: [Schema.Types.Mixed],
|
||||||
default: [],
|
default: [],
|
||||||
},
|
},
|
||||||
|
category: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
index: true,
|
||||||
|
default: 'general',
|
||||||
|
},
|
||||||
|
support_contact: {
|
||||||
|
type: {
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
minlength: [3, 'Support contact name must be at least 3 characters.'],
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: String,
|
||||||
|
match: [
|
||||||
|
/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/,
|
||||||
|
'Please enter a valid email address.',
|
||||||
|
],
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: {},
|
||||||
|
},
|
||||||
|
is_promoted: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timestamps: true,
|
timestamps: true,
|
||||||
|
|
|
||||||
49
packages/data-schemas/src/schema/agentCategory.ts
Normal file
49
packages/data-schemas/src/schema/agentCategory.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { Schema, Document } from 'mongoose';
|
||||||
|
|
||||||
|
export interface IAgentCategory extends Document {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
order: number;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentCategorySchema = new Schema<IAgentCategory>(
|
||||||
|
{
|
||||||
|
value: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
trim: true,
|
||||||
|
lowercase: true,
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
isActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
agentCategorySchema.index({ isActive: 1, order: 1 });
|
||||||
|
|
||||||
|
export default agentCategorySchema;
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export { default as actionSchema } from './action';
|
export { default as actionSchema } from './action';
|
||||||
export { default as agentSchema } from './agent';
|
export { default as agentSchema } from './agent';
|
||||||
|
export { default as agentCategorySchema } from './agentCategory';
|
||||||
export { default as assistantSchema } from './assistant';
|
export { default as assistantSchema } from './assistant';
|
||||||
export { default as balanceSchema } from './balance';
|
export { default as balanceSchema } from './balance';
|
||||||
export { default as bannerSchema } from './banner';
|
export { default as bannerSchema } from './banner';
|
||||||
|
|
|
||||||
|
|
@ -36,4 +36,10 @@ export interface IAgent extends Omit<Document, 'model'> {
|
||||||
versions?: Omit<IAgent, 'versions'>[];
|
versions?: Omit<IAgent, 'versions'>[];
|
||||||
category: string;
|
category: string;
|
||||||
support_contact?: ISupportContact;
|
support_contact?: ISupportContact;
|
||||||
|
category: string;
|
||||||
|
support_contact?: {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
is_promoted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
packages/data-schemas/src/types/agentCategory.ts
Normal file
19
packages/data-schemas/src/types/agentCategory.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { Document, Types } from 'mongoose';
|
||||||
|
|
||||||
|
export type AgentCategory = {
|
||||||
|
/** Unique identifier for the category (e.g., 'general', 'hr', 'finance') */
|
||||||
|
value: string;
|
||||||
|
/** Display label for the category */
|
||||||
|
label: string;
|
||||||
|
/** Description of the category */
|
||||||
|
description?: string;
|
||||||
|
/** Display order for sorting categories */
|
||||||
|
order: number;
|
||||||
|
/** Whether the category is active and should be displayed */
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IAgentCategory = AgentCategory &
|
||||||
|
Document & {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue