Files
MOPC-Portal/src/server/routers/notion-import.ts
Matt e7c86a7b1b Add dynamic apply wizard customization with admin settings UI
- Create wizard config types, utilities, and defaults (wizard-config.ts)
- Add admin apply settings page with drag-and-drop step ordering, dropdown
  option management, feature toggles, welcome message customization, and
  custom field builder with select/multiselect options editor
- Build dynamic apply wizard component with animated step transitions,
  mobile-first responsive design, and config-driven form validation
- Update step components to accept dynamic config (categories, ocean issues,
  field visibility, feature flags)
- Replace hardcoded enum validation with string-based validation for
  admin-configurable dropdown values, with safe enum casting at storage layer
- Add wizard template system (model, router, admin UI) with built-in
  MOPC Classic preset
- Add program wizard config CRUD procedures to program router
- Update application router getConfig to return wizardConfig, submit handler
  to store custom field data in metadataJson
- Add edition-based apply page, project pool page, and supporting routers
- Fix CSS (invalid sm:fixed-none), Enter key handler (skip textarea),
  safe area insets for notched phones, buildStepsArray field visibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 13:21:26 +01:00

245 lines
7.1 KiB
TypeScript

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'
import { normalizeCountryToCode } from '@/lib/countries'
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
country: z.string().optional(), // Country name or ISO code
}),
// 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)
}
}
// Get country and normalize to ISO code
let country: string | null = null
if (input.mappings.country) {
const countryValue = getPropertyValue(record.properties, input.mappings.country)
if (typeof countryValue === 'string') {
country = normalizeCountryToCode(countryValue)
}
}
// 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,
input.mappings.country,
].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: {
programId: round.programId,
roundId: round.id,
status: 'SUBMITTED',
title: title.trim(),
teamName: typeof teamName === 'string' ? teamName.trim() : null,
description: typeof description === 'string' ? description : null,
tags,
country,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
externalIdsJson: {
notionPageId: record.id,
notionDatabaseId: input.databaseId,
} as Prisma.InputJsonValue,
},
})
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
}