Skip to content

Commit 9f2ff7e

Browse files
Merge pull request #883 from simstudioai/staging
v0.3.20: KB Tag fixes
2 parents aeef2b7 + be65bf7 commit 9f2ff7e

File tree

11 files changed

+6513
-122
lines changed

11 files changed

+6513
-122
lines changed

apps/sim/app/api/knowledge/[id]/documents/[documentId]/tag-definitions/route.ts

Lines changed: 186 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { and, eq, sql } from 'drizzle-orm'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { z } from 'zod'
55
import { getSession } from '@/lib/auth'
6-
import { MAX_TAG_SLOTS, TAG_SLOTS } from '@/lib/constants/knowledge'
6+
import {
7+
getMaxSlotsForFieldType,
8+
getSlotsForFieldType,
9+
SUPPORTED_FIELD_TYPES,
10+
} from '@/lib/constants/knowledge'
711
import { createLogger } from '@/lib/logs/console/logger'
812
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
913
import { db } from '@/db'
@@ -14,17 +18,60 @@ export const dynamic = 'force-dynamic'
1418
const logger = createLogger('DocumentTagDefinitionsAPI')
1519

1620
const TagDefinitionSchema = z.object({
17-
tagSlot: z.enum(TAG_SLOTS as [string, ...string[]]),
21+
tagSlot: z.string(), // Will be validated against field type slots
1822
displayName: z.string().min(1, 'Display name is required').max(100, 'Display name too long'),
19-
fieldType: z.string().default('text'), // Currently only 'text', future: 'date', 'number', 'range'
23+
fieldType: z.enum(SUPPORTED_FIELD_TYPES as [string, ...string[]]).default('text'),
24+
// Optional: for editing existing definitions
25+
_originalDisplayName: z.string().optional(),
2026
})
2127

2228
const BulkTagDefinitionsSchema = z.object({
23-
definitions: z
24-
.array(TagDefinitionSchema)
25-
.max(MAX_TAG_SLOTS, `Cannot define more than ${MAX_TAG_SLOTS} tags`),
29+
definitions: z.array(TagDefinitionSchema),
2630
})
2731

32+
// Helper function to get the next available slot for a knowledge base and field type
33+
async function getNextAvailableSlot(
34+
knowledgeBaseId: string,
35+
fieldType: string,
36+
existingBySlot?: Map<string, any>
37+
): Promise<string | null> {
38+
// Get available slots for this field type
39+
const availableSlots = getSlotsForFieldType(fieldType)
40+
let usedSlots: Set<string>
41+
42+
if (existingBySlot) {
43+
// Use provided map if available (for performance in batch operations)
44+
// Filter by field type
45+
usedSlots = new Set(
46+
Array.from(existingBySlot.entries())
47+
.filter(([_, def]) => def.fieldType === fieldType)
48+
.map(([slot, _]) => slot)
49+
)
50+
} else {
51+
// Query database for existing tag definitions of the same field type
52+
const existingDefinitions = await db
53+
.select({ tagSlot: knowledgeBaseTagDefinitions.tagSlot })
54+
.from(knowledgeBaseTagDefinitions)
55+
.where(
56+
and(
57+
eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId),
58+
eq(knowledgeBaseTagDefinitions.fieldType, fieldType)
59+
)
60+
)
61+
62+
usedSlots = new Set(existingDefinitions.map((def) => def.tagSlot))
63+
}
64+
65+
// Find the first available slot for this field type
66+
for (const slot of availableSlots) {
67+
if (!usedSlots.has(slot)) {
68+
return slot
69+
}
70+
}
71+
72+
return null // No available slots for this field type
73+
}
74+
2875
// Helper function to clean up unused tag definitions
2976
async function cleanupUnusedTagDefinitions(knowledgeBaseId: string, requestId: string) {
3077
try {
@@ -191,35 +238,93 @@ export async function POST(
191238

192239
const validatedData = BulkTagDefinitionsSchema.parse(body)
193240

194-
// Validate no duplicate tag slots
195-
const tagSlots = validatedData.definitions.map((def) => def.tagSlot)
196-
const uniqueTagSlots = new Set(tagSlots)
197-
if (tagSlots.length !== uniqueTagSlots.size) {
198-
return NextResponse.json({ error: 'Duplicate tag slots not allowed' }, { status: 400 })
241+
// Validate slots are valid for their field types
242+
for (const definition of validatedData.definitions) {
243+
const validSlots = getSlotsForFieldType(definition.fieldType)
244+
if (validSlots.length === 0) {
245+
return NextResponse.json(
246+
{ error: `Unsupported field type: ${definition.fieldType}` },
247+
{ status: 400 }
248+
)
249+
}
250+
251+
if (!validSlots.includes(definition.tagSlot)) {
252+
return NextResponse.json(
253+
{
254+
error: `Invalid slot '${definition.tagSlot}' for field type '${definition.fieldType}'. Valid slots: ${validSlots.join(', ')}`,
255+
},
256+
{ status: 400 }
257+
)
258+
}
259+
}
260+
261+
// Validate no duplicate tag slots within the same field type
262+
const slotsByFieldType = new Map<string, Set<string>>()
263+
for (const definition of validatedData.definitions) {
264+
if (!slotsByFieldType.has(definition.fieldType)) {
265+
slotsByFieldType.set(definition.fieldType, new Set())
266+
}
267+
const slotsForType = slotsByFieldType.get(definition.fieldType)!
268+
if (slotsForType.has(definition.tagSlot)) {
269+
return NextResponse.json(
270+
{
271+
error: `Duplicate slot '${definition.tagSlot}' for field type '${definition.fieldType}'`,
272+
},
273+
{ status: 400 }
274+
)
275+
}
276+
slotsForType.add(definition.tagSlot)
199277
}
200278

201279
const now = new Date()
202280
const createdDefinitions: (typeof knowledgeBaseTagDefinitions.$inferSelect)[] = []
203281

204-
// Get existing definitions count before transaction for cleanup check
282+
// Get existing definitions
205283
const existingDefinitions = await db
206284
.select()
207285
.from(knowledgeBaseTagDefinitions)
208286
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
209287

210-
// Check if we're trying to create more tag definitions than available slots
211-
const existingTagNames = new Set(existingDefinitions.map((def) => def.displayName))
212-
const trulyNewTags = validatedData.definitions.filter(
213-
(def) => !existingTagNames.has(def.displayName)
214-
)
288+
// Group by field type for validation
289+
const existingByFieldType = new Map<string, number>()
290+
for (const def of existingDefinitions) {
291+
existingByFieldType.set(def.fieldType, (existingByFieldType.get(def.fieldType) || 0) + 1)
292+
}
215293

216-
if (existingDefinitions.length + trulyNewTags.length > MAX_TAG_SLOTS) {
217-
return NextResponse.json(
218-
{
219-
error: `Cannot create ${trulyNewTags.length} new tags. Knowledge base already has ${existingDefinitions.length} tag definitions. Maximum is ${MAX_TAG_SLOTS} total.`,
220-
},
221-
{ status: 400 }
294+
// Validate we don't exceed limits per field type
295+
const newByFieldType = new Map<string, number>()
296+
for (const definition of validatedData.definitions) {
297+
// Skip validation for edit operations - they don't create new slots
298+
if (definition._originalDisplayName) {
299+
continue
300+
}
301+
302+
const existingTagNames = new Set(
303+
existingDefinitions
304+
.filter((def) => def.fieldType === definition.fieldType)
305+
.map((def) => def.displayName)
222306
)
307+
308+
if (!existingTagNames.has(definition.displayName)) {
309+
newByFieldType.set(
310+
definition.fieldType,
311+
(newByFieldType.get(definition.fieldType) || 0) + 1
312+
)
313+
}
314+
}
315+
316+
for (const [fieldType, newCount] of newByFieldType.entries()) {
317+
const existingCount = existingByFieldType.get(fieldType) || 0
318+
const maxSlots = getMaxSlotsForFieldType(fieldType)
319+
320+
if (existingCount + newCount > maxSlots) {
321+
return NextResponse.json(
322+
{
323+
error: `Cannot create ${newCount} new '${fieldType}' tags. Knowledge base already has ${existingCount} '${fieldType}' tag definitions. Maximum is ${maxSlots} per field type.`,
324+
},
325+
{ status: 400 }
326+
)
327+
}
223328
}
224329

225330
// Use transaction to ensure consistency
@@ -228,72 +333,93 @@ export async function POST(
228333
const existingByName = new Map(existingDefinitions.map((def) => [def.displayName, def]))
229334
const existingBySlot = new Map(existingDefinitions.map((def) => [def.tagSlot, def]))
230335

231-
// Process each new definition
336+
// Process each definition
232337
for (const definition of validatedData.definitions) {
338+
if (definition._originalDisplayName) {
339+
// This is an EDIT operation - find by original name and update
340+
const originalDefinition = existingByName.get(definition._originalDisplayName)
341+
342+
if (originalDefinition) {
343+
logger.info(
344+
`[${requestId}] Editing tag definition: ${definition._originalDisplayName} -> ${definition.displayName} (slot ${originalDefinition.tagSlot})`
345+
)
346+
347+
await tx
348+
.update(knowledgeBaseTagDefinitions)
349+
.set({
350+
displayName: definition.displayName,
351+
fieldType: definition.fieldType,
352+
updatedAt: now,
353+
})
354+
.where(eq(knowledgeBaseTagDefinitions.id, originalDefinition.id))
355+
356+
createdDefinitions.push({
357+
...originalDefinition,
358+
displayName: definition.displayName,
359+
fieldType: definition.fieldType,
360+
updatedAt: now,
361+
})
362+
continue
363+
}
364+
logger.warn(
365+
`[${requestId}] Could not find original definition for: ${definition._originalDisplayName}`
366+
)
367+
}
368+
369+
// Regular create/update logic
233370
const existingByDisplayName = existingByName.get(definition.displayName)
234-
const existingByTagSlot = existingBySlot.get(definition.tagSlot)
235371

236372
if (existingByDisplayName) {
237-
// Update existing definition (same display name)
238-
if (existingByDisplayName.tagSlot !== definition.tagSlot) {
239-
// Slot is changing - check if target slot is available
240-
if (existingByTagSlot && existingByTagSlot.id !== existingByDisplayName.id) {
241-
// Target slot is occupied by a different definition - this is a conflict
242-
// For now, keep the existing slot to avoid constraint violation
243-
logger.warn(
244-
`[${requestId}] Slot conflict for ${definition.displayName}: keeping existing slot ${existingByDisplayName.tagSlot}`
245-
)
246-
createdDefinitions.push(existingByDisplayName)
247-
continue
248-
}
249-
}
373+
// Display name exists - UPDATE operation
374+
logger.info(
375+
`[${requestId}] Updating existing tag definition: ${definition.displayName} (slot ${existingByDisplayName.tagSlot})`
376+
)
250377

251378
await tx
252379
.update(knowledgeBaseTagDefinitions)
253380
.set({
254-
tagSlot: definition.tagSlot,
255381
fieldType: definition.fieldType,
256382
updatedAt: now,
257383
})
258384
.where(eq(knowledgeBaseTagDefinitions.id, existingByDisplayName.id))
259385

260386
createdDefinitions.push({
261387
...existingByDisplayName,
262-
tagSlot: definition.tagSlot,
263-
fieldType: definition.fieldType,
264-
updatedAt: now,
265-
})
266-
} else if (existingByTagSlot) {
267-
// Slot is occupied by a different display name - update it
268-
await tx
269-
.update(knowledgeBaseTagDefinitions)
270-
.set({
271-
displayName: definition.displayName,
272-
fieldType: definition.fieldType,
273-
updatedAt: now,
274-
})
275-
.where(eq(knowledgeBaseTagDefinitions.id, existingByTagSlot.id))
276-
277-
createdDefinitions.push({
278-
...existingByTagSlot,
279-
displayName: definition.displayName,
280388
fieldType: definition.fieldType,
281389
updatedAt: now,
282390
})
283391
} else {
284-
// Create new definition
392+
// Display name doesn't exist - CREATE operation
393+
const targetSlot = await getNextAvailableSlot(
394+
knowledgeBaseId,
395+
definition.fieldType,
396+
existingBySlot
397+
)
398+
399+
if (!targetSlot) {
400+
logger.error(
401+
`[${requestId}] No available slots for new tag definition: ${definition.displayName}`
402+
)
403+
continue
404+
}
405+
406+
logger.info(
407+
`[${requestId}] Creating new tag definition: ${definition.displayName} -> ${targetSlot}`
408+
)
409+
285410
const newDefinition = {
286411
id: randomUUID(),
287412
knowledgeBaseId,
288-
tagSlot: definition.tagSlot,
413+
tagSlot: targetSlot as any,
289414
displayName: definition.displayName,
290415
fieldType: definition.fieldType,
291416
createdAt: now,
292417
updatedAt: now,
293418
}
294419

295420
await tx.insert(knowledgeBaseTagDefinitions).values(newDefinition)
296-
createdDefinitions.push(newDefinition)
421+
existingBySlot.set(targetSlot as any, newDefinition)
422+
createdDefinitions.push(newDefinition as any)
297423
}
298424
}
299425
})

0 commit comments

Comments
 (0)