Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions server/api/session/[id]/index.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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: {
Expand Down
15 changes: 10 additions & 5 deletions server/api/session/index.get.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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: {
Expand All @@ -36,4 +40,5 @@ export default defineEventHandler(async () => {
uri: `https://coscup.org/2026/session/${submission.code}`,
}
})
.filter(Boolean)
})
35 changes: 30 additions & 5 deletions server/utils/opass/pretalxToOpass.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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: {
Expand All @@ -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 {
Expand All @@ -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: {
Expand All @@ -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: {
Expand All @@ -86,6 +110,7 @@ export function pretalxToOpass(pretalxData: PretalxResult) {
},
}
})
.filter(Boolean)

// TODO: tags

Expand Down
63 changes: 63 additions & 0 deletions server/utils/pretalx/fetch.ts
Original file line number Diff line number Diff line change
@@ -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<T extends PretalxTable>(
table: T,
input: unknown,
): PretalxResponse<T> {
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<T>
}

function getPretalxItemKey<T extends PretalxTable>(item: TableTypeMap[T]): string | number {
if ('code' in item) {
return item.code
}

return item.id
}

export async function fetchPretalxTable<T extends PretalxTable>(
table: T,
): Promise<PretalxData<T>> {
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<T> = { arr: [], map: {} }
let url: string | null = table

while (url) {
const response: PretalxResponse<T> = 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
}
44 changes: 19 additions & 25 deletions server/utils/pretalx/index.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -10,31 +12,23 @@ export default defineCachedFunction(
})
}

const tables = ['submissions', 'submission-types', 'speakers', 'rooms', 'answers', 'slots'] as const
const results: Partial<PretalxResult> = {}

for (const table of tables) {
let url: string = table
results[table] = { arr: [], map: {} } satisfies PretalxData<typeof table>

while (url) {
const response = await $fetch<PretalxResponse<typeof table>>(
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,
Expand Down
52 changes: 41 additions & 11 deletions server/utils/pretalx/parser.ts
Original file line number Diff line number Diff line change
@@ -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<string, number | null> = {
const QUESTION_MAP = {
language: 269,
languageOther: 300,
enTitle: 257,
Expand All @@ -18,11 +19,14 @@ const QUESTION_MAP: Record<string, number | null> = {
qa: null,
slide: null,
record: null,
} as const
} as const satisfies Record<string, number | null>

export function parseAnswer(answers: Answer['id'][], pretalxData: PretalxResult): any {
type QuestionKey = keyof typeof QUESTION_MAP
type ParsedAnswer = Partial<Record<QuestionKey, string>>

Comment on lines +24 to +26
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QuestionKey = keyof typeof QUESTION_MAP won’t become a precise union of keys while QUESTION_MAP is typed as Record<string, number | null> (that annotation erases literal keys and makes keyof effectively string). Consider declaring QUESTION_MAP as a const object (e.g. as const satisfies Record<string, number | null>) so QuestionKey stays strongly typed.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

68355e4

export function parseAnswer(answers: Answer['id'][], pretalxData: PretalxResult): ParsedAnswer {
const answerMap = pretalxData.answers.map
const results: Record<keyof typeof QUESTION_MAP, unknown> = {}
const results: ParsedAnswer = {}

const questionMap = answers.reduce((acc: Record<Answer['id'], Answer>, cur: Answer['id']) => {
const ans = answerMap[cur]
Expand All @@ -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) {
Expand All @@ -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<Slot, 'room'> & { 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}`,
})
}
Comment on lines 69 to +76
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseSlot returns null when slot.room is null/undefined, which drops start/end even though the slot exists. Consider returning the slot regardless and making only room optional, so schedule times remain available even when room is missing.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

這邊應該直接 throw error


const room = roomMap[roomId]

Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If roomId is set but roomMap[roomId] is missing, this currently returns a slot with room: undefined silently. Consider throwing a 500 with the missing roomId (consistent with the missing slot/type/speaker handling) to make data issues easier to debug.

Suggested change
if (!room) {
throw createError({
statusCode: 500,
statusMessage: `Room not found: ${roomId}`,
})
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

這跟剛剛那個是一樣的

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 {
Expand All @@ -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]
}
Loading