guidly/api/_lib/db.ts
laruevin d5ed7fdcf9 Initial commit: Guidly project with CI/CD pipeline
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>
2026-02-11 11:42:42 +07:00

369 lines
9.8 KiB
TypeScript

import { Pool } from 'pg'
const pool = new Pool({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
ssl: process.env.DATABASE_SSL === 'false' ? false : { rejectUnauthorized: false },
})
// ============================================
// Users
// ============================================
export async function findUserByTelegramId(telegramId: number) {
const { rows } = await pool.query(
'SELECT * FROM users WHERE telegram_id = $1',
[telegramId]
)
return rows[0] || null
}
export async function createUser(data: {
telegram_id: number
username?: string
display_name?: string
language_code?: string
}) {
const { rows } = await pool.query(
`INSERT INTO users (telegram_id, username, display_name, language_code)
VALUES ($1, $2, $3, $4)
ON CONFLICT (telegram_id) DO UPDATE SET
username = COALESCE($2, users.username),
display_name = COALESCE($3, users.display_name),
language_code = COALESCE($4, users.language_code),
updated_at = NOW()
RETURNING *`,
[data.telegram_id, data.username || null, data.display_name || null, data.language_code || 'ru']
)
return rows[0]
}
// ============================================
// Cities
// ============================================
export async function getActiveCities() {
const { rows } = await pool.query(
"SELECT * FROM cities WHERE status = 'active' ORDER BY name"
)
return rows
}
export async function getCityById(id: string) {
const { rows } = await pool.query('SELECT * FROM cities WHERE id = $1', [id])
return rows[0] || null
}
// ============================================
// Quests
// ============================================
export async function getActiveQuestForUser(userId: string) {
const { rows } = await pool.query(
`SELECT q.*, c.name as city_name, c.country as city_country
FROM quests q
JOIN cities c ON q.city_id = c.id
WHERE q.user_id = $1 AND q.status = 'in_progress'
LIMIT 1`,
[userId]
)
return rows[0] || null
}
export async function createQuest(data: {
city_id: string
user_id: string
title?: string
description?: string
number_of_days: number
pace: string
companions: string
user_comment?: string
}) {
const { rows } = await pool.query(
`INSERT INTO quests (city_id, user_id, title, description, number_of_days, pace, companions, user_comment)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[
data.city_id, data.user_id, data.title || null, data.description || null,
data.number_of_days, data.pace, data.companions, data.user_comment || null
]
)
return rows[0]
}
export async function updateQuestStatus(questId: string, status: string) {
const extra = status === 'in_progress'
? ", started_at = NOW()"
: status === 'completed'
? ", completed_at = NOW()"
: ""
const { rows } = await pool.query(
`UPDATE quests SET status = $1, updated_at = NOW()${extra} WHERE id = $2 RETURNING *`,
[status, questId]
)
return rows[0] || null
}
// ============================================
// Quest Days
// ============================================
export async function createQuestDay(data: {
quest_id: string
day_number: number
theme?: string
}) {
const { rows } = await pool.query(
`INSERT INTO quest_days (quest_id, day_number, theme)
VALUES ($1, $2, $3)
RETURNING *`,
[data.quest_id, data.day_number, data.theme || null]
)
return rows[0]
}
export async function getQuestDays(questId: string) {
const { rows } = await pool.query(
'SELECT * FROM quest_days WHERE quest_id = $1 ORDER BY day_number',
[questId]
)
return rows
}
export async function getActiveDayForQuest(questId: string) {
const { rows } = await pool.query(
`SELECT * FROM quest_days
WHERE quest_id = $1 AND status = 'in_progress'
LIMIT 1`,
[questId]
)
return rows[0] || null
}
export async function updateDayStatus(dayId: string, status: string) {
const extra = status === 'in_progress'
? ", started_at = NOW()"
: status === 'completed'
? ", completed_at = NOW()"
: ""
const { rows } = await pool.query(
`UPDATE quest_days SET status = $1${extra} WHERE id = $2 RETURNING *`,
[status, dayId]
)
return rows[0] || null
}
// ============================================
// Quest Points
// ============================================
export async function createQuestPoint(data: {
day_id: string
title: string
location_lat?: number
location_lon?: number
teaser_text?: string
content_text?: string
order_in_day: number
status?: string
}) {
const { rows } = await pool.query(
`INSERT INTO quest_points (day_id, title, location_lat, location_lon, teaser_text, content_text, order_in_day, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (day_id, order_in_day) DO NOTHING
RETURNING *`,
[
data.day_id, data.title, data.location_lat || null, data.location_lon || null,
data.teaser_text || null, data.content_text || null, data.order_in_day,
data.status || 'hidden'
]
)
return rows[0] || null
}
export async function getActivePointForDay(dayId: string) {
const { rows } = await pool.query(
`SELECT * FROM quest_points
WHERE day_id = $1 AND status = 'active'
LIMIT 1`,
[dayId]
)
return rows[0] || null
}
export async function getPointsForDay(dayId: string) {
const { rows } = await pool.query(
'SELECT * FROM quest_points WHERE day_id = $1 ORDER BY order_in_day',
[dayId]
)
return rows
}
export async function getPointById(pointId: string) {
const { rows } = await pool.query(
'SELECT * FROM quest_points WHERE id = $1',
[pointId]
)
return rows[0] || null
}
export async function updatePointStatus(pointId: string, status: string) {
const extra = status === 'active'
? ""
: status === 'completed'
? ", completed_at = NOW()"
: ""
const arrivedExtra = status === 'active' ? "" : ""
const { rows } = await pool.query(
`UPDATE quest_points SET status = $1${extra} WHERE id = $2 RETURNING *`,
[status, pointId]
)
return rows[0] || null
}
export async function markPointArrived(pointId: string) {
const { rows } = await pool.query(
`UPDATE quest_points SET arrived_at = NOW() WHERE id = $1 RETURNING *`,
[pointId]
)
return rows[0] || null
}
export async function updatePointContent(pointId: string, contentText: string) {
const { rows } = await pool.query(
`UPDATE quest_points SET content_text = $1 WHERE id = $2 RETURNING *`,
[contentText, pointId]
)
return rows[0] || null
}
// ============================================
// Quest Events
// ============================================
export async function createQuestEvent(data: {
quest_id: string
user_id: string
point_id?: string
event_type: string
payload?: Record<string, unknown>
}) {
const { rows } = await pool.query(
`INSERT INTO quest_events (quest_id, user_id, point_id, event_type, payload)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[data.quest_id, data.user_id, data.point_id || null, data.event_type, JSON.stringify(data.payload || {})]
)
return rows[0]
}
export async function getUnprocessedEvents() {
const { rows } = await pool.query(
`SELECT * FROM quest_events
WHERE processed = false
ORDER BY created_at ASC
LIMIT 20`
)
return rows
}
export async function markEventProcessed(eventId: string) {
await pool.query(
'UPDATE quest_events SET processed = true WHERE id = $1',
[eventId]
)
}
// ============================================
// Chat History
// ============================================
export async function saveChatMessage(data: {
user_id: string
quest_id: string
point_id?: string
message_type: string
content: string
}) {
const { rows } = await pool.query(
`INSERT INTO chat_history (user_id, quest_id, point_id, message_type, content)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[data.user_id, data.quest_id, data.point_id || null, data.message_type, data.content]
)
return rows[0]
}
export async function getChatHistory(questId: string) {
const { rows } = await pool.query(
'SELECT * FROM chat_history WHERE quest_id = $1 ORDER BY created_at',
[questId]
)
return rows
}
// ============================================
// Achievements
// ============================================
export async function createAchievement(data: {
user_id: string
type: string
city_id?: string
}) {
const { rows } = await pool.query(
`INSERT INTO achievements (user_id, type, city_id)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, type, city_id) DO NOTHING
RETURNING *`,
[data.user_id, data.type, data.city_id || null]
)
return rows[0] || null
}
export async function getUserAchievements(userId: string) {
const { rows } = await pool.query(
'SELECT * FROM achievements WHERE user_id = $1 ORDER BY achieved_at',
[userId]
)
return rows
}
// ============================================
// Full quest state (for API responses)
// ============================================
export async function getFullQuestState(userId: string) {
const quest = await getActiveQuestForUser(userId)
if (!quest) return null
const day = await getActiveDayForQuest(quest.id)
if (!day) {
return { quest, day: null, point: null, dayProgress: null }
}
const points = await getPointsForDay(day.id)
const activePoint = points.find((p: { status: string }) => p.status === 'active') || null
const completedCount = points.filter((p: { status: string }) => p.status === 'completed').length
return {
quest,
day,
point: activePoint,
dayProgress: {
totalPoints: points.length,
completedPoints: completedCount,
}
}
}
export { pool }