2026-01-30 13:41:32 +01:00
|
|
|
import { z } from 'zod'
|
|
|
|
|
import { TRPCError } from '@trpc/server'
|
|
|
|
|
import { Prisma } from '@prisma/client'
|
|
|
|
|
import { router, adminProcedure } from '../trpc'
|
|
|
|
|
import {
|
|
|
|
|
testNotionConnection,
|
|
|
|
|
getNotionDatabaseSchema,
|
|
|
|
|
queryNotionDatabase,
|
|
|
|
|
} from '@/lib/notion'
|
|
|
|
|
|
|
|
|
|
export const notionImportRouter = router({
|
|
|
|
|
/**
|
|
|
|
|
* Test connection to Notion API
|
|
|
|
|
*/
|
|
|
|
|
testConnection: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
apiKey: z.string().min(1),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ input }) => {
|
|
|
|
|
return testNotionConnection(input.apiKey)
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get database schema (properties) for mapping
|
|
|
|
|
*/
|
|
|
|
|
getDatabaseSchema: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
apiKey: z.string().min(1),
|
|
|
|
|
databaseId: z.string().min(1),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.query(async ({ input }) => {
|
|
|
|
|
try {
|
|
|
|
|
return await getNotionDatabaseSchema(input.apiKey, input.databaseId)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message:
|
|
|
|
|
error instanceof Error
|
|
|
|
|
? error.message
|
|
|
|
|
: 'Failed to fetch database schema',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Preview data from Notion database
|
|
|
|
|
*/
|
|
|
|
|
previewData: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
apiKey: z.string().min(1),
|
|
|
|
|
databaseId: z.string().min(1),
|
|
|
|
|
limit: z.number().int().min(1).max(10).default(5),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.query(async ({ input }) => {
|
|
|
|
|
try {
|
|
|
|
|
const records = await queryNotionDatabase(
|
|
|
|
|
input.apiKey,
|
|
|
|
|
input.databaseId,
|
|
|
|
|
input.limit
|
|
|
|
|
)
|
|
|
|
|
return { records, count: records.length }
|
|
|
|
|
} catch (error) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message:
|
|
|
|
|
error instanceof Error
|
|
|
|
|
? error.message
|
|
|
|
|
: 'Failed to fetch data from Notion',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Import projects from Notion database
|
|
|
|
|
*/
|
|
|
|
|
importProjects: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
apiKey: z.string().min(1),
|
|
|
|
|
databaseId: z.string().min(1),
|
|
|
|
|
roundId: z.string(),
|
|
|
|
|
// Column mappings: Notion property name -> Project field
|
|
|
|
|
mappings: z.object({
|
|
|
|
|
title: z.string(), // Required
|
|
|
|
|
teamName: z.string().optional(),
|
|
|
|
|
description: z.string().optional(),
|
|
|
|
|
tags: z.string().optional(), // Multi-select property
|
|
|
|
|
}),
|
|
|
|
|
// Store unmapped columns in metadataJson
|
|
|
|
|
includeUnmappedInMetadata: z.boolean().default(true),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Verify round exists
|
|
|
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.roundId },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Fetch all records from Notion
|
|
|
|
|
const records = await queryNotionDatabase(input.apiKey, input.databaseId)
|
|
|
|
|
|
|
|
|
|
if (records.length === 0) {
|
|
|
|
|
return { imported: 0, skipped: 0, errors: [] }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const results = {
|
|
|
|
|
imported: 0,
|
|
|
|
|
skipped: 0,
|
|
|
|
|
errors: [] as Array<{ recordId: string; error: string }>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Process each record
|
|
|
|
|
for (const record of records) {
|
|
|
|
|
try {
|
|
|
|
|
// Get mapped values
|
|
|
|
|
const title = getPropertyValue(record.properties, input.mappings.title)
|
|
|
|
|
|
|
|
|
|
if (!title || typeof title !== 'string' || !title.trim()) {
|
|
|
|
|
results.errors.push({
|
|
|
|
|
recordId: record.id,
|
|
|
|
|
error: 'Missing or invalid title',
|
|
|
|
|
})
|
|
|
|
|
results.skipped++
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const teamName = input.mappings.teamName
|
|
|
|
|
? getPropertyValue(record.properties, input.mappings.teamName)
|
|
|
|
|
: null
|
|
|
|
|
|
|
|
|
|
const description = input.mappings.description
|
|
|
|
|
? getPropertyValue(record.properties, input.mappings.description)
|
|
|
|
|
: null
|
|
|
|
|
|
|
|
|
|
let tags: string[] = []
|
|
|
|
|
if (input.mappings.tags) {
|
|
|
|
|
const tagsValue = getPropertyValue(record.properties, input.mappings.tags)
|
|
|
|
|
if (Array.isArray(tagsValue)) {
|
|
|
|
|
tags = tagsValue.filter((t): t is string => typeof t === 'string')
|
|
|
|
|
} else if (typeof tagsValue === 'string') {
|
|
|
|
|
tags = tagsValue.split(',').map((t) => t.trim()).filter(Boolean)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build metadata from unmapped columns
|
|
|
|
|
let metadataJson: Record<string, unknown> | null = null
|
|
|
|
|
if (input.includeUnmappedInMetadata) {
|
|
|
|
|
const mappedKeys = new Set([
|
|
|
|
|
input.mappings.title,
|
|
|
|
|
input.mappings.teamName,
|
|
|
|
|
input.mappings.description,
|
|
|
|
|
input.mappings.tags,
|
|
|
|
|
].filter(Boolean))
|
|
|
|
|
|
|
|
|
|
metadataJson = {}
|
|
|
|
|
for (const [key, value] of Object.entries(record.properties)) {
|
|
|
|
|
if (!mappedKeys.has(key) && value !== null && value !== undefined) {
|
|
|
|
|
metadataJson[key] = value
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Object.keys(metadataJson).length === 0) {
|
|
|
|
|
metadataJson = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create project
|
2026-02-04 14:15:06 +01:00
|
|
|
await ctx.prisma.project.create({
|
2026-01-30 13:41:32 +01:00
|
|
|
data: {
|
2026-02-04 14:15:06 +01:00
|
|
|
roundId: round.id,
|
|
|
|
|
status: 'SUBMITTED',
|
2026-01-30 13:41:32 +01:00
|
|
|
title: title.trim(),
|
|
|
|
|
teamName: typeof teamName === 'string' ? teamName.trim() : null,
|
|
|
|
|
description: typeof description === 'string' ? description : null,
|
|
|
|
|
tags,
|
|
|
|
|
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
|
|
|
|
externalIdsJson: {
|
|
|
|
|
notionPageId: record.id,
|
|
|
|
|
notionDatabaseId: input.databaseId,
|
|
|
|
|
} as Prisma.InputJsonValue,
|
2026-02-02 22:33:55 +01:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
results.imported++
|
|
|
|
|
} catch (error) {
|
|
|
|
|
results.errors.push({
|
|
|
|
|
recordId: record.id,
|
|
|
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
|
|
|
})
|
|
|
|
|
results.skipped++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Audit log
|
|
|
|
|
await ctx.prisma.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'IMPORT',
|
|
|
|
|
entityType: 'Project',
|
|
|
|
|
detailsJson: {
|
|
|
|
|
source: 'notion',
|
|
|
|
|
databaseId: input.databaseId,
|
|
|
|
|
roundId: input.roundId,
|
|
|
|
|
imported: results.imported,
|
|
|
|
|
skipped: results.skipped,
|
|
|
|
|
},
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return results
|
|
|
|
|
}),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Helper to get a property value from a record
|
|
|
|
|
*/
|
|
|
|
|
function getPropertyValue(
|
|
|
|
|
properties: Record<string, unknown>,
|
|
|
|
|
propertyName: string
|
|
|
|
|
): unknown {
|
|
|
|
|
return properties[propertyName] ?? null
|
|
|
|
|
}
|