390 lines
10 KiB
TypeScript
390 lines
10 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
|
|
}
|
|
|
|
export async function getOrCreateCityByName(name: string) {
|
|
const normalizedName = name.trim().replace(/\s+/g, ' ')
|
|
|
|
const { rows: existing } = await pool.query(
|
|
'SELECT * FROM cities WHERE lower(name) = lower($1) LIMIT 1',
|
|
[normalizedName]
|
|
)
|
|
if (existing[0]) return existing[0]
|
|
|
|
const { rows } = await pool.query(
|
|
`INSERT INTO cities (name, country, description, status)
|
|
VALUES ($1, $2, $3, 'active')
|
|
RETURNING *`,
|
|
[normalizedName, 'Unknown', 'User-added city']
|
|
)
|
|
return rows[0]
|
|
}
|
|
|
|
// ============================================
|
|
// 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 claimUnprocessedEvents(limit = 20) {
|
|
const { rows } = await pool.query(
|
|
`WITH next_events AS (
|
|
SELECT id
|
|
FROM quest_events
|
|
WHERE processed = false
|
|
ORDER BY created_at ASC
|
|
FOR UPDATE SKIP LOCKED
|
|
LIMIT $1
|
|
)
|
|
UPDATE quest_events q
|
|
SET processed = true
|
|
FROM next_events n
|
|
WHERE q.id = n.id
|
|
RETURNING q.*`,
|
|
[limit]
|
|
)
|
|
return rows
|
|
}
|
|
|
|
// ============================================
|
|
// 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 }
|