guidly/api/quest/events.ts
Jaymiesh c15e6eac7a
All checks were successful
CI/CD / lint (push) Successful in 7s
CI/CD / build (push) Successful in 18s
CI/CD / deploy (push) Successful in 20s
Fix duplicate quest event processing and make events idempotent
2026-02-11 16:00:59 +07:00

140 lines
3.8 KiB
TypeScript

import type { ApiRequest, ApiResponse } from '../_lib/types.js'
import { cors, withTelegramAuth } from '../_lib/auth.js'
import { questEventSchema } from '../_lib/validation.js'
import {
findUserByTelegramId,
createQuestEvent,
getPointById,
updatePointStatus,
markPointArrived,
updatePointContent,
getActivePointForDay,
getActiveDayForQuest,
getActiveQuestForUser,
updateDayStatus,
getPointsForDay,
} from '../_lib/db.js'
async function handler(req: ApiRequest, res: ApiResponse) {
cors(req, res)
if (req.method === 'OPTIONS') {
return res.status(200).end()
}
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' })
}
const telegramUser = req.telegramUser
if (!telegramUser) {
return res.status(401).json({ error: 'Not authenticated' })
}
const parsed = questEventSchema.safeParse(req.body)
if (!parsed.success) {
return res.status(400).json({ error: 'Invalid request', details: parsed.error.issues })
}
const { questId, pointId, eventType, payload } = parsed.data
const user = await findUserByTelegramId(telegramUser.id)
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
// Immediate state updates for UI responsiveness
let nextAction: string = 'wait'
let updatedPoint = null
let shouldCreateEvent = true
const currentPoint = pointId ? await getPointById(pointId) : null
switch (eventType) {
case 'arrived': {
if (pointId) {
if (currentPoint?.arrived_at) {
shouldCreateEvent = false
updatedPoint = currentPoint
} else {
await markPointArrived(pointId)
updatedPoint = await getPointById(pointId)
}
nextAction = 'show_content'
}
break
}
case 'point_completed': {
if (pointId) {
if (currentPoint?.status === 'completed' || currentPoint?.status === 'skipped') {
shouldCreateEvent = false
} else {
await updatePointStatus(pointId, 'completed')
}
// Check if there are more points in the day
const quest = await getActiveQuestForUser(user.id)
if (quest) {
const day = await getActiveDayForQuest(quest.id)
if (day) {
const points = await getPointsForDay(day.id)
const nextPoint = points.find((p: { status: string }) => p.status === 'hidden' || p.status === 'preview')
if (nextPoint) {
nextAction = 'next_point'
} else {
nextAction = 'day_complete'
}
}
}
}
break
}
case 'skipped': {
if (pointId) {
if (currentPoint?.status === 'completed' || currentPoint?.status === 'skipped') {
shouldCreateEvent = false
} else {
await updatePointStatus(pointId, 'skipped')
}
nextAction = 'next_point'
}
break
}
case 'finished_today': {
const quest = await getActiveQuestForUser(user.id)
if (quest) {
const day = await getActiveDayForQuest(quest.id)
if (day) {
// Mark all remaining points as skipped
const points = await getPointsForDay(day.id)
for (const point of points) {
if (point.status !== 'completed' && point.status !== 'skipped') {
await updatePointStatus(point.id, 'skipped')
}
}
await updateDayStatus(day.id, 'completed')
nextAction = 'day_complete'
}
}
break
}
}
// Write event to DB (bot will process it asynchronously)
if (shouldCreateEvent) {
await createQuestEvent({
quest_id: questId,
user_id: user.id,
point_id: pointId,
event_type: eventType,
payload,
})
}
return res.json({
success: true,
updatedPoint,
nextAction,
})
}
export default withTelegramAuth(handler)