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()
app.use(bodyParser.json())
app.use(bodyParser.json({limit: '10mb'}))
app.use(cookieParser())
BigInt.prototype.toJSON = function () {
return Number(this)
}
/* app.use((req, res, next) => {
app.use((req, res, next) => {
if(!(req.body instanceof Object))
return next()
@@ -26,14 +26,14 @@ BigInt.prototype.toJSON = function () {
.map(key => req.body[key] = escapeHtml(req.body[key]))
next()
}) */
})
app.post('(/api/admin/auth/telegram|/api/miniapp/auth)', (req, res, next) => {
const data = Object.assign({}, req.query)
delete data.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 secretKey = crypto.createHmac('sha256', 'WebAppData').update(BOT_TOKEN).digest()
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) => {
console.error(`Error for ${req.path}: ${err}`)
console.trace()
console.log('\n\n')
let message, code
[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
app.listen(PORT, async () => {
console.log(`Listening at port ${PORT}`)
bot.start(
process.env.API_ID || 26746106,
+(process.env.API_ID || 26746106),
process.env.API_HASH || '29e5f83c04e635fa583721473a6003b5',
process.env.BOT_TOKEN || '7236504417:AAGVaodw3cRwGlf-jAhwnYb51OHaXcgpW8k'
)

View File

@@ -1,24 +1,45 @@
const crypto = require('crypto')
const express = require('express')
const multer = require('multer')
const db = require('../include/db')
const bot = require('./bot')
const fs = require('fs')
const cookieParser = require('cookie-parser')
const app = express.Router()
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 1_000_000 // 1mb
}
})
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) => {
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()
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) => {
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)
.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)
.get(res.locals)
@@ -70,73 +91,198 @@ app.post('/auth/telegram', (req, res, next) => {
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})
})
app.post('/auth/register', (req, res, next) => {
/*
Регистрация нового клиента выполняется за ТРИ последовательных вызова
1. Отравляется email. Если email корректный и уже неиспользуется, то сервер возвращает ОК и на указанный email отправляется код.
2. Отправляется email + код из письма. Если указан корректный код, то сервер отвечает ОК.
3. Отправляется email + код из письма + желаемый пароль. Если все ОК, то сервер создает учетную запись и возвращает ОК.
*/
app.post('/auth/email/register', (req, res, next) => {
const email = String(req.body.email ?? '').trim()
const code = String(req.body.code ?? '').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 stepNo = email && !code ? 1 : email && code && !password ? 2 : email && code && password ? 3 : -1
if (stepNo == -1)
throw Error('BAD_STEP::400')
const customer_id = db
.prepare('select id from customers where email = :email')
.pluck(true)
.get({email})
if (stepNo == 1) {
if (!checkEmail(email))
throw Error('INCORRECT_EMAIL::400')
if (customer_id)
throw Error('USED_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 (email && !code) {
const code = Math.random().toString().substr(2, 4)
emailCache[email] = code
// To-Do: send email
console.log(`${email} => ${code}`)
if (stepNo == 2) {
if (cache.register[email] != code)
throw Error('INCORRECT_CODE::400')
}
if (email && code && !password) {
if (emailCache[email] != code)
throw Error('INCORRECT_CODE::400')
}
if (stepNo == 3) {
if (!checkPassword(password))
throw Error('INCORRECT_PASSWORD::400')
if (email && code && password) {
if (password.length < 8)
throw Error('INCORRECT_PASSWORD::400')
db
.prepare('insert into customers (email, password) values (:email, :password)')
.run({email, password})
db
.prepare('insert into customers (email, password, is_blocked) values (:email, :password, 0)')
.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
app.get('/customer/profile', (req, res, next) => {
const row = db
.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
where id = :customer_id
`)
.get(res.locals)
if (row?.upload_group_id) {
row.upload_group = db
.prepare(`select id, name, telegram_id from groups where id = :group_id and project_id is null`)
if (row?.upload_chat_id) {
row.upload_chat = db
.prepare(`select id, name, telegram_id from chats where id = :chat_id and project_id is null`)
.safeIntegers(true)
.get({ group_id: row.upload_group_id})
delete row.upload_group_id
.get({ chat_id: row.upload_chat_id})
delete row.upload_chat_id
}
for (const key in row) {
@@ -167,15 +313,36 @@ app.put('/customer/profile', (req, res, next) => {
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
app.get('/project', (req, res, next) => {
const where = req.query.id ? ' and id = ' + parseInt(req.query.id) : ''
const rows = db
.prepare(`
select id, name, description, logo
from projects
where customer_id = :customer_id ${where} and is_deleted <> 1
select id, name, description, logo, is_logo_bg, is_archived,
(select count(*) from chats where project_id = p.id) chat_count,
(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
`)
.all(res.locals)
@@ -204,7 +371,7 @@ app.post('/project', (req, res, next) => {
.pluck(true)
.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) => {
@@ -212,11 +379,12 @@ app.put('/project/:pid(\\d+)', (req, res, next) => {
res.locals.name = req.body?.name
res.locals.description = req.body?.description
res.locals.logo = req.body?.logo
res.locals.is_logo_bg = req.body?.is_logo_bg
const info = db
.prepareUpdate(
'projects',
['name', 'description', 'logo'],
['name', 'description', 'logo', 'is_logo_bg'],
res.locals,
['id', 'customer_id'])
.run(res.locals)
@@ -224,39 +392,41 @@ app.put('/project/:pid(\\d+)', (req, res, next) => {
if (info.changes == 0)
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.is_archived = +(req.params.action == 'archive')
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)
if (info.changes == 0)
throw Error('NOT_FOUND::404')
throw Error('BAD_REQUEST::400')
const groupIds = db
.prepare(`select id from groups where project_id = :id`)
const chatIds = db
.prepare(`select id from chats where project_id = :id`)
.pluck(true)
.all(res.locals)
for (const groupId of groupIds) {
await bot.sendMessage(groupId, 'Проект удален')
await bot.leaveGroup(groupId)
for (const chatId of chatIds) {
await bot.sendMessage(chatId, res.locals.is_archived ? 'Проект помещен в архив. Отслеживание сообщений прекращено.' : 'Проект восстановлен из архива.')
}
db.prepare(`updates groups set project_id = null where id in (${ groupIds.join(', ')})`).run()
res.status(200).json({success: true})
res.redirect(req.baseUrl + `/project?id=${req.params.pid}`)
})
app.use ('/project/:pid(\\d+)/*', (req, res, next) => {
res.locals.project_id = parseInt(req.params.pid)
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)
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
where id in (
select user_id
from group_users
where group_id in (select id from groups where project_id = :project_id)
from chat_users
where chat_id in (select id from chats where project_id = :project_id)
) ${where}
`)
.safeIntegers(true)
@@ -337,7 +507,7 @@ app.get('/project/:pid(\\d+)/company', (req, res, next) => {
const rows = db
.prepare(`
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
where project_id = :project_id ${where}
order by name
@@ -373,7 +543,7 @@ app.post('/project/:pid(\\d+)/company', (req, res, next) => {
.pluck(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) => {
@@ -395,7 +565,7 @@ app.put('/project/:pid(\\d+)/company/:cid(\\d+)', (req, res, next) => {
if (info.changes == 0)
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) => {
@@ -411,13 +581,13 @@ app.delete('/project/:pid(\\d+)/company/:cid(\\d+)', (req, res, next) => {
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 rows = db
.prepare(`
select id, name, telegram_id, is_channel, user_count, bot_can_ban
from groups
from chats
where project_id = :project_id ${where}
`)
.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})
})
app.get('/project/:pid(\\d+)/group/:gid(\\d+)', (req, res, next) => {
res.redirect(req.baseUrl + `/project/${req.params.pid}/group?id=${req.params.uid}`)
app.get('/project/:pid(\\d+)/chat/:gid(\\d+)', (req, res, next) => {
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) => {
res.locals.group_id = parseInt(req.params.gid)
app.delete('/project/:pid(\\d+)/chat/:gid(\\d+)', async (req, res, next) => {
res.locals.chat_id = parseInt(req.params.gid)
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)
if (info.changes == 0)
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})
})
@@ -463,8 +633,8 @@ app.put('/project/:pid(\\d+)/company/:cid(\\d+)/user', (req, res, next) => {
let rows = db
.prepare(`
select user_id
from group_users
where group_id in (select id from groups where project_id = :project_id)
from chat_users
where chat_id in (select id from chats where project_id = :project_id)
`)
.pluck(true) // .raw?
.get(res.locals)

View File

@@ -10,9 +10,7 @@ const { NewMessage } = require('telegram/events')
const { Button } = require('telegram/tl/custom/button')
const { CustomFile } = require('telegram/client/uploads')
//const session = new StringSession('1AgAOMTQ5LjE1NC4xNjcuNTABu2OaFuD5Oyi5wGck+n5ldAfshzYfwlWee+OUxYBvFzlKAdW11Hsndu1SJBLUnKjP8sTJEPbLwdqANBhBXmQMghLVAblwK6TxLfsWxy2zf/HGLeNXohhrsep0hBxu9imyHV6OI6gQG+c5qaGkzjZrz0AcS4ut0xy99XrXgjiNfnjeMX7a0mOk6IK9iKdwbX9kXTfclFLVppiBGXolYJjVb2E57tk4+7RncIVyw+Fxn0NZfnhEfHJZly6j03arZOeM5VYl9ul8+3lJDD+KJJHeMgImmYjmcFcF3CbtkhPuTSPnWKtCnm2sRzepn5VFfoG6zgYff04fBdKGvHAai+wQSOY=')
const session = new StringSession('1AgAOMTQ5LjE1NC4xNjcuNTEBuzSgmBQR5/m8M8cyOnsLCIOkYQJTizJoJRZiPKK+eBjMuodc0JuKQwzeWBRJI/c6YxaBHvokpngf5kr57uly+meSPPlFq6MyoSSQDbEJ3VAAWJu+/ALN0ickE92RjRfM5Kw6DimC9FXuMgJJsoUHtk/i+ZGXy9JB+q67G0yy8NvFIuWpFHJDkwmi0qTlTgJ5UOm4PYkV01iNUcV5siaWFVTTLsetHtBUdMOzg5WjjvuOyYV/MIx+z7ynhvF3DxLPCugxqhCvZ/RW+0vldrTX5TZ0BzIDk2eNFQjRORJcZo6upwvH7aZYStV4DxhIi1dEYu5gyvnt4vkbR5kuvE/GqO0=')
const session = new StringSession(process.env.BOT_SID || '')
let client
@@ -82,38 +80,38 @@ async function updateUserPhoto (userId, data) {
.run({ user_id: userId, photo_id: photoId, photo: file.toString('base64') })
}
async function registerGroup (telegramId, isChannel) {
async function registerChat (telegramId, isChannel) {
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)
.run({ telegram_id: telegramId, is_channel: +isChannel })
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)
.get({telegram_id: telegramId})
if (!row?.name) {
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
.prepare(`update groups set name = :name where id = :group_id`)
.run({ group_id: row.id, name: group.title })
.prepare(`update chats set name = :name where id = :chat_id`)
.run({ chat_id: row.id, name: chat.title })
}
return row.id
}
async function attachGroup(groupId, isChannel, projectId) {
async function attachChat(chatId, isChannel, projectId) {
const info = db
.prepare(`update groups set project_id = :project_id where id = :group_id and coalesce(project_id, 1) = 1`)
.run({ group_id: groupId, project_id: projectId })
.prepare(`update chats set project_id = :project_id where id = :chat_id and coalesce(project_id, 1) = 1`)
.run({ chat_id: chatId, project_id: projectId })
if (info.changes == 1) {
const inputPeer = isChannel ?
new Api.InputPeerChannel({ channelId: tgGroupId }) :
new Api.InputPeerChat({ chatlId: tgGroupId })
new Api.InputPeerChannel({ channelId: tgChatId }) :
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 message = db
@@ -127,14 +125,14 @@ async function attachGroup(groupId, isChannel, projectId) {
return info.changes == 1
}
async function onGroupAttach (tgGroupId, isChannel) {
async function onChatAttach (tgChatId, isChannel) {
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)
.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 ?
new Api.InputPeerChannel(entity) :
new Api.InputPeerChat(entity)
@@ -151,48 +149,44 @@ async function onGroupAttach (tgGroupId, isChannel) {
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
.prepare(`delete from group_users where group_id = :group_id`)
.run({ group_id: groupId })
.prepare(`delete from chat_users where chat_id = :chat_id`)
.run({ chat_id: chatId })
if (onlyReset)
return
const group = db
.prepare(`select telegram_id, is_channel, access_hash from groups where id = :group_id`)
.get({ group_id: groupId})
const chat = db
.prepare(`select telegram_id, is_channel, access_hash from chats where id = :chat_id`)
.get({ chat_id: chatId})
console.log (123, group)
if (!group)
if (!chat)
return
const tgGroupId = group.telegram_id
const isChannel = group.is_channel
let accessHash = group.access_hash
console.log ('HERE')
const tgChatId = chat.telegram_id
const isChannel = chat.is_channel
let accessHash = chat.access_hash
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)
.run({
group_id: groupId,
chat_id: chatId,
access_hash: accessHash,
})
const result = isChannel ?
await client.invoke(new Api.channels.GetParticipants({
channel: new Api.PeerChannel({ channelId: tgGroupId }),
channel: new Api.PeerChannel({ channelId: tgChatId }),
filter: new Api.ChannelParticipantsRecent(),
limit: 999999,
offset: 0
})) : await client.invoke(new Api.messages.GetFullChat({
chatId: tgGroupId,
chatId: tgChatId,
}))
const users = result.users.filter(user => !user.bot)
@@ -202,8 +196,8 @@ async function reloadGroupUsers(groupId, onlyReset) {
if (updateUser(userId, user)) {
await updateUserPhoto (userId, user)
const query = `insert or ignore into group_users (group_id, user_id) values (:group_id, :user_id)`
db.prepare(query).run({ group_id: groupId, user_id: userId })
const query = `insert or ignore into chat_users (chat_id, user_id) values (:chat_id, :user_id)`
db.prepare(query).run({ chat_id: chatId, user_id: userId })
}
}
}
@@ -212,11 +206,11 @@ async function registerUpload(data) {
if (!data.projectId || !data.media)
return false
const uploadGroup = db
const uploadChat = db
.prepare(`
select id, telegram_id, project_id, is_channel, access_hash
from groups
where id = (select upload_group_id
from chats
where id = (select upload_chat_id
from customers
where id = (select customer_id from projects where id = :project_id limit 1)
limit 1)
@@ -225,14 +219,14 @@ async function registerUpload(data) {
.safeIntegers(true)
.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
const tgUploadGroupId = uploadGroup.telegram_id
const tgUploadChatId = uploadChat.telegram_id
const peer = uploadGroup.is_channel ?
new Api.PeerChannel({ channelId: tgUploadGroupId }) :
new Api.PeerChat({ chatlId: tgUploadGroupId })
const peer = uploadChat.is_channel ?
new Api.PeerChannel({ channelId: tgUploadChatId }) :
new Api.PeerChat({ chatlId: tgUploadChatId })
let resultId = 0
@@ -248,16 +242,16 @@ async function registerUpload(data) {
const update = result.updates.find(u =>
(u.className == 'UpdateNewMessage' || u.className == 'UpdateNewChannelMessage') &&
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)
const udoc = update?.message?.media?.document
if (udoc) {
resultId = db
.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)
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)
returning id
`)
@@ -265,9 +259,9 @@ async function registerUpload(data) {
.pluck(true)
.get({
project_id: data.projectId,
origin_group_id: data.originGroupId,
origin_chat_id: data.originchatId,
origin_message_id: data.originMessageId,
group_id: uploadGroup.id,
chat_id: uploadChat.id,
message_id: update.message.id,
file_id: udoc.id.value,
filename: udoc.attributes.find(attr => attr.className == 'DocumentAttributeFilename')?.fileName,
@@ -291,14 +285,14 @@ async function registerUpload(data) {
async function onNewServiceMessage (msg, isChannel) {
const action = msg.action || {}
const tgGroupId = isChannel ? msg.peerId?.channelId?.value : msg.peerId?.chatId?.value
const groupId = await registerGroup(tgGroupId, isChannel)
const tgChatId = isChannel ? msg.peerId?.channelId?.value : msg.peerId?.chatId?.value
const chatId = await registerChat(tgChatId, isChannel)
// Group/Channel rename
// Ghat rename
if (action.className == 'MessageActionChatEditTitle') {
const info = db
.prepare(`
update groups
update chats
set name = :name, is_channel = :is_channel, last_update_time = :last_update_time
where telegram_id = :telegram_id
`)
@@ -307,7 +301,7 @@ async function onNewServiceMessage (msg, isChannel) {
name: action.title,
is_channel: +isChannel,
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') {
const info = db
.prepare(`
update groups
update chats
set telegram_id = :new_telegram_id, name = :name, is_channel = 1, last_update_time = :last_update_time
where telegram_id = :old_telegram_id
`)
@@ -323,7 +317,7 @@ async function onNewServiceMessage (msg, isChannel) {
.run({
name: action.title,
last_update_time: Date.now() / 1000,
old_telegram_id: tgGroupId,
old_telegram_id: tgChatId,
new_telegram_id: action.channelId.value
})
}
@@ -352,27 +346,27 @@ async function onNewServiceMessage (msg, isChannel) {
}
const query = isAdd ?
`insert or ignore into group_users (group_id, user_id) values (:group_id, :user_id)` :
`delete from group_users where group_id = :group_id and user_id = :user_id`
db.prepare(query).run({ group_id: groupId, user_id: userId })
`insert or ignore into chat_users (chat_id, user_id) values (:chat_id, :user_id)` :
`delete from chat_users where chat_id = :chat_id and user_id = :user_id`
db.prepare(query).run({ chat_id: chatId, user_id: userId })
}
}
}
}
async function onNewMessage (msg, isChannel) {
const tgGroupId = isChannel ? msg.peerId?.channelId?.value : msg.peerId?.chatId?.value
const groupId = await registerGroup(tgGroupId, isChannel)
const tgChatId = isChannel ? msg.peerId?.channelId?.value : msg.peerId?.chatId?.value
const chatId = await registerChat(tgChatId, isChannel)
// Document is detected
if (msg.media?.document) {
const doc = msg.media.document
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)
.pluck(true)
.get({telegram_id: tgGroupId})
.get({telegram_id: tgChatId})
const media = new Api.InputMediaDocument({
id: new Api.InputDocument({
@@ -386,7 +380,7 @@ async function onNewMessage (msg, isChannel) {
projectId,
media,
caption: msg.message,
originGroupId: groupId,
originchatId: chatId,
originMessageId: msg.id,
parentType: 0,
publishedBy: registerUser (msg.fromId?.userId?.value)
@@ -399,22 +393,22 @@ async function onNewMessage (msg, isChannel) {
select name
from projects
where id in (
select project_id from groups where id = :group_id
select project_id from chats where id = :chat_id
union
select id from projects where upload_group_id = :group_id)
select id from projects where upload_chat_id = :chat_id)
`)
.pluck(true)
.get({ group_id: groupId })
.get({ chat_id: chatId })
if (projectName)
return await bot.sendMessage(groupId, 'Группа уже используется на проекте ' + projectName)
return await bot.sendMessage(chatId, 'Группа уже используется на проекте ' + projectName)
const [_, time64, key] = msg.message.substr(3).split('-')
const now = Math.floor(Date.now() / 1000)
const time = Buffer.from(time64, 'base64')
if (now - 3600 >= time && time >= now)
return await bot.sendMessage(groupId, 'Время действия ключа для привязки истекло')
return await bot.sendMessage(chatId, 'Время действия ключа для привязки истекло')
const projectId = db
.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 })
if (projectId) {
await attachGroup(groupId, isChannel, projectId)
await onGroupAttach(tgGroupId, isChannel)
await attachChat(chatId, isChannel, projectId)
await onChatAttach(tgChatId, isChannel)
}
}
@@ -440,12 +434,12 @@ async function onNewMessage (msg, isChannel) {
db
.prepare(`
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
`)
.safeIntegers(true)
.run({
group_id: groupId,
chat_id: chatId,
customer_id: customerId,
telegram_user_id: tgUserId
})
@@ -462,9 +456,9 @@ async function onNewMessage (msg, isChannel) {
db
.prepare(`
update groups
update chats
set project_id = :project_id
where id = :group_id and exists(
where id = :chat_id and exists(
select 1
from customers
where id = :customer_id and telegram_user_id = :telegram_user_id)
@@ -472,13 +466,13 @@ async function onNewMessage (msg, isChannel) {
.safeIntegers(true)
.run({
project_id: projectId,
group_id: groupId,
chat_id: chatId,
customer_id: customerId,
telegram_user_id: tgUserId
})
await reloadGroupUsers(groupId, false)
await onGroupAttach(tgGroupId, isChannel)
await reloadChatUsers(chatId, false)
await onChatAttach(tgChatId, isChannel)
}
}
}
@@ -516,34 +510,34 @@ async function onNewUserMessage (msg) {
}
async function onUpdatePaticipant (update, isChannel) {
const tgGroupId = isChannel ? update.channelId?.value : update.chatlId?.value
if (!tgGroupId || update.userId?.value != bot.id)
const tgChatId = isChannel ? update.channelId?.value : update.chatlId?.value
if (!tgChatId || update.userId?.value != bot.id)
return
const groupId = await registerGroup (tgGroupId, isChannel)
const chatId = await registerChat (tgChatId, isChannel)
const isBan = update.prevParticipant && !update.newParticipant
const isAdd = (!update.prevParticipant || update.prevParticipant?.className == 'ChannelParticipantBanned') && update.newParticipant
if (isBan || isAdd)
await reloadGroupUsers(groupId, isBan)
await reloadChatUsers(chatId, isBan)
if (isBan) {
db
.prepare(`update groups set project_id = null where id = :group_id`)
.run({group_id: groupId})
.prepare(`update chats set project_id = null where id = :chat_id`)
.run({chat_id: chatId})
}
const botCanBan = update.newParticipant?.adminRights?.banUsers || 0
db
.prepare(`update groups set bot_can_ban = :bot_can_ban where id = :group_id`)
.run({group_id: groupId, bot_can_ban: +botCanBan})
.prepare(`update chats set bot_can_ban = :bot_can_ban where id = :chat_id`)
.run({chat_id: chatId, bot_can_ban: +botCanBan})
}
class Bot extends EventEmitter {
async start (apiId, apiHash, botAuthToken) {
this.id = 7236504417n
this.id = BigInt(botAuthToken.split(':')[0])
client = new TelegramClient(session, apiId, apiHash, {})
@@ -568,7 +562,7 @@ class Bot extends EventEmitter {
})
await client.start({botAuthToken})
console.log('SID: ', session.save())
console.log('BOT_SID: ', session.save())
}
async uploadDocument(projectId, fileName, mime, data, parentType, parentId, publishedBy) {
@@ -616,20 +610,20 @@ class Bot extends EventEmitter {
}
}
async reloadGroupUsers(groupId, onlyReset) {
return reloadGroupUsers(groupId, onlyReset)
async reloadChatUsers(chatId, onlyReset) {
return reloadChatUsers(chatId, onlyReset)
}
async sendMessage (groupId, message) {
const group = db
.prepare(`select telegram_id, is_channel from groups where id = :group_id`)
.get({ group_id: groupId})
async sendMessage (chatId, message) {
const chat = db
.prepare(`select telegram_id, is_channel from chats where id = :chat_id`)
.get({ chat_id: chatId})
if (!group)
if (!chat)
return
const entity = group.is_channel ? { channelId: group.telegram_id } : { chatId: group.telegram_id }
const inputPeer = await client.getEntity( group.is_channel ?
const entity = chat.is_channel ? { channelId: chat.telegram_id } : { chatId: chat.telegram_id }
const inputPeer = await client.getEntity( chat.is_channel ?
new Api.InputPeerChannel(entity) :
new Api.InputPeerChat(entity)
)
@@ -640,19 +634,19 @@ class Bot extends EventEmitter {
await delay(1000)
}
async leaveGroup (groupId) {
const group = db
.prepare(`select telegram_id, is_channel from groups where id = :group_id`)
.get({ group_id: groupId})
async leaveChat (chatId) {
const chat = db
.prepare(`select telegram_id, is_channel from chats where id = :chat_id`)
.get({ chat_id: chatId})
if (!group)
if (!chat)
return
if (group.is_channel) {
const inputPeer = await client.getEntity(new Api.InputPeerChannel({ channelId: group.telegram_id }))
if (chat.is_channel) {
const inputPeer = await client.getEntity(new Api.InputPeerChannel({ channelId: chat.telegram_id }))
await client.invoke(new Api.channels.LeaveChannel({ channel: inputPeer }))
} 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
.prepare(`
select 1
from group_users
from chat_users
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 projects where id = :project_id and is_deleted = 1)
`)
@@ -70,13 +70,13 @@ app.get('/project', (req, res, next) => {
const rows = db
.prepare(`
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
inner join customers c on p.customer_id = c.id
where p.id in (
select project_id
from groups
where id in (select group_id from group_users where user_id = :user_id)
from chats
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)
${where} and is_deleted <> 1
`)
@@ -114,9 +114,9 @@ app.get('/project/:pid(\\d+)/user', (req, res, next) => {
.prepare(`
with actuals (user_id) as (
select distinct user_id
from group_users
where group_id in (select id from groups where project_id = :project_id)
and group_id in (select group_id from group_users where user_id = :user_id)
from chat_users
where chat_id in (select id from chats where project_id = :project_id)
and chat_id in (select chat_id from chat_users where user_id = :user_id)
),
contributors (user_id) as (
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) => {
const groupIds = db
.prepare(`select id from groups where project_id = :project_id`)
const chatIds = db
.prepare(`select id from chats where project_id = :project_id`)
.all(res.locals)
.map(e => e.id)
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
for (const groupId of groupIds) {
await bot.reloadGroupUsers(groupId)
for (const chatId of chatIds) {
await bot.reloadGroupUsers(chatId)
await sleep(1000)
}
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 rows = db
.prepare(`
select id, name, telegram_id
from groups
where project_id = :project_id and id in (select group_id from group_users where user_id = :user_id)
from chats
where project_id = :project_id and id in (select chat_id from chat_users where user_id = :user_id)
${where}
`)
.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})
})
app.get('/project/:pid(\\d+)/group/:gid(\\d+)', (req, res, next) => {
res.redirect(req.baseUrl + `/project/${req.params.pid}/group?id=${req.params.gid}`)
app.get('/project/:pid(\\d+)/chat/:gid(\\d+)', (req, res, next) => {
res.redirect(req.baseUrl + `/project/${req.params.pid}/chat?id=${req.params.gid}`)
})
// TASK
@@ -237,8 +237,8 @@ app.get('/project/:pid(\\d+)/task', (req, res, next) => {
const rows = db
.prepare(`
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_group_array(id) from documents where parent_type = 1 and parent_id = t.id) attachments
(select json_chat_array(user_id) from task_users where task_id = t.id) observers,
(select json_chat_array(id) from documents where parent_type = 1 and parent_id = t.id) attachments
from tasks t
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))
@@ -351,8 +351,8 @@ app.put('/project/:pid(\\d+)/task/:tid(\\d+)/observer', (req, res, next) => {
let rows = db
.prepare(`
select user_id
from group_users
where group_id in (select id from groups where project_id = :project_id)
from chat_users
where chat_id in (select id from chats where project_id = :project_id)
`)
.pluck(true)
.all(res.locals)
@@ -379,8 +379,8 @@ app.get('/project/:pid(\\d+)/meeting', (req, res, next) => {
const rows = db
.prepare(`
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_group_array(id) from documents where parent_type = 2 and parent_id = m.id) attachments
(select json_chat_array(user_id) from meeting_users where meeting_id = m.id) participants,
(select json_chat_array(id) from documents where parent_type = 2 and parent_id = m.id) attachments
from meetings m
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))
@@ -483,8 +483,8 @@ app.put('/project/:pid(\\d+)/meeting/:mid(\\d+)/participants', (req, res, next)
let rows = db
.prepare(`
select user_id
from group_users
where group_id in (select id from groups where project_id = :project_id)
from chat_users
where chat_id in (select id from chats where project_id = :project_id)
`)
.pluck(true) // .raw?
.all(res.locals)
@@ -516,10 +516,10 @@ app.get('/project/:pid(\\d+)/document', (req, res, next) => {
// To-Do: отдавать готовую ссылку --> как минимум GROUP_ID надо заменить на tgGroupId
const rows = db
.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
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
parent_type = 1 and parent_id in (
select id
@@ -566,9 +566,9 @@ app.use('/project/:pid(\\d+)/document/:did(\\d+)', (req, res, next) => {
throw Error('NOT_FOUND::404')
if (doc.parent_type == 0) {
res.locals.group_id = doc.group_id
res.locals.chat_id = doc.chat_id
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)
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 '{}',
is_blocked integer default 0,
json_company text default '{}',
upload_group_id integer,
upload_chat_id integer,
json_backup_server text default '{}',
json_backup_params text default '{}'
json_backup_params text default '{}',
json_settings text default '{}'
) strict;
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),
description text check(description is null or length(description) < 4096),
logo text,
is_deleted integer default 0
is_logo_bg integer default 0,
is_archived integer default 0
) strict;
create table if not exists groups (
create table if not exists chats (
id integer primary key autoincrement,
project_id integer references projects(id) on delete cascade,
name text,
@@ -35,7 +37,7 @@ create table if not exists groups (
user_count 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 (
id integer primary key autoincrement,
@@ -90,9 +92,9 @@ create table if not exists meetings (
create table if not exists documents (
id integer primary key autoincrement,
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,
group_id integer references groups(id) on delete set null,
chat_id integer references chats(id) on delete set null,
message_id integer,
file_id integer,
access_hash integer,
@@ -142,20 +144,20 @@ create table if not exists company_users (
primary key (company_id, user_id)
) without rowid;
create table if not exists group_users (
group_id integer references groups(id) on delete cascade,
create table if not exists chat_users (
chat_id integer references chats(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;
pragma foreign_keys = on;
create trigger if not exists trg_groups_update after update
on groups
create trigger if not exists trg_chats_update after update
on chats
when NEW.project_id is null
begin
delete from group_users where group_id = NEW.id;
delete from chat_users where chat_id = NEW.id;
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"
},
"node_modules/node-abi": {
"version": "3.74.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.74.0.tgz",
"integrity": "sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==",
"version": "3.75.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz",
"integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==",
"license": "MIT",
"dependencies": {
"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": {
"@quasar/cli": "^2.5.0",
"@quasar/extras": "^1.16.4",
"@quasar/vite-plugin": "^1.9.0",
"axios": "^1.2.1",
"pinia": "^2.0.11",
"quasar": "^2.16.0",
"quasar": "^2.18.1",
"vue": "^3.4.18",
"vue-i18n": "^9.2.2",
"vue-router": "^4.0.12"
@@ -30,7 +31,6 @@
"@twa-dev/types": "^8.0.2",
"@types/node": "^20.17.30",
"@types/telegram-web-app": "^7.10.1",
"@vue/devtools": "^7.7.2",
"@vue/eslint-config-typescript": "^14.1.3",
"autoprefixer": "^10.4.2",
"eslint": "^9.14.0",

View File

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

View File

@@ -4,23 +4,34 @@
<script setup lang="ts">
import { inject, onMounted } from 'vue'
import { useRouter } from 'vue-router'
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
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>
type LocaleCode = keyof typeof localeMap
const normLocale = (locale?: string): string | undefined => {
if (!locale) return undefined
const code = locale.split('-')[0] as LocaleCode
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()
})
const textSizeStore = useTextSizeStore()
onMounted(async () => {
try {
await textSizeStore.initialize()
} catch (err) {
console.error('Error load font size:', err)
}
})
</script>

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
import { defineBoot } from '#q-app/wrappers';
import { createI18n } from 'vue-i18n';
import { defineBoot } from '#q-app/wrappers'
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
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
/* eslint-disable @typescript-eslint/no-empty-object-type */
@@ -26,8 +26,8 @@ export default defineBoot(({ app }) => {
locale: 'en-US',
legacy: false,
messages,
});
})
// Set i18n instance on app
app.use(i18n);
});
app.use(i18n)
})

View File

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

View File

@@ -12,23 +12,27 @@
no-error-icon
dense
filled
label-slot
class = "w100 fix-bottom-padding"
:label="$t('project_card__project_name')"
:rules="[rules.name]"
/>
>
<template #label>
{{ $t('project_card__project_name') }} <span class="text-red">*</span>
</template>
</q-input>
<q-input
v-model="modelValue.description"
dense
filled
autogrow
class="w100"
class="w100 q-pt-sm"
:label="$t('project_card__project_description')"
/>
<q-checkbox
v-if="modelValue.logo"
v-model="modelValue.logo_as_bg"
v-model="modelValue.is_logo_bg"
class="w100"
dense
>
@@ -41,7 +45,7 @@
<script setup lang="ts">
import { watch, computed } from 'vue'
import type { ProjectParams } from 'src/types'
import type { ProjectParams } from 'types/Project'
import { useI18n } from 'vue-i18n'
const { t }= useI18n()
@@ -68,7 +72,7 @@
</script>
<style>
.q-field--with-bottom.fix-bottom-padding {
padding-bottom: 0 !important
}
.q-field--with-bottom.fix-bottom-padding {
padding-bottom: 0 !important
}
</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">
<pn-account-block-name/>
<q-btn
v-if="user?.email"
@click="logout()"
flat
round
@@ -53,7 +52,6 @@
const router = useRouter()
const authStore = useAuthStore()
const user = authStore.user
const items = computed(() => ([
{ 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 () {
await authStore.logout()
await router.push({ name: 'login' })
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,35 +1,18 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import type { Project, ProjectParams } from '../types'
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', () => {
const projects = ref<Project[]>([])
const currentProjectId = ref<number | null>(null)
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() {
const prjs = await api.get('/project')
console.log(2222, prjs)
if (Array.isArray(prjs)) projects.value.push(...prjs)
const response = await api.get('/project')
const projectsAPI = response.data.data.map((el: RawProject) => clientConverter<Project, RawProject>(el, ['is_logo_bg', 'is_archived']))
projects.value.push(...projectsAPI)
isInit.value = true
}
@@ -37,27 +20,32 @@ export const useProjectsStore = defineStore('projects', () => {
return projects.value.find(el =>el.id === id)
}
async function addProject (projectData: ProjectParams) {
const newProject = await api.put('/project', projectData)
console.log(newProject)
// projects.value.push(newProject)
async function add (projectData: ProjectParams) {
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'])
projects.value.push(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)
Object.assign(projects.value[idx] || {}, project)
if (projects.value[idx]) Object.assign(projects.value[idx], projectAPI)
}
function archiveProject (id :number, status :boolean) {
const idx = projects.value.findIndex(item => item.id === id)
if (projects.value[idx]) projects.value[idx].is_archive = status
async function archive (id :number) {
const response = await api.put('/project/'+ id + '/archive')
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) {
const idx = projects.value.findIndex(item => item.id === id)
projects.value.splice(idx, 1)
async function restore (id :number) {
const response = await api.put('/project/'+ id + '/restore')
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) {
@@ -74,10 +62,10 @@ export const useProjectsStore = defineStore('projects', () => {
projects,
currentProjectId,
projectById,
addProject,
updateProject,
archiveProject,
deleteProject,
add,
update,
archive,
restore,
setCurrentProjectId,
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
description?: string
logo?: string
logo_as_bg: boolean
is_logo_bg: boolean
}
interface Project extends ProjectParams {
id: number
is_archive: boolean
chats: number
companies: number
persons: number
is_archived: boolean
chat_count: number
user_count: number
}
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",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"src/*": ["./src/*"],
"app/*": ["./src/*"],
"components/*": ["./src/components/*"],
"layouts/*": ["./src/layouts/*"],
"pages/*": ["./src/pages/*"],
"assets/*": ["./src/assets/*"],
"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"]
},