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:
mguschin
2026-03-17 19:33:32 +03:00
parent db0c1cbed3
commit 854c912a88
100 changed files with 5770 additions and 39 deletions

116
web/src/routes/profile.ts Normal file
View 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);
});