Migrate web app from Python/FastAPI to Node.js/TypeScript
Replace the entire Python/FastAPI backend with a Node.js/TypeScript stack: - Framework: Hono + @hono/node-server - Templates: Nunjucks (.njk) replacing Jinja2 (.html) - ORM: Drizzle ORM with mysql2 (same MariaDB schema, no migrations needed) - Sessions: hono-sessions with CookieStore - CSS: Pico CSS v2 replacing Bootstrap 5 (Bootstrap Icons CDN kept) - Dev: tsx watch; Prod: tsc + node dist/index.js Original Python app preserved in web-python/ as backup. Updated Dockerfile.web and docker-compose.yml for Node.js deployment. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
124
web/src/routes/auth.ts
Normal file
124
web/src/routes/auth.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Hono } from "hono";
|
||||
import { getCookie } from "hono/cookie";
|
||||
import { db } from "../db/client.js";
|
||||
import { users } from "../db/schema.js";
|
||||
import { eq, or } from "drizzle-orm";
|
||||
import { hashPassword, verifyPassword, getSessionUserId, type AppEnv } from "../lib/auth.js";
|
||||
import { validateRegistration, validateLogin } from "../lib/validate.js";
|
||||
import { render } from "../lib/render.js";
|
||||
import { config } from "../config.js";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export const authRouter = new Hono<AppEnv>();
|
||||
|
||||
authRouter.get("/register", async (c) => {
|
||||
const userId = getSessionUserId(c);
|
||||
if (userId) return c.redirect("/profile", 303);
|
||||
return c.html(render("register.njk", { user: null }));
|
||||
});
|
||||
|
||||
authRouter.post("/register", async (c) => {
|
||||
const form = await c.req.formData();
|
||||
const data: Record<string, string> = {};
|
||||
form.forEach((v, k) => { data[k] = String(v); });
|
||||
|
||||
const errors = validateRegistration(data);
|
||||
|
||||
if (!errors.length) {
|
||||
const existing = await db.select().from(users).where(
|
||||
or(eq(users.email, data.email.trim()), eq(users.phone, data.phone.trim()))
|
||||
).limit(1);
|
||||
if (existing.length) {
|
||||
if (existing[0].email === data.email.trim()) {
|
||||
errors.push("Пользователь с таким email уже существует");
|
||||
} else {
|
||||
errors.push("Пользователь с таким телефоном уже существует");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
return c.html(render("register.njk", { user: null, errors, form: data }));
|
||||
}
|
||||
|
||||
const token = randomUUID().replace(/-/g, "");
|
||||
await db.insert(users).values({
|
||||
first_name: data.first_name.trim(),
|
||||
last_name: data.last_name.trim(),
|
||||
email: data.email.trim(),
|
||||
phone: data.phone.trim(),
|
||||
password_hash: await hashPassword(data.password),
|
||||
email_confirm_token: token,
|
||||
});
|
||||
|
||||
const confirmUrl = `${config.BASE_URL}/confirm-email?token=${token}`;
|
||||
console.log("=".repeat(40));
|
||||
console.log("ПОДТВЕРЖДЕНИЕ EMAIL");
|
||||
console.log(`Пользователь: ${data.email.trim()}`);
|
||||
console.log(`Ссылка: ${confirmUrl}`);
|
||||
console.log("=".repeat(40));
|
||||
|
||||
return c.html(render("confirm_email.njk", { user: null }));
|
||||
});
|
||||
|
||||
authRouter.get("/confirm-email", async (c) => {
|
||||
const token = c.req.query("token");
|
||||
if (!token) {
|
||||
return c.html(render("message.njk", {
|
||||
user: null, title: "Ошибка", message: "Неверная или устаревшая ссылка.",
|
||||
}));
|
||||
}
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.email_confirm_token, token)).limit(1);
|
||||
if (!user) {
|
||||
return c.html(render("message.njk", {
|
||||
user: null, title: "Ошибка", message: "Неверная или устаревшая ссылка.",
|
||||
}));
|
||||
}
|
||||
|
||||
await db.update(users)
|
||||
.set({ is_email_confirmed: true, email_confirm_token: null })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
return c.html(render("email_confirmed.njk", { user: null }));
|
||||
});
|
||||
|
||||
authRouter.get("/login", async (c) => {
|
||||
const userId = getSessionUserId(c);
|
||||
if (userId) return c.redirect("/profile", 303);
|
||||
return c.html(render("login.njk", { user: null }));
|
||||
});
|
||||
|
||||
authRouter.post("/login", async (c) => {
|
||||
const form = await c.req.formData();
|
||||
const data: Record<string, string> = {};
|
||||
form.forEach((v, k) => { data[k] = String(v); });
|
||||
|
||||
const errors = validateLogin(data);
|
||||
if (errors.length) {
|
||||
return c.html(render("login.njk", { user: null, errors, form: data }));
|
||||
}
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.email, data.email.trim())).limit(1);
|
||||
if (!user || !(await verifyPassword(data.password, user.password_hash))) {
|
||||
return c.html(render("login.njk", {
|
||||
user: null, errors: ["Неверный email или пароль"], form: data,
|
||||
}));
|
||||
}
|
||||
|
||||
if (!user.is_email_confirmed) {
|
||||
return c.html(render("login.njk", {
|
||||
user: null, errors: ["Пожалуйста, подтвердите ваш email"], form: data,
|
||||
}));
|
||||
}
|
||||
|
||||
const session = c.get("session") as { set: (k: string, v: unknown) => void };
|
||||
session.set("user_id", user.id);
|
||||
return c.redirect("/profile", 303);
|
||||
});
|
||||
|
||||
authRouter.get("/logout", (c) => {
|
||||
const session = c.get("session") as { deleteSession: () => void };
|
||||
session.deleteSession();
|
||||
return c.redirect("/login", 303);
|
||||
});
|
||||
260
web/src/routes/catalog.ts
Normal file
260
web/src/routes/catalog.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { Hono } from "hono";
|
||||
import { db } from "../db/client.js";
|
||||
import { users, evotor_connections, sync_configs, sync_filters, cached_stores, cached_groups, cached_products } from "../db/schema.js";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { getSessionUserId, type AppEnv } from "../lib/auth.js";
|
||||
import { render } from "../lib/render.js";
|
||||
import { refreshCatalogCache } from "../lib/evotorApi.js";
|
||||
|
||||
export const catalogRouter = new Hono<AppEnv>();
|
||||
|
||||
async function requireUser(c: Parameters<typeof getSessionUserId>[0]) {
|
||||
const id = getSessionUserId(c);
|
||||
if (!id) return null;
|
||||
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
||||
return user ?? null;
|
||||
}
|
||||
|
||||
async function getOrCreateSyncConfig(userId: number) {
|
||||
const [existing] = await db.select().from(sync_configs).where(eq(sync_configs.user_id, userId)).limit(1);
|
||||
if (existing) return existing;
|
||||
await db.insert(sync_configs).values({ user_id: userId, is_enabled: false });
|
||||
const [created] = await db.select().from(sync_configs).where(eq(sync_configs.user_id, userId)).limit(1);
|
||||
return created!;
|
||||
}
|
||||
|
||||
async function getFilterMap(configId: number): Promise<Record<string, string>> {
|
||||
const filters = await db.select().from(sync_filters).where(eq(sync_filters.sync_config_id, configId));
|
||||
return Object.fromEntries(filters.map((f) => [f.entity_id, f.filter_mode]));
|
||||
}
|
||||
|
||||
catalogRouter.get("/catalog", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const [evotor] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, user.id)).limit(1);
|
||||
if (!evotor) {
|
||||
return c.html(render("catalog_stores.njk", { user, evotor: null, stores: [], filter_map: {}, fetched_at: null }));
|
||||
}
|
||||
|
||||
let stores = await db.select().from(cached_stores)
|
||||
.where(eq(cached_stores.user_id, user.id))
|
||||
.orderBy(cached_stores.name);
|
||||
|
||||
if (!stores.length) {
|
||||
await refreshCatalogCache(user.id, evotor.access_token);
|
||||
stores = await db.select().from(cached_stores)
|
||||
.where(eq(cached_stores.user_id, user.id))
|
||||
.orderBy(cached_stores.name);
|
||||
}
|
||||
|
||||
const config = await getOrCreateSyncConfig(user.id);
|
||||
const filter_map = await getFilterMap(config.id);
|
||||
const fetched_at = stores[0]?.fetched_at ?? null;
|
||||
|
||||
return c.html(render("catalog_stores.njk", { user, evotor, stores, filter_map, fetched_at }));
|
||||
});
|
||||
|
||||
catalogRouter.get("/catalog/groups", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const store_id = c.req.query("store_id") ?? "";
|
||||
const [store] = await db.select().from(cached_stores)
|
||||
.where(and(eq(cached_stores.user_id, user.id), eq(cached_stores.evotor_id, store_id))).limit(1);
|
||||
if (!store) return c.redirect("/catalog", 303);
|
||||
|
||||
const groups = await db.select().from(cached_groups)
|
||||
.where(and(eq(cached_groups.user_id, user.id), eq(cached_groups.store_evotor_id, store_id)))
|
||||
.orderBy(cached_groups.name);
|
||||
|
||||
const product_counts: Record<string, number> = {};
|
||||
for (const g of groups) {
|
||||
const [row] = await db.select({ count: sql<number>`count(*)` }).from(cached_products)
|
||||
.where(and(eq(cached_products.user_id, user.id), eq(cached_products.group_evotor_id, g.evotor_id)));
|
||||
product_counts[g.evotor_id] = Number(row?.count ?? 0);
|
||||
}
|
||||
|
||||
const config = await getOrCreateSyncConfig(user.id);
|
||||
const filter_map = await getFilterMap(config.id);
|
||||
|
||||
return c.html(render("catalog_groups.njk", { user, store, groups, product_counts, filter_map }));
|
||||
});
|
||||
|
||||
catalogRouter.get("/catalog/products", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const store_id = c.req.query("store_id") ?? "";
|
||||
const group_id = c.req.query("group_id") ?? null;
|
||||
|
||||
const [store] = await db.select().from(cached_stores)
|
||||
.where(and(eq(cached_stores.user_id, user.id), eq(cached_stores.evotor_id, store_id))).limit(1);
|
||||
if (!store) return c.redirect("/catalog", 303);
|
||||
|
||||
let group = null;
|
||||
let products;
|
||||
|
||||
if (group_id) {
|
||||
[group] = await db.select().from(cached_groups)
|
||||
.where(and(eq(cached_groups.user_id, user.id), eq(cached_groups.evotor_id, group_id))).limit(1);
|
||||
products = await db.select().from(cached_products)
|
||||
.where(and(
|
||||
eq(cached_products.user_id, user.id),
|
||||
eq(cached_products.store_evotor_id, store_id),
|
||||
eq(cached_products.group_evotor_id, group_id),
|
||||
))
|
||||
.orderBy(cached_products.name);
|
||||
} else {
|
||||
products = await db.select().from(cached_products)
|
||||
.where(and(eq(cached_products.user_id, user.id), eq(cached_products.store_evotor_id, store_id)))
|
||||
.orderBy(cached_products.name);
|
||||
}
|
||||
|
||||
const config = await getOrCreateSyncConfig(user.id);
|
||||
const filter_map = await getFilterMap(config.id);
|
||||
|
||||
return c.html(render("catalog_products.njk", { user, store, group: group ?? null, products, filter_map }));
|
||||
});
|
||||
|
||||
catalogRouter.post("/catalog/filter", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const form = await c.req.formData();
|
||||
const entity_type = String(form.get("entity_type") ?? "");
|
||||
const entity_id = String(form.get("entity_id") ?? "");
|
||||
const entity_name = String(form.get("entity_name") ?? "") || null;
|
||||
const filter_mode = String(form.get("filter_mode") ?? "");
|
||||
const parent_entity_id = String(form.get("parent_entity_id") ?? "") || null;
|
||||
const redirect_to = String(form.get("redirect_to") ?? "/catalog");
|
||||
|
||||
const config = await getOrCreateSyncConfig(user.id);
|
||||
|
||||
const [existing] = await db.select().from(sync_filters)
|
||||
.where(and(
|
||||
eq(sync_filters.sync_config_id, config.id),
|
||||
eq(sync_filters.entity_type, entity_type),
|
||||
eq(sync_filters.entity_id, entity_id),
|
||||
)).limit(1);
|
||||
|
||||
if (filter_mode === "none") {
|
||||
if (existing) await db.delete(sync_filters).where(eq(sync_filters.id, existing.id));
|
||||
} else if (existing) {
|
||||
await db.update(sync_filters)
|
||||
.set({ filter_mode, entity_name })
|
||||
.where(eq(sync_filters.id, existing.id));
|
||||
} else {
|
||||
await db.insert(sync_filters).values({
|
||||
sync_config_id: config.id,
|
||||
entity_type,
|
||||
entity_id,
|
||||
entity_name,
|
||||
filter_mode,
|
||||
parent_entity_id,
|
||||
});
|
||||
}
|
||||
|
||||
return c.redirect(redirect_to, 303);
|
||||
});
|
||||
|
||||
catalogRouter.post("/catalog/refresh", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const [evotor] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, user.id)).limit(1);
|
||||
if (evotor) await refreshCatalogCache(user.id, evotor.access_token);
|
||||
|
||||
return c.redirect("/catalog", 303);
|
||||
});
|
||||
|
||||
catalogRouter.get("/catalog/export", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const type = c.req.query("type") ?? "products";
|
||||
const store_id = c.req.query("store_id") ?? null;
|
||||
const group_id = c.req.query("group_id") ?? null;
|
||||
|
||||
const syncConfig = await getOrCreateSyncConfig(user.id);
|
||||
const fmap = await getFilterMap(syncConfig.id);
|
||||
|
||||
const filterLabel = (eid: string) => {
|
||||
const m = fmap[eid];
|
||||
if (m === "include") return "Включено";
|
||||
if (m === "exclude") return "Исключено";
|
||||
return "Нет правила";
|
||||
};
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
let filename: string;
|
||||
const rows: string[][] = [];
|
||||
|
||||
const BOM = "\uFEFF";
|
||||
|
||||
if (type === "stores") {
|
||||
filename = `stores_${today}.csv`;
|
||||
rows.push(["Название", "Адрес", "ID", "Фильтр"]);
|
||||
const stores = await db.select().from(cached_stores)
|
||||
.where(eq(cached_stores.user_id, user.id))
|
||||
.orderBy(cached_stores.name);
|
||||
for (const s of stores) {
|
||||
rows.push([s.name, s.address ?? "", s.evotor_id, filterLabel(s.evotor_id)]);
|
||||
}
|
||||
} else if (type === "groups") {
|
||||
filename = `groups_${today}.csv`;
|
||||
rows.push(["Магазин", "Название", "ID", "Фильтр"]);
|
||||
const storeMap: Record<string, string> = {};
|
||||
const allStores = await db.select().from(cached_stores).where(eq(cached_stores.user_id, user.id));
|
||||
for (const s of allStores) storeMap[s.evotor_id] = s.name;
|
||||
|
||||
const q = db.select().from(cached_groups).where(
|
||||
store_id
|
||||
? and(eq(cached_groups.user_id, user.id), eq(cached_groups.store_evotor_id, store_id))
|
||||
: eq(cached_groups.user_id, user.id)
|
||||
).orderBy(cached_groups.name);
|
||||
const groups = await q;
|
||||
for (const g of groups) {
|
||||
rows.push([storeMap[g.store_evotor_id] ?? "", g.name, g.evotor_id, filterLabel(g.evotor_id)]);
|
||||
}
|
||||
} else {
|
||||
filename = `products_${today}.csv`;
|
||||
rows.push(["Магазин", "Группа", "Название", "Артикул", "Цена", "Количество", "Ед. измерения", "В продаже", "ID", "Фильтр"]);
|
||||
const storeMap: Record<string, string> = {};
|
||||
const groupMap: Record<string, string> = {};
|
||||
const allStores = await db.select().from(cached_stores).where(eq(cached_stores.user_id, user.id));
|
||||
for (const s of allStores) storeMap[s.evotor_id] = s.name;
|
||||
const allGroups = await db.select().from(cached_groups).where(eq(cached_groups.user_id, user.id));
|
||||
for (const g of allGroups) groupMap[g.evotor_id] = g.name;
|
||||
|
||||
const conditions = [eq(cached_products.user_id, user.id)];
|
||||
if (store_id) conditions.push(eq(cached_products.store_evotor_id, store_id));
|
||||
if (group_id) conditions.push(eq(cached_products.group_evotor_id, group_id));
|
||||
const products = await db.select().from(cached_products).where(and(...conditions)).orderBy(cached_products.name);
|
||||
for (const p of products) {
|
||||
rows.push([
|
||||
storeMap[p.store_evotor_id] ?? "",
|
||||
p.group_evotor_id ? (groupMap[p.group_evotor_id] ?? "") : "",
|
||||
p.name,
|
||||
p.article_number ?? "",
|
||||
p.price ? String(p.price) : "",
|
||||
p.quantity ? String(p.quantity) : "",
|
||||
p.measure_name ?? "",
|
||||
p.allow_to_sell === true ? "Да" : p.allow_to_sell === false ? "Нет" : "",
|
||||
p.evotor_id,
|
||||
filterLabel(p.evotor_id),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
const csv = BOM + rows.map((row) =>
|
||||
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(",")
|
||||
).join("\r\n");
|
||||
|
||||
return new Response(csv, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
});
|
||||
83
web/src/routes/connections.ts
Normal file
83
web/src/routes/connections.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Hono } from "hono";
|
||||
import { db } from "../db/client.js";
|
||||
import { users, evotor_connections, vk_connections } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getSessionUserId, type AppEnv } from "../lib/auth.js";
|
||||
import { render } from "../lib/render.js";
|
||||
|
||||
export const connectionsRouter = new Hono<AppEnv>();
|
||||
|
||||
const SERVICE_TYPES = [
|
||||
{
|
||||
type: "evotor",
|
||||
name: "Эвотор",
|
||||
icon: "bi-shop",
|
||||
description: "Подключите кассу Эвотор для синхронизации каталога товаров.",
|
||||
configure_url: "/evotor",
|
||||
connect_url: "/evotor",
|
||||
},
|
||||
{
|
||||
type: "vk",
|
||||
name: "ВКонтакте",
|
||||
icon: "bi-bag",
|
||||
description: "Подключите аккаунт ВКонтакте для публикации товаров в вашу группу.",
|
||||
configure_url: "/vk",
|
||||
connect_url: "/vk",
|
||||
},
|
||||
];
|
||||
|
||||
async function requireUser(c: Parameters<typeof getSessionUserId>[0]) {
|
||||
const id = getSessionUserId(c);
|
||||
if (!id) return null;
|
||||
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
||||
return user ?? null;
|
||||
}
|
||||
|
||||
connectionsRouter.get("/connections", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const [evotor] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, user.id)).limit(1);
|
||||
const [vk] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, user.id)).limit(1);
|
||||
|
||||
const connections = SERVICE_TYPES
|
||||
.map((svc) => {
|
||||
const conn = svc.type === "evotor" ? evotor : vk;
|
||||
if (!conn) return null;
|
||||
const details = svc.type === "evotor"
|
||||
? (evotor?.store_name ?? null)
|
||||
: (vk?.first_name ? `${vk.first_name} ${vk.last_name ?? ""}`.trim() : null);
|
||||
return { ...svc, is_online: conn.is_online, last_checked_at: conn.last_checked_at, details };
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return c.html(render("connections.njk", { user, connections }));
|
||||
});
|
||||
|
||||
connectionsRouter.get("/connections/add", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const [evotor] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, user.id)).limit(1);
|
||||
const [vk] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, user.id)).limit(1);
|
||||
|
||||
const available = SERVICE_TYPES.filter((svc) => {
|
||||
return svc.type === "evotor" ? !evotor : !vk;
|
||||
});
|
||||
|
||||
return c.html(render("connections_add.njk", { user, available }));
|
||||
});
|
||||
|
||||
connectionsRouter.post("/connections/delete", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const svcType = c.req.query("type");
|
||||
if (svcType === "evotor") {
|
||||
await db.delete(evotor_connections).where(eq(evotor_connections.user_id, user.id));
|
||||
} else if (svcType === "vk") {
|
||||
await db.delete(vk_connections).where(eq(vk_connections.user_id, user.id));
|
||||
}
|
||||
|
||||
return c.redirect("/connections", 303);
|
||||
});
|
||||
147
web/src/routes/evotor.ts
Normal file
147
web/src/routes/evotor.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { Hono } from "hono";
|
||||
import { db } from "../db/client.js";
|
||||
import { users, evotor_connections } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getSessionUserId, type AppEnv } from "../lib/auth.js";
|
||||
import { render } from "../lib/render.js";
|
||||
import { config } from "../config.js";
|
||||
|
||||
export const evotorRouter = new Hono<AppEnv>();
|
||||
|
||||
const EVOTOR_APP_URL = "https://market.evotor.ru/store/apps/{app_id}";
|
||||
const EVOTOR_STORES_URL = "https://api.evotor.ru/stores";
|
||||
|
||||
async function requireUser(c: Parameters<typeof getSessionUserId>[0]) {
|
||||
const id = getSessionUserId(c);
|
||||
if (!id) return null;
|
||||
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
||||
return user ?? null;
|
||||
}
|
||||
|
||||
async function fetchStoreInfo(token: string): Promise<{ store_id: string | null; store_name: string | null }> {
|
||||
try {
|
||||
const resp = await fetch(EVOTOR_STORES_URL, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data = await resp.json() as unknown;
|
||||
const stores = (typeof data === "object" && data !== null && "items" in data)
|
||||
? (data as { items: unknown[] }).items
|
||||
: (Array.isArray(data) ? data : []);
|
||||
if (Array.isArray(stores) && stores.length > 0) {
|
||||
const s = stores[0] as Record<string, unknown>;
|
||||
return {
|
||||
store_id: (s.uuid as string | null) ?? (s.id as string | null) ?? null,
|
||||
store_name: (s.name as string | null) ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { store_id: null, store_name: null };
|
||||
} catch {
|
||||
return { store_id: null, store_name: null };
|
||||
}
|
||||
}
|
||||
|
||||
evotorRouter.get("/evotor", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const [connection] = await db.select().from(evotor_connections)
|
||||
.where(eq(evotor_connections.user_id, user.id)).limit(1);
|
||||
const error = c.req.query("error") ?? null;
|
||||
const app_url = config.EVOTOR_APP_ID
|
||||
? EVOTOR_APP_URL.replace("{app_id}", config.EVOTOR_APP_ID)
|
||||
: null;
|
||||
|
||||
return c.html(render("evotor.njk", { user, connection: connection ?? null, error, app_url }));
|
||||
});
|
||||
|
||||
// Webhook endpoint: Evotor POSTs {"userId": "...", "token": "..."}
|
||||
evotorRouter.post("/evotor/callback", async (c) => {
|
||||
if (config.EVOTOR_WEBHOOK_SECRET) {
|
||||
const authHeader = c.req.header("Authorization") ?? "";
|
||||
if (authHeader !== `Bearer ${config.EVOTOR_WEBHOOK_SECRET}`) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
const payload = await c.req.json() as { userId?: string; token?: string };
|
||||
if (!payload.userId || !payload.token) {
|
||||
return c.json({ error: "Invalid payload" }, 400);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const { store_id, store_name } = await fetchStoreInfo(payload.token);
|
||||
|
||||
const [existing] = await db.select().from(evotor_connections)
|
||||
.where(eq(evotor_connections.evotor_user_id, payload.userId)).limit(1);
|
||||
|
||||
if (existing) {
|
||||
await db.update(evotor_connections)
|
||||
.set({ access_token: payload.token, store_id, store_name, is_online: true, last_checked_at: now, updated_at: now })
|
||||
.where(eq(evotor_connections.id, existing.id));
|
||||
} else {
|
||||
await db.insert(evotor_connections).values({
|
||||
evotor_user_id: payload.userId,
|
||||
access_token: payload.token,
|
||||
store_id,
|
||||
store_name,
|
||||
is_online: true,
|
||||
last_checked_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ status: "ok" });
|
||||
});
|
||||
|
||||
evotorRouter.post("/evotor/token", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const form = await c.req.formData();
|
||||
const token = String(form.get("token") ?? "").trim();
|
||||
if (!token) return c.redirect("/evotor?error=empty_token", 303);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Validate token by fetching stores
|
||||
let storeInfo: { store_id: string | null; store_name: string | null };
|
||||
try {
|
||||
const resp = await fetch(EVOTOR_STORES_URL, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (resp.status === 401) return c.redirect("/evotor?error=invalid_token", 303);
|
||||
storeInfo = await fetchStoreInfo(token);
|
||||
} catch {
|
||||
storeInfo = { store_id: null, store_name: null };
|
||||
}
|
||||
|
||||
const [existing] = await db.select().from(evotor_connections)
|
||||
.where(eq(evotor_connections.user_id, user.id)).limit(1);
|
||||
|
||||
if (existing) {
|
||||
await db.update(evotor_connections)
|
||||
.set({ access_token: token, store_id: storeInfo.store_id, store_name: storeInfo.store_name, is_online: true, last_checked_at: now, updated_at: now })
|
||||
.where(eq(evotor_connections.id, existing.id));
|
||||
} else {
|
||||
await db.insert(evotor_connections).values({
|
||||
user_id: user.id,
|
||||
access_token: token,
|
||||
store_id: storeInfo.store_id,
|
||||
store_name: storeInfo.store_name,
|
||||
is_online: true,
|
||||
last_checked_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
return c.redirect("/connections", 303);
|
||||
});
|
||||
|
||||
evotorRouter.post("/evotor/disconnect", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
await db.delete(evotor_connections).where(eq(evotor_connections.user_id, user.id));
|
||||
return c.redirect("/connections", 303);
|
||||
});
|
||||
116
web/src/routes/profile.ts
Normal file
116
web/src/routes/profile.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Hono } from "hono";
|
||||
import { db } from "../db/client.js";
|
||||
import { users } from "../db/schema.js";
|
||||
import { eq, and, ne } from "drizzle-orm";
|
||||
import { hashPassword, verifyPassword, getSessionUserId, type AppEnv } from "../lib/auth.js";
|
||||
import { validateProfile, validateResetPassword } from "../lib/validate.js";
|
||||
import { render } from "../lib/render.js";
|
||||
|
||||
export const profileRouter = new Hono<AppEnv>();
|
||||
|
||||
async function requireUser(c: Parameters<typeof getSessionUserId>[0]) {
|
||||
const id = getSessionUserId(c);
|
||||
if (!id) return null;
|
||||
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
||||
return user ?? null;
|
||||
}
|
||||
|
||||
profileRouter.get("/profile", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
return c.html(render("profile_view.njk", { user }));
|
||||
});
|
||||
|
||||
profileRouter.get("/profile/edit", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
return c.html(render("profile_edit.njk", { user }));
|
||||
});
|
||||
|
||||
profileRouter.post("/profile/edit", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const form = await c.req.formData();
|
||||
const data: Record<string, string> = {};
|
||||
form.forEach((v, k) => { data[k] = String(v); });
|
||||
|
||||
const errors = validateProfile(data);
|
||||
if (!errors.length) {
|
||||
const existing = await db.select().from(users).where(
|
||||
and(eq(users.phone, data.phone.trim()), ne(users.id, user.id))
|
||||
).limit(1);
|
||||
if (existing.length) errors.push("Пользователь с таким телефоном уже существует");
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
return c.html(render("profile_edit.njk", { user, errors, form: data }));
|
||||
}
|
||||
|
||||
await db.update(users)
|
||||
.set({ first_name: data.first_name.trim(), last_name: data.last_name.trim(), phone: data.phone.trim() })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
const [updated] = await db.select().from(users).where(eq(users.id, user.id)).limit(1);
|
||||
return c.html(render("profile_edit.njk", { user: updated, success: "Профиль обновлен" }));
|
||||
});
|
||||
|
||||
profileRouter.get("/profile/change-password", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
return c.html(render("profile_change_password.njk", { user }));
|
||||
});
|
||||
|
||||
profileRouter.post("/profile/change-password", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const form = await c.req.formData();
|
||||
const data: Record<string, string> = {};
|
||||
form.forEach((v, k) => { data[k] = String(v); });
|
||||
|
||||
const errors: string[] = [];
|
||||
const currentPassword = data.current_password ?? "";
|
||||
if (!currentPassword) {
|
||||
errors.push("Введите текущий пароль");
|
||||
} else if (!(await verifyPassword(currentPassword, user.password_hash))) {
|
||||
errors.push("Неверный текущий пароль");
|
||||
}
|
||||
errors.push(...validateResetPassword(data));
|
||||
|
||||
if (errors.length) {
|
||||
return c.html(render("profile_change_password.njk", { user, errors }));
|
||||
}
|
||||
|
||||
await db.update(users)
|
||||
.set({ password_hash: await hashPassword(data.password) })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
return c.html(render("profile_change_password.njk", { user, success: "Пароль изменен" }));
|
||||
});
|
||||
|
||||
profileRouter.get("/profile/delete", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
return c.html(render("profile_delete.njk", { user }));
|
||||
});
|
||||
|
||||
profileRouter.post("/profile/delete", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const form = await c.req.formData();
|
||||
const password = String(form.get("password") ?? "");
|
||||
|
||||
if (!password) {
|
||||
return c.html(render("profile_delete.njk", { user, errors: ["Введите пароль для подтверждения"] }));
|
||||
}
|
||||
if (!(await verifyPassword(password, user.password_hash))) {
|
||||
return c.html(render("profile_delete.njk", { user, errors: ["Неверный пароль"] }));
|
||||
}
|
||||
|
||||
await db.delete(users).where(eq(users.id, user.id));
|
||||
const session = c.get("session") as { deleteSession: () => void };
|
||||
session.deleteSession();
|
||||
return c.redirect("/", 303);
|
||||
});
|
||||
100
web/src/routes/reset.ts
Normal file
100
web/src/routes/reset.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Hono } from "hono";
|
||||
import { db } from "../db/client.js";
|
||||
import { users } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { hashPassword, type AppEnv } from "../lib/auth.js";
|
||||
import { validateResetPassword } from "../lib/validate.js";
|
||||
import { render } from "../lib/render.js";
|
||||
import { config } from "../config.js";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export const resetRouter = new Hono<AppEnv>();
|
||||
|
||||
resetRouter.get("/forgot-password", (c) => {
|
||||
return c.html(render("forgot_password.njk", { user: null }));
|
||||
});
|
||||
|
||||
resetRouter.post("/forgot-password", async (c) => {
|
||||
const form = await c.req.formData();
|
||||
const email = (String(form.get("email") ?? "")).trim();
|
||||
|
||||
if (email) {
|
||||
const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
||||
if (user) {
|
||||
const token = randomUUID().replace(/-/g, "");
|
||||
const expires = new Date(Date.now() + config.PASSWORD_RESET_EXPIRE_MINUTES * 60 * 1000);
|
||||
await db.update(users)
|
||||
.set({ password_reset_token: token, password_reset_expires: expires })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
const resetUrl = `${config.BASE_URL}/reset-password?token=${token}`;
|
||||
console.log("=".repeat(40));
|
||||
console.log("СБРОС ПАРОЛЯ");
|
||||
console.log(`Пользователь: ${user.email}`);
|
||||
console.log(`Ссылка: ${resetUrl}`);
|
||||
console.log(`Действительна: ${config.PASSWORD_RESET_EXPIRE_MINUTES} мин.`);
|
||||
console.log("=".repeat(40));
|
||||
}
|
||||
}
|
||||
|
||||
return c.html(render("message.njk", {
|
||||
user: null,
|
||||
title: "Сброс пароля",
|
||||
message: "Если аккаунт с таким email существует, мы отправили письмо со ссылкой для сброса пароля.",
|
||||
}));
|
||||
});
|
||||
|
||||
resetRouter.get("/reset-password", async (c) => {
|
||||
const token = c.req.query("token") ?? "";
|
||||
const [user] = await db.select().from(users).where(eq(users.password_reset_token, token)).limit(1);
|
||||
|
||||
if (!user || !user.password_reset_expires) {
|
||||
return c.html(render("message.njk", {
|
||||
user: null, title: "Ошибка", message: "Неверная или устаревшая ссылка.",
|
||||
}));
|
||||
}
|
||||
if (new Date() > user.password_reset_expires) {
|
||||
return c.html(render("message.njk", {
|
||||
user: null, title: "Ошибка", message: "Срок действия ссылки истек.",
|
||||
}));
|
||||
}
|
||||
|
||||
return c.html(render("reset_password.njk", { user: null, token }));
|
||||
});
|
||||
|
||||
resetRouter.post("/reset-password", async (c) => {
|
||||
const token = c.req.query("token") ?? "";
|
||||
const [user] = await db.select().from(users).where(eq(users.password_reset_token, token)).limit(1);
|
||||
|
||||
if (!user || !user.password_reset_expires) {
|
||||
return c.html(render("message.njk", {
|
||||
user: null, title: "Ошибка", message: "Неверная или устаревшая ссылка.",
|
||||
}));
|
||||
}
|
||||
if (new Date() > user.password_reset_expires) {
|
||||
return c.html(render("message.njk", {
|
||||
user: null, title: "Ошибка", message: "Срок действия ссылки истек.",
|
||||
}));
|
||||
}
|
||||
|
||||
const form = await c.req.formData();
|
||||
const data: Record<string, string> = {};
|
||||
form.forEach((v, k) => { data[k] = String(v); });
|
||||
const errors = validateResetPassword(data);
|
||||
|
||||
if (errors.length) {
|
||||
return c.html(render("reset_password.njk", { user: null, token, errors }));
|
||||
}
|
||||
|
||||
await db.update(users)
|
||||
.set({ password_hash: await hashPassword(data.password), password_reset_token: null, password_reset_expires: null })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
return c.html(render("message.njk", {
|
||||
user: null,
|
||||
title: "Пароль изменен",
|
||||
message: "Ваш пароль успешно изменен. Теперь вы можете войти.",
|
||||
link: "/login",
|
||||
link_text: "Войти",
|
||||
}));
|
||||
});
|
||||
88
web/src/routes/sync.ts
Normal file
88
web/src/routes/sync.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Hono } from "hono";
|
||||
import { db } from "../db/client.js";
|
||||
import { users, evotor_connections, vk_connections, sync_configs, sync_filters } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getSessionUserId, type AppEnv } from "../lib/auth.js";
|
||||
import { render } from "../lib/render.js";
|
||||
|
||||
export const syncRouter = new Hono<AppEnv>();
|
||||
|
||||
async function requireUser(c: Parameters<typeof getSessionUserId>[0]) {
|
||||
const id = getSessionUserId(c);
|
||||
if (!id) return null;
|
||||
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
||||
return user ?? null;
|
||||
}
|
||||
|
||||
async function getOrCreateSyncConfig(userId: number) {
|
||||
const [existing] = await db.select().from(sync_configs).where(eq(sync_configs.user_id, userId)).limit(1);
|
||||
if (existing) return existing;
|
||||
await db.insert(sync_configs).values({ user_id: userId, is_enabled: false });
|
||||
const [created] = await db.select().from(sync_configs).where(eq(sync_configs.user_id, userId)).limit(1);
|
||||
return created!;
|
||||
}
|
||||
|
||||
syncRouter.get("/sync", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const [evotor] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, user.id)).limit(1);
|
||||
const [vk] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, user.id)).limit(1);
|
||||
const config = await getOrCreateSyncConfig(user.id);
|
||||
const filters = await db.select().from(sync_filters).where(eq(sync_filters.sync_config_id, config.id));
|
||||
|
||||
const summary = {
|
||||
stores: filters.filter((f) => f.entity_type === "store").length,
|
||||
groups: filters.filter((f) => f.entity_type === "group").length,
|
||||
products: filters.filter((f) => f.entity_type === "product").length,
|
||||
total: filters.length,
|
||||
};
|
||||
|
||||
let status: string;
|
||||
if (config.confirmed_at && config.is_enabled) {
|
||||
status = "active";
|
||||
} else if (config.confirmed_at && !config.is_enabled) {
|
||||
status = "paused";
|
||||
} else if (summary.total > 0) {
|
||||
status = "pending";
|
||||
} else {
|
||||
status = "unconfigured";
|
||||
}
|
||||
|
||||
return c.html(render("sync.njk", {
|
||||
user,
|
||||
evotor: evotor ?? null,
|
||||
vk: vk ?? null,
|
||||
config,
|
||||
summary,
|
||||
status,
|
||||
}));
|
||||
});
|
||||
|
||||
syncRouter.post("/sync/toggle", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const config = await getOrCreateSyncConfig(user.id);
|
||||
await db.update(sync_configs)
|
||||
.set({ is_enabled: !config.is_enabled })
|
||||
.where(eq(sync_configs.id, config.id));
|
||||
|
||||
return c.redirect("/sync", 303);
|
||||
});
|
||||
|
||||
syncRouter.post("/sync/confirm", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const config = await getOrCreateSyncConfig(user.id);
|
||||
const filters = await db.select().from(sync_filters).where(eq(sync_filters.sync_config_id, config.id));
|
||||
|
||||
if (config.is_enabled && filters.length > 0) {
|
||||
await db.update(sync_configs)
|
||||
.set({ confirmed_at: new Date() })
|
||||
.where(eq(sync_configs.id, config.id));
|
||||
}
|
||||
|
||||
return c.redirect("/sync", 303);
|
||||
});
|
||||
120
web/src/routes/vk.ts
Normal file
120
web/src/routes/vk.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Hono } from "hono";
|
||||
import { db } from "../db/client.js";
|
||||
import { users, vk_connections } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getSessionUserId, type AppEnv } from "../lib/auth.js";
|
||||
import { render } from "../lib/render.js";
|
||||
import { config } from "../config.js";
|
||||
|
||||
export const vkRouter = new Hono<AppEnv>();
|
||||
|
||||
const VK_API_URL = "https://api.vk.com/method";
|
||||
const VK_OAUTH_URL = "https://oauth.vk.com/authorize";
|
||||
|
||||
async function requireUser(c: Parameters<typeof getSessionUserId>[0]) {
|
||||
const id = getSessionUserId(c);
|
||||
if (!id) return null;
|
||||
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
||||
return user ?? null;
|
||||
}
|
||||
|
||||
async function fetchGroupInfo(token: string): Promise<{ groupId: string | null; groupName: string | null }> {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
access_token: token,
|
||||
v: config.VK_API_VERSION,
|
||||
filter: "admin",
|
||||
extended: "1",
|
||||
count: "1",
|
||||
});
|
||||
const resp = await fetch(`${VK_API_URL}/groups.get?${params}`, {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!resp.ok) return { groupId: null, groupName: null };
|
||||
const data = await resp.json() as { error?: unknown; response?: { items?: Array<{ id: number; name: string }> } };
|
||||
if (data.error) return { groupId: null, groupName: null };
|
||||
const items = data.response?.items ?? [];
|
||||
if (items.length === 0) return { groupId: null, groupName: null };
|
||||
return { groupId: String(items[0].id), groupName: items[0].name };
|
||||
} catch {
|
||||
return { groupId: null, groupName: null };
|
||||
}
|
||||
}
|
||||
|
||||
vkRouter.get("/vk", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const [connection] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, user.id)).limit(1);
|
||||
const error = c.req.query("error") ?? null;
|
||||
|
||||
return c.html(render("vk.njk", {
|
||||
user,
|
||||
connection: connection ?? null,
|
||||
error,
|
||||
vk_client_id: config.VK_CLIENT_ID,
|
||||
callback_url: `${config.BASE_URL}/vk/callback`,
|
||||
}));
|
||||
});
|
||||
|
||||
vkRouter.get("/vk/connect", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
if (!config.VK_CLIENT_ID) return c.redirect("/vk?error=no_client_id", 303);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.VK_CLIENT_ID,
|
||||
scope: "market,groups",
|
||||
redirect_uri: `${config.BASE_URL}/vk/callback`,
|
||||
display: "page",
|
||||
response_type: "token",
|
||||
v: config.VK_API_VERSION,
|
||||
});
|
||||
return c.redirect(`${VK_OAUTH_URL}?${params}`, 302);
|
||||
});
|
||||
|
||||
vkRouter.get("/vk/callback", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
return c.html(render("vk_callback.njk", { user }));
|
||||
});
|
||||
|
||||
vkRouter.post("/vk/token", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const form = await c.req.formData();
|
||||
const token = String(form.get("token") ?? "").trim();
|
||||
if (!token) return c.redirect("/vk?error=empty_token", 303);
|
||||
|
||||
const { groupId, groupName } = await fetchGroupInfo(token);
|
||||
if (!groupId) return c.redirect("/vk?error=invalid_token", 303);
|
||||
|
||||
const now = new Date();
|
||||
const [existing] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, user.id)).limit(1);
|
||||
|
||||
if (existing) {
|
||||
await db.update(vk_connections)
|
||||
.set({ access_token: token, vk_user_id: groupId, first_name: groupName, last_name: null, is_online: true, last_checked_at: now, updated_at: now })
|
||||
.where(eq(vk_connections.id, existing.id));
|
||||
} else {
|
||||
await db.insert(vk_connections).values({
|
||||
user_id: user.id,
|
||||
access_token: token,
|
||||
vk_user_id: groupId,
|
||||
first_name: groupName,
|
||||
last_name: null,
|
||||
is_online: true,
|
||||
last_checked_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
return c.redirect("/connections", 303);
|
||||
});
|
||||
|
||||
vkRouter.post("/vk/disconnect", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
await db.delete(vk_connections).where(eq(vk_connections.user_id, user.id));
|
||||
return c.redirect("/connections", 303);
|
||||
});
|
||||
Reference in New Issue
Block a user