Telegram Bot + Mini App for city walking quests. - React 19 + TypeScript + Vite 6 frontend - Express 5 + PostgreSQL backend - grammY Telegram bot with DeepSeek AI - GitLab CI/CD: lint, build, deploy to production Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
123 lines
3.2 KiB
TypeScript
123 lines
3.2 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' })
|
|
}
|
|
|
|
// Write event to DB (bot will process it asynchronously)
|
|
await createQuestEvent({
|
|
quest_id: questId,
|
|
user_id: user.id,
|
|
point_id: pointId,
|
|
event_type: eventType,
|
|
payload,
|
|
})
|
|
|
|
// Immediate state updates for UI responsiveness
|
|
let nextAction: string = 'wait'
|
|
let updatedPoint = null
|
|
|
|
switch (eventType) {
|
|
case 'arrived': {
|
|
if (pointId) {
|
|
await markPointArrived(pointId)
|
|
updatedPoint = await getPointById(pointId)
|
|
nextAction = 'show_content'
|
|
}
|
|
break
|
|
}
|
|
case 'point_completed': {
|
|
if (pointId) {
|
|
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) {
|
|
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
|
|
}
|
|
}
|
|
|
|
return res.json({
|
|
success: true,
|
|
updatedPoint,
|
|
nextAction,
|
|
})
|
|
}
|
|
|
|
export default withTelegramAuth(handler)
|