Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
230
src/server/routers/notion-import.ts
Normal file
230
src/server/routers/notion-import.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
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
|
||||
await ctx.prisma.project.create({
|
||||
data: {
|
||||
roundId: round.id,
|
||||
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,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user