diff --git a/server/api/session/[id]/index.get.ts b/server/api/session/[id]/index.get.ts index d6bca47..dc5a603 100644 --- a/server/api/session/[id]/index.get.ts +++ b/server/api/session/[id]/index.get.ts @@ -14,6 +14,13 @@ export default defineEventHandler(async (event) => { }) } + if (submission.slots[0] === undefined) { + throw createError({ + statusCode: 404, + statusMessage: 'Slot not found', + }) + } + if (submission.state !== 'confirmed') { throw createError({ statusCode: 404, @@ -28,9 +35,9 @@ export default defineEventHandler(async (event) => { return { id: submission.code, - room: slot.room?.name, - start: slot.start, - end: slot.end, + room: slot?.room?.name, + start: slot?.start, + end: slot?.end, language: answers.language, speakers, zh: { diff --git a/server/api/session/index.get.ts b/server/api/session/index.get.ts index 6778ca9..6008364 100644 --- a/server/api/session/index.get.ts +++ b/server/api/session/index.get.ts @@ -1,4 +1,4 @@ -import type { Submission } from '~~/server/utils/pretalx/type' +import type { Submission } from '#shared/types/pretalx' import pretalxData from '~~/server/utils/pretalx' import { parseAnswer, parseSlot, parseSpeaker, parseType } from '~~/server/utils/pretalx/parser' @@ -10,16 +10,20 @@ export default defineEventHandler(async () => { return submissions .filter((submission: Submission) => submission.state === 'confirmed') .map((submission: Submission) => { + if (!submission.slots[0]) { + return null + } + const answers = parseAnswer(submission.answers, data) - const slot = parseSlot(submission.slots[0]!, data) + const slot = parseSlot(submission.slots[0], data) const speakers = parseSpeaker(submission.speakers, data) const type = parseType(submission.submission_type, data) return { id: submission.code, - room: slot.room?.name, - start: slot.start, - end: slot.end, + room: slot?.room?.name, + start: slot?.start, + end: slot?.end, language: answers.language, speakers, zh: { @@ -36,4 +40,5 @@ export default defineEventHandler(async () => { uri: `https://coscup.org/2026/session/${submission.code}`, } }) + .filter(Boolean) }) diff --git a/server/utils/opass/pretalxToOpass.ts b/server/utils/opass/pretalxToOpass.ts index 6b9a7da..e65616b 100644 --- a/server/utils/opass/pretalxToOpass.ts +++ b/server/utils/opass/pretalxToOpass.ts @@ -1,4 +1,4 @@ -import type { PretalxResult, Room, Speaker, Submission, SubmissionType } from '~~/server/utils/pretalx/type' +import type { PretalxResult, Room, Speaker, Submission, SubmissionType } from '#shared/types/pretalx' import { parseAnswer, parseSlot } from '~~/server/utils/pretalx/parser' export function pretalxToOpass(pretalxData: PretalxResult) { @@ -13,15 +13,19 @@ export function pretalxToOpass(pretalxData: PretalxResult) { const slot = parseSlot(submission.slots[0]!, pretalxData) submission.speakers.forEach((id) => speakerIds.add(id)) - roomIds.add(slot.room?.id) + typeIds.add(submission.submission_type) + if (slot?.room?.id) { + roomIds.add(slot.room.id) + } + return { id: submission.code, type: submission.submission_type, - room: slot.room?.id, - start: slot.start, - end: slot.end, + room: slot?.room?.id, + start: slot?.start, + end: slot?.end, language: answer.language, speakers: submission.speakers, zh: { @@ -43,6 +47,12 @@ export function pretalxToOpass(pretalxData: PretalxResult) { const speakers = Array.from(speakerIds, (id: Speaker['code']) => { const speaker = pretalxData.speakers.map[id] + + if (!speaker) { + console.error(`Speaker with code ${id} not found in pretalx data.`) + return null + } + const answer = parseAnswer(speaker.answers, pretalxData) return { @@ -58,9 +68,16 @@ export function pretalxToOpass(pretalxData: PretalxResult) { }, } }) + .filter(Boolean) const types = Array.from(typeIds, (id: SubmissionType['id']) => { const type = pretalxData['submission-types'].map[id] + + if (!type) { + console.error(`Submission type with id ${id} not found in pretalx data.`) + return null + } + return { id: type.id, zh: { @@ -71,11 +88,18 @@ export function pretalxToOpass(pretalxData: PretalxResult) { }, } }) + .filter(Boolean) const rooms = [...roomIds] .filter(Boolean) .map((id: Room['id']) => { const room = pretalxData.rooms.map[id] + + if (!room) { + console.error(`Room with id ${id} not found in pretalx data.`) + return null + } + return { id: room.id, zh: { @@ -86,6 +110,7 @@ export function pretalxToOpass(pretalxData: PretalxResult) { }, } }) + .filter(Boolean) // TODO: tags diff --git a/server/utils/pretalx/fetch.ts b/server/utils/pretalx/fetch.ts new file mode 100644 index 0000000..b41d2af --- /dev/null +++ b/server/utils/pretalx/fetch.ts @@ -0,0 +1,63 @@ +import type { PretalxData, PretalxResponse, PretalxTable, TableTypeMap } from '#shared/types/pretalx' +import { PRETALX_TABLE_SCHEMAS } from '#shared/types/pretalx' +import { z } from 'zod' + +function parsePretalxResponse( + table: T, + input: unknown, +): PretalxResponse { + return z.object({ + count: z.number(), + next: z.string().nullable(), + previous: z.string().nullable(), + results: z.array(PRETALX_TABLE_SCHEMAS[table]), + }).parse(input) as PretalxResponse +} + +function getPretalxItemKey(item: TableTypeMap[T]): string | number { + if ('code' in item) { + return item.code + } + + return item.id +} + +export async function fetchPretalxTable( + table: T, +): Promise> { + const { pretalxApiUrl, pretalxApiToken } = useRuntimeConfig() + + if (!pretalxApiUrl || !pretalxApiToken) { + throw createError({ + statusCode: 500, + statusMessage: 'Missing NUXT_PRETALX_API_URL or NUXT_PRETALX_API_TOKEN environment variable', + }) + } + + const data: PretalxData = { arr: [], map: {} } + let url: string | null = table + + while (url) { + const response: PretalxResponse = parsePretalxResponse( + table, + await $fetch( + url, + { + baseURL: pretalxApiUrl, + headers: { + Authorization: `Token ${pretalxApiToken}`, + }, + }, + ), + ) + + data.arr.push(...response.results) + Object.assign( + data.map, + Object.fromEntries(response.results.map((item) => [getPretalxItemKey(item), item])), + ) + url = response.next + } + + return data +} diff --git a/server/utils/pretalx/index.ts b/server/utils/pretalx/index.ts index 3c1561a..072cb24 100644 --- a/server/utils/pretalx/index.ts +++ b/server/utils/pretalx/index.ts @@ -1,4 +1,6 @@ -import type { PretalxData, PretalxResponse, PretalxResult } from './type' +import type { PretalxResult } from '#shared/types/pretalx' + +import { fetchPretalxTable } from './fetch' export default defineCachedFunction( async () => { @@ -10,31 +12,23 @@ export default defineCachedFunction( }) } - const tables = ['submissions', 'submission-types', 'speakers', 'rooms', 'answers', 'slots'] as const - const results: Partial = {} - - for (const table of tables) { - let url: string = table - results[table] = { arr: [], map: {} } satisfies PretalxData - - while (url) { - const response = await $fetch>( - url, - { - baseURL: pretalxApiUrl, - headers: { - Authorization: `Token ${pretalxApiToken}`, - }, - }, - ) - - results[table].arr.push(...response.results) - Object.assign(results[table].map, Object.fromEntries(response.results.map((item: any) => [item.id || item.code, item]))) - url = response.next - } - } + const [submissions, submissionTypes, speakers, rooms, answers, slots] = await Promise.all([ + fetchPretalxTable('submissions'), + fetchPretalxTable('submission-types'), + fetchPretalxTable('speakers'), + fetchPretalxTable('rooms'), + fetchPretalxTable('answers'), + fetchPretalxTable('slots'), + ]) - return results as PretalxResult + return { + submissions, + speakers, + rooms, + answers, + slots, + 'submission-types': submissionTypes, + } satisfies PretalxResult }, { maxAge: Infinity, diff --git a/server/utils/pretalx/parser.ts b/server/utils/pretalx/parser.ts index 9c8dd91..a1732fb 100644 --- a/server/utils/pretalx/parser.ts +++ b/server/utils/pretalx/parser.ts @@ -1,10 +1,11 @@ -import type { Answer, PretalxResult, Slot, Submission, SubmissionType } from './type' +import type { Answer, PretalxResult, Room, Slot, Submission, SubmissionType } from '#shared/types/pretalx' +import type { SessionSpeaker } from '#shared/types/session' // 對應 pretalx 的問題 ID。 // key 為系統內使用的欄位名稱,value 為 pretalx 的 question id。 // 這個對應表會被 `parseAnswer` 使用,將 pretalx 的 answers // 轉換為以 key 為索引的 Record 物件並回傳。 -const QUESTION_MAP: Record = { +const QUESTION_MAP = { language: 269, languageOther: 300, enTitle: 257, @@ -18,11 +19,14 @@ const QUESTION_MAP: Record = { qa: null, slide: null, record: null, -} as const +} as const satisfies Record -export function parseAnswer(answers: Answer['id'][], pretalxData: PretalxResult): any { +type QuestionKey = keyof typeof QUESTION_MAP +type ParsedAnswer = Partial> + +export function parseAnswer(answers: Answer['id'][], pretalxData: PretalxResult): ParsedAnswer { const answerMap = pretalxData.answers.map - const results: Record = {} + const results: ParsedAnswer = {} const questionMap = answers.reduce((acc: Record, cur: Answer['id']) => { const ans = answerMap[cur] @@ -36,7 +40,7 @@ export function parseAnswer(answers: Answer['id'][], pretalxData: PretalxResult) return acc }, {}) - for (const question in QUESTION_MAP) { + for (const question of Object.keys(QUESTION_MAP) as QuestionKey[]) { const questionId = QUESTION_MAP[question] if (!questionId) { @@ -49,28 +53,46 @@ export function parseAnswer(answers: Answer['id'][], pretalxData: PretalxResult) return results } -export function parseSlot(slotId: Slot['id'], pretalxData: PretalxResult) { +export function parseSlot(slotId: Slot['id'], pretalxData: PretalxResult): (Omit & { room?: Room }) | null { const slotMap = pretalxData.slots.map const roomMap = pretalxData.rooms.map const slot = slotMap[slotId] if (!slot) { - console.warn('slot not found', slotId) - return {} + throw createError({ + statusCode: 500, + statusMessage: `Slot not found: ${slotId}`, + }) } const roomId = slot.room + + if (!roomId) { + throw createError({ + statusCode: 500, + statusMessage: `Slot not found: ${slotId}`, + }) + } + const room = roomMap[roomId] return { ...slot, room } } -export function parseSpeaker(speakerIds: Submission['speakers'], pretalxData: PretalxResult) { +export function parseSpeaker(speakerIds: Submission['speakers'], pretalxData: PretalxResult): SessionSpeaker[] { const speakerMap = pretalxData.speakers.map return speakerIds.map((speakerId: string) => { const speaker = speakerMap[speakerId] + + if (!speaker) { + throw createError({ + statusCode: 500, + statusMessage: `Speaker not found: ${speakerId}`, + }) + } + const answer = parseAnswer(speaker.answers, pretalxData) return { @@ -88,7 +110,15 @@ export function parseSpeaker(speakerIds: Submission['speakers'], pretalxData: Pr }) } -export function parseType(typeId: SubmissionType['id'], pretalxData: PretalxResult) { +export function parseType(typeId: SubmissionType['id'], pretalxData: PretalxResult): SubmissionType { const typeMap = pretalxData['submission-types'].map + + if (!typeMap[typeId]) { + throw createError({ + statusCode: 500, + statusMessage: `Submission type not found: ${typeId}`, + }) + } + return typeMap[typeId] } diff --git a/server/utils/pretalx/type.ts b/server/utils/pretalx/type.ts deleted file mode 100644 index 8e123f5..0000000 --- a/server/utils/pretalx/type.ts +++ /dev/null @@ -1,79 +0,0 @@ -export interface Submission { - code: string - title: string - speakers: string[] - submission_type: number - track: number - tags: string[] - state: 'confirmed' - abstract: string - slots: number[] - answers: number[] -} - -export interface SubmissionType { - id: number - name: { - 'en': string - 'zh-hans': string - } -} - -export interface Speaker { - code: string - name: string - biography: string - answers: number[] - avatar_url: string -} - -export interface Room { - id: number - name: { - 'en': string - 'zh-hans': string - } - description: { - 'en': string - 'zh-hans': string - } -} - -export interface Answer { - id: number - question: number - answer: string -} - -export interface Slot { - id: number - room: number - start: string - end: string - duration: number -} - -export interface TableTypeMap { - 'submissions': Submission - 'submission-types': SubmissionType - 'speakers': Speaker - 'rooms': Room - 'answers': Answer - 'slots': Slot -} - -export interface PretalxResponse { - count: number - next: string - previous: string - results: TableTypeMap[T][] -} - -export interface PretalxData { - arr: TableTypeMap[T][] - map: Record -} - -export type PretalxResult = { - [K in keyof TableTypeMap]: any -} diff --git a/shared/types/pretalx.ts b/shared/types/pretalx.ts new file mode 100644 index 0000000..f674491 --- /dev/null +++ b/shared/types/pretalx.ts @@ -0,0 +1,120 @@ +import { z } from 'zod' + +export const PRETALX_TABLES = [ + 'submissions', + 'submission-types', + 'speakers', + 'rooms', + 'answers', + 'slots', +] as const +export const PretalxTableSchema = z.enum(PRETALX_TABLES) + +export const PretalxLocaleSchema = z.object({ + 'en': z.string().optional().default(''), + 'zh-hans': z.string().optional().default(''), +}) + +export const SubmissionSchema = z.object({ + code: z.string(), + title: z.string(), + speakers: z.array(z.string()), + submission_type: z.number(), + track: z.number().nullable().optional(), + tags: z.array(z.number()), + state: z.string(), + abstract: z.string().nullable().transform((value) => value ?? ''), + slots: z.array(z.number()), + answers: z.array(z.number()), +}) + +export const SubmissionTypeSchema = z.object({ + id: z.number(), + name: PretalxLocaleSchema, +}) + +export const SpeakerSchema = z.object({ + code: z.string(), + name: z.string(), + biography: z.string().nullable().transform((value) => value ?? ''), + answers: z.array(z.number()), + avatar_url: z.string().nullable(), +}) + +export const RoomSchema = z.object({ + id: z.number(), + name: PretalxLocaleSchema, + description: PretalxLocaleSchema, +}) + +const AnswerValueSchema = z + .union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.string()), + z.array(z.number()), + z.null(), + ]) + .transform((value) => { + if (value === null) { + return '' + } + + if (Array.isArray(value)) { + return value.join(', ') + } + + return String(value) + }) + +export const AnswerSchema = z.object({ + id: z.number(), + question: z.number(), + answer: AnswerValueSchema, +}) + +export const SlotSchema = z.object({ + id: z.number(), + room: z.number().nullable().transform((value) => value), + start: z.string().nullable().transform((value) => value), + end: z.string().nullable().transform((value) => value), + duration: z.number(), +}) + +export const PRETALX_TABLE_SCHEMAS = { + 'submissions': SubmissionSchema, + 'submission-types': SubmissionTypeSchema, + 'speakers': SpeakerSchema, + 'rooms': RoomSchema, + 'answers': AnswerSchema, + 'slots': SlotSchema, +} as const + +export type PretalxTable = z.infer +export type PretalxLocale = z.infer +export type Submission = z.infer +export type SubmissionType = z.infer +export type Speaker = z.infer +export type Room = z.infer +export type Answer = z.infer +export type Slot = z.infer +export type TableTypeMap = { + [K in PretalxTable]: z.infer<(typeof PRETALX_TABLE_SCHEMAS)[K]> +} + +export interface PretalxResponse { + count: number + next: string | null + previous: string | null + results: TableTypeMap[T][] +} + +export interface PretalxData { + arr: TableTypeMap[T][] + map: Record +} + +export type PretalxResult = { + [K in PretalxTable]: PretalxData +} diff --git a/shared/types/session.ts b/shared/types/session.ts new file mode 100644 index 0000000..8bf9b7d --- /dev/null +++ b/shared/types/session.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' +import { PretalxLocaleSchema } from './pretalx' + +const SessionSpeakerContentSchema = z.object({ + name: z.string(), + bio: z.string(), +}) + +export const SessionSpeakerSchema = z.object({ + id: z.string(), + avatar: z.string().nullable(), + zh: SessionSpeakerContentSchema, + en: SessionSpeakerContentSchema, +}) + +const SessionContentSchema = z.object({ + title: z.string(), + describe: z.string(), + type: z.string(), +}) + +export const SessionSummarySchema = z.object({ + id: z.string(), + room: PretalxLocaleSchema.optional(), + start: z.string().nullable().optional(), + end: z.string().nullable().optional(), + language: z.string().optional(), + speakers: z.array(SessionSpeakerSchema), + zh: SessionContentSchema, + en: SessionContentSchema, + tags: z.array(z.string()), + uri: z.string(), +}) + +export const SessionDetailSchema = SessionSummarySchema.extend({ + co_write: z.null(), + qa: z.null(), + slide: z.null(), + record: z.null(), +}) + +export type SessionSpeaker = z.infer +export type SessionSummary = z.infer +export type SessionDetail = z.infer