This commit is contained in:
2025-05-04 22:22:20 +03:00
parent cda54b1e95
commit ebd77a3e66
54 changed files with 1194 additions and 2580 deletions

11
backend/.env Normal file
View File

@@ -0,0 +1,11 @@
# node --env-file .env app.js
# Usage: process.env.PORT
# https://nodejs.org/en/learn/command-line/how-to-read-environment-variables-from-nodejs
PORT=3000
API_ID=26746106
API_HASH=29e5f83c04e635fa583721473a6003b5
BOT_TOKEN=7236504417:AAGVaodw3cRwGlf-jAhwnYb51OHaXcgpW8k
BOT_SID=1AgAOMTQ5LjE1NC4xNjcuNTEBu6zLey/IpmODwaKLyTbkKwFDY28LSFPf4UZpgSKCO4bP5gGtFOGVmNDhsJxhMtUWNzhyOX46GyDliNiZ4FUQdoQ6G93DEN8mYcREljmiCp5JchNyZPmhGxl2GeclPo0tp9T/yXFUyo7PD8YpuykHH/MdWVyZxPp93Pjjpi+E03DKCwD00tEpi2TAGzW/MyQ8HUAUIK45nkJA7dnv8Up7NB9LWJ2z+8Dx81oGdVYyOHBL9qy9722LyKtvLD47KpwINjJyZOdhdBM1W8bhsGE4JkHs6DXFXOzrmMrWaE30z4corikkQoNIDL/tXttv+bJULQbbyGZvskbXuvwkV/NVen0=

Binary file not shown.

BIN
backend/_old/backend_v2.zip Normal file

Binary file not shown.

BIN
backend/_old/backend_v3.zip Normal file

Binary file not shown.

1
backend/app.bat Normal file
View File

@@ -0,0 +1 @@
node --env-file .env app

View File

@@ -8,14 +8,14 @@ const bot = require('./apps/bot')
const app = express() const app = express()
app.use(bodyParser.json()) app.use(bodyParser.json({limit: '10mb'}))
app.use(cookieParser()) app.use(cookieParser())
BigInt.prototype.toJSON = function () { BigInt.prototype.toJSON = function () {
return Number(this) return Number(this)
} }
/* app.use((req, res, next) => { app.use((req, res, next) => {
if(!(req.body instanceof Object)) if(!(req.body instanceof Object))
return next() return next()
@@ -26,14 +26,14 @@ BigInt.prototype.toJSON = function () {
.map(key => req.body[key] = escapeHtml(req.body[key])) .map(key => req.body[key] = escapeHtml(req.body[key]))
next() next()
}) */ })
app.post('(/api/admin/auth/telegram|/api/miniapp/auth)', (req, res, next) => { app.post('(/api/admin/auth/telegram|/api/miniapp/auth)', (req, res, next) => {
const data = Object.assign({}, req.query) const data = Object.assign({}, req.query)
delete data.hash delete data.hash
const hash = req.query?.hash const hash = req.query?.hash
const BOT_TOKEN = '7236504417:AAGVaodw3cRwGlf-jAhwnYb51OHaXcgpW8k' const BOT_TOKEN = process.env.BOT_TOKEN || '7236504417:AAGVaodw3cRwGlf-jAhwnYb51OHaXcgpW8k'
const dataCheckString = Object.keys(data).sort().map((key) => `${key}=${data[key]}`).join('\n') const dataCheckString = Object.keys(data).sort().map((key) => `${key}=${data[key]}`).join('\n')
const secretKey = crypto.createHmac('sha256', 'WebAppData').update(BOT_TOKEN).digest() const secretKey = crypto.createHmac('sha256', 'WebAppData').update(BOT_TOKEN).digest()
const hmac = crypto.createHmac('sha256', secretKey).update(dataCheckString).digest('hex') const hmac = crypto.createHmac('sha256', secretKey).update(dataCheckString).digest('hex')
@@ -59,20 +59,19 @@ app.use('/api/miniapp', require('./apps/miniapp'))
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
console.error(`Error for ${req.path}: ${err}`) console.error(`Error for ${req.path}: ${err}`)
console.trace()
console.log('\n\n')
let message, code let message, code
[message, code = 500] = err.message.split('::') [message, code = 500] = err.message.split('::')
res.status(+code).json({success: false, error: { message, code}})
res.status(code).json({success: false, error: { message, code}})
}) })
app.use(express.static('public'))
const PORT = process.env.PORT || 3000 const PORT = process.env.PORT || 3000
app.listen(PORT, async () => { app.listen(PORT, async () => {
console.log(`Listening at port ${PORT}`) console.log(`Listening at port ${PORT}`)
bot.start( bot.start(
process.env.API_ID || 26746106, +(process.env.API_ID || 26746106),
process.env.API_HASH || '29e5f83c04e635fa583721473a6003b5', process.env.API_HASH || '29e5f83c04e635fa583721473a6003b5',
process.env.BOT_TOKEN || '7236504417:AAGVaodw3cRwGlf-jAhwnYb51OHaXcgpW8k' process.env.BOT_TOKEN || '7236504417:AAGVaodw3cRwGlf-jAhwnYb51OHaXcgpW8k'
) )

View File

@@ -1,24 +1,45 @@
const crypto = require('crypto') const crypto = require('crypto')
const express = require('express') const express = require('express')
const multer = require('multer')
const db = require('../include/db') const db = require('../include/db')
const bot = require('./bot') const bot = require('./bot')
const fs = require('fs') const fs = require('fs')
const cookieParser = require('cookie-parser')
const app = express.Router() const app = express.Router()
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 1_000_000 // 1mb
}
})
const sessions = {} const sessions = {}
const emailCache = {} // key = email, value = code const cache = {
// email -> code
register: {},
recovery: {},
'change-password': {},
'change-email': {},
'change-email2': {}
}
function checkEmail(email){
return String(email)
.toLowerCase()
.match(/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/)
}
function sendEmail(email, subject, message) {
console.log(`${email} --> ${subject}: ${message}`)
}
function checkPassword(password) {
return password.length >= 8
}
app.use((req, res, next) => { app.use((req, res, next) => {
if (req.path == '/auth/email' || req.path == '/auth/telegram' || req.path == '/auth/register' || req.path == '/auth/logout') const public = [
'/auth/email',
'/auth/telegram',
'/auth/email/register',
'/auth/email/recovery',
'/auth/logout'
]
if (public.includes(req.path))
return next() return next()
const asid = req.query.asid || req.cookies.asid const asid = req.query.asid || req.cookies.asid
@@ -59,10 +80,10 @@ app.post('/auth/email', (req, res, next) => {
app.post('/auth/telegram', (req, res, next) => { app.post('/auth/telegram', (req, res, next) => {
let customer_id = db let customer_id = db
.prepare(`select id from customers where is_blocked = 0 and telegram_id = :telegram_id`) .prepare(`select id from customers where telegram_id = :telegram_id`)
.pluck(true) .pluck(true)
.get(res.locals) || db .get(res.locals) || db
.prepare(`replace into customers (telegram_id, is_blocked) values (:telegram_id, 0) returning id`) .prepare(`replace into customers (telegram_id) values (:telegram_id) returning id`)
.pluck(true) .pluck(true)
.get(res.locals) .get(res.locals)
@@ -70,73 +91,198 @@ app.post('/auth/telegram', (req, res, next) => {
res.status(200).json({success: true}) res.status(200).json({success: true})
}) })
app.get('/auth/logout', (req, res, next) => { /*
if (req.session?.asid) Регистрация нового клиента выполняется за ТРИ последовательных вызова
delete sessions[req.session.asid] 1. Отравляется email. Если email корректный и уже неиспользуется, то сервер возвращает ОК и на указанный email отправляется код.
res.setHeader('Set-Cookie', [`asid=; expired; httpOnly;path=/api/admin`]) 2. Отправляется email + код из письма. Если указан корректный код, то сервер отвечает ОК.
res.status(200).json({success: true}) 3. Отправляется email + код из письма + желаемый пароль. Если все ОК, то сервер создает учетную запись и возвращает ОК.
}) */
app.post('/auth/email/register', (req, res, next) => {
app.post('/auth/register', (req, res, next) => {
const email = String(req.body.email ?? '').trim() const email = String(req.body.email ?? '').trim()
const code = String(req.body.code ?? '').trim() const code = String(req.body.code ?? '').trim()
const password = String(req.body.password ?? '').trim() const password = String(req.body.password ?? '').trim()
if (email) {
const validateEmail = email => String(email).toLowerCase().match(/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/)
if (!validateEmail(email))
throw Error('INCORRECT_EMAIL::400')
const customer_id = db
.prepare('select id from customers where email = :email')
.pluck(true)
.get({email})
if (customer_id)
throw Error('USED_EMAIL::400')
}
if (email && !code) {
const code = Math.random().toString().substr(2, 4)
emailCache[email] = code
// To-Do: send email
console.log(`${email} => ${code}`)
}
if (email && code && !password) {
if (emailCache[email] != code)
throw Error('INCORRECT_CODE::400')
}
if (email && code && password) {
if (password.length < 8)
throw Error('INCORRECT_PASSWORD::400')
db
.prepare('insert into customers (email, password, is_blocked) values (:email, :password, 0)')
.run({email, password})
}
res.status(200).json({success: true})
})
const stepNo = email && !code ? 1 : email && code && !password ? 2 : email && code && password ? 3 : -1
if (stepNo == -1)
throw Error('BAD_STEP::400')
if (stepNo == 1) {
if (!checkEmail(email))
throw Error('INCORRECT_EMAIL::400')
const customer_id = db
.prepare('select id from customers where email = :email')
.pluck(true)
.get({email})
if (customer_id)
throw Error('USED_EMAIL::400')
const code = Math.random().toString().substr(2, 4)
cache.register[email] = code
sendEmail(email, 'REGISTER', `${email} => ${code}`)
}
if (stepNo == 2) {
if (cache.register[email] != code)
throw Error('INCORRECT_CODE::400')
}
if (stepNo == 3) {
if (!checkPassword(password))
throw Error('INCORRECT_PASSWORD::400')
db
.prepare('insert into customers (email, password) values (:email, :password)')
.run({email, password})
delete cache.register[email]
}
res.status(200).json({success: true})
})
/*
Смена email выполняется за ЧЕТЫРЕ последовательных вызовов
1. Отравляется пустой закпрос. Сервер на email пользователя из базы отправляет код.
2. Отправляется код из письма. Если указан корректный код, то сервер отвечает ОК.
3. Отправляется код из письма + новый email. Сервер отправляет код2 на новый email.
4. Отправлются оба кода и новый email. Если они проходят проверку, то сервер меняет email пользователя на новый и возвращает ОК.
*/
app.post('/auth/email/change-email', (req, res, next) => {
const email2 = String(req.body.email ?? '').trim()
const code = String(req.body.code ?? '').trim()
const code2 = String(req.body.code2 ?? '').trim()
const email = db
.prepare('select email from customers where id = :customer_id')
.pluck(true)
.get(res.locals)
const stepNo = !code ? 1 : code && !email ? 2 : code && email && !code2 ? 3 : code && email && code2 ? 4 : -1
if (stepNo == -1)
throw Error('BAD_STEP::400')
if (stepNo == 1) {
const code = Math.random().toString().substr(2, 4)
cache['change-email'][email] = code
sendEmail(email, 'CHANGE-EMAIL', `${email} => ${code}`)
}
if (stepNo == 2) {
if (cache['change-email'][email] != code)
throw Error('INCORRECT_CODE::400')
}
if (stepNo == 3) {
if (!checkEmail(email2))
throw Error('INCORRECT_EMAIL::400')
const code2 = Math.random().toString().substr(2, 4)
cache['change-email2'][email2] = code2
sendEmail(email2, 'CHANGE-EMAIL2', `${email2} => ${code2}`)
}
if (stepNo == 4) {
if (cache['change-email'][email] != code || cache['change-email2'][email2] != code2)
throw Error('INCORRECT_CODE::400')
const info = db
.prepare('update customers set email = :email where id = :customer_id')
.run(res.locals)
if (info.changes == 0)
throw Error('BAD_REQUEST::400')
delete cache['change-email'][email]
delete cache['change-email2'][email2]
}
res.status(200).json({success: true})
})
/*
Смена пароля/восстановление доступа выполняется за ТРИ последовательных вызова
1. Отравляется пустой закпрос для смены запоса и email, в случае восстановления доступа. Сервер на email отправляет код.
2. Отправляется email + код из письма. Если указан корректный код, то сервер отвечает ОК.
3. Отправляется email + код из письма + новый пароль. Сервер изменяет пароль и возвращает ОК.
*/
app.post('/auth/email/:action(change-password|recovery)', (req, res, next) => {
const code = String(req.body.code ?? '').trim()
const password = String(req.body.password)
const action = req.params.action
const email = action == 'change-password' ? db
.prepare('select email from customers where id = :customer_id')
.pluck(true)
.get(res.locals) :
String(req.body.email ?? '').trim()
const stepNo = action == 'change-password' ?
(!code && !password ? 1 : code && !password ? 2 : code && password ? 3 : -1) :
(!email && !code && !password ? 1 : email && code && !password ? 2 : email && code && password ? 3 : -1)
if (stepNo == -1)
throw Error('BAD_STEP::400')
if (stepNo == 1) {
if (!checkEmail(email))
throw Error('INCORRECT_EMAIL::400')
const code = Math.random().toString().substr(2, 4)
cache[action][email] = code
sendEmail(email, action.toUpperCase(), `${email} => ${code}`)
}
if (stepNo == 2) {
if (cache[action][email] != code)
throw Error('INCORRECT_CODE::400')
}
if (stepNo == 3) {
if (cache[action][email] != code)
throw Error('INCORRECT_CODE::400')
if (!checkPassword(password))
throw Error('INCORRECT_PASSWORD::400')
const info = db
.prepare('update customers set password = :password where email = :email')
.run({ email, password })
if (info.changes == 0)
throw Error('BAD_REQUEST::400')
delete cache[action][email]
}
res.status(200).json({success: true})
})
app.get('/auth/logout', (req, res, next) => {
if (req.session?.asid)
delete sessions[req.session.asid]
res.setHeader('Set-Cookie', [`asid=; expired; httpOnly;path=/api/admin`])
res.status(200).json({success: true})
})
// CUSTOMER // CUSTOMER
app.get('/customer/profile', (req, res, next) => { app.get('/customer/profile', (req, res, next) => {
const row = db const row = db
.prepare(` .prepare(`
select id, name, email, plan, coalesce(json_balance, '{}') json_balance, coalesce(json_company, '{}') json_company, upload_group_id select id, name, email, plan, coalesce(json_balance, '{}') json_balance, coalesce(json_company, '{}') json_company, upload_chat_id
from customers from customers
where id = :customer_id where id = :customer_id
`) `)
.get(res.locals) .get(res.locals)
if (row?.upload_group_id) { if (row?.upload_chat_id) {
row.upload_group = db row.upload_chat = db
.prepare(`select id, name, telegram_id from groups where id = :group_id and project_id is null`) .prepare(`select id, name, telegram_id from chats where id = :chat_id and project_id is null`)
.safeIntegers(true) .safeIntegers(true)
.get({ group_id: row.upload_group_id}) .get({ chat_id: row.upload_chat_id})
delete row.upload_group_id delete row.upload_chat_id
} }
for (const key in row) { for (const key in row) {
@@ -167,15 +313,36 @@ app.put('/customer/profile', (req, res, next) => {
res.status(200).json({success: true}) res.status(200).json({success: true})
}) })
app.get('/customer/settings', (req, res, next) => {
const row = db
.prepare(`select coalesce(json_settings, '{}') from customers where id = :customer_id`)
.pluck(true)
.get(res.locals)
res.status(200).json({success: true, data: JSON.parse(row)})
})
app.put('/customer/settings', (req, res, next) => {
res.locals.json_settings = JSON.stringify(req.body || {})
db
.prepare(`update customers set json_settings = :json_settings where id = :customer_id`)
.run(res.locals)
res.status(200).json({success: true})
})
// PROJECT // PROJECT
app.get('/project', (req, res, next) => { app.get('/project', (req, res, next) => {
const where = req.query.id ? ' and id = ' + parseInt(req.query.id) : '' const where = req.query.id ? ' and id = ' + parseInt(req.query.id) : ''
const rows = db const rows = db
.prepare(` .prepare(`
select id, name, description, logo select id, name, description, logo, is_logo_bg, is_archived,
from projects (select count(*) from chats where project_id = p.id) chat_count,
where customer_id = :customer_id ${where} and is_deleted <> 1 (select count(distinct user_id) from chat_users where chat_id in (select id from chats where project_id = p.id)) user_count
from projects p
where customer_id = :customer_id ${where}
order by name order by name
`) `)
.all(res.locals) .all(res.locals)
@@ -204,7 +371,7 @@ app.post('/project', (req, res, next) => {
.pluck(true) .pluck(true)
.get(res.locals) .get(res.locals)
res.status(200).json({success: true, data: id}) res.redirect(req.baseUrl + `/project?id=${id}`)
}) })
app.put('/project/:pid(\\d+)', (req, res, next) => { app.put('/project/:pid(\\d+)', (req, res, next) => {
@@ -212,11 +379,12 @@ app.put('/project/:pid(\\d+)', (req, res, next) => {
res.locals.name = req.body?.name res.locals.name = req.body?.name
res.locals.description = req.body?.description res.locals.description = req.body?.description
res.locals.logo = req.body?.logo res.locals.logo = req.body?.logo
res.locals.is_logo_bg = req.body?.is_logo_bg
const info = db const info = db
.prepareUpdate( .prepareUpdate(
'projects', 'projects',
['name', 'description', 'logo'], ['name', 'description', 'logo', 'is_logo_bg'],
res.locals, res.locals,
['id', 'customer_id']) ['id', 'customer_id'])
.run(res.locals) .run(res.locals)
@@ -224,39 +392,41 @@ app.put('/project/:pid(\\d+)', (req, res, next) => {
if (info.changes == 0) if (info.changes == 0)
throw Error('NOT_FOUND::404') throw Error('NOT_FOUND::404')
res.status(200).json({success: true}) res.redirect(req.baseUrl + `/project?id=${req.params.pid}`)
}) })
app.delete('/project/:pid(\\d+)', async (req, res, next) => { app.put('/project/:pid(\\d+)/:action(archive|restore)', async (req, res, next) => {
res.locals.id = req.params.pid res.locals.id = req.params.pid
res.locals.is_archived = +(req.params.action == 'archive')
const info = db const info = db
.prepare('update projects set id_deleted = 1 where id = :id and customer_id = :customer_id') .prepare(`
update projects
set is_archived = :is_archived
where id = :id and customer_id = :customer_id and coalesce(is_archived, 0) = not :is_archived
`)
.run(res.locals) .run(res.locals)
if (info.changes == 0) if (info.changes == 0)
throw Error('NOT_FOUND::404') throw Error('BAD_REQUEST::400')
const groupIds = db const chatIds = db
.prepare(`select id from groups where project_id = :id`) .prepare(`select id from chats where project_id = :id`)
.pluck(true) .pluck(true)
.all(res.locals) .all(res.locals)
for (const groupId of groupIds) { for (const chatId of chatIds) {
await bot.sendMessage(groupId, 'Проект удален') await bot.sendMessage(chatId, res.locals.is_archived ? 'Проект помещен в архив. Отслеживание сообщений прекращено.' : 'Проект восстановлен из архива.')
await bot.leaveGroup(groupId)
} }
db.prepare(`updates groups set project_id = null where id in (${ groupIds.join(', ')})`).run() res.redirect(req.baseUrl + `/project?id=${req.params.pid}`)
res.status(200).json({success: true})
}) })
app.use ('/project/:pid(\\d+)/*', (req, res, next) => { app.use ('/project/:pid(\\d+)/*', (req, res, next) => {
res.locals.project_id = parseInt(req.params.pid) res.locals.project_id = parseInt(req.params.pid)
const row = db const row = db
.prepare('select 1 from projects where id = :project_id and customer_id = :customer_id and is_deleted <> 1') .prepare('select 1 from projects where id = :project_id and customer_id = :customer_id and is_archived <> 1')
.get(res.locals) .get(res.locals)
if (!row) if (!row)
@@ -277,8 +447,8 @@ app.get('/project/:pid(\\d+)/user', (req, res, next) => {
left join user_details ud on u.id = ud.user_id and ud.project_id = :project_id left join user_details ud on u.id = ud.user_id and ud.project_id = :project_id
where id in ( where id in (
select user_id select user_id
from group_users from chat_users
where group_id in (select id from groups where project_id = :project_id) where chat_id in (select id from chats where project_id = :project_id)
) ${where} ) ${where}
`) `)
.safeIntegers(true) .safeIntegers(true)
@@ -337,7 +507,7 @@ app.get('/project/:pid(\\d+)/company', (req, res, next) => {
const rows = db const rows = db
.prepare(` .prepare(`
select id, name, email, phone, description, logo, select id, name, email, phone, description, logo,
(select json_group_array(user_id) from company_users where company_id = c.id) users (select json_chat_array(user_id) from company_users where company_id = c.id) users
from companies c from companies c
where project_id = :project_id ${where} where project_id = :project_id ${where}
order by name order by name
@@ -373,7 +543,7 @@ app.post('/project/:pid(\\d+)/company', (req, res, next) => {
.pluck(res.locals) .pluck(res.locals)
.get(res.locals) .get(res.locals)
res.status(200).json({success: true, data: id}) res.redirect(req.baseUrl + `/project/${req.params.pid}/company?id=${id}`)
}) })
app.put('/project/:pid(\\d+)/company/:cid(\\d+)', (req, res, next) => { app.put('/project/:pid(\\d+)/company/:cid(\\d+)', (req, res, next) => {
@@ -395,7 +565,7 @@ app.put('/project/:pid(\\d+)/company/:cid(\\d+)', (req, res, next) => {
if (info.changes == 0) if (info.changes == 0)
throw Error('NOT_FOUND::404') throw Error('NOT_FOUND::404')
res.status(200).json({success: true}) res.redirect(req.baseUrl + `/project/${req.params.pid}/company?id=${req.params.cid}`)
}) })
app.delete('/project/:pid(\\d+)/company/:cid(\\d+)', (req, res, next) => { app.delete('/project/:pid(\\d+)/company/:cid(\\d+)', (req, res, next) => {
@@ -411,13 +581,13 @@ app.delete('/project/:pid(\\d+)/company/:cid(\\d+)', (req, res, next) => {
res.status(200).json({success: true}) res.status(200).json({success: true})
}) })
app.get('/project/:pid(\\d+)/group', (req, res, next) => { app.get('/project/:pid(\\d+)/chat', (req, res, next) => {
const where = req.query.id ? ' and id = ' + parseInt(req.query.id) : '' const where = req.query.id ? ' and id = ' + parseInt(req.query.id) : ''
const rows = db const rows = db
.prepare(` .prepare(`
select id, name, telegram_id, is_channel, user_count, bot_can_ban select id, name, telegram_id, is_channel, user_count, bot_can_ban
from groups from chats
where project_id = :project_id ${where} where project_id = :project_id ${where}
`) `)
.all(res.locals) .all(res.locals)
@@ -428,20 +598,20 @@ app.get('/project/:pid(\\d+)/group', (req, res, next) => {
res.status(200).json({success: true, data: where ? rows[0] : rows}) res.status(200).json({success: true, data: where ? rows[0] : rows})
}) })
app.get('/project/:pid(\\d+)/group/:gid(\\d+)', (req, res, next) => { app.get('/project/:pid(\\d+)/chat/:gid(\\d+)', (req, res, next) => {
res.redirect(req.baseUrl + `/project/${req.params.pid}/group?id=${req.params.uid}`) res.redirect(req.baseUrl + `/project/${req.params.pid}/chat?id=${req.params.uid}`)
}) })
app.delete('/project/:pid(\\d+)/group/:gid(\\d+)', async (req, res, next) => { app.delete('/project/:pid(\\d+)/chat/:gid(\\d+)', async (req, res, next) => {
res.locals.group_id = parseInt(req.params.gid) res.locals.chat_id = parseInt(req.params.gid)
const info = db const info = db
.prepare(`update groups set project_id = null where id = :group_id and project_id = :project_id`) .prepare(`update chats set project_id = null where id = :chat_id and project_id = :project_id`)
.run(res.locals) .run(res.locals)
if (info.changes == 0) if (info.changes == 0)
throw Error('NOT_FOUND::404') throw Error('NOT_FOUND::404')
await bot.sendMessage(res.locals.group_id, 'Группа удалена из проекта') await bot.sendMessage(res.locals.chat_id, 'Чат удален из проекта')
res.status(200).json({success: true}) res.status(200).json({success: true})
}) })
@@ -463,8 +633,8 @@ app.put('/project/:pid(\\d+)/company/:cid(\\d+)/user', (req, res, next) => {
let rows = db let rows = db
.prepare(` .prepare(`
select user_id select user_id
from group_users from chat_users
where group_id in (select id from groups where project_id = :project_id) where chat_id in (select id from chats where project_id = :project_id)
`) `)
.pluck(true) // .raw? .pluck(true) // .raw?
.get(res.locals) .get(res.locals)

View File

@@ -10,9 +10,7 @@ const { NewMessage } = require('telegram/events')
const { Button } = require('telegram/tl/custom/button') const { Button } = require('telegram/tl/custom/button')
const { CustomFile } = require('telegram/client/uploads') const { CustomFile } = require('telegram/client/uploads')
//const session = new StringSession('1AgAOMTQ5LjE1NC4xNjcuNTABu2OaFuD5Oyi5wGck+n5ldAfshzYfwlWee+OUxYBvFzlKAdW11Hsndu1SJBLUnKjP8sTJEPbLwdqANBhBXmQMghLVAblwK6TxLfsWxy2zf/HGLeNXohhrsep0hBxu9imyHV6OI6gQG+c5qaGkzjZrz0AcS4ut0xy99XrXgjiNfnjeMX7a0mOk6IK9iKdwbX9kXTfclFLVppiBGXolYJjVb2E57tk4+7RncIVyw+Fxn0NZfnhEfHJZly6j03arZOeM5VYl9ul8+3lJDD+KJJHeMgImmYjmcFcF3CbtkhPuTSPnWKtCnm2sRzepn5VFfoG6zgYff04fBdKGvHAai+wQSOY=') const session = new StringSession(process.env.BOT_SID || '')
const session = new StringSession('1AgAOMTQ5LjE1NC4xNjcuNTEBuzSgmBQR5/m8M8cyOnsLCIOkYQJTizJoJRZiPKK+eBjMuodc0JuKQwzeWBRJI/c6YxaBHvokpngf5kr57uly+meSPPlFq6MyoSSQDbEJ3VAAWJu+/ALN0ickE92RjRfM5Kw6DimC9FXuMgJJsoUHtk/i+ZGXy9JB+q67G0yy8NvFIuWpFHJDkwmi0qTlTgJ5UOm4PYkV01iNUcV5siaWFVTTLsetHtBUdMOzg5WjjvuOyYV/MIx+z7ynhvF3DxLPCugxqhCvZ/RW+0vldrTX5TZ0BzIDk2eNFQjRORJcZo6upwvH7aZYStV4DxhIi1dEYu5gyvnt4vkbR5kuvE/GqO0=')
let client let client
@@ -82,38 +80,38 @@ async function updateUserPhoto (userId, data) {
.run({ user_id: userId, photo_id: photoId, photo: file.toString('base64') }) .run({ user_id: userId, photo_id: photoId, photo: file.toString('base64') })
} }
async function registerGroup (telegramId, isChannel) { async function registerChat (telegramId, isChannel) {
db db
.prepare(`insert or ignore into groups (telegram_id, is_channel) values (:telegram_id, :is_channel)`) .prepare(`insert or ignore into chats (telegram_id, is_channel) values (:telegram_id, :is_channel)`)
.safeIntegers(true) .safeIntegers(true)
.run({ telegram_id: telegramId, is_channel: +isChannel }) .run({ telegram_id: telegramId, is_channel: +isChannel })
const row = db const row = db
.prepare(`select id, name from groups where telegram_id = :telegram_id`) .prepare(`select id, name from chats where telegram_id = :telegram_id`)
.safeIntegers(true) .safeIntegers(true)
.get({telegram_id: telegramId}) .get({telegram_id: telegramId})
if (!row?.name) { if (!row?.name) {
const entity = isChannel ? { channelId: telegramId } : { chatId: telegramId } const entity = isChannel ? { channelId: telegramId } : { chatId: telegramId }
const group = await client.getEntity(isChannel ? new Api.PeerChannel(entity) : new Api.PeerChat(entity)) const chat = await client.getEntity(isChannel ? new Api.PeerChannel(entity) : new Api.PeerChat(entity))
db db
.prepare(`update groups set name = :name where id = :group_id`) .prepare(`update chats set name = :name where id = :chat_id`)
.run({ group_id: row.id, name: group.title }) .run({ chat_id: row.id, name: chat.title })
} }
return row.id return row.id
} }
async function attachGroup(groupId, isChannel, projectId) { async function attachChat(chatId, isChannel, projectId) {
const info = db const info = db
.prepare(`update groups set project_id = :project_id where id = :group_id and coalesce(project_id, 1) = 1`) .prepare(`update chats set project_id = :project_id where id = :chat_id and coalesce(project_id, 1) = 1`)
.run({ group_id: groupId, project_id: projectId }) .run({ chat_id: chatId, project_id: projectId })
if (info.changes == 1) { if (info.changes == 1) {
const inputPeer = isChannel ? const inputPeer = isChannel ?
new Api.InputPeerChannel({ channelId: tgGroupId }) : new Api.InputPeerChannel({ channelId: tgChatId }) :
new Api.InputPeerChat({ chatlId: tgGroupId }) new Api.InputPeerChat({ chatId: tgChatId })
const query = `select (select name from customers where id = p.customer_id) || ' >> ' || p.name from projects p where id = :project_id` const query = `select (select name from customers where id = p.customer_id) || ' >> ' || p.name from projects p where id = :project_id`
const message = db const message = db
@@ -127,14 +125,14 @@ async function attachGroup(groupId, isChannel, projectId) {
return info.changes == 1 return info.changes == 1
} }
async function onGroupAttach (tgGroupId, isChannel) { async function onChatAttach (tgChatId, isChannel) {
const projectId = db const projectId = db
.prepare(`select project_id from groups where telegram_id = :telegram_id`) .prepare(`select project_id from chats where telegram_id = :telegram_id`)
.safeIntegers(true) .safeIntegers(true)
.pluck(true) .pluck(true)
.get({ telegram_id: tgGroupId }) .get({ telegram_id: tgChatId })
const entity = isChannel ? { channelId: tgGroupId } : { chatId: tgGroupId } const entity = isChannel ? { channelId: tgChatId } : { chatId: tgChatId }
const inputPeer = await client.getEntity( isChannel ? const inputPeer = await client.getEntity( isChannel ?
new Api.InputPeerChannel(entity) : new Api.InputPeerChannel(entity) :
new Api.InputPeerChat(entity) new Api.InputPeerChat(entity)
@@ -151,48 +149,44 @@ async function onGroupAttach (tgGroupId, isChannel) {
unpin: false unpin: false
})) }))
//fs.appendFileSync('./1.log', '\n>' + tgGroupId + ':' + isChannel + '<\n') //fs.appendFileSync('./1.log', '\n>' + tgChatId + ':' + isChannel + '<\n')
} }
async function reloadGroupUsers(groupId, onlyReset) { async function reloadChatUsers(chatId, onlyReset) {
db db
.prepare(`delete from group_users where group_id = :group_id`) .prepare(`delete from chat_users where chat_id = :chat_id`)
.run({ group_id: groupId }) .run({ chat_id: chatId })
if (onlyReset) if (onlyReset)
return return
const group = db const chat = db
.prepare(`select telegram_id, is_channel, access_hash from groups where id = :group_id`) .prepare(`select telegram_id, is_channel, access_hash from chats where id = :chat_id`)
.get({ group_id: groupId}) .get({ chat_id: chatId})
console.log (123, group) if (!chat)
if (!group)
return return
const tgGroupId = group.telegram_id const tgChatId = chat.telegram_id
const isChannel = group.is_channel const isChannel = chat.is_channel
let accessHash = group.access_hash let accessHash = chat.access_hash
console.log ('HERE')
db db
.prepare(`update groups set access_hash = :access_hash where id = :group_id`) .prepare(`update chats set access_hash = :access_hash where id = :chat_id`)
.safeIntegers(true) .safeIntegers(true)
.run({ .run({
group_id: groupId, chat_id: chatId,
access_hash: accessHash, access_hash: accessHash,
}) })
const result = isChannel ? const result = isChannel ?
await client.invoke(new Api.channels.GetParticipants({ await client.invoke(new Api.channels.GetParticipants({
channel: new Api.PeerChannel({ channelId: tgGroupId }), channel: new Api.PeerChannel({ channelId: tgChatId }),
filter: new Api.ChannelParticipantsRecent(), filter: new Api.ChannelParticipantsRecent(),
limit: 999999, limit: 999999,
offset: 0 offset: 0
})) : await client.invoke(new Api.messages.GetFullChat({ })) : await client.invoke(new Api.messages.GetFullChat({
chatId: tgGroupId, chatId: tgChatId,
})) }))
const users = result.users.filter(user => !user.bot) const users = result.users.filter(user => !user.bot)
@@ -202,8 +196,8 @@ async function reloadGroupUsers(groupId, onlyReset) {
if (updateUser(userId, user)) { if (updateUser(userId, user)) {
await updateUserPhoto (userId, user) await updateUserPhoto (userId, user)
const query = `insert or ignore into group_users (group_id, user_id) values (:group_id, :user_id)` const query = `insert or ignore into chat_users (chat_id, user_id) values (:chat_id, :user_id)`
db.prepare(query).run({ group_id: groupId, user_id: userId }) db.prepare(query).run({ chat_id: chatId, user_id: userId })
} }
} }
} }
@@ -212,11 +206,11 @@ async function registerUpload(data) {
if (!data.projectId || !data.media) if (!data.projectId || !data.media)
return false return false
const uploadGroup = db const uploadChat = db
.prepare(` .prepare(`
select id, telegram_id, project_id, is_channel, access_hash select id, telegram_id, project_id, is_channel, access_hash
from groups from chats
where id = (select upload_group_id where id = (select upload_chat_id
from customers from customers
where id = (select customer_id from projects where id = :project_id limit 1) where id = (select customer_id from projects where id = :project_id limit 1)
limit 1) limit 1)
@@ -225,14 +219,14 @@ async function registerUpload(data) {
.safeIntegers(true) .safeIntegers(true)
.get({project_id: data.projectId}) .get({project_id: data.projectId})
if (!uploadGroup || !uploadGroup.telegram_id || uploadGroup.id == data.originGroupId) if (!uploadChat || !uploadChat.telegram_id || uploadChat.id == data.originchatId)
return false return false
const tgUploadGroupId = uploadGroup.telegram_id const tgUploadChatId = uploadChat.telegram_id
const peer = uploadGroup.is_channel ? const peer = uploadChat.is_channel ?
new Api.PeerChannel({ channelId: tgUploadGroupId }) : new Api.PeerChannel({ channelId: tgUploadChatId }) :
new Api.PeerChat({ chatlId: tgUploadGroupId }) new Api.PeerChat({ chatlId: tgUploadChatId })
let resultId = 0 let resultId = 0
@@ -248,16 +242,16 @@ async function registerUpload(data) {
const update = result.updates.find(u => const update = result.updates.find(u =>
(u.className == 'UpdateNewMessage' || u.className == 'UpdateNewChannelMessage') && (u.className == 'UpdateNewMessage' || u.className == 'UpdateNewChannelMessage') &&
u.message.className == 'Message' && u.message.className == 'Message' &&
(u.message.peerId.channelId?.value == tgUploadGroupId || u.message.peerId.chatId?.value == tgUploadGroupId) && (u.message.peerId.channelId?.value == tgUploadChatId || u.message.peerId.chatId?.value == tgUploadChatId) &&
u.message.media) u.message.media)
const udoc = update?.message?.media?.document const udoc = update?.message?.media?.document
if (udoc) { if (udoc) {
resultId = db resultId = db
.prepare(` .prepare(`
insert into documents (project_id, origin_group_id, origin_message_id, group_id, message_id, insert into documents (project_id, origin_chat_id, origin_message_id, chat_id, message_id,
file_id, access_hash, filename, mime, caption, size, published_by, parent_type, parent_id) file_id, access_hash, filename, mime, caption, size, published_by, parent_type, parent_id)
values (:project_id, :origin_group_id, :origin_message_id, :group_id, :message_id, values (:project_id, :origin_chat_id, :origin_message_id, :chat_id, :message_id,
:file_id, :access_hash, :filename, :mime, :caption, :size, :published_by, :parent_type, :parent_id) :file_id, :access_hash, :filename, :mime, :caption, :size, :published_by, :parent_type, :parent_id)
returning id returning id
`) `)
@@ -265,9 +259,9 @@ async function registerUpload(data) {
.pluck(true) .pluck(true)
.get({ .get({
project_id: data.projectId, project_id: data.projectId,
origin_group_id: data.originGroupId, origin_chat_id: data.originchatId,
origin_message_id: data.originMessageId, origin_message_id: data.originMessageId,
group_id: uploadGroup.id, chat_id: uploadChat.id,
message_id: update.message.id, message_id: update.message.id,
file_id: udoc.id.value, file_id: udoc.id.value,
filename: udoc.attributes.find(attr => attr.className == 'DocumentAttributeFilename')?.fileName, filename: udoc.attributes.find(attr => attr.className == 'DocumentAttributeFilename')?.fileName,
@@ -291,14 +285,14 @@ async function registerUpload(data) {
async function onNewServiceMessage (msg, isChannel) { async function onNewServiceMessage (msg, isChannel) {
const action = msg.action || {} const action = msg.action || {}
const tgGroupId = isChannel ? msg.peerId?.channelId?.value : msg.peerId?.chatId?.value const tgChatId = isChannel ? msg.peerId?.channelId?.value : msg.peerId?.chatId?.value
const groupId = await registerGroup(tgGroupId, isChannel) const chatId = await registerChat(tgChatId, isChannel)
// Group/Channel rename // Ghat rename
if (action.className == 'MessageActionChatEditTitle') { if (action.className == 'MessageActionChatEditTitle') {
const info = db const info = db
.prepare(` .prepare(`
update groups update chats
set name = :name, is_channel = :is_channel, last_update_time = :last_update_time set name = :name, is_channel = :is_channel, last_update_time = :last_update_time
where telegram_id = :telegram_id where telegram_id = :telegram_id
`) `)
@@ -307,7 +301,7 @@ async function onNewServiceMessage (msg, isChannel) {
name: action.title, name: action.title,
is_channel: +isChannel, is_channel: +isChannel,
last_update_time: Math.floor (Date.now() / 1000), last_update_time: Math.floor (Date.now() / 1000),
telegram_id: tgGroupId telegram_id: tgChatId
}) })
} }
@@ -315,7 +309,7 @@ async function onNewServiceMessage (msg, isChannel) {
if (action.className == 'MessageActionChatMigrateTo') { if (action.className == 'MessageActionChatMigrateTo') {
const info = db const info = db
.prepare(` .prepare(`
update groups update chats
set telegram_id = :new_telegram_id, name = :name, is_channel = 1, last_update_time = :last_update_time set telegram_id = :new_telegram_id, name = :name, is_channel = 1, last_update_time = :last_update_time
where telegram_id = :old_telegram_id where telegram_id = :old_telegram_id
`) `)
@@ -323,7 +317,7 @@ async function onNewServiceMessage (msg, isChannel) {
.run({ .run({
name: action.title, name: action.title,
last_update_time: Date.now() / 1000, last_update_time: Date.now() / 1000,
old_telegram_id: tgGroupId, old_telegram_id: tgChatId,
new_telegram_id: action.channelId.value new_telegram_id: action.channelId.value
}) })
} }
@@ -352,27 +346,27 @@ async function onNewServiceMessage (msg, isChannel) {
} }
const query = isAdd ? const query = isAdd ?
`insert or ignore into group_users (group_id, user_id) values (:group_id, :user_id)` : `insert or ignore into chat_users (chat_id, user_id) values (:chat_id, :user_id)` :
`delete from group_users where group_id = :group_id and user_id = :user_id` `delete from chat_users where chat_id = :chat_id and user_id = :user_id`
db.prepare(query).run({ group_id: groupId, user_id: userId }) db.prepare(query).run({ chat_id: chatId, user_id: userId })
} }
} }
} }
} }
async function onNewMessage (msg, isChannel) { async function onNewMessage (msg, isChannel) {
const tgGroupId = isChannel ? msg.peerId?.channelId?.value : msg.peerId?.chatId?.value const tgChatId = isChannel ? msg.peerId?.channelId?.value : msg.peerId?.chatId?.value
const groupId = await registerGroup(tgGroupId, isChannel) const chatId = await registerChat(tgChatId, isChannel)
// Document is detected // Document is detected
if (msg.media?.document) { if (msg.media?.document) {
const doc = msg.media.document const doc = msg.media.document
const projectId = db const projectId = db
.prepare(`select project_id from groups where telegram_id = :telegram_id`) .prepare(`select project_id from chats where telegram_id = :telegram_id`)
.safeIntegers(true) .safeIntegers(true)
.pluck(true) .pluck(true)
.get({telegram_id: tgGroupId}) .get({telegram_id: tgChatId})
const media = new Api.InputMediaDocument({ const media = new Api.InputMediaDocument({
id: new Api.InputDocument({ id: new Api.InputDocument({
@@ -386,7 +380,7 @@ async function onNewMessage (msg, isChannel) {
projectId, projectId,
media, media,
caption: msg.message, caption: msg.message,
originGroupId: groupId, originchatId: chatId,
originMessageId: msg.id, originMessageId: msg.id,
parentType: 0, parentType: 0,
publishedBy: registerUser (msg.fromId?.userId?.value) publishedBy: registerUser (msg.fromId?.userId?.value)
@@ -399,22 +393,22 @@ async function onNewMessage (msg, isChannel) {
select name select name
from projects from projects
where id in ( where id in (
select project_id from groups where id = :group_id select project_id from chats where id = :chat_id
union union
select id from projects where upload_group_id = :group_id) select id from projects where upload_chat_id = :chat_id)
`) `)
.pluck(true) .pluck(true)
.get({ group_id: groupId }) .get({ chat_id: chatId })
if (projectName) if (projectName)
return await bot.sendMessage(groupId, 'Группа уже используется на проекте ' + projectName) return await bot.sendMessage(chatId, 'Группа уже используется на проекте ' + projectName)
const [_, time64, key] = msg.message.substr(3).split('-') const [_, time64, key] = msg.message.substr(3).split('-')
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
const time = Buffer.from(time64, 'base64') const time = Buffer.from(time64, 'base64')
if (now - 3600 >= time && time >= now) if (now - 3600 >= time && time >= now)
return await bot.sendMessage(groupId, 'Время действия ключа для привязки истекло') return await bot.sendMessage(chatId, 'Время действия ключа для привязки истекло')
const projectId = db const projectId = db
.prepare(`select id from projects where generate_key(id, :time) = :key`) .prepare(`select id from projects where generate_key(id, :time) = :key`)
@@ -422,8 +416,8 @@ async function onNewMessage (msg, isChannel) {
.get({ key: msg.message.trim(), time }) .get({ key: msg.message.trim(), time })
if (projectId) { if (projectId) {
await attachGroup(groupId, isChannel, projectId) await attachChat(chatId, isChannel, projectId)
await onGroupAttach(tgGroupId, isChannel) await onChatAttach(tgChatId, isChannel)
} }
} }
@@ -440,12 +434,12 @@ async function onNewMessage (msg, isChannel) {
db db
.prepare(` .prepare(`
update customers update customers
set upload_group_id = :group_id set upload_chat_id = :chat_id
where id = :customer_id and telegram_user_id = :telegram_user_id where id = :customer_id and telegram_user_id = :telegram_user_id
`) `)
.safeIntegers(true) .safeIntegers(true)
.run({ .run({
group_id: groupId, chat_id: chatId,
customer_id: customerId, customer_id: customerId,
telegram_user_id: tgUserId telegram_user_id: tgUserId
}) })
@@ -462,9 +456,9 @@ async function onNewMessage (msg, isChannel) {
db db
.prepare(` .prepare(`
update groups update chats
set project_id = :project_id set project_id = :project_id
where id = :group_id and exists( where id = :chat_id and exists(
select 1 select 1
from customers from customers
where id = :customer_id and telegram_user_id = :telegram_user_id) where id = :customer_id and telegram_user_id = :telegram_user_id)
@@ -472,13 +466,13 @@ async function onNewMessage (msg, isChannel) {
.safeIntegers(true) .safeIntegers(true)
.run({ .run({
project_id: projectId, project_id: projectId,
group_id: groupId, chat_id: chatId,
customer_id: customerId, customer_id: customerId,
telegram_user_id: tgUserId telegram_user_id: tgUserId
}) })
await reloadGroupUsers(groupId, false) await reloadChatUsers(chatId, false)
await onGroupAttach(tgGroupId, isChannel) await onChatAttach(tgChatId, isChannel)
} }
} }
} }
@@ -516,34 +510,34 @@ async function onNewUserMessage (msg) {
} }
async function onUpdatePaticipant (update, isChannel) { async function onUpdatePaticipant (update, isChannel) {
const tgGroupId = isChannel ? update.channelId?.value : update.chatlId?.value const tgChatId = isChannel ? update.channelId?.value : update.chatlId?.value
if (!tgGroupId || update.userId?.value != bot.id) if (!tgChatId || update.userId?.value != bot.id)
return return
const groupId = await registerGroup (tgGroupId, isChannel) const chatId = await registerChat (tgChatId, isChannel)
const isBan = update.prevParticipant && !update.newParticipant const isBan = update.prevParticipant && !update.newParticipant
const isAdd = (!update.prevParticipant || update.prevParticipant?.className == 'ChannelParticipantBanned') && update.newParticipant const isAdd = (!update.prevParticipant || update.prevParticipant?.className == 'ChannelParticipantBanned') && update.newParticipant
if (isBan || isAdd) if (isBan || isAdd)
await reloadGroupUsers(groupId, isBan) await reloadChatUsers(chatId, isBan)
if (isBan) { if (isBan) {
db db
.prepare(`update groups set project_id = null where id = :group_id`) .prepare(`update chats set project_id = null where id = :chat_id`)
.run({group_id: groupId}) .run({chat_id: chatId})
} }
const botCanBan = update.newParticipant?.adminRights?.banUsers || 0 const botCanBan = update.newParticipant?.adminRights?.banUsers || 0
db db
.prepare(`update groups set bot_can_ban = :bot_can_ban where id = :group_id`) .prepare(`update chats set bot_can_ban = :bot_can_ban where id = :chat_id`)
.run({group_id: groupId, bot_can_ban: +botCanBan}) .run({chat_id: chatId, bot_can_ban: +botCanBan})
} }
class Bot extends EventEmitter { class Bot extends EventEmitter {
async start (apiId, apiHash, botAuthToken) { async start (apiId, apiHash, botAuthToken) {
this.id = 7236504417n this.id = BigInt(botAuthToken.split(':')[0])
client = new TelegramClient(session, apiId, apiHash, {}) client = new TelegramClient(session, apiId, apiHash, {})
@@ -568,7 +562,7 @@ class Bot extends EventEmitter {
}) })
await client.start({botAuthToken}) await client.start({botAuthToken})
console.log('SID: ', session.save()) console.log('BOT_SID: ', session.save())
} }
async uploadDocument(projectId, fileName, mime, data, parentType, parentId, publishedBy) { async uploadDocument(projectId, fileName, mime, data, parentType, parentId, publishedBy) {
@@ -616,20 +610,20 @@ class Bot extends EventEmitter {
} }
} }
async reloadGroupUsers(groupId, onlyReset) { async reloadChatUsers(chatId, onlyReset) {
return reloadGroupUsers(groupId, onlyReset) return reloadChatUsers(chatId, onlyReset)
} }
async sendMessage (groupId, message) { async sendMessage (chatId, message) {
const group = db const chat = db
.prepare(`select telegram_id, is_channel from groups where id = :group_id`) .prepare(`select telegram_id, is_channel from chats where id = :chat_id`)
.get({ group_id: groupId}) .get({ chat_id: chatId})
if (!group) if (!chat)
return return
const entity = group.is_channel ? { channelId: group.telegram_id } : { chatId: group.telegram_id } const entity = chat.is_channel ? { channelId: chat.telegram_id } : { chatId: chat.telegram_id }
const inputPeer = await client.getEntity( group.is_channel ? const inputPeer = await client.getEntity( chat.is_channel ?
new Api.InputPeerChannel(entity) : new Api.InputPeerChannel(entity) :
new Api.InputPeerChat(entity) new Api.InputPeerChat(entity)
) )
@@ -640,19 +634,19 @@ class Bot extends EventEmitter {
await delay(1000) await delay(1000)
} }
async leaveGroup (groupId) { async leaveChat (chatId) {
const group = db const chat = db
.prepare(`select telegram_id, is_channel from groups where id = :group_id`) .prepare(`select telegram_id, is_channel from chats where id = :chat_id`)
.get({ group_id: groupId}) .get({ chat_id: chatId})
if (!group) if (!chat)
return return
if (group.is_channel) { if (chat.is_channel) {
const inputPeer = await client.getEntity(new Api.InputPeerChannel({ channelId: group.telegram_id })) const inputPeer = await client.getEntity(new Api.InputPeerChannel({ channelId: chat.telegram_id }))
await client.invoke(new Api.channels.LeaveChannel({ channel: inputPeer })) await client.invoke(new Api.channels.LeaveChannel({ channel: inputPeer }))
} else { } else {
await client.invoke(new Api.messages.DeleteChatUser({ chatId: group.telegram_id, userId: this.id })) await client.invoke(new Api.messages.DeleteChatUser({ chatId: chat.telegram_id, userId: this.id }))
} }
} }
} }

View File

@@ -19,9 +19,9 @@ function hasAccess(project_id, user_id) {
return !!db return !!db
.prepare(` .prepare(`
select 1 select 1
from group_users from chat_users
where user_id = :user_id and where user_id = :user_id and
group_id in (select id from groups where project_id = :project_id) and chat_id in (select id from chats where project_id = :project_id) and
not exists(select 1 from user_details where user_id = :user_id and project_id = :project_id and is_blocked = 1) and not exists(select 1 from user_details where user_id = :user_id and project_id = :project_id and is_blocked = 1) and
not exists(select 1 from projects where id = :project_id and is_deleted = 1) not exists(select 1 from projects where id = :project_id and is_deleted = 1)
`) `)
@@ -70,13 +70,13 @@ app.get('/project', (req, res, next) => {
const rows = db const rows = db
.prepare(` .prepare(`
select p.id, p.name, p.description, p.logo, select p.id, p.name, p.description, p.logo,
c.name customer_name, c.upload_group_id <> 0 has_upload c.name customer_name, c.upload_chat_id <> 0 has_upload
from projects p from projects p
inner join customers c on p.customer_id = c.id inner join customers c on p.customer_id = c.id
where p.id in ( where p.id in (
select project_id select project_id
from groups from chats
where id in (select group_id from group_users where user_id = :user_id) where id in (select chat_id from chat_users where user_id = :user_id)
) and not exists(select 1 from user_details where user_id = :user_id and project_id = p.id and is_blocked = 1) ) and not exists(select 1 from user_details where user_id = :user_id and project_id = p.id and is_blocked = 1)
${where} and is_deleted <> 1 ${where} and is_deleted <> 1
`) `)
@@ -114,9 +114,9 @@ app.get('/project/:pid(\\d+)/user', (req, res, next) => {
.prepare(` .prepare(`
with actuals (user_id) as ( with actuals (user_id) as (
select distinct user_id select distinct user_id
from group_users from chat_users
where group_id in (select id from groups where project_id = :project_id) where chat_id in (select id from chats where project_id = :project_id)
and group_id in (select group_id from group_users where user_id = :user_id) and chat_id in (select chat_id from chat_users where user_id = :user_id)
), ),
contributors (user_id) as ( contributors (user_id) as (
select created_by from tasks where project_id = :project_id select created_by from tasks where project_id = :project_id
@@ -193,29 +193,29 @@ app.get('/project/:pid(\\d+)/user', (req, res, next) => {
}) })
app.get('/project/:pid(\\d+)/user/reload', async (req, res, next) => { app.get('/project/:pid(\\d+)/user/reload', async (req, res, next) => {
const groupIds = db const chatIds = db
.prepare(`select id from groups where project_id = :project_id`) .prepare(`select id from chats where project_id = :project_id`)
.all(res.locals) .all(res.locals)
.map(e => e.id) .map(e => e.id)
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
for (const groupId of groupIds) { for (const chatId of chatIds) {
await bot.reloadGroupUsers(groupId) await bot.reloadGroupUsers(chatId)
await sleep(1000) await sleep(1000)
} }
res.status(200).json({success: true}) res.status(200).json({success: true})
}) })
app.get('/project/:pid(\\d+)/group', (req, res, next) => { app.get('/project/:pid(\\d+)/chat', (req, res, next) => {
const where = req.query.id ? ' and id = ' + parseInt(req.query.id) : '' const where = req.query.id ? ' and id = ' + parseInt(req.query.id) : ''
const rows = db const rows = db
.prepare(` .prepare(`
select id, name, telegram_id select id, name, telegram_id
from groups from chats
where project_id = :project_id and id in (select group_id from group_users where user_id = :user_id) where project_id = :project_id and id in (select chat_id from chat_users where user_id = :user_id)
${where} ${where}
`) `)
.all(res.locals) .all(res.locals)
@@ -226,8 +226,8 @@ app.get('/project/:pid(\\d+)/group', (req, res, next) => {
res.status(200).json({success: true, data: where ? rows[0] : rows}) res.status(200).json({success: true, data: where ? rows[0] : rows})
}) })
app.get('/project/:pid(\\d+)/group/:gid(\\d+)', (req, res, next) => { app.get('/project/:pid(\\d+)/chat/:gid(\\d+)', (req, res, next) => {
res.redirect(req.baseUrl + `/project/${req.params.pid}/group?id=${req.params.gid}`) res.redirect(req.baseUrl + `/project/${req.params.pid}/chat?id=${req.params.gid}`)
}) })
// TASK // TASK
@@ -237,8 +237,8 @@ app.get('/project/:pid(\\d+)/task', (req, res, next) => {
const rows = db const rows = db
.prepare(` .prepare(`
select id, name, created_by, assigned_to, priority, status, time_spent, create_date, plan_date, close_date, select id, name, created_by, assigned_to, priority, status, time_spent, create_date, plan_date, close_date,
(select json_group_array(user_id) from task_users where task_id = t.id) observers, (select json_chat_array(user_id) from task_users where task_id = t.id) observers,
(select json_group_array(id) from documents where parent_type = 1 and parent_id = t.id) attachments (select json_chat_array(id) from documents where parent_type = 1 and parent_id = t.id) attachments
from tasks t from tasks t
where project_id = :project_id and where project_id = :project_id and
(created_by = :user_id or assigned_to = :user_id or exists(select 1 from task_users where task_id = t.id and user_id = :user_id)) (created_by = :user_id or assigned_to = :user_id or exists(select 1 from task_users where task_id = t.id and user_id = :user_id))
@@ -351,8 +351,8 @@ app.put('/project/:pid(\\d+)/task/:tid(\\d+)/observer', (req, res, next) => {
let rows = db let rows = db
.prepare(` .prepare(`
select user_id select user_id
from group_users from chat_users
where group_id in (select id from groups where project_id = :project_id) where chat_id in (select id from chats where project_id = :project_id)
`) `)
.pluck(true) .pluck(true)
.all(res.locals) .all(res.locals)
@@ -379,8 +379,8 @@ app.get('/project/:pid(\\d+)/meeting', (req, res, next) => {
const rows = db const rows = db
.prepare(` .prepare(`
select id, name, description, created_by, meet_date, select id, name, description, created_by, meet_date,
(select json_group_array(user_id) from meeting_users where meeting_id = m.id) participants, (select json_chat_array(user_id) from meeting_users where meeting_id = m.id) participants,
(select json_group_array(id) from documents where parent_type = 2 and parent_id = m.id) attachments (select json_chat_array(id) from documents where parent_type = 2 and parent_id = m.id) attachments
from meetings m from meetings m
where project_id = :project_id and where project_id = :project_id and
(created_by = :user_id or exists(select 1 from meeting_users where meeting_id = m.id and user_id = :user_id)) (created_by = :user_id or exists(select 1 from meeting_users where meeting_id = m.id and user_id = :user_id))
@@ -483,8 +483,8 @@ app.put('/project/:pid(\\d+)/meeting/:mid(\\d+)/participants', (req, res, next)
let rows = db let rows = db
.prepare(` .prepare(`
select user_id select user_id
from group_users from chat_users
where group_id in (select id from groups where project_id = :project_id) where chat_id in (select id from chats where project_id = :project_id)
`) `)
.pluck(true) // .raw? .pluck(true) // .raw?
.all(res.locals) .all(res.locals)
@@ -516,10 +516,10 @@ app.get('/project/:pid(\\d+)/document', (req, res, next) => {
// To-Do: отдавать готовую ссылку --> как минимум GROUP_ID надо заменить на tgGroupId // To-Do: отдавать готовую ссылку --> как минимум GROUP_ID надо заменить на tgGroupId
const rows = db const rows = db
.prepare(` .prepare(`
select id, origin_group_id, origin_message_id, filename, mime, caption, size, published_by, parent_id, parent_type select id, origin_chat_id, origin_message_id, filename, mime, caption, size, published_by, parent_id, parent_type
from documents d from documents d
where project_id = :project_id ${where} and ( where project_id = :project_id ${where} and (
origin_group_id in (select group_id from group_users where user_id = :user_id) origin_chat_id in (select chat_id from chat_users where user_id = :user_id)
or or
parent_type = 1 and parent_id in ( parent_type = 1 and parent_id in (
select id select id
@@ -566,9 +566,9 @@ app.use('/project/:pid(\\d+)/document/:did(\\d+)', (req, res, next) => {
throw Error('NOT_FOUND::404') throw Error('NOT_FOUND::404')
if (doc.parent_type == 0) { if (doc.parent_type == 0) {
res.locals.group_id = doc.group_id res.locals.chat_id = doc.chat_id
const row = db const row = db
.prepare(`select 1 from group_users where group_id = :group_id and user_id = :user_id`) .prepare(`select 1 from chat_users where chat_id = :chat_id and user_id = :user_id`)
.get(res.locals) .get(res.locals)
if (row) { if (row) {

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -10,9 +10,10 @@ create table if not exists customers (
json_balance text default '{}', json_balance text default '{}',
is_blocked integer default 0, is_blocked integer default 0,
json_company text default '{}', json_company text default '{}',
upload_group_id integer, upload_chat_id integer,
json_backup_server text default '{}', json_backup_server text default '{}',
json_backup_params text default '{}' json_backup_params text default '{}',
json_settings text default '{}'
) strict; ) strict;
create table if not exists projects ( create table if not exists projects (
@@ -21,10 +22,11 @@ create table if not exists projects (
name text not null check(trim(name) <> '' and length(name) < 256), name text not null check(trim(name) <> '' and length(name) < 256),
description text check(description is null or length(description) < 4096), description text check(description is null or length(description) < 4096),
logo text, logo text,
is_deleted integer default 0 is_logo_bg integer default 0,
is_archived integer default 0
) strict; ) strict;
create table if not exists groups ( create table if not exists chats (
id integer primary key autoincrement, id integer primary key autoincrement,
project_id integer references projects(id) on delete cascade, project_id integer references projects(id) on delete cascade,
name text, name text,
@@ -35,7 +37,7 @@ create table if not exists groups (
user_count integer, user_count integer,
last_update_time integer last_update_time integer
); );
create unique index if not exists idx_groups_telegram_id on groups (telegram_id); create unique index if not exists idx_chats_telegram_id on chats (telegram_id);
create table if not exists users ( create table if not exists users (
id integer primary key autoincrement, id integer primary key autoincrement,
@@ -90,9 +92,9 @@ create table if not exists meetings (
create table if not exists documents ( create table if not exists documents (
id integer primary key autoincrement, id integer primary key autoincrement,
project_id integer references projects(id) on delete cascade, project_id integer references projects(id) on delete cascade,
origin_group_id integer references groups(id) on delete set null, origin_chat_id integer references chats(id) on delete set null,
origin_message_id integer, origin_message_id integer,
group_id integer references groups(id) on delete set null, chat_id integer references chats(id) on delete set null,
message_id integer, message_id integer,
file_id integer, file_id integer,
access_hash integer, access_hash integer,
@@ -142,20 +144,20 @@ create table if not exists company_users (
primary key (company_id, user_id) primary key (company_id, user_id)
) without rowid; ) without rowid;
create table if not exists group_users ( create table if not exists chat_users (
group_id integer references groups(id) on delete cascade, chat_id integer references chats(id) on delete cascade,
user_id integer references users(id) on delete cascade, user_id integer references users(id) on delete cascade,
primary key (group_id, user_id) primary key (chat_id, user_id)
) without rowid; ) without rowid;
pragma foreign_keys = on; pragma foreign_keys = on;
create trigger if not exists trg_groups_update after update create trigger if not exists trg_chats_update after update
on groups on chats
when NEW.project_id is null when NEW.project_id is null
begin begin
delete from group_users where group_id = NEW.id; delete from chat_users where chat_id = NEW.id;
end; end;

BIN
backend/docs/api.xls Normal file

Binary file not shown.

12
backend/docs/public.txt Normal file
View File

@@ -0,0 +1,12 @@
Описание ПО
Программа состоит из двух частей: бота и приложение miniapp, привязанное к боту. Приложение отображает проекты для пользователя, задачи и встречи на проекте, прикрепленные к ним файлы, а также список тех, с кем пользователь пересекался в группах. Бот отслеживает не только участников группы, но и при наличии админских прав в группе, пересылаемые файлы и сообщения для резервного копирования.
Термины
Клиенты - специальные аккаунты для организаций, регистрируемые в miniapp, позволяющие управлять проектами и контактами на них.
Пользователи - пользователи Telegram, участвующие в одной или нескольких группах где есть бот. Пользователи могут заходить в miniapp и
После добавления бота в группу, группа привязывается к специальному внутреннему клиенту, который имеет только один проект "Вне проектов". Администратор группы может
После добавления в группу и привязки токеном к аккаунту клиента, бот отслеживает новые сообщения в группе. При обнаружении файла он копируется с специальную группу, указанную клиентом, и регистрирует его в списке файлов, доступных для участников группы. Бот не хранит ни сами файлы, ни сообщения.

View File

@@ -0,0 +1,93 @@
id-телеграма могут быть больше 32-битного int, поэтому лучше использовать свои id + кеш, который позволит быстро конвертировать одни id в другие. Хотя может быть использование BigInt будет достаточно. Большие id могут негативно сказаться на скорости выборки данных и индексировании.
A: Будут использоваться свои id, а ID Telegram записиываться в отдельные колонки.
-----------------------------------------------------
По хорошему для каждого customer надо иметь свою базу. Поскольку для отображения данных проекта требуется информация только именно этого customer, то информация может быть локализвана. При этом остается общая база, в которой хранится список пользователей (таблица users) и информация, необходимая для формирования первой сводной страницы, напр. общее число незавершенных задач и календарь совещаний, а также id-клиентов, у которых пользователь участвует/вал в проектах.
? Можно ли сделать так, чтобы клиент имел свою установку, но при этом пользователи обращались к нашему боту/miniapp?
Если данные запрашивать не напрямую из базы, а по http/https, то каждый узел будет иметь свою базу.
-----------------------------------------------------
Хотелось бы не хранить сообщения локально. А запрашивать их за день и отправлять их бекап в специальную группу. Бот имеет ограничение 20-30 запросов в секунду, т.е. за минуту бот может получить данные из 1200 чатов.
A: Не хранить сообщения. Запрашивать их, если клиент установил настройку.
-----------------------------------------------------
При удалении проекта/задачи/встречи/компании надо также чистить links. Возможно лучше сделать отдельные таблицы для каждой связки, чтобы использовать FK + delete cascade
A: Операция выполняется DELETE CASCADE автоматически
-----------------------------------------------------
ФИО пользователей задает customer и ФИО в адресной книге всегда показывается. Телефон задает пользователь и пользователь может указать на каком проекте его показывать, а где нет. По умолчанию "Не показывать"
<- может храниться в users.is_show_phone = [project_id1, project_id3]
A: Хранится в настойках пользователя в таблице users
-----------------------------------------------------
Если пользователь изменил задачу, то у тех, у кого она есть в миниаппе, должны получить по ней обновление. Вопрос в том, как маякнуть клиенту, что что-то обновилось?
Два варианта: клиент должен запрашивать обновления скажем раз в минуту или же использовать WebSocket. Есть вероятность, что для сервера поддерживать кучу WebScoket слишком накладно.
-----------------------------------------------------
Не делать API, который возвращает одну сущность, а только список.
Если надо сущность, то можно /api/project?id=1
Или можно использовать redirect с /api/project/:id на /api/project?id=1
A: Да
-----------------------------------------------------
Кто заполняет ФИО пользователя? Если админ, тогда возможна ситуация, когда на проекте одного админа человек будет с ФИО, а на проекте другого - нет. Возможно ФИО также должен заполнять сам пользователь. По сути есть некоторая дыра, когда пользователь предоставляет ФИО, телефон и номер телеграма всем желающим,
когда можно заманить человека в группу с ботом и узнать его личные данные.
A: Фамилию задает клиент отдельно на каждом проекте. А также роль и департамент.
-----------------------------------------------------
Польщователь сам решает показывать ли ему ФИО и/или телефон на
проектах конкретного админа системы.
А: Пользователь решает только про телефон
-----------------------------------------------------
Добавить документы может любой из участников. Удалять можно только своё или если создатель.
A: Да.
-----------------------------------------------------
Если переслать документ боту, то бот спросит к какой задаче прикрепить. Автор в этом случае тот, кто переслал сообщение.
A: В пересылаем соообщении нет информации о том, откуда оно было получено, соотв. нельзя определить к какому проекту относится пересылаемое напрямую. Как вариант - использовать последний проект, где пользователь отправил сообщение в группе + кнопка Другой проект.
Если бот не имеет админских прав, то это будет плохо работать.
-----------------------------------------------------
Предположим, что бота добавляют в группу А. Что делать, если не все в группе используют бота/miniapp?
-----------------------------------------------------
Если бот не получает админских прав, то привязать группу токеном не получится, т.к. бот не видит сообщений. Можно сделать, чтобы пользователь отправил комманду /attach<token>. Пока передать параметр в команду невозможно.
https://gram.js.org/tl/bots/SetBotCommands
Для отправки пользователям сообщений бот должен знать не только их id, но и accessHash. Аналогично для отправки сообщения в чат или скачивания файла, т.е. надо хранить еще и это поле.
Когда пользователь отписывается от бота, то надо обнулять его hash. В адресной книге тех, к кому у бота нет доступа, т.е. они участники группы, но у них нет бота, отображать специальным образом.
А: если у того, кому назначили задачу, нет бота и следовательно accessHash, то можно отправлять в один из чатов, в котором участвуют создатель и ответственный, возможно отсортировав по времени последнего сообщения от каждого из них. Наблюдатели должны использовать бота.
-----------------------------------------------------
Возможно дать некоторым пользователям добавлять группы к проекту самостоятельно, путем нажатия кнопки "Добавить группу".
-----------------------------------------------------
В uTasks поначалу отображали только задачи, связанные с пользователем, потом они добавили крыж "Показывать все задачи" по просьбам пользователей. Почему пользователям это понадобилось? Возможно потому что у них нет механизма наблюдателей.
Возможно нужно для контроля начальником => отдельное приложение
-----------------------------------------------------
Предположим, что бот был в группе, а потом его удалили из нее.
Должна ли группа отображаться в списке групп проекта?
-----------------------------------------------------

69
backend/docs/telegram.txt Normal file
View File

@@ -0,0 +1,69 @@
const { Api, TelegramClient } = require('telegram')
const { StringSession } = require('telegram/sessions')
const session = new StringSession('')
const apiId = 26746106
const apiHash = '29e5f83c04e635fa583721473a6003b5'
const BOT_TOKEN = '7236504417:AAGVaodw3cRwGlf-jAhwnYb51OHaXcgpW8k';
const client = new TelegramClient(session, apiId, apiHash, {})
(async function run() {
await client.start({botAuthToken: BOT_TOKEN})
...
})()
Получение информации о себе
const result = await client.getEntity("me")
Группа при создании имеет тип group. Её id выглядит как -4646437202
Для получения информации надо отбросить минус
const result = await client.getEntity(new Api.PeerChat({ chatId: 4646437202n }))
Для получения пользователей и описания
const result = await client.invoke(new Api.messages.GetFullChat({chatId: 4646437202n}))
После включения топиков группа меняется на канал и её id становится вида -1002496664184
Для получения информации
const result = await client.getEntity(new Api.PeerChannel({ channelId: -1002496664184n }))
При этом аттрибут forum = true
Если топики отключить, то id и класс уже не меняется, но forum = false
Для получения списка пользователей канала
const channel = new Api.PeerChannel({ channelId: -1002496664184n })
const result = await client.invoke(
new Api.channels.GetParticipants({
channel,
filter: new Api.ChannelParticipantsRecent(),
limit: 999999,
offset:0 ,
})
)
Скачивание файла из сообщения на диск
client.addEventHandler(async (update) => {
const msg = update.message
if (msg?.className == 'Message') {
const buffer = await client.downloadMedia(msg, {})
fs.writeFileSync("d:/CODES/Telegram/tmp/file", buffer)
}
}, new NewMessage({}));
Примеры кода
Получение списка пользователей в супергруппе
https://gist.github.com/waptik/9de410055eac8a60668ce7ac1e5183ac
Ссылка для выбора группы - https://qna.habr.com/q/1374460
У пользователя есть Name = First Name + Last Name - обязательно, а также username - опционально для t.me/username
Выбор куда отправить - ForwardTo - https://core.telegram.org/widgets/share
Размеры превьюшек - https://core.telegram.org/api/files#stripped-thumbnails
you can just pass an ID.
if channel/superroup => add -100
if group add => -
if user don't add anything
for example for your test group2 it would be
client.sendMessage(-463172658,{params})
https://github.com/gram-js/gramjs/issues/146#issuecomment-913528553

View File

@@ -1 +0,0 @@
node app

View File

@@ -1134,9 +1134,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/node-abi": { "node_modules/node-abi": {
"version": "3.74.0", "version": "3.75.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.74.0.tgz", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz",
"integrity": "sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==", "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"semver": "^7.3.5" "semver": "^7.3.5"

View File

@@ -1,339 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="format-detection" content="telephone=no" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="MobileOptimized" content="176" />
<meta name="HandheldFriendly" content="True" />
<meta name="robots" content="noindex,nofollow" />
<script src="https://telegram.org/js/telegram-web-app.js?57"></script>
<title></title>
<style>
*[hidden] {display: none;}
.block {border: 1px black solid; padding:10px; margin-bottom: 10px;}
#settings input {width: 45%;}
#delete {color:red; cursor: pointer; margin-left: 10px;}
</style>
</head>
<body>
<div id = "adminapp" hidden>
<b>Админка2</b>
<div id = "settings" class = "block">
<b>Настройки</b><br>
<input id = "name" type = "text" placeholder = "Name">
<div>
<button id = "upload-group" href = "">Не задано</button>
<button id = "upload-group-selector" href = "" admin>Задать</button>
</div>
<b>Company</b>
<div id = "company">
<input id = "company-name" type = "text" placeholder = "Name">
<input id = "company-phone" type = "text" placeholder = "Phone">
<input id = "company-site" type = "text" placeholder = "Site">
<input id = "company-description" type = "text" placeholder = "Description">
</div>
<button id = "update-profile">Update</button>
</div>
<div id = "projects" class = "block">
<b>Список проектов</b><button id = "add-project">Добавить/Обновить</button>
<br>
<input id = "project-id" placeholder = "Id">
<input id = "project-name" placeholder = "Name">
<input id = "project-description" placeholder = "Description">
<br>
<div id = "project-list">Loading...</div>
</div>
<div id = "groups" class = "block">
<b>Список групп</b><button id = "add-group" href="" admin>Добавить</button>
<br>
<div id = "group-list">Проект не выбран</div>
<button id = "share" href = "">Отправить ключ подключения</button>
</div>
<div id = "users" class = "block">
<b>Список пользователей</b>
<br>
<div id = "user-list">Проект не выбран</div>
</div>
<div id = "companies" class = "block">
<b>Список компаний</b><button id = "add-company">Добавить/Обновить</button>
<br>
<input id = "company-id" placeholder = "Id">
<input id = "company-name" placeholder = "Name">
<input id = "company-description" placeholder = "Description">
<br>
<div id = "company-list">Проект не выбран</div>
</div>
</div>
<div id = "miniapp" style = "min-height: 20px; border: 1px black solid; padding:10px; word-break: break-all;" hidden>
Пользов
</div>
<script type="text/javascript">
const $miniapp = document.getElementById('miniapp')
const $adminapp = document.getElementById('adminapp')
$adminapp.querySelector('#settings #update-profile').addEventListener('click', async function (event) {
const $e = this.parentNode
const data = {
name: $e.querySelector('#name').value,
company: {
name: $e.querySelector('#company-name').value,
phone: $e.querySelector('#company-phone').value,
site: $e.querySelector('#company-site').value,
description: $e.querySelector('#company-description').value
}
}
const res = await fetch('/api/admin/customer/profile', {
method: 'PUT',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
}).then(res => res.json())
console.log(res)
})
$adminapp.querySelector('#projects #project-list').addEventListener('click', async function (event) {
const $e = event.target
if ($e.id != 'delete')
return
const id = $e.parentNode.id
const res = await fetch('/api/admin/project/' + id, {
method: 'DELETE'
}).then(res => res.json())
.then(res => $e.parentNode.remove())
.catch(alert)
})
$adminapp.querySelector('#projects #project-list').addEventListener('click', async function (event) {
const $e = event.target
if ($e.parentNode.id != 'project-list')
return
$adminapp.querySelector('#projects #project-id').value = $e.id
$adminapp.querySelector('#projects #project-name').value = $e.getAttribute('name')
$adminapp.querySelector('#projects #project-description').value = $e.getAttribute('title')
$adminapp.querySelector('#groups #add-group').setAttribute('href', 'https://t.me/ready_or_not_2025_bot?startgroup=' + $e.id)
reloadGroups($e.id)
reloadUsers($e.id)
reloadCompanies($e.id)
const reqKey = await fetch('/api/admin/project/' + $e.id + '/token').then(res => res.json())
const url = 'https://t.me/share/url?url=' + reqKey.data
$adminapp.querySelector('#groups #share').setAttribute('href', url)
})
$adminapp.querySelector('#companies #company-list').addEventListener('click', async function (event) {
const $e = event.target
if ($e.id != 'delete')
return
const id = $e.parentNode.id
const project_id = $adminapp.querySelector('#groups #group-list').getAttribute('project-id')
const res = await fetch(`/api/admin/project/${project_id}/company/${id}`, {
method: 'DELETE'
}).then(res => res.json())
.then(res => $e.parentNode.remove())
.catch(alert)
})
$adminapp.querySelector('#companies #company-list').addEventListener('click', async function (event) {
const $e = event.target
if ($e.parentNode.id != 'company-list')
return
$adminapp.querySelector('#companies #company-id').value = $e.id
$adminapp.querySelector('#companies #company-name').value = $e.getAttribute('name')
$adminapp.querySelector('#companies #company-description').value = $e.getAttribute('title')
})
$adminapp.querySelector('#groups #group-list').addEventListener('click', async function (event) {
const $e = event.target
if ($e.id != 'delete')
return
const id = $e.parentNode.id
const project_id = $adminapp.querySelector('#groups #group-list').getAttribute('project-id')
const res = await fetch(`/api/admin/project/${project_id}/group/${id}`, {
method: 'DELETE'
}).then(res => res.json())
.then(res => $e.parentNode.remove())
.catch(alert)
})
$adminapp.querySelectorAll('button[href]')
.forEach($e => {
$e.addEventListener('click', async function (event) {
// &admin=change_info+delete_messages+restrict_members+invite_users+pin_messages+promote_members
let url = this.getAttribute('href')
if ($e.hasAttribute('admin'))
url += '&admin=change_info+pin_messages'
window.Telegram.WebApp.openTelegramLink(url)
})
})
async function reloadProjects() {
const reqProjects = await fetch('/api/admin/project').then(res => res.json())
const projects = reqProjects.data
const $projectList = $adminapp.querySelector('#projects #project-list')
$projectList.innerHTML = projects.map(e => `<div id = "${e.id}" title = "${e.description}" name = "${e.name}">${e.name}<span id = "delete">x</div></div>`).join('') || 'Пусто'
}
async function reloadGroups(projectId) {
const req = await fetch('/api/admin/project/' + projectId + '/group').then(res => res.json())
const groups = req.data
const $groupList = $adminapp.querySelector('#groups #group-list')
$groupList.setAttribute('project-id', projectId)
$groupList.innerHTML = groups.map(e => `<div id = "${e.id}" name = "${e.name}">${e.name}<span id = "delete">x</div></div>`).join('') || 'Пусто'
}
async function reloadCompanies(projectId) {
const req = await fetch('/api/admin/project/' + projectId + '/company').then(res => res.json())
const companies = req.data
const $companyList = $adminapp.querySelector('#companies #company-list')
$companyList.setAttribute('project-id', projectId)
$companyList.innerHTML = companies.map(e => `<div id = "${e.id}" name = "${e.name}" title = "${e.description}">${e.name}<span id = "delete">x</div></div>`).join('') || 'Пусто'
}
async function reloadUsers(projectId) {
const req = await fetch('/api/admin/project/' + projectId + '/user').then(res => res.json())
const users = req.data
const $userList = $adminapp.querySelector('#users #user-list')
$userList.setAttribute('user-id', projectId)
$userList.innerHTML = users.map(e => `<div id = "${e.id}">${e.firstname} ${e.lastname}</div>`).join('') || 'Пусто'
}
$adminapp.querySelector('#projects #add-project').addEventListener('click', async function (event) {
const $e = this.parentNode
const id = +$e.querySelector('#project-id').value
const data = {
name: $e.querySelector('#project-name').value,
description: $e.querySelector('#project-description').value
}
const url = id ? '/api/admin/project/' + id : '/api/admin/project'
const method = id ? 'PUT' : 'POST'
await fetch(url, {
method,
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
}).then(res => res.json()).then(() => {
reloadProjects()
$e.querySelector('#project-id').value = ''
$e.querySelector('#project-name').value = ''
$e.querySelector('#project-description').value = ''
}).catch(alert)
})
$adminapp.querySelector('#companies #add-company').addEventListener('click', async function (event) {
const $e = this.parentNode
const project_id = +$adminapp.querySelector('#groups #group-list').getAttribute('project-id')
const id = +$e.querySelector('#company-id').value
const data = {
name: $e.querySelector('#company-name').value,
description: $e.querySelector('#company-description').value
}
const url = '/api/admin/project/' + project_id + '/company' + (id ? '/' + id : '')
const method = id ? 'PUT' : 'POST'
await fetch(url, {
method,
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
}).then(res => res.json()).then(() => {
reloadCompanies(project_id)
$e.querySelector('#company-id').value = ''
$e.querySelector('#company-name').value = ''
$e.querySelector('#company-description').value = ''
}).catch(alert)
})
window.addEventListener('load', async (event) => {
const startParams = (Telegram.WebApp.initDataUnsafe.start_param || '').split('_')
//const isAdmin = startParams[0] == 'admin'
const isAdmin = true
const $app = isAdmin ? $adminapp : $miniapp
$app.hidden = false
const login_url = isAdmin ? '/api/admin/auth/telegram?' : '/api/miniapp/auth?'
await Telegram.WebApp.ready()
console.log(Telegram)
if (Telegram.WebApp.initData == '') {
alert('NO INIT DATA')
return 0
}
console.log('TG', Telegram)
const login = await fetch(login_url + Telegram.WebApp.initData, {method: 'POST'}).then(res => res.json())
console.log(login)
if (isAdmin) {
const reqProfile = await fetch('/api/admin/customer/profile').then(res => res.json())
const profile = reqProfile.data
$adminapp.querySelector('#settings #name').value = profile.name || ''
$adminapp.querySelector('#settings #company-name').value = profile.company?.name || ''
$adminapp.querySelector('#settings #company-phone').value = profile.company?.phone || ''
$adminapp.querySelector('#settings #company-site').value = profile.company?.site || ''
$adminapp.querySelector('#settings #company-description').value = profile.company?.description || ''
$adminapp.querySelector('#settings #upload-group-selector').setAttribute('href', 'https://t.me/ready_or_not_2025_bot?startgroup=-' + profile.id)
const $uploadGroup = $adminapp.querySelector('#settings #upload-group')
if (profile.upload_group) {
$uploadGroup.innerHTML = profile.upload_group.name || 'Ошибка'
$uploadGroup.setAttribute('href', 'https://t.me/c/' + profile.upload_group.telegram_id)
} else {
$uploadGroup.innerHTML = 'Не задано'
$uploadGroup.setAttribute('href', '')
}
await reloadProjects()
} else {
// Пользовательский
if (startParams[1])
alert('Группа на проекте ' + startParams[1])
}
})
</script>
</body>
</html>

View File

@@ -1,65 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<style>
.block {margin-bottom: 10px}
#projects > div {cursor: pointer}
</style>
<div class = "block">
<label for = "user">USER</label>
<select id = "user"></select>
</div>
<div class = "block">
<div id = "project-title">ПРОЕКТЫ</div>
<div id = "projects">EMPTY</div>
</div>
<div class = "block">
<div id = "member-title">УЧАСТНИКИ</div>
<div id = "members"></div>
</div>
<script type="text/javascript">
(async function () {
try {
const $user = document.getElementById('user')
const $projects = document.getElementById('projects')
const $members = document.getElementById('members')
let members = {}
$user.addEventListener('change', async () => {
const user_id = $user.value
const projects = await fetch('/api/miniapp/project?user_id=' + user_id).then(res => res.json())
$projects.innerHTML = projects.data.map(e =>
`<div id = "${e.id}" title = '${JSON.stringify(e)}'>${e.name} - ${e.description}</div>`
).join('')
$members.innerHTML = ''
})
$projects.addEventListener('click', async (evt) => {
if (evt.target.parentNode != $projects)
return
const _members = await fetch('/api/miniapp/project/' + evt.target.id + '/member?user_id=' + $user.value).then(res => res.json())
members = _members.data
$members.innerHTML = members.map(e =>
`<div id = "${e.id}" title = '${JSON.stringify(e)}' is-blocked = '${e.is_blocked}'>${e.id} - ${e.telegram_name}</div>`
).join('')
})
const user_ids = await fetch('/api/miniapp/user').then(res => res.json())
$user.innerHTML = user_ids.data.map(id => `<option value = '${id}'>${id}</option>`).join('')
$user.dispatchEvent(new Event('change'))
} catch (err) {
console.error(err)
alert(err.message)
}
})();
</script>
</body>
</html>

Binary file not shown.

1525
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,9 +16,10 @@
"dependencies": { "dependencies": {
"@quasar/cli": "^2.5.0", "@quasar/cli": "^2.5.0",
"@quasar/extras": "^1.16.4", "@quasar/extras": "^1.16.4",
"@quasar/vite-plugin": "^1.9.0",
"axios": "^1.2.1", "axios": "^1.2.1",
"pinia": "^2.0.11", "pinia": "^2.0.11",
"quasar": "^2.16.0", "quasar": "^2.18.1",
"vue": "^3.4.18", "vue": "^3.4.18",
"vue-i18n": "^9.2.2", "vue-i18n": "^9.2.2",
"vue-router": "^4.0.12" "vue-router": "^4.0.12"
@@ -30,7 +31,6 @@
"@twa-dev/types": "^8.0.2", "@twa-dev/types": "^8.0.2",
"@types/node": "^20.17.30", "@types/node": "^20.17.30",
"@types/telegram-web-app": "^7.10.1", "@types/telegram-web-app": "^7.10.1",
"@vue/devtools": "^7.7.2",
"@vue/eslint-config-typescript": "^14.1.3", "@vue/eslint-config-typescript": "^14.1.3",
"autoprefixer": "^10.4.2", "autoprefixer": "^10.4.2",
"eslint": "^9.14.0", "eslint": "^9.14.0",

View File

@@ -3,6 +3,7 @@
import { defineConfig } from '#q-app/wrappers' import { defineConfig } from '#q-app/wrappers'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import path from 'path'
export default defineConfig((ctx) => { export default defineConfig((ctx) => {
return { return {
@@ -13,11 +14,11 @@ export default defineConfig((ctx) => {
// --> boot files are part of "main.js" // --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-vite/boot-files // https://v2.quasar.dev/quasar-cli-vite/boot-files
boot: [ boot: [
'telegram-boot',
'i18n', 'i18n',
'axios', 'axios',
'auth-init', 'auth-init',
'global-components', 'global-components'
'telegram-boot'
], ],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css
@@ -52,11 +53,14 @@ export default defineConfig((ctx) => {
// extendTsConfig (tsConfig) {} // extendTsConfig (tsConfig) {}
}, },
alias: {
'composables': path.resolve(__dirname, './src/composables'),
'types': path.resolve(__dirname, './src/types')
},
vueRouterMode: 'history', // available values: 'hash', 'history' vueRouterMode: 'history', // available values: 'hash', 'history'
vueDevtools: true, // Должно быть true // devtool: 'source-map', // Для лучшей отладки
devtool: 'source-map', // Для лучшей отладки
// vueRouterBase, // vueRouterBase,
// vueDevtools,
// vueOptionsAPI: false, // vueOptionsAPI: false,
// rebuildCache: true, // rebuilds Vite/linter/etc cache on startup // rebuildCache: true, // rebuilds Vite/linter/etc cache on startup
@@ -103,14 +107,13 @@ export default defineConfig((ctx) => {
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver
devServer: { devServer: {
vueDevtools: true,
port: 9000, port: 9000,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3000', target: 'http://localhost:3000',
changeOrigin: true changeOrigin: true
} }
} },
// https: true, // https: true,
// open: true // opens browser window automatically // open: true // opens browser window automatically
}, },
@@ -250,4 +253,4 @@ export default defineConfig((ctx) => {
extraScripts: [] extraScripts: []
} }
} }
}); })

View File

@@ -4,23 +4,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { inject, onMounted } from 'vue' import { inject, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import type { WebApp } from '@twa-dev/types' import type { WebApp } from '@twa-dev/types'
import { useTextSizeStore } from './stores/textSize' import { useI18n } from 'vue-i18n'
const { locale } = useI18n()
const router = useRouter()
const tg = inject('tg') as WebApp const tg = inject('tg') as WebApp
tg.onEvent('settingsButtonClicked', async () => {
await router.push({ name: 'settings' }) const getLocale = (): string => {
}) const localeMap = {
ru: 'ru-RU',
en: 'en-US'
} as const satisfies Record<string, string>
const textSizeStore = useTextSizeStore() type LocaleCode = keyof typeof localeMap
onMounted(async () => {
try { const normLocale = (locale?: string): string | undefined => {
await textSizeStore.initialize() if (!locale) return undefined
} catch (err) { const code = locale.split('-')[0] as LocaleCode
console.error('Error load font size:', err) return localeMap[code] ?? undefined
}
const tgLang = tg?.initDataUnsafe?.user?.language_code
const normalizedTgLang = normLocale(tgLang)
return normalizedTgLang ?? normLocale(navigator.language) ?? 'en-US'
} }
})
onMounted(() => {
locale.value = getLocale()
})
</script> </script>

View File

@@ -1,6 +1,5 @@
import { defineBoot } from '#q-app/wrappers' import { defineBoot } from '#q-app/wrappers'
import axios, { type AxiosError } from 'axios' import axios, { type AxiosError } from 'axios'
import { useAuthStore } from 'src/stores/auth'
class ServerError extends Error { class ServerError extends Error {
constructor( constructor(
@@ -30,10 +29,6 @@ api.interceptors.response.use(
errorData.message errorData.message
) )
if (error.response?.status === 401) {
await useAuthStore().logout()
}
return Promise.reject(serverError) return Promise.reject(serverError)
} }
) )
@@ -42,4 +37,4 @@ export default defineBoot(({ app }) => {
app.config.globalProperties.$api = api app.config.globalProperties.$api = api
}) })
export { api, ServerError } export { api, ServerError }

View File

@@ -1,33 +1,73 @@
export function isObjEqual (obj1: Record<string, string | number | boolean>, obj2: Record<string, string | number | boolean>): boolean { function isDirty (
const filteredObj1 = filterIgnored(obj1) obj1: Record<string, unknown> | null | undefined,
const filteredObj2 = filterIgnored(obj2) obj2: Record<string, unknown> | null | undefined
): boolean {
const actualObj1 = obj1 ?? {}
const actualObj2 = obj2 ?? {}
const filteredObj1 = filterIgnored(actualObj1)
const filteredObj2 = filterIgnored(actualObj2)
const allKeys = new Set([...Object.keys(filteredObj1), ...Object.keys(filteredObj2)]) const allKeys = new Set([...Object.keys(filteredObj1), ...Object.keys(filteredObj2)])
for (const key of allKeys) { for (const key of allKeys) {
const hasKey1 = Object.prototype.hasOwnProperty.call(filteredObj1, key) const hasKey1 = Object.hasOwn(filteredObj1, key)
const hasKey2 = Object.prototype.hasOwnProperty.call(filteredObj2, key) const hasKey2 = Object.hasOwn(filteredObj2, key)
if (hasKey1 !== hasKey2) return false if (hasKey1 !== hasKey2) return false
if (hasKey1 && hasKey2 && filteredObj1[key] !== filteredObj2[key]) return false
if (hasKey1 && hasKey2) {
const val1 = filteredObj1[key]
const val2 = filteredObj2[key]
if (typeof val1 === 'string' && typeof val2 === 'string') {
if (val1.trim() !== val2.trim()) return false
} else if (val1 !== val2) {
return false
}
}
} }
return true return true
} }
function filterIgnored(obj: Record<string, string | number | boolean>): Record<string, string | number | boolean> { function filterIgnored(obj: Record<string, unknown>): Record<string, string | number | boolean> {
const filtered: Record<string, string | number | boolean> = {} const filtered: Record<string, string | number | boolean> = {}
for (const key in obj) { for (const key in obj) {
const value = obj[key] const originalValue = obj[key]
if (value !== "" && value !== 0 && value !== false) filtered[key] = value
// Пропускаем значения, которые не string, number или boolean
if (
typeof originalValue !== 'string' &&
typeof originalValue !== 'number' &&
typeof originalValue !== 'boolean'
) {
continue
}
let value = originalValue
if (typeof value === 'string') {
value = value.trim()
if (value === '') continue
}
if (value === 0 || value === false) continue
filtered[key] = value
} }
return filtered return filtered
} }
export function parseIntString (s: string | string[] | undefined) :number | null { function parseIntString (s: string | string[] | undefined) :number | null {
if (typeof s !== 'string') return null if (typeof s !== 'string') return null
const regex = /^[+-]?\d+$/ const regex = /^[+-]?\d+$/
return regex.test(s) ? Number(s) : null return regex.test(s) ? Number(s) : null
}
export {
isDirty,
parseIntString
} }

View File

@@ -1,11 +1,11 @@
import { defineBoot } from '#q-app/wrappers'; import { defineBoot } from '#q-app/wrappers'
import { createI18n } from 'vue-i18n'; import { createI18n } from 'vue-i18n'
import messages from 'src/i18n'; import messages from 'src/i18n'
export type MessageLanguages = keyof typeof messages; export type MessageLanguages = keyof typeof messages
// Type-define 'en-US' as the master schema for the resource // Type-define 'en-US' as the master schema for the resource
export type MessageSchema = typeof messages['en-US']; export type MessageSchema = typeof messages['en-US']
// See https://vue-i18n.intlify.dev/guide/advanced/typescript.html#global-resource-schema-type-definition // See https://vue-i18n.intlify.dev/guide/advanced/typescript.html#global-resource-schema-type-definition
/* eslint-disable @typescript-eslint/no-empty-object-type */ /* eslint-disable @typescript-eslint/no-empty-object-type */
@@ -26,8 +26,8 @@ export default defineBoot(({ app }) => {
locale: 'en-US', locale: 'en-US',
legacy: false, legacy: false,
messages, messages,
}); })
// Set i18n instance on app // Set i18n instance on app
app.use(i18n); app.use(i18n)
}); })

View File

@@ -13,7 +13,7 @@
tgUser?.first_name + tgUser?.first_name +
(tgUser?.first_name && tgUser?.last_name ? ' ' : '') + (tgUser?.first_name && tgUser?.last_name ? ' ' : '') +
tgUser?.last_name + tgUser?.last_name +
!(tgUser?.first_name || tgUser?.last_name) ? tgUser?.username : '' (!(tgUser?.first_name || tgUser?.last_name) ? tgUser?.username : '')
}} }}
</span> </span>
</div> </div>

View File

@@ -12,23 +12,27 @@
no-error-icon no-error-icon
dense dense
filled filled
label-slot
class = "w100 fix-bottom-padding" class = "w100 fix-bottom-padding"
:label="$t('project_card__project_name')"
:rules="[rules.name]" :rules="[rules.name]"
/> >
<template #label>
{{ $t('project_card__project_name') }} <span class="text-red">*</span>
</template>
</q-input>
<q-input <q-input
v-model="modelValue.description" v-model="modelValue.description"
dense dense
filled filled
autogrow autogrow
class="w100" class="w100 q-pt-sm"
:label="$t('project_card__project_description')" :label="$t('project_card__project_description')"
/> />
<q-checkbox <q-checkbox
v-if="modelValue.logo" v-if="modelValue.logo"
v-model="modelValue.logo_as_bg" v-model="modelValue.is_logo_bg"
class="w100" class="w100"
dense dense
> >
@@ -41,7 +45,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { watch, computed } from 'vue' import { watch, computed } from 'vue'
import type { ProjectParams } from 'src/types' import type { ProjectParams } from 'types/Project'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const { t }= useI18n() const { t }= useI18n()
@@ -68,7 +72,7 @@
</script> </script>
<style> <style>
.q-field--with-bottom.fix-bottom-padding { .q-field--with-bottom.fix-bottom-padding {
padding-bottom: 0 !important padding-bottom: 0 !important
} }
</style> </style>

View File

@@ -0,0 +1,22 @@
import { useQuasar } from 'quasar'
import { useI18n } from 'vue-i18n'
import type { ServerError } from 'boot/axios'
export type { ServerError }
export function useNotify() {
const $q = useQuasar()
const { t } = useI18n()
const notifyError = (error: ServerError) => {
$q.notify({
message: `${t(error.message)} (${t('code')}: ${error.code})`,
type: 'negative',
position: 'bottom',
timeout: 2000,
multiLine: true
})
}
return { notifyError}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,6 @@
<div class="flex items-center no-wrap w100"> <div class="flex items-center no-wrap w100">
<pn-account-block-name/> <pn-account-block-name/>
<q-btn <q-btn
v-if="user?.email"
@click="logout()" @click="logout()"
flat flat
round round
@@ -53,7 +52,6 @@
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const user = authStore.user
const items = computed(() => ([ const items = computed(() => ([
{ id: 1, name: 'account__subscribe', description: 'account__subscribe_description', icon: 'mdi-crown-circle-outline', iconColor: 'orange', pathName: 'subscribe' }, { id: 1, name: 'account__subscribe', description: 'account__subscribe_description', icon: 'mdi-crown-circle-outline', iconColor: 'orange', pathName: 'subscribe' },
@@ -73,6 +71,7 @@
async function logout () { async function logout () {
await authStore.logout() await authStore.logout()
await router.push({ name: 'login' })
} }
</script> </script>

View File

@@ -1,10 +1,11 @@
<template> <template>
<q-page class="flex column items-center justify-center"> <q-page class="flex column items-center justify-between">
<div :style="{ height: `${blockHeight}px` }" />
<q-card <q-card
id="login_block" id="login_block"
flat flat
class="flex column items-center w80 justify-between q-py-lg login-card " class="flex column items-center w80 justify-between q-py-md login-card "
> >
<login-logo <login-logo
class="col-grow q-pa-md" class="col-grow q-pa-md"
@@ -88,30 +89,35 @@
<div <div
v-if="isTelegramApp" v-if="isTelegramApp"
id="alt_login" id="alt_login"
class="w80 q-flex column items-center q-pt-xl" class="w80 q-flex column items-center q-pt-md"
> >
<div <div
class="orline w100 text-grey" class="orline w100 text-grey"
> >
<span class="q-mx-sm">{{$t('login__or_continue_as')}}</span> <span class="q-mx-sm text-caption">{{$t('login__or_continue_as')}}</span>
</div> </div>
<q-btn <q-btn
flat flat
sm sm
no-caps no-caps
color="primary" color="primary"
:disabled="!acceptTermsOfUse || !isEmailValid || !isPasswordValid" :disabled="!acceptTermsOfUse"
@click="handleTelegramLogin" @click="handleTelegramLogin"
> >
<div class="flex items-center text-blue"> <div class="flex items-center text-blue">
<q-icon name="telegram" size="md" class="q-mx-none text-blue"/> <q-avatar size="md" class="q-mr-sm">
<div class="q-ml-xs ellipsis" style="max-width: 100px"> <q-img v-if="tgUser?.photo_url" :src="tgUser.photo_url"/>
<q-icon v-else size="md" class="q-mr-none" name="telegram"/>
</q-avatar>
<span>
{{ {{
tgUser?.first_name + tgUser?.first_name +
(tgUser?.first_name && tgUser?.last_name ? ' ' : '') + (tgUser?.first_name && tgUser?.last_name ? ' ' : '') +
tgUser?.last_name tgUser?.last_name +
(!(tgUser?.first_name || tgUser?.last_name) ? tgUser?.username : '')
}} }}
</div> </span>
</div> </div>
</q-btn> </q-btn>
</div> </div>
@@ -119,8 +125,10 @@
<div <div
id="term-of-use" id="term-of-use"
class="absolute-bottom q-py-lg text-white flex justify-center row" class="q-pb-md text-white flex justify-center row text-caption"
ref="bottomBlock"
> >
<q-resize-observer @resize="syncHeights" />
<q-checkbox <q-checkbox
v-model="acceptTermsOfUse" v-model="acceptTermsOfUse"
checked-icon="task_alt" checked-icon="task_alt"
@@ -128,6 +136,7 @@
:color="acceptTermsOfUse ? 'brand' : 'red'" :color="acceptTermsOfUse ? 'brand' : 'red'"
dense dense
keep-color keep-color
size="sm"
/> />
<span class="q-px-xs"> <span class="q-px-xs">
{{ $t('login__accept_terms_of_use') + ' ' }} {{ $t('login__accept_terms_of_use') + ' ' }}
@@ -144,14 +153,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, inject, onUnmounted } from 'vue' import { ref, computed, inject, onUnmounted } from 'vue'
import { useQuasar } from 'quasar'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import loginLogo from 'components/login-page/loginLogo.vue' import loginLogo from 'components/login-page/loginLogo.vue'
import { useI18n } from "vue-i18n" import { useI18n } from "vue-i18n"
import { useAuthStore } from 'src/stores/auth' import { useAuthStore } from 'stores/auth'
import type { WebApp } from '@twa-dev/types' import type { WebApp } from '@twa-dev/types'
import { QInput } from 'quasar' import { QInput } from 'quasar'
import type { ServerError } from 'boot/axios' import { useNotify, type ServerError } from 'composables/useNotify'
const { notifyError } = useNotify()
type ValidationRule = (val: string) => boolean | string type ValidationRule = (val: string) => boolean | string
@@ -160,7 +169,6 @@
const authStore = useAuthStore() const authStore = useAuthStore()
const router = useRouter() const router = useRouter()
const $q = useQuasar()
const { t } = useI18n() const { t } = useI18n()
const login = ref<string>('') const login = ref<string>('')
@@ -168,6 +176,9 @@
const isPwd = ref<boolean>(true) const isPwd = ref<boolean>(true)
const acceptTermsOfUse = ref<boolean>(true) const acceptTermsOfUse = ref<boolean>(true)
const bottomBlock = ref<HTMLDivElement | null>(null)
const blockHeight = ref<number>(0)
const emailInput = ref<InstanceType<typeof QInput>>() const emailInput = ref<InstanceType<typeof QInput>>()
const passwordInput = ref<InstanceType<typeof QInput>>() const passwordInput = ref<InstanceType<typeof QInput>>()
@@ -203,32 +214,21 @@
if (validateTimerId.value[type] !== null) { if (validateTimerId.value[type] !== null) {
clearTimeout(validateTimerId.value[type]) clearTimeout(validateTimerId.value[type])
} }
if (type === 'login') await emailInput.value?.validate() if (type === 'login' && login.value !== '') await emailInput.value?.validate()
if (type === 'password') await passwordInput.value?.validate() if (type === 'password' && password.value !== '') await passwordInput.value?.validate()
})() })()
}, 300) }, 500)
}
function onErrorLogin (error: ServerError) {
$q.notify({
message: t(error.message) + ' (' + t('code') + ':' + error.code + ')',
type: 'negative',
position: 'bottom',
timeout: 2000,
multiLine: true
})
} }
async function sendAuth() { async function sendAuth() {
try { void await authStore.loginWithCredentials(login.value, password.value) } try { void await authStore.loginWithCredentials(login.value, password.value) }
catch (error) { catch (error) {
console.log(error) notifyError(error as ServerError)
onErrorLogin(error as ServerError)
} }
await router.push({ name: 'projects' }) await router.push({ name: 'projects' })
} }
async function forgotPwd() { async function forgotPwd() {
sessionStorage.setItem('pendingLogin', login.value) sessionStorage.setItem('pendingLogin', login.value)
await router.push({ name: 'recovery_password' }) await router.push({ name: 'recovery_password' })
@@ -244,19 +244,22 @@
return !!window.Telegram?.WebApp?.initData return !!window.Telegram?.WebApp?.initData
}) })
/* const handleSubmit = async () => { async function handleTelegramLogin () {
await authStore.loginWithCredentials(email.value, password.value) // @ts-expect-ignore
} */ const initData = window.Telegram.WebApp.initData
await authStore.loginWithTelegram(initData)
await router.push({ name: 'projects' })
}
async function handleTelegramLogin () { function syncHeights() {
// @ts-expect-ignore if (bottomBlock.value) {
const initData = window.Telegram.WebApp.initData blockHeight.value = bottomBlock.value.offsetHeight
await authStore.loginWithTelegram(initData) }
} }
onUnmounted(() => { onUnmounted(() => {
Object.values(validateTimerId.value).forEach(timer => timer && clearTimeout(timer)) Object.values(validateTimerId.value).forEach(timer => timer && clearTimeout(timer))
}) })
</script> </script>

View File

@@ -25,10 +25,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import projectInfoBlock from 'src/components/projectInfoBlock.vue' import projectInfoBlock from 'components/projectInfoBlock.vue'
import { useProjectsStore } from 'stores/projects' import { useProjectsStore } from 'stores/projects'
import type { ProjectParams } from 'src/types' import type { ProjectParams } from 'types/Project'
import { useNotify, type ServerError } from 'composables/useNotify'
const { notifyError } = useNotify()
const router = useRouter() const router = useRouter()
const projectsStore = useProjectsStore() const projectsStore = useProjectsStore()
@@ -37,7 +38,7 @@
name: '', name: '',
logo: '', logo: '',
description: '', description: '',
logo_as_bg: false is_logo_bg: false
} }
const project = ref<ProjectParams>({ ...initialProject }) const project = ref<ProjectParams>({ ...initialProject })
@@ -48,14 +49,18 @@
project.value.name !== initialProject.name || project.value.name !== initialProject.name ||
project.value.logo !== initialProject.logo || project.value.logo !== initialProject.logo ||
project.value.description !== initialProject.description || project.value.description !== initialProject.description ||
project.value.logo_as_bg !== initialProject.logo_as_bg project.value.is_logo_bg !== initialProject.is_logo_bg
) )
}) })
async function addProject (data: ProjectParams) { async function addProject (data: ProjectParams) {
const newProject = await projectsStore.addProject(data) try {
// await router.push({name: 'chats', params: { id: newProject.id}}) const newProject = await projectsStore.add(data)
console.log(newProject) await router.replace({ name: 'chats', params: { id: newProject.id }})
console.log(newProject)
} catch (error) {
notifyError(error as ServerError)
}
} }
</script> </script>

View File

@@ -6,7 +6,7 @@
<span>{{ $t('project_card__project_card') }}</span> <span>{{ $t('project_card__project_card') }}</span>
</div> </div>
<q-btn <q-btn
v-if="isFormValid && isDirty()" v-if="isFormValid && (!isDirty(originalProject, project))"
@click="updateProject()" @click="updateProject()"
flat flat
round round
@@ -29,39 +29,33 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useProjectsStore } from 'stores/projects' import { useProjectsStore } from 'stores/projects'
import projectInfoBlock from 'src/components/projectInfoBlock.vue' import projectInfoBlock from 'components/projectInfoBlock.vue'
import type { Project } from '../types' import { parseIntString, isDirty } from 'boot/helpers'
import { parseIntString, isObjEqual } from 'boot/helpers' import type { ProjectParams } from 'types/Project'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const projectsStore = useProjectsStore() const projectsStore = useProjectsStore()
const project = ref<Project>() const project = ref<ProjectParams>()
const originalProject = ref<ProjectParams>()
const id = parseIntString(route.params.id) const id = parseIntString(route.params.id)
const isFormValid = ref(false) const isFormValid = ref(false)
const originalProject = ref<Project>({} as Project)
const isDirty = () => {
return true // project.value && !isObjEqual(originalProject.value, project.value)
}
onMounted(async () => { onMounted(async () => {
if (id && projectsStore.projectById(id)) { if (id && projectsStore.projectById(id)) {
const initial = projectsStore.projectById(id) const initial = projectsStore.projectById(id)
project.value = { ...initial } as ProjectParams
project.value = { ...initial } as Project originalProject.value = {...project.value}
originalProject.value = JSON.parse(JSON.stringify(project.value))
} else { } else {
await abort() await abort()
} }
}) })
function updateProject () { async function updateProject () {
if (id && project.value) { if (id && project.value) {
projectsStore.updateProject(id, project.value) await projectsStore.update(id, project.value)
router.back() router.back()
} }
} }

View File

@@ -63,11 +63,11 @@
<div class="flex items-center column"> <div class="flex items-center column">
<div class="flex items-center"> <div class="flex items-center">
<q-icon name="mdi-chat-outline"/> <q-icon name="mdi-chat-outline"/>
<span>{{ item.chats }} </span> <span>{{ item.chat_count }} </span>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<q-icon name="mdi-account-outline"/> <q-icon name="mdi-account-outline"/>
<span>{{ item.persons }}</span> <span>{{ item.user_count }}</span>
</div> </div>
</div> </div>
</q-item-section> </q-item-section>
@@ -113,10 +113,16 @@
class="flex w100 column q-pt-xl q-pa-md" class="flex w100 column q-pt-xl q-pa-md"
> >
<div class="flex column justify-center col-grow items-center text-grey"> <div class="flex column justify-center col-grow items-center text-grey">
<q-icon name="mdi-briefcase-plus-outline" size="160px" class="q-pb-md"/> <q-btn flat no-caps @click="createNewProject">
<div class="text-h6"> <div class="flex column justify-center col-grow items-center">
{{$t('projects__lets_start')}} <q-icon name="mdi-briefcase-plus-outline" size="160px" class="q-pb-md"/>
</div> <div class="text-h6 text-brand">
{{$t('projects__lets_start')}}
</div>
</div>
</q-btn>
<div class="text-caption" align="center"> <div class="text-caption" align="center">
{{$t('projects__lets_start_description')}} {{$t('projects__lets_start_description')}}
</div> </div>
@@ -169,9 +175,12 @@
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useProjectsStore } from 'stores/projects' import { useProjectsStore } from 'stores/projects'
import { useSettingsStore } from 'stores/settings'
import type { Project } from 'types/Project'
const router = useRouter() const router = useRouter()
const projectsStore = useProjectsStore() const projectsStore = useProjectsStore()
const settingsStore = useSettingsStore()
const projects = projectsStore.projects const projects = projectsStore.projects
const searchProject = ref('') const searchProject = ref('')
@@ -197,20 +206,14 @@
} }
function restoreFromArchive () { function restoreFromArchive () {
if (archiveProjectId.value) { if (archiveProjectId.value) projectsStore.restore(archiveProjectId.value)
const projectTemp = projectsStore.projectById(archiveProjectId.value)
if (projectTemp) {
projectTemp.is_archive = false
projectsStore.updateProject(archiveProjectId.value, projectTemp)
}
}
} }
const displayProjects = computed(() => { const displayProjects = computed(() => {
if (!searchProject.value || !(searchProject.value && searchProject.value.trim())) return projects if (!searchProject.value || !(searchProject.value && searchProject.value.trim())) return projects
const searchChatValue = searchProject.value.trim().toLowerCase() const searchChatValue = searchProject.value.trim().toLowerCase()
const arrOut = projects const arrOut = projects
.filter(el => .filter((el: Project) =>
el.name.toLowerCase().includes(searchChatValue) || el.name.toLowerCase().includes(searchChatValue) ||
el.description && el.description.toLowerCase().includes(searchProject.value) el.description && el.description.toLowerCase().includes(searchProject.value)
) )
@@ -218,17 +221,20 @@
}) })
const activeProjects = computed(() => { const activeProjects = computed(() => {
return displayProjects.value.filter(el => !el.is_archive) return displayProjects.value.filter((el: Project) => !el.is_archived)
}) })
const archiveProjects = computed(() => { const archiveProjects = computed(() => {
return displayProjects.value.filter(el => el.is_archive) return displayProjects.value.filter((el: Project) => el.is_archived)
}) })
onMounted(async () => { onMounted(async () => {
if (!projectsStore.isInit) { if (!projectsStore.isInit) {
await projectsStore.init() await projectsStore.init()
} }
if (!settingsStore.isInit) {
await settingsStore.init()
}
}) })
watch(showDialogArchive, (newD :boolean) => { watch(showDialogArchive, (newD :boolean) => {

View File

@@ -40,18 +40,18 @@
<q-item-section> <q-item-section>
<div class="flex justify-end"> <div class="flex justify-end">
<q-btn <q-btn
@click="textSizeStore.decreaseFontSize()" @click="settingsStore.decreaseFontSize()"
color="negative" flat color="negative" flat
icon="mdi-format-font-size-decrease" icon="mdi-format-font-size-decrease"
class="q-pa-sm q-mx-xs" class="q-pa-sm q-mx-xs"
:disable="currentTextSize <= minTextSize" :disable="!settingsStore.canDecrease"
/> />
<q-btn <q-btn
@click="textSizeStore.increaseFontSize()" @click="settingsStore.increaseFontSize()"
color="positive" flat color="positive" flat
icon="mdi-format-font-size-increase" icon="mdi-format-font-size-increase"
class="q-pa-sm q-mx-xs" class="q-pa-sm q-mx-xs"
:disable="currentTextSize >= maxTextSize" :disable="!settingsStore.canIncrease"
/> />
</div> </div>
</q-item-section> </q-item-section>
@@ -62,28 +62,22 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n' import { ref, computed } from 'vue'
import { watch, ref } from 'vue' import { useSettingsStore } from 'stores/settings'
import { useTextSizeStore } from 'src/stores/textSize'
const { locale } = useI18n()
const savedLocale = localStorage.getItem('locale') || 'en-US'
locale.value = savedLocale
const localeOptions = ref([ const localeOptions = ref([
{ value: 'en-US', label: 'English' }, { value: 'en-US', label: 'English' },
{ value: 'ru-RU', label: 'Русский' } { value: 'ru-RU', label: 'Русский' }
]) ])
watch(locale, (newLocale) => { const locale = computed({
localStorage.setItem('locale', newLocale) get: () => settingsStore.settings.locale,
// eslint-disable-next-line @typescript-eslint/no-misused-promises
set: (value: string) => settingsStore.updateLocale(value)
}) })
const textSizeStore = useTextSizeStore() const settingsStore = useSettingsStore()
const currentTextSize = textSizeStore.currentFontSize
const maxTextSize = textSizeStore.maxFontSize
const minTextSize = textSizeStore.minFontSize
</script> </script>
<style scoped> <style scoped>

View File

@@ -71,20 +71,12 @@
<q-card class="q-pa-none q-ma-none"> <q-card class="q-pa-none q-ma-none">
<q-card-section align="center"> <q-card-section align="center">
<div class="text-h6 text-negative "> <div class="text-h6 text-negative ">
{{ $t( {{ $t('project__archive_warning')}}
dialogType === 'archive'
? 'project__archive_warning'
: 'project__delete_warning'
)}}
</div> </div>
</q-card-section> </q-card-section>
<q-card-section class="q-pt-none" align="center"> <q-card-section class="q-pt-none" align="center">
{{ $t( {{ $t('project__archive_warning_message')}}
dialogType === 'archive'
? 'project__archive_warning_message'
: 'project__delete_warning_message'
)}}
</q-card-section> </q-card-section>
<q-card-actions align="center"> <q-card-actions align="center">
@@ -96,14 +88,10 @@
/> />
<q-btn <q-btn
flat flat
:label="$t( :label="$t('project__archive')"
dialogType === 'archive'
? 'project__archive'
: 'project__delete'
)"
color="negative" color="negative"
v-close-popup v-close-popup
@click="dialogType === 'archive' ? archiveProject() : deleteProject()" @click="archiveProject"
/> />
</q-card-actions> </q-card-actions>
</q-card> </q-card>
@@ -122,22 +110,22 @@
const expandProjectInfo = ref<boolean>(false) const expandProjectInfo = ref<boolean>(false)
const showDialog = ref<boolean>(false) const showDialog = ref<boolean>(false)
const dialogType = ref<null | 'archive' | 'delete'>(null) const dialogType = ref<null | 'archive'>(null)
const headerHeight = ref<number>(0) const headerHeight = ref<number>(0)
const menuItems = [ const menuItems = [
{ id: 1, title: 'project__edit', icon: 'mdi-square-edit-outline', iconColor: '', func: editProject }, { id: 1, title: 'project__edit', icon: 'mdi-square-edit-outline', iconColor: '', func: editProject },
// { id: 2, title: 'project__backup', icon: 'mdi-content-save-outline', iconColor: '', func: () => {} }, // { id: 2, title: 'project__backup', icon: 'mdi-content-save-outline', iconColor: '', func: () => {} },
{ id: 3, title: 'project__archive', icon: 'mdi-archive-outline', iconColor: '', func: () => { showDialog.value = true; dialogType.value = 'archive' }}, { id: 3, title: 'project__archive', icon: 'mdi-archive-outline', iconColor: 'red', func: () => { showDialog.value = true; dialogType.value = 'archive' }}
{ id: 4, title: 'project__delete', icon: 'mdi-trash-can-outline', iconColor: 'red', func: () => { showDialog.value = true; dialogType.value = 'delete' }},
] ]
const projectId = computed(() => parseIntString(route.params.id)) const projectId = computed(() => parseIntString(route.params.id))
const project =ref({ const project =ref({
name: '', name: '',
description: '', description: '',
logo: '' logo: '',
is_logo_bg: false
}) })
const loadProjectData = async () => { const loadProjectData = async () => {
@@ -154,7 +142,8 @@
project.value = { project.value = {
name: projectFromStore.name, name: projectFromStore.name,
description: projectFromStore.description || '', description: projectFromStore.description || '',
logo: projectFromStore.logo || '' logo: projectFromStore.logo || '',
is_logo_bg: projectFromStore.is_logo_bg || false
} }
} }
} }
@@ -164,15 +153,13 @@ async function abort () {
} }
async function editProject () { async function editProject () {
if (projectId.value) void projectsStore.update(projectId.value, project.value)
await router.push({ name: 'project_info' }) await router.push({ name: 'project_info' })
} }
function archiveProject () { async function archiveProject () {
console.log('archive project') if (projectId.value) void projectsStore.archive(projectId.value)
} await router.replace({ name: 'projects' })
function deleteProject () {
console.log('delete project')
} }
function toggleExpand () { function toggleExpand () {

View File

@@ -8,10 +8,10 @@ import {
import routes from './routes' import routes from './routes'
import { useAuthStore } from 'stores/auth' import { useAuthStore } from 'stores/auth'
import { useProjectsStore } from 'stores/projects' import { useProjectsStore } from 'stores/projects'
const tg = window.Telegram?.WebApp
declare module 'vue-router' { declare module 'vue-router' {
interface RouteMeta { interface RouteMeta {
public?: boolean
guestOnly?: boolean guestOnly?: boolean
hideBackButton?: boolean hideBackButton?: boolean
backRoute?: string backRoute?: string
@@ -32,29 +32,14 @@ export default defineRouter(function (/* { store, ssrContext } */) {
history: createHistory(process.env.VUE_ROUTER_BASE) history: createHistory(process.env.VUE_ROUTER_BASE)
}) })
Router.beforeEach(async (to) => { Router.beforeEach((to) => {
const authStore = useAuthStore() const authStore = useAuthStore()
if (!authStore.isInitialized) {
await authStore.initialize()
}
if (to.meta.guestOnly && authStore.isAuthenticated) { if (to.meta.guestOnly && authStore.isAuth) {
return { name: 'projects' } return { name: 'projects' }
} }
if (to.meta.requiresAuth && !authStore.isAuthenticated) { if (to.meta.requiresAuth && !authStore.isAuth) {
return {
name: 'login',
replace: true
}
}
if (to.meta.public) {
return true
}
if (!to.meta.public && !authStore.isAuthenticated) {
return { return {
name: 'login', name: 'login',
replace: true replace: true
@@ -67,17 +52,19 @@ export default defineRouter(function (/* { store, ssrContext } */) {
if (currentRoute.meta.backRoute) { if (currentRoute.meta.backRoute) {
await Router.push({ name: currentRoute.meta.backRoute }) await Router.push({ name: currentRoute.meta.backRoute })
} else { } else {
if (window.history.length > 1) Router.go(-1) if (window.history.length > 1) Router.go(-1)
else await Router.push({ name: 'projects' }) else await Router.push({ name: 'projects' })
} }
} }
Router.afterEach((to) => { Router.afterEach((to) => {
const BackButton = window.Telegram?.WebApp?.BackButton if (tg) {
if (BackButton) { const BackButton = tg?.BackButton
BackButton[to.meta.hideBackButton ? 'hide' : 'show']() if (BackButton) {
BackButton.offClick(handleBackButton as () => void) BackButton[to.meta.hideBackButton ? 'hide' : 'show']()
BackButton.onClick(handleBackButton as () => void) BackButton.offClick(handleBackButton as () => void)
BackButton.onClick(handleBackButton as () => void)
}
} }
if (!to.params.id) { if (!to.params.id) {

View File

@@ -111,10 +111,7 @@ const routes: RouteRecordRaw[] = [
name: 'create_account', name: 'create_account',
path: '/create-account', path: '/create-account',
component: () => import('src/pages/AccountCreatePage.vue'), component: () => import('src/pages/AccountCreatePage.vue'),
meta: { meta: { guestOnly: true }
public: true,
guestOnly: true
}
}, },
{ {
name: 'change_account_password', name: 'change_account_password',
@@ -138,13 +135,11 @@ const routes: RouteRecordRaw[] = [
name: 'terms', name: 'terms',
path: '/terms-of-use', path: '/terms-of-use',
component: () => import('pages/TermsPage.vue'), component: () => import('pages/TermsPage.vue'),
meta: { public: true }
}, },
{ {
name: 'privacy', name: 'privacy',
path: '/privacy', path: '/privacy',
component: () => import('pages/PrivacyPage.vue'), component: () => import('pages/PrivacyPage.vue'),
meta: { public: true }
}, },
{ {
name: 'your_company', name: 'your_company',
@@ -157,18 +152,15 @@ const routes: RouteRecordRaw[] = [
path: '/login', path: '/login',
component: () => import('pages/LoginPage.vue'), component: () => import('pages/LoginPage.vue'),
meta: { meta: {
public: true, hideBackButton: true,
guestOnly: true guestOnly: true
} }
}, },
{ {
name: 'recovery_password', name: 'recovery_password',
path: '/recovery-password', path: '/recovery-password',
component: () => import('src/pages/AccountForgotPasswordPage.vue'), component: () => import('src/pages/AccountForgotPasswordPage.vue'),
meta: { meta: { guestOnly: true }
public: true,
guestOnly: true
}
}, },
{ {
name: 'add_company', name: 'add_company',
@@ -187,7 +179,6 @@ const routes: RouteRecordRaw[] = [
{ {
path: '/:catchAll(.*)*', path: '/:catchAll(.*)*',
component: () => import('pages/ErrorNotFound.vue'), component: () => import('pages/ErrorNotFound.vue'),
meta: { public: true }
} }
] ]

View File

@@ -12,7 +12,7 @@ interface User {
} }
const ENDPOINT_MAP = { const ENDPOINT_MAP = {
register: '/auth/register', register: '/auth/email/register',
forgot: '/auth/forgot', forgot: '/auth/forgot',
change: '/auth/change' change: '/auth/change'
} as const } as const
@@ -23,46 +23,33 @@ export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null) const user = ref<User | null>(null)
const isInitialized = ref(false) const isInitialized = ref(false)
const isAuthenticated = computed(() => !!user.value) const isAuth = computed(() => !!user.value)
const initialize = async () => { const initialize = async () => {
try { try {
const { data } = await api.get('/customer/profile') const { data } = await api.get('/customer/profile')
user.value = data.data user.value = data.data
} catch (error) { } catch (error) { if (isAuth.value) console.log(error) }
handleAuthError(error as ServerError) finally {
} finally {
isInitialized.value = true isInitialized.value = true
} }
} }
const handleAuthError = (error: ServerError) => {
if (error.code === '401') {
user.value = null
} else {
console.error('Authentication error:', error)
}
}
const loginWithCredentials = async (email: string, password: string) => { const loginWithCredentials = async (email: string, password: string) => {
await api.post('/auth/email', { email, password }, { withCredentials: true }) await api.post('/auth/email', { email, password }, { withCredentials: true })
await initialize() await initialize()
} }
const loginWithTelegram = async (initData: string) => { const loginWithTelegram = async (initData: string) => {
await api.post('/auth/telegram', { initData }, { withCredentials: true }) await api.post('/auth/telegram?'+ initData, {}, { withCredentials: true })
console.log(initData)
await initialize() await initialize()
} }
const logout = async () => { const logout = async () => {
try { await api.get('/auth/logout', {})
await api.get('/auth/logout', {}) user.value = null
user.value = null isInitialized.value = false
isInitialized.value = false
} finally {
// @ts-expect-ignore
// window.Telegram?.WebApp.close()
}
} }
const initRegistration = async (flowType: AuthFlowType, email: string) => { const initRegistration = async (flowType: AuthFlowType, email: string) => {
@@ -84,7 +71,7 @@ export const useAuthStore = defineStore('auth', () => {
return { return {
user, user,
isAuthenticated, isAuth,
isInitialized, isInitialized,
initialize, initialize,
loginWithCredentials, loginWithCredentials,

View File

@@ -1,35 +1,18 @@
import { ref } from 'vue' import { ref } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import type { Project, ProjectParams } from '../types'
import { api } from 'boot/axios' import { api } from 'boot/axios'
import { clientConverter, serverConverter } from 'types/booleanConvertor'
import type { Project, ProjectParams, RawProject, RawProjectParams } from 'types/Project'
export const useProjectsStore = defineStore('projects', () => { export const useProjectsStore = defineStore('projects', () => {
const projects = ref<Project[]>([]) const projects = ref<Project[]>([])
const currentProjectId = ref<number | null>(null) const currentProjectId = ref<number | null>(null)
const isInit = ref<boolean>(false) const isInit = ref<boolean>(false)
/* projects.value.push(
{ id: 1, name: 'Тестовый проект', description: 'Пример тестового проекта - тут описание чего-то', logo: 'https://cdn.quasar.dev/img/boy-avatar.png', chats: 3, companies: 1, persons: 5, is_archive: false, logo_as_bg: false },
{ id: 2, name: 'Разделка бобра на куски', description: 'Пример тестового проекта - тут описание чего-то', logo: '', chats: 8, companies: 12, persons: 1, is_archive: false, logo_as_bg: false },
{ id: 3, name: 'Комплекс мер', description: '', logo: '', chats: 8, companies: 3, persons: 4, is_archive: true, logo_as_bg: false },
{ id: 4, name: 'Тестовый проект 2', description: 'Пример тестового проекта - тут описание чего-то', logo: '', chats: 12, companies: 11, persons: 15, is_archive: false, logo_as_bg: false },
{ id: 11, name: 'Тестовый проект 12', description: 'Пример тестового проекта - тут описание чего-то', logo: 'https://cdn.quasar.dev/img/boy-avatar.png', chats: 5, companies: 2, persons: 5, is_archive: false, logo_as_bg: false },
{ id: 12, name: 'Разделка бобра на куски 11 Ох как много кусков пипец каааак много - резать тяжело', description: '', logo: '', chats: 8, companies: 3, persons: 1, is_archive: false, logo_as_bg: false },
{ id: 13, name: 'Тестовый проект и что-то еще', description: 'Пример тестового проекта - тут описание чего-то Ох как много кусков пипец каааак много - резать тяжело Ох как много кусков пипец каааак много - резать тяжело', logo: 'https://cdn.quasar.dev/img/mountains.jpg', chats: 8, companies: 3, persons: 4, is_archive: false, logo_as_bg: true },
{ id: 14, name: 'Тестовый проект', description: 'Пример тестового проекта - тут описание чего-то', logo: 'https://cdn.quasar.dev/img/mountains.jpg', chats: 12, companies: 11, persons: 15, is_archive: false, logo_as_bg: false },
{ id: 112, name: 'Разделка бобра на куски 11 Ох как много кусков пипец каааак много - резать тяжело', description: '', logo: '', chats: 8, companies: 3, persons: 1, is_archive: false, logo_as_bg: false},
{ id: 113, name: 'Тестовый проект и что-то еще', description: 'Пример тестового проекта - тут описание чего-то Ох как много кусков пипец каааак много - резать тяжело Ох как много кусков пипец каааак много - резать тяжело', logo: 'https://cdn.quasar.dev/img/mountains.jpg', chats: 8, companies: 3, persons: 4, is_archive: false, logo_as_bg: false },
{ id: 114, name: 'Тестовый проект', description: 'Пример тестового проекта - тут описание чего-то', logo: 'https://cdn.quasar.dev/img/mountains.jpg', chats: 12, companies: 11, persons: 15, is_archive: true, logo_as_bg: false },
{ id: 1112, name: 'Разделка бобра на куски 11 Ох как много кусков пипец каааак много - резать тяжело', description: '', logo: '', chats: 8, companies: 3, persons: 1, is_archive: false, logo_as_bg: false },
{ id: 1113, name: 'Тестовый проект и что-то еще', description: 'Пример тестового проекта - тут описание чего-то Ох как много кусков пипец каааак много - резать тяжело Ох как много кусков пипец каааак много - резать тяжело', logo: 'https://cdn.quasar.dev/img/mountains.jpg', chats: 8, companies: 3, persons: 4, is_archive: false, logo_as_bg: false },
{ id: 1114, name: 'Тестовый проект', description: 'Пример тестового проекта - тут описание чего-то', logo: 'https://cdn.quasar.dev/img/mountains.jpg', chats: 12, companies: 11, persons: 15, is_archive: false, logo_as_bg: false },
) */
async function init() { async function init() {
const prjs = await api.get('/project') const response = await api.get('/project')
console.log(2222, prjs) const projectsAPI = response.data.data.map((el: RawProject) => clientConverter<Project, RawProject>(el, ['is_logo_bg', 'is_archived']))
if (Array.isArray(prjs)) projects.value.push(...prjs) projects.value.push(...projectsAPI)
isInit.value = true isInit.value = true
} }
@@ -37,27 +20,32 @@ export const useProjectsStore = defineStore('projects', () => {
return projects.value.find(el =>el.id === id) return projects.value.find(el =>el.id === id)
} }
async function addProject (projectData: ProjectParams) { async function add (projectData: ProjectParams) {
const newProject = await api.put('/project', projectData) const response = await api.post('/project', serverConverter<ProjectParams, RawProjectParams>(projectData, ['is_logo_bg']))
const newProject = clientConverter<Project, RawProject>(response.data.data, ['is_logo_bg', 'is_archived'])
console.log(newProject) projects.value.push(newProject)
// projects.value.push(newProject)
return newProject return newProject
} }
function updateProject (id :number, project :Project) { async function update (id :number, projectData :ProjectParams) {
const response = await api.put('/project/'+ id, serverConverter<ProjectParams, RawProjectParams>(projectData, ['is_logo_bg']))
const projectAPI = clientConverter<Project, RawProject>(response.data.data, ['is_logo_bg', 'is_archived'])
const idx = projects.value.findIndex(item => item.id === id) const idx = projects.value.findIndex(item => item.id === id)
Object.assign(projects.value[idx] || {}, project) if (projects.value[idx]) Object.assign(projects.value[idx], projectAPI)
} }
function archiveProject (id :number, status :boolean) { async function archive (id :number) {
const idx = projects.value.findIndex(item => item.id === id) const response = await api.put('/project/'+ id + '/archive')
if (projects.value[idx]) projects.value[idx].is_archive = status const projectAPI = clientConverter<Project, RawProject>(response.data.data, ['is_logo_bg', 'is_archived'])
const idx = projects.value.findIndex(item => item.id === projectAPI.id)
if (projects.value[idx] && projectAPI.is_archived) Object.assign(projects.value[idx], projectAPI)
} }
function deleteProject (id :number) { async function restore (id :number) {
const idx = projects.value.findIndex(item => item.id === id) const response = await api.put('/project/'+ id + '/restore')
projects.value.splice(idx, 1) const projectAPI = clientConverter<Project, RawProject>(response.data.data, ['is_logo_bg', 'is_archived'])
const idx = projects.value.findIndex(item => item.id === projectAPI.id)
if (projects.value[idx] && !projectAPI.is_archived) Object.assign(projects.value[idx], projectAPI)
} }
function setCurrentProjectId (id: number | null) { function setCurrentProjectId (id: number | null) {
@@ -74,10 +62,10 @@ export const useProjectsStore = defineStore('projects', () => {
projects, projects,
currentProjectId, currentProjectId,
projectById, projectById,
addProject, add,
updateProject, update,
archiveProject, archive,
deleteProject, restore,
setCurrentProjectId, setCurrentProjectId,
getCurrentProject getCurrentProject
} }

105
src/stores/settings.ts Normal file
View File

@@ -0,0 +1,105 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from 'boot/axios'
import { useI18n } from 'vue-i18n'
interface AppSettings {
fontSize?: number
locale?: string
}
const defaultFontSize = 16
const minFontSize = 12
const maxFontSize = 20
const fontSizeStep = 2
export const useSettingsStore = defineStore('settings', () => {
const { locale: i18nLocale } = useI18n()
const settings = ref<AppSettings>({
fontSize: defaultFontSize,
locale: i18nLocale.value // Инициализация из i18n
})
// State
const isInit = ref(false)
// Getters
const currentFontSize = computed(() => settings.value.fontSize ?? defaultFontSize)
const canIncrease = computed(() => currentFontSize.value < maxFontSize)
const canDecrease = computed(() => currentFontSize.value > minFontSize)
// Helpers
const clampFontSize = (size: number) =>
Math.max(minFontSize, Math.min(size, maxFontSize))
const updateCssVariable = () => {
document.documentElement.style.setProperty(
'--dynamic-font-size',
`${currentFontSize.value}px`
)
}
const applyLocale = () => {
if (settings.value.locale && i18nLocale) {
i18nLocale.value = settings.value.locale
}
}
const saveSettings = async () => {
await api.put('/custome/settings', settings.value)
}
// Actions
const init = async () => {
if (isInit.value) return
try {
const { data } = await api.get<AppSettings>('/customer/settings')
settings.value = {
...settings.value,
...data
}
updateCssVariable()
applyLocale()
} finally {
isInit.value = true
}
}
const updateSettings = async (newSettings: Partial<AppSettings>) => {
settings.value = { ...settings.value, ...newSettings }
updateCssVariable()
applyLocale()
await saveSettings()
}
const updateLocale = async (newLocale: string) => {
settings.value.locale = newLocale
applyLocale()
await saveSettings()
}
const increaseFontSize = async () => {
const newSize = clampFontSize(currentFontSize.value + fontSizeStep)
await updateSettings({ fontSize: newSize })
}
const decreaseFontSize = async () => {
const newSize = clampFontSize(currentFontSize.value - fontSizeStep)
await updateSettings({ fontSize: newSize })
}
return {
settings,
isInit,
currentFontSize,
canIncrease,
canDecrease,
init,
increaseFontSize,
decreaseFontSize,
updateSettings,
updateLocale
}
})

View File

@@ -1,121 +0,0 @@
import { api } from 'boot/axios'
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface FontSizeResponse {
fontSize: number
}
interface FontSizeError {
message: string
code: number
}
export const useTextSizeStore = defineStore('textSize', () => {
// State
const baseSize = ref<number>(16) // Значение по умолчанию
const isLoading = ref<boolean>(false)
const error = ref<FontSizeError | null>(null)
const isInitialized = ref<boolean>(false)
// Константы
const minFontSize = 12
const maxFontSize = 20
const fontSizeStep = 2
// Getters
const currentFontSize = computed(() => baseSize.value)
const canIncrease = computed(() => baseSize.value < maxFontSize)
const canDecrease = computed(() => baseSize.value > minFontSize)
// Actions
const fetchFontSize = async () => {
try {
isLoading.value = true
const response = await api.get<FontSizeResponse>('customer/settings')
baseSize.value = clampFontSize(response.data.fontSize)
updateCssVariable()
} catch (err) {
handleError(err, 'Failed to fetch font size')
baseSize.value = 16 // Fallback к значению по умолчанию
throw err
} finally {
isLoading.value = false
}
}
const updateFontSize = async (newSize: number) => {
try {
const validatedSize = clampFontSize(newSize)
await api.put('customer/settings', { fontSize: validatedSize })
baseSize.value = validatedSize
updateCssVariable()
error.value = null
} catch (err) {
handleError(err, 'Failed to update font size')
throw err
}
}
const increaseFontSize = async () => {
if (!canIncrease.value) return
await updateFontSize(baseSize.value + fontSizeStep)
}
const decreaseFontSize = async () => {
if (!canDecrease.value) return
await updateFontSize(baseSize.value - fontSizeStep)
}
// Helpers
const clampFontSize = (size: number): number => {
return Math.max(minFontSize, Math.min(size, maxFontSize))
}
const updateCssVariable = () => {
document.documentElement.style.setProperty(
'--dynamic-font-size',
`${baseSize.value}px`
)
}
const handleError = (err: unknown, defaultMessage: string) => {
const apiError = err as { response?: { data: { message: string; code: number } } }
error.value = {
message: apiError?.response?.data?.message || defaultMessage,
code: apiError?.response?.data?.code || 500
}
console.error('FontSize Error:', error.value)
}
// Инициализация при первом использовании
const initialize = async () => {
if (isInitialized.value) return
try {
await fetchFontSize()
} catch {
// Оставляем значение по умолчанию
} finally {
isInitialized.value = true
}
}
return {
baseSize,
currentFontSize,
minFontSize,
maxFontSize,
isLoading,
error,
canIncrease,
canDecrease,
fetchFontSize,
increaseFontSize,
decreaseFontSize,
updateFontSize,
initialize
}
})

View File

@@ -12,15 +12,14 @@ interface ProjectParams {
name: string name: string
description?: string description?: string
logo?: string logo?: string
logo_as_bg: boolean is_logo_bg: boolean
} }
interface Project extends ProjectParams { interface Project extends ProjectParams {
id: number id: number
is_archive: boolean is_archived: boolean
chats: number chat_count: number
companies: number user_count: number
persons: number
} }
interface Chat { interface Chat {

18
src/types/Chats.ts Normal file
View File

@@ -0,0 +1,18 @@
interface Chat {
id: number
project_id: number
telegram_id: number
name: string
is_channel: boolean
bot_can_ban: boolean
user_count: number
last_update_time: number
description?: string
logo?: string
owner_id: number
[key: string]: unknown
}
export type {
Chat
}

21
src/types/Company.ts Normal file
View File

@@ -0,0 +1,21 @@
interface User {
id: number
project_id: number
telegram_id: number
firstname?: string
lastname?: string
username?: string
photo: string
phone: string
settings?: {
language?: string
fontSize?: number
timezone: number
}
[key: string]: unknown
}
export type {
Company,
CompanyParams
}

38
src/types/Project.ts Normal file
View File

@@ -0,0 +1,38 @@
interface ProjectParams {
name: string
description?: string
logo?: string
is_logo_bg: boolean
[key: string]: unknown
}
interface Project extends ProjectParams {
id: number
is_archived: boolean
chat_count: number
user_count: number
[key: string]: unknown
}
interface RawProjectParams {
name: string
description?: string
logo?: string
is_logo_bg: number
[key: string]: unknown
}
interface RawProject extends RawProjectParams{
id: number
is_archived: number
chat_count: number
user_count: number
[key: string]: unknown
}
export type {
Project,
ProjectParams,
RawProject,
RawProjectParams
}

20
src/types/Users.ts Normal file
View File

@@ -0,0 +1,20 @@
interface User {
id: number
project_id: number
telegram_id: number
firstname?: string
lastname?: string
username?: string
photo: string
phone: string
settings?: {
language?: string
fontSize?: number
timezone: number
}
[key: string]: unknown
}
export type {
User
}

View File

@@ -0,0 +1,38 @@
export function clientConverter<TClient, TServer extends Record<string, unknown>>(
data: TServer | null | undefined,
booleanFields: Array<keyof TClient>
): TClient {
if (!data) {
throw new Error("Invalid data: null or undefined");
}
return Object.entries(data).reduce((acc, [key, value]) => {
const typedKey = key as keyof TClient;
return {
...acc,
[typedKey]: booleanFields.includes(typedKey)
? Boolean(value)
: value
};
}, {} as TClient);
}
export function serverConverter<TClient, TServer extends Record<string, unknown>>(
data: TClient | null | undefined,
booleanFields: Array<keyof TClient>
): TServer {
if (!data) {
throw new Error("Invalid data: null or undefined");
}
return Object.entries(data).reduce((acc, [key, value]) => {
const typedKey = key as keyof TClient;
return {
...acc,
[key]: booleanFields.includes(typedKey)
? value ? 1 : 0
: value
};
}, {} as TServer);
}

View File

@@ -1,16 +1,17 @@
{ {
"extends": "./.quasar/tsconfig.json", "extends": "./.quasar/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"baseUrl": ".",
"paths": { "paths": {
"src/*": ["./src/*"], "src/*": ["./src/*"],
"app/*": ["./src/*"], "app/*": ["./src/*"],
"components/*": ["./src/components/*"],
"layouts/*": ["./src/layouts/*"],
"pages/*": ["./src/pages/*"],
"assets/*": ["./src/assets/*"], "assets/*": ["./src/assets/*"],
"boot/*": ["./src/boot/*"], "boot/*": ["./src/boot/*"],
"stores/*": ["./src/stores/*"] "components/*": ["./src/components/*"],
"composables/*": ["./src/composables/*"],
"layouts/*": ["./src/layouts/*"],
"pages/*": ["./src/pages/*"],
"stores/*": ["./src/stores/*"],
"types/*": ["./src/types/*"]
}, },
"types": ["@twa-dev/types", "node"] "types": ["@twa-dev/types", "node"]
}, },